This commit is contained in:
Matan Shavit 2025-12-24 12:49:58 +01:00 committed by GitHub
commit 3ebf851ac6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 553 additions and 0 deletions

View file

@ -41,6 +41,9 @@ jobs:
with:
python-version: '3.10'
- uses: oven-sh/setup-bun@v2
if: matrix.language == 'bun'
- run: echo "$CONDA\Scripts" >> "$GITHUB_PATH"
shell: bash
if: matrix.os == 'windows-latest' && matrix.language == 'conda'

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from pre_commit.lang_base import Language
from pre_commit.languages import bun
from pre_commit.languages import conda
from pre_commit.languages import coursier
from pre_commit.languages import dart
@ -25,6 +26,7 @@ from pre_commit.languages import unsupported_script
languages: dict[str, Language] = {
'bun': bun,
'conda': conda,
'coursier': coursier,
'dart': dart,

195
pre_commit/languages/bun.py Normal file
View file

@ -0,0 +1,195 @@
from __future__ import annotations
import contextlib
import functools
import os.path
import platform
import shutil
import sys
import tempfile
import urllib.error
import urllib.request
import zipfile
from collections.abc import Generator
from collections.abc import Sequence
import pre_commit.constants as C
from pre_commit import lang_base
from pre_commit.envcontext import envcontext
from pre_commit.envcontext import PatchesT
from pre_commit.envcontext import Var
from pre_commit.languages.python import bin_dir
from pre_commit.prefix import Prefix
from pre_commit.util import cmd_output_b
ENVIRONMENT_DIR = 'bunenv'
run_hook = lang_base.basic_run_hook
# Architecture mapping for Bun binary downloads
_ARCH_ALIASES = {
'x86_64': 'x64',
'amd64': 'x64',
'aarch64': 'aarch64',
'arm64': 'aarch64',
}
_ARCH = platform.machine().lower()
_ARCH = _ARCH_ALIASES.get(_ARCH, _ARCH)
@functools.lru_cache(maxsize=1)
def get_default_version() -> str:
"""Detect if Bun is installed system-wide."""
# Check for system-installed bun
if lang_base.exe_exists('bun'):
return 'system'
else:
return C.DEFAULT
def _get_platform() -> str:
"""Get platform string for Bun binary downloads."""
if sys.platform == 'darwin':
return 'darwin'
elif sys.platform == 'win32':
return 'windows'
elif sys.platform.startswith('linux'):
return 'linux'
else:
raise AssertionError(f'Unsupported platform: {sys.platform}')
def _normalize_version(version: str) -> str:
"""Normalize version string for download URL."""
if version == C.DEFAULT:
return 'latest'
# Ensure version has 'bun-v' prefix for download URL
if not version.startswith('bun-v'):
if version.startswith('v'):
return f'bun-{version}'
else:
return f'bun-v{version}'
return version
def _get_download_url(version: str) -> str:
"""Construct Bun binary download URL from GitHub releases."""
platform_name = _get_platform()
normalized_version = _normalize_version(version)
# Bun release URL format:
# https://github.com/oven-sh/bun/releases/download/bun-v1.1.42/bun-darwin-x64.zip
# https://github.com/oven-sh/bun/releases/download/bun-v1.1.42/bun-linux-x64.zip
# https://github.com/oven-sh/bun/releases/download/bun-v1.1.42/bun-windows-x64.zip
base_url = 'https://github.com/oven-sh/bun/releases'
if normalized_version == 'latest':
# Use latest release
return f'{base_url}/latest/download/bun-{platform_name}-{_ARCH}.zip'
else:
# Use specific version
return (
f'{base_url}/download/{normalized_version}/'
f'bun-{platform_name}-{_ARCH}.zip'
)
def _install_bun(version: str, dest: str) -> None:
"""Download and extract Bun binary to destination directory."""
url = _get_download_url(version)
try:
resp = urllib.request.urlopen(url)
except urllib.error.HTTPError as e:
if e.code == 404:
raise ValueError(
f'Could not find Bun version matching your requirements '
f'(version={version}; os={_get_platform()}; '
f'arch={_ARCH}). Check available versions at '
f'https://github.com/oven-sh/bun/releases',
) from e
else:
raise
with tempfile.TemporaryFile() as f:
shutil.copyfileobj(resp, f)
f.seek(0)
with zipfile.ZipFile(f) as zipf:
zipf.extractall(dest)
# Bun zipfile contains a directory like 'bun-darwin-x64' or 'bun-linux-x64'
# Move the binary from the extracted directory to dest/bin/
bin_dir_path = os.path.join(dest, 'bin')
os.makedirs(bin_dir_path, exist_ok=True)
# Find the extracted directory
for item in os.listdir(dest):
item_path = os.path.join(dest, item)
if os.path.isdir(item_path) and item.startswith('bun-'):
# Move bun executable to bin directory
bun_exe = 'bun.exe' if sys.platform == 'win32' else 'bun'
src_exe = os.path.join(item_path, bun_exe)
if os.path.exists(src_exe):
shutil.move(src_exe, os.path.join(bin_dir_path, bun_exe))
# Remove the extracted directory
shutil.rmtree(item_path)
break
def get_env_patch(venv: str) -> PatchesT:
"""Prepare environment variables for Bun execution."""
# Bun is much simpler than Node - primarily just needs PATH
return (
('PATH', (bin_dir(venv), os.pathsep, Var('PATH'))),
# BUN_INSTALL controls where global packages are installed
('BUN_INSTALL', venv),
)
@contextlib.contextmanager
def in_env(prefix: Prefix, version: str) -> Generator[None]:
"""Context manager for Bun environment."""
envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version)
with envcontext(get_env_patch(envdir)):
yield
def health_check(prefix: Prefix, version: str) -> str | None:
"""Check if Bun environment is healthy."""
with in_env(prefix, version):
retcode, _, _ = cmd_output_b('bun', '--version', check=False)
if retcode != 0: # pragma: no cover
return f'`bun --version` returned {retcode}'
else:
return None
def install_environment(
prefix: Prefix,
version: str,
additional_dependencies: Sequence[str],
) -> None:
"""Install Bun environment and dependencies."""
assert prefix.exists('package.json')
envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version)
# Install Bun binary (unless using system version)
if version != 'system':
_install_bun(version, envdir)
with in_env(prefix, version):
# Install local dependencies from package.json
# Use --no-progress to avoid cluttering output
install_cmd = ('bun', 'install', '--no-progress')
lang_base.setup_cmd(prefix, install_cmd)
# Install the package globally from the current directory
# Bun's global install uses `bun add -g` with file: protocol
# We need to install from an absolute file path, so we use file:.
# Note: Unlike npm, bun creates symlinks to the local package,
# so we must NOT delete node_modules or the bin directory.
abs_prefix = os.path.abspath(prefix.prefix_dir)
install = ['bun', 'add', '-g', f'file:{abs_prefix}']
if additional_dependencies:
install.extend(additional_dependencies)
lang_base.setup_cmd(prefix, tuple(install))

