From d6f8217226e5f2c6aaeca87be61d5bb3b70b1f57 Mon Sep 17 00:00:00 2001 From: Reto Trappitsch Date: Fri, 12 Apr 2024 11:11:03 +0200 Subject: [PATCH 1/5] add first iteration --- src/box/cli.py | 13 ++- src/box/installer.py | 31 ++++++- src/box/installer_utils/windows_hlp.py | 115 +++++++++++++++++++++++++ src/box/utils.py | 14 ++- 4 files changed, 166 insertions(+), 7 deletions(-) diff --git a/src/box/cli.py b/src/box/cli.py index 1cb7a48..2b94a44 100644 --- a/src/box/cli.py +++ b/src/box/cli.py @@ -1,5 +1,10 @@ +"""CLI for box-packager.""" + +from pathlib import Path + import rich_click as click +import box from box.cleaner import CleanProject from box.config import uninitialize from box.initialization import InitializeProject @@ -146,12 +151,18 @@ def installer(verbose): ut.check_boxproject() my_installer = CreateInstaller(verbose=verbose) my_installer.create_installer() - if (inst_name := my_installer.installer_name) is not None: + inst_name = my_installer.installer_name + if Path(box.RELEASE_DIR_NAME).joinpath(inst_name).exists(): fmt.success( f"Installer successfully created.\n" f"You can find the installer file {inst_name} " f"in the `target/release` folder." ) + else: + raise click.ClickException( + "Installer was not created. " + "Run with `box installer -v` to get verbose feedback." + ) @cli.command(name="clean") diff --git a/src/box/installer.py b/src/box/installer.py index 4e1d467..ecb614c 100644 --- a/src/box/installer.py +++ b/src/box/installer.py @@ -55,8 +55,8 @@ def create_installer(self): self.linux_cli() elif self._os == "Linux" and self._mode == "GUI": self.linux_gui() - # elif self._os == "Windows" and self._mode == "CLI": - # self.unsupported_os_or_mode() + elif self._os == "Windows" and self._mode == "CLI": + self.windows_cli() elif self._os == "Windows" and self._mode == "GUI": self.windows_gui() else: @@ -121,10 +121,33 @@ def linux_gui(self) -> None: def unsupported_os_or_mode(self): """Print a message for unsupported OS or mode.""" fmt.warning( - f"Creating an installer for a {self._mode} is \ - currently not supported on {self._os}." + f"Creating an installer for a {self._mode} is " + f"currently not supported on {self._os}." ) + def windows_cli(self): + """Create a Windows CLI installer.""" + self._check_makensis() + + from box.installer_utils.windows_hlp import nsis_cli_script + + name_pkg = self._config.name_pkg + version = self._config.version + + installer_name = f"{name_pkg}-v{version}-win.exe" + + with ut.set_dir(RELEASE_DIR_NAME): + nsis_script_name = Path("make_installer.nsi") + with open(nsis_script_name, "w") as f: + f.write(nsis_cli_script(name_pkg, installer_name, self._release_file)) + + # make the installer + subprocess.run(["makensis", nsis_script_name], **self.subp_kwargs) + + # nsis_script_name.unlink() + + self._installer_name = installer_name + def windows_gui(self): """Create a Windows GUI installer.""" self._check_makensis() diff --git a/src/box/installer_utils/windows_hlp.py b/src/box/installer_utils/windows_hlp.py index f553f85..50dc7f0 100644 --- a/src/box/installer_utils/windows_hlp.py +++ b/src/box/installer_utils/windows_hlp.py @@ -3,6 +3,121 @@ from pathlib import Path +def nsis_cli_script(project_name: str, installer_name: str, binary_path: Path): + """Create NSIS script for CLI installer. + + :param project_name: Name of the project + :param installer_name: Name of the installer to be produced by NSIS + :param binary_path: Path to the binary to be installed + """ + return rf"""; NSIS script to create installer for {project_name} + +;-------------------------------- +;Include Modern UI + + !include "MUI2.nsh" + +;-------------------------------- +;General + + ;Name and file + Name "{project_name}" + OutFile "{installer_name}" + Unicode True + + ;Default installation folder + InstallDir "$LOCALAPPDATA\{project_name}" + + ;Get installation folder from registry if available + InstallDirRegKey HKCU "Software\{project_name}" "" + + ;Request application privileges + RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !define MUI_ABORTWARNING + +;-------------------------------- +;Pages + + !insertmacro MUI_PAGE_COMPONENTS + !insertmacro MUI_PAGE_DIRECTORY + + !insertmacro MUI_PAGE_INSTFILES + + !insertmacro MUI_UNPAGE_CONFIRM + !insertmacro MUI_UNPAGE_INSTFILES + +;-------------------------------- +;Languages + + !insertmacro MUI_LANGUAGE "English" + +;-------------------------------- +;Installer Sections + +Section "{project_name}" SecInst + + SetOutPath "$INSTDIR" + + ; Files for {project_name} + File "{binary_path.name}" + + ;Store installation folder + WriteRegStr HKCU "Software\{project_name}" "" $INSTDIR + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" "DisplayName" "{project_name}" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" "UninstallString" '"$INSTDIR\Uninstall-{project_name}.exe"' + + ;Create uninstaller + WriteUninstaller "$INSTDIR\Uninstall-{project_name}.exe"\ + + ; Add the binary path to the PATH environment variable + nsExec::Exec 'echo %PATH% | find "$INSTDIR"' + Pop $0 ; gets result code + + ${{If}} $0 = 0 + nsExec::Exec 'setx PATH=%PATH%;$INSTDIR' + ${{EndIf}} + +SectionEnd + +;-------------------------------- +;Descriptions + + ;Language strings + LangString DESC_SecInst ${{LANG_ENGLISH}} "Selection." + + ;Assign language strings to sections + !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN + !insertmacro MUI_DESCRIPTION_TEXT ${{SecInst}} $(DESC_SecInst) + !insertmacro MUI_FUNCTION_DESCRIPTION_END + +;-------------------------------- +;Uninstaller Section + +Section "Uninstall" + + + ; Delete {project_name} folder + Delete "$INSTDIR\{binary_path.name}" + Delete "$INSTDIR\Uninstall-{project_name}.exe" + + RMDir "$INSTDIR" + + ; Delete PyApp virtual environment + RMDir /r "$LOCALAPPDATA\pyapp\data\{project_name}" + + ; Delete registry key + DeleteRegKey /ifempty HKCU "Software\{project_name}" + DeleteRegKey /ifempty HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" + +SectionEnd +""" + + def nsis_gui_script( project_name: str, installer_name: str, binary_path: Path, icon_path: Path ): diff --git a/src/box/utils.py b/src/box/utils.py index 2cdac43..bb2650c 100644 --- a/src/box/utils.py +++ b/src/box/utils.py @@ -3,6 +3,7 @@ from contextlib import contextmanager import os from pathlib import Path +import subprocess from rich_click import ClickException @@ -11,7 +12,6 @@ # available app entry types that are used in box PYAPP_APP_ENTRY_TYPES = ["spec", "module", "script", "notebook"] - # supported Python versions. Default will be set to last entry (latest). PYAPP_PYTHON_VERSIONS = ( "pypy2.7", @@ -45,7 +45,17 @@ def cmd_python() -> str: :return: Command to run Python """ - return "py" if is_windows() else "python" + if is_windows(): + try: + subprocess.run( + ["py", "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return "py" + except FileNotFoundError: + pass + return "python" def is_windows() -> bool: From b7697fe1ba80aebeb9a2fa5f9a2ccc31b5751642 Mon Sep 17 00:00:00 2001 From: Reto Trappitsch Date: Fri, 12 Apr 2024 15:34:08 +0200 Subject: [PATCH 2/5] windows cli installer working --- src/box/installer.py | 24 ++++++++++++-------- src/box/installer_utils/windows_hlp.py | 31 +++++++++++++++----------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/box/installer.py b/src/box/installer.py index 8a0946c..d6891f7 100644 --- a/src/box/installer.py +++ b/src/box/installer.py @@ -89,12 +89,12 @@ def linux_cli(self) -> None: def linux_gui(self) -> None: """Create a Linux GUI installer.""" - name_pkg = self._config.name_pkg + name = self._config.name version = self._config.version icon = get_icon() icon_name = icon.name - bash_part = linux_gui.create_bash_installer(name_pkg, version, icon_name) + bash_part = linux_gui.create_bash_installer(name, version, icon_name) with open(self._release_file, "rb") as f: binary_part = f.read() @@ -102,9 +102,7 @@ def linux_gui(self) -> None: with open(icon, "rb") as f: icon_part = f.read() - installer_file = Path(RELEASE_DIR_NAME).joinpath( - f"{name_pkg}-v{version}-linux.sh" - ) + installer_file = Path(RELEASE_DIR_NAME).joinpath(f"{name}-v{version}-linux.sh") with open(installer_file, "wb") as f: f.write(bash_part.encode("utf-8")) f.write(binary_part) @@ -131,20 +129,28 @@ def windows_cli(self): from box.installer_utils.windows_hlp import nsis_cli_script - name_pkg = self._config.name_pkg + name = self._config.name version = self._config.version - installer_name = f"{name_pkg}-v{version}-win.exe" + installer_name = f"{name}-v{version}-win.exe" with ut.set_dir(RELEASE_DIR_NAME): nsis_script_name = Path("make_installer.nsi") with open(nsis_script_name, "w") as f: - f.write(nsis_cli_script(name_pkg, installer_name, self._release_file)) + f.write( + nsis_cli_script( + name, + installer_name, + self._config.author, + self._config.version, + self._release_file, + ) + ) # make the installer subprocess.run(["makensis", nsis_script_name], **self.subp_kwargs) - # nsis_script_name.unlink() + nsis_script_name.unlink() self._installer_name = installer_name diff --git a/src/box/installer_utils/windows_hlp.py b/src/box/installer_utils/windows_hlp.py index 7a9dabc..36dd8e2 100644 --- a/src/box/installer_utils/windows_hlp.py +++ b/src/box/installer_utils/windows_hlp.py @@ -3,11 +3,15 @@ from pathlib import Path -def nsis_cli_script(project_name: str, installer_name: str, binary_path: Path): +def nsis_cli_script( + project_name: str, installer_name: str, author: str, version: str, binary_path: Path +): """Create NSIS script for CLI installer. :param project_name: Name of the project :param installer_name: Name of the installer to be produced by NSIS + :param author: Author of the project + :param version: Version of the project :param binary_path: Path to the binary to be installed """ return rf"""; NSIS script to create installer for {project_name} @@ -48,6 +52,11 @@ def nsis_cli_script(project_name: str, installer_name: str, binary_path: Path): !insertmacro MUI_PAGE_INSTFILES + !define MUI_FINISHPAGE_TITLE "Successfully installed {project_name}" + !define MUI_FINISHPAGE_TITLE_3LINES + !define MUI_FINISHPAGE_TEXT "Your command line interface CLI has been successfully installed. However, the installer did not modify your PATH variable.$\r$\nPlease add the installation folder$\r$\n$INSTDIR$\r$\nto your PATH variable manually." + !insertmacro MUI_PAGE_FINISH + !insertmacro MUI_UNPAGE_CONFIRM !insertmacro MUI_UNPAGE_INSTFILES @@ -68,19 +77,15 @@ def nsis_cli_script(project_name: str, installer_name: str, binary_path: Path): ;Store installation folder WriteRegStr HKCU "Software\{project_name}" "" $INSTDIR - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" "DisplayName" "{project_name}" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" "UninstallString" '"$INSTDIR\Uninstall-{project_name}.exe"' ;Create uninstaller - WriteUninstaller "$INSTDIR\Uninstall-{project_name}.exe"\ - - ; Add the binary path to the PATH environment variable - nsExec::Exec 'echo %PATH% | find "$INSTDIR"' - Pop $0 ; gets result code + WriteUninstaller "$INSTDIR\Uninstall-{project_name}.exe" - ${{If}} $0 = 0 - nsExec::Exec 'setx PATH=%PATH%;$INSTDIR' - ${{EndIf}} + ; Add uninstaller key information for add/remove software entry + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" "DisplayName" "{project_name}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" "UninstallString" "$INSTDIR\Uninstall-{project_name}.exe" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" "Publisher" "{author}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" "DisplayVersion" "{version}" SectionEnd @@ -111,8 +116,8 @@ def nsis_cli_script(project_name: str, installer_name: str, binary_path: Path): RMDir /r "$LOCALAPPDATA\pyapp\data\{project_name}" ; Delete registry key - DeleteRegKey /ifempty HKCU "Software\{project_name}" - DeleteRegKey /ifempty HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" + DeleteRegKey HKCU "Software\{project_name}" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\{project_name}" SectionEnd """ From 6340bfcacc7b2beaa93afd05e6066e30892b245b Mon Sep 17 00:00:00 2001 From: Reto Trappitsch Date: Fri, 12 Apr 2024 15:49:36 +0200 Subject: [PATCH 3/5] fix up tests --- src/box/cli.py | 23 ++++++++++++----------- src/box/installer.py | 6 +++--- tests/cli/test_cli_installer.py | 9 +++++++-- tests/unit/test_utils.py | 8 ++++++++ 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/box/cli.py b/src/box/cli.py index 2b94a44..f7b002c 100644 --- a/src/box/cli.py +++ b/src/box/cli.py @@ -152,17 +152,18 @@ def installer(verbose): my_installer = CreateInstaller(verbose=verbose) my_installer.create_installer() inst_name = my_installer.installer_name - if Path(box.RELEASE_DIR_NAME).joinpath(inst_name).exists(): - fmt.success( - f"Installer successfully created.\n" - f"You can find the installer file {inst_name} " - f"in the `target/release` folder." - ) - else: - raise click.ClickException( - "Installer was not created. " - "Run with `box installer -v` to get verbose feedback." - ) + if inst_name is not None: + if Path(box.RELEASE_DIR_NAME).joinpath(inst_name).exists(): + fmt.success( + f"Installer successfully created.\n" + f"You can find the installer file {inst_name} " + f"in the `target/release` folder." + ) + else: + raise click.ClickException( + "Installer was not created. " + "Run with `box installer -v` to get verbose feedback." + ) @cli.command(name="clean") diff --git a/src/box/installer.py b/src/box/installer.py index d6891f7..415f952 100644 --- a/src/box/installer.py +++ b/src/box/installer.py @@ -160,18 +160,18 @@ def windows_gui(self): from box.installer_utils.windows_hlp import nsis_gui_script - name_pkg = self._config.name_pkg + name = self._config.name version = self._config.version icon = get_icon("ico") - installer_name = f"{name_pkg}-v{version}-win.exe" + installer_name = f"{name}-v{version}-win.exe" with ut.set_dir(RELEASE_DIR_NAME): nsis_script_name = Path("make_installer.nsi") with open(nsis_script_name, "w") as f: f.write( nsis_gui_script( - name_pkg, + name, installer_name, self._config.author, self._config.version, diff --git a/tests/cli/test_cli_installer.py b/tests/cli/test_cli_installer.py index dd52d5d..9ab0f13 100644 --- a/tests/cli/test_cli_installer.py +++ b/tests/cli/test_cli_installer.py @@ -77,7 +77,8 @@ def test_installer_cli_linux(rye_project): @pytest.mark.skipif("sys.platform == 'win32'", reason="Not supported on Windows") def test_installer_gui_linux(rye_project): """Create installer for linux GUI.""" - installer_fname_exp = f"{rye_project.name}-v0.1.0-linux.sh" + conf = config.PyProjectParser() + installer_fname_exp = f"{conf.name}-v0.1.0-linux.sh" target_file_content = setup_mock_target_binary(rye_project) icon_file_content = setup_mock_icon(rye_project) @@ -120,9 +121,13 @@ def test_installer_gui_windows(rye_project, mocker, verbose): if not verbose: subp_kwargs["stdout"] = subp_kwargs["stderr"] = sp_devnull_mock - installer_fname_exp = f"{rye_project.name.lower()}-v0.1.0-win.exe" + conf = config.PyProjectParser() + installer_fname_exp = f"{conf.name}-v0.1.0-win.exe" _ = setup_mock_target_binary(rye_project) _ = setup_mock_icon(rye_project, ico=True) + # create the installer binary + installer_binary = rye_project.joinpath(f"target/release/{installer_fname_exp}") + installer_binary.touch() # make it a GUI project config.pyproject_writer("is_gui", True) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 9d9d7c0..236d179 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -16,9 +16,17 @@ def test_cmd_python(mocker, os_python): """Get python on mulitple operating systems.""" # mock os.name mocker.patch("os.name", os_python[0]) + mocker.patch("subprocess.run") assert ut.cmd_python() == os_python[1] +def test_cmd_python_py_not_found(mocker): + """Default to python on windows if py not found.""" + mocker.patch("os.name", "nt") + mocker.patch("subprocess.run", side_effect=FileNotFoundError) + assert ut.cmd_python() == "python" + + def test_check_boxproject(rye_project): """Check if the box project is already initialized.""" ut.check_boxproject() From 15b92c25c37eb72287eb5cc009303072bc6286b8 Mon Sep 17 00:00:00 2001 From: Reto Trappitsch Date: Fri, 12 Apr 2024 15:54:26 +0200 Subject: [PATCH 4/5] add tests for windows cli installer and exception if no installer created --- tests/cli/test_cli_installer.py | 51 +++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/cli/test_cli_installer.py b/tests/cli/test_cli_installer.py index 9ab0f13..03289ca 100644 --- a/tests/cli/test_cli_installer.py +++ b/tests/cli/test_cli_installer.py @@ -110,6 +110,57 @@ def test_installer_gui_linux(rye_project): assert installer_file.name in result.output +@pytest.mark.parametrize("verbose", [True, False]) +def test_installer_cli_windows(rye_project, mocker, verbose): + """Create an installer with NSIS for a CLI on Windows.""" + mocker.patch("sys.platform", "win32") + subp_mock = mocker.patch("subprocess.run") + sp_devnull_mock = mocker.patch("subprocess.DEVNULL") + + subp_kwargs = {} + if not verbose: + subp_kwargs["stdout"] = subp_kwargs["stderr"] = sp_devnull_mock + + conf = config.PyProjectParser() + installer_fname_exp = f"{conf.name}-v0.1.0-win.exe" + _ = setup_mock_target_binary(rye_project) + # create the installer binary + installer_binary = rye_project.joinpath(f"target/release/{installer_fname_exp}") + installer_binary.touch() + + # run the CLI + runner = CliRunner() + if verbose: + args = ["installer", "-v"] + else: + args = ["installer"] + result = runner.invoke(cli, args) + + assert result.exit_code == 0 + + make_installer_pth = rye_project.joinpath("target/release/make_installer.nsi") + release_path = rye_project.joinpath("target/release") + subp_mock.assert_called_with( + ["makensis", make_installer_pth.relative_to(release_path)], **subp_kwargs + ) + assert installer_fname_exp in result.output + + +def test_installer_cli_windows_not_created(rye_project, mocker): + """Raise ClickException if the installer was not created.""" + mocker.patch("sys.platform", "win32") + mocker.patch("subprocess.run") + + _ = setup_mock_target_binary(rye_project) + + runner = CliRunner() + result = runner.invoke(cli, ["installer"]) + + assert result.exit_code != 0 + assert result.exception + assert "Installer was not created" in result.output + + @pytest.mark.parametrize("verbose", [True, False]) def test_installer_gui_windows(rye_project, mocker, verbose): """Create an installer with NSIS on Windows.""" From 7226f36e71cf5532520861f24aba4f8cc621fc12 Mon Sep 17 00:00:00 2001 From: Reto Trappitsch Date: Fri, 12 Apr 2024 15:59:23 +0200 Subject: [PATCH 5/5] add docs for windows cli installer --- docs/.includes/installer_cli.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/.includes/installer_cli.md b/docs/.includes/installer_cli.md index 11a793e..0fef30d 100644 --- a/docs/.includes/installer_cli.md +++ b/docs/.includes/installer_cli.md @@ -24,7 +24,21 @@ === "Windows" - CLI installers on Windows are currently not supported. + Windows installers are created using + [NSIS](https://nsis.sourceforge.io/Main_Page). + You must ensure that NSIS is installed and available on the system path. + The installer is an executable in `target/release/projectname-v1.2.3-win.exe` + that can be run by double-clicking it. + + The installer will ask the user for the target directory. + It will then copy the binary to the target directory and create an uninstaller. + + When using the uninstaller that is created with NSIS, all PyApp data from this project will be removed as well in order to provide the user with a clean uninstallation. + + !!! warning + The installer will not add the install directory to the `PATH` variable. + You or the user must do this manually. + This is also stated on the last page of the installer. === "macOS"