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)