diff --git a/.vscode/settings.json b/.vscode/settings.json index 92184457..9211c3a5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -98,6 +98,7 @@ "tmpfilename", "ungroup", "unnegated", + "Unraid", "upgrader", "urandom", "urlsafe", diff --git a/app/__init__.py b/app/__init__.py index e69de29b..96b3d490 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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)) diff --git a/app/library/PackageInstaller.py b/app/library/PackageInstaller.py index ef6faa97..65cceba2 100644 --- a/app/library/PackageInstaller.py +++ b/app/library/PackageInstaller.py @@ -1,13 +1,20 @@ 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 [] @@ -30,30 +37,68 @@ class Packages: class PackageInstaller: - """ - This class is responsible for installing and upgrading pip packages. - """ + 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): - try: - importlib.import_module(pkg) - if upgrade is False: - LOG.info(f"'{pkg}' is already installed. Skipping upgrades. as requested.") + 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 - LOG.info(f"'{pkg}' is already installed. Checking for upgrades...") - subprocess.run( - [sys.executable, "-m", "pip", "install", "--upgrade", pkg], - check=True, - creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - ) - except ImportError: + 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...") - subprocess.run( - [sys.executable, "-m", "pip", "install", pkg], - check=True, - creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, - ) + + 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): """ @@ -63,7 +108,7 @@ class PackageInstaller: 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 LOG.info(f"Checking for user pip packages: {', '.join(pkgs.packages)}") diff --git a/app/routes/api/dev.py b/app/routes/api/dev.py index c53fe397..778cbe26 100644 --- a/app/routes/api/dev.py +++ b/app/routes/api/dev.py @@ -40,3 +40,29 @@ async def debug_asyncio(config: Config, encoder: Encoder) -> Response: 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)