diff --git a/doc/changelog.d/151.maintenance.md b/doc/changelog.d/151.maintenance.md new file mode 100644 index 00000000..b5e6a856 --- /dev/null +++ b/doc/changelog.d/151.maintenance.md @@ -0,0 +1 @@ +Visor-cli updates for logs and init actions diff --git a/src/ansys/visor/viewer/__init__.py b/src/ansys/visor/viewer/__init__.py index ce5eca7e..9a0c6096 100644 --- a/src/ansys/visor/viewer/__init__.py +++ b/src/ansys/visor/viewer/__init__.py @@ -1,11 +1,19 @@ # Copyright 2026 ANSYS, Inc. All Rights Reserved. # Restricted Rights Legend: See LICENSE for details. -from ansys.visor.viewer.app.visor import Visor from ansys.visor.viewer.core.metadata import Metadata __all__ = ['Visor', 'Metadata'] + +def __getattr__(name): + # Import Visor on first use so that lightweight subpackages (e.g. the CLI) + # don't load the app and its module-level loggers, which open log files. + if name == "Visor": + from ansys.visor.viewer.app.visor import Visor + return Visor + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + # Version # ------------------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/cli/apis.py b/src/ansys/visor/viewer/cli/apis.py index 029f1a99..690885c8 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -2,6 +2,7 @@ import json import os +import shutil import time import requests @@ -59,7 +60,7 @@ def info(self): resp = requests.get(f"{self.base}/info") print(resp.json()) - def initialize(self, host, port, rendering_mode, standalone, dark_mode): + def initialize(self, host, port, rendering_mode, standalone, dark_mode, start=False): """Initialize the server with viewer configuration. Parameters @@ -69,17 +70,33 @@ def initialize(self, host, port, rendering_mode, standalone, dark_mode): port : int Port the viewer client should connect to. Pass ``0`` to let the Visor server pick an unused port on its own host. - standalone : RenderingMode + rendering_mode : RenderingMode Rendering mode to use for the viewer instance. Must be one of the values defined in ``RenderingMode``. standalone : bool Whether to run in standalone mode (no external orchestrator). dark_mode : bool Whether to enable dark mode in the viewer UI. + start : bool + Whether to start the viewer instance immediately after initialization. + + Raises + ------ + requests.HTTPError + If the ``/initialize`` request or, when ``start`` is ``True``, + the ``/start`` request returns an HTTP error status. """ - data = {"host": host, "port": port, "rendering_mode": rendering_mode, "standalone": standalone, "dark_mode": dark_mode} + data = {"host": host, "port": port, "rendering_mode": rendering_mode, "standalone": standalone, + "dark_mode": dark_mode} resp = requests.post(f"{self.base}/initialize", json=data) print(resp.json()) + resp.raise_for_status() + if start: + resp = requests.post(f"{self.base}/start", json={}) + print(resp) + print(resp.json()) + resp.raise_for_status() + def list(self): """Print the URLs of all currently available viewer instances.""" @@ -229,25 +246,25 @@ def list_logs(self): return files = [f for f in os.listdir(self.log_dir) if f.endswith(".log")] if not files: - print("No log files found.") + print(f"No log files found in log dir {self.log_dir}.") else: - print("Available log files:") + print(f"Available log files in log dir {self.log_dir}:") for f in files: print(" " + f[:-4]) # strip .log - def show_log(self, log_name, follow=False, lines=10): + def tail_log(self, log_name, follow=False, lines=10): """Display the contents of a log file. Parameters ---------- log_name : str - Name of the log file to display, without the ``.log`` extension. + Name of the log file to tail, without the ``.log`` extension. follow : bool, optional When ``True``, print the last *lines* lines and then stream new content as it is appended (like ``tail -f``). Defaults to ``False``. lines : int, optional - Number of lines from the end of the file to display. Defaults + Number of lines from the end of the file to tail. Defaults to ``10``. """ log_path = os.path.join(self.log_dir, f"{log_name}.log") @@ -272,3 +289,40 @@ def show_log(self, log_name, follow=False, lines=10): print("".join(deque(f, maxlen=lines)), end="") except FileNotFoundError: print(f"Log file not found: {log_path}") + + def clear_logs(self, force=False): + """Delete the log directory. + + Parameters + ---------- + force : bool, optional + When ``True``, skip the confirmation prompt. Defaults to ``False``. + + Returns + ------- + bool + ``False`` if removing the log directory failed, ``True`` otherwise + (including when the directory does not exist or the user aborts). + + Notes + ----- + Nothing should be writing to the log directory (e.g. a running Visor + server); otherwise deletion may fail on Windows or remove files that + are still in use on Unix. + """ + if not os.path.isdir(self.log_dir): + print(f"Log directory not found: {self.log_dir}") + return True + if not force: + print(f"Remove log directory {self.log_dir}? (y/n): ", end="") + choice = input().strip().lower() + if choice != "y": + print("Aborted.") + return True + try: + shutil.rmtree(self.log_dir) + except Exception as e: + print(f"Failed to remove log directory {self.log_dir}: {e}") + return False + print(f"Log directory removed: {self.log_dir}") + return True diff --git a/src/ansys/visor/viewer/cli/parse_args.py b/src/ansys/visor/viewer/cli/parse_args.py index e5151a6a..19b016b9 100644 --- a/src/ansys/visor/viewer/cli/parse_args.py +++ b/src/ansys/visor/viewer/cli/parse_args.py @@ -65,6 +65,12 @@ def parse_args(): default=None, help="Dark mode (True or False, default: from settings or server default)" ) + init_parser.add_argument( + "--start", + default=False, + action="store_true", + help="Start the instance after initialization (default: False)" + ) # list API server_sub.add_parser("list", help="List Visor instances") @@ -118,18 +124,34 @@ def parse_args(): # Logs subcommands ###################### logs_parser = subparsers.add_parser("logs", help="Log file operations") - logs_parser.add_argument("log_name", nargs="?", help="Name of the log file (without .log)") - logs_parser.add_argument("-f", "--follow", - action="store_true", - help="Follow the log file (like tail -f)" - ) logs_parser.add_argument("--log-dir", default=None, help="Directory containing log files (default: from settings)" ) - logs_parser.add_argument( + logs_sub = logs_parser.add_subparsers(dest="action", required=True) + + # list API + logs_sub.add_parser("list", help="List available logs") + + # show API + tail_parser = logs_sub.add_parser("tail", help="Tail the log file") + tail_parser.add_argument("log_name", + nargs="?", + help="Name of the log file (without .log)", + default="visor") + tail_parser.add_argument("-f", "--follow", + action="store_true", + help="Follow the log file (like tail -f)" + ) + tail_parser.add_argument( "-n", "--lines", type=int, default=10, help="Number of lines to show from the end of the log file (default: 10)" ) + # clear API + clear_parser = logs_sub.add_parser("clear", help="Delete the log directory and its contents") + clear_parser.add_argument("-f", "--force", + action="store_true", + help="Delete the log directory without prompting for confirmation") + return parser.parse_args() diff --git a/src/ansys/visor/viewer/cli/visor_cli.py b/src/ansys/visor/viewer/cli/visor_cli.py index 1741b55f..7ea734b2 100644 --- a/src/ansys/visor/viewer/cli/visor_cli.py +++ b/src/ansys/visor/viewer/cli/visor_cli.py @@ -1,5 +1,6 @@ """Command line interface for Visor Viewer.""" +import socket import sys import requests @@ -26,6 +27,22 @@ def check_server_running(host, port): print("Could not connect to server:", e) return False +def check_server_reachable(host, port): + """ + Check whether anything is listening on the server address, healthy or not. + + Intended for guarding destructive commands. Probes the TCP socket directly + so that post-connect failures (resets, protocol errors, no reply) still + count as reachable. Returns False only when a TCP connection cannot be + established. + """ + binding_host = settings.binding_host or host + try: + with socket.create_connection((binding_host, port), timeout=1): + return True + except OSError: + return False # refused, unreachable, DNS failure, or connect timeout + def check_init_args( api_host, api_port, @@ -33,7 +50,7 @@ def check_init_args( port, rendering_mode, standalone, - dark_mode + dark_mode, ): """ If the user explicitly passed --rendering-mode, --standalone, or --dark-mode, check whether @@ -76,19 +93,44 @@ def check_init_args( ) sys.exit(1) +def needs_server_running(args): + """ + Determine if the current command requires a running server. + Returns False if the command is 'server start', 'server health', or 'logs'. + """ + if args.group == "logs" or \ + args.group == "server" and args.action in ["start", "health"]: + return False + return True + +def needs_server_stopped(args): + """ + Determine if the current command requires a stopped server. + Returns True if the command is 'logs clear'. + """ + return args.group == "logs" and args.action == "clear" def main(): """Main CLI tool for Visor Viewer.""" args = parse_args() - # Check if the server is running before executing instance commands - # unless the command is to start the server - if not (args.group == "server" and args.action in ["start", "health"]): + # Most commands talk to the server, so make sure it is up first + if needs_server_running(args): if not check_server_running(args.api_host, args.api_port): print("Server is not running. Please start the server first.") sys.exit(1) + # Clearing logs while the server is writing to them is unsafe + if needs_server_stopped(args): + print(f"Checking if the server is running on {args.api_host}:{args.api_port}...") + if check_server_reachable(args.api_host, args.api_port): + print( + f"Server is running on {args.api_host}:{args.api_port}. " + "Please stop the server before clearing logs." + ) + sys.exit(1) + # Execute the appropriate API action based on the group and action if args.group == "server": api = ServerAPI(args.api_host, args.api_port) @@ -106,9 +148,9 @@ def main(): args.port, args.rendering_mode, args.standalone, - args.dark_mode + args.dark_mode, ) - api.initialize(args.host, args.port, args.rendering_mode, args.standalone, args.dark_mode) + api.initialize(args.host, args.port, args.rendering_mode, args.standalone, args.dark_mode, args.start) elif args.action == "list": api.list() elif args.group == "instance": @@ -134,10 +176,13 @@ def main(): if args.group == "logs": api = LogsAPI(log_dir=args.log_dir) - if not args.log_name: + if args.action == "list": api.list_logs() - else: - api.show_log(args.log_name, args.follow, args.lines) + elif args.action == "tail": + api.tail_log(args.log_name, args.follow, args.lines) + elif args.action == "clear": + if not api.clear_logs(args.force): + sys.exit(1) return diff --git a/tests/unit/cli/test_apis.py b/tests/unit/cli/test_apis.py index 47e756d6..5aa569dd 100644 --- a/tests/unit/cli/test_apis.py +++ b/tests/unit/cli/test_apis.py @@ -1,5 +1,10 @@ import json -from unittest.mock import mock_open, patch +import subprocess +import sys +from unittest.mock import MagicMock, mock_open, patch + +import pytest +import requests from ansys.visor.viewer.cli.apis import InstanceAPI, LogsAPI, ServerAPI from ansys.visor.viewer.core.visor_enums import RenderingMode @@ -41,10 +46,37 @@ def test_serverapi_initialize_prints_response(): api = ServerAPI("host", 1234) with patch("requests.post") as mock_post, patch("builtins.print") as mock_print: mock_post.return_value.json.return_value = {"result": "ok"} - api.initialize("h", 1, RenderingMode.LOCAL, True, dark_mode=False) + api.initialize("h", 1, RenderingMode.LOCAL, True, dark_mode=False, start=False) mock_post.assert_called_once() mock_print.assert_called_with({"result": "ok"}) +def test_serverapi_initialize_with_start_also_starts_instance(): + """Verify that passing start=True also posts to the /start endpoint.""" + api = ServerAPI("host", 1234) + with patch("requests.post") as mock_post, patch("builtins.print"): + mock_post.return_value.json.return_value = {"result": "ok"} + api.initialize("h", 1, RenderingMode.LOCAL, True, dark_mode=False, start=True) + assert mock_post.call_count == 2 + mock_post.assert_any_call(f"{api.base}/initialize", json={ + "host": "h", "port": 1, "rendering_mode": RenderingMode.LOCAL, + "standalone": True, "dark_mode": False, + }) + mock_post.assert_any_call(f"{api.base}/start", json={}) + +def test_serverapi_initialize_with_start_raises_on_start_failure(): + """Verify that an HTTP error from /start is propagated.""" + api = ServerAPI("host", 1234) + init_resp = MagicMock() + init_resp.json.return_value = {"result": "ok"} + start_resp = MagicMock() + start_resp.json.return_value = {"detail": "failed"} + start_resp.raise_for_status.side_effect = requests.HTTPError("500") + with patch("requests.post", side_effect=[init_resp, start_resp]), patch("builtins.print"): + with pytest.raises(requests.HTTPError): + api.initialize("h", 1, RenderingMode.LOCAL, True, dark_mode=False, start=True) + init_resp.raise_for_status.assert_called_once() + start_resp.raise_for_status.assert_called_once() + def test_serverapi_list_prints_urls(): """Verify that available instance URLs are printed.""" api = ServerAPI("host", 1234) @@ -137,7 +169,7 @@ def test_logsapi_list_logs_prints_files(tmp_path): api = LogsAPI(str(log_dir)) with patch("builtins.print") as mock_print: api.list_logs() - mock_print.assert_any_call("Available log files:") + mock_print.assert_any_call(f"Available log files in log dir {log_dir}:") mock_print.assert_any_call(" foo") mock_print.assert_any_call(" bar") @@ -146,7 +178,7 @@ def test_logsapi_list_logs_prints_no_files(tmp_path): api = LogsAPI(str(tmp_path)) with patch("builtins.print") as mock_print: api.list_logs() - mock_print.assert_any_call("No log files found.") + mock_print.assert_any_call(f"No log files found in log dir {tmp_path}.") def test_logsapi_list_logs_prints_dir_not_found(): """Verify that a missing log directory is reported.""" @@ -155,25 +187,25 @@ def test_logsapi_list_logs_prints_dir_not_found(): api.list_logs() mock_print.assert_any_call("Log directory not found: not_a_dir") -def test_logsapi_show_log_prints_last_lines(): +def test_logsapi_tail_log_prints_last_lines(): """Verify that the requested tail of the log file is printed.""" api = LogsAPI() log_content = "line1\nline2\nline3\n" m = mock_open(read_data=log_content) with patch("builtins.open", m), patch("builtins.print") as mock_print, patch("os.path.join", return_value="file.log"): - api.show_log("file", follow=False, lines=2) + api.tail_log("file", follow=False, lines=2) # Should print last 2 lines printed = "".join([call.args[0] for call in mock_print.call_args_list]) assert "line2" in printed and "line3" in printed -def test_logsapi_show_log_file_not_found(): +def test_logsapi_tail_log_file_not_found(): """Verify that a missing log file is reported.""" api = LogsAPI() with patch("builtins.open", side_effect=FileNotFoundError), patch("builtins.print") as mock_print, patch("os.path.join", return_value="file.log"): - api.show_log("file") + api.tail_log("file") mock_print.assert_any_call("Log file not found: file.log") -def test_logsapi_show_log_follow_prints_and_waits(monkeypatch): +def test_logsapi_tail_log_follow_prints_and_waits(monkeypatch): """Verify that follow mode continues monitoring the log file.""" api = LogsAPI() log_content = "line1\nline2\nline3\n" @@ -196,8 +228,83 @@ def sleep_side_effect(_): with patch("builtins.print") as mock_print, patch("time.sleep", side_effect=sleep_side_effect): try: - api.show_log("file", follow=True, lines=2) + api.tail_log("file", follow=True, lines=2) except SystemExit: pass printed = "".join([call.args[0] for call in mock_print.call_args_list]) assert "line2" in printed or "line3" in printed + +def test_logsapi_clear_logs_prints_dir_not_found(): + """Verify that a missing log directory is reported when clearing.""" + api = LogsAPI("not_a_dir") + with patch("builtins.print") as mock_print: + api.clear_logs() + mock_print.assert_any_call("Log directory not found: not_a_dir") + +def test_logsapi_clear_logs_aborted_on_no(tmp_path): + """Verify that clearing is aborted when the user does not confirm.""" + api = LogsAPI(str(tmp_path)) + with patch("builtins.input", return_value="n"), \ + patch("builtins.print") as mock_print, \ + patch("shutil.rmtree") as mock_rmtree: + api.clear_logs() + mock_print.assert_any_call("Aborted.") + mock_rmtree.assert_not_called() + +def test_logsapi_clear_logs_removes_dir_on_confirm(tmp_path): + """Verify that the log directory is removed when the user confirms.""" + api = LogsAPI(str(tmp_path)) + with patch("builtins.input", return_value="y"), \ + patch("builtins.print") as mock_print, \ + patch("shutil.rmtree") as mock_rmtree: + api.clear_logs() + mock_rmtree.assert_called_once_with(str(tmp_path)) + mock_print.assert_any_call("Log directory removed: " + str(tmp_path)) + +def test_logsapi_clear_logs_reports_failure(tmp_path): + """Verify that failures during removal are reported.""" + api = LogsAPI(str(tmp_path)) + with patch("builtins.input", return_value="y"), \ + patch("builtins.print") as mock_print, \ + patch("shutil.rmtree", side_effect=OSError("boom")): + assert api.clear_logs() is False + mock_print.assert_any_call(f"Failed to remove log directory {tmp_path}: boom") + +def test_logsapi_clear_logs_force_reports_failure(tmp_path): + """Verify that a forced removal failure returns False.""" + api = LogsAPI(str(tmp_path)) + with patch("builtins.print") as mock_print, \ + patch("shutil.rmtree", side_effect=OSError("boom")): + assert api.clear_logs(force=True) is False + mock_print.assert_any_call(f"Failed to remove log directory {tmp_path}: boom") + +def test_logsapi_clear_logs_force_skips_confirmation(tmp_path): + """Verify that force=True removes the log directory without prompting.""" + api = LogsAPI(str(tmp_path)) + with patch("builtins.input") as mock_input, \ + patch("builtins.print") as mock_print, \ + patch("shutil.rmtree") as mock_rmtree: + api.clear_logs(force=True) + mock_input.assert_not_called() + mock_rmtree.assert_called_once_with(str(tmp_path)) + mock_print.assert_any_call("Log directory removed: " + str(tmp_path)) + +def test_cli_import_does_not_open_log_files(tmp_path): + """Verify that importing the CLI does not load app loggers or open log files. + + Runs in a fresh interpreter because other tests may already have imported + the application modules into this process. + """ + code = ( + "import logging, sys\n" + "import ansys.visor.viewer.cli.visor_cli\n" + "assert 'ansys.visor.viewer.core.visor_logging' not in sys.modules, 'visor_logging imported'\n" + "assert 'ansys.visor.viewer.app.visor' not in sys.modules, 'app imported'\n" + "files = [r() for r in logging._handlerList if isinstance(r(), logging.FileHandler)]\n" + "assert not files, files\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], cwd=tmp_path, capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + assert not (tmp_path / "logs").exists() diff --git a/tests/unit/cli/test_parse_args.py b/tests/unit/cli/test_parse_args.py index f4ba97ed..6309f436 100644 --- a/tests/unit/cli/test_parse_args.py +++ b/tests/unit/cli/test_parse_args.py @@ -41,16 +41,18 @@ def test_server_init_args_defaults(): assert args.standalone is None assert args.port == 0 assert args.dark_mode is None + assert args.start is False def test_server_init_args_custom(): """Verify that custom server init arguments are parsed correctly.""" args = run_parse_args([ "visor-cli", "server", "init", - "--host", "myhost", "--port", "12345", "--standalone", "False" + "--host", "myhost", "--port", "12345", "--standalone", "False", "--start" ]) assert args.host == "myhost" assert args.port == 12345 assert args.standalone is False + assert args.start is True def test_server_list_args(): """Verify that server list arguments are parsed correctly.""" @@ -119,24 +121,53 @@ def test_instance_stop_args(): assert args.action == "stop" def test_logs_list_args_defaults(): - """Verify that logs arguments use the expected default values.""" - args = run_parse_args(["visor-cli", "logs"]) + """Verify that log list arguments use the expected default values.""" + args = run_parse_args(["visor-cli", "logs", "list"]) assert args.group == "logs" - assert args.log_name is None + assert args.action == "list" + assert args.log_dir is None + +def test_logs_tail_args_defaults(): + """Verify that log tail uses the expected default argument values.""" + args = run_parse_args(["visor-cli", "logs", "tail"]) + assert args.group == "logs" + assert args.action == "tail" + assert args.log_name == "visor" assert args.follow is False assert args.log_dir is None assert args.lines == 10 -def test_logs_show_args_custom(): - """Verify that custom log display arguments are parsed correctly.""" +def test_logs_tail_args_custom(): + """Verify that custom log tail arguments are parsed correctly.""" args = run_parse_args([ - "visor-cli", "logs", "mylog", "-f", "--log-dir", "/tmp", "-n", "5" + "visor-cli", "logs", "--log-dir", "/tmp", "tail", "mylog", "-f", "-n", "5" ]) assert args.log_name == "mylog" assert args.follow is True assert args.log_dir == "/tmp" assert args.lines == 5 +def test_logs_clear_args(): + """Verify that the log clear subcommand is parsed correctly.""" + args = run_parse_args(["visor-cli", "logs", "clear"]) + assert args.group == "logs" + assert args.action == "clear" + assert args.force is False + +def test_logs_clear_args_force_short_flag(): + """Verify that the -f flag sets force=True for log clear.""" + args = run_parse_args(["visor-cli", "logs", "clear", "-f"]) + assert args.group == "logs" + assert args.action == "clear" + assert args.force is True + +def test_logs_clear_args_force_long_flag(): + """Verify that the --force flag sets force=True for log clear.""" + args = run_parse_args(["visor-cli", "logs", "clear", "--force"]) + assert args.group == "logs" + assert args.action == "clear" + assert args.force is True + def test_missing_group_raises(): """Verify that omitting the command group raises SystemExit.""" with patch.object(sys, "argv", ["visor-cli"]): diff --git a/tests/unit/cli/test_visor_cli.py b/tests/unit/cli/test_visor_cli.py index a304efb0..9f3caaae 100644 --- a/tests/unit/cli/test_visor_cli.py +++ b/tests/unit/cli/test_visor_cli.py @@ -1,3 +1,4 @@ +import socket from unittest.mock import MagicMock, patch import pytest @@ -94,13 +95,31 @@ def test_main_server_info_list(mock_parse_args, mock_server_api): def test_main_server_init(mock_parse_args, mock_server_api): """Verify that server initialization forwards the expected arguments.""" - args = make_args("server", "init", host="h", port=42, rendering_mode=RenderingMode.LOCAL, standalone=True, dark_mode=False) + args = make_args( + "server", "init", + host="h", port=42, rendering_mode=RenderingMode.LOCAL, + standalone=True, dark_mode=False, start=False, + ) mock_parse_args.return_value = args api = MagicMock() mock_server_api.return_value = api with patch("ansys.visor.viewer.cli.visor_cli.check_server_running", return_value=True): visor_cli.main() - api.initialize.assert_called_once_with("h", 42, RenderingMode.LOCAL, True, False) + api.initialize.assert_called_once_with("h", 42, RenderingMode.LOCAL, True, False, False) + +def test_main_server_init_with_start(mock_parse_args, mock_server_api): + """Verify that server initialization forwards the --start flag.""" + args = make_args( + "server", "init", + host="h", port=42, rendering_mode=RenderingMode.LOCAL, + standalone=True, dark_mode=False, start=True, + ) + mock_parse_args.return_value = args + api = MagicMock() + mock_server_api.return_value = api + with patch("ansys.visor.viewer.cli.visor_cli.check_server_running", return_value=True): + visor_cli.main() + api.initialize.assert_called_once_with("h", 42, RenderingMode.LOCAL, True, False, True) def test_main_instance_actions(mock_parse_args, mock_instance_api): """Verify that instance actions invoke the corresponding API methods.""" @@ -126,7 +145,7 @@ def test_main_instance_actions(mock_parse_args, mock_instance_api): def test_main_logs_list(mock_parse_args, mock_logs_api): """Verify that log listing invokes the list_logs method.""" - args = make_args("logs", None, log_name=None, follow=False, log_dir=None, lines=10) + args = make_args("logs", "list", log_name=None, follow=False, log_dir=None, lines=10) mock_parse_args.return_value = args api = MagicMock() mock_logs_api.return_value = api @@ -134,15 +153,122 @@ def test_main_logs_list(mock_parse_args, mock_logs_api): visor_cli.main() api.list_logs.assert_called_once() -def test_main_logs_show(mock_parse_args, mock_logs_api): - """Verify that log display invokes show_log with the requested options.""" - args = make_args("logs", None, log_name="mylog", follow=True, log_dir="dir", lines=5) +def test_main_logs_tail(mock_parse_args, mock_logs_api): + """Verify that log tail invokes tail_log with the requested options.""" + args = make_args("logs", "tail", log_name="mylog", follow=True, log_dir="dir", lines=5) mock_parse_args.return_value = args api = MagicMock() mock_logs_api.return_value = api with patch("ansys.visor.viewer.cli.visor_cli.check_server_running", return_value=True): visor_cli.main() - api.show_log.assert_called_once_with("mylog", True, 5) + api.tail_log.assert_called_once_with("mylog", True, 5) + +def test_main_logs_clear(mock_parse_args, mock_logs_api): + """Verify that log clear invokes the clear_logs method.""" + args = make_args("logs", "clear", log_dir=None, force=False) + mock_parse_args.return_value = args + api = MagicMock() + mock_logs_api.return_value = api + with patch("ansys.visor.viewer.cli.visor_cli.check_server_reachable", return_value=False): + visor_cli.main() + api.clear_logs.assert_called_once_with(False) + +def test_main_logs_clear_force(mock_parse_args, mock_logs_api): + """Verify that log clear -f forwards force=True to clear_logs.""" + args = make_args("logs", "clear", log_dir=None, force=True) + mock_parse_args.return_value = args + api = MagicMock() + mock_logs_api.return_value = api + with patch("ansys.visor.viewer.cli.visor_cli.check_server_reachable", return_value=False): + visor_cli.main() + api.clear_logs.assert_called_once_with(True) + +def test_main_logs_clear_failure_exits_nonzero(mock_parse_args, mock_logs_api): + """Verify that a failed log clear exits with a nonzero status.""" + args = make_args("logs", "clear", log_dir=None, force=True) + mock_parse_args.return_value = args + api = MagicMock() + api.clear_logs.return_value = False + mock_logs_api.return_value = api + with patch("ansys.visor.viewer.cli.visor_cli.check_server_reachable", return_value=False): + with pytest.raises(SystemExit) as exc: + visor_cli.main() + assert exc.value.code == 1 + +def test_main_logs_clear_rejected_when_server_reachable(mock_parse_args, mock_logs_api, capsys): + """Verify that log clear exits without clearing when the server is reachable.""" + args = make_args("logs", "clear", log_dir=None, force=True) + mock_parse_args.return_value = args + api = MagicMock() + mock_logs_api.return_value = api + with patch("ansys.visor.viewer.cli.visor_cli.check_server_reachable", return_value=True) as mock_check: + with pytest.raises(SystemExit) as exc: + visor_cli.main() + assert exc.value.code == 1 + mock_check.assert_called_once_with("host", 1234) + api.clear_logs.assert_not_called() + assert "Server is running" in capsys.readouterr().out + +def test_check_server_reachable_connected(): + """Verify that an established TCP connection counts as reachable.""" + with patch("ansys.visor.viewer.cli.visor_cli.socket.create_connection") as mock_conn: + assert visor_cli.check_server_reachable("host", 1234) is True + mock_conn.assert_called_once_with(("host", 1234), timeout=1) + +@pytest.mark.parametrize( + "exc", + [ + ConnectionRefusedError("refused"), + socket.timeout("connect timeout"), + socket.gaierror("dns failure"), + ], +) +def test_check_server_reachable_connect_failure(exc): + """Verify that only failing to establish a connection counts as unreachable.""" + with patch("ansys.visor.viewer.cli.visor_cli.socket.create_connection", side_effect=exc): + assert visor_cli.check_server_reachable("host", 1234) is False + +@pytest.mark.parametrize( + "group, action, expected", + [ + ("server", "start", False), + ("server", "health", False), + ("server", "info", True), + ("server", "init", True), + ("instance", "start", True), + ("logs", "list", False), + ("logs", "tail", False), + ("logs", "clear", False), + ], +) +def test_needs_server_running(group, action, expected): + """Verify which commands require a running server.""" + assert visor_cli.needs_server_running(make_args(group, action)) is expected + +@pytest.mark.parametrize( + "group, action, expected", + [ + ("logs", "clear", True), + ("logs", "list", False), + ("logs", "tail", False), + ("server", "start", False), + ("instance", "stop", False), + ], +) +def test_needs_server_stopped(group, action, expected): + """Verify that only log clear requires a stopped server.""" + assert visor_cli.needs_server_stopped(make_args(group, action)) is expected + +def test_main_logs_does_not_require_running_server(mock_parse_args, mock_logs_api): + """Verify that log commands do not require the server to be running.""" + args = make_args("logs", "list", log_name=None, follow=False, log_dir=None, lines=10) + mock_parse_args.return_value = args + api = MagicMock() + mock_logs_api.return_value = api + with patch("ansys.visor.viewer.cli.visor_cli.check_server_running") as mock_check: + visor_cli.main() + mock_check.assert_not_called() + api.list_logs.assert_called_once() def test_main_server_not_running_exits(mock_parse_args, capsys): """Verify that instance commands exit when the server is not running."""