diff --git a/python/coinbase-agentkit/Makefile b/python/coinbase-agentkit/Makefile index ee990e321..c3a2c3490 100644 --- a/python/coinbase-agentkit/Makefile +++ b/python/coinbase-agentkit/Makefile @@ -1,3 +1,9 @@ +ifneq (,$(wildcard ./.env)) + include .env +endif + +export + .PHONY: install install: poetry install @@ -28,12 +34,20 @@ local-docs: docs .PHONY: test test: - poetry run pytest -m "not integration" + poetry run pytest -m "not (e2e or integration)" + +.PHONY: test-e2e +test-e2e: + poetry run pytest -m e2e .PHONY: test-integration test-integration: poetry run pytest -m integration +.PHONY: check-description-length +check-description-length: + poetry run check-description-length + .PHONY: generate-action-provider generate-action-provider: poetry run generate-action-provider diff --git a/python/coinbase-agentkit/README.md b/python/coinbase-agentkit/README.md index ce09059f4..faf34c784 100644 --- a/python/coinbase-agentkit/README.md +++ b/python/coinbase-agentkit/README.md @@ -15,6 +15,7 @@ AgentKit is a framework for easily enabling AI agents to take actions onchain. I - [Adding Actions to your Action Provider](#adding-actions-to-your-action-provider) - [Adding Actions that use a Wallet Provider](#adding-actions-that-use-a-wallet-provider) - [Adding an Action Provider to your AgentKit instance](#adding-an-action-provider-to-your-agentkit-instance) +- [Action Providers](#action-providers) - [Wallet Providers](#wallet-providers) - [CdpWalletProvider](#cdpwalletprovider) - [Network Configuration](#network-configuration) @@ -206,6 +207,324 @@ agent_kit = AgentKit(AgentKitConfig( )) ``` +## Action Providers + +This section provides a detailed list of all available action providers and their actions. + +
+Basename + + + + + +
register_basenameRegisters a custom .base.eth or .basetest.eth domain name for the wallet address.
+
+ +
+CDP Wallet + + + + + + + + + + + + + + + + + +
deploy_contractDeploys a custom smart contract using specified Solidity version and constructor arguments.
deploy_nftDeploys a standard ERC-721 NFT contract with configurable name, symbol, and metadata URI.
deploy_tokenDeploys a standard ERC-20 token contract with configurable name, symbol, and initial supply.
tradeExecutes a token swap between two assets at current market rates on mainnet networks.
+
+ +
+CDP API + + + + + + + + + +
fetch_priceRetrieves the current price of a cryptocurrency from the CDP API.
fetch_base_scanFetches on-chain data from Base blockchain explorer via CDP API.
+
+ +
+Compound + + + + + + + + + + + + + + + + + + + + + +
supplySupplies collateral assets (WETH, CBETH, CBBTC, WSTETH, or USDC) to Compound.
withdrawWithdraws previously supplied collateral assets from Compound.
borrowBorrows base assets (WETH or USDC) from Compound using supplied collateral.
repayRepays borrowed assets back to Compound.
get_portfolioRetrieves portfolio details including collateral balances and borrowed amounts.
+
+ +
+ERC20 + + + + + + + + + +
get_balanceRetrieves the token balance for a specified address and ERC-20 contract.
transferTransfers a specified amount of ERC-20 tokens to a destination address.
+
+ +
+ERC721 + + + + + + + + + + + + + +
get_balanceRetrieves the NFT balance for a specified address and ERC-721 contract.
transferTransfers ownership of a specific NFT token to a destination address.
mintCreates a new NFT token and assigns it to a specified destination address.
+
+ +
+Hyperbolic + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
generate_textGenerate text using AI models.
generate_imageGenerate images using AI models.
generate_audioGenerate text-to-speech audio.
get_available_gpusGet available GPU resources.
get_available_gpus_by_typeGet GPUs filtered by model type.
get_available_gpus_typesGet list of available GPU types.
get_gpu_statusCheck status of GPU resources.
rent_computeRent GPU compute resources.
terminate_computeTerminate a rented GPU compute instance.
get_current_balanceGet current account balance.
get_purchase_historyGet purchase history.
get_spend_historyGet spending history.
link_wallet_addressLink a wallet address to your account.
+
+ +
+Morpho + + + + + + + + + +
depositDeposits a specified amount of assets into a designated Morpho Vault.
withdrawWithdraws a specified amount of assets from a designated Morpho Vault.
+
+ +
+Pyth + + + + + + + + + +
fetch_priceRetrieves current price data from a specified Pyth price feed.
fetch_price_feed_idRetrieves the unique price feed identifier for a given token symbol.
+
+ +
+SSH + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ssh_connectEstablishes an SSH connection to a remote server.
remote_shellExecutes shell commands on a remote server via SSH.
ssh_statusChecks status of SSH connections.
ssh_list_connectionsLists active SSH connections.
ssh_disconnectDisconnects from an SSH server.
ssh_add_host_keyAdds an SSH host key to known_hosts.
sftp_uploadUploads files to a remote server via SFTP.
sftp_downloadDownloads files from a remote server via SFTP.
+
+ +
+Superfluid + + + + + + + + + + + + + +
create_flowCreates a new token streaming flow to a recipient address.
delete_flowDeletes an existing token streaming flow.
get_flowGets details of an existing token streaming flow.
+
+ +
+Twitter + + + + + + + + + + + + + + + + + +
account_detailsFetches profile information and metadata for the authenticated Twitter account.
account_mentionsRetrieves recent mentions and interactions for the authenticated account.
post_tweetCreates a new tweet on the authenticated Twitter account.
post_tweet_replyCreates a reply to an existing tweet using the tweet's unique identifier.
+
+ +
+Wallet + + + + + + + + + + + + + +
get_wallet_detailsRetrieves wallet address, network info, balances, and provider details.
get_balanceGets the native currency balance of the connected wallet.
native_transferTransfers native blockchain tokens (e.g., ETH) to a destination address.
+
+ +
+WETH + + + + + +
wrap_ethConverts native ETH to Wrapped ETH (WETH) on supported networks.
+
+ +
+WOW + + + + + + + + + + + + + +
buy_tokenPurchases WOW tokens from a contract using ETH based on bonding curve pricing.
create_tokenCreates a new WOW memecoin with bonding curve functionality via Zora factory.
sell_tokenSells WOW tokens back to the contract for ETH based on bonding curve pricing.
+
+ ## Wallet Providers AgentKit supports the following wallet providers: diff --git a/python/coinbase-agentkit/changelog.d/+6348ed13.feature.md b/python/coinbase-agentkit/changelog.d/+6348ed13.feature.md new file mode 100644 index 000000000..16b9205ef --- /dev/null +++ b/python/coinbase-agentkit/changelog.d/+6348ed13.feature.md @@ -0,0 +1 @@ +Added: Hyperbolic action providers for AI, Billing, Marketplace, and Settings diff --git a/python/coinbase-agentkit/changelog.d/+f20b45dd.feature.md b/python/coinbase-agentkit/changelog.d/+f20b45dd.feature.md new file mode 100644 index 000000000..899cc4d14 --- /dev/null +++ b/python/coinbase-agentkit/changelog.d/+f20b45dd.feature.md @@ -0,0 +1 @@ +Added: SSH action provider for connecting, running remote commands, downloading and uploading diff --git a/python/coinbase-agentkit/coinbase_agentkit/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/__init__.py index bfd02a3e3..0bad532c9 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/__init__.py @@ -11,8 +11,10 @@ compound_action_provider, create_action, erc20_action_provider, + hyperbolic_action_provider, morpho_action_provider, pyth_action_provider, + ssh_action_provider, superfluid_action_provider, twitter_action_provider, wallet_action_provider, @@ -46,17 +48,19 @@ "EthAccountWalletProviderConfig", "SmartWalletProvider", "SmartWalletProviderConfig", - "erc20_action_provider", + "allora_action_provider", "cdp_api_action_provider", "cdp_wallet_action_provider", "compound_action_provider", + "erc20_action_provider", + "hyperbolic_action_provider", "morpho_action_provider", "pyth_action_provider", + "ssh_action_provider", "superfluid_action_provider", "twitter_action_provider", "wallet_action_provider", "weth_action_provider", "wow_action_provider", - "allora_action_provider", "__version__", ] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py index 72018d6a4..0adf9ad3f 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py @@ -11,8 +11,13 @@ from .cdp.cdp_wallet_action_provider import CdpWalletActionProvider, cdp_wallet_action_provider from .compound.compound_action_provider import CompoundActionProvider, compound_action_provider from .erc20.erc20_action_provider import ERC20ActionProvider, erc20_action_provider +from .hyperboliclabs.hyperbolic_action_provider import ( + HyperbolicActionProvider, + hyperbolic_action_provider, +) from .morpho.morpho_action_provider import MorphoActionProvider, morpho_action_provider from .pyth.pyth_action_provider import PythActionProvider, pyth_action_provider +from .ssh.ssh_action_provider import SshActionProvider, ssh_action_provider from .superfluid.superfluid_action_provider import ( SuperfluidActionProvider, superfluid_action_provider, @@ -26,6 +31,8 @@ "Action", "ActionProvider", "create_action", + "AlloraActionProvider", + "allora_action_provider", "BasenameActionProvider", "basename_action_provider", "CdpApiActionProvider", @@ -36,10 +43,14 @@ "compound_action_provider", "ERC20ActionProvider", "erc20_action_provider", + "HyperbolicActionProvider", + "hyperbolic_action_provider", "MorphoActionProvider", "morpho_action_provider", "PythActionProvider", "pyth_action_provider", + "SshActionProvider", + "ssh_action_provider", "SuperfluidActionProvider", "superfluid_action_provider", "TwitterActionProvider", @@ -50,6 +61,4 @@ "weth_action_provider", "WowActionProvider", "wow_action_provider", - "AlloraActionProvider", - "allora_action_provider", ] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/README.md b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/README.md new file mode 100644 index 000000000..c124d19f3 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/README.md @@ -0,0 +1,608 @@ +# Hyperbolic Labs Action Provider + +This directory contains the **Hyperbolic Labs Action Provider** implementation, which provides actions to interact with Hyperbolic Labs services. + +## Getting Started + +1. Create a new Hyperbolic Labs account at https://hyperbolic.xyz/ +2. Retrieve your API key at https://hyperbolic.xyz/settings +3. Set the `HYPERBOLIC_API_KEY` environment variable to the API key +4. Upload your SSH public key at https://hyperbolic.xyz/settings + +Note: +- Supported SSH key types: DSA/DSS, ECDSA, ED25519, RSA +- Your SSH public key must be defined in your Hyperbolic Labs account before you can rent GPU instances + +### Environment Variables +``` +HYPERBOLIC_API_KEY +HYPERBOLIC_SSH_PRIVATE_KEY_PATH +``` + +If `HYPERBOLIC_SSH_PRIVATE_KEY_PATH` is undefined, the SSH action provider will use the default SSH key at `~/.ssh/id_rsa`. + +## Directory Structure + +``` +hyperboliclabs/ +├── action_provider.py # Base action provider +├── hyperbolic_action_provider.py # Main Hyperbolic action provider +├── service.py # API service layer +├── utils.py # Utility functions +├── constants.py # Constants and configuration +├── __init__.py # Main exports +├── ai/ # AI services +│ ├── action_provider.py # AI action provider +│ ├── schemas.py # AI action schemas +│ ├── service.py # AI service implementation +│ ├── types.py # AI type definitions +│ ├── utils.py # AI utilities +│ └── __init__.py # AI module exports +├── marketplace/ # Marketplace services +│ ├── action_provider.py # Marketplace action provider +│ ├── schemas.py # Marketplace action schemas +│ ├── service.py # Marketplace service implementation +│ ├── types.py # Marketplace type definitions +│ ├── utils.py # Marketplace utilities +│ └── __init__.py # Marketplace module exports +├── billing/ # Billing services +│ ├── action_provider.py # Billing action provider +│ ├── schemas.py # Billing action schemas +│ ├── service.py # Billing service implementation +│ ├── types.py # Billing type definitions +│ ├── utils.py # Billing utilities +│ └── __init__.py # Billing module exports +├── settings/ # Settings services +│ ├── action_provider.py # Settings action provider +│ ├── schemas.py # Settings action schemas +│ ├── service.py # Settings service implementation +│ ├── types.py # Settings type definitions +│ ├── utils.py # Settings utilities +│ └── __init__.py # Settings module exports +└── README.md # This file + +# From python/coinbase-agentkit/ +tests/action_providers/hyperboliclabs/ +├── conftest.py # Test configuration +├── test_service.py # Test service layer +├── test_service_e2e.py # End-to-end tests +├── test_hyperbolic_action_provider.py # Test action provider +├── ai/ # AI service tests +├── marketplace/ # Marketplace service tests +├── billing/ # Billing service tests +└── settings/ # Settings service tests +``` + +## Actions + +### AI +- `generate_text`: Generate text using AI models + - Can specify model and prompts + - Powers conversational AI features + - Optional system prompt to guide behavior + +- `generate_image`: Generate images using AI models + - Configurable image dimensions and quality settings + - Supports negative prompts + - Multiple images per request + +- `generate_audio`: Generate text-to-speech audio + - Multiple language and speaker options + - Adjustable speaking speed + +### Marketplace +- `get_available_gpus`: Get available GPU resources + - Lists GPUs available for use + - Shows pricing information + +- `get_available_gpus_by_type`: Get GPUs filtered by model type + - Filters available GPUs by specific model + +- `get_available_gpus_types`: Get list of available GPU types + - Shows all GPU models currently available + +- `get_gpu_status`: Check status of GPU resources + - Shows current usage and availability + +- `rent_compute`: Rent GPU compute resources + - Specify cluster, node, and GPU count + - Returns instance information + +- `terminate_compute`: Terminate a rented GPU compute instance + - Release GPU resources no longer needed + +### Billing +- `get_current_balance`: Get current account balance + - Shows available funds + +- `get_purchase_history`: Get purchase history + - Lists previous purchases + - Shows transaction details + +- `get_spend_history`: Get spending history + - Shows where funds have been spent + - Lists transactions by service + +### Settings +- `link_wallet_address`: Link a wallet address to your account + - Enables crypto funding of account + - Supports Ethereum-compatible addresses + +## Environment Variables + +The Hyperbolic Labs action provider requires the following environment variables: + +- `HYPERBOLIC_API_KEY`: API key for authentication with Hyperbolic Labs +- `HYPERBOLIC_SSH_PRIVATE_KEY_PATH`: Path to SSH private key + +## Adding New Actions + +To add new Hyperbolic Labs actions: + +1. Identify which service category your action belongs to +2. Define your action schema in the appropriate `schemas.py` +3. Implement the action in the corresponding action provider +4. Implement tests in the corresponding test directory + +## Prompts + +### *please show my hyperbolic balance* + +
+Tool Output + +``` +Your current Hyperbolic platform balance is $3.61. +``` +
+ +
+Agent Output + +``` +Your current Hyperbolic platform balance is $3.61. +``` +
+ +### *please show my hyperbolic purchase history* + +
+Tool Output + +``` +Purchase History (showing 5 most recent): +- $1.00 on March 06, 2025 +- $10.00 on March 06, 2025 +``` +
+ +
+Agent Output + +``` +Here is your Hyperbolic purchase history (showing the 5 most recent transactions): + +- $1.00 on March 06, 2025 +- $10.00 on March 06, 2025 +``` +
+ +### *please show my hyperbolic spend history* + +
+Tool Output + +``` +=== GPU Rental Spending Analysis === + +Instance Rentals (showing 5 most recent): +- antique-peach-rhinoceros: + GPU: NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090 (Count: 1) + Duration: 225 seconds + Cost: $0.03 +- clearcut-chrysanthemum-ape: + GPU: NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090 (Count: 1) + Duration: 90 seconds + Cost: $0.01 +- austere-cyclamen-frog: + GPU: NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090 (Count: 1) + Duration: 2335 seconds + Cost: $0.23 +- greedy-daffodil-dragon: + GPU: NVIDIA-GeForce-RTX-3070 (Count: 1) + Duration: Unavailable (missing timestamp data) + Cost: Unavailable +- super-grape-crocodile: + GPU: NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090, NVIDIA-GeForce-RTX-4090 (Count: 1) + Duration: Unavailable (missing timestamp data) + Cost: Unavailable + +GPU Type Statistics (showing 2 most recent): + +NVIDIA-GeForce-RTX-4090: + Total Rentals: 10.0 + Total Time: 363954 seconds + Total Cost: $6.80 + +NVIDIA-H100-80GB-HBM3: + Total Rentals: 14.0 + Total Time: 3084 seconds + Total Cost: $0.16 + +Total Spending: $6.96 +``` +
+ +
+Agent Output + +``` +Here is your Hyperbolic spend history: + +### Instance Rentals (showing 5 most recent): +1. **antique-peach-rhinoceros** + - GPU: NVIDIA GeForce RTX 4090 (Count: 1) + - Duration: 225 seconds + - Cost: $0.03 + +2. **clearcut-chrysanthemum-ape** + - GPU: NVIDIA GeForce RTX 4090 (Count: 1) + - Duration: 90 seconds + - Cost: $0.01 + +3. **austere-cyclamen-frog** + - GPU: NVIDIA GeForce RTX 4090 (Count: 1) + - Duration: 2335 seconds + - Cost: $0.23 + +4. **greedy-daffodil-dragon** + - GPU: NVIDIA GeForce RTX 3070 (Count: 1) + - Duration: Unavailable (missing timestamp data) + - Cost: Unavailable + +5. **super-grape-crocodile** + - GPU: NVIDIA GeForce RTX 4090 (Count: 1) + - Duration: Unavailable (missing timestamp data) + - Cost: Unavailable + +### GPU Type Statistics (showing 2 most recent): +- **NVIDIA GeForce RTX 4090** + - Total Rentals: 10 + - Total Time: 363,954 seconds + - Total Cost: $6.80 + +- **NVIDIA H100 80GB HBM3** + - Total Rentals: 14 + - Total Time: 3,084 seconds + - Total Cost: $0.16 + +### Total Spending: $6.96 +``` +
+ +### *please use hyperbolic to generate a short paragrpah describing the number zero* + +
+Tool Output + +``` +Text generation successful: +- Saved to: /Users/chris/repositories/agentkit/python/examples/langchain-cdp-chatbot/tmp/generated_text_d6f5a399-659f-4950-8c02-e7c23f7c942f.txt + +Preview (first 500 chars): +The number zero is a fascinating and fundamental concept in mathematics, representing the absence or nullity of quantity. It is a placeholder digit that allows us to denote the concept of nothingness or the lack of value in a numerical sequence. Despite its seemingly simple nature, zero has played a crucial role in the development of mathematics, enabling the creation of the decimal system and facilitating calculations that involve negative numbers, fractions, and decimals. In many cultures, zer... +``` +
+ +
+Agent Output + +``` +I have generated a short paragraph describing the number zero: + +"The number zero is a fascinating and fundamental concept in mathematics, representing the absence or nullity of quantity. It is a placeholder digit that allows us to denote the concept of nothingness or the lack of value in a numerical sequence. Despite its seemingly simple nature, zero has played a crucial role in the development of mathematics, enabling the creation of the decimal system and facilitating calculations that involve negative numbers, fractions, and decimals." + +If you need anything else, feel free to ask! +``` +
+ +### *please use hyperbolic to generate an image describing the number zero* + +
+Tool Output + +``` +Image generation successful: +- Saved to: /Users/chris/repositories/agentkit/python/examples/langchain-cdp-chatbot/tmp/generated_image_afeb3db9-66a5-40d6-a2f3-e4fdbffae129.png +``` +
+ +
+Agent Output + +``` +I have generated an artistic image representing the number zero, incorporating themes of absence, infinity, and mathematical concepts. If you would like to view the image or need any further assistance, please let me know! +``` +
+ +### *please use hyperbolic to generate a short audio segment describing the number zero* + +
+Tool Output + +``` +Audio generation successful: +- Saved to: /Users/chris/repositories/agentkit/python/examples/langchain-cdp-chatbot/tmp/generated_audio_91103779-dee8-4b33-b34d-a8b08c18187a.mp3 +``` +
+ +
+Agent Output + +``` +I have generated a short audio segment describing the number zero. If you would like to listen to it or need any further assistance, please let me know! +``` +
+ +### *please get available hyperbolic gpus by type* + +
+Tool Output + +``` +Available GPU Types: +- NVIDIA-GeForce-RTX-3070 +- NVIDIA-GeForce-RTX-3080 +- NVIDIA-GeForce-RTX-4090 +- NVIDIA-H100-80GB-HBM3 +``` +
+ +
+Agent Output + +``` +Here are the available GPU types on the Hyperbolic platform: + +- NVIDIA GeForce RTX 3070 +- NVIDIA GeForce RTX 3080 +- NVIDIA GeForce RTX 4090 +- NVIDIA H100 80GB HBM3 + +If you need more information or assistance, feel free to ask! +``` +
+ +### *please get available hyperbolic 4090s* + +
+Tool Output + +``` +Available NVIDIA-GeForce-RTX-4090 GPU Options: + +Cluster: growing-rosemary-dragonfly +Node ID: antalpha-super-server-100194 +GPU Model: NVIDIA-GeForce-RTX-4090 +Available GPUs: 1/8 +Price: $0.35/hour per GPU +---------------------------------------- + + +Cluster: lame-clover-jellyfish +Node ID: zillion23 +GPU Model: NVIDIA-GeForce-RTX-4090 +Available GPUs: 6/8 +Price: $0.40/hour per GPU +---------------------------------------- + + +Cluster: webbed-peperomia-magpie +Node ID: las1-prd-acl-msi-07.fen.intra +GPU Model: NVIDIA-GeForce-RTX-4090 +Available GPUs: 1/4 +Price: $0.30/hour per GPU +---------------------------------------- +``` +
+ +
+Agent Output + +``` +Here are the available NVIDIA GeForce RTX 4090 GPU options: + +1. **Cluster:** growing-rosemary-dragonfly + - **Node ID:** antalpha-super-server-100194 + - **Available GPUs:** 1/8 + - **Price:** $0.35/hour per GPU + +2. **Cluster:** lame-clover-jellyfish + - **Node ID:** zillion23 + - **Available GPUs:** 6/8 + - **Price:** $0.40/hour per GPU + +3. **Cluster:** webbed-peperomia-magpie + - **Node ID:** las1-prd-acl-msi-07.fen.intra + - **Available GPUs:** 1/4 + - **Price:** $0.30/hour per GPU + +If you need further details or assistance, let me know! +``` +
+ +### *please rent the cheapest hyperbolic 4090* + +
+Tool Output + +``` +{ + "status": "success", + "instance_name": "mortified-morningglory-wombat" +} + +Next Steps: +1. Your GPU instance is being provisioned +2. Use get_gpu_status to check when it's ready +3. Once status is 'running', you can: + - Connect via SSH using the provided command + - Run commands using remote_shell + - Install packages and set up your environment +``` +
+ +
+Agent Output + +``` +The cheapest NVIDIA GeForce RTX 4090 GPU has been successfully rented. Here are the details: + +- **Instance Name:** mortified-morningglory-wombat + +### Next Steps: +1. Your GPU instance is being provisioned. +2. Use the **get_gpu_status** function to check when it's ready. +3. Once the status is 'running', you can: + - Connect via SSH using the provided command. + - Run commands using remote_shell. + - Install packages and set up your environment. + +If you need further assistance, feel free to ask! +``` +
+ +### *please show my hyperbolic instances* + +
+Tool Output + +``` +Your Rented GPU Instances: +Instance ID: mortified-morningglory-wombat +Status: running (Ready to use) +GPU Model: NVIDIA-GeForce-RTX-4090 +GPU Count: 1 +GPU Memory: 24.0 GB +SSH Command: ssh ubuntu@mortified-morningglory-wombat.1.cricket.hyperbolic.xyz -p 31065 +---------------------------------------- + +---------------------------------------- + +SSH Connection Instructions: +1. Wait until instance status is 'running' +2. Use the ssh_connect action with the provided host and username +3. Once connected, use remote_shell to execute commands +``` +
+ +
+Agent Output + +``` +Here are your rented Hyperbolic GPU instances: + +### Instance Details: +- **Instance ID:** mortified-morningglory-wombat +- **Status:** Running (Ready to use) +- **GPU Model:** NVIDIA GeForce RTX 4090 +- **GPU Count:** 1 +- **GPU Memory:** 24.0 GB + +### SSH Command: +To connect to your instance, use the following command: +ssh ubuntu@mortified-morningglory-wombat.1.cricket.hyperbolic.xyz -p 31065 + +### SSH Connection Instructions: +1. Wait until the instance status is 'running'. +2. Use the provided SSH command to connect. +3. Once connected, you can use remote_shell to execute commands. + +If you need further assistance, feel free to ask! +``` +
+ +### *please terminate my hyperbolic instance* + +
+Tool Output + +``` +{ + "status": "success", + "message": null, + "error_code": null +} + +Next Steps: +1. Your GPU instance has been terminated +2. Any active SSH connections have been closed +3. You can check your spend history with get_spend_history +4. To rent a new instance, use get_available_gpus and rent_compute +``` +
+ +
+Agent Output + +``` +Your Hyperbolic GPU instance has been successfully terminated. + +### Next Steps: +1. Any active SSH connections have been closed. +2. You can check your spend history using the **get_spend_history** function. +3. To rent a new instance, use the **get_available_gpus** function and then rent a compute. + +If you need any further assistance, feel free to ask! +``` +
+ +### *please link my current wallet address to my account at hyperbolic* + +
+Tool Output + +``` +Wallet Details: +- Provider: cdp_wallet_provider +- Address: 0x5E83884F5d399131bbDe98f60854E43c7A12Cf7A +- Network: + * Protocol Family: evm + * Network ID: base-mainnet + * Chain ID: 8453 +- Native Balance: 0 +------------------- + +{ + "success": true, + "error_code": null, + "message": null +} +wallet_address: 0x5E83884F5d399131bbDe98f60854E43c7A12Cf7A + +Next Steps: +1. Your wallet has been successfully linked to your Hyperbolic account +2. To add funds, send any of these tokens on Base Mainnet: + - USDC + - USDT + - DAI +3. Send to this Hyperbolic address: 0xd3cB24E0Ba20865C530831C85Bd6EbC25f6f3B60 +``` +
+ +
+Agent Output + +``` +Your wallet address has been successfully linked to your Hyperbolic account. + +### Next Steps: +1. To add funds, you can send any of the following tokens on the Base Mainnet: + - USDC + - USDT + - DAI +2. Send to this Hyperbolic address: **0xd3cB24E0Ba20865C530831C85Bd6EbC25f6f3B60** + +If you need further assistance, feel free to ask! +``` +
\ No newline at end of file diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/__init__.py new file mode 100644 index 000000000..1eef2e888 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/__init__.py @@ -0,0 +1,8 @@ +"""Hyperbolic Labs Action Provider for renting compute resources through the Hyperbolic Platform.""" + +from .hyperbolic_action_provider import HyperbolicActionProvider, hyperbolic_action_provider + +__all__ = [ + "HyperbolicActionProvider", + "hyperbolic_action_provider", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/action_provider.py new file mode 100644 index 000000000..be2803ce6 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/action_provider.py @@ -0,0 +1,60 @@ +"""Base class for Hyperbolic action providers. + +This module provides a base class for all Hyperbolic action providers +with centralized API key handling. +""" + +from ...network import Network +from ..action_provider import ActionProvider, TWalletProvider +from .utils import get_api_key + + +class ActionProvider(ActionProvider[TWalletProvider]): + """Base class for all Hyperbolic action providers. + + This base class centralizes API key handling for all Hyperbolic action providers. + It requires an API key which can be provided directly or through the + HYPERBOLIC_API_KEY environment variable. + """ + + def __init__( + self, + name: str, + action_providers: list["ActionProvider[TWalletProvider]"], + api_key: str | None = None, + ): + """Initialize the Hyperbolic base action provider. + + Args: + name: The name of the action provider. + action_providers: List of sub-providers. + api_key: Optional API key for authentication. If not provided, + will attempt to read from HYPERBOLIC_API_KEY environment variable. + + Raises: + ValueError: If API key is not provided and not found in environment. + + """ + try: + self.api_key = api_key or get_api_key() + except ValueError as e: + raise ValueError( + f"{e!s} Please provide it directly " + "or set the HYPERBOLIC_API_KEY environment variable." + ) from e + + super().__init__(name, action_providers) + + def supports_network(self, network: Network) -> bool: + """Check if network is supported by Hyperbolic actions. + + Hyperbolic services are not network-specific, so this always returns True. + + Args: + network: The network to check. + + Returns: + bool: Always True as Hyperbolic services are network-agnostic. + + """ + return True diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/__init__.py new file mode 100644 index 000000000..5fa0c41c3 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/__init__.py @@ -0,0 +1,12 @@ +"""Hyperbolic AI action provider module. + +This module provides actions for interacting with Hyperbolic AI services, +including text, image, and audio generation. +""" + +from .action_provider import AIActionProvider, ai_action_provider + +__all__ = [ + "AIActionProvider", + "ai_action_provider", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/action_provider.py new file mode 100644 index 000000000..af2a1ed56 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/action_provider.py @@ -0,0 +1,272 @@ +"""Hyperbolic AI action provider. + +This module provides actions for interacting with Hyperbolic AI services. +It includes functionality for text, image and audio generation. +""" + +import uuid +from typing import Any + +from ...action_decorator import create_action +from ..action_provider import ActionProvider +from .schemas import ( + GenerateAudioSchema, + GenerateImageSchema, + GenerateTextSchema, +) +from .service import AIService +from .types import ( + AudioGenerationRequest, + ChatCompletionRequest, + ChatMessage, + ImageGenerationRequest, +) +from .utils import save_base64_data, save_text + + +class AIActionProvider(ActionProvider): + """Action provider for generating text, images and audio via AI.""" + + description = "Action provider for generating text, images and audio via AI." + + def __init__( + self, + api_key: str | None = None, + ): + """Initialize the AI action provider. + + Args: + api_key: The API key for authentication. + + """ + super().__init__("hyperbolic_ai", [], api_key=api_key) + self.ai_service = AIService(api_key=self.api_key) + + @create_action( + name="generate_text", + description=""" +This tool generates text using specified language model. + +Required inputs: +- prompt: Text prompt for generation. +- model: (Optional) Model to use for text generation. + Default: "meta-llama/Meta-Llama-3-70B-Instruct" + +Example successful response: + Text generation successful: + - Saved to: /path/to/generated_text_{uuid}.txt + + Preview (first 500 chars): + +Example error response: + Error: Invalid model specified + Error: API request failed + +Important notes: +- The prompt should be clear and specific +- Response length depends on the prompt and model +- The generated text is saved to a file with a UUID in the name +""", + schema=GenerateTextSchema, + ) + def generate_text(self, args: dict[str, Any]) -> str: + """Generate text using specified language model. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + validated_args = GenerateTextSchema(**args) + + messages = [] + if validated_args.system_prompt: + messages.append(ChatMessage(role="system", content=validated_args.system_prompt)) + messages.append(ChatMessage(role="user", content=validated_args.prompt)) + + request = ChatCompletionRequest( + messages=messages, + model=validated_args.model, + ) + + response = self.ai_service.generate_text(request) + + if not response.choices or len(response.choices) == 0: + return "Error: Generation failed: No text was generated by the model." + + generated_text = response.choices[0].message.content + + filename = f"./tmp/generated_text_{uuid.uuid4()}.txt" + file_path = save_text(generated_text, filename) + + preview = generated_text[:500] + "..." if len(generated_text) > 500 else generated_text + + output = [ + "Text generation successful:", + f"- Saved to: {file_path}", + "", + "Preview (first 500 chars):", + preview, + ] + + return "\n".join(output) + except Exception as e: + return f"Error: Text generation: {e!s}" + + @create_action( + name="generate_image", + description=""" +This tool generates images using specified model. + +Required inputs: +- prompt: The image prompt to generate from +- model_name: (Optional) The model to use (default: "SDXL1.0-base") +- height: (Optional) Image height in pixels (default: 1024) +- width: (Optional) Image width in pixels (default: 1024) +- steps: (Optional) Number of inference steps (default: 30) +- num_images: (Optional) Number of images to generate (default: 1) +- negative_prompt: (Optional) What to avoid in the image + +Example successful response: + Image generation successful: + - Saved to: /path/to/generated_image_{uuid}.png + +Example error response: + Error: Invalid model specified + Error: API request failed + +Important notes: +- The prompt should be descriptive and specific +- Images are saved as PNG files +- The image files include a UUID in the filename +""", + schema=GenerateImageSchema, + ) + def generate_image(self, args: dict[str, Any]) -> str: + """Generate images using specified model. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + validated_args = GenerateImageSchema(**args) + + request = ImageGenerationRequest( + prompt=validated_args.prompt, + model_name=validated_args.model_name, + height=validated_args.height, + width=validated_args.width, + steps=validated_args.steps, + num_images=validated_args.num_images, + negative_prompt=validated_args.negative_prompt, + ) + + response = self.ai_service.generate_image(request) + + file_paths = [] + for img in response.images: + filename = f"./tmp/generated_image_{uuid.uuid4()}.png" + file_path = save_base64_data(img.image, filename) + file_paths.append(file_path) + + if not file_paths: + return "Error: Generation failed: No images were generated." + + output = ["Image generation successful:"] + for path in file_paths: + output.append(f"- Saved to: {path}") + + return "\n".join(output) + + except Exception as e: + return f"Error: Image generation: {e!s}" + + @create_action( + name="generate_audio", + description=""" +This tool generates audio from text using specified language and speaker. + +Required inputs: +- text: The text to convert to speech +- language: (Optional) The language code (default: "EN") +- speaker: (Optional) The speaker voice (default: "EN-US") +- speed: (Optional) Speaking speed multiplier (0.1-5.0) + +Example successful response: + Audio generation successful: + - Saved to: /path/to/generated_audio_{uuid}.mp3 + +Example error response: + Error: Invalid language specified + Error: API request failed + +Important notes: +- The text should be in the specified language +- Audio is saved as MP3 file +- The audio files include a UUID in the filename +""", + schema=GenerateAudioSchema, + ) + def generate_audio(self, args: dict[str, Any]) -> str: + """Generate audio from text using specified language and speaker. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + if isinstance(args, str): + args = {"text": args} + + validated_args = GenerateAudioSchema(**args) + + request = AudioGenerationRequest( + text=validated_args.text, + language=validated_args.language, + speaker=validated_args.speaker, + speed=validated_args.speed, + ) + + response = self.ai_service.generate_audio(request) + + filename = f"./tmp/generated_audio_{uuid.uuid4()}.mp3" + file_path = save_base64_data(response.audio, filename) + + output = [ + "Audio generation successful:", + f"- Saved to: {file_path}", + ] + + return "\n".join(output) + + except Exception as e: + return f"Error: Audio generation: {e!s}" + + +def ai_action_provider( + api_key: str | None = None, +) -> AIActionProvider: + """Create a new instance of the AIActionProvider. + + Args: + api_key: Optional API key for authentication. If not provided, + will attempt to read from HYPERBOLIC_API_KEY environment variable. + + Returns: + A new AI action provider instance. + + Raises: + ValueError: If API key is not provided and not found in environment. + + """ + return AIActionProvider(api_key=api_key) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/schemas.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/schemas.py new file mode 100644 index 000000000..9e46bd6ec --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/schemas.py @@ -0,0 +1,78 @@ +"""Schemas for Hyperbolic AI actions. + +This module provides simplified schemas for AI action inputs. +""" + +from pydantic import BaseModel, Field + + +class GenerateTextSchema(BaseModel): + """Schema for generate_text action.""" + + prompt: str = Field(description="The text prompt to generate from", min_length=1) + model: str = Field( + default="meta-llama/Meta-Llama-3-70B-Instruct", + description="The model to use for text generation", + ) + system_prompt: str | None = Field( + default=None, + description="Optional system prompt to guide the model's behavior", + ) + + +class GenerateImageSchema(BaseModel): + """Schema for generate_image action.""" + + prompt: str = Field(description="The image prompt to generate from", min_length=1) + model_name: str = Field( + default="SDXL1.0-base", + description="The model to use for image generation", + ) + height: int = Field( + default=1024, + description="Image height in pixels", + ge=64, + le=2048, + ) + width: int = Field( + default=1024, + description="Image width in pixels", + ge=64, + le=2048, + ) + steps: int = Field( + default=30, + description="Number of inference steps", + ge=1, + le=100, + ) + num_images: int = Field( + default=1, + description="Number of images to generate", + ge=1, + le=4, + ) + negative_prompt: str | None = Field( + None, + description="Text specifying what the model should not generate", + ) + + +class GenerateAudioSchema(BaseModel): + """Schema for generate_audio action.""" + + text: str = Field(description="The text to convert to speech", min_length=1) + language: str = Field( + default="EN", + description="The language code (e.g., 'EN', 'ES', 'FR', 'ZH', 'JP', 'KR')", + ) + speaker: str = Field( + default="EN-US", + description="The speaker voice (e.g., 'EN-US', 'EN-GB', 'ES-ES')", + ) + speed: float | None = Field( + None, + description="Speaking speed multiplier", + ge=0.1, + le=5.0, + ) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/service.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/service.py new file mode 100644 index 000000000..b55adc6ca --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/service.py @@ -0,0 +1,90 @@ +"""Service for AI-related operations.""" + +from ..constants import AI_SERVICES_BASE_URL, AI_SERVICES_ENDPOINTS, SUPPORTED_IMAGE_MODELS +from ..service import Base +from .types import ( + AudioGenerationRequest, + AudioGenerationResponse, + ChatCompletionRequest, + ChatCompletionResponse, + ImageGenerationRequest, + ImageGenerationResponse, +) + + +class AIService(Base): + """AI service for Hyperbolic platform.""" + + def __init__(self, api_key: str): + """Initialize AI service. + + Args: + api_key: API key for authentication. + + """ + super().__init__(api_key, AI_SERVICES_BASE_URL) + + def generate_text( + self, + request: ChatCompletionRequest, + ) -> ChatCompletionResponse: + """Generate text using specified model. + + Args: + request: The ChatCompletionRequest object containing the request parameters. + + Returns: + ChatCompletionResponse: The chat completion response. + + """ + response = self.make_request( + endpoint=AI_SERVICES_ENDPOINTS["TEXT_GENERATION"], + data=request.model_dump(exclude_none=True), + ) + + return ChatCompletionResponse(**response.json()) + + def generate_image( + self, + request: ImageGenerationRequest, + ) -> ImageGenerationResponse: + """Generate images using specified model. + + Args: + request: The ImageGenerationRequest object containing the request parameters. + + Returns: + ImageGenerationResponse: The image generation response. + + """ + if request.model_name not in SUPPORTED_IMAGE_MODELS: + raise ValueError( + f"Model {request.model_name} not supported. Use one of: {SUPPORTED_IMAGE_MODELS}" + ) + + response = self.make_request( + endpoint=AI_SERVICES_ENDPOINTS["IMAGE_GENERATION"], + data=request.model_dump(exclude_none=True), + ) + + return ImageGenerationResponse(**response.json()) + + def generate_audio( + self, + request: AudioGenerationRequest, + ) -> AudioGenerationResponse: + """Generate audio using specified model. + + Args: + request: The AudioGenerationRequest object containing the request parameters. + + Returns: + AudioGenerationResponse: The audio generation response. + + """ + response = self.make_request( + endpoint=AI_SERVICES_ENDPOINTS["AUDIO_GENERATION"], + data=request.model_dump(exclude_none=True), + ) + + return AudioGenerationResponse(**response.json()) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/types.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/types.py new file mode 100644 index 000000000..d8752b0a6 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/types.py @@ -0,0 +1,203 @@ +"""Types for Hyperbolic AI services. + +This module provides type definitions for AI API communication. +""" + +from pydantic import BaseModel, Field, field_validator + +from ..constants import ( + SUPPORTED_AUDIO_LANGUAGES, + SUPPORTED_AUDIO_SPEAKERS, + SUPPORTED_MESSAGE_ROLES, +) + + +class ChatMessage(BaseModel): + """A single message in a chat conversation.""" + + role: str = Field(..., description="The role of the message sender") + content: str = Field(..., description="The content of the message") + + @classmethod + @field_validator("role") + def validate_role(cls, v): + """Validate that the role is supported.""" + if v not in SUPPORTED_MESSAGE_ROLES: + raise ValueError(f"Role must be one of {SUPPORTED_MESSAGE_ROLES}") + return v + + +class ChatCompletionRequest(BaseModel): + """Request model for chat completion API.""" + + messages: list[ChatMessage] = Field(..., description="List of chat messages") + model: str = Field(..., description="The model to use for completion") + frequency_penalty: float | None = Field( + None, description="Penalty for token frequency", ge=0.0, le=1.0 + ) + logit_bias: dict[str, float] | None = Field(None, description="Token bias mapping") + logprobs: int | None = Field(None, description="Number of log probabilities to return") + top_logprobs: int | None = Field( + None, description="Number of most likely tokens to return", ge=0, le=20 + ) + max_tokens: int | None = Field(None, description="Maximum number of tokens to generate") + n: int | None = Field(None, description="Number of completions to generate") + presence_penalty: float | None = Field( + None, description="Penalty for token presence", ge=0.0, le=1.0 + ) + seed: int | None = Field(None, description="Random seed for generation") + stop: list[str] | None = Field(None, description="Sequences where the API will stop generating") + stream: bool | None = Field(False, description="Whether to stream the response") + temperature: float | None = Field(None, description="Sampling temperature", ge=0.0, le=2.0) + top_p: float | None = Field(None, description="Nucleus sampling threshold", ge=0.0, le=1.0) + user: str | None = Field(None, description="Unique identifier for the end-user") + top_k: int | None = Field(None, description="Number of top tokens to consider") + min_p: float | None = Field( + None, description="Minimum token probability threshold", ge=0.0, le=1.0 + ) + repetition_penalty: float | None = Field( + None, description="Penalty for token repetition", ge=0.0 + ) + + +class ChatCompletionResponseMessage(BaseModel): + """A message in the chat completion response.""" + + role: str = Field(..., description="The role of the message sender") + content: str = Field(..., description="The content of the message") + + @classmethod + @field_validator("role") + def validate_role(cls, v): + """Validate that the role is supported.""" + if v != "assistant": + raise ValueError("Role must be 'assistant'") + return v + + +class ChatCompletionResponseChoice(BaseModel): + """A single choice in the chat completion response.""" + + index: int = Field(..., description="Index of this choice") + message: ChatCompletionResponseMessage = Field(..., description="The message content") + finish_reason: str | None = Field(None, description="Reason for finishing") + + +class ChatCompletionResponseUsage(BaseModel): + """Token usage information.""" + + prompt_tokens: int = Field(..., description="Number of tokens in the prompt") + completion_tokens: int = Field(..., description="Number of tokens in the completion") + total_tokens: int = Field(..., description="Total number of tokens used") + + +class ChatCompletionResponse(BaseModel): + """Response model for chat completion API.""" + + id: str = Field(..., description="Unique identifier for this completion") + object: str = Field(..., description="Object type") + created: int = Field(..., description="Unix timestamp of creation") + model: str = Field(..., description="Model used for completion") + choices: list[ChatCompletionResponseChoice] = Field( + ..., description="List of completion choices" + ) + usage: ChatCompletionResponseUsage | None = Field(None, description="Token usage information") + + +class ImageGenerationRequest(BaseModel): + """Request model for image generation API.""" + + prompt: str = Field(..., description="The text description of the image to generate") + model_name: str = Field(..., description="The name of Stable Diffusion model to use") + height: int = Field(..., description="Height of the image to generate") + width: int = Field(..., description="Width of the image to generate") + backend: str = Field("auto", description="Computational backend for the model") + negative_prompt: str | None = Field( + None, description="Text specifying what the model should not generate" + ) + num_images: int = Field(1, description="Number of images to generate") + steps: int = Field(30, description="Number of inference steps") + seed: int | None = Field(None, description="Random seed for reproducibility") + cfg_scale: float | None = Field( + None, description="Guidance scale for image relevance to prompt" + ) + style_preset: str | None = Field( + None, description="Topic to guide the image model towards a particular style" + ) + enable_refiner: bool = Field(False, description="Enable Stable Diffusion XL-refiner") + controlnet_name: str | None = Field(None, description="Name of ControlNet to use") + controlnet_image: str | None = Field( + None, description="Base64 encoded image for ControlNet input" + ) + loras: dict[str, float] | None = Field(None, description="Pairs of lora name and weight") + + +class ImageMetadata(BaseModel): + """Metadata for a generated image.""" + + seed: int | None = Field(None, description="The seed used for generation") + prompt: str | None = Field(None, description="The prompt used for generation") + negative_prompt: str | None = Field(None, description="The negative prompt used") + cfg_scale: float | None = Field(None, description="The guidance scale used") + steps: int | None = Field(None, description="Number of inference steps used") + + +class GeneratedImage(BaseModel): + """A single generated image with its metadata.""" + + image: str = Field(..., description="Base64 encoded image data") + random_seed: int = Field(..., description="Random seed used for generation") + index: int = Field(..., description="Index of the image in batch") + + +class ImageGenerationResponse(BaseModel): + """Response model for image generation API.""" + + images: list[GeneratedImage] = Field(..., description="List of generated images") + inference_time: float | None = Field(None, description="Time taken for inference in seconds") + + +class AudioGenerationRequest(BaseModel): + """Request model for audio generation API.""" + + text: str = Field(..., description="Text input to convert to speech") + language: str | None = Field(None, description="Language code for text-to-speech") + speaker: str | None = Field( + None, description="Specific speaker voice for the selected language" + ) + sdp_ratio: float | None = Field(None, description="SDP ratio parameter", ge=0.0, le=1.0) + noise_scale: float | None = Field(None, description="Noise scale parameter", ge=0.0, le=1.0) + noise_scale_w: float | None = Field(None, description="Noise scale w parameter", ge=0.0, le=1.0) + speed: float | None = Field(None, description="Speech speed multiplier", ge=0.1, le=5.0) + + @classmethod + @field_validator("language") + def validate_language(cls, v): + """Validate that the language is supported.""" + if v is not None and v not in SUPPORTED_AUDIO_LANGUAGES: + raise ValueError(f"Language must be one of {SUPPORTED_AUDIO_LANGUAGES}") + return v + + @classmethod + @field_validator("speaker") + def validate_speaker(cls, v, values): + """Validate that the speaker is valid for the selected language.""" + if v is None or "language" not in values: + return v + + language = values["language"] + valid_speakers = SUPPORTED_AUDIO_SPEAKERS + + if language in valid_speakers and v not in valid_speakers[language]: + raise ValueError( + f"Invalid speaker '{v}' for language '{language}'. Valid speakers: {valid_speakers[language]}" + ) + + return v + + +class AudioGenerationResponse(BaseModel): + """Response model for audio generation API.""" + + audio: str = Field(..., description="Base64 encoded audio data in MP3 format") + duration: float | None = Field(None, description="Duration of the generated audio in seconds") diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/utils.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/utils.py new file mode 100644 index 000000000..9ce766e29 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/ai/utils.py @@ -0,0 +1,70 @@ +"""Utility functions for Hyperbolic AI services. + +This module provides utility functions for handling AI service operations +such as saving generated data. +""" + +import base64 +import os + + +def save_base64_data(base64_data: str, output_path: str) -> str: + """Save base64 encoded data to a file. + + Args: + base64_data: The base64 encoded data string + output_path: Path where to save the file + + Returns: + str: The absolute path to the saved file + + Raises: + ValueError: If the base64 data is invalid + OSError: If there's an error saving the file + + """ + try: + if "," in base64_data: + base64_data = base64_data.split(",")[1] + + decoded_data = base64.b64decode(base64_data) + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + + with open(output_path, "wb") as f: + f.write(decoded_data) + + return os.path.abspath(output_path) + except base64.binascii.Error as e: + raise ValueError(f"Invalid base64 data: {e!s}") from e + except OSError as e: + raise OSError(f"Error saving file: {e!s}") from e + + +def save_text(text: str, output_path: str) -> str: + """Save text data to a file and return a preview. + + Args: + text: The text to save + output_path: Path where to save the text file + + Returns: + str: The absolute path to the saved text file + + Raises: + OSError: If there's an error saving the file + + """ + try: + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(text) + + return os.path.abspath(output_path) + except OSError as e: + raise OSError(f"Error saving text file: {e!s}") from e + + +__all__ = [ + "save_base64_data", + "save_text", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/__init__.py new file mode 100644 index 000000000..12b8b5f52 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/__init__.py @@ -0,0 +1,11 @@ +"""Hyperbolic billing package. + +This package provides actions for interacting with Hyperbolic billing services. +""" + +from .action_provider import BillingActionProvider, hyperbolic_billing_action_provider + +__all__ = [ + "BillingActionProvider", + "hyperbolic_billing_action_provider", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/action_provider.py new file mode 100644 index 000000000..906850c60 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/action_provider.py @@ -0,0 +1,217 @@ +"""Hyperbolic Billing action provider. + +This module provides actions for interacting with Hyperbolic billing services. +It includes functionality for checking balance and spend history. +""" + +from typing import Any + +from ...action_decorator import create_action +from ..action_provider import ActionProvider +from ..marketplace.service import MarketplaceService +from .schemas import ( + GetCurrentBalanceSchema, + GetPurchaseHistorySchema, + GetSpendHistorySchema, +) +from .service import BillingService +from .utils import ( + format_purchase_history, + format_spend_history, +) + + +class BillingActionProvider(ActionProvider): + """Provides actions for interacting with Hyperbolic billing. + + This provider enables interaction with the Hyperbolic billing services for balance + and spend history. It requires an API key which can be provided directly or + through the HYPERBOLIC_API_KEY environment variable. + """ + + def __init__( + self, + api_key: str | None = None, + ): + """Initialize the Hyperbolic billing action provider. + + Args: + api_key: Optional API key for authentication. If not provided, + will attempt to read from HYPERBOLIC_API_KEY environment variable. + + Raises: + ValueError: If API key is not provided and not found in environment. + + """ + super().__init__("hyperbolic_billing", [], api_key=api_key) + self.billing = BillingService(self.api_key) + self.marketplace = MarketplaceService(self.api_key) + + @create_action( + name="get_current_balance", + description=""" +This tool retrieves your current Hyperbolic platform credit balance. + +It does not take any inputs. + +Example successful response: + Your current Hyperbolic platform balance is $3.61. + +Example error response: + Error: API request failed + Error: Invalid authentication credentials + +Important notes: +- This shows platform credits only, NOT cryptocurrency wallet balances +- All amounts are shown in USD +- Balance is displayed with 2 decimal precision +""", + schema=GetCurrentBalanceSchema, + ) + def get_current_balance(self, args: dict[str, Any]) -> str: + """Retrieve current balance and purchase history from the account. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + GetCurrentBalanceSchema(**args) + + response = self.billing.get_balance() + balance_usd = float(response.credits) / 100 + + return f"Your current Hyperbolic platform balance is ${balance_usd:.2f}.\n" + except Exception as e: + return f"Error: Balance retrieval: {e!s}" + + @create_action( + name="get_spend_history", + description=""" +This tool retrieves your GPU rental spending history from Hyperbolic platform. + +It does not take any inputs. + +Example successful response: + === GPU Rental Spending Analysis === + + Instance Rentals (showing 5 most recent): + - antique-peach-rhinoceros: + GPU: NVIDIA-GeForce-RTX-4090 (Count: 1) + Duration: 225 seconds + Cost: $0.03 + - clearcut-chrysanthemum-ape: + GPU: NVIDIA-GeForce-RTX-4090 (Count: 1) + Duration: 90 seconds + Cost: $0.01 + + GPU Type Statistics (showing 2 most recent): + NVIDIA-GeForce-RTX-4090: + Total Rentals: 10.0 + Total Time: 363954 seconds + Total Cost: $6.80 + + NVIDIA-H100-80GB-HBM3: + Total Rentals: 14.0 + Total Time: 3084 seconds + Total Cost: $0.16 + + Total Spending: $6.96 + +Example error response: + Error: API request failed + +Important notes: +- All costs are in USD +- Duration is in seconds +- History includes instance names with animal-based identifiers +""", + schema=GetSpendHistorySchema, + ) + def get_spend_history(self, args: dict[str, Any]) -> str: + """Retrieve GPU rental spending history from the platform. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + GetSpendHistorySchema(**args) + + response = self.marketplace.get_instance_history() + if not response: + return "Could not retrieve instance history. Please try again later." + + if not response.instance_history: + return "No rental history found." + + return format_spend_history(response) + except Exception as e: + return f"Error: Spend history retrieval: {e!s}" + + @create_action( + name="get_purchase_history", + description=""" +This tool retrieves your purchase history of Hyperbolic platform credits. + +It does not take any inputs. + +Example successful response: + Purchase History (showing 5 most recent): + - $1.00 on March 06, 2025 + - $10.00 on March 06, 2025 + +Example error response: + Error: API request failed + Error: Invalid authentication credentials + Error: No previous purchases found + +Important notes: +- This shows platform credit purchases only +- All amounts are shown in USD +- Purchase history is limited to 5 most recent by default +""", + schema=GetPurchaseHistorySchema, + ) + def get_purchase_history(self, args: dict[str, Any]) -> str: + """Retrieve the purchase history of platform credits. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + GetPurchaseHistorySchema(**args) + history_response = self.billing.get_purchase_history() + + return format_purchase_history(history_response) + except Exception as e: + return f"Error: Purchase history retrieval: {e!s}" + + +def hyperbolic_billing_action_provider( + api_key: str | None = None, +) -> BillingActionProvider: + """Create a new instance of the BillingActionProvider. + + Args: + api_key: Optional API key for authentication. If not provided, + will attempt to read from HYPERBOLIC_API_KEY environment variable. + + Returns: + A new Billing action provider instance. + + Raises: + ValueError: If API key is not provided and not found in environment. + + """ + return BillingActionProvider(api_key=api_key) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/schemas.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/schemas.py new file mode 100644 index 000000000..ca3f86b77 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/schemas.py @@ -0,0 +1,24 @@ +"""Schemas for Hyperbolic billing actions. + +This module provides simplified schemas for billing action inputs. +""" + +from pydantic import BaseModel + + +class GetCurrentBalanceSchema(BaseModel): + """Schema for get_current_balance action.""" + + pass + + +class GetPurchaseHistorySchema(BaseModel): + """Schema for get_purchase_history action.""" + + pass + + +class GetSpendHistorySchema(BaseModel): + """Schema for get_spend_history action.""" + + pass diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/service.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/service.py new file mode 100644 index 000000000..e44b8ec85 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/service.py @@ -0,0 +1,41 @@ +"""Service for billing-related operations.""" + +from ..constants import BILLING_BASE_URL, BILLING_ENDPOINTS +from ..service import Base +from .types import ( + BillingBalanceResponse, + BillingPurchaseHistoryResponse, +) + + +class BillingService(Base): + """Service for billing-related operations.""" + + def __init__(self, api_key: str): + """Initialize the billing service. + + Args: + api_key: The API key for authentication. + + """ + super().__init__(api_key, BILLING_BASE_URL) + + def get_balance(self) -> BillingBalanceResponse: + """Get current balance information. + + Returns: + BillingBalanceResponse: The balance data. + + """ + response = self.make_request(endpoint=BILLING_ENDPOINTS["GET_BALANCE"], method="GET") + return BillingBalanceResponse(**response.json()) + + def get_purchase_history(self) -> BillingPurchaseHistoryResponse: + """Get purchase history. + + Returns: + BillingPurchaseHistoryResponse: The purchase history data. + + """ + response = self.make_request(endpoint=BILLING_ENDPOINTS["PURCHASE_HISTORY"], method="GET") + return BillingPurchaseHistoryResponse(**response.json()) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/types.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/types.py new file mode 100644 index 000000000..944771097 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/types.py @@ -0,0 +1,83 @@ +"""Types for Hyperbolic billing operations. + +This module provides type definitions for billing API communication. +""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class Price(BaseModel): + """Price information.""" + + amount: float = Field(..., description="Price amount") + period: str = Field(..., description="Pricing period (e.g., 'hourly')") + agent: str | None = Field(None, description="Pricing agent") + + +class GpuHardware(BaseModel): + """GPU hardware information.""" + + hardware_type: str = Field("gpu", description="Type of hardware") + model: str = Field(..., description="GPU model name") + clock_speed: float | None = Field(None, description="GPU clock speed") + compute_power: float | None = Field(None, description="GPU compute power") + ram: float | None = Field(None, description="GPU RAM in GB") + interface: str | None = Field(None, description="GPU interface type") + + +class CpuHardware(BaseModel): + """CPU hardware information.""" + + hardware_type: str = Field("cpu", description="Type of hardware") + model: str = Field(..., description="CPU model name") + cores: int | None = Field(None, description="Number of physical cores") + virtual_cores: int = Field(..., description="Number of virtual cores") + + +class StorageHardware(BaseModel): + """Storage hardware information.""" + + hardware_type: str = Field(None, description="Type of hardware") + capacity: float = Field(..., description="Storage capacity") + + +class RamHardware(BaseModel): + """RAM hardware information.""" + + hardware_type: str = Field("ram", description="Type of hardware") + capacity: float = Field(..., description="RAM capacity") + + +class HardwareInfo(BaseModel): + """Complete hardware information.""" + + cpus: list[CpuHardware] | None = Field(None, description="List of CPU specifications") + gpus: list[GpuHardware] = Field(..., description="List of GPU specifications") + storage: list[StorageHardware] | None = Field( + None, description="List of storage specifications" + ) + ram: list[RamHardware] | None = Field(None, description="List of RAM specifications") + + +class BillingBalanceResponse(BaseModel): + """Response model for billing balance API.""" + + credits: Any = Field(..., description="Current balance in credits/USD") + + +class BillingPurchaseHistoryEntry(BaseModel): + """A single entry in the purchase history.""" + + amount: str = Field(..., description="Purchase amount in USD") + timestamp: str = Field(..., description="ISO format timestamp of the purchase") + source: str = Field(..., description="Source of the purchase") + + +class BillingPurchaseHistoryResponse(BaseModel): + """Response model for billing purchase history API.""" + + purchase_history: list[BillingPurchaseHistoryEntry] = Field( + ..., description="List of purchase history entries" + ) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/utils.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/utils.py new file mode 100644 index 000000000..f47c1e044 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/billing/utils.py @@ -0,0 +1,155 @@ +"""Utility functions for Hyperbolic billing services. + +This module provides utility functions for formatting and processing +billing information from Hyperbolic services. +""" + +from collections import defaultdict +from datetime import datetime + +from ..marketplace.types import InstanceHistoryResponse +from .types import ( + BillingPurchaseHistoryResponse, +) + + +def calculate_duration_seconds(start_time: str, end_time: str) -> float: + """Calculate duration in seconds between two timestamps. + + Args: + start_time: ISO format timestamp string. + end_time: ISO format timestamp string. + + Returns: + float: Duration in seconds. + + """ + if not start_time or not end_time: + return 0.0 + + start = datetime.fromisoformat( + start_time.replace("Z", "+00:00") if "Z" in start_time else start_time + ) + end = datetime.fromisoformat(end_time.replace("Z", "+00:00") if "Z" in end_time else end_time) + duration = end - start + + return duration.total_seconds() + + +def format_purchase_history(purchases: BillingPurchaseHistoryResponse, limit: int = 5) -> str: + """Format purchase history into a readable string. + + Args: + purchases: Billing purchase history response. + limit: Maximum number of purchase records to include in the output. + + Returns: + str: Formatted purchase history string. + + """ + if not purchases.purchase_history: + return "No previous purchases found" + + output = [f"Purchase History (showing {limit} most recent):"] + + for purchase in purchases.purchase_history[:limit]: + amount = float(purchase.amount) / 100 + timestamp = datetime.fromisoformat(purchase.timestamp.replace("Z", "+00:00")) + formatted_date = timestamp.strftime("%B %d, %Y") + output.append(f"- ${amount:.2f} on {formatted_date}") + + return "\n".join(output) + + +def format_spend_history(instance_history: InstanceHistoryResponse, limit: int = 5) -> str: + """Format spend history into a readable analysis. + + Args: + instance_history: Instance history response with rental records. + limit: Maximum number of spend records to include in the output. + + Returns: + str: Formatted analysis string. + + """ + if not instance_history.instance_history: + return "No rental history found." + + total_cost = 0 + gpu_stats = defaultdict(lambda: {"count": 0, "total_cost": 0, "total_seconds": 0}) + + instances_summary = [] + + for instance in instance_history.instance_history: + has_complete_time_data = instance.started_at and instance.terminated_at + + duration_seconds = 0 + duration_hours = 0 + cost = 0 + + if has_complete_time_data: + duration_seconds = calculate_duration_seconds( + instance.started_at, instance.terminated_at + ) + duration_hours = duration_seconds / 3600.0 + cost = (duration_hours * instance.price.amount) / 100.0 + total_cost += cost + + gpu_models = [] + if instance.hardware and instance.hardware.gpus and len(instance.hardware.gpus) > 0: + gpu_models = [gpu.model for gpu in instance.hardware.gpus if gpu.model] + + gpu_model = ", ".join(gpu_models) if gpu_models else "Unknown GPU" + gpu_count = instance.gpu_count or 0 + + if has_complete_time_data: + if gpu_models: + gpu_count_per_model = gpu_count / len(gpu_models) if len(gpu_models) > 0 else 0 + cost_per_model = cost / len(gpu_models) if len(gpu_models) > 0 else 0 + + for model in gpu_models: + gpu_stats[model]["count"] += gpu_count_per_model + gpu_stats[model]["total_cost"] += cost_per_model + gpu_stats[model]["total_seconds"] += duration_seconds + else: + gpu_stats["Unknown GPU"]["count"] += gpu_count + gpu_stats["Unknown GPU"]["total_cost"] += cost + gpu_stats["Unknown GPU"]["total_seconds"] += duration_seconds + + summary = { + "name": instance.instance_name or "unnamed-instance", + "gpu_model": gpu_model, + "gpu_count": gpu_count, + "duration_seconds": int(duration_seconds) if has_complete_time_data else None, + "cost": round(cost, 2) if has_complete_time_data else None, + "has_complete_time_data": has_complete_time_data, + } + instances_summary.append(summary) + + output = ["=== GPU Rental Spending Analysis ===\n"] + + output.append(f"Instance Rentals (showing {min(len(instances_summary), limit)} most recent):") + for instance in instances_summary[:limit]: + output.append(f"- {instance['name']}:") + output.append(f" GPU: {instance['gpu_model']} (Count: {instance['gpu_count']})") + + if instance["has_complete_time_data"]: + output.append(f" Duration: {instance['duration_seconds']} seconds") + output.append(f" Cost: ${instance['cost']:.2f}") + else: + output.append(" Duration: Unavailable (missing timestamp data)") + output.append(" Cost: Unavailable") + + if gpu_stats: + output.append(f"\nGPU Type Statistics (showing {min(len(gpu_stats), limit)} most recent):") + for gpu_model, stats in list(gpu_stats.items())[:limit]: + output.append(f"\n{gpu_model}:") + output.append(f" Total Rentals: {stats['count']}") + output.append(f" Total Time: {int(stats['total_seconds'])} seconds") + output.append(f" Total Cost: ${stats['total_cost']:.2f}") + + output.append(f"\nTotal Spending: ${total_cost:.2f}") + else: + output.append("\nNo complete rental data available to calculate statistics.") + + return "\n".join(output) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/constants.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/constants.py new file mode 100644 index 000000000..e0366eeed --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/constants.py @@ -0,0 +1,67 @@ +"""Constants for Hyperbolic API integration.""" + +# API Configuration +API_BASE_URL = "https://api.hyperbolic.xyz" +API_VERSION = "v1" + +# Default request headers +DEFAULT_HEADERS = { + "Content-Type": "application/json", +} + +# Base URLs for services +MARKETPLACE_BASE_URL = f"{API_BASE_URL}/{API_VERSION}/marketplace" +AI_SERVICES_BASE_URL = f"{API_BASE_URL}/{API_VERSION}" +BILLING_BASE_URL = f"{API_BASE_URL}/billing" +SETTINGS_BASE_URL = f"{API_BASE_URL}/settings" + +# API Endpoint paths +MARKETPLACE_ENDPOINTS = { + "LIST_INSTANCES": "", + "CREATE_INSTANCE": "/instances/create", + "LIST_USER_INSTANCES": "/instances", + "INSTANCE_HISTORY": "/instances/history", + "TERMINATE_INSTANCE": "/instances/terminate", +} + +AI_SERVICES_ENDPOINTS = { + "TEXT_GENERATION": "/chat/completions", + "IMAGE_GENERATION": "/image/generation", + "AUDIO_GENERATION": "/audio/generation", +} + +BILLING_ENDPOINTS = { + "GET_BALANCE": "/get_current_balance", + "PURCHASE_HISTORY": "/purchase_history", +} + +SETTINGS_ENDPOINTS = { + "LINK_WALLET": "/crypto-address", +} + +# Supported image models +SUPPORTED_IMAGE_MODELS = [ + "SDXL1.0-base", # Stable Diffusion XL 1.0 + "SD2", # Stable Diffusion v2 + "SD1.5", # Stable Diffusion v1-5 + "SSD", # Segmind Stable Diffusion 1B + "SDXL-turbo", # SDXL-Turbo + "SDXL-ControlNet", # SDXL1.0-base + ControlNet + "SD1.5-ControlNet", # SD1.5 + ControlNet +] + +# Supported message roles +SUPPORTED_MESSAGE_ROLES = ["system", "assistant", "user"] + +# Supported audio languages +SUPPORTED_AUDIO_LANGUAGES = ["EN", "ES", "FR", "ZH", "JP", "KR"] + +# Audio speakers by language +SUPPORTED_AUDIO_SPEAKERS = { + "EN": ["EN-US", "EN-BR", "EN-INDIA", "EN-AU"], + "ES": ["ES"], + "FR": ["FR"], + "ZH": ["ZH"], + "JP": ["JP"], + "KR": ["KR"], +} diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/hyperbolic_action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/hyperbolic_action_provider.py new file mode 100644 index 000000000..bd4a34944 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/hyperbolic_action_provider.py @@ -0,0 +1,100 @@ +"""Hyperbolic action provider. + +This module provides a unified interface to all Hyperbolic platform services. +It includes sub-providers for marketplace (GPU compute), billing, AI services, and account settings. +""" + +from ...network import Network +from ..action_provider import ActionProvider +from .ai.action_provider import ( + AIActionProvider, +) +from .billing.action_provider import ( + BillingActionProvider, +) +from .marketplace.action_provider import ( + MarketplaceActionProvider, +) +from .settings.action_provider import ( + SettingsActionProvider, +) +from .utils import get_api_key + + +class HyperbolicActionProvider(ActionProvider): + """Provides unified access to all Hyperbolic platform services. + + This provider aggregates functionality from all Hyperbolic action providers: + - ai: text, image, and audio generation + - billing: balance, spending history + - marketplace: GPU compute resources + - settings: account settings management + + It requires an API key which can be provided directly or through the + HYPERBOLIC_API_KEY environment variable. + """ + + def __init__( + self, + api_key: str | None = None, + ): + """Initialize the Hyperbolic action provider with all sub-providers. + + Args: + api_key: Optional API key for authentication. If not provided, + will attempt to read from HYPERBOLIC_API_KEY environment variable. + + Raises: + ValueError: If API key is not provided and not found in environment. + + """ + try: + api_key = api_key or get_api_key() + except ValueError as e: + raise ValueError( + f"{e!s} Please provide it directly " + "or set the HYPERBOLIC_API_KEY environment variable." + ) from e + + self.marketplace_provider = MarketplaceActionProvider(api_key) + self.billing_provider = BillingActionProvider(api_key) + self.ai_provider = AIActionProvider(api_key) + self.settings_provider = SettingsActionProvider(api_key) + + super().__init__( + "hyperbolic", + [ + self.marketplace_provider, + self.billing_provider, + self.ai_provider, + self.settings_provider, + ], + ) + + def supports_network(self, network: Network) -> bool: + """Check if network is supported by Hyperbolic actions. + + Args: + network: The network to check. + + Returns: + bool: True, as Hyperbolic actions don't require any specific network. + + """ + return True + + +def hyperbolic_action_provider( + api_key: str | None = None, +) -> HyperbolicActionProvider: + """Create a new instance of the HyperbolicActionProvider. + + Args: + api_key: Optional API key for authentication. If not provided, + will attempt to read from HYPERBOLIC_API_KEY environment variable. + + Returns: + A new Hyperbolic action provider instance. + + """ + return HyperbolicActionProvider(api_key=api_key) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/__init__.py new file mode 100644 index 000000000..91b0e34f1 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/__init__.py @@ -0,0 +1,15 @@ +"""Hyperbolic Marketplace action provider. + +This package contains functionality for interacting with Hyperbolic's marketplace services, +including managing GPU instances and SSH access. +""" + +from .action_provider import ( + MarketplaceActionProvider, + hyperbolic_marketplace_action_provider, +) + +__all__ = [ + "MarketplaceActionProvider", + "hyperbolic_marketplace_action_provider", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/action_provider.py new file mode 100644 index 000000000..786eb67d9 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/action_provider.py @@ -0,0 +1,408 @@ +"""Hyperbolic Marketplace action provider. + +This module provides actions for interacting with Hyperbolic marketplace services. +It includes functionality for managing GPU instances and SSH access. +""" + +from typing import Any + +from ...action_decorator import create_action +from ..action_provider import ActionProvider +from .schemas import ( + GetAvailableGpusByTypeSchema, + GetAvailableGpusSchema, + GetAvailableGpusTypesSchema, + GetGpuStatusSchema, + RentComputeSchema, + TerminateComputeSchema, +) +from .service import MarketplaceService +from .utils import ( + format_all_gpu_instances, + format_gpu_instances_by_type, + format_gpu_status, + format_gpu_types, + format_rent_compute_response, + format_terminate_compute_response, +) + + +class MarketplaceActionProvider(ActionProvider): + """Provides actions for interacting with Hyperbolic marketplace. + + This provider enables interaction with the Hyperbolic marketplace for GPU compute resources. + It requires an API key which can be provided directly or through the HYPERBOLIC_API_KEY + environment variable. + """ + + def __init__( + self, + api_key: str | None = None, + ): + """Initialize the Hyperbolic marketplace action provider. + + Args: + api_key: Optional API key for authentication. If not provided, + will attempt to read from HYPERBOLIC_API_KEY environment variable. + + Raises: + ValueError: If API key is not provided and not found in environment. + + """ + super().__init__("hyperbolic_marketplace", [], api_key=api_key) + self.marketplace = MarketplaceService(self.api_key) + + @create_action( + name="get_available_gpus", + description=""" +This tool retrieves all the available GPU machines on the Hyperbolic platform. + +It does not take any inputs. + +Example successful response: + Cluster: us-east-1 + Node ID: node-123 + GPU Model: NVIDIA A100 + Available GPUs: 4/8 + Price: $2.50/hour per GPU + +Example error response: + Error: API request failed + Error: Invalid authentication credentials + Error: Rate limit exceeded + +Important notes: +- The GPU prices are shown in dollars per hour +- Only non-reserved and available GPU instances are returned +- GPU availability is real-time and may change between queries +""", + schema=GetAvailableGpusSchema, + ) + def get_available_gpus(self, args: dict[str, Any]) -> str: + """Retrieve available GPU instances from the marketplace. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + GetAvailableGpusSchema(**args) + + try: + response = self.marketplace.get_available_instances() + + if not response.instances: + return "No available GPU instances found." + + return format_all_gpu_instances(response.instances) + + except Exception as e: + return f"Error: GPU retrieval: {e!s}" + + @create_action( + name="get_available_gpus_types", + description=""" +This tool retrieves all the available GPU types/models on the Hyperbolic platform. + +It does not take any inputs. + +Example successful response: + Available GPU Types: + - NVIDIA-GeForce-RTX-3070 + - NVIDIA-GeForce-RTX-3080 + - NVIDIA-GeForce-RTX-4090 + - NVIDIA-H100-80GB-HBM3 + +Example error response: + Error: API request failed + +Important notes: +- Only models with available GPUs are listed +- GPU availability is real-time and may change between queries +- The GPU model names include manufacturer and specific model details +""", + schema=GetAvailableGpusTypesSchema, + ) + def get_available_gpus_types(self, args: dict[str, Any]) -> str: + """Retrieve available GPU types/models from the marketplace. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + GetAvailableGpusTypesSchema(**args) + + try: + response = self.marketplace.get_available_instances() + + if not response.instances: + return "No available GPU instances found." + + return format_gpu_types(response.instances) + + except Exception as e: + return f"Error: GPU types retrieval: {e!s}" + + @create_action( + name="get_available_gpus_by_type", + description=""" +This tool retrieves all available GPU machines of a specific model on the Hyperbolic platform. + +Required inputs: +- gpu_model: The GPU model to filter by (e.g., "NVIDIA-GeForce-RTX-4090") + +Example successful response: + Available NVIDIA-GeForce-RTX-4090 GPU Options: + + Cluster: growing-rosemary-dragonfly + Node ID: antalpha-super-server-100194 + GPU Model: NVIDIA-GeForce-RTX-4090 + Available GPUs: 1/8 + Price: $0.35/hour per GPU + ---------------------------------------- + + Cluster: webbed-peperomia-magpie + Node ID: las1-prd-acl-msi-07.fen.intra + GPU Model: NVIDIA-GeForce-RTX-4090 + Available GPUs: 1/4 + Price: $0.30/hour per GPU + ---------------------------------------- + +Example error response: + Error: API request failed + Error: No available GPU instances with the model 'NVIDIA-GeForce-RTX-4090' found + +Important notes: +- GPU model name must be exact (including hyphens) +- Only available instances are shown +- Availability is real-time and may change +""", + schema=GetAvailableGpusByTypeSchema, + ) + def get_available_gpus_by_type(self, args: dict[str, Any]) -> str: + """Retrieve available GPU instances of a specific model from the marketplace. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + validated_args = GetAvailableGpusByTypeSchema(**args) + gpu_model = validated_args.gpu_model + + try: + response = self.marketplace.get_available_instances() + + if not response.instances: + return "No available GPU instances found." + + return format_gpu_instances_by_type(response.instances, gpu_model) + + except Exception as e: + return f"Error: GPU retrieval: {e!s}" + + @create_action( + name="get_gpu_status", + description=""" +This tool retrieves the status and SSH commands for your currently rented GPUs on the Hyperbolic platform. + +It does not take any inputs. + +Example successful response: + Your Rented GPU Instances: + Instance ID: mortified-morningglory-wombat + Status: running (Ready to use) + GPU Model: NVIDIA-GeForce-RTX-4090 + GPU Count: 1 + GPU Memory: 24.0 GB + SSH Command: ssh ubuntu@mortified-morningglory-wombat.1.cricket.hyperbolic.xyz -p 31065 + ---------------------------------------- + + SSH Connection Instructions: + 1. Wait until instance status is 'running' + 2. Use the ssh_connect action with the provided host and username + 3. Once connected, use remote_shell to execute commands + +Example error response: + Error: API request failed + No rented GPU instances found. + +Important notes: +- If status is "starting", the GPU is not ready yet - check again in a few seconds +- Once status is "running", you can use the SSH command to access the instance +""", + schema=GetGpuStatusSchema, + ) + def get_gpu_status(self, args: dict[str, Any]) -> str: + """Retrieve status and SSH commands for currently rented GPUs. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + GetGpuStatusSchema(**args) + + try: + response = self.marketplace.get_rented_instances() + + if not response.instances: + return "No rented GPU instances found." + + output = ["Your Rented GPU Instances:"] + for _i, instance in enumerate(response.instances): + formatted = format_gpu_status(instance) + output.append(formatted) + output.append("-" * 40) + + output.extend( + [ + "\nSSH Connection Instructions:", + "1. Wait until instance status is 'running'", + "2. Use the ssh_connect action with the provided host and username", + "3. Once connected, use remote_shell to execute commands", + ] + ) + + return "\n".join(output) + + except Exception as e: + return f"Error: GPU status retrieval: {e!s}" + + @create_action( + name="rent_compute", + description=""" +This tool rents a GPU machine on Hyperbolic platform. + +Required inputs: +- cluster_name: Which cluster the node is on +- node_name: Which node to rent +- gpu_count: How many GPUs to rent + +Example successful response: + { + "status": "success", + "instance_name": "mortified-morningglory-wombat" + } + + Next Steps: + 1. Your GPU instance is being provisioned + 2. Use get_gpu_status to check when it's ready + 3. Once status is 'running', you can: + - Connect via SSH + - Run remote commands + +Example error response: + Error: Invalid cluster name + Error: Node not available + Error: API request failed + +Important notes: +- Use get_available_gpus_by_type to find available GPUs of a specific model +- Use get_available_gpus for valid cluster and node names +- Do not ask for a duration, it is not needed +""", + schema=RentComputeSchema, + ) + def rent_compute(self, args: dict[str, Any]) -> str: + """Rents a GPU machine on the platform. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + validated_args = RentComputeSchema(**args) + + try: + response = self.marketplace.rent_instance(validated_args) + + return format_rent_compute_response(response) + + except Exception as e: + return f"Error: Compute rental: {e!s}" + + @create_action( + name="terminate_compute", + description=""" +This tool terminates a GPU instance on the Hyperbolic platform. + +Required inputs: +- id: The ID of the instance to terminate (e.g., "mortified-morningglory-wombat") + +Example successful response: + { + "status": "success", + "message": null, + "error_code": null + } + + Next Steps: + 1. Your GPU instance has been terminated + 2. Any active SSH connections have been closed + 3. You can check your spend history with get_spend_history + 4. To rent a new instance, use get_available_gpus and rent_compute + +Example error response: + Error: Instance not found + Error: Instance already terminated + Error: API request failed + +Important notes: +- The instance ID must be valid and active +- After termination, the instance will no longer be accessible +- You can get instance IDs using get_gpu_status +- You can immediately terminate an instance +- Terminated instances will still appear in your spend history +""", + schema=TerminateComputeSchema, + ) + def terminate_compute(self, args: dict[str, Any]) -> str: + """Terminates a GPU instance on the platform. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + validated_args = TerminateComputeSchema(**args) + + try: + response = self.marketplace.terminate_instance(validated_args) + + return format_terminate_compute_response(response) + + except Exception as e: + return f"Error: Compute termination: {e!s}" + + +def hyperbolic_marketplace_action_provider( + api_key: str | None = None, +) -> MarketplaceActionProvider: + """Create a new instance of the MarketplaceActionProvider. + + Args: + api_key: Optional API key for authentication. If not provided, + will attempt to read from HYPERBOLIC_API_KEY environment variable. + + Returns: + A new Marketplace action provider instance. + + Raises: + ValueError: If API key is not provided and not found in environment. + + """ + return MarketplaceActionProvider(api_key=api_key) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/schemas.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/schemas.py new file mode 100644 index 000000000..55bded02e --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/schemas.py @@ -0,0 +1,41 @@ +"""Schemas for Hyperbolic marketplace actions.""" + +from pydantic import BaseModel, Field + + +class GetAvailableGpusSchema(BaseModel): + """Schema for get_available_gpus action.""" + + pass + + +class GetAvailableGpusByTypeSchema(BaseModel): + """Schema for get_available_gpus_by_type action.""" + + gpu_model: str = Field(description="The GPU model to filter by (e.g., 'NVIDIA A100')") + + +class GetAvailableGpusTypesSchema(BaseModel): + """Schema for get_available_gpus_types action.""" + + pass + + +class GetGpuStatusSchema(BaseModel): + """Schema for get_gpu_status action.""" + + pass + + +class RentComputeSchema(BaseModel): + """Schema for rent_compute action.""" + + cluster_name: str = Field(description="The cluster to rent from") + node_name: str = Field(description="The node ID to rent") + gpu_count: str = Field(description="Number of GPUs to rent") + + +class TerminateComputeSchema(BaseModel): + """Schema for terminate_compute action.""" + + id: str = Field(description="The ID of the instance to terminate") diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/service.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/service.py new file mode 100644 index 000000000..b493c7916 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/service.py @@ -0,0 +1,102 @@ +"""Service for marketplace-related operations.""" + +from ..constants import MARKETPLACE_BASE_URL, MARKETPLACE_ENDPOINTS +from ..service import Base +from .types import ( + AvailableInstancesResponse, + InstanceHistoryResponse, + RentedInstancesResponse, + RentInstanceRequest, + RentInstanceResponse, + TerminateInstanceRequest, + TerminateInstanceResponse, +) + + +class MarketplaceService(Base): + """Service for marketplace-related operations.""" + + def __init__(self, api_key: str): + """Initialize the marketplace service. + + Args: + api_key: The API key for authentication. + + """ + super().__init__(api_key, MARKETPLACE_BASE_URL) + + def get_available_instances(self) -> AvailableInstancesResponse: + """Get available GPU instances from the marketplace. + + Returns: + AvailableInstancesResponse: The marketplace instances data. + + """ + response = self.make_request( + endpoint=MARKETPLACE_ENDPOINTS["LIST_INSTANCES"], method="POST", data={"filters": {}} + ) + return AvailableInstancesResponse(**response.json()) + + def get_instance_history(self) -> InstanceHistoryResponse: + """Get GPU instance rental history. + + Returns: + InstanceHistoryResponse: The instance history data. + + """ + response = self.make_request( + endpoint=MARKETPLACE_ENDPOINTS["INSTANCE_HISTORY"], method="GET" + ) + return InstanceHistoryResponse(**response.json()) + + def get_rented_instances(self) -> RentedInstancesResponse: + """Get currently rented GPU instances. + + Returns: + RentedInstancesResponse: The rented instances data. + + """ + response = self.make_request( + endpoint=MARKETPLACE_ENDPOINTS["LIST_USER_INSTANCES"], method="GET" + ) + + parsed_response = RentedInstancesResponse(**response.json()) + + return parsed_response + + def rent_instance( + self, + request: RentInstanceRequest, + ) -> RentInstanceResponse: + """Rent a GPU compute instance. + + Args: + request: The RentInstanceRequest object containing rental parameters. + + Returns: + RentInstanceResponse: The rental response data. + + """ + response = self.make_request( + endpoint=MARKETPLACE_ENDPOINTS["CREATE_INSTANCE"], data=request.model_dump() + ) + return RentInstanceResponse(**response.json()) + + def terminate_instance( + self, + request: TerminateInstanceRequest, + ) -> TerminateInstanceResponse: + """Terminate a GPU compute instance. + + Args: + request: The TerminateInstanceRequest object containing the instance ID. + + Returns: + TerminateInstanceResponse: The termination response data. + + """ + response = self.make_request( + endpoint=MARKETPLACE_ENDPOINTS["TERMINATE_INSTANCE"], data=request.model_dump() + ) + + return TerminateInstanceResponse(**response.json()) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/types.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/types.py new file mode 100644 index 000000000..3aea39627 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/types.py @@ -0,0 +1,242 @@ +"""Types for Hyperbolic marketplace operations. + +This module provides type definitions for marketplace API communication. +""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class GpuHardware(BaseModel): + """GPU hardware information.""" + + hardware_type: str = Field("gpu", description="Type of hardware") + model: str = Field(..., description="GPU model name") + clock_speed: float | None = Field(None, description="GPU clock speed") + compute_power: float | None = Field(None, description="GPU compute power") + ram: float | None = Field(None, description="GPU RAM in GB") + interface: str | None = Field(None, description="GPU interface type") + + +class CpuHardware(BaseModel): + """CPU hardware information.""" + + hardware_type: str = Field("cpu", description="Type of hardware") + model: str = Field(..., description="CPU model name") + cores: int | None = Field(None, description="Number of physical cores") + virtual_cores: int = Field(..., description="Number of virtual cores") + + +class StorageHardware(BaseModel): + """Storage hardware information.""" + + hardware_type: str = Field(None, description="Type of hardware") + capacity: float = Field(..., description="Storage capacity") + + +class RamHardware(BaseModel): + """RAM hardware information.""" + + hardware_type: str = Field("ram", description="Type of hardware") + capacity: float = Field(..., description="RAM capacity") + + +class HardwareInfo(BaseModel): + """Complete hardware information.""" + + cpus: list[CpuHardware] | None = Field(None, description="List of CPU specifications") + gpus: list[GpuHardware] = Field(..., description="List of GPU specifications") + storage: list[StorageHardware] | None = Field( + None, description="List of storage specifications" + ) + ram: list[RamHardware] | None = Field(None, description="List of RAM specifications") + + +class NestedGpuHardware(BaseModel): + """GPU hardware information for nested instances.""" + + hardware_type: str = Field("gpu", description="Type of hardware") + model: str = Field(..., description="GPU model name") + ram: float | None = Field(None, description="GPU RAM in GB") + + +class NestedStorageHardware(BaseModel): + """Storage hardware information for nested instances.""" + + hardware_type: str = Field(None, description="Type of hardware") + capacity: float = Field(..., description="Storage capacity") + + +class NestedHardwareComponent(BaseModel): + """A single hardware component in the nested structure.""" + + gpu: NestedGpuHardware | None = Field(None, description="GPU hardware if present") + storage: NestedStorageHardware | None = Field(None, description="Storage hardware if present") + + +class Location(BaseModel): + """Location information.""" + + region: str | None = Field(None, description="Geographic region") + + +class Price(BaseModel): + """Price information.""" + + amount: float = Field(..., description="Price amount") + period: str = Field(..., description="Pricing period (e.g., 'hourly')") + agent: str | None = Field(None, description="Pricing agent") + + +class PricingInfo(BaseModel): + """Pricing information for an instance.""" + + price: Price = Field(..., description="Price details") + + +class NestedInstance(BaseModel): + """Instance information in nested format.""" + + id: str = Field(..., description="Instance identifier") + status: str = Field(..., description="Instance status") + hardware: list[NestedHardwareComponent] = Field(..., description="List of hardware components") + + +class NodeInstance(BaseModel): + """Top-level node information.""" + + id: str = Field(..., description="Instance identifier") + status: str = Field(..., description="Instance status") + hardware: HardwareInfo = Field(..., description="Hardware specifications") + location: Location | None = Field(None, description="Location information") + instances: list[NestedInstance] = Field( + default_factory=list, description="List of nested instances" + ) + network: dict[str, Any] = Field(default_factory=dict, description="Network configuration") + gpus_total: int | None = Field(None, description="Total number of GPUs") + gpus_reserved: int | None = Field(None, description="Number of reserved GPUs") + has_persistent_storage: bool | None = Field( + None, description="Whether persistent storage is available" + ) + supplier_id: str | None = Field(None, description="Supplier identifier") + pricing: PricingInfo | None = Field(None, description="Pricing information") + reserved: bool | None = Field(None, description="Whether the instance is reserved") + cluster_name: str | None = Field(None, description="Cluster name") + owner: str | None = Field(None, description="Owner identifier") + gpu_count: int | None = Field(None, description="Number of GPUs allocated") + + +class AvailableInstance(NodeInstance): + """Available instance information.""" + + pass + + +class AvailableInstancesResponse(BaseModel): + """Response for available instances.""" + + instances: list[AvailableInstance] = Field(..., description="List of available instances") + + +class SSHAccess(BaseModel): + """SSH access information for connecting to a remote instance.""" + + host: str = Field(..., description="SSH host address") + username: str = Field(..., description="SSH username") + key_path: str | None = Field("~/.ssh/id_rsa", description="Path to SSH key file") + ssh_command: str | None = Field(None, description="Full SSH command if provided") + + +class NodeRental(BaseModel): + """Record of a rented compute node.""" + + id: str = Field(..., description="Instance identifier") + start: str | None = Field(None, description="Start time of the rental") + end: str | None = Field(None, description="End time of the rental") + instance: NodeInstance = Field(..., description="Full node details") + ssh_command: str | None = Field( + None, description="SSH command to access the node", alias="sshCommand" + ) + ssh_access: SSHAccess | None = Field(None, description="SSH access details") + + model_config = {"populate_by_name": True} + + @property + def status(self) -> str: + """Get the node status from the nested instance.""" + return self.instance.status + + +class InstanceHistoryEntry(BaseModel): + """Instance history entry.""" + + instance_name: str = Field(..., description="Name of the instance") + started_at: str | None = Field(None, description="Start time in ISO format") + terminated_at: str | None = Field(None, description="Termination time in ISO format") + price: Price = Field(..., description="Price information") + hardware: HardwareInfo = Field(..., description="Hardware specifications") + gpu_count: int = Field(..., description="Number of GPUs") + + +class InstanceHistoryResponse(BaseModel): + """Response for instance history.""" + + instance_history: list[InstanceHistoryEntry] = Field( + ..., description="List of instance history entries" + ) + + +class TerminateInstanceRequest(BaseModel): + """Request to terminate an instance.""" + + id: str = Field(..., description="ID of the instance to terminate") + + +class TerminateInstanceResponse(BaseModel): + """Response from terminate instance API endpoint.""" + + status: str | None = Field(None, description="Response status") + message: str | None = Field(None, description="Optional response message") + error_code: int | None = Field(None, description="Error code for failed operations") + + @property + def get_status(self) -> str: + """Return status string based on fields for backward compatibility.""" + if self.status == "success" or self.status == "status": + return "success" + if self.error_code is not None: + return f"error_{self.error_code}" + if self.status is not None: + return self.status + return "error" + + +class ContainerImage(BaseModel): + """Container image configuration.""" + + name: str = Field(..., description="Name of the container image") + tag: str = Field(..., description="Tag of the container image") + port: int = Field(..., description="Port to expose") + + +class RentInstanceRequest(BaseModel): + """Request to rent an instance.""" + + cluster_name: str = Field(..., description="Name of the cluster to create instance in") + node_name: str = Field(..., description="Name of the node") + gpu_count: int = Field(..., description="Number of GPUs to allocate") + image: ContainerImage | None = Field(None, description="Optional container configuration") + + +class RentInstanceResponse(BaseModel): + """Response from rent instance API endpoint.""" + + status: str = Field(..., description="Response status") + instance_name: str | None = Field(None, description="Instance name (e.g. 'actual-bonsai-frog')") + + +class RentedInstancesResponse(BaseModel): + """Response for rented instances.""" + + instances: list[NodeRental] = Field(..., description="List of rented nodes") diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/utils.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/utils.py new file mode 100644 index 000000000..e5b698aaf --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/marketplace/utils.py @@ -0,0 +1,304 @@ +"""Utility functions for Hyperbolic Marketplace action provider.""" + +import os + +from .types import ( + AvailableInstance, + NodeRental, + RentInstanceResponse, + TerminateInstanceResponse, +) + + +def get_api_key() -> str: + """Get Hyperbolic API key from environment variables. + + Returns: + str: The API key. + + Raises: + ValueError: If API key is not configured. + + """ + api_key = os.getenv("HYPERBOLIC_API_KEY") + if not api_key: + raise ValueError("HYPERBOLIC_API_KEY is not configured.") + return api_key + + +def format_gpu_instance(instance: AvailableInstance) -> str | None: + """Format a single GPU instance into a readable string. + + Args: + instance: AvailableInstance object containing instance details. + + Returns: + str | None: Formatted string if instance has available GPUs, None otherwise. + + """ + if instance.reserved: + return None + + cluster_name = instance.cluster_name or "Unknown Cluster" + node_id = instance.id + + gpus = instance.hardware.gpus + gpu_model = gpus[0].model if gpus else "Unknown Model" + + price_amount = instance.pricing.price.amount / 100 if instance.pricing else 0 + + gpus_total = instance.gpus_total or 0 + gpus_reserved = instance.gpus_reserved or 0 + gpus_available = gpus_total - gpus_reserved + + if gpus_available <= 0: + return None + + return ( + f"Cluster: {cluster_name}\n" + f"Node ID: {node_id}\n" + f"GPU Model: {gpu_model}\n" + f"Available GPUs: {gpus_available}/{gpus_total}\n" + f"Price: ${price_amount:.2f}/hour per GPU\n" + f"{'-' * 40}\n\n" + ) + + +def format_gpu_types(instances: list[AvailableInstance]) -> str: + """Format a list of available GPU types/models. + + Args: + instances: List of AvailableInstance objects. + + Returns: + str: Formatted string with available GPU types. + + """ + gpu_models = set() + + for instance in instances: + if instance.reserved: + continue + + gpus_total = instance.gpus_total or 0 + gpus_reserved = instance.gpus_reserved or 0 + gpus_available = gpus_total - gpus_reserved + + if gpus_available <= 0: + continue + + gpus = instance.hardware.gpus + if gpus: + gpu_models.add(gpus[0].model) + + if not gpu_models: + return "No available GPU types found." + + gpu_models_list = sorted(gpu_models) + formatted_models = "\n".join([f"- {model}" for model in gpu_models_list]) + return f"Available GPU Types:\n{formatted_models}" + + +def format_gpu_instances_by_type(instances: list[AvailableInstance], gpu_model: str) -> str: + """Format a list of available GPU instances of a specific model. + + Args: + instances: List of AvailableInstance objects. + gpu_model: The specific GPU model to filter by. + + Returns: + str: Formatted string with available GPU instances of the specified model. + + """ + formatted_instances = [] + + for instance in instances: + if instance.reserved: + continue + + gpus = instance.hardware.gpus + instance_gpu_model = gpus[0].model if gpus else "Unknown Model" + + if instance_gpu_model != gpu_model: + continue + + gpus_total = instance.gpus_total or 0 + gpus_reserved = instance.gpus_reserved or 0 + gpus_available = gpus_total - gpus_reserved + + if gpus_available <= 0: + continue + + formatted = format_gpu_instance(instance) + if formatted is not None: + formatted_instances.append(formatted) + + if not formatted_instances: + return f"No available GPU instances with the model '{gpu_model}' found." + + return f"Available {gpu_model} GPU Options:\n\n" + "\n".join(formatted_instances) + + +def format_all_gpu_instances(instances: list[AvailableInstance]) -> str: + """Format a list of all available GPU instances. + + Args: + instances: List of AvailableInstance objects. + + Returns: + str: Formatted string with all available GPU instances. + + """ + formatted_instances = [] + + for instance in instances: + formatted = format_gpu_instance(instance) + if formatted is not None: + formatted_instances.append(formatted) + + if not formatted_instances: + return "No available GPU instances with free resources found." + + return "Available GPU Options:\n\n" + "\n".join(formatted_instances) + + +def format_gpu_status(instance: NodeRental) -> str: + """Format a rented GPU instance status into a readable string. + + Args: + instance: NodeRental object containing instance details. + + Returns: + str: Formatted status string. + + """ + instance_id = instance.id + status = instance.status + status_detail = "" + + gpus = instance.instance.hardware.gpus + + gpu_model = "Unknown Model" + if gpus: + gpu_model = gpus[0].model + + gpu_count = instance.instance.gpu_count or (len(gpus) if gpus else 1) + + gpu_memory = None + if gpus and gpus[0].ram: + ram_gb = gpus[0].ram / 1024 + gpu_memory = f"{ram_gb:.1f} GB" + + ssh_command = instance.ssh_command + + output = [f"Instance ID: {instance_id}"] + + if status.lower() == "running": + output.append(f"Status: {status} (Ready to use)") + elif status.lower() == "starting": + output.append(f"Status: {status} (Still initializing)") + elif status.lower() == "terminated": + output.append(f"Status: {status} (No longer available)") + elif status.lower() == "unknown": + output.append(f"Status: {status} (Instance is still being provisioned)") + elif status.lower() == "online": + output.append("Status: running (Ready to use)") + else: + output.append(f"Status: {status}") + + if status_detail: + output.append(f"Status Detail: {status_detail}") + + output.append(f"GPU Model: {gpu_model}") + if gpu_count > 0: + output.append(f"GPU Count: {gpu_count}") + if gpu_memory: + output.append(f"GPU Memory: {gpu_memory}") + + if ssh_command: + output.append(f"SSH Command: {ssh_command}") + elif instance.ssh_access: + key_path = instance.ssh_access.key_path or "~/.ssh/id_rsa" + constructed_ssh_cmd = ( + f"ssh {instance.ssh_access.username}@{instance.ssh_access.host} -i {key_path}" + ) + output.append(f"SSH Command: {constructed_ssh_cmd}") + else: + if status.lower() in ["running", "online"]: + output.append( + "SSH Command: Not available yet. Instance is running but SSH details are not provided." + ) + output.append( + "Try again in a few seconds or check the Hyperbolic dashboard for SSH details." + ) + else: + output.append("SSH Command: Not available yet. Instance is still being provisioned.") + + if status.lower() == "starting": + output.append("The instance is starting up. Please check again in a few seconds.") + elif status.lower() == "unknown": + output.append( + "The instance status is unknown. Please check again in 30-60 seconds." + ) + else: + output.append(f"Current status: {status}. Check again when status is 'running'.") + + output.append("-" * 40) + output.append("") + + result = "\n".join(output) + + return result + + +def format_rent_compute_response(response_data: RentInstanceResponse) -> str: + """Format compute rental response into a readable string. + + Args: + response_data: RentInstanceResponse object from compute rental API. + + Returns: + str: Formatted response string with next steps. + + """ + formatted_response = response_data.model_dump_json(indent=2) + + next_steps = ( + "\nNext Steps:\n" + "1. Your GPU instance is being provisioned\n" + "2. Use get_gpu_status to check when it's ready\n" + "3. Once status is 'running', you can:\n" + " - Connect via SSH using the provided command\n" + " - Run commands using remote_shell\n" + " - Install packages and set up your environment" + ) + + return f"{formatted_response}\n{next_steps}" + + +def format_terminate_compute_response(response_data: TerminateInstanceResponse) -> str: + """Format compute termination response into a readable string. + + Args: + response_data: TerminateInstanceResponse object from compute termination API. + + Returns: + str: Formatted response string with next steps. + + """ + if response_data.error_code is not None or ( + response_data.status and response_data.status.lower() != "success" + ): + error_msg = f"Error terminating compute: {response_data.message}" + return error_msg + + formatted_response = response_data.model_dump_json(indent=2) + + next_steps = ( + "\nNext Steps:\n" + "1. Your GPU instance has been terminated\n" + "2. Any active SSH connections have been closed\n" + "3. You can check your spend history with get_spend_history\n" + "4. To rent a new instance, use get_available_gpus and rent_compute" + ) + return f"{formatted_response}\n{next_steps}" diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/service.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/service.py new file mode 100644 index 000000000..2a5c64d8f --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/service.py @@ -0,0 +1,84 @@ +"""Base service for making API requests to Hyperbolic platform.""" + +from typing import Any + +import requests + +from .constants import API_BASE_URL + + +class Base: + """Base class with common functionality.""" + + def __init__(self, api_key: str, base_url: str | None = None): + """Initialize the service. + + Args: + api_key: The API key for authentication. + base_url: Optional base URL for the service. If not provided, + will use API_BASE_URL from constants. + + """ + self.api_key = api_key + self.base_url = base_url or API_BASE_URL + + def make_request( + self, + endpoint: str, + method: str = "POST", + data: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> requests.Response: + """Make an API request to the service endpoint. + + Args: + endpoint: The endpoint path to call. + method: The HTTP method to use (default: "POST"). + data: Optional JSON body for the request. + params: Optional query parameters. + headers: Optional additional headers. + + Returns: + requests.Response: The raw HTTP response object. + + Raises: + requests.HTTPError: If the API returns an error response (4xx, 5xx), + with the error message and code extracted from the response when available. + requests.RequestException: For other request-related errors. + + """ + if not headers: + headers = {} + headers.update( + {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"} + ) + + url = f"{self.base_url}{endpoint}" + response = requests.request( + method=method, url=url, headers=headers, json=data, params=params + ) + + try: + response.raise_for_status() + except requests.HTTPError as e: + try: + error_json = response.json() + error_message = error_json.get("message", "") + error_code = error_json.get("error_code") + + if error_message or error_code: + enhanced_message = str(e) + if error_message: + enhanced_message += f": {error_message}" + if error_code: + enhanced_message += f" (code: {error_code})" + + enriched_error = requests.HTTPError(enhanced_message, response=response) + raise enriched_error from e + except ValueError: + pass + + raise + + return response diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/__init__.py new file mode 100644 index 000000000..3f41ef444 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/__init__.py @@ -0,0 +1,12 @@ +"""Hyperbolic settings services. + +This package provides modules for interacting with Hyperbolic settings services. +It includes models, schemas, and utility functions for settings operations like wallet linking. +""" + +from .action_provider import SettingsActionProvider, hyperbolic_settings_action_provider + +__all__ = [ + "SettingsActionProvider", + "hyperbolic_settings_action_provider", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/action_provider.py new file mode 100644 index 000000000..530f4dca2 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/action_provider.py @@ -0,0 +1,114 @@ +"""Hyperbolic Settings action provider. + +This module provides actions for interacting with Hyperbolic settings services. +It includes functionality for managing account settings like wallet linking. +""" + +from typing import Any + +from ...action_decorator import create_action +from ..action_provider import ActionProvider +from .schemas import LinkWalletAddressSchema +from .service import SettingsService +from .types import WalletLinkRequest +from .utils import format_wallet_link_response + + +class SettingsActionProvider(ActionProvider): + """Provides actions for interacting with Hyperbolic settings. + + This provider enables interaction with the Hyperbolic settings services for managing + account settings. It requires an API key which can be provided directly or + through the HYPERBOLIC_API_KEY environment variable. + """ + + def __init__( + self, + api_key: str | None = None, + ): + """Initialize the Hyperbolic settings action provider. + + Args: + api_key: Optional API key for authentication. If not provided, + will attempt to read from HYPERBOLIC_API_KEY environment variable. + + Raises: + ValueError: If API key is not provided and not found in environment. + + """ + super().__init__("hyperbolic_settings", [], api_key=api_key) + self.settings = SettingsService(self.api_key) + + @create_action( + name="link_wallet_address", + description=""" +This tool links a wallet address to your Hyperbolic account. + +Required inputs: +- address: The wallet address to link to your Hyperbolic account + +Example successful response: + { + "success": true, + "error_code": null, + "message": null + } + wallet_address: 0x5E83884F5d399131bbDe98f60854E43c7A12Cf7A + + Next Steps: + 1. Your wallet has been successfully linked to your Hyperbolic account + 2. To add funds, send any of these tokens on Base Mainnet: + - USDC + - USDT + - DAI + 3. Send to this Hyperbolic address: 0xd3cB24E0Ba20865C530831C85Bd6EbC25f6f3B60 + +Example error response: + Error: Invalid wallet address format + Error: API request failed + +Important notes: +- The wallet address must be a valid 0x formatted Ethereum address +- If no address is provided, you can get the user's wallet address +""", + schema=LinkWalletAddressSchema, + ) + def link_wallet_address(self, args: dict[str, Any]) -> str: + """Links a wallet address to your Hyperbolic account. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + validated_args = LinkWalletAddressSchema(**args) + + try: + request = WalletLinkRequest(address=validated_args.address) + response = self.settings.link_wallet(request) + + return format_wallet_link_response(response, validated_args.address) + + except Exception as e: + return f"Error: Wallet linking: {e!s}" + + +def hyperbolic_settings_action_provider( + api_key: str | None = None, +) -> SettingsActionProvider: + """Create a new instance of the SettingsActionProvider. + + Args: + api_key: Optional API key for authentication. If not provided, + will attempt to read from HYPERBOLIC_API_KEY environment variable. + + Returns: + A new Settings action provider instance. + + Raises: + ValueError: If API key is not provided and not found in environment. + + """ + return SettingsActionProvider(api_key=api_key) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/schemas.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/schemas.py new file mode 100644 index 000000000..ce56fa98c --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/schemas.py @@ -0,0 +1,9 @@ +"""Schemas for Hyperbolic Settings action provider.""" + +from pydantic import BaseModel, Field + + +class LinkWalletAddressSchema(BaseModel): + """Input schema for linking a wallet address to your account.""" + + address: str = Field(..., description="The wallet address to link to your Hyperbolic account") diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/service.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/service.py new file mode 100644 index 000000000..deb8dc942 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/service.py @@ -0,0 +1,33 @@ +"""Service for account settings-related operations.""" + +from ..constants import SETTINGS_BASE_URL, SETTINGS_ENDPOINTS +from ..service import Base +from .types import WalletLinkRequest, WalletLinkResponse + + +class SettingsService(Base): + """Service for account settings operations.""" + + def __init__(self, api_key: str): + """Initialize the settings service. + + Args: + api_key: The API key for authentication. + + """ + super().__init__(api_key, SETTINGS_BASE_URL) + + def link_wallet(self, request: WalletLinkRequest) -> WalletLinkResponse: + """Link a wallet address to the Hyperbolic account. + + Args: + request: The wallet link request containing the wallet address. + + Returns: + WalletLinkResponse: The wallet linking response data. + + """ + response = self.make_request( + endpoint=SETTINGS_ENDPOINTS["LINK_WALLET"], data=request.model_dump() + ) + return WalletLinkResponse(**response.json()) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/types.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/types.py new file mode 100644 index 000000000..fab9f8e85 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/types.py @@ -0,0 +1,35 @@ +"""Types for Hyperbolic settings operations. + +This module provides type definitions for settings API communication. +""" + +from pydantic import BaseModel, Field + + +class WalletLinkRequest(BaseModel): + """Request model for wallet linking.""" + + address: str = Field( + ..., description="The wallet address to link to your Hyperbolic account", min_length=2 + ) + + +class WalletLinkResponse(BaseModel): + """Response model for wallet linking API. + + The API returns either a success response with {"success": true} + or an error response with {"error_code": int, "message": str} + """ + + success: bool | None = Field(None, description="Whether the operation was successful") + error_code: int | None = Field(None, description="Error code for failed operations") + message: str | None = Field(None, description="Response message or error description") + + @property + def status(self) -> str: + """Return status string based on success boolean for backward compatibility.""" + if self.success is True: + return "success" + if self.error_code is not None: + return f"error_{self.error_code}" + return "error" diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/utils.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/utils.py new file mode 100644 index 000000000..6f804bb39 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/settings/utils.py @@ -0,0 +1,46 @@ +"""Utility functions for Hyperbolic settings services. + +This module provides utility functions for formatting and processing +settings information from Hyperbolic services. +""" + +from .types import WalletLinkResponse + + +def format_wallet_link_response(response_data: WalletLinkResponse, wallet_address: str) -> str: + """Format wallet linking response into a readable string. + + Args: + response_data: WalletLinkResponse object from wallet linking API. + wallet_address: Optional wallet address to include in the output. + + Returns: + str: Formatted response string with next steps. + + """ + output = [] + + output.append(response_data.model_dump_json(indent=2)) + + if response_data.success is True: + output.append(f"wallet_address: {wallet_address}") + + hyperbolic_address = "0xd3cB24E0Ba20865C530831C85Bd6EbC25f6f3B60" + next_steps = ( + "\nNext Steps:\n" + "1. Your wallet has been successfully linked to your Hyperbolic account\n" + "2. To add funds, send any of these tokens on Base Mainnet:\n" + " - USDC\n" + " - USDT\n" + " - DAI\n" + f"3. Send to this Hyperbolic address: {hyperbolic_address}" + ) + + output.append(next_steps) + + return "\n".join(output) + + +__all__ = [ + "format_wallet_link_response", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/utils.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/utils.py new file mode 100644 index 000000000..ac4b07883 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/hyperboliclabs/utils.py @@ -0,0 +1,24 @@ +"""Utility functions for Hyperbolic action provider.""" + +import os + + +def get_api_key() -> str: + """Get Hyperbolic API key from environment variables. + + Returns: + str: The API key. + + Raises: + ValueError: If API key is not configured. + + """ + api_key = os.getenv("HYPERBOLIC_API_KEY") + if not api_key: + raise ValueError("HYPERBOLIC_API_KEY is not configured.") + return api_key + + +__all__ = [ + "get_api_key", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/README.md b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/README.md new file mode 100644 index 000000000..1690b7600 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/README.md @@ -0,0 +1,372 @@ +# SSH Action Provider + +This directory contains the **SshActionProvider** implementation, which provides actions for interacting with remote servers via SSH. + +## Directory Structure + +``` +ssh/ +├── ssh_action_provider.py # SSH action provider implementation +├── connection.py # SSH connection management +├── connection_pool.py # Pool for managing multiple connections +├── schemas.py # SSH action schemas +├── __init__.py # Main exports +└── README.md # This file + +# From python/coinbase-agentkit/ +tests/action_providers/ssh/ +├── conftest.py # Test configuration +├── test_action_provider.py # Test action provider functionality +├── test_add_host_key.py # Test adding host keys +├── test_connection.py # Test SSH connection handling +├── test_connection_pool.py # Test connection pool management +├── test_disconnect.py # Test disconnection +├── test_download.py # Test file downloads via SFTP +├── test_execute.py # Test remote command execution +├── test_keys.py # Test SSH key handling +├── test_list_connections.py # Test listing active connections +├── test_params.py # Test SSH parameters +├── test_remote_shell.py # Test remote shell commands +├── test_sftp.py # Test SFTP operations +├── test_ssh_connect.py # Test SSH connection +├── test_status.py # Test connection status checks +└── test_upload.py # Test file uploads via SFTP +``` + +## Actions + +- `ssh_connect`: Establish an SSH connection to a remote server + - Supports password and key-based authentication + - Generates a connection ID for future operations if one is not provided + - Optional known_hosts_file parameter for custom host verification + +- `remote_shell`: Execute shell commands on the remote server + - Uses an established connection + - Returns command output + - Configurable timeout and stderr handling + +- `ssh_disconnect`: Close an SSH connection + - Frees up resources + +- `ssh_status`: Check the status of an SSH connection + - Shows connection details + +- `list_connections`: List all active SSH connections + - Shows connection details for all active connections + +- `ssh_upload`: Upload a file to the remote server via SFTP + - Requires full local and remote paths + +- `ssh_download`: Download a file from the remote server via SFTP + - Requires full local and remote paths + +- `ssh_add_host_key`: Add a host key to the known hosts file + - Useful for host verification + - Supports various key types (ssh-rsa, ssh-ed25519, etc.) + - Configurable known_hosts file path + +## Adding New Actions + +To add new SSH actions: + +1. Define your action schema in `schemas.py` +2. Implement the action in `ssh_action_provider.py` +3. Implement tests in a new file in `tests/action_providers/ssh/` + +## Notes + +- The SSH action provider maintains a pool of connections for efficient management +- Actions like `remote_shell`, `ssh_upload`, and `ssh_download` require an active connection established via `ssh_connect` +- For file transfers (`ssh_upload` and `ssh_download`), full local and remote paths are required + +## Prompts + +### *please show my hyperbolic gpu instances* + +
+Tool Output + +``` +Your Rented GPU Instances: +Instance ID: positive-peach-goat +Status: running (Ready to use) +GPU Model: NVIDIA-GeForce-RTX-4090 +GPU Count: 1 +GPU Memory: 24.0 GB +SSH Command: ssh ubuntu@positive-peach-goat.1.cricket.hyperbolic.xyz -p 31274 + +SSH Connection Instructions: +1. Wait until instance status is 'running' +2. Use the ssh_connect action with the provided host and username +3. Once connected, use remote_shell to execute commands +``` +
+ +
+Agent Output + +``` +You have a running GPU instance with the following details: + +- **Instance ID**: positive-peach-goat +- **Status**: running (Ready to use) +- **GPU Model**: NVIDIA-GeForce-RTX-4090 +- **GPU Count**: 1 +- **GPU Memory**: 24.0 GB +- **SSH Command**: `ssh ubuntu@positive-peach-goat.1.cricket.hyperbolic.xyz -p 31274` + +If you would like me to SSH into this instance, please provide the password for the `ubuntu` user, or the private key if you are using key-based authentication. +``` +
+ +### *please open an ssh connection to my hyperbolic gpu instance using /Users/chris/.ssh/hyperbolic* + +
+Tool Output + +``` +Error: Host verification: Host key verification failed for [positive-peach-goat.1.cricket.hyperbolic.xyz]:31274. Server sent: + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIG6/psa2jo3Wu2XVnImwzQ10eQg+M/9RqrcWwdZY7mU + +To add this host key, use the ssh_add_host_key action with the following parameters: + host: [positive-peach-goat.1.cricket.hyperbolic.xyz]:31274 + key: AAAAC3NzaC1lZDI1NTE5AAAAIIG6/psa2jo3Wu2XVnImwzQ10eQg+M/9RqrcWwdZY7mU + key_type: ssh-ed25519 +``` + +``` +Host key for '[positive-peach-goat.1.cricket.hyperbolic.xyz]:31274' successfully added to /Users/chris/.ssh/known_hosts +``` + +``` +Connection ID: hyperbolic-gpu-instance +Successfully connected to positive-peach-goat.1.cricket.hyperbolic.xyz as ubuntu +``` +
+ +
+Agent Output + +``` +You are now successfully connected to your Hyperbolic GPU instance with the following details: + +- **Connection ID**: hyperbolic-gpu-instance +- **Host**: positive-peach-goat.1.cricket.hyperbolic.xyz +- **Username**: ubuntu + +You can now execute commands on the instance. If you need to run specific commands or perform actions, just let me know! +``` +
+ +### *please run `ls -lah` on my hyperbolic gpu instance* + +
+Tool Output + +``` +Output from connection 'hyperbolic-gpu-instance': + +total 48K +drwxr-x--- 6 ubuntu ubuntu 4.0K Mar 12 10:54 . +drwxr-xr-x 1 root root 4.0K Feb 14 20:24 .. +-rw-r--r-- 1 ubuntu ubuntu 220 Jan 6 2022 .bash_logout +-rw-r--r-- 1 ubuntu ubuntu 3.7K Jan 6 2022 .bashrc +drwx------ 2 ubuntu ubuntu 4.0K Mar 12 10:54 .cache +drwxrwxr-x 3 ubuntu ubuntu 4.0K Mar 12 10:54 .config +-rw-r--r-- 1 ubuntu ubuntu 807 Jan 6 2022 .profile +drwxr-xr-x 2 ubuntu ubuntu 4.0K Feb 14 20:24 .ssh +drwx------ 2 ubuntu ubuntu 16K Mar 12 10:51 lost+found +``` +
+ +
+Agent Output + +``` +The output of the `ls -lah` command on your Hyperbolic GPU instance is as follows: + +total 48K +drwxr-x--- 6 ubuntu ubuntu 4.0K Mar 12 10:54 . +drwxr-xr-x 1 root root 4.0K Feb 14 20:24 .. +-rw-r--r-- 1 ubuntu ubuntu 220 Jan 6 2022 .bash_logout +-rw-r--r-- 1 ubuntu ubuntu 3.7K Jan 6 2022 .bashrc +drwx------ 2 ubuntu ubuntu 4.0K Mar 12 10:54 .cache +drwxrwxr-x 3 ubuntu ubuntu 4.0K Mar 12 10:54 .config +-rw-r--r-- 1 ubuntu ubuntu 807 Jan 6 2022 .profile +drwxr-xr-x 2 ubuntu ubuntu 4.0K Feb 14 20:24 .ssh +drwx------ 2 ubuntu ubuntu 16K Mar 12 10:51 lost+found + +If you need to run more commands or require further assistance, just let me know! +``` +
+ +### *please show my hyperbolic gpu instance ssh connection status* + +
+Tool Output + +``` +Connection ID: hyperbolic-gpu-instance +Status: Connected +Host: positive-peach-goat.1.cricket.hyperbolic.xyz:31274 +Username: ubuntu +Connected since: 2025-03-12 03:54:10 +``` +
+ +
+Agent Output + +``` +The SSH connection status for your Hyperbolic GPU instance is as follows: + +- **Connection ID**: hyperbolic-gpu-instance +- **Status**: Connected +- **Host**: positive-peach-goat.1.cricket.hyperbolic.xyz:31274 +- **Username**: ubuntu +- **Connected since**: March 12, 2025, 03:54:10 + +If you need any further assistance or commands to be executed, feel free to ask! +``` +
+ +### *please list open ssh connections* + +
+Tool Output + +``` +Active SSH Connections: 1 +Connection ID: hyperbolic-gpu-instance +Status: Connected +Host: positive-peach-goat.1.cricket.hyperbolic.xyz:31274 +Username: ubuntu +``` +
+ +
+Agent Output + +``` +You currently have the following active SSH connection: + +- **Connection ID**: hyperbolic-gpu-instance +- **Status**: Connected +- **Host**: positive-peach-goat.1.cricket.hyperbolic.xyz:31274 +- **Username**: ubuntu + +If you need any further actions or information, just let me know! +``` +
+ +### *please upload from my local /Users/chris/hello.txt to /home/ubuntu/hello.txt* + +
+Tool Output + +``` +File upload successful: +Local file: /Users/chris/hello.txt +Remote destination: /home/ubuntu/hello.txt +``` +
+ +
+Agent Output + +``` +The file upload was successful! The file **hello.txt** has been uploaded from your local path **/Users/chris/hello.txt** to the remote path **/home/ubuntu/hello.txt** on your Hyperbolic GPU instance. + +If you need further assistance or have more tasks, feel free to ask! +``` +
+ +### *please run `cat hello.txt`* + +
+Tool Output + +``` +Output from connection 'hyperbolic-gpu-instance': + +world +``` +
+ +
+Agent Output + +``` +The output of the `cat hello.txt` command is as follows: + +``` +world +``` + +If you have any more commands to run or need further assistance, just let me know! +``` +
+ +### *please run `echo 'fizz' > buzz.txt`* + +
+Tool Output + +``` +Output from connection 'hyperbolic-gpu-instance': + +``` +
+ +
+Agent Output + +``` +The command `echo 'fizz' > buzz.txt` has been executed successfully. + +If you need to check the contents of **buzz.txt** or perform any other actions, just let me know! +``` +
+ +### *please download buzz.txt to /Users/chris/buzz.txt* + +
+Tool Output + +``` +File download successful: +Remote file: buzz.txt +Local destination: /Users/chris/buzz.txt +``` +
+ +
+Agent Output + +``` +The file **buzz.txt** has been successfully downloaded from your Hyperbolic GPU instance to your local path **/Users/chris/buzz.txt**. + +If you need any further assistance or have more tasks, feel free to ask! +``` +
+ +### *please close all ssh connections* + +
+Tool Output + +``` +Connection ID: hyperbolic-gpu-instance +Disconnected from positive-peach-goat.1.cricket.hyperbolic.xyz +``` +
+ +
+Agent Output + +``` +The SSH connection to your Hyperbolic GPU instance has been successfully closed. + +If you need any further assistance or have more tasks, just let me know! +``` +
\ No newline at end of file diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/__init__.py new file mode 100644 index 000000000..f8f75a65b --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/__init__.py @@ -0,0 +1,15 @@ +"""SSH connection package. + +This package provides SSH connection functionality for the agent toolkit. +""" + +from .connection import SSHConnection, SSHConnectionError, SSHConnectionParams, SSHKeyError +from .connection_pool import SSHConnectionPool + +__all__ = [ + "SSHConnection", + "SSHConnectionPool", + "SSHConnectionParams", + "SSHConnectionError", + "SSHKeyError", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/connection.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/connection.py new file mode 100644 index 000000000..db386c9e1 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/connection.py @@ -0,0 +1,644 @@ +"""SSH Connection. + +This module implements the SSHConnection class, which manages SSH connections +to remote servers and provides functionality for executing commands. + +@module ssh/connection +""" + +import contextlib +import io +import os +from datetime import datetime + +import paramiko +from pydantic import BaseModel, Field, model_validator + + +class SSHConnectionParams(BaseModel): + """Validates SSH connection parameters.""" + + connection_id: str = Field(description="Unique identifier for this connection") + host: str = Field(description="Remote server hostname/IP") + username: str = Field(description="SSH username") + password: str | None = Field(None, description="SSH password for authentication") + private_key: str | None = Field(None, description="SSH private key content as a string") + private_key_path: str | None = Field( + None, description="Path to private key file for authentication" + ) + port: int = Field(22, description="SSH port number") + + @classmethod + @model_validator(mode="after") + def check_auth_method_provided(cls): + """Ensure at least one authentication method is provided.""" + if not any([cls.password, cls.private_key, cls.private_key_path]): + raise ValueError( + "At least one authentication method must be provided (password, private_key, or private_key_path)" + ) + + if not cls.host: + raise ValueError("Host must be provided") + if not cls.username: + raise ValueError("Username must be provided") + + return cls + + +class SSHConnectionError(Exception): + """Exception raised for SSH connection errors.""" + + pass + + +class SSHKeyError(Exception): + """Exception raised for SSH key-related errors.""" + + pass + + +class UnknownHostKeyError(SSHConnectionError): + """Exception raised when a host key is not recognized. + + This includes information to add the key using the ssh_add_host_key action. + """ + + def __init__(self, message: str): + """Initialize with error message. + + Args: + message: The error message with host key information + + """ + super().__init__(message) + + +class CapturingRejectPolicy(paramiko.MissingHostKeyPolicy): + """A host key policy that rejects unknown host keys but captures their details.""" + + def missing_host_key(self, client, hostname, key): + """Handle an unknown host key encounter. + + Args: + client: The SSHClient instance + hostname: The server hostname or IP + key: The server's host key + + Raises: + UnknownHostKeyError: Always, with host key details + + """ + key_type = key.get_name() + key_data = key.get_base64() + + message = ( + f"Host key verification failed for {hostname}. Server sent:\n" + f" {key_type} {key_data}\n\n" + f"To add this host key, use the ssh_add_host_key action with the following parameters:\n" + f" host: {hostname}\n" + f" key: {key_data}\n" + f" key_type: {key_type}" + ) + + raise UnknownHostKeyError(message) + + +class SSHConnection: + """Manages an SSH connection to a remote server. + + This class encapsulates all SSH connection functionality including + establishing connections, executing commands, and managing the connection state. + """ + + def __init__(self, params: SSHConnectionParams): + """Initialize SSH connection. + + Args: + params: SSH connection parameters + + Raises: + ValueError: If the parameters are invalid + + """ + self.params = params + + self.connected = False + self.connection_time = None + self.known_hosts_file = None + + self.ssh_client = None + + def is_connected(self) -> bool: + """Check if there's an active SSH connection. + + Returns: + bool: Whether the connection is active + + """ + if not self.ssh_client: + return False + + if not self.connected: + return False + + result = None + + try: + _, stdout, _ = self.ssh_client.exec_command("echo 1", timeout=5) + result = stdout.read().decode().strip() + except Exception: + pass + + if result != "1": + self.reset_connection() + return False + + return True + + def reset_connection(self) -> None: + """Reset the connection state.""" + self.connected = False + self.connection_time = None + + if not self.ssh_client: + return + + with contextlib.suppress(Exception): + self.ssh_client.close() + + self.ssh_client = None + + def connect(self) -> None: + """Establish SSH connection using instance attributes. + + Raises: + SSHKeyError: If there's an issue with the SSH key + SSHConnectionError: If the connection fails + UnknownHostKeyError: If the host key is not recognized + + """ + params = self.params + self.disconnect() + + try: + if params.password: + self.connect_with_password( + params.host, params.username, params.password, params.port + ) + elif params.private_key: + self.connect_with_key( + params.host, + params.username, + params.private_key, + params.port, + password=params.password, + ) + else: + private_key_path = params.private_key_path or os.getenv( + "SSH_PRIVATE_KEY_PATH", "~/.ssh/id_rsa" + ) + private_key_path = os.path.expanduser(private_key_path) + self.connect_with_key_path( + params.host, + params.username, + private_key_path, + params.port, + password=params.password, + ) + + _, stdout, stderr = self.ssh_client.exec_command( + 'echo "Connection successful"', timeout=5 + ) + result = stdout.read().decode().strip() + + if result != "Connection successful": + e = stderr.read().decode().strip() + self.connected = False + raise SSHConnectionError(f"Connection test failed: {e!s}") + + self.connected = True + self.connection_time = datetime.now() + + except UnknownHostKeyError: + self.reset_connection() + raise + + def _load_key_from_string(self, key_string: str, password: str | None = None) -> paramiko.PKey: + """Load a private key from a string. + + This method attempts to load the key as different formats (RSA, DSS, ECDSA, Ed25519) + until one succeeds. RSA keys are tried first for test compatibility. + + Args: + key_string: Private key content as a string + password: Optional password for encrypted keys + + Returns: + paramiko.PKey: The loaded key + + Raises: + SSHKeyError: If there's an issue with the key + + """ + key_file = io.StringIO(key_string) + key_classes = [ + paramiko.RSAKey, + paramiko.DSSKey, + paramiko.ECDSAKey, + paramiko.Ed25519Key, + ] + + password_required = False + last_error = None + + for key_class in key_classes: + key_file.seek(0) + try: + return key_class.from_private_key(key_file, password=password) + except paramiko.ssh_exception.PasswordRequiredException: + password_required = True + except paramiko.ssh_exception.SSHException as e: + last_error = e + continue + except Exception as e: + raise SSHKeyError(f"Failed to load key from string: {e!s}") from e + + if password_required: + raise SSHKeyError("Password-protected key provided but no password was given") + + if last_error: + raise SSHKeyError(f"Failed to load key from string: {last_error!s}") + + raise SSHKeyError("Key format not supported or invalid key data") + + def _init_ssh_client(self): + """Initialize the SSH client with appropriate host key settings.""" + self.ssh_client = paramiko.SSHClient() + self.ssh_client.load_system_host_keys() + + if self.known_hosts_file: + try: + known_hosts_path = os.path.expanduser(self.known_hosts_file) + if os.path.exists(known_hosts_path): + self.ssh_client.load_host_keys(known_hosts_path) + except Exception as e: + print(f"Warning: Failed to load known_hosts file: {e!s}") + + self.ssh_client.set_missing_host_key_policy(CapturingRejectPolicy()) + + def connect_with_key( + self, + host: str, + username: str, + private_key: str | paramiko.RSAKey, + port: int = 22, + timeout: int = 10, + password: str | None = None, + ) -> None: + """Connect to a remote server using a private key. + + Args: + host: Remote server hostname/IP + username: SSH username + private_key: SSH private key as string or paramiko.RSAKey object + port: SSH port number (default: 22) + timeout: Connection timeout in seconds (default: 10) + password: Optional password for encrypted keys + + """ + try: + self.disconnect() + self._init_ssh_client() + + if isinstance(private_key, str): + key_obj = self._load_key_from_string(private_key, password=password) + else: + key_obj = private_key + + self.ssh_client.connect( + hostname=host, username=username, pkey=key_obj, port=port, timeout=timeout + ) + except SSHKeyError: + raise + except UnknownHostKeyError: + raise + except Exception as e: + raise SSHConnectionError(f"Failed to connect with key: {e!s}") from e + + def connect_with_key_path( + self, + host: str, + username: str, + private_key_path: str, + port: int = 22, + timeout: int = 10, + password: str | None = None, + ) -> None: + """Connect to a remote server using a private key from file. + + Args: + host: Remote server hostname/IP + username: SSH username + private_key_path: Path to SSH private key file + port: SSH port number (default: 22) + timeout: Connection timeout in seconds (default: 10) + password: Optional password for encrypted keys + + Raises: + SSHKeyError: If there's an issue with the SSH key + SSHConnectionError: If the connection fails + + """ + private_key_path = os.path.expanduser(private_key_path) + if not os.path.exists(private_key_path): + raise SSHKeyError(f"Key file not found at {private_key_path}") + + try: + key_obj = self._load_key_from_file(private_key_path, password=password) + self.connect_with_key(host, username, key_obj, port, timeout) + except SSHKeyError: + raise + except UnknownHostKeyError: + raise + except Exception as e: + raise SSHConnectionError(f"Failed to connect with key file: {e!s}") from e + + def _load_key_from_file(self, key_path: str, password: str | None = None) -> paramiko.PKey: + """Load a private key from a file. + + This method attempts to load the key as different formats (RSA, DSS, ECDSA, Ed25519) + until one succeeds. RSA keys are tried first for test compatibility. + + Args: + key_path: Path to the key file + password: Optional password for encrypted keys + + Returns: + paramiko.PKey: The loaded key + + Raises: + SSHKeyError: If there's an issue with the key file + + """ + key_classes = [ + paramiko.RSAKey, + paramiko.DSSKey, + paramiko.ECDSAKey, + paramiko.Ed25519Key, + ] + + password_required = False + last_error = None + + for key_class in key_classes: + try: + return key_class.from_private_key_file(key_path, password=password) + except paramiko.ssh_exception.PasswordRequiredException: + password_required = True + except paramiko.ssh_exception.SSHException as e: + last_error = e + continue + except FileNotFoundError as e: + raise SSHKeyError(f"Failed to load key file {key_path}: {e!s}") from e + except Exception as e: + raise SSHKeyError(f"Failed to load key file {key_path}: {e!s}") from e + + if password_required: + raise SSHKeyError("Password-protected key file requires a password") + + if last_error: + raise SSHKeyError(f"Invalid key format in {key_path}: {last_error!s}") + + raise SSHKeyError(f"Key format not supported or invalid key file {key_path}") + + def connect_with_password( + self, + host: str, + username: str, + password: str, + port: int = 22, + timeout: int = 10, + ) -> None: + """Connect to a remote server using password authentication. + + Args: + host: Remote server hostname/IP + username: SSH username + password: SSH password + port: SSH port number (default: 22) + timeout: Connection timeout in seconds (default: 10) + + """ + try: + self.disconnect() + self._init_ssh_client() + self.ssh_client.connect( + hostname=host, username=username, password=password, port=port, timeout=timeout + ) + except UnknownHostKeyError: + raise + except Exception as e: + raise SSHConnectionError(f"Failed to connect with password: {e!s}") from e + + def execute(self, command: str, timeout: int = 30, ignore_stderr: bool = False) -> str: + """Execute command on connected server. + + Args: + command: Shell command to execute + timeout: Command execution timeout in seconds + ignore_stderr: If True, stderr output won't cause exceptions + + Returns: + str: Command output (stdout) and optionally stderr if present + + Raises: + SSHConnectionError: If connection is lost or command execution fails + + """ + params = self.params + if not self.is_connected(): + raise SSHConnectionError( + f"No active SSH connection for {params.connection_id}. Please connect first." + ) + + try: + stdin, stdout, stderr = self.ssh_client.exec_command(command, timeout=timeout) + exit_status = stdout.channel.recv_exit_status() + output = stdout.read().decode() + error_output = stderr.read().decode() + + if error_output and (ignore_stderr or exit_status == 0): + if output: + return f"{output}\n[stderr]: {error_output}" + return error_output + + if exit_status != 0 and error_output: + raise SSHConnectionError( + f"Command execution failed on {params.connection_id} (exit code {exit_status}): {error_output}" + ) + + if exit_status != 0: + raise SSHConnectionError( + f"Command execution failed on {params.connection_id} with exit code {exit_status}" + ) + + if not output and exit_status == 0: + return "" + + return output + + except Exception as e: + self.reset_connection() + raise SSHConnectionError( + f"Command execution failed on {params.connection_id}: {e!s}" + ) from e + + def disconnect(self) -> None: + """Close SSH connection. + + Raises: + SSHConnectionError: If disconnection fails + + """ + self.reset_connection() + + def get_connection_info(self) -> str: + """Get information about the current connection. + + Returns: + str: Connection information as a formatted string + + """ + params = self.params + output = [ + f"Connection ID: {params.connection_id}", + ] + + if self.is_connected(): + connection_time = ( + self.connection_time.strftime("%Y-%m-%d %H:%M:%S") + if self.connection_time + else "Unknown" + ) + output.extend( + [ + "Status: Connected", + f"Host: {params.host}:{params.port}", + f"Username: {params.username}", + f"Connected since: {connection_time}", + ] + ) + else: + output.append("Status: Not connected") + + return "\n".join(output) + + def get_sftp_client(self) -> paramiko.SFTPClient: + """Get an SFTP client from the current SSH connection. + + Returns: + paramiko.SFTPClient: SFTP client object + + Raises: + SSHConnectionError: If there's no active connection or SFTP initialization fails + + """ + if not self.is_connected(): + raise SSHConnectionError("No active SSH connection. Please connect first.") + + try: + return self.ssh_client.open_sftp() + except Exception as e: + self.reset_connection() + raise SSHConnectionError(f"Failed to initialize SFTP client: {e!s}") from e + + def upload_file(self, local_path: str, remote_path: str) -> None: + """Upload a local file to the remote server. + + Args: + local_path: Path to the local file + remote_path: Destination path on the remote server + + Raises: + SSHConnectionError: If connection is lost or file transfer fails + FileNotFoundError: If the local file doesn't exist + + """ + params = self.params + if not os.path.exists(local_path): + raise FileNotFoundError(f"Local file not found: {local_path}") + + try: + sftp = self.get_sftp_client() + sftp.put(local_path, remote_path) + sftp.close() + except Exception as e: + self.reset_connection() + raise SSHConnectionError(f"File upload failed for {params.connection_id}: {e!s}") from e + + def download_file(self, remote_path: str, local_path: str) -> None: + """Download a file from the remote server. + + Args: + remote_path: Path to the file on the remote server + local_path: Destination path on the local machine + + Raises: + SSHConnectionError: If connection is lost or file transfer fails + + """ + params = self.params + try: + sftp = self.get_sftp_client() + sftp.get(remote_path, local_path) + sftp.close() + except Exception as e: + self.reset_connection() + raise SSHConnectionError( + f"File download failed for {params.connection_id}: {e!s}" + ) from e + + def list_directory(self, remote_path: str) -> list[str]: + """List contents of a directory on the remote server. + + Args: + remote_path: Path to the directory on the remote server + + Returns: + list[str]: List of filenames in the directory + + Raises: + SSHConnectionError: If connection is lost or directory listing fails + + """ + params = self.params + try: + sftp = self.get_sftp_client() + files = sftp.listdir(remote_path) + sftp.close() + return files + except Exception as e: + self.reset_connection() + raise SSHConnectionError( + f"Directory listing failed on {params.connection_id}: {e!s}" + ) from e + + def __enter__(self): + """Enter context manager. + + Returns: + SSHConnection: The connection instance + + """ + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Exit context manager and close all connections. + + Args: + exc_type: Exception type + exc_val: Exception value + exc_tb: Exception traceback + + """ + self.disconnect() diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/connection_pool.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/connection_pool.py new file mode 100644 index 000000000..84de46d05 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/connection_pool.py @@ -0,0 +1,215 @@ +"""SSH Connection Pool. + +This module implements the SSHConnectionPool class, which manages multiple SSH connections +and provides methods to create, retrieve, and close connections. + +@module ssh/pool +""" + +from .connection import SSHConnection, SSHConnectionError, SSHConnectionParams + + +class SSHConnectionPool: + """Manages multiple SSH connections. + + This class maintains a pool of SSH connections, limits the total number + of connections, and provides methods to create, retrieve, and close connections. + """ + + def __init__(self, max_connections: int = 5): + """Initialize connection pool. + + Args: + max_connections: Maximum number of concurrent connections + + """ + self.connections = {} + self.max_connections = max_connections + self.connection_params = {} + + def has_connection(self, connection_id: str) -> bool: + """Check if a connection exists in the pool. + + Args: + connection_id: Unique identifier for the connection + + Returns: + bool: True if the connection exists in the pool + + """ + return connection_id in self.connections + + def get_connection(self, connection_id: str) -> SSHConnection: + """Get an existing connection from the pool. + + Args: + connection_id: Unique identifier for the connection + + Returns: + SSHConnection: The connection object + + Raises: + SSHConnectionError: If the connection ID is not found or connection limit reached + + """ + if connection_id in self.connections: + return self.connections[connection_id] + + params = self._get_connection_params(connection_id) + if not params: + raise SSHConnectionError(f"Connection ID '{connection_id}' not found") + + if len(self.connections) >= self.max_connections: + raise SSHConnectionError(f"Connection limit reached ({self.max_connections})") + + return self.create_connection(params) + + def close_idle_connections(self) -> int: + """Close any idle connections in the pool. + + Returns: + int: Number of closed connections + + """ + closed_count = 0 + for conn_id, conn in list(self.connections.items()): + if not conn.is_connected(): + self.close_connection(conn_id) + closed_count += 1 + return closed_count + + def create_connection(self, params: SSHConnectionParams) -> SSHConnection: + """Create a new connection and add it to the pool. + + Args: + params: SSH connection parameters + + Returns: + SSHConnection: The newly created SSH connection + + Raises: + SSHConnectionError: If the connection limit is reached + ValueError: If the connection parameters are invalid + + """ + self.close_idle_connections() + + if len(self.connections) >= self.max_connections: + raise SSHConnectionError(f"Connection limit reached ({self.max_connections})") + + try: + stored_params = self._set_connection_params(params) + connection = SSHConnection(stored_params) + + self.connections[params.connection_id] = connection + + return connection + except ValueError as e: + self._remove_connection_params(params.connection_id) + raise ValueError( + f"Invalid connection parameters for '{params.connection_id}': {e!s}" + ) from e + + def close_connection(self, connection_id: str) -> SSHConnection | None: + """Close and remove a connection from the pool. + + Args: + connection_id: Unique identifier for the connection + + """ + if connection_id not in self.connections: + return None + + connection = self.connections[connection_id] + connection.disconnect() + + del self.connections[connection_id] + + return connection + + def close_and_remove_connection(self, connection_id: str) -> None: + """Close a connection and remove it completely from the pool including parameters. + + Args: + connection_id: Unique identifier for the connection + + """ + self.close_connection(connection_id) + self._remove_connection_params(connection_id) + + def close_all_connections(self) -> None: + """Close all active connections in the pool.""" + for connection_id in list(self.connections.keys()): + self.close_connection(connection_id) + + def clear_connection_pool(self) -> None: + """Close all connections and clear all stored parameters.""" + self.close_all_connections() + self.connection_params.clear() + + def get_connections(self): + """Get all connections in the pool. + + Returns: + dict: Dictionary of all connections + + """ + return self.connections + + def __enter__(self): + """Enter context manager. + + Returns: + SSHConnectionPool: The connection pool instance + + """ + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Exit context manager and close all connections. + + Args: + exc_type: Exception type + exc_val: Exception value + exc_tb: Exception traceback + + """ + self.clear_connection_pool() + + def _get_connection_params(self, connection_id: str) -> SSHConnectionParams | None: + """Get stored connection parameters. + + Args: + connection_id: Unique identifier for the connection + + Returns: + SSHConnectionParams: The stored parameters or None if not found + + """ + return self.connection_params.get(connection_id) + + def _set_connection_params(self, params: SSHConnectionParams) -> SSHConnectionParams: + """Store connection parameters. + + Args: + params: SSH connection parameters + + Returns: + SSHConnectionParams: The stored parameters + + Raises: + ValueError: If the parameters are invalid + + """ + self.connection_params[params.connection_id] = params + return params + + def _remove_connection_params(self, connection_id: str) -> None: + """Remove stored connection parameters. + + Args: + connection_id: Unique identifier for the connection + + """ + if connection_id in self.connection_params: + del self.connection_params[connection_id] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/schemas.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/schemas.py new file mode 100644 index 000000000..7a7e55efb --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/schemas.py @@ -0,0 +1,89 @@ +"""Schemas for ssh Action Provider. + +This file contains the Pydantic schemas that define the input types +for the ssh action provider's actions. + +@module ssh/schemas +""" + +from pydantic import BaseModel, Field + +from .connection import SSHConnectionParams + + +class CustomSSHConnectionParams(SSHConnectionParams): + """Extended SSH connection parameters with known_hosts_file option.""" + + known_hosts_file: str | None = Field( + None, description="Path to the known_hosts file (default: system default)" + ) + + +SSHConnectionSchema = CustomSSHConnectionParams + + +class RemoteShellSchema(BaseModel): + """Schema for remote_shell action.""" + + connection_id: str = Field(description="Identifier for the SSH connection to use") + command: str = Field( + description="The shell command to execute on the remote server", + min_length=1, + ) + ignore_stderr: bool = Field(False, description="If True, stderr output won't cause exceptions") + timeout: int = Field(30, description="Command execution timeout in seconds") + + +class DisconnectSchema(BaseModel): + """Schema for ssh_disconnect action.""" + + connection_id: str = Field(description="Identifier for the SSH connection to disconnect") + + +class ConnectionStatusSchema(BaseModel): + """Schema for ssh_status action.""" + + connection_id: str = Field(description="Identifier for the SSH connection to check status") + + +class ListConnectionsSchema(BaseModel): + """Schema for list_connections action.""" + + pass + + +class FileUploadSchema(BaseModel): + """Schema for ssh_upload action.""" + + connection_id: str = Field(description="Identifier for the SSH connection to use") + local_path: str = Field(description="Path to the local file to upload") + remote_path: str = Field(description="Destination path on the remote server") + + +class FileDownloadSchema(BaseModel): + """Schema for ssh_download action.""" + + connection_id: str = Field(description="Identifier for the SSH connection to use") + remote_path: str = Field(description="Path to the file on the remote server") + local_path: str = Field(description="Destination path on the local machine") + + +class AddHostKeySchema(BaseModel): + """Schema for ssh_add_host_key action.""" + + host: str = Field( + description="Hostname or IP address of the server (can include port as [hostname]:port)", + min_length=1, + ) + key: str = Field( + description="The host key to add", + min_length=1, + ) + key_type: str = Field( + default="ssh-rsa", + description="The type of the SSH key (e.g., ssh-rsa, ssh-ed25519)", + ) + known_hosts_file: str = Field( + default="~/.ssh/known_hosts", + description="Path to the known_hosts file", + ) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/ssh_action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/ssh_action_provider.py new file mode 100644 index 000000000..274f15375 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/ssh/ssh_action_provider.py @@ -0,0 +1,608 @@ +"""SSH Action Provider. + +This module implements the SSH Action Provider, which enables executing +commands on remote servers via SSH, file transfers via SFTP, and connection management. + +@module ssh/ssh_action_provider +""" + +import contextlib +import os +import uuid +from typing import Any + +import paramiko +from pydantic import ValidationError + +from ...network import Network +from ..action_decorator import create_action +from ..action_provider import ActionProvider +from .connection import SSHConnectionError, SSHKeyError, UnknownHostKeyError +from .connection_pool import SSHConnectionPool +from .schemas import ( + AddHostKeySchema, + ConnectionStatusSchema, + DisconnectSchema, + FileDownloadSchema, + FileUploadSchema, + ListConnectionsSchema, + RemoteShellSchema, + SSHConnectionSchema, +) + + +class SshActionProvider(ActionProvider): + """SshActionProvider provides actions for SSH operations. + + This provider enables connecting to remote servers via SSH and executing commands. + It supports managing multiple concurrent SSH connections. + """ + + def __init__(self, max_connections: int = 10): + """Initialize the SshActionProvider.""" + super().__init__("ssh", []) + self.connection_pool = SSHConnectionPool(max_connections=max_connections) + + @create_action( + name="ssh_connect", + description=""" +This tool establishes an SSH connection to a remote server. + +Required inputs: +- host: Hostname or IP address +- username: SSH username + +Optional inputs: +- connection_id: Unique ID (auto-generated if omitted) +- password: SSH password +- private_key: SSH private key as string +- private_key_path: Path to key file +- port: SSH port (default: 22) +- known_hosts_file: Path to custom known_hosts file + +Example successful response: + Connection ID: my_server + Successfully connected to host.example.com as username + +Example error response: + SSH Connection Error: Authentication failed + SSH Connection Error: Connection refused + SSH Key Error: Key file not found + Host key verification failed: Use ssh_add_host_key + +Important notes: +- Maintains multiple simultaneous connections +- Use remote_shell to execute commands after connecting +- Either password or key authentication required +- Default key path is ~/.ssh/id_rsa +- If host key verification fails, use ssh_add_host_key +""", + schema=SSHConnectionSchema, + ) + def ssh_connect(self, args: dict[str, Any]) -> str: + """Establish SSH connection to remote server. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + if args.get("connection_id") is None: + args["connection_id"] = str(uuid.uuid4()) + + validated_args = SSHConnectionSchema(**args) + connection_id = validated_args.connection_id + + with contextlib.suppress(SSHConnectionError): + self.connection_pool.close_connection(connection_id) + + connection = self.connection_pool.create_connection(validated_args) + connection.connect() + + output = [ + f"Connection ID: {connection_id}", + f"Successfully connected to {connection.params.host} as {connection.params.username}", + ] + + return "\n".join(output) + + except UnknownHostKeyError as e: + return f"Error: Host verification: {e!s}" + except SSHConnectionError as e: + return f"Error: Connection: {e!s}" + except SSHKeyError as e: + return f"Error: SSH key issue: {e!s}" + except ValidationError as e: + return f"Error: Invalid input parameters: {e!s}" + + @create_action( + name="remote_shell", + description=""" +This tool executes shell commands on a remote server via SSH. + +Required inputs: +- connection_id: Identifier for the SSH connection to use +- command: The shell command to execute on the remote server +- ignore_stderr: If True, stderr output won't cause exceptions +- timeout: Command execution timeout in seconds + +Example successful response: + Output from connection 'my_server': + + Command output from remote server + +Example error response: + Error: No active SSH connection. Please connect first. + Error: Invalid connection ID. + Error: Command execution failed. + Error: SSH connection lost. + +Important notes: +- Requires an active SSH connection (use ssh_connect first) +- Use 'ssh_status' to check current connection status +- Commands are executed in the connected SSH session +- Returns command output as a string +- You can install any packages you need on the remote server +""", + schema=RemoteShellSchema, + ) + def remote_shell(self, args: dict[str, Any]) -> str: + """Execute a command on the remote server. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + validated_args = RemoteShellSchema(**args) + connection_id = validated_args.connection_id + command = validated_args.command.strip() + ignore_stderr = validated_args.ignore_stderr + timeout = validated_args.timeout + + if not self.connection_pool.has_connection(connection_id): + return f"Error: Connection ID '{connection_id}' not found. Use ssh_connect first." + + connection = self.connection_pool.get_connection(connection_id) + if not connection.is_connected(): + return f"Error: Connection state: Connection '{connection_id}' is not currently active. Use ssh_connect to establish the connection." + + result = connection.execute(command, timeout=timeout, ignore_stderr=ignore_stderr) + return f"Output from connection '{connection_id}':\n\n{result}" + + except SSHConnectionError as e: + return f"Error: Connection: {e!s}. Please reconnect using ssh_connect." + except ValidationError as e: + return f"Error: Invalid parameters: {e!s}" + except Exception as e: + return f"Error: Command execution: {e!s}" + + @create_action( + name="ssh_disconnect", + description=""" +This tool disconnects an active SSH connection. + +Required inputs: +- connection_id: Identifier for the SSH connection to disconnect + +Example successful response: + Connection ID: my_server + Disconnected from host.example.com + +Example error response: + Error: Invalid connection ID. + +Important notes: +- After disconnection, the connection ID is no longer valid +- You will need to establish a new connection to reconnect +""", + schema=DisconnectSchema, + ) + def ssh_disconnect(self, args: dict[str, Any]) -> str: + """Disconnects from an active SSH session. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + validated_args = DisconnectSchema(**args) + connection_id = validated_args.connection_id + + connection = self.connection_pool.close_connection(connection_id) + + result = ( + f"Connection ID: {connection_id}\nDisconnected from {connection.params.host}" + if connection + else f"Connection ID: {connection_id}\nNo active connection to disconnect" + ) + + return result + + except SSHConnectionError as e: + return f"Error: Connection: {e!s}" + except ValidationError as e: + return f"Error: Invalid parameters: {e!s}" + except Exception as e: + return f"Error disconnecting: {e!s}" + + @create_action( + name="ssh_status", + description=""" +This tool checks the status of a specific SSH connection. + +Required inputs: +- connection_id: Identifier for the SSH connection to check + +Example successful response: + Connection ID: my_server + Status: Connected + Host: 192.168.1.100:22 + Username: admin + Connected since: 2023-01-01 12:34:56 + +Example error response: + Error: Invalid connection ID. + +Important notes: +- Use this to verify connection status before executing commands +- To list all connections, use the list_connections action +""", + schema=ConnectionStatusSchema, + ) + def ssh_status(self, args: dict[str, Any]) -> str: + """Retrieve status of a specific SSH connection. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + validated_args = ConnectionStatusSchema(**args) + connection_id = validated_args.connection_id + + connection = self.connection_pool.get_connection(connection_id) + return connection.get_connection_info() + except SSHConnectionError as e: + return f"Error: Connection not found: {e!s}" + except ValidationError as e: + return f"Error: Invalid input parameters: {e!s}" + + @create_action( + name="list_connections", + description=""" +This tool lists all active SSH connections. + +It does not take any inputs. + +Example successful response: + Active SSH Connections: 2 + + Connection ID: server1 + Status: Connected + Host: 192.168.1.100:22 + Username: admin + + Connection ID: server2 + Status: Not connected + +Example error response: + No active SSH connections +""", + schema=ListConnectionsSchema, + ) + def list_connections(self, args: dict[str, Any]) -> str: + """List all SSH connections in the pool. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + connections = self.connection_pool.get_connections() + if not connections: + return "No active SSH connections" + + result = [f"Active SSH Connections: {len(connections)}"] + + for conn_id, connection in connections.items(): + if len(result) > 1: + result.append("") + + if connection.is_connected(): + result.append(f"Connection ID: {conn_id}") + result.append("Status: Connected") + result.append(f"Host: {connection.params.host}:{connection.params.port}") + result.append(f"Username: {connection.params.username}") + else: + result.append(f"Connection ID: {conn_id}") + result.append("Status: Not connected") + + return "\n".join(result) + + except Exception as e: + return f"Error: Connection listing: {e!s}" + + @create_action( + name="ssh_upload", + description=""" +This tool uploads a file to a remote server via SFTP. + +Required inputs: +- connection_id: Identifier for the SSH connection to use +- local_path: Path to the local file to upload +- remote_path: Destination path on the remote server + +Example successful response: + File upload successful: + Local file: /path/to/local/file.txt + Remote destination: /path/on/server/file.txt + +Example error response: + Error: No active SSH connection. Please connect first. + Error: Local file not found. + Error: Permission denied on remote server. + +Important notes: +- Requires an active SSH connection (use ssh_connect first) +- Local path must be accessible to the agent +- Remote path must include the target filename, not just a directory +- User running the agent must have permission to read the local file +- SSH user must have permission to write to the remote location +""", + schema=FileUploadSchema, + ) + def ssh_upload(self, args: dict[str, Any]) -> str: + """Upload a file to the remote server. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + validated_args = FileUploadSchema(**args) + connection_id = validated_args.connection_id + local_path = validated_args.local_path + remote_path = validated_args.remote_path + + if not self.connection_pool.has_connection(connection_id): + return f"Error: Connection ID '{connection_id}' not found. Use ssh_connect first." + + if not os.path.exists(local_path): + return f"Error: Local file not found at {local_path}" + + if not os.path.isfile(local_path): + return f"Error: {local_path} is not a file" + + connection = self.connection_pool.get_connection(connection_id) + + if not connection.is_connected(): + return f"Error: Connection '{connection_id}' is not currently active. Use ssh_connect to establish the connection." + + connection.upload_file(local_path, remote_path) + + return ( + f"File upload successful:\n" + f"Local file: {local_path}\n" + f"Remote destination: {remote_path}" + ) + + except SSHConnectionError as e: + return f"Error: SSH connection: {e!s}" + except paramiko.SFTPError as e: + return f"Error: SFTP operation: {e!s}" + except OSError as e: + return f"Error: I/O operation: {e!s}" + except ValidationError as e: + return f"Error: Invalid input parameters: {e!s}" + except Exception as e: + return f"Error: File upload: {e!s}" + + @create_action( + name="ssh_download", + description=""" +This tool downloads a file from a remote server via SFTP. + +Required inputs: +- connection_id: Identifier for the SSH connection to use +- remote_path: Path to the file on the remote server +- local_path: Destination path on the local machine + +Example successful response: + File download successful: + Remote file: /path/on/server/file.txt + Local destination: /path/to/local/file.txt + +Example error response: + Error: No active SSH connection. Please connect first. + Error: Remote file not found. + Error: Permission denied on local machine. + +Important notes: +- Requires an active SSH connection (use ssh_connect first) +- Remote file must exist and be readable by the SSH user +- Local path must include the target filename, not just a directory +- User running the agent must have permission to write to the local path +- If the local file already exists, it will be overwritten +""", + schema=FileDownloadSchema, + ) + def ssh_download(self, args: dict[str, Any]) -> str: + """Download a file from the remote server. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + validated_args = FileDownloadSchema(**args) + connection_id = validated_args.connection_id + remote_path = validated_args.remote_path + local_path = validated_args.local_path + + if not self.connection_pool.has_connection(connection_id): + return f"Error: Connection ID '{connection_id}' not found. Use ssh_connect first." + + connection = self.connection_pool.get_connection(connection_id) + + if not connection.is_connected(): + return f"Error: Connection '{connection_id}' is not currently active. Use ssh_connect to establish the connection." + + local_path = os.path.expanduser(local_path) + + local_dir = os.path.dirname(local_path) + if local_dir and not os.path.exists(local_dir): + os.makedirs(local_dir) + + connection.download_file(remote_path, local_path) + + return ( + f"File download successful:\n" + f"Remote file: {remote_path}\n" + f"Local destination: {local_path}" + ) + + except SSHConnectionError as e: + return f"Error: SSH connection: {e!s}" + except paramiko.SFTPError as e: + return f"Error: SFTP operation: {e!s}" + except OSError as e: + return f"Error: I/O operation: {e!s}" + except ValidationError as e: + return f"Error: Invalid input parameters: {e!s}" + except Exception as e: + return f"Error: File download: {e!s}" + + @create_action( + name="ssh_add_host_key", + description=""" +This tool adds an SSH host key to the local known_hosts file. + +Required inputs: +- host: Hostname or IP of server. For non-standard ports, use "[hostname]:port". + Example: "[example.com]:2222" +- key: The SSH host key to add +- key_type: Type of SSH key (default: ssh-rsa, e.g., ssh-ed25519) + +Optional inputs: +- known_hosts_file: Path to known_hosts file (default: ~/.ssh/known_hosts) + +Example successful response: + Host key for 'example.com' successfully added to ~/.ssh/known_hosts + Host key for '[example.com]:2222' successfully added to ~/.ssh/known_hosts + +Example error response: + Error: Unable to access known_hosts file + +Important notes: +- This tool modifies the local known_hosts file +- Host keys are typically obtained from SSH connection errors +- For non-standard ports, OpenSSH format [hostname]:port is required +- Existing entries for the same host will be updated (not duplicated) +""", + schema=AddHostKeySchema, + ) + def ssh_add_host_key(self, args: dict[str, Any]) -> str: + """Add an SSH host key to the known_hosts file. + + Args: + args (dict[str, Any]): Input arguments for the action. + + Returns: + str: A message containing the action response or error details. + + """ + try: + validated_args = AddHostKeySchema(**args) + host = validated_args.host + key = validated_args.key + key_type = validated_args.key_type + known_hosts_file = os.path.expanduser(validated_args.known_hosts_file) + + host_entry = host + entry = f"{host_entry} {key_type} {key}\n" + + known_hosts_dir = os.path.dirname(known_hosts_file) + if known_hosts_dir and not os.path.exists(known_hosts_dir): + os.makedirs(known_hosts_dir, mode=0o700, exist_ok=True) + + existing_entry = False + existing_lines = [] + + if os.path.exists(known_hosts_file): + with open(known_hosts_file) as f: + existing_lines = f.readlines() + + for i, line in enumerate(existing_lines): + if line.strip() and line.split()[0] == host_entry: + existing_lines[i] = entry + existing_entry = True + break + + if existing_entry: + with open(known_hosts_file, "w") as f: + f.writelines(existing_lines) + return f"Host key for '{host_entry}' updated in {known_hosts_file}" + else: + with open(known_hosts_file, "a+") as f: + f.write(entry) + return f"Host key for '{host_entry}' successfully added to {known_hosts_file}" + + except ValidationError as e: + return f"Error: Invalid input parameters: {e!s}" + except FileNotFoundError as e: + return f"Error: Unable to access known_hosts file: {e!s}" + except PermissionError as e: + return f"Error: Permission denied when accessing known_hosts file: {e!s}" + except OSError as e: + return f"Error: File operation: {e!s}" + except Exception as e: + return f"Error: Host key addition: {e!s}" + + def supports_network(self, network: Network) -> bool: + """Check if this provider supports the specified network. + + Args: + network: The network to check. + + Returns: + bool: Always True as SSH is network-agnostic. + + """ + return True + + +def ssh_action_provider( + max_connections: int = 10, +) -> SshActionProvider: + """Create a new instance of the SshActionProvider. + + Args: + max_connections: Maximum number of concurrent SSH connections (default: 10) + + Returns: + An initialized SshActionProvider + + """ + return SshActionProvider(max_connections=max_connections) diff --git a/python/coinbase-agentkit/poetry.lock b/python/coinbase-agentkit/poetry.lock index d2274eaa0..351194797 100644 --- a/python/coinbase-agentkit/poetry.lock +++ b/python/coinbase-agentkit/poetry.lock @@ -1,16 +1,14 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" -version = "2.5.0" +version = "2.6.1" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "aiohappyeyeballs-2.5.0-py3-none-any.whl", hash = "sha256:0850b580748c7071db98bffff6d4c94028d0d3035acc20fd721a0ce7e8cac35d"}, - {file = "aiohappyeyeballs-2.5.0.tar.gz", hash = "sha256:18fde6204a76deeabc97c48bdd01d5801cfda5d6b9c8bbeb1aaaee9d648ca191"}, + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, ] [[package]] @@ -19,8 +17,6 @@ version = "3.11.13" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "aiohttp-3.11.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a4fe27dbbeec445e6e1291e61d61eb212ee9fed6e47998b27de71d70d3e8777d"}, {file = "aiohttp-3.11.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9e64ca2dbea28807f8484c13f684a2f761e69ba2640ec49dacd342763cc265ef"}, @@ -124,8 +120,6 @@ version = "1.3.2" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, @@ -140,8 +134,6 @@ version = "1.0.0" description = "A light, configurable Sphinx theme" optional = false python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, @@ -153,10 +145,10 @@ version = "0.2.0" description = "Allora Network SDK" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" -files = [] -develop = false +files = [ + {file = "allora_sdk-0.2.0-py3-none-any.whl", hash = "sha256:9935a6e1693e347c8f73fccda831c3ff817ff90ba644e32f1aa3534869ebacf5"}, + {file = "allora_sdk-0.2.0.tar.gz", hash = "sha256:849e6da659fce3b5c401e3b58a219165bc222f7b79273b6dcf0e8e3c4b7334e5"}, +] [package.dependencies] aiohttp = "*" @@ -173,31 +165,23 @@ packaging = "24.2" platformdirs = "4.3.6" pluggy = "1.5.0" pydantic = "2.10.4" -pydantic_core = "2.27.2" +pydantic-core = "2.27.2" pyproject-api = "1.8.0" requests = "2.32.3" tox = "4.23.2" -typing_extensions = "4.12.2" +typing-extensions = "4.12.2" urllib3 = "2.3.0" virtualenv = "20.28.1" [package.extras] dev = ["fastapi", "pytest", "pytest-asyncio", "starlette", "tox"] -[package.source] -type = "git" -url = "https://github.com/allora-network/allora-sdk-py.git" -reference = "master" -resolved_reference = "ac4ecaa5f978fd27bd5ab0db0bdb38ef7235b157" - [[package]] name = "annotated-types" version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -209,8 +193,6 @@ version = "4.8.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"}, {file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"}, @@ -233,8 +215,6 @@ version = "1.5.1" description = "Fast ASN.1 parser and serializer with definitions for private keys, public keys, certificates, CRL, OCSP, CMS, PKCS#3, PKCS#7, PKCS#8, PKCS#12, PKCS#5, X.509 and TSP" optional = false python-versions = "*" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67"}, {file = "asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c"}, @@ -246,8 +226,6 @@ version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -255,22 +233,20 @@ files = [ [[package]] name = "attrs" -version = "25.1.0" +version = "25.3.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, - {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, + {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, + {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, ] [package.extras] benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] @@ -280,8 +256,6 @@ version = "2.17.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, @@ -290,14 +264,76 @@ files = [ [package.extras] dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] +[[package]] +name = "bcrypt" +version = "4.3.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.8" +files = [ + {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d"}, + {file = "bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4"}, + {file = "bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669"}, + {file = "bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304"}, + {file = "bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51"}, + {file = "bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62"}, + {file = "bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505"}, + {file = "bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a"}, + {file = "bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938"}, + {file = "bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "bip-utils" version = "2.9.3" description = "Generation of mnemonics, seeds, private/public keys and addresses for different types of cryptocurrencies" optional = false python-versions = ">=3.7" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "bip_utils-2.9.3-py3-none-any.whl", hash = "sha256:ee26b8417a576c7f89b847da37316db01a5cece1994c1609d37fbeefb91ad45e"}, {file = "bip_utils-2.9.3.tar.gz", hash = "sha256:72a8c95484b57e92311b0b2a3d5195b0ce4395c19a0b157d4a289e8b1300f48a"}, @@ -332,8 +368,6 @@ version = "3.1.1" description = "efficient arrays of booleans -- C extension" optional = false python-versions = "*" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "bitarray-3.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb630142c0371862e114cc92fb3c7984b72bbbc4c7e7465225402ab8c7737637"}, {file = "bitarray-3.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:737755812c8834077885c0843e80eaaa2bf289c149f40a4ad0ed79e848fcbaf3"}, @@ -477,8 +511,6 @@ version = "5.5.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, @@ -490,8 +522,6 @@ version = "24.1.2" description = "Composable complex class support for attrs and dataclasses." optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "cattrs-24.1.2-py3-none-any.whl", hash = "sha256:67c7495b760168d931a10233f979b28dc04daf853b30752246f4f8471c6d68d0"}, {file = "cattrs-24.1.2.tar.gz", hash = "sha256:8028cfe1ff5382df59dd36474a86e02d817b06eaf8af84555441bac915d2ef85"}, @@ -518,8 +548,6 @@ version = "5.6.5" description = "CBOR (de)serializer with extensive tag support" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "cbor2-5.6.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e16c4a87fc999b4926f5c8f6c696b0d251b4745bc40f6c5aee51d69b30b15ca2"}, {file = "cbor2-5.6.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87026fc838370d69f23ed8572939bd71cea2b3f6c8f8bb8283f573374b4d7f33"}, @@ -578,8 +606,6 @@ version = "0.21.0" description = "CDP Python SDK" optional = false python-versions = "<4.0,>=3.10" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "cdp_sdk-0.21.0-py3-none-any.whl", hash = "sha256:36a2ec372c79354133f142566674f6f5a21f474d31f378154a3b4e0e0089818a"}, {file = "cdp_sdk-0.21.0.tar.gz", hash = "sha256:6d832189e84cec76c3353f52835ddf06789630325ca5f0ea1a48ad663b698e7d"}, @@ -601,8 +627,6 @@ version = "2024.12.14" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, @@ -614,8 +638,6 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -695,8 +717,6 @@ version = "5.2.0" description = "Universal encoding detector for Python 3" optional = false python-versions = ">=3.7" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, @@ -708,8 +728,6 @@ version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, @@ -807,107 +825,111 @@ files = [ [[package]] name = "ckzg" -version = "2.0.1" +version = "2.1.0" description = "Python bindings for C-KZG-4844" optional = false python-versions = "*" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" -files = [ - {file = "ckzg-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7f9ba6d215f8981c5545f952aac84875bd564a63da02fb22a3d1321662ecdc0"}, - {file = "ckzg-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8fdec3ff96399acba9baeef9e1b0b5258c08f73245780e6c69f7b73def5e8d0a"}, - {file = "ckzg-2.0.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1644369af9900a9f109d417d6760693edf134118f3100d0c68f56667de775b80"}, - {file = "ckzg-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0a2146f122d489ac7e67ae0c0743f8d0db1718e6aeed8f05717340594fe07dd"}, - {file = "ckzg-2.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:979841be50f2782b447762db38e9bc927ae251f6ca86c54a26561a52068ee779"}, - {file = "ckzg-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4516d86647ee4e8ea9470f4adf68fbebb6dc1bdedff7d9592c2504fe53145908"}, - {file = "ckzg-2.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:91866fc58a29b4829201efd9ffadfac3ffeca6359254a54a360ff6a189c34bf5"}, - {file = "ckzg-2.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed35508dac059b2c0a7994383bc7a92eaf35d0b9ce790016819e2619e0f4b8a9"}, - {file = "ckzg-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:449c4fe38017351eca362106420eeb2d28d50b7e54aa8668b3af29a8ab780132"}, - {file = "ckzg-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:260608a22e2f2cadcd31f4495832d45d6460438c38faba9761b92df885a99d88"}, - {file = "ckzg-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e1015f99c50215098751b07d7e459ba9a2790d3692ca81552eed29996128e90d"}, - {file = "ckzg-2.0.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dd350d97554c161dc5b8c7b32c2dc8e659632c374f60e2669fb3c9b5b294827"}, - {file = "ckzg-2.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eec7724fa8dc4ae95757efe4a87e7b2d4b880cb348c72ce7355fc0c4f64bc298"}, - {file = "ckzg-2.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3fa0f4398fa67fb71f0a2b34a652cc89e6e0e6af1340b0dc771db1a5f3e089c"}, - {file = "ckzg-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f865a0297aabeeb638187a46f7df445763360417b9df4dea60560d512c2cda09"}, - {file = "ckzg-2.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b6ec738350771dbf5974fb70cc8bbb20a4df784af770f7e655922adc08a2171"}, - {file = "ckzg-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b4b669fc77edeb16adc182efc32b3737b36f741a2e33a170d40619e8b171a94"}, - {file = "ckzg-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:decb97f4a17c7338b2130dcc4b045df4cc0e7785ece872c764b554c7c73a99ff"}, - {file = "ckzg-2.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:285cf3121b8a8c5609c5b706314f68d2ba2784ab02c5bb7487c6ae1714ecb27f"}, - {file = "ckzg-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f927bc41c2551b0ef0056a649a7ebed29d9665680a10795f4cee5002c69ddb7"}, - {file = "ckzg-2.0.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fd9fb690c88919f30c9f3ab7cc46a7ecd734d5ff4c9ccea383c119b9b7cc4da"}, - {file = "ckzg-2.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fabc3bd41b306d1c7025d561c3281a007c2aca8ceaf998582dc3894904d9c73e"}, - {file = "ckzg-2.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eb50c53efdb9c34f762bd0c8006cf79bc92a9daf47aa6b541e496988484124f"}, - {file = "ckzg-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7960cc62f959403293fb53a3c2404778369ae7cefc6d7f202e5e00567cf98c4b"}, - {file = "ckzg-2.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d721bcd492294c70eca39da0b0a433c29b6a571dbac2f7084bab06334904af06"}, - {file = "ckzg-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dde2391d025b5033ef0eeacf62b11ecfe446aea25682b5f547a907766ad0a8cb"}, - {file = "ckzg-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fab8859d9420f6f7df4e094ee3639bc49d18c8dab0df81bee825e2363dd67a09"}, - {file = "ckzg-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9747d92883199d4f8f3a3d7018134745fddcf692dfe67115434e4b32609ea785"}, - {file = "ckzg-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b2cf58fb9e165da97f0ffe9f4a6efb73992645fac8e0fa223a6cc7ec486a434a"}, - {file = "ckzg-2.0.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d25d006899d76bb8c9d3e8b27981dd6b66a78f9826e33c1bf981af6577a69a19"}, - {file = "ckzg-2.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04bf0b32f04f5ea5e4b8518e292d3321bc05596fde95f9c3b4f504e5e4bc780"}, - {file = "ckzg-2.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d0cf3dccd72376bff10e1833641cc9d642f34f60ca63972626d9dfcfdc8e77f"}, - {file = "ckzg-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:770809c7e93087470cc524724419b0f85590edb033c7c73ba94aef70b36ca18b"}, - {file = "ckzg-2.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31b59b8124148d5e21f7e41b35532d7af98260c44a77c3917958adece84296d"}, - {file = "ckzg-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:174f0c356df644d6e349ce03b7284d83dbec859e11ca5d1b1b3bace8b8fbc65d"}, - {file = "ckzg-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:30e375cd45142e56b5dbfdec05ce4deb2368d7f7dedfc7408ba37d5639af05ff"}, - {file = "ckzg-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:abdee71958b214730a8341b16bdd413d0fab1b1a2504fbdb7b0ef2aeee9f9d22"}, - {file = "ckzg-2.0.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b4442667058db791325fe231f22e4fc7aaa3495d535d75af5595bc5f4f86036"}, - {file = "ckzg-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c3c9aa9d4477ad52f3561b717e776c1a8a442d9d8b06600c7d8a2857d1ecf05"}, - {file = "ckzg-2.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68e0a9cde35f11e80b4e560d22990f2f29dd200a95d3141acde137cb6c883f9a"}, - {file = "ckzg-2.0.1-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:4508a089e53330866d3360000d76483400eeab5f8057b8e1f3e344ce2cc0097b"}, - {file = "ckzg-2.0.1-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:828cecee16ec576dcf4386beac4eedfd058fd32ee90827f2282e7156a53600be"}, - {file = "ckzg-2.0.1-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:bd437ec1dfb4f5609979328b5f465a74307f45d46d24234868c67d44da96903b"}, - {file = "ckzg-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:70406b10acf68469ac62110047044a6c1a998f5d5fcd6e27cb3ec2d5760d0490"}, - {file = "ckzg-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2f53fba88febac17e82a96eb83dc38ecf4b28abcdd15c0246534c358bd3b26c4"}, - {file = "ckzg-2.0.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be8e0d5015e7755af4ddaab9ae1a4084f72c84b2cbb53628f4366aeed46cc380"}, - {file = "ckzg-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:261414121091042d29f28fc319d7c9a7f950f91f8bf54c010b581ee6a0499473"}, - {file = "ckzg-2.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:524e1e66edd2be2c38b660824aa7b5d4525b41b30ac029d80738a8eee491aeb5"}, - {file = "ckzg-2.0.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:4a12a1d8ef8f475d9f0af9a538e1674057e007806cb1204bb269ea00d9f8c1e5"}, - {file = "ckzg-2.0.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:4cc4bb5f62417a58065deeaf124e178cb1787ef3228e6032600d1e0a2775765b"}, - {file = "ckzg-2.0.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:e7b015f5615bcb82fa0d935481a209fc1dcd9308fb52fb1a7e5400108df67a94"}, - {file = "ckzg-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0518933ff3b9550f9dd60d833cdb74e8e97cc1cc58f0560b706916606dfd47d0"}, - {file = "ckzg-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ac0bca0795990076cde1930ecec307379b5303e34367c6e6e8a16bdba5a7ba5"}, - {file = "ckzg-2.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8086d23a41020ede312843bda7ea4ee0c9831265379027904106f99f2f8ed469"}, - {file = "ckzg-2.0.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31d1b141d41fa51aeac9440c936b812e885aef5719adfbd3a27550d8dc433997"}, - {file = "ckzg-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60a58e4d8cb91bad669ca111b7ccdd05c32de6787fdb571bb599625b043ad75b"}, - {file = "ckzg-2.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633e143385622d7a43fcb5c4f400ec5ec15df0b1c74ab7d6449a41a7abed24ad"}, - {file = "ckzg-2.0.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4876313614ea01f9a0039b5ca2c754340ba40aa8405f8756912d90ae55718011"}, - {file = "ckzg-2.0.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:19c86c8102200484074afac06b3946b457ba9915636de187f63854522be2e3bd"}, - {file = "ckzg-2.0.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:564abf27878f129781e1df4d33b1c4e264e5b25f89c1bdf95b7d6256e4bceb6c"}, - {file = "ckzg-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:bc2da29bb970d3f5de04fb60797dbb4490c010ffc683cbc6016349dd6fa60d14"}, - {file = "ckzg-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9c1869671140ae7e698520b678b594ebd26fb59ef476711403541597d7d32c01"}, - {file = "ckzg-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1dd2aec2c61e8cc2ec815900f6768c6fe74b8fd29810e79b57c4150c6db32fb6"}, - {file = "ckzg-2.0.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9632ef17285dbdd3fcd9780f599c266da736d9b2897decc4ea02ba8690bdf72"}, - {file = "ckzg-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5747d7926873e3af0f6af5fca666feb0097d06cab525950e2664a6fbcb90165d"}, - {file = "ckzg-2.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75484ffb78aaebaeb3a30f1194a9143b904312b0f365fc4101e58e1bf5f89f66"}, - {file = "ckzg-2.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b2f72bc861b8bee9bac3314c58586d1ab2d23530f932a8f0a8562c8a4a6a45f9"}, - {file = "ckzg-2.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6f85e5802fea5b77f52fc3a14c8dec18a3f2b7c7070c811a4608940834f563cc"}, - {file = "ckzg-2.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:583a0b6b531a16974676439b23e7defb3dfe9732f18d13d2316152019c538af1"}, - {file = "ckzg-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:fafb9ac36b3398f8091d40773d9a450e5f74883dad8ca4ee22d472e7a231ef4d"}, - {file = "ckzg-2.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a12e96f20dce35e5222f898a5c8355054ef7c5ee038eeb97dbb694640b57577b"}, - {file = "ckzg-2.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:4e0ebc55253addaa24dd2cd871bbe3b8f57855f32b5f74e70bf2cb76b6f7da54"}, - {file = "ckzg-2.0.1-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f917a7bf363a3735db30559e1ed63cf1ccf414234433ba687fa72c007abd756"}, - {file = "ckzg-2.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30f08c984286853271d4adae219e9ba87275a15047dbaa262ab8dd6c01be97b0"}, - {file = "ckzg-2.0.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fa1ea4888417e1f109fd5e57965788fb7f53b674329b937a65604a3c1ca1d03"}, - {file = "ckzg-2.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0b249914aeaf05cabc71c5c3797e3d6c126cb2c64192b7eb6755ef6aa5ab2f11"}, - {file = "ckzg-2.0.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a038e26baf650e1c733dcaa066ec948e75556b0c485e8c790c9a758875c71a93"}, - {file = "ckzg-2.0.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d6deb2c822122bdd32b555fa3b9216c86a355f24a2cc6a46b9b5743b412b60c"}, - {file = "ckzg-2.0.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50f6f2fbceba9ece3fbc1d2613a246f4e6ec4d787f542859e70c358928c0e4a1"}, - {file = "ckzg-2.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33ca40ef30129e2347bff3c95ad093403a0d5703476705ab92c92fbffe89bd5a"}, - {file = "ckzg-2.0.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:700b989c2f7089edc8fac6dfbd1b4677e85b966216ebedee8eb5e7894765c188"}, - {file = "ckzg-2.0.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f11933c007c3df02446a81957ac6e2488058b969e2eff5357c98ab569a0c7999"}, - {file = "ckzg-2.0.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:3dbc9580eccecbd485f22e48f6044c48cbe6d838a7b7514cce179c085c65a960"}, - {file = "ckzg-2.0.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad6eb83f343fea6dd9a13fd1bce87b9cd26abeeb72f0674a62d26e40fe0b8aca"}, - {file = "ckzg-2.0.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:269f82b992facbd20461310cf5784551c77d11017b7d4b85d741d70359be6794"}, - {file = "ckzg-2.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:895d67cfd43130652e1ae39b90465b392d9a72c7c7e6f250eaf14689bfda6351"}, - {file = "ckzg-2.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:369cf1aeaf336c31f2050a7f54ae21cf46f4b2db23ebb013fff621144ab361bb"}, - {file = "ckzg-2.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:24fda2637598a467e7b11ff664805ee7fdf4f6c7b0c043d6d0a6ccb69b5681ee"}, - {file = "ckzg-2.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea27baabe5b22b92901c428768eacf93b992ac7681f93768ab24818ad26ccfed"}, - {file = "ckzg-2.0.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a33f71e382020f2bc4ead2bd6881a9bd3811d929f272da239ac01ad615a00802"}, - {file = "ckzg-2.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:926507c569727bb4c851a1eea702c5e902267de96e06ce2d685019f973f72968"}, - {file = "ckzg-2.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f5f29518b0a4555d8f2a28559209bd1d4080547aa629ff9ee51799346573b3f"}, - {file = "ckzg-2.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4595db84ce63c227e4448de0f7b39d3043e3477d78394ff651708c37fee6c486"}, - {file = "ckzg-2.0.1.tar.gz", hash = "sha256:62c5adc381637affa7e1df465c57750b356a761b8a3164c3106589b02532b9c9"}, +files = [ + {file = "ckzg-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:458769e7dcdd041bf1d58c009863bde4f602089b0de62e49a2485b7e48e129b7"}, + {file = "ckzg-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8653a0f35d55ca292a73914c212b5d4614f24ec2e7eb66be9e709d6c108a0fbc"}, + {file = "ckzg-2.1.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71774f6e8412c34feffe9cce1e3a1221fc0bbd9c1f9acae7afb5ace4ac3f1240"}, + {file = "ckzg-2.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70531bdef698cca186c31e27a7c14d08f427573b9df372ce43f8ac8dad662dc7"}, + {file = "ckzg-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbe539177c79777041ffca8a2e3f429711cdce05fc2e9affafef6ba439b16b63"}, + {file = "ckzg-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2dd9afa57bbfd5ab750b20f7e3624a090bf9925b04d8857fe8aca61918611a82"}, + {file = "ckzg-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8bf9fd661620183089f6e2039f6382f60a6288f74e1fbb630cb2371b7006587f"}, + {file = "ckzg-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:27231f39e2c5a4adf5e1baad7ddeae3d63dbae2545e4912a74b614ee022402f4"}, + {file = "ckzg-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7430d3af61e11b06e185be7568c002671a64bdfb8f496d4c4b45974c5e980704"}, + {file = "ckzg-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab860ba475cd68e38fa8de5c1f07b1a46f658ef41ac9c7ba60ae5a4a638c14b8"}, + {file = "ckzg-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:76bd7d0c63f74cc904cc8aaed022cd893302717e136b12457383e8651d53ad6e"}, + {file = "ckzg-2.1.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e328dc6610f7e1f9714f138ecdb05bd32a783c2505e4444391b00e8c72130dc"}, + {file = "ckzg-2.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23f499f51c9df8fad422594210d3960628cab23bc8b48a61ac0646de1fb16945"}, + {file = "ckzg-2.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760208e97ac8800d9283858c23cab4502b008acca86003cbccab0b0953a36b9b"}, + {file = "ckzg-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:21a7996e9f04853b1ad218839b1ed664d53b61044e0005cc79a6fcd397da61fa"}, + {file = "ckzg-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7cd3fe081168d4aa428a71471c9e801fbfb574d7c457dd75389483b586c342e4"}, + {file = "ckzg-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d4ce9a235a089006c8a68f45cd9ca6c930eca2ceda0bc402e15a604eed9c834"}, + {file = "ckzg-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:ae06cc3788001cf4ef695ab26a6afa486bb800221d04d38d87dc9160409d889c"}, + {file = "ckzg-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0959685678e3b89d740412f6d7ae0821b74ccbeac04080cb066774ea3044e2e9"}, + {file = "ckzg-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1bc4c46b0d7db4dd88b55cbd40d13e193536dcd5ae5d0634f0d838de832f429e"}, + {file = "ckzg-2.1.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a4a3de4f0e264c6d1676379a8968aef681f14df7b1144b7a9ba391180f33510"}, + {file = "ckzg-2.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d9b35cf921a68769dce6fb64c34a7c331e6f7b8055bfbb8661e7163180f2742"}, + {file = "ckzg-2.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb1054189f4c6b83d19e2c1a65521485227eb3b63fa3211adccaa7c587befc2a"}, + {file = "ckzg-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cecfafea88ef8106440e22eb6db56bf702f30d3af8a3fb54b2a628a5c4e10056"}, + {file = "ckzg-2.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a13ce05bc4d2b93aa4f8aaecf9d785878f3d319a05e499268035a1ab1d464d52"}, + {file = "ckzg-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:516610ac84f414338b0c1dc41a423906503e34b6672450e50cf22a21a707e51f"}, + {file = "ckzg-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:e28a995e3b2923b05adb023412943dfd3b1aa1ca4e3a93d2149dcfbc15de639f"}, + {file = "ckzg-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:32204d9fc72711894f8ff588227b9268904da56151366bf5c21169c6ff6f2209"}, + {file = "ckzg-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:581d93c81029db42aaebe04982322646225a0a34f508f1a82612ba88fb2fd1b6"}, + {file = "ckzg-2.1.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9f4084b88ac67d041a03a6c7d9233f67cdfb1919e1e6d0cab40b388a4bb8244"}, + {file = "ckzg-2.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5367d66dcafb74444a65833b9e503d9b2454df0435ebf8a962b0def89def67c0"}, + {file = "ckzg-2.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae11520748b624d627b50653b8faae1cd44df7c99f512da7e0d16f78f5ada9aa"}, + {file = "ckzg-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52fca805e9c552be32257dd5d72ca815d3febefc63b025e95281b94251ba0b61"}, + {file = "ckzg-2.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0788882e8967807922435177cb76d573351c646ba86d834d56e8177e6c1de250"}, + {file = "ckzg-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e80e763346cccd018498687d7440d4c7adb7d8a36c5dd6444875b041817abe9b"}, + {file = "ckzg-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba123c5e2ec009d3e4b121eec1386e625be4a977ea6d6a019f87858b9ac45062"}, + {file = "ckzg-2.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:972dd1fcbe1ef1b4554ba4748081739d89871c76c8e96351cab087ea0e9e2ce7"}, + {file = "ckzg-2.1.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ff24a2ac8273e38ac886ffa4f799c825e8767da052c82367a2b0f092d6dc017"}, + {file = "ckzg-2.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:573dfb85500d8b0dc0aa4af9f86ba52cedf2263cc9c236b6d8af58d96a8aaff8"}, + {file = "ckzg-2.1.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f70462b8710b75c3c0633940df3d0b1828aee8476091fa8eb399d4e7f5f0fb3f"}, + {file = "ckzg-2.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:c157448257a9ddfeee866b712f4928fb56fb4667ef9be5827b321e295d05ff9c"}, + {file = "ckzg-2.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:1bc4e2b1fac373f01b78da9abcc03d18f90d0dd2b83ff9df6da2da80115fafdd"}, + {file = "ckzg-2.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:479c456e0391e6a7db8f02439aa2d1941fa1346fcaf20b58f520bb802afdb11a"}, + {file = "ckzg-2.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:70f149eaf805636055716f25cfb85338a75e51fb972518c244b28646327b6d1a"}, + {file = "ckzg-2.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ad024ea421d0cd0d7e80b704c87c8c38c0192d75cbcfd6afb7d76e00b9d3d3b8"}, + {file = "ckzg-2.1.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e30003042db2bcc99b1cf6feb0040a2b02d8ec6b43d55c454a9a16021c7bc2b2"}, + {file = "ckzg-2.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8107a011a0de57c6aa27a205d62105b0e45175089318b3c3ba275c2c9e0b0029"}, + {file = "ckzg-2.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8773010a7fed7571ebec86b7dfea4ba4deb2a8b21ab342973168e506cf66b66"}, + {file = "ckzg-2.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:be9f4b5d96595b1e49a12b76e9f90b32e7e37fe42fdfe411b280df9ba0af7272"}, + {file = "ckzg-2.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:8f2fde806a0492972f919619f904b6f2234331eae5bd4a4b2c273504274cdb39"}, + {file = "ckzg-2.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:fcc7b430901faeda167cb33eccd51d30a7e5c702bf185cbeef9712957f2ec916"}, + {file = "ckzg-2.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:366334929417a603e8de5002844d8fc0f67ee1286ac7cb8d78caa74f5766db04"}, + {file = "ckzg-2.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:80e4d572571abf867120867578a97d25c38af5cf00cbf5456606cbb20e0c5006"}, + {file = "ckzg-2.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f3dda26f3fb5594c202f80ca1079354765073f3d2fcbc63b708ff03c55393ceb"}, + {file = "ckzg-2.1.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6690a023d6fb91beb0ecf8b41bfcaabfda87b45fd628535da677c5c94d6febaf"}, + {file = "ckzg-2.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3bedbe094a986cc6a50eb8c4014b21052272754dc7a86b02aa7e6f5b9a12145"}, + {file = "ckzg-2.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b357414c0c09255328f27e3c3f8fcb913c4de799d6ec537643c88b8cd5df1cdf"}, + {file = "ckzg-2.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:75143bad46b1caabc2fba83c21d9f1679b089bfd5375fe7c8289a7d13fe1a2fc"}, + {file = "ckzg-2.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:2f04a7ad4aa8b10e2aed54a95a63866d1ad619d5aa6e73db40833d523706310b"}, + {file = "ckzg-2.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ce76923efacc8c519ce2d8709e6ce4df0707262dbede3d37ba0e0120b9ada368"}, + {file = "ckzg-2.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:90ea2e514b2af0de1a59550f9823e2616e5f63e16e6a310b7de2f5909945a544"}, + {file = "ckzg-2.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c4a92da6bbf19f8b4a6b6d9c949e72fca2f651529d0a323feeb45727ed046b7c"}, + {file = "ckzg-2.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:abd64e12e8bb774e14709f24be5d0e24355cb676038e7f02e8a13858eb527e4b"}, + {file = "ckzg-2.1.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c2f23cef9bf9990b08efe79ee72aac400b1016b22c42aaf69c93239bd663720"}, + {file = "ckzg-2.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1ed4395b0915acf0b86b35966b401b4f9cf30e0795d8d7997085443806c684"}, + {file = "ckzg-2.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dbd94d24ffcd1e2804c61993df7c752d23808fd43badf56f8b933daca130401"}, + {file = "ckzg-2.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:42c42ad84cb0110062906d60f6a32f67e706031d97c9afc79621c92eaf7df509"}, + {file = "ckzg-2.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f0d64df163c6ba1db52bcd12a70d32528522fd5429d093a634ea504495f0a602"}, + {file = "ckzg-2.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:61a03d7268130a5afb20ba588d4fee992c2d35cec1539892e6948d12aa025e33"}, + {file = "ckzg-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:f01c50be2397b89e54b4f5282a2b0160af9e49fdb08198cc5d94ad1c80afa97e"}, + {file = "ckzg-2.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ed0efa4e954c228252f9b3c74388a528fb5d3e137f7572ea55c16fd8f56a964f"}, + {file = "ckzg-2.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:95f12a25f358c0618548dd1ddeef6d70a27ee2ac1d399dacce5183c25d59b295"}, + {file = "ckzg-2.1.0-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35fff44368bf005651fcafe90a2f5d4865ad03291f21948d3acb107ebdd616cc"}, + {file = "ckzg-2.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:297e21e2a22c26d5011c0295ffdcb87ec51b43c7543e76361cdbca89f6e5a0fa"}, + {file = "ckzg-2.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee22d56169d506839412c826174f87b5713a17353cd6d712cfa9632fc56840e4"}, + {file = "ckzg-2.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d79f8737ae76344fc03b2d2c7048347720c7e005c020d95136f16d197c63a7bb"}, + {file = "ckzg-2.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:626b3d9c73f749474f43abebc508c7c10003dbd85719baac5fd12c7968737c3c"}, + {file = "ckzg-2.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:bdcd452dd267e5b77ab78499337d040db343b59b4aef0acb2166aaca47d93d26"}, + {file = "ckzg-2.1.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9951ec7eb20d9abbe464f993d594744f9ab6981f6487f60762854ca63568d2dd"}, + {file = "ckzg-2.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7b3f250044aa66d07c12cf76e054ef957c729a9605bd636cd3cb603cf7a159b"}, + {file = "ckzg-2.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c63f51ba05fc41d241389ac970015126c3ae992ad7b9056522dc2ac042bbbe3b"}, + {file = "ckzg-2.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:851fd1a2d60634dff8601b7bf953cfd0108adec63cd015168f72403c4688fbd6"}, + {file = "ckzg-2.1.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b35aa859efefa8974ef0c50c76705969e1ee17303b1ae0fccf1b035e51577292"}, + {file = "ckzg-2.1.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fab2f33916972b19ac7224c29091ec89f6b5997a10ce32b72097e30b8df6fdf"}, + {file = "ckzg-2.1.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f34073ea63d4a17ee22d36f7c5478b5d654df47d098fb557e5cdf2bb0c873367"}, + {file = "ckzg-2.1.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fe6ba7defd447c1a5d87303fe4a33bbb2242a91318ab1409f31582e71fac0ee"}, + {file = "ckzg-2.1.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:5ffb58e81630aaf23deaf7e640cebf794ccd4ab2fbb25ebfabbf5aad507c63d6"}, + {file = "ckzg-2.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:73cca48d5d56a18f2bc797757a98475f2a2d28256f58b6f97d0f156ca04b395e"}, + {file = "ckzg-2.1.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b84f655d40a4d05da0378134ac1f0fabcea311666babe53c695f19fdcab5e877"}, + {file = "ckzg-2.1.0-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24d9259a7042329c1b587ff76fe185e2701f81bba8ce01e74f67ef0a359e38c2"}, + {file = "ckzg-2.1.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a87db93fd1b7858207063c76e3ee7ee807cec348f7d0df7a3519364e659df441"}, + {file = "ckzg-2.1.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f6baa8e94f06a95e9ed2818c4d3c387b9146f02e1505039a014ff3e211ab42f"}, + {file = "ckzg-2.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:ed79be3a40f1964f0ec6295a971f7008a603f45e30907ca60fff7d94f1271da8"}, + {file = "ckzg-2.1.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:bf5d6633b0f512d898b9a4b67463b024ed9c37424e59bf2f788406914ab63a14"}, + {file = "ckzg-2.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:647d5bd3e8b03b918bd249b9e80647bbc70ad2864b921dc94c1247a04d50fd81"}, + {file = "ckzg-2.1.0-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c0b9fe5522130abebf7e271f0dce9f4522d3c3ccc77715795eacd0c4f3039d8"}, + {file = "ckzg-2.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47f85abda0807b423a4fc2cc0a05819508d91633f034c385428ebcc2bbaf60d7"}, + {file = "ckzg-2.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a7a543a65c57dc484f9ed86af2e6fb2105d16b72d7c4c33d15721edf1a88979"}, + {file = "ckzg-2.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe451513ec9eb9bb05e8918320a9f5c41e4bf232bc48907efb04795975998ec3"}, + {file = "ckzg-2.1.0.tar.gz", hash = "sha256:73a353f31c945f0617a6b98d82fbb23909ac5039a10e345c681b728dd917b51a"}, ] [[package]] @@ -916,8 +938,6 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -932,8 +952,6 @@ version = "20.0.0" description = "Cross-platform Python CFFI bindings for libsecp256k1" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "coincurve-20.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d559b22828638390118cae9372a1bb6f6594f5584c311deb1de6a83163a0919b"}, {file = "coincurve-20.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33d7f6ebd90fcc550f819f7f2cce2af525c342aac07f0ccda46ad8956ad9d99b"}, @@ -1000,8 +1018,6 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -1013,8 +1029,6 @@ version = "7.6.12" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8"}, {file = "coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879"}, @@ -1093,8 +1107,6 @@ version = "1.7" description = "CRC Generator" optional = false python-versions = "*" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e"}, ] @@ -1105,8 +1117,6 @@ version = "44.0.2" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.7" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"}, {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"}, @@ -1164,8 +1174,6 @@ version = "1.0.1" description = "Cython implementation of Toolz: High performance functional utilities" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "(python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\") and implementation_name == \"cpython\"" files = [ {file = "cytoolz-1.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cec9af61f71fc3853eb5dca3d42eb07d1f48a4599fa502cbe92adde85f74b042"}, {file = "cytoolz-1.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:140bbd649dbda01e91add7642149a5987a7c3ccc251f2263de894b89f50b6608"}, @@ -1281,8 +1289,6 @@ version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, @@ -1294,8 +1300,6 @@ version = "0.15" description = "On the fly conversion of Python docstrings to markdown" optional = false python-versions = ">=3.6" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "docstring-to-markdown-0.15.tar.gz", hash = "sha256:e146114d9c50c181b1d25505054a8d0f7a476837f0da2c19f07e06eaed52b73d"}, {file = "docstring_to_markdown-0.15-py3-none-any.whl", hash = "sha256:27afb3faedba81e34c33521c32bbd258d7fbb79eedf7d29bc4e81080e854aec0"}, @@ -1307,8 +1311,6 @@ version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, @@ -1316,15 +1318,13 @@ files = [ [[package]] name = "ecdsa" -version = "0.19.0" +version = "0.19.1" description = "ECDSA cryptographic signature library (pure python)" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.6" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" files = [ - {file = "ecdsa-0.19.0-py2.py3-none-any.whl", hash = "sha256:2cea9b88407fdac7bbeca0833b189e4c9c53f2ef1e1eaa29f6224dbc809b707a"}, - {file = "ecdsa-0.19.0.tar.gz", hash = "sha256:60eaad1199659900dd0af521ed462b793bbdf867432b3948e87416ae4caf6bf8"}, + {file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"}, + {file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"}, ] [package.dependencies] @@ -1340,8 +1340,6 @@ version = "1.4.1" description = "Ed25519 public-key signatures (BLAKE2b fork)" optional = false python-versions = "*" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "ed25519-blake2b-1.4.1.tar.gz", hash = "sha256:731e9f93cd1ac1a64649575f3519a99ffe0bb1e4cf7bf5f5f0be513a39df7363"}, ] @@ -1352,8 +1350,6 @@ version = "5.2.0" description = "eth_abi: Python utilities for working with Ethereum ABI definitions, especially encoding and decoding" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "eth_abi-5.2.0-py3-none-any.whl", hash = "sha256:17abe47560ad753f18054f5b3089fcb588f3e3a092136a416b6c1502cb7e8877"}, {file = "eth_abi-5.2.0.tar.gz", hash = "sha256:178703fa98c07d8eecd5ae569e7e8d159e493ebb6eeb534a8fe973fbc4e40ef0"}, @@ -1376,8 +1372,6 @@ version = "0.13.5" description = "eth-account: Sign Ethereum transactions and messages with local private keys" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "eth_account-0.13.5-py3-none-any.whl", hash = "sha256:e43fd30c9a7fabb882b50e8c4c41d4486d2f3478ad97c66bb18cfcc872fdbec8"}, {file = "eth_account-0.13.5.tar.gz", hash = "sha256:010c9ce5f3d2688106cf9bfeb711bb8eaf0154ea6f85325f54fecea85c2b3759"}, @@ -1406,8 +1400,6 @@ version = "0.7.1" description = "eth-hash: The Ethereum hashing function, keccak256, sometimes (erroneously) called sha3" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "eth_hash-0.7.1-py3-none-any.whl", hash = "sha256:0fb1add2adf99ef28883fd6228eb447ef519ea72933535ad1a0b28c6f65f868a"}, {file = "eth_hash-0.7.1.tar.gz", hash = "sha256:d2411a403a0b0a62e8247b4117932d900ffb4c8c64b15f92620547ca5ce46be5"}, @@ -1429,8 +1421,6 @@ version = "0.8.1" description = "eth-keyfile: A library for handling the encrypted keyfiles used to store ethereum private keys" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "eth_keyfile-0.8.1-py3-none-any.whl", hash = "sha256:65387378b82fe7e86d7cb9f8d98e6d639142661b2f6f490629da09fddbef6d64"}, {file = "eth_keyfile-0.8.1.tar.gz", hash = "sha256:9708bc31f386b52cca0969238ff35b1ac72bd7a7186f2a84b86110d3c973bec1"}, @@ -1452,8 +1442,6 @@ version = "0.6.1" description = "eth-keys: Common API for Ethereum key operations" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "eth_keys-0.6.1-py3-none-any.whl", hash = "sha256:7deae4cd56e862e099ec58b78176232b931c4ea5ecded2f50c7b1ccbc10c24cf"}, {file = "eth_keys-0.6.1.tar.gz", hash = "sha256:a43e263cbcabfd62fa769168efc6c27b1f5603040e4de22bb84d12567e4fd962"}, @@ -1475,8 +1463,6 @@ version = "2.2.0" description = "eth-rlp: RLP definitions for common Ethereum objects in Python" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "eth_rlp-2.2.0-py3-none-any.whl", hash = "sha256:5692d595a741fbaef1203db6a2fedffbd2506d31455a6ad378c8449ee5985c47"}, {file = "eth_rlp-2.2.0.tar.gz", hash = "sha256:5e4b2eb1b8213e303d6a232dfe35ab8c29e2d3051b86e8d359def80cd21db83d"}, @@ -1499,8 +1485,6 @@ version = "5.2.0" description = "eth-typing: Common type annotations for ethereum python packages" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "eth_typing-5.2.0-py3-none-any.whl", hash = "sha256:e1f424e97990fc3c6a1c05a7b0968caed4e20e9c99a4d5f4db3df418e25ddc80"}, {file = "eth_typing-5.2.0.tar.gz", hash = "sha256:28685f7e2270ea0d209b75bdef76d8ecef27703e1a16399f6929820d05071c28"}, @@ -1520,8 +1504,6 @@ version = "5.2.0" description = "eth-utils: Common utility functions for python code that interacts with Ethereum" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "eth_utils-5.2.0-py3-none-any.whl", hash = "sha256:4d43eeb6720e89a042ad5b28d4b2111630ae764f444b85cbafb708d7f076da10"}, {file = "eth_utils-5.2.0.tar.gz", hash = "sha256:17e474eb654df6e18f20797b22c6caabb77415a996b3ba0f3cc8df3437463134"}, @@ -1544,8 +1526,6 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -1560,8 +1540,6 @@ version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, @@ -1578,8 +1556,6 @@ version = "1.5.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a"}, {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb"}, @@ -1681,8 +1657,6 @@ version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, @@ -1694,8 +1668,6 @@ version = "1.3.0" description = "hexbytes: Python `bytes` subclass that decodes hex, with a readable console output" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "hexbytes-1.3.0-py3-none-any.whl", hash = "sha256:83720b529c6e15ed21627962938dc2dec9bb1010f17bbbd66bf1e6a8287d522c"}, {file = "hexbytes-1.3.0.tar.gz", hash = "sha256:4a61840c24b0909a6534350e2d28ee50159ca1c9e89ce275fd31c110312cf684"}, @@ -1712,8 +1684,6 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1728,8 +1698,6 @@ version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, @@ -1741,8 +1709,6 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -1754,8 +1720,6 @@ version = "0.19.2" description = "An autocompletion tool for Python that can be used for text editors." optional = false python-versions = ">=3.6" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, @@ -1775,8 +1739,6 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -1794,8 +1756,6 @@ version = "2023.0.1" description = "Python implementation of the Language Server Protocol." optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "lsprotocol-2023.0.1-py3-none-any.whl", hash = "sha256:c75223c9e4af2f24272b14c6375787438279369236cd568f596d4951052a60f2"}, {file = "lsprotocol-2023.0.1.tar.gz", hash = "sha256:cc5c15130d2403c18b734304339e51242d3018a05c4f7d0f198ad6e0cd21861d"}, @@ -1811,8 +1771,6 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -1837,8 +1795,6 @@ version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -1909,8 +1865,6 @@ version = "0.4.2" description = "Collection of plugins for markdown-it-py" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636"}, {file = "mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5"}, @@ -1930,8 +1884,6 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1943,8 +1895,6 @@ version = "6.1.0" description = "multidict implementation" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, @@ -2049,8 +1999,6 @@ version = "1.15.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, @@ -2104,8 +2052,6 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -2117,8 +2063,6 @@ version = "4.0.1" description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," optional = false python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d"}, {file = "myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4"}, @@ -2145,8 +2089,6 @@ version = "3.2.2" description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" optional = false python-versions = ">=3.6" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, @@ -2163,21 +2105,38 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] +[[package]] +name = "paramiko" +version = "3.5.1" +description = "SSH2 protocol library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61"}, + {file = "paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822"}, +] + +[package.dependencies] +bcrypt = ">=3.2" +cryptography = ">=3.3" +pynacl = ">=1.5" + +[package.extras] +all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +invoke = ["invoke (>=2.0)"] + [[package]] name = "parsimonious" version = "0.10.0" description = "(Soon to be) the fastest pure-Python PEG parser I could muster" optional = false python-versions = "*" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f"}, {file = "parsimonious-0.10.0.tar.gz", hash = "sha256:8281600da180ec8ae35427a4ab4f7b82bfec1e3d1e52f80cb60ea82b9512501c"}, @@ -2192,8 +2151,6 @@ version = "0.8.4" description = "A Python Parser" optional = false python-versions = ">=3.6" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, @@ -2203,14 +2160,100 @@ files = [ qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] testing = ["docopt", "pytest"] +[[package]] +name = "pillow" +version = "11.1.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pillow-11.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:e1abe69aca89514737465752b4bcaf8016de61b3be1397a8fc260ba33321b3a8"}, + {file = "pillow-11.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c640e5a06869c75994624551f45e5506e4256562ead981cce820d5ab39ae2192"}, + {file = "pillow-11.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a07dba04c5e22824816b2615ad7a7484432d7f540e6fa86af60d2de57b0fcee2"}, + {file = "pillow-11.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e267b0ed063341f3e60acd25c05200df4193e15a4a5807075cd71225a2386e26"}, + {file = "pillow-11.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bd165131fd51697e22421d0e467997ad31621b74bfc0b75956608cb2906dda07"}, + {file = "pillow-11.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:abc56501c3fd148d60659aae0af6ddc149660469082859fa7b066a298bde9482"}, + {file = "pillow-11.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:54ce1c9a16a9561b6d6d8cb30089ab1e5eb66918cb47d457bd996ef34182922e"}, + {file = "pillow-11.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73ddde795ee9b06257dac5ad42fcb07f3b9b813f8c1f7f870f402f4dc54b5269"}, + {file = "pillow-11.1.0-cp310-cp310-win32.whl", hash = "sha256:3a5fe20a7b66e8135d7fd617b13272626a28278d0e578c98720d9ba4b2439d49"}, + {file = "pillow-11.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:b6123aa4a59d75f06e9dd3dac5bf8bc9aa383121bb3dd9a7a612e05eabc9961a"}, + {file = "pillow-11.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:a76da0a31da6fcae4210aa94fd779c65c75786bc9af06289cd1c184451ef7a65"}, + {file = "pillow-11.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e06695e0326d05b06833b40b7ef477e475d0b1ba3a6d27da1bb48c23209bf457"}, + {file = "pillow-11.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96f82000e12f23e4f29346e42702b6ed9a2f2fea34a740dd5ffffcc8c539eb35"}, + {file = "pillow-11.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3cd561ded2cf2bbae44d4605837221b987c216cff94f49dfeed63488bb228d2"}, + {file = "pillow-11.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f189805c8be5ca5add39e6f899e6ce2ed824e65fb45f3c28cb2841911da19070"}, + {file = "pillow-11.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:dd0052e9db3474df30433f83a71b9b23bd9e4ef1de13d92df21a52c0303b8ab6"}, + {file = "pillow-11.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:837060a8599b8f5d402e97197d4924f05a2e0d68756998345c829c33186217b1"}, + {file = "pillow-11.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aa8dd43daa836b9a8128dbe7d923423e5ad86f50a7a14dc688194b7be5c0dea2"}, + {file = "pillow-11.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0a2f91f8a8b367e7a57c6e91cd25af510168091fb89ec5146003e424e1558a96"}, + {file = "pillow-11.1.0-cp311-cp311-win32.whl", hash = "sha256:c12fc111ef090845de2bb15009372175d76ac99969bdf31e2ce9b42e4b8cd88f"}, + {file = "pillow-11.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fbd43429d0d7ed6533b25fc993861b8fd512c42d04514a0dd6337fb3ccf22761"}, + {file = "pillow-11.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:f7955ecf5609dee9442cbface754f2c6e541d9e6eda87fad7f7a989b0bdb9d71"}, + {file = "pillow-11.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2062ffb1d36544d42fcaa277b069c88b01bb7298f4efa06731a7fd6cc290b81a"}, + {file = "pillow-11.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a85b653980faad27e88b141348707ceeef8a1186f75ecc600c395dcac19f385b"}, + {file = "pillow-11.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9409c080586d1f683df3f184f20e36fb647f2e0bc3988094d4fd8c9f4eb1b3b3"}, + {file = "pillow-11.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fdadc077553621911f27ce206ffcbec7d3f8d7b50e0da39f10997e8e2bb7f6a"}, + {file = "pillow-11.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:93a18841d09bcdd774dcdc308e4537e1f867b3dec059c131fde0327899734aa1"}, + {file = "pillow-11.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9aa9aeddeed452b2f616ff5507459e7bab436916ccb10961c4a382cd3e03f47f"}, + {file = "pillow-11.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3cdcdb0b896e981678eee140d882b70092dac83ac1cdf6b3a60e2216a73f2b91"}, + {file = "pillow-11.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36ba10b9cb413e7c7dfa3e189aba252deee0602c86c309799da5a74009ac7a1c"}, + {file = "pillow-11.1.0-cp312-cp312-win32.whl", hash = "sha256:cfd5cd998c2e36a862d0e27b2df63237e67273f2fc78f47445b14e73a810e7e6"}, + {file = "pillow-11.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a697cd8ba0383bba3d2d3ada02b34ed268cb548b369943cd349007730c92bddf"}, + {file = "pillow-11.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:4dd43a78897793f60766563969442020e90eb7847463eca901e41ba186a7d4a5"}, + {file = "pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc"}, + {file = "pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0"}, + {file = "pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1"}, + {file = "pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec"}, + {file = "pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5"}, + {file = "pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114"}, + {file = "pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352"}, + {file = "pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3"}, + {file = "pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9"}, + {file = "pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c"}, + {file = "pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65"}, + {file = "pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861"}, + {file = "pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081"}, + {file = "pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c"}, + {file = "pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547"}, + {file = "pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab"}, + {file = "pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9"}, + {file = "pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe"}, + {file = "pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756"}, + {file = "pillow-11.1.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:bf902d7413c82a1bfa08b06a070876132a5ae6b2388e2712aab3a7cbc02205c6"}, + {file = "pillow-11.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c1eec9d950b6fe688edee07138993e54ee4ae634c51443cfb7c1e7613322718e"}, + {file = "pillow-11.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e275ee4cb11c262bd108ab2081f750db2a1c0b8c12c1897f27b160c8bd57bbc"}, + {file = "pillow-11.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4db853948ce4e718f2fc775b75c37ba2efb6aaea41a1a5fc57f0af59eee774b2"}, + {file = "pillow-11.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:ab8a209b8485d3db694fa97a896d96dd6533d63c22829043fd9de627060beade"}, + {file = "pillow-11.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:54251ef02a2309b5eec99d151ebf5c9904b77976c8abdcbce7891ed22df53884"}, + {file = "pillow-11.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5bb94705aea800051a743aa4874bb1397d4695fb0583ba5e425ee0328757f196"}, + {file = "pillow-11.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89dbdb3e6e9594d512780a5a1c42801879628b38e3efc7038094430844e271d8"}, + {file = "pillow-11.1.0-cp39-cp39-win32.whl", hash = "sha256:e5449ca63da169a2e6068dd0e2fcc8d91f9558aba89ff6d02121ca8ab11e79e5"}, + {file = "pillow-11.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3362c6ca227e65c54bf71a5f88b3d4565ff1bcbc63ae72c34b07bbb1cc59a43f"}, + {file = "pillow-11.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:b20be51b37a75cc54c2c55def3fa2c65bb94ba859dde241cd0a4fd302de5ae0a"}, + {file = "pillow-11.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8c730dc3a83e5ac137fbc92dfcfe1511ce3b2b5d7578315b63dbbb76f7f51d90"}, + {file = "pillow-11.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7d33d2fae0e8b170b6a6c57400e077412240f6f5bb2a342cf1ee512a787942bb"}, + {file = "pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8d65b38173085f24bc07f8b6c505cbb7418009fa1a1fcb111b1f4961814a442"}, + {file = "pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:015c6e863faa4779251436db398ae75051469f7c903b043a48f078e437656f83"}, + {file = "pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d44ff19eea13ae4acdaaab0179fa68c0c6f2f45d66a4d8ec1eda7d6cecbcc15f"}, + {file = "pillow-11.1.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d3d8da4a631471dfaf94c10c85f5277b1f8e42ac42bade1ac67da4b4a7359b73"}, + {file = "pillow-11.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4637b88343166249fe8aa94e7c4a62a180c4b3898283bb5d3d2fd5fe10d8e4e0"}, + {file = "pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions"] +xmp = ["defusedxml"] + [[package]] name = "platformdirs" version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, @@ -2227,8 +2270,6 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -2244,8 +2285,6 @@ version = "0.9.1" description = "A collection of helpful Python tools!" optional = false python-versions = "*" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pockets-0.9.1-py2.py3-none-any.whl", hash = "sha256:68597934193c08a08eb2bf6a1d85593f627c22f9b065cc727a4f03f669d96d86"}, {file = "pockets-0.9.1.tar.gz", hash = "sha256:9320f1a3c6f7a9133fe3b571f283bcf3353cd70249025ae8d618e40e9f7e92b3"}, @@ -2260,8 +2299,6 @@ version = "3.0.50" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8.0" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198"}, {file = "prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab"}, @@ -2276,8 +2313,6 @@ version = "0.3.0" description = "Accelerated property cache" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "propcache-0.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:efa44f64c37cc30c9f05932c740a8b40ce359f51882c70883cc95feac842da4d"}, {file = "propcache-0.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2383a17385d9800b6eb5855c2f05ee550f803878f344f58b6e194de08b96352c"}, @@ -2381,118 +2416,123 @@ files = [ [[package]] name = "py-sr25519-bindings" -version = "0.2.1" +version = "0.2.2" description = "Python bindings for schnorrkel RUST crate" optional = false python-versions = ">=3.7" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" -files = [ - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10489c399768dc4ac91c90a6c8da60aeb77a48b21a81944244d41b0d4c4be2f"}, - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8358a7b3048765008a79733447dfdcafdce3f66859c98634055fee6868252e12"}, - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:202af5a516614907ddaef073104ae6d0a98ec96743d11cb87faa09d2b235a6b4"}, - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0b0d977c9ba6063d7807dda84264f10b1951736ba528b4d4078e5c9989051b1"}, - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e6c46cbbb87eb9db3c7deebd71c296d67c0725d9379ee737255e22c15c64bae"}, - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9368e9ca0bc1c967db0dd5cfc401f23d364064e99a48d21ea12a068612ccce7e"}, - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9f1ade92569b0281ff24476bd93333865370d86746b2d7949545f1ca70ac4e14"}, - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7286da1662afc300038441620092a0ae527430f7c50b0768e826d46893dd5095"}, - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1afbf451ecb78d5a1fa3be0f1cafb914aa2d4464ce15374bbff495cc384b1947"}, - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:873c0ec12fed805f4086e36ebbb673c95af09e4007ea66d5a9bbd2cc29dfa076"}, - {file = "py_sr25519_bindings-0.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5917f8584cf6a81e32f03547d9fbd8c783db2372d49bd9ff8c5c57d969ea1039"}, - {file = "py_sr25519_bindings-0.2.1-cp310-none-win32.whl", hash = "sha256:09f184393e01d0d2b62d3782a6d18dd0824a225444e0171c08e03f8cf3920e7b"}, - {file = "py_sr25519_bindings-0.2.1-cp310-none-win_amd64.whl", hash = "sha256:2d548a8ea057c6f150572059475761101ba8ef15e3b349d2d0cb108652f6aaf8"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4941e6e0e180f7e72565043ed3ba7190455c9feaa2ab9ee6038904f2b4bb6c5b"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b63d7cf5bb4d9b986d7f7012c80b92be70311dc9b75862f7880e03b71a29543d"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6752bf3b109446d99f3a368e3ba805812fc5bc09e52ef1c82f5a47e43b19973"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0368dcdf5ec8d2bb9c13273c78c3c5b033211d37a70a2f1d2080f29a7d118340"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2618b02b4a3babac07b8bb61fe9550f911f038bb079665682ca76b2e664e5258"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab1bc4dc524efefaecf3a85f4a0ff05c1ca9509d4d64056199984550f3c98b3"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ccdc89d5e3ae0dd163c8150ec76b6bb3291c1cec9746eb79e9544b3423f35f9"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ae6545c414cfa5d7207c9c77aaa576bb374982fb2105a7a9c2764afa5621f6d4"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7046774e39e0166d3c12632969c9d1713e6ad9ca8206bbe82923ba6935b0a01f"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cba9a8821176895b080ea761e5ab9cd8727660bf401478a6532a30ae3429573d"}, - {file = "py_sr25519_bindings-0.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c31aba05819e5b6b26746dc1b078cf680bd471f135c55e376e95c7774e22e936"}, - {file = "py_sr25519_bindings-0.2.1-cp311-none-win32.whl", hash = "sha256:d4bfb9c9a5c46563ccf12e74862ee95d2961556ba7aca62c9e4d6e4f7c37b4e0"}, - {file = "py_sr25519_bindings-0.2.1-cp311-none-win_amd64.whl", hash = "sha256:4f0d5c065d5e6122e53e771035aa335534363b451358b408d211df1c46773617"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:01ef73c0b3d3f703b54ee69c0f5ff4aa54b4233212c466fd497c7a84d170963a"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7ce8ac85e5ea82825a863f3f6f071e5ead610d7675820eb8ffe772267445ec0b"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f59ac8c03c8ef819db063627f4a8247aab0db11d88b21562abbe371612cf66ab"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2c11fc77b57308e3ada9a40e7c343027129b582d3091ebd992c99b1832ac8c1"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92af2831d6896f0b3fef792d1f2da780fabf6c78dac12535b394cbdb51c0d257"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc99f7f310b7641e510810c1d6a6b51792ab2ccefac3ab288445a9fcbc9a8265"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1dc4995a352a6e5851a41cb0ea37d8c9083d173515b7fd2f381b014f57dc1cda"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f103dc5c420057c4447bd6ebf28b2b68ff3ab8da85a5f7ff39c405293de80c78"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:902ee675497b8d356a2abe2abc4278cd76c503f76d06ef2bcd797c1df59e84b7"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5dd9748f4bd9a3bc4d5c1245f6edcc723075b1470b4c36add4474df4c53604e8"}, - {file = "py_sr25519_bindings-0.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c24bc55699d12948571969c26e65138a942bdaca062171288c40c44b9a4f266"}, - {file = "py_sr25519_bindings-0.2.1-cp312-none-win32.whl", hash = "sha256:d4799c9a8f280abdfe564d397bad45da380275c8d22604e059bd7b3d5af404b5"}, - {file = "py_sr25519_bindings-0.2.1-cp312-none-win_amd64.whl", hash = "sha256:0746befd71d1766d8747910cfeb2cec2be2c859c3b3618eda1dc3cb4a1b85175"}, - {file = "py_sr25519_bindings-0.2.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfb80d71c010654638873e594e348a0add78dba66d089ef07d02998712744e80"}, - {file = "py_sr25519_bindings-0.2.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:761e48147e3b1e65b9c5ed3f547e600126f02d6b8e99aa99eb8faeb2c69166c2"}, - {file = "py_sr25519_bindings-0.2.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a14ce5fa0759710d45848cc98b49a10f7db3f1002726b61c57b9cdaf91c2f5f"}, - {file = "py_sr25519_bindings-0.2.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d731da6f49ee67dcd90ed25a393f9027e7a0caece837b1a66ffba10e63861356"}, - {file = "py_sr25519_bindings-0.2.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:507ae0d8894307642056f99df4acf2da9fe11153fd6e9d9e255d1d05db1b348e"}, - {file = "py_sr25519_bindings-0.2.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:a3dad5382f60696161e91d6dd2d9381e9de61af1bf5699084939780d86115e12"}, - {file = "py_sr25519_bindings-0.2.1-cp37-cp37m-musllinux_1_2_armv7l.whl", hash = "sha256:f913f2ddb478232a7a716603b47d276a4ab29230a4d3e87406523a0f1ae5c191"}, - {file = "py_sr25519_bindings-0.2.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:4844e2999a1d0ac5e9a166a2cc3557aedce6144b886bc9efd7b3f2e081feca97"}, - {file = "py_sr25519_bindings-0.2.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:f81ad03ff65d92c9a6deba451c922204af97dbc9a0d0680a91495ad523944929"}, - {file = "py_sr25519_bindings-0.2.1-cp37-none-win32.whl", hash = "sha256:87ac70b8424b91ba5a446a6e6dcf33d55eb4acadf1cec393294ec740d26aa7c6"}, - {file = "py_sr25519_bindings-0.2.1-cp37-none-win_amd64.whl", hash = "sha256:593b639e25a6d334a25c4b51ab2eeb80f13d510433a42abf5f2302876e637435"}, - {file = "py_sr25519_bindings-0.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5059645a99bcb77a8cadacd1a5b01dc3041b3f684595e47669a484dc6e862bc1"}, - {file = "py_sr25519_bindings-0.2.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ce9bef8a02542a1f80560137a67e011f74e0cd77b168214d2e564225f73aa01"}, - {file = "py_sr25519_bindings-0.2.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:007e6b548bfbf4bf4d0daa30784c7e03935bf47081cc9a3095cf52712ae64c72"}, - {file = "py_sr25519_bindings-0.2.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5b65131461d87062c75f2076a2c99aea4072e4886275e87e8616b3433e5c456"}, - {file = "py_sr25519_bindings-0.2.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:db721395bb9c7d61392ab3957781450cba281b814c94f1888bb576891d3016d1"}, - {file = "py_sr25519_bindings-0.2.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b590397aaf2f222a5768f0b74bf08315ef105bc70c50f9bf5f3e6b97458d772d"}, - {file = "py_sr25519_bindings-0.2.1-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:a5b43cdf722f40f042ed05607bca7032055df4cdc413f52746e972ec393aa82f"}, - {file = "py_sr25519_bindings-0.2.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:46033ed3fe67ad11fa0f46f19483175a83185a02af6eb93d7391e81b3219c5a8"}, - {file = "py_sr25519_bindings-0.2.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4e3c1d51ae59b1bf295f1c5af21adc1acab60a7a018e081873f124456492db88"}, - {file = "py_sr25519_bindings-0.2.1-cp38-none-win32.whl", hash = "sha256:6b34f32efccb5a26c14f4ec1666f2821760981a709e04a486357bc0a152f5d94"}, - {file = "py_sr25519_bindings-0.2.1-cp38-none-win_amd64.whl", hash = "sha256:9ab1d3c8c3458a74217b849ffed3e03c98e746d488c9cf9b773f55ad8d3031ad"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:89014247bb398acf99e508a0eff7b1dee8cea4b1d441ceeee8de275b1944812f"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3ce7c463b73864909391bfad078b1c88ebbc1eb84f58336c605cbcaf3cecd2f"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a2c28840138ba0a4e6f8c6953821cbd1d80d2e52404ff9722030a22d26addd"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0cea045676c3c482423232d19b6aac2458925416fcceec0a37c938f8bc9c00d"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cedbcc9779630c7cd364a66e686aa5c2ad0dd81fbb95edb689a6f63eb3323d6"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d064e6154554e18f3c40349c7df01297d812da5f6c4bcb825fa9f4fe2dd402d"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6156c50f92b705d89f82b0dcb51eb0eaf0f22fba9fa51648a5e0c8274b0e0502"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a915deadf311592c9d7dc6cf6b0550830aeb08c5029cb06e882c32dcb560125b"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:8b56ceec5f83dd9c4b809f3be3ef4262d1e833d1ed8f16d7d8283fb2c5ae1a75"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:73948c2b022287ff478a276b725a98a3bea34920cfe0edbedc0154f9a6125061"}, - {file = "py_sr25519_bindings-0.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8bc937794b947b9da2f20fa0d8f5002d20d2bfc2656a21ef834e1af2d3fdca4"}, - {file = "py_sr25519_bindings-0.2.1-cp39-none-win32.whl", hash = "sha256:d27b882546d5ad78f71c1ec48033267a0dd812fb1583881c39a75b3180a7e80b"}, - {file = "py_sr25519_bindings-0.2.1-cp39-none-win_amd64.whl", hash = "sha256:5ad0d7b14339452072773bae6d4570684895658a046279bebd3410941846ea65"}, - {file = "py_sr25519_bindings-0.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50f8b34fed2c98814dcd414379ef43bf63cd4c05d7d90b83c590cca60fe804d6"}, - {file = "py_sr25519_bindings-0.2.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:141b0f8fb99cb249984f7c9ec67dd1768aae4d137d47ea0eca027d669503e132"}, - {file = "py_sr25519_bindings-0.2.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45cfef18bdfde67d445650a388bfafecbd1844a64c19087e9e4267548998c100"}, - {file = "py_sr25519_bindings-0.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:639410c0258a543bb84b0518616af724716737054ac5c78daa4d956d17841b17"}, - {file = "py_sr25519_bindings-0.2.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a98e5a395445046f37fc4e365556ce06fa344e3b711de0564ac3fd2b351a1b3e"}, - {file = "py_sr25519_bindings-0.2.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:7935b79a91aa72db42b5015117018554980c320256e63bc930b8bd148a0765a4"}, - {file = "py_sr25519_bindings-0.2.1-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:cedf5d0669c23ddab8804982f665c7e99b13e8452db78128f231217b8528c31a"}, - {file = "py_sr25519_bindings-0.2.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:9ea24db07992f756409729adad1e3ec9aa0a9d4fece5da90768a56ac1563f0f4"}, - {file = "py_sr25519_bindings-0.2.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e8b7e42cd4a5177dd83bbcdef77591fd72d3da02616545011ebcdd872f8cc39d"}, - {file = "py_sr25519_bindings-0.2.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0db53685c6282b29d118ccac3bdaad257723494c07c38dc9a4f31027dc41885"}, - {file = "py_sr25519_bindings-0.2.1-pp37-pypy37_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6e42e6edca45b9f116c97068416eb96c6606498289c056731dc08b645592b1ec"}, - {file = "py_sr25519_bindings-0.2.1-pp37-pypy37_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:927d773693d41f6fb9644148649d78875ac27d21dcfd3436502d68c5cc6b0f30"}, - {file = "py_sr25519_bindings-0.2.1-pp37-pypy37_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d169fd6a803a80c3554562c38894d942da8a408a43685b723bcd3a79ce884ee4"}, - {file = "py_sr25519_bindings-0.2.1-pp37-pypy37_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:37f48ba05b3306b2aa9b97b6b91361c83467ce8b77348c2ecb28090fae193d6d"}, - {file = "py_sr25519_bindings-0.2.1-pp37-pypy37_pp73-musllinux_1_2_i686.whl", hash = "sha256:9c9385e98e166cb293dda2a0691b511d770a5ffa0d7fe8495fd558387cbe06fe"}, - {file = "py_sr25519_bindings-0.2.1-pp37-pypy37_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:0855eb9ad70f3673d88e25cae4d799aff0e9fcfb6cbd24a8a41e1c7915f5f5a8"}, - {file = "py_sr25519_bindings-0.2.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1662dc8161fbb4e5220a89e8f4fd42a1ce5d71471e5d5a9398ed07ced12d2dc"}, - {file = "py_sr25519_bindings-0.2.1-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c3bca18a20ea6f12f662f4a38e8132f952f3ec77e4a1e0b4654a5fc0aeb54eb"}, - {file = "py_sr25519_bindings-0.2.1-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b857902b2c74269ae4fb9d1ac915993bbd55291351f6b8bb2bb6a08b5631bb5"}, - {file = "py_sr25519_bindings-0.2.1-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:04f44054b3244e13c1f9440b616251ff200fe679ce7d934783f214065a22f78e"}, - {file = "py_sr25519_bindings-0.2.1-pp38-pypy38_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:7f8e2c3e0ecb086648c64274a98d6663bece7aaafbee8b7e229fc3f024d4ffb5"}, - {file = "py_sr25519_bindings-0.2.1-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:23edd08e0866b5ccf9fdbafa7e6a0646070b37ad6869723252136a2c47b5b5fc"}, - {file = "py_sr25519_bindings-0.2.1-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5bfd4d91329889109d8d5cbd22fa4138e778cb7522704f45f451b23a5573b1aa"}, - {file = "py_sr25519_bindings-0.2.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:858b80041b18fdde666427ec9843303931ab2184cdf698285e8d34f3f6c4fad0"}, - {file = "py_sr25519_bindings-0.2.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9a848030227b8099c26c4f38b35fbae55cb78e0d3fab69804bf220e60a85455"}, - {file = "py_sr25519_bindings-0.2.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de7afdcc714fd83fc3636b9cea6c2ef6515e59f97410e73210276c3e0e64a28b"}, - {file = "py_sr25519_bindings-0.2.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:9333d891f1305f686f6ef4b9aef204df3090d037056e9f6e1276165c29ef70c2"}, - {file = "py_sr25519_bindings-0.2.1-pp39-pypy39_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:970e0635104f2d5e771de3b8863eb0f7d04617c164d49d17e02ecc60c3a97182"}, - {file = "py_sr25519_bindings-0.2.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:995e2c49dd0df3adb7907b2dc5a30d4df64160023205d89256b88a956c64637c"}, - {file = "py_sr25519_bindings-0.2.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:4177bf68b73080ca0a21cf1231877dbec4f4485ee22bc97b7d447a0e29fe9c30"}, - {file = "py_sr25519_bindings-0.2.1.tar.gz", hash = "sha256:1b96d3dde43adcf86ab427a9fd72b2c6291dca36eb40747df631588c16f01c1a"}, +files = [ + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd29a2ee1dfa55a3e17cf18fe0fa5f5749e0c49c9bd9c423a46e1cc242225447"}, + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58702966f1547e47bfbf70924eef881087bff969e2dca15953cdc95cb2abb4a2"}, + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b5b2cdf08be144f395508acebd5fa41c81dbee1364de75ff80fe0f1308fd969"}, + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77f7f4b323c628242909228eaac78bf6376b39b8988704e7910e858c2017b487"}, + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:58b1e28fc1c57f69d37556b8f3c51cdd84446f643729789b6c0ce19ce2726bd5"}, + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:512185a3be65a893208e9c30d11948c8b405532756f3bcab16d1dbe5d8e3355e"}, + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3e5bf9343f3708cfdf5228dbb1b8a093c64144bb9c4bd02cfb014fb2241dd965"}, + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e9ac26fd6b8606796fcaee90fe277820efbe168490659d26295fd0fc7b37ee4a"}, + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:297e29518a01a24b943333fc9fe5e7102cb7083f2d256270f42476bcf5ba666d"}, + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-win32.whl", hash = "sha256:f1ab3c36d94dec25767e2a54a2fb0eb320fc0c3e1d7ea573288b961d432672ef"}, + {file = "py_sr25519_bindings-0.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:cc40a53600e68848658cf6046cd43ef2ec9f0c8c04ebf8ea3636dd58c1c25296"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c0931d8fd07e13131e652f3211c1f1c12b7a5153bed9217e4483b195515c76f"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:09657937b8f04c034622691c4753fcef0b3857939dbeff72590b7f5de336302d"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ca700354e8cc3d082426ca5cdc7dd34a05988adec4edc0cd42d31c4ba16fbc0"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddb490e99d5646ba68f5308fed1b92efbc85470b1171a2b78e555b44a7073570"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8a479a7510f30d2912f552335cb83d321c0be83832a71cd0bcd190f6356a7bf"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab25059d290753202f160bb8a4fd3c789ab9663381ca564338015fd3b7625dde"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:315382f207430143cd748f805f13bf56f36fc66726303b491cd38ce78d8972e9"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1a65275421f30e3d563c6f3dec552060f1f85b7840ab8ecf1d48ced008d0ba5f"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1350c85bdc903105d8fdc7dd369b802bf2821c321fea8aa0929f7a7063437d81"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3a34fa18885345a0102c3ffbaa17a32cd67d28a60376158508d5ed7f96a478f7"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c63b0966b45870f0b1dfc2a366f1763f4a165f3aec2b02e7464cfb2c6ca09e94"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-win32.whl", hash = "sha256:7bf982a7d34f6eb0c7c42b7f59610a527e9b02654079fb78d7eb757c6bd79d9d"}, + {file = "py_sr25519_bindings-0.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:d9ee79ec4e722993da24385a8eb85d97878ef67d48d0e706c098c626d798c7bc"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f22542738ed98fac0d3da2479dd3f26c695594800877a4d8bb116c47e4fd4b7c"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b312b8ac7c8354d5cf1b9aad993bbafbd99cc97b6d246f246e76814f576ed809"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c70ff898fa46f380a535c843e3a1a9824d1849216067bbf28eb9ad225b92f0bb"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:292be23ad53d9f9dbf1703a2a341005629a8f93c57cfad254c8c1230ec7d3fe3"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:673b31e8f59bc1478814b011921073f8ad4e2c78a1d6580b3ddb1a9d7edc4392"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849f77ab12210e8549e58d444e9199d9aba83a988e99ca8bef04dd53e81f9561"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf8c1d329275c41836aee5f8789ab14100dbdc2b6f3a0210fac2abb0f7507c24"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:48f053c5e8cb66125057b25223ef5ff57bb4383a82871d47089397317c5fd792"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fea3ce0ac6a26a52735bb48f8daafb82d17147f776bb6d9d3c330bd2ccffe20d"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f44a0a9cb155af6408e3f73833a935abc98934ce097b2ad07dd13e3a88f82cb8"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cc531500823ece8d6889082642e9ea06f2eaffd0ed43d65871cb4727429027c"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-win32.whl", hash = "sha256:840c3ec1fc8dde12421369afa9761943efe377a7bd55a97524587e8b5a6546c2"}, + {file = "py_sr25519_bindings-0.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:c3ee5fd07b2974ce147ac7546b18729d2eb4efebe8eaad178690aaca656487f3"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3bb2c5fba39a82880c43b0d75e87f4d4a2416717c5fa2122b22e02689c2120e3"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1393798a36f74482c53c254969ae8d92f6549767ef69575206eaaf629cbf2a64"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29b9ee2e2f8f36676fa2a72af5bdfe257d331b3d83e5a92b45bad2f25a5b975c"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e932c33f6b660319c950c300c32ad2c0ba9642743a2e709a2fb886d32c28baf"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1fce13a3434c57af097b8b07b69e3821b1f10623754204112c14bd544bd961c1"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16501bd5b9a37623dbf48aa6b197c57c004f9125e190450e041289a8c3eceac7"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:beb12471fb76be707fc9213d39e5be4cf4add7e38e08bc1fbf7e786250977e00"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:55134f0ba34c27fbb8b489a338c6cb6a31465813f615ed93afbd67e844ef3aed"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:785521c868738a2345e3625ad9166ede228f63e9d3f0c7ff8e35f49d636bce04"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c8cab5620a4ef4cc69a314c9e9ac17af1c0d4d11e297fcefe5d71d827fd7ee21"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15ae6f86f112c6b23d357b5a98a6cb493f5c2734fabff354a8198be9dea0e90e"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-win32.whl", hash = "sha256:cba9efa48f48bf56e73a528005978b6f05cb2c847e21eb9645bbc6581619482f"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:9cdb4e0f231fd5824f73361a37a102871866d29752f96d88b1da958f1e5ff2d4"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1d436db7f48dabd4201bb1a88c66a6a3cd15a40e89a236ec1b8cb60037dc1a9"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a9b8c9a81f90dc330eabbdc3ec5f9fdf84a34cd37a1e660cbf5c5daec7b2d08f"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f496da3eb2d843bd12ccff871d22d086b08cfe95852ca91dcdbd91e350aca8d"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:862fa69f948cb3028051a71ce0d2d88cbe8b52723c782f0972d12f5f85a25637"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:1111597744d7993ce732f785e97e0d2e4f9554509d90ba4b0e99829dbf1c2e6d"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c4518b553335f70f18b8167eb2b7f533a66eb703f251d4d4b36c4a03d14cd75e"}, + {file = "py_sr25519_bindings-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c917a8f365450be06e051f8d8671c182057cdda42bd5f6883c5f537a2bac4f5a"}, + {file = "py_sr25519_bindings-0.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b8b2666d381416fb07336c6596a5554dd0e0f1ec50ff32bcc975ae29df79961"}, + {file = "py_sr25519_bindings-0.2.2-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5644353660fd9f97318d70fb7cf362f969a5ee572b61df8f18eda5fea80a6514"}, + {file = "py_sr25519_bindings-0.2.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0728fff2a29d4cc76c4cf22142cd2e2e8dc37745b213a866412980191e1260c"}, + {file = "py_sr25519_bindings-0.2.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b74c31e2960c4af5b709b562aaf610989af532aee771fcdf175533de60441607"}, + {file = "py_sr25519_bindings-0.2.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3d3c6836df2d67008e3f11080fb216e414cc924de256dd118e50a92cd334f143"}, + {file = "py_sr25519_bindings-0.2.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:dc5a57b67384244083b8d0831f9490dadca34e0543c1bf2f3a876aa4e7081961"}, + {file = "py_sr25519_bindings-0.2.2-cp37-cp37m-musllinux_1_2_armv7l.whl", hash = "sha256:ea139e7bf80ddc1c682db439825bec56baf745d643c146a783e9ddb737af266a"}, + {file = "py_sr25519_bindings-0.2.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:727f91cff7901db2d4e0f762dafd48c2b1086945b4903dcdd0a3eb65624c17c8"}, + {file = "py_sr25519_bindings-0.2.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0e4217f59936ae13fa4215838d2da59c130572408e3f29a9f7ca436924f4b356"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e6f2c026aa910cac7f16723b6b3e9736fe805e51b6ba41cfb4e25a4c0a6442"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:776d1a1f114b1f0553c9c8336545daaf20443d0b681c47c499377f69406f7a56"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:314893d4ea96877560bc12446956d61707ca46fb99040ffad751a0710a7aa87f"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:277e8ef38c9d899b1855fdcde07ae73a9917e06c46df556b8ca3216ae585b532"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:695e80ccdb710efba2f909235b18eaf230cf0b3f60e8d52a1c904eaeeff839ba"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2777013ff914bcc87e657657e99922fa48f3bb674734550989fb210fb3d878a2"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:30f1af9306fda911f296db29b4fff06197d3f38de5643b3d95862d3833db1e41"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:56eeafc92d3c66990ab97ff91c09b3295aea6dac9b64af0227750a8192aeaeec"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f7126db740eb190cf1ba993e066f03c2914edaf08e6963d10bbdd740922c95e6"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-win32.whl", hash = "sha256:7b20210a0a0b39e3f0bcb7832a3df736eeea2fcc5776dba1ce5b0c050e489145"}, + {file = "py_sr25519_bindings-0.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:5329e2e54eb9850c2eb84d6a226bd98cdc3597535453eced920035e1e026dced"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b32743bba0a53225097120b212da88c14584022a357d7e91cf19ed0a3adad9f6"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd56849d9195693c6a8e7c48efe4256918b6afeec090915f3f8f883cdb8addda"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f46c87fd7a8e55c4fce272d4e34663d3c7c3ffee906826a2a16a1400027aa5b9"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee25565a287690d6e48302f4584775622ce3329d2ab92fd3b0a4f063d4ca91f"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1199dab44704aa34401428ca3170da5b7ffdc8c65208a2c75a3c1fe5298b20e"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702a28e8ead7d1d664bea22168158edcc0e3d36e5cc1a79c7373ab1636f89cc2"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3de67c503155019c494e5887d1797f046afe1aeb99ee4b3cf86c15386330f034"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e1a48b295b562836d72dee7136c0503ea63cd89fec85d418b91ba040471c37ed"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:acebe18639616cd00ad0544d9bdaa73c545f00c4fc0d29b8a4e1e6c447f7a607"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-win32.whl", hash = "sha256:62e4bdd094589446a24dc1029cf2ef6c869e1f4fede04e17335bc92e60640fc5"}, + {file = "py_sr25519_bindings-0.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:0c2483031813f908da35c380196bc88410e2542482a5b4b51d265c6566de116c"}, + {file = "py_sr25519_bindings-0.2.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c9ee94a9fa1625f3192c89ecacc2bd012e09b57e6d2ad8ede027b31381609d3"}, + {file = "py_sr25519_bindings-0.2.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e4c95b3b6230faf2e89f6e5407d63a9d0d52385e6b7d42205570f5ee2f927940"}, + {file = "py_sr25519_bindings-0.2.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ab2b063129babc8f1d9fe6cf5c2d8cc434b6797c553440302da1fab987d74ab"}, + {file = "py_sr25519_bindings-0.2.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1bf93d46ab717089f4fac9b764ea4e7be9f4a45a62bd9919ef850ae8d2ae433"}, + {file = "py_sr25519_bindings-0.2.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5e927dd15d61e76fde3fa8bd6d776148ea987944fee1fe815fbc40c6a77f61ad"}, + {file = "py_sr25519_bindings-0.2.2-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:6584cf4c87fae9bdd64bc50dd786d9c805165bb6bc7a1ff545e77b29a78acb8d"}, + {file = "py_sr25519_bindings-0.2.2-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:a26b1add87dc6086463975aa1d889f39f90b0d22949d4de52e8a53e516bd2ac4"}, + {file = "py_sr25519_bindings-0.2.2-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:84cb2c645ce60a04688dedf61ed289d4fb716aef4129313814be1a2d47e268e7"}, + {file = "py_sr25519_bindings-0.2.2-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:76e660007dd415de22b1d99a884ee39cb6abf03f24377f58e4498533856c2bac"}, + {file = "py_sr25519_bindings-0.2.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0afcb14cd493485175dc9bf8c57b8b37581bbb29f55b6e4f3ce1f803222488"}, + {file = "py_sr25519_bindings-0.2.2-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b5c5f9080e879badefff596361520b2d9de9d9c4be7c14b36a017d798c451e2"}, + {file = "py_sr25519_bindings-0.2.2-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a42353737c22a3fa425ac350f5fac74d5b2f9c3cdb8ad44dbb367bd7869774cc"}, + {file = "py_sr25519_bindings-0.2.2-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:3ea3dc2c6a2a38b791114bd50021f10db2dd2a1d7a1a1aac0c7e80d885c0d3b5"}, + {file = "py_sr25519_bindings-0.2.2-pp38-pypy38_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:888d98a2d7736e0f269c8ab1f09dfac04af2d024b18c0485adc3615277f3442b"}, + {file = "py_sr25519_bindings-0.2.2-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:50976e9b22328df5696fcbfded27215a35225ee41e0b3f1b26a01ab00ad08143"}, + {file = "py_sr25519_bindings-0.2.2-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6449963cd6b300b224a1bc1fec77df012e30d609e54ccffe4f44f4563eab27c2"}, + {file = "py_sr25519_bindings-0.2.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:726346118fc2b785525945ee71ea1acf9be84c41e266c2345c93c7d4d6132cbc"}, + {file = "py_sr25519_bindings-0.2.2-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4dd5cfe48faafa7e112a021284663db8b64efd9b2225e69906f0bf7f3159a3ce"}, + {file = "py_sr25519_bindings-0.2.2-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85432a949143d7a20e452b4c89d5f0ad9a0162e1ce5a904fc157fe204cbe5ded"}, + {file = "py_sr25519_bindings-0.2.2-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:04a2f2eac269bb2f9bf30c795990211cd8d4cfdd28eafbd73b2dfc77a9ef940f"}, + {file = "py_sr25519_bindings-0.2.2-pp39-pypy39_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:bc7ecd25700a664d835cc9db5f6a4f65fef62a395762487c8c2a661566316e8f"}, + {file = "py_sr25519_bindings-0.2.2-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:9efc5526c0eb74c2f8df809c47e22d62febc31db8f38b5c6b1253e810e0ed71f"}, + {file = "py_sr25519_bindings-0.2.2-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:daa74fdd7bac2d97fbbbbb1ca40a0c02102220d09cfa9695cbde8d2cbedfadb7"}, + {file = "py_sr25519_bindings-0.2.2.tar.gz", hash = "sha256:192d65d3bc43c6f4121a0732e1f6eb6ad869897ca26368ba032e96a82b3b7606"}, ] [[package]] @@ -2501,8 +2541,6 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -2514,8 +2552,6 @@ version = "3.21.0" description = "Cryptographic library for Python" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pycryptodome-3.21.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dad9bf36eda068e89059d1f07408e397856be9511d7113ea4b586642a429a4fd"}, {file = "pycryptodome-3.21.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a1752eca64c60852f38bb29e2c86fca30d7672c024128ef5d70cc15868fa10f4"}, @@ -2557,8 +2593,6 @@ version = "2.10.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"}, {file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"}, @@ -2579,8 +2613,6 @@ version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, @@ -2693,8 +2725,6 @@ version = "1.3.1" description = "A pythonic generic language server (pronounced like 'pie glass')" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pygls-1.3.1-py3-none-any.whl", hash = "sha256:6e00f11efc56321bdeb6eac04f6d86131f654c7d49124344a9ebb968da3dd91e"}, {file = "pygls-1.3.1.tar.gz", hash = "sha256:140edceefa0da0e9b3c533547c892a42a7d2fd9217ae848c330c53d266a55018"}, @@ -2713,8 +2743,6 @@ version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, @@ -2729,8 +2757,6 @@ version = "2.10.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, @@ -2748,8 +2774,6 @@ version = "1.5.0" description = "Python binding to the Networking and Cryptography (NaCl) library" optional = false python-versions = ">=3.6" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, @@ -2776,8 +2800,6 @@ version = "1.8.0" description = "API to interact with the python pyproject.toml based projects" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228"}, {file = "pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496"}, @@ -2797,8 +2819,6 @@ version = "8.3.5" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, @@ -2821,8 +2841,6 @@ version = "6.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, @@ -2841,8 +2859,6 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2857,8 +2873,6 @@ version = "1.0.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, @@ -2873,8 +2887,6 @@ version = "1.1.2" description = "JSON RPC 2.0 server library" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "python-lsp-jsonrpc-1.1.2.tar.gz", hash = "sha256:4688e453eef55cd952bff762c705cedefa12055c0aec17a06f595bcc002cc912"}, {file = "python_lsp_jsonrpc-1.1.2-py3-none-any.whl", hash = "sha256:7339c2e9630ae98903fdaea1ace8c47fba0484983794d6aafd0bd8989be2b03c"}, @@ -2892,8 +2904,6 @@ version = "1.12.2" description = "Python Language Server for the Language Server Protocol" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "python_lsp_server-1.12.2-py3-none-any.whl", hash = "sha256:750116459449184ba20811167cdf96f91296ae12f1f65ebd975c5c159388111b"}, {file = "python_lsp_server-1.12.2.tar.gz", hash = "sha256:fea039a36b3132774d0f803671184cf7dde0c688e7b924f23a6359a66094126d"}, @@ -2926,8 +2936,6 @@ version = "16.0.0" description = "Unicode normalization forms (NFC, NFKC, NFD, NFKD). A library independent of the Python core Unicode database." optional = false python-versions = ">=3.6" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "pyunormalize-16.0.0-py3-none-any.whl", hash = "sha256:c647d95e5d1e2ea9a2f448d1d95d8518348df24eab5c3fd32d2b5c3300a49152"}, {file = "pyunormalize-16.0.0.tar.gz", hash = "sha256:2e1dfbb4a118154ae26f70710426a52a364b926c9191f764601f5a8cb12761f7"}, @@ -2935,31 +2943,27 @@ files = [ [[package]] name = "pywin32" -version = "308" +version = "309" description = "Python for Window Extensions" optional = false python-versions = "*" -groups = ["main"] -markers = "(python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\") and platform_system == \"Windows\"" -files = [ - {file = "pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e"}, - {file = "pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e"}, - {file = "pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c"}, - {file = "pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a"}, - {file = "pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b"}, - {file = "pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6"}, - {file = "pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897"}, - {file = "pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47"}, - {file = "pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091"}, - {file = "pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed"}, - {file = "pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4"}, - {file = "pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd"}, - {file = "pywin32-308-cp37-cp37m-win32.whl", hash = "sha256:1f696ab352a2ddd63bd07430080dd598e6369152ea13a25ebcdd2f503a38f1ff"}, - {file = "pywin32-308-cp37-cp37m-win_amd64.whl", hash = "sha256:13dcb914ed4347019fbec6697a01a0aec61019c1046c2b905410d197856326a6"}, - {file = "pywin32-308-cp38-cp38-win32.whl", hash = "sha256:5794e764ebcabf4ff08c555b31bd348c9025929371763b2183172ff4708152f0"}, - {file = "pywin32-308-cp38-cp38-win_amd64.whl", hash = "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de"}, - {file = "pywin32-308-cp39-cp39-win32.whl", hash = "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341"}, - {file = "pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920"}, +files = [ + {file = "pywin32-309-cp310-cp310-win32.whl", hash = "sha256:5b78d98550ca093a6fe7ab6d71733fbc886e2af9d4876d935e7f6e1cd6577ac9"}, + {file = "pywin32-309-cp310-cp310-win_amd64.whl", hash = "sha256:728d08046f3d65b90d4c77f71b6fbb551699e2005cc31bbffd1febd6a08aa698"}, + {file = "pywin32-309-cp310-cp310-win_arm64.whl", hash = "sha256:c667bcc0a1e6acaca8984eb3e2b6e42696fc035015f99ff8bc6c3db4c09a466a"}, + {file = "pywin32-309-cp311-cp311-win32.whl", hash = "sha256:d5df6faa32b868baf9ade7c9b25337fa5eced28eb1ab89082c8dae9c48e4cd51"}, + {file = "pywin32-309-cp311-cp311-win_amd64.whl", hash = "sha256:e7ec2cef6df0926f8a89fd64959eba591a1eeaf0258082065f7bdbe2121228db"}, + {file = "pywin32-309-cp311-cp311-win_arm64.whl", hash = "sha256:54ee296f6d11db1627216e9b4d4c3231856ed2d9f194c82f26c6cb5650163f4c"}, + {file = "pywin32-309-cp312-cp312-win32.whl", hash = "sha256:de9acacced5fa82f557298b1fed5fef7bd49beee04190f68e1e4783fbdc19926"}, + {file = "pywin32-309-cp312-cp312-win_amd64.whl", hash = "sha256:6ff9eebb77ffc3d59812c68db33c0a7817e1337e3537859499bd27586330fc9e"}, + {file = "pywin32-309-cp312-cp312-win_arm64.whl", hash = "sha256:619f3e0a327b5418d833f44dc87859523635cf339f86071cc65a13c07be3110f"}, + {file = "pywin32-309-cp313-cp313-win32.whl", hash = "sha256:008bffd4afd6de8ca46c6486085414cc898263a21a63c7f860d54c9d02b45c8d"}, + {file = "pywin32-309-cp313-cp313-win_amd64.whl", hash = "sha256:bd0724f58492db4cbfbeb1fcd606495205aa119370c0ddc4f70e5771a3ab768d"}, + {file = "pywin32-309-cp313-cp313-win_arm64.whl", hash = "sha256:8fd9669cfd41863b688a1bc9b1d4d2d76fd4ba2128be50a70b0ea66b8d37953b"}, + {file = "pywin32-309-cp38-cp38-win32.whl", hash = "sha256:617b837dc5d9dfa7e156dbfa7d3906c009a2881849a80a9ae7519f3dd8c6cb86"}, + {file = "pywin32-309-cp38-cp38-win_amd64.whl", hash = "sha256:0be3071f555480fbfd86a816a1a773880ee655bf186aa2931860dbb44e8424f8"}, + {file = "pywin32-309-cp39-cp39-win32.whl", hash = "sha256:72ae9ae3a7a6473223589a1621f9001fe802d59ed227fd6a8503c9af67c1d5f4"}, + {file = "pywin32-309-cp39-cp39-win_amd64.whl", hash = "sha256:88bc06d6a9feac70783de64089324568ecbc65866e2ab318eab35da3811fd7ef"}, ] [[package]] @@ -2968,8 +2972,6 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -3032,8 +3034,6 @@ version = "2.1.0" description = "Python library to build pretty command line user prompts ⭐️" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec"}, {file = "questionary-2.1.0.tar.gz", hash = "sha256:6302cdd645b19667d8f6e6634774e9538bfcd1aad9be287e743d96cacaf95587"}, @@ -3048,8 +3048,6 @@ version = "2024.11.6" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, @@ -3153,8 +3151,6 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -3176,8 +3172,6 @@ version = "2.0.0" description = "OAuthlib authentication support for Requests." optional = false python-versions = ">=3.4" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, @@ -3196,8 +3190,6 @@ version = "13.9.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, @@ -3217,8 +3209,6 @@ version = "4.1.0" description = "rlp: A package for Recursive Length Prefix encoding and decoding" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "rlp-4.1.0-py3-none-any.whl", hash = "sha256:8eca394c579bad34ee0b937aecb96a57052ff3716e19c7a578883e767bc5da6f"}, {file = "rlp-4.1.0.tar.gz", hash = "sha256:be07564270a96f3e225e2c107db263de96b5bc1f27722d2855bd3459a08e95a9"}, @@ -3239,8 +3229,6 @@ version = "0.7.4" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478"}, {file = "ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63"}, @@ -3268,8 +3256,6 @@ version = "0.0.58" description = "A Language Server Protocol implementation for Ruff." optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "ruff_lsp-0.0.58-py3-none-any.whl", hash = "sha256:d59f420ef56a58497f646fef0f5b87d6518e3d63e02044e36677cbdc1f9b7717"}, {file = "ruff_lsp-0.0.58.tar.gz", hash = "sha256:378db39955b32260473602b531dc6333d6686d1d8956673ef1c5203e08132032"}, @@ -3291,8 +3277,6 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -3304,8 +3288,6 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -3317,8 +3299,6 @@ version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." optional = false python-versions = "*" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, @@ -3330,8 +3310,6 @@ version = "8.1.3" description = "Python documentation generator" optional = false python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2"}, {file = "sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927"}, @@ -3367,8 +3345,6 @@ version = "2024.10.3" description = "Rebuild Sphinx documentation on changes, with hot reloading in the browser." optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa"}, {file = "sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1"}, @@ -3391,8 +3367,6 @@ version = "2.5.0" description = "Type hints (PEP 484) support for the Sphinx autodoc extension" optional = false python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sphinx_autodoc_typehints-2.5.0-py3-none-any.whl", hash = "sha256:53def4753239683835b19bfa8b68c021388bd48a096efcb02cdab508ece27363"}, {file = "sphinx_autodoc_typehints-2.5.0.tar.gz", hash = "sha256:259e1026b218d563d72743f417fcc25906a9614897fe37f91bd8d7d58f748c3b"}, @@ -3412,8 +3386,6 @@ version = "2.0.0" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, @@ -3430,8 +3402,6 @@ version = "2.0.0" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, @@ -3448,8 +3418,6 @@ version = "2.1.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, @@ -3466,8 +3434,6 @@ version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = false python-versions = ">=3.5" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, @@ -3482,8 +3448,6 @@ version = "0.7" description = "Sphinx \"napoleon\" extension." optional = false python-versions = "*" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sphinxcontrib-napoleon-0.7.tar.gz", hash = "sha256:407382beed396e9f2d7f3043fad6afda95719204a1e1a231ac865f40abcbfcf8"}, {file = "sphinxcontrib_napoleon-0.7-py2.py3-none-any.whl", hash = "sha256:711e41a3974bdf110a484aec4c1a556799eb0b3f3b897521a018ad7e2db13fef"}, @@ -3499,8 +3463,6 @@ version = "2.0.0" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, @@ -3517,8 +3479,6 @@ version = "2.0.0" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, @@ -3531,15 +3491,13 @@ test = ["pytest"] [[package]] name = "starlette" -version = "0.46.0" +version = "0.46.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "starlette-0.46.0-py3-none-any.whl", hash = "sha256:913f0798bd90ba90a9156383bcf1350a17d6259451d0d8ee27fc0cf2db609038"}, - {file = "starlette-0.46.0.tar.gz", hash = "sha256:b359e4567456b28d473d0193f34c0de0ed49710d75ef183a74a5ce0499324f50"}, + {file = "starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227"}, + {file = "starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230"}, ] [package.dependencies] @@ -3554,8 +3512,6 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -3597,8 +3553,6 @@ version = "1.0.0" description = "List processing tools and functional utilities" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "(python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\") and (implementation_name == \"cpython\" or implementation_name == \"pypy\")" files = [ {file = "toolz-1.0.0-py3-none-any.whl", hash = "sha256:292c8f1c4e7516bf9086f8850935c799a874039c8bcf959d47b600e4c44a6236"}, {file = "toolz-1.0.0.tar.gz", hash = "sha256:2c86e3d9a04798ac556793bced838816296a2f085017664e4995cb40a1047a02"}, @@ -3610,8 +3564,6 @@ version = "24.8.0" description = "Building newsfiles for your project." optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "towncrier-24.8.0-py3-none-any.whl", hash = "sha256:9343209592b839209cdf28c339ba45792fbfe9775b5f9c177462fd693e127d8d"}, {file = "towncrier-24.8.0.tar.gz", hash = "sha256:013423ee7eed102b2f393c287d22d95f66f1a3ea10a4baa82d298001a7f18af3"}, @@ -3631,8 +3583,6 @@ version = "4.23.2" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "tox-4.23.2-py3-none-any.whl", hash = "sha256:452bc32bb031f2282881a2118923176445bac783ab97c874b8770ab4c3b76c38"}, {file = "tox-4.23.2.tar.gz", hash = "sha256:86075e00e555df6e82e74cfc333917f91ecb47ffbc868dcafbd2672e332f4a2c"}, @@ -3660,8 +3610,6 @@ version = "4.15.0" description = "Twitter library for Python" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "tweepy-4.15.0-py3-none-any.whl", hash = "sha256:64adcea317158937059e4e2897b3ceb750b0c2dd5df58938c2da8f7eb3b88e6a"}, {file = "tweepy-4.15.0.tar.gz", hash = "sha256:1345cbcdf0a75e2d89f424c559fd49fda4d8cd7be25cd5131e3b57bad8a21d76"}, @@ -3685,8 +3633,6 @@ version = "0.9.4" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.6" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "typer-0.9.4-py3-none-any.whl", hash = "sha256:aa6c4a4e2329d868b80ecbaf16f807f2b54e192209d7ac9dd42691d63f7a54eb"}, {file = "typer-0.9.4.tar.gz", hash = "sha256:f714c2d90afae3a7929fcd72a3abb08df305e1ff61719381384211c4070af57f"}, @@ -3708,8 +3654,6 @@ version = "2.32.0.20250306" description = "Typing stubs for requests" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "types_requests-2.32.0.20250306-py3-none-any.whl", hash = "sha256:25f2cbb5c8710b2022f8bbee7b2b66f319ef14aeea2f35d80f18c9dbf3b60a0b"}, {file = "types_requests-2.32.0.20250306.tar.gz", hash = "sha256:0962352694ec5b2f95fda877ee60a159abdf84a0fc6fdace599f20acb41a03d1"}, @@ -3724,8 +3668,6 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -3737,8 +3679,6 @@ version = "5.10.0" description = "Ultra fast JSON encoder and decoder for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd"}, {file = "ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf"}, @@ -3826,8 +3766,6 @@ version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, @@ -3845,8 +3783,6 @@ version = "0.34.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4"}, {file = "uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9"}, @@ -3866,8 +3802,6 @@ version = "20.28.1" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb"}, {file = "virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329"}, @@ -3888,8 +3822,6 @@ version = "1.0.4" description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "watchfiles-1.0.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ba5bb3073d9db37c64520681dd2650f8bd40902d991e7b4cfaeece3e32561d08"}, {file = "watchfiles-1.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f25d0ba0fe2b6d2c921cf587b2bf4c451860086534f40c384329fb96e2044d1"}, @@ -3973,8 +3905,6 @@ version = "0.2.13" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = "*" -groups = ["dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, @@ -3982,15 +3912,13 @@ files = [ [[package]] name = "web3" -version = "7.8.0" +version = "7.9.0" description = "web3: A Python library for interacting with Ethereum" optional = false python-versions = "<4,>=3.8" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ - {file = "web3-7.8.0-py3-none-any.whl", hash = "sha256:c8771b3d8772f7104a0462804449beb57d36cef7bd8b411140f95a92fc46b559"}, - {file = "web3-7.8.0.tar.gz", hash = "sha256:712bc9fd6b1ef6e467ee24c25b581e1951cab2cba17f9f548f12587734f2c857"}, + {file = "web3-7.9.0-py3-none-any.whl", hash = "sha256:7818267675283e9cae4487d2805fc34b899aa26f41b0000c798c79b1684899eb"}, + {file = "web3-7.9.0.tar.gz", hash = "sha256:3c4487a7ac57e0a187bd7ee03db455d94f354d15cca45f097f15f7281ad1a01f"}, ] [package.dependencies] @@ -4010,8 +3938,8 @@ typing-extensions = ">=4.0.1" websockets = ">=10.0.0,<14.0.0" [package.extras] -dev = ["build (>=0.9.0)", "bumpversion (>=0.5.3)", "eth-tester[py-evm] (>=0.12.0b1,<0.13.0b1)", "flaky (>=3.7.0)", "hypothesis (>=3.31.2)", "ipython", "mypy (==1.10.0)", "pre-commit (>=3.4.0)", "py-geth (>=5.1.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.18.1,<0.23)", "pytest-mock (>=1.10)", "pytest-xdist (>=2.4.0)", "setuptools (>=38.6.0)", "sphinx (>=6.0.0)", "sphinx-autobuild (>=2021.3.14)", "sphinx_rtd_theme (>=1.0.0)", "towncrier (>=21,<22)", "tox (>=4.0.0)", "tqdm (>4.32)", "twine (>=1.13)", "wheel"] -docs = ["sphinx (>=6.0.0)", "sphinx-autobuild (>=2021.3.14)", "sphinx_rtd_theme (>=1.0.0)", "towncrier (>=21,<22)"] +dev = ["build (>=0.9.0)", "bump_my_version (>=0.19.0)", "eth-tester[py-evm] (>=0.12.0b1,<0.13.0b1)", "flaky (>=3.7.0)", "hypothesis (>=3.31.2)", "ipython", "mypy (==1.10.0)", "pre-commit (>=3.4.0)", "py-geth (>=5.1.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.18.1,<0.23)", "pytest-mock (>=1.10)", "pytest-xdist (>=2.4.0)", "setuptools (>=38.6.0)", "sphinx (>=6.0.0)", "sphinx-autobuild (>=2021.3.14)", "sphinx_rtd_theme (>=1.0.0)", "towncrier (>=24,<25)", "tox (>=4.0.0)", "tqdm (>4.32)", "twine (>=1.13)", "wheel"] +docs = ["sphinx (>=6.0.0)", "sphinx-autobuild (>=2021.3.14)", "sphinx_rtd_theme (>=1.0.0)", "towncrier (>=24,<25)"] test = ["eth-tester[py-evm] (>=0.12.0b1,<0.13.0b1)", "flaky (>=3.7.0)", "hypothesis (>=3.31.2)", "mypy (==1.10.0)", "pre-commit (>=3.4.0)", "py-geth (>=5.1.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.18.1,<0.23)", "pytest-mock (>=1.10)", "pytest-xdist (>=2.4.0)", "tox (>=4.0.0)"] tester = ["eth-tester[py-evm] (>=0.12.0b1,<0.13.0b1)", "py-geth (>=5.1.0)"] @@ -4021,8 +3949,6 @@ version = "13.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee"}, {file = "websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7"}, @@ -4118,8 +4044,6 @@ version = "1.18.3" description = "Yet another URL library" optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.10\" or python_version == \"3.11\" or python_version >= \"3.12\"" files = [ {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7df647e8edd71f000a5208fe6ff8c382a1de8edfbccdbbfe649d263de07d8c34"}, {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c69697d3adff5aa4f874b19c0e4ed65180ceed6318ec856ebc423aa5850d84f7"}, @@ -4211,6 +4135,6 @@ multidict = ">=4.0" propcache = ">=0.2.0" [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.10" -content-hash = "11a76a87141dea3ecac2eade46493d3fe263d24558451b1f3789c0156e59f7ce" +content-hash = "bf7760b36f4be25f4b9743f8c965fd4c0d22be9eb75c53c47318436fa7830102" diff --git a/python/coinbase-agentkit/pyproject.toml b/python/coinbase-agentkit/pyproject.toml index 4f51f638e..2271a7341 100644 --- a/python/coinbase-agentkit/pyproject.toml +++ b/python/coinbase-agentkit/pyproject.toml @@ -18,8 +18,10 @@ web3 = "^7.6.0" python-dotenv = "^1.0.1" requests = "^2.31.0" allora-sdk = "^0.2.0" +paramiko = "^3.5.1" [tool.poetry.scripts] +check-description-length = "scripts.check_description_length:main" generate-action-provider = "scripts.generate_action_provider.main:main" [tool.poetry.group.dev.dependencies] @@ -41,6 +43,7 @@ typer = "^0.9.0" prompt-toolkit = "^3.0.50" questionary = "^2.1.0" rich = "^13.7.1" +pillow = "^11.1.0" [build-system] requires = ["poetry-core"] @@ -82,3 +85,10 @@ name = "Added" [tool.towncrier.fragment.bugfix] name = "Fixed" + +[tool.pytest.ini_options] +addopts = "-m 'not e2e'" +markers = [ + "e2e: marks tests as end-to-end tests that interact with real services", + "manual: marks tests that should only be run manually (e.g., tests that incur costs)" +] diff --git a/python/coinbase-agentkit/scripts/check_description_length.py b/python/coinbase-agentkit/scripts/check_description_length.py new file mode 100644 index 000000000..061ddeb28 --- /dev/null +++ b/python/coinbase-agentkit/scripts/check_description_length.py @@ -0,0 +1,114 @@ +"""Check description lengths in all action providers across the codebase.""" + +import glob +import os +import re +import sys + + +def check_file(filepath): + """Check description lengths in a file. + + Parses the given file for description fields in triple quotes and checks their lengths. + Prints warnings for descriptions exceeding 1024 characters. + + Args: + filepath (str): Path to the file to check + + """ + with open(filepath) as file: + content = file.read() + + pattern = re.compile(r'description="""(.*?)""",', re.DOTALL) + matches = pattern.findall(content) + + if matches: + print(f"File: {filepath}") + for i, desc in enumerate(matches): + print(f" Description {i+1} length: {len(desc)} chars") + if len(desc) > 1024: + print(" ** EXCEEDS 1024 LIMIT **") + print(f" Start: {desc[:50]}...") + print(f" End: ...{desc[-50:]}") + print("\n") + + +def find_action_providers(base_dir): + """Find all action provider files in the codebase using wildcard patterns. + + Searches the directory tree for files matching patterns that indicate + they are action provider files. + + Args: + base_dir (str): Base directory to start the search from + + Returns: + list: List of paths to action provider files + + """ + patterns = [ + os.path.join(base_dir, "**", "*action_provider.py"), + ] + + action_providers = [] + for pattern in patterns: + action_providers.extend(glob.glob(pattern, recursive=True)) + + return action_providers + + +def check_description_length(): + """Check description lengths across action provider files in the codebase. + + This function finds all action provider files in the codebase and checks if any + description fields exceed the 1024 character limit. It handles both single file checking + (when a file path is provided as a command line argument) and scanning the entire + codebase for action provider files. + + If a command line argument is provided, it checks only that specific file. + Otherwise, it searches for action provider files within the expected directory + structure and checks each one. + + Returns: + None: Results are printed to stdout + + """ + if len(sys.argv) > 1: + check_file(sys.argv[1]) + sys.exit(0) + + base_dir = "coinbase_agentkit" + + if not os.path.exists(base_dir): + print( + f"Directory {base_dir} not found. Make sure you're running this from the correct location." + ) + base_dir = "python/coinbase-agentkit/coinbase_agentkit" + if not os.path.exists(base_dir): + print(f"Directory {base_dir} not found either. Exiting.") + sys.exit(1) + + action_provider_files = find_action_providers(base_dir) + + if not action_provider_files: + print(f"No action provider files found in {base_dir}.") + sys.exit(1) + + print(f"Found {len(action_provider_files)} action provider files to check.\n") + + for file_path in sorted(action_provider_files): + check_file(file_path) + + print(f"Checked {len(action_provider_files)} action provider files.") + + +def main() -> None: + """Execute the main entry point. + + Calls the check_description_length function to start the script execution. + """ + check_description_length() + + +if __name__ == "__main__": + main() diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/__init__.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/__init__.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/conftest.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/conftest.py new file mode 100644 index 000000000..bac5c68d1 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/conftest.py @@ -0,0 +1,38 @@ +"""Test fixtures for Hyperbolic AI service.""" + +from unittest.mock import patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider import AIActionProvider + + +@pytest.fixture +def mock_ai_service(): + """Create a mock AIService for testing. + + Returns: + MagicMock: A mock object that simulates the AIService. + + """ + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.AIService" + ) as mock: + yield mock.return_value + + +@pytest.fixture +def provider(mock_api_key, mock_ai_service): + """Create a HyperbolicAIActionProvider with a mock API key and service. + + Args: + mock_api_key: Mock API key for authentication. + mock_ai_service: Mock AIService to use in the provider. + + Returns: + HyperbolicAIActionProvider: Provider with mock API key and service. + + """ + provider = AIActionProvider(api_key=mock_api_key) + provider.ai_service = mock_ai_service + return provider diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/e2e/__init__.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/e2e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/e2e/conftest.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/e2e/conftest.py new file mode 100644 index 000000000..4809a485c --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/e2e/conftest.py @@ -0,0 +1,19 @@ +"""Fixtures for AI service end-to-end tests.""" + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.ai.service import AIService + + +@pytest.fixture +def ai_service(api_key: str) -> AIService: + """Create AIService instance for testing. + + Args: + api_key: API key for authentication. + + Returns: + AIService: Instance of AIService for testing. + + """ + return AIService(api_key) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/e2e/test_service.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/e2e/test_service.py new file mode 100644 index 000000000..f1356ddd0 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/e2e/test_service.py @@ -0,0 +1,253 @@ +"""End-to-end tests for Hyperbolic AI service. + +These tests make real API calls to the Hyperbolic platform. +They require a valid API key in the HYPERBOLIC_API_KEY environment variable. +""" + +import base64 +import os +from io import BytesIO + +import pytest +import requests +from PIL import Image + +from coinbase_agentkit.action_providers.hyperboliclabs.ai.types import ( + AudioGenerationRequest, + AudioGenerationResponse, + ChatCompletionRequest, + ChatCompletionResponse, + ChatMessage, + ImageGenerationRequest, +) +from coinbase_agentkit.action_providers.hyperboliclabs.constants import ( + SUPPORTED_AUDIO_LANGUAGES, + SUPPORTED_IMAGE_MODELS, +) + + +def encode_image(img): + """Encode PIL.Image into base64 string.""" + buffered = BytesIO() + img.save(buffered, format="PNG") + encoded_string = base64.b64encode(buffered.getvalue()).decode("utf-8") + return encoded_string + + +def decode_image(encoded_string): + """Decode base64 string into PIL.Image.""" + image_data = base64.b64decode(encoded_string) + image = Image.open(BytesIO(image_data)).convert("RGB") + return image + + +def save_image(image_data, output_path): + """Save base64 string as image file.""" + image = decode_image(image_data) + image.save(output_path) + return output_path + + +@pytest.mark.e2e +def test_ai_audio_generation(ai_service): + """Test audio generation with different inputs and parameters.""" + text = "Hello, this is a test" + + request = AudioGenerationRequest(text=text) + response = ai_service.generate_audio(request) + assert isinstance(response, AudioGenerationResponse) + assert response.audio is not None + assert isinstance(response.audio, str) + if response.duration: + assert response.duration > 0 + + request = AudioGenerationRequest( + text="Testing audio parameters", + language="EN", + speaker="EN-US", + sdp_ratio=0.5, + noise_scale=0.6, + noise_scale_w=0.7, + speed=1.2, + ) + response = ai_service.generate_audio(request) + assert isinstance(response, AudioGenerationResponse) + assert response.audio is not None + if response.duration: + assert response.duration > 0 + + +@pytest.mark.e2e +@pytest.mark.parametrize("language", SUPPORTED_AUDIO_LANGUAGES) +def test_ai_audio_generation_languages(ai_service, language): + """Test audio generation with different languages.""" + text = "Hello, this is a language test" + request = AudioGenerationRequest(text=text, language=language) + response = ai_service.generate_audio(request) + + assert isinstance(response, AudioGenerationResponse) + assert response.audio is not None + if response.duration: + assert response.duration > 0 + + +@pytest.mark.e2e +def test_ai_chat_completion(ai_service): + """Test chat completion with different prompts.""" + messages = [ + ChatMessage(role="system", content="You are a helpful assistant."), + ChatMessage(role="user", content="What is 2+2?"), + ] + request = ChatCompletionRequest(messages=messages, model="meta-llama/Meta-Llama-3-70B-Instruct") + response = ai_service.generate_text(request) + assert isinstance(response, ChatCompletionResponse) + assert len(response.choices) > 0 + assert response.choices[0].message.role == "assistant" + + if response.usage: + assert response.usage.prompt_tokens > 0 + assert response.usage.completion_tokens > 0 + assert response.usage.total_tokens == ( + response.usage.prompt_tokens + response.usage.completion_tokens + ) + + +@pytest.mark.e2e +def test_ai_image_generation(ai_service, tmp_path): + """Test image generation with different models and parameters.""" + prompt = "A beautiful sunset over mountains" + + request = ImageGenerationRequest( + prompt=prompt, + model_name="SDXL1.0-base", + height=1024, + width=1024, + steps=30, + num_images=1, + ) + response = ai_service.generate_image(request) + + assert len(response.images) > 0 + assert response.inference_time is not None + assert isinstance(response.inference_time, float) + + image = response.images[0] + assert image.image is not None + assert isinstance(image.random_seed, int) + assert isinstance(image.index, int) + + output_path = os.path.join(tmp_path, "generated_image.png") + saved_path = save_image(image.image, output_path) + assert os.path.exists(saved_path) + print(f"\nImage saved to: {saved_path}") + + request = ImageGenerationRequest( + prompt=prompt, + model_name="SDXL1.0-base", + height=512, + width=512, + steps=20, + num_images=1, + ) + response = ai_service.generate_image(request) + + assert len(response.images) > 0 + assert response.inference_time is not None + + image = response.images[0] + assert image.image is not None + assert isinstance(image.random_seed, int) + assert isinstance(image.index, int) + + output_path = os.path.join(tmp_path, "generated_image_custom.png") + saved_path = save_image(image.image, output_path) + assert os.path.exists(saved_path) + print(f"\nCustom image saved to: {saved_path}") + + +@pytest.mark.e2e +@pytest.mark.parametrize("model", SUPPORTED_IMAGE_MODELS) +def test_ai_image_generation_models(ai_service, model, tmp_path): + """Test image generation with different models.""" + if "ControlNet" in model: + pytest.skip("ControlNet models are tested separately") + + prompt = "A serene lake at dawn" + request = ImageGenerationRequest( + prompt=prompt, + model_name=model, + height=512, + width=512, + steps=20, + num_images=1, + cfg_scale=15, + ) + response = ai_service.generate_image(request) + + assert len(response.images) > 0 + assert response.inference_time is not None + assert isinstance(response.inference_time, float) + + image = response.images[0] + assert image.image is not None + assert isinstance(image.random_seed, int) + assert isinstance(image.index, int) + + output_path = os.path.join(tmp_path, f"generated_image_{model.lower()}.png") + saved_path = save_image(image.image, output_path) + assert os.path.exists(saved_path) + print(f"\nImage from {model} saved to: {saved_path}") + + +@pytest.mark.e2e +@pytest.mark.parametrize("model", ["SDXL-ControlNet", "SD1.5-ControlNet"]) +@pytest.mark.parametrize("controlnet_type", ["canny", "depth", "softedge", "openpose"]) +def test_ai_image_generation_controlnet(ai_service, model, controlnet_type, tmp_path): + """Test image generation with ControlNet models and different control types.""" + prompt = "an astronaut on mars" + + image_url = ( + "https://huggingface.co/lllyasviel/sd-controlnet-depth/resolve/main/images/stormtrooper.png" + ) + response = requests.get(image_url) + response.raise_for_status() + image = Image.open(BytesIO(response.content)) + test_image = encode_image(image) + + request = ImageGenerationRequest( + prompt=prompt, + model_name=model, + height=512, + width=512, + steps=20, + num_images=1, + cfg_scale=15, + controlnet_name=controlnet_type, + controlnet_image=test_image, + seed=5742320, + ) + + print("\nRequest data:") + print(f" Model: {model}") + print(f" ControlNet type: {controlnet_type}") + + try: + response = ai_service.generate_image(request) + + assert len(response.images) > 0 + assert response.inference_time is not None + assert isinstance(response.inference_time, float) + + image = response.images[0] + assert image.image is not None + assert isinstance(image.random_seed, int) + assert isinstance(image.index, int) + + output_path = os.path.join( + tmp_path, f"generated_image_{model.lower()}_{controlnet_type}.png" + ) + saved_path = save_image(image.image, output_path) + assert os.path.exists(saved_path) + print(f"\nControlNet image saved to: {saved_path}") + except Exception as e: + pytest.skip(f"ControlNet test failed: {e!s}") diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_action_provider.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_action_provider.py new file mode 100644 index 000000000..97dd3b919 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_action_provider.py @@ -0,0 +1,54 @@ +"""Tests for the Hyperbolic AI action provider initialization.""" + +import os +from unittest.mock import patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider import ( + AIActionProvider, + ai_action_provider, +) +from coinbase_agentkit.network import Network + + +def test_init_with_api_key(mock_api_key): + """Test initialization with API key.""" + provider = AIActionProvider(api_key=mock_api_key) + assert provider is not None + assert provider.api_key == mock_api_key + + +def test_init_with_env_var(mock_api_key): + """Test initialization with environment variable.""" + with patch.dict(os.environ, {"HYPERBOLIC_API_KEY": mock_api_key}): + provider = AIActionProvider() + assert provider is not None + assert provider.api_key == mock_api_key + + +def test_init_missing_api_key(): + """Test initialization with missing API key.""" + with patch.dict(os.environ, clear=True), pytest.raises(ValueError): + AIActionProvider() + + +def test_supports_network(mock_api_key): + """Test supports_network method.""" + provider = AIActionProvider(api_key=mock_api_key) + network = Network( + name="test_network", + protocol_family="ethereum", + chain_id="1", + network_id="1", + ) + assert provider.supports_network(network) is True + + +def test_factory_function(mock_api_key): + """Test the factory function.""" + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.AIActionProvider" + ) as mock: + ai_action_provider(mock_api_key) + mock.assert_called_once_with(api_key=mock_api_key) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_generate_audio.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_generate_audio.py new file mode 100644 index 000000000..89647ed28 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_generate_audio.py @@ -0,0 +1,159 @@ +"""Tests for generate_audio action in HyperbolicAIActionProvider.""" + +import os +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from coinbase_agentkit.action_providers.hyperboliclabs.ai.schemas import ( + GenerateAudioSchema, +) +from coinbase_agentkit.action_providers.hyperboliclabs.ai.types import ( + AudioGenerationResponse, +) + + +@pytest.fixture +def mock_response(): + """Create a standard mock audio response.""" + return AudioGenerationResponse( + audio="base64_encoded_audio_data", + duration=3.5, + ) + + +def test_generate_audio_success(provider, mock_ai_service, mock_response): + """Test successful audio generation.""" + mock_ai_service.generate_audio.return_value = mock_response + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.save_base64_data" + ) as mock_save: + mock_file_path = "/tmp/generated_audio_test.mp3" + mock_save.return_value = mock_file_path + + args = {"text": "Test audio text"} + result = provider.generate_audio(args) + + assert isinstance(result, str) + + assert "Audio generation successful:" in result + assert mock_file_path in result + + mock_ai_service.generate_audio.assert_called_once() + request = mock_ai_service.generate_audio.call_args[0][0] + assert request.text == "Test audio text" + assert request.language == "EN" + assert request.speaker == "EN-US" + + +def test_generate_audio_with_minimal_input(provider, mock_ai_service, mock_response): + """Test audio generation with a dictionary containing only the required text field.""" + mock_ai_service.generate_audio.return_value = mock_response + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.save_base64_data" + ) as mock_save: + mock_file_path = "/tmp/generated_audio_test.mp3" + mock_save.return_value = mock_file_path + + result = provider.generate_audio({"text": "Test audio text"}) + + assert isinstance(result, str) + + assert "Audio generation successful:" in result + assert mock_file_path in result + + request = mock_ai_service.generate_audio.call_args[0][0] + assert request.text == "Test audio text" + assert request.language == "EN" + assert request.speaker == "EN-US" + + +def test_generate_audio_with_custom_parameters(provider, mock_ai_service, mock_response): + """Test audio generation with custom parameters.""" + mock_ai_service.generate_audio.return_value = mock_response + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.save_base64_data" + ) as mock_save: + mock_file_path = "/tmp/generated_audio_test.mp3" + mock_save.return_value = mock_file_path + + args = { + "text": "Test audio text", + "language": "ES", + "speaker": "ES-ES", + "speed": 1.2, + } + result = provider.generate_audio(args) + + assert isinstance(result, str) + + assert "Audio generation successful:" in result + assert mock_file_path in result + + request = mock_ai_service.generate_audio.call_args[0][0] + assert request.text == "Test audio text" + assert request.language == "ES" + assert request.speaker == "ES-ES" + assert request.speed == 1.2 + + +def test_generate_audio_schema_validation(): + """Test schema validation for generate_audio.""" + valid_data = {"text": "Test audio text"} + schema = GenerateAudioSchema(**valid_data) + assert schema.text == "Test audio text" + assert schema.language == "EN" + assert schema.speaker == "EN-US" + assert schema.speed is None + + with pytest.raises(ValidationError): + GenerateAudioSchema() + + with pytest.raises(ValidationError): + GenerateAudioSchema(text="") + + with pytest.raises(ValidationError): + GenerateAudioSchema(text="Test", speed=0.05) + + with pytest.raises(ValidationError): + GenerateAudioSchema(text="Test", speed=6.0) + + +def test_generate_audio_error(provider, mock_ai_service): + """Test audio generation with error.""" + mock_ai_service.generate_audio.side_effect = Exception("API error") + + args = {"text": "Test audio text"} + result = provider.generate_audio(args) + + assert isinstance(result, str) + assert "Error: Audio generation: API error" in result + + +def test_generate_audio_saves_to_file(provider, mock_ai_service, mock_response, tmpdir): + """Test that audio generation saves the output to a file.""" + mock_ai_service.generate_audio.return_value = mock_response + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.save_base64_data" + ) as mock_save: + mock_file_path = os.path.join(tmpdir, "generated_audio_test.mp3") + mock_save.return_value = mock_file_path + + args = {"text": "Test audio text"} + result = provider.generate_audio(args) + + assert isinstance(result, str) + + assert "Audio generation successful:" in result + assert mock_file_path in result + + mock_save.assert_called_once() + audio_data, file_path_arg = mock_save.call_args[0] + assert audio_data == "base64_encoded_audio_data" + assert file_path_arg.startswith("./tmp/generated_audio_") + assert file_path_arg.endswith(".mp3") diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_generate_image.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_generate_image.py new file mode 100644 index 000000000..cd8f30047 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_generate_image.py @@ -0,0 +1,199 @@ +"""Tests for generate_image action in HyperbolicAIActionProvider.""" + +import os +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from coinbase_agentkit.action_providers.hyperboliclabs.ai.schemas import ( + GenerateImageSchema, +) +from coinbase_agentkit.action_providers.hyperboliclabs.ai.types import ( + GeneratedImage, + ImageGenerationResponse, +) + + +@pytest.fixture +def mock_response(): + """Create a standard mock image response.""" + return ImageGenerationResponse( + images=[ + GeneratedImage( + image="base64_encoded_image_data", + random_seed=12345, + index=0, + ) + ], + inference_time=5.67, + ) + + +def test_generate_image_success(provider, mock_ai_service, mock_response): + """Test successful image generation.""" + mock_ai_service.generate_image.return_value = mock_response + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.save_base64_data" + ) as mock_save: + mock_file_path = "/tmp/generated_image_test.png" + mock_save.return_value = mock_file_path + + args = {"prompt": "Test image prompt"} + result = provider.generate_image(args) + + assert isinstance(result, str) + + assert "Image generation successful:" in result + assert mock_file_path in result + + mock_ai_service.generate_image.assert_called_once() + request = mock_ai_service.generate_image.call_args[0][0] + assert request.prompt == "Test image prompt" + assert request.model_name == "SDXL1.0-base" + assert request.height == 1024 + assert request.width == 1024 + + +def test_generate_image_with_custom_parameters(provider, mock_ai_service, mock_response): + """Test image generation with custom parameters.""" + mock_ai_service.generate_image.return_value = mock_response + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.save_base64_data" + ) as mock_save: + mock_file_path = "/tmp/generated_image_test.png" + mock_save.return_value = mock_file_path + + args = { + "prompt": "Test image prompt", + "model_name": "SD1.5", + "height": 512, + "width": 512, + "steps": 50, + "negative_prompt": "blurry, low quality", + } + result = provider.generate_image(args) + + assert isinstance(result, str) + + assert "Image generation successful:" in result + assert mock_file_path in result + + request = mock_ai_service.generate_image.call_args[0][0] + assert request.prompt == "Test image prompt" + assert request.model_name == "SD1.5" + assert request.height == 512 + assert request.width == 512 + assert request.steps == 50 + assert request.negative_prompt == "blurry, low quality" + + +def test_generate_image_multiple_images(provider, mock_ai_service): + """Test generation of multiple images.""" + mock_response = ImageGenerationResponse( + images=[ + GeneratedImage( + image="base64_encoded_image_data_1", + random_seed=12345, + index=0, + ), + GeneratedImage( + image="base64_encoded_image_data_2", + random_seed=67890, + index=1, + ), + ], + inference_time=10.5, + ) + mock_ai_service.generate_image.return_value = mock_response + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.save_base64_data" + ) as mock_save: + mock_file_paths = ["/tmp/generated_image_test_1.png", "/tmp/generated_image_test_2.png"] + mock_save.side_effect = mock_file_paths + + args = { + "prompt": "Test image prompt", + "num_images": 2, + } + result = provider.generate_image(args) + + assert isinstance(result, str) + + assert "Image generation successful:" in result + assert mock_file_paths[0] in result + assert mock_file_paths[1] in result + + request = mock_ai_service.generate_image.call_args[0][0] + assert request.prompt == "Test image prompt" + assert request.num_images == 2 + + +def test_generate_image_schema_validation(): + """Test schema validation for generate_image.""" + valid_data = {"prompt": "Test image prompt"} + schema = GenerateImageSchema(**valid_data) + assert schema.prompt == "Test image prompt" + assert schema.model_name == "SDXL1.0-base" + assert schema.height == 1024 + assert schema.width == 1024 + assert schema.steps == 30 + assert schema.num_images == 1 + assert schema.negative_prompt is None + + with pytest.raises(ValidationError): + GenerateImageSchema() + + with pytest.raises(ValidationError): + GenerateImageSchema(prompt="") + + with pytest.raises(ValidationError): + GenerateImageSchema(prompt="Test", height=4000) + + with pytest.raises(ValidationError): + GenerateImageSchema(prompt="Test", width=32) + + with pytest.raises(ValidationError): + GenerateImageSchema(prompt="Test", steps=150) + + with pytest.raises(ValidationError): + GenerateImageSchema(prompt="Test", num_images=10) + + +def test_generate_image_error(provider, mock_ai_service): + """Test image generation with error.""" + mock_ai_service.generate_image.side_effect = Exception("API error") + + args = {"prompt": "Test image prompt"} + result = provider.generate_image(args) + + assert isinstance(result, str) + assert "Error: Image generation: API error" in result + + +def test_generate_image_saves_to_file(provider, mock_ai_service, mock_response, tmpdir): + """Test that image generation saves the output to a file.""" + mock_ai_service.generate_image.return_value = mock_response + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.save_base64_data" + ) as mock_save: + mock_file_path = os.path.join(tmpdir, "generated_image_test.png") + mock_save.return_value = mock_file_path + + args = {"prompt": "Test image prompt"} + result = provider.generate_image(args) + + assert isinstance(result, str) + + assert "Image generation successful:" in result + assert mock_file_path in result + + mock_save.assert_called_once() + image_data, file_path_arg = mock_save.call_args[0] + assert image_data == "base64_encoded_image_data" + assert file_path_arg.startswith("./tmp/generated_image_") + assert file_path_arg.endswith(".png") diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_generate_text.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_generate_text.py new file mode 100644 index 000000000..19e88f87d --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_generate_text.py @@ -0,0 +1,119 @@ +"""Tests for generate_text action in HyperbolicAIActionProvider.""" + +import os +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from coinbase_agentkit.action_providers.hyperboliclabs.ai.schemas import ( + GenerateTextSchema, +) +from coinbase_agentkit.action_providers.hyperboliclabs.ai.types import ( + ChatCompletionResponse, + ChatCompletionResponseChoice, + ChatCompletionResponseMessage, + ChatCompletionResponseUsage, +) + + +@pytest.fixture +def mock_response(): + """Create a standard mock response.""" + return ChatCompletionResponse( + id="chat-12345", + object="chat.completion", + created=1677858242, + model="meta-llama/Meta-Llama-3-70B-Instruct", + choices=[ + ChatCompletionResponseChoice( + index=0, + message=ChatCompletionResponseMessage( + role="assistant", + content="Generated text response.", + ), + finish_reason="stop", + ) + ], + usage=ChatCompletionResponseUsage( + prompt_tokens=10, + completion_tokens=20, + total_tokens=30, + ), + ) + + +def test_generate_text_success(provider, mock_ai_service, mock_response): + """Test successful text generation.""" + mock_ai_service.generate_text.return_value = mock_response + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.save_text" + ) as mock_save_text: + mock_file_path = "/tmp/generated_text_test.txt" + mock_save_text.return_value = mock_file_path + + args = {"prompt": "Test prompt"} + result = provider.generate_text(args) + + assert isinstance(result, str) + + assert "Text generation successful:" in result + assert mock_file_path in result + assert "Preview" in result + + mock_save_text.assert_called_once() + text_arg, file_path_arg = mock_save_text.call_args[0] + assert text_arg == "Generated text response." + assert file_path_arg.startswith("./tmp/generated_text_") + assert file_path_arg.endswith(".txt") + + +def test_generate_text_schema_validation(): + """Test validation of input schema.""" + with pytest.raises(ValidationError): + GenerateTextSchema(**{}) + + with pytest.raises(ValidationError): + GenerateTextSchema(**{"prompt": ""}) + + schema = GenerateTextSchema(**{"prompt": "Test prompt"}) + assert schema.prompt == "Test prompt" + assert schema.model == "meta-llama/Meta-Llama-3-70B-Instruct" + + +def test_generate_text_error(provider, mock_ai_service): + """Test error handling in text generation.""" + mock_ai_service.generate_text.side_effect = Exception("API error") + + args = {"prompt": "Test prompt"} + result = provider.generate_text(args) + + assert isinstance(result, str) + assert "Error: Text generation: API error" in result + + +def test_generate_text_saves_to_file(provider, mock_ai_service, mock_response, tmpdir): + """Test that text generation saves the output to a file.""" + mock_ai_service.generate_text.return_value = mock_response + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.ai.action_provider.save_text" + ) as mock_save_text: + mock_file_path = os.path.join(tmpdir, "generated_text_test.txt") + mock_save_text.return_value = mock_file_path + + args = {"prompt": "Test prompt"} + result = provider.generate_text(args) + + assert isinstance(result, str) + + assert "Text generation successful:" in result + assert mock_file_path in result + assert "Preview" in result + + mock_save_text.assert_called_once() + text_arg, file_path_arg = mock_save_text.call_args[0] + assert text_arg == "Generated text response." + assert file_path_arg.startswith("./tmp/generated_text_") + assert file_path_arg.endswith(".txt") diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_service.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_service.py new file mode 100644 index 000000000..dbd77b529 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/ai/test_service.py @@ -0,0 +1,214 @@ +"""Unit tests for Hyperbolic AI service.""" + +import pytest +import requests + +from coinbase_agentkit.action_providers.hyperboliclabs.ai.service import AIService +from coinbase_agentkit.action_providers.hyperboliclabs.ai.types import ( + AudioGenerationRequest, + ChatCompletionRequest, + ChatMessage, + ImageGenerationRequest, +) +from coinbase_agentkit.action_providers.hyperboliclabs.constants import ( + AI_SERVICES_BASE_URL, + SUPPORTED_IMAGE_MODELS, +) + + +def test_ai_service_init(mock_api_key): + """Test AI service initialization.""" + service = AIService(mock_api_key) + assert service.base_url == AI_SERVICES_BASE_URL + + +def test_ai_text_generation(mock_request, mock_api_key): + """Test text generation with different message types.""" + service = AIService(mock_api_key) + model = "meta-llama/Meta-Llama-3-70B-Instruct" + + mock_request.return_value.json.return_value = { + "id": "chat-12345", + "object": "chat.completion", + "created": 1677858242, + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Generated text response", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30, + }, + } + + request = ChatCompletionRequest( + messages=[ChatMessage(role="user", content="Test prompt")], + model=model, + ) + response = service.generate_text(request) + assert response.id == "chat-12345" + assert response.model == model + assert len(response.choices) == 1 + assert response.choices[0].message.content == "Generated text response" + assert response.usage.total_tokens == 30 + + request = ChatCompletionRequest( + messages=[ + ChatMessage(role="system", content="You are a helpful assistant."), + ChatMessage(role="user", content="What is 2+2?"), + ], + model=model, + ) + response = service.generate_text(request) + assert response.id == "chat-12345" + assert response.model == model + assert len(response.choices) == 1 + assert response.choices[0].message.role == "assistant" + assert response.usage.total_tokens == 30 + + +def test_ai_image_generation(mock_request, mock_api_key): + """Test image generation with different parameters.""" + service = AIService(mock_api_key) + model = SUPPORTED_IMAGE_MODELS[0] + + mock_request.return_value.json.return_value = { + "images": [ + { + "image": "base64_encoded_image_data", + "random_seed": 12345, + "index": 0, + } + ], + "inference_time": 1.5, + } + + request = ImageGenerationRequest( + prompt="Test prompt", + model_name=model, + height=1024, + width=1024, + ) + response = service.generate_image(request) + assert len(response.images) == 1 + assert response.images[0].image == "base64_encoded_image_data" + assert response.images[0].random_seed == 12345 + assert response.inference_time == 1.5 + + request = ImageGenerationRequest( + prompt="Test prompt with options", + model_name=model, + height=512, + width=512, + steps=20, + num_images=2, + negative_prompt="low quality", + seed=12345, + cfg_scale=7.5, + ) + + mock_request.return_value.json.return_value = { + "images": [ + { + "image": "base64_encoded_image_data_1", + "random_seed": 12345, + "index": 0, + }, + { + "image": "base64_encoded_image_data_2", + "random_seed": 12346, + "index": 1, + }, + ], + "inference_time": 2.5, + } + + response = service.generate_image(request) + assert len(response.images) == 2 + assert response.images[0].image == "base64_encoded_image_data_1" + assert response.images[1].image == "base64_encoded_image_data_2" + assert response.inference_time == 2.5 + + +def test_ai_audio_generation(mock_request, mock_api_key): + """Test audio generation with different parameters.""" + service = AIService(mock_api_key) + speaker = "EN-US" + language = "EN" + + mock_request.return_value.json.return_value = { + "audio": "base64_encoded_audio_data", + "duration": 3.5, + } + + request = AudioGenerationRequest( + text="Test text", + speaker=speaker, + language=language, + ) + response = service.generate_audio(request) + assert response.audio == "base64_encoded_audio_data" + assert response.duration == 3.5 + + request = AudioGenerationRequest( + text="Test text with options", + language=language, + speaker=speaker, + sdp_ratio=0.5, + noise_scale=0.6, + noise_scale_w=0.7, + speed=1.2, + ) + response = service.generate_audio(request) + assert response.audio == "base64_encoded_audio_data" + assert response.duration == 3.5 + + +def test_ai_service_error_handling(mock_request, mock_api_key): + """Test error handling in AI service.""" + service = AIService(mock_api_key) + + mock_request.side_effect = requests.exceptions.HTTPError( + "400 Bad Request: Invalid model specified" + ) + with pytest.raises(requests.exceptions.HTTPError, match="400 Bad Request"): + request = ChatCompletionRequest( + messages=[ChatMessage(role="user", content="Test prompt")], + model="invalid-model", + ) + service.generate_text(request) + + mock_request.side_effect = requests.exceptions.ConnectionError( + "Failed to establish a connection" + ) + with pytest.raises( + requests.exceptions.ConnectionError, match="Failed to establish a connection" + ): + request = ChatCompletionRequest( + messages=[ChatMessage(role="user", content="Test prompt")], + model="meta-llama/Meta-Llama-3-70B-Instruct", + ) + service.generate_text(request) + + +def test_ai_image_generation_invalid_model(mock_api_key): + """Test image generation with invalid model.""" + service = AIService(mock_api_key) + + request = ImageGenerationRequest( + prompt="Test prompt", + model_name="InvalidModel", + height=1024, + width=1024, + ) + + with pytest.raises(ValueError, match="Model InvalidModel not supported"): + service.generate_image(request) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/__init__.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/conftest.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/conftest.py new file mode 100644 index 000000000..54499f43a --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/conftest.py @@ -0,0 +1,37 @@ +"""Test fixtures for Hyperbolic billing service.""" + +from unittest.mock import patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.billing.action_provider import ( + BillingActionProvider, +) + + +@pytest.fixture +def mock_billing_service(): + """Create a mock BillingService for testing. + + Returns: + MagicMock: A mock object that simulates the BillingService. + + """ + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.billing.action_provider.BillingService" + ) as mock: + yield mock.return_value + + +@pytest.fixture +def provider(mock_api_key): + """Create a HyperbolicBillingActionProvider with a mock API key. + + Args: + mock_api_key: Mock API key for authentication. + + Returns: + HyperbolicBillingActionProvider: Provider with mock API key. + + """ + return BillingActionProvider(api_key=mock_api_key) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/e2e/__init__.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/e2e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/e2e/conftest.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/e2e/conftest.py new file mode 100644 index 000000000..a79418bbf --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/e2e/conftest.py @@ -0,0 +1,19 @@ +"""Fixtures for billing service end-to-end tests.""" + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.billing.service import BillingService + + +@pytest.fixture +def billing(api_key: str): + """Create a Billing service instance for testing. + + Args: + api_key: API key for authentication. + + Returns: + Billing: A billing service instance initialized with the API key. + + """ + return BillingService(api_key) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/e2e/test_service.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/e2e/test_service.py new file mode 100644 index 000000000..55cac2e45 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/e2e/test_service.py @@ -0,0 +1,55 @@ +"""End-to-end tests for Hyperbolic billing service.""" + +import json +from datetime import datetime, timezone + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.billing.types import ( + BillingBalanceResponse, + BillingPurchaseHistoryResponse, +) + + +@pytest.mark.e2e +def test_billing_balance(billing): + """Test getting current balance.""" + response = billing.get_balance() + print("\nBalance response:", json.dumps(response.model_dump(), indent=2)) + + assert isinstance(response, BillingBalanceResponse) + assert isinstance(response.credits, int | str) + if isinstance(response.credits, str): + assert float(response.credits) >= 0 + else: + assert response.credits >= 0 + + +@pytest.mark.e2e +def test_billing_history(billing): + """Test getting purchase history.""" + response = billing.get_purchase_history() + print("\nPurchase history response:", json.dumps(response.model_dump(), indent=2)) + + assert isinstance(response, BillingPurchaseHistoryResponse) + assert isinstance(response.purchase_history, list) + + if response.purchase_history: + purchase = response.purchase_history[0] + + assert float(purchase.amount) > 0, "Purchase amount should be positive" + assert ( + float(purchase.amount) <= 100000 + ), "Purchase amount should be within reasonable limits" + assert round(float(purchase.amount), 2) == float( + purchase.amount + ), "Amount should have at most 2 decimal places" + + purchase_time = datetime.fromisoformat(purchase.timestamp) + current_time = datetime.now(timezone.utc) + assert purchase_time <= current_time, "Purchase time should not be in the future" + + assert purchase.source in [ + "signup_promo", + "stripe_purchase", + ], "Source should be a known value" diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_action_provider.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_action_provider.py new file mode 100644 index 000000000..f9bf0f398 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_action_provider.py @@ -0,0 +1,54 @@ +"""Tests for the Hyperbolic Billing action provider initialization.""" + +import os +from unittest.mock import patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.billing.action_provider import ( + BillingActionProvider, + hyperbolic_billing_action_provider, +) +from coinbase_agentkit.network import Network + + +def test_init_with_api_key(mock_api_key): + """Test initialization with API key.""" + provider = BillingActionProvider(api_key=mock_api_key) + assert provider is not None + assert provider.api_key == mock_api_key + + +def test_init_with_env_var(mock_api_key): + """Test initialization with environment variable.""" + with patch.dict(os.environ, {"HYPERBOLIC_API_KEY": mock_api_key}): + provider = BillingActionProvider() + assert provider is not None + assert provider.api_key == mock_api_key + + +def test_init_missing_api_key(): + """Test initialization with missing API key.""" + with patch.dict(os.environ, clear=True), pytest.raises(ValueError): + BillingActionProvider() + + +def test_supports_network(mock_api_key): + """Test supports_network method.""" + provider = BillingActionProvider(api_key=mock_api_key) + network = Network( + name="test_network", + protocol_family="ethereum", + chain_id="1", + network_id="1", + ) + assert provider.supports_network(network) is True + + +def test_factory_function(mock_api_key): + """Test the factory function.""" + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.billing.action_provider.BillingActionProvider" + ) as mock: + hyperbolic_billing_action_provider(mock_api_key) + mock.assert_called_once_with(api_key=mock_api_key) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_get_current_balance.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_get_current_balance.py new file mode 100644 index 000000000..5c8943b6e --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_get_current_balance.py @@ -0,0 +1,32 @@ +"""Tests for get_current_balance action in HyperbolicBillingActionProvider.""" + +from unittest.mock import Mock + +from coinbase_agentkit.action_providers.hyperboliclabs.billing.types import ( + BillingBalanceResponse, +) + + +def test_get_current_balance_success(provider): + """Test successful get_current_balance action.""" + provider.billing.get_balance = Mock(return_value=BillingBalanceResponse(credits="15000")) + + result = provider.get_current_balance({}) + + assert "Your current Hyperbolic platform balance is $150.00" in result + + +def test_get_current_balance_empty_history(provider): + """Test get_current_balance action with empty purchase history.""" + provider.billing.get_balance = Mock(return_value=BillingBalanceResponse(credits="0")) + + result = provider.get_current_balance({}) + assert "Your current Hyperbolic platform balance is $0.00" in result + + +def test_get_current_balance_api_error(provider): + """Test get_current_balance action with API error.""" + provider.billing.get_balance = Mock(side_effect=Exception("API Error")) + + result = provider.get_current_balance({}) + assert "Error: Balance retrieval: API Error" in result diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_get_purchase_history.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_get_purchase_history.py new file mode 100644 index 000000000..a66fec7af --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_get_purchase_history.py @@ -0,0 +1,58 @@ +"""Tests for get_purchase_history action in BillingActionProvider.""" + +from unittest.mock import Mock + +from coinbase_agentkit.action_providers.hyperboliclabs.billing.types import ( + BillingPurchaseHistoryEntry, + BillingPurchaseHistoryResponse, +) + + +def test_get_purchase_history_success(provider): + """Test successful get_purchase_history action.""" + purchase_entries = [ + BillingPurchaseHistoryEntry( + amount="10000", + timestamp="2024-01-15T12:00:00+00:00", + source="stripe_purchase", + ), + BillingPurchaseHistoryEntry( + amount="5000", + timestamp="2023-12-30T15:30:00+00:00", + source="stripe_purchase", + ), + BillingPurchaseHistoryEntry( + amount="7500", + timestamp="2023-11-20T10:45:00+00:00", + source="stripe_purchase", + ), + ] + + provider.billing.get_purchase_history = Mock( + return_value=BillingPurchaseHistoryResponse(purchase_history=purchase_entries) + ) + + result = provider.get_purchase_history({}) + + assert "Purchase History (showing 5 most recent):" in result + assert "$100.00 on January 15, 2024" in result + assert "$50.00 on December 30, 2023" in result + assert "$75.00 on November 20, 2023" in result + + +def test_get_purchase_history_empty(provider): + """Test get_purchase_history action with empty purchase history.""" + provider.billing.get_purchase_history = Mock( + return_value=BillingPurchaseHistoryResponse(purchase_history=[]) + ) + + result = provider.get_purchase_history({}) + assert "No previous purchases found" in result + + +def test_get_purchase_history_api_error(provider): + """Test get_purchase_history action with API error.""" + provider.billing.get_purchase_history = Mock(side_effect=Exception("API Error")) + + result = provider.get_purchase_history({}) + assert "Error: Purchase history retrieval: API Error" in result diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_get_spend_history.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_get_spend_history.py new file mode 100644 index 000000000..cbca8b99b --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_get_spend_history.py @@ -0,0 +1,261 @@ +"""Tests for get_spend_history action in HyperbolicBillingActionProvider.""" + +from unittest.mock import Mock + +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.types import ( + GpuHardware, + HardwareInfo, + InstanceHistoryEntry, + InstanceHistoryResponse, + Price, +) + + +def test_get_spend_history_success(provider): + """Test successful get_spend_history action.""" + instance_entries = [ + InstanceHistoryEntry( + instance_name="instance-123", + started_at="2024-01-15T12:00:00Z", + terminated_at="2024-01-15T13:00:00Z", + gpu_count=2, + hardware=HardwareInfo( + gpus=[ + GpuHardware( + hardware_type="gpu", + model="NVIDIA A100", + ram=80.0, + ) + ] + ), + price=Price( + amount=2500.0, + period="hourly", + ), + ), + InstanceHistoryEntry( + instance_name="instance-456", + started_at="2024-01-14T10:00:00Z", + terminated_at="2024-01-14T12:00:00Z", + gpu_count=1, + hardware=HardwareInfo( + gpus=[ + GpuHardware( + hardware_type="gpu", + model="NVIDIA A100", + ram=80.0, + ) + ] + ), + price=Price( + amount=1250.0, + period="hourly", + ), + ), + ] + + provider.marketplace.get_instance_history = Mock( + return_value=InstanceHistoryResponse(instance_history=instance_entries) + ) + + result = provider.get_spend_history({}) + + assert "=== GPU Rental Spending Analysis ===" in result + assert "Instance Rentals (showing 2 most recent):" in result + assert "- instance-123:" in result + assert "GPU: NVIDIA A100 (Count: 2)" in result + assert "Duration: 3600 seconds" in result + assert "Cost: $25.00" in result + assert "- instance-456:" in result + assert "GPU: NVIDIA A100 (Count: 1)" in result + assert "Duration: 7200 seconds" in result + assert "Cost: $25.00" in result + assert "GPU Type Statistics (showing 1 most recent):" in result + assert "NVIDIA A100:" in result + assert "Total Rentals: 3" in result + assert "Total Time: 10800 seconds" in result + assert "Total Cost: $50.00" in result + assert "Total Spending: $50.00" in result + + +def test_get_spend_history_empty(provider): + """Test get_spend_history action with empty history.""" + provider.marketplace.get_instance_history = Mock( + return_value=InstanceHistoryResponse(instance_history=[]) + ) + + result = provider.get_spend_history({}) + assert "No rental history found." in result + + +def test_get_spend_history_api_error(provider): + """Test get_spend_history action with API error.""" + provider.marketplace.get_instance_history = Mock(side_effect=Exception("API Error")) + + result = provider.get_spend_history({}) + assert "Error: Spend history retrieval: API Error" in result + + +def test_get_spend_history_invalid_response(provider): + """Test get_spend_history action with invalid response format.""" + provider.marketplace.get_instance_history = Mock(side_effect=Exception("Invalid response")) + + result = provider.get_spend_history({}) + assert "Error: Spend history retrieval: Invalid response" in result + + +def test_get_spend_history_malformed_instance(provider): + """Test get_spend_history action with malformed instance data.""" + instance_entry = InstanceHistoryEntry( + instance_name="instance-123", + started_at="2024-01-15T12:00:00Z", + terminated_at="2024-01-15T13:00:00Z", + gpu_count=0, + hardware=HardwareInfo( + gpus=[ + GpuHardware( + hardware_type="gpu", + model="Unknown GPU", + ) + ] + ), + price=Price( + amount=0.0, + period="hourly", + ), + ) + + provider.marketplace.get_instance_history = Mock( + return_value=InstanceHistoryResponse(instance_history=[instance_entry]) + ) + + result = provider.get_spend_history({}) + + assert "- instance-123:" in result + assert "GPU: Unknown GPU (Count: 0)" in result + assert "Duration: 3600 seconds" in result + assert "Cost: $0.00" in result + + +def test_get_spend_history_missing_timestamps(provider): + """Test get_spend_history action with missing timestamp data.""" + instance_entries = [ + InstanceHistoryEntry( + instance_name="instance-123", + started_at="2024-01-15T12:00:00Z", + terminated_at="2024-01-15T13:00:00Z", + gpu_count=1, + hardware=HardwareInfo( + gpus=[ + GpuHardware( + hardware_type="gpu", + model="NVIDIA A100", + ram=80.0, + ) + ] + ), + price=Price( + amount=2500.0, + period="hourly", + ), + ), + InstanceHistoryEntry( + instance_name="instance-456", + started_at=None, + terminated_at=None, + gpu_count=2, + hardware=HardwareInfo( + gpus=[ + GpuHardware( + hardware_type="gpu", + model="NVIDIA A100", + ram=80.0, + ) + ] + ), + price=Price( + amount=1250.0, + period="hourly", + ), + ), + InstanceHistoryEntry( + instance_name="instance-789", + started_at="2024-01-14T10:00:00Z", + terminated_at=None, + gpu_count=1, + hardware=HardwareInfo( + gpus=[ + GpuHardware( + hardware_type="gpu", + model="NVIDIA A100", + ram=80.0, + ) + ] + ), + price=Price( + amount=1250.0, + period="hourly", + ), + ), + ] + + provider.marketplace.get_instance_history = Mock( + return_value=InstanceHistoryResponse(instance_history=instance_entries) + ) + + result = provider.get_spend_history({}) + + assert "- instance-123:" in result + assert "GPU: NVIDIA A100 (Count: 1)" in result + assert "Duration: 3600 seconds" in result + assert "Cost: $25.00" in result + + assert "- instance-456:" in result + assert "GPU: NVIDIA A100 (Count: 2)" in result + assert "Duration: Unavailable (missing timestamp data)" in result + assert "Cost: Unavailable" in result + + assert "- instance-789:" in result + assert "GPU: NVIDIA A100 (Count: 1)" in result + assert "Duration: Unavailable (missing timestamp data)" in result + assert "Cost: Unavailable" in result + + assert "Total Rentals: 1" in result + assert "Total Spending: $25.00" in result + + +def test_get_spend_history_missing_hardware(provider): + """Test get_spend_history action with edge cases: multiple GPUs, unknown models, empty instance name.""" + instance_entries = [ + InstanceHistoryEntry( + instance_name="instance-123", + started_at="2024-01-15T12:00:00Z", + terminated_at="2024-01-15T13:00:00Z", + gpu_count=2, + hardware=HardwareInfo( + gpus=[ + GpuHardware(hardware_type="gpu", model="NVIDIA A100"), + GpuHardware(hardware_type="gpu", model="NVIDIA V100"), + ] + ), + price=Price(amount=2500.0, period="hourly"), + ), + InstanceHistoryEntry( + instance_name="", + started_at="2024-01-15T12:00:00Z", + terminated_at="2024-01-15T13:00:00Z", + gpu_count=1, + hardware=HardwareInfo(gpus=[GpuHardware(hardware_type="gpu", model="NVIDIA A100")]), + price=Price(amount=2500.0, period="hourly"), + ), + ] + + provider.marketplace.get_instance_history = Mock( + return_value=InstanceHistoryResponse(instance_history=instance_entries) + ) + + result = provider.get_spend_history({}) + + assert "GPU: NVIDIA A100, NVIDIA V100 (Count: 2)" in result + assert "unnamed-instance" in result + assert "None" not in result diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_service.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_service.py new file mode 100644 index 000000000..77382c075 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/billing/test_service.py @@ -0,0 +1,76 @@ +"""Unit tests for Hyperbolic Billing service.""" + +import pytest +import requests + +from coinbase_agentkit.action_providers.hyperboliclabs.billing.service import BillingService +from coinbase_agentkit.action_providers.hyperboliclabs.constants import ( + BILLING_BASE_URL, + BILLING_ENDPOINTS, +) + + +def test_billing_service_init(mock_api_key): + """Test Billing service initialization.""" + service = BillingService(mock_api_key) + assert service.base_url == BILLING_BASE_URL + + +def test_billing_get_current_balance(mock_request, mock_api_key): + """Test get_balance method.""" + service = BillingService(mock_api_key) + mock_request.return_value.json.return_value = {"credits": "1000.50"} + + response = service.get_balance() + assert response.credits == "1000.50" + + mock_request.assert_called_with( + method="GET", + url=f"{BILLING_BASE_URL}{BILLING_ENDPOINTS['GET_BALANCE']}", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {mock_api_key}"}, + json=None, + params=None, + ) + + +def test_billing_get_purchase_history(mock_request, mock_api_key): + """Test get_purchase_history method.""" + service = BillingService(mock_api_key) + mock_request.return_value.json.return_value = { + "purchase_history": [ + { + "amount": "100.00", + "timestamp": "2024-01-01T00:00:00Z", + "source": "stripe_purchase", + } + ] + } + + response = service.get_purchase_history() + assert len(response.purchase_history) == 1 + assert response.purchase_history[0].amount == "100.00" + assert response.purchase_history[0].timestamp == "2024-01-01T00:00:00Z" + assert response.purchase_history[0].source == "stripe_purchase" + + mock_request.assert_called_with( + method="GET", + url=f"{BILLING_BASE_URL}{BILLING_ENDPOINTS['PURCHASE_HISTORY']}", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {mock_api_key}"}, + json=None, + params=None, + ) + + +def test_billing_service_error_handling(mock_request, mock_api_key): + """Test error handling in billing service.""" + service = BillingService(mock_api_key) + mock_request.side_effect = requests.exceptions.HTTPError("403 Forbidden: Insufficient credits") + + with pytest.raises(requests.exceptions.HTTPError, match="403 Forbidden"): + service.get_balance() + + mock_request.reset_mock() + mock_request.side_effect = requests.exceptions.HTTPError("500 Internal Server Error") + + with pytest.raises(requests.exceptions.HTTPError, match="500 Internal Server Error"): + service.get_purchase_history() diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/conftest.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/conftest.py new file mode 100644 index 000000000..2117cdee9 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/conftest.py @@ -0,0 +1,40 @@ +"""Common test fixtures for Hyperbolic services.""" + +import os +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def api_key() -> str: + """Get API key for testing. + + Returns: + str: API key from environment. + + Skips the test if HYPERBOLIC_API_KEY is not set. + + """ + api_key = os.environ.get("HYPERBOLIC_API_KEY", "") + if not api_key: + pytest.skip("HYPERBOLIC_API_KEY environment variable not set") + return api_key + + +@pytest.fixture +def mock_api_key(): + """Mock API key for testing.""" + return "test-api-key" + + +@pytest.fixture +def mock_request(): + """Mock requests for all tests.""" + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.service.requests.request" + ) as mock: + mock.return_value.status_code = 200 + mock.return_value.json.return_value = {"status": "success"} + mock.return_value.raise_for_status.return_value = None + yield mock diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/__init__.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/conftest.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/conftest.py new file mode 100644 index 000000000..4e4363cb3 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/conftest.py @@ -0,0 +1,41 @@ +"""Test fixtures for Hyperbolic marketplace service.""" + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.action_provider import ( + MarketplaceActionProvider, +) +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.service import MarketplaceService + +TEST_CLUSTER = "test-cluster" +TEST_NODE = "test-node" +TEST_GPU_COUNT = 2 +TEST_INSTANCE_ID = "test-instance-id" + + +@pytest.fixture +def marketplace(api_key: str): + """Create a Marketplace service instance for testing. + + Args: + api_key: API key for authentication. + + Returns: + Marketplace: A marketplace service instance initialized with the API key. + + """ + return MarketplaceService(api_key) + + +@pytest.fixture +def provider(mock_api_key): + """Create a HyperbolicMarketplaceActionProvider with a mock API key. + + Args: + mock_api_key: Mock API key for authentication. + + Returns: + HyperbolicMarketplaceActionProvider: Provider with mock API key. + + """ + return MarketplaceActionProvider(api_key=mock_api_key) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/e2e/__init__.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/e2e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/e2e/conftest.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/e2e/conftest.py new file mode 100644 index 000000000..984ce4758 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/e2e/conftest.py @@ -0,0 +1,172 @@ +"""Fixtures for marketplace service end-to-end tests.""" + +import json +import time + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.types import ( + RentInstanceRequest, + TerminateInstanceRequest, +) + + +@pytest.fixture +def rented_instance(marketplace, request): + """Fixture to rent the cheapest available instance. + + This fixture will: + 1. Find the cheapest available instance in North America + 2. Rent it + 3. Ensure it's terminated after the test (unless disable_auto_terminate is True) + + WARNING: This fixture rents actual GPU instances and incurs costs. + It should only be used in tests that are explicitly marked to run manually. + + The fixture will fail if: + - No instances are available + - Instance rental fails + - Instance termination fails or instance remains running + + Args: + marketplace: The marketplace service fixture + request: The pytest request object to get marker information + + Yields: + tuple: (instance_id, original_response) of the rented instance + + Raises: + AssertionError: If instance cannot be terminated properly + Exception: For other failures during rental or cleanup + + """ + disable_auto_terminate = False + marker = request.node.get_closest_marker("disable_auto_terminate") + if marker: + disable_auto_terminate = True + print( + "\nWARNING: Auto-termination is disabled. Remember to manually terminate the instance!" + ) + + available = marketplace.get_available_instances() + assert len(available.instances) > 0, "No instances available to rent" + + print("\nAll available instances:") + for instance in available.instances: + print(f"\nCluster: {instance.cluster_name}") + print(f"Node: {instance.id}") + print(f"Price: ${instance.pricing.price.amount/100:.2f}/hour") + print(f"Reserved: {instance.reserved}") + print(f"Status: {instance.status}") + if instance.location and instance.location.region: + print(f"Region: {instance.location.region}") + print(f"GPUs: {instance.gpus_total} total, {instance.gpus_reserved} reserved") + if instance.hardware and instance.hardware.gpus: + for gpu in instance.hardware.gpus: + print(f"GPU Model: {gpu.model}") + + def is_na_instance(instance): + """Check if instance is in North America based on node name patterns.""" + node_id = instance.id.lower() + na_patterns = ["las1", "ceti", "ses", "gpu-compute"] + return any(pattern in node_id for pattern in na_patterns) + + available_instances = [ + i + for i in available.instances + if not i.reserved and is_na_instance(i) and (i.gpus_total - i.gpus_reserved) > 0 + ] + assert ( + len(available_instances) > 0 + ), "No unreserved North American instances with available GPUs found" + + selected_instance = min(available_instances, key=lambda i: i.pricing.price.amount) + + print("\nSelected North American instance:") + print(f"Cluster: {selected_instance.cluster_name}") + print(f"Node: {selected_instance.id}") + print(f"Price: ${selected_instance.pricing.price.amount/100:.2f}/hour") + print(f"Available GPUs: {selected_instance.gpus_total - selected_instance.gpus_reserved}") + + instance_id = None + try: + request = RentInstanceRequest( + cluster_name=selected_instance.cluster_name, node_name=selected_instance.id, gpu_count=1 + ) + + response = marketplace.rent_instance(request) + print("\nFull API response:", json.dumps(response.model_dump(), indent=2)) + + assert response.status == "success" + assert response.instance_name is not None, "API returned success but no instance name" + + instance_id = response.instance_name + + yield instance_id, response + + finally: + if instance_id and not disable_auto_terminate: + print(f"\nCleaning up instance {instance_id}") + try: + wait_for_termination(marketplace, instance_id) + except Exception as e: + error_msg = ( + f"Error during cleanup of instance {instance_id}: {e}\n" + "IMPORTANT: You may have a running instance still incurring costs!" + ) + raise Exception(error_msg) from e + elif instance_id and disable_auto_terminate: + print(f"\nSkipping auto-termination for instance {instance_id} as requested.") + print( + "IMPORTANT: Remember to manually terminate this instance to avoid unnecessary costs!" + ) + + +def wait_for_termination( + marketplace, instance_id: str, max_retries: int = 3, wait_seconds: int = 5 +) -> None: + """Wait for an instance to be fully terminated. + + Args: + marketplace: The marketplace service instance + instance_id: ID of the instance to check + max_retries: Maximum number of termination attempts + wait_seconds: Seconds to wait between checks + + Raises: + AssertionError: If instance doesn't terminate after max retries + + """ + for attempt in range(max_retries): + terminate_request = TerminateInstanceRequest(id=instance_id) + terminate_response = marketplace.terminate_instance(terminate_request) + assert ( + terminate_response.status == "success" + ), f"Termination failed: {terminate_response.message}" + + time.sleep(wait_seconds) + rented = marketplace.get_rented_instances() + still_running = [ + i.id for i in rented.instances if i.status.lower() not in ["terminated", "terminating"] + ] + + if instance_id not in still_running: + print(f"Instance {instance_id} successfully terminated") + return + + if attempt < max_retries - 1: + print(f"Instance still running, retrying... (attempt {attempt + 1}/{max_retries})") + + raise AssertionError( + f"Failed to terminate instance {instance_id} after {max_retries} attempts. " + "IMPORTANT: You may have a running instance still incurring costs!" + ) + + +def pytest_configure(config): + """Register custom markers.""" + config.addinivalue_line("markers", "e2e: mark tests as end-to-end tests (may incur costs)") + config.addinivalue_line( + "markers", + "disable_auto_terminate: mark tests to disable automatic termination of GPU instances (DANGER: instances must be manually terminated!)", + ) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/e2e/test_service.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/e2e/test_service.py new file mode 100644 index 000000000..59f184c66 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/e2e/test_service.py @@ -0,0 +1,174 @@ +"""End-to-end tests for Hyperbolic Marketplace service. + +These tests make real API calls to the Hyperbolic platform. +They require a valid API key in the HYPERBOLIC_API_KEY environment variable. +""" + +import json +import time + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.types import ( + AvailableInstance, + AvailableInstancesResponse, + InstanceHistoryEntry, + InstanceHistoryResponse, + RentedInstancesResponse, + RentInstanceResponse, + TerminateInstanceRequest, + TerminateInstanceResponse, +) + + +@pytest.mark.e2e +def test_marketplace_available_instances(marketplace): + """Test getting available GPU instances.""" + response = marketplace.get_available_instances() + print("\nAvailable instances response:", json.dumps(response.model_dump(), indent=2)) + + assert isinstance(response, AvailableInstancesResponse) + assert isinstance(response.instances, list) + + if response.instances: + instance = response.instances[0] + assert isinstance(instance, AvailableInstance) + assert instance.id is not None + assert instance.status is not None + assert instance.hardware is not None + assert instance.hardware.gpus is not None + assert len(instance.hardware.gpus) > 0 + + gpu = instance.hardware.gpus[0] + assert gpu.model is not None + assert gpu.ram is not None + assert gpu.hardware_type == "gpu" + + assert instance.gpus_total > 0 + assert instance.gpus_reserved >= 0 + assert instance.gpus_reserved <= instance.gpus_total + + assert instance.pricing is not None + assert instance.pricing.price is not None + assert instance.pricing.price.amount > 0 + assert instance.pricing.price.period == "hourly" + assert instance.pricing.price.agent == "platform" + + +@pytest.mark.e2e +def test_marketplace_instance_history(marketplace): + """Test getting GPU instance rental history.""" + response = marketplace.get_instance_history() + print("\nInstance history response:", json.dumps(response.model_dump(), indent=2)) + + assert isinstance(response, InstanceHistoryResponse) + assert isinstance(response.instance_history, list) + + if response.instance_history: + entry = response.instance_history[0] + assert isinstance(entry, InstanceHistoryEntry) + assert entry.instance_name is not None + assert entry.price is not None + assert entry.hardware is not None + assert entry.gpu_count > 0 + + +@pytest.mark.e2e +def test_marketplace_rented_instances(marketplace): + """Test getting rented GPU instances.""" + response = marketplace.get_rented_instances() + print("\nRented nodes response:", json.dumps(response.model_dump(), indent=2)) + + assert isinstance(response, RentedInstancesResponse) + assert isinstance(response.instances, list) + + if response.instances: + node = response.instances[0] + assert node.id is not None + assert node.status is not None + assert node.instance is not None + + node_details = node.instance + assert node_details.hardware is not None + assert node_details.hardware.cpus is not None + assert node_details.hardware.gpus is not None + assert node_details.hardware.storage is not None + assert node_details.hardware.ram is not None + + assert node.ssh_command is not None or node.ssh_access is not None + + +@pytest.mark.e2e +@pytest.mark.skip( + reason="This test rents actual GPU instances and incurs costs. Only run manually." +) +def test_marketplace_rent(marketplace, rented_instance): + """Test renting a GPU instance. + + WARNING: This test will rent actual GPU instances and incurs costs. + It should only be run manually and with full awareness of the costs involved. + """ + instance_id, rent_response = rented_instance + + assert isinstance(rent_response, RentInstanceResponse) + assert rent_response.status == "success" + assert rent_response.instance_name is not None + assert rent_response.instance_name == instance_id + + print("\nWaiting for instance to initialize...") + time.sleep(3) + + rented = marketplace.get_rented_instances() + assert any(i.id == instance_id for i in rented.instances), "Rented instance not found" + + instance = next(i for i in rented.instances if i.id == instance_id) + + assert instance.status is not None + assert instance.instance is not None + assert instance.instance.hardware is not None + assert instance.instance.hardware.gpus is not None + + if instance.status.lower() in ["running", "online"]: + assert instance.ssh_command is not None or instance.ssh_access is not None + + +@pytest.mark.e2e +@pytest.mark.skip( + reason="This test terminates actual GPU instances and incurs costs. Only run manually." +) +@pytest.mark.disable_auto_terminate +def test_marketplace_terminate(marketplace, rented_instance, request): + """Test terminating a GPU instance. + + This test can run in two modes: + 1. Normal mode: Rents an instance and terminates it explicitly (default) + 2. Manual mode: With @pytest.mark.disable_auto_terminate marker, rents an instance but doesn't + terminate it, allowing manual termination via the action provider + + WARNING: This test rents actual GPU instances. + It should only be run manually and with full awareness of the implications. + When using the disable_auto_terminate marker, remember to manually terminate + the instance to avoid unnecessary costs. + """ + instance_id, _ = rented_instance + + print("\nWaiting for instance to be ready for termination...") + time.sleep(3) + + terminate_request = TerminateInstanceRequest(id=instance_id) + terminate_response = marketplace.terminate_instance(terminate_request) + + assert isinstance(terminate_response, TerminateInstanceResponse) + assert terminate_response.status == "success" + + time.sleep(3) + + rented = marketplace.get_rented_instances() + instance_status = next( + (i.status.lower() for i in rented.instances if i.id == instance_id), None + ) + assert instance_status in [ + None, + "terminated", + "terminating", + ], f"Instance should be terminated but is {instance_status}" diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_action_provider.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_action_provider.py new file mode 100644 index 000000000..da8d235a8 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_action_provider.py @@ -0,0 +1,54 @@ +"""Tests for the Hyperbolic Marketplace action provider initialization.""" + +import os +from unittest.mock import patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.action_provider import ( + MarketplaceActionProvider, + hyperbolic_marketplace_action_provider, +) +from coinbase_agentkit.network import Network + + +def test_init_with_api_key(mock_api_key): + """Test initialization with API key.""" + provider = MarketplaceActionProvider(api_key=mock_api_key) + assert provider is not None + assert provider.api_key == mock_api_key + + +def test_init_with_env_var(mock_api_key): + """Test initialization with environment variable.""" + with patch.dict(os.environ, {"HYPERBOLIC_API_KEY": mock_api_key}): + provider = MarketplaceActionProvider() + assert provider is not None + assert provider.api_key == mock_api_key + + +def test_init_missing_api_key(): + """Test initialization with missing API key.""" + with patch.dict(os.environ, clear=True), pytest.raises(ValueError): + MarketplaceActionProvider() + + +def test_supports_network(mock_api_key): + """Test supports_network method.""" + provider = MarketplaceActionProvider(api_key=mock_api_key) + network = Network( + name="test_network", + protocol_family="ethereum", + chain_id="1", + network_id="1", + ) + assert provider.supports_network(network) is True + + +def test_factory_function(mock_api_key): + """Test the factory function.""" + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.marketplace.action_provider.MarketplaceActionProvider" + ) as mock: + hyperbolic_marketplace_action_provider(mock_api_key) + mock.assert_called_once_with(api_key=mock_api_key) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_get_available_gpus.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_get_available_gpus.py new file mode 100644 index 000000000..96cf55a74 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_get_available_gpus.py @@ -0,0 +1,132 @@ +"""Tests for get_available_gpus action in HyperbolicMarketplaceActionProvider.""" + +from unittest.mock import patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.action_provider import ( + MarketplaceActionProvider, +) +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.types import ( + AvailableInstance, + AvailableInstancesResponse, + CpuHardware, + GpuHardware, + HardwareInfo, + Location, + Price, + PricingInfo, + RamHardware, + StorageHardware, +) + + +@pytest.fixture +def mock_api_response(): + """Mock API response for available GPUs.""" + gpu = GpuHardware( + hardware_type="gpu", + model="NVIDIA-GeForce-RTX-3070", + clock_speed=1000, + compute_power=1000, + ram=8192, + interface="PCIeX16", + ) + + cpu = CpuHardware(hardware_type="cpu", model="AMD-23-49", virtual_cores=32) + + storage = StorageHardware(hardware_type="storage", capacity=80) + + ram = RamHardware(hardware_type="ram", capacity=1070) + + hardware = HardwareInfo(cpus=[cpu], gpus=[gpu], storage=[storage], ram=[ram]) + + price1 = Price(amount=2000, period="hourly", agent="platform") + price2 = Price(amount=1600, period="hourly", agent="platform") + + instance1 = AvailableInstance( + id="korea-amd14-78", + status="node_ready", + hardware=hardware, + location=Location(region="region-1"), + gpus_total=1, + gpus_reserved=0, + has_persistent_storage=True, + pricing=PricingInfo(price=price1), + reserved=False, + cluster_name="angelic-mushroom-dolphin", + ) + + instance2 = AvailableInstance( + id="korea-amd11-181", + status="node_ready", + hardware=hardware, + location=Location(region="region-1"), + gpus_total=1, + gpus_reserved=0, + has_persistent_storage=True, + pricing=PricingInfo(price=price2), + reserved=False, + cluster_name="beneficial-palm-boar", + ) + + return AvailableInstancesResponse(instances=[instance1, instance2]) + + +@pytest.fixture +def provider(mock_api_key): + """Create HyperbolicMarketplaceActionProvider instance with test API key.""" + return MarketplaceActionProvider(api_key=mock_api_key) + + +def test_get_available_gpus_success(provider, mock_api_response): + """Test successful get_available_gpus action.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object( + provider.marketplace, "get_available_instances", return_value=mock_api_response + ), + ): + result = provider.get_available_gpus({}) + + assert "Available GPU Options:" in result + + assert "Cluster: angelic-mushroom-dolphin" in result + assert "Node ID: korea-amd14-78" in result + assert "GPU Model: NVIDIA-GeForce-RTX-3070" in result + assert "Available GPUs: 1/1" in result + assert "Price: $20.00/hour per GPU" in result + + assert "Cluster: beneficial-palm-boar" in result + assert "Node ID: korea-amd11-181" in result + assert "GPU Model: NVIDIA-GeForce-RTX-3070" in result + assert "Available GPUs: 1/1" in result + assert "Price: $16.00/hour per GPU" in result + + first_idx = result.find("angelic-mushroom-dolphin") + second_idx = result.find("beneficial-palm-boar") + assert first_idx < second_idx, "GPU instances should be listed in order" + + +def test_get_available_gpus_empty_response(provider): + """Test get_available_gpus action with empty response.""" + empty_response = AvailableInstancesResponse(instances=[]) + + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.marketplace, "get_available_instances", return_value=empty_response), + ): + result = provider.get_available_gpus({}) + assert "No available GPU instances found." in result + + +def test_get_available_gpus_api_error(provider): + """Test get_available_gpus action with API error.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object( + provider.marketplace, "get_available_instances", side_effect=Exception("API Error") + ), + ): + result = provider.get_available_gpus({}) + assert "Error: GPU retrieval: API Error" in result diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_get_available_gpus_types.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_get_available_gpus_types.py new file mode 100644 index 000000000..ecc037339 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_get_available_gpus_types.py @@ -0,0 +1,223 @@ +"""Tests for get_available_gpus_types and get_available_gpus_by_type actions in HyperbolicMarketplaceActionProvider.""" + +from unittest.mock import patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.action_provider import ( + MarketplaceActionProvider, +) +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.types import ( + AvailableInstance, + AvailableInstancesResponse, + CpuHardware, + GpuHardware, + HardwareInfo, + Location, + Price, + PricingInfo, + RamHardware, + StorageHardware, +) + + +@pytest.fixture +def mock_response(): + """Mock API response for available GPUs with different models.""" + gpu1 = GpuHardware( + hardware_type="gpu", + model="NVIDIA-GeForce-RTX-3070", + clock_speed=1000, + compute_power=1000, + ram=8192, + interface="PCIeX16", + ) + + gpu2 = GpuHardware( + hardware_type="gpu", + model="NVIDIA-A100", + clock_speed=1200, + compute_power=1500, + ram=40960, + interface="PCIeX16", + ) + + cpu = CpuHardware(hardware_type="cpu", model="AMD-23-49", virtual_cores=32) + storage = StorageHardware(hardware_type="storage", capacity=80) + ram = RamHardware(hardware_type="ram", capacity=1070) + + hardware1 = HardwareInfo(cpus=[cpu], gpus=[gpu1], storage=[storage], ram=[ram]) + hardware2 = HardwareInfo(cpus=[cpu], gpus=[gpu2], storage=[storage], ram=[ram]) + + price1 = Price(amount=2000, period="hourly", agent="platform") + price2 = Price(amount=1600, period="hourly", agent="platform") + price3 = Price(amount=3000, period="hourly", agent="platform") + + instance1 = AvailableInstance( + id="korea-amd14-78", + status="node_ready", + hardware=hardware1, + location=Location(region="region-1"), + gpus_total=1, + gpus_reserved=0, + has_persistent_storage=True, + pricing=PricingInfo(price=price1), + reserved=False, + cluster_name="angelic-mushroom-dolphin", + ) + + instance2 = AvailableInstance( + id="korea-amd11-181", + status="node_ready", + hardware=hardware1, + location=Location(region="region-1"), + gpus_total=1, + gpus_reserved=0, + has_persistent_storage=True, + pricing=PricingInfo(price=price2), + reserved=False, + cluster_name="beneficial-palm-boar", + ) + + instance3 = AvailableInstance( + id="us-east-a100-01", + status="node_ready", + hardware=hardware2, + location=Location(region="region-2"), + gpus_total=4, + gpus_reserved=1, + has_persistent_storage=True, + pricing=PricingInfo(price=price3), + reserved=False, + cluster_name="quantum-crystal-hawk", + ) + + instance4 = AvailableInstance( + id="korea-amd11-999", + status="node_ready", + hardware=hardware1, + location=Location(region="region-1"), + gpus_total=1, + gpus_reserved=1, + has_persistent_storage=True, + pricing=PricingInfo(price=price2), + reserved=False, + cluster_name="peaceful-ocean-bear", + ) + + return AvailableInstancesResponse(instances=[instance1, instance2, instance3, instance4]) + + +@pytest.fixture +def provider(mock_api_key): + """Create HyperbolicMarketplaceActionProvider instance with test API key.""" + return MarketplaceActionProvider(api_key=mock_api_key) + + +def test_get_available_gpus_types_success(provider, mock_response): + """Test successful get_available_gpus_types action.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.marketplace, "get_available_instances", return_value=mock_response), + ): + result = provider.get_available_gpus_types({}) + + assert "Available GPU Types:" in result + + assert "NVIDIA-GeForce-RTX-3070" in result + assert "NVIDIA-A100" in result + + +def test_get_available_gpus_types_empty_response(provider): + """Test get_available_gpus_types action with empty response.""" + empty_response = AvailableInstancesResponse(instances=[]) + + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.marketplace, "get_available_instances", return_value=empty_response), + ): + result = provider.get_available_gpus_types({}) + assert "No available GPU instances found." in result + + +def test_get_available_gpus_types_api_error(provider): + """Test get_available_gpus_types action with API error.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object( + provider.marketplace, "get_available_instances", side_effect=Exception("API Error") + ), + ): + result = provider.get_available_gpus_types({}) + assert "Error: GPU types retrieval: API Error" in result + + +def test_get_available_gpus_by_type_success(provider, mock_response): + """Test successful get_available_gpus_by_type action.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.marketplace, "get_available_instances", return_value=mock_response), + ): + result = provider.get_available_gpus_by_type({"gpu_model": "NVIDIA-GeForce-RTX-3070"}) + + assert "Available NVIDIA-GeForce-RTX-3070 GPU Options:" in result + + assert "Cluster: angelic-mushroom-dolphin" in result + assert "Node ID: korea-amd14-78" in result + assert "GPU Model: NVIDIA-GeForce-RTX-3070" in result + assert "Price: $20.00/hour per GPU" in result + + assert "Cluster: beneficial-palm-boar" in result + assert "Node ID: korea-amd11-181" in result + assert "GPU Model: NVIDIA-GeForce-RTX-3070" in result + assert "Price: $16.00/hour per GPU" in result + + assert "Cluster: quantum-crystal-hawk" not in result + assert "Node ID: us-east-a100-01" not in result + + result = provider.get_available_gpus_by_type({"gpu_model": "NVIDIA-A100"}) + + assert "Available NVIDIA-A100 GPU Options:" in result + + assert "Cluster: quantum-crystal-hawk" in result + assert "Node ID: us-east-a100-01" in result + assert "GPU Model: NVIDIA-A100" in result + assert "Available GPUs: 3/4" in result + assert "Price: $30.00/hour per GPU" in result + + assert "Cluster: angelic-mushroom-dolphin" not in result + assert "Cluster: beneficial-palm-boar" not in result + + +def test_get_available_gpus_by_type_not_found(provider, mock_response): + """Test get_available_gpus_by_type action with GPU model not found.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.marketplace, "get_available_instances", return_value=mock_response), + ): + result = provider.get_available_gpus_by_type({"gpu_model": "NVIDIA-H100"}) + assert "No available GPU instances with the model 'NVIDIA-H100' found." in result + + +def test_get_available_gpus_by_type_empty_response(provider): + """Test get_available_gpus_by_type action with empty response.""" + empty_response = AvailableInstancesResponse(instances=[]) + + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.marketplace, "get_available_instances", return_value=empty_response), + ): + result = provider.get_available_gpus_by_type({"gpu_model": "NVIDIA-A100"}) + assert "No available GPU instances found." in result + + +def test_get_available_gpus_by_type_api_error(provider): + """Test get_available_gpus_by_type action with API error.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object( + provider.marketplace, "get_available_instances", side_effect=Exception("API Error") + ), + ): + result = provider.get_available_gpus_by_type({"gpu_model": "NVIDIA-A100"}) + assert "Error: GPU retrieval: API Error" in result diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_get_gpu_status.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_get_gpu_status.py new file mode 100644 index 000000000..742ea5b6a --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_get_gpu_status.py @@ -0,0 +1,250 @@ +"""Tests for the get_gpu_status action in HyperbolicMarketplaceActionProvider.""" + +from unittest.mock import patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.action_provider import ( + MarketplaceActionProvider, +) +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.types import ( + CpuHardware, + GpuHardware, + HardwareInfo, + NodeInstance, + NodeRental, + RamHardware, + RentedInstancesResponse, + SSHAccess, + StorageHardware, +) + + +@pytest.fixture +def mock_rented_instances_with_ssh_command(): + """Mock response for rented instances with ssh_command.""" + gpu = GpuHardware( + hardware_type="gpu", + model="NVIDIA-A100", + clock_speed=1200, + compute_power=1500, + ram=40960, + interface="PCIeX16", + ) + + cpu = CpuHardware(hardware_type="cpu", model="AMD-23-49", virtual_cores=32) + storage = StorageHardware(hardware_type="storage", capacity=80) + ram = RamHardware(hardware_type="ram", capacity=1070) + hardware = HardwareInfo(cpus=[cpu], gpus=[gpu], storage=[storage], ram=[ram]) + + node_instance = NodeInstance( + id="node-123", + status="running", + hardware=hardware, + gpu_count=1, + ) + + rental_with_ssh_command = NodeRental( + id="running-instance-1", + start="2023-01-01T00:00:00Z", + end=None, + instance=node_instance, + ssh_command="ssh user@hostname.example.com -i ~/.ssh/id_rsa", + ssh_access=None, + ) + + return RentedInstancesResponse(instances=[rental_with_ssh_command]) + + +@pytest.fixture +def mock_rented_instances_with_ssh_access(): + """Mock response for rented instances with ssh_access.""" + gpu = GpuHardware( + hardware_type="gpu", + model="NVIDIA-GeForce-RTX-3070", + clock_speed=1000, + compute_power=1000, + ram=8192, + interface="PCIeX16", + ) + + cpu = CpuHardware(hardware_type="cpu", model="AMD-23-49", virtual_cores=32) + storage = StorageHardware(hardware_type="storage", capacity=80) + ram = RamHardware(hardware_type="ram", capacity=1070) + hardware = HardwareInfo(cpus=[cpu], gpus=[gpu], storage=[storage], ram=[ram]) + + node_instance = NodeInstance( + id="node-456", + status="running", + hardware=hardware, + gpu_count=1, + ) + + ssh_access = SSHAccess( + host="hostname2.example.com", + username="ubuntu", + key_path="~/.ssh/id_rsa", + ssh_command=None, + ) + + rental_with_ssh_access = NodeRental( + id="running-instance-2", + start="2023-01-01T00:00:00Z", + end=None, + instance=node_instance, + ssh_command=None, + ssh_access=ssh_access, + ) + + return RentedInstancesResponse(instances=[rental_with_ssh_access]) + + +@pytest.fixture +def mock_rented_instances_without_ssh(): + """Mock response for rented instances without SSH command or access.""" + gpu = GpuHardware( + hardware_type="gpu", + model="NVIDIA-H100", + clock_speed=1500, + compute_power=2000, + ram=81920, + interface="PCIeX16", + ) + + cpu = CpuHardware(hardware_type="cpu", model="AMD-23-49", virtual_cores=32) + storage = StorageHardware(hardware_type="storage", capacity=80) + ram = RamHardware(hardware_type="ram", capacity=1070) + hardware = HardwareInfo(cpus=[cpu], gpus=[gpu], storage=[storage], ram=[ram]) + + node_instance = NodeInstance( + id="node-789", + status="starting", + hardware=hardware, + gpu_count=1, + ) + + rental_without_ssh = NodeRental( + id="starting-instance-1", + start="2023-01-01T00:00:00Z", + end=None, + instance=node_instance, + ssh_command=None, + ssh_access=None, + ) + + return RentedInstancesResponse(instances=[rental_without_ssh]) + + +@pytest.fixture +def provider(mock_api_key): + """Create HyperbolicMarketplaceActionProvider instance with test API key.""" + return MarketplaceActionProvider(api_key=mock_api_key) + + +def test_get_gpu_status_with_ssh_command(provider, mock_rented_instances_with_ssh_command): + """Test get_gpu_status when instances have ssh_command.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object( + provider.marketplace, + "get_rented_instances", + return_value=mock_rented_instances_with_ssh_command, + ), + ): + result = provider.get_gpu_status({}) + + assert "Your Rented GPU Instances:" in result + + assert "Instance ID: running-instance-1" in result + assert "Status: running (Ready to use)" in result + assert "GPU Model: NVIDIA-A100" in result + assert "GPU Count: 1" in result + assert "GPU Memory: 40.0 GB" in result + + assert "SSH Command: ssh user@hostname.example.com -i ~/.ssh/id_rsa" in result + + assert "SSH Connection Instructions:" in result + assert "1. Wait until instance status is 'running'" in result + assert "2. Use the ssh_connect action with the provided host and username" in result + assert "3. Once connected, use remote_shell to execute commands" in result + + +def test_get_gpu_status_with_ssh_access(provider, mock_rented_instances_with_ssh_access): + """Test get_gpu_status when instances have ssh_access but no ssh_command.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object( + provider.marketplace, + "get_rented_instances", + return_value=mock_rented_instances_with_ssh_access, + ), + ): + result = provider.get_gpu_status({}) + + assert "Your Rented GPU Instances:" in result + + assert "Instance ID: running-instance-2" in result + assert "Status: running (Ready to use)" in result + assert "GPU Model: NVIDIA-GeForce-RTX-3070" in result + assert "GPU Count: 1" in result + assert "GPU Memory: 8.0 GB" in result + + assert "SSH Command: ssh ubuntu@hostname2.example.com -i ~/.ssh/id_rsa" in result + + assert "SSH Connection Instructions:" in result + assert "1. Wait until instance status is 'running'" in result + assert "2. Use the ssh_connect action with the provided host and username" in result + assert "3. Once connected, use remote_shell to execute commands" in result + + +def test_get_gpu_status_without_ssh(provider, mock_rented_instances_without_ssh): + """Test get_gpu_status when instances have no SSH information.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object( + provider.marketplace, + "get_rented_instances", + return_value=mock_rented_instances_without_ssh, + ), + ): + result = provider.get_gpu_status({}) + + assert "Your Rented GPU Instances:" in result + + assert "Instance ID: starting-instance-1" in result + assert "Status: starting (Still initializing)" in result + assert "GPU Model: NVIDIA-H100" in result + assert "GPU Count: 1" in result + assert "GPU Memory: 80.0 GB" in result + + assert "SSH Command: Not available yet. Instance is still being provisioned." in result + assert "The instance is starting up. Please check again in a few seconds." in result + + assert "SSH Connection Instructions:" in result + assert "1. Wait until instance status is 'running'" in result + assert "2. Use the ssh_connect action with the provided host and username" in result + assert "3. Once connected, use remote_shell to execute commands" in result + + +def test_get_gpu_status_empty_response(provider): + """Test get_gpu_status with no rented instances.""" + empty_response = RentedInstancesResponse(instances=[]) + + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.marketplace, "get_rented_instances", return_value=empty_response), + ): + result = provider.get_gpu_status({}) + assert "No rented GPU instances found." in result + + +def test_get_gpu_status_api_error(provider): + """Test get_gpu_status with API error.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object( + provider.marketplace, "get_rented_instances", side_effect=Exception("API Error") + ), + ): + result = provider.get_gpu_status({}) + assert "Error: GPU status retrieval: API Error" in result diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_rent_compute.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_rent_compute.py new file mode 100644 index 000000000..535f4a38a --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_rent_compute.py @@ -0,0 +1,67 @@ +"""Tests for rent_compute action in HyperbolicMarketplaceActionProvider.""" + +from unittest.mock import Mock, patch + +import pytest +from pydantic import ValidationError + +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.types import ( + RentInstanceResponse, +) + +MOCK_INSTANCE_ID = "test-instance-123" +MOCK_CLUSTER = "us-east-1" +MOCK_NODE = "node-456" +MOCK_GPU_COUNT = "2" + + +def test_rent_compute_success(provider): + """Test successful compute rental.""" + mock_response = RentInstanceResponse(status="success", instance_name="i-123456") + + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.marketplace, "rent_instance", return_value=mock_response), + ): + result = provider.rent_compute( + {"cluster_name": "us-east-1", "node_name": "node-789", "gpu_count": "2"} + ) + + assert '"status": "success"' in result + assert '"instance_name": "i-123456"' in result + + assert "Next Steps:" in result + assert "1. Your GPU instance is being provisioned" in result + assert "2. Use get_gpu_status to check when it's ready" in result + assert "3. Once status is 'running', you can:" in result + assert "- Connect via SSH using the provided command" in result + assert "- Run commands using remote_shell" in result + assert "- Install packages and set up your environment" in result + + +def test_rent_compute_api_error(provider): + """Test compute rental with API error.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.marketplace, "rent_instance", side_effect=Exception("API Error")), + ): + result = provider.rent_compute( + {"cluster_name": "us-east-1", "node_name": "node-789", "gpu_count": "2"} + ) + assert "Error: Compute rental: API Error" in result + + +def test_rent_compute_missing_fields(provider): + """Test compute rental with missing required fields.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch("requests.post", return_value=Mock()), + ): + with pytest.raises(ValidationError, match="Field required"): + provider.rent_compute({"node_name": "node-789", "gpu_count": "2"}) + + with pytest.raises(ValidationError, match="Field required"): + provider.rent_compute({"cluster_name": "us-east-1", "gpu_count": "2"}) + + with pytest.raises(ValidationError, match="Field required"): + provider.rent_compute({"cluster_name": "us-east-1", "node_name": "node-789"}) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_service.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_service.py new file mode 100644 index 000000000..b5e62a181 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_service.py @@ -0,0 +1,325 @@ +"""Unit tests for Hyperbolic Marketplace service.""" + +import pytest +import requests + +from coinbase_agentkit.action_providers.hyperboliclabs.constants import MARKETPLACE_BASE_URL +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.service import MarketplaceService +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.types import ( + AvailableInstancesResponse, + InstanceHistoryResponse, + RentedInstancesResponse, + RentInstanceRequest, + RentInstanceResponse, + TerminateInstanceRequest, + TerminateInstanceResponse, +) + +from .conftest import ( + TEST_CLUSTER, + TEST_GPU_COUNT, + TEST_INSTANCE_ID, + TEST_NODE, +) + + +def test_marketplace_init(mock_api_key): + """Test Marketplace service initialization.""" + service = MarketplaceService(mock_api_key) + assert service.base_url == MARKETPLACE_BASE_URL + assert service.api_key == mock_api_key + + +def test_marketplace_get_available_instances(mock_request, mock_api_key): + """Test get_available_instances method with empty response.""" + service = MarketplaceService(mock_api_key) + mock_request.return_value.json.return_value = {"instances": []} + + response = service.get_available_instances() + assert isinstance(response, AvailableInstancesResponse) + assert response.instances == [] + + mock_request.assert_called_once() + assert mock_request.call_args is not None + assert "Bearer" in mock_request.call_args.kwargs.get("headers", {}).get("Authorization", "") + assert mock_request.call_args.kwargs.get("json") == {"filters": {}} + + +def test_marketplace_get_available_instances_with_data(mock_request, mock_api_key): + """Test get_available_instances method with populated response.""" + service = MarketplaceService(mock_api_key) + + mock_request.return_value.json.return_value = { + "instances": [ + { + "id": "node-123", + "status": "available", + "hardware": { + "gpus": [{"model": "NVIDIA A100", "hardware_type": "gpu"}], + }, + "pricing": {"price": {"amount": 2.5, "period": "hourly"}}, + "cluster_name": "us-east-1", + "gpu_count": 8, + "gpus_reserved": 4, + } + ] + } + + response = service.get_available_instances() + assert isinstance(response, AvailableInstancesResponse) + assert len(response.instances) == 1 + assert response.instances[0].id == "node-123" + assert response.instances[0].status == "available" + assert response.instances[0].cluster_name == "us-east-1" + assert response.instances[0].gpu_count == 8 + assert response.instances[0].gpus_reserved == 4 + assert response.instances[0].hardware.gpus[0].model == "NVIDIA A100" + + mock_request.assert_called_once() + + +def test_marketplace_get_instance_history(mock_request, mock_api_key): + """Test get_instance_history method with empty response.""" + service = MarketplaceService(mock_api_key) + mock_request.return_value.json.return_value = {"instance_history": []} + + response = service.get_instance_history() + assert isinstance(response, InstanceHistoryResponse) + assert response.instance_history == [] + + mock_request.assert_called_once() + assert mock_request.call_args is not None + assert "Bearer" in mock_request.call_args.kwargs.get("headers", {}).get("Authorization", "") + + +def test_marketplace_get_instance_history_with_data(mock_request, mock_api_key): + """Test get_instance_history method with populated response.""" + service = MarketplaceService(mock_api_key) + + mock_request.return_value.json.return_value = { + "instance_history": [ + { + "instance_name": "test-instance-1", + "started_at": "2023-01-01T00:00:00Z", + "terminated_at": "2023-01-02T00:00:00Z", + "price": {"amount": 2.5, "period": "hourly"}, + "hardware": { + "gpus": [{"model": "NVIDIA A100", "hardware_type": "gpu"}], + }, + "gpu_count": 2, + } + ] + } + + response = service.get_instance_history() + assert isinstance(response, InstanceHistoryResponse) + assert len(response.instance_history) == 1 + assert response.instance_history[0].instance_name == "test-instance-1" + assert response.instance_history[0].started_at == "2023-01-01T00:00:00Z" + assert response.instance_history[0].terminated_at == "2023-01-02T00:00:00Z" + assert response.instance_history[0].price.amount == 2.5 + assert response.instance_history[0].price.period == "hourly" + assert response.instance_history[0].gpu_count == 2 + + mock_request.assert_called_once() + + +def test_marketplace_get_rented_instances(mock_request, mock_api_key): + """Test get_rented_instances method with empty response.""" + service = MarketplaceService(mock_api_key) + mock_request.return_value.json.return_value = {"instances": []} + + response = service.get_rented_instances() + assert isinstance(response, RentedInstancesResponse) + assert response.instances == [] + + mock_request.assert_called_once() + assert mock_request.call_args is not None + assert "Bearer" in mock_request.call_args.kwargs.get("headers", {}).get("Authorization", "") + + +def test_marketplace_get_rented_instances_with_data(mock_request, mock_api_key): + """Test get_rented_instances method with populated response.""" + service = MarketplaceService(mock_api_key) + + mock_request.return_value.json.return_value = { + "instances": [ + { + "id": "i-12345", + "start": "2023-01-01T00:00:00Z", + "instance": { + "id": "node-123", + "status": "running", + "hardware": { + "gpus": [{"model": "NVIDIA A100", "hardware_type": "gpu"}], + }, + }, + "ssh_access": { + "host": "host.example.com", + "username": "user", + }, + } + ] + } + + response = service.get_rented_instances() + assert isinstance(response, RentedInstancesResponse) + assert len(response.instances) == 1 + assert response.instances[0].id == "i-12345" + assert response.instances[0].start == "2023-01-01T00:00:00Z" + assert response.instances[0].instance.id == "node-123" + assert response.instances[0].instance.status == "running" + assert response.instances[0].ssh_access is not None + assert response.instances[0].ssh_access.host == "host.example.com" + assert response.instances[0].ssh_access.username == "user" + + mock_request.assert_called_once() + + +def test_marketplace_rent_instance_success(mock_request, mock_api_key): + """Test rent_instance method with successful response.""" + service = MarketplaceService(mock_api_key) + + mock_request.return_value.json.return_value = { + "status": "success", + "instance_name": TEST_INSTANCE_ID, + } + + request = RentInstanceRequest( + cluster_name=TEST_CLUSTER, + node_name=TEST_NODE, + gpu_count=TEST_GPU_COUNT, + ) + response = service.rent_instance(request) + assert isinstance(response, RentInstanceResponse) + assert response.status == "success" + assert response.instance_name == TEST_INSTANCE_ID + + mock_request.assert_called_once() + assert mock_request.call_args is not None + assert "Bearer" in mock_request.call_args.kwargs.get("headers", {}).get("Authorization", "") + + json_data = mock_request.call_args.kwargs.get("json", {}) + assert json_data.get("cluster_name") == TEST_CLUSTER + assert json_data.get("node_name") == TEST_NODE + assert json_data.get("gpu_count") == TEST_GPU_COUNT + assert "image" in json_data + + +def test_marketplace_rent_instance_with_message(mock_request, mock_api_key): + """Test rent_instance method with additional message in response.""" + service = MarketplaceService(mock_api_key) + + mock_request.return_value.json.return_value = { + "status": "success", + "instance_name": "instance-abc123", + "message": "Instance created successfully", + } + + request = RentInstanceRequest( + cluster_name="us-west-2", + node_name="node-456", + gpu_count=4, + ) + response = service.rent_instance(request) + assert isinstance(response, RentInstanceResponse) + assert response.status == "success" + assert response.instance_name == "instance-abc123" + + mock_request.assert_called_once() + assert mock_request.call_args is not None + + json_data = mock_request.call_args.kwargs.get("json", {}) + assert json_data.get("cluster_name") == "us-west-2" + assert json_data.get("node_name") == "node-456" + assert json_data.get("gpu_count") == 4 + assert "image" in json_data + + +def test_marketplace_rent_instance_error(mock_request, mock_api_key): + """Test rent_instance method with HTTP error response.""" + service = MarketplaceService(mock_api_key) + mock_request.side_effect = requests.exceptions.HTTPError("400 Client Error: Bad Request") + + request = RentInstanceRequest( + cluster_name="invalid", + node_name="invalid", + gpu_count=0, + ) + with pytest.raises(requests.exceptions.HTTPError, match="400 Client Error"): + service.rent_instance(request) + + +def test_marketplace_rent_instance_connection_error(mock_request, mock_api_key): + """Test rent_instance method with connection error.""" + service = MarketplaceService(mock_api_key) + mock_request.side_effect = requests.exceptions.ConnectionError("Connection refused") + + request = RentInstanceRequest( + cluster_name=TEST_CLUSTER, + node_name=TEST_NODE, + gpu_count=TEST_GPU_COUNT, + ) + with pytest.raises(requests.exceptions.ConnectionError, match="Connection refused"): + service.rent_instance(request) + + +def test_marketplace_terminate_instance_success(mock_request, mock_api_key): + """Test terminate_instance method with successful response.""" + service = MarketplaceService(mock_api_key) + + mock_request.return_value.json.return_value = { + "status": "success", + "message": "Instance terminated successfully", + } + + request = TerminateInstanceRequest(id=TEST_INSTANCE_ID) + response = service.terminate_instance(request) + assert isinstance(response, TerminateInstanceResponse) + assert response.status == "success" + assert response.message == "Instance terminated successfully" + + mock_request.assert_called_once() + assert mock_request.call_args is not None + assert "Bearer" in mock_request.call_args.kwargs.get("headers", {}).get("Authorization", "") + assert mock_request.call_args.kwargs.get("json") == {"id": TEST_INSTANCE_ID} + + +def test_marketplace_terminate_instance_with_custom_message(mock_request, mock_api_key): + """Test terminate_instance method with custom success message.""" + service = MarketplaceService(mock_api_key) + + mock_request.return_value.json.return_value = { + "status": "success", + "message": "Instance i-abc123 has been terminated", + } + + request = TerminateInstanceRequest(id="i-abc123") + response = service.terminate_instance(request) + assert isinstance(response, TerminateInstanceResponse) + assert response.status == "success" + assert response.message == "Instance i-abc123 has been terminated" + + mock_request.assert_called_once() + assert mock_request.call_args is not None + assert mock_request.call_args.kwargs.get("json") == {"id": "i-abc123"} + + +def test_marketplace_terminate_instance_not_found(mock_request, mock_api_key): + """Test terminate_instance method with not found error.""" + service = MarketplaceService(mock_api_key) + mock_request.side_effect = requests.exceptions.HTTPError("404 Client Error: Not Found") + + request = TerminateInstanceRequest(id="invalid-id") + with pytest.raises(requests.exceptions.HTTPError, match="404 Client Error"): + service.terminate_instance(request) + + +def test_marketplace_terminate_instance_bad_request(mock_request, mock_api_key): + """Test terminate_instance method with bad request error.""" + service = MarketplaceService(mock_api_key) + mock_request.side_effect = requests.exceptions.HTTPError("400 Client Error: Bad Request") + + request = TerminateInstanceRequest(id="") + with pytest.raises(requests.exceptions.HTTPError, match="400 Client Error"): + service.terminate_instance(request) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_terminate_compute.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_terminate_compute.py new file mode 100644 index 000000000..6cdb33bb9 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/marketplace/test_terminate_compute.py @@ -0,0 +1,54 @@ +"""Tests for terminate_compute action in HyperbolicMarketplaceActionProvider.""" + +from unittest.mock import Mock, patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.marketplace.types import ( + TerminateInstanceResponse, +) + + +def test_terminate_compute_success(provider): + """Test successful compute termination.""" + mock_response = TerminateInstanceResponse( + status="success", message="Instance terminated successfully" + ) + + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.marketplace, "terminate_instance", return_value=mock_response), + ): + result = provider.terminate_compute({"id": "i-123456"}) + + assert '"status": "success"' in result + assert '"message": "Instance terminated successfully"' in result + + assert "Next Steps:" in result + assert "1. Your GPU instance has been terminated" in result + assert "2. Any active SSH connections have been closed" in result + assert "3. You can check your spend history with get_spend_history" in result + assert "4. To rent a new instance, use get_available_gpus and rent_compute" in result + + +def test_terminate_compute_api_error(provider): + """Test compute termination with API error.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object( + provider.marketplace, "terminate_instance", side_effect=Exception("API Error") + ), + ): + result = provider.terminate_compute({"id": "i-123456"}) + assert "Error: Compute termination: API Error" in result + + +def test_terminate_compute_missing_instance_id(provider): + """Test compute termination with missing instance ID.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch("requests.post", return_value=Mock()), + ): + with pytest.raises(Exception) as exc_info: + provider.terminate_compute({}) + assert "Field required" in str(exc_info.value) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/__init__.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/conftest.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/conftest.py new file mode 100644 index 000000000..a80e7c120 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/conftest.py @@ -0,0 +1,34 @@ +"""Test fixtures for Hyperbolic settings service.""" + +import secrets + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.settings.action_provider import ( + SettingsActionProvider, +) + + +@pytest.fixture +def provider(mock_api_key): + """Create a HyperbolicSettingsActionProvider with a mock API key. + + Args: + mock_api_key: Mock API key for authentication. + + Returns: + HyperbolicSettingsActionProvider: Provider with mock API key. + + """ + return SettingsActionProvider(api_key=mock_api_key) + + +@pytest.fixture +def random_eth_address(): + """Generate a random Ethereum address for testing. + + Returns: + str: A random Ethereum address with 0x prefix. + + """ + return "0x" + secrets.token_hex(20) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/e2e/__init__.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/e2e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/e2e/conftest.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/e2e/conftest.py new file mode 100644 index 000000000..1a53b4875 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/e2e/conftest.py @@ -0,0 +1,19 @@ +"""Fixtures for settings service end-to-end tests.""" + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.settings.service import SettingsService + + +@pytest.fixture +def settings(api_key: str): + """Create a Settings service instance for testing. + + Args: + api_key: API key for authentication. + + Returns: + Settings: A settings service instance initialized with the API key. + + """ + return SettingsService(api_key) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/e2e/test_service.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/e2e/test_service.py new file mode 100644 index 000000000..df134e0fb --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/e2e/test_service.py @@ -0,0 +1,45 @@ +"""End-to-end tests for Hyperbolic Settings service. + +These tests make real API calls to the Hyperbolic platform. +They require a valid API key in the HYPERBOLIC_API_KEY environment variable. +""" + +import pytest +import requests + +from coinbase_agentkit.action_providers.hyperboliclabs.settings.types import ( + WalletLinkRequest, + WalletLinkResponse, +) + +ALREADY_ASSIGNED_ETH_ADDRESS = "0x6eD68a1982ac2266ceB9C1907B629649aAd9AC20" + + +@pytest.mark.e2e +def test_settings_link_wallet_success(settings, random_eth_address): + """Test successfully linking a wallet address.""" + request = WalletLinkRequest(address=random_eth_address) + response = settings.link_wallet(request) + + assert isinstance(response, WalletLinkResponse) + + if response.status == "success": + print("Successfully linked wallet address") + else: + pytest.skip(f"Couldn't link random wallet address: {response.message}") + + +@pytest.mark.e2e +def test_settings_link_wallet_already_assigned(settings): + """Test the case where the wallet address is already assigned.""" + print(f"\nTesting with known assigned address: {ALREADY_ASSIGNED_ETH_ADDRESS}") + + request = WalletLinkRequest(address=ALREADY_ASSIGNED_ETH_ADDRESS) + + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + settings.link_wallet(request) + + assert "403" in str(excinfo.value), "Expected a 403 Forbidden error" + assert ( + "already assigned" in str(excinfo.value).lower() + ), f"Expected 'already assigned' error but got: {excinfo.value!s}" diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/test_action_provider.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/test_action_provider.py new file mode 100644 index 000000000..b5dfc1b62 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/test_action_provider.py @@ -0,0 +1,54 @@ +"""Tests for the Hyperbolic Settings action provider initialization.""" + +import os +from unittest.mock import patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.settings.action_provider import ( + SettingsActionProvider, + hyperbolic_settings_action_provider, +) +from coinbase_agentkit.network import Network + + +def test_init_with_api_key(mock_api_key): + """Test initialization with API key.""" + provider = SettingsActionProvider(api_key=mock_api_key) + assert provider is not None + assert provider.api_key == mock_api_key + + +def test_init_with_env_var(mock_api_key): + """Test initialization with environment variable.""" + with patch.dict(os.environ, {"HYPERBOLIC_API_KEY": mock_api_key}): + provider = SettingsActionProvider() + assert provider is not None + assert provider.api_key == mock_api_key + + +def test_init_missing_api_key(): + """Test initialization with missing API key.""" + with patch.dict(os.environ, clear=True), pytest.raises(ValueError): + SettingsActionProvider() + + +def test_supports_network(mock_api_key): + """Test supports_network method.""" + provider = SettingsActionProvider(api_key=mock_api_key) + network = Network( + name="test_network", + protocol_family="ethereum", + chain_id="1", + network_id="1", + ) + assert provider.supports_network(network) is True + + +def test_factory_function(mock_api_key): + """Test the factory function.""" + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.settings.action_provider.SettingsActionProvider" + ) as mock: + hyperbolic_settings_action_provider(mock_api_key) + mock.assert_called_once_with(api_key=mock_api_key) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/test_link_wallet_address.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/test_link_wallet_address.py new file mode 100644 index 000000000..27cd135b5 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/test_link_wallet_address.py @@ -0,0 +1,64 @@ +"""Tests for link_wallet_address action in HyperbolicSettingsActionProvider.""" + +from unittest.mock import patch + +import pytest + +from coinbase_agentkit.action_providers.hyperboliclabs.settings.types import WalletLinkResponse + +VALID_ETH_ADDRESS = "0x123456789abcdef" + + +def test_link_wallet_address_success(provider): + """Test successful wallet address linking.""" + mock_response = WalletLinkResponse(success=True, message="Wallet address linked successfully") + + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.settings, "link_wallet", return_value=mock_response), + ): + result = provider.link_wallet_address({"address": VALID_ETH_ADDRESS}) + + assert '"success": true' in result + assert '"message": "Wallet address linked successfully"' in result + assert f"wallet_address: {VALID_ETH_ADDRESS}" in result + + assert "Next Steps:" in result + assert "1. Your wallet has been successfully linked to your Hyperbolic account" in result + assert "2. To add funds, send any of these tokens on Base Mainnet:" in result + assert "- USDC" in result + assert "- USDT" in result + assert "- DAI" in result + assert ( + "3. Send to this Hyperbolic address: 0xd3cB24E0Ba20865C530831C85Bd6EbC25f6f3B60" + in result + ) + + +def test_link_wallet_address_api_error(provider): + """Test wallet address linking with API error.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + patch.object(provider.settings, "link_wallet", side_effect=Exception("API Error")), + ): + result = provider.link_wallet_address({"address": VALID_ETH_ADDRESS}) + assert "Error: Wallet linking: API Error" in result + + +def test_link_wallet_address_missing_address(provider): + """Test wallet address linking with missing address.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + ): + with pytest.raises(Exception) as exc_info: + provider.link_wallet_address({}) + assert "Field required" in str(exc_info.value) + + +def test_link_wallet_address_empty_address(provider): + """Test wallet address linking with empty address.""" + with ( + patch("coinbase_agentkit.action_providers.action_decorator.send_analytics_event"), + ): + result = provider.link_wallet_address({"address": ""}) + assert "Error" in result diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/test_service.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/test_service.py new file mode 100644 index 000000000..ff2aabe8c --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/settings/test_service.py @@ -0,0 +1,130 @@ +"""Test HyperbolicLabs Settings service.""" + +from unittest.mock import ANY + +import pytest +import requests + +from coinbase_agentkit.action_providers.hyperboliclabs.constants import ( + SETTINGS_BASE_URL, + SETTINGS_ENDPOINTS, +) +from coinbase_agentkit.action_providers.hyperboliclabs.settings.service import SettingsService +from coinbase_agentkit.action_providers.hyperboliclabs.settings.types import ( + WalletLinkRequest, + WalletLinkResponse, +) + + +def test_settings_service_init(mock_api_key): + """Test Settings service initialization.""" + service = SettingsService(mock_api_key) + assert service.base_url == SETTINGS_BASE_URL + + +def test_settings_link_wallet_success(mock_request, mock_api_key): + """Test successful wallet linking.""" + service = SettingsService(mock_api_key) + mock_response = { + "success": True, + } + mock_request.return_value.json.return_value = mock_response + + wallet_address = "0x1234567890abcdef1234567890abcdef12345678" + request = WalletLinkRequest(address=wallet_address) + response = service.link_wallet(request) + + assert isinstance(response, WalletLinkResponse) + assert response.success is True + + mock_request.assert_called_with( + method="POST", + url=f"{SETTINGS_BASE_URL}{SETTINGS_ENDPOINTS['LINK_WALLET']}", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {mock_api_key}"}, + json={"address": wallet_address}, + params=None, + ) + + +@pytest.mark.parametrize( + "invalid_address", + [ + "0xinvalid", + "not-a-wallet", + ], +) +def test_settings_link_wallet_invalid_address(mock_request, mock_api_key, invalid_address): + """Test wallet linking with invalid addresses.""" + service = SettingsService(mock_api_key) + mock_request.side_effect = requests.exceptions.HTTPError( + "400 Bad Request: Invalid wallet address" + ) + + with pytest.raises(requests.exceptions.HTTPError, match="400 Bad Request"): + request = WalletLinkRequest(address=invalid_address) + service.link_wallet(request) + + +def test_settings_service_error_handling(mock_request, mock_api_key): + """Test error handling in settings service.""" + service = SettingsService(mock_api_key) + mock_request.side_effect = requests.exceptions.HTTPError("403 Forbidden: Unauthorized access") + + with pytest.raises(requests.exceptions.HTTPError, match="403 Forbidden"): + address = "0x1234567890abcdef1234567890abcdef12345678" + request = WalletLinkRequest(address=address) + service.link_wallet(request) + + +def test_link_wallet_success(mock_request, mock_api_key): + """Test that linking a wallet address works correctly.""" + service = SettingsService(mock_api_key) + mock_request.return_value.json.return_value = { + "success": True, + "message": "Wallet address linked successfully", + "address": "0x1234567890abcdef1234567890abcdef12345678", + } + + wallet_address = "0x1234567890abcdef1234567890abcdef12345678" + request = WalletLinkRequest(address=wallet_address) + response = service.link_wallet(request) + + assert isinstance(response, WalletLinkResponse) + assert response.success is True + assert response.message == "Wallet address linked successfully" + + mock_request.assert_called_once_with( + method="POST", + url=f"{SETTINGS_BASE_URL}{SETTINGS_ENDPOINTS['LINK_WALLET']}", + headers=ANY, + json={"address": wallet_address}, + params=None, + ) + + +def test_link_wallet_invalid_address(mock_request, mock_api_key): + """Test that linking an invalid wallet address raises an error.""" + service = SettingsService(mock_api_key) + mock_request.side_effect = requests.exceptions.HTTPError( + "422 Client Error: Unprocessable Entity" + ) + + invalid_address = "not-a-valid-address" + request = WalletLinkRequest(address=invalid_address) + + with pytest.raises(requests.exceptions.HTTPError): + service.link_wallet(request) + + +def test_link_wallet_api_error(mock_request, mock_api_key): + """Test that API errors are properly propagated.""" + service = SettingsService(mock_api_key) + mock_request.side_effect = requests.exceptions.HTTPError( + "500 Server Error: Internal Server Error" + ) + + address = "0x1234567890abcdef1234567890abcdef12345678" + request = WalletLinkRequest(address=address) + + with pytest.raises(requests.exceptions.HTTPError): + service.link_wallet(request) diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/test_hyperbolic_action_provider.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/test_hyperbolic_action_provider.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/test_service.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/test_service.py new file mode 100644 index 000000000..a9352e4f9 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/test_service.py @@ -0,0 +1,97 @@ +"""Unit tests for the Base service class.""" + +from unittest.mock import patch + +import pytest +import requests + +from coinbase_agentkit.action_providers.hyperboliclabs.service import Base + + +@pytest.fixture +def mock_request(): + """Mock the request function for testing.""" + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.service.requests.request" + ) as mock: + mock.return_value.status_code = 200 + mock.return_value.json.return_value = {"status": "success"} + mock.return_value.raise_for_status.return_value = None + yield mock + + +def test_init(): + """Test Base class initialization.""" + base = Base("test_api_key") + assert base.api_key == "test_api_key" + assert base.base_url is not None + + +def test_make_request(): + """Test make_request method.""" + base = Base("test_api_key", "https://api.example.com") + + with patch( + "coinbase_agentkit.action_providers.hyperboliclabs.service.requests.request" + ) as mock_request: + mock_response = mock_request.return_value + mock_response.json.return_value = {"status": "success"} + mock_response.ok = True + + response = base.make_request("/test") + + mock_request.assert_called_once() + assert response is mock_response + assert response.json() == {"status": "success"} + + +def test_service_init(mock_api_key): + """Test Base service initialization.""" + service = Base(mock_api_key) + assert service.api_key == mock_api_key + + custom_url = "https://custom.api.com" + service_with_url = Base(mock_api_key, custom_url) + assert service_with_url.base_url == custom_url + + +def test_service_make_request(mock_request, mock_api_key): + """Test Base service make_request method.""" + service = Base(mock_api_key) + mock_response = mock_request.return_value + mock_response.json.return_value = {"status": "success"} + + response = service.make_request("/test") + assert response is mock_response + assert response.json() == {"status": "success"} + mock_request.assert_called_once() + + data = {"key": "value"} + params = {"query": "param"} + custom_headers = {"Custom": "Header"} + + response = service.make_request( + endpoint="/test", method="POST", data=data, params=params, headers=custom_headers + ) + assert response is mock_response + assert response.json() == {"status": "success"} + assert mock_request.call_count == 2 + + +def test_service_make_request_error(mock_request, mock_api_key): + """Test Base service error handling.""" + service = Base(mock_api_key) + mock_request.side_effect = requests.exceptions.HTTPError("500 Server Error") + + with pytest.raises(requests.exceptions.HTTPError, match="500 Server Error"): + service.make_request("/test") + + +def test_service_make_request_invalid_method(mock_request, mock_api_key): + """Test Base service with invalid HTTP method.""" + service = Base(mock_api_key) + + mock_request.side_effect = ValueError("Invalid HTTP method: INVALID") + + with pytest.raises(ValueError, match="Invalid HTTP method"): + service.make_request("/test", method="INVALID") diff --git a/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/test_service_e2e.py b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/test_service_e2e.py new file mode 100644 index 000000000..1cd79068e --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/hyperboliclabs/test_service_e2e.py @@ -0,0 +1,32 @@ +"""End-to-end tests for the Base service class.""" + +import pytest +import requests + +from coinbase_agentkit.action_providers.hyperboliclabs.service import Base + + +@pytest.mark.e2e +def test_make_request_success(api_key): + """Test successful API request.""" + base = Base(api_key) + + try: + response = base.make_request(endpoint="/v1", method="GET") + + assert isinstance(response, requests.Response) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + print(f"Endpoint not found but service is responsive: {e}") + else: + raise + + +@pytest.mark.e2e +def test_make_request_error(api_key): + """Test API request with error response.""" + base = Base(api_key) + + with pytest.raises(requests.exceptions.HTTPError): + base.make_request(endpoint="/nonexistent-endpoint", method="GET") diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/__init__.py b/python/coinbase-agentkit/tests/action_providers/ssh/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/conftest.py b/python/coinbase-agentkit/tests/action_providers/ssh/conftest.py new file mode 100644 index 000000000..4ee1da771 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/conftest.py @@ -0,0 +1,125 @@ +"""Test fixtures for ssh action provider tests.""" + +from unittest import mock + +import paramiko +import pytest + +from coinbase_agentkit.action_providers.ssh.connection import SSHConnection, SSHConnectionParams +from coinbase_agentkit.action_providers.ssh.ssh_action_provider import SshActionProvider + +MOCK_CONNECTION_ID = "test-conn" +MOCK_CONNECTION_HOST = "test-host" +MOCK_CONNECTION_USERNAME = "test-user" +MOCK_CONNECTION_PORT = 22 +MOCK_CONNECTION_PASSWORD = "test-pass" +MOCK_CONNECTION_INFO = "Connection Info Mock" + + +@pytest.fixture +def mock_ssh_client(): + """Create a mock SSH client with standard behaviors.""" + mock_client = mock.Mock(spec=paramiko.SSHClient) + + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"Connection successful" + mock_stdout.channel = mock.Mock() + mock_stdout.channel.recv_exit_status.return_value = 0 + + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"" + + mock_client.mock_stdout = mock_stdout + mock_client.mock_stderr = mock_stderr + + mock_client.exec_command.return_value = (None, mock_stdout, mock_stderr) + + mock_transport = mock.Mock() + mock_transport.is_active.return_value = True + mock_client.get_transport.return_value = mock_transport + + return mock_client + + +@pytest.fixture +def mock_rsa_key(): + """Create a mock RSA key.""" + mock_key = mock.Mock(spec=paramiko.RSAKey) + mock_key.from_private_key_file.return_value = mock_key + mock_key.from_private_key.return_value = mock_key + return mock_key + + +@pytest.fixture +def mock_fs(): + """Create mock filesystem operations.""" + with ( + mock.patch("os.path.exists") as mock_exists, + mock.patch("os.path.expanduser") as mock_expanduser, + mock.patch("os.path.dirname") as mock_dirname, + mock.patch("os.makedirs") as mock_makedirs, + ): + mock_exists.return_value = True + mock_expanduser.side_effect = lambda x: x.replace("~", "/home/user") + mock_dirname.side_effect = lambda x: "/".join(x.split("/")[:-1]) + yield { + "exists": mock_exists, + "expanduser": mock_expanduser, + "dirname": mock_dirname, + "makedirs": mock_makedirs, + } + + +@pytest.fixture +def connection_params(): + """Create a standard set of connection parameters for testing.""" + return SSHConnectionParams( + connection_id=MOCK_CONNECTION_ID, + host=MOCK_CONNECTION_HOST, + username=MOCK_CONNECTION_USERNAME, + password=MOCK_CONNECTION_PASSWORD, + port=MOCK_CONNECTION_PORT, + ) + + +@pytest.fixture +def ssh_connection(mock_ssh_client, connection_params): + """Create an SSH connection instance with all mocks configured.""" + conn = SSHConnection(connection_params) + conn.ssh_client = mock_ssh_client + conn.connected = False + + def set_connected(connected=True): + conn.connected = connected + return mock.patch.object(conn, "is_connected", return_value=connected) + + conn.set_connected = set_connected + + return conn + + +@pytest.fixture +def ssh_provider(): + """Create a SshActionProvider instance with mocked connections for testing.""" + with mock.patch( + "coinbase_agentkit.action_providers.ssh.ssh_action_provider.SSHConnectionPool" + ) as mock_pool_class: + mock_pool = mock_pool_class.return_value + + mock_pool.has_connection.return_value = False + mock_pool.get_connections.return_value = {} + + mock_connection = mock.Mock() + mock_connection.params.host = MOCK_CONNECTION_HOST + mock_connection.params.username = MOCK_CONNECTION_USERNAME + mock_connection.params.port = MOCK_CONNECTION_PORT + mock_connection.is_connected.return_value = True + mock_connection.get_connection_info.return_value = MOCK_CONNECTION_INFO + + mock_pool.get_connection.return_value = mock_connection + mock_pool.create_connection.return_value = mock_connection + mock_pool.close_connection.return_value = mock_connection + + provider = SshActionProvider() + provider.connection_pool = mock_pool + return provider diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_action_provider.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_action_provider.py new file mode 100644 index 000000000..0f8e50244 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_action_provider.py @@ -0,0 +1,20 @@ +"""Tests for SshActionProvider basic functionality. + +This module tests the initialization and basic functionality of the SshActionProvider, +while specific action tests have been moved to their respective files. +""" + +from coinbase_agentkit.action_providers.ssh.ssh_action_provider import SshActionProvider + + +def test_initialization(ssh_provider): + """Test provider initialization.""" + assert isinstance(ssh_provider, SshActionProvider) + assert ssh_provider.name == "ssh" + + +def test_supports_all_networks(ssh_provider): + """Test that the provider supports all networks.""" + networks = ["ethereum", "bitcoin", "ethereum_sepolia", "polygon"] + for network in networks: + assert ssh_provider.supports_network(network) is True diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_add_host_key.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_add_host_key.py new file mode 100644 index 000000000..3583f0422 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_add_host_key.py @@ -0,0 +1,158 @@ +"""Tests for ssh_add_host_key action. + +This module tests the ssh_add_host_key action of the SshActionProvider, +which adds host keys to the SSH known_hosts file. +""" + +import os +import tempfile +from unittest import mock + +import pytest + + +@pytest.fixture +def temp_known_hosts(): + """Create a temporary known_hosts file for testing.""" + with tempfile.NamedTemporaryFile(mode="w+", delete=False) as temp_file: + temp_file.write("existing.example.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ==\n") + temp_file.write("other.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHRVs==\n") + temp_file_path = temp_file.name + + yield temp_file_path + + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +def test_add_host_key_basic(ssh_provider, temp_known_hosts): + """Test adding a new host key.""" + result = ssh_provider.ssh_add_host_key( + { + "host": "test.example.com", + "key": "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==", + "known_hosts_file": temp_known_hosts, + } + ) + + assert "successfully added" in result + assert "Host key for 'test.example.com'" in result + + with open(temp_known_hosts) as f: + content = f.read() + + assert "test.example.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ==" in content + + +def test_add_host_key_update_existing(ssh_provider, temp_known_hosts): + """Test updating an existing host key.""" + result = ssh_provider.ssh_add_host_key( + { + "host": "existing.example.com", + "key": "NEWKEY_AAAAB3NzaC1yc2EAAAADAQABAAABAQ==", + "known_hosts_file": temp_known_hosts, + } + ) + + assert "updated in" in result + assert "Host key for 'existing.example.com'" in result + + with open(temp_known_hosts) as f: + content = f.read() + + assert "existing.example.com ssh-rsa NEWKEY_AAAAB3NzaC1yc2EAAAADAQABAAABAQ==" in content + + +def test_add_host_key_with_custom_port(ssh_provider, temp_known_hosts): + """Test adding a host key with a non-standard port.""" + result = ssh_provider.ssh_add_host_key( + { + "host": "[port.example.com]:2222", + "key": "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==", + "known_hosts_file": temp_known_hosts, + } + ) + + assert "successfully added" in result + assert "Host key for '[port.example.com]:2222'" in result + + with open(temp_known_hosts) as f: + content = f.read() + + assert "[port.example.com]:2222 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ==" in content + + +def test_add_host_key_with_custom_key_type(ssh_provider, temp_known_hosts): + """Test adding a host key with a custom key type.""" + result = ssh_provider.ssh_add_host_key( + { + "host": "keytype.example.com", + "key": "AAAAC3NzaC1lZDI1NTE5AAAAIHRVs==", + "key_type": "ssh-ed25519", + "known_hosts_file": temp_known_hosts, + } + ) + + assert "successfully added" in result + assert "Host key for 'keytype.example.com'" in result + + with open(temp_known_hosts) as f: + content = f.read() + + assert "keytype.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHRVs==" in content + + +def test_add_host_key_create_file(ssh_provider): + """Test adding a host key when the known_hosts file doesn't exist.""" + with tempfile.TemporaryDirectory() as temp_dir: + new_file_path = os.path.join(temp_dir, "new_known_hosts") + + result = ssh_provider.ssh_add_host_key( + { + "host": "new.example.com", + "key": "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==", + "known_hosts_file": new_file_path, + } + ) + + assert "successfully added" in result + assert "Host key for 'new.example.com'" in result + + assert os.path.exists(new_file_path) + with open(new_file_path) as f: + content = f.read() + + assert "new.example.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ==" in content + + +def test_add_host_key_invalid_params(ssh_provider): + """Test adding a host key with invalid parameters.""" + result = ssh_provider.ssh_add_host_key( + { + "key_type": "ssh-rsa", + } + ) + + assert "Invalid input parameters" in result + + +def test_add_host_key_file_error(ssh_provider): + """Test handling file access errors.""" + with ( + mock.patch("os.path.exists") as mock_exists, + mock.patch("os.makedirs"), + mock.patch("builtins.open") as mock_open, + ): + mock_exists.return_value = True + + mock_open.side_effect = OSError("Permission denied") + + result = ssh_provider.ssh_add_host_key( + { + "host": "error.example.com", + "key": "AAAAB3NzaC1yc2EAAAADAQABAAABAQ==", + } + ) + + assert "Error" in result + assert "Error: File operation:" in result diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_connection.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_connection.py new file mode 100644 index 000000000..3ec4775b7 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_connection.py @@ -0,0 +1,302 @@ +"""Tests for SSH connection initialization and basic methods. + +This module tests the basic functionality of the SSHConnection class, including +initialization, connection establishment, and status checking. +""" + +from unittest import mock + +import paramiko +import pytest + +from coinbase_agentkit.action_providers.ssh.connection import ( + SSHConnection, + SSHConnectionError, + SSHConnectionParams, + SSHKeyError, +) + +MOCK_CONNECTION_ID = "test-conn" +MOCK_HOST = "example.com" +MOCK_USERNAME = "testuser" +MOCK_PASSWORD = "testpass" +MOCK_PORT = 22 +MOCK_TIMEOUT = 10 +MOCK_PRIVATE_KEY = "KEY_CONTENT" +MOCK_KEY_PATH = "/path/to/key" +MOCK_EXPANDED_KEY_PATH = "/expanded/path/to/key" + + +@pytest.fixture +def connection_params(): + """Create a standard set of connection parameters for testing.""" + return SSHConnectionParams( + connection_id=MOCK_CONNECTION_ID, + host=MOCK_HOST, + username=MOCK_USERNAME, + password=MOCK_PASSWORD, + ) + + +@pytest.fixture +def ssh_connection(connection_params): + """Create an SSH connection instance for testing.""" + return SSHConnection(connection_params) + + +def test_ssh_connection_initialization(ssh_connection, connection_params): + """Test that SSH connection is initialized correctly.""" + assert ssh_connection.params == connection_params + assert ssh_connection.ssh_client is None + assert ssh_connection.connected is False + assert ssh_connection.connection_time is None + + +def test_connect_with_password(ssh_connection): + """Test connecting with password authentication.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"Connection successful" + mock_stdout.channel.recv_exit_status.return_value = 0 + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"" + mock_client.exec_command.return_value = (None, mock_stdout, mock_stderr) + + ssh_connection.connect() + + mock_client.set_missing_host_key_policy.assert_called_once() + mock_client.connect.assert_called_once_with( + hostname=MOCK_HOST, + username=MOCK_USERNAME, + password=MOCK_PASSWORD, + port=MOCK_PORT, + timeout=MOCK_TIMEOUT, + ) + assert ssh_connection.connected is True + assert ssh_connection.connection_time is not None + + +def test_connect_with_password_failure(ssh_connection): + """Test handling connection failure with password authentication.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + mock_client.connect.side_effect = paramiko.SSHException("Authentication failed") + + with pytest.raises(SSHConnectionError) as exc_info: + ssh_connection.connect() + + assert "Failed to connect with password" in str(exc_info.value) + assert ssh_connection.connected is False + + +def test_connect_with_key(connection_params): + """Test connecting with private key authentication.""" + key_params = SSHConnectionParams( + connection_id=MOCK_CONNECTION_ID, + host=MOCK_HOST, + username=MOCK_USERNAME, + private_key=MOCK_PRIVATE_KEY, + ) + ssh_connection = SSHConnection(key_params) + + with ( + mock.patch("paramiko.SSHClient") as mock_ssh_client_class, + mock.patch("paramiko.RSAKey") as mock_rsa_key, + ): + mock_client = mock_ssh_client_class.return_value + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"Connection successful" + mock_stdout.channel.recv_exit_status.return_value = 0 + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"" + mock_client.exec_command.return_value = (None, mock_stdout, mock_stderr) + + mock_key = mock.Mock() + mock_rsa_key.from_private_key.return_value = mock_key + + ssh_connection.connect() + + mock_client.set_missing_host_key_policy.assert_called_once() + mock_client.connect.assert_called_once_with( + hostname=MOCK_HOST, + username=MOCK_USERNAME, + pkey=mock_key, + port=MOCK_PORT, + timeout=MOCK_TIMEOUT, + ) + assert ssh_connection.connected is True + + +def test_connect_with_key_path(): + """Test connecting with key path authentication.""" + params = SSHConnectionParams( + connection_id=MOCK_CONNECTION_ID, + host=MOCK_HOST, + username=MOCK_USERNAME, + private_key_path=MOCK_KEY_PATH, + ) + ssh_connection = SSHConnection(params) + + with ( + mock.patch("paramiko.SSHClient") as mock_ssh_client_class, + mock.patch("paramiko.RSAKey") as mock_rsa_key, + mock.patch("os.path.exists") as mock_exists, + mock.patch("os.path.expanduser") as mock_expanduser, + ): + mock_client = mock_ssh_client_class.return_value + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"Connection successful" + mock_stdout.channel.recv_exit_status.return_value = 0 + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"" + mock_client.exec_command.return_value = (None, mock_stdout, mock_stderr) + + mock_expanduser.return_value = MOCK_EXPANDED_KEY_PATH + mock_exists.return_value = True + + mock_key = mock.Mock() + mock_rsa_key.from_private_key_file.return_value = mock_key + + ssh_connection.connect() + + mock_client.set_missing_host_key_policy.assert_called_once() + mock_client.connect.assert_called_once_with( + hostname=MOCK_HOST, + username=MOCK_USERNAME, + pkey=mock_key, + port=MOCK_PORT, + timeout=MOCK_TIMEOUT, + ) + assert ssh_connection.connected is True + + +def test_connect_with_nonexistent_key_file(): + """Test error when key file doesn't exist.""" + params = SSHConnectionParams( + connection_id=MOCK_CONNECTION_ID, + host=MOCK_HOST, + username=MOCK_USERNAME, + private_key_path=MOCK_KEY_PATH, + ) + ssh_connection = SSHConnection(params) + + with ( + mock.patch("os.path.exists") as mock_exists, + mock.patch("os.path.expanduser") as mock_expanduser, + ): + mock_expanduser.return_value = MOCK_EXPANDED_KEY_PATH + mock_exists.return_value = False + + with pytest.raises(SSHKeyError) as exc_info: + ssh_connection.connect() + + assert "Key file not found" in str(exc_info.value) + + +def test_is_connected_true(ssh_connection): + """Test is_connected when connection is active.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"1" + mock_client.exec_command.return_value = (None, mock_stdout, None) + + result = ssh_connection.is_connected() + + assert result is True + mock_client.exec_command.assert_called_once_with("echo 1", timeout=5) + + +def test_is_connected_failed_command(ssh_connection): + """Test is_connected when echo test fails.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"" + mock_client.exec_command.return_value = (None, mock_stdout, None) + + result = ssh_connection.is_connected() + + assert result is False + assert ssh_connection.connected is False + assert ssh_connection.ssh_client is None + + +def test_is_connected_no_client(ssh_connection): + """Test is_connected when no SSH client exists.""" + assert ssh_connection.is_connected() is False + + +def test_is_connected_not_connected_flag(ssh_connection): + """Test is_connected when connected flag is False.""" + ssh_connection.ssh_client = mock.Mock() + ssh_connection.connected = False + assert ssh_connection.is_connected() is False + + +def test_reset_connection(ssh_connection): + """Test resetting a connection.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + ssh_connection.reset_connection() + + assert ssh_connection.connected is False + assert ssh_connection.connection_time is None + assert ssh_connection.ssh_client is None + mock_client.close.assert_called_once() + + +def test_disconnect(ssh_connection): + """Test disconnecting from SSH server.""" + with mock.patch.object(ssh_connection, "reset_connection") as mock_reset: + ssh_connection.disconnect() + mock_reset.assert_called_once() + + +def test_get_connection_info_connected(ssh_connection): + """Test get_connection_info when connected.""" + ssh_connection.params.connection_id = MOCK_CONNECTION_ID + ssh_connection.params.host = MOCK_HOST + ssh_connection.params.port = MOCK_PORT + ssh_connection.params.username = MOCK_USERNAME + ssh_connection.connected = True + + with mock.patch.object(ssh_connection, "is_connected", return_value=True): + info = ssh_connection.get_connection_info() + + assert f"Connection ID: {MOCK_CONNECTION_ID}" in info + assert "Status: Connected" in info + assert f"Host: {MOCK_HOST}:{MOCK_PORT}" in info + assert f"Username: {MOCK_USERNAME}" in info + + +def test_get_connection_info_not_connected(ssh_connection): + """Test get_connection_info when not connected.""" + ssh_connection.params.connection_id = MOCK_CONNECTION_ID + + with mock.patch.object(ssh_connection, "is_connected", return_value=False): + info = ssh_connection.get_connection_info() + + assert f"Connection ID: {MOCK_CONNECTION_ID}" in info + assert "Status: Not connected" in info + + +def test_connection_context_manager(ssh_connection): + """Test using SSHConnection as a context manager.""" + mock_disconnect = mock.Mock() + ssh_connection.disconnect = mock_disconnect + + with ssh_connection as conn: + assert conn is ssh_connection + + mock_disconnect.assert_called_once() diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_connection_pool.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_connection_pool.py new file mode 100644 index 000000000..88af7fd45 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_connection_pool.py @@ -0,0 +1,299 @@ +"""Tests for SSH connection pool. + +This module provides comprehensive tests for the SSHConnectionPool class +and its interaction with SSHConnection. +""" + +from unittest import mock + +import pytest + +from coinbase_agentkit.action_providers.ssh.connection import ( + SSHConnection, + SSHConnectionError, + SSHConnectionParams, +) +from coinbase_agentkit.action_providers.ssh.connection_pool import SSHConnectionPool + +MOCK_CONNECTION_ID = "test-conn" +MOCK_HOST = "example.com" +MOCK_USERNAME = "testuser" +MOCK_PASSWORD = "testpass" + +MOCK_CONNECTION_ID2 = "test-conn2" +MOCK_HOST2 = "example2.com" +MOCK_USERNAME2 = "testuser2" +MOCK_PASSWORD2 = "testpass2" + + +@pytest.fixture +def connection_params(): + """Create valid connection parameters for testing.""" + return SSHConnectionParams( + connection_id=MOCK_CONNECTION_ID, + host=MOCK_HOST, + username=MOCK_USERNAME, + password=MOCK_PASSWORD, + ) + + +@pytest.fixture +def connection_pool(): + """Create a connection pool for testing.""" + return SSHConnectionPool(max_connections=3) + + +def test_pool_initialization(connection_pool): + """Test connection pool initialization.""" + assert connection_pool.max_connections == 3 + assert connection_pool.connections == {} + assert connection_pool.connection_params == {} + + +def test_pool_has_connection_false(connection_pool): + """Test has_connection returns False when connection doesn't exist.""" + assert connection_pool.has_connection("nonexistent-id") is False + + +def test_pool_has_connection_true(connection_pool, connection_params): + """Test has_connection returns True when connection exists.""" + with mock.patch.object(SSHConnection, "__init__", return_value=None): + connection = SSHConnection(connection_params) + connection_pool.connections[MOCK_CONNECTION_ID] = connection + + assert connection_pool.has_connection(MOCK_CONNECTION_ID) is True + + +def test_pool_get_connection_params(connection_pool, connection_params): + """Test getting connection parameters.""" + connection_pool.connection_params[MOCK_CONNECTION_ID] = connection_params + retrieved_params = connection_pool._get_connection_params(MOCK_CONNECTION_ID) + + assert retrieved_params == connection_params + assert retrieved_params.connection_id == MOCK_CONNECTION_ID + assert retrieved_params.host == MOCK_HOST + + +def test_pool_get_connection_params_nonexistent(connection_pool): + """Test getting nonexistent connection parameters.""" + assert connection_pool._get_connection_params("nonexistent-id") is None + + +def test_pool_set_connection_params(connection_pool, connection_params): + """Test setting connection parameters.""" + result = connection_pool._set_connection_params(connection_params) + + assert result == connection_params + assert connection_pool.connection_params[MOCK_CONNECTION_ID] == connection_params + + +def test_pool_remove_connection_params(connection_pool, connection_params): + """Test removing connection parameters.""" + connection_pool.connection_params[MOCK_CONNECTION_ID] = connection_params + connection_pool._remove_connection_params(MOCK_CONNECTION_ID) + + assert MOCK_CONNECTION_ID not in connection_pool.connection_params + + +def test_pool_remove_nonexistent_connection_params(connection_pool): + """Test removing nonexistent connection parameters.""" + connection_pool._remove_connection_params("nonexistent-id") + + +def test_pool_get_connection_nonexistent(connection_pool): + """Test get_connection for a nonexistent connection.""" + with pytest.raises(SSHConnectionError) as exc_info: + connection_pool.get_connection("nonexistent-id") + + assert "Connection ID 'nonexistent-id' not found" in str(exc_info.value) + + +def test_pool_get_connection_existing(connection_pool, connection_params): + """Test get_connection for an existing connection.""" + with mock.patch.object(SSHConnection, "__init__", return_value=None): + connection = SSHConnection(connection_params) + connection_pool.connections[MOCK_CONNECTION_ID] = connection + + result = connection_pool.get_connection(MOCK_CONNECTION_ID) + + assert result == connection + + +def test_pool_get_connection_from_params(connection_pool, connection_params): + """Test get_connection recreates connection from stored params.""" + connection_pool.connection_params[MOCK_CONNECTION_ID] = connection_params + + mock_connection = mock.Mock() + with mock.patch.object( + connection_pool, "create_connection", return_value=mock_connection + ) as mock_create: + result = connection_pool.get_connection(MOCK_CONNECTION_ID) + + assert result == mock_connection + mock_create.assert_called_once_with(connection_params) + + +def test_pool_create_connection(connection_pool, connection_params): + """Test creating a new connection.""" + with mock.patch( + "coinbase_agentkit.action_providers.ssh.connection_pool.SSHConnection" + ) as mock_connection_class: + mock_connection = mock.Mock() + mock_connection_class.return_value = mock_connection + + result = connection_pool.create_connection(connection_params) + + assert result == mock_connection + assert connection_pool.connections[MOCK_CONNECTION_ID] == mock_connection + assert MOCK_CONNECTION_ID in connection_pool.connection_params + mock_connection_class.assert_called_once_with(connection_params) + + +def test_pool_create_connection_limit_reached(connection_pool, connection_params): + """Test creating a connection when limit is reached.""" + connection_pool.max_connections = 1 + with mock.patch("coinbase_agentkit.action_providers.ssh.connection_pool.SSHConnection"): + connection_pool.create_connection(connection_params) + + params2 = SSHConnectionParams( + connection_id=MOCK_CONNECTION_ID2, + host=MOCK_HOST2, + username=MOCK_USERNAME2, + password=MOCK_PASSWORD2, + ) + + with pytest.raises(SSHConnectionError) as exc_info: + connection_pool.create_connection(params2) + + assert "Connection limit reached" in str(exc_info.value) + + +def test_pool_create_connection_validation_error(connection_pool): + """Test creating a connection with invalid parameters.""" + invalid_params = mock.Mock() + invalid_params.connection_id = "invalid-conn" + + with mock.patch( + "coinbase_agentkit.action_providers.ssh.connection_pool.SSHConnection", + side_effect=ValueError("Invalid params"), + ): + with pytest.raises(ValueError) as exc_info: + connection_pool.create_connection(invalid_params) + + assert "Invalid connection parameters" in str(exc_info.value) + assert "invalid-conn" in str(exc_info.value) + + assert "invalid-conn" not in connection_pool.connection_params + + +def test_pool_close_idle_connections(connection_pool, connection_params): + """Test closing idle connections.""" + mock_active = mock.Mock() + mock_active.is_connected.return_value = True + + mock_idle = mock.Mock() + mock_idle.is_connected.return_value = False + + connection_pool.connections = { + "active-conn": mock_active, + "idle-conn": mock_idle, + } + + with mock.patch.object(connection_pool, "close_connection") as mock_close: + closed_count = connection_pool.close_idle_connections() + + assert closed_count == 1 + mock_close.assert_called_once_with("idle-conn") + + +def test_pool_close_connection_existing(connection_pool): + """Test closing an existing connection.""" + mock_connection = mock.Mock() + connection_pool.connections = {"test-conn": mock_connection} + + result = connection_pool.close_connection("test-conn") + + assert result == mock_connection + assert "test-conn" not in connection_pool.connections + mock_connection.disconnect.assert_called_once() + + +def test_pool_close_connection_nonexistent(connection_pool): + """Test closing a nonexistent connection.""" + result = connection_pool.close_connection("nonexistent-id") + + assert result is None + + +def test_pool_close_and_remove_connection(connection_pool, connection_params): + """Test closing and removing a connection completely.""" + mock_connection = mock.Mock() + connection_pool.connections[MOCK_CONNECTION_ID] = mock_connection + + connection_pool.connection_params[MOCK_CONNECTION_ID] = connection_params + + with mock.patch.object( + connection_pool, "close_connection", return_value=mock_connection + ) as mock_close: + connection_pool.close_and_remove_connection(MOCK_CONNECTION_ID) + + mock_close.assert_called_once_with(MOCK_CONNECTION_ID) + assert MOCK_CONNECTION_ID not in connection_pool.connection_params + + +def test_pool_close_all_connections(connection_pool): + """Test closing all connections.""" + mock_conn1 = mock.Mock() + mock_conn2 = mock.Mock() + + connection_pool.connections = { + "conn1": mock_conn1, + "conn2": mock_conn2, + } + + with mock.patch.object(connection_pool, "close_connection") as mock_close: + connection_pool.close_all_connections() + + assert mock_close.call_count == 2 + mock_close.assert_any_call("conn1") + mock_close.assert_any_call("conn2") + + +def test_pool_clear_connection_pool(connection_pool, connection_params): + """Test clearing the connection pool.""" + mock_connection = mock.Mock() + connection_pool.connections = {"test-conn": mock_connection} + + connection_pool.connection_params[MOCK_CONNECTION_ID] = connection_params + + with mock.patch.object(connection_pool, "close_all_connections") as mock_close_all: + connection_pool.clear_connection_pool() + + mock_close_all.assert_called_once() + assert connection_pool.connection_params == {} + + +def test_pool_get_connections(connection_pool): + """Test getting all connections.""" + mock_conn1 = mock.Mock() + mock_conn2 = mock.Mock() + + connections = { + "conn1": mock_conn1, + "conn2": mock_conn2, + } + connection_pool.connections = connections + + result = connection_pool.get_connections() + + assert result == connections + + +def test_pool_context_manager(connection_pool): + """Test using SSHConnectionPool as a context manager.""" + with ( + mock.patch.object(connection_pool, "clear_connection_pool") as mock_clear, + connection_pool as pool, + ): + assert pool == connection_pool + mock_clear.assert_called_once() diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_disconnect.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_disconnect.py new file mode 100644 index 000000000..608e1165f --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_disconnect.py @@ -0,0 +1,35 @@ +"""Tests for ssh_disconnect action. + +This module tests the ssh_disconnect action of the SshActionProvider, which allows +terminating SSH connections and removing them from the connection pool. +""" + +from unittest import mock + + +def test_ssh_disconnect_success(ssh_provider): + """Test successful SSH disconnection.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + mock_connection.params.host = "example.com" + mock_pool.has_connection.return_value = True + mock_pool.close_connection.return_value = mock_connection + + result = ssh_provider.ssh_disconnect({"connection_id": "test-conn"}) + + assert "Connection ID: test-conn" in result + assert "Disconnected from example.com" in result + mock_pool.close_connection.assert_called_once_with("test-conn") + + +def test_ssh_disconnect_not_found(ssh_provider): + """Test SSH disconnection with connection not found.""" + mock_pool = ssh_provider.connection_pool + mock_pool.has_connection.return_value = False + mock_pool.close_connection.return_value = None + + result = ssh_provider.ssh_disconnect({"connection_id": "test-conn"}) + + assert "Connection ID: test-conn" in result + assert "No active connection to disconnect" in result + mock_pool.close_connection.assert_called_once_with("test-conn") diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_download.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_download.py new file mode 100644 index 000000000..bcd1efa96 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_download.py @@ -0,0 +1,105 @@ +"""Tests for ssh_download action. + +This module tests the ssh_download action of the SshActionProvider, which allows +downloading files from a remote server using SFTP. +""" + +from unittest import mock + +from coinbase_agentkit.action_providers.ssh.connection import SSHConnectionError + + +def test_ssh_download_success(ssh_provider): + """Test successful file download.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + + with ( + mock.patch("os.path.dirname", return_value="/local/directory"), + mock.patch("os.makedirs"), + mock.patch("os.path.expanduser", return_value="/local/path"), + ): + mock_pool.has_connection.return_value = True + mock_pool.get_connection.return_value = mock_connection + mock_connection.is_connected.return_value = True + + result = ssh_provider.ssh_download( + { + "connection_id": "test-conn", + "remote_path": "/remote/path", + "local_path": "/local/path", + } + ) + + assert "File download successful" in result + assert "/remote/path" in result + assert "/local/path" in result + mock_connection.download_file.assert_called_once_with("/remote/path", "/local/path") + + +def test_ssh_download_connection_not_found(ssh_provider): + """Test file download with connection not found.""" + mock_pool = ssh_provider.connection_pool + + mock_pool.has_connection.return_value = False + + result = ssh_provider.ssh_download( + { + "connection_id": "test-conn", + "remote_path": "/remote/path", + "local_path": "/local/path", + } + ) + + assert "Error: Connection ID 'test-conn' not found" in result + mock_pool.has_connection.assert_called_once_with("test-conn") + + +def test_ssh_download_not_connected(ssh_provider): + """Test file download with inactive connection.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + + mock_pool.has_connection.return_value = True + mock_pool.get_connection.return_value = mock_connection + mock_connection.is_connected.return_value = False + + result = ssh_provider.ssh_download( + { + "connection_id": "test-conn", + "remote_path": "/remote/path", + "local_path": "/local/path", + } + ) + + assert "Error: Connection 'test-conn' is not currently active" in result + mock_pool.get_connection.assert_called_once_with("test-conn") + mock_connection.is_connected.assert_called_once() + + +def test_ssh_download_error(ssh_provider): + """Test file download with error.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + + with ( + mock.patch("os.path.dirname", return_value="/local/directory"), + mock.patch("os.makedirs"), + mock.patch("os.path.expanduser", return_value="/local/path"), + ): + mock_pool.has_connection.return_value = True + mock_pool.get_connection.return_value = mock_connection + mock_connection.is_connected.return_value = True + mock_connection.download_file.side_effect = SSHConnectionError("Download failed") + + result = ssh_provider.ssh_download( + { + "connection_id": "test-conn", + "remote_path": "/remote/path", + "local_path": "/local/path", + } + ) + + assert "Error: SSH connection:" in result + assert "Download failed" in result + mock_connection.download_file.assert_called_once_with("/remote/path", "/local/path") diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_execute.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_execute.py new file mode 100644 index 000000000..2649c304f --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_execute.py @@ -0,0 +1,195 @@ +"""Tests for SSH command execution. + +This module tests the execute method of the SSHConnection class, including +error handling and result processing. +""" + +from unittest import mock + +import paramiko +import pytest + +from coinbase_agentkit.action_providers.ssh.connection import ( + SSHConnectionError, +) + + +def test_execute_command_success(ssh_connection): + """Test successful command execution.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + with mock.patch.object(ssh_connection, "is_connected", return_value=True): + mock_stdin = mock.Mock() + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"command output" + mock_stdout.channel.recv_exit_status.return_value = 0 + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"" + mock_client.exec_command.return_value = (mock_stdin, mock_stdout, mock_stderr) + + result = ssh_connection.execute("ls -la") + + assert result == "command output" + mock_client.exec_command.assert_called_once_with("ls -la", timeout=30) + + +def test_execute_not_connected(ssh_connection): + """Test execute when not connected.""" + with pytest.raises(SSHConnectionError) as exc_info: + ssh_connection.execute("ls -la") + assert "No active SSH connection" in str(exc_info.value) + + +def test_execute_command_with_stderr(ssh_connection): + """Test command execution with stderr output but success status.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + with mock.patch.object(ssh_connection, "is_connected", return_value=True): + mock_stdin = mock.Mock() + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"command output" + mock_stdout.channel.recv_exit_status.return_value = 0 + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"warning message" + mock_client.exec_command.return_value = (mock_stdin, mock_stdout, mock_stderr) + + result = ssh_connection.execute("ls -la") + + assert result == "command output\n[stderr]: warning message" + mock_client.exec_command.assert_called_once_with("ls -la", timeout=30) + + +def test_execute_command_with_stderr_ignore(ssh_connection): + """Test command execution with stderr output and ignore_stderr=True.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + with mock.patch.object(ssh_connection, "is_connected", return_value=True): + mock_stdin = mock.Mock() + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"command output" + mock_stdout.channel.recv_exit_status.return_value = 1 + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"warning message" + mock_client.exec_command.return_value = (mock_stdin, mock_stdout, mock_stderr) + + result = ssh_connection.execute("ls -la", ignore_stderr=True) + + assert result == "command output\n[stderr]: warning message" + mock_client.exec_command.assert_called_once_with("ls -la", timeout=30) + + +def test_execute_command_failure_stderr(ssh_connection): + """Test command execution failure with stderr output.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + with mock.patch.object(ssh_connection, "is_connected", return_value=True): + mock_stdin = mock.Mock() + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"" + mock_stdout.channel.recv_exit_status.return_value = 1 + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"command failed" + mock_client.exec_command.return_value = (mock_stdin, mock_stdout, mock_stderr) + + with pytest.raises(SSHConnectionError) as exc_info: + ssh_connection.execute("invalid-command") + + assert "Command execution failed" in str(exc_info.value) + assert "command failed" in str(exc_info.value) + mock_client.exec_command.assert_called_once_with("invalid-command", timeout=30) + + +def test_execute_command_failure_no_stderr(ssh_connection): + """Test command execution failure with no stderr output.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + with mock.patch.object(ssh_connection, "is_connected", return_value=True): + mock_stdin = mock.Mock() + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"" + mock_stdout.channel.recv_exit_status.return_value = 1 + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"" + mock_client.exec_command.return_value = (mock_stdin, mock_stdout, mock_stderr) + + with pytest.raises(SSHConnectionError) as exc_info: + ssh_connection.execute("invalid-command") + + assert "Command execution failed" in str(exc_info.value) + assert "exit code 1" in str(exc_info.value) + + +def test_execute_command_empty_output(ssh_connection): + """Test command execution with empty but successful output.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + with mock.patch.object(ssh_connection, "is_connected", return_value=True): + mock_stdin = mock.Mock() + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"" + mock_stdout.channel.recv_exit_status.return_value = 0 + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"" + mock_client.exec_command.return_value = (mock_stdin, mock_stdout, mock_stderr) + + result = ssh_connection.execute("touch file.txt") + + assert result == "" + mock_client.exec_command.assert_called_once_with("touch file.txt", timeout=30) + + +def test_execute_command_exception(ssh_connection): + """Test handling exceptions during command execution.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + with mock.patch.object(ssh_connection, "is_connected", return_value=True): + mock_client.exec_command.side_effect = paramiko.SSHException("Connection lost") + + with pytest.raises(SSHConnectionError) as exc_info: + ssh_connection.execute("ls -la") + + assert "Command execution failed" in str(exc_info.value) + assert "Connection lost" in str(exc_info.value) + + +def test_execute_command_custom_timeout(ssh_connection): + """Test command execution with custom timeout.""" + with mock.patch("paramiko.SSHClient") as mock_ssh_client_class: + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + with mock.patch.object(ssh_connection, "is_connected", return_value=True): + mock_stdin = mock.Mock() + mock_stdout = mock.Mock() + mock_stdout.read.return_value = b"command output" + mock_stdout.channel.recv_exit_status.return_value = 0 + mock_stderr = mock.Mock() + mock_stderr.read.return_value = b"" + mock_client.exec_command.return_value = (mock_stdin, mock_stdout, mock_stderr) + + result = ssh_connection.execute("ls -la", timeout=60) + + assert result == "command output" + mock_client.exec_command.assert_called_once_with("ls -la", timeout=60) diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_keys.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_keys.py new file mode 100644 index 000000000..f5b2834fb --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_keys.py @@ -0,0 +1,149 @@ +"""Tests for SSH key handling. + +This module tests the key handling functionality of the SSHConnection class, +including loading keys from files and strings. +""" + +from unittest import mock + +import paramiko +import pytest + +from coinbase_agentkit.action_providers.ssh.connection import ( + SSHConnection, + SSHConnectionParams, + SSHKeyError, +) + + +@pytest.fixture +def ssh_connection(): + """Create an SSH connection instance for testing.""" + params = SSHConnectionParams( + connection_id="test-conn", + host="example.com", + username="testuser", + password="testpass", + ) + return SSHConnection(params) + + +def test_load_key_from_string_success(ssh_connection): + """Test loading an RSA key from a string successfully.""" + with mock.patch("paramiko.RSAKey") as mock_rsa_key: + mock_key = mock.Mock() + mock_rsa_key.from_private_key.return_value = mock_key + + key = ssh_connection._load_key_from_string("KEY_CONTENT") + + assert key == mock_key + mock_rsa_key.from_private_key.assert_called_once() + + +def test_load_key_from_string_password_required(ssh_connection): + """Test loading an RSA key that requires a password without providing one.""" + with mock.patch("paramiko.RSAKey") as mock_rsa_key: + mock_rsa_key.from_private_key.side_effect = ( + paramiko.ssh_exception.PasswordRequiredException() + ) + + with pytest.raises(SSHKeyError) as exc_info: + ssh_connection._load_key_from_string("KEY_CONTENT") + + assert "Password-protected key provided but no password was given" in str(exc_info.value) + + +def test_load_key_from_string_other_error(ssh_connection): + """Test handling other errors when loading an RSA key from a string.""" + with mock.patch("paramiko.RSAKey") as mock_rsa_key: + mock_rsa_key.from_private_key.side_effect = Exception("Invalid key format") + + with pytest.raises(SSHKeyError) as exc_info: + ssh_connection._load_key_from_string("KEY_CONTENT") + + assert "Failed to load key from string" in str(exc_info.value) + + +def test_load_key_from_file_success(ssh_connection): + """Test loading an RSA key from a file successfully.""" + with ( + mock.patch("paramiko.RSAKey") as mock_rsa_key, + mock.patch("os.path.exists", return_value=True), + ): + mock_key = mock.Mock() + mock_rsa_key.from_private_key_file.return_value = mock_key + + key = ssh_connection._load_key_from_file("/path/to/key") + + assert key == mock_key + mock_rsa_key.from_private_key_file.assert_called_once_with("/path/to/key", password=None) + + +def test_load_key_from_file_password_required(ssh_connection, mock_fs): + """Test loading an RSA key file that requires a password without providing one.""" + with ( + mock.patch("paramiko.RSAKey") as mock_rsa, + mock.patch("paramiko.DSSKey") as mock_dss, + mock.patch("paramiko.ECDSAKey") as mock_ecdsa, + mock.patch("paramiko.Ed25519Key") as mock_ed25519, + ): + mock_rsa.from_private_key_file.side_effect = ( + paramiko.ssh_exception.PasswordRequiredException() + ) + + mock_dss.from_private_key_file.side_effect = paramiko.ssh_exception.SSHException("Not DSS") + mock_ecdsa.from_private_key_file.side_effect = paramiko.ssh_exception.SSHException( + "Not ECDSA" + ) + mock_ed25519.from_private_key_file.side_effect = paramiko.ssh_exception.SSHException( + "Not Ed25519" + ) + + with pytest.raises(SSHKeyError) as exc_info: + ssh_connection._load_key_from_file("/path/to/key") + + assert "Password-protected key file requires a password" in str(exc_info.value) + + +def test_load_key_from_file_other_error(ssh_connection): + """Test handling other errors when loading an RSA key from a file.""" + with mock.patch("paramiko.RSAKey") as mock_rsa_key: + mock_rsa_key.from_private_key_file.side_effect = Exception("Invalid key format") + + with pytest.raises(SSHKeyError) as exc_info: + ssh_connection._load_key_from_file("/path/to/key") + + assert "Failed to load key file" in str(exc_info.value) + + +def test_load_key_from_string_with_password(ssh_connection): + """Test loading a password-protected key from string with password.""" + with mock.patch("paramiko.RSAKey") as mock_rsa_key: + mock_key = mock.Mock() + mock_rsa_key.from_private_key.return_value = mock_key + + key = ssh_connection._load_key_from_string("KEY_CONTENT", password="keypass") + + assert key == mock_key + mock_rsa_key.from_private_key.assert_called_with(mock.ANY, password="keypass") + + +def test_load_key_from_file_with_password(ssh_connection, mock_fs): + """Test loading a password-protected key file with password.""" + with ( + mock.patch("paramiko.RSAKey") as mock_rsa, + mock.patch("paramiko.DSSKey") as mock_dss, + mock.patch("paramiko.ECDSAKey") as mock_ecdsa, + mock.patch("paramiko.Ed25519Key") as mock_ed25519, + ): + mock_key = mock.Mock() + mock_rsa.from_private_key_file.return_value = mock_key + + mock_dss.from_private_key_file.side_effect = Exception("Should not be called") + mock_ecdsa.from_private_key_file.side_effect = Exception("Should not be called") + mock_ed25519.from_private_key_file.side_effect = Exception("Should not be called") + + key = ssh_connection._load_key_from_file("/path/to/key", password="keypass") + + assert key is not None + mock_rsa.from_private_key_file.assert_called_once() diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_list_connections.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_list_connections.py new file mode 100644 index 000000000..e2b46ee8a --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_list_connections.py @@ -0,0 +1,51 @@ +"""Tests for list_connections action. + +This module tests the list_connections action of the SshActionProvider, which +provides information about all active SSH connections in the pool. +""" + +from unittest import mock + + +def test_list_connections_with_connections(ssh_provider): + """Test listing connections when connections exist.""" + mock_pool = ssh_provider.connection_pool + + mock_conn1 = mock.Mock() + mock_conn1.params.host = "host1" + mock_conn1.params.port = 22 + mock_conn1.params.username = "user1" + mock_conn1.is_connected.return_value = True + + mock_conn2 = mock.Mock() + mock_conn2.params.host = "host2" + mock_conn2.params.port = 22 + mock_conn2.params.username = "user2" + mock_conn2.is_connected.return_value = True + + connections = { + "conn1": mock_conn1, + "conn2": mock_conn2, + } + + mock_pool.get_connections.return_value = connections + + result = ssh_provider.list_connections({}) + + assert "Active SSH Connections: 2" in result + assert "Connection ID: conn1" in result + assert "Connection ID: conn2" in result + assert "host1:22" in result + assert "host2:22" in result + assert "user1" in result + assert "user2" in result + + +def test_list_connections_no_connections(ssh_provider): + """Test listing connections when no connections exist.""" + mock_pool = ssh_provider.connection_pool + mock_pool.get_connections.return_value = {} + + result = ssh_provider.list_connections({}) + + assert "No active SSH connections" in result diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_params.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_params.py new file mode 100644 index 000000000..1981bad92 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_params.py @@ -0,0 +1,67 @@ +"""Tests for SSH connection parameters. + +This module tests the SSHConnectionParams model, including validation +and parameter handling. +""" + +from coinbase_agentkit.action_providers.ssh.connection import SSHConnectionParams + + +def test_connection_params_with_password(): + """Test creating connection parameters with password authentication.""" + params = SSHConnectionParams( + connection_id="test-conn", + host="example.com", + username="testuser", + password="testpass", + ) + + assert params.connection_id == "test-conn" + assert params.host == "example.com" + assert params.username == "testuser" + assert params.password == "testpass" + assert params.port == 22 + + +def test_connection_params_with_private_key(): + """Test creating connection parameters with private key authentication.""" + params = SSHConnectionParams( + connection_id="test-conn", + host="example.com", + username="testuser", + private_key="SSH_KEY_CONTENT", + ) + + assert params.connection_id == "test-conn" + assert params.host == "example.com" + assert params.username == "testuser" + assert params.private_key == "SSH_KEY_CONTENT" + assert params.password is None + + +def test_connection_params_with_key_path(): + """Test creating connection parameters with key path authentication.""" + params = SSHConnectionParams( + connection_id="test-conn", + host="example.com", + username="testuser", + private_key_path="~/.ssh/id_rsa", + ) + + assert params.connection_id == "test-conn" + assert params.host == "example.com" + assert params.username == "testuser" + assert params.private_key_path == "~/.ssh/id_rsa" + + +def test_connection_params_with_custom_port(): + """Test creating connection parameters with custom port.""" + params = SSHConnectionParams( + connection_id="test-conn", + host="example.com", + username="testuser", + password="testpass", + port=2222, + ) + + assert params.port == 2222 diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_remote_shell.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_remote_shell.py new file mode 100644 index 000000000..b30d2760a --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_remote_shell.py @@ -0,0 +1,115 @@ +"""Tests for remote_shell action. + +This module tests the remote_shell action of the SshActionProvider, which allows +executing commands on a remote server. +""" + +from coinbase_agentkit.action_providers.ssh.connection import SSHConnectionError + + +def test_remote_shell_success(ssh_provider): + """Test successful remote shell command execution.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock_pool.get_connection.return_value + + mock_connection.execute.return_value = "Command output" + mock_pool.has_connection.return_value = True + mock_connection.is_connected.return_value = True + + result = ssh_provider.remote_shell( + { + "connection_id": "test-conn", + "command": "ls -la", + } + ) + + assert "Output from connection 'test-conn':" in result + assert "Command output" in result + mock_pool.get_connection.assert_called_once_with("test-conn") + mock_connection.execute.assert_called_once_with("ls -la", timeout=30, ignore_stderr=False) + + +def test_remote_shell_connection_not_found(ssh_provider): + """Test remote shell with connection not found.""" + mock_pool = ssh_provider.connection_pool + + mock_pool.has_connection.return_value = False + + result = ssh_provider.remote_shell( + { + "connection_id": "test-conn", + "command": "ls -la", + } + ) + + assert "Error: Connection ID 'test-conn' not found" in result + mock_pool.has_connection.assert_called_once_with("test-conn") + mock_pool.get_connection.assert_not_called() + + +def test_remote_shell_not_connected(ssh_provider): + """Test remote shell with inactive connection.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock_pool.get_connection.return_value + + mock_pool.has_connection.return_value = True + mock_connection.is_connected.return_value = False + + result = ssh_provider.remote_shell( + { + "connection_id": "test-conn", + "command": "ls -la", + } + ) + + assert "Error: Connection state:" in result + mock_pool.get_connection.assert_called_once_with("test-conn") + mock_connection.is_connected.assert_called_once() + mock_connection.execute.assert_not_called() + + +def test_remote_shell_execution_error(ssh_provider): + """Test remote shell with command execution error.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock_pool.get_connection.return_value + + mock_pool.has_connection.return_value = True + mock_connection.is_connected.return_value = True + mock_connection.execute.side_effect = SSHConnectionError("Command execution failed") + + result = ssh_provider.remote_shell( + { + "connection_id": "test-conn", + "command": "ls -la", + } + ) + + assert "Error: Connection:" in result + assert "Command execution failed" in result + mock_pool.get_connection.assert_called_once_with("test-conn") + mock_connection.execute.assert_called_once_with("ls -la", timeout=30, ignore_stderr=False) + + +def test_remote_shell_missing_command(ssh_provider): + """Test remote shell with missing command parameter.""" + result = ssh_provider.remote_shell( + { + "connection_id": "test-conn", + } + ) + + assert "Error: Invalid parameters:" in result + assert "Field required" in result or "command" in result + + +def test_remote_shell_empty_command(ssh_provider): + """Test remote shell with empty command.""" + result = ssh_provider.remote_shell( + { + "connection_id": "test-conn", + "command": "", + } + ) + + assert "Error: Invalid parameters:" in result + assert "min_length" in result or "at least" in result or "length" in result diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_sftp.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_sftp.py new file mode 100644 index 000000000..6aa8c5e63 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_sftp.py @@ -0,0 +1,197 @@ +"""Tests for SSH SFTP operations. + +This module tests the SFTP functionality of the SSHConnection class, including +file upload, download, and directory listing. +""" + +import os +from unittest import mock + +import paramiko +import pytest + +from coinbase_agentkit.action_providers.ssh.connection import ( + SSHConnection, + SSHConnectionError, + SSHConnectionParams, +) + + +@pytest.fixture +def connection_params(): + """Create a standard set of connection parameters for testing.""" + return SSHConnectionParams( + connection_id="test-conn", + host="example.com", + username="testuser", + password="testpass", + ) + + +@pytest.fixture +def ssh_connection(connection_params): + """Create an SSH connection instance for testing.""" + return SSHConnection(connection_params) + + +@mock.patch("paramiko.SSHClient") +def test_get_sftp_client(mock_ssh_client_class, ssh_connection): + """Test getting an SFTP client.""" + mock_client = mock_ssh_client_class.return_value + ssh_connection.ssh_client = mock_client + ssh_connection.connected = True + + mock_sftp = mock.Mock() + mock_client.open_sftp.return_value = mock_sftp + + with mock.patch.object(ssh_connection, "is_connected", return_value=True): + sftp = ssh_connection.get_sftp_client() + + assert sftp == mock_sftp + mock_client.open_sftp.assert_called_once() + + +def test_get_sftp_client_not_connected(ssh_connection): + """Test getting an SFTP client when not connected.""" + with ( + mock.patch.object(ssh_connection, "is_connected", return_value=False), + pytest.raises(SSHConnectionError) as exc_info, + ): + ssh_connection.get_sftp_client() + + assert "No active SSH connection" in str(exc_info.value) + + +@mock.patch("os.path.exists") +def test_upload_file(mock_exists, ssh_connection): + """Test uploading a file.""" + mock_exists.return_value = True + + mock_sftp = mock.Mock() + + with mock.patch.object(ssh_connection, "get_sftp_client", return_value=mock_sftp): + ssh_connection.upload_file("/local/path", "/remote/path") + + mock_sftp.put.assert_called_once_with("/local/path", "/remote/path") + mock_sftp.close.assert_called_once() + + +@mock.patch("os.path.exists") +def test_upload_file_not_found(mock_exists, ssh_connection): + """Test uploading a non-existent file.""" + mock_exists.return_value = False + + with pytest.raises(FileNotFoundError) as exc_info: + ssh_connection.upload_file("/local/path", "/remote/path") + + assert "Local file not found" in str(exc_info.value) + + +@mock.patch("os.path.exists") +def test_upload_file_error(mock_exists, ssh_connection): + """Test error handling during file upload.""" + mock_exists.return_value = True + + mock_sftp = mock.Mock() + mock_sftp.put.side_effect = paramiko.SFTPError("Permission denied") + + with ( + mock.patch.object(ssh_connection, "get_sftp_client", return_value=mock_sftp), + pytest.raises(SSHConnectionError) as exc_info, + ): + ssh_connection.upload_file("/local/path", "/remote/path") + + assert "File upload failed" in str(exc_info.value) + + +def test_download_file(ssh_connection): + """Test downloading a file.""" + mock_sftp = mock.Mock() + + with mock.patch.object(ssh_connection, "get_sftp_client", return_value=mock_sftp): + ssh_connection.download_file("/remote/path", "/local/path") + + mock_sftp.get.assert_called_once_with("/remote/path", "/local/path") + mock_sftp.close.assert_called_once() + + +def test_download_file_error(ssh_connection): + """Test error handling during file download.""" + mock_sftp = mock.Mock() + mock_sftp.get.side_effect = paramiko.SFTPError("File not found") + + with ( + mock.patch.object(ssh_connection, "get_sftp_client", return_value=mock_sftp), + pytest.raises(SSHConnectionError) as exc_info, + ): + ssh_connection.download_file("/remote/path", "/local/path") + + assert "File download failed" in str(exc_info.value) + + +def test_list_directory(ssh_connection): + """Test listing directory contents.""" + mock_sftp = mock.Mock() + mock_sftp.listdir.return_value = ["file1", "file2", "directory"] + + with mock.patch.object(ssh_connection, "get_sftp_client", return_value=mock_sftp): + files = ssh_connection.list_directory("/remote/path") + + assert files == ["file1", "file2", "directory"] + mock_sftp.listdir.assert_called_once_with("/remote/path") + mock_sftp.close.assert_called_once() + + +def test_list_directory_error(ssh_connection): + """Test error handling during directory listing.""" + mock_sftp = mock.Mock() + mock_sftp.listdir.side_effect = paramiko.SFTPError("Directory not found") + + with ( + mock.patch.object(ssh_connection, "get_sftp_client", return_value=mock_sftp), + pytest.raises(SSHConnectionError) as exc_info, + ): + ssh_connection.list_directory("/remote/path") + + assert "Directory listing failed" in str(exc_info.value) + + +@mock.patch("os.path.dirname") +@mock.patch("os.makedirs") +@mock.patch("os.path.exists") +def test_create_local_directories_for_download( + mock_exists, mock_makedirs, mock_dirname, ssh_connection +): + """Test creating local directories during download (helper test).""" + mock_exists.return_value = True + + mock_dirname.return_value = "/local" + + mock_sftp = mock.Mock() + + with mock.patch.object(ssh_connection, "get_sftp_client", return_value=mock_sftp): + os.makedirs = mock_makedirs + os.makedirs("/local", exist_ok=True) + ssh_connection.download_file("/remote/path", "/local/path") + + mock_makedirs.assert_called_once_with("/local", exist_ok=True) + mock_sftp.get.assert_called_once_with("/remote/path", "/local/path") + + +@mock.patch("os.path.dirname") +@mock.patch("os.path.exists") +def test_create_remote_directory_for_upload(mock_exists, mock_dirname, ssh_connection): + """Test creating remote directory during upload (helper test).""" + mock_exists.return_value = True + + mock_dirname.return_value = "/remote" + + mock_sftp = mock.Mock() + + with mock.patch.object(ssh_connection, "get_sftp_client", return_value=mock_sftp): + mock_sftp.mkdir("/remote") + ssh_connection.upload_file("/local/path", "/remote/path") + + mock_sftp.mkdir.assert_called_once_with("/remote") + mock_sftp.put.assert_called_once_with("/local/path", "/remote/path") + mock_sftp.close.assert_called_once() diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_ssh_connect.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_ssh_connect.py new file mode 100644 index 000000000..0c1420a30 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_ssh_connect.py @@ -0,0 +1,206 @@ +"""Tests for SSH connect action. + +This module tests the ssh_connect action of the SshActionProvider, including +successful connections, validation, and error handling. +""" + +import uuid +from unittest import mock + +from coinbase_agentkit.action_providers.ssh.connection import ( + SSHConnectionError, + SSHKeyError, + UnknownHostKeyError, +) +from coinbase_agentkit.action_providers.ssh.ssh_action_provider import SshActionProvider + + +def test_ssh_connect_success(ssh_provider): + """Test successful SSH connection.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + mock_connection.params.host = "example.com" + mock_connection.params.username = "testuser" + mock_pool.create_connection.return_value = mock_connection + + result = ssh_provider.ssh_connect( + { + "connection_id": "test-conn", + "host": "example.com", + "username": "testuser", + "password": "testpass", + } + ) + + assert "Successfully connected to" in result + assert "Connection ID: test-conn" in result + mock_pool.create_connection.assert_called_once() + mock_connection.connect.assert_called_once_with() + + +def test_ssh_connect_with_auto_id(ssh_provider): + """Test SSH connection with auto-generated ID.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + mock_connection.params.host = "example.com" + mock_connection.params.username = "testuser" + mock_pool.create_connection.return_value = mock_connection + + mock_uuid = "mock-uuid-1234" + with mock.patch.object(uuid, "uuid4", return_value=mock_uuid): + result = ssh_provider.ssh_connect( + { + "host": "example.com", + "username": "testuser", + "password": "testpass", + } + ) + + assert mock_uuid in result + assert "Successfully connected to" in result + mock_pool.create_connection.assert_called_once() + mock_connection.connect.assert_called_once_with() + + +def test_ssh_connect_connection_error(ssh_provider): + """Test SSH connection with connection error.""" + mock_pool = ssh_provider.connection_pool + mock_pool.create_connection.side_effect = SSHConnectionError("Connection failed") + + result = ssh_provider.ssh_connect( + { + "connection_id": "test-conn", + "host": "example.com", + "username": "testuser", + "password": "testpass", + } + ) + + assert "Error: Connection:" in result + + +def test_ssh_connect_validation_error(): + """Test SSH connection with validation error.""" + provider = SshActionProvider() + + result = provider.ssh_connect({"connection_id": "test-conn"}) + + assert "Invalid input parameters" in result + + +def test_ssh_connect_with_private_key(ssh_provider): + """Test SSH connection using private key authentication.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + mock_connection.params.host = "example.com" + mock_connection.params.username = "testuser" + mock_pool.create_connection.return_value = mock_connection + + result = ssh_provider.ssh_connect( + { + "connection_id": "test-conn", + "host": "example.com", + "username": "testuser", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMockKeyContent\n-----END RSA PRIVATE KEY-----", + } + ) + + assert "Successfully connected to" in result + assert "Connection ID: test-conn" in result + mock_pool.create_connection.assert_called_once() + mock_connection.connect.assert_called_once_with() + + +def test_ssh_connect_with_key_path(ssh_provider): + """Test SSH connection using private key file path.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + mock_connection.params.host = "example.com" + mock_connection.params.username = "testuser" + mock_pool.create_connection.return_value = mock_connection + + result = ssh_provider.ssh_connect( + { + "connection_id": "test-conn", + "host": "example.com", + "username": "testuser", + "private_key_path": "~/.ssh/test_key", + } + ) + + assert "Successfully connected to" in result + assert "Connection ID: test-conn" in result + mock_pool.create_connection.assert_called_once() + mock_connection.connect.assert_called_once_with() + + +def test_ssh_connect_key_error(ssh_provider): + """Test SSH connection with key file error.""" + mock_pool = ssh_provider.connection_pool + mock_pool.create_connection.side_effect = SSHKeyError("Key file not found") + + result = ssh_provider.ssh_connect( + { + "connection_id": "test-conn", + "host": "example.com", + "username": "testuser", + "private_key_path": "~/.ssh/nonexistent_key", + } + ) + + assert "Error: SSH key issue:" in result + + +def test_ssh_connect_with_custom_port(ssh_provider): + """Test SSH connection using a custom port.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + mock_connection.params.host = "example.com" + mock_connection.params.username = "testuser" + mock_connection.params.port = 2222 + mock_pool.create_connection.return_value = mock_connection + + result = ssh_provider.ssh_connect( + { + "connection_id": "test-conn", + "host": "example.com", + "username": "testuser", + "password": "testpass", + "port": 2222, + } + ) + + assert "Successfully connected to" in result + assert "Connection ID: test-conn" in result + mock_pool.create_connection.assert_called_once() + mock_connection.connect.assert_called_once_with() + + +def test_ssh_connect_unknown_host_key(ssh_provider): + """Test handling of unknown host keys.""" + mock_pool = ssh_provider.connection_pool + + error_message = ( + "Host key verification failed for example.com. Server sent:\n" + " ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ==\n\n" + "To add this host key, use the ssh_add_host_key action with the following parameters:\n" + " host: example.com\n" + " key: AAAAB3NzaC1yc2EAAAADAQABAAABAQ==\n" + " key_type: ssh-rsa" + ) + + mock_pool.create_connection.side_effect = UnknownHostKeyError(error_message) + + result = ssh_provider.ssh_connect( + { + "connection_id": "test-conn", + "host": "example.com", + "username": "testuser", + "password": "testpass", + } + ) + + assert "Host key verification failed" in result + assert "host: example.com" in result + assert "key: AAAAB3NzaC1yc2EAAAADAQABAAABAQ==" in result + assert "key_type: ssh-rsa" in result diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_status.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_status.py new file mode 100644 index 000000000..fd3877460 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_status.py @@ -0,0 +1,47 @@ +"""Tests for ssh_status action. + +This module tests the ssh_status action of the SshActionProvider, which allows +checking the status of active SSH connections. +""" + +from unittest import mock + +from coinbase_agentkit.action_providers.ssh.connection import SSHConnectionError + + +def test_ssh_status_success(ssh_provider): + """Test successful SSH status check.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + connection_info = ( + "Connection ID: test-conn\nStatus: Connected\nHost: example.com:22\nUsername: user" + ) + mock_connection.get_connection_info.return_value = connection_info + mock_pool.has_connection.return_value = True + mock_pool.get_connection.return_value = mock_connection + + result = ssh_provider.ssh_status( + { + "connection_id": "test-conn", + } + ) + + assert result == connection_info + mock_pool.get_connection.assert_called_once_with("test-conn") + mock_connection.get_connection_info.assert_called_once() + + +def test_ssh_status_not_found(ssh_provider): + """Test SSH status with connection not found.""" + mock_pool = ssh_provider.connection_pool + error_message = "Connection ID 'test-conn' not found" + mock_pool.get_connection.side_effect = SSHConnectionError(error_message) + + result = ssh_provider.ssh_status( + { + "connection_id": "test-conn", + } + ) + + assert "Error: Connection not found:" in result + mock_pool.get_connection.assert_called_once_with("test-conn") diff --git a/python/coinbase-agentkit/tests/action_providers/ssh/test_upload.py b/python/coinbase-agentkit/tests/action_providers/ssh/test_upload.py new file mode 100644 index 000000000..6066639ed --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/ssh/test_upload.py @@ -0,0 +1,111 @@ +"""Tests for ssh_upload action. + +This module tests the ssh_upload action of the SshActionProvider, which allows +uploading files to a remote server using SFTP. +""" + +from unittest import mock + +from coinbase_agentkit.action_providers.ssh.connection import SSHConnectionError + + +def test_ssh_upload_success(ssh_provider): + """Test successful file upload.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + + with ( + mock.patch("os.path.exists", return_value=True), + mock.patch("os.path.isfile", return_value=True), + ): + mock_pool.has_connection.return_value = True + mock_pool.get_connection.return_value = mock_connection + mock_connection.is_connected.return_value = True + + result = ssh_provider.ssh_upload( + { + "connection_id": "test-conn", + "local_path": "/local/path", + "remote_path": "/remote/path", + } + ) + + assert "File upload successful" in result + assert "/local/path" in result + assert "/remote/path" in result + mock_connection.upload_file.assert_called_once_with("/local/path", "/remote/path") + + +def test_ssh_upload_connection_not_found(ssh_provider): + """Test file upload with connection not found.""" + mock_pool = ssh_provider.connection_pool + + with ( + mock.patch("os.path.exists", return_value=True), + mock.patch("os.path.isfile", return_value=True), + ): + mock_pool.has_connection.return_value = False + + result = ssh_provider.ssh_upload( + { + "connection_id": "test-conn", + "local_path": "/local/path", + "remote_path": "/remote/path", + } + ) + + assert "Error: Connection ID 'test-conn' not found" in result + mock_pool.has_connection.assert_called_once_with("test-conn") + + +def test_ssh_upload_not_connected(ssh_provider): + """Test file upload with inactive connection.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + + with ( + mock.patch("os.path.exists", return_value=True), + mock.patch("os.path.isfile", return_value=True), + ): + mock_pool.has_connection.return_value = True + mock_pool.get_connection.return_value = mock_connection + mock_connection.is_connected.return_value = False + + result = ssh_provider.ssh_upload( + { + "connection_id": "test-conn", + "local_path": "/local/path", + "remote_path": "/remote/path", + } + ) + + assert "Error: Connection 'test-conn' is not currently active" in result + mock_pool.get_connection.assert_called_once_with("test-conn") + mock_connection.is_connected.assert_called_once() + + +def test_ssh_upload_error(ssh_provider): + """Test file upload with error.""" + mock_pool = ssh_provider.connection_pool + mock_connection = mock.Mock() + + with ( + mock.patch("os.path.exists", return_value=True), + mock.patch("os.path.isfile", return_value=True), + ): + mock_pool.has_connection.return_value = True + mock_pool.get_connection.return_value = mock_connection + mock_connection.is_connected.return_value = True + mock_connection.upload_file.side_effect = SSHConnectionError("Upload failed") + + result = ssh_provider.ssh_upload( + { + "connection_id": "test-conn", + "local_path": "/local/path", + "remote_path": "/remote/path", + } + ) + + assert "Error: SSH connection:" in result + assert "Upload failed" in result + mock_connection.upload_file.assert_called_once_with("/local/path", "/remote/path")