# pipmaster/async_package_manager.py # -*- coding: utf-8 -*- """ Asynchronous Package Manager using pip. Provides an async class and functions to interact with pip asynchronously. Author: ParisNeo Created: 23/04/2025 Last Updated: 24/04/2025 """ import asyncio import sys import importlib.metadata import ascii_colors as logging import shutil import shlex from typing import Optional, List, Tuple, Union, Dict, Any from .package_manager import PackageManager, Requirement logger = logging.getLogger(__name__) class AsyncPackageManager: """ Manages Python package installations and queries using pip asynchronously. Mirrors the synchronous PackageManager interface but uses async methods. """ def __init__( self, python_executable: Optional[str] = None, pip_command_base: Optional[List[str]] = None, ): """Initializes the AsyncPackageManager.""" # Reuse sync init logic to determine command base sync_pm = PackageManager(python_executable, pip_command_base) self.pip_command_base = sync_pm.pip_command_base self.target_python_executable = sync_pm.target_python_executable self._sync_pm_instance = sync_pm # Keep a sync instance for metadata checks logger.debug(f"[Async] Initialized for Python: {self.target_python_executable}") async def _run_command( self, command: List[str], capture_output: bool = False, dry_run: bool = False, verbose: bool = False ) -> Tuple[bool, str]: """ Runs a command asynchronously using asyncio.create_subprocess_shell. """ # Dry run logic is synchronous and quick, handle it first. if dry_run: # Re-use the synchronous dry-run logic which is well-tested # This is non-blocking and fast. success, output = self._sync_pm_instance._run_command(command, dry_run=True) return success, output command_str = " ".join(self.pip_command_base + command) logger.info(f"[Async] Executing: {command_str}") try: stdout_pipe = asyncio.subprocess.PIPE if capture_output or verbose else asyncio.subprocess.DEVNULL stderr_pipe = asyncio.subprocess.PIPE if capture_output or verbose else asyncio.subprocess.DEVNULL if verbose and not capture_output: # For verbose, we stream directly to console, which is complex to do # perfectly async. The simplest robust approach is to capture and print. stdout_pipe = asyncio.subprocess.PIPE stderr_pipe = asyncio.subprocess.STDOUT # Merge stderr into stdout process = await asyncio.create_subprocess_shell( command_str, stdout=stdout_pipe, stderr=stderr_pipe, ) if verbose and not capture_output: # Stream output if verbose while True: line = await process.stdout.readline() if not line: break print(line.decode().strip()) await process.wait() stdout_str, stderr_str = "", "" # Already printed else: # Standard capture stdout, stderr = await process.communicate() stdout_str = stdout.decode("utf-8", errors="ignore") if stdout else "" stderr_str = stderr.decode("utf-8", errors="ignore") if stderr else "" if process.returncode == 0: logger.info(f"[Async] Command succeeded: {command_str}") return True, stdout_str or "Command executed successfully." else: error_message = f"[Async] Command failed (code {process.returncode}): {command_str}" if stdout_str: error_message += f"\n--- stdout ---\n{stdout_str.strip()}" if stderr_str: error_message += f"\n--- stderr ---\n{stderr_str.strip()}" logger.error(error_message) return False, error_message except FileNotFoundError: error_message = f"[Async] Error: Command failed. Is '{self.pip_command_base[0]}' valid?" logger.exception(error_message) return False, error_message except Exception as e: error_message = f"[Async] Unexpected error running command '{command_str}': {e}" logger.exception(error_message) return False, error_message # --- Async Wrappers for Core Methods --- async def install( self, package: str, index_url: Optional[str] = None, force_reinstall: bool = False, upgrade: bool = True, extra_args: Optional[List[str]] = None, dry_run: bool = False, verbose: bool = False, ) -> bool: """Async version of install.""" command = ["install"] if upgrade: command.append("--upgrade") if force_reinstall: command.append("--force-reinstall") if index_url: command.extend(["--index-url", index_url]) if extra_args: command.extend(extra_args) command.append(package) success, _ = await self._run_command(command, dry_run=dry_run, verbose=verbose, capture_output=False) return success async def install_if_missing( self, package: str, version_specifier: Optional[str] = None, always_update: bool = False, index_url: Optional[str] = None, extra_args: Optional[List[str]] = None, dry_run: bool = False, verbose: bool = False, ) -> bool: """Async version of install_if_missing.""" loop = asyncio.get_running_loop() # Use run_in_executor to call the sync logic without blocking # It's complex and better to not re-implement it here. is_needed, install_target, force = await loop.run_in_executor( None, self._sync_pm_instance._check_if_install_is_needed, package, version_specifier, always_update ) if not is_needed: logger.info(f"[Async] Requirement for '{package}' already met. Skipping.") return True return await self.install( install_target, index_url=index_url, upgrade=True, force_reinstall=force, extra_args=extra_args, dry_run=dry_run, verbose=verbose, ) async def ensure_packages( self, requirements: Union[str, Dict[str, Optional[str]], List[str]], index_url: Optional[str] = None, extra_args: Optional[List[str]] = None, dry_run: bool = False, verbose: bool = False, ) -> bool: """Async version of ensure_packages.""" loop = asyncio.get_running_loop() # The logic for checking packages is synchronous, so we run it in an executor packages_to_process = await loop.run_in_executor( None, self._sync_pm_instance._get_packages_to_process, requirements, verbose ) if not packages_to_process: logger.debug("[Async]All specified package requirements are already met.") return True package_list_str = "', '".join(packages_to_process) logger.info(f"[Async] Found {len(packages_to_process)} packages requiring installation/update: '{package_list_str}'") return await self.install_multiple( packages=packages_to_process, index_url=index_url, force_reinstall=False, upgrade=True, extra_args=extra_args, dry_run=dry_run, verbose=verbose, ) async def ensure_requirements( self, requirements_file: str, dry_run: bool = False, verbose: bool = False, ) -> bool: """ Asynchronously ensures that all packages from a requirements.txt file are installed. This method parses a requirements file, respecting comments and some pip options, and then uses the efficient `async_ensure_packages` method to install any missing or outdated packages. Note: File parsing is synchronous. The installation part is asynchronous. Args: requirements_file (str): Path to the requirements.txt file. dry_run (bool): If True, simulate installations without making changes. verbose (bool): If True, show detailed output during checks and installation. Returns: bool: True if all requirements were met or successfully installed, False otherwise. """ try: with open(requirements_file, 'r', encoding='utf-8') as f: lines = f.readlines() except FileNotFoundError: logger.error(f"[Async]Requirements file not found: {requirements_file}") return False requirements_list = [] pip_options = [] for line in lines: line = line.strip() if not line or line.startswith('#'): continue if line.startswith('-'): pip_options.extend(shlex.split(line)) else: package_req = line.split('#')[0].strip() if package_req: requirements_list.append(package_req) if not requirements_list and not pip_options: logger.info(f"[Async] No valid requirements found in {requirements_file}. Nothing to do.") return True if verbose: logger.info(f"[Async] Found {len(requirements_list)} requirements and options {pip_options} in {requirements_file}.") return await self.ensure_packages( requirements=requirements_list, index_url=None, # Let options from file handle this via extra_args extra_args=pip_options, dry_run=dry_run, verbose=verbose ) async def install_multiple( self, packages: List[str], index_url: Optional[str] = None, force_reinstall: bool = False, upgrade: bool = True, extra_args: Optional[List[str]] = None, dry_run: bool = False, verbose: bool = False, ) -> bool: """Async version of install_multiple.""" if not packages: return True command = ["install"] if upgrade: command.append("--upgrade") if force_reinstall: command.append("--force-reinstall") if index_url: command.extend(["--index-url", index_url]) if extra_args: command.extend(extra_args) command.extend(packages) success, _ = await self._run_command(command, dry_run=dry_run, verbose=verbose, capture_output=False) return success async def uninstall( self, package: str, extra_args: Optional[List[str]] = None, dry_run: bool = False, verbose: bool = False ) -> bool: """Async version of uninstall.""" command = ["uninstall", "-y", package] if extra_args: command.extend(extra_args) success, _ = await self._run_command(command, dry_run=dry_run, verbose=verbose, capture_output=False) return success async def uninstall_multiple( self, packages: List[str], extra_args: Optional[List[str]] = None, dry_run: bool = False, verbose: bool = False ) -> bool: """Async version of uninstall_multiple.""" if not packages: return True command = ["uninstall", "-y"] + packages if extra_args: command.extend(extra_args) success, _ = await self._run_command(command, dry_run=dry_run, verbose=verbose, capture_output=False) return success async def get_package_info(self, package_name: str) -> Optional[str]: """Async version of get_package_info.""" success, output = await self._run_command( ["show", package_name], capture_output=True ) return output if success else None async def check_vulnerabilities( self, package_name: Optional[str] = None, requirements_file: Optional[str] = None, extra_args: Optional[List[str]] = None, ) -> Tuple[bool, str]: """Async vulnerability check using pip-audit.""" # This one is a pure subprocess call, so it's a good candidate for a direct async implementation. loop = asyncio.get_running_loop() # `shutil.which` is sync but very fast, acceptable to call directly. pip_audit_exe = await loop.run_in_executor(None, shutil.which, "pip-audit") if not pip_audit_exe: logger.error("[Async] pip-audit not found.") return True, "pip-audit not found." command_list = [pip_audit_exe] if requirements_file: command_list.extend(["-r", requirements_file]) elif package_name: logger.warning("[Async] Checking single package vuln via pip-audit not supported yet.") if extra_args: command_list.extend(extra_args) command_str = " ".join(command_list) logger.info(f"[Async] Running vulnerability check: {command_str}") try: process = await asyncio.create_subprocess_shell( command_str, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await process.communicate() stdout_str = stdout.decode("utf-8", errors="ignore") stderr_str = stderr.decode("utf-8", errors="ignore") if process.returncode == 0: logger.info("[Async] pip-audit: No vulnerabilities found.") return False, stdout_str elif process.returncode == 1: logger.warning(f"[Async] pip-audit: Vulnerabilities found!\n{stdout_str}\n{stderr_str}") return True, f"Vulnerabilities found:\n{stdout_str}\n{stderr_str}" else: logger.error(f"[Async] pip-audit failed (code {process.returncode}): {command_str}\n{stderr_str}") return True, f"pip-audit error:\n{stderr_str}" except Exception as e: logger.exception(f"[Async] Failed to run pip-audit: {e}") return True, f"Error running pip-audit: {e}" # --- Module-level Async Functions --- _default_async_pm = AsyncPackageManager() async def async_install(package: str, **kwargs: Any) -> bool: """Installs a single package asynchronously.""" return await _default_async_pm.install(package, **kwargs) async def async_install_if_missing(package: str, **kwargs: Any) -> bool: """Conditionally installs a single package asynchronously.""" return await _default_async_pm.install_if_missing(package, **kwargs) async def async_ensure_packages(requirements: Union[str, Dict[str, Optional[str]], List[str]], **kwargs: Any) -> bool: """Ensures a set of requirements are met asynchronously.""" return await _default_async_pm.ensure_packages(requirements, **kwargs) async def async_ensure_requirements(requirements_file: str, **kwargs: Any) -> bool: """Ensures requirements from a file are met asynchronously.""" return await _default_async_pm.ensure_requirements(requirements_file, **kwargs) async def async_install_multiple(packages: List[str], **kwargs: Any) -> bool: """Installs multiple packages asynchronously.""" return await _default_async_pm.install_multiple(packages, **kwargs) async def async_uninstall(package: str, **kwargs: Any) -> bool: """Uninstalls a single package asynchronously.""" return await _default_async_pm.uninstall(package, **kwargs) async def async_uninstall_multiple(packages: List[str], **kwargs: Any) -> bool: """Uninstalls multiple packages asynchronously.""" return await _default_async_pm.uninstall_multiple(packages, **kwargs) async def async_get_package_info(package_name: str) -> Optional[str]: """Gets package details asynchronously.""" return await _default_async_pm.get_package_info(package_name) async def async_check_vulnerabilities(**kwargs: Any) -> Tuple[bool, str]: """Checks for vulnerabilities asynchronously.""" return await _default_async_pm.check_vulnerabilities(**kwargs)