From 4aa35bb447931419a7e1e716a5983a9198ef1be0 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 29 Sep 2026 06:58:40 -0700 Subject: [PATCH 01/16] feat/chore: visor-cli logs update --- src/ansys/visor/viewer/cli/apis.py | 25 +++++++++++++++++++-- src/ansys/visor/viewer/cli/parse_args.py | 28 ++++++++++++++++++------ src/ansys/visor/viewer/cli/visor_cli.py | 10 +++++---- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/ansys/visor/viewer/cli/apis.py b/src/ansys/visor/viewer/cli/apis.py index 029f1a99..9b1b5b82 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -1,7 +1,9 @@ """API client for controlling a Visor server.""" import json +import logging import os +import shutil import time import requests @@ -229,9 +231,9 @@ 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 @@ -272,3 +274,22 @@ 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): + """Delete the log directory.""" + if not os.path.isdir(self.log_dir): + print(f"Log directory not found: {self.log_dir}") + return + print(f"Clear log directory {self.log_dir}? (y/n): ", end="") + choice = input().strip().lower() + if choice != "y": + print("Aborted.") + return + try: + # Release this process's own file handles (e.g. visor.log opened by + # module-level loggers on import) so Windows allows deletion. + logging.shutdown() + shutil.rmtree(self.log_dir) + print("Log directory cleared.") + except Exception as e: + print(f"Failed to clear log directory: {e}") diff --git a/src/ansys/visor/viewer/cli/parse_args.py b/src/ansys/visor/viewer/cli/parse_args.py index e5151a6a..4bec6f8f 100644 --- a/src/ansys/visor/viewer/cli/parse_args.py +++ b/src/ansys/visor/viewer/cli/parse_args.py @@ -117,19 +117,33 @@ 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 = subparsers.add_parser("log", help="Log file operations") 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 + show_parser = logs_sub.add_parser("show", help="Show the log file") + show_parser.add_argument("log_name", + nargs="?", + help="Name of the log file (without .log)", + default="visor") + show_parser.add_argument("-f", "--follow", + action="store_true", + help="Follow the log file (like tail -f)" + ) + show_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 + logs_sub.add_parser("clear", help="Clear available logs") + + 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..1ab0e3c0 100644 --- a/src/ansys/visor/viewer/cli/visor_cli.py +++ b/src/ansys/visor/viewer/cli/visor_cli.py @@ -84,7 +84,7 @@ def main(): # 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"]): + if not (args.group == "server" and args.action in ["start", "health"] or args.group == "log"): if not check_server_running(args.api_host, args.api_port): print("Server is not running. Please start the server first.") sys.exit(1) @@ -132,12 +132,14 @@ def main(): elif args.action == "load": api.load(args.state_dir) - if args.group == "logs": + if args.group == "log": api = LogsAPI(log_dir=args.log_dir) - if not args.log_name: + if args.action == "list": api.list_logs() - else: + elif args.action == "show": api.show_log(args.log_name, args.follow, args.lines) + elif args.action == "clear": + api.clear_logs() return From 5792de6ee31246ef6466a9bcd0b9acf46fd2cc21 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 29 Sep 2026 07:30:29 -0700 Subject: [PATCH 02/16] feat/chore: allow --start on visor-cli server init --- src/ansys/visor/viewer/cli/apis.py | 14 +++++++++++--- src/ansys/visor/viewer/cli/parse_args.py | 6 ++++++ src/ansys/visor/viewer/cli/visor_cli.py | 6 +++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/ansys/visor/viewer/cli/apis.py b/src/ansys/visor/viewer/cli/apis.py index 9b1b5b82..14c36838 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -61,7 +61,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): """Initialize the server with viewer configuration. Parameters @@ -71,17 +71,25 @@ 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. """ - 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()) + if start: + resp = requests.post(f"{self.base}/start", json={}) + print(resp) + print(resp.json()) + def list(self): """Print the URLs of all currently available viewer instances.""" diff --git a/src/ansys/visor/viewer/cli/parse_args.py b/src/ansys/visor/viewer/cli/parse_args.py index 4bec6f8f..61a81375 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") diff --git a/src/ansys/visor/viewer/cli/visor_cli.py b/src/ansys/visor/viewer/cli/visor_cli.py index 1ab0e3c0..16d7c255 100644 --- a/src/ansys/visor/viewer/cli/visor_cli.py +++ b/src/ansys/visor/viewer/cli/visor_cli.py @@ -33,7 +33,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 @@ -106,9 +106,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": From 622198c70c6183e61b5e84a15f43b5e95593ec52 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 29 Sep 2026 11:01:46 -0700 Subject: [PATCH 03/16] feat/chore: update tests for visor-cli changes --- tests/unit/cli/test_apis.py | 59 +++++++++++++++++++++++++++++-- tests/unit/cli/test_parse_args.py | 28 +++++++++++---- tests/unit/cli/test_visor_cli.py | 47 +++++++++++++++++++++--- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/tests/unit/cli/test_apis.py b/tests/unit/cli/test_apis.py index 47e756d6..96283051 100644 --- a/tests/unit/cli/test_apis.py +++ b/tests/unit/cli/test_apis.py @@ -41,10 +41,23 @@ 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_list_prints_urls(): """Verify that available instance URLs are printed.""" api = ServerAPI("host", 1234) @@ -137,7 +150,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 +159,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.""" @@ -201,3 +214,43 @@ def sleep_side_effect(_): 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("logging.shutdown") as mock_shutdown, \ + patch("shutil.rmtree") as mock_rmtree: + api.clear_logs() + mock_shutdown.assert_called_once() + mock_rmtree.assert_called_once_with(str(tmp_path)) + mock_print.assert_any_call("Log directory cleared.") + +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("logging.shutdown"), \ + patch("shutil.rmtree", side_effect=OSError("boom")): + api.clear_logs() + mock_print.assert_any_call("Failed to clear log directory: boom") + diff --git a/tests/unit/cli/test_parse_args.py b/tests/unit/cli/test_parse_args.py index f4ba97ed..1440bd3c 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,10 +121,18 @@ 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"]) - assert args.group == "logs" - assert args.log_name is None + """Verify that log list arguments use the expected default values.""" + args = run_parse_args(["visor-cli", "log", "list"]) + assert args.group == "log" + assert args.action == "list" + assert args.log_dir is None + +def test_logs_show_args_defaults(): + """Verify that log show uses the expected default argument values.""" + args = run_parse_args(["visor-cli", "log", "show"]) + assert args.group == "log" + assert args.action == "show" + assert args.log_name == "visor" assert args.follow is False assert args.log_dir is None assert args.lines == 10 @@ -130,13 +140,19 @@ def test_logs_list_args_defaults(): def test_logs_show_args_custom(): """Verify that custom log display arguments are parsed correctly.""" args = run_parse_args([ - "visor-cli", "logs", "mylog", "-f", "--log-dir", "/tmp", "-n", "5" + "visor-cli", "log", "--log-dir", "/tmp", "show", "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", "log", "clear"]) + assert args.group == "log" + assert args.action == "clear" + 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..dace4db8 100644 --- a/tests/unit/cli/test_visor_cli.py +++ b/tests/unit/cli/test_visor_cli.py @@ -94,13 +94,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 +144,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("log", "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 @@ -136,7 +154,7 @@ def test_main_logs_list(mock_parse_args, mock_logs_api): 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) + args = make_args("log", "show", log_name="mylog", follow=True, log_dir="dir", lines=5) mock_parse_args.return_value = args api = MagicMock() mock_logs_api.return_value = api @@ -144,6 +162,27 @@ def test_main_logs_show(mock_parse_args, mock_logs_api): visor_cli.main() api.show_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("log", "clear", log_dir=None) + 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.clear_logs.assert_called_once() + +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("log", "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.""" args = make_args("instance", "start", file_path="f", metadata_path="m", timeout=1) From 5444cf42f85c6a0aa2da653395c6448eeb8edbbc Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Wed, 30 Sep 2026 13:19:20 -0700 Subject: [PATCH 04/16] feat/chore: visor-cli use tail as action name --- src/ansys/visor/viewer/cli/apis.py | 6 +++--- src/ansys/visor/viewer/cli/parse_args.py | 8 ++++---- src/ansys/visor/viewer/cli/visor_cli.py | 4 ++-- tests/unit/cli/test_apis.py | 12 ++++++------ tests/unit/cli/test_parse_args.py | 14 +++++++------- tests/unit/cli/test_visor_cli.py | 8 ++++---- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/ansys/visor/viewer/cli/apis.py b/src/ansys/visor/viewer/cli/apis.py index 14c36838..f686f698 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -245,19 +245,19 @@ def list_logs(self): 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") diff --git a/src/ansys/visor/viewer/cli/parse_args.py b/src/ansys/visor/viewer/cli/parse_args.py index 61a81375..7c1bcb43 100644 --- a/src/ansys/visor/viewer/cli/parse_args.py +++ b/src/ansys/visor/viewer/cli/parse_args.py @@ -134,16 +134,16 @@ def parse_args(): logs_sub.add_parser("list", help="List available logs") # show API - show_parser = logs_sub.add_parser("show", help="Show the log file") - show_parser.add_argument("log_name", + 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") - show_parser.add_argument("-f", "--follow", + tail_parser.add_argument("-f", "--follow", action="store_true", help="Follow the log file (like tail -f)" ) - show_parser.add_argument( + 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)" ) diff --git a/src/ansys/visor/viewer/cli/visor_cli.py b/src/ansys/visor/viewer/cli/visor_cli.py index 16d7c255..a698a332 100644 --- a/src/ansys/visor/viewer/cli/visor_cli.py +++ b/src/ansys/visor/viewer/cli/visor_cli.py @@ -136,8 +136,8 @@ def main(): api = LogsAPI(log_dir=args.log_dir) if args.action == "list": api.list_logs() - elif args.action == "show": - 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": api.clear_logs() return diff --git a/tests/unit/cli/test_apis.py b/tests/unit/cli/test_apis.py index 96283051..b7270112 100644 --- a/tests/unit/cli/test_apis.py +++ b/tests/unit/cli/test_apis.py @@ -168,25 +168,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" @@ -209,7 +209,7 @@ 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]) diff --git a/tests/unit/cli/test_parse_args.py b/tests/unit/cli/test_parse_args.py index 1440bd3c..03625e0b 100644 --- a/tests/unit/cli/test_parse_args.py +++ b/tests/unit/cli/test_parse_args.py @@ -127,20 +127,20 @@ def test_logs_list_args_defaults(): assert args.action == "list" assert args.log_dir is None -def test_logs_show_args_defaults(): - """Verify that log show uses the expected default argument values.""" - args = run_parse_args(["visor-cli", "log", "show"]) +def test_logs_tail_args_defaults(): + """Verify that log tail uses the expected default argument values.""" + args = run_parse_args(["visor-cli", "log", "tail"]) assert args.group == "log" - assert args.action == "show" + 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", "log", "--log-dir", "/tmp", "show", "mylog", "-f", "-n", "5" + "visor-cli", "log", "--log-dir", "/tmp", "tail", "mylog", "-f", "-n", "5" ]) assert args.log_name == "mylog" assert args.follow is True diff --git a/tests/unit/cli/test_visor_cli.py b/tests/unit/cli/test_visor_cli.py index dace4db8..626f6bbd 100644 --- a/tests/unit/cli/test_visor_cli.py +++ b/tests/unit/cli/test_visor_cli.py @@ -152,15 +152,15 @@ 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("log", "show", 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("log", "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.""" From e6c889c170ed7a1c1ae4dad245dd843653ebe241 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Wed, 30 Sep 2026 13:25:40 -0700 Subject: [PATCH 05/16] feat/chore: add -f option for visor-cli log clear --- src/ansys/visor/viewer/cli/apis.py | 13 +++++++------ src/ansys/visor/viewer/cli/parse_args.py | 6 ++++-- src/ansys/visor/viewer/cli/visor_cli.py | 2 +- tests/unit/cli/test_apis.py | 13 +++++++++++++ tests/unit/cli/test_parse_args.py | 15 +++++++++++++++ tests/unit/cli/test_visor_cli.py | 14 ++++++++++++-- 6 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/ansys/visor/viewer/cli/apis.py b/src/ansys/visor/viewer/cli/apis.py index f686f698..5dd036b0 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -283,16 +283,17 @@ def tail_log(self, log_name, follow=False, lines=10): except FileNotFoundError: print(f"Log file not found: {log_path}") - def clear_logs(self): + def clear_logs(self, force=False): """Delete the log directory.""" if not os.path.isdir(self.log_dir): print(f"Log directory not found: {self.log_dir}") return - print(f"Clear log directory {self.log_dir}? (y/n): ", end="") - choice = input().strip().lower() - if choice != "y": - print("Aborted.") - return + if not force: + print(f"Clear log directory {self.log_dir}? (y/n): ", end="") + choice = input().strip().lower() + if choice != "y": + print("Aborted.") + return try: # Release this process's own file handles (e.g. visor.log opened by # module-level loggers on import) so Windows allows deletion. diff --git a/src/ansys/visor/viewer/cli/parse_args.py b/src/ansys/visor/viewer/cli/parse_args.py index 7c1bcb43..d09c6bc1 100644 --- a/src/ansys/visor/viewer/cli/parse_args.py +++ b/src/ansys/visor/viewer/cli/parse_args.py @@ -149,7 +149,9 @@ def parse_args(): ) # clear API - logs_sub.add_parser("clear", help="Clear available logs") - + 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 a698a332..a8302080 100644 --- a/src/ansys/visor/viewer/cli/visor_cli.py +++ b/src/ansys/visor/viewer/cli/visor_cli.py @@ -139,7 +139,7 @@ def main(): elif args.action == "tail": api.tail_log(args.log_name, args.follow, args.lines) elif args.action == "clear": - api.clear_logs() + api.clear_logs(args.force) return diff --git a/tests/unit/cli/test_apis.py b/tests/unit/cli/test_apis.py index b7270112..ed392698 100644 --- a/tests/unit/cli/test_apis.py +++ b/tests/unit/cli/test_apis.py @@ -254,3 +254,16 @@ def test_logsapi_clear_logs_reports_failure(tmp_path): api.clear_logs() mock_print.assert_any_call("Failed to clear log directory: 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("logging.shutdown") as mock_shutdown, \ + patch("shutil.rmtree") as mock_rmtree: + api.clear_logs(force=True) + mock_input.assert_not_called() + mock_shutdown.assert_called_once() + mock_rmtree.assert_called_once_with(str(tmp_path)) + mock_print.assert_any_call("Log directory cleared.") + diff --git a/tests/unit/cli/test_parse_args.py b/tests/unit/cli/test_parse_args.py index 03625e0b..fdbd87cd 100644 --- a/tests/unit/cli/test_parse_args.py +++ b/tests/unit/cli/test_parse_args.py @@ -152,6 +152,21 @@ def test_logs_clear_args(): args = run_parse_args(["visor-cli", "log", "clear"]) assert args.group == "log" 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", "log", "clear", "-f"]) + assert args.group == "log" + 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", "log", "clear", "--force"]) + assert args.group == "log" + assert args.action == "clear" + assert args.force is True def test_missing_group_raises(): """Verify that omitting the command group raises SystemExit.""" diff --git a/tests/unit/cli/test_visor_cli.py b/tests/unit/cli/test_visor_cli.py index 626f6bbd..a29a9711 100644 --- a/tests/unit/cli/test_visor_cli.py +++ b/tests/unit/cli/test_visor_cli.py @@ -164,13 +164,23 @@ def test_main_logs_tail(mock_parse_args, mock_logs_api): def test_main_logs_clear(mock_parse_args, mock_logs_api): """Verify that log clear invokes the clear_logs method.""" - args = make_args("log", "clear", log_dir=None) + args = make_args("log", "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_running", return_value=True): visor_cli.main() - api.clear_logs.assert_called_once() + 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("log", "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_running", return_value=True): + visor_cli.main() + api.clear_logs.assert_called_once_with(True) 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.""" From 5f26d81727325caefbb086d8a7b26632c95aa151 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 07:06:19 -0700 Subject: [PATCH 06/16] feat/chore: rename log back to log and update clear action logging --- src/ansys/visor/viewer/cli/apis.py | 6 +++--- src/ansys/visor/viewer/cli/parse_args.py | 2 +- src/ansys/visor/viewer/cli/visor_cli.py | 4 ++-- tests/unit/cli/test_apis.py | 6 +++--- tests/unit/cli/test_parse_args.py | 22 +++++++++++----------- tests/unit/cli/test_visor_cli.py | 10 +++++----- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/ansys/visor/viewer/cli/apis.py b/src/ansys/visor/viewer/cli/apis.py index 5dd036b0..89c84596 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -289,7 +289,7 @@ def clear_logs(self, force=False): print(f"Log directory not found: {self.log_dir}") return if not force: - print(f"Clear log directory {self.log_dir}? (y/n): ", end="") + print(f"Remove log directory {self.log_dir}? (y/n): ", end="") choice = input().strip().lower() if choice != "y": print("Aborted.") @@ -299,6 +299,6 @@ def clear_logs(self, force=False): # module-level loggers on import) so Windows allows deletion. logging.shutdown() shutil.rmtree(self.log_dir) - print("Log directory cleared.") + print(f"Log directory removed: {self.log_dir}") except Exception as e: - print(f"Failed to clear log directory: {e}") + print(f"Failed to remove log directory {self.log_dir}: {e}") diff --git a/src/ansys/visor/viewer/cli/parse_args.py b/src/ansys/visor/viewer/cli/parse_args.py index d09c6bc1..19b016b9 100644 --- a/src/ansys/visor/viewer/cli/parse_args.py +++ b/src/ansys/visor/viewer/cli/parse_args.py @@ -123,7 +123,7 @@ def parse_args(): ###################### # Logs subcommands ###################### - logs_parser = subparsers.add_parser("log", help="Log file operations") + logs_parser = subparsers.add_parser("logs", help="Log file operations") logs_parser.add_argument("--log-dir", default=None, help="Directory containing log files (default: from settings)" diff --git a/src/ansys/visor/viewer/cli/visor_cli.py b/src/ansys/visor/viewer/cli/visor_cli.py index a8302080..7d047b86 100644 --- a/src/ansys/visor/viewer/cli/visor_cli.py +++ b/src/ansys/visor/viewer/cli/visor_cli.py @@ -84,7 +84,7 @@ def main(): # 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"] or args.group == "log"): + if not (args.group == "server" and args.action in ["start", "health"] or args.group == "logs"): if not check_server_running(args.api_host, args.api_port): print("Server is not running. Please start the server first.") sys.exit(1) @@ -132,7 +132,7 @@ def main(): elif args.action == "load": api.load(args.state_dir) - if args.group == "log": + if args.group == "logs": api = LogsAPI(log_dir=args.log_dir) if args.action == "list": api.list_logs() diff --git a/tests/unit/cli/test_apis.py b/tests/unit/cli/test_apis.py index ed392698..e2188763 100644 --- a/tests/unit/cli/test_apis.py +++ b/tests/unit/cli/test_apis.py @@ -242,7 +242,7 @@ def test_logsapi_clear_logs_removes_dir_on_confirm(tmp_path): api.clear_logs() mock_shutdown.assert_called_once() mock_rmtree.assert_called_once_with(str(tmp_path)) - mock_print.assert_any_call("Log directory cleared.") + 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.""" @@ -252,7 +252,7 @@ def test_logsapi_clear_logs_reports_failure(tmp_path): patch("logging.shutdown"), \ patch("shutil.rmtree", side_effect=OSError("boom")): api.clear_logs() - mock_print.assert_any_call("Failed to clear log directory: boom") + 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.""" @@ -265,5 +265,5 @@ def test_logsapi_clear_logs_force_skips_confirmation(tmp_path): mock_input.assert_not_called() mock_shutdown.assert_called_once() mock_rmtree.assert_called_once_with(str(tmp_path)) - mock_print.assert_any_call("Log directory cleared.") + mock_print.assert_any_call("Log directory removed: " + str(tmp_path)) diff --git a/tests/unit/cli/test_parse_args.py b/tests/unit/cli/test_parse_args.py index fdbd87cd..6309f436 100644 --- a/tests/unit/cli/test_parse_args.py +++ b/tests/unit/cli/test_parse_args.py @@ -122,15 +122,15 @@ def test_instance_stop_args(): def test_logs_list_args_defaults(): """Verify that log list arguments use the expected default values.""" - args = run_parse_args(["visor-cli", "log", "list"]) - assert args.group == "log" + args = run_parse_args(["visor-cli", "logs", "list"]) + assert args.group == "logs" 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", "log", "tail"]) - assert args.group == "log" + 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 @@ -140,7 +140,7 @@ def test_logs_tail_args_defaults(): def test_logs_tail_args_custom(): """Verify that custom log tail arguments are parsed correctly.""" args = run_parse_args([ - "visor-cli", "log", "--log-dir", "/tmp", "tail", "mylog", "-f", "-n", "5" + "visor-cli", "logs", "--log-dir", "/tmp", "tail", "mylog", "-f", "-n", "5" ]) assert args.log_name == "mylog" assert args.follow is True @@ -149,22 +149,22 @@ def test_logs_tail_args_custom(): def test_logs_clear_args(): """Verify that the log clear subcommand is parsed correctly.""" - args = run_parse_args(["visor-cli", "log", "clear"]) - assert args.group == "log" + 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", "log", "clear", "-f"]) - assert args.group == "log" + 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", "log", "clear", "--force"]) - assert args.group == "log" + args = run_parse_args(["visor-cli", "logs", "clear", "--force"]) + assert args.group == "logs" assert args.action == "clear" assert args.force is True diff --git a/tests/unit/cli/test_visor_cli.py b/tests/unit/cli/test_visor_cli.py index a29a9711..0293cf4d 100644 --- a/tests/unit/cli/test_visor_cli.py +++ b/tests/unit/cli/test_visor_cli.py @@ -144,7 +144,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("log", "list", 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 @@ -154,7 +154,7 @@ def test_main_logs_list(mock_parse_args, mock_logs_api): 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("log", "tail", log_name="mylog", follow=True, log_dir="dir", lines=5) + 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 @@ -164,7 +164,7 @@ def test_main_logs_tail(mock_parse_args, mock_logs_api): def test_main_logs_clear(mock_parse_args, mock_logs_api): """Verify that log clear invokes the clear_logs method.""" - args = make_args("log", "clear", log_dir=None, force=False) + args = make_args("logs", "clear", log_dir=None, force=False) mock_parse_args.return_value = args api = MagicMock() mock_logs_api.return_value = api @@ -174,7 +174,7 @@ def test_main_logs_clear(mock_parse_args, mock_logs_api): 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("log", "clear", log_dir=None, force=True) + args = make_args("logs", "clear", log_dir=None, force=True) mock_parse_args.return_value = args api = MagicMock() mock_logs_api.return_value = api @@ -184,7 +184,7 @@ def test_main_logs_clear_force(mock_parse_args, mock_logs_api): 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("log", "list", 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 From c62c8c7b89faacc31033929309bb1ce3c7a644a0 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:36:05 +0000 Subject: [PATCH 07/16] chore: adding changelog file 151.maintenance.md [dependabot-skip] --- doc/changelog.d/151.maintenance.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/151.maintenance.md 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 From f21f44801631f2b8a5a06e99ec5adb55c3773a7d Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 08:10:11 -0700 Subject: [PATCH 08/16] Potential fix for pull request finding 'Abort before starting when initialization fails' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/ansys/visor/viewer/cli/apis.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ansys/visor/viewer/cli/apis.py b/src/ansys/visor/viewer/cli/apis.py index 89c84596..6ac0b800 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -85,6 +85,7 @@ def initialize(self, host, port, rendering_mode, standalone, dark_mode, start): "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) From 2d9d70fdc8bfb9ad9e82f9f7afe1a8d49b50ddcf Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 08:17:26 -0700 Subject: [PATCH 09/16] Potential fix for pull request finding 'Preserve start method compatibility with a default argument' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/ansys/visor/viewer/cli/apis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ansys/visor/viewer/cli/apis.py b/src/ansys/visor/viewer/cli/apis.py index 6ac0b800..9eb9043a 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -61,7 +61,7 @@ def info(self): resp = requests.get(f"{self.base}/info") print(resp.json()) - def initialize(self, host, port, rendering_mode, standalone, dark_mode, start): + def initialize(self, host, port, rendering_mode, standalone, dark_mode, start=False): """Initialize the server with viewer configuration. Parameters From e49e7f0605644bda07b14fd1d1e30ea8804881c1 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 09:26:08 -0700 Subject: [PATCH 10/16] address logging shutdown issue --- src/ansys/visor/viewer/__init__.py | 10 ++++- src/ansys/visor/viewer/cli/apis.py | 18 ++++++--- src/ansys/visor/viewer/cli/visor_cli.py | 39 +++++++++++++++++--- tests/unit/cli/test_apis.py | 26 ++++++++++--- tests/unit/cli/test_visor_cli.py | 49 ++++++++++++++++++++++++- 5 files changed, 123 insertions(+), 19 deletions(-) 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 9eb9043a..fb072ef8 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -1,7 +1,6 @@ """API client for controlling a Visor server.""" import json -import logging import os import shutil import time @@ -285,7 +284,19 @@ def tail_log(self, log_name, follow=False, lines=10): print(f"Log file not found: {log_path}") def clear_logs(self, force=False): - """Delete the log directory.""" + """Delete the log directory. + + Parameters + ---------- + force : bool, optional + When ``True``, skip the confirmation prompt. Defaults to ``False``. + + 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 @@ -296,9 +307,6 @@ def clear_logs(self, force=False): print("Aborted.") return try: - # Release this process's own file handles (e.g. visor.log opened by - # module-level loggers on import) so Windows allows deletion. - logging.shutdown() shutil.rmtree(self.log_dir) print(f"Log directory removed: {self.log_dir}") except Exception as e: diff --git a/src/ansys/visor/viewer/cli/visor_cli.py b/src/ansys/visor/viewer/cli/visor_cli.py index 7d047b86..d8264a1b 100644 --- a/src/ansys/visor/viewer/cli/visor_cli.py +++ b/src/ansys/visor/viewer/cli/visor_cli.py @@ -13,7 +13,7 @@ from ansys.visor.viewer.config import settings -def check_server_running(host, port): +def check_server_running(host, port, verbose=True): """Check if the server is running by making a health check request""" try: binding_host = settings.binding_host or host @@ -21,9 +21,11 @@ def check_server_running(host, port): resp = requests.get(f"{scheme}://{binding_host}:{port}/health", timeout=1) if resp.status_code == 200: return True - print("Server health check failed:", resp.text) + if verbose: + print("Server health check failed:", resp.text) except Exception as e: - print("Could not connect to server:", e) + if verbose: + print("Could not connect to server:", e) return False def check_init_args( @@ -76,19 +78,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"] or args.group == "logs"): + # 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_running(args.api_host, args.api_port, verbose=False): + 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) diff --git a/tests/unit/cli/test_apis.py b/tests/unit/cli/test_apis.py index e2188763..c2c38ec6 100644 --- a/tests/unit/cli/test_apis.py +++ b/tests/unit/cli/test_apis.py @@ -1,4 +1,6 @@ import json +import subprocess +import sys from unittest.mock import mock_open, patch from ansys.visor.viewer.cli.apis import InstanceAPI, LogsAPI, ServerAPI @@ -237,10 +239,8 @@ def test_logsapi_clear_logs_removes_dir_on_confirm(tmp_path): api = LogsAPI(str(tmp_path)) with patch("builtins.input", return_value="y"), \ patch("builtins.print") as mock_print, \ - patch("logging.shutdown") as mock_shutdown, \ patch("shutil.rmtree") as mock_rmtree: api.clear_logs() - mock_shutdown.assert_called_once() mock_rmtree.assert_called_once_with(str(tmp_path)) mock_print.assert_any_call("Log directory removed: " + str(tmp_path)) @@ -249,7 +249,6 @@ def test_logsapi_clear_logs_reports_failure(tmp_path): api = LogsAPI(str(tmp_path)) with patch("builtins.input", return_value="y"), \ patch("builtins.print") as mock_print, \ - patch("logging.shutdown"), \ patch("shutil.rmtree", side_effect=OSError("boom")): api.clear_logs() mock_print.assert_any_call(f"Failed to remove log directory {tmp_path}: boom") @@ -259,11 +258,28 @@ def test_logsapi_clear_logs_force_skips_confirmation(tmp_path): api = LogsAPI(str(tmp_path)) with patch("builtins.input") as mock_input, \ patch("builtins.print") as mock_print, \ - patch("logging.shutdown") as mock_shutdown, \ patch("shutil.rmtree") as mock_rmtree: api.clear_logs(force=True) mock_input.assert_not_called() - mock_shutdown.assert_called_once() 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_visor_cli.py b/tests/unit/cli/test_visor_cli.py index 0293cf4d..d76f6ea8 100644 --- a/tests/unit/cli/test_visor_cli.py +++ b/tests/unit/cli/test_visor_cli.py @@ -168,7 +168,7 @@ def test_main_logs_clear(mock_parse_args, mock_logs_api): 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): + with patch("ansys.visor.viewer.cli.visor_cli.check_server_running", return_value=False): visor_cli.main() api.clear_logs.assert_called_once_with(False) @@ -178,10 +178,55 @@ def test_main_logs_clear_force(mock_parse_args, mock_logs_api): 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): + with patch("ansys.visor.viewer.cli.visor_cli.check_server_running", return_value=False): visor_cli.main() api.clear_logs.assert_called_once_with(True) +def test_main_logs_clear_rejected_when_server_running(mock_parse_args, mock_logs_api, capsys): + """Verify that log clear exits without clearing when the server is running.""" + 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_running", 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, verbose=False) + api.clear_logs.assert_not_called() + assert "Server is running" in capsys.readouterr().out + +@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) From c4b96aaebae1bb686913164b568a228a448c0e42 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 09:51:34 -0700 Subject: [PATCH 11/16] use reachability check for log clear --- src/ansys/visor/viewer/cli/visor_cli.py | 34 ++++++++++++++++++++----- tests/unit/cli/test_visor_cli.py | 33 +++++++++++++++++++----- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/ansys/visor/viewer/cli/visor_cli.py b/src/ansys/visor/viewer/cli/visor_cli.py index d8264a1b..caf7b4df 100644 --- a/src/ansys/visor/viewer/cli/visor_cli.py +++ b/src/ansys/visor/viewer/cli/visor_cli.py @@ -13,7 +13,7 @@ from ansys.visor.viewer.config import settings -def check_server_running(host, port, verbose=True): +def check_server_running(host, port): """Check if the server is running by making a health check request""" try: binding_host = settings.binding_host or host @@ -21,13 +21,33 @@ def check_server_running(host, port, verbose=True): resp = requests.get(f"{scheme}://{binding_host}:{port}/health", timeout=1) if resp.status_code == 200: return True - if verbose: - print("Server health check failed:", resp.text) + print("Server health check failed:", resp.text) except Exception as e: - if verbose: - print("Could not connect to server:", e) + 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: any HTTP response (including + errors such as 500) or an established-but-failing connection counts as + reachable. Returns False only when a connection cannot be established. + """ + binding_host = settings.binding_host or host + url = f"{settings.url_scheme}://{binding_host}:{port}/health" + try: + requests.get(url, timeout=1) + except requests.exceptions.SSLError: + return True # TCP connection was made; something is listening + except requests.exceptions.ConnectionError: + return False # refused or connect timeout: nothing is listening + except requests.exceptions.RequestException: + return True # e.g. read timeout: connected but no reply, assume running + except Exception: + return True # unknown failure: assume the server may be running + return True + def check_init_args( api_host, api_port, @@ -108,8 +128,8 @@ def main(): # 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_running(args.api_host, args.api_port, verbose=False): + 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." diff --git a/tests/unit/cli/test_visor_cli.py b/tests/unit/cli/test_visor_cli.py index d76f6ea8..f574a0d3 100644 --- a/tests/unit/cli/test_visor_cli.py +++ b/tests/unit/cli/test_visor_cli.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +import requests import ansys.visor.viewer.cli.visor_cli as visor_cli from ansys.visor.viewer.core.visor_enums import RenderingMode @@ -168,7 +169,7 @@ def test_main_logs_clear(mock_parse_args, mock_logs_api): 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=False): + 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) @@ -178,24 +179,44 @@ def test_main_logs_clear_force(mock_parse_args, mock_logs_api): 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=False): + 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_rejected_when_server_running(mock_parse_args, mock_logs_api, capsys): - """Verify that log clear exits without clearing when the server is running.""" +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_running", return_value=True) as mock_check: + 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, verbose=False) + mock_check.assert_called_once_with("host", 1234) api.clear_logs.assert_not_called() assert "Server is running" in capsys.readouterr().out +@pytest.mark.parametrize("status_code", [200, 404, 500, 503]) +def test_check_server_reachable_any_http_response(mock_requests_get, status_code): + """Verify that any HTTP response, healthy or not, counts as reachable.""" + mock_requests_get.return_value = MagicMock(status_code=status_code) + assert visor_cli.check_server_reachable("host", 1234) is True + +@pytest.mark.parametrize( + "exc, expected", + [ + (requests.exceptions.ConnectionError("refused"), False), + (requests.exceptions.ConnectTimeout("connect timeout"), False), + (requests.exceptions.SSLError("bad handshake"), True), + (requests.exceptions.ReadTimeout("no reply"), True), + ], +) +def test_check_server_reachable_exceptions(mock_requests_get, exc, expected): + """Verify that only failing to establish a connection counts as unreachable.""" + mock_requests_get.side_effect = exc + assert visor_cli.check_server_reachable("host", 1234) is expected + @pytest.mark.parametrize( "group, action, expected", [ From 023260a725c4bddbf90662c5cf168acb3920c804 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 10:04:18 -0700 Subject: [PATCH 12/16] test TCP connection directly --- src/ansys/visor/viewer/cli/visor_cli.py | 23 +++++++++------------- tests/unit/cli/test_visor_cli.py | 26 ++++++++++++------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/ansys/visor/viewer/cli/visor_cli.py b/src/ansys/visor/viewer/cli/visor_cli.py index caf7b4df..7c3bbc0a 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 @@ -30,23 +31,17 @@ def check_server_reachable(host, port): """ Check whether anything is listening on the server address, healthy or not. - Intended for guarding destructive commands: any HTTP response (including - errors such as 500) or an established-but-failing connection counts as - reachable. Returns False only when a connection cannot be established. + 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 - url = f"{settings.url_scheme}://{binding_host}:{port}/health" try: - requests.get(url, timeout=1) - except requests.exceptions.SSLError: - return True # TCP connection was made; something is listening - except requests.exceptions.ConnectionError: - return False # refused or connect timeout: nothing is listening - except requests.exceptions.RequestException: - return True # e.g. read timeout: connected but no reply, assume running - except Exception: - return True # unknown failure: assume the server may be running - return True + 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, diff --git a/tests/unit/cli/test_visor_cli.py b/tests/unit/cli/test_visor_cli.py index f574a0d3..6256ea3f 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 @@ -197,25 +198,24 @@ def test_main_logs_clear_rejected_when_server_reachable(mock_parse_args, mock_lo api.clear_logs.assert_not_called() assert "Server is running" in capsys.readouterr().out -@pytest.mark.parametrize("status_code", [200, 404, 500, 503]) -def test_check_server_reachable_any_http_response(mock_requests_get, status_code): - """Verify that any HTTP response, healthy or not, counts as reachable.""" - mock_requests_get.return_value = MagicMock(status_code=status_code) - assert visor_cli.check_server_reachable("host", 1234) is True +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, expected", + "exc", [ - (requests.exceptions.ConnectionError("refused"), False), - (requests.exceptions.ConnectTimeout("connect timeout"), False), - (requests.exceptions.SSLError("bad handshake"), True), - (requests.exceptions.ReadTimeout("no reply"), True), + ConnectionRefusedError("refused"), + socket.timeout("connect timeout"), + socket.gaierror("dns failure"), ], ) -def test_check_server_reachable_exceptions(mock_requests_get, exc, expected): +def test_check_server_reachable_connect_failure(exc): """Verify that only failing to establish a connection counts as unreachable.""" - mock_requests_get.side_effect = exc - assert visor_cli.check_server_reachable("host", 1234) is expected + 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", From d5043bd6e6f493b2ba2a051e84fcb30ebb286549 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 10:07:02 -0700 Subject: [PATCH 13/16] pre-commit fix --- tests/unit/cli/test_visor_cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/cli/test_visor_cli.py b/tests/unit/cli/test_visor_cli.py index 6256ea3f..5a995ee4 100644 --- a/tests/unit/cli/test_visor_cli.py +++ b/tests/unit/cli/test_visor_cli.py @@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch import pytest -import requests import ansys.visor.viewer.cli.visor_cli as visor_cli from ansys.visor.viewer.core.visor_enums import RenderingMode From 3ca6e7c4c12062820ca04a12164220dff56d5094 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 10:57:04 -0700 Subject: [PATCH 14/16] apply fix for /start failures on init and nonzero exit when deletion fails on clear --- src/ansys/visor/viewer/cli/apis.py | 8 +++++--- src/ansys/visor/viewer/cli/visor_cli.py | 3 ++- tests/unit/cli/test_apis.py | 5 ++++- tests/unit/cli/test_visor_cli.py | 12 ++++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/ansys/visor/viewer/cli/apis.py b/src/ansys/visor/viewer/cli/apis.py index fb072ef8..80ed76a3 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -299,15 +299,17 @@ def clear_logs(self, force=False): """ if not os.path.isdir(self.log_dir): print(f"Log directory not found: {self.log_dir}") - return + 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 + return True try: shutil.rmtree(self.log_dir) - print(f"Log directory removed: {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/visor_cli.py b/src/ansys/visor/viewer/cli/visor_cli.py index 7c3bbc0a..7ea734b2 100644 --- a/src/ansys/visor/viewer/cli/visor_cli.py +++ b/src/ansys/visor/viewer/cli/visor_cli.py @@ -181,7 +181,8 @@ def main(): elif args.action == "tail": api.tail_log(args.log_name, args.follow, args.lines) elif args.action == "clear": - api.clear_logs(args.force) + 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 c2c38ec6..2c1c2cb8 100644 --- a/tests/unit/cli/test_apis.py +++ b/tests/unit/cli/test_apis.py @@ -1,7 +1,10 @@ import json import subprocess import sys -from unittest.mock import mock_open, patch +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 diff --git a/tests/unit/cli/test_visor_cli.py b/tests/unit/cli/test_visor_cli.py index 5a995ee4..9f3caaae 100644 --- a/tests/unit/cli/test_visor_cli.py +++ b/tests/unit/cli/test_visor_cli.py @@ -183,6 +183,18 @@ def test_main_logs_clear_force(mock_parse_args, mock_logs_api): 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) From bc74b0075682acc963dd9e7e4d0fa0c98522d20f Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 10:57:54 -0700 Subject: [PATCH 15/16] pre-commit fix --- tests/unit/cli/test_apis.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/unit/cli/test_apis.py b/tests/unit/cli/test_apis.py index 2c1c2cb8..c2c38ec6 100644 --- a/tests/unit/cli/test_apis.py +++ b/tests/unit/cli/test_apis.py @@ -1,10 +1,7 @@ import json import subprocess import sys -from unittest.mock import MagicMock, mock_open, patch - -import pytest -import requests +from unittest.mock import mock_open, patch from ansys.visor.viewer.cli.apis import InstanceAPI, LogsAPI, ServerAPI from ansys.visor.viewer.core.visor_enums import RenderingMode From fcb904cd12fdbab2ab48aa7f48ce9729e7f22116 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 1 Oct 2026 11:31:28 -0700 Subject: [PATCH 16/16] add start fix --- src/ansys/visor/viewer/cli/apis.py | 13 +++++++++++++ tests/unit/cli/test_apis.py | 29 +++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/ansys/visor/viewer/cli/apis.py b/src/ansys/visor/viewer/cli/apis.py index 80ed76a3..690885c8 100644 --- a/src/ansys/visor/viewer/cli/apis.py +++ b/src/ansys/visor/viewer/cli/apis.py @@ -79,6 +79,12 @@ def initialize(self, host, port, rendering_mode, standalone, dark_mode, start=Fa 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} @@ -89,6 +95,7 @@ def initialize(self, host, port, rendering_mode, standalone, dark_mode, start=Fa resp = requests.post(f"{self.base}/start", json={}) print(resp) print(resp.json()) + resp.raise_for_status() def list(self): @@ -291,6 +298,12 @@ def clear_logs(self, force=False): 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 diff --git a/tests/unit/cli/test_apis.py b/tests/unit/cli/test_apis.py index c2c38ec6..5aa569dd 100644 --- a/tests/unit/cli/test_apis.py +++ b/tests/unit/cli/test_apis.py @@ -1,7 +1,10 @@ import json import subprocess import sys -from unittest.mock import mock_open, patch +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 @@ -60,6 +63,20 @@ def test_serverapi_initialize_with_start_also_starts_instance(): }) 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) @@ -250,7 +267,15 @@ def test_logsapi_clear_logs_reports_failure(tmp_path): with patch("builtins.input", return_value="y"), \ patch("builtins.print") as mock_print, \ patch("shutil.rmtree", side_effect=OSError("boom")): - api.clear_logs() + 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):