diff --git a/pyproject.toml b/pyproject.toml index e782ac8..e140bda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ 'psutil<5.10', 'pyyaml<6.1', 'pywin32<=306; sys_platform == "win32"', - 'httpx<0.28', + 'requests', 'platformdirs<=4.2.2', 'uvicorn', ] @@ -52,7 +52,6 @@ dependencies = [ "pytest-datadir", "fastapi", "pyinstaller", - "httpx==0.27.0", "requests", "platformdirs==4.2.2", "uvicorn", @@ -79,7 +78,7 @@ dependencies = [ "mypy>=1.0.0", "fastapi", "pyinstaller", - "httpx==0.27.0" + "requests" ] [tool.hatch.envs.types.scripts] check = "mypy --install-types --non-interactive {args:src/antares_web_installer tests}" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e69de29 diff --git a/src/antares_web_installer/app.py b/src/antares_web_installer/app.py index ac98895..8961a46 100644 --- a/src/antares_web_installer/app.py +++ b/src/antares_web_installer/app.py @@ -5,12 +5,12 @@ import textwrap import time import webbrowser - from difflib import SequenceMatcher from pathlib import Path from shutil import copy2, copytree +from typing import List -import httpx +import requests if os.name == "nt": from pythoncom import com_error @@ -31,6 +31,9 @@ SHORTCUT_NAMES = {"posix": "AntaresWebServer.desktop", "nt": "AntaresWebServer.lnk"} SERVER_ADDRESS = "http://127.0.0.1:8080" +HEALTHCHECK_ADDRESS = f"{SERVER_ADDRESS}/api/health" + +MAX_SERVER_START_TIME = 60 class InstallError(Exception): @@ -54,7 +57,7 @@ class App: def __post_init__(self): # Prepare the path to the executable which is located in the target directory server_name = SERVER_NAMES[os.name] - self.server_path = self.target_dir.joinpath("AntaresWeb", server_name) + self.server_path = self.target_dir / "AntaresWeb" / server_name # Set all progress variables needed to compute current progress of the installation self.nb_steps = 2 # kill, install steps @@ -95,35 +98,43 @@ def kill_running_server(self) -> None: Check whether Antares service is up. Kill the process if so. """ - processes_list = list(psutil.process_iter(["pid", "name"])) - processes_list_length = len(processes_list) + server_processes = self._get_server_processes() + if len(server_processes) > 0: + logger.info("Attempt to stop running Antares server ...") + for p in server_processes: + try: + p.kill() + except psutil.NoSuchProcess: + logger.debug(f"The process '{p.pid}' was stopped before being killed.") + continue + gone, alive = psutil.wait_procs(server_processes, timeout=5) + alive_count = len(alive) + if alive_count > 0: + raise InstallError( + "Could not to stop Antares server. Please stop it before launching again the installation." + ) + else: + logger.info("Antares server successfully stopped...") + else: + logger.info("No running server found, resuming installation.") + self.update_progress(100) - for index, proc in enumerate(processes_list): - # evaluate matching between query process name and existing process name + def _get_server_processes(self) -> List[psutil.Process]: + res = [] + for process in psutil.process_iter(["pid", "name"]): try: - matching_ratio = SequenceMatcher(None, "antareswebserver", proc.name().lower()).ratio() + # evaluate matching between query process name and existing process name + matching_ratio = SequenceMatcher(None, "antareswebserver", process.name().lower()).ratio() except FileNotFoundError: - logger.warning("The process '{}' does not exist anymore. Skipping its analysis".format(proc.name())) + logger.warning("The process '{}' does not exist anymore. Skipping its analysis".format(process.name())) continue except psutil.NoSuchProcess: - logger.warning("The process '{}' was stopped before being analyzed. Skipping.".format(proc.name())) + logger.warning("The process '{}' was stopped before being analyzed. Skipping.".format(process.name())) continue if matching_ratio > 0.8: - logger.info("Running server found. Attempt to stop it ...") - logger.debug(f"Server process:{proc.name()} - process id: {proc.pid}") - running_app = psutil.Process(pid=proc.pid) - running_app.kill() - - try: - running_app.wait(5) - except psutil.TimeoutExpired as e: - raise InstallError( - "Impossible to kill the server. Please kill it manually before relaunching the installer." - ) from e - else: - logger.info("The application was successfully stopped.") - self.update_progress((index + 1) * 100 / processes_list_length) - logger.info("No other processes found.") + res.append(process) + logger.debug(f"Running server found: {process.name()} - process id: {process.pid}") + return res def install_files(self): """ """ @@ -254,7 +265,7 @@ def create_shortcuts(self): description="Launch Antares Web Server in background", ) except com_error as e: - raise InstallError("Impossible to create a new shortcut: {}\nSkip shortcut creation".format(e)) + raise InstallError(f"Impossible to create a new shortcut: {e}\nSkipping shortcut creation") from e else: assert shortcut_path in list(desktop_path.iterdir()) logger.info(f"Server shortcut {shortcut_path} was successfully created.") @@ -270,42 +281,33 @@ def start_server(self): args = [self.server_path] server_process = subprocess.Popen( args=args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, cwd=self.target_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, shell=True, ) self.update_progress(50) - if not server_process.poll(): - logger.info("Server is starting up ...") - else: - stdout, stderr = server_process.communicate() - msg = f"The server unexpectedly stopped running. (code {server_process.returncode})" - logger.info(msg) - logger.info(f"Server unexpectedly stopped.\nstdout: {stdout}\nstderr: {stderr}") - raise InstallError(msg) - - nb_attempts = 0 - max_attempts = 10 - - while nb_attempts < max_attempts: - logger.info(f"Attempt #{nb_attempts}...") + start_time = time.time() + nb_attempts = 1 + while time.time() - start_time < MAX_SERVER_START_TIME: + logger.info(f"Waiting for server start (attempt #{nb_attempts})...") + if server_process.poll() is not None: + raise InstallError("Server failed to start, please check server logs.") try: - res = httpx.get(SERVER_ADDRESS + "/health", timeout=1) + res = requests.get(HEALTHCHECK_ADDRESS) if res.status_code == 200: logger.info("The server is now running.") break - except httpx.RequestError: - time.sleep(1) + else: + logger.debug(f"Got HTTP status code {res.status_code} while requesting {HEALTHCHECK_ADDRESS}") + logger.debug(f"Content: {res.text}") + except requests.RequestException as req_err: + logger.debug(f"Error while requesting {HEALTHCHECK_ADDRESS}: {req_err}", exc_info=req_err) + time.sleep(1) nb_attempts += 1 else: - stdout, stderr = server_process.communicate() - msg = "The server didn't start in time" - logger.error(msg) - logger.error(f"stdout: {stdout}\nstderr: {stderr}") - raise InstallError(msg) + raise InstallError("Server didn't start in time, please check server logs.") def open_browser(self): """ diff --git a/src/antares_web_installer/config/__init__.py b/src/antares_web_installer/config/__init__.py index a16fc7b..5fdaa15 100644 --- a/src/antares_web_installer/config/__init__.py +++ b/src/antares_web_installer/config/__init__.py @@ -29,5 +29,8 @@ def update_config(source_path: Path, target_path: Path, version: str) -> None: with target_path.open("r") as f: config = yaml.safe_load(f) + if version_info < (2, 18): + update_to_2_15(config) + with source_path.open(mode="w") as f: yaml.dump(config, f) diff --git a/src/antares_web_installer/config/config_2_18.py b/src/antares_web_installer/config/config_2_18.py new file mode 100644 index 0000000..538152d --- /dev/null +++ b/src/antares_web_installer/config/config_2_18.py @@ -0,0 +1,10 @@ +import typing as t + + +def update_to_2_18(config: t.MutableMapping[str, t.Any]) -> None: + """ + Update the configuration file to version 2.18 in-place: + we need to ensure root_path is / and api_prefix is /api + """ + del config["root_path"] + config["api_prefix"] = "/api" diff --git a/src/antares_web_installer/gui/__main__.py b/src/antares_web_installer/gui/__main__.py old mode 100644 new mode 100755 diff --git a/src/antares_web_installer/gui/controller.py b/src/antares_web_installer/gui/controller.py index 8afd4a7..d05dc4c 100644 --- a/src/antares_web_installer/gui/controller.py +++ b/src/antares_web_installer/gui/controller.py @@ -6,27 +6,21 @@ import typing from pathlib import Path from threading import Thread - -from antares_web_installer.gui.mvc import Controller, ControllerError -from antares_web_installer.gui.model import WizardModel -from antares_web_installer.gui.view import WizardView +from typing import Optional from antares_web_installer import logger from antares_web_installer.app import App, InstallError from antares_web_installer.gui.logger import ConsoleHandler, ProgressHandler, LogFileHandler +from antares_web_installer.gui.model import WizardModel +from antares_web_installer.gui.mvc import Controller +from antares_web_installer.gui.view import WizardView -class InstallationThread(Thread): - def __init__(self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=None): - super().__init__(group, target, name, args, kwargs, daemon=daemon) - - def run(self): - try: - super().run() - except OSError as e: - raise e - except InstallError as e: - raise e +def run_installation(app: App) -> None: + try: + app.run() + except Exception as e: + logger.exception(f"An error occurred during installation: {e}") class WizardController(Controller): @@ -39,8 +33,8 @@ class WizardController(Controller): def __init__(self): super().__init__() self.app = None - self.log_dir = None - self.log_file = None + self.log_dir: Optional[Path] = None + self.log_file: Optional[Path] = None # init loggers self.logger = logger @@ -64,17 +58,18 @@ def init_view(self) -> "WizardView": return WizardView(self) def init_file_handler(self): - self.log_dir = self.model.target_dir.joinpath("logs/") + self.log_dir: Path = self.model.target_dir / "logs" tmp_file_name = "wizard.log" if not self.log_dir.exists(): - self.log_dir = self.model.source_dir.joinpath("logs/") # use the source directory as tmp dir for logs + self.log_dir = self.model.source_dir / "logs" # use the source directory as tmp dir for logs self.logger.debug( "No log directory found with path '{}'. Attempt to generate the path.".format(self.log_dir) ) + self.log_dir.mkdir(parents=True, exist_ok=True) self.logger.info("Path '{}' was successfully created.".format(self.log_dir)) - self.log_file = self.log_dir.joinpath(tmp_file_name) + self.log_file = self.log_dir / tmp_file_name # check if file exists if self.log_file not in list(self.log_dir.iterdir()): @@ -140,29 +135,18 @@ def install(self, callback: typing.Callable): logger.warning("Impossible to create a new shortcut. Skip this step.") logger.debug(e) - thread = InstallationThread(target=self.app.run, args=()) + self.thread = Thread(target=lambda: run_installation(self.app), args=()) try: - thread.run() + self.thread.start() except InstallError as e: self.view.raise_error(e) - def installation_over(self) -> None: - """ - This method makes sure the thread terminated. If not, it waits for it to terminate. - """ - if self.thread: - while self.thread.join(): - if not self.thread.is_alive(): - break - def get_target_dir(self) -> Path: return self.model.target_dir - def set_target_dir(self, path: Path): - result = self.model.set_target_dir(path) - if not result: - raise ControllerError("Path '{}' is not a directory.".format(path)) + def set_target_dir(self, path: Path) -> None: + self.model.set_target_dir(path) def get_shortcut(self) -> bool: return self.model.shortcut diff --git a/src/antares_web_installer/gui/logger.py b/src/antares_web_installer/gui/logger.py index e98d72e..3b253cf 100644 --- a/src/antares_web_installer/gui/logger.py +++ b/src/antares_web_installer/gui/logger.py @@ -2,8 +2,11 @@ import typing +MessageConsumer = typing.Callable[[str], None] + + class ConsoleHandler(logging.Handler): - def __init__(self, callback: typing.Callable): + def __init__(self, callback: MessageConsumer): logging.Handler.__init__(self) self.setLevel(logging.INFO) formatter = logging.Formatter("[%(asctime)-15s] %(message)s") @@ -15,7 +18,7 @@ def emit(self, logs: logging.LogRecord): class ProgressHandler(logging.Handler): - def __init__(self, callback: typing.Callable): + def __init__(self, callback: MessageConsumer): """ This logging handler intercept all logs that are progression values @param progress_var: tkinter.StringVar diff --git a/src/antares_web_installer/gui/mvc.py b/src/antares_web_installer/gui/mvc.py index fb9bf7c..8d3455a 100644 --- a/src/antares_web_installer/gui/mvc.py +++ b/src/antares_web_installer/gui/mvc.py @@ -6,6 +6,8 @@ from __future__ import annotations import tkinter as tk +import traceback +from tkinter import messagebox class Model: @@ -35,6 +37,11 @@ class View(tk.Tk): def __init__(self, controller: Controller): super().__init__() self.controller = controller + self.report_callback_exception = self.show_error + + def show_error(self, *args): + err = traceback.format_exception(*args) + messagebox.showerror("Exception", "".join(err)) class ControllerError(Exception): diff --git a/src/antares_web_installer/gui/view.py b/src/antares_web_installer/gui/view.py index e85f1f0..2dc140e 100644 --- a/src/antares_web_installer/gui/view.py +++ b/src/antares_web_installer/gui/view.py @@ -154,11 +154,7 @@ def get_target_dir(self) -> Path: return self.controller.get_target_dir() def set_target_dir(self, new_target_dir: str): - try: - self.controller.set_target_dir(Path(new_target_dir)) - except ControllerError as e: - logger.warning("Path is not valid: {}".format(e)) - self.raise_warning("Path selected is not valid") + self.controller.set_target_dir(Path(new_target_dir)) def get_launch(self) -> bool: return self.controller.get_launch() @@ -188,5 +184,4 @@ def run_installation(self, callback): self.controller.install(callback) def installation_over(self): - self.controller.installation_over() self.frames["progress_frame"].installation_over() diff --git a/src/antares_web_installer/gui/widgets/frame.py b/src/antares_web_installer/gui/widgets/frame.py index e7234e9..9bd42a9 100644 --- a/src/antares_web_installer/gui/widgets/frame.py +++ b/src/antares_web_installer/gui/widgets/frame.py @@ -280,5 +280,5 @@ def __init__(self, master: tk.Misc, window: "WizardView", *args, **kwargs): style="Description.TLabel", ).pack(side="top", fill="x") - self.control_btn = ControlFrame(parent=self, window=window, finish_btn=True) + self.control_btn = ControlFrame(parent=self, window=window, cancel_btn=False, finish_btn=True) self.control_btn.pack(side="bottom", fill="x") diff --git a/src/antares_web_installer/shortcuts/_win32_shell.py b/src/antares_web_installer/shortcuts/_win32_shell.py index 205095a..e146b6a 100644 --- a/src/antares_web_installer/shortcuts/_win32_shell.py +++ b/src/antares_web_installer/shortcuts/_win32_shell.py @@ -11,13 +11,12 @@ import os import typing as t +from contextlib import contextmanager +import pythoncom import win32com.client from win32com.shell import shell, shellcon -_WSHELL = win32com.client.Dispatch("Wscript.Shell") - - # Windows Special Folders # see: https://docs.microsoft.com/en-us/windows/win32/shell/csidl @@ -42,6 +41,15 @@ def get_start_menu() -> str: return shell.SHGetFolderPath(0, shellcon.CSIDL_PROGRAMS, None, 0) # type: ignore +@contextmanager +def initialize_com(): + try: + pythoncom.CoInitialize() + yield + finally: + pythoncom.CoUninitialize() + + def create_shortcut( target: t.Union[str, os.PathLike], exe_path: t.Union[str, os.PathLike], @@ -56,13 +64,15 @@ def create_shortcut( if isinstance(arguments, str): arguments = [arguments] if arguments else [] - wscript = _WSHELL.CreateShortCut(str(target)) - wscript.TargetPath = str(exe_path) - wscript.Arguments = " ".join(arguments) - wscript.WorkingDirectory = str(working_dir) - wscript.WindowStyle = 0 - if description: - wscript.Description = description - if icon_path: - wscript.IconLocation = str(icon_path) - wscript.save() + with initialize_com(): + _WSHELL = win32com.client.Dispatch("Wscript.Shell") + wscript = _WSHELL.CreateShortCut(str(target)) + wscript.TargetPath = str(exe_path) + wscript.Arguments = " ".join(arguments) + wscript.WorkingDirectory = str(working_dir) + wscript.WindowStyle = 0 + if description: + wscript.Description = description + if icon_path: + wscript.IconLocation = str(icon_path) + wscript.save() diff --git a/tests/config/test_config_2_18.py b/tests/config/test_config_2_18.py new file mode 100644 index 0000000..c6a1201 --- /dev/null +++ b/tests/config/test_config_2_18.py @@ -0,0 +1,12 @@ +from pathlib import Path + +import yaml + +from antares_web_installer.config.config_2_18 import update_to_2_18 + + +def test_update_to_2_18(datadir: Path) -> None: + config = yaml.safe_load(datadir.joinpath("application-2.17.yaml").read_text()) + expected = yaml.safe_load(datadir.joinpath("application-2.18.yaml").read_text()) + update_to_2_18(config) + assert config == expected diff --git a/tests/config/test_config_2_18/application-2.17.yaml b/tests/config/test_config_2_18/application-2.17.yaml new file mode 100644 index 0000000..7641da3 --- /dev/null +++ b/tests/config/test_config_2_18/application-2.17.yaml @@ -0,0 +1,42 @@ +security: + disabled: false + jwt: + key: super-secret + login: + admin: + pwd: admin + +db: + url: "sqlite:////home/john/antares_data/database.db" + +storage: + tmp_dir: /tmp + matrixstore: /home/john/antares_data/matrices + archive_dir: /home/john/antares_data/archives + allow_deletion: false + workspaces: + default: + path: /home/john/antares_data/internal_studies/ + studies: + path: /home/john/antares_data/studies/ + +launcher: + default: local + local: + binaries: + 850: /home/john/opt/antares-8.5.0-Ubuntu-20.04/antares-solver + 860: /home/john/opt/antares-8.6.0-Ubuntu-20.04/antares-8.6-solver + enable_nb_cores_detection: True + +debug: false + +root_path: "/api" + +server: + worker_threadpool_size: 12 + services: + - watcher + - matrix_gc + +logging: + level: INFO diff --git a/tests/config/test_config_2_18/application-2.18.yaml b/tests/config/test_config_2_18/application-2.18.yaml new file mode 100644 index 0000000..2a6d796 --- /dev/null +++ b/tests/config/test_config_2_18/application-2.18.yaml @@ -0,0 +1,42 @@ +security: + disabled: false + jwt: + key: super-secret + login: + admin: + pwd: admin + +db: + url: "sqlite:////home/john/antares_data/database.db" + +storage: + tmp_dir: /tmp + matrixstore: /home/john/antares_data/matrices + archive_dir: /home/john/antares_data/archives + allow_deletion: false + workspaces: + default: + path: /home/john/antares_data/internal_studies/ + studies: + path: /home/john/antares_data/studies/ + +launcher: + default: local + local: + binaries: + 850: /home/john/opt/antares-8.5.0-Ubuntu-20.04/antares-solver + 860: /home/john/opt/antares-8.6.0-Ubuntu-20.04/antares-8.6-solver + enable_nb_cores_detection: True + +debug: false + +api_prefix: "/api" + +server: + worker_threadpool_size: 12 + services: + - watcher + - matrix_gc + +logging: + level: INFO diff --git a/tests/integration/server_mock/web.py b/tests/integration/server_mock/web.py index b484601..f29c0ca 100644 --- a/tests/integration/server_mock/web.py +++ b/tests/integration/server_mock/web.py @@ -30,7 +30,7 @@ async def lifespan(_unused_app: FastAPI) -> t.AsyncGenerator[None, None]: app = FastAPI(lifespan=lifespan) -@app.get("/health") +@app.get("/api/health") async def health(): """ Endpoint to check that the server is ready. diff --git a/tests/integration/test_run_server.py b/tests/integration/test_run_server.py index 84063fe..5b7a1ce 100644 --- a/tests/integration/test_run_server.py +++ b/tests/integration/test_run_server.py @@ -1,8 +1,9 @@ -import httpx # type: ignore -import subprocess import socket +import subprocess import time +import requests # type: ignore + SPAWN_TIMEOUT = 10 """Timeout in seconds to wait for the server process to start.""" @@ -67,7 +68,7 @@ def test_server_health(antares_web_server_paths): else: raise RuntimeError("The server did not start in time.") - res = httpx.get("http://localhost:8080/health", timeout=0.25) + res = requests.get("http://localhost:8080/api/health", timeout=0.25) assert res.status_code == 200, res.json() assert res.json() == {"status": "available"}