Files
mnote/wolai-backend/.venv312/Lib/site-packages/pipmaster/package_manager.py
T

1991 lines
78 KiB
Python
Raw Normal View History

2025-12-08 19:56:24 +08:00
# -*- coding: utf-8 -*-
"""
Synchronous Package Manager using pip.
Provides a class and functions to interact with pip for package management
within the current environment or a specified Python environment.
Author: ParisNeo
Created: 01/04/2024
Last Updated: 23/04/2025
"""
import subprocess
import sys
from pathlib import Path
import importlib.metadata
from packaging.version import parse as parse_version
from packaging.requirements import Requirement
import ascii_colors as logging
import platform
import shutil
import shlex
from typing import Optional, List, Tuple, Union, Dict, Any
import os
import locale
# Setup basic logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
class PackageManager:
"""
Manages Python package installations and queries using pip.
Allows targeting different Python environments via the `python_executable` parameter.
"""
def __init__(
self,
python_executable: Optional[str] = None,
pip_command_base: Optional[List[str]] = None,
venv_path: Optional[str] = None,
):
"""
Initializes the PackageManager.
Args:
python_executable (str, optional): Path to the Python executable
to use (default: sys.executable).
pip_command_base (List[str], optional): Override the base command
list (e.g., ['/custom/python', '-m', 'pip']). Overrides python_executable.
venv_path (str, optional): Path to a virtual environment. If provided,
will use its Python executable. If missing, creates the venv
using python_executable (if provided) or sys.executable.
"""
self._executable = None
# 1. priorité à pip_command_base
if pip_command_base:
self.pip_command_base = pip_command_base
logger.info(f"Using custom pip command base: {' '.join(pip_command_base)}")
self._executable = pip_command_base[0]
# 2. sinon priorité au venv_path
elif venv_path:
venv_path = Path(venv_path).resolve()
if os.name == "nt":
venv_python = venv_path / "Scripts" / "python.exe"
else:
venv_python = venv_path / "bin" / "python"
# créer si manquant
if not venv_python.exists():
base_python = python_executable or sys.executable
logger.info(
f"Virtual environment not found at {venv_path}, creating it with {base_python}..."
)
venv_path.parent.mkdir(parents=True, exist_ok=True)
try:
subprocess.run([base_python, "-m", "venv", str(venv_path)], check=True, capture_output=True, text=True)
logger.info(f"Virtual environment created at {venv_path}")
except subprocess.CalledProcessError as e:
error_msg = (
f"Failed to create virtual environment at {venv_path}.\n"
f"Command: {' '.join(e.cmd)}\n"
f"Exit Code: {e.returncode}\n"
f"Stdout: {e.stdout.strip()}\n"
f"Stderr: {e.stderr.strip()}"
)
logger.error(error_msg)
raise RuntimeError(error_msg) from e
if not venv_python.exists():
raise RuntimeError(f"Failed to create or locate virtual environment at {venv_path}")
self._executable = str(venv_python)
self.pip_command_base = [self._executable, "-m", "pip"]
logger.info(f"Using virtual environment Python: {self._executable}")
# 3. fallback ancien comportement
else:
self._executable = python_executable or sys.executable
quoted_executable = (
f'"{self._executable}"'
if " " in self._executable and not self._executable.startswith('"')
else self._executable
)
self.pip_command_base = [quoted_executable, "-m", "pip"]
logger.debug(
f"Targeting pip associated with Python: {self._executable} "
f"| Command base: {' '.join(self.pip_command_base)}"
)
self.target_python_executable = self._executable
# Determine if the selected executable is the same as the current environment's
try:
target_path = Path(self._executable).resolve(strict=True)
current_path = Path(sys.executable).resolve(strict=True)
self._is_current_env = (target_path == current_path)
except (FileNotFoundError, TypeError):
# TypeError can happen if self._executable is None, though logic should prevent this.
self._is_current_env = False
def _run_command(
self,
command: List[str],
capture_output: bool = False,
dry_run: bool = False,
verbose: bool = False,
) -> Tuple[bool, str]:
full_command_list = self.pip_command_base + command
# FIX: Ensure all items are strings before joining
command_str_for_log = " ".join(map(str, full_command_list))
if dry_run:
if command[0] in ["install", "uninstall", "download"]:
insert_pos = next((i for i, arg in enumerate(command) if i > 0 and not arg.startswith("-")), -1)
dry_run_command_list = (
self.pip_command_base + command[:insert_pos] + ["--dry-run"] + command[insert_pos:]
if insert_pos != -1 else self.pip_command_base + command + ["--dry-run"]
)
dry_run_cmd_str_for_log = " ".join(map(str, dry_run_command_list))
logger.info(f"DRY RUN: Would execute: {dry_run_cmd_str_for_log}")
return True, f"Dry run: Command would be '{dry_run_cmd_str_for_log}'"
else:
logger.info(f"DRY RUN: Would execute: {command_str_for_log}")
return True, f"Dry run: Command would be '{command_str_for_log}'"
logger.info(f"Executing: {command_str_for_log}")
try:
# Détection automatique de lencodage système
encoding = locale.getpreferredencoding(False)
# Forcer UTF-8 si possible (Python >=3.7)
env = os.environ.copy()
env["PYTHONUTF8"] = "1"
if capture_output:
result = subprocess.run(
full_command_list,
check=False,
capture_output=True,
text=True,
encoding=encoding,
errors="replace",
env=env,
)
else:
if verbose:
result = subprocess.run(
full_command_list,
check=False,
text=True,
encoding=encoding,
errors="replace",
env=env,
)
else:
result = subprocess.run(
full_command_list,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
encoding=encoding,
errors="replace",
env=env,
)
output = result.stdout or ""
error_out = result.stderr or ""
if result.returncode == 0:
logger.info(f"Command succeeded: {command_str_for_log}")
return True, output if capture_output else "Command executed successfully."
else:
error_message = f"Command failed with exit code {result.returncode}: {command_str_for_log}"
if capture_output:
if output.strip():
error_message += f"\n--- stdout ---\n{output.strip()}"
if error_out.strip():
error_message += f"\n--- stderr ---\n{error_out.strip()}"
else:
error_message += "\nCheck console output for details."
permission_errors = ["permission denied", "access is denied", "[winerror 5]"]
if any(p_error in error_out.lower() for p_error in permission_errors):
self._show_manual_command_message(full_command_list)
logger.error(error_message)
return False, error_message
except FileNotFoundError:
error_message = f"Error: Command execution failed. Is '{self.pip_command_base[0]}' a valid executable path?"
logger.exception(error_message)
return False, error_message
except Exception as e:
if "denied" in str(e).lower():
error_message = f"Permission Denied while executing command: {command_str_for_log}"
logger.exception(error_message)
self._show_manual_command_message(full_command_list)
return False, error_message
else:
error_message = f"An unexpected error occurred while running command '{command_str_for_log}': {e}"
logger.exception(error_message)
return False, error_message
def _show_manual_command_message(self, command: List[str]):
"""
Displays a user-friendly message asking to run a command manually with elevated privileges.
"""
command_str = " ".join(command)
from ascii_colors import ASCIIColors
ASCIIColors.orange("="*80)
ASCIIColors.bold(" PIPMASTER - MANUAL ACTION REQUIRED")
ASCIIColors.orange("="*80)
ASCIIColors.cyan("\nPipmaster encountered a 'Permission Denied' error.")
ASCIIColors.print("This is often caused by an antivirus program or insufficient user privileges.")
if platform.system() == "Windows":
ASCIIColors.multicolor(["\nPlease open an ", "Administrator Command Prompt or PowerShell"," and run the following command:"],[ ASCIIColors.color_white, ASCIIColors.style_bold, ASCIIColors.color_white])
else:
ASCIIColors.multicolor(["\nPlease open a terminal and run the following command, possibly with ", "sudo", ":"],[ ASCIIColors.color_white, ASCIIColors.style_bold, ASCIIColors.color_white])
ASCIIColors.multicolor(["\n" + " "*4, f" {command_str} ", "\n"],[ ASCIIColors.color_white, ASCIIColors.style_bold, ASCIIColors.color_white])
ASCIIColors.orange("="*80)
# --- Core Package Methods ---
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, # Added verbose
) -> bool:
"""
Installs or upgrades a single package.
Args:
package (str): The package name, optionally with version specifier.
index_url (str, optional): Custom index URL.
force_reinstall (bool): If True, use --force-reinstall.
upgrade (bool): If True, use --upgrade (pip default behavior).
extra_args (List[str], optional): Additional arguments for pip.
dry_run (bool): If True, simulate the command.
verbose (bool): If True, show pip's output directly (if not capturing).
Returns:
bool: True on success or successful dry run, False otherwise.
"""
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) # Append package last
success, _ = self._run_command(
command, dry_run=dry_run, verbose=verbose, capture_output=False
)
return success
def _check_if_install_is_needed(
self, package: str, version_specifier: Optional[str], always_update: bool
) -> Tuple[bool, str, bool]:
"""
Internal helper to determine if a package needs to be installed or updated.
Returns: (needs_install, install_target, force_reinstall)
"""
try:
req = Requirement(package)
pkg_name = req.name
effective_specifier = version_specifier or str(req.specifier) or None
except ValueError:
pkg_name = package
effective_specifier = version_specifier
is_installed_flag = self.is_installed(pkg_name)
install_target = package if 'req' in locals() and req.specifier else f"{pkg_name}{effective_specifier or ''}"
force_reinstall = False
if not is_installed_flag:
logger.info(f"Package '{pkg_name}' not found. Installing...")
return True, install_target, force_reinstall
installed_version_str = self.get_installed_version(pkg_name)
logger.info(f"Package '{pkg_name}' is already installed (version {installed_version_str}).")
if effective_specifier and not self.is_version_compatible(pkg_name, effective_specifier):
logger.warning(
f"Installed version {installed_version_str} of '{pkg_name}' does not meet specifier "
f"'{effective_specifier}'. Needs update/reinstall."
)
force_reinstall = True
return True, install_target, force_reinstall
if always_update:
logger.info(f"Flag 'always_update=True' set. Checking for updates for '{pkg_name}'.")
return True, install_target, force_reinstall
logger.info(f"'{pkg_name}' is installed and meets requirements. No action needed.")
return False, install_target, force_reinstall
def install_if_missing(
self,
package: str,
version: Optional[str] = None,
enforce_version: bool = False,
always_update: bool = False,
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
version_specifier: Optional[str] = None,
dry_run: bool = False,
verbose: bool = False, # Added verbose
) -> bool:
"""
Installs a package conditionally based on presence and version requirements.
Args:
package (str): Name of the package (e.g., "numpy"). Can include specifier.
version (str, optional): DEPRECATED. Use version_specifier.
enforce_version (bool): DEPRECATED. Use version_specifier="==x.y.z".
always_update (bool): If True and package is installed, update to latest.
index_url (str, optional): Custom index URL for pip.
extra_args (List[str], optional): Additional arguments for pip.
version_specifier (str, optional): A PEP 440 specifier (e.g., ">=1.2", "==1.3.4").
Takes precedence over `version`/`enforce_version`.
dry_run (bool): If True, simulate the command.
verbose (bool): If True, show pip's output directly (if not capturing).
Returns:
bool: True if installation was successful, not needed, or dry run ok. False otherwise.
"""
try:
req = Requirement(package)
pkg_name = req.name
effective_specifier = version_specifier or str(req.specifier) or None
except ValueError:
pkg_name = package
effective_specifier = version_specifier
if effective_specifier is None and enforce_version and version:
logger.warning(
"Using deprecated 'version' and 'enforce_version'. Prefer 'version_specifier=\"==%s\"'.",
version,
)
effective_specifier = f"=={version}"
elif effective_specifier is None and version and not enforce_version:
logger.warning(
"Using deprecated 'version' without 'enforce_version'. Interpreting as '>={%s}'. Prefer 'version_specifier=\">=%s\"'.",
version,
version,
)
effective_specifier = f">={version}"
is_installed_flag = self.is_installed(pkg_name)
if is_installed_flag:
installed_version_str = self.get_installed_version(pkg_name)
logger.info(
f"Package '{pkg_name}' is already installed (version {installed_version_str})."
)
needs_install = False
if effective_specifier and not self.is_version_compatible(
pkg_name, effective_specifier
):
logger.warning(
f"Installed version {installed_version_str} of '{pkg_name}' does not meet specifier '{effective_specifier}'. Needs update/reinstall."
)
needs_install = True
elif always_update:
logger.info(
f"Flag 'always_update=True' set. Checking for updates for '{pkg_name}'."
)
needs_install = True
if not needs_install:
logger.info(
f"'{pkg_name}' is installed and meets requirements. No action needed."
)
return True
install_target = (
package if 'req' in locals() and req.specifier else f"{pkg_name}{effective_specifier or ''}"
)
logger.info(
f"Attempting to install/update '{pkg_name}' to satisfy '{install_target}'..."
)
force = needs_install and effective_specifier is not None
return self.install(
install_target,
index_url=index_url,
upgrade=True,
force_reinstall=force,
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
else:
logger.info(f"Package '{pkg_name}' not found. Installing...")
install_target = (
package
if "req" in locals() and req.specifier
else f"{pkg_name}{effective_specifier or ''}"
) # Handle case where req parsing failed
return self.install(
install_target,
index_url=index_url,
upgrade=always_update,
force_reinstall=False,
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
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, # Added verbose
) -> bool:
"""Installs or upgrades multiple packages."""
if not packages:
logger.info("No packages provided to install_multiple.")
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(list(packages)) # Add packages at the end
success, _ = self._run_command(
command, dry_run=dry_run, verbose=verbose, capture_output=not verbose
)
return success
def install_multiple_if_not_installed(
self,
packages: List[str],
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False, # Added verbose
) -> bool:
"""
Installs multiple packages only if they are not already installed.
Does *not* check version compatibility, only presence. Use ensure_packages for that.
"""
if not packages:
logger.info("No packages provided to install_multiple_if_not_installed.")
return True
packages_to_install = []
for pkg_input in packages:
try:
req = Requirement(pkg_input)
pkg_name = req.name
except ValueError:
pkg_name = pkg_input # Assume simple name if parsing fails
if not self.is_installed(pkg_name):
logger.info(
f"Package '{pkg_name}' (from '{pkg_input}') marked for installation."
)
packages_to_install.append(pkg_input) # Use original string
else:
if verbose:
logger.info(f"Package '{pkg_name}' is already installed. Skipping.")
if not packages_to_install:
logger.info("All specified packages are already installed.")
return True
logger.info(
f"Attempting to install missing packages: {', '.join(packages_to_install)}"
)
return self.install_multiple(
packages_to_install,
index_url=index_url,
upgrade=False,
force_reinstall=False,
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
def install_version(
self,
package: str,
version: str,
index_url: Optional[str] = None,
force_reinstall: bool = False,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
) -> bool:
"""Installs a specific version of a package."""
install_target = f"{package}=={version}"
command = ["install", install_target]
if force_reinstall:
command.append("--force-reinstall")
if index_url:
command.extend(["--index-url", index_url])
if extra_args:
command.extend(extra_args)
success, _ = self._run_command(command, dry_run=dry_run)
return success
# --- Verification Methods ---
def is_installed(
self, package_name: str, version_specifier: Optional[str] = None
) -> bool:
"""
Checks if a package is installed, optionally checking version compatibility.
Args:
package_name (str): The name of the package (without specifier).
version_specifier (str, optional): A PEP 440 specifier (e.g., ">=1.2").
If provided, checks version compatibility.
Returns:
bool: True if installed (and meets specifier if provided), False otherwise.
"""
try:
if self._is_current_env:
# For VCS installs with #egg=name, this will check 'name'
dist = importlib.metadata.distribution(package_name)
if version_specifier:
return self.is_version_compatible(
package_name, version_specifier, _dist=dist
)
return True # Installed, no version check needed
else:
check_script = f"import importlib.metadata; exit(0) if '{package_name}' in [d.metadata['name'] for d in importlib.metadata.distributions()] else exit(1)"
command = [self.target_python_executable, "-c", check_script]
try:
result = subprocess.run(command, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result.returncode != 0:
return False # Package not found
# If found, and a version specifier is provided, check compatibility
if version_specifier:
return self.is_version_compatible(package_name, version_specifier)
return True # Installed, no version check needed
except Exception as e:
logger.error(f"Error checking installation of '{package_name}': {e}")
return False
except importlib.metadata.PackageNotFoundError:
return False
def get_installed_version(self, package_name: str) -> Optional[str]:
"""Gets the installed version of a package using importlib.metadata."""
try:
if self._is_current_env:
return importlib.metadata.version(package_name)
else:
# Command to execute in the target environment
command = [
self.target_python_executable,
"-c",
f"import importlib.metadata; print(importlib.metadata.version('{package_name}'))",
]
# Execute the command and capture the output
result = subprocess.run(
command,
capture_output=True,
text=True,
check=False, # Don't raise an exception on non-zero exit codes
encoding=locale.getpreferredencoding(False),
errors="replace"
)
# If the command was successful, return the stripped output
if result.returncode == 0:
return result.stdout.strip()
else:
# FIX: Check if the error is an expected "PackageNotFoundError"
stderr_output = result.stderr.strip() if result.stderr else ""
if "PackageNotFoundError" in stderr_output:
# This is expected if the package isn't installed. Don't log as an error.
logger.debug(f"Package '{package_name}' not found in target environment. This is expected before installation.")
elif stderr_output:
# Log a real error if stderr contains something else
logger.error(
f"Error getting version for '{package_name}' in target environment ({self.target_python_executable}):\n{stderr_output}"
)
return None
except importlib.metadata.PackageNotFoundError:
return None
except FileNotFoundError:
logger.error(
f"The python executable '{self.target_python_executable}' was not found."
)
return None
except Exception as e:
logger.error(
f"An unexpected error occurred while getting version for '{package_name}': {e}"
)
return None
def get_current_package_version(self, package_name: str) -> Optional[str]:
"""
Gets the installed version of a package. Alias for get_installed_version.
This method provides a more explicit name for querying the version of a package
that is currently installed in the target environment.
Args:
package_name (str): The name of the package.
Returns:
Optional[str]: The installed version string or None if the package is not found.
"""
return self.get_installed_version(package_name)
def is_version_compatible(
self,
package_name: str,
version_specifier: str,
_dist: Optional[importlib.metadata.Distribution] = None, # Internal optimization
) -> bool:
"""
Checks if the installed version of a package meets a version specifier.
Args:
package_name (str): The name of the package.
version_specifier (str): A PEP 440 version specifier string (e.g., ">=1.0", "<2.0").
_dist (Distribution, optional): Pre-fetched distribution object.
Returns:
bool: True if installed and meets specifier, False otherwise.
"""
try:
# Avoid redundant lookup if Distribution object is passed
installed_version_str = (
_dist.version if _dist else self.get_installed_version(package_name)
)
if not installed_version_str:
return False # Not installed
# Use packaging.specifiers which is more direct than Requirement parsing trick
req = Requirement(
f"dummy{version_specifier}"
) # Parse specifier
return req.specifier.contains(installed_version_str, prereleases=True)
except importlib.metadata.PackageNotFoundError:
return False # Not installed
except ValueError as e:
logger.error(
f"Error parsing version or specifier for package {package_name} ('{version_specifier}'): {e}"
)
return False
def get_package_info(self, package_name: str) -> Optional[str]:
"""Runs `pip show` to get package details."""
success, output = self._run_command(
["show", package_name], capture_output=True
)
return output if success else None
# --- Update / Uninstall Methods ---
def install_or_update(
self,
package: str,
index_url: Optional[str] = None,
force_reinstall: bool = False,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose = False, # Added verbose
) -> bool:
"""Installs a package if missing, or updates it if installed."""
logger.info(f"Ensuring package '{package}' is installed and up-to-date.")
# install handles the upgrade logic correctly
return self.install(
package,
index_url=index_url,
force_reinstall=force_reinstall,
upgrade=True,
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
def uninstall(
self,
package: str,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""Uninstalls a single package."""
command = ["uninstall", "-y", package]
if extra_args:
command.extend(extra_args)
success, _ = self._run_command(
command, dry_run=dry_run, verbose=verbose, capture_output=False
) # Usually don't capture uninstall output unless error
return success
def uninstall_multiple(
self,
packages: List[str],
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""Uninstalls multiple packages."""
if not packages:
logger.info("No packages provided to uninstall_multiple.")
return True
command = ["uninstall", "-y"] + list(packages)
if extra_args:
command.extend(extra_args)
success, _ = self._run_command(
command, dry_run=dry_run, verbose=verbose, capture_output=False
)
return success
def install_or_update_multiple(
self,
packages: List[str],
index_url: Optional[str] = None,
force_reinstall: bool = False,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
) -> bool:
"""Installs or updates multiple packages."""
logger.info(
f"Ensuring packages are installed and up-to-date: {', '.join(packages)}"
)
# install_multiple handles the upgrade logic
return self.install_multiple(
packages,
index_url=index_url,
force_reinstall=force_reinstall,
upgrade=True,
extra_args=extra_args,
dry_run=dry_run,
)
# --- Other Utilities ---
def install_edit(
self,
path: str,
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
) -> bool:
"""Installs a package in editable mode."""
command = ["install", "-e", path]
if index_url:
command.extend(["--index-url", index_url])
if extra_args:
command.extend(extra_args)
success, _ = self._run_command(command, dry_run=dry_run)
return success
def install_requirements(
self,
requirements_file: str,
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
) -> bool:
"""Installs packages from a requirements file."""
command = ["install", "-r", requirements_file]
if index_url:
command.extend(["--index-url", index_url])
if extra_args:
command.extend(extra_args)
success, _ = self._run_command(command, dry_run=dry_run)
return success
def check_vulnerabilities(
self,
package_name: Optional[str] = None,
requirements_file: Optional[str] = None,
extra_args: Optional[List[str]] = None,
) -> Tuple[bool, str]:
"""
Checks for vulnerabilities using pip-audit. Requires 'pip-audit' to be installed.
Checks the current environment targeted by this PackageManager instance.
Provide EITHER package_name OR requirements_file to check specific targets,
otherwise the whole environment is checked.
Args:
package_name (str, optional): Check a specific package.
requirements_file (str, optional): Check dependencies in a requirements file.
extra_args (List[str], optional): Additional arguments for pip-audit.
Returns:
Tuple[bool, str]: (vulnerabilities_found, audit_output_or_error)
Note: vulnerabilities_found is True if pip-audit finds issues.
"""
pip_audit_exe = shutil.which("pip-audit")
if not pip_audit_exe:
logger.error(
"pip-audit command not found. Please install it (`pip install pip-audit` or `pip install pipmaster[audit]`)"
)
return True, "pip-audit not found." # Assume vulnerable if tool missing
command = [pip_audit_exe]
if package_name:
# pip-audit doesn't directly check a single *installed* package easily.
# A workaround is needed, e.g., creating a temp req file.
# For now, let's support file or full env check primarily.
logger.warning(
"Checking single package vulnerability via pip-audit is not directly supported yet. Checking full environment."
)
# To implement later: create temp file with "package_name==version", run audit -r tempfile
pass # Fall through to check full environment
elif requirements_file:
command.extend(["-r", requirements_file])
if extra_args:
command.extend(extra_args)
# Construct the command using the *system* pip-audit, not the target python's pip module
audit_command_str = " ".join(command)
logger.info(f"Running vulnerability check: {audit_command_str}")
try:
result = subprocess.run(
audit_command_str,
shell=True,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
)
# pip-audit exit codes: 0 = no vulns, 1 = vulns found, >1 = error
if result.returncode == 0:
logger.info("pip-audit: No vulnerabilities found.")
return False, result.stdout # No vulns found = False
elif result.returncode == 1:
logger.warning(
f"pip-audit: Vulnerabilities found!\n{result.stdout}\n{result.stderr}"
)
return (
True,
f"Vulnerabilities found:\n{result.stdout}\n{result.stderr}",
) # Vulns found = True
else:
logger.error(
f"pip-audit command failed (exit code {result.returncode}): {audit_command_str}\n{result.stderr}"
)
return True, f"pip-audit error:\n{result.stderr}" # Assume vulnerable on error
except Exception as e:
logger.exception(f"Failed to run pip-audit: {e}")
return True, f"Error running pip-audit: {e}" # Assume vulnerable on error
def _get_packages_to_process(self, requirements: Union[str, Dict[str, Optional[str]], List[str]], always_update: bool, verbose: bool) -> List[str]:
"""
Parses requirements and checks which packages need installation/update.
Internal helper for ensure_packages.
"""
if not requirements:
return []
if isinstance(requirements, str):
requirements = [requirements]
packages_to_process: List[str] = []
processed_packages = set()
if verbose:
logger.info("--- Checking Package Requirements ---")
items_to_check = []
is_dict_input = isinstance(requirements, dict)
if is_dict_input:
items_to_check = list(requirements.items())
elif isinstance(requirements, list):
items_to_check = requirements
else:
logger.error(f"Invalid requirements type: {type(requirements)}. Must be dict or list.")
return []
for item in items_to_check:
try:
# Handle structured dictionary for conditional VCS install
if isinstance(item, dict):
package_name = item.get("name")
vcs_url = item.get("vcs")
version_requirement = item.get("condition")
if not all([package_name, vcs_url, version_requirement]):
logger.error(f"Invalid dictionary requirement. Must contain 'name', 'vcs', and 'condition'. Got: {item}")
continue
if package_name in processed_packages: continue
processed_packages.add(package_name)
# CORRECTED LOGIC: Install from VCS only if the requirement is NOT met.
requirement_is_met = self.is_installed(package_name, version_specifier=version_requirement)
if not requirement_is_met:
installed_version = self.get_installed_version(package_name)
if installed_version:
install_reason = f"Installed version {installed_version} of '{package_name}' does not meet requirement '{version_requirement}'."
else:
install_reason = f"Package '{package_name}' is not installed."
logger.warning(f"{install_reason} Adding '{vcs_url}' to the install list.")
packages_to_process.append(vcs_url)
elif verbose:
installed_version = self.get_installed_version(package_name)
logger.info(f"Requirement for '{package_name}' is met. Installed version {installed_version} satisfies '{version_requirement}'. Skipping VCS install.")
continue
# --- Existing logic for str and simple dict items ---
package_name: str = ""
effective_specifier: Optional[str] = None
install_target_string: str = ""
is_vcs = False
is_pinned = False
if is_dict_input:
package_name, effective_specifier = item
install_target_string = f"{package_name}{effective_specifier or ''}"
req = Requirement(install_target_string)
package_name = req.name
is_pinned = "==" in (effective_specifier or "")
else: # str or list item
package_input_str = item
install_target_string = package_input_str
if any(package_input_str.startswith(vcs) for vcs in ["git+", "hg+", "svn+", "bzr+"]):
is_vcs = True
if "#egg=" in package_input_str:
package_name = package_input_str.split("#egg=")[-1].split("&")[0]
else:
package_name = package_input_str
else:
req = Requirement(package_input_str)
package_name = req.name
effective_specifier = str(req.specifier) or None
is_pinned = "==" in (effective_specifier or "")
if package_name in processed_packages: continue
processed_packages.add(package_name)
if verbose:
logger.info(f"Checking requirement: '{install_target_string}'")
if is_vcs:
if always_update:
logger.info(f"VCS requirement '{install_target_string}' will be updated as always_update is True.")
packages_to_process.append(install_target_string)
elif package_name and not self.is_installed(package_name):
logger.warning(f"VCS Requirement '{package_name}' not found. Adding to install list.")
packages_to_process.append(install_target_string)
elif verbose:
logger.info(f"VCS requirement '{package_name or install_target_string}' is assumed to be installed and always_update is False.")
continue
if self.is_installed(package_name, version_specifier=effective_specifier):
if always_update and not is_pinned:
logger.info(f"'{package_name}' is installed, but always_update=True and not pinned. Adding to update list.")
packages_to_process.append(package_name) # Add bare package name for upgrade
elif verbose:
spec_str = f" (satisfies '{effective_specifier}')" if effective_specifier else ""
logger.info(f"Requirement met for '{package_name}'{spec_str}.")
else:
installed_version = self.get_installed_version(package_name)
if installed_version:
logger.warning(f"Requirement NOT met for '{package_name}'. Installed: {installed_version}, Required: '{effective_specifier or 'latest'}'. Adding to update list.")
else:
logger.warning(f"Requirement NOT met for '{package_name}'. Package not installed. Adding to install list.")
packages_to_process.append(install_target_string)
except Exception as e:
logger.error(f"Error checking requirement for '{item}': {e}", exc_info=True)
if 'install_target_string' in locals() and install_target_string:
packages_to_process.append(install_target_string)
return packages_to_process
def ensure_packages(
self,
requirements: Union[str, Dict[str, Optional[str]], List[str]],
always_update: bool = False,
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""
Ensures that required packages, including from GitHub, are installed and meet version requirements.
This is the most efficient method for managing a set of dependencies, as it
checks all requirements first and then performs a single 'pip install'
command for only those packages that need to be installed or updated.
Args:
requirements (Union[str, Dict[str, Optional[str]], List[str]]):
- str: A single package requirement string (e.g., "requests>=2.25", "git+https://github.com/user/repo.git").
- List[str]: A list of package requirement strings. Can also contain dictionaries for advanced cases.
- Dict[str, Optional[str]]: A dictionary mapping package names to
optional PEP 440 version specifiers. GitHub URLs are not supported in this format.
- **Advanced List Usage**: A list item can be a dictionary for conditional VCS installation:
`{"name": "pkg", "vcs": "git+...", "condition": ">=1.0"}`.
This installs from the VCS URL if `pkg` is not installed or if its installed version does not satisfy ">=1.0".
always_update (bool): If True, updates packages to the latest version if they
don't have a specific version pin (e.g., "package==1.2.3").
index_url (str, optional): Custom index URL for installations.
extra_args (List[str], optional): Additional arguments for the pip install command.
dry_run (bool): If True, simulate installations without making changes.
verbose (bool): If True, show pip's output directly during installation.
Returns:
bool: True if all requirements were met initially or successfully
resolved/installed/updated, False if any installation failed.
"""
if not isinstance(requirements, (str, dict, list)):
logger.error(
f"Invalid requirements type: {type(requirements)}. Must be str, dict, or list."
)
return False
if not requirements:
logger.info("ensure_packages called with empty requirements.")
return True
packages_to_process = self._get_packages_to_process(requirements, always_update, verbose)
if not packages_to_process:
logger.debug("All specified package requirements are already met.")
return True
# If we need to install/update packages
package_list_str = "', '".join(packages_to_process)
logger.info(
f"Found {len(packages_to_process)} packages requiring installation/update: '{package_list_str}'"
)
if dry_run:
logger.debug("Dry run enabled. Simulating installation...")
else:
logger.info("Running installation/update command...")
# Use install_multiple to handle the batch installation efficiently
success = self.install_multiple(
packages=packages_to_process,
index_url=index_url,
force_reinstall=False, # Let --upgrade handle it
upgrade=True, # Important to handle version updates/latest install
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
if dry_run and success:
logger.info(
f"Dry run successful for processing requirements. No changes were made."
)
elif success:
logger.info(
"Successfully processed all required package installations/updates."
)
else:
logger.error(
"Failed to install/update one or more required packages."
)
return False
return True
def ensure_requirements(
self,
requirements_file: str,
always_update: bool = False,
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""
Ensures all packages from a requirements.txt file are installed, with an option to update.
This method parses a requirements file, including pip options like '--extra-index-url',
and uses the efficient `ensure_packages` method to install any missing or outdated packages.
Args:
requirements_file (str): Path to the requirements.txt file.
always_update (bool): If True, updates packages to the latest version if they
are not explicitly pinned with '=='.
index_url (str, optional): Custom index URL for installations, can be overridden by the file.
extra_args (List[str], optional): Additional arguments for pip, can be supplemented by the 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.
"""
req_path = Path(requirements_file)
if not req_path.is_file():
logger.error(f"Requirements file not found: {requirements_file}")
return False
try:
with open(req_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
requirements_list = []
extra_args_from_file = []
# Parse the file to separate packages from pip options
for line in lines:
line = line.strip()
if not line or line.startswith('#'):
continue # Skip empty lines and comments
# Check if the line is a pip option
if line.startswith('-'):
# Use shlex to correctly split arguments like --extra-index-url <url>
extra_args_from_file.extend(shlex.split(line))
else:
# Otherwise, it's a package requirement
requirements_list.append(line)
if not requirements_list:
logger.info(f"Requirements file '{requirements_file}' contains no package definitions.")
# If there are only options, we still return True as there's nothing to install.
return True
logger.info(f"Processing {len(requirements_list)} requirements from '{requirements_file}'.")
# Combine args passed to the function with args found in the file
combined_extra_args = list(extra_args) if extra_args else []
combined_extra_args.extend(extra_args_from_file)
# Delegate to the powerful ensure_packages method
return self.ensure_packages(
requirements=requirements_list,
always_update=always_update,
index_url=index_url,
extra_args=combined_extra_args,
dry_run=dry_run,
verbose=verbose
)
except Exception as e:
logger.error(f"Failed to read or parse requirements file '{requirements_file}': {e}", exc_info=True)
return False
# --- Module-level Convenience Functions (using default PackageManager) ---
_default_pm = PackageManager()
# --- Factory function ---
def get_pip_manager(python_executable: Optional[str] = None) -> PackageManager:
"""
Gets a PackageManager instance, potentially targeting a specific Python environment.
Args:
python_executable (str, optional): Path to the Python executable
of the target environment. Defaults to sys.executable (current env).
Returns:
PackageManager: An instance configured for the target environment.
"""
if python_executable:
# Create a new instance for the specific environment
return PackageManager(python_executable=python_executable)
# Return the cached default instance for the current environment
return _default_pm
# --- Wrapped Methods ---
def install(
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:
"""
Installs or upgrades a single package using the default PackageManager.
Args:
package (str): The package name, optionally with version specifier.
index_url (str, optional): Custom index URL.
force_reinstall (bool): If True, use --force-reinstall.
upgrade (bool): If True, use --upgrade (pip default behavior).
extra_args (List[str], optional): Additional arguments for pip.
dry_run (bool): If True, simulate the command.
verbose (bool): If True, shows pip's output directly (if not capturing).
Returns:
bool: True on success or successful dry run, False otherwise.
(Delegates to PackageManager.install)
"""
return _default_pm.install(
package=package,
index_url=index_url,
force_reinstall=force_reinstall,
upgrade=upgrade,
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
def install_if_missing(
package: str,
version: Optional[str] = None,
enforce_version: bool = False,
always_update: bool = False,
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
version_specifier: Optional[str] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""
Installs a package conditionally using the default PackageManager.
Args:
package (str): Name of the package (e.g., "numpy"). Can include specifier.
version (str, optional): DEPRECATED. Use version_specifier.
enforce_version (bool): DEPRECATED. Use version_specifier="==x.y.z".
always_update (bool): If True and package is installed, update to latest.
index_url (str, optional): Custom index URL for pip.
extra_args (List[str], optional): Additional arguments for pip.
version_specifier (str, optional): A PEP 440 specifier (e.g., ">=1.2", "==1.3.4").
dry_run (bool): If True, simulate the command.
verbose (bool): If True, shows pip's output directly (if not capturing).
Returns:
bool: True if installation was successful, not needed, or dry run ok. False otherwise.
(Delegates to PackageManager.install_if_missing)
"""
return _default_pm.install_if_missing(
package=package,
version=version,
enforce_version=enforce_version,
always_update=always_update,
index_url=index_url,
extra_args=extra_args,
version_specifier=version_specifier,
dry_run=dry_run,
verbose=verbose,
)
def install_edit(
path: str,
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
) -> bool:
"""
Installs a package in editable mode using the default PackageManager.
Args:
path (str): Path to the local package source directory.
index_url (str, optional): Custom index URL.
extra_args (List[str], optional): Additional arguments for pip.
dry_run (bool): If True, simulate the command.
Returns:
bool: True on success or successful dry run, False otherwise.
(Delegates to PackageManager.install_edit)
"""
return _default_pm.install_edit(
path=path, index_url=index_url, extra_args=extra_args, dry_run=dry_run
)
def install_requirements(
requirements_file: str,
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
) -> bool:
"""
Installs packages from a requirements file using the default PackageManager.
Args:
requirements_file (str): Path to the requirements file.
index_url (str, optional): Custom index URL.
extra_args (List[str], optional): Additional arguments for pip.
dry_run (bool): If True, simulate the command.
Returns:
bool: True on success or successful dry run, False otherwise.
(Delegates to PackageManager.install_requirements)
"""
return _default_pm.install_requirements(
requirements_file=requirements_file,
index_url=index_url,
extra_args=extra_args,
dry_run=dry_run,
)
def install_multiple(
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:
"""
Installs or upgrades multiple packages using the default PackageManager.
Args:
packages (List[str]): A list of package names/specifiers.
index_url (str, optional): Custom index URL.
force_reinstall (bool): If True, use --force-reinstall.
upgrade (bool): If True, use --upgrade.
extra_args (List[str], optional): Additional arguments for pip.
dry_run (bool): If True, simulate the command.
verbose (bool): If True, show pip's output.
Returns:
bool: True on success or successful dry run, False otherwise.
(Delegates to PackageManager.install_multiple)
"""
return _default_pm.install_multiple(
packages=packages,
index_url=index_url,
force_reinstall=force_reinstall,
upgrade=upgrade,
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
def install_multiple_if_not_installed(
packages: List[str],
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""
Installs multiple packages only if they are not already installed, using the default PackageManager.
Does *not* check version compatibility, only presence.
Args:
packages (List[str]): A list of package names/specifiers to check/install.
index_url (str, optional): Custom index URL for installing missing packages.
extra_args (List[str], optional): Additional arguments for pip install command.
dry_run (bool): If True, simulate the command for missing packages.
verbose (bool): If true, shows information about packages being skipped.
Returns:
bool: True if all originally missing packages installed successfully or dry run ok, False otherwise.
(Delegates to PackageManager.install_multiple_if_not_installed)
"""
return _default_pm.install_multiple_if_not_installed(
packages=packages,
index_url=index_url,
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
def install_version(
package: str,
version: str,
index_url: Optional[str] = None,
force_reinstall: bool = False,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
) -> bool:
"""
Installs a specific version of a package using the default PackageManager.
Args:
package (str): The name of the package.
version (str): The exact version string to install (e.g., "1.2.3").
index_url (str, optional): Custom index URL.
force_reinstall (bool): If True, use --force-reinstall.
extra_args (List[str], optional): Additional arguments for pip.
dry_run (bool): If True, simulate the command.
Returns:
bool: True on success or successful dry run, False otherwise.
(Delegates to PackageManager.install_version)
"""
return _default_pm.install_version(
package=package,
version=version,
index_url=index_url,
force_reinstall=force_reinstall,
extra_args=extra_args,
dry_run=dry_run,
)
def is_installed(
package_name: str, version_specifier: Optional[str] = None
) -> bool:
"""
Checks if a package is installed in the current environment, optionally checking version.
Args:
package_name (str): The name of the package (without specifier).
version_specifier (str, optional): A PEP 440 specifier (e.g., ">=1.2").
Returns:
bool: True if installed (and meets specifier if provided), False otherwise.
(Delegates to PackageManager.is_installed)
"""
return _default_pm.is_installed(
package_name=package_name, version_specifier=version_specifier
)
def get_installed_version(package_name: str) -> Optional[str]:
"""
Gets the installed version of a package in the current environment.
Args:
package_name (str): The name of the package.
Returns:
Optional[str]: The installed version string or None if not found.
(Delegates to PackageManager.get_installed_version)
"""
return _default_pm.get_installed_version(package_name=package_name)
def get_current_package_version(package_name: str) -> Optional[str]:
"""
Gets the installed version of a package in the current environment. Alias for get_installed_version.
Args:
package_name (str): The name of the package.
Returns:
Optional[str]: The installed version string or None if not found.
(Delegates to PackageManager.get_current_package_version)
"""
return _default_pm.get_current_package_version(package_name=package_name)
def is_version_compatible(
package_name: str,
version_specifier: str,
) -> bool:
"""
Checks if the installed version in the current environment meets a version specifier.
Args:
package_name (str): The name of the package.
version_specifier (str): A PEP 440 version specifier string (e.g., ">=1.0").
Returns:
bool: True if installed and meets specifier, False otherwise.
(Delegates to PackageManager.is_version_compatible)
"""
return _default_pm.is_version_compatible(
package_name=package_name, version_specifier=version_specifier
)
def get_package_info(package_name: str) -> Optional[str]:
"""
Runs `pip show` for a package in the current environment.
Args:
package_name (str): The name of the package.
Returns:
Optional[str]: The output of `pip show` or None on error.
(Delegates to PackageManager.get_package_info)
"""
return _default_pm.get_package_info(package_name=package_name)
def install_or_update(
package: str,
index_url: Optional[str] = None,
force_reinstall: bool = False,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""
Installs a package if missing, or updates it if installed, using the default PackageManager.
Args:
package (str): The package name, optionally with version specifier.
index_url (str, optional): Custom index URL.
force_reinstall (bool): If True, use --force-reinstall during update/install.
extra_args (List[str], optional): Additional arguments for pip.
dry_run (bool): If True, simulate the command.
verbose (bool): If True, shows pip's output.
Returns:
bool: True on success or successful dry run, False otherwise.
(Delegates to PackageManager.install_or_update)
"""
return _default_pm.install_or_update(
package=package,
index_url=index_url,
force_reinstall=force_reinstall,
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
def uninstall(
package: str,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""
Uninstalls a single package from the current environment.
Args:
package (str): The name of the package to uninstall.
extra_args (List[str], optional): Additional arguments for pip uninstall.
dry_run (bool): If True, simulate the command.
verbose (bool): If True, show pip's output.
Returns:
bool: True on success or successful dry run, False otherwise.
(Delegates to PackageManager.uninstall)
"""
return _default_pm.uninstall(
package=package, extra_args=extra_args, dry_run=dry_run, verbose=verbose
)
def uninstall_multiple(
packages: List[str],
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""
Uninstalls multiple packages from the current environment.
Args:
packages (List[str]): A list of package names to uninstall.
extra_args (List[str], optional): Additional arguments for pip uninstall.
dry_run (bool): If True, simulate the command.
verbose (bool): If True, show pip's output.
Returns:
bool: True on success or successful dry run, False otherwise.
(Delegates to PackageManager.uninstall_multiple)
"""
return _default_pm.uninstall_multiple(
packages=packages, extra_args=extra_args, dry_run=dry_run, verbose=verbose
)
def install_or_update_multiple(
packages: List[str],
index_url: Optional[str] = None,
force_reinstall: bool = False,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""
Installs or updates multiple packages using the default PackageManager.
Args:
packages (List[str]): A list of package names/specifiers.
index_url (str, optional): Custom index URL.
force_reinstall (bool): If True, use --force-reinstall.
extra_args (List[str], optional): Additional arguments for pip.
dry_run (bool): If True, simulate the command.
verbose (bool): If True, show pip's output.
Returns:
bool: True on success or successful dry run, False otherwise.
(Delegates to PackageManager.install_or_update_multiple)
"""
return _default_pm.install_or_update_multiple(
packages=packages,
index_url=index_url,
force_reinstall=force_reinstall,
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
def check_vulnerabilities(
package_name: Optional[str] = None,
requirements_file: Optional[str] = None,
extra_args: Optional[List[str]] = None,
) -> Tuple[bool, str]:
"""
Checks for vulnerabilities in the current environment using pip-audit.
Requires 'pip-audit' to be installed (e.g., `pip install pipmaster[audit]`).
Provide EITHER package_name OR requirements_file for specific checks,
otherwise the whole environment is checked.
Args:
package_name (str, optional): Check a specific package (support limited).
requirements_file (str, optional): Check dependencies in a requirements file.
extra_args (List[str], optional): Additional arguments for pip-audit.
Returns:
Tuple[bool, str]: (vulnerabilities_found, audit_output_or_error)
(Delegates to PackageManager.check_vulnerabilities)
"""
return _default_pm.check_vulnerabilities(
package_name=package_name,
requirements_file=requirements_file,
extra_args=extra_args,
)
def ensure_packages(
requirements: Union[str, Dict[str, Optional[str]], List[str]],
always_update: bool = False,
index_url: Optional[str] = None,
extra_args: Optional[List[str]] = None,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""
Ensures packages meet requirements in the current environment using the default PackageManager.
Checks each requirement from the dictionary or list. Installs or updates
packages efficiently in a single batch if needed.
Args:
requirements (Union[str, Dict[str, Optional[str]], List[str]]):
- str: A single package requirement string (e.g., "requests>=2.25", "git+https://github.com/user/repo.git").
- List[str]: A list of package requirement strings. Can also contain dictionaries for advanced cases.
- Dict[str, Optional[str]]: A dictionary mapping package names to optional PEP 440 version specifiers.
- **Advanced List Usage**: A list item can be a dictionary for conditional VCS installation:
`{"name": "pkg", "vcs": "git+...", "condition": ">=1.0"}`.
This installs from the VCS URL if `pkg` is not installed or if its installed version does not satisfy ">=1.0".
always_update (bool): If True, updates packages to the latest version if they
don't have a specific version pin (e.g., "package==1.2.3").
index_url (str, optional): Custom index URL for installations.
extra_args (List[str], optional): Additional arguments for the pip install command.
dry_run (bool): If True, simulate installations without making changes.
verbose (bool): If True, show pip's output directly during installation.
Returns:
bool: True if all requirements were met initially or successfully resolved,
False if any installation failed.
(Delegates to PackageManager.ensure_packages)
"""
return _default_pm.ensure_packages(
requirements=requirements,
always_update=always_update,
index_url=index_url,
extra_args=extra_args,
dry_run=dry_run,
verbose=verbose,
)
def ensure_requirements(
requirements_file: str,
always_update: bool = False,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""
Ensures that all packages from a requirements.txt file are installed.
This method parses a requirements file and uses the efficient
`ensure_packages` method to install any missing or outdated packages.
Args:
requirements_file (str): Path to the requirements.txt file.
always_update (bool): If True, updates packages to the latest version if they
are not explicitly pinned with '=='.
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.
(Delegates to PackageManager.ensure_requirements)
"""
return _default_pm.ensure_requirements(
requirements_file=requirements_file,
always_update=always_update,
dry_run=dry_run,
verbose=verbose,
)
# --- Deprecated Functions ---
def is_version_higher(package_name: str, required_version: str) -> bool:
"""DEPRECATED: Use is_version_compatible(package, f'>={required_version}')"""
logger.warning("is_version_higher is deprecated. Use is_version_compatible instead.")
return _default_pm.is_version_compatible(package_name, f">={required_version}")
def is_version_exact(package_name: str, required_version: str) -> bool:
"""DEPRECATED: Use is_version_compatible(package, f'=={required_version}')"""
logger.warning("is_version_exact is deprecated. Use is_version_compatible instead.")
return _default_pm.is_version_compatible(package_name, f"=={required_version}")
# --- UV / Conda Backends ---
# In pipmaster/package_manager.py
class UvPackageManager:
"""
Manages Python environments and packages using uv.
Requires the 'uv' executable to be in the system's PATH.
"""
def __init__(self, environment_path: Optional[str] = None):
"""
Initializes the UvPackageManager.
Args:
environment_path (str, optional): The path to the uv virtual environment.
If not provided, some methods like install/uninstall will fail until an
environment is created and targeted.
"""
self.uv_executable = shutil.which("uv")
if not self.uv_executable:
raise FileNotFoundError(
"The 'uv' executable was not found in your system's PATH. Please install uv first."
)
self.environment_path: Optional[str] = environment_path
self.python_executable: Optional[str] = None
if self.environment_path:
if platform.system() == "Windows":
py_path = f"{self.environment_path}\\Scripts\\python.exe"
else:
py_path = f"{self.environment_path}/bin/python"
self.python_executable = py_path
logger.info(
f"UvPackageManager targeting environment: {self.environment_path}"
)
else:
logger.info("UvPackageManager initialized without a specific environment.")
def _run_command(
self, command: List[str], capture_output: bool = False, verbose: bool = False
) -> Tuple[bool, str]:
"""Runs a 'uv' command using subprocess."""
command_str_for_exec = " ".join([f'"{self.uv_executable}"'] + command)
logger.info(f"Executing: {command_str_for_exec}")
try:
run_kwargs = {
"shell": True,
"check": False,
"text": True,
"encoding": "utf-8",
}
if capture_output:
run_kwargs["capture_output"] = True
else:
if not verbose:
run_kwargs["stdout"] = subprocess.DEVNULL
run_kwargs["stderr"] = subprocess.DEVNULL
result = subprocess.run(command_str_for_exec, **run_kwargs)
output = result.stdout if capture_output and result.stdout else ""
if result.returncode == 0:
logger.info(f"uv command succeeded: {command_str_for_exec}")
return True, output if capture_output else "Command executed successfully."
else:
error_message = (
f"uv command failed with exit code {result.returncode}: {command_str_for_exec}"
)
if 'capture_output' in run_kwargs and run_kwargs.get('capture_output'):
if result.stdout:
error_message += f"\n--- stdout ---\n{result.stdout.strip()}"
if result.stderr:
error_message += f"\n--- stderr ---\n{result.stderr.strip()}"
else:
error_message += "\nCheck console output for details."
logger.error(error_message)
return False, error_message
except Exception as e:
error_message = f"An unexpected error occurred while running uv command '{command_str_for_exec}': {e}"
logger.exception(error_message)
return False, error_message
def create_env(self, path: str, python_version: Optional[str] = None) -> bool:
"""Creates a new virtual environment at the specified path."""
command = ["venv", f'"{path}"'] # Quote path for safety
if python_version:
command.extend(["--python", python_version])
success, _ = self._run_command(command, verbose=True)
if success:
self.environment_path = path
if platform.system() == "Windows":
self.python_executable = f"{path}\\Scripts\\python.exe"
else:
self.python_executable = f"{path}/bin/python"
logger.info(
f"UvPackageManager is now targeting the newly created environment at {path}"
)
return success
def install(
self,
package: str,
extra_args: Optional[List[str]] = None,
verbose: bool = False,
) -> bool:
"""Installs a package into the configured environment."""
if not self.python_executable:
logger.error("Cannot install package: No target environment is configured.")
return False
command = ["pip", "install", f'--python="{self.python_executable}"', package]
if extra_args:
command.extend(extra_args)
success, _ = self._run_command(
command, verbose=verbose, capture_output=False
)
return success
def install_multiple(
self,
packages: List[str],
extra_args: Optional[List[str]] = None,
verbose: bool = False,
) -> bool:
"""Installs multiple packages into the configured environment."""
if not self.python_executable:
logger.error(
"Cannot install packages: No target environment is configured."
)
return False
if not packages:
return True
command = ["pip", "install", f'--python="{self.python_executable}"'] + packages
if extra_args:
command.extend(extra_args)
success, _ = self._run_command(
command, verbose=verbose, capture_output=False
)
return success
def uninstall(
self, package: str, extra_args: Optional[List[str]] = None, verbose: bool = False
) -> bool:
"""Uninstalls a package from the configured environment."""
if not self.python_executable:
logger.error(
"Cannot uninstall package: No target environment is configured."
)
return False
command = ["pip", "uninstall", f'--python="{self.python_executable}"', package]
if extra_args:
command.extend(extra_args)
success, _ = self._run_command(
command, verbose=verbose, capture_output=False
)
return success
def run_with_uvx(self, command: List[str], verbose: bool = False) -> bool:
"""
Executes a tool in a temporary environment using `uv tool run` (the long
form of `uvx`), which is the correct command for this purpose.
Args:
command (List[str]): The command and its arguments. The first element
is assumed to be the tool/package name.
verbose (bool): If True, show command's output on the console.
"""
if not command:
logger.error("run_with_uvx requires a command to execute.")
return False
# ---- THE CORRECT IMPLEMENTATION BASED ON DOCUMENTATION ----
# The syntax is `uv tool run <tool> [args...]`.
# `command` is already in the format `<tool> [args...]`.
uvx_command = ["tool", "run"] + command
# ---- END OF FIX ----
success, _ = self._run_command(
uvx_command, verbose=verbose, capture_output=False
)
return success
class CondaPackageManager:
def __init__(self, environment_name_or_path: Optional[str] = None):
logger.warning("CondaPackageManager is not yet implemented.")
raise NotImplementedError("Conda backend support is not yet implemented.")
def get_uv_manager(environment_path: Optional[str] = None) -> UvPackageManager:
"""
Gets a UV Package Manager instance.
Args:
environment_path (str, optional): The path to the uv virtual environment
to be managed.
Returns:
UvPackageManager: An instance configured for the specified environment.
"""
return UvPackageManager(environment_path=environment_path)
def get_conda_manager(environment_name_or_path: Optional[str] = None) -> Any:
"""Gets a Conda Package Manager instance (Not Implemented)."""
logger.warning("get_conda_manager is not yet implemented.")
raise NotImplementedError("Conda backend support is not yet implemented.")