From f41b669d67bb846f926df95bcf4e06b77ae1b8ce Mon Sep 17 00:00:00 2001 From: Sylvain Leclerc Date: Fri, 11 Oct 2024 18:36:09 +0200 Subject: [PATCH] feature(installer): update installer version and improve desktop version launcher (#2157) - updates installer submodule to benefit from last fixes and v2.18 handling - make application load dependencies only when necessary, to speed up startup (in particular when only version is requested) - make application exit when the server process ends, instead of hanging - detect when server is already running - separate one popup for "starting" and another for "started" - fix alembic handling in pyinstaller: alembic must not be defined as a script, otherwise it's executed when the application quits. Instead, it must just be analyzed separately to get its dependencies --------- Signed-off-by: Sylvain Leclerc --- AntaresWebLinux.spec | 27 +++-- AntaresWebWin.spec | 27 +++-- antarest/core/cli.py | 101 ++++++++++++++++++ antarest/desktop/__init__.py | 11 ++ antarest/desktop/systray_app.py | 184 ++++++++++++++++++++++++++++++++ antarest/gui.py | 116 ++++++-------------- antarest/main.py | 88 +-------------- installer | 2 +- 8 files changed, 367 insertions(+), 189 deletions(-) create mode 100644 antarest/core/cli.py create mode 100644 antarest/desktop/__init__.py create mode 100644 antarest/desktop/systray_app.py diff --git a/AntaresWebLinux.spec b/AntaresWebLinux.spec index c11ec84a2b..8671f11bfa 100644 --- a/AntaresWebLinux.spec +++ b/AntaresWebLinux.spec @@ -1,25 +1,27 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path -from PyInstaller.utils.hooks import collect_dynamic_libs block_cipher = None -# We need to analyze all alembic files to be sure the migration phase works fine -migrations_dir = Path('alembic/versions') -migration_files = [str(f) for f in migrations_dir.iterdir() if f.is_file() and f.suffix == '.py'] +# We need to analyze all alembic files to be sure the migration phase works fine: +# alembic loads version files by their path, so we need to add them as "data" to the package, +# but all the dependencies they use need to be included also, wo we need to perform a +# dedicated analyse for this. +versions_dir = Path('alembic/versions') +versions_files = [str(f) for f in versions_dir.iterdir() if f.is_file() and f.suffix == '.py'] +alembic_analysis = Analysis(["alembic/env.py"] + versions_files) -binaries = [('./alembic.ini', './alembic.ini')] + collect_dynamic_libs('tables') - -antares_web_server_a = Analysis(['antarest/gui.py', 'alembic/env.py'] + migration_files, +antares_web_server_a = Analysis(['antarest/gui.py'], pathex=[], - binaries=binaries, - datas=[('./resources', './resources'), ('./alembic', './alembic')], + binaries=[], + datas=[('./resources', './resources'), ('./alembic', './alembic'), ('./alembic.ini', './')], hiddenimports=[ 'cmath', 'antarest.dbmodel', 'plyer.platforms.linux', 'plyer.platforms.linux.notification', 'pythonjsonlogger.jsonlogger', + 'tables', ], hookspath=['extra-hooks'], hooksconfig={}, @@ -29,8 +31,13 @@ antares_web_server_a = Analysis(['antarest/gui.py', 'alembic/env.py'] + migratio win_private_assemblies=False, cipher=block_cipher, noarchive=False) -antares_web_server_pyz = PYZ(antares_web_server_a.pure, antares_web_server_a.zipped_data, + +all_python = antares_web_server_a.pure + alembic_analysis.pure +all_zipped_data = antares_web_server_a.zipped_data + alembic_analysis.zipped_data + +antares_web_server_pyz = PYZ(all_python, all_zipped_data, cipher=block_cipher) + antares_web_server_exe = EXE(antares_web_server_pyz, antares_web_server_a.scripts, [], diff --git a/AntaresWebWin.spec b/AntaresWebWin.spec index baa0c614db..8afcb62dae 100644 --- a/AntaresWebWin.spec +++ b/AntaresWebWin.spec @@ -1,25 +1,27 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path -from PyInstaller.utils.hooks import collect_dynamic_libs block_cipher = None -# We need to analyze all alembic files to be sure the migration phase works fine -migrations_dir = Path('alembic/versions') -migration_files = [str(f) for f in migrations_dir.iterdir() if f.is_file() and f.suffix == '.py'] +# We need to analyze all alembic files to be sure the migration phase works fine: +# alembic loads version files by their path, so we need to add them as "data" to the package, +# but all the dependencies they use need to be included also, wo we need to perform a +# dedicated analyse for this. +versions_dir = Path('alembic/versions') +versions_files = [str(f) for f in versions_dir.iterdir() if f.is_file() and f.suffix == '.py'] +alembic_analysis = Analysis(["alembic/env.py"] + versions_files) -binaries = [('./alembic.ini', './alembic.ini')] + collect_dynamic_libs('tables') - -antares_web_server_a = Analysis(['antarest/gui.py', 'alembic/env.py'] + migration_files, +antares_web_server_a = Analysis(['antarest/gui.py'], pathex=[], - binaries=binaries, - datas=[('./resources', './resources'), ('./alembic', './alembic')], + binaries=[], + datas=[('./resources', './resources'), ('./alembic', './alembic'), ('./alembic.ini', './')], hiddenimports=[ 'cmath', 'antarest.dbmodel', 'plyer.platforms.win', 'plyer.platforms.win.notification', 'pythonjsonlogger.jsonlogger', + 'tables', ], hookspath=['extra-hooks'], hooksconfig={}, @@ -29,8 +31,13 @@ antares_web_server_a = Analysis(['antarest/gui.py', 'alembic/env.py'] + migratio win_private_assemblies=False, cipher=block_cipher, noarchive=False) -antares_web_server_pyz = PYZ(antares_web_server_a.pure, antares_web_server_a.zipped_data, + +all_python = antares_web_server_a.pure + alembic_analysis.pure +all_zipped_data = antares_web_server_a.zipped_data + alembic_analysis.zipped_data + +antares_web_server_pyz = PYZ(all_python, all_zipped_data, cipher=block_cipher) + antares_web_server_exe = EXE(antares_web_server_pyz, antares_web_server_a.scripts, [], diff --git a/antarest/core/cli.py b/antarest/core/cli.py new file mode 100644 index 0000000000..e7de0a8140 --- /dev/null +++ b/antarest/core/cli.py @@ -0,0 +1,101 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. + +import argparse +from pathlib import Path + + +class PathType: + """file or directory path type for `argparse` parser + + The `PathType` class represents a type of argument that can be used + with the `argparse` library. + This class takes three boolean arguments, `exists`, `file_ok`, and `dir_ok`, + which specify whether the path argument must exist, whether it can be a file, + and whether it can be a directory, respectively. + + Example Usage:: + + import argparse + from antarest.main import PathType + + parser = argparse.ArgumentParser() + parser.add_argument("--input", type=PathType(file_ok=True, exists=True)) + args = parser.parse_args() + + print(args.input) + + In the above example, `PathType` is used to specify the type of the `--input` + argument for the `argparse` parser. The argument must be an existing file path. + If the given path is not an existing file, the argparse library raises an error. + The Path object representing the given path is then printed to the console. + """ + + def __init__( + self, + exists: bool = False, + file_ok: bool = False, + dir_ok: bool = False, + ) -> None: + if not (file_ok or dir_ok): + msg = "Either `file_ok` or `dir_ok` must be set at a minimum." + raise ValueError(msg) + self.exists = exists + self.file_ok = file_ok + self.dir_ok = dir_ok + + def __call__(self, string: str) -> Path: + """ + Check whether the given string represents a valid path. + + If `exists` is `False`, the method simply returns the given path. + If `exists` is True, it checks whether the path exists and whether it is + a file or a directory, depending on the values of `file_ok` and `dir_ok`. + If the path exists and is of the correct type, the method returns the path; + otherwise, it raises an :class:`argparse.ArgumentTypeError` with an + appropriate error message. + + Args: + string: file or directory path + + Returns: + the file or directory path + + Raises + argparse.ArgumentTypeError: if the path is invalid + """ + file_path = Path(string).expanduser() + if not self.exists: + return file_path + if self.file_ok and self.dir_ok: + if file_path.exists(): + return file_path + msg = f"The file or directory path does not exist: '{file_path}'" + raise argparse.ArgumentTypeError(msg) + elif self.file_ok: + if file_path.is_file(): + return file_path + elif file_path.exists(): + msg = f"The path is not a regular file: '{file_path}'" + else: + msg = f"The file path does not exist: '{file_path}'" + raise argparse.ArgumentTypeError(msg) + elif self.dir_ok: + if file_path.is_dir(): + return file_path + elif file_path.exists(): + msg = f"The path is not a directory: '{file_path}'" + else: + msg = f"The directory path does not exist: '{file_path}'" + raise argparse.ArgumentTypeError(msg) + else: # pragma: no cover + raise NotImplementedError((self.file_ok, self.dir_ok)) diff --git a/antarest/desktop/__init__.py b/antarest/desktop/__init__.py new file mode 100644 index 0000000000..058c6b221a --- /dev/null +++ b/antarest/desktop/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. diff --git a/antarest/desktop/systray_app.py b/antarest/desktop/systray_app.py new file mode 100644 index 0000000000..905d5a0db9 --- /dev/null +++ b/antarest/desktop/systray_app.py @@ -0,0 +1,184 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# +# See AUTHORS.txt +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# SPDX-License-Identifier: MPL-2.0 +# +# This file is part of the Antares project. +import contextlib +import multiprocessing +import platform +import time +import webbrowser +from dataclasses import dataclass +from multiprocessing import Process +from pathlib import Path +from threading import Thread + +import httpx +import uvicorn +from PyQt5.QtGui import QCursor, QIcon +from PyQt5.QtWidgets import QApplication, QMenu, QSystemTrayIcon + +from antarest.core.utils.utils import get_local_path + +RESOURCE_PATH = get_local_path() / "resources" + + +def run_server(config_file: Path) -> None: + from antarest.main import fastapi_app + + app = fastapi_app( + config_file, + mount_front=True, + auto_upgrade_db=True, + )[0] + # noinspection PyTypeChecker + uvicorn.run(app, host="127.0.0.1", port=8080) + + +def start_server(config_file: Path) -> Process: + server = multiprocessing.Process( + target=run_server, + args=(config_file,), + ) + server.start() + return server + + +def open_app() -> None: + webbrowser.open("http://localhost:8080") + + +def monitor_server_process(server: Process, app: QApplication) -> None: + """ + Quits the application when server process ends. + """ + server.join() + app.quit() + + +def setup_exit_application_on_server_end(server: Process, app: QApplication) -> None: + """ + Quits this application when the server process ends, for any reason. + + This allows to close the system tray application when the server + is shutdown by some external action (server failing to start for example, + or killed). + """ + Thread(target=monitor_server_process, args=(server, app)).start() + + +def check_server_started() -> bool: + with contextlib.suppress(httpx.ConnectError): + res = httpx.get("http://localhost:8080/api/health") + if res.status_code == 200: + return True + return False + + +def wait_for_server_start() -> None: + for _ in range(30, 0, -1): + if check_server_started(): + break + time.sleep(1) + + +@dataclass(frozen=True) +class AntaresSystrayApp: + """ + Used to keep ownership of root Qt objects. + QMenu can only be owned by QWidgets, but we don't have one. + """ + + app: QApplication + menu: QMenu + + +def create_systray_app() -> AntaresSystrayApp: + """ + Creates the small application that allows to open + the browser or shutdown the server. + """ + app = QApplication([]) + app.setQuitOnLastWindowClosed(False) + + # Adding an icon + icon = QIcon(str(RESOURCE_PATH / "webapp" / "logo16.png")) + # Adding item on the menu bar + tray = QSystemTrayIcon(icon, app) + tray.setToolTip("AntaresWebServer") + + # Creating the options + menu = QMenu() + open_app_action = menu.addAction("Open application") + assert open_app_action is not None + open_app_action.triggered.connect(open_app) + # To quit the app + quit_action = menu.addAction("Quit") + assert quit_action is not None + quit_action.triggered.connect(app.quit) + + # Adding options to the System Tray + def handle_action(reason: int) -> None: + """ + Shows context menu also on left click + """ + if reason == QSystemTrayIcon.Trigger: # type: ignore + menu = tray.contextMenu() + assert menu is not None + menu.popup(QCursor.pos()) + + tray.setContextMenu(menu) + tray.activated.connect(handle_action) + + tray.setVisible(True) + + return AntaresSystrayApp(app, menu) + + +def notification_popup(message: str, threaded: bool = True) -> None: + """ + Pos a notification message above system tray. + """ + if platform.system() == "Windows": + # noinspection PyPackageRequirements + from win10toast import ToastNotifier # type: ignore + + toaster = ToastNotifier() + toaster.show_toast( + "AntaresWebServer", + message, + icon_path=RESOURCE_PATH / "webapp" / "favicon.ico", + threaded=threaded, + ) + else: + from plyer import notification # type: ignore + + notification.notify( + title="AntaresWebServer", + message=message, + app_name="AntaresWebServer", + app_icon=str(RESOURCE_PATH / "webapp" / "favicon.ico"), + timeout=600, + ) + + +def run_systray_app(config_file: Path) -> None: + if check_server_started(): + notification_popup( + "Antares Web Server already running, you can manage the application within the system tray.", threaded=False + ) + return + notification_popup("Starting Antares Web Server...") + systray_app = create_systray_app() + server = start_server(config_file) + setup_exit_application_on_server_end(server, systray_app.app) + wait_for_server_start() + notification_popup("Antares Web Server started, you can manage the application within the system tray.") + systray_app.app.exec_() + server.kill() diff --git a/antarest/gui.py b/antarest/gui.py index f36904a060..4926e011af 100644 --- a/antarest/gui.py +++ b/antarest/gui.py @@ -9,98 +9,52 @@ # SPDX-License-Identifier: MPL-2.0 # # This file is part of the Antares project. - -import contextlib +import argparse import multiprocessing -import platform -import time -import webbrowser -from multiprocessing import Process -from pathlib import Path - -import httpx -import uvicorn -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QAction, QApplication, QMenu, QSystemTrayIcon - -from antarest.core.utils.utils import get_local_path -from antarest.main import fastapi_app, parse_arguments - -RESOURCE_PATH = get_local_path() / "resources" - -def run_server(config_file: Path) -> None: - app = fastapi_app( - config_file, - mount_front=True, - auto_upgrade_db=True, - )[0] - # noinspection PyTypeChecker - uvicorn.run(app, host="127.0.0.1", port=8080) +from antarest import __version__ +from antarest.core.cli import PathType -def open_app() -> None: - webbrowser.open("http://localhost:8080") +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "-c", + "--config", + type=PathType(exists=True, file_ok=True), + dest="config_file", + help="path to the config file [default: '%(default)s']", + default="./config.yaml", + ) + parser.add_argument( + "-v", + "--version", + action="version", + help="Display the server version and exit", + version=__version__, + ) + return parser.parse_args() def main() -> None: + """ + Entry point for "desktop" version of antares-web. + + This process actually only runs a small app which is accessible + in the system tray. + It spawns the actual server as a separate process. + The systray app allows to shutdown the server, and to open + antares webapp in the users's browser. + """ multiprocessing.freeze_support() + arguments = parse_arguments() - if platform.system() == "Windows": - # noinspection PyPackageRequirements - from win10toast import ToastNotifier # type: ignore - toaster = ToastNotifier() - toaster.show_toast( - "AntaresWebServer", - "Antares Web Server started, you can manage the application within the systray app", - icon_path=RESOURCE_PATH / "webapp" / "favicon.ico", - threaded=True, - ) - else: - from plyer import notification # type: ignore + # VERY important to keep this import here in order to have fast startup + # when only getting version + from antarest.desktop.systray_app import run_systray_app - notification.notify( - title="AntaresWebServer", - message="Antares Web Server started, you can manage the application within the systray app", - app_name="AntaresWebServer", - app_icon=RESOURCE_PATH / "webapp" / "favicon.ico", - timeout=600, - ) - app = QApplication([]) - app.setQuitOnLastWindowClosed(False) - # Adding an icon - icon = QIcon(str(RESOURCE_PATH / "webapp" / "logo16.png")) - # Adding item on the menu bar - tray = QSystemTrayIcon() - tray.setIcon(icon) - tray.setVisible(True) - # Creating the options - menu = QMenu() - open_app_action = QAction("Open application") - menu.addAction(open_app_action) - open_app_action.triggered.connect(open_app) - # To quit the app - quit_action = QAction("Quit") - quit_action.triggered.connect(app.quit) - menu.addAction(quit_action) - # Adding options to the System Tray - tray.setContextMenu(menu) - app.processEvents() - tray.setToolTip("AntaresWebServer") - server = Process( - target=run_server, - args=(arguments.config_file,), - ) - server.start() - for _ in range(30, 0, -1): - with contextlib.suppress(httpx.ConnectError): - res = httpx.get("http://localhost:8080") - if res.status_code == 200: - break - time.sleep(1) - app.exec_() - server.kill() + run_systray_app(arguments.config_file) if __name__ == "__main__": diff --git a/antarest/main.py b/antarest/main.py index e521124447..51440a8560 100644 --- a/antarest/main.py +++ b/antarest/main.py @@ -32,6 +32,7 @@ from antarest import __version__ from antarest.core.application import AppBuildContext +from antarest.core.cli import PathType from antarest.core.config import Config from antarest.core.core_blueprint import create_utils_routes from antarest.core.filesystem_blueprint import create_file_system_blueprint @@ -56,93 +57,6 @@ logger = logging.getLogger(__name__) -class PathType: - """file or directory path type for `argparse` parser - - The `PathType` class represents a type of argument that can be used - with the `argparse` library. - This class takes three boolean arguments, `exists`, `file_ok`, and `dir_ok`, - which specify whether the path argument must exist, whether it can be a file, - and whether it can be a directory, respectively. - - Example Usage:: - - import argparse - from antarest.main import PathType - - parser = argparse.ArgumentParser() - parser.add_argument("--input", type=PathType(file_ok=True, exists=True)) - args = parser.parse_args() - - print(args.input) - - In the above example, `PathType` is used to specify the type of the `--input` - argument for the `argparse` parser. The argument must be an existing file path. - If the given path is not an existing file, the argparse library raises an error. - The Path object representing the given path is then printed to the console. - """ - - def __init__( - self, - exists: bool = False, - file_ok: bool = False, - dir_ok: bool = False, - ) -> None: - if not (file_ok or dir_ok): - msg = "Either `file_ok` or `dir_ok` must be set at a minimum." - raise ValueError(msg) - self.exists = exists - self.file_ok = file_ok - self.dir_ok = dir_ok - - def __call__(self, string: str) -> Path: - """ - Check whether the given string represents a valid path. - - If `exists` is `False`, the method simply returns the given path. - If `exists` is True, it checks whether the path exists and whether it is - a file or a directory, depending on the values of `file_ok` and `dir_ok`. - If the path exists and is of the correct type, the method returns the path; - otherwise, it raises an :class:`argparse.ArgumentTypeError` with an - appropriate error message. - - Args: - string: file or directory path - - Returns: - the file or directory path - - Raises - argparse.ArgumentTypeError: if the path is invalid - """ - file_path = Path(string).expanduser() - if not self.exists: - return file_path - if self.file_ok and self.dir_ok: - if file_path.exists(): - return file_path - msg = f"The file or directory path does not exist: '{file_path}'" - raise argparse.ArgumentTypeError(msg) - elif self.file_ok: - if file_path.is_file(): - return file_path - elif file_path.exists(): - msg = f"The path is not a regular file: '{file_path}'" - else: - msg = f"The file path does not exist: '{file_path}'" - raise argparse.ArgumentTypeError(msg) - elif self.dir_ok: - if file_path.is_dir(): - return file_path - elif file_path.exists(): - msg = f"The path is not a directory: '{file_path}'" - else: - msg = f"The directory path does not exist: '{file_path}'" - raise argparse.ArgumentTypeError(msg) - else: # pragma: no cover - raise NotImplementedError((self.file_ok, self.dir_ok)) - - def parse_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument( diff --git a/installer b/installer index bd51be05c7..eda9075b77 160000 --- a/installer +++ b/installer @@ -1 +1 @@ -Subproject commit bd51be05c7f0f1c634f2cd0c564686ed34b3a6f7 +Subproject commit eda9075b7798827a5679f4aeca6fc27f8e520159