diff --git a/archpkg/cache.py b/archpkg/cache.py new file mode 100644 index 0000000..42bf2fd --- /dev/null +++ b/archpkg/cache.py @@ -0,0 +1,499 @@ +# cache.py +"""Caching module for archpkg-helper with SQLite backend and configurable TTL. +Provides efficient caching of search results with privacy considerations.""" + +import sqlite3 +import json +import time +import hashlib +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Union +from dataclasses import dataclass, field +import logging +from contextlib import contextmanager + +from archpkg.logging_config import get_logger, PackageHelperLogger + +logger = get_logger(__name__) + +@dataclass +class CacheConfig: + """Configuration for cache behavior with sensible defaults.""" + ttl_seconds: int = 24 * 60 * 60 # 24 hours default + max_entries: int = 1000 + cache_dir: Optional[Path] = None + enabled: bool = True + cleanup_interval: int = 3600 # Cleanup expired entries every hour + db_name: str = "cache.db" + + def __post_init__(self): + if self.cache_dir is None: + # Use XDG_CACHE_HOME standard or fallback to ~/.cache + cache_home = os.environ.get('XDG_CACHE_HOME', + os.path.expanduser('~/.cache')) + self.cache_dir = Path(cache_home) / 'archpkg-helper' + + # Ensure cache directory exists with proper permissions + try: + self.cache_dir.mkdir(parents=True, exist_ok=True, mode=0o755) + logger.debug(f"Cache directory ready: {self.cache_dir}") + except (OSError, PermissionError) as e: + logger.warning(f"Failed to create cache directory: {e}") + # Fallback to temp directory + import tempfile + self.cache_dir = Path(tempfile.gettempdir()) / 'archpkg-helper' + self.cache_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Using fallback cache directory: {self.cache_dir}") + +class CacheManager: + """Manages caching operations with SQLite backend and automatic cleanup.""" + + def __init__(self, config: Optional[CacheConfig] = None): + """Initialize cache manager with configuration. + + Args: + config: Cache configuration, uses defaults if None + """ + self.config = config or CacheConfig() + self.db_path = self.config.cache_dir / self.config.db_name + self._last_cleanup = time.time() + + if self.config.enabled: + self._init_db() + logger.info(f"Cache manager initialized: {self.db_path}") + else: + logger.info("Cache disabled by configuration") + + def _init_db(self) -> None: + """Initialize SQLite database with optimized schema.""" + try: + with self._get_connection() as conn: + conn.executescript(''' + CREATE TABLE IF NOT EXISTS cache_entries ( + key TEXT PRIMARY KEY, + query_hash TEXT NOT NULL, + source TEXT NOT NULL, + value TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + access_count INTEGER DEFAULT 1, + last_accessed INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_expires_at + ON cache_entries(expires_at); + + CREATE INDEX IF NOT EXISTS idx_query_source + ON cache_entries(query_hash, source); + + CREATE INDEX IF NOT EXISTS idx_last_accessed + ON cache_entries(last_accessed); + + -- Enable WAL mode for better concurrency + PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + PRAGMA cache_size=10000; + PRAGMA temp_store=memory; + ''') + logger.debug("Database schema initialized successfully") + except sqlite3.Error as e: + logger.error(f"Failed to initialize database: {e}") + # Disable cache if database initialization fails + self.config.enabled = False + + @contextmanager + def _get_connection(self): + """Get database connection with proper error handling.""" + conn = None + try: + conn = sqlite3.connect(self.db_path, timeout=5.0) + conn.row_factory = sqlite3.Row # Enable column access by name + yield conn + conn.commit() # Ensure changes are committed + except sqlite3.Error as e: + logger.error(f"Database connection error: {e}") + if conn: + conn.rollback() + raise + finally: + if conn: + conn.close() + + def _generate_cache_key(self, query: str, source: str) -> str: + """Generate consistent cache key for query and source. + + Args: + query: Search query string + source: Package source (aur, pacman, apt, etc.) + + Returns: + str: SHA256 hash of query and source + """ + # Normalize query for consistent caching + normalized_query = query.lower().strip() + key_data = f"{normalized_query}:{source.lower()}" + return hashlib.sha256(key_data.encode('utf-8')).hexdigest() + + def _generate_query_hash(self, query: str) -> str: + """Generate hash for query tracking (privacy-safe).""" + normalized_query = query.lower().strip() + return hashlib.sha256(normalized_query.encode('utf-8')).hexdigest()[:16] + + def get(self, query: str, source: str) -> Optional[List[Tuple[str, str, str]]]: + """Retrieve cached search results if available and not expired. + + Args: + query: Search query string + source: Package source (aur, pacman, apt, etc.) + + Returns: + Optional[List[Tuple[str, str, str]]]: Cached results or None if not found/expired + """ + if not self.config.enabled: + return None + + key = self._generate_cache_key(query, source) + current_time = int(time.time()) + + try: + with self._get_connection() as conn: + cursor = conn.execute(''' + SELECT value, expires_at, access_count + FROM cache_entries + WHERE key = ? AND expires_at > ? + ''', (key, current_time)) + + row = cursor.fetchone() + if row: + try: + # Update access statistics + conn.execute(''' + UPDATE cache_entries + SET access_count = access_count + 1, last_accessed = ? + WHERE key = ? + ''', (current_time, key)) + + # Parse cached results + cached_data = json.loads(row['value']) + logger.debug(f"Cache hit for {source} query: {query[:50]}...") + logger.debug(f"Cache entry accessed {row['access_count']} times") + + return cached_data + except (json.JSONDecodeError, TypeError) as e: + logger.warning(f"Invalid cached data for key {key}: {e}") + # Remove corrupted entry + conn.execute('DELETE FROM cache_entries WHERE key = ?', (key,)) + return None + else: + logger.debug(f"Cache miss for {source} query: {query[:50]}...") + return None + + except sqlite3.Error as e: + logger.error(f"Database error during cache get: {e}") + return None + + def set(self, query: str, source: str, results: List[Tuple[str, str, str]], + custom_ttl: Optional[int] = None) -> bool: + """Store search results in cache with TTL. + + Args: + query: Search query string + source: Package source (aur, pacman, apt, etc.) + results: Search results to cache + custom_ttl: Custom TTL in seconds, uses config default if None + + Returns: + bool: True if cached successfully, False otherwise + """ + if not self.config.enabled or not results: + return False + + # Privacy check: don't cache potentially sensitive queries + if self._is_sensitive_query(query): + logger.debug(f"Skipping cache for potentially sensitive query: {query[:20]}...") + return False + + key = self._generate_cache_key(query, source) + query_hash = self._generate_query_hash(query) + current_time = int(time.time()) + ttl = custom_ttl or self.config.ttl_seconds + expires_at = current_time + ttl + + try: + # Serialize results (filter out sensitive information) + sanitized_results = self._sanitize_results(results) + json_value = json.dumps(sanitized_results, separators=(',', ':')) + + with self._get_connection() as conn: + conn.execute(''' + INSERT OR REPLACE INTO cache_entries + (key, query_hash, source, value, created_at, expires_at, access_count, last_accessed) + VALUES (?, ?, ?, ?, ?, ?, 1, ?) + ''', (key, query_hash, source, json_value, current_time, expires_at, current_time)) + + logger.debug(f"Cached {len(results)} results for {source} query (TTL: {ttl}s)") + + # Trigger cleanup if needed + self._maybe_cleanup(conn) + + return True + + except (sqlite3.Error, TypeError, ValueError) as e: + logger.error(f"Failed to cache results: {e}") + return False + + def _is_sensitive_query(self, query: str) -> bool: + """Check if query might contain sensitive information. + + Args: + query: Search query to check + + Returns: + bool: True if query appears sensitive + """ + sensitive_patterns = [ + 'password', 'secret', 'key', 'token', 'private', + 'credential', 'auth', 'login', 'admin', 'root' + ] + + query_lower = query.lower() + return any(pattern in query_lower for pattern in sensitive_patterns) + + def _sanitize_results(self, results: List[Tuple[str, str, str]]) -> List[Tuple[str, str, str]]: + """Sanitize search results before caching (privacy protection). + + Args: + results: Original search results + + Returns: + List[Tuple[str, str, str]]: Sanitized results + """ + sanitized = [] + for name, desc, source in results: + # Limit description length to prevent bloat + safe_desc = (desc or "")[:500] if desc else "" + # Remove potential sensitive information from descriptions + if not any(word in safe_desc.lower() for word in ['password', 'secret', 'private']): + sanitized.append((name, safe_desc, source)) + + return sanitized + + def _maybe_cleanup(self, conn: sqlite3.Connection) -> None: + """Perform cleanup if enough time has passed since last cleanup.""" + current_time = time.time() + if current_time - self._last_cleanup > self.config.cleanup_interval: + self._cleanup_expired(conn) + self._enforce_max_entries(conn) + self._last_cleanup = current_time + + def _cleanup_expired(self, conn: Optional[sqlite3.Connection] = None) -> int: + """Remove expired cache entries. + + Args: + conn: Optional database connection to use + + Returns: + int: Number of entries removed + """ + current_time = int(time.time()) + + def do_cleanup(connection): + cursor = connection.execute( + 'DELETE FROM cache_entries WHERE expires_at <= ?', + (current_time,) + ) + return cursor.rowcount + + try: + if conn: + removed_count = do_cleanup(conn) + else: + with self._get_connection() as connection: + removed_count = do_cleanup(connection) + + if removed_count > 0: + logger.debug(f"Cleaned up {removed_count} expired cache entries") + + return removed_count + + except sqlite3.Error as e: + logger.error(f"Failed to cleanup expired entries: {e}") + return 0 + + def _enforce_max_entries(self, conn: sqlite3.Connection) -> int: + """Enforce maximum cache entries by removing least recently used entries. + + Args: + conn: Database connection + + Returns: + int: Number of entries removed + """ + try: + # Count current entries + cursor = conn.execute('SELECT COUNT(*) FROM cache_entries') + current_count = cursor.fetchone()[0] + + if current_count <= self.config.max_entries: + return 0 + + # Remove oldest entries to get back under limit + excess_count = current_count - self.config.max_entries + cursor = conn.execute(''' + DELETE FROM cache_entries + WHERE key IN ( + SELECT key FROM cache_entries + ORDER BY last_accessed ASC + LIMIT ? + ) + ''', (excess_count,)) + + removed_count = cursor.rowcount + if removed_count > 0: + logger.debug(f"Removed {removed_count} LRU cache entries to enforce limit") + + return removed_count + + except sqlite3.Error as e: + logger.error(f"Failed to enforce max entries: {e}") + return 0 + + def clear(self, source: Optional[str] = None) -> int: + """Clear cache entries. + + Args: + source: Optional source filter, clears all if None + + Returns: + int: Number of entries removed + """ + if not self.config.enabled: + return 0 + + try: + with self._get_connection() as conn: + if source: + cursor = conn.execute('DELETE FROM cache_entries WHERE source = ?', (source,)) + logger.info(f"Cleared {source} cache entries") + else: + cursor = conn.execute('DELETE FROM cache_entries') + logger.info("Cleared all cache entries") + + return cursor.rowcount + + except sqlite3.Error as e: + logger.error(f"Failed to clear cache: {e}") + return 0 + + def get_stats(self) -> Dict[str, Any]: + """Get cache statistics. + + Returns: + Dict[str, Any]: Cache statistics + """ + if not self.config.enabled: + return {"enabled": False} + + try: + with self._get_connection() as conn: + # Get basic stats + cursor = conn.execute(''' + SELECT + COUNT(*) as total_entries, + COUNT(CASE WHEN expires_at > ? THEN 1 END) as valid_entries, + SUM(access_count) as total_accesses, + AVG(access_count) as avg_access_count + FROM cache_entries + ''', (int(time.time()),)) + + basic_stats = cursor.fetchone() + + # Get source breakdown + cursor = conn.execute(''' + SELECT source, COUNT(*) as count + FROM cache_entries + WHERE expires_at > ? + GROUP BY source + ''', (int(time.time()),)) + + source_stats = dict(cursor.fetchall()) + + return { + "enabled": True, + "db_path": str(self.db_path), + "total_entries": basic_stats[0] or 0, + "valid_entries": basic_stats[1] or 0, + "total_accesses": basic_stats[2] or 0, + "avg_access_count": round(basic_stats[3] or 0, 2), + "source_breakdown": source_stats, + "config": { + "ttl_seconds": self.config.ttl_seconds, + "max_entries": self.config.max_entries, + "cache_dir": str(self.config.cache_dir) + } + } + + except sqlite3.Error as e: + logger.error(f"Failed to get cache stats: {e}") + return {"enabled": True, "error": str(e)} + + def invalidate_query(self, query: str, source: Optional[str] = None) -> int: + """Invalidate cache entries for a specific query. + + Args: + query: Query to invalidate + source: Optional source filter + + Returns: + int: Number of entries invalidated + """ + if not self.config.enabled: + return 0 + + query_hash = self._generate_query_hash(query) + + try: + with self._get_connection() as conn: + if source: + cursor = conn.execute( + 'DELETE FROM cache_entries WHERE query_hash = ? AND source = ?', + (query_hash, source) + ) + else: + cursor = conn.execute( + 'DELETE FROM cache_entries WHERE query_hash = ?', + (query_hash,) + ) + + removed_count = cursor.rowcount + if removed_count > 0: + logger.debug(f"Invalidated {removed_count} cache entries for query: {query[:50]}...") + + return removed_count + + except sqlite3.Error as e: + logger.error(f"Failed to invalidate cache: {e}") + return 0 + +# Global cache manager instance +_cache_manager: Optional[CacheManager] = None + +def get_cache_manager(config: Optional[CacheConfig] = None) -> CacheManager: + """Get global cache manager instance (singleton pattern). + + Args: + config: Cache configuration for first initialization + + Returns: + CacheManager: Global cache manager instance + """ + global _cache_manager + if _cache_manager is None: + _cache_manager = CacheManager(config) + return _cache_manager + +def reset_cache_manager() -> None: + """Reset global cache manager (useful for testing).""" + global _cache_manager + _cache_manager = None \ No newline at end of file diff --git a/archpkg/cli.py b/archpkg/cli.py index 4821685..200e8f9 100755 --- a/archpkg/cli.py +++ b/archpkg/cli.py @@ -24,6 +24,7 @@ from archpkg.command_gen import generate_command from archpkg.logging_config import get_logger, PackageHelperLogger from archpkg.suggest import suggest_apps, list_purposes +from archpkg.cache import get_cache_manager, CacheConfig console = Console() logger = get_logger(__name__) @@ -323,6 +324,7 @@ def main() -> None: search_parser = subparsers.add_parser('search', help='Search for packages by name') search_parser.add_argument('query', type=str, nargs='*', help='Name of the software to search for') search_parser.add_argument('--aur', action='store_true', help='Prefer AUR packages over Pacman when both are available') + search_parser.add_argument('--no-cache', action='store_true', help='Bypass cache and perform fresh search') # Suggest command suggest_parser = subparsers.add_parser('suggest', help='Get app suggestions based on purpose') @@ -332,14 +334,53 @@ def main() -> None: # Global arguments parser.add_argument('--debug', action='store_true', help='Enable debug logging to console') parser.add_argument('--log-info', action='store_true', help='Show logging configuration and exit') + parser.add_argument('--no-cache', action='store_true', help='Bypass cache and perform fresh search') + parser.add_argument('--cache-stats', action='store_true', help='Show cache statistics and exit') + parser.add_argument('--clear-cache', choices=['all', 'aur', 'pacman', 'apt', 'dnf', 'flatpak', 'snap'], + help='Clear cache for specified source or all sources') args = parser.parse_args() + # Initialize cache manager + cache_config = CacheConfig(enabled=not args.no_cache) + cache_manager = get_cache_manager(cache_config) + # Enable debug mode if requested if args.debug: PackageHelperLogger.set_debug_mode(True) logger.info("Debug mode enabled via command line argument") + # Handle cache-related commands + if args.cache_stats: + stats = cache_manager.get_stats() + console.print(Panel( + f"[bold cyan]Cache Statistics:[/bold cyan]\n" + f"Enabled: {'[green]Yes[/green]' if stats.get('enabled') else '[red]No[/red]'}\n" + f"Total entries: [yellow]{stats.get('total_entries', 0)}[/yellow]\n" + f"Valid entries: [green]{stats.get('valid_entries', 0)}[/green]\n" + f"Total accesses: [blue]{stats.get('total_accesses', 0)}[/blue]\n" + f"Average access count: [magenta]{stats.get('avg_access_count', 0)}[/magenta]\n" + f"Database path: [cyan]{stats.get('db_path', 'N/A')}[/cyan]\n" + f"TTL: [yellow]{stats.get('config', {}).get('ttl_seconds', 0)}s[/yellow]\n" + f"Max entries: [yellow]{stats.get('config', {}).get('max_entries', 0)}[/yellow]\n\n" + f"[bold]Source breakdown:[/bold]\n" + + '\n'.join([f" {source}: {count}" for source, count in stats.get('source_breakdown', {}).items()]), + title="Cache Statistics", + border_style="blue" + )) + return + + if args.clear_cache: + source = None if args.clear_cache == 'all' else args.clear_cache + cleared_count = cache_manager.clear(source) + target = args.clear_cache if args.clear_cache != 'all' else 'all sources' + console.print(Panel( + f"[green]Successfully cleared {cleared_count} cache entries for {target}.[/green]", + title="Cache Cleared", + border_style="green" + )) + return + # Show logging info if requested if args.log_info: from archpkg.logging_config import get_log_info @@ -361,7 +402,7 @@ def main() -> None: return elif args.command == 'search' or args.command is None: # Default to search behavior for backward compatibility - handle_search_command(args) + handle_search_command(args, cache_manager) return else: console.print(Panel( @@ -417,7 +458,7 @@ def handle_suggest_command(args) -> None: suggest_apps(purpose) -def handle_search_command(args) -> None: +def handle_search_command(args, cache_manager) -> None: """Handle the search command (original functionality).""" if not args.query: console.print(Panel( @@ -455,6 +496,7 @@ def handle_search_command(args) -> None: results = [] search_errors = [] + use_cache = not args.no_cache # Search based on detected distribution if detected == "arch": @@ -462,7 +504,7 @@ def handle_search_command(args) -> None: try: logger.debug("Starting AUR search") - aur_results = search_aur(query) + aur_results = search_aur(query, cache_manager if use_cache else None) results.extend(aur_results) logger.info(f"AUR search returned {len(aur_results)} results") except Exception as e: @@ -471,7 +513,7 @@ def handle_search_command(args) -> None: try: logger.debug("Starting pacman search") - pacman_results = search_pacman(query) + pacman_results = search_pacman(query, cache_manager if use_cache else None) results.extend(pacman_results) logger.info(f"Pacman search returned {len(pacman_results)} results") except Exception as e: @@ -483,7 +525,7 @@ def handle_search_command(args) -> None: try: logger.debug("Starting APT search") - apt_results = search_apt(query) + apt_results = search_apt(query, cache_manager if use_cache else None) results.extend(apt_results) logger.info(f"APT search returned {len(apt_results)} results") except Exception as e: @@ -495,7 +537,7 @@ def handle_search_command(args) -> None: try: logger.debug("Starting DNF search") - dnf_results = search_dnf(query) + dnf_results = search_dnf(query, cache_manager if use_cache else None) results.extend(dnf_results) logger.info(f"DNF search returned {len(dnf_results)} results") except Exception as e: @@ -507,7 +549,7 @@ def handle_search_command(args) -> None: try: logger.debug("Starting Flatpak search") - flatpak_results = search_flatpak(query) + flatpak_results = search_flatpak(query, cache_manager if use_cache else None) results.extend(flatpak_results) logger.info(f"Flatpak search returned {len(flatpak_results)} results") except Exception as e: @@ -516,7 +558,7 @@ def handle_search_command(args) -> None: try: logger.debug("Starting Snap search") - snap_results = search_snap(query) + snap_results = search_snap(query, cache_manager if use_cache else None) results.extend(snap_results) logger.info(f"Snap search returned {len(snap_results)} results") except Exception as e: diff --git a/archpkg/config.py b/archpkg/config.py index 5e40556..cec9d45 100644 --- a/archpkg/config.py +++ b/archpkg/config.py @@ -97,4 +97,31 @@ 'log_slow_operations_threshold': 5.0, # seconds 'enable_memory_logging': False, 'enable_detailed_tracing': False +} + +# Cache configuration settings +CACHE_CONFIG = { + 'enabled': True, + 'ttl_seconds': 24 * 60 * 60, # 24 hours default + 'max_entries': 1000, + 'cleanup_interval': 3600, # 1 hour + 'cache_dir': None, # Auto-detect using XDG_CACHE_HOME + 'db_name': 'cache.db', + + # Privacy settings + 'respect_privacy': True, + 'exclude_sensitive_queries': True, + 'max_description_length': 500, + + # Performance settings + 'enable_compression': False, # Future feature + 'batch_size': 100, # For bulk operations + 'connection_timeout': 5.0, # Database connection timeout +} + +# Cache-related timeouts (separate from search timeouts) +CACHE_TIMEOUTS = { + 'db_connection': 5.0, + 'cleanup_operation': 30.0, + 'stats_query': 10.0, } \ No newline at end of file diff --git a/archpkg/search_apt.py b/archpkg/search_apt.py index a5804ea..5a12b0a 100644 --- a/archpkg/search_apt.py +++ b/archpkg/search_apt.py @@ -3,18 +3,19 @@ IMPROVEMENTS: Standardized source name to lowercase, used config timeouts, unified exception handling.""" import subprocess -from typing import List, Tuple +from typing import List, Tuple, Optional from archpkg.config import TIMEOUTS from archpkg.exceptions import PackageManagerNotFound, PackageSearchException, TimeoutError, ValidationError from archpkg.logging_config import get_logger, PackageHelperLogger logger = get_logger(__name__) -def search_apt(query: str) -> List[Tuple[str, str, str]]: +def search_apt(query: str, cache_manager: Optional[object] = None) -> List[Tuple[str, str, str]]: """Search for packages using the APT package manager. Args: query: Search query string + cache_manager: Optional cache manager for storing/retrieving results Returns: List[Tuple[str, str, str]]: List of (name, description, source) tuples @@ -32,6 +33,13 @@ def search_apt(query: str) -> List[Tuple[str, str, str]]: logger.error("Empty search query provided to APT search") raise ValidationError("Search query cannot be empty. Please provide a package name to search for.") + # Check cache first if available + if cache_manager: + cached_results = cache_manager.get(query, 'apt') + if cached_results is not None: + logger.info(f"Retrieved {len(cached_results)} APT results from cache") + return cached_results + # Check if apt-cache is available logger.debug("Checking APT availability") try: @@ -108,6 +116,12 @@ def search_apt(query: str) -> List[Tuple[str, str, str]]: logger.debug(f"Found APT package: {name.strip()}") logger.info(f"APT search completed: {len(packages)} packages found from {lines_processed} lines") + + # Cache results if cache manager is available + if cache_manager and packages: + cache_manager.set(query, 'apt', packages) + logger.debug(f"Cached {len(packages)} APT results") + return packages except subprocess.TimeoutExpired: diff --git a/archpkg/search_aur.py b/archpkg/search_aur.py index f36b052..3e62647 100644 --- a/archpkg/search_aur.py +++ b/archpkg/search_aur.py @@ -4,18 +4,19 @@ import requests import json -from typing import List, Tuple +from typing import List, Tuple, Optional from archpkg.config import TIMEOUTS from archpkg.exceptions import NetworkError, TimeoutError, ValidationError, PackageSearchException from archpkg.logging_config import get_logger, PackageHelperLogger logger = get_logger(__name__) -def search_aur(query: str) -> List[Tuple[str, str, str]]: +def search_aur(query: str, cache_manager: Optional[object] = None) -> List[Tuple[str, str, str]]: """Search for packages in the Arch User Repository (AUR). Args: query: Search query string + cache_manager: Optional cache manager for storing/retrieving results Returns: List[Tuple[str, str, str]]: List of (name, description, source) tuples @@ -33,6 +34,13 @@ def search_aur(query: str) -> List[Tuple[str, str, str]]: logger.error("Empty search query provided to AUR search") raise ValidationError("Empty search query provided") + # Check cache first if available + if cache_manager: + cached_results = cache_manager.get(query, 'aur') + if cached_results is not None: + logger.info(f"Retrieved {len(cached_results)} AUR results from cache") + return cached_results + # Construct AUR RPC search API URL url = f"https://aur.archlinux.org/rpc/?v=5&type=search&arg={query.strip()}" logger.debug(f"AUR API URL: {url}") @@ -80,6 +88,12 @@ def search_aur(query: str) -> List[Tuple[str, str, str]]: logger.info(f"AUR search completed: {len(processed_results)} valid packages found") # IMPROVED: Standardized source name to lowercase + + # Cache results if cache manager is available + if cache_manager and processed_results: + cache_manager.set(query, 'aur', processed_results) + logger.debug(f"Cached {len(processed_results)} AUR results") + return processed_results except requests.exceptions.ConnectionError as e: diff --git a/archpkg/search_dnf.py b/archpkg/search_dnf.py index 24ac926..5b6af90 100644 --- a/archpkg/search_dnf.py +++ b/archpkg/search_dnf.py @@ -4,18 +4,19 @@ import subprocess import re -from typing import List, Tuple +from typing import List, Tuple, Optional from archpkg.config import TIMEOUTS from archpkg.exceptions import PackageManagerNotFound, PackageSearchException, TimeoutError, ValidationError, NetworkError from archpkg.logging_config import get_logger, PackageHelperLogger logger = get_logger(__name__) -def search_dnf(query: str) -> List[Tuple[str, str, str]]: +def search_dnf(query: str, cache_manager: Optional[object] = None) -> List[Tuple[str, str, str]]: """Search for packages using DNF package manager. Args: query: Search query string + cache_manager: Optional cache manager for storing/retrieving results Returns: List[Tuple[str, str, str]]: List of (name, description, source) tuples @@ -33,6 +34,13 @@ def search_dnf(query: str) -> List[Tuple[str, str, str]]: logger.error("Empty search query provided to DNF search") raise ValidationError("Empty search query provided") + # Check cache first if available + if cache_manager: + cached_results = cache_manager.get(query, 'dnf') + if cached_results is not None: + logger.info(f"Retrieved {len(cached_results)} DNF results from cache") + return cached_results + # Check if DNF is available and working logger.debug("Checking DNF availability") try: @@ -137,6 +145,12 @@ def search_dnf(query: str) -> List[Tuple[str, str, str]]: logger.debug(f"Found DNF package: {name}") logger.info(f"DNF search completed: {len(packages)} packages found from {lines_processed} lines") + + # Cache results if cache manager is available + if cache_manager and packages: + cache_manager.set(query, 'dnf', packages) + logger.debug(f"Cached {len(packages)} DNF results") + return packages except subprocess.TimeoutExpired: diff --git a/archpkg/search_flatpak.py b/archpkg/search_flatpak.py index 1e43adb..f4fe89f 100644 --- a/archpkg/search_flatpak.py +++ b/archpkg/search_flatpak.py @@ -3,18 +3,19 @@ IMPROVEMENTS: Standardized source name to lowercase, used config timeouts, unified exception handling.""" import subprocess -from typing import List, Tuple +from typing import List, Tuple, Optional from archpkg.config import TIMEOUTS from archpkg.exceptions import PackageManagerNotFound, PackageSearchException, TimeoutError, ValidationError from archpkg.logging_config import get_logger, PackageHelperLogger logger = get_logger(__name__) -def search_flatpak(query: str) -> List[Tuple[str, str, str]]: +def search_flatpak(query: str, cache_manager: Optional[object] = None) -> List[Tuple[str, str, str]]: """Search for packages using the Flatpak package manager. Args: query: Search query string + cache_manager: Optional cache manager for storing/retrieving results Returns: List[Tuple[str, str, str]]: List of (name, description, source) tuples @@ -31,6 +32,13 @@ def search_flatpak(query: str) -> List[Tuple[str, str, str]]: logger.error("Empty search query provided to Flatpak search") raise ValidationError("Empty search query provided") + # Check cache first if available + if cache_manager: + cached_results = cache_manager.get(query, 'flatpak') + if cached_results is not None: + logger.info(f"Retrieved {len(cached_results)} Flatpak results from cache") + return cached_results + # Check if flatpak is available and working logger.debug("Checking Flatpak availability") try: @@ -115,6 +123,12 @@ def search_flatpak(query: str) -> List[Tuple[str, str, str]]: logger.debug(f"Skipping malformed Flatpak result line: {line}") logger.info(f"Flatpak search completed: {len(packages)} packages found from {lines_processed} lines") + + # Cache results if cache manager is available + if cache_manager and packages: + cache_manager.set(query, 'flatpak', packages) + logger.debug(f"Cached {len(packages)} Flatpak results") + return packages except subprocess.TimeoutExpired: diff --git a/archpkg/search_pacman.py b/archpkg/search_pacman.py index 3d8bf5f..660bde0 100644 --- a/archpkg/search_pacman.py +++ b/archpkg/search_pacman.py @@ -3,18 +3,19 @@ IMPROVEMENTS: Kept source name lowercase (already consistent), used config timeouts, unified exception handling.""" import subprocess -from typing import List, Tuple +from typing import List, Tuple, Optional from archpkg.config import TIMEOUTS from archpkg.exceptions import PackageManagerNotFound, PackageSearchException, TimeoutError, ValidationError from archpkg.logging_config import get_logger, PackageHelperLogger logger = get_logger(__name__) -def search_pacman(query: str) -> List[Tuple[str, str, str]]: +def search_pacman(query: str, cache_manager: Optional[object] = None) -> List[Tuple[str, str, str]]: """Search for packages using the pacman package manager. Args: query: Search query string + cache_manager: Optional cache manager for storing/retrieving results Returns: List[Tuple[str, str, str]]: List of (name, description, source) tuples @@ -31,6 +32,13 @@ def search_pacman(query: str) -> List[Tuple[str, str, str]]: logger.error("Empty search query provided to pacman search") raise ValidationError("Empty search query provided") + # Check cache first if available + if cache_manager: + cached_results = cache_manager.get(query, 'pacman') + if cached_results is not None: + logger.info(f"Retrieved {len(cached_results)} pacman results from cache") + return cached_results + # Check if pacman is available and working logger.debug("Checking pacman availability") try: @@ -117,6 +125,12 @@ def search_pacman(query: str) -> List[Tuple[str, str, str]]: i += 1 logger.info(f"Pacman search completed: {len(results)} packages found from {lines_processed} lines") + + # Cache results if cache manager is available + if cache_manager and results: + cache_manager.set(query, 'pacman', results) + logger.debug(f"Cached {len(results)} pacman results") + return results except subprocess.TimeoutExpired: diff --git a/archpkg/search_snap.py b/archpkg/search_snap.py index dbce6fa..520e833 100644 --- a/archpkg/search_snap.py +++ b/archpkg/search_snap.py @@ -3,18 +3,19 @@ IMPROVEMENTS: Kept source name lowercase (already consistent), used config timeouts, unified exception handling.""" import subprocess -from typing import List, Tuple +from typing import List, Tuple, Optional from archpkg.config import TIMEOUTS from archpkg.exceptions import PackageManagerNotFound, PackageSearchException, TimeoutError, ValidationError, NetworkError from archpkg.logging_config import get_logger, PackageHelperLogger logger = get_logger(__name__) -def search_snap(query: str) -> List[Tuple[str, str, str]]: +def search_snap(query: str, cache_manager: Optional[object] = None) -> List[Tuple[str, str, str]]: """Search for packages using the Snap package manager. Args: query: Search query string + cache_manager: Optional cache manager for storing/retrieving results Returns: List[Tuple[str, str, str]]: List of (name, description, source) tuples @@ -32,6 +33,13 @@ def search_snap(query: str) -> List[Tuple[str, str, str]]: logger.error("Empty search query provided to Snap search") raise ValidationError("Empty search query provided") + # Check cache first if available + if cache_manager: + cached_results = cache_manager.get(query, 'snap') + if cached_results is not None: + logger.info(f"Retrieved {len(cached_results)} Snap results from cache") + return cached_results + # Check if snap is available and working logger.debug("Checking Snap availability") try: @@ -120,6 +128,12 @@ def search_snap(query: str) -> List[Tuple[str, str, str]]: logger.debug(f"Skipping malformed Snap result line: {line}") logger.info(f"Snap search completed: {len(packages)} packages found from {lines_processed} lines") + + # Cache results if cache manager is available + if cache_manager and packages: + cache_manager.set(query, 'snap', packages) + logger.debug(f"Cached {len(packages)} Snap results") + return packages except subprocess.TimeoutExpired: