diff --git a/doc/changelog.d/4329.miscellaneous.md b/doc/changelog.d/4329.miscellaneous.md new file mode 100644 index 00000000000..66816e8df79 --- /dev/null +++ b/doc/changelog.d/4329.miscellaneous.md @@ -0,0 +1 @@ +List_instances diff --git a/src/ansys/mapdl/core/cli/__init__.py b/src/ansys/mapdl/core/cli/__init__.py index e2eedcb92df..0f62e648588 100644 --- a/src/ansys/mapdl/core/cli/__init__.py +++ b/src/ansys/mapdl/core/cli/__init__.py @@ -20,12 +20,16 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from ansys.mapdl.core import _HAS_CLICK +try: + import click # noqa: F401 + + _HAS_CLICK = True +except ImportError: # pragma: no cover + _HAS_CLICK = False if _HAS_CLICK: ################################### # PyMAPDL CLI - import click @click.group(invoke_without_command=True) @click.pass_context @@ -38,9 +42,9 @@ def main(ctx: click.Context): from ansys.mapdl.core.cli.stop import stop as stop_cmd main.add_command(convert_cmd, name="convert") + main.add_command(list_instances, name="list") main.add_command(start_cmd, name="start") main.add_command(stop_cmd, name="stop") - main.add_command(list_instances, name="list") else: diff --git a/src/ansys/mapdl/core/cli/core.py b/src/ansys/mapdl/core/cli/core.py new file mode 100644 index 00000000000..40d1d296689 --- /dev/null +++ b/src/ansys/mapdl/core/cli/core.py @@ -0,0 +1,127 @@ +# Copyright (C) 2016 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Minimal core functionality for CLI operations. +This module avoids importing heavy dependencies like pandas, numpy, etc. +""" + +from typing import Any, Dict, List + +import psutil + + +def is_valid_ansys_process_name(name: str) -> bool: + """Check if process name indicates ANSYS/MAPDL""" + return ("ansys" in name.lower()) or ("mapdl" in name.lower()) + + +def is_alive_status(status) -> bool: + """Check if process status indicates alive""" + return status in [ + psutil.STATUS_RUNNING, + psutil.STATUS_IDLE, + psutil.STATUS_SLEEPING, + ] + + +def get_mapdl_instances() -> List[Dict[str, Any]]: + """Get list of MAPDL instances with minimal data""" + instances = [] + + for proc in psutil.process_iter(attrs=["name"]): + name = proc.info["name"] + if not is_valid_ansys_process_name(name): + continue + + try: + status = proc.status() + if not is_alive_status(status): + continue + + cmdline = proc.cmdline() + if "-grpc" not in cmdline: + continue + + # Get port from cmdline + port = None + try: + ind_grpc = cmdline.index("-port") + port = int(cmdline[ind_grpc + 1]) + except (ValueError, IndexError): + continue + + children = proc.children(recursive=True) + is_instance = len(children) >= 2 + + cwd = proc.cwd() + instances.append( + { + "name": name, + "status": status, + "port": port, + "pid": proc.pid, + "cmdline": cmdline, + "is_instance": is_instance, + "cwd": cwd, + } + ) + + except (psutil.NoSuchProcess, psutil.ZombieProcess, psutil.AccessDenied): + continue + + return instances + + +def get_ansys_process_from_port(port: int): + import socket + + import psutil + + from ansys.mapdl.core.cli.core import is_alive_status, is_valid_ansys_process_name + + # Filter by name first + potential_procs = [] + for proc in psutil.process_iter(attrs=["name"]): + name = proc.info["name"] + if is_valid_ansys_process_name(name): + potential_procs.append(proc) + + for proc in potential_procs: + try: + status = proc.status() + if not is_alive_status(status): + continue + cmdline = proc.cmdline() + if "-grpc" not in cmdline: + continue + # Check if listening on the port + connections = proc.connections() + for conn in connections: + if ( + conn.status == "LISTEN" + and conn.family == socket.AF_INET + and conn.laddr[1] == port + ): + return proc + except (psutil.NoSuchProcess, psutil.ZombieProcess, psutil.AccessDenied): + continue diff --git a/src/ansys/mapdl/core/cli/list_instances.py b/src/ansys/mapdl/core/cli/list_instances.py index 7ad94d1bf19..515d00ce458 100644 --- a/src/ansys/mapdl/core/cli/list_instances.py +++ b/src/ansys/mapdl/core/cli/list_instances.py @@ -66,46 +66,16 @@ help="Print running location info.", ) def list_instances(instances, long, cmd, location) -> None: - import psutil + return _list_instances(instances, long, cmd, location) + + +def _list_instances(instances, long, cmd, location): from tabulate import tabulate + from ansys.mapdl.core.cli.core import get_mapdl_instances + # Assuming all ansys processes have -grpc flag - mapdl_instances = [] - - def is_grpc_based(proc): - cmdline = proc.cmdline() - return "-grpc" in cmdline - - def get_port(proc): - cmdline = proc.cmdline() - ind_grpc = cmdline.index("-port") - return cmdline[ind_grpc + 1] - - def is_valid_process(proc): - valid_status = proc.status() in [ - psutil.STATUS_RUNNING, - psutil.STATUS_IDLE, - psutil.STATUS_SLEEPING, - ] - valid_ansys_process = ("ansys" in proc.name().lower()) or ( - "mapdl" in proc.name().lower() - ) - return valid_status and valid_ansys_process and is_grpc_based(proc) - - for proc in psutil.process_iter(): - # Check if the process is running and not suspended - try: - if is_valid_process(proc): - # Checking the number of children we infer if the process is the main process, - # or one of the main process thread. - if len(proc.children(recursive=True)) < 2: - proc.ansys_instance = False - else: - proc.ansys_instance = True - mapdl_instances.append(proc) - - except (psutil.NoSuchProcess, psutil.ZombieProcess) as e: - continue + mapdl_instances = get_mapdl_instances() # printing if long: @@ -124,23 +94,23 @@ def is_valid_process(proc): table = [] for each_p in mapdl_instances: - if instances and not each_p.ansys_instance: + if instances and not each_p.get("is_instance", False): # Skip child processes if only printing instances continue proc_line = [] - proc_line.append(each_p.name()) + proc_line.append(each_p["name"]) if not instances: - proc_line.append(each_p.ansys_instance) + proc_line.append(each_p.get("is_instance", False)) - proc_line.extend([each_p.status(), get_port(each_p), each_p.pid]) + proc_line.extend([each_p["status"], each_p["port"], each_p["pid"]]) if cmd: - proc_line.append(" ".join(each_p.cmdline())) + proc_line.append(" ".join(each_p["cmdline"])) if location: - proc_line.append(each_p.cwd()) + proc_line.append(each_p["cwd"]) table.append(proc_line) diff --git a/tests/test_cli.py b/tests/test_cli.py index ed7780d9351..2cc13997b32 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -31,6 +31,8 @@ import psutil import pytest +import ansys.mapdl.core.cli.core as core_module +from ansys.mapdl.core.cli.core import get_ansys_process_from_port from ansys.mapdl.core.plotting import GraphicsBackend from conftest import VALID_PORTS, requires @@ -46,6 +48,7 @@ def make_fake_process(pid, name, port=PORT1, ansys_process=False, n_children=0): mock_process = MagicMock(spec=psutil.Process) mock_process.pid = pid mock_process.name.return_value = name + mock_process.info = {"name": name} # For attrs=['name'] optimization mock_process.status.return_value = psutil.STATUS_RUNNING mock_process.children.side_effect = lambda *arg, **kwargs: [ i for i in range(n_children) @@ -210,6 +213,7 @@ def make_other_user_process(pid, name, ansys_process=True): mock_process = MagicMock(spec=psutil.Process) mock_process.pid = pid mock_process.name.return_value = name + mock_process.info = {"name": name} # For attrs=['name'] optimization mock_process.status.return_value = psutil.STATUS_RUNNING if ansys_process: @@ -226,6 +230,7 @@ def make_inaccessible_process(pid: int, name: str): mock_process = MagicMock(spec=psutil.Process) mock_process.pid = pid mock_process.name.return_value = name + mock_process.info = {"name": name} # For attrs=['name'] optimization # Simulate the original issue: AccessDenied when accessing process info mock_process.cmdline.side_effect = psutil.AccessDenied(pid, name) @@ -301,6 +306,7 @@ def test_pymapdl_stop_with_username_containing_domain(run_cli): mock_process = MagicMock(spec=psutil.Process) mock_process.pid = 12 mock_process.name.return_value = "ansys252" + mock_process.info = {"name": "ansys252"} # For attrs=['name'] optimization mock_process.status.return_value = psutil.STATUS_RUNNING mock_process.cmdline.return_value = ["ansys251", "-grpc", "-port", "50052"] mock_process.username.return_value = f"DOMAIN\\{current_user}" @@ -592,3 +598,258 @@ def test_convert_passing(mock_conv, run_cli, tmpdir, arg, value): assert kwargs[key] == GraphicsBackend[value.upper()] else: assert kwargs[key] == default_[key] + + +def make_mock_process_for_port_test( + pid, + name, + status=psutil.STATUS_RUNNING, + cmdline=None, + connections=None, + raise_exception=None, +): + """Helper to create mock process for get_ansys_process_from_port tests.""" + mock_proc = MagicMock(spec=psutil.Process) + mock_proc.pid = pid + mock_proc.info = {"name": name} + + class NoSuchProcess(psutil.NoSuchProcess): + def __init__(self): + super().__init__(pid) + + class ZombieProcess(psutil.ZombieProcess): + def __init__(self): + super().__init__(pid) + + if raise_exception == "status": + mock_proc.status.side_effect = NoSuchProcess + else: + mock_proc.status.return_value = status + if raise_exception == "cmdline": + mock_proc.cmdline.side_effect = psutil.AccessDenied + else: + mock_proc.cmdline.return_value = cmdline or [] + if raise_exception == "connections": + mock_proc.connections.side_effect = ZombieProcess + else: + mock_proc.connections.return_value = connections or [] + return mock_proc + + +def test_get_ansys_process_from_port_no_processes(): + """Test get_ansys_process_from_port with no processes.""" + with patch("psutil.process_iter", return_value=[]): + result = get_ansys_process_from_port(50052) + assert result is None + + +def test_get_ansys_process_from_port_not_ansys(): + """Test get_ansys_process_from_port with non-ANSYS process.""" + mock_proc = make_mock_process_for_port_test(1, "python") + with ( + patch("psutil.process_iter", return_value=[mock_proc]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=False), + patch.object(core_module, "is_alive_status", return_value=True), + ): + result = get_ansys_process_from_port(50052) + assert result is None + + +def test_get_ansys_process_from_port_not_alive(): + """Test get_ansys_process_from_port with ANSYS process not alive.""" + mock_proc = make_mock_process_for_port_test(1, "ansys", status=psutil.STATUS_DEAD) + with ( + patch("psutil.process_iter", return_value=[mock_proc]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=True), + patch.object(core_module, "is_alive_status", return_value=False), + ): + result = get_ansys_process_from_port(50052) + assert result is None + + +def test_get_ansys_process_from_port_no_grpc(): + """Test get_ansys_process_from_port with ANSYS process without -grpc.""" + mock_proc = make_mock_process_for_port_test( + 1, "ansys", cmdline=["ansys", "-port", "50052"] + ) + with ( + patch("psutil.process_iter", return_value=[mock_proc]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=True), + patch.object(core_module, "is_alive_status", return_value=True), + ): + result = get_ansys_process_from_port(50052) + assert result is None + + +def test_get_ansys_process_from_port_not_listening(): + """Test get_ansys_process_from_port with ANSYS process not listening on port.""" + import socket + + mock_conn = MagicMock() + mock_conn.status = "LISTEN" + mock_conn.family = socket.AF_INET + mock_conn.laddr = ("127.0.0.1", 50053) # wrong port + mock_proc = make_mock_process_for_port_test( + 1, + "ansys", + cmdline=["ansys", "-grpc", "-port", "50052"], + connections=[mock_conn], + ) + with ( + patch("psutil.process_iter", return_value=[mock_proc]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=True), + patch.object(core_module, "is_alive_status", return_value=True), + ): + result = get_ansys_process_from_port(50052) + assert result is None + + +def test_get_ansys_process_from_port_not_listen_status(): + """Test get_ansys_process_from_port with connection not LISTEN.""" + import socket + + mock_conn = MagicMock() + mock_conn.status = "ESTABLISHED" + mock_conn.family = socket.AF_INET + mock_conn.laddr = ("127.0.0.1", 50052) + mock_proc = make_mock_process_for_port_test( + 1, + "ansys", + cmdline=["ansys", "-grpc", "-port", "50052"], + connections=[mock_conn], + ) + with ( + patch("psutil.process_iter", return_value=[mock_proc]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=True), + patch.object(core_module, "is_alive_status", return_value=True), + ): + result = get_ansys_process_from_port(50052) + assert result is None + + +def test_get_ansys_process_from_port_wrong_family(): + """Test get_ansys_process_from_port with wrong family.""" + import socket + + mock_conn = MagicMock() + mock_conn.status = "LISTEN" + mock_conn.family = socket.AF_INET6 # wrong family + mock_conn.laddr = ("127.0.0.1", 50052) + mock_proc = make_mock_process_for_port_test( + 1, + "ansys", + cmdline=["ansys", "-grpc", "-port", "50052"], + connections=[mock_conn], + ) + with ( + patch("psutil.process_iter", return_value=[mock_proc]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=True), + patch.object(core_module, "is_alive_status", return_value=True), + ): + result = get_ansys_process_from_port(50052) + assert result is None + + +def test_get_ansys_process_from_port_success(): + """Test get_ansys_process_from_port finds the process.""" + import socket + + mock_conn = MagicMock() + mock_conn.status = "LISTEN" + mock_conn.family = socket.AF_INET + mock_conn.laddr = ("127.0.0.1", 50052) + mock_proc = make_mock_process_for_port_test( + 1, + "ansys", + cmdline=["ansys", "-grpc", "-port", "50052"], + connections=[mock_conn], + ) + with ( + patch("psutil.process_iter", return_value=[mock_proc]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=True), + patch.object(core_module, "is_alive_status", return_value=True), + ): + result = get_ansys_process_from_port(50052) + assert result == mock_proc + + +def test_get_ansys_process_from_port_exception_status(): + """Test get_ansys_process_from_port handles exception in status.""" + mock_proc = make_mock_process_for_port_test(1, "ansys") + + class NoSuchProcess(psutil.NoSuchProcess): + def __init__(self): + super().__init__(1) + + with ( + patch("psutil.process_iter", return_value=[mock_proc]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=True), + patch.object(core_module, "is_alive_status", side_effect=NoSuchProcess), + ): + result = get_ansys_process_from_port(50052) + assert result is None + + +def test_get_ansys_process_from_port_exception_cmdline(): + """Test get_ansys_process_from_port handles exception in cmdline.""" + mock_proc = make_mock_process_for_port_test(1, "ansys", raise_exception="cmdline") + with ( + patch("psutil.process_iter", return_value=[mock_proc]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=True), + patch.object(core_module, "is_alive_status", return_value=True), + ): + # with pytest.raises(psutil.AccessDenied): + result = get_ansys_process_from_port(50052) + assert result is None + + +def test_get_ansys_process_from_port_exception_connections(): + """Test get_ansys_process_from_port handles exception in connections.""" + mock_proc = make_mock_process_for_port_test( + 1, + "ansys", + cmdline=["ansys", "-grpc", "-port", "50052"], + raise_exception="connections", + ) + with ( + patch("psutil.process_iter", return_value=[mock_proc]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=True), + patch.object(core_module, "is_alive_status", return_value=True), + ): + result = get_ansys_process_from_port(50052) + assert result is None + + +def test_get_ansys_process_from_port_multiple_processes(): + """Test get_ansys_process_from_port with multiple processes, returns first match.""" + import socket + + mock_conn1 = MagicMock() + mock_conn1.status = "LISTEN" + mock_conn1.family = socket.AF_INET + mock_conn1.laddr = ("127.0.0.1", 50052) + mock_proc1 = make_mock_process_for_port_test( + 1, + "ansys", + cmdline=["ansys", "-grpc", "-port", "50052"], + connections=[mock_conn1], + ) + + mock_conn2 = MagicMock() + mock_conn2.status = "LISTEN" + mock_conn2.family = socket.AF_INET + mock_conn2.laddr = ("127.0.0.1", 50053) + mock_proc2 = make_mock_process_for_port_test( + 2, + "ansys", + cmdline=["ansys", "-grpc", "-port", "50053"], + connections=[mock_conn2], + ) + + with ( + patch("psutil.process_iter", return_value=[mock_proc1, mock_proc2]), + patch.object(core_module, "is_valid_ansys_process_name", return_value=True), + patch.object(core_module, "is_alive_status", return_value=True), + ): + result = get_ansys_process_from_port(50052) + assert result == mock_proc1