#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Provides the main entry point and application management classes for the
simulation.
:module: main
:author: Le Bars, Yoann
"""
# Defines the public names of this module.
__all__ = ['main', 'AppManager']
import sys
from multiprocess import freeze_support
from PySide6.QtWidgets import QApplication, QMainWindow, QWidget, QMessageBox
from PySide6.QtCore import (
QLibraryInfo, QTranslator, QLocale, Slot, QFile, QIODevice, qCritical)
from PySide6.QtGui import QIcon
from src.args import CmdLineArgs, CmdLineValidationError, ExitCode
from src import constants
from src import display
from src.logger import Logger
from src import ui_main as ui_mainwindow
from src import rc_linguist, rc_about, rc_icons # pylint: disable=unused-import
class MissingAboutFile(Exception):
"""
Exception when the about file is missing.
"""
class MainWindow(QMainWindow):
"""
The main application window.
This class sets up the UI defined in Qt Designer, embeds the 3D display widget,
and connects all the necessary signals and slots for user interaction.
:ivar ui_mainwindow.Ui_MainWindow _ui: Window descriptor from Qt Designer.
:ivar display.Display _view: The 3D display widget where the simulation is rendered.
:ivar QLocale _locale: The system locale used for translations.
:ivar CmdLineArgs _args: Parsed command-line arguments.
"""
_ui: ui_mainwindow.Ui_MainWindow
_view: display.Display
_locale: QLocale
def __init__(self, locale: QLocale, args: 'CmdLineArgs') -> None:
"""
Initialises the main window and its components.
:param QLocale locale: System locale descriptor.
:param CmdLineArgs args: Parsed command-line arguments.
"""
super().__init__()
self._locale = locale
# Set up the UI from the generated Ui_MainWindow class.
self._ui = ui_mainwindow.Ui_MainWindow()
self._ui.setupUi(self)
# Create the 3D display and embed it in the main window.
self._view = display.Display(args)
container: QWidget = QWidget.createWindowContainer(self._view)
self.setCentralWidget(container)
# Connect all UI signals to their corresponding slots.
self._connect_signals()
# Start the simulation after the window is set up.
self._view.run_simulation()
def _connect_signals(self) -> None:
"""Connects all UI signals to their respective slots."""
self._ui.actionAbout_Qt.triggered.connect(QApplication.aboutQt)
self._ui.actionAbout.triggered.connect(self.about)
self._ui.actionQuit.triggered.connect(QApplication.quit)
self._view.signal_quit.connect(self._receive_quit)
self._ui.menubar.setNativeMenuBar(True)
def cleanup(self) -> None:
"""
Explicitly cleans up resources, like the multiprocessing pool.
"""
self._view.cleanup()
@Slot(bool)
def _receive_quit(self) -> None:
"""
Receiving message indicating the application should stop.
"""
QApplication.quit()
def _load_about_content(self) -> str:
"""
Loads the 'About' content from the appropriate HTML resource file.
It first tries to load the locale-specific file and falls back to the
default `about.htm` if not found.
:raises MissingAboutFile: If neither the locale-specific nor the default
'About' file can be loaded from resources.
:returns: The formatted HTML content for the 'About' dialog.
:rtype: str
"""
# Attempt to load the locale-specific file, falling back to the default.
for path in [f':about/locales/about_{self._locale.name()}.htm',
':about/locales/about.htm']:
about_file = QFile(path)
if not about_file.open(QIODevice.ReadOnly | QIODevice.Text):
continue # Try the next file in the list.
break
else:
raise MissingAboutFile(
self.tr('The about file is missing from the application resources.'))
try:
html_template = about_file.readAll().data().decode('utf-8')
content = html_template.format(constants.__version__)
finally:
about_file.close()
return content
def about(self) -> None:
"""
Displays information about the application.
"""
try:
about_content = self._load_about_content()
QMessageBox.about(self, self.tr(
'About Pure Python'), about_content)
except MissingAboutFile as e:
QMessageBox.critical(self, self.tr('Error'), str(e))
[docs]
class AppManager:
"""
Orchestrates the application's lifecycle: setup, execution, and cleanup.
This class encapsulates the initialisation of translations, argument parsing,
logging, and the main window, thereby simplifying the global `main` function.
:ivar list[str] _argv: The raw command-line arguments.
:ivar dict _kwargs: Keyword arguments for programmatic setup.
:ivar QApplication | None app: The core Qt application instance.
:ivar QLocale | None locale: The system locale used for translations.
:ivar CmdLineArgs | None cmd_args: Parsed and validated command-line arguments.
:ivar Logger | None logger: The application's logging manager.
:ivar MainWindow | None window: The main application window.
"""
# We use Optional types because these attributes are initialised in setup(), not __init__().
app: QApplication | None = None
locale: QLocale | None = None
cmd_args: CmdLineArgs | None = None
logger: Logger | None = None
window: MainWindow | None = None
def __init__(self, argv: list[str], **kwargs):
"""
Initialises the application manager.
:param list[str] argv: Command-line arguments from `sys.argv`.
:param kwargs: Keyword arguments for programmatic setup (e.g., for testing).
"""
self._argv = argv
self._kwargs = kwargs
[docs]
def setup(self) -> None:
"""Initialises all application components."""
# The QApplication must be created before any other Qt objects.
self.app = QApplication(self._argv)
self._setup_translations()
self._parse_and_validate_args(self._argv[1:], **self._kwargs)
assert self.cmd_args is not None, "Argument parsing failed"
self.logger = Logger(self.cmd_args)
self._setup_app_metadata()
self.window = MainWindow(locale=self.locale, args=self.cmd_args)
def _setup_translations(self) -> None:
"""Loads and installs Qt and application-specific translations."""
# Translation path for Qt base translations (e.g., "OK", "Cancel" buttons).
qt_path: str = QLibraryInfo.path(QLibraryInfo.TranslationsPath)
self.locale = QLocale.system()
# Load and install Qt base translations.
qt_translator = QTranslator(self.app)
if self.locale and qt_translator.load(self.locale, 'qtbase', '_', qt_path):
self.app.installTranslator(qt_translator)
# Load and install application-specific translations.
app_translator = QTranslator(self.app)
app_path = ':/translations/locales'
if self.locale and app_translator.load(self.locale, 'pure-python', '_', app_path):
self.app.installTranslator(app_translator)
def _parse_and_validate_args(self, argv: list[str], **kwargs) -> None:
"""
Parses and validates command-line arguments.
:param list[str] argv: The list of command-line arguments.
:param kwargs: Keyword arguments for testing.
:raises CmdLineValidationError: If arguments are invalid.
"""
if kwargs:
# For testing purposes
self.cmd_args = CmdLineArgs(**kwargs) # type: ignore
else:
# Parse arguments from the actual command line.
self.cmd_args = CmdLineArgs.parse(self.app, argv)
# Validate the parsed arguments.
self.cmd_args.validate()
def _setup_app_metadata(self) -> None:
"""Sets application-wide metadata like name, version, and icon."""
assert self.app is not None, "QApplication not initialised"
app_name = self.app.translate('main', 'Pure Python')
self.app.setApplicationName(app_name)
self.app.setApplicationDisplayName(app_name)
self.app.setApplicationVersion(constants.__version__)
self.app.setOrganizationName('eiG')
self.app.setOrganizationDomain('eig.fr')
self.app.setWindowIcon(QIcon(':/icons/resources/avatar-farfadet.jpg'))
[docs]
def run(self) -> ExitCode:
"""
Orchestrates the entire application lifecycle.
This method calls the setup routines, shows the main window, starts the Qt
event loop, and ensures that cleanup is performed upon exit.
:returns: The application's exit code.
"""
try:
self.setup()
assert self.window and self.app and self.logger, "Application setup failed"
self.window.show()
exit_code = self.app.exec()
except CmdLineValidationError as e:
qCritical(f"{e}")
return e.code
except Exception as e: # pylint: disable=broad-except
qCritical(f"An unknown error occurred: {e}")
return ExitCode.UNKNOWN_EXCEPTION
finally:
# Ensure resources are cleaned up regardless of how the app exits.
if self.window:
self.window.cleanup()
if self.logger:
self.logger.cleanup()
return ExitCode(exit_code)
[docs]
def main(**kwargs: int | float | bool) -> ExitCode:
"""
Main entry point for the application.
:param kwargs: Keyword arguments for testing, corresponding to command-line options
(e.g., `n_iter=100`, `n_bodies=10`). If provided, command-line parsing is
skipped.
:returns: The application’s exit code.
:rtype: ExitCode
"""
manager = AppManager(sys.argv, **kwargs)
return manager.run()
if __name__ == "__main__":
# This is required for PyInstaller/Nuitka to correctly handle multiprocessing
# when the application is frozen into a single executable.
freeze_support()
# Set the start method to 'spawn' to ensure compatibility with multi-threaded
# frameworks like Qt. This must be done within the `if __name__ == '__main__':`
# block and before any multiprocessing objects are created.
from multiprocess import set_start_method # pylint: disable=ungrouped-imports
set_start_method('spawn')
# The return value of main() is used as the exit code.
# argparse's --help and --version actions call sys.exit() themselves.
# The path hack for direct script execution has been removed in favor of
# standard module execution (`python -m src.main`) to adhere to PEP 8
# and resolve Pylint's `wrong-import-position` warnings.
# If you need to run this file directly, you must ensure the project root
# is in your PYTHONPATH.
sys.exit(main())