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
import signal
from multiprocess import freeze_support
from PySide6.QtWidgets import QApplication, QMainWindow
from PySide6.QtCore import (
    QLibraryInfo, QTranslator, QLocale, qCritical)
from PySide6.QtGui import QIcon
# QML is no longer used for the main window
# from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType
# from PySide6.QtQuick import QQuickView  # Not used anymore
from src.args import CmdLineArgs, CmdLineValidationError, ExitCode
from src import constants
from src.logger import Logger
from src.qml_bridge import QmlBridge
from src.app_profiler import AppProfiler
from src import rc_linguist, rc_about, rc_icons  # pylint: disable=unused-import
# Note: rc_main will be generated when main.qrc is compiled by pyside6-project
# For now, we'll load QML from file path instead of resources


# MainWindow class removed - replaced by native QMainWindow in AppManager


[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 QmlBridge | None bridge: The QML bridge for exposing C++ functionality. :ivar QMainWindow | None main_window: The main application window. :ivar Display | None _display_ref: Reference to Display to keep it alive until QApplication is destroyed. """ # 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 bridge: QmlBridge | None = None main_window: QMainWindow | None = None # Native QMainWindow (not from QML) _display_ref = None # Reference to Display to keep it alive until QApplication is destroyed 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. AppProfiler.start_qt_init() self.app = QApplication(self._argv) AppProfiler.stop_qt_init() 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() # Create the QML bridge (used for Display and actions) AppProfiler.start_window_creation() self.bridge = QmlBridge(self.cmd_args, self.app) AppProfiler.stop_window_creation() # Create a main window widget to host the Display self.main_window = QMainWindow() self.main_window.setWindowTitle( QApplication.translate("MainWindow", "Pure Python")) self.main_window.resize(800, 600) # Create native menu bar with QML bridge integration # Use "MainWindow" context to match the translations menu_bar = self.main_window.menuBar() # File menu file_menu = menu_bar.addMenu( QApplication.translate("MainWindow", "&File")) quit_action = file_menu.addAction( QApplication.translate("MainWindow", "&Quit")) quit_action.setShortcut("Ctrl+Q") quit_action.triggered.connect(self.bridge.quit) # Help menu help_menu = menu_bar.addMenu( QApplication.translate("MainWindow", "&Help")) about_action = help_menu.addAction( QApplication.translate("MainWindow", "About Pure Python")) about_action.triggered.connect(self.bridge.showAbout) about_qt_action = help_menu.addAction( QApplication.translate("MainWindow", "About Qt")) about_qt_action.triggered.connect(self.bridge.showAboutQt) # Add the Display container as the central widget # With simplified cleanup (letting QApplication handle destruction), # createWindowContainer should work without segfault container = self.bridge.displayContainer if container is not None: self.main_window.setCentralWidget(container) # Connect simulation finished to close the window self.bridge.simulationFinished.connect(self.main_window.close) # --- Graceful Shutdown Setup --- # Ensure that Ctrl+C (SIGINT) triggers a clean shutdown, allowing the # `finally` block in `run()` to execute for resource cleanup. signal.signal(signal.SIGINT, lambda *args: self.app.quit())
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.main_window and self.app and self.logger and self.bridge, "Application setup failed" # Show the main window (Display is embedded via container) self.main_window.show() self.bridge.startSimulation() # Start timing the event loop AppProfiler.start_event_loop() exit_code = self.app.exec() # Stop timing the event loop (exec() has returned) AppProfiler.stop_event_loop() 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: # Stop event loop timing if it's still running (should already be stopped, but be safe) AppProfiler.stop_event_loop() AppProfiler.start_cleanup() # According to Qt forum suggestions: # - Let QApplication deal with widget destruction automatically # - Only stop the physics thread properly before QApplication destruction # - No manual widget deletion needed after exec() returns # Step 1: Stop the physics thread and cleanup display (prints profiling report) # This is the only cleanup we need to do manually, as the thread must be # stopped before QApplication is destroyed if self.bridge: self.bridge.cleanup() # Step 2: Cleanup logger (closes log files) if self.logger: self.logger.cleanup() # Step 3: Let QApplication handle all widget destruction automatically # QApplication knows all widgets (via QApplication::allWidgets()) and will # destroy them properly. No manual deletion needed. AppProfiler.stop_cleanup() AppProfiler.print_report() return ExitCode(exit_code)
[docs] def main(**kwargs: int | float | bool) -> ExitCode: """ Main entry point for the application. NOTE: The Display (Qt3DWindow) is integrated into QMainWindow using createWindowContainer(). With simplified cleanup (letting QApplication handle destruction automatically), this works without segfault. See SEGFAULT_ISSUE.md for details. :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())