diff --git a/moonraker/components/update_manager/app_deploy.py b/moonraker/components/update_manager/app_deploy.py index 6353c2f..591a9e1 100644 --- a/moonraker/components/update_manager/app_deploy.py +++ b/moonraker/components/update_manager/app_deploy.py @@ -10,6 +10,7 @@ import pathlib import shutil import hashlib import logging +import re from .base_deploy import BaseDeploy # Annotation imports @@ -20,12 +21,16 @@ from typing import ( Union, Dict, List, + Tuple ) if TYPE_CHECKING: from confighelper import ConfigHelper from .update_manager import CommandHelper from ..machine import Machine from ..file_manager.file_manager import FileManager + from ..shell_command import ShellCommandFactory as ShellCmd + +MIN_PIP_VERSION = (23, 0) SUPPORTED_CHANNELS = { "zip": ["stable", "beta"], @@ -71,25 +76,37 @@ class AppDeploy(BaseDeploy): raise config.error( f"Invalid Channel '{self.channel}' for config " f"section [{config.get_name()}], type: {self.type}") - self._verify_path(config, 'path', self.path) + self._verify_path(config, 'path', self.path, check_file=False) self.executable: Optional[pathlib.Path] = None - self.pip_exe: Optional[pathlib.Path] = None + self.pip_cmd: Optional[str] = None + self.pip_version: Tuple[int, ...] = tuple() self.venv_args: Optional[str] = None if executable is not None: self.executable = pathlib.Path(executable).expanduser() - self.pip_exe = self.executable.parent.joinpath("pip") - if not self.pip_exe.exists(): + self._verify_path(config, 'env', self.executable, check_exe=True) + # Detect if executable is actually located in a virtualenv + # by checking the parent for the activation script + act_path = self.executable.parent.joinpath("activate") + while not act_path.is_file(): if self.executable.is_symlink(): self.executable = pathlib.Path(os.readlink(self.executable)) - self.pip_exe = self.executable.parent.joinpath("pip") - if not self.pip_exe.exists(): - logging.info( - f"Update Manger {self.name}: Unable to locate pip " - "executable") - self.pip_exe = None - self._verify_path(config, 'env', self.executable) + act_path = self.executable.parent.joinpath("activate") + else: + break + if act_path.is_file(): + venv_dir = self.executable.parent.parent + self.log_info(f"Detected virtualenv: {venv_dir}") + pip_exe = self.executable.parent.joinpath("pip") + if pip_exe.is_file(): + self.pip_cmd = f"{self.executable} -m pip" + else: + self.log_info("Unable to locate pip executable") + else: + self.log_info( + f"Unable to detect virtualenv at: {executable}" + ) + self.executable = pathlib.Path(executable).expanduser() self.venv_args = config.get('venv_args', None) - self.info_tags: List[str] = config.getlist("info_tags", []) self.managed_services: List[str] = [] svc_default = [] @@ -143,19 +160,32 @@ class AppDeploy(BaseDeploy): async def initialize(self) -> Dict[str, Any]: storage = await super().initialize() - self.need_channel_update = storage.get('need_channel_upate', False) - self._is_valid = storage.get('is_valid', False) + self.need_channel_update = storage.get("need_channel_update", False) + self._is_valid = storage.get("is_valid", False) + self.pip_version = tuple(storage.get("pip_version", [])) + if self.pip_version: + ver_str = ".".join([str(part) for part in self.pip_version]) + self.log_info(f"Stored pip version: {ver_str}") return storage - def _verify_path(self, - config: ConfigHelper, - option: str, - file_path: pathlib.Path - ) -> None: - if not file_path.exists(): - raise config.error( - f"Invalid path for option `{option}` in section " - f"[{config.get_name()}]: Path `{file_path}` does not exist") + def _verify_path( + self, + config: ConfigHelper, + option: str, + path: pathlib.Path, + check_file: bool = True, + check_exe: bool = False + ) -> None: + base_msg = ( + f"Invalid path for option `{option}` in section " + f"[{config.get_name()}]: Path `{path}`" + ) + if not path.exists(): + raise config.error(f"{base_msg} does not exist") + if check_file and not path.is_file(): + raise config.error(f"{base_msg} is not a file") + if check_exe and not os.access(path, os.X_OK): + raise config.error(f"{base_msg} is not executable") def check_need_channel_swap(self) -> bool: return self.need_channel_update @@ -234,6 +264,7 @@ class AppDeploy(BaseDeploy): storage = super().get_persistent_data() storage['is_valid'] = self._is_valid storage['need_channel_update'] = self.need_channel_update + storage['pip_version'] = list(self.pip_version) return storage async def _get_file_hash(self, @@ -272,8 +303,9 @@ class AppDeploy(BaseDeploy): async def _update_virtualenv(self, requirements: Union[pathlib.Path, List[str]] ) -> None: - if self.pip_exe is None: + if self.pip_cmd is None: return + await self._update_pip() # Update python dependencies if isinstance(requirements, pathlib.Path): if not requirements.is_file(): @@ -285,20 +317,68 @@ class AppDeploy(BaseDeploy): args = " ".join(requirements) self.notify_status("Updating python packages...") try: - # First attempt to update pip - # await self.cmd_helper.run_cmd( - # f"{self.pip_exe} install -U pip", timeout=1200., notify=True, - # retries=3) await self.cmd_helper.run_cmd( - f"{self.pip_exe} install {args}", timeout=1200., notify=True, - retries=3) + f"{self.pip_cmd} install {args}", timeout=1200., notify=True, + retries=3 + ) except Exception: self.log_exc("Error updating python requirements") - async def _build_virtualenv(self) -> None: - if self.pip_exe is None or self.venv_args is None: + async def _update_pip(self) -> None: + if self.pip_cmd is None: return - bin_dir = self.pip_exe.parent + update_ver = await self._check_pip_version() + if update_ver is None: + return + cur_vstr = ".".join([str(part) for part in self.pip_version]) + self.notify_status( + f"Updating pip from version {cur_vstr} to {update_ver}..." + ) + try: + await self.cmd_helper.run_cmd( + f"{self.pip_cmd} install pip=={update_ver}", + timeout=1200., notify=True, retries=3 + ) + except Exception: + self.log_exc("Error updating python pip") + + async def _check_pip_version(self) -> Optional[str]: + if self.pip_cmd is None: + return None + self.notify_status("Checking pip version...") + scmd: ShellCmd = self.server.lookup_component("shell_command") + try: + data: str = await scmd.exec_cmd( + f"{self.pip_cmd} --version", timeout=30., retries=3 + ) + match = re.match( + r"^pip ([0-9.]+) from .+? \(python ([0-9.]+)\)$", data.strip() + ) + if match is None: + return None + pipver_str: str = match.group(1) + pyver_str: str = match.group(2) + pipver = tuple([int(part) for part in pipver_str.split(".")]) + pyver = tuple([int(part) for part in pyver_str.split(".")]) + except Exception: + self.log_exc("Error Getting Pip Version") + return None + self.pip_version = pipver + if not self.pip_version: + return None + self.log_info( + f"Dectected pip version: {pipver_str}, Python {pyver_str}" + ) + if pyver < (3, 7): + return None + if self.pip_version < MIN_PIP_VERSION: + return ".".join([str(ver) for ver in MIN_PIP_VERSION]) + return None + + async def _build_virtualenv(self) -> None: + if self.executable is None or self.venv_args is None: + return + bin_dir = self.executable.parent env_path = bin_dir.parent.resolve() self.notify_status(f"Creating virtualenv at: {env_path}...") if env_path.exists(): @@ -309,5 +389,5 @@ class AppDeploy(BaseDeploy): except Exception: self.log_exc(f"Error creating virtualenv") return - if not self.pip_exe.exists(): + if not self.executable.exists(): raise self.log_exc("Failed to create new virtualenv", False)