diff --git a/doc/source/installer.rst b/doc/source/installer.rst index 35e23d9e..723053b4 100644 --- a/doc/source/installer.rst +++ b/doc/source/installer.rst @@ -30,12 +30,27 @@ Installing the ``Ansys Python Manager`` #. **OS** supported for **Ubuntu(20.04 and 22.04)**. #. Update ``apt-get`` repository and install the following packages with **sudo** privileges: - **wget, gnome, libffi-dev, libssl-dev, libsqlite3-dev, libxcb-xinerama0 and build-essential** packages with **sudo** privileges + **wget, gnome-terminal, libffi-dev, libssl-dev, libsqlite3-dev, libxcb-xinerama0 and build-essential** packages with **sudo** privileges .. code:: shell sudo apt-get update -y - sudo apt-get install wget gnome libffi-dev libssl-dev libsqlite3-dev libxcb-xinerama0 build-essential -y + sudo apt-get install wget gnome-terminal libffi-dev libssl-dev libsqlite3-dev libxcb-xinerama0 build-essential -y + + .. note:: + + A terminal emulator is required for the ``Ansys Python Manager`` to run + commands (install packages, launch consoles, and so on). ``gnome-terminal`` + is recommended, but ``konsole``, ``xfce4-terminal``, ``mate-terminal``, + ``tilix``, and ``xterm`` are also supported. This is particularly relevant + when running under **WSL (Windows Subsystem for Linux)**, which does not + ship with a terminal emulator by default. In that case, install a + lightweight option instead of the full ``gnome-terminal`` package: + + .. code:: shell + + sudo apt-get update -y + sudo apt-get install xterm -y #. Install **zlib** package @@ -92,13 +107,19 @@ Installing the ``Ansys Python Manager`` #. **OS** supported for **CentOS9** and **RHEL9**. #. Update ``yum`` repository and install the following packages with **sudo** privileges: - **wget, gnome-terminal, Development Tools, libffi-devel, openssl-devel, rpm-build, sqlite-devel, sqlite-libs, libXinerama-devel, coreutils** + **wget, gnome-terminal, Development Tools, libffi-devel, openssl-devel, rpm-build, sqlite-devel, sqlite-libs, libXinerama-devel, coreutils, mesa-libGL, mesa-libEGL, xcb-util-cursor** .. code:: shell sudo yum update -y; sudo yum groupinstall 'Development Tools' -y; - sudo yum install wget gnome-terminal libffi-devel openssl-devel rpm-build sqlite-devel sqlite-libs libXinerama-devel coreutils -y; + sudo yum install wget gnome-terminal libffi-devel openssl-devel rpm-build sqlite-devel sqlite-libs libXinerama-devel coreutils mesa-libGL mesa-libEGL xcb-util-cursor -y; + + .. note:: + + Under **WSL (Windows Subsystem for Linux)**, you can install the lighter + ``xterm`` instead of ``gnome-terminal``. ``mesa-libGL``, ``mesa-libEGL``, + and ``xcb-util-cursor`` are still required to display the application. #. Install **zlib** package using **wget** @@ -157,13 +178,19 @@ Installing the ``Ansys Python Manager`` #. **OS** supported for **Fedora39**. #. Update ``yum`` repository and install the following packages with **sudo** privileges: - **wget, gnome-terminal, Development Tools, libffi-devel, openssl-devel, rpm-build, sqlite-devel, sqlite-libs, libXinerama-devel, coreutils** + **wget, gnome-terminal, Development Tools, libffi-devel, openssl-devel, rpm-build, sqlite-devel, sqlite-libs, libXinerama-devel, coreutils, mesa-libGL, mesa-libEGL, xcb-util-cursor** .. code:: shell sudo yum update -y; - sudo yum groupinstall 'Development Tools' -y; - sudo yum install wget gnome-terminal libffi-devel openssl-devel rpm-build sqlite-devel sqlite-libs libXinerama-devel coreutils -y; + sudo yum group install development-tools -y; + sudo yum install wget gnome-terminal libffi-devel openssl-devel rpm-build sqlite-devel sqlite-libs libXinerama-devel coreutils mesa-libGL mesa-libEGL xcb-util-cursor -y; + + .. note:: + + Under **WSL (Windows Subsystem for Linux)**, you can install the lighter + ``xterm`` instead of ``gnome-terminal``. ``mesa-libGL``, ``mesa-libEGL``, + and ``xcb-util-cursor`` are still required to display the application. #. Install **zlib** package using **wget** diff --git a/linux/debian/installer.sh b/linux/debian/installer.sh index 55f64bd6..7b5da09a 100644 --- a/linux/debian/installer.sh +++ b/linux/debian/installer.sh @@ -13,7 +13,7 @@ else dependencies_available=false fi # check other dependencies -arr=("wget" "gnome" "libffi-dev" "libssl-dev" "build-essential" "libsqlite3-dev" "libxcb-xinerama0") +arr=("wget" "gnome-terminal" "libffi-dev" "libssl-dev" "build-essential" "libsqlite3-dev" "libxcb-xinerama0") for x in "${arr[@]}"; do c="dpkg -s $x >/dev/null 2>&1" eval $c @@ -75,7 +75,7 @@ else dependencies_available=false fi # check other dependencies - arr=("wget" "gnome" "libffi-dev" "libssl-dev" "build-essential" "libsqlite3-dev" "libxcb-xinerama0") + arr=("wget" "gnome-terminal" "libffi-dev" "libssl-dev" "build-essential" "libsqlite3-dev" "libxcb-xinerama0") for x in "${arr[@]}"; do c="dpkg -s $x >/dev/null 2>&1" eval $c @@ -111,4 +111,4 @@ else printf "Dependencies installation required sudo access.\n" echo -e '\e]8;;https://installer.docs.pyansys.com/version/stable/installer.html\aFollow prerequisites in this link\e]8;;\a' fi -fi \ No newline at end of file +fi diff --git a/linux/non-debian/installer_CentOS.sh b/linux/non-debian/installer_CentOS.sh index c7ffe194..8d99a97e 100644 --- a/linux/non-debian/installer_CentOS.sh +++ b/linux/non-debian/installer_CentOS.sh @@ -2,6 +2,15 @@ clear missing_deps=() dependencies_available=true +if command -v dnf >/dev/null 2>&1; then + pkg_manager=dnf +elif command -v yum >/dev/null 2>&1; then + pkg_manager=yum +else + echo "Neither dnf nor yum was found. Install the dependencies manually, following:" + echo "https://installer.docs.pyansys.com/version/stable/installer.html" + exit 1 +fi # Check user input for sudo permission while true; do read -p "Require sudo permission to install this package and the dependencies. Do you want to continue?(Y/N): " user_selection @@ -26,7 +35,7 @@ if [ "$user_selection" = "Y" ]; then dependencies_available=false fi # check other dependencies - yum grouplist | grep 'Development Tools' + $pkg_manager group list --installed 2>/dev/null | grep -q 'Development Tools' ret=$? if [ $ret -eq 0 ]; then : @@ -34,7 +43,7 @@ if [ "$user_selection" = "Y" ]; then missing_deps+=("Development Tools") dependencies_available=false fi - arr=("wget" "gnome-terminal" "libffi-devel" "openssl-devel" "rpm-build" "sqlite-devel" "sqlite-libs" "libXinerama-devel" "coreutils") + arr=("wget" "gnome-terminal" "libffi-devel" "openssl-devel" "rpm-build" "sqlite-devel" "sqlite-libs" "libXinerama-devel" "coreutils" "mesa-libGL" "mesa-libEGL" "xcb-util-cursor") for x in "${arr[@]}"; do c="rpm -qa | grep $x" eval $c @@ -47,7 +56,10 @@ if [ "$user_selection" = "Y" ]; then fi done if [ $dependencies_available = true ]; then - sudo rpm -iv ansys_python_manager_*.rpm + if ! sudo rpm -iv ansys_python_manager_*.rpm; then + printf "\nInstallation failed...\n" + exit 1 + fi available=$(cat ~/.bashrc | grep -zoP "# Add alias for Ansys Python Manager \nalias ansys_python_manager=~/.local/opt/ansys_python_manager/ansys_python_manager" | wc -l) echo $available if [ $available -lt 1 ] @@ -57,21 +69,38 @@ if [ "$user_selection" = "Y" ]; then printf "\nInstallation success...\nIt is suggested to restart your machine to begin using the software....\n" else echo "Missing dependencies..." - install_script="sudo yum update -y; sudo yum install " + install_script="true" + packages="" install_zlib=false + install_dev_tools=false for x in "${missing_deps[@]}"; do - if [ $x == "zlib" ]; then + if [ "$x" == "zlib" ]; then install_zlib=true + elif [ "$x" == "Development Tools" ]; then + install_dev_tools=true else - install_script="$install_script $x" + packages="$packages $x" fi done - install_script="$install_script -y" + if [ -n "$packages" ]; then + install_script="$install_script && sudo $pkg_manager install$packages -y" + fi + if [ $install_dev_tools = true ]; then + # dnf5 (Fedora 41+) only accepts the group ID + install_script="$install_script && { sudo $pkg_manager group install 'Development Tools' -y || sudo $pkg_manager group install development-tools -y; }" + fi + sudo $pkg_manager update -y + if ! eval $install_script; then + printf "\nFailed to install the dependencies. Installation aborted...\n" + exit 1 + fi if [ $install_zlib = true ]; then - install_script="$install_script; mkdir ansys-prereq; cd ansys-prereq; wget https://zlib.net/current/zlib.tar.gz; tar xvzf zlib.tar.gz; cd zlib-*; sudo make clean; ./configure; sudo make; sudo make install; cd ../..; rm -rf ansys-prereq;" + mkdir ansys-prereq; cd ansys-prereq; wget https://zlib.net/current/zlib.tar.gz; tar xvzf zlib.tar.gz; cd zlib-*; sudo make clean; ./configure; sudo make; sudo make install; cd ../..; rm -rf ansys-prereq; + fi + if ! sudo rpm -iv ansys_python_manager_*.rpm; then + printf "\nInstallation failed...\n" + exit 1 fi - eval $install_script - sudo rpm -iv ansys_python_manager_*.rpm printf "\nInstallation success...\n" fi else diff --git a/src/ansys/tools/installer/create_virtual_environment.py b/src/ansys/tools/installer/create_virtual_environment.py index bc750df9..21376c64 100644 --- a/src/ansys/tools/installer/create_virtual_environment.py +++ b/src/ansys/tools/installer/create_virtual_environment.py @@ -128,8 +128,28 @@ def create_venv(self): Path(venv_dir).mkdir(parents=True, exist_ok=True) try: self.cmd_create_venv(venv_dir) - except: - self.failed_to_create_dialog() + except Exception as err: + LOG.error(err) + error = str(err) + else: + error = None + # Linux terminals do not report the exit status of the command + if is_linux_os() and not os.path.exists( + os.path.join(venv_dir, "bin", "python") + ): + error = ( + "The virtual environment was not created. Check the " + "terminal output for details." + ) + LOG.error(f"{error} Path: {venv_dir}") + + if error is not None: + # Allow retrying with the same name + if os.path.isdir(venv_dir) and not os.listdir(venv_dir): + os.rmdir(venv_dir) + self.failed_to_create_dialog(details=error) + self.update_table() + return self.update_table() self.venv_success_dialog() @@ -143,7 +163,7 @@ def venv_success_dialog(self): msg.setWindowIcon(self.app_icon) msg.exec_() - def failed_to_create_dialog(self, case_1=False, case_2=False): + def failed_to_create_dialog(self, case_1=False, case_2=False, details=None): """Dialogs for if environment gets failed to create.""" if case_1: # Case 1: check for name of environment @@ -172,7 +192,9 @@ def failed_to_create_dialog(self, case_1=False, case_2=False): # In case of critical error msg = QtWidgets.QMessageBox() msg.setText("Error: Failed to create virtual environment!") - msg.setInformativeText("There might be some issue with application.") + msg.setInformativeText( + details or "There might be some issue with application." + ) msg.setWindowTitle("Error") msg.setIcon(msg.Icon.Critical) msg.setWindowIcon(self.app_icon) diff --git a/src/ansys/tools/installer/installed_table.py b/src/ansys/tools/installer/installed_table.py index ce25ac25..81c0d3d4 100644 --- a/src/ansys/tools/installer/installed_table.py +++ b/src/ansys/tools/installer/installed_table.py @@ -505,6 +505,9 @@ def _update_pck_mnger(self): """ if self.is_chk_box_active(): if "Python" in self.table.active_version: + if is_linux_os(): + # run_linux_command already updates pip and uv first + return cmd = "python -m pip install -U pip uv && exit" else: # Otherwise, conda cmd = "conda update conda --yes && exit" @@ -595,12 +598,31 @@ def delete_virtual_environment(self, point): subprocess.call(f'start /w /min cmd /K "{shell_cmd}"', shell=True) if os.path.exists(parent_path): shutil.rmtree(parent_path) - except: - pass + except Exception as err: + LOG.error(err) + if self._parent is not None and hasattr(self._parent, "show_error"): + self._parent.show_error(str(err)) # Finally, update the venv table self.venv_table.update() + def _run_linux_or_report_error(self, func, *args, **kwargs): + """Run a Linux command launcher, surfacing failures to the user. + + Notes + ----- + This is primarily needed to catch ``NoLinuxTerminalError``, which is + raised when no supported terminal emulator (such as ``gnome-terminal``) + is available. This is a common situation on WSL (Windows Subsystem for + Linux), where no terminal emulator is installed by default. + """ + try: + func(*args, **kwargs) + except Exception as err: + LOG.error(err) + if self._parent is not None and hasattr(self._parent, "show_error"): + self._parent.show_error(str(err)) + def launch_cmd( self, extra: str = "", @@ -672,7 +694,9 @@ def launch_cmd( cmd = f"&& echo Python set to {py_path}" if is_linux_os(): - run_linux_command(py_path, extra, working_dir=working_dir) + self._run_linux_or_report_error( + run_linux_command, py_path, extra, working_dir=working_dir + ) else: # Update the package managers shell_cmd = f"set PATH={new_path} && python -m pip install --upgrade pip uv && exit" @@ -687,7 +711,9 @@ def launch_cmd( else: cmd = f"&& echo Python set to {py_path}" if is_linux_os(): - run_linux_command(py_path, extra, True, working_dir=working_dir) + self._run_linux_or_report_error( + run_linux_command, py_path, extra, True, working_dir=working_dir + ) else: shell_cmd = f'set PATH={myenv} && {py_path}\\Scripts\\activate.bat && cd /d ""{working_dir}"" {cmd}' subprocess.call(f'start {min_win} cmd /K "{shell_cmd}"', shell=True) @@ -702,7 +728,13 @@ def launch_cmd( else: cmd = f"&& echo Activating conda forge at path {py_path}" if is_linux_os(): - run_linux_command_conda(py_path, extra, True, working_dir=working_dir) + self._run_linux_or_report_error( + run_linux_command_conda, + py_path, + extra, + True, + working_dir=working_dir, + ) else: shell_cmd = f'set PATH={myenv} && {miniforge_path}\\Scripts\\activate.bat && conda activate {py_path} && cd /d ""{working_dir}"" {cmd}' subprocess.call(f'start {min_win} cmd /K "{shell_cmd}"', shell=True) @@ -717,7 +749,13 @@ def launch_cmd( else: cmd = f"&& echo Activating conda forge at path {py_path}" if is_linux_os(): - run_linux_command_conda(py_path, extra, False, working_dir=working_dir) + self._run_linux_or_report_error( + run_linux_command_conda, + py_path, + extra, + False, + working_dir=working_dir, + ) else: shell_cmd = f'set PATH={myenv} && {miniforge_path}\\Scripts\\activate.bat && conda activate {py_path} && cd /d ""{working_dir}"" {cmd}' subprocess.call(f'start {min_win} cmd /K "{shell_cmd}"', shell=True) diff --git a/src/ansys/tools/installer/linux_functions.py b/src/ansys/tools/installer/linux_functions.py index 472a6e23..60415fff 100644 --- a/src/ansys/tools/installer/linux_functions.py +++ b/src/ansys/tools/installer/linux_functions.py @@ -26,8 +26,10 @@ import logging import os from pathlib import Path +import shlex import shutil import subprocess +import sys from github import Github from packaging import version @@ -45,6 +47,60 @@ ansys_linux_path = f"/home/{user_name}/.local/ansys" +# Ordered list of supported terminal emulators, from most to least preferred. +# Each entry maps a terminal executable name to a callable that builds the +# ``argv`` list used to run ``command`` inside of it. +# +# Notes +# ----- +# ``gnome-terminal`` is a client/server application: by default it forwards +# the request to a background ``gnome-terminal-server`` process and returns +# immediately, which is why the explicit ``--wait`` flag is required to block +# until the spawned command finishes. Most other terminal emulators (konsole, +# xfce4-terminal, xterm, etc.) keep running in the foreground by default, so +# blocking behavior comes "for free" when the process is not explicitly +# backgrounded. +_LINUX_TERMINALS = { + "gnome-terminal": lambda command, wait: ( + ["gnome-terminal"] + (["--wait"] if wait else []) + ["--", "sh", "-c", command] + ), + "konsole": lambda command, wait: ["konsole", "-e", "sh", "-c", command], + "xfce4-terminal": lambda command, wait: ( + ["xfce4-terminal", "--disable-server", "-x", "sh", "-c", command] + ), + "mate-terminal": lambda command, wait: ( + ["mate-terminal", "--disable-factory", "-x", "sh", "-c", command] + ), + "tilix": lambda command, wait: ["tilix", "-e", "sh", "-c", command], + "xterm": lambda command, wait: ["xterm", "-e", "sh", "-c", command], + "x-terminal-emulator": lambda command, wait: ( + ["x-terminal-emulator", "-e", "sh", "-c", command] + ), +} + + +class NoLinuxTerminalError(RuntimeError): + """Raised when no supported terminal emulator is available on the system.""" + + +def find_linux_terminal(): + """Find the first available, supported terminal emulator on this system. + + Returns + ------- + str or None + The name of the first supported terminal emulator found on the + ``PATH``, or ``None`` if none of them are available. This is + commonly the case on WSL (Windows Subsystem for Linux) distributions, + which do not ship with a terminal emulator by default. + + """ + for terminal in _LINUX_TERMINALS: + if shutil.which(terminal): + return terminal + return None + + def is_linux_os(): """ Create OS is Linux or Not. @@ -160,17 +216,13 @@ def find_miniforge_linux(ansys_manager_installed_only=False): paths = {} if not ansys_manager_installed_only: try: - subprocess.check_output("printenv | grep CONDA_PYTHON_EXE > /tmp/conda.txt") - with open("/tmp/conda.txt") as f: - conda_system_path = f.read() - conda_system_path = conda_system_path.replace("CONDA_PYTHON_EXE=", "") - conda_system_path = conda_system_path.replace("/bin/python", "").strip() - version = subprocess.check_output([f"conda", "--version"]) - version = version.split()[1].decode("utf-8") - paths[conda_system_path] = (version, True) - os.remove("/tmp/conda.txt") - except: - pass + conda_system_path = os.environ["CONDA_PYTHON_EXE"] + conda_system_path = conda_system_path.replace("/bin/python", "").strip() + version = subprocess.check_output(["conda", "--version"]) + version = version.split()[1].decode("utf-8") + paths[conda_system_path] = (version, True) + except Exception as e: + LOG.debug(e) try: version = subprocess.check_output( [f"{ansys_linux_path}/conda/bin/conda", "--version"] @@ -195,8 +247,11 @@ def create_venv_linux(venv_dir, py_path): ... ) """ + py_path = shlex.quote(py_path) + venv_python = shlex.quote(f"{venv_dir}/bin/python") execute_linux_command(f"{py_path} -m pip install -U pip uv") - execute_linux_command(f"{py_path} -m uv venv {venv_dir}") + execute_linux_command(f"{py_path} -m uv venv --seed {shlex.quote(venv_dir)}") + execute_linux_command(f"{venv_python} -m pip install -U pip uv") def create_venv_linux_conda(venv_dir, py_path): @@ -242,23 +297,34 @@ def run_linux_command(pypath, extra, venv=False, working_dir=None): >>> run_linux_command("/home/sha/.local/ansys/python-3.12.0/bin/python3", "uv pip list") """ + # For a venv, ``pypath`` is the venv folder; otherwise the interpreter itself + python = shlex.quote(f"{pypath}/bin/python" if venv else pypath) + # Update package manager before executing commands - execute_linux_command(f"{pypath} -m pip install -U pip uv") + update_cmd = f"{python} -m pip install -U pip uv" + if venv: + # Venvs created by older versions of this app have neither pip nor uv + update_cmd = ( + f"{python} -m pip --version >/dev/null 2>&1 || " + f"{python} -m ensurepip --upgrade; {update_cmd}" + ) + execute_linux_command(update_cmd) - prefix = f"{pypath}" extra = extra.replace("timeout", "sleep") - python_name = prefix.split("/")[-1] - major_version = ( - list(python_name)[-1] if list(python_name)[-1].isnumeric() and not venv else "" - ) if not extra: extra = "bash" if "sleep" not in extra and extra != "bash": extra += '; read -p "Press Enter to Continue.... " confirm || exit 1' if venv: - prefix = f". {pypath}/bin/activate; " + prefix = f". {shlex.quote(pypath)}/bin/activate; " else: - prefix = "/".join(prefix.split("/")[:-1]) + "/" + # Resolve uv, pip, and installed scripts from this interpreter first + bin_dir = shlex.quote(os.path.dirname(pypath)) + prefix = f'PATH={bin_dir}:"$PATH"; export PATH; ' + # ``--system`` would target the first ``python3`` on the PATH, + # which is the OS interpreter rather than the selected one + extra = extra.replace("--system", f"--python {python}") + extra = extra.replace("python -m ", f"{python} -m ") cd_cmd = ( f"cd {working_dir!r}" if working_dir and os.path.isdir(working_dir) else "cd ~" ) @@ -405,18 +471,71 @@ def query_gh_latest_release_linux(token=None): def execute_linux_command(command, wait=True): """ - Run linux command on gnome terminal. + Run a Linux command in the first available terminal emulator. + + Previously this always shelled out to ``gnome-terminal``, which is not + installed by default on many Linux systems (for example, WSL + distributions), causing every action relying on this function to fail + silently. This now detects an available terminal emulator amongst + several common alternatives before running the command. + + Parameters + ---------- + command : str + Command to run inside of the terminal. + wait : bool, default: True + Whether to block until the spawned terminal (and command) finishes. + + Raises + ------ + NoLinuxTerminalError + If no supported terminal emulator could be found on the ``PATH``. Examples -------- >>> execute_linux_command("ls") """ - wait_command = "" - if wait: - wait_command = "--wait" - LOG.debug(f"gnome-terminal {wait_command} -- sh -c '{command}'") - os.system(f"gnome-terminal {wait_command} -- sh -c '{command}'") + terminal = find_linux_terminal() + if terminal is None: + pkg_manager = "apt-get" if shutil.which("apt-get") else "dnf" + msg = ( + "No supported terminal emulator was found on this system (tried: " + f"{', '.join(_LINUX_TERMINALS)}). Ansys Python Manager requires one " + "of these to run commands. This is a common issue on WSL (Windows " + "Subsystem for Linux), which does not install a terminal emulator " + f"by default. Install one, for example with: sudo {pkg_manager} install xterm" + ) + LOG.error(msg) + raise NoLinuxTerminalError(msg) + + argv = _LINUX_TERMINALS[terminal](command, wait) + LOG.debug("Executing linux command with %s: %s", terminal, argv) + try: + if wait: + subprocess.run(argv, env=_user_environment()) + else: + subprocess.Popen(argv, start_new_session=True, env=_user_environment()) + except Exception as err: + msg = f"Failed to execute command using {terminal}: {err}" + LOG.error(msg) + raise NoLinuxTerminalError(msg) from err + + +def _user_environment(): + """Return the environment to launch user terminals with. + + A frozen (PyInstaller) build points ``LD_LIBRARY_PATH`` at its bundled + libraries and keeps the user's original value in ``LD_LIBRARY_PATH_ORIG``. + Without restoring it, every command run in the terminal would load the + app's bundled libraries (for example, OpenSSL) instead of the system ones. + """ + env = dict(os.environ) + if "LD_LIBRARY_PATH_ORIG" in env: + env["LD_LIBRARY_PATH"] = env.pop("LD_LIBRARY_PATH_ORIG") + elif getattr(sys, "frozen", False): + env.pop("LD_LIBRARY_PATH", None) + return env def get_os_version(): diff --git a/src/ansys/tools/installer/uninstall.py b/src/ansys/tools/installer/uninstall.py index 7d3fad40..a40ace5a 100644 --- a/src/ansys/tools/installer/uninstall.py +++ b/src/ansys/tools/installer/uninstall.py @@ -190,13 +190,17 @@ def _uninstall(self): if self.uninstall_window_cache_remove_configs_checkbox.isChecked(): self._remove_configs() - os_version = get_os_version() - if os_version in ["centos", "fedora"]: - script_path = os.path.join(ASSETS_PATH, "uninstaller_yum.sh") - execute_linux_command(f"{script_path}", wait=False) - elif get_os_version().startswith("2"): - script_path = os.path.join(ASSETS_PATH, "uninstaller_ubuntu.sh") - execute_linux_command(f"{script_path}", wait=False) + try: + os_version = get_os_version() + if os_version in ["centos", "fedora"]: + script_path = os.path.join(ASSETS_PATH, "uninstaller_yum.sh") + execute_linux_command(f"{script_path}", wait=False) + elif get_os_version().startswith("2"): + script_path = os.path.join(ASSETS_PATH, "uninstaller_ubuntu.sh") + execute_linux_command(f"{script_path}", wait=False) + except Exception as e: + self._parent.show_error(str(e)) + return self.user_confirmation_form.close() self._parent.uninstall_window.close() diff --git a/tests/test_linux_functions.py b/tests/test_linux_functions.py index 092632d0..32972c02 100644 --- a/tests/test_linux_functions.py +++ b/tests/test_linux_functions.py @@ -20,7 +20,13 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import pytest + from ansys.tools.installer.linux_functions import ( + NoLinuxTerminalError, + create_venv_linux, + execute_linux_command, + find_linux_terminal, get_conda_url_and_filename, get_vanilla_url_and_filename, run_linux_command, @@ -52,3 +58,165 @@ def test_run_linux_command_accepts_working_dir(): sig_conda = inspect.signature(run_linux_command_conda) assert "working_dir" in sig_conda.parameters + + +def test_find_linux_terminal_returns_none_when_no_terminal_available(monkeypatch): + """No terminal emulator should be found when none are on the PATH.""" + monkeypatch.setattr( + "ansys.tools.installer.linux_functions.shutil.which", lambda _name: None + ) + assert find_linux_terminal() is None + + +def test_find_linux_terminal_finds_non_gnome_terminal(monkeypatch): + """A non gnome-terminal emulator (e.g. xterm) should still be detected.""" + monkeypatch.setattr( + "ansys.tools.installer.linux_functions.shutil.which", + lambda name: "/usr/bin/xterm" if name == "xterm" else None, + ) + assert find_linux_terminal() == "xterm" + + +def test_execute_linux_command_raises_clear_error_without_terminal(monkeypatch): + """execute_linux_command should raise a clear, actionable error (e.g. on WSL).""" + monkeypatch.setattr( + "ansys.tools.installer.linux_functions.shutil.which", lambda _name: None + ) + with pytest.raises(NoLinuxTerminalError): + execute_linux_command("echo hello") + + +@pytest.mark.parametrize( + "available, expected", + [({"apt-get"}, "sudo apt-get install xterm"), ({"dnf"}, "sudo dnf install xterm")], +) +def test_no_terminal_error_suggests_distro_package_manager( + monkeypatch, available, expected +): + """The install hint must match the distro's package manager (apt vs dnf).""" + monkeypatch.setattr( + "ansys.tools.installer.linux_functions.shutil.which", + lambda name: f"/usr/bin/{name}" if name in available else None, + ) + with pytest.raises(NoLinuxTerminalError, match=expected): + execute_linux_command("echo hello") + + +def test_execute_linux_command_uses_available_terminal(monkeypatch): + """execute_linux_command should use whichever supported terminal is found.""" + calls = [] + monkeypatch.setattr( + "ansys.tools.installer.linux_functions.shutil.which", + lambda name: "/usr/bin/xterm" if name == "xterm" else None, + ) + monkeypatch.setattr( + "ansys.tools.installer.linux_functions.subprocess.run", + lambda argv, **_kwargs: calls.append(argv), + ) + execute_linux_command("echo hello", wait=True) + assert len(calls) == 1 + assert calls[0][0] == "xterm" + assert "echo hello" in calls[0] + + +@pytest.mark.parametrize( + "frozen, env_in, expected", + [ + # PyInstaller saved the user's original value: restore it. + ( + True, + {"LD_LIBRARY_PATH": "/app/_internal", "LD_LIBRARY_PATH_ORIG": "/u"}, + "/u", + ), + # User had none: the bundle path must not leak into the terminal. + (True, {"LD_LIBRARY_PATH": "/app/_internal"}, None), + # Not frozen: leave the user's environment untouched. + (False, {"LD_LIBRARY_PATH": "/u"}, "/u"), + ], +) +@pytest.mark.parametrize("wait", [True, False]) +def test_execute_linux_command_does_not_leak_bundle_libraries( + monkeypatch, frozen, env_in, expected, wait +): + """Terminals must not inherit the frozen app's bundled library path.""" + envs = [] + monkeypatch.setattr( + "ansys.tools.installer.linux_functions.shutil.which", + lambda name: "/usr/bin/xterm" if name == "xterm" else None, + ) + monkeypatch.setattr( + "ansys.tools.installer.linux_functions.subprocess.run", + lambda argv, env=None, **_kwargs: envs.append(env), + ) + monkeypatch.setattr( + "ansys.tools.installer.linux_functions.subprocess.Popen", + lambda argv, env=None, **_kwargs: envs.append(env), + ) + monkeypatch.delenv("LD_LIBRARY_PATH", raising=False) + monkeypatch.delenv("LD_LIBRARY_PATH_ORIG", raising=False) + for key, value in env_in.items(): + monkeypatch.setenv(key, value) + monkeypatch.setattr("sys.frozen", frozen, raising=False) + + execute_linux_command("echo hello", wait=wait) + + assert len(envs) == 1 + assert envs[0].get("LD_LIBRARY_PATH") == expected + assert "LD_LIBRARY_PATH_ORIG" not in envs[0] + + +@pytest.fixture +def linux_commands(monkeypatch): + """Record the commands sent to the terminal instead of running them.""" + commands = [] + monkeypatch.setattr( + "ansys.tools.installer.linux_functions.execute_linux_command", + lambda command, wait=True: commands.append(command), + ) + return commands + + +def test_create_venv_linux_seeds_pip_and_uv(linux_commands): + """Venvs must contain pip and uv, which package management relies on.""" + create_venv_linux("/home/u/venvs/my env", "/home/u/python-3.11/bin/python3") + assert linux_commands == [ + "/home/u/python-3.11/bin/python3 -m pip install -U pip uv", + "/home/u/python-3.11/bin/python3 -m uv venv --seed '/home/u/venvs/my env'", + "'/home/u/venvs/my env/bin/python' -m pip install -U pip uv", + ] + + +def test_run_linux_command_venv_uses_venv_python(linux_commands): + """Updates must run with the venv's interpreter, not the venv folder.""" + run_linux_command("/home/u/venvs/env1", "uv pip list", venv=True) + update, command = linux_commands + python = "/home/u/venvs/env1/bin/python" + assert update == ( + f"{python} -m pip --version >/dev/null 2>&1 || " + f"{python} -m ensurepip --upgrade; {python} -m pip install -U pip uv" + ) + assert ". /home/u/venvs/env1/bin/activate; uv pip list" in command + + +def test_run_linux_command_base_python_targets_selected_interpreter(linux_commands): + """``uv --system`` and ``python`` must resolve to the selected interpreter.""" + python = "/home/u/python-3.11/bin/python3" + run_linux_command( + python, + "uv pip install --system numpy && python -m jupyter lab && timeout 3", + ) + update, command = linux_commands + assert update == f"{python} -m pip install -U pip uv" + assert 'PATH=/home/u/python-3.11/bin:"$PATH"; export PATH; ' in command + assert f"uv pip install --python {python} numpy" in command + assert f"{python} -m jupyter lab" in command + assert "--system" not in command + assert "sleep 3" in command + + +def test_run_linux_command_base_python_console(linux_commands): + """The console must start a regular shell with the interpreter on the PATH.""" + run_linux_command("/home/u/python-3.11/bin/python3", "") + assert linux_commands[1] == ( + 'cd ~ ; PATH=/home/u/python-3.11/bin:"$PATH"; export PATH; bash' + )