diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index bcde6083..edca2bfa 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -54,7 +54,7 @@ jobs: - uses: pyansys/actions/tests-pytest@v4 with: - pytest-extra-args: "--cov=ansys --cov-report=term" + python-version: ${{ matrix.python-version }} doc-build: name: "Build documentation" @@ -119,12 +119,7 @@ jobs: run: pyinstaller frozen.spec - name: Install NSIS - run: | - iwr -useb get.scoop.sh -outfile 'install.ps1' - .\install.ps1 -RunAsAdmin - scoop update - scoop bucket add extras - scoop install nsis + run: choco install nsis -y - name: Print NSIS version run: makensis -VERSION @@ -159,12 +154,8 @@ jobs: - name: Display structure of downloaded files run: ls -R - - uses: montudor/action-zip@v1 - with: - args: zip -qq -r python-installer-gui.zip installer - - name: "Release to GitHub" uses: softprops/action-gh-release@v1 with: - files: python-installer-gui.zip + files: installer/*.exe generate_release_notes: true \ No newline at end of file diff --git a/frozen.spec b/frozen.spec index afbcb405..ed6da00c 100644 --- a/frozen.spec +++ b/frozen.spec @@ -15,7 +15,8 @@ except NameError: OUT_PATH = 'ansys_python_manager' APP_NAME = 'Ansys Python Manager' -ASSETS_PATH = os.path.join(THIS_PATH, 'src/ansys/tools/installer/assets') +INSTALLER_PATH = os.path.join(THIS_PATH, 'src/ansys/tools/installer') +ASSETS_PATH = os.path.join(INSTALLER_PATH, 'assets') ICON_FILE = os.path.join(ASSETS_PATH, 'pyansys_icon.ico') # consider testing paths @@ -28,13 +29,14 @@ added_files = [ (os.path.join(ASSETS_PATH, 'pyansys-light-crop.png'), 'assets'), (os.path.join(ASSETS_PATH, 'ansys-favicon.png'), 'assets'), (os.path.join(ASSETS_PATH, 'pyansys_icon.ico'), 'assets'), + (os.path.join(INSTALLER_PATH, 'VERSION'), '.'), ] a = Analysis([main_py], pathex=[], binaries=[], datas=added_files, - hiddenimports=[], + hiddenimports=['_cffi_backend'], hookspath=[], runtime_hooks=[], excludes=[], diff --git a/pyproject.toml b/pyproject.toml index 8be40422..d5b5ff57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ maintainers = [ {name = "PyAnsys Developers", email = "pyansys.maintainers@ansys.com"}, ] dependencies = [ + "packaging", + "PyGitHub", "appdirs", "requests", "PySide6", diff --git a/setup.nsi b/setup.nsi index 46d50b88..814698db 100644 --- a/setup.nsi +++ b/setup.nsi @@ -1,47 +1,79 @@ ; NSIS script for Ansys Python Manager installer - ; Set the name, version, and output path of the installer +!define VERSION_FILE "src/ansys/tools/installer/VERSION" !define PRODUCT_NAME "Ansys Python Manager" -!define PRODUCT_VERSION "0.1.0-beta0" -!define OUTFILE_NAME "Ansys Python Manager Setup-v${PRODUCT_VERSION}.exe" +!define /file PRODUCT_VERSION "src/ansys/tools/installer/VERSION" +!define OUTFILE_NAME "Ansys-Python-Manager-Setup-v${PRODUCT_VERSION}.exe" + Name "${PRODUCT_NAME}" VIProductVersion "${PRODUCT_VERSION}" OutFile "dist\${OUTFILE_NAME}" + +!include "MUI2.nsh" +!include "InstallOptions.nsh" +!define MUI_PAGE_CUSTOMFUNCTION_PRE oneclickpre +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE oneclickleave +!insertmacro MUI_PAGE_INSTFILES +!include "uninstall.nsi" + ; Define the installer sections Section "Ansys Python Manager" SEC01 ; Set the installation directory to the program files directory SetOutPath "$PROGRAMFILES64\ANSYS Inc\Ansys Python Manager" - + ; Copy the files from the dist\ansys_python_manager directory + ; File /r /oname=ignore "dist\ansys_python_manager\*" File /r "dist\ansys_python_manager\*" - + ; Create the start menu directory CreateDirectory "$SMPROGRAMS\Ansys Python Manager" - + ; Create the start menu shortcut CreateShortCut "$SMPROGRAMS\Ansys Python Manager\Ansys Python Manager.lnk" "$INSTDIR\Ansys Python Manager.exe" + + ; Add the program to the installed programs list + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayName" "${PRODUCT_NAME}" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayIcon" "$\"$INSTDIR\Ansys Python Manager.exe$\"" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "Publisher" "ANSYS Inc" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "Version" "${PRODUCT_VERSION}" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayVersion" "${PRODUCT_VERSION}" + + WriteUninstaller "$INSTDIR\uninstall.exe" + + ; start after install + Exec "$INSTDIR\Ansys Python Manager.exe" + SectionEnd ; Define the uninstaller section Section "Uninstall" SEC02 - ; Remove the installed files + Delete "$PROGRAMFILES64\Ansys Python Manager\*.*" RMDir "$PROGRAMFILES64\Ansys Python Manager" - - ; Remove the registry keys - DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Ansys Python Manager" - - ; Remove the start menu shortcut and directory + DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" Delete "$SMPROGRAMS\Ansys Python Manager\Ansys Python Manager.lnk" RMDir "$SMPROGRAMS\Ansys Python Manager" SectionEnd -; Set the installer properties -Name "${PRODUCT_NAME}" Icon "dist\ansys_python_manager\assets\pyansys_icon.ico" InstallDir "$PROGRAMFILES64\ANSYS Inc\Ansys Python Manager" -; Simplify the installer GUI +; Define the custom functions for the MUI2 OneClick plugin InstProgressFlags smooth +Function oneclickpre + !insertmacro MUI_HEADER_TEXT "Installing ${PRODUCT_NAME}" "Please wait while the installation completes." + ; !define MUI_UNICON "${NSISDIR}\Contrib\Graphics\Icons\modern-uninstall.ico" + HideWindow +FunctionEnd + +Function oneclickleave + Quit +FunctionEnd + +; Call the MUI2 OneClick plugin +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + diff --git a/src/ansys/tools/installer/VERSION b/src/ansys/tools/installer/VERSION new file mode 100644 index 00000000..9482f367 --- /dev/null +++ b/src/ansys/tools/installer/VERSION @@ -0,0 +1 @@ +0.1.0-beta1 diff --git a/src/ansys/tools/installer/__init__.py b/src/ansys/tools/installer/__init__.py index cb923ec4..7b9e1be9 100644 --- a/src/ansys/tools/installer/__init__.py +++ b/src/ansys/tools/installer/__init__.py @@ -1,16 +1,28 @@ """ Ansys Python Manager """ - -__version__ = "0.0.dev0" - -import logging import os +import sys import warnings from appdirs import user_cache_dir -from ansys.tools.installer.main import AnsysPythonInstaller, open_gui +if getattr(sys, "frozen", False): + # If the application is run as a bundle, the PyInstaller bootloader + # extends the sys module by a flag frozen=True and sets the app + # path into variable _MEIPASS'. + try: + _THIS_PATH = sys._MEIPASS + except: + # this might occur on a single file install + os.path.dirname(sys.executable) +else: + _THIS_PATH = os.path.dirname(os.path.abspath(__file__)) + +# Read in version programmatically from plain text +# this is done so NSIS can also link to the same version +with open(os.path.join(_THIS_PATH, "VERSION")) as fid: + __version__ = fid.read() CACHE_DIR = user_cache_dir("ansys_python_installer") @@ -23,16 +35,8 @@ warnings.warn(f"Unable create cache at {CACHE_DIR}. Using temporary directory") CACHE_DIR = tempdir.gettempdir() -ENABLE_LOGGING = True -if ENABLE_LOGGING: - logger = logging.getLogger() - logger.setLevel(logging.DEBUG) - - # Create a console handler that writes to stdout - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.DEBUG) - formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") - console_handler.setFormatter(formatter) - # Add the console handler to the logger - logger.addHandler(console_handler) +try: + from ansys.tools.installer.main import open_gui # place this at end to allow import +except ModuleNotFoundError: # encountered during install + pass diff --git a/src/ansys/tools/installer/__main__.py b/src/ansys/tools/installer/__main__.py index 1829d0f3..eda1fabb 100644 --- a/src/ansys/tools/installer/__main__.py +++ b/src/ansys/tools/installer/__main__.py @@ -1,6 +1,6 @@ """Main entrypoint for this module.""" -from ansys.tools.installer import open_gui +from ansys.tools.installer.main import open_gui if __name__ == "__main__": open_gui() diff --git a/src/ansys/tools/installer/auto_updater.py b/src/ansys/tools/installer/auto_updater.py new file mode 100644 index 00000000..73ccf4fc --- /dev/null +++ b/src/ansys/tools/installer/auto_updater.py @@ -0,0 +1,45 @@ +""" +Check for updates. +""" + +from github import Github +from packaging import version + +# Readonly on this repo +# This repository will be released to the public, there's no issue with this token. +# Exp Mon, Jan 1 2024, should be able to use public unauth by then +READ_ONLY_PAT = "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" + + +def query_gh_latest_release(): + """Check GitHub for updates. + + Compares the current version with the version on GitHub. + + Returns the version of the latest release and the download url of + the executable installer. + + Returns + ------- + str + Tag of the latest version. + + str + Url of the latest release installer. + + """ + gh = Github(READ_ONLY_PAT) + repo = gh.get_repo(f"pyansys/python-installer-qt-gui") + + # Get the latest release and its tag name + latest_release = repo.get_latest_release() + latest_version_tag = latest_release.tag_name + + download_asset = None + for asset in latest_release.get_assets(): + if asset.name.endswith(".exe"): + download_asset = asset + + download_url = None if download_asset is None else download_asset.url + + return version.parse(latest_version_tag), download_url diff --git a/src/ansys/tools/installer/common.py b/src/ansys/tools/installer/common.py index dab3c132..85340269 100644 --- a/src/ansys/tools/installer/common.py +++ b/src/ansys/tools/installer/common.py @@ -1,4 +1,11 @@ +from functools import wraps +import logging +import sys from threading import Thread +import traceback + +LOG = logging.getLogger(__name__) +LOG.setLevel("DEBUG") def threaded(fn): @@ -10,3 +17,34 @@ def wrapper(*args, **kwargs): return thread return wrapper + + +def protected(fn): + """Captures any exceptions from a function and passes it to the gui. + + Attempts to display the error using ``show_error`` and protects + the main application from segmentation faulting. + """ + + @wraps(fn) + def wrapper(*args, **kwargs): + self = args[0] + try: + return fn(*args, **kwargs) + except Exception as exception: + exc_info = sys.exc_info() + traceback.print_exception(*exc_info) + LOG.error(exception) + if hasattr(self, "exceptions"): + self._exceptions.append(exception) + + # Visual error handing + if hasattr(self, "parent"): + if hasattr(self.parent, "show_error"): + self.parent.show_error(exception) + return wrapper + + if hasattr(self, "_show_error"): + self._show_error(exception) + + return wrapper diff --git a/src/ansys/tools/installer/find_python.py b/src/ansys/tools/installer/find_python.py index 0edc686d..d201a266 100644 --- a/src/ansys/tools/installer/find_python.py +++ b/src/ansys/tools/installer/find_python.py @@ -1,6 +1,9 @@ +""" +Search for Python or miniforge installations within the Windows registry. + +""" import logging import os -import subprocess try: import winreg @@ -11,7 +14,7 @@ raise err else: # This means that we are trying to build the docs, - # or develop on Linux... but definitely not "use" it on + # or develop on Linux... but definitely do not "use" it on # an OS different than Windows since it would crash. So, # just ignore it. pass @@ -21,13 +24,22 @@ def find_miniforge(): - """Find all installations of miniforge.""" - forge = _find_miniforge(True) - forge.update(_find_miniforge(False)) - return forge + """Find all installations of miniforge within the Windows registry. + + Returns + ------- + dict + Dictionary containing a key for each path and a ``tuple`` + containing ``(version_str, is_admin)``. + + """ + paths = _find_miniforge(True) + paths.update(_find_miniforge(False)) + return paths def _find_miniforge(admin=False): + """Search for any miniforge installations in the registry.""" if admin: root_key = winreg.HKEY_LOCAL_MACHINE else: @@ -35,79 +47,92 @@ def _find_miniforge(admin=False): paths = {} key = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall" - with winreg.OpenKey( - root_key, - key, - access=winreg.KEY_READ, - ) as reg_key: - info = winreg.QueryInfoKey(reg_key) - for i in range(info[0]): - subkey_name = winreg.EnumKey(reg_key, i) - if "Miniforge" in subkey_name: - with winreg.OpenKey( - root_key, - key + "\\" + subkey_name, - access=winreg.KEY_READ, - ) as sub_key: - ver = winreg.QueryValueEx(sub_key, "DisplayVersion")[0] - uninstall_exe = winreg.QueryValueEx(sub_key, "UninstallString")[ - 0 - ].replace('"', "") - miniforge_path = os.path.dirname(uninstall_exe) - paths[miniforge_path] = (ver, admin) + try: + with winreg.OpenKey( + root_key, + key, + access=winreg.KEY_READ, + ) as reg_key: + info = winreg.QueryInfoKey(reg_key) + for i in range(info[0]): + subkey_name = winreg.EnumKey(reg_key, i) + if "Miniforge" in subkey_name: + with winreg.OpenKey( + root_key, + key + "\\" + subkey_name, + access=winreg.KEY_READ, + ) as sub_key: + ver = winreg.QueryValueEx(sub_key, "DisplayVersion")[0] + uninstall_exe = winreg.QueryValueEx(sub_key, "UninstallString")[ + 0 + ].replace('"', "") + miniforge_path = os.path.dirname(uninstall_exe) + paths[miniforge_path] = (ver, admin) + except FileNotFoundError: + pass + return paths -def find_installed_python(version, admin=False): +def _find_installed_python(admin=False): """Check the registry for any installed instances of Python.""" if admin: - key = winreg.HKEY_LOCAL_MACHINE + root_key = winreg.HKEY_LOCAL_MACHINE else: - key = winreg.HKEY_CURRENT_USER + root_key = winreg.HKEY_CURRENT_USER install_path = None + paths = {} try: + base_key = f"SOFTWARE\\Python\\PythonCore" with winreg.OpenKey( - key, - f"SOFTWARE\\Python\\PythonCore\\{version}\\InstallPath", + root_key, + base_key, access=winreg.KEY_READ, ) as reg_key: info = winreg.QueryInfoKey(reg_key) - for i in range(info[1]): - name, value, _ = winreg.EnumValue(reg_key, i) - if name == "ExecutablePath": - if os.path.isfile(value) and value.endswith("python.exe"): - install_path = os.path.dirname(value) + for i in range(info[0]): + name = winreg.EnumKey(reg_key, i) + ver, path = get_python_info(f"{base_key}\\{name}", root_key) + if ver is not None and path is not None: + paths[path] = (ver, admin) except FileNotFoundError: pass - return install_path + return paths + + +def get_python_info(key, root_key): + """For a given key, read the install path and python version.""" + with winreg.OpenKey(root_key, key, access=winreg.KEY_READ) as reg_key: + try: + ver = winreg.QueryValueEx(reg_key, "Version")[0] + except FileNotFoundError: + ver = None + + try: + with winreg.OpenKey( + root_key, f"{key}\\InstallPath", access=winreg.KEY_READ + ) as path_key: + path = winreg.QueryValueEx(path_key, None)[0] + except FileNotFoundError: + path = None + + return ver, path def find_all_python(): - """Find any installed instances of python.""" - installed = [{}, {}] - for admin in [True, False]: - for version in range(7, 13): - path = find_installed_python(f"3.{version}", admin) - if path is not None: - # quickly check the patch version of python - command = f'{os.path.join(path, "python.exe")} --version' - ps_command = ["powershell.exe", "-command", command] - LOG.debug("Running: %s", str(ps_command)) - proc = subprocess.Popen( - ps_command, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - creationflags=subprocess.CREATE_NO_WINDOW, - ) - out, error = proc.communicate() - nice_version = out.decode().strip() - if "Python" in nice_version: - nice_version = nice_version.replace("Python", "").strip() - LOG.debug("Found %s at %s", nice_version, path) - installed[admin][nice_version] = path - - return installed + """Find any installed instances of python. + + Returns + ------- + dict + Dictionary containing a key for each path and a ``tuple`` + containing ``(version_str, is_admin)``. + + """ + paths = _find_installed_python(True) + paths.update(_find_installed_python(False)) + return paths diff --git a/src/ansys/tools/installer/installed_table.py b/src/ansys/tools/installer/installed_table.py index 0b8d40ef..ccc7d194 100644 --- a/src/ansys/tools/installer/installed_table.py +++ b/src/ansys/tools/installer/installed_table.py @@ -1,10 +1,11 @@ import logging import os import subprocess +import time from PySide6 import QtCore, QtWidgets -from ansys.tools.installer.common import threaded +# from ansys.tools.installer.common import threaded from ansys.tools.installer.find_python import find_all_python, find_miniforge ALLOWED_FOCUS_EVENTS = [QtCore.QEvent.WindowActivate, QtCore.QEvent.Show] @@ -14,32 +15,58 @@ class PyInstalledTable(QtWidgets.QTableWidget): + """Table of locally installed Python environments.""" + + signal_update = QtCore.Signal() + def __init__(self, parent=None): + """Initialize the table by populating it.""" super().__init__(1, 1, parent) + self._destroyed = False + self._locked = True self.populate() + self.signal_update.connect(self.populate) + + def update(self, timeout=1.0): + """Update this table. + + Respects a lock to ensure no race conditions or multiple calls on the table. + + """ + tstart = time.time() + while self._locked: + time.sleep(0.001) + if time.time() - tstart > timeout: + return + + self.signal_update.emit() - @threaded def populate(self): """Populate the table.""" + self._locked = True LOG.debug("Populating the table") self.clear() - installed = find_all_python() + + # query for all installations of Python + installed_python = find_all_python() installed_forge = find_miniforge() - tot = len(installed[0]) + len(installed[1]) + len(installed_forge) + + if self._destroyed: + return + + tot = len(installed_python) + len(installed_forge) self.setRowCount(tot) self.setColumnCount(3) - self.setHorizontalHeaderLabels(["Version", "Admin", "Path"]) self.verticalHeader().setVisible(False) self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) row = 0 - for admin in [True, False]: - for version, path in installed[admin].items(): - self.setItem(row, 0, QtWidgets.QTableWidgetItem(f"Python v{version}")) - self.setItem(row, 1, QtWidgets.QTableWidgetItem(str(admin))) - self.setItem(row, 2, QtWidgets.QTableWidgetItem(path)) - row += 1 + for path, (version, admin) in installed_python.items(): + self.setItem(row, 0, QtWidgets.QTableWidgetItem(f"Python {version}")) + self.setItem(row, 1, QtWidgets.QTableWidgetItem(str(admin))) + self.setItem(row, 2, QtWidgets.QTableWidgetItem(path)) + row += 1 for path, (version, admin) in installed_forge.items(): self.setItem(row, 0, QtWidgets.QTableWidgetItem(f"Conda {version}")) @@ -51,6 +78,14 @@ def populate(self): self.selectRow(0) self.horizontalHeader().setStretchLastSection(True) + self.destroyed.connect(self.stop) + + self._locked = False + + def stop(self): + """Flag that this object is gone.""" + self._destroyed = True + @property def active_path(self): """Path of the active row.""" @@ -63,9 +98,10 @@ def active_version(self): class InstalledTab(QtWidgets.QWidget): - signal_update = QtCore.Signal() + """Installed Python versions tab.""" def __init__(self, parent): + """Initialize this tab.""" super().__init__() self._parent = parent layout = QtWidgets.QVBoxLayout() @@ -133,40 +169,33 @@ def __init__(self, parent): self.table = PyInstalledTable() layout.addWidget(self.table) - # Connect the focusInEvent signal to the on_focus_in method + # ensure the table is always in focus self.installEventFilter(self) - # other connects - self.signal_update.connect(self.table.populate) - def update_table(self): - """Update this tab's table.""" - print("emit") - self.signal_update.emit() + """Update the Python version table.""" + self.table.update() def eventFilter(self, source, event): + """Filter events and ensure that the table always remains in focus.""" if event.type() in ALLOWED_FOCUS_EVENTS and source is self: self.table.setFocus() return super().eventFilter(source, event) - def on_focus_in(self, event): - # Set the focus to the table whenever the widget gains focus - self.table.setFocus() - def launch_spyder(self): - """Launch spyder IDE""" + """Launch spyder IDE.""" # handle errors error_msg = "pip install spyder && spyder || echo Failed to launch. Try reinstalling spyder with pip install spyder --force-reinstall" self.launch_cmd(f"spyder || {error_msg}") def launch_jupyterlab(self): - """Launch Jupyterlab""" + """Launch Jupyterlab.""" # handle errors error_msg = "pip install jupyterlab && python -m jupyter lab || echo Failed to launch. Try reinstalling jupyterlab with pip install jupyterlab --force-reinstall" self.launch_cmd(f"python -m jupyter lab || {error_msg}") def launch_jupyter_notebook(self): - """Launch Jupyter Notebook""" + """Launch Jupyter Notebook.""" # handle errors error_msg = "pip install jupyter && python -m jupyter notebook || echo Failed to launch. Try reinstalling jupyter with pip install jupyter --force-reinstall" self.launch_cmd(f"python -m jupyter notebook || {error_msg}") @@ -186,7 +215,14 @@ def list_packages(self): self.launch_cmd("pip list") def launch_cmd(self, extra=""): - """""" + """Run a command in a new command prompt. + + Parameters + ---------- + extra : str, default: "" + Any additional command(s). + + """ py_path = self.table.active_path if "Python" in self.table.active_version: scripts_path = os.path.join(py_path, "Scripts") diff --git a/src/ansys/tools/installer/main.py b/src/ansys/tools/installer/main.py index 72236ceb..acc32fc0 100644 --- a/src/ansys/tools/installer/main.py +++ b/src/ansys/tools/installer/main.py @@ -3,20 +3,30 @@ from math import floor import os import sys -from threading import Thread -import urllib.request from PySide6 import QtCore, QtGui, QtWidgets +from packaging import version import requests -from ansys.tools.installer.common import threaded +from ansys.tools.installer import CACHE_DIR, __version__ +from ansys.tools.installer.auto_updater import READ_ONLY_PAT, query_gh_latest_release +from ansys.tools.installer.common import protected, threaded from ansys.tools.installer.installed_table import InstalledTab -from ansys.tools.installer.installer import install_python +from ansys.tools.installer.installer import install_python, run_ps +from ansys.tools.installer.misc import enable_logging from ansys.tools.installer.progress_bar import ProgressBar LOG = logging.getLogger(__name__) LOG.setLevel("DEBUG") +ABOUT_TEXT = f"""
Created by the PyAnsys Team.
+If you have any questions or issues, please open an issue the python-installer-qt-gui Issues page.
+Alternatively, you can contact us at pyansys.core@ansys.com.
+Copyright 2023 ANSYS, Inc. All rights reserved.
+""" + + INSTALL_TEXT = """Choose to use either the standard Python install from python.org or miniforge.""" PYTHON_VERSION_TEXT = """Choose the version of Python to install. @@ -40,17 +50,19 @@ ASSETS_PATH = os.path.join(THIS_PATH, "assets") -class AnsysPythonInstaller(QtWidgets.QWidget): +class AnsysPythonInstaller(QtWidgets.QMainWindow): signal_error = QtCore.Signal(str) signal_open_pbar = QtCore.Signal(int, str) signal_increment_pbar = QtCore.Signal() signal_close_pbar = QtCore.Signal() signal_set_pbar_value = QtCore.Signal(int) + signal_close = QtCore.Signal() def __init__(self, show=True): super().__init__() self.setWindowTitle("Ansys Python Manager") self.setGeometry(50, 50, 500, 700) # width should auto-update + self._exceptions = [] self._pbar = None self._err_message_box = None @@ -64,16 +76,48 @@ def __init__(self, show=True): # Set the application icon self.setWindowIcon(icon) - # Menu - menu_layout = QtWidgets.QVBoxLayout() - menu_layout.setContentsMargins(0, 0, 0, 0) - menu_widget = QtWidgets.QWidget() - menu_widget.setLayout(menu_layout) + # Create a menu bar + menubar = self.menuBar() + + file_menu = menubar.addMenu("&File") + + updates_action = QtGui.QAction("Check for Updates", self) + updates_action.triggered.connect(self.check_for_updates) + file_menu.addAction(updates_action) + + file_menu.addSeparator() # ------------------------------------------- + + # Create an "Exit" action + exit_action = QtGui.QAction("&Exit", self) + exit_action.setShortcut(QtGui.QKeySequence("Ctrl+Q")) + exit_action.triggered.connect(QtWidgets.QApplication.quit) + file_menu.addAction(exit_action) + + help_menu = menubar.addMenu("&Help") + + # Create a "Visit Website" action + visit_action = QtGui.QAction("&Online Documentation", self) + visit_action.triggered.connect(self.visit_website) + help_menu.addAction(visit_action) + # Create an "About" action + about_action = QtGui.QAction("&About", self) + about_action.triggered.connect(self.show_about_dialog) + + # Add the "About" action to the "Help" menu + help_menu.addAction(about_action) + + # Header + header_layout = QtWidgets.QVBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) + header_widget = QtWidgets.QWidget() + header_widget.setLayout(header_layout) + + # Header icon self.menu_heading = QtWidgets.QLabel() pixmap = QtGui.QPixmap(os.path.join(ASSETS_PATH, "pyansys-light-crop.png")) self.menu_heading.setPixmap(pixmap) - menu_layout.addWidget(self.menu_heading) + header_layout.addWidget(self.menu_heading) # Main content self.tab_widget = QtWidgets.QTabWidget() @@ -168,9 +212,13 @@ def __init__(self, show=True): # Add menu and tab widget to main layout main_layout = QtWidgets.QVBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget(menu_widget) + main_layout.addWidget(header_widget) main_layout.addWidget(self.tab_widget) - self.setLayout(main_layout) + + # create central widget + central_widget = QtWidgets.QWidget() + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) # connects self.signal_open_pbar.connect(self._pbar_open) @@ -178,10 +226,68 @@ def __init__(self, show=True): self.signal_increment_pbar.connect(self._pbar_increment) self.signal_set_pbar_value.connect(self._pbar_set_value) self.signal_error.connect(self._show_error) + self.signal_close.connect(self._close) if show: self.show() + @protected + def _exe_update(self, filename): + """After downloading the update for this application, run the file and shutdown this application.""" + run_ps(f"(Start-Process {filename})") + + # exiting + LOG.debug("Closing...") + self.close_emit() + + def close_emit(self): + """Trigger the exit signal.""" + self.signal_close.emit() + + def _close(self): + self.close() + + @protected + def check_for_updates(self): + LOG.debug("Checking for updates") + ( + ver, + url, + ) = query_gh_latest_release() + cur_ver = version.parse(__version__) + # if ver > cur_ver: + if True: + LOG.debug("Update available.") + reply = QtWidgets.QMessageBox.question( + None, + "Update", + f"A new version {ver} is available. You are currently running version {cur_ver}. Do you want to update?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.Yes, + ) + if reply == QtWidgets.QMessageBox.Yes: + self._download( + url, + f"Ansys-Python-Manager-Setup-v{ver}.exe", + when_finished=self._exe_update, + auth=READ_ONLY_PAT, + ) + else: + LOG.debug("Up to date.") + QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Information, + "Information", + f"Ansys Python Installer is up-to-date.\n\nVersion is {__version__}", + QtWidgets.QMessageBox.Ok, + ).exec_() + + def visit_website(self): + url = QtCore.QUrl("https://installer.docs.pyansys.com/") + QtGui.QDesktopServices.openUrl(url) + + def show_about_dialog(self): + mbox = QtWidgets.QMessageBox.about(self, "About", ABOUT_TEXT) + def _install_type_changed(self, *args): self.python_version_select.setEnabled( self.installation_type_select.currentText() == "Standard" @@ -196,13 +302,25 @@ def pbar_increment(self): def _pbar_increment(self): """Increment the progress bar. + Not to be accessed outside of the main thread. """ if self._pbar is not None: self._pbar.increment() def pbar_open(self, nticks=5, label=""): - """Open the progress bar.""" + """Open the progress bar. + + + Parameters + ---------- + nticks : int, default: 5 + Number of "ticks" to set the progress bar to. + + label : str, default: "" + Label of the progress bar. + + """ self.signal_open_pbar.emit(nticks, label) def _pbar_open(self, nticks, label): @@ -230,6 +348,12 @@ def pbar_set_value(self, value): """Set progress bar position. Thread safe. + + Parameters + ---------- + value : int + Value to set active progress bar to. + """ self.signal_set_pbar_value.emit(value) @@ -237,30 +361,54 @@ def _pbar_set_value(self, value): """Set progress bar position. Not to be accessed outside of the main thread. + + Parameters + ---------- + value : int + Value to set active progress bar to. + """ if self._pbar is not None: self._pbar.set_value(value) - def error_dialog(self, txt, textinfo=None): - """Create an error dialogue.""" - self._err_message_box = QtWidgets.QMessageBox(self) - self._err_message_box.setIcon(QtWidgets.QMessageBox.Critical) - self._err_message_box.setText(txt) - def _show_error(self, text): - """Display an error.""" - self._err_message_box = QtWidgets.QMessageBox(self) - self._err_message_box.setIcon(QtWidgets.QMessageBox.Critical) - self._err_message_box.setText(text) + """Display an error. + + Not thread safe. Call ``show_error`` instead for thread safety. + + Parameters + ---------- + text : str + Message to display as an error. + + """ + if not isinstance(text, str): + text = str(text) + self._err_message_box = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Critical, "Error", text, QtWidgets.QMessageBox.Ok + ) self._err_message_box.show() def show_error(self, text): - """Thread safe show error.""" + """Thread safe show error. + + This can be called from any thread. + + Parameters + ---------- + text : str + Message to display as an error. + + """ LOG.error(text) self.signal_error.emit(text) def download_and_install(self): - """Download and install.""" + """Download and install. + + Called when ``self.submit_button.clicked`` is emitted. + + """ self.setEnabled(False) QtWidgets.QApplication.processEvents() @@ -283,7 +431,7 @@ def download_and_install(self): self.setEnabled(True) @threaded - def _download(self, url, filename, when_finished=None): + def _download(self, url, filename, when_finished=None, auth=None): """Download a file with a progress bar. Checks cache first. If cached file exists and is the same size @@ -291,16 +439,50 @@ def _download(self, url, filename, when_finished=None): ``when_finished`` must accept one parameter, the path of the file downloaded. + Parameters + ---------- + url : str + File to download. + + filename : str + The basename of the file to download. + + when_finished : callable, optional + Function to call when complete. Function should accept one + parameter: the full path of the file downloaded. + + auth : str, optional + Authorization token for GitHub. This is used when + downloading release artifacts from private/internal + repositories. + """ - from ansys.tools.installer import CACHE_DIR + request_headers = {} + if auth: + request_headers = { + "Authorization": f"token {READ_ONLY_PAT}", + "Accept": "application/octet-stream", + } + + # initiate the download + session = requests.Session() + response = session.get( + url, allow_redirects=True, stream=True, headers=request_headers + ) + tsize = int(response.headers.get("Content-Length", 0)) + + if response.status_code != 200: + self.show_error( + f"Unable to download {filename}.\n\nReceived {response.status_code} from {url}" + ) + self.pbar_close() + return output_path = os.path.join(CACHE_DIR, filename) - if os.path.isfile(output_path): + if os.path.isfile(output_path) and tsize: LOG.debug("%s exists at in %s", filename, CACHE_DIR) - response = requests.head(url, allow_redirects=True) - content_length = int(response.headers["Content-Length"]) file_sz = os.path.getsize(output_path) - if content_length == file_sz: + if tsize == file_sz: LOG.debug("Sizes match. Using cached file from %s", output_path) if when_finished is not None: when_finished(output_path) @@ -321,33 +503,19 @@ def update(b=1, bsize=1, tsize=None): if total[2] != val: self.pbar_set_value(val) - def download(): - """Execute download.""" - self.pbar_open(100, f"Downloading {filename}") - - # first, query if the file exists - response = requests.head(url, allow_redirects=True) + self.pbar_open(100, f"Downloading {filename}") - if response.status_code != 200: - self.show_error( - f"Unable to download {filename}.\n\nReceived {response.status_code} from {url}" - ) - self.pbar_close() - return "" - - total_size = None - try: - total[0] = int(response.headers["Content-Length"]) - except: - total[0] = 50 * 2**20 # dummy 50 MB - - urllib.request.urlretrieve(url, filename=output_path, reporthook=update) - self.pbar_close() + chunk_size = 200 * 1024 # 200kb + with open(output_path, "wb") as f: + for chunk in response.iter_content(chunk_size): + f.write(chunk) + update(0, chunk_size, tsize) + tsize = None - if when_finished is not None: - when_finished(output_path) + self.pbar_close() - Thread(target=download).start() + if when_finished is not None: + when_finished(output_path) def _run_exe(self, filename): """Execute a file.""" @@ -362,6 +530,42 @@ def _run_exe(self, filename): def open_gui(): """Start the installer as a QT Application.""" + + import argparse + import ctypes + import msvcrt + + kernel32 = ctypes.windll.kernel32 + + # Parse command-line arguments + parser = argparse.ArgumentParser() + parser.add_argument("--console", action="store_true", help="Open console window") + try: + args = parser.parse_args() + except AttributeError: + kernel32.AllocConsole() + + # Redirect stdout and stderr to the console + sys.stdout = open("CONOUT$", "w") + sys.stderr = open("CONOUT$", "w") + + try: + args = parser.parse_args() + except SystemExit: + print("\nPress any key to continue...") + msvcrt.getch() + return + + # Allocate console if --console option is specified + if args.console: + kernel32.AllocConsole() + + # Redirect stdout and stderr to the console + sys.stdout = open("CONOUT$", "w") + sys.stderr = open("CONOUT$", "w") + + enable_logging() + app = QtWidgets.QApplication(sys.argv) window = AnsysPythonInstaller() window.show() diff --git a/src/ansys/tools/installer/misc.py b/src/ansys/tools/installer/misc.py new file mode 100644 index 00000000..4ab89dc1 --- /dev/null +++ b/src/ansys/tools/installer/misc.py @@ -0,0 +1,30 @@ +""" +Contains miscellaneous functionalities this library. +""" + +import logging +import sys + + +def enable_logging(): + """Log to stdout.""" + + class SafeStreamHandler(logging.StreamHandler): + def emit(self, record): + try: + if not self.stream.closed: + super().emit(record) + except (ValueError, AttributeError): + pass + + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + + # Create a console handler that writes to stdout + console_handler = SafeStreamHandler(sys.stdout) + console_handler.setLevel(logging.DEBUG) + formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + console_handler.setFormatter(formatter) + + # Add the console handler to the logger + logger.addHandler(console_handler) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index cf6705ff..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from ansys.tools.installer import AnsysPythonInstaller - - -@pytest.fixture() -def gui(qtbot): - return AnsysPythonInstaller(show=False) diff --git a/tests/test_auto_updater.py b/tests/test_auto_updater.py new file mode 100644 index 00000000..29b03799 --- /dev/null +++ b/tests/test_auto_updater.py @@ -0,0 +1,11 @@ +from packaging.version import Version + +from ansys.tools.installer.auto_updater import query_gh_latest_release + + +def test_query_gh_latest_release(): + latest_version_tag, download_url = query_gh_latest_release() + + ver = latest_version_tag + assert isinstance(ver, Version) + assert "http" in download_url diff --git a/tests/test_gui.py b/tests/test_gui.py index ea005ab3..089041c2 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1,10 +1,43 @@ from PySide6 import QtWidgets +import pytest +from pytestqt.qtbot import QtBot +from ansys.tools.installer.main import AnsysPythonInstaller -def test_main_window_header(qtbot, gui): + +@pytest.fixture(scope="session") +def qtbot_session(qapp, request): + result = QtBot(qapp) + yield result + + +@pytest.fixture(scope="session") +def gui(qtbot_session): + installer = AnsysPythonInstaller(show=False) + yield installer + # qtbot_session.wait(1000) + # installer.close() + + +def test_main_window_header(gui): assert isinstance(gui.menu_heading, QtWidgets.QLabel) # verify image loaded pixmap = gui.menu_heading.pixmap() assert pixmap is not None assert not pixmap.isNull() + + +def test_downloader(gui): + # this URL is subject to change + url = "https://cdn.jsdelivr.net/gh/belaviyo/download-with/samples/sample.png" + + files = [] + + def when_finished(out_path): + files.append(out_path) + + thread = gui._download(url, "sample.png", when_finished=when_finished) + thread.join() + assert len(files) == 1 + assert files[0].endswith("sample.png") diff --git a/uninstall.nsi b/uninstall.nsi new file mode 100644 index 00000000..483e987f --- /dev/null +++ b/uninstall.nsi @@ -0,0 +1,25 @@ +; Uninstaller script for Ansys Python Manager + +; Name and version of the program to be uninstalled are already defined + + +; Define the uninstaller section +Section "Uninstall" + ; Prompt the user to confirm uninstallation + MessageBox MB_YESNO|MB_ICONQUESTION "Are you sure you want to uninstall ${PRODUCT_NAME} ${PRODUCT_VERSION}?" /SD IDYES IDYES +2 + Abort + + ; Remove the installed files + Delete "$INSTDIR\*.*" + RMDir "$INSTDIR" + + ; Remove the registry keys + DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" + + ; Remove the start menu shortcut and directory + Delete "$SMPROGRAMS\Ansys Python Manager\Ansys Python Manager.lnk" + RMDir "$SMPROGRAMS\Ansys Python Manager" + + ; Display the uninstallation complete message + MessageBox MB_OK|MB_ICONINFORMATION "${PRODUCT_NAME} ${PRODUCT_VERSION} has been successfully uninstalled." +SectionEnd