1991 lines
78 KiB
Python
1991 lines
78 KiB
Python
# -*- 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 l’encodage 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.") |