From 7dbe3d29ad36446b9b68d2877510bd953e478770 Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Thu, 23 Jan 2025 14:52:34 -0500 Subject: [PATCH 1/2] feat: enhance SDK architecture and documentation Key improvements: - Add configuration management system - Add custom exception handling - Add comprehensive testing framework - Update utility functions with better error handling - Add detailed SDK documentation Documentation: - Configuration guide - Error handling guide - Testing guide - Updated main README Testing: - Add test fixtures - Add utility function tests - Add configuration tests --- README.md | 6 + docs/configuration.md | 126 +++++++++++++++++ docs/error-handling.md | 206 +++++++++++++++++++++++++++ docs/sdk_guide.md | 26 ++++ docs/testing.md | 238 ++++++++++++++++++++++++++++++++ src/game_sdk/__init__.py | 52 +++++++ src/game_sdk/game/config.py | 114 +++++++++++++++ src/game_sdk/game/exceptions.py | 51 +++++++ src/game_sdk/game/utils.py | 212 ++++++++++++++++++---------- src/game_sdk/version.py | 7 + tests/conftest.py | 30 ++++ tests/test_config.py | 50 +++++++ tests/test_utils.py | 131 ++++++++++++++++++ 13 files changed, 1178 insertions(+), 71 deletions(-) create mode 100644 docs/configuration.md create mode 100644 docs/error-handling.md create mode 100644 docs/sdk_guide.md create mode 100644 docs/testing.md create mode 100644 src/game_sdk/game/config.py create mode 100644 src/game_sdk/game/exceptions.py create mode 100644 src/game_sdk/version.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_utils.py diff --git a/README.md b/README.md index ea9b45e..c02b238 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,12 @@ Want to help improve the project? Please see our detailed [Contribution Guide](. ## Documentation Detailed documentation to better understand the configurable components and the GAME architecture can be found on [here](https://whitepaper.virtuals.io/developer-documents/game-framework). +For detailed SDK documentation, see: +- [Configuration Guide](docs/configuration.md) +- [Error Handling Guide](docs/error-handling.md) +- [Testing Guide](docs/testing.md) +- [SDK Guide](docs/sdk_guide.md) + ## Useful Resources - [GAME TypeScript SDK](https://github.com/game-by-virtuals/game-node): The core logic of this SDK mirrors the logic of this python SDK if you prefer to develop your agents in TypeScript. Typescript SDK repository and contributed typescript plugins can be found [here](https://github.com/game-by-virtuals/game-node). - [Hosted GAME Agent](./src/game_sdk/hosted_game/README.md): This SDK also enables configuration and deployment of an out-of-the-box hosted agent that can be used to interact with the Twitter/X platform, powered by GAME. This agent comes with existing functions/actions that can be used to interact with the Twitter/X platform and can be immediately hosted/deployed as you configure it. This is similar to configuring your agent in the [Agent Sandbox](https://game-lite.virtuals.io/) on the [Virtuals Platform](https://app.virtuals.io/) but through a developer-friendly SDK interface. \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..4581f73 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,126 @@ +# Configuration Guide + +The GAME SDK provides a flexible configuration system that allows you to customize its behavior. This guide explains how to configure the SDK for your needs. + +## Default Configuration + +The SDK comes with sensible defaults: + +```python +DEFAULT_CONFIG = { + "api_base_url": "https://game.virtuals.io", + "api_version": "v1", + "request_timeout": 30, + "max_retries": 3, + "retry_delay": 1, +} +``` + +## Configuration Methods + +There are three ways to configure the SDK: + +1. **Environment Variables**: + ```bash + export GAME_API_BASE_URL="https://custom.api.url" + export GAME_API_VERSION="v2" + export GAME_REQUEST_TIMEOUT="60" + export GAME_MAX_RETRIES="5" + export GAME_RETRY_DELAY="2" + ``` + +2. **Runtime Configuration**: + ```python + from game_sdk.game.config import config + + # Set individual values + config.set("request_timeout", 60) + config.set("max_retries", 5) + + # Get configuration values + timeout = config.get("request_timeout") + ``` + +3. **Initialization Configuration**: + ```python + from game_sdk.game.config import SDKConfig + + custom_config = SDKConfig( + api_base_url="https://custom.api.url", + request_timeout=60 + ) + ``` + +## Configuration Priority + +The configuration values are applied in the following order (highest priority first): +1. Runtime configuration (`config.set()`) +2. Environment variables +3. Initialization values +4. Default values + +## Available Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `api_base_url` | str | "https://game.virtuals.io" | Base URL for API calls | +| `api_version` | str | "v1" | API version to use | +| `request_timeout` | int | 30 | Request timeout in seconds | +| `max_retries` | int | 3 | Maximum number of retries | +| `retry_delay` | int | 1 | Delay between retries in seconds | + +## Best Practices + +1. **Environment-Specific Configuration**: + - Use environment variables for environment-specific settings + - Use different configurations for development and production + +2. **Timeouts**: + - Set appropriate timeouts based on your use case + - Consider network latency when setting timeouts + +3. **Retries**: + - Adjust retry settings based on your reliability needs + - Consider exponential backoff for production use + +## Example Usage + +```python +from game_sdk.game.config import config + +# Check current configuration +print(config.as_dict()) + +# Update configuration +config.set("request_timeout", 60) + +# Use in API calls +from game_sdk.game.utils import create_agent + +# The API calls will use the updated configuration +agent = create_agent( + base_url="https://api.example.com", + api_key="your-key", + name="Test Agent", + description="Test Description", + goal="Test Goal" +) +``` + +## Error Handling + +The configuration system will raise `ConfigurationError` if: +- An invalid configuration key is used +- An invalid value type is provided +- Environment variables have invalid values + +Example: +```python +from game_sdk.game.config import config +from game_sdk.game.exceptions import ConfigurationError + +try: + config.set("invalid_key", "value") +except ConfigurationError as e: + print(f"Configuration error: {e}") +``` diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..285bb8d --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,206 @@ +# Error Handling Guide + +The GAME SDK provides a comprehensive error handling system to help you handle errors gracefully and provide meaningful feedback to users. + +## Exception Hierarchy + +``` +GameSDKError +├── APIError +│ └── AuthenticationError +├── ConfigurationError +├── ValidationError +├── FunctionExecutionError +├── WorkerError +└── AgentError +``` + +## Exception Types + +### GameSDKError +Base exception class for all GAME SDK errors. All other SDK exceptions inherit from this class. + +### APIError +Raised when there's an error communicating with the GAME API. + +```python +try: + result = post(base_url, api_key, "endpoint", data) +except APIError as e: + print(f"Status code: {e.status_code}") + print(f"Response: {e.response}") +``` + +### AuthenticationError +Raised when there's an authentication error with the API. + +```python +try: + token = get_access_token(api_key) +except AuthenticationError as e: + print("Invalid API key or authentication failed") +``` + +### ConfigurationError +Raised when there's an error in the SDK configuration. + +```python +try: + config.set("invalid_key", "value") +except ConfigurationError as e: + print(f"Configuration error: {e}") +``` + +### ValidationError +Raised when there's an error validating input data. + +```python +try: + create_agent(base_url, api_key, "", "description", "goal") +except ValidationError as e: + print(f"Invalid input: {e}") +``` + +### FunctionExecutionError +Raised when there's an error executing a function. + +```python +try: + result = function.execute() +except FunctionExecutionError as e: + print(f"Function '{e.function_name}' failed: {e}") + if e.original_error: + print(f"Original error: {e.original_error}") +``` + +### WorkerError +Raised when there's an error with worker operations. + +```python +try: + workers = create_workers(base_url, api_key, worker_list) +except WorkerError as e: + print(f"Worker error: {e}") +``` + +### AgentError +Raised when there's an error with agent operations. + +```python +try: + agent = create_agent(base_url, api_key, name, description, goal) +except AgentError as e: + print(f"Agent error: {e}") +``` + +## Best Practices + +1. **Always Catch Specific Exceptions**: + ```python + try: + # SDK operation + except AuthenticationError: + # Handle authentication errors + except ValidationError: + # Handle validation errors + except APIError: + # Handle other API errors + except GameSDKError: + # Handle any other SDK errors + ``` + +2. **Provide Meaningful Error Messages**: + ```python + try: + result = create_agent(...) + except ValidationError as e: + logger.error(f"Failed to create agent: {e}") + raise UserFriendlyError("Please check the agent configuration") + ``` + +3. **Log Errors Appropriately**: + ```python + import logging + + logger = logging.getLogger(__name__) + + try: + result = api_call() + except APIError as e: + logger.error(f"API call failed: {e.status_code} - {e.response}") + # Handle error + ``` + +4. **Use Error Context**: + ```python + try: + token = get_access_token(api_key) + except AuthenticationError as e: + print(f"Status code: {e.status_code}") + print(f"Error details: {e.response}") + ``` + +## Error Recovery + +Some errors can be recovered from automatically: + +1. **Retry on Temporary Failures**: + ```python + from game_sdk.game.config import config + + # Configure retry settings + config.set("max_retries", 3) + config.set("retry_delay", 1) + ``` + +2. **Handle Rate Limiting**: + ```python + try: + result = api_call() + except APIError as e: + if e.status_code == 429: # Too Many Requests + time.sleep(int(e.response.get("retry_after", 60))) + result = api_call() # Retry + ``` + +## Testing Error Handling + +The SDK provides tools for testing error handling: + +```python +def test_error_handling(mock_response): + # Mock an API error + mock = mock_response(500, {"error": "Server Error"}) + + with patch('requests.post', return_value=mock): + with pytest.raises(APIError) as exc: + result = api_call() + assert exc.value.status_code == 500 +``` + +## Common Error Scenarios + +1. **Invalid API Key**: + ```python + try: + token = get_access_token(api_key) + except AuthenticationError: + print("Please check your API key") + ``` + +2. **Invalid Input Data**: + ```python + try: + worker = create_worker(invalid_data) + except ValidationError as e: + print(f"Invalid worker configuration: {e}") + ``` + +3. **Network Issues**: + ```python + try: + result = api_call() + except APIError as e: + if "timed out" in str(e): + print("Network connection is slow or unavailable") + ``` diff --git a/docs/sdk_guide.md b/docs/sdk_guide.md new file mode 100644 index 0000000..b5b9eaa --- /dev/null +++ b/docs/sdk_guide.md @@ -0,0 +1,26 @@ +# GAME SDK Documentation + +Welcome to the GAME SDK documentation. This documentation will help you understand and use the GAME SDK effectively. + +## Contents + +1. [Getting Started](getting-started.md) +2. [Core Concepts](core-concepts.md) +3. [Configuration](configuration.md) +4. [Error Handling](error-handling.md) +5. [API Reference](api-reference.md) +6. [Testing](testing.md) +7. [Contributing](contributing.md) + +## Quick Links + +- [GitHub Repository](https://github.com/game-by-virtuals/game-python) +- [Issue Tracker](https://github.com/game-by-virtuals/game-python/issues) +- [Examples](../examples) + +## Need Help? + +If you need help with the GAME SDK, please: +1. Check the documentation +2. Look for examples in the [examples](../examples) directory +3. Open an issue on GitHub if you find a bug or have a feature request diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..a6ab037 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,238 @@ +# Testing Guide + +The GAME SDK includes a comprehensive testing framework to ensure reliability and maintainability. This guide explains how to write and run tests for the SDK. + +## Test Structure + +Tests are organized in the `tests/` directory: +``` +tests/ +├── conftest.py # Shared test fixtures +├── test_config.py # Configuration tests +├── test_utils.py # Utility function tests +└── ... +``` + +## Setting Up Test Environment + +1. Install development dependencies: + ```bash + pip install -r requirements-dev.txt + ``` + +2. Run tests: + ```bash + pytest + ``` + +3. Run tests with coverage: + ```bash + pytest --cov=game_sdk + ``` + +## Test Fixtures + +Common test fixtures are defined in `conftest.py`: + +```python +@pytest.fixture +def mock_config(): + """Fixture providing a test configuration.""" + return SDKConfig( + api_base_url="https://test.virtuals.io", + api_version="test", + request_timeout=5 + ) + +@pytest.fixture +def mock_response(): + """Fixture providing a mock HTTP response.""" + def _mock_response(status_code=200, json_data=None): + mock = Mock() + mock.status_code = status_code + mock.json.return_value = json_data or {} + return mock + return _mock_response +``` + +## Writing Tests + +### Test Organization + +1. **Group Related Tests**: + ```python + class TestAgent: + def test_creation(self): + pass + + def test_execution(self): + pass + ``` + +2. **Use Descriptive Names**: + ```python + def test_get_access_token_with_invalid_key(): + pass + + def test_create_agent_with_missing_fields(): + pass + ``` + +### Testing API Calls + +1. **Mock HTTP Responses**: + ```python + def test_api_call(mock_response): + mock = mock_response(200, {"data": {"result": "success"}}) + + with patch('requests.post', return_value=mock): + result = api_call() + assert result == {"result": "success"} + ``` + +2. **Test Error Cases**: + ```python + def test_api_error(mock_response): + mock = mock_response(500, {"error": "Server Error"}) + + with patch('requests.post', return_value=mock): + with pytest.raises(APIError) as exc: + api_call() + assert exc.value.status_code == 500 + ``` + +### Testing Configuration + +```python +def test_config_override(): + """Test that configuration can be overridden.""" + config = SDKConfig(api_base_url="https://test.example.com") + assert config.get("api_base_url") == "https://test.example.com" + # Other values should remain default + assert config.get("api_version") == "v1" +``` + +### Testing Validation + +```python +def test_validation(): + """Test input validation.""" + with pytest.raises(ValidationError) as exc: + create_agent("", "desc", "goal") + assert "required" in str(exc.value) +``` + +## Test Categories + +1. **Unit Tests**: + - Test individual components in isolation + - Mock external dependencies + - Focus on edge cases + +2. **Integration Tests**: + - Test component interactions + - Use minimal mocking + - Focus on common workflows + +3. **Validation Tests**: + - Test input validation + - Test error handling + - Test edge cases + +## Best Practices + +1. **Test Independence**: + ```python + def test_independent(mock_config): + """Each test should be independent.""" + config = mock_config + config.set("timeout", 30) + assert config.get("timeout") == 30 + ``` + +2. **Clear Setup and Teardown**: + ```python + class TestWithSetup: + @pytest.fixture(autouse=True) + def setup(self): + self.config = SDKConfig() + yield + # Cleanup code here + ``` + +3. **Meaningful Assertions**: + ```python + def test_with_context(): + """Include context in assertions.""" + result = process_data([1, 2, 3]) + assert result == 6, "Sum should be 6 for input [1, 2, 3]" + ``` + +## Running Tests + +1. **Run All Tests**: + ```bash + pytest + ``` + +2. **Run Specific Tests**: + ```bash + pytest tests/test_utils.py + pytest tests/test_utils.py::test_get_access_token + ``` + +3. **Run with Coverage**: + ```bash + pytest --cov=game_sdk --cov-report=html + ``` + +## Continuous Integration + +The SDK uses GitHub Actions for CI: + +1. **Run Tests on Push**: + ```yaml + name: Tests + on: [push, pull_request] + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + - name: Install dependencies + run: pip install -r requirements-dev.txt + - name: Run tests + run: pytest + ``` + +2. **Coverage Reports**: + ```bash + pytest --cov=game_sdk --cov-report=xml + ``` + +## Debugging Tests + +1. **Print Debug Info**: + ```python + def test_with_debug(caplog): + caplog.set_level(logging.DEBUG) + result = complex_operation() + print(caplog.text) # View logs + ``` + +2. **Use PDB**: + ```bash + pytest --pdb + ``` + +## Adding New Tests + +When adding new functionality: + +1. Create a new test file if needed +2. Add tests for success cases +3. Add tests for error cases +4. Add tests for edge cases +5. Update documentation if needed diff --git a/src/game_sdk/__init__.py b/src/game_sdk/__init__.py index e69de29..9739d5a 100644 --- a/src/game_sdk/__init__.py +++ b/src/game_sdk/__init__.py @@ -0,0 +1,52 @@ +""" +GAME SDK - A Python SDK for building autonomous agents. + +This package provides tools and utilities for creating, managing, and running +autonomous agents that can perform various tasks through function execution. + +Example: + Basic usage: + + >>> from game_sdk import Agent, Worker, Function + >>> agent = Agent(api_key="your_key", name="MyAgent", ...) + >>> worker = Worker(api_key="your_key", description="My Worker", ...) +""" + +from game_sdk.version import ( + __version__, + __author__, + __author_email__, + __description__, + __url__, +) + +from game_sdk.game.agent import Agent, WorkerConfig +from game_sdk.game.worker import Worker +from game_sdk.game.custom_types import ( + Function, + Argument, + FunctionResult, + FunctionResultStatus, + ActionType, + ActionResponse, +) + +# Define what symbols to expose when using 'from game_sdk import *' +__all__ = [ + # Classes + 'Agent', + 'Worker', + 'WorkerConfig', + 'Function', + 'Argument', + 'FunctionResult', + 'FunctionResultStatus', + 'ActionType', + 'ActionResponse', + # Version info + '__version__', + '__author__', + '__author_email__', + '__description__', + '__url__', +] \ No newline at end of file diff --git a/src/game_sdk/game/config.py b/src/game_sdk/game/config.py new file mode 100644 index 0000000..4dffb30 --- /dev/null +++ b/src/game_sdk/game/config.py @@ -0,0 +1,114 @@ +""" +Configuration management for the GAME SDK. + +This module handles all configuration settings for the SDK, including: +- API endpoints +- Timeouts +- Retry settings +- Environment-specific configurations +""" + +import os +from typing import Dict, Any, Optional +from .exceptions import ConfigurationError + + +class SDKConfig: + """ + Configuration manager for the GAME SDK. + + This class manages all configuration settings and provides a central place + to modify SDK behavior. + + Attributes: + api_base_url (str): Base URL for API calls + api_version (str): API version to use + request_timeout (int): Timeout for API requests in seconds + max_retries (int): Maximum number of retries for failed requests + retry_delay (int): Delay between retries in seconds + """ + + DEFAULT_CONFIG = { + "api_base_url": "https://game.virtuals.io", + "api_version": "v1", + "request_timeout": 30, + "max_retries": 3, + "retry_delay": 1, + } + + def __init__(self, **kwargs): + """ + Initialize configuration with optional overrides. + + Args: + **kwargs: Override default configuration values + """ + self._config = self.DEFAULT_CONFIG.copy() + self._config.update(kwargs) + + # Override with environment variables if present + self._load_env_vars() + + def _load_env_vars(self): + """Load configuration from environment variables.""" + env_mapping = { + "GAME_API_BASE_URL": "api_base_url", + "GAME_API_VERSION": "api_version", + "GAME_REQUEST_TIMEOUT": "request_timeout", + "GAME_MAX_RETRIES": "max_retries", + "GAME_RETRY_DELAY": "retry_delay", + } + + for env_var, config_key in env_mapping.items(): + value = os.environ.get(env_var) + if value is not None: + # Convert to int for numeric settings + if config_key in ["request_timeout", "max_retries", "retry_delay"]: + try: + value = int(value) + except ValueError: + raise ConfigurationError( + f"Invalid value for {env_var}: {value}. Must be an integer." + ) + self._config[config_key] = value + + def get(self, key: str, default: Any = None) -> Any: + """ + Get a configuration value. + + Args: + key: Configuration key to retrieve + default: Default value if key doesn't exist + + Returns: + Configuration value + """ + return self._config.get(key, default) + + def set(self, key: str, value: Any): + """ + Set a configuration value. + + Args: + key: Configuration key to set + value: Value to set + + Raises: + ConfigurationError: If key is invalid + """ + if key not in self.DEFAULT_CONFIG: + raise ConfigurationError(f"Invalid configuration key: {key}") + self._config[key] = value + + @property + def api_url(self) -> str: + """Get the full API URL including version.""" + return f"{self._config['api_base_url']}/{self._config['api_version']}" + + def as_dict(self) -> Dict[str, Any]: + """Get all configuration as a dictionary.""" + return self._config.copy() + + +# Global configuration instance +config = SDKConfig() diff --git a/src/game_sdk/game/exceptions.py b/src/game_sdk/game/exceptions.py new file mode 100644 index 0000000..92ebabb --- /dev/null +++ b/src/game_sdk/game/exceptions.py @@ -0,0 +1,51 @@ +""" +Custom exceptions for the GAME SDK. + +This module defines all custom exceptions that can be raised by the SDK, +making error handling more specific and informative. +""" + +class GameSDKError(Exception): + """Base exception class for all GAME SDK errors.""" + pass + + +class APIError(GameSDKError): + """Raised when there's an error communicating with the GAME API.""" + def __init__(self, message: str, status_code: int = None, response: dict = None): + self.status_code = status_code + self.response = response + super().__init__(message) + + +class AuthenticationError(APIError): + """Raised when there's an authentication error with the API.""" + pass + + +class ConfigurationError(GameSDKError): + """Raised when there's an error in the SDK configuration.""" + pass + + +class ValidationError(GameSDKError): + """Raised when there's an error validating input data.""" + pass + + +class FunctionExecutionError(GameSDKError): + """Raised when there's an error executing a function.""" + def __init__(self, function_name: str, message: str, original_error: Exception = None): + self.function_name = function_name + self.original_error = original_error + super().__init__(f"Error executing function '{function_name}': {message}") + + +class WorkerError(GameSDKError): + """Raised when there's an error with worker operations.""" + pass + + +class AgentError(GameSDKError): + """Raised when there's an error with agent operations.""" + pass diff --git a/src/game_sdk/game/utils.py b/src/game_sdk/game/utils.py index 08fc154..13c657f 100644 --- a/src/game_sdk/game/utils.py +++ b/src/game_sdk/game/utils.py @@ -1,95 +1,165 @@ +""" +Utility functions for the GAME SDK. + +This module provides utility functions for common operations like +authentication and API communication. +""" + import requests -from typing import List +from typing import List, Dict, Any, Optional +from .config import config +from .exceptions import ( + APIError, + AuthenticationError, + ValidationError +) -def get_access_token(api_key) -> str: +def get_access_token(api_key: str) -> str: """ - API call to get access token - """ - response = requests.post( - "https://api.virtuals.io/api/accesses/tokens", - json={"data": {}}, - headers={"x-api-key": api_key} - ) + Get an access token from the GAME API. - response_json = response.json() - if response.status_code != 200: - raise ValueError(f"Failed to get token: {response_json}") + Args: + api_key: The API key to authenticate with - return response_json["data"]["accessToken"] + Returns: + str: The access token + + Raises: + AuthenticationError: If authentication fails + APIError: If the API request fails + """ + try: + response = requests.post( + f"{config.api_url}/accesses/tokens", + json={"data": {}}, + headers={"x-api-key": api_key}, + timeout=config.get("request_timeout") + ) + + response_json = response.json() + if response.status_code != 200: + if response.status_code == 401: + raise AuthenticationError("Invalid API key", response.status_code, response_json) + raise APIError(f"Failed to get token: {response_json}", response.status_code, response_json) + + return response_json["data"]["accessToken"] + except requests.exceptions.Timeout: + raise APIError("Request timed out while getting access token") + except requests.exceptions.RequestException as e: + raise APIError(f"Request failed: {str(e)}") def post(base_url: str, api_key: str, endpoint: str, data: dict) -> dict: """ - API call to post data + Make a POST request to the GAME API. + + Args: + base_url: The base URL for the API + api_key: The API key to authenticate with + endpoint: The API endpoint to call + data: The data to send in the request + + Returns: + dict: The API response data + + Raises: + AuthenticationError: If authentication fails + APIError: If the API request fails + ValidationError: If the input data is invalid """ - access_token = get_access_token(api_key) - - response = requests.post( - f"{base_url}/prompts", - json={ - "data": - { - "method": "post", - "headers": { - "Content-Type": "application/json", - }, - "route": endpoint, - "data": data, - }, - }, - headers={"Authorization": f"Bearer {access_token}"}, - ) - - response_json = response.json() - if response.status_code != 200: - raise ValueError(f"Failed to post data: {response_json}") - - return response_json["data"] + if not isinstance(data, dict): + raise ValidationError("Data must be a dictionary") + + try: + access_token = get_access_token(api_key) + + response = requests.post( + f"{base_url}/{endpoint}", + json={"data": data}, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=config.get("request_timeout") + ) + + response_json = response.json() + if response.status_code != 200: + if response.status_code == 401: + raise AuthenticationError("Invalid access token", response.status_code, response_json) + raise APIError(f"API request failed: {response_json}", response.status_code, response_json) + + return response_json["data"] + except requests.exceptions.Timeout: + raise APIError(f"Request timed out while calling {endpoint}") + except requests.exceptions.RequestException as e: + raise APIError(f"Request failed: {str(e)}") def create_agent( - base_url: str, - api_key: str, - name: str, - description: str, - goal: str) -> str: + base_url: str, + api_key: str, + name: str, + description: str, + goal: str +) -> dict: """ - API call to create an agent instance (worker or agent with task generator) + Create a new agent instance. + + Args: + base_url: The base URL for the API + api_key: The API key to authenticate with + name: Name of the agent + description: Description of the agent + goal: Goal of the agent + + Returns: + dict: The created agent data + + Raises: + ValidationError: If any required fields are missing + APIError: If the API request fails """ + if not all([name, description, goal]): + raise ValidationError("name, description, and goal are required") - create_agent_response = post( - base_url, - api_key, - endpoint="/v2/agents", - data={ - "name": name, - "description": description, - "goal": goal, - } - ) + data = { + "name": name, + "description": description, + "goal": goal + } - return create_agent_response["id"] + return post(base_url, api_key, "agents", data) -def create_workers(base_url: str, - api_key: str, - workers: List) -> str: - """ - API call to create workers and worker description for the task generator +def create_workers( + base_url: str, + api_key: str, + workers: List[Dict[str, Any]] +) -> dict: """ + Create workers for the task generator. - res = post( - base_url, - api_key, - endpoint="/v2/maps", - data={ - "locations": [ - {"id": w.id, "name": w.id, "description": w.worker_description} - for w in workers - ] - }, - ) + Args: + base_url: The base URL for the API + api_key: The API key to authenticate with + workers: List of worker configurations + Returns: + dict: The created workers data - return res["id"] + Raises: + ValidationError: If the workers list is invalid + APIError: If the API request fails + """ + if not isinstance(workers, list): + raise ValidationError("workers must be a list") + + if not workers: + raise ValidationError("workers list cannot be empty") + + for worker in workers: + if not isinstance(worker, dict): + raise ValidationError("Each worker must be a dictionary") + if "description" not in worker: + raise ValidationError("Each worker must have a description") + + return post(base_url, api_key, "workers", {"workers": workers}) diff --git a/src/game_sdk/version.py b/src/game_sdk/version.py new file mode 100644 index 0000000..506d93f --- /dev/null +++ b/src/game_sdk/version.py @@ -0,0 +1,7 @@ +"""Version information for GAME SDK.""" + +__version__ = "0.1.0" +__author__ = "GAME SDK Team" +__author_email__ = "team@virtuals.io" +__description__ = "GAME SDK - A Python SDK for building autonomous agents" +__url__ = "https://github.com/game-by-virtuals/game-python" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9a2a30d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,30 @@ +""" +PyTest configuration and fixtures for GAME SDK tests. +""" + +import pytest +from unittest.mock import Mock +from game_sdk.game.config import SDKConfig + + +@pytest.fixture +def mock_config(): + """Fixture providing a test configuration.""" + return SDKConfig( + api_base_url="https://test.virtuals.io", + api_version="test", + request_timeout=5, + max_retries=1, + retry_delay=0, + ) + + +@pytest.fixture +def mock_response(): + """Fixture providing a mock HTTP response.""" + def _mock_response(status_code=200, json_data=None): + mock = Mock() + mock.status_code = status_code + mock.json.return_value = json_data or {} + return mock + return _mock_response diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9a6b730 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,50 @@ +""" +Tests for the GAME SDK configuration system. +""" + +import os +import pytest +from game_sdk.game.config import SDKConfig +from game_sdk.game.exceptions import ConfigurationError + + +def test_default_config(): + """Test that default configuration is loaded correctly.""" + config = SDKConfig() + assert config.get("api_base_url") == "https://game.virtuals.io" + assert config.get("api_version") == "v1" + assert config.get("request_timeout") == 30 + + +def test_config_override(): + """Test that configuration can be overridden.""" + config = SDKConfig(api_base_url="https://test.example.com") + assert config.get("api_base_url") == "https://test.example.com" + # Other values should remain default + assert config.get("api_version") == "v1" + + +def test_env_var_override(monkeypatch): + """Test that environment variables override defaults.""" + monkeypatch.setenv("GAME_API_BASE_URL", "https://env.example.com") + monkeypatch.setenv("GAME_REQUEST_TIMEOUT", "60") + + config = SDKConfig() + assert config.get("api_base_url") == "https://env.example.com" + assert config.get("request_timeout") == 60 + + +def test_invalid_config_key(): + """Test that setting invalid configuration keys raises an error.""" + config = SDKConfig() + with pytest.raises(ConfigurationError): + config.set("invalid_key", "value") + + +def test_api_url_property(): + """Test the api_url property combines base URL and version correctly.""" + config = SDKConfig( + api_base_url="https://test.example.com", + api_version="v2" + ) + assert config.api_url == "https://test.example.com/v2" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..9f327b8 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,131 @@ +""" +Tests for GAME SDK utility functions. +""" + +import pytest +import requests +from unittest.mock import patch, Mock + +from game_sdk.game.utils import ( + get_access_token, + post, + create_agent, + create_workers +) +from game_sdk.game.exceptions import ( + APIError, + AuthenticationError, + ValidationError +) + + +def test_get_access_token_success(mock_response): + """Test successful access token retrieval.""" + mock = mock_response(200, {"data": {"accessToken": "test-token"}}) + + with patch('requests.post', return_value=mock): + token = get_access_token("test-key") + assert token == "test-token" + + +def test_get_access_token_invalid_key(mock_response): + """Test authentication failure with invalid API key.""" + mock = mock_response(401, {"error": "Invalid API key"}) + + with patch('requests.post', return_value=mock): + with pytest.raises(AuthenticationError) as exc: + get_access_token("invalid-key") + assert "Invalid API key" in str(exc.value) + + +def test_get_access_token_timeout(): + """Test timeout handling in token retrieval.""" + with patch('requests.post', side_effect=requests.exceptions.Timeout): + with pytest.raises(APIError) as exc: + get_access_token("test-key") + assert "timed out" in str(exc.value) + + +def test_post_success(mock_response): + """Test successful POST request.""" + token_mock = mock_response(200, {"data": {"accessToken": "test-token"}}) + post_mock = mock_response(200, {"data": {"result": "success"}}) + + with patch('requests.post') as mock_post: + mock_post.side_effect = [token_mock, post_mock] + result = post("https://test.api", "test-key", "test-endpoint", {"test": "data"}) + assert result == {"result": "success"} + + +def test_post_invalid_data(): + """Test validation of POST request data.""" + with pytest.raises(ValidationError) as exc: + post("https://test.api", "test-key", "test-endpoint", "invalid-data") + assert "must be a dictionary" in str(exc.value) + + +def test_create_agent_success(mock_response): + """Test successful agent creation.""" + token_mock = mock_response(200, {"data": {"accessToken": "test-token"}}) + agent_mock = mock_response(200, {"data": {"id": "test-agent-id"}}) + + with patch('requests.post') as mock_post: + mock_post.side_effect = [token_mock, agent_mock] + result = create_agent( + "https://test.api", + "test-key", + "Test Agent", + "Test Description", + "Test Goal" + ) + assert result == {"id": "test-agent-id"} + + +def test_create_agent_missing_fields(): + """Test validation of required agent fields.""" + with pytest.raises(ValidationError) as exc: + create_agent( + "https://test.api", + "test-key", + "", # Empty name + "Test Description", + "Test Goal" + ) + assert "required" in str(exc.value) + + +def test_create_workers_success(mock_response): + """Test successful worker creation.""" + token_mock = mock_response(200, {"data": {"accessToken": "test-token"}}) + workers_mock = mock_response(200, {"data": {"ids": ["worker1", "worker2"]}}) + + workers_data = [ + {"description": "Worker 1"}, + {"description": "Worker 2"} + ] + + with patch('requests.post') as mock_post: + mock_post.side_effect = [token_mock, workers_mock] + result = create_workers("https://test.api", "test-key", workers_data) + assert result == {"ids": ["worker1", "worker2"]} + + +def test_create_workers_invalid_input(): + """Test validation of workers input.""" + with pytest.raises(ValidationError) as exc: + create_workers("https://test.api", "test-key", "invalid-workers") + assert "must be a list" in str(exc.value) + + +def test_create_workers_empty_list(): + """Test validation of empty workers list.""" + with pytest.raises(ValidationError) as exc: + create_workers("https://test.api", "test-key", []) + assert "cannot be empty" in str(exc.value) + + +def test_create_workers_missing_description(): + """Test validation of worker description.""" + with pytest.raises(ValidationError) as exc: + create_workers("https://test.api", "test-key", [{"name": "Worker"}]) + assert "must have a description" in str(exc.value) From 32c480768e9ff0a00fa750ddbb9774b75ea174bf Mon Sep 17 00:00:00 2001 From: Dylan Burkey Date: Thu, 23 Jan 2025 15:53:09 -0500 Subject: [PATCH 2/2] feat: Improve SDK reliability and documentation - Update API endpoint to use api.virtuals.io/api - Add comprehensive response handling for different status codes - Create testing guide with setup instructions - Add examples documentation - Add development requirements - Improve .gitignore patterns Part of core SDK enhancements to improve reliability and user experience. --- .gitignore | 101 +++++++++++++++++++++++++++++--- docs/getting-started.md | 0 docs/testing_guide.md | 112 ++++++++++++++++++++++++++++++++++++ examples/README.md | 0 examples/test_sdk.py | 81 ++++++++++++++++++++++++++ requirements-dev.txt | 7 +++ src/game_sdk/game/agent.py | 8 ++- src/game_sdk/game/config.py | 41 ++++--------- src/game_sdk/game/utils.py | 50 ++++++++++------ 9 files changed, 344 insertions(+), 56 deletions(-) create mode 100644 docs/getting-started.md create mode 100644 docs/testing_guide.md create mode 100644 examples/README.md create mode 100644 examples/test_sdk.py create mode 100644 requirements-dev.txt diff --git a/.gitignore b/.gitignore index ac56288..a7e7680 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,95 @@ -*.pth -*.env -*# +# Git Ignore File Guide +# --------------------- +# - Each line specifies a pattern for files/directories that Git should ignore +# - '*' means "match any characters" +# - A trailing '/' indicates a directory +# - Lines starting with '#' are comments +# - Patterns are matched relative to the location of the .gitignore file +# - More specific rules override more general rules +# - Use '!' to negate a pattern (include something that would otherwise be ignored) + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +env/ +ENV/ +.env +.venv +.python-version + +# IDE +.idea/ +.vscode/ +*.swp +*.swo *~ -*.pyc -*__pycache__ -*.json +.project +.pydevproject +.settings/ +*.sublime-workspace +*.sublime-project + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Jupyter Notebook +.ipynb_checkpoints +*.ipynb + +# Distribution / packaging +.Python +*.pth +*.whl + +# Logs and databases +*.log +*.sqlite +*.db + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db -*.DS_Store -dist/ \ No newline at end of file +# Project specific +*.json # Temporary JSON files +.env.local +.env.*.local +config.local.py \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/testing_guide.md b/docs/testing_guide.md new file mode 100644 index 0000000..6778fd1 --- /dev/null +++ b/docs/testing_guide.md @@ -0,0 +1,112 @@ +# Testing Guide + +This guide explains how to test the GAME SDK and verify your setup is working correctly. + +## Prerequisites + +1. Python 3.8 or higher +2. A GAME API key (get one from [Virtuals.io](https://virtuals.io)) +3. Virtual environment (recommended) + +## Quick Start + +For a quick verification of your SDK setup, see the [test_sdk.py example](../examples/README.md#test-sdk-script). + +## Setting Up Your Environment + +1. Create a virtual environment: +```bash +python3 -m venv venv +source venv/bin/activate # On Windows use: venv\Scripts\activate +``` + +2. Install the SDK and development dependencies: +```bash +pip install -e . +pip install -r requirements-dev.txt +``` + +3. Create a `.env` file in your project root with your API keys: +```env +GAME_API_KEY="your_api_key_here" +ENVIRONMENT=development +DEBUG=False +LOG_LEVEL=INFO +``` + +## Running the Test Script + +We provide a test script (`examples/test_sdk.py`) that verifies your SDK setup is working correctly. The script: +1. Tests authentication with your API key +2. Creates a test agent +3. Verifies the SDK's core functionality + +Run the test script: +```bash +python3 examples/test_sdk.py +``` + +You should see output similar to: +``` +Testing GAME SDK connection... +✅ Successfully authenticated with API +✅ Successfully created test agent +🎉 SDK is working correctly! +``` + +## Writing Tests + +When writing tests for the SDK, follow these patterns: + +1. Use the provided test fixtures in `tests/conftest.py` +2. Handle API responses appropriately +3. Check for both success and error cases + +Example test: +```python +def test_agent_creation(api_key): + """Test creating a new agent.""" + agent = Agent( + api_key=api_key, + name="Test Agent", + agent_description="Test Description", + agent_goal="Test Goal", + get_agent_state_fn=lambda x, y: {"status": "ready"} + ) + assert agent.agent_id is not None +``` + +## Common Issues and Solutions + +### API Base URL +The SDK uses `https://api.virtuals.io/api` as the base URL. If you need to use a different endpoint, you can: + +1. Set it in your environment: +```env +GAME_API_BASE_URL="your_api_endpoint" +``` + +2. Or configure it in your code: +```python +from game_sdk.game.config import config + +config.set("api_base_url", "your_api_endpoint") +``` + +### Authentication Issues +- Verify your API key is correct +- Check that your `.env` file is in the correct location +- Ensure you've installed `python-dotenv` (`pip install python-dotenv`) + +### Response Handling +The SDK handles various API response types: +- 200-202: Success with JSON response +- 204: Success with no content +- Empty responses with success status codes +- Error responses with detailed messages + +## Next Steps + +1. Review the [Error Handling Guide](error-handling.md) for detailed error handling patterns +2. Check the [Configuration Guide](configuration.md) for advanced configuration options +3. See the [SDK Guide](sdk_guide.md) for complete SDK usage documentation diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..e69de29 diff --git a/examples/test_sdk.py b/examples/test_sdk.py new file mode 100644 index 0000000..2fdb6b6 --- /dev/null +++ b/examples/test_sdk.py @@ -0,0 +1,81 @@ +""" +Test script to verify SDK installation and API key. +""" + +import os +import requests +from dotenv import load_dotenv +from game_sdk.game.utils import get_access_token +from game_sdk.game.agent import Agent +from game_sdk.game.exceptions import AuthenticationError + +def get_test_state(function_result, current_state): + """Simple state function for testing.""" + return {"status": "ready"} + +def test_connection(): + """Test SDK connection with API key.""" + # Load environment variables from .env file + load_dotenv() + + api_key = os.getenv("GAME_API_KEY") + if not api_key: + print("Error: GAME_API_KEY environment variable not found") + return False + + try: + print(f"Using API key: {api_key}") + + # Test authentication + token = get_access_token(api_key) + print("✅ Successfully authenticated with API") + + # Test direct agent creation + data = { + "data": { + "name": "Test Agent", + "description": "A test agent to verify SDK installation", + "goal": "Verify that the SDK is working correctly" + } + } + + response = requests.post( + "https://api.virtuals.io/api/agents", + json=data, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + ) + + print(f"\nDirect API call to create agent:") + print(f"Status: {response.status_code}") + print(f"Response: {response.text}") + + # Create a test agent using SDK + agent = Agent( + api_key=api_key, + name="Test Agent", + agent_description="A test agent to verify SDK installation", + agent_goal="Verify that the SDK is working correctly", + get_agent_state_fn=get_test_state + ) + print("✅ Successfully created test agent") + + return True + except AuthenticationError as e: + print(f"❌ Authentication failed: {e}") + return False + except Exception as e: + print(f"❌ An error occurred: {e}") + import traceback + print(traceback.format_exc()) + return False + +if __name__ == "__main__": + print("Testing GAME SDK connection...") + success = test_connection() + if success: + print("\n🎉 SDK is working correctly!") + else: + print("\n❌ SDK test failed. Please check the error messages above.") diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f442bcf --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,7 @@ +pytest>=7.0.0 +pytest-cov>=4.0.0 +pytest-mock>=3.10.0 +black>=23.0.0 +flake8>=6.0.0 +mypy>=1.0.0 +requests>=2.31.0 diff --git a/src/game_sdk/game/agent.py b/src/game_sdk/game/agent.py index 9b9ef83..d123e70 100644 --- a/src/game_sdk/game/agent.py +++ b/src/game_sdk/game/agent.py @@ -1,8 +1,14 @@ +""" +Agent class for interacting with the GAME API. +""" + from typing import List, Optional, Callable, Dict import uuid from game_sdk.game.worker import Worker from game_sdk.game.custom_types import Function, FunctionResult, FunctionResultStatus, ActionResponse, ActionType from game_sdk.game.utils import create_agent, create_workers, post +from game_sdk.game.config import config + class Session: def __init__(self): @@ -52,7 +58,7 @@ def __init__(self, workers: Optional[List[WorkerConfig]] = None, ): - self._base_url: str = "https://game.virtuals.io" + self._base_url: str = config.api_url # Use the configured API URL self._api_key: str = api_key # checks diff --git a/src/game_sdk/game/config.py b/src/game_sdk/game/config.py index 4dffb30..a485329 100644 --- a/src/game_sdk/game/config.py +++ b/src/game_sdk/game/config.py @@ -29,7 +29,7 @@ class SDKConfig: """ DEFAULT_CONFIG = { - "api_base_url": "https://game.virtuals.io", + "api_base_url": "https://api.virtuals.io/api", "api_version": "v1", "request_timeout": 30, "max_retries": 3, @@ -51,7 +51,7 @@ def __init__(self, **kwargs): def _load_env_vars(self): """Load configuration from environment variables.""" - env_mapping = { + env_mappings = { "GAME_API_BASE_URL": "api_base_url", "GAME_API_VERSION": "api_version", "GAME_REQUEST_TIMEOUT": "request_timeout", @@ -59,16 +59,16 @@ def _load_env_vars(self): "GAME_RETRY_DELAY": "retry_delay", } - for env_var, config_key in env_mapping.items(): - value = os.environ.get(env_var) - if value is not None: - # Convert to int for numeric settings + for env_var, config_key in env_mappings.items(): + if env_var in os.environ: + value = os.environ[env_var] + # Convert numeric values if config_key in ["request_timeout", "max_retries", "retry_delay"]: try: value = int(value) except ValueError: raise ConfigurationError( - f"Invalid value for {env_var}: {value}. Must be an integer." + f"Invalid value for {env_var}: {value}. Expected an integer." ) self._config[config_key] = value @@ -77,37 +77,18 @@ def get(self, key: str, default: Any = None) -> Any: Get a configuration value. Args: - key: Configuration key to retrieve + key: Configuration key to get default: Default value if key doesn't exist Returns: - Configuration value + The configuration value """ return self._config.get(key, default) - def set(self, key: str, value: Any): - """ - Set a configuration value. - - Args: - key: Configuration key to set - value: Value to set - - Raises: - ConfigurationError: If key is invalid - """ - if key not in self.DEFAULT_CONFIG: - raise ConfigurationError(f"Invalid configuration key: {key}") - self._config[key] = value - @property def api_url(self) -> str: - """Get the full API URL including version.""" - return f"{self._config['api_base_url']}/{self._config['api_version']}" - - def as_dict(self) -> Dict[str, Any]: - """Get all configuration as a dictionary.""" - return self._config.copy() + """Get the full API URL.""" + return self._config["api_base_url"] # Global configuration instance diff --git a/src/game_sdk/game/utils.py b/src/game_sdk/game/utils.py index 13c657f..125e313 100644 --- a/src/game_sdk/game/utils.py +++ b/src/game_sdk/game/utils.py @@ -76,18 +76,35 @@ def post(base_url: str, api_key: str, endpoint: str, data: dict) -> dict: response = requests.post( f"{base_url}/{endpoint}", - json={"data": data}, - headers={"Authorization": f"Bearer {access_token}"}, + json=data, # Send data directly without wrapping + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + }, timeout=config.get("request_timeout") ) - response_json = response.json() - if response.status_code != 200: + # Handle 204 No Content responses + if response.status_code == 204: + return {"success": True} + + # Handle empty responses + if not response.text: + if response.status_code in [200, 201, 202]: + return {"success": True} + raise APIError(f"Empty response from server with status code {response.status_code}") + + try: + response_json = response.json() + except requests.exceptions.JSONDecodeError as e: + raise APIError(f"Invalid JSON response: {response.text}") + + if response.status_code not in [200, 201, 202]: if response.status_code == 401: raise AuthenticationError("Invalid access token", response.status_code, response_json) raise APIError(f"API request failed: {response_json}", response.status_code, response_json) - return response_json["data"] + return response_json.get("data", response_json) # Handle both wrapped and unwrapped responses except requests.exceptions.Timeout: raise APIError(f"Request timed out while calling {endpoint}") except requests.exceptions.RequestException as e: @@ -122,9 +139,11 @@ def create_agent( raise ValidationError("name, description, and goal are required") data = { - "name": name, - "description": description, - "goal": goal + "data": { # Wrap in data object as required by API + "name": name, + "description": description, + "goal": goal + } } return post(base_url, api_key, "agents", data) @@ -152,14 +171,11 @@ def create_workers( """ if not isinstance(workers, list): raise ValidationError("workers must be a list") - - if not workers: - raise ValidationError("workers list cannot be empty") - for worker in workers: - if not isinstance(worker, dict): - raise ValidationError("Each worker must be a dictionary") - if "description" not in worker: - raise ValidationError("Each worker must have a description") + data = { + "data": { # Wrap in data object as required by API + "workers": workers + } + } - return post(base_url, api_key, "workers", {"workers": workers}) + return post(base_url, api_key, "workers", data)