diff --git a/.gitignore b/.gitignore index f61b850..24413be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,48 +1,62 @@ -.venv +<<<<<<< HEAD +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ .env -__pycache__ -.pytest_cache -.pypirc -*.db -test -test_state.json -task_flow.egg-info -example_repo -signature.js -git-filter-repo -task/orca/ -**/dist/ -# yarn.lock -package-lock.json -node_modules -build -migrate.sh -*/dev.js -executables/* -namespace/* -config/* -.env.local -taskStateInfoKeypair.json -localKOIIDB.db -metadata.json -.npmrc -*.pem -.vscode -.cursor -data/chunks -data/process -test_state.csv -todos-example.csv - - -# Ignore auto-generated repository directories -repos/ +.venv/ +venv/ +dist/ +build/ +*.egg-info/ +.DS_Store +======= +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +# Virtual environments +venv/ +env/ +.env +.venv -# Ignore Data -data/* +# Pytest +.pytest_cache/ +# IDEs and editors +.idea/ +.vscode/ +*.swp +*.swo -venv +# Logs +*.log -**/venv/ +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +>>>>>>> pr-24-HermanKoii-prometheus-test diff --git a/config.py b/config.py new file mode 100644 index 0000000..c255b14 --- /dev/null +++ b/config.py @@ -0,0 +1,122 @@ +import os +from typing import Optional, Dict, Any +from dotenv import load_dotenv +import re + +class InvalidConfigurationError(ValueError): + """Custom exception for invalid configuration.""" + pass + +class CoinGeckoConfig: + """ + Configuration management for CoinGecko API client. + + Supports configuration through: + 1. Environment variables + 2. Explicit parameters + 3. Default values + """ + + def __init__( + self, + api_base_url: Optional[str] = None, + api_key: Optional[str] = None, + timeout: Optional[int] = None, + max_retries: Optional[int] = None + ): + """ + Initialize CoinGecko API configuration. + + Args: + api_base_url (Optional[str]): Base URL for CoinGecko API + api_key (Optional[str]): API key for authentication + timeout (Optional[int]): Request timeout in seconds + max_retries (Optional[int]): Maximum number of request retries + + Raises: + InvalidConfigurationError: If configuration parameters are invalid + """ + # Load .env file if it exists + load_dotenv() + + # Priority: Explicit parameters > Environment Variables > Default Values + self.api_base_url = ( + api_base_url or + os.getenv('COINGECKO_API_BASE_URL', 'https://api.coingecko.com/api/v3') + ) + + self.api_key = ( + api_key or + os.getenv('COINGECKO_API_KEY') + ) + + self.timeout = ( + timeout or + int(os.getenv('COINGECKO_API_TIMEOUT', 10)) + ) + + self.max_retries = ( + max_retries or + int(os.getenv('COINGECKO_MAX_RETRIES', 3)) + ) + + # Validate configuration during initialization + self._validate() + + def get_config(self) -> Dict[str, Any]: + """ + Get current configuration as a dictionary. + + Returns: + Dict[str, Any]: Configuration dictionary + """ + return { + 'api_base_url': self.api_base_url, + 'api_key': '****' if self.api_key else None, # Mask API key + 'timeout': self.timeout, + 'max_retries': self.max_retries + } + + def _validate(self) -> None: + """ + Validate the current configuration. + + Raises: + InvalidConfigurationError: If configuration is invalid + """ + # Validate API base URL + if not isinstance(self.api_base_url, str): + raise InvalidConfigurationError("API base URL must be a string") + + # Use regex to validate URL structure + url_pattern = re.compile( + r'^https?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+(?:[A-Z]{2,6}\\.?|[A-Z0-9-]{2,}\\.?)|' # domain + r'localhost|' # localhost + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # or IP + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + + if not url_pattern.match(self.api_base_url): + raise InvalidConfigurationError("Invalid API base URL format") + + # Validate timeout + if not isinstance(self.timeout, int) or self.timeout <= 0: + raise InvalidConfigurationError("Timeout must be a positive integer") + + # Validate max retries + if not isinstance(self.max_retries, int) or self.max_retries < 0: + raise InvalidConfigurationError("Max retries must be a non-negative integer") + + def validate(self) -> bool: + """ + Check if configuration is valid. + + Returns: + bool: Whether the configuration is valid + """ + try: + self._validate() + return True + except InvalidConfigurationError: + return False \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c58dfce --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest>=7.3.1 +pytest-asyncio>=0.21.0 +requests-mock>=1.9.3 +requests>=2.25.1 +aiohttp>=3.8.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9e96d9e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +aiohttp>=3.8.4 \ No newline at end of file diff --git a/src/base_client.py b/src/base_client.py new file mode 100644 index 0000000..854ae44 --- /dev/null +++ b/src/base_client.py @@ -0,0 +1,102 @@ +import logging +import requests +from typing import Dict, Any, Optional + +class BaseAPIClient: + """ + Base API client for making HTTP requests with robust error handling and logging. + + This class provides a standardized interface for making API requests with + comprehensive logging and error management. + """ + + def __init__( + self, + base_url: str, + timeout: int = 10, + log_level: int = logging.INFO + ): + """ + Initialize the base API client. + + Args: + base_url (str): Base URL for the API + timeout (int, optional): Request timeout in seconds. Defaults to 10. + log_level (int, optional): Logging level. Defaults to logging.INFO. + """ + self.base_url = base_url + self.timeout = timeout + + # Configure logging + logging.basicConfig( + level=log_level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + self.logger = logging.getLogger(self.__class__.__name__) + + def _make_request( + self, + method: str, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """ + Make an HTTP request with robust error handling and logging. + + Args: + method (str): HTTP method (GET, POST, etc.) + endpoint (str): API endpoint + params (dict, optional): Query parameters + headers (dict, optional): Request headers + + Returns: + dict: Parsed JSON response + + Raises: + ValueError: For invalid method + RuntimeError: For network or API errors + """ + url = f"{self.base_url}/{endpoint}" + + # Default headers + request_headers = headers or {} + request_headers.setdefault('Accept', 'application/json') + + # Log request details + self.logger.info(f"Sending {method} request to {url}") + if params: + self.logger.debug(f"Request params: {params}") + + try: + response = requests.request( + method=method.upper(), + url=url, + params=params, + headers=request_headers, + timeout=self.timeout + ) + + # Raise an exception for HTTP errors + response.raise_for_status() + + # Log successful response + self.logger.info(f"Received {response.status_code} response") + + return response.json() + + except requests.exceptions.Timeout: + self.logger.error(f"Request to {url} timed out") + raise RuntimeError(f"Request to {url} timed out after {self.timeout} seconds") + + except requests.exceptions.HTTPError as e: + self.logger.error(f"HTTP error occurred: {e}") + raise RuntimeError(f"HTTP error: {e}") + + except requests.exceptions.RequestException as e: + self.logger.error(f"Network error occurred: {e}") + raise RuntimeError(f"Network error: {e}") + + except ValueError as e: + self.logger.error(f"JSON parsing error: {e}") + raise RuntimeError(f"Could not parse response: {e}") \ No newline at end of file diff --git a/src/coingecko_api/__init__.py b/src/coingecko_api/__init__.py new file mode 100644 index 0000000..ae43eba --- /dev/null +++ b/src/coingecko_api/__init__.py @@ -0,0 +1,2 @@ +# CoinGecko API Client Package +from .base_client import CoinGeckoBaseClient, BaseAPIConfig \ No newline at end of file diff --git a/src/coingecko_api/base_client.py b/src/coingecko_api/base_client.py new file mode 100644 index 0000000..538df1d --- /dev/null +++ b/src/coingecko_api/base_client.py @@ -0,0 +1,97 @@ +import asyncio +import logging +from typing import Dict, Any, Optional +from dataclasses import dataclass, field +import aiohttp +import json + +logger = logging.getLogger(__name__) + +@dataclass +class BaseAPIConfig: + """Configuration for the CoinGecko API client.""" + base_url: str = "https://api.coingecko.com/api/v3" + timeout: int = 10 + retries: int = 3 + backoff_factor: float = 0.5 + +class CoinGeckoBaseClient: + """Base client for interacting with the CoinGecko API.""" + + def __init__(self, config: BaseAPIConfig = BaseAPIConfig()): + """ + Initialize the CoinGecko base client. + + Args: + config (BaseAPIConfig, optional): Configuration for the API client. + Defaults to default BaseAPIConfig. + """ + self._config = config + self._session: Optional[aiohttp.ClientSession] = None + + async def __aenter__(self): + """Async context manager entry point.""" + self._session = aiohttp.ClientSession() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit point.""" + if self._session: + await self._session.close() + + async def _request( + self, + method: str, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """ + Make an asynchronous request to the CoinGecko API. + + Args: + method (str): HTTP method (GET, POST, etc.) + endpoint (str): API endpoint + params (dict, optional): Query parameters + headers (dict, optional): Request headers + + Returns: + dict: Parsed JSON response + + Raises: + aiohttp.ClientError: For network-related errors + json.JSONDecodeError: For JSON parsing errors + """ + url = f"{self._config.base_url}{endpoint}" + headers = headers or {} + params = params or {} + + for attempt in range(self._config.retries): + try: + if not self._session: + self._session = aiohttp.ClientSession() + + async with self._session.request( + method, + url, + params=params, + headers=headers, + timeout=aiohttp.ClientTimeout(total=self._config.timeout) + ) as response: + response.raise_for_status() + return await response.json() + + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + logger.warning(f"Request failed (Attempt {attempt + 1}): {e}") + + # Exponential backoff + if attempt < self._config.retries - 1: + await asyncio.sleep(self._config.backoff_factor * (2 ** attempt)) + else: + raise + + async def close(self): + """Close the HTTP session.""" + if self._session: + await self._session.close() + self._session = None \ No newline at end of file diff --git a/tests/test_base_client.py b/tests/test_base_client.py new file mode 100644 index 0000000..e05bb70 --- /dev/null +++ b/tests/test_base_client.py @@ -0,0 +1,86 @@ +import pytest +import requests +import requests_mock +import aiohttp +from src.base_client import BaseAPIClient +from src.coingecko_api.base_client import CoinGeckoBaseClient, BaseAPIConfig + +# Synchronous BaseAPIClient Tests +def test_base_client_initialization(): + """Test BaseAPIClient initialization.""" + base_url = "https://api.example.com" + client = BaseAPIClient(base_url) + + assert client.base_url == base_url + assert client.timeout == 10 + assert client.logger is not None + +def test_successful_get_request(): + """Test a successful GET request.""" + base_url = "https://api.example.com" + client = BaseAPIClient(base_url) + + with requests_mock.Mocker() as m: + mock_response = {"data": "test"} + m.get(f"{base_url}/test_endpoint", json=mock_response, status_code=200) + + result = client._make_request("GET", "test_endpoint") + assert result == mock_response + +def test_request_timeout(): + """Test request timeout handling.""" + base_url = "https://api.example.com" + client = BaseAPIClient(base_url, timeout=1) + + with requests_mock.Mocker() as m: + m.get(f"{base_url}/timeout_endpoint", exc=requests.exceptions.Timeout) + + with pytest.raises(RuntimeError, match="timed out"): + client._make_request("GET", "timeout_endpoint") + +def test_http_error_handling(): + """Test HTTP error handling.""" + base_url = "https://api.example.com" + client = BaseAPIClient(base_url) + + with requests_mock.Mocker() as m: + m.get(f"{base_url}/error_endpoint", status_code=404) + + with pytest.raises(RuntimeError, match="HTTP error"): + client._make_request("GET", "error_endpoint") + +def test_request_with_params(): + """Test request with query parameters.""" + base_url = "https://api.example.com" + client = BaseAPIClient(base_url) + + with requests_mock.Mocker() as m: + mock_response = {"data": "test"} + m.get(f"{base_url}/params_endpoint", json=mock_response, status_code=200) + + result = client._make_request("GET", "params_endpoint", params={"key": "value"}) + assert result == mock_response + +# Asynchronous CoinGecko Base Client Tests +@pytest.mark.asyncio +async def test_coingecko_base_client_initialization(): + """Test CoinGecko base client initialization.""" + async with CoinGeckoBaseClient() as client: + assert client is not None + +@pytest.mark.asyncio +async def test_coingecko_base_client_config(): + """Test CoinGecko client configuration.""" + config = BaseAPIConfig(base_url="https://test.api.com", timeout=5, retries=2) + client = CoinGeckoBaseClient(config) + assert client._config.base_url == "https://test.api.com" + assert client._config.timeout == 5 + assert client._config.retries == 2 + +@pytest.mark.skip(reason="Requires actual API access") +@pytest.mark.asyncio +async def test_coingecko_base_client_request(): + """Placeholder for CoinGecko client request test.""" + async with CoinGeckoBaseClient() as client: + # Simulate a request + pass \ No newline at end of file