192 lines
7 KiB
Python
192 lines
7 KiB
Python
import importlib
|
|
import importlib.metadata
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
LOG: logging.Logger = logging.getLogger("package_installer")
|
|
|
|
|
|
def parse_version(v: str) -> tuple[int, ...]:
|
|
return tuple(int("".join(filter(str.isdigit, part)) or "0") for part in v.strip().split("."))
|
|
|
|
|
|
class Packages:
|
|
def __init__(self, env: str | None, file: str | None, upgrade: bool = False):
|
|
from_env = env.split() if env else []
|
|
from_file = []
|
|
|
|
if file:
|
|
file = Path(file)
|
|
if file.exists() and os.access(str(file), os.R_OK):
|
|
with open(file) as f:
|
|
from_file: list[str] = [pkg.strip() for pkg in f if pkg.strip()]
|
|
|
|
self.packages: list[str] = list(set(from_env + from_file))
|
|
self.upgrade = bool(upgrade)
|
|
|
|
def has_packages(self) -> bool:
|
|
return len(self.packages) > 0
|
|
|
|
def allow_upgrade(self) -> bool:
|
|
return self.upgrade
|
|
|
|
|
|
class PackageInstaller:
|
|
def __init__(self, pkg_path: Path | None = None):
|
|
self.user_site: Path | None = None
|
|
"Where to install user packages."
|
|
|
|
if pkg_path:
|
|
self.user_site = pkg_path
|
|
|
|
if not self.user_site and (base_dir := os.environ.get("YTP_CONFIG_PATH")):
|
|
base_dir = Path(base_dir)
|
|
self.user_site = base_dir / f"python{sys.version_info.major}.{sys.version_info.minor}-packages"
|
|
self.user_site.mkdir(parents=True, exist_ok=True)
|
|
|
|
if not self.user_site:
|
|
LOG.error("No valid package path provided or found in environment. Aborting.")
|
|
return
|
|
|
|
if str(self.user_site) not in sys.path:
|
|
sys.path.insert(0, str(self.user_site))
|
|
|
|
def action(self, pkg: str, upgrade: bool = False) -> None:
|
|
version: str | None = None
|
|
if "==" in pkg:
|
|
pkg, version = pkg.split("==", 1)
|
|
|
|
current_version: str | None = self._get_installed_version(pkg)
|
|
|
|
if current_version and version and self.compare_versions(current_version, version):
|
|
LOG.info(f"'{pkg}' is already installed with the specified version ({version}). Skipping installation.")
|
|
return
|
|
|
|
if upgrade and current_version and not version:
|
|
latest_version: str | None = self._get_latest_version(pkg)
|
|
if latest_version and parse_version(current_version) >= parse_version(latest_version):
|
|
LOG.info(f"'{pkg}' is already the latest version ({current_version}). Skipping upgrade.")
|
|
return
|
|
|
|
if current_version:
|
|
LOG.info(f"'{pkg}' is already installed (version: {current_version}). Proceeding with upgrade...")
|
|
else:
|
|
LOG.info(f"'{pkg}' is not installed. Installing...")
|
|
|
|
self._install_pkg(pkg, version=version)
|
|
|
|
def _get_installed_version(self, pkg: str) -> str | None:
|
|
try:
|
|
return importlib.metadata.version(pkg)
|
|
except importlib.metadata.PackageNotFoundError:
|
|
return None
|
|
|
|
def _get_latest_version(self, pkg: str) -> str | None:
|
|
url: str = f"https://pypi.org/pypi/{pkg}/json"
|
|
try:
|
|
with httpx.Client(timeout=5.0) as client:
|
|
resp = client.get(url)
|
|
if 200 == resp.status_code:
|
|
return resp.json()["info"]["version"]
|
|
LOG.warning(f"Failed to fetch '{pkg}' info: HTTP {resp.status_code}")
|
|
except Exception as e:
|
|
LOG.warning(f"Error while querying PyPI for '{pkg}': {e}")
|
|
return None
|
|
|
|
def _install_pkg(self, pkg: str, version: str | None = None) -> bool:
|
|
cmd: list[str] = [
|
|
sys.executable,
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"--no-warn-script-location",
|
|
"--upgrade",
|
|
"--target",
|
|
str(self.user_site),
|
|
]
|
|
|
|
if "yt_dlp" == pkg:
|
|
pkg = "yt-dlp"
|
|
|
|
if version:
|
|
if "nightly" == version and pkg == "yt-dlp":
|
|
cmd.extend(["--pre", "yt-dlp[default]"])
|
|
elif "master" == version and pkg == "yt-dlp":
|
|
cmd.append("git+https://github.com/yt-dlp/yt-dlp.git@master")
|
|
else:
|
|
cmd.append(version if str(version).startswith("git+") else f"{pkg}=={version}")
|
|
else:
|
|
cmd.extend(["--disable-pip-version-check", pkg])
|
|
|
|
try:
|
|
proc: subprocess.CompletedProcess[bytes] = subprocess.run(
|
|
cmd,
|
|
check=True,
|
|
capture_output=True,
|
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
|
)
|
|
self.out(out=proc.stdout, err=proc.stderr)
|
|
|
|
return 0 == proc.returncode
|
|
except subprocess.CalledProcessError as e:
|
|
LOG.error(f"Failed to install '{pkg}' (exit {e.returncode}). {e!s}")
|
|
self.out(out=e.stdout, err=e.stderr)
|
|
raise
|
|
|
|
def check(self, pkgs: Packages):
|
|
"""
|
|
Checks for user supplied pip packages and installs them if they are not already installed.
|
|
|
|
Args:
|
|
pkgs (GetPackages): the class with packages and their settings.
|
|
|
|
"""
|
|
if not pkgs.has_packages() or not self.user_site:
|
|
return
|
|
|
|
LOG.info(f"Checking for user pip packages: {', '.join(pkgs.packages)}")
|
|
for package in pkgs.packages:
|
|
try:
|
|
self.action(package, upgrade=pkgs.allow_upgrade())
|
|
except Exception as e:
|
|
LOG.error(f"Failed to install or upgrade package '{package}'. Error message: {e!s}")
|
|
LOG.exception(e)
|
|
|
|
def compare_versions(self, current: str, target: str) -> bool:
|
|
"""
|
|
Compare versions, handling yt-dlp format where pip uses 2025.7.21 but actual is 2025.07.21
|
|
Returns True if versions match, False otherwise
|
|
"""
|
|
if current == target:
|
|
return True
|
|
|
|
# Handle yt-dlp version format differences
|
|
current_parts: list[str] = current.split(".")
|
|
target_parts: list[str] = target.split(".")
|
|
|
|
if len(current_parts) == len(target_parts):
|
|
# Normalize parts by zero-padding single digits
|
|
current_normalized: list[str] = [
|
|
part.zfill(2) if part.isdigit() and len(part) == 1 else part for part in current_parts
|
|
]
|
|
target_normalized: list[str] = [
|
|
part.zfill(2) if part.isdigit() and len(part) == 1 else part for part in target_parts
|
|
]
|
|
if current_normalized == target_normalized:
|
|
return True
|
|
|
|
return False
|
|
|
|
def out(self, out: bytes | str | None = None, err: bytes | str | None = None) -> None:
|
|
if out:
|
|
for line in (line.strip() for line in out.strip().splitlines() if line.strip()):
|
|
LOG.info(line.decode() if isinstance(line, bytes) else line)
|
|
|
|
if err:
|
|
for line in (line.strip() for line in err.strip().splitlines() if line.strip()):
|
|
LOG.warning(line.decode() if isinstance(line, bytes) else line)
|