View file

@ -0,0 +1,5 @@
- id: test-bun-hook
name: Test Bun Hook
entry: test-bun-hook
language: bun
files: \.txt$

View file

@ -0,0 +1,16 @@
#!/usr/bin/env node
// Simple test hook that validates file content
const fs = require('fs');
const files = process.argv.slice(2);
let failed = false;
files.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
if (content.includes('bad')) {
console.error(`Error in ${file}: contains 'bad'`);
failed = true;
}
});
process.exit(failed ? 1 : 0);

View file

@ -0,0 +1,7 @@
{
"name": "test-bun-hook",
"version": "1.0.0",
"bin": {
"test-bun-hook": "./bin/test-hook.js"
}
}

325
tests/languages/bun_test.py Normal file
View file

@ -0,0 +1,325 @@
from __future__ import annotations
import os
import sys
from unittest import mock
import pytest
import pre_commit.constants as C
from pre_commit import lang_base
from pre_commit import parse_shebang
from pre_commit.languages import bun
from pre_commit.prefix import Prefix
from pre_commit.store import _make_local_repo
from testing.language_helpers import run_language
ACTUAL_GET_DEFAULT_VERSION = bun.get_default_version.__wrapped__
@pytest.fixture
def find_exe_mck():
with mock.patch.object(parse_shebang, 'find_executable') as mck:
yield mck
def test_sets_system_when_bun_is_available(find_exe_mck):
find_exe_mck.return_value = '/path/to/exe'
assert ACTUAL_GET_DEFAULT_VERSION() == 'system'
def test_uses_default_when_bun_is_not_available(find_exe_mck):
find_exe_mck.return_value = None
assert ACTUAL_GET_DEFAULT_VERSION() == C.DEFAULT
def _make_hello_world(tmp_path):
"""Create a simple Node/Bun package for testing."""
package_json = '''\
{
"name": "test-bun-hook",
"version": "1.0.0",
"bin": {"bun-hello": "./bin/bun-hello.js"}
}
'''
bin_script = '''\
#!/usr/bin/env node
console.log('Hello World');
'''
tmp_path.joinpath('package.json').write_text(package_json)
bin_dir = tmp_path.joinpath('bin')
bin_dir.mkdir()
bin_dir.joinpath('bun-hello.js').write_text(bin_script)
def test_bun_default_version():
"""Test default version detection."""
version = bun.get_default_version()
# Should return either 'system' or 'default'
assert version in {'system', 'default'}
def test_bun_hook_system(tmp_path):
"""Test running a hook with system Bun."""
_make_hello_world(tmp_path)
ret = run_language(tmp_path, bun, 'bun-hello')
assert ret == (0, b'Hello World\n')
def test_bun_hook_default_version(tmp_path):
"""Test running a hook with downloaded Bun (default/latest)."""
_make_hello_world(tmp_path)
ret = run_language(tmp_path, bun, 'bun-hello', version=C.DEFAULT)
assert ret == (0, b'Hello World\n')
def test_bun_hook_specific_version(tmp_path):
"""Test running a hook with specific Bun version."""
_make_hello_world(tmp_path)
# Use a known stable version
ret = run_language(tmp_path, bun, 'bun-hello', version='1.1.42')
assert ret == (0, b'Hello World\n')
def test_bun_additional_dependencies(tmp_path):
"""Test installing additional dependencies."""
_make_local_repo(str(tmp_path))
ret, out = run_language(
tmp_path,
bun,
'bun pm ls -g',
deps=('lodash',),
)
assert b'lodash' in out
def test_bun_with_package_json_only(tmp_path):
"""Test that package.json is required."""
# Don't create package.json - just create a Prefix
prefix = Prefix(str(tmp_path))
with pytest.raises(AssertionError):
bun.install_environment(prefix, 'system', ())
def test_environment_dir():
"""Test ENVIRONMENT_DIR is set correctly."""
assert bun.ENVIRONMENT_DIR == 'bunenv'
def test_run_hook_uses_basic():
"""Test that run_hook is set to basic implementation."""
assert bun.run_hook is lang_base.basic_run_hook
def test_bun_health_check_success(tmp_path):
"""Test health check with valid environment."""
_make_hello_world(tmp_path)
_make_local_repo(str(tmp_path))
prefix = Prefix(str(tmp_path))
bun.install_environment(prefix, 'system', ())
health = bun.health_check(prefix, 'system')
assert health is None # None means healthy
def test_get_platform_darwin():
"""Test platform detection for macOS."""
with mock.patch.object(sys, 'platform', 'darwin'):
assert bun._get_platform() == 'darwin'
def test_get_platform_linux():
"""Test platform detection for Linux."""
with mock.patch.object(sys, 'platform', 'linux'):
assert bun._get_platform() == 'linux'
def test_get_platform_linux_with_suffix():
"""Test platform detection for Linux with version suffix."""
with mock.patch.object(sys, 'platform', 'linux2'):
assert bun._get_platform() == 'linux'
def test_get_platform_windows():
"""Test platform detection for Windows."""
with mock.patch.object(sys, 'platform', 'win32'):
assert bun._get_platform() == 'windows'
def test_get_platform_unsupported():
"""Test platform detection fails for unsupported platform."""
with mock.patch.object(sys, 'platform', 'freebsd'):
with pytest.raises(
AssertionError, match='Unsupported platform: freebsd',
):
bun._get_platform()
def test_normalize_version_default():
"""Test version normalization for default version."""
assert bun._normalize_version(C.DEFAULT) == 'latest'
def test_normalize_version_latest():
"""Test version normalization for 'latest' string.
Note: 'latest' as a direct string gets treated as a version tag,
not as the special latest keyword. Use C.DEFAULT for that.
"""
assert bun._normalize_version('latest') == 'bun-vlatest'
def test_normalize_version_plain_number():
"""Test version normalization for plain version number."""
assert bun._normalize_version('1.1.42') == 'bun-v1.1.42'
def test_normalize_version_with_v_prefix():
"""Test version normalization for version with 'v' prefix."""
assert bun._normalize_version('v1.1.42') == 'bun-v1.1.42'
def test_normalize_version_with_bun_v_prefix():
"""Test version normalization for version already with 'bun-v' prefix."""
assert bun._normalize_version('bun-v1.1.42') == 'bun-v1.1.42'
def test_install_bun_invalid_version_raises_error(tmp_path):
"""Test that installing invalid Bun version raises ValueError."""
import urllib.error
# Create a mock HTTPError with 404 status
mock_error = urllib.error.HTTPError(
url='https://github.com/oven-sh/bun/releases/'
'download/bun-v99.99.99/bun-darwin-x64.zip',
code=404,
msg='Not Found',
hdrs=None, # type: ignore
fp=None,
)
with mock.patch('urllib.request.urlopen', side_effect=mock_error):
with pytest.raises(
ValueError, match='Could not find Bun version',
):
bun._install_bun('99.99.99', str(tmp_path))
def test_install_bun_other_http_error_propagates(tmp_path):
"""Test that non-404 HTTP errors are propagated."""
import urllib.error
# Create a mock HTTPError with 500 status
mock_error = urllib.error.HTTPError(
url='https://github.com/oven-sh/bun/releases/'
'download/bun-v1.1.42/bun-darwin-x64.zip',
code=500,
msg='Internal Server Error',
hdrs=None, # type: ignore
fp=None,
)
with mock.patch('urllib.request.urlopen', side_effect=mock_error):
with pytest.raises(urllib.error.HTTPError) as exc_info:
bun._install_bun('1.1.42', str(tmp_path))
assert exc_info.value.code == 500
def test_install_bun_no_bun_directory_found(tmp_path):
"""Test extraction works even if no bun directory found."""
from unittest.mock import MagicMock
dest = str(tmp_path / 'bunenv')
os.makedirs(dest, exist_ok=True)
# Create a file after extraction (not a bun- directory)
(tmp_path / 'bunenv' / 'some-other-file.txt').write_text('content')
# Create a mock zip file that does nothing on extractall
mock_zipfile = MagicMock()
mock_zipfile.__enter__.return_value = mock_zipfile
mock_zipfile.__exit__.return_value = None
mock_zipfile.extractall = MagicMock()
with mock.patch('urllib.request.urlopen') as mock_urlopen, \
mock.patch('shutil.copyfileobj'), \
mock.patch('zipfile.ZipFile', return_value=mock_zipfile):
# Mock urlopen to return a fake response
mock_response = MagicMock()
mock_urlopen.return_value = mock_response
# Should complete without error (loop exits)
bun._install_bun('1.1.42', dest)
# Verify bin directory was still created
assert os.path.exists(os.path.join(dest, 'bin'))
def test_install_bun_missing_executable_in_directory(tmp_path):
"""Test extraction handles missing executable gracefully."""
from unittest.mock import MagicMock
dest = str(tmp_path / 'bunenv')
os.makedirs(dest, exist_ok=True)
# Create a bun directory without the executable
bun_dir = tmp_path / 'bunenv' / 'bun-darwin-x64'
bun_dir.mkdir()
(bun_dir / 'README.md').write_text('readme')
# Create a mock zip file that does nothing
mock_zipfile = MagicMock()
mock_zipfile.__enter__.return_value = mock_zipfile
mock_zipfile.__exit__.return_value = None
mock_zipfile.extractall = MagicMock()
with mock.patch('urllib.request.urlopen') as mock_urlopen, \
mock.patch('shutil.copyfileobj'), \
mock.patch('zipfile.ZipFile', return_value=mock_zipfile):
mock_response = MagicMock()
mock_urlopen.return_value = mock_response
# Should complete without error
bun._install_bun('1.1.42', dest)
# Verify the bun directory was still removed
assert not bun_dir.exists()
def test_install_environment_system_version_skips_download(tmp_path):
"""Test that system version doesn't download Bun binary."""
_make_hello_world(tmp_path)
_make_local_repo(str(tmp_path))
prefix = Prefix(str(tmp_path))
# Mock _install_bun to ensure it's never called
with mock.patch.object(bun, '_install_bun') as mock_install:
bun.install_environment(prefix, 'system', ())
# Verify _install_bun was NOT called
mock_install.assert_not_called()
# Verify environment still works
assert bun.health_check(prefix, 'system') is None
def test_install_environment_system_version_skips_download_mock(tmp_path):
"""Test that system version doesn't download Bun binary (mocked)."""
_make_hello_world(tmp_path)
_make_local_repo(str(tmp_path))
prefix = Prefix(str(tmp_path))
# Mock all the bun commands to avoid needing system bun
with mock.patch.object(bun, '_install_bun') as mock_install, \
mock.patch('pre_commit.lang_base.setup_cmd'):
bun.install_environment(prefix, 'system', ())
# Verify _install_bun was NOT called for system version
mock_install.assert_not_called()