Source code for pysnap.cli.formatters
"""Output formatters used by the PySnap CLI."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TextIO
from pysnap.core.access import SSH_HOST_ADDRESS, AccessInfo
from pysnap.core.settings import host_pipe_name
from pysnap.core.models import (
ComPortSetting,
IntegrationTestResult,
NicSetting,
VMGroup,
VMInfo,
VMMonitorRecord,
VMSettingsResult,
)
[docs]
@dataclass
class ImportProgressBar:
"""Render a live import progress bar to a text stream."""
stream: TextIO
label: str = "Importing"
width: int = 32
current_percent: int = 0
started: bool = False
[docs]
def update(self, percent: int) -> None:
"""Update the progress bar.
:param percent: Percentage reported by VBoxManage.
"""
bounded_percent = max(0, min(percent, 100))
if bounded_percent < self.current_percent:
return
self.started = True
self.current_percent = bounded_percent
filled = round((bounded_percent / 100) * self.width)
bar = "#" * filled + "." * (self.width - filled)
self.stream.write(
f"\r{self.label} [{bar}] {bounded_percent:3d}%"
)
self.stream.flush()
[docs]
def finish(self) -> None:
"""Terminate the progress-bar line when it was shown."""
if not self.started:
return
self.stream.write("\n")
self.stream.flush()
[docs]
def format_groups(groups: list[VMGroup]) -> str:
"""Format a group listing for terminal output.
:param groups: Group objects to format.
:returns: Human-readable text output.
"""
if not groups:
return "No virtual machines found."
lines: list[str] = []
for group in groups:
lines.append(f"Group: {group.name}")
for vm_name in group.vm_names:
lines.append(f"- {vm_name}")
return "\n".join(lines)
[docs]
def format_vm_info(vm_info: VMInfo, access: AccessInfo | None = None) -> str:
"""Format one VM description for terminal output.
:param vm_info: VM information to format.
:param access: How PySnap reaches the guest, if known.
:returns: Human-readable text output.
"""
serial_value = str(vm_info.serial_port) if vm_info.serial_port is not None else "none"
lines = [
f"Name: {vm_info.name}",
f"Group: {', '.join(vm_info.groups) if vm_info.groups else '/Others'}",
f"Serial port: {serial_value}",
]
if vm_info.parent_name:
lines.append(f"Parent VM: {vm_info.parent_name}")
if access is not None:
lines.append(f"Access: {format_access(access)}")
return "\n".join(lines)
_ACCESS_SOURCES = {
"override": "set with pysnap set",
"protosettings": "protosettings",
"default": "default",
}
[docs]
def format_access(access: AccessInfo) -> str:
"""Describe how PySnap reaches a guest in one line.
:param access: Access description.
:returns: For example ``ssh root@127.0.0.1:2222 (default)``.
"""
source = _ACCESS_SOURCES.get(access.source, access.source)
if access.mode == "ssh":
if access.ssh_port is None:
return f"ssh as {access.ssh_user}, no forwarded port yet: run pysnap plug ({source})"
return f"ssh {access.ssh_user}@{SSH_HOST_ADDRESS}:{access.ssh_port} ({source})"
port = access.serial_port if access.serial_port is not None else "none"
return f"COM1 serial console, port {port} ({source})"
[docs]
def format_vm_settings(result: VMSettingsResult) -> str:
"""Format the serial ports and network adapters of a VM.
:param result: Result of ``pysnap set``.
:returns: Human-readable text output.
"""
lines = [f"{result.vm_name} ({result.vm_state or 'unknown state'})"]
if result.access is not None:
lines.append(f" Access {format_access(result.access)}")
for number, setting in enumerate(result.hardware.com_ports, start=1):
lines.append(f" COM{number} {_describe_com_port(setting)}")
# Show NIC1-NIC4 and further adapters up to the last one in use.
used = [number for number, nic in enumerate(result.hardware.nics, start=1) if nic.attachment != "none"]
shown = max([4, *used])
for number, setting in enumerate(result.hardware.nics[:shown], start=1):
lines.append(f" NIC{number:<2} {_describe_nic(setting)}")
return "\n".join(lines)
def _describe_com_port(setting: ComPortSetting) -> str:
"""Describe one serial port."""
if setting.mode == "off":
return "off"
if setting.mode == "tcpserver":
return f"TCP server, port {setting.port}" if setting.port is not None else "TCP server"
if setting.mode in {"server", "client"}:
role = "creates" if setting.mode == "server" else "connects to"
name = host_pipe_name(setting.path or "")
if name is not None:
return f'pipe {setting.mode}, {role} "{name}" ({setting.path})'
return f"pipe {setting.mode}, {role} {setting.path}"
return f"{setting.mode} {setting.detail}" if setting.detail else setting.mode
def _describe_nic(setting: NicSetting) -> str:
"""Describe one network adapter."""
if setting.attachment == "none":
return "off"
if setting.attachment == "nat":
return "NAT"
if setting.attachment == "intnet":
return f'internal network "{setting.network}"'
return setting.attachment
[docs]
def format_import_result(imported_vms: list[VMInfo]) -> str:
"""Format the result of an appliance import.
:param imported_vms: Imported VM information objects.
:returns: Human-readable text output.
"""
lines = ["Imported virtual machines:"]
for vm_info in imported_vms:
lines.append(f"- {vm_info.name} ({vm_info.primary_group})")
return "\n".join(lines)
[docs]
def format_integration_test_result(result: IntegrationTestResult) -> str:
"""Format the result of a completed integration test run.
:param result: Integration test result to render.
:returns: Human-readable text output.
"""
lines = ["Integration test completed successfully.", "", "Machines:"]
for vm_info in result.machines:
lines.append(format_vm_info(vm_info))
lines.append("")
if result.monitor_records:
lines.append("Monitor:")
lines.extend(format_monitor_records(list(result.monitor_records)).splitlines())
lines.append("")
lines.append("Deletion order:")
for vm_name in result.deleted_vm_names:
lines.append(f"- {vm_name}")
return "\n".join(lines).rstrip()
def _monitor_port(record: VMMonitorRecord) -> str:
"""Return the port column of ``monitor``: COM1 port or ``ssh:PORT``."""
if record.access_mode == "ssh":
return f"ssh:{record.ssh_port if record.ssh_port is not None else 'none'}"
return str(record.serial_port) if record.serial_port is not None else "none"
[docs]
def format_monitor_records(records: list[VMMonitorRecord]) -> str:
"""Format compact runtime records for the ``monitor`` command.
:param records: Monitor records to format.
:returns: Human-readable text output.
"""
if not records:
return "No active virtual machines found."
return "\n".join(
f"{record.name} (state: {record.display_state} ; "
f"{_monitor_port(record)} ; "
f"{record.group})"
for record in records
)