Source code for src.main

#!/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())