diff --git a/docs/.includes/installer_cli.md b/docs/.includes/installer_cli.md index 0fef30d..5d1e49e 100644 --- a/docs/.includes/installer_cli.md +++ b/docs/.includes/installer_cli.md @@ -42,4 +42,13 @@ === "macOS" - CLI installers on macOS are currently not supported. + MacOS CLI tool installers are created using + [applecrate](https://github.com/RhetTbull/applecrate). + The installer is an executable in + `target/release/projectname-v1.2.3-macos.pkg` + that can be run by double-clicking it. + + !!! bug + The uninstaller does currently not remove the virtual environment + that is created by PyApp, but only removes the executable. + This will be fixed in a future release. diff --git a/docs/.includes/installer_gui.md b/docs/.includes/installer_gui.md index 81bace6..569adb4 100644 --- a/docs/.includes/installer_gui.md +++ b/docs/.includes/installer_gui.md @@ -51,4 +51,18 @@ === "macOS" - GUI installers on macOS are currently not supported. + A MacOS GUI is created by manually first putting together + a minimal `.app` directory structure. + This directory contains the binary, the icon, and a `Info.plist` file. + + A `.dmg` file is then created using + [dmgbuild](https://github.com/dmgbuild/dmgbuild). + + !!! note + The building process of the `.dmg` file can currently not yet + be customized. + We are using some default settings, however, + hopefully in the future we can make this more customizable. + + In order for this to work, you must have an `icon.icns` file + in the `assets` folder of your project directory. diff --git a/pyproject.toml b/pyproject.toml index c328263..9db43ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,8 @@ dependencies = [ "rich-click>=1.7.3", "rich>=13.7.0", "colorama>=0.4.6", - "box-packager>=0.1.0", + "applecrate>=0.2.0; sys_platform=='darwin'", + "dmgbuild>=1.6.1; sys_platform=='darwin'", ] requires-python = ">= 3.8" license = { text = "MIT" } @@ -48,6 +49,7 @@ dev-dependencies = [ "pytest-mock>=3.12.0", "gitpython>=3.1.42", "build>=1.2.1", + "applecrate>=0.2.0", ] [tool.rye.scripts] diff --git a/requirements-dev.lock b/requirements-dev.lock index 54506da..d05f0b8 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -6,8 +6,11 @@ # features: [] # all-features: true # with-sources: false +# generate-hashes: false -e file:. +applecrate==0.2.0 + # via box-packager babel==2.14.0 # via mkdocs-material bracex==2.4 @@ -18,6 +21,7 @@ certifi==2024.2.2 charset-normalizer==3.3.2 # via requests click==8.1.7 + # via applecrate # via box-packager # via mkdocs # via mkdocs-click @@ -27,6 +31,10 @@ colorama==0.4.6 # via mkdocs-material coverage==7.4.3 # via pytest-cov +dmgbuild==1.6.1 + # via box-packager +ds-store==1.3.1 + # via dmgbuild ghp-import==2.1.0 # via mkdocs gitdb==4.0.11 @@ -37,8 +45,12 @@ idna==3.6 iniconfig==2.0.0 # via pytest jinja2==3.1.3 + # via applecrate # via mkdocs # via mkdocs-material +mac-alias==2.2.2 + # via dmgbuild + # via ds-store markdown==3.5.2 # via mkdocs # via mkdocs-click @@ -46,6 +58,8 @@ markdown==3.5.2 # via pymdown-extensions markdown-it-py==3.0.0 # via rich +markdown2==2.4.13 + # via applecrate markupsafe==2.1.5 # via jinja2 # via mkdocs @@ -66,6 +80,7 @@ mkdocs-material==9.5.13 mkdocs-material-extensions==1.3.1 # via mkdocs-material packaging==23.2 + # via applecrate # via build # via mkdocs # via pytest @@ -73,6 +88,8 @@ paginate==0.5.6 # via mkdocs-material pathspec==0.12.1 # via mkdocs +pip==24.0 + # via applecrate platformdirs==4.2.0 # via mkdocs pluggy==1.4.0 @@ -111,6 +128,8 @@ six==1.16.0 # via python-dateutil smmap==5.0.1 # via gitdb +toml==0.10.2 + # via applecrate tomlkit==0.12.4 # via box-packager typing-extensions==4.10.0 diff --git a/requirements.lock b/requirements.lock index 93e3ca2..9059ecd 100644 --- a/requirements.lock +++ b/requirements.lock @@ -6,8 +6,11 @@ # features: [] # all-features: true # with-sources: false +# generate-hashes: false -e file:. +applecrate==0.2.0 + # via box-packager babel==2.14.0 # via mkdocs-material bracex==2.4 @@ -17,6 +20,7 @@ certifi==2024.2.2 charset-normalizer==3.3.2 # via requests click==8.1.7 + # via applecrate # via box-packager # via mkdocs # via mkdocs-click @@ -24,13 +28,21 @@ click==8.1.7 colorama==0.4.6 # via box-packager # via mkdocs-material +dmgbuild==1.6.1 + # via box-packager +ds-store==1.3.1 + # via dmgbuild ghp-import==2.1.0 # via mkdocs idna==3.6 # via requests jinja2==3.1.3 + # via applecrate # via mkdocs # via mkdocs-material +mac-alias==2.2.2 + # via dmgbuild + # via ds-store markdown==3.5.2 # via mkdocs # via mkdocs-click @@ -38,6 +50,8 @@ markdown==3.5.2 # via pymdown-extensions markdown-it-py==3.0.0 # via rich +markdown2==2.4.13 + # via applecrate markupsafe==2.1.5 # via jinja2 # via mkdocs @@ -58,11 +72,14 @@ mkdocs-material==9.5.13 mkdocs-material-extensions==1.3.1 # via mkdocs-material packaging==23.2 + # via applecrate # via mkdocs paginate==0.5.6 # via mkdocs-material pathspec==0.12.1 # via mkdocs +pip==24.0 + # via applecrate platformdirs==4.2.0 # via mkdocs pygments==2.17.2 @@ -89,6 +106,8 @@ rich-click==1.7.3 # via box-packager six==1.16.0 # via python-dateutil +toml==0.10.2 + # via applecrate tomlkit==0.12.4 # via box-packager typing-extensions==4.10.0 diff --git a/src/box/installer.py b/src/box/installer.py index 341cb3a..a4db8bb 100644 --- a/src/box/installer.py +++ b/src/box/installer.py @@ -2,6 +2,7 @@ import os from pathlib import Path +import shutil import subprocess import sys @@ -28,6 +29,7 @@ def __init__(self, verbose: bool = False): if not verbose: self.subp_kwargs["stdout"] = subprocess.DEVNULL self.subp_kwargs["stderr"] = subprocess.DEVNULL + self._verbose = verbose if sys.platform.startswith("linux"): self._os = "Linux" @@ -58,6 +60,10 @@ def create_installer(self): self.windows_cli() elif self._os == "Windows" and self._mode == "GUI": self.windows_gui() + elif self._os == "macOS" and self._mode == "CLI": + self.macos_cli() + elif self._os == "macOS" and self._mode == "GUI": + self.macos_gui() else: self.unsupported_os_or_mode() @@ -117,6 +123,71 @@ def linux_gui(self) -> None: mode |= (mode & 0o444) >> 2 os.chmod(installer_file, mode) + def macos_cli(self): + """Create a macOS CLI installer using applecrate.""" + from applecrate import build_installer + + name = self._config.name + version = self._config.version + installer_file = Path(RELEASE_DIR_NAME).joinpath(f"{name}-v{version}-macos.pkg") + + kwargs = {} + if self._verbose: + kwargs["verbose"] = click.secho + build_installer( + app=name, + version=version, + install=[ + ( + self._release_file, + f"/usr/local/bin/{self._release_file.name}", + ) + ], + output=installer_file, + **kwargs, + ) + + self._installer_name = installer_file.name + + def macos_gui(self): + """Create a macOS GUI installer using applecrate.""" + import dmgbuild + + from box.installer_utils.mac_hlp import dmgbuild_settings, make_app + + app_path = Path(RELEASE_DIR_NAME).joinpath(f"{self._config.name}.app") + dmg_path = Path(RELEASE_DIR_NAME).joinpath( + f"{self._config.name}-v{self._config.version}-macos.dmg" + ) + + # remove old app if it exists + if app_path.exists(): + shutil.rmtree(app_path) + + make_app( + Path(RELEASE_DIR_NAME), + self._config.name, + self._config.author, + self._config.version, + get_icon("icns"), + ) + + # create the dmg + settings = dmgbuild_settings( + Path(RELEASE_DIR_NAME), self._config.name, get_icon("icns") + ) + with ut.set_dir(RELEASE_DIR_NAME): + dmgbuild.build_dmg( + filename=dmg_path.with_suffix("").name, + volume_name=f"{dmg_path.name}", + settings=settings, + ) + + # remove the app folder + shutil.rmtree(app_path) + + self._installer_name = dmg_path.name + def unsupported_os_or_mode(self): """Print a message for unsupported OS or mode.""" fmt.warning( @@ -229,7 +300,7 @@ def get_icon(suffix: str = None) -> Path: - icon.jpg - icon.jpeg - Note: Windows `.ico` files must be called out explicitly. + Note: Windows `.ico` files must be called out explicitly, same with MacOS `.icns` files. :param suffix: The suffix of the icon file. diff --git a/src/box/installer_utils/mac_hlp.py b/src/box/installer_utils/mac_hlp.py new file mode 100644 index 0000000..9e9d28e --- /dev/null +++ b/src/box/installer_utils/mac_hlp.py @@ -0,0 +1,84 @@ +# Helper functions to create Mac Installers + +from pathlib import Path +import shutil + + +def dmgbuild_settings(target_path: Path, name_pkg: str, icon: Path) -> dict: + """Create the settings for building the dmg file. + + :param target_path: Path to the target folder, i.e., where the app is and where the dmg will be created. + :param name_pkg: The name of the package as a string, same name as the app (but without the `.app`)! + :param icon: Path to the icon file. + """ + settings = { + "files": [str(target_path.joinpath(name_pkg).with_suffix(".app").absolute())], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": {f"{name_pkg}.app": (140, 120), "Applications": (500, 120)}, + "background": "builtin-arrow", + } + + return settings + + +def make_app( + target_path: Path, name_pkg: str, author: str, version: str, icon: Path +) -> None: + """Create an apple executable `.app` file. + + Creates the folder structure and `Info.plist` file required for an `.app` Apple App. + + :param target_path: Path to the target folder, i.e., where the binary is and where the app will be created. + :param name_pkg: The name of the package as a string, same name as the binary! + :param version: Version of the package, as string, `X.Y.Z`. + :param icon: Path to the icon `.icns` file. + """ + app_path = target_path.joinpath(name_pkg).with_suffix(".app") + app_path.mkdir() + + # create resource directory and copy icon into it + res_path = app_path.joinpath("Contents/Resources") + res_path.mkdir(parents=True) + shutil.copy(icon, res_path.joinpath(icon.name)) + + # create MacOS directory and copy binary into it + macos_path = app_path.joinpath("Contents/MacOS") + macos_path.mkdir(parents=True) + shutil.copy(target_path.joinpath(name_pkg), macos_path.joinpath(name_pkg)) + + # Create the Info.plist file + name_pkg_short = name_pkg if len(name_pkg) <= 16 else name_pkg[:16] + info_plist = rf""" + + + + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDevelopmentRegion + en + CFBundlePackageType + APPL + CFBundleIdentifier + com.box-package.{name_pkg} + CFBundleExecutable + {name_pkg} + CFBundleIconFile + {icon.name} + CFBundleDisplayName + {name_pkg} + CFBundleName + {name_pkg_short} + CFBundleVersion + {version} + CFBundleShortVersionString + {version} + NSHumanReadableCopyright + {author} + CFBundleSignature + ???? + +""" + + info_plist_file = app_path.joinpath("Contents/Info.plist") + with open(info_plist_file, "w") as f: + f.write(info_plist) diff --git a/tests/cli/test_cli_installer.py b/tests/cli/test_cli_installer.py index c38661f..0b6577d 100644 --- a/tests/cli/test_cli_installer.py +++ b/tests/cli/test_cli_installer.py @@ -4,8 +4,10 @@ import sys from pathlib import Path import stat +from unittest.mock import MagicMock, patch from click.testing import CliRunner +import rich_click as click import pytest from box.cli import cli @@ -28,7 +30,7 @@ def setup_mock_target_binary(path: Path, release_name: str) -> str: return target_file_content -def setup_mock_icon(path: Path, ico=False) -> str: +def setup_mock_icon(path: Path, suffix=None) -> str: """Set up a mock icon in the assets folder of the given path. :param path: The path to the project. @@ -38,7 +40,7 @@ def setup_mock_icon(path: Path, ico=False) -> str: """ assets_dir = path.joinpath("assets") assets_dir.mkdir(parents=True) - icon_file = assets_dir.joinpath("icon.ico" if ico else "icon.svg") + icon_file = assets_dir.joinpath(f"icon{suffix}" if suffix else "icon.svg") icon_file_content = "This is the content of the mock icon file..." icon_file.write_text(icon_file_content) return icon_file_content @@ -181,7 +183,7 @@ def test_installer_gui_windows(rye_project, mocker, verbose): conf = config.PyProjectParser() installer_fname_exp = f"{conf.name}-v0.1.0-win.exe" _ = setup_mock_target_binary(rye_project, conf.name) - _ = setup_mock_icon(rye_project, ico=True) + _ = setup_mock_icon(rye_project, suffix=".ico") # create the installer binary installer_binary = rye_project.joinpath(f"target/release/{installer_fname_exp}") installer_binary.touch() @@ -207,16 +209,109 @@ def test_installer_gui_windows(rye_project, mocker, verbose): assert installer_fname_exp in result.output -@pytest.mark.parametrize("platform", ["darwin", "aix"]) -def test_not_implemented_installers(rye_project, mocker, platform): +@pytest.mark.parametrize("verbose", [True, False]) +def test_installer_cli_macos(rye_project, mocker, verbose): + """Create an installer for macos using applecrate.""" + applecrate_mock = MagicMock() + + with patch.dict("sys.modules", {"applecrate": applecrate_mock}): + mocker.patch("sys.platform", "darwin") + conf = config.PyProjectParser() + installer_fname_exp = f"{conf.name}-v0.1.0-macos.pkg" + _ = setup_mock_target_binary(rye_project, conf.name) + # create the installer binary + installer_binary = rye_project.joinpath(f"target/release/{installer_fname_exp}") + installer_binary.touch() + + call_args_exp = { + "app": conf.name, + "version": conf.version, + "install": [ + ( + Path("target/release").joinpath(conf.name), + f"/usr/local/bin/{conf.name}", + ), + ], + "output": installer_binary.relative_to(rye_project), + } + + # run the CLI + args = ["installer"] + if verbose: + args.append("-v") + call_args_exp["verbose"] = click.secho + runner = CliRunner() + result = runner.invoke(cli, args) + + assert result.exit_code == 0 + + applecrate_mock.build_installer.assert_called_with(**call_args_exp) + assert installer_fname_exp in result.output + + +def test_installer_gui_macos(rye_project, mocker): + """Create an GUI installer dmg for macos.""" + dmgbuild_mock = MagicMock() + + with patch.dict("sys.modules", {"dmgbuild": dmgbuild_mock}): + mocker.patch("sys.platform", "darwin") + + conf = config.PyProjectParser() + installer_fname_exp = f"{conf.name}-v0.1.0-macos.dmg" + + _ = setup_mock_target_binary(rye_project, conf.name) + _ = setup_mock_icon(rye_project, suffix=".icns") + + # create the installer binary + installer_binary = rye_project.joinpath(f"target/release/{installer_fname_exp}") + installer_binary.touch() + + # create app_file - must be deleted when starting the CLI + app_file = rye_project.joinpath(f"target/release/{conf.name}").with_suffix( + ".app" + ) + app_file.mkdir() + + # make it a GUI project + config.pyproject_writer("is_gui", True) + + hlp_settings_exp = { + "files": [ + f"{rye_project.joinpath('target/release').joinpath(conf.name).with_suffix('.app').absolute()}" + ], + "symlinks": {"Applications": "/Applications"}, + "icon_locations": { + f"{conf.name}.app": (140, 120), + "Applications": (500, 120), + }, + "background": "builtin-arrow", + } + call_args_exp = { + "filename": installer_binary.with_suffix("").name, + "volume_name": installer_fname_exp, + "settings": hlp_settings_exp, + } + + # run the CLI + runner = CliRunner() + result = runner.invoke(cli, ["installer"]) + + assert result.exit_code == 0 + + # assert there's no app file around at the end + assert not app_file.exists() + + # dmg build assertions + dmgbuild_mock.build_dmg.assert_called_with(**call_args_exp) + assert installer_fname_exp in result.output + + +def test_not_implemented_installers(rye_project, mocker): """Present a warning but exit gracefully when installer is not implemented.""" + platform = "aix" # mock the platform to return with sys.platform mocker.patch("sys.platform", platform) - os_exp = "" - if platform == "darwin": - os_exp = "macOS" - conf = config.PyProjectParser() _ = setup_mock_target_binary(rye_project, conf.name) @@ -225,4 +320,4 @@ def test_not_implemented_installers(rye_project, mocker, platform): assert result.exit_code == 0 assert "currently not supported" in result.output - assert os_exp in result.output + assert platform in result.output