Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4aa35bb
feat/chore: visor-cli logs update
LKasianAnsys Sep 29, 2026
5792de6
feat/chore: allow --start on visor-cli server init
LKasianAnsys Sep 29, 2026
622198c
feat/chore: update tests for visor-cli changes
LKasianAnsys Sep 29, 2026
5444cf4
feat/chore: visor-cli use tail as action name
LKasianAnsys Sep 30, 2026
e6c889c
feat/chore: add -f option for visor-cli log clear
LKasianAnsys Sep 30, 2026
5f26d81
feat/chore: rename log back to log and update clear action logging
LKasianAnsys Oct 1, 2026
c62c8c7
chore: adding changelog file 151.maintenance.md [dependabot-skip]
pyansys-ci-bot Oct 1, 2026
f21f448
Potential fix for pull request finding 'Abort before starting when in…
LKasianAnsys Oct 1, 2026
2d9d70f
Potential fix for pull request finding 'Preserve start method compati…
LKasianAnsys Oct 1, 2026
e49e7f0
address logging shutdown issue
LKasianAnsys Oct 1, 2026
c4b96aa
use reachability check for log clear
LKasianAnsys Oct 1, 2026
023260a
test TCP connection directly
LKasianAnsys Oct 1, 2026
d5043bd
pre-commit fix
LKasianAnsys Oct 1, 2026
3ca6e7c
apply fix for /start failures on init and nonzero exit when deletion …
LKasianAnsys Oct 1, 2026
bc74b00
pre-commit fix
LKasianAnsys Oct 1, 2026
fcb904c
add start fix
LKasianAnsys Oct 1, 2026
2a131d7
Merge branch 'main' into maint/visor-cli-updates
LKasianAnsys Oct 1, 2026
8799db1
Merge branch 'main' into maint/visor-cli-updates
LKasianAnsys Oct 2, 2026
cc5b4f8
Merge branch 'main' into maint/visor-cli-updates
LKasianAnsys Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changelog.d/151.maintenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Visor-cli updates for logs and init actions
Comment thread
LKasianAnsys marked this conversation as resolved.
10 changes: 9 additions & 1 deletion src/ansys/visor/viewer/__init__.py
Original file line number Diff line number Diff line change
@@ -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
# ------------------------------------------------------------------------------

Expand Down
70 changes: 62 additions & 8 deletions src/ansys/visor/viewer/cli/apis.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import os
import shutil
import time

import requests
Expand Down Expand Up @@ -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
Expand All @@ -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:
Comment thread
Copilot marked this conversation as resolved.
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."""
Expand Down Expand Up @@ -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")
Expand All @@ -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
34 changes: 28 additions & 6 deletions src/ansys/visor/viewer/cli/parse_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
63 changes: 54 additions & 9 deletions src/ansys/visor/viewer/cli/visor_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Command line interface for Visor Viewer."""

import socket
import sys

import requests
Expand All @@ -26,14 +27,30 @@ 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,
host,
port,
rendering_mode,
standalone,
dark_mode
dark_mode,
):
"""
If the user explicitly passed --rendering-mode, --standalone, or --dark-mode, check whether
Expand Down Expand Up @@ -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):

@LKasianAnsys LKasianAnsys Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@margalva @ansBAkula this copilot comment is a valid point. I think the server check here at the CLI layer is likely sufficient though; the visor-cli logs clear action is for convenience (instead of running rm -r logs manually). Its current server check is not a guarantee it's safe, it's a best-effort guard. But, let me know what you think.

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)
Expand All @@ -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":
Expand All @@ -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


Expand Down
Loading
Loading