- Add core module with SMART data parsing and health calculation - Add CLI with Rich-based terminal UI and health bar visualization - Add GUI with PyQt6 tabs for summary and detailed views - Support multiple health indicators (ID 231, 169, 233) for different SSD manufacturers - Add bilingual support (Russian/English) with auto-detection - Add GitHub Actions workflow for building binaries on Linux, Windows, macOS - Calculate health based on reallocated sectors, pending sectors, SSD life, and more Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
560 lines
20 KiB
Python
560 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""GUI application for S.M.A.R.T. disk health monitoring using PyQt6."""
|
||
|
||
import sys
|
||
from typing import Optional, Dict, List
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QApplication,
|
||
QMainWindow,
|
||
QWidget,
|
||
QVBoxLayout,
|
||
QHBoxLayout,
|
||
QTableWidget,
|
||
QTableWidgetItem,
|
||
QPushButton,
|
||
QLabel,
|
||
QHeaderView,
|
||
QMessageBox,
|
||
QProgressBar,
|
||
QFrame,
|
||
QScrollArea,
|
||
QTabWidget,
|
||
QTreeWidget,
|
||
QTreeWidgetItem,
|
||
)
|
||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||
from PyQt6.QtGui import QFont, QColor, QPalette
|
||
|
||
from smart_report.core import (
|
||
collect_all_disks_data,
|
||
DiskSmartData,
|
||
is_root,
|
||
get_locale,
|
||
get_message,
|
||
)
|
||
|
||
|
||
class SmartDataWorker(QThread):
|
||
"""Worker thread for collecting SMART data."""
|
||
|
||
data_ready = pyqtSignal(list)
|
||
error = pyqtSignal(str)
|
||
|
||
def __init__(self, lang: str):
|
||
super().__init__()
|
||
self.lang = lang
|
||
|
||
def run(self):
|
||
try:
|
||
data = collect_all_disks_data(self.lang)
|
||
self.data_ready.emit(data)
|
||
except Exception as e:
|
||
self.error.emit(str(e))
|
||
|
||
|
||
class HealthBarWidget(QFrame):
|
||
"""Custom health bar widget with percentage."""
|
||
|
||
def __init__(self, health: int, parent=None):
|
||
super().__init__(parent)
|
||
self.health = health
|
||
self.setFixedHeight(30)
|
||
self.setMinimumWidth(200)
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self.progress = QProgressBar()
|
||
self.progress.setRange(0, 100)
|
||
self.progress.setValue(self.health)
|
||
self.progress.setFormat(f" {self.health}%")
|
||
self.progress.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self.progress.setStyleSheet(self.get_stylesheet())
|
||
|
||
layout.addWidget(self.progress)
|
||
|
||
def get_stylesheet(self) -> str:
|
||
if self.health >= 85:
|
||
color = "#27ae60" # Green
|
||
elif self.health >= 60:
|
||
color = "#f39c12" # Yellow
|
||
elif self.health >= 30:
|
||
color = "#e74c3c" # Red
|
||
else:
|
||
color = "#c0392b" # Dark red
|
||
|
||
return f"""
|
||
QProgressBar {{
|
||
border: 1px solid #ccc;
|
||
border-radius: 3px;
|
||
text-align: center;
|
||
font-weight: bold;
|
||
}}
|
||
QProgressBar::chunk {{
|
||
background-color: {color};
|
||
border-radius: 2px;
|
||
}}
|
||
"""
|
||
|
||
|
||
class SmartAttributesTree(QTreeWidget):
|
||
"""Tree widget for displaying SMART attributes."""
|
||
|
||
def __init__(self, data: DiskSmartData, lang: str, parent=None):
|
||
super().__init__(parent)
|
||
self.data = data
|
||
self.lang = lang
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.setHeaderLabels([
|
||
get_message("disk", self.lang),
|
||
"Value",
|
||
"Worst",
|
||
"Threshold",
|
||
"Raw"
|
||
])
|
||
self.setAlternatingRowColors(True)
|
||
self.setRootIsDecorated(False)
|
||
|
||
# Populate with attributes
|
||
for attr_id in sorted(self.data.attrs.keys(), key=lambda x: int(x)):
|
||
attr = self.data.attrs[attr_id]
|
||
item = QTreeWidgetItem([
|
||
f"{attr_id} - {attr['name']}",
|
||
attr['value'],
|
||
attr['worst'],
|
||
attr['threshold'],
|
||
attr['raw']
|
||
])
|
||
|
||
# Highlight critical attributes
|
||
if attr_id in ['5', '197', '198']:
|
||
item.setForeground(0, QColor("#e74c3c"))
|
||
font = item.font(0)
|
||
font.setBold(True)
|
||
item.setFont(0, font)
|
||
elif attr_id in ['194', '9', '12']:
|
||
item.setForeground(0, QColor("#3498db"))
|
||
font = item.font(0)
|
||
font.setBold(True)
|
||
item.setFont(0, font)
|
||
|
||
self.addTopLevelItem(item)
|
||
|
||
self.resizeColumnToContents(0)
|
||
|
||
|
||
class DiskHealthWindow(QMainWindow):
|
||
"""Main window for disk health monitoring."""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.lang = get_locale()
|
||
self.worker: Optional[SmartDataWorker] = None
|
||
self.current_data: List[DiskSmartData] = []
|
||
self.init_ui()
|
||
self.refresh_data()
|
||
|
||
def init_ui(self):
|
||
"""Initialize the user interface."""
|
||
self.setWindowTitle(get_message("disk_health_report", self.lang))
|
||
self.setMinimumSize(1100, 650)
|
||
|
||
# Central widget
|
||
central_widget = QWidget()
|
||
self.setCentralWidget(central_widget)
|
||
main_layout = QVBoxLayout(central_widget)
|
||
main_layout.setSpacing(10)
|
||
main_layout.setContentsMargins(15, 15, 15, 15)
|
||
|
||
# Header
|
||
header_layout = QHBoxLayout()
|
||
self.title_label = QLabel(get_message("disk_health_report", self.lang))
|
||
self.title_label.setFont(QFont("Arial", 18, QFont.Weight.Bold))
|
||
header_layout.addWidget(self.title_label)
|
||
header_layout.addStretch()
|
||
|
||
self.status_label = QLabel("")
|
||
self.status_label.setStyleSheet("color: #666;")
|
||
header_layout.addWidget(self.status_label)
|
||
|
||
main_layout.addLayout(header_layout)
|
||
|
||
# Tab widget
|
||
self.tabs = QTabWidget()
|
||
self.tabs.setStyleSheet("""
|
||
QTabWidget::pane {
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
background-color: white;
|
||
}
|
||
QTabBar::tab {
|
||
background-color: #f5f5f5;
|
||
border: 1px solid #ddd;
|
||
border-bottom: none;
|
||
border-top-left-radius: 4px;
|
||
border-top-right-radius: 4px;
|
||
padding: 8px 16px;
|
||
margin-right: 2px;
|
||
}
|
||
QTabBar::tab:selected {
|
||
background-color: white;
|
||
border-bottom: 1px solid white;
|
||
}
|
||
QTabBar::tab:hover:!selected {
|
||
background-color: #e8e8e8;
|
||
}
|
||
""")
|
||
|
||
# Summary tab
|
||
self.summary_tab = QWidget()
|
||
self.summary_layout = QVBoxLayout(self.summary_tab)
|
||
self.summary_layout.setContentsMargins(10, 10, 10, 10)
|
||
|
||
self.summary_scroll = QScrollArea()
|
||
self.summary_scroll.setWidgetResizable(True)
|
||
self.summary_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
|
||
self.summary_container = QWidget()
|
||
self.summary_container_layout = QVBoxLayout(self.summary_container)
|
||
self.summary_container_layout.setSpacing(15)
|
||
|
||
self.summary_scroll.setWidget(self.summary_container)
|
||
self.summary_layout.addWidget(self.summary_scroll)
|
||
|
||
self.tabs.addTab(self.summary_tab, "📊 Summary / Обзор")
|
||
|
||
# Detailed tab
|
||
self.detailed_tab = QWidget()
|
||
self.detailed_layout = QVBoxLayout(self.detailed_tab)
|
||
self.detailed_layout.setContentsMargins(10, 10, 10, 10)
|
||
|
||
self.detailed_scroll = QScrollArea()
|
||
self.detailed_scroll.setWidgetResizable(True)
|
||
self.detailed_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
|
||
self.detailed_container = QWidget()
|
||
self.detailed_container_layout = QVBoxLayout(self.detailed_container)
|
||
self.detailed_container_layout.setSpacing(20)
|
||
|
||
self.detailed_scroll.setWidget(self.detailed_container)
|
||
self.detailed_layout.addWidget(self.detailed_scroll)
|
||
|
||
self.tabs.addTab(self.detailed_tab, "📋 Detailed / Подробно")
|
||
|
||
main_layout.addWidget(self.tabs)
|
||
|
||
# Footer with buttons
|
||
footer_layout = QHBoxLayout()
|
||
|
||
self.refresh_btn = QPushButton(get_message("refresh", self.lang))
|
||
self.refresh_btn.clicked.connect(self.refresh_data)
|
||
self.refresh_btn.setMinimumSize(120, 35)
|
||
self.refresh_btn.setStyleSheet(
|
||
"QPushButton { background-color: #3498db; color: white; "
|
||
"border: none; padding: 8px 16px; border-radius: 4px; font-weight: bold; }"
|
||
"QPushButton:hover { background-color: #2980b9; }"
|
||
"QPushButton:disabled { background-color: #95a5a6; }"
|
||
)
|
||
footer_layout.addWidget(self.refresh_btn)
|
||
|
||
footer_layout.addStretch()
|
||
|
||
self.root_label = QLabel("")
|
||
self.root_label.setStyleSheet("color: #e74c3c; font-weight: bold;")
|
||
footer_layout.addWidget(self.root_label)
|
||
|
||
main_layout.addLayout(footer_layout)
|
||
|
||
# Check root status
|
||
self.update_root_status()
|
||
|
||
def update_root_status(self):
|
||
"""Update root status label."""
|
||
if not is_root():
|
||
self.root_label.setText(get_message("run_with_sudo", self.lang))
|
||
else:
|
||
self.root_label.setText(get_message("running_as_root", self.lang))
|
||
|
||
def refresh_data(self):
|
||
"""Start data collection in background thread."""
|
||
self.refresh_btn.setEnabled(False)
|
||
self.status_label.setText(get_message("collecting_data", self.lang))
|
||
|
||
# Clear existing widgets
|
||
while self.summary_container_layout.count():
|
||
item = self.summary_container_layout.takeAt(0)
|
||
if item.widget():
|
||
item.widget().deleteLater()
|
||
|
||
while self.detailed_container_layout.count():
|
||
item = self.detailed_container_layout.takeAt(0)
|
||
if item.widget():
|
||
item.widget().deleteLater()
|
||
|
||
self.worker = SmartDataWorker(self.lang)
|
||
self.worker.data_ready.connect(self.on_data_ready)
|
||
self.worker.error.connect(self.on_error)
|
||
self.worker.start()
|
||
|
||
def on_data_ready(self, data: list):
|
||
"""Handle collected data."""
|
||
self.current_data = data
|
||
|
||
# Populate summary tab
|
||
for disk_data in data:
|
||
self.summary_container_layout.addWidget(self.create_summary_panel(disk_data))
|
||
|
||
# Populate detailed tab
|
||
for disk_data in data:
|
||
self.detailed_container_layout.addWidget(self.create_detailed_panel(disk_data))
|
||
|
||
self.status_label.setText(get_message("disks_found", self.lang, len(data)))
|
||
self.refresh_btn.setEnabled(True)
|
||
|
||
def create_summary_panel(self, data: DiskSmartData) -> QFrame:
|
||
"""Create a summary panel widget for a single disk."""
|
||
panel = QFrame()
|
||
panel.setFrameStyle(QFrame.Shape.StyledPanel)
|
||
panel.setStyleSheet(
|
||
"QFrame { background-color: white; border: 1px solid #ddd; "
|
||
"border-radius: 8px; padding: 15px; }"
|
||
)
|
||
|
||
layout = QVBoxLayout(panel)
|
||
layout.setSpacing(10)
|
||
|
||
# Header row
|
||
header_layout = QHBoxLayout()
|
||
header_layout.addWidget(QLabel(f"📀 <b>{data.disk}</b> - {data.model}"))
|
||
header_layout.addStretch()
|
||
|
||
# Health bar
|
||
health_bar = HealthBarWidget(data.health)
|
||
header_layout.addWidget(health_bar)
|
||
|
||
layout.addLayout(header_layout)
|
||
|
||
# Info grid
|
||
info_layout = QHBoxLayout()
|
||
|
||
if data.smart_supported:
|
||
# Status
|
||
status_color = (
|
||
"#27ae60" if data.status == "GOOD" else ("#e74c3c" if data.status == "BAD" else "#f39c12")
|
||
)
|
||
status_label = QLabel(f"<b>{get_message('status', self.lang)}:</b> {data.status}")
|
||
status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;")
|
||
info_layout.addWidget(status_label)
|
||
|
||
# Temperature
|
||
info_layout.addWidget(QLabel(f"<b>{get_message('temperature', self.lang)}:</b> {data.temp}"))
|
||
|
||
# Power hours
|
||
info_layout.addWidget(QLabel(f"<b>{get_message('power_hours', self.lang)}:</b> {data.power_hours}"))
|
||
|
||
# Power cycles
|
||
info_layout.addWidget(QLabel(f"<b>{get_message('power_cycles', self.lang)}:</b> {data.power_cycles}"))
|
||
|
||
info_layout.addStretch()
|
||
|
||
layout.addLayout(info_layout)
|
||
|
||
# Critical attributes
|
||
attrs_layout = QHBoxLayout()
|
||
attrs_layout.addWidget(
|
||
QLabel(f"• <b>{get_message('reallocated', self.lang)}:</b> {data.reallocated}")
|
||
)
|
||
attrs_layout.addWidget(
|
||
QLabel(f"• <b>{get_message('pending', self.lang)}:</b> {data.pending}")
|
||
)
|
||
attrs_layout.addWidget(
|
||
QLabel(f"• <b>{get_message('uncorrectable', self.lang)}:</b> {data.uncorrectable}")
|
||
)
|
||
attrs_layout.addStretch()
|
||
layout.addLayout(attrs_layout)
|
||
|
||
# Warnings
|
||
if data.warnings:
|
||
warnings_text = "<br>".join([f"⚠️ {w}" for w in data.warnings])
|
||
warnings_label = QLabel(f"<span style='color: #e74c3c; font-weight: bold;'>{warnings_text}</span>")
|
||
warnings_label.setWordWrap(True)
|
||
warnings_label.setStyleSheet("background-color: #ffeaea; padding: 10px; border-radius: 4px;")
|
||
layout.addWidget(warnings_label)
|
||
else:
|
||
not_supported = QLabel(f"<i>{get_message('smart_not_supported', self.lang)}</i>")
|
||
not_supported.setStyleSheet("color: #999;")
|
||
layout.addWidget(not_supported)
|
||
|
||
return panel
|
||
|
||
def create_detailed_panel(self, data: DiskSmartData) -> QFrame:
|
||
"""Create a detailed panel widget for a single disk."""
|
||
panel = QFrame()
|
||
panel.setFrameStyle(QFrame.Shape.StyledPanel)
|
||
panel.setStyleSheet(
|
||
"QFrame { background-color: white; border: 1px solid #ddd; "
|
||
"border-radius: 8px; padding: 15px; }"
|
||
)
|
||
|
||
layout = QVBoxLayout(panel)
|
||
layout.setSpacing(15)
|
||
|
||
# Header
|
||
header_layout = QHBoxLayout()
|
||
header_layout.addWidget(QLabel(f"📀 <b style='font-size: 16px;'>{data.disk}</b>"))
|
||
header_layout.addWidget(QLabel(f"<b>Model:</b> {data.model}"))
|
||
header_layout.addWidget(QLabel(f"<b>Size:</b> {data.size}"))
|
||
header_layout.addStretch()
|
||
|
||
# Health indicator
|
||
health_label = QLabel(f"{get_message('health', self.lang)}: {data.health}%")
|
||
health_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
|
||
if data.health >= 85:
|
||
health_label.setStyleSheet("color: #27ae60;")
|
||
elif data.health >= 60:
|
||
health_label.setStyleSheet("color: #f39c12;")
|
||
elif data.health >= 30:
|
||
health_label.setStyleSheet("color: #e74c3c;")
|
||
else:
|
||
health_label.setStyleSheet("color: #c0392b;")
|
||
header_layout.addWidget(health_label)
|
||
|
||
layout.addLayout(header_layout)
|
||
|
||
if not data.smart_supported:
|
||
not_supported = QLabel(f"<i>{get_message('smart_not_supported', self.lang)}</i>")
|
||
not_supported.setStyleSheet("color: #999; font-size: 14px;")
|
||
layout.addWidget(not_supported)
|
||
return panel
|
||
|
||
# Info section
|
||
info_frame = QFrame()
|
||
info_frame.setStyleSheet("background-color: #f9f9f9; border-radius: 4px; padding: 10px;")
|
||
info_layout = QHBoxLayout(info_frame)
|
||
|
||
status_color = (
|
||
"#27ae60" if data.status == "GOOD" else ("#e74c3c" if data.status == "BAD" else "#f39c12")
|
||
)
|
||
info_layout.addWidget(QLabel(f"<b>Status:</b> <span style='color: {status_color};'>{data.status}</span>"))
|
||
info_layout.addWidget(QLabel(f"<b>Temperature:</b> {data.temp}"))
|
||
info_layout.addWidget(QLabel(f"<b>Power-On Hours:</b> {data.power_hours}"))
|
||
info_layout.addWidget(QLabel(f"<b>Power Cycles:</b> {data.power_cycles}"))
|
||
info_layout.addStretch()
|
||
|
||
layout.addWidget(info_frame)
|
||
|
||
# Critical attributes section
|
||
critical_frame = QFrame()
|
||
critical_frame.setStyleSheet("background-color: #fff5f5; border: 1px solid #ffcccc; border-radius: 4px; padding: 10px;")
|
||
critical_layout = QHBoxLayout(critical_frame)
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>⚠️ {get_message('critical_attrs', self.lang)}:</b>"))
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>{get_message('reallocated', self.lang)}:</b> {data.reallocated}"))
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>{get_message('pending', self.lang)}:</b> {data.pending}"))
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>{get_message('uncorrectable', self.lang)}:</b> {data.uncorrectable}"))
|
||
|
||
# Additional attributes
|
||
if data.remaining_lifetime < 100:
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #27ae60;'>Rem. Life:</b> {data.remaining_lifetime}%"))
|
||
if data.media_wearout_indicator < 100:
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>Wearout:</b> {data.media_wearout_indicator}%"))
|
||
if data.ssd_life_left < 100 and data.remaining_lifetime == 100:
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>SSD Life:</b> {data.ssd_life_left}%"))
|
||
if data.host_writes_gb > 0:
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #3498db;'>Writes:</b> {data.host_writes_gb}GB"))
|
||
if data.crc_errors > 0:
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>CRC:</b> {data.crc_errors}"))
|
||
if data.program_fail_count > 0:
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>Prog Fail:</b> {data.program_fail_count}"))
|
||
if data.erase_fail_count > 0:
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>Erase Fail:</b> {data.erase_fail_count}"))
|
||
if data.command_timeout > 0:
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>Timeouts:</b> {data.command_timeout}"))
|
||
if data.reallocated_event_count > 0:
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #f39c12;'>Realloc Ev:</b> {data.reallocated_event_count}"))
|
||
if data.reported_uncorrect > 0:
|
||
critical_layout.addWidget(QLabel(f"<b style='color: #e74c3c;'>Uncorrect:</b> {data.reported_uncorrect}"))
|
||
|
||
critical_layout.addStretch()
|
||
layout.addWidget(critical_frame)
|
||
|
||
# Warnings
|
||
if data.warnings:
|
||
warnings_frame = QFrame()
|
||
warnings_frame.setStyleSheet("background-color: #ffeaea; border: 1px solid #e74c3c; border-radius: 4px; padding: 10px;")
|
||
warnings_layout = QVBoxLayout(warnings_frame)
|
||
warnings_layout.addWidget(QLabel("<b style='color: #e74c3c;'>⚠️ WARNINGS / ПРЕДУПРЕЖДЕНИЯ:</b>"))
|
||
for warning in data.warnings:
|
||
warnings_layout.addWidget(QLabel(f"<span style='color: #e74c3c;'>• {warning}</span>"))
|
||
layout.addWidget(warnings_frame)
|
||
|
||
# SMART attributes tree
|
||
layout.addWidget(QLabel("<b style='font-size: 14px;'>All S.M.A.R.T. Attributes / Все атрибуты S.M.A.R.T.:</b>"))
|
||
|
||
attrs_tree = SmartAttributesTree(data, self.lang)
|
||
attrs_tree.setMinimumHeight(300)
|
||
attrs_tree.setStyleSheet("""
|
||
QTreeWidget {
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
background-color: white;
|
||
gridline-color: #eee;
|
||
}
|
||
QTreeWidget::item {
|
||
padding: 4px;
|
||
}
|
||
QTreeWidget::item:selected {
|
||
background-color: #3498db;
|
||
color: white;
|
||
}
|
||
QHeaderView::section {
|
||
background-color: #f5f5f5;
|
||
padding: 8px;
|
||
border: none;
|
||
border-bottom: 1px solid #ddd;
|
||
font-weight: bold;
|
||
}
|
||
""")
|
||
layout.addWidget(attrs_tree)
|
||
|
||
return panel
|
||
|
||
def on_error(self, error_msg: str):
|
||
"""Handle error."""
|
||
QMessageBox.critical(
|
||
self, get_message("error", self.lang), f"{get_message('error', self.lang)}:\n{error_msg}"
|
||
)
|
||
self.status_label.setText("")
|
||
self.refresh_btn.setEnabled(True)
|
||
|
||
def closeEvent(self, event):
|
||
"""Handle window close."""
|
||
if self.worker and self.worker.isRunning():
|
||
self.worker.terminate()
|
||
self.worker.wait()
|
||
event.accept()
|
||
|
||
|
||
def main():
|
||
"""Main entry point."""
|
||
app = QApplication(sys.argv)
|
||
app.setStyle("Fusion")
|
||
|
||
# Set application palette
|
||
palette = app.palette()
|
||
palette.setColor(palette.ColorRole.Window, QColor(245, 245, 245))
|
||
app.setPalette(palette)
|
||
|
||
window = DiskHealthWindow()
|
||
window.show()
|
||
|
||
sys.exit(app.exec())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|