From 5b2d127138b3b3a78e1648d4342be9c942f4156a Mon Sep 17 00:00:00 2001 From: Eric Davis <6662995+endavis@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:16:21 +0100 Subject: [PATCH 1/2] feat: add reusable GitHub Release binary installer framework Addresses #293 --- tests/test_doit_install_tools.py | 477 +++++++++++++++++++++++++++++++ tools/doit/install.py | 78 +---- tools/doit/install_tools.py | 179 ++++++++++++ 3 files changed, 669 insertions(+), 65 deletions(-) create mode 100644 tests/test_doit_install_tools.py create mode 100644 tools/doit/install_tools.py diff --git a/tests/test_doit_install_tools.py b/tests/test_doit_install_tools.py new file mode 100644 index 0000000..ff247ff --- /dev/null +++ b/tests/test_doit_install_tools.py @@ -0,0 +1,477 @@ +"""Tests for install_tools.py reusable tool installation framework.""" + +import json +import subprocess # nosec B404 - needed for CompletedProcess in tests +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from tools.doit.install_tools import ( + create_install_task, + download_github_release_binary, + get_install_dir, + get_latest_github_release, + install_tool, +) + + +class TestGetLatestGithubRelease: + """Tests for get_latest_github_release function.""" + + @patch("tools.doit.install_tools.urllib.request.urlopen") + @patch("tools.doit.install_tools.urllib.request.Request") + def test_returns_version_without_v_prefix( + self, mock_request_cls: MagicMock, mock_urlopen: MagicMock + ) -> None: + """Test that leading 'v' is stripped from tag_name.""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"tag_name": "v2.34.0"}).encode() + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_response + + result = get_latest_github_release("direnv/direnv") + + assert result == "2.34.0" + + @patch("tools.doit.install_tools.urllib.request.urlopen") + @patch("tools.doit.install_tools.urllib.request.Request") + def test_returns_version_without_v_when_no_prefix( + self, mock_request_cls: MagicMock, mock_urlopen: MagicMock + ) -> None: + """Test version returned as-is when no 'v' prefix.""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"tag_name": "2.34.0"}).encode() + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_response + + result = get_latest_github_release("owner/repo") + + assert result == "2.34.0" + + @patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}) + @patch("tools.doit.install_tools.urllib.request.urlopen") + @patch("tools.doit.install_tools.urllib.request.Request") + def test_adds_auth_header_when_token_present( + self, mock_request_cls: MagicMock, mock_urlopen: MagicMock + ) -> None: + """Test that GITHUB_TOKEN is used for Authorization header.""" + mock_request_instance = MagicMock() + mock_request_cls.return_value = mock_request_instance + + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"tag_name": "v1.0.0"}).encode() + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_response + + get_latest_github_release("owner/repo") + + mock_request_instance.add_header.assert_called_once_with( + "Authorization", "token test-token" + ) + + @patch.dict("os.environ", {}, clear=True) + @patch("tools.doit.install_tools.urllib.request.urlopen") + @patch("tools.doit.install_tools.urllib.request.Request") + def test_no_auth_header_when_no_token( + self, mock_request_cls: MagicMock, mock_urlopen: MagicMock + ) -> None: + """Test that no Authorization header is added without GITHUB_TOKEN.""" + mock_request_instance = MagicMock() + mock_request_cls.return_value = mock_request_instance + + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"tag_name": "v1.0.0"}).encode() + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_response + + get_latest_github_release("owner/repo") + + mock_request_instance.add_header.assert_not_called() + + @patch("tools.doit.install_tools.urllib.request.urlopen") + @patch("tools.doit.install_tools.urllib.request.Request") + def test_constructs_correct_api_url( + self, mock_request_cls: MagicMock, mock_urlopen: MagicMock + ) -> None: + """Test that the correct GitHub API URL is constructed.""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"tag_name": "v1.0.0"}).encode() + mock_response.__enter__ = MagicMock(return_value=mock_response) + mock_response.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_response + + get_latest_github_release("owner/repo") + + mock_request_cls.assert_called_once_with( + "https://api.github.com/repos/owner/repo/releases/latest" + ) + + +class TestGetInstallDir: + """Tests for get_install_dir function.""" + + @patch("tools.doit.install_tools.Path.home") + def test_returns_local_bin_path(self, mock_home: MagicMock, tmp_path: Path) -> None: + """Test that ~/.local/bin path is returned.""" + mock_home.return_value = tmp_path + result = get_install_dir() + assert result == tmp_path / ".local" / "bin" + + @patch("tools.doit.install_tools.Path.home") + def test_creates_directory_if_not_exists(self, mock_home: MagicMock, tmp_path: Path) -> None: + """Test that the directory is created when it does not exist.""" + mock_home.return_value = tmp_path + expected = tmp_path / ".local" / "bin" + assert not expected.exists() + + get_install_dir() + + assert expected.exists() + + @patch("tools.doit.install_tools.Path.home") + def test_no_error_if_directory_exists(self, mock_home: MagicMock, tmp_path: Path) -> None: + """Test that no error occurs when directory already exists.""" + mock_home.return_value = tmp_path + expected = tmp_path / ".local" / "bin" + expected.mkdir(parents=True) + + result = get_install_dir() + + assert result == expected + + +class TestDownloadGithubReleaseBinary: + """Tests for download_github_release_binary function.""" + + @patch("tools.doit.install_tools.get_install_dir") + @patch("tools.doit.install_tools.urllib.request.urlretrieve") + def test_downloads_to_correct_path( + self, + mock_urlretrieve: MagicMock, + mock_get_install_dir: MagicMock, + tmp_path: Path, + ) -> None: + """Test that binary is downloaded to the correct destination.""" + mock_get_install_dir.return_value = tmp_path + dest = tmp_path / "mytool" + dest.touch() + + result = download_github_release_binary( + repo="owner/repo", + version="1.2.3", + asset_pattern="mytool.linux-amd64", + dest_name="mytool", + ) + + assert result == dest + mock_urlretrieve.assert_called_once_with( + "https://github.com/owner/repo/releases/download/v1.2.3/mytool.linux-amd64", + dest, + ) + + @patch("tools.doit.install_tools.get_install_dir") + @patch("tools.doit.install_tools.urllib.request.urlretrieve") + def test_version_placeholder_in_asset_pattern( + self, + mock_urlretrieve: MagicMock, + mock_get_install_dir: MagicMock, + tmp_path: Path, + ) -> None: + """Test that {version} placeholder is substituted in asset_pattern.""" + mock_get_install_dir.return_value = tmp_path + dest = tmp_path / "tool" + dest.touch() + + download_github_release_binary( + repo="owner/repo", + version="3.0.0", + asset_pattern="tool-v{version}-linux-amd64", + dest_name="tool", + ) + + mock_urlretrieve.assert_called_once_with( + "https://github.com/owner/repo/releases/download/v3.0.0/tool-v3.0.0-linux-amd64", + dest, + ) + + @patch("tools.doit.install_tools.get_install_dir") + @patch("tools.doit.install_tools.urllib.request.urlretrieve") + def test_sets_executable_permissions( + self, + mock_urlretrieve: MagicMock, + mock_get_install_dir: MagicMock, + tmp_path: Path, + ) -> None: + """Test that the binary is made executable after download.""" + mock_get_install_dir.return_value = tmp_path + dest = tmp_path / "mytool" + dest.touch(mode=0o644) + + download_github_release_binary( + repo="owner/repo", + version="1.0.0", + asset_pattern="mytool.linux-amd64", + dest_name="mytool", + ) + + assert dest.stat().st_mode & 0o755 == 0o755 + + +class TestInstallTool: + """Tests for install_tool function.""" + + @patch("tools.doit.install_tools.subprocess.run") + @patch("tools.doit.install_tools.shutil.which", return_value="/usr/bin/mytool") + def test_skips_install_when_tool_exists( + self, mock_which: MagicMock, mock_run: MagicMock, capsys: pytest.CaptureFixture[str] + ) -> None: + """Test that install is skipped when tool is already on PATH.""" + mock_run.return_value = subprocess.CompletedProcess( + args=["mytool", "--version"], returncode=0, stdout="1.0.0\n", stderr="" + ) + + install_tool( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + ) + + captured = capsys.readouterr() + assert "already installed" in captured.out + assert "1.0.0" in captured.out + + @patch("tools.doit.install_tools.subprocess.run") + @patch("tools.doit.install_tools.shutil.which", return_value="/usr/bin/mytool") + def test_uses_custom_version_cmd(self, mock_which: MagicMock, mock_run: MagicMock) -> None: + """Test that custom version_cmd is used for version check.""" + mock_run.return_value = subprocess.CompletedProcess( + args=["mytool", "version"], returncode=0, stdout="2.0.0\n", stderr="" + ) + + install_tool( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + version_cmd=["mytool", "version"], + ) + + mock_run.assert_called_once_with( + ["mytool", "version"], + capture_output=True, + text=True, + check=True, + ) + + @patch("tools.doit.install_tools.subprocess.run") + @patch("tools.doit.install_tools.shutil.which", return_value="/usr/bin/mytool") + def test_falls_back_to_stderr_for_version( + self, mock_which: MagicMock, mock_run: MagicMock, capsys: pytest.CaptureFixture[str] + ) -> None: + """Test that stderr is used when stdout is empty for version output.""" + mock_run.return_value = subprocess.CompletedProcess( + args=["mytool", "--version"], returncode=0, stdout="", stderr="3.0.0\n" + ) + + install_tool( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + ) + + captured = capsys.readouterr() + assert "3.0.0" in captured.out + + @patch("tools.doit.install_tools.download_github_release_binary") + @patch("tools.doit.install_tools.get_latest_github_release", return_value="2.0.0") + @patch("tools.doit.install_tools.platform.system", return_value="Linux") + @patch("tools.doit.install_tools.shutil.which", return_value=None) + def test_installs_on_linux( + self, + mock_which: MagicMock, + mock_system: MagicMock, + mock_get_release: MagicMock, + mock_download: MagicMock, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Test that tool is downloaded on Linux when not installed.""" + install_tool( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + ) + + mock_download.assert_called_once_with( + repo="owner/repo", + version="2.0.0", + asset_pattern="mytool.linux-amd64", + dest_name="mytool", + ) + captured = capsys.readouterr() + assert "mytool installed" in captured.out + + @patch("tools.doit.install_tools.subprocess.run") + @patch("tools.doit.install_tools.get_latest_github_release", return_value="2.0.0") + @patch("tools.doit.install_tools.platform.system", return_value="Darwin") + @patch("tools.doit.install_tools.shutil.which", return_value=None) + def test_installs_via_brew_on_darwin( + self, + mock_which: MagicMock, + mock_system: MagicMock, + mock_get_release: MagicMock, + mock_run: MagicMock, + ) -> None: + """Test that brew is used on macOS.""" + install_tool( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + ) + + mock_run.assert_called_once_with(["brew", "install", "mytool"], check=True) + + @patch("tools.doit.install_tools.get_latest_github_release", return_value="1.0.0") + @patch("tools.doit.install_tools.platform.system", return_value="Windows") + @patch("tools.doit.install_tools.shutil.which", return_value=None) + def test_exits_on_unsupported_os( + self, + mock_which: MagicMock, + mock_system: MagicMock, + mock_get_release: MagicMock, + ) -> None: + """Test that sys.exit is called for unsupported OS.""" + with pytest.raises(SystemExit): + install_tool( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + ) + + @patch("tools.doit.install_tools.download_github_release_binary") + @patch("tools.doit.install_tools.get_latest_github_release", return_value="1.0.0") + @patch("tools.doit.install_tools.platform.system", return_value="Linux") + @patch("tools.doit.install_tools.shutil.which", return_value=None) + def test_prints_post_install_message( + self, + mock_which: MagicMock, + mock_system: MagicMock, + mock_get_release: MagicMock, + mock_download: MagicMock, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Test that post_install_message is printed after install.""" + install_tool( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + post_install_message="Run 'mytool init' to get started.", + ) + + captured = capsys.readouterr() + assert "Run 'mytool init' to get started." in captured.out + + @patch("tools.doit.install_tools.download_github_release_binary") + @patch("tools.doit.install_tools.get_latest_github_release", return_value="1.0.0") + @patch("tools.doit.install_tools.platform.system", return_value="Linux") + @patch("tools.doit.install_tools.shutil.which", return_value=None) + def test_no_post_install_message_when_none( + self, + mock_which: MagicMock, + mock_system: MagicMock, + mock_get_release: MagicMock, + mock_download: MagicMock, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Test that no extra message is printed when post_install_message is None.""" + install_tool( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + ) + + captured = capsys.readouterr() + assert captured.out.strip().endswith("mytool installed.") + + @patch("tools.doit.install_tools.subprocess.run") + @patch("tools.doit.install_tools.shutil.which", return_value="/usr/bin/mytool") + def test_default_version_cmd(self, mock_which: MagicMock, mock_run: MagicMock) -> None: + """Test that default version_cmd is [name, '--version'].""" + mock_run.return_value = subprocess.CompletedProcess( + args=["mytool", "--version"], returncode=0, stdout="1.0.0\n", stderr="" + ) + + install_tool( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + ) + + mock_run.assert_called_once_with( + ["mytool", "--version"], + capture_output=True, + text=True, + check=True, + ) + + +class TestCreateInstallTask: + """Tests for create_install_task function.""" + + def test_returns_valid_doit_task(self) -> None: + """Test that a valid doit task dict is returned.""" + result = create_install_task( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + ) + + assert isinstance(result, dict) + assert "actions" in result + assert "title" in result + assert len(result["actions"]) == 1 + assert callable(result["actions"][0]) + + @patch("tools.doit.install_tools.install_tool") + def test_action_calls_install_tool(self, mock_install: MagicMock) -> None: + """Test that the task action calls install_tool with correct args.""" + result = create_install_task( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + version_cmd=["mytool", "version"], + post_install_message="Done!", + ) + + # Execute the action + result["actions"][0]() + + mock_install.assert_called_once_with( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + version_cmd=["mytool", "version"], + post_install_message="Done!", + ) + + @patch("tools.doit.install_tools.install_tool") + def test_action_passes_none_defaults(self, mock_install: MagicMock) -> None: + """Test that None defaults are passed through to install_tool.""" + result = create_install_task( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + ) + + result["actions"][0]() + + mock_install.assert_called_once_with( + name="mytool", + repo="owner/repo", + asset_patterns={"linux": "mytool.linux-amd64"}, + version_cmd=None, + post_install_message=None, + ) diff --git a/tools/doit/install.py b/tools/doit/install.py index 30b6407..a78814f 100644 --- a/tools/doit/install.py +++ b/tools/doit/install.py @@ -1,71 +1,16 @@ """Installation-related doit tasks.""" -import json -import os -import platform -import shutil -import subprocess # nosec B404 - subprocess is required for doit tasks -import sys -import urllib.request from typing import Any from doit.tools import title_with_actions +from .install_tools import create_install_task -def _get_latest_github_release(repo: str) -> str: - """Helper to get latest GitHub release version.""" - url = f"https://api.github.com/repos/{repo}/releases/latest" - request = urllib.request.Request(url) - - github_token = os.environ.get("GITHUB_TOKEN") - if github_token: - request.add_header("Authorization", f"token {github_token}") - - with urllib.request.urlopen(request) as response: # nosec B310 - URL is hardcoded GitHub API - data = json.loads(response.read().decode()) - tag_name: str = data["tag_name"] - return tag_name.lstrip("v") - - -def _install_direnv() -> None: - """Install direnv if not already installed.""" - if shutil.which("direnv"): - version = subprocess.run( - ["direnv", "--version"], - capture_output=True, - text=True, - check=True, - ).stdout.strip() - print(f"✓ direnv already installed: {version}") - return - - print("Installing direnv...") - version = _get_latest_github_release("direnv/direnv") - print(f"Latest version: {version}") - - system = platform.system().lower() - install_dir = os.path.expanduser("~/.local/bin") - if not os.path.exists(install_dir): - os.makedirs(install_dir, exist_ok=True) - - if system == "linux": - bin_url = ( - f"https://github.com/direnv/direnv/releases/download/v{version}/direnv.linux-amd64" - ) - bin_path = os.path.join(install_dir, "direnv") - print(f"Downloading {bin_url}...") - urllib.request.urlretrieve(bin_url, bin_path) # nosec B310 - downloading from hardcoded GitHub release URL - os.chmod(bin_path, 0o755) # nosec B103 - rwxr-xr-x is required for executable binary - elif system == "darwin": - subprocess.run(["brew", "install", "direnv"], check=True) - else: - print(f"Unsupported OS: {system}") - sys.exit(1) - - print("✓ direnv installed.") - print("\nIMPORTANT: Add direnv hook to your shell:") - print(" Bash: echo 'eval \"$(direnv hook bash)\"'") - print(" Zsh: echo 'eval \"$(direnv hook zsh)\"'") +_DIRENV_POST_INSTALL_MESSAGE = ( + "\nIMPORTANT: Add direnv hook to your shell:\n" + " Bash: echo 'eval \"$(direnv hook bash)\"'\n" + " Zsh: echo 'eval \"$(direnv hook zsh)\"'" +) def task_install() -> dict[str, Any]: @@ -96,7 +41,10 @@ def task_install_dev() -> dict[str, Any]: def task_install_direnv() -> dict[str, Any]: """Install direnv for automatic environment loading.""" - return { - "actions": [_install_direnv], - "title": title_with_actions, - } + return create_install_task( + name="direnv", + repo="direnv/direnv", + asset_patterns={"linux": "direnv.linux-amd64"}, + version_cmd=["direnv", "--version"], + post_install_message=_DIRENV_POST_INSTALL_MESSAGE, + ) diff --git a/tools/doit/install_tools.py b/tools/doit/install_tools.py new file mode 100644 index 0000000..a69a3c0 --- /dev/null +++ b/tools/doit/install_tools.py @@ -0,0 +1,179 @@ +"""Reusable framework for installing tools from GitHub releases.""" + +import json +import os +import platform +import shutil +import subprocess # nosec B404 - subprocess is required for version checks +import sys +import urllib.request +from pathlib import Path +from typing import Any + +from doit.tools import title_with_actions + + +def get_latest_github_release(repo: str) -> str: + """Get the latest release version for a GitHub repository. + + Queries the GitHub API for the latest release tag. Supports + authenticated requests via GITHUB_TOKEN environment variable. + + Args: + repo: GitHub repository in "owner/name" format (e.g. "direnv/direnv"). + + Returns: + Version string with leading 'v' stripped (e.g. "2.34.0"). + """ + url = f"https://api.github.com/repos/{repo}/releases/latest" + request = urllib.request.Request(url) + + github_token = os.environ.get("GITHUB_TOKEN") + if github_token: + request.add_header("Authorization", f"token {github_token}") + + with urllib.request.urlopen(request) as response: # nosec B310 - URL is hardcoded GitHub API + data = json.loads(response.read().decode()) + tag_name: str = data["tag_name"] + return tag_name.lstrip("v") + + +def get_install_dir() -> Path: + """Get the standard installation directory for user-local binaries. + + Returns: + Path to ~/.local/bin, created if it does not exist. + """ + install_dir = Path.home() / ".local" / "bin" + install_dir.mkdir(parents=True, exist_ok=True) + return install_dir + + +def download_github_release_binary( + repo: str, version: str, asset_pattern: str, dest_name: str +) -> Path: + """Download a binary asset from a GitHub release. + + Constructs the download URL from the repo, version, and asset pattern, + downloads the file to the user-local bin directory, and makes it + executable. + + Args: + repo: GitHub repository in "owner/name" format. + version: Release version (without leading 'v'). + asset_pattern: Filename pattern with {version} placeholder + (e.g. "tool.linux-amd64" or "tool-v{version}-linux-amd64"). + dest_name: Name of the installed binary (e.g. "tool"). + + Returns: + Path to the downloaded and installed binary. + """ + asset_name = asset_pattern.format(version=version) + url = f"https://github.com/{repo}/releases/download/v{version}/{asset_name}" + install_dir = get_install_dir() + dest_path = install_dir / dest_name + + print(f"Downloading {url}...") + urllib.request.urlretrieve(url, dest_path) # nosec B310 - downloading from constructed GitHub release URL + dest_path.chmod(0o755) # nosec B103 - rwxr-xr-x is required for executable binary + + return dest_path + + +def install_tool( + name: str, + repo: str, + asset_patterns: dict[str, str], + version_cmd: list[str] | None = None, + post_install_message: str | None = None, +) -> None: + """Install a tool from GitHub releases if not already present. + + Checks if the tool is already on PATH. If so, prints its version + and returns. Otherwise, downloads the latest release for the current + platform and installs it. + + Args: + name: Tool name used for PATH lookup and as the binary dest name. + repo: GitHub repository in "owner/name" format. + asset_patterns: Mapping of platform.system().lower() values + (e.g. "linux", "darwin") to asset filename patterns. + Patterns may include a {version} placeholder. + version_cmd: Command list to run for checking installed version + (e.g. ["tool", "--version"]). Defaults to [name, "--version"]. + post_install_message: Optional message printed after installation. + """ + if version_cmd is None: + version_cmd = [name, "--version"] + + if shutil.which(name): + result = subprocess.run( + version_cmd, + capture_output=True, + text=True, + check=True, + ) + version_output = result.stdout.strip() or result.stderr.strip() + print(f"\u2713 {name} already installed: {version_output}") + return + + print(f"Installing {name}...") + version = get_latest_github_release(repo) + print(f"Latest version: {version}") + + system = platform.system().lower() + if system == "darwin": + subprocess.run(["brew", "install", name], check=True) + elif system in asset_patterns: + download_github_release_binary( + repo=repo, + version=version, + asset_pattern=asset_patterns[system], + dest_name=name, + ) + else: + print(f"Unsupported OS for {name}: {system}") + sys.exit(1) + + print(f"\u2713 {name} installed.") + if post_install_message: + print(post_install_message) + + +def create_install_task( + name: str, + repo: str, + asset_patterns: dict[str, str], + version_cmd: list[str] | None = None, + post_install_message: str | None = None, +) -> dict[str, Any]: + """Create a doit task dict for installing a tool from GitHub releases. + + This is a factory function that returns a doit-compatible task + dictionary. The task's action calls install_tool with the provided + parameters. + + Args: + name: Tool name used for PATH lookup and as the binary dest name. + repo: GitHub repository in "owner/name" format. + asset_patterns: Mapping of platform names to asset filename patterns. + version_cmd: Command list for version check. Defaults to [name, "--version"]. + post_install_message: Optional message printed after installation. + + Returns: + A doit task dictionary with actions and title. + """ + + def _action() -> None: + install_tool( + name=name, + repo=repo, + asset_patterns=asset_patterns, + version_cmd=version_cmd, + post_install_message=post_install_message, + ) + + return { + "actions": [_action], + "title": title_with_actions, + } From de5dc586d12a1a3d1561125eb32a97355463ac2a Mon Sep 17 00:00:00 2001 From: Eric Davis <6662995+endavis@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:18:47 +0100 Subject: [PATCH 2/2] fix: skip executable permissions test on Windows chmod is a no-op on Windows, so the permission assertion fails. --- tests/test_doit_install_tools.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_doit_install_tools.py b/tests/test_doit_install_tools.py index ff247ff..fa0ba58 100644 --- a/tests/test_doit_install_tools.py +++ b/tests/test_doit_install_tools.py @@ -2,6 +2,7 @@ import json import subprocess # nosec B404 - needed for CompletedProcess in tests +import sys from pathlib import Path from unittest.mock import MagicMock, patch @@ -199,6 +200,9 @@ def test_version_placeholder_in_asset_pattern( dest, ) + @pytest.mark.skipif( + sys.platform == "win32", reason="Windows does not support Unix file permissions" + ) @patch("tools.doit.install_tools.get_install_dir") @patch("tools.doit.install_tools.urllib.request.urlretrieve") def test_sets_executable_permissions(