ytptube/app/library/PackageInstaller.py
2025-07-24 17:22:05 +03:00

120 lines
4 KiB
Python

import importlib
import importlib.metadata
import logging
import os
import subprocess
import sys
from pathlib import Path
import httpx
LOG = 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:
user_site: Path | None = None
def __init__(self):
if 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 str(self.user_site) not in sys.path:
sys.path.insert(0, str(self.user_site))
def action(self, pkg: str, upgrade: bool = False):
current_version = self._get_installed_version(pkg)
if upgrade and current_version:
latest_version = 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)
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 = 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):
subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"--upgrade",
"--disable-pip-version-check",
"--target",
str(self.user_site),
"--no-warn-script-location",
pkg,
],
check=True,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
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)