Fix upgrading and installing pip packages

This commit is contained in:
arabcoders 2025-07-24 17:22:05 +03:00
parent 78e09e03cf
commit ca4e3dfd79
4 changed files with 105 additions and 20 deletions

View file

@ -98,6 +98,7 @@
"tmpfilename", "tmpfilename",
"ungroup", "ungroup",
"unnegated", "unnegated",
"Unraid",
"upgrader", "upgrader",
"urandom", "urandom",
"urlsafe", "urlsafe",

View file

@ -0,0 +1,13 @@
import os
import sys
from pathlib import Path
if base_dir := os.environ.get("YTP_CONFIG_PATH"):
base_dir = Path(base_dir)
user_site = base_dir / f"python{sys.version_info.major}.{sys.version_info.minor}-packages"
if not user_site.exists():
user_site.mkdir(parents=True, exist_ok=True)
if user_site.is_dir() and str(user_site) not in sys.path:
sys.path.insert(0, str(user_site))

View file

@ -1,13 +1,20 @@
import importlib import importlib
import importlib.metadata
import logging import logging
import os import os
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import httpx
LOG = logging.getLogger("package_installer") 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: class Packages:
def __init__(self, env: str | None, file: str | None, upgrade: bool = False): def __init__(self, env: str | None, file: str | None, upgrade: bool = False):
from_env = env.split() if env else [] from_env = env.split() if env else []
@ -30,30 +37,68 @@ class Packages:
class PackageInstaller: class PackageInstaller:
""" user_site: Path | None = None
This class is responsible for installing and upgrading pip packages.
""" 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): def action(self, pkg: str, upgrade: bool = False):
try: current_version = self._get_installed_version(pkg)
importlib.import_module(pkg)
if upgrade is False: if upgrade and current_version:
LOG.info(f"'{pkg}' is already installed. Skipping upgrades. as requested.") 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 return
LOG.info(f"'{pkg}' is already installed. Checking for upgrades...") if current_version:
subprocess.run( LOG.info(f"'{pkg}' is already installed (version: {current_version}). Proceeding with upgrade...")
[sys.executable, "-m", "pip", "install", "--upgrade", pkg], else:
check=True,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
except ImportError:
LOG.info(f"'{pkg}' is not installed. Installing...") LOG.info(f"'{pkg}' is not installed. Installing...")
subprocess.run(
[sys.executable, "-m", "pip", "install", pkg], self._install_pkg(pkg)
check=True,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, 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): def check(self, pkgs: Packages):
""" """
@ -63,7 +108,7 @@ class PackageInstaller:
pkgs (GetPackages): the class with packages and their settings. pkgs (GetPackages): the class with packages and their settings.
""" """
if not pkgs.has_packages(): if not pkgs.has_packages() or not self.user_site:
return return
LOG.info(f"Checking for user pip packages: {', '.join(pkgs.packages)}") LOG.info(f"Checking for user pip packages: {', '.join(pkgs.packages)}")

View file

@ -40,3 +40,29 @@ async def debug_asyncio(config: Config, encoder: Encoder) -> Response:
dumps=encoder.encode, dumps=encoder.encode,
) )
@route("GET", "api/dev/pip", "check_pip_packages")
async def check_pip_packages(config: Config, encoder: Encoder) -> Response:
pkgs = config.pip_packages.split(" ") if config.pip_packages else []
if not pkgs:
return web.json_response(
data={"message": "No pip packages configured."},
status=web.HTTPOk.status_code,
)
def _get_installed_version(pkg: str) -> str | None:
try:
import importlib.metadata
return importlib.metadata.version(pkg)
except importlib.metadata.PackageNotFoundError:
return None
response = {}
for pkg in pkgs:
if not (pkg := pkg.strip()):
continue
response.update({pkg: _get_installed_version(pkg)})
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)