diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac9f86..599fbbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added support for macOS in `invoke yakerize`. +* Added support for yakerize configuration through the context configuration dictionary. + ### Changed ### Removed diff --git a/requirements.txt b/requirements.txt index c0e6e6c..4a2527d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ tomlkit -semver \ No newline at end of file +semver +pythonnet \ No newline at end of file diff --git a/src/compas_invocations2/grasshopper.py b/src/compas_invocations2/grasshopper.py index 2cf9fa0..98f18b3 100644 --- a/src/compas_invocations2/grasshopper.py +++ b/src/compas_invocations2/grasshopper.py @@ -7,11 +7,14 @@ """ import os +import platform import re import shutil +import subprocess import tempfile from pathlib import Path from typing import List +from typing import Optional import invoke import requests @@ -21,18 +24,58 @@ YAK_URL = r"https://files.mcneel.com/yak/tools/latest/yak.exe" +# The `yak` CLI shipped inside the Rhino application bundle on macOS. +RHINO_YAK_PATHS = [ + "/Applications/Rhino 9.app/Contents/Resources/bin/yak", + "/Applications/Rhino 8.app/Contents/Resources/bin/yak", + "/Applications/Rhino 7.app/Contents/Resources/bin/yak", +] + 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") + # absolute, because callers run yak from inside a different working directory + target_path = os.path.abspath(os.path.join(target_dir, "yak.exe")) with open(target_path, "wb") as f: f.write(response.content) return target_path +def _find_native_yak() -> Optional[str]: + """Return the path to a natively runnable ``yak`` CLI, if one is installed.""" + for path in RHINO_YAK_PATHS: + if os.path.isfile(path) and os.access(path, os.X_OK): + return path + return shutil.which("yak") + + +def _get_yak_command(download_dir: str) -> List[str]: + """Return the argv prefix used to invoke yak, downloading ``yak.exe`` if needed. + + The only yak binary McNeel publishes for download is a .NET Framework ``yak.exe``. + On Windows it runs as-is. Elsewhere it needs a runtime, so we prefer the native + ``yak`` CLI that ships inside the Rhino application bundle (macOS) or is otherwise + on PATH, and fall back to running the downloaded ``yak.exe`` under Mono. + """ + if platform.system() == "Windows": + return [_download_yak_executable(download_dir)] + + native_yak = _find_native_yak() + if native_yak: + return [native_yak] + + mono = shutil.which("mono") + if not mono: + raise invoke.Exit( + "No yak executable available. Install Rhino (which bundles the `yak` CLI) " + "or install Mono (`brew install mono`) so that the downloaded yak.exe can be run." + ) + return [mono, _download_yak_executable(download_dir)] + + def _set_version_in_manifest(manifest_path: str, version: str): with open(manifest_path, "r") as f: lines = f.readlines() @@ -130,10 +173,39 @@ def _get_user_object_path(context): return None +def _get_yak_setting(ctx, key: str) -> Optional[str]: + """Return a path configured under the ``yak`` section of the project's tasks.py. + + Relative paths are resolved against ``base_folder``, matching the convention + used for the ``ghuser`` config sections. + """ + if not hasattr(ctx, "yak"): + return None + + value = ctx.yak.get(key) + if not value: + return None + + return value if os.path.isabs(value) else os.path.join(ctx.base_folder, value) + + +def _resolve_yak_path(ctx, key: str, value: Optional[str], description: str) -> str: + """Resolve a path from the task argument, falling back to the ``yak`` config section.""" + path = value or _get_yak_setting(ctx, key) + if not path: + raise invoke.Exit( + f"""Please provide the path to the {description}, either using `--{key.replace("_", "-")}` + or by setting `yak.{key}` in the configuration of your tasks.py.""" + ) + if not os.path.exists(path): + raise invoke.Exit(f"{description.capitalize()} not found at {path}. Please provide a valid path.") + return path + + @invoke.task( help={ - "manifest_path": "Path to the manifest file.", - "logo_path": "Path to the logo file.", + "manifest_path": "(Optional) Path to the manifest file. Defaults to the `yak.manifest_path` setting.", + "logo_path": "(Optional) Path to the logo file. Defaults to the `yak.logo_path` setting.", "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.", @@ -143,8 +215,8 @@ def _get_user_object_path(context): ) def yakerize( ctx, - manifest_path: str, - logo_path: str, + manifest_path: str = None, + logo_path: str = None, gh_components_dir: str = None, readme_path: str = None, license_path: str = None, @@ -158,6 +230,9 @@ def yakerize( 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).""" ) + manifest_path = _resolve_yak_path(ctx, "manifest_path", manifest_path, "manifest file") + logo_path = _resolve_yak_path(ctx, "logo_path", logo_path, "logo file") + 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.") @@ -182,7 +257,8 @@ def yakerize( else: os.makedirs(target_dir, exist_ok=False) - manifest_target = shutil.copy(manifest_path, target_dir) + # yak only recognizes a manifest named `manifest.yml`, regardless of the source filename + manifest_target = shutil.copy(manifest_path, os.path.join(target_dir, "manifest.yml")) _set_version_in_manifest(manifest_target, version) shutil.copy(logo_path, target_dir) @@ -202,17 +278,15 @@ def yakerize( # 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) + yak_cmd = _get_yak_command(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: + subprocess.run(yak_cmd + ["build", "--platform", "any"], check=True) + except (OSError, subprocess.CalledProcessError) 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.") @@ -234,18 +308,24 @@ def publish_yak(ctx, yak_file: str, test_server: bool = False): if not yak_file.endswith(".yak"): raise invoke.Exit("Invalid file type. Must be a .yak file.") + yak_file = os.path.abspath(yak_file) + with chdir(ctx.base_folder): with tempfile.TemporaryDirectory("actions.publish_yak") as action_dir: try: - _download_yak_executable(action_dir) + yak_cmd = _get_yak_command(action_dir) except ValueError: raise invoke.Exit("Failed to download the yak executable") - yak_exe_path: str = os.path.join(action_dir, "yak.exe") + cmd = yak_cmd + ["push"] 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}") + cmd += ["--source", "https://test.yak.rhino3d.com"] + cmd.append(yak_file) + + try: + subprocess.run(cmd, check=True) + except (OSError, subprocess.CalledProcessError) as e: + raise invoke.Exit(f"Failed to publish the yak package: {e}") def _is_header_line(line: str) -> bool: