""" HCFS Synchronous Client High-level synchronous client for HCFS API operations. """ import json import time from typing import List, Optional, Dict, Any, Iterator from datetime import datetime import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from .models import ( Context, SearchResult, ContextFilter, PaginationOptions, SearchOptions, ClientConfig, AnalyticsData, BatchResult ) from .exceptions import ( HCFSError, HCFSConnectionError, HCFSAuthenticationError, HCFSNotFoundError, HCFSValidationError, handle_api_error ) from .utils import MemoryCache, validate_path, normalize_path from .decorators import cached_context, retry_on_failure, rate_limited class HCFSClient: """ Synchronous HCFS API client with caching and retry capabilities. This client provides a high-level interface for interacting with the HCFS API, including context management, search operations, and batch processing. Example: >>> from hcfs.sdk import HCFSClient, Context >>> >>> # Initialize client >>> client = HCFSClient( ... base_url="https://api.hcfs.example.com", ... api_key="your-api-key" ... ) >>> >>> # Create a context >>> context = Context( ... path="/docs/readme", ... content="This is a README file", ... summary="Project documentation" ... ) >>> created = client.create_context(context) >>> >>> # Search contexts >>> results = client.search_contexts("README documentation") >>> for result in results: ... print(f"Found: {result.context.path} (score: {result.score})") """ def __init__(self, config: Optional[ClientConfig] = None, **kwargs): """ Initialize HCFS client. Args: config: Client configuration object **kwargs: Configuration overrides (base_url, api_key, etc.) """ # Merge configuration if config: self.config = config else: self.config = ClientConfig(**kwargs) # Initialize session with retry strategy self.session = requests.Session() # Configure retries retry_strategy = Retry( total=self.config.retry.max_attempts if self.config.retry.enabled else 0, status_forcelist=[429, 500, 502, 503, 504], backoff_factor=self.config.retry.base_delay, raise_on_status=False ) adapter = HTTPAdapter( max_retries=retry_strategy, pool_connections=self.config.max_connections, pool_maxsize=self.config.max_keepalive_connections ) self.session.mount("http://", adapter) self.session.mount("https://", adapter) # Set headers self.session.headers.update({ "User-Agent": self.config.user_agent, "Content-Type": "application/json" }) if self.config.api_key: self.session.headers["X-API-Key"] = self.config.api_key elif self.config.jwt_token: self.session.headers["Authorization"] = f"Bearer {self.config.jwt_token}" # Initialize cache self._cache = MemoryCache( max_size=self.config.cache.max_size, strategy=self.config.cache.strategy, ttl_seconds=self.config.cache.ttl_seconds ) if self.config.cache.enabled else None # Analytics self.analytics = AnalyticsData() def health_check(self) -> Dict[str, Any]: """ Check API health status. Returns: Health status information Raises: HCFSConnectionError: If health check fails """ try: response = self.session.get( f"{self.config.base_url}/health", timeout=self.config.timeout ) if response.status_code == 200: self._update_analytics("health_check", success=True) return response.json() else: self._update_analytics("health_check", success=False) handle_api_error(response) except requests.exceptions.RequestException as e: self._update_analytics("health_check", success=False, error=str(e)) raise HCFSConnectionError(f"Health check failed: {str(e)}") @cached_context() @retry_on_failure() def create_context(self, context: Context) -> Context: """ Create a new context. Args: context: Context object to create Returns: Created context with assigned ID Raises: HCFSValidationError: If context data is invalid HCFSError: If creation fails """ if not validate_path(context.path): raise HCFSValidationError(f"Invalid context path: {context.path}") context.path = normalize_path(context.path) try: response = self.session.post( f"{self.config.base_url}/api/v1/contexts", json=context.to_create_dict(), timeout=self.config.timeout ) if response.status_code == 200: data = response.json()["data"] created_context = Context(**data) self._update_analytics("create_context", success=True) return created_context else: self._update_analytics("create_context", success=False) handle_api_error(response) except requests.exceptions.RequestException as e: self._update_analytics("create_context", success=False, error=str(e)) raise HCFSConnectionError(f"Failed to create context: {str(e)}") @cached_context() def get_context(self, context_id: int) -> Context: """ Retrieve a context by ID. Args: context_id: Context identifier Returns: Context object Raises: HCFSNotFoundError: If context doesn't exist """ try: response = self.session.get( f"{self.config.base_url}/api/v1/contexts/{context_id}", timeout=self.config.timeout ) if response.status_code == 200: data = response.json()["data"] context = Context(**data) self._update_analytics("get_context", success=True) return context else: self._update_analytics("get_context", success=False) handle_api_error(response) except requests.exceptions.RequestException as e: self._update_analytics("get_context", success=False, error=str(e)) raise HCFSConnectionError(f"Failed to get context: {str(e)}") def list_contexts(self, filter_opts: Optional[ContextFilter] = None, pagination: Optional[PaginationOptions] = None) -> List[Context]: """ List contexts with filtering and pagination. Args: filter_opts: Context filtering options pagination: Pagination configuration Returns: List of contexts """ params = {} if filter_opts: params.update(filter_opts.to_query_params()) if pagination: params.update(pagination.to_query_params()) try: response = self.session.get( f"{self.config.base_url}/api/v1/contexts", params=params, timeout=self.config.timeout ) if response.status_code == 200: data = response.json()["data"] contexts = [Context(**ctx_data) for ctx_data in data] self._update_analytics("list_contexts", success=True) return contexts else: self._update_analytics("list_contexts", success=False) handle_api_error(response) except requests.exceptions.RequestException as e: self._update_analytics("list_contexts", success=False, error=str(e)) raise HCFSConnectionError(f"Failed to list contexts: {str(e)}") def update_context(self, context_id: int, updates: Dict[str, Any]) -> Context: """ Update an existing context. Args: context_id: Context identifier updates: Fields to update Returns: Updated context Raises: HCFSNotFoundError: If context doesn't exist HCFSValidationError: If update data is invalid """ try: response = self.session.put( f"{self.config.base_url}/api/v1/contexts/{context_id}", json=updates, timeout=self.config.timeout ) if response.status_code == 200: data = response.json()["data"] updated_context = Context(**data) self._update_analytics("update_context", success=True) # Invalidate cache if self._cache: cache_key = f"get_context:{context_id}" self._cache.remove(cache_key) return updated_context else: self._update_analytics("update_context", success=False) handle_api_error(response) except requests.exceptions.RequestException as e: self._update_analytics("update_context", success=False, error=str(e)) raise HCFSConnectionError(f"Failed to update context: {str(e)}") def delete_context(self, context_id: int) -> bool: """ Delete a context. Args: context_id: Context identifier Returns: True if deletion was successful Raises: HCFSNotFoundError: If context doesn't exist """ try: response = self.session.delete( f"{self.config.base_url}/api/v1/contexts/{context_id}", timeout=self.config.timeout ) if response.status_code == 200: self._update_analytics("delete_context", success=True) # Invalidate cache if self._cache: cache_key = f"get_context:{context_id}" self._cache.remove(cache_key) return True else: self._update_analytics("delete_context", success=False) handle_api_error(response) except requests.exceptions.RequestException as e: self._update_analytics("delete_context", success=False, error=str(e)) raise HCFSConnectionError(f"Failed to delete context: {str(e)}") @rate_limited(requests_per_second=10.0) def search_contexts(self, query: str, options: Optional[SearchOptions] = None) -> List[SearchResult]: """ Search contexts using various search methods. Args: query: Search query string options: Search configuration options Returns: List of search results ordered by relevance """ search_opts = options or SearchOptions() request_data = { "query": query, **search_opts.to_request_dict() } try: response = self.session.post( f"{self.config.base_url}/api/v1/search", json=request_data, timeout=self.config.timeout ) if response.status_code == 200: data = response.json()["data"] results = [] for result_data in data: context = Context(**result_data["context"]) search_result = SearchResult( context=context, score=result_data["score"], explanation=result_data.get("explanation"), highlights=result_data.get("highlights", []) ) results.append(search_result) self._update_analytics("search_contexts", success=True) return sorted(results, key=lambda x: x.score, reverse=True) else: self._update_analytics("search_contexts", success=False) handle_api_error(response) except requests.exceptions.RequestException as e: self._update_analytics("search_contexts", success=False, error=str(e)) raise HCFSConnectionError(f"Search failed: {str(e)}") def batch_create_contexts(self, contexts: List[Context]) -> BatchResult: """ Create multiple contexts in a single batch operation. Args: contexts: List of contexts to create Returns: Batch operation results """ request_data = { "contexts": [ctx.to_create_dict() for ctx in contexts] } start_time = time.time() try: response = self.session.post( f"{self.config.base_url}/api/v1/contexts/batch", json=request_data, timeout=self.config.timeout * 3 # Extended timeout for batch ops ) execution_time = time.time() - start_time if response.status_code == 200: data = response.json()["data"] result = BatchResult( success_count=data["success_count"], error_count=data["error_count"], total_items=data["total_items"], successful_items=data.get("created_ids", []), failed_items=data.get("errors", []), execution_time=execution_time ) self._update_analytics("batch_create", success=True) return result else: self._update_analytics("batch_create", success=False) handle_api_error(response) except requests.exceptions.RequestException as e: execution_time = time.time() - start_time self._update_analytics("batch_create", success=False, error=str(e)) return BatchResult( success_count=0, error_count=len(contexts), total_items=len(contexts), successful_items=[], failed_items=[{"error": str(e)}], execution_time=execution_time ) def get_statistics(self) -> Dict[str, Any]: """ Get comprehensive system statistics. Returns: System statistics and metrics """ try: response = self.session.get( f"{self.config.base_url}/api/v1/stats", timeout=self.config.timeout ) if response.status_code == 200: self._update_analytics("get_statistics", success=True) return response.json() else: self._update_analytics("get_statistics", success=False) handle_api_error(response) except requests.exceptions.RequestException as e: self._update_analytics("get_statistics", success=False, error=str(e)) raise HCFSConnectionError(f"Failed to get statistics: {str(e)}") def iterate_contexts(self, filter_opts: Optional[ContextFilter] = None, page_size: int = 100) -> Iterator[Context]: """ Iterate through all contexts with automatic pagination. Args: filter_opts: Context filtering options page_size: Number of contexts per page Yields: Context objects """ page = 1 while True: pagination = PaginationOptions(page=page, page_size=page_size) contexts = self.list_contexts(filter_opts, pagination) if not contexts: break for context in contexts: yield context # If we got fewer contexts than requested, we've reached the end if len(contexts) < page_size: break page += 1 def clear_cache(self) -> None: """Clear all cached data.""" if self._cache: self._cache.clear() def get_cache_stats(self) -> Dict[str, Any]: """Get cache statistics.""" if self._cache: stats = self._cache.stats() self.analytics.cache_stats = stats return stats return {} def get_analytics(self) -> AnalyticsData: """ Get client analytics and usage statistics. Returns: Analytics data including operation counts and performance metrics """ # Update cache stats if self._cache: self.analytics.cache_stats = self._cache.stats() return self.analytics def _update_analytics(self, operation: str, success: bool, error: Optional[str] = None): """Update internal analytics tracking.""" self.analytics.operation_count[operation] = self.analytics.operation_count.get(operation, 0) + 1 if not success: error_key = error or "unknown_error" self.analytics.error_stats[error_key] = self.analytics.error_stats.get(error_key, 0) + 1 def close(self): """Close the client and cleanup resources.""" self.session.close() def __enter__(self): """Context manager entry.""" return self def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" self.close()