From 9fc7a235e223ec46d74fef1d8b5b5ec565f397c5 Mon Sep 17 00:00:00 2001 From: Tushar Bana Date: Wed, 5 Aug 2026 15:34:15 +0530 Subject: [PATCH] fix: detect available Linux terminal emulator instead of hardcoding gnome-terminal (#591) - Add find_linux_terminal() to detect gnome-terminal, konsole, xfce4-terminal, mate-terminal, tilix, xterm, or x-terminal-emulator via shutil.which - Raise a clear NoLinuxTerminalError with actionable guidance when none are found, instead of silently doing nothing (the root cause of #591 on WSL, which ships no terminal emulator by default) - Rewrite execute_linux_command to use subprocess argv lists instead of os.system, fixing a latent shell-quoting bug - Surface terminal-launch failures to the user via show_error() in installed_table.py (Console/launch actions), uninstall.py (uninstall flow), and create_virtual_environment.py (venv creation), instead of failing silently or showing a misleading success dialog after a failure - Fix find_miniforge_linux() silently failing to detect system conda (invalid shell pipe passed to subprocess without shell=True) - Fix Ubuntu prerequisites (docs + installer.sh) requiring the full gnome desktop metapackage instead of just gnome-terminal - Add regression tests for terminal detection and the no-terminal error path --- doc/source/installer.rst | 19 ++- linux/debian/installer.sh | 6 +- .../installer/create_virtual_environment.py | 5 +- src/ansys/tools/installer/installed_table.py | 47 ++++++- src/ansys/tools/installer/linux_functions.py | 120 +++++++++++++++--- src/ansys/tools/installer/uninstall.py | 18 ++- tests/test_linux_functions.py | 48 +++++++ 7 files changed, 227 insertions(+), 36 deletions(-) diff --git a/doc/source/installer.rst b/doc/source/installer.rst index 35e23d9e..0ba156c1 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 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/src/ansys/tools/installer/create_virtual_environment.py b/src/ansys/tools/installer/create_virtual_environment.py index bc750df9..da35e156 100644 --- a/src/ansys/tools/installer/create_virtual_environment.py +++ b/src/ansys/tools/installer/create_virtual_environment.py @@ -128,8 +128,11 @@ def create_venv(self): Path(venv_dir).mkdir(parents=True, exist_ok=True) try: self.cmd_create_venv(venv_dir) - except: + except Exception as err: + LOG.error(err) self.failed_to_create_dialog() + self.update_table() + return self.update_table() self.venv_success_dialog() diff --git a/src/ansys/tools/installer/installed_table.py b/src/ansys/tools/installer/installed_table.py index ce25ac25..1e05dcff 100644 --- a/src/ansys/tools/installer/installed_table.py +++ b/src/ansys/tools/installer/installed_table.py @@ -595,12 +595,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 +691,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 +708,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 +725,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 +746,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..76541eed 100644 --- a/src/ansys/tools/installer/linux_functions.py +++ b/src/ansys/tools/installer/linux_functions.py @@ -45,6 +45,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 +214,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"] @@ -405,18 +455,54 @@ 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: + 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 " + "by default. Install one, for example with: sudo apt-get 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) + else: + subprocess.Popen(argv, start_new_session=True) + except Exception as err: + msg = f"Failed to execute command using {terminal}: {err}" + LOG.error(msg) + raise NoLinuxTerminalError(msg) from err 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..b42c6fbd 100644 --- a/tests/test_linux_functions.py +++ b/tests/test_linux_functions.py @@ -20,7 +20,12 @@ # 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, + execute_linux_command, + find_linux_terminal, get_conda_url_and_filename, get_vanilla_url_and_filename, run_linux_command, @@ -52,3 +57,46 @@ 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") + + +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: 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]