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

1991 lines
78 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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.")