diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f86f4..50d1b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added task `yakerize` to create YAK package for Grasshopper. + ### Changed * Task `build-cpython-ghuser-components` now uses `ghuser_cpython` configuration key. +* Moved task `build-cpython-ghuser-components` from `build` to `grasshopper`. +* Moved task `build-ghuser-components` from `build` to `grasshopper`. ### Removed diff --git a/requirements-dev.txt b/requirements-dev.txt index 35a45e4..ceadf0f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,4 +6,5 @@ invoke >=0.14 ruff sphinx_compas2_theme twine -wheel \ No newline at end of file +wheel +toml \ No newline at end of file diff --git a/src/compas_invocations2/grasshopper.py b/src/compas_invocations2/grasshopper.py new file mode 100644 index 0000000..cb51c83 --- /dev/null +++ b/src/compas_invocations2/grasshopper.py @@ -0,0 +1,194 @@ +""" +Adapted from: https://github.com/diffCheckOrg/diffCheck/blob/main/invokes/yakerize.py + +Yakerize.py was originally developed as part of the DiffCheck plugin by +Andrea Settimi, Damien Gilliard, Eleni Skevaki, Marirena Kladeftira (IBOIS, CRCL, EPFL) in 2024. +It is distributed under the MIT License, provided this attribution is retained. +""" + +import os +import shutil +import tempfile + +import invoke +import requests +import toml + +from compas_invocations2.console import chdir + +YAK_URL = r"https://files.mcneel.com/yak/tools/latest/yak.exe" + + +def _download_yak_executable(target_dir: str): + response = requests.get(YAK_URL) + if response.status_code != 200: + raise ValueError(f"Failed to download the yak.exe from url:{YAK_URL} with error : {response.status_code}") + + target_path = os.path.join(target_dir, "yak.exe") + with open(target_path, "wb") as f: + f.write(response.content) + return target_path + + +def _set_version_in_manifest(manifest_path: str, version: str): + with open(manifest_path, "r") as f: + lines = f.readlines() + + new_lines = [] + for line in lines: + if "{{ version }}" in line: + new_lines.append(line.replace("{{ version }}", version)) + else: + new_lines.append(line) + + with open(manifest_path, "w") as f: + f.writelines(new_lines) + + +def _clear_directory(path_to_dir): + for f in os.listdir(path_to_dir): + file_path = os.path.join(path_to_dir, f) + try: + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + except Exception as e: + raise invoke.Exit(f"Failed to delete {file_path}: {e}") + + +def _get_version_from_toml(toml_file: str) -> str: + pyproject_data = toml.load(toml_file) + version = pyproject_data.get("tool", {}).get("bumpversion", {}).get("current_version", None) + if not version: + raise invoke.Exit("Failed to get version from pyproject.toml. Please provide a version number.") + return version + + +def _get_user_object_path(context): + if hasattr(context, "ghuser_cpython"): + print("checking ghuser_cpython") + return os.path.join(context.base_folder, context.ghuser_cpython.target_dir) + elif hasattr(context, "ghuser"): + print("checking ghuser") + return os.path.join(context.base_folder, context.ghuser.target_dir) + else: + return None + + +@invoke.task( + help={ + "manifest_path": "Path to the manifest file.", + "logo_path": "Path to the logo file.", + "gh_components_dir": "(Optional) Path to the directory containing the .ghuser files.", + "readme_path": "(Optional) Path to the readme file.", + "license_path": "(Optional) Path to the license file.", + "version": "(Optional) The version number to set in the manifest file.", + "target_rhino": "(Optional) The target Rhino version for the package. Defaults to 'rh8'.", + } +) +def yakerize( + ctx, + manifest_path: str, + logo_path: str, + gh_components_dir: str = None, + readme_path: str = None, + license_path: str = None, + version: str = None, + target_rhino: str = "rh8", +) -> bool: + """Create a Grasshopper YAK package from the current project.""" + # https://developer.rhino3d.com/guides/yak/the-anatomy-of-a-package/ + if target_rhino.split("_")[0] not in ["rh6", "rh7", "rh8"]: + raise invoke.Exit( + f"""Invalid target Rhino version `{target_rhino}`. Must be one of: rh6, rh7, rh8. + Minor version is optional and can be appended with a '_' (e.g. rh8_15).""" + ) + gh_components_dir = gh_components_dir or _get_user_object_path(ctx) + if not gh_components_dir: + raise invoke.Exit("Please provide the path to the directory containing the .ghuser files.") + + readme_path = readme_path or os.path.join(ctx.base_folder, "README.md") + if not os.path.exists(readme_path): + raise invoke.Exit(f"Readme file not found at {readme_path}. Please provide a valid path.") + + license_path = license_path or os.path.join(ctx.base_folder, "LICENSE") + if not os.path.exists(license_path): + raise invoke.Exit(f"License file not found at {license_path}. Please provide a valid path.") + + version = version or _get_version_from_toml(os.path.join(ctx.base_folder, "pyproject.toml")) + target_dir = os.path.join(ctx.base_folder, "dist", "yak_package") + + ##################################################################### + # Copy manifest, logo, misc folder (readme, license, etc) + ##################################################################### + # if target dit exists, make sure it's empty + if os.path.exists(target_dir) and os.path.isdir(target_dir): + _clear_directory(target_dir) + else: + os.makedirs(target_dir, exist_ok=False) + + manifest_target = shutil.copy(manifest_path, target_dir) + _set_version_in_manifest(manifest_target, version) + shutil.copy(logo_path, target_dir) + + path_miscdir: str = os.path.join(target_dir, "misc") + os.makedirs(path_miscdir, exist_ok=False) + shutil.copy(readme_path, path_miscdir) + shutil.copy(license_path, path_miscdir) + + for f in os.listdir(gh_components_dir): + if f.endswith(".ghuser"): + shutil.copy(os.path.join(gh_components_dir, f), target_dir) + + ##################################################################### + # Yak exe + ##################################################################### + + # yak executable shouldn't be in the target directory, otherwise it will be included in the package + target_parent = os.sep.join(target_dir.split(os.sep)[:-1]) + try: + yak_exe_path = _download_yak_executable(target_parent) + except ValueError: + raise invoke.Exit("Failed to download the yak executable") + else: + yak_exe_path = os.path.abspath(yak_exe_path) + + with chdir(target_dir): + try: + # not using `ctx.run()` here to get properly formatted output (unicode+colors) + os.system(f"{yak_exe_path} build --platform any") + except Exception as e: + raise invoke.Exit(f"Failed to build the yak package: {e}") + if not any([f.endswith(".yak") for f in os.listdir(target_dir)]): + raise invoke.Exit("No .yak file was created in the build directory.") + + # filename is what tells YAK the target Rhino version..? + taget_file = next((f for f in os.listdir(target_dir) if f.endswith(".yak"))) + new_filename = taget_file.replace("any-any", f"{target_rhino}-any") + os.rename(taget_file, new_filename) + + +@invoke.task( + help={"yak_file": "Path to the .yak file to publish.", "test_server": "True to publish to the test server."} +) +def publish_yak(ctx, yak_file: str, test_server: bool = False): + """Publish a YAK package to the YAK server.""" + + if not os.path.exists(yak_file) or not os.path.isfile(yak_file): + raise invoke.Exit(f"Yak file not found at {yak_file}. Please provide a valid path.") + if not yak_file.endswith(".yak"): + raise invoke.Exit("Invalid file type. Must be a .yak file.") + + with chdir(ctx.base_folder): + with tempfile.TemporaryDirectory("actions.publish_yak") as action_dir: + try: + _download_yak_executable(action_dir) + except ValueError: + raise invoke.Exit("Failed to download the yak executable") + + yak_exe_path: str = os.path.join(action_dir, "yak.exe") + if test_server: + ctx.run(f"{yak_exe_path} push --source https://test.yak.rhino3d.com {yak_file}") + else: + ctx.run(f"{yak_exe_path} push {yak_file}")