diff --git a/README.md b/README.md index 7b1ee11..454af5e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - **[NSIS](https://nsis.sourceforge.io/Main_Page)** (for packaging windows installers) - **[Rust](https://www.rust-lang.org/tools/install)** and **Cargo** (for packaging cargo projects). - **[x86_64-pc-windows-gnu](https://doc.rust-lang.org/nightly/rustc/platform-support/windows-gnu.html)** (for building windows binaries) +- **[mingw-w64-binutils](https://archlinux.org/packages/extra/x86_64/mingw-w64-binutils/)** (required for embedding windows icons onto executables) ## Packaging Usage diff --git a/pyproject.toml b/pyproject.toml index b6381cf..fa0ec26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ suap = "suap.main:app" [dependency-groups] dev = [ "ty>=0.0.23", - "ruff>=0.15.6" + "ruff>=0.15.6", + "pytest>=9.0.2", ] [build-system] @@ -29,4 +30,4 @@ version = { attr = "suap.__version__" } include = ["suap*"] [tool.setuptools.package-data] -suap = ["templates/*"] \ No newline at end of file +suap = ["templates/*"] diff --git a/ruff.toml b/ruff.toml index d291cda..b63a8e4 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,3 +1,4 @@ [lint.per-file-ignores] -"__init__.py" = ["F403"] \ No newline at end of file +"__init__.py" = ["F403"] +"tests/*" = ["E712"] \ No newline at end of file diff --git a/suap/__init__.py b/suap/__init__.py index 3ec9d59..50be976 100644 --- a/suap/__init__.py +++ b/suap/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0-alpha.1" \ No newline at end of file +__version__ = "0.1.0-alpha.2" \ No newline at end of file diff --git a/suap/commands/packaging/checks.py b/suap/commands/packaging/checks.py index 6acdee4..98ac2ab 100644 --- a/suap/commands/packaging/checks.py +++ b/suap/commands/packaging/checks.py @@ -1,6 +1,7 @@ import re import typer import logging + from ...projects import ProjectData __all__ = () @@ -23,9 +24,7 @@ def check_project_data_validity(project_data: ProjectData) -> None: raise typer.Exit(1) if any(char.isupper() for char in project_name): - logger.error( - "Project name must be all lowercase!" - ) + logger.error("Project name must be all lowercase!") raise typer.Exit(1) diff --git a/suap/commands/packaging/icons.py b/suap/commands/packaging/icons.py index 15867db..a71a190 100644 --- a/suap/commands/packaging/icons.py +++ b/suap/commands/packaging/icons.py @@ -1,5 +1,4 @@ -from typing import Optional - +import typer import shutil import logging from pathlib import Path @@ -10,7 +9,7 @@ logger = logging.getLogger(__name__) -def get_platform_icon_path(icons_path: Path, platform_format: PlatformFormat) -> Optional[Path]: +def get_platform_icon_path(icons_path: Path, platform_format: PlatformFormat) -> Path: """ Returns `None` if an icon can't be found / doesn't exist. Otherwise a `Path` is returned. """ @@ -42,14 +41,20 @@ def get_platform_icon_path(icons_path: Path, platform_format: PlatformFormat) -> ) if platform_format & PlatformFormat.WINDOWS: - logger.warning( - "You will MOST LIKELY want a platform specific icon for Windows "\ - "(e.g: 'windows.ico') as support for PNGs are a gray area and can cause problems." + logger.error( + "Platform specific icon is REQUIRED for WINDOWS platform! A " \ + f"'windows.ico' file must be provided in your icons folder ({icons_path})." ) + raise typer.Exit(1) + return original_icon_path - return None + logger.error( + "At least an original icon is required in your icons " \ + f"path at '{icons_path}' (e.g: 'original.png')!" + ) + raise typer.Exit(1) def format_icon_with_project_name( icon_path: Path, diff --git a/suap/commands/packaging/nsis.py b/suap/commands/packaging/nsis.py index dd4bbf5..a20c2fb 100644 --- a/suap/commands/packaging/nsis.py +++ b/suap/commands/packaging/nsis.py @@ -4,11 +4,9 @@ from typer import Exit from pathlib import Path -# just so we can get the root path of our library -import suap - from ...mime_type import MimeType from ...projects import ProjectData +from ...templating import Template, Key from ...formats import make_nsis_installer __all__ = ( @@ -38,56 +36,62 @@ def format_config_and_make_nsis_installer( temp_folder_path.mkdir(exist_ok = True) nsis_installer_script_path = temp_folder_path.joinpath("nsis_installer.nsi") - templates_path = Path(suap.__file__).parent.joinpath("templates") - installer_script_template_path = templates_path.joinpath("nsis_installer_script.nsi") + installer_script_template = Template("nsis_installer_script.nsi") - logger.debug( - f"Opening and reading NSIS template installer script at '{installer_script_template_path}'..." - ) + semver = project_data.version - with open(installer_script_template_path, mode = "r") as file: - installer_script_string = file.read() + binary_name_key = Key("binary-name", binary_name) - logger.debug("Formatting template and creating custom install script...") + display_name_key = Key( + name = "display-name", + value = display_name if display_name is not None else project_data.name + ) - semver = project_data.version - display_name = display_name if display_name is not None else project_data.name - - replace_map: dict[str, str] = { - "suap-binary-name": binary_name, - "suap-binary-path": str(binary_path.absolute()), - "suap-binary-dist-path": str(binary_dist_path.absolute()), - "suap-display-name": display_name, - "suap-icon-path": str(icon_path.absolute()), - "suap-icon-file-name": icon_path.name, - - "suap-project-name": project_data.name, - "suap-project-version": f"{semver.major}.{semver.minor}.{semver.patch}" \ - f".{semver.prerelease.split('.')[-1] if semver.prerelease is not None else 0}", - "suap-project-description": project_data.description, - - "suap-app-capabilities-macro": generate_app_capabilities_macro( - mime_types, - templates_path, - binary_name, - display_name, - icon_path, - project_data, - uninstall = False, - ), - "suap-app-capabilities-uni-macro": generate_app_capabilities_macro( - mime_types, - templates_path, - binary_name, - display_name, - icon_path, - project_data, - uninstall = True, - ), - } - - for (suap_key, value) in replace_map.items(): - installer_script_string = installer_script_string.replace(f"{{{suap_key}}}", value) + icon_file_name_key = Key("icon-file-name", icon_path.name) + + installer_script_string = installer_script_template.format( + keys = ( + binary_name_key, + Key("binary-path", str(binary_path.absolute())), + Key("binary-dist-path", str(binary_dist_path.absolute())), + + display_name_key, + + Key("icon-path", str(icon_path.absolute())), + icon_file_name_key, + + Key("project-name", project_data.name), + Key( + "project-version", + f"{semver.major}.{semver.minor}.{semver.patch}" \ + f".{semver.prerelease.split('.')[-1] if semver.prerelease is not None else 0}" + ), + Key("project-description", project_data.description), + + Key( + name = "app-capabilities-macro", + value = generate_app_capabilities_macro( + mime_types, + binary_name_key, + display_name_key, + icon_file_name_key, + project_data, + uninstall = False, + ) + ), + Key( + name = "app-capabilities-uni-macro", + value = generate_app_capabilities_macro( + mime_types, + binary_name_key, + display_name_key, + icon_file_name_key, + project_data, + uninstall = True, + ) + ), + ) + ) logger.debug( f"Creating and writing to custom NSIS installer script at '{nsis_installer_script_path}'..." @@ -103,22 +107,21 @@ def format_config_and_make_nsis_installer( def generate_app_capabilities_macro( mime_types: list[MimeType], - templates_path: Path, - binary_name: str, - display_name: str, - icon_path: Path, + binary_name_key: Key, + display_name_key: Key, + icon_file_name_key: Key, project_data: ProjectData, uninstall: bool, ) -> str: if len(mime_types) == 0: return "" - app_capabilities_template_path = templates_path.joinpath( - "nsis_app_capabilities_uni_macro.nsi" if uninstall else "nsis_app_capabilities_macro.nsi" - ) + template_name = "nsis_app_capabilities_macro.nsi" + + if uninstall: + template_name = "nsis_app_capabilities_uni_macro.nsi" - with open(app_capabilities_template_path, mode = "r") as file: - app_capabilities_macro_string = file.read() + app_capabilities_template = Template(template_name) file_associations_and_types_lines = [] @@ -133,7 +136,7 @@ def generate_app_capabilities_macro( if not uninstall: file_associations_and_types_lines.append( - f'WriteRegStr HKCR "Applications\\{binary_name}.exe\\SupportedTypes" "{file_extension}" ""' + f'WriteRegStr HKCR "Applications\\{binary_name_key.name}.exe\\SupportedTypes" "{file_extension}" ""' ) file_associations_and_types_lines.append( @@ -146,35 +149,26 @@ def generate_app_capabilities_macro( else: file_associations_and_types_lines.append( - f'DeleteRegValue HKCR "Applications\\{binary_name}.exe\\SupportedTypes" "{file_extension}"' + f'DeleteRegValue HKCR "Applications\\{binary_name_key.name}.exe\\SupportedTypes" "{file_extension}"' ) file_associations_and_types_lines.append( f'DeleteRegValue HKCR "{file_extension}\\OpenWithProgIds" "Cloudy.{project_name}.1"' ) - replace_map: dict[str, str] = { - "suap-binary-name": binary_name, - "suap-display-name": display_name, - "suap-icon-file-name": icon_path.name, - - "suap-project-name": project_data.name, - "suap-project-description": project_data.description, - - "suap-file-associations-and-types-macro": "\n".join(file_associations_and_types_lines), - } - - for (suap_key, value) in replace_map.items(): - app_capabilities_macro_string = app_capabilities_macro_string.replace( - f"{{{suap_key}}}", value + app_capabilities_macro_string = app_capabilities_template.format( + keys = ( + binary_name_key, + display_name_key, + icon_file_name_key, + Key("project-name", project_data.name), + Key("project-description", project_data.description), + Key("file-associations-and-types-macro", value = "\n".join(file_associations_and_types_lines)), ) + ) formatted_macro_string = "".join([ f" {line}" for line in app_capabilities_macro_string.splitlines(keepends = True) ]) - logger.debug( - f"Generated and formatted app capabilities macro: \n{formatted_macro_string}" - ) - return formatted_macro_string \ No newline at end of file diff --git a/suap/commands/packaging/package.py b/suap/commands/packaging/package.py index 80a5250..77892a5 100644 --- a/suap/commands/packaging/package.py +++ b/suap/commands/packaging/package.py @@ -73,6 +73,8 @@ def package( ) raise Exit(1) + # TODO: move this and other processed and + # checked data into some sort of object / class icons_path = Path(icons_config_path) if not icons_path.exists(): @@ -82,13 +84,6 @@ def package( platform_icon_path = get_platform_icon_path(icons_path, platform_format) - if platform_icon_path is None: - logger.error( - "At least an original icon is required in your icons " \ - f"path at '{icons_config_path}' (e.g: 'original.png')!" - ) - raise Exit(1) - if project == ProjectType.CARGO: projects_config_data: Optional[ConfigProjectData] = config_data.get("project", None) @@ -121,7 +116,12 @@ def package( ) raise Exit(1) - if not build_cargo_project(toolchain_name, project_data.name): + if not build_cargo_project( + toolchain_name, + project_data.name, + platform_icon_path, + temp_folder_path + ): raise Exit(1) cargo_release_path = Path(f"./target/{toolchain_name}/release") diff --git a/suap/errors.py b/suap/errors.py new file mode 100644 index 0000000..ddc0a73 --- /dev/null +++ b/suap/errors.py @@ -0,0 +1,16 @@ +__all__ = () + +class SuapError(Exception): + message: str + + def __init__(self, message: str): + self.message = message + + super().__init__(message) + +class TemplateKeysRemainingError(SuapError): + def __init__(self, remaining_keys: set[str]): + super().__init__( + "There are keys defined in the template " \ + f"that were not formatted! Remaining Keys: {remaining_keys}" + ) \ No newline at end of file diff --git a/suap/projects/cargo/building.py b/suap/projects/cargo/building.py index 2d63604..e0523c6 100644 --- a/suap/projects/cargo/building.py +++ b/suap/projects/cargo/building.py @@ -2,6 +2,7 @@ import os import logging +from pathlib import Path from subprocess import check_call, CalledProcessError from ...platform_format import PlatformFormat @@ -28,15 +29,28 @@ def get_cargo_toolchain(platform_format: PlatformFormat) -> Optional[str]: return toolchain_name -def build_cargo_project(toolchain_name: str, cargo_crate_name: str) -> bool: +def build_cargo_project( + toolchain_name: str, + cargo_crate_name: str, + icon_path: Path, + temp_folder_path: Path +) -> bool: logger.debug(f"Invoking 'cargo build' with toolchain '{toolchain_name}'...") try: default_env = os.environ.copy() - default_env["RUSTFLAGS"] = "-Awarnings" # hides warnings in console + + rust_flags = ["-Awarnings"] # hides warnings in console + + if toolchain_name == "x86_64-pc-windows-gnu": + compiled_resource_path = build_windows_resource_file(icon_path, temp_folder_path) + + rust_flags.extend(["-C", f"link-arg={compiled_resource_path}"]) + + default_env["RUSTFLAGS"] = " ".join(rust_flags) logger.warning( - f"RUSTFLAGS are not inherited! 'RUSTFLAGS' will contain -> '{default_env['RUSTFLAGS']}'." + f"RUSTFLAGS is NOT inherited! 'RUSTFLAGS' will contain -> '{default_env['RUSTFLAGS']}'." ) check_call( @@ -59,4 +73,42 @@ def build_cargo_project(toolchain_name: str, cargo_crate_name: str) -> bool: return False - return True \ No newline at end of file + return True + +def build_windows_resource_file(icon_path: Path, temp_folder_path: Path) -> Optional[Path]: + logger.debug("Building windows resource file...") + + resource_file_path = temp_folder_path.joinpath("resource.rc") + compiled_resource_binary_path = temp_folder_path.joinpath("resource.res") + + resource_contents = f""" +IDI_MYICON ICON "{icon_path.absolute()}" +""" + + with open(resource_file_path, mode = "w") as file: + file.write(resource_contents) + + try: + logger.debug(f"Invoking 'x86_64-w64-mingw32-windres' to build '{compiled_resource_binary_path}'...") + + check_call( + # now if you're using a distro that doesn't name + # windres as "x86_64-w64-mingw32-windres"... *Eh... your loss...* + args = [ + "x86_64-w64-mingw32-windres", + f"{resource_file_path.absolute()}", + "-O", + "coff", + "-o", + f"{compiled_resource_binary_path.absolute()}" + ] + ) + + except CalledProcessError as error: + logger.error( + f"Failed to build windows resource file (resource.res)! Error: {error}" + ) + + return None + + return compiled_resource_binary_path \ No newline at end of file diff --git a/suap/templates/test_template.txt b/suap/templates/test_template.txt new file mode 100644 index 0000000..8fe73ea --- /dev/null +++ b/suap/templates/test_template.txt @@ -0,0 +1 @@ +The kitty goes... {suap-insert-meow}! Some weird formatting... {{suap-some-weird-format-1}" {{suap-some-weird-format-1}}} {{suap-some-weird-format-2}} suap-umm \ No newline at end of file diff --git a/suap/templating.py b/suap/templating.py new file mode 100644 index 0000000..1d655b7 --- /dev/null +++ b/suap/templating.py @@ -0,0 +1,61 @@ +import re +import logging +from pathlib import Path +from dataclasses import dataclass + +from .errors import TemplateKeysRemainingError + +__all__ = () + +logger = logging.getLogger(__name__) + +@dataclass +class Key: + name: str + value: str + +class Template(): + def __init__(self, template_name: str): + self.__template_string = self.__get_template_contents(template_name) + + # NOTE: Help improving this would be cool, I'm not good at regex + defined_suap_keys_list = re.findall( + "{suap-(.*?)}", self.__template_string, re.DOTALL + ) + + self.__defined_suap_keys: set[str] = set(defined_suap_keys_list) + + def format(self, keys: tuple[Key, ...]) -> str: + formatted_string = self.__template_string + + remaining_keys = self.__defined_suap_keys + + for key in keys: + key_name = key.name + key_value = str(key.value) + + if key_name in remaining_keys: + logger.debug(f"Formatting template key '{key_name}' with value '{key_value}'...") + + remaining_keys.remove(key_name) + + formatted_string = formatted_string.replace( + f"{{suap-{key_name}}}", key_value + ) + + if len(remaining_keys) > 0: + raise TemplateKeysRemainingError(remaining_keys) + + return formatted_string + + def __get_template_contents(self, template_name: str) -> str: + specific_template_path = Path(__file__).parent.joinpath("templates", template_name) + + logger.debug( + f"Opening and reading '{template_name}' template at '{specific_template_path}'..." + ) + + with open(specific_template_path, mode = "r") as file: + template_contents = file.read() + + return template_contents \ No newline at end of file diff --git a/tests/test_templating.py b/tests/test_templating.py new file mode 100644 index 0000000..3cbfda8 --- /dev/null +++ b/tests/test_templating.py @@ -0,0 +1,33 @@ +from suap.templating import Template, Key +from suap.errors import TemplateKeysRemainingError + +def test_keys_remaining(): + template = Template("test_template.txt") + + try: + template.format( + keys = ( + Key("some-weird-format-2", "this should work!"), + ) + ) + + except Exception as error: + assert isinstance(error, TemplateKeysRemainingError) == True + return + + assert False, "Unreadable! The 'TemplateKeysRemainingError' exception did not occur!" + +def test_correct_formatting(): + template = Template("test_template.txt") + + formatted_template = template.format( + keys = ( + Key("insert-meow", "MEOW"), + Key("some-weird-format-1", "some weird format"), + Key("some-weird-format-2", "this should work!"), + ) + ) + + expected_result = """The kitty goes... MEOW! Some weird formatting... {some weird format" {some weird format}} {this should work!} suap-umm""" + + assert formatted_template == expected_result \ No newline at end of file diff --git a/uv.lock b/uv.lock index a0c0cc9..51142e5 100644 --- a/uv.lock +++ b/uv.lock @@ -32,6 +32,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -53,6 +62,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -62,6 +89,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -128,6 +171,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, { name = "ty" }, ] @@ -140,6 +184,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pytest", specifier = ">=9.0.2" }, { name = "ruff", specifier = ">=0.15.6" }, { name = "ty", specifier = ">=0.0.23" }, ]