diff --git a/pre_commit/all_languages.py b/pre_commit/all_languages.py index ba569c37..42176229 100644 --- a/pre_commit/all_languages.py +++ b/pre_commit/all_languages.py @@ -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 system languages: dict[str, Language] = { + 'bun': bun, 'conda': conda, 'coursier': coursier, 'dart': dart, diff --git a/pre_commit/languages/bun.py b/pre_commit/languages/bun.py new file mode 100644 index 00000000..2d9c557d --- /dev/null +++ b/pre_commit/languages/bun.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import contextlib +import functools +import os +import sys +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 UNSET +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 +from pre_commit.util import cmd_output_b +from pre_commit.util import rmtree + +ENVIRONMENT_DIR = 'bun_env' +run_hook = lang_base.basic_run_hook + + +@functools.lru_cache(maxsize=1) +def get_default_version() -> str: + # nodeenv does not yet support `-n system` on windows + if sys.platform == 'win32': + return C.DEFAULT + # if node is already installed, we can save a bunch of setup time by + # using the installed version + elif lang_base.exe_exists('bun'): + return 'system' + else: + return C.DEFAULT + + +def get_env_patch(venv: str) -> PatchesT: + if sys.platform == 'cygwin': # pragma: no cover + _, win_venv, _ = cmd_output('cygpath', '-w', venv) + install_prefix = fr'{win_venv.strip()}\bin' + lib_dir = 'lib' + elif sys.platform == 'win32': # pragma: no cover + install_prefix = bin_dir(venv) + lib_dir = 'Scripts' + else: # pragma: win32 no cover + install_prefix = venv + lib_dir = 'lib' + return ( + ('NODE_VIRTUAL_ENV', venv), + ('NPM_CONFIG_PREFIX', install_prefix), + ('npm_config_prefix', install_prefix), + ('NPM_CONFIG_USERCONFIG', UNSET), + ('npm_config_userconfig', UNSET), + ('NODE_PATH', os.path.join(venv, lib_dir, 'node_modules')), + ('PATH', (bin_dir(venv), os.pathsep, Var('PATH'))), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None]: + 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: + with in_env(prefix, version): + retcode, _, _ = cmd_output_b('bun', '--version', check=False) + if retcode != 0: # pragma: win32 no cover + return f'`bun --version` returned {retcode}' + else: + return None + + +def install_environment( + prefix: Prefix, version: str, additional_dependencies: Sequence[str], +) -> None: + assert prefix.exists('package.json') + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + if sys.platform == 'win32': # pragma: no cover + envdir = fr'\\?\{os.path.normpath(envdir)}' + cmd = [sys.executable, '-mnodeenv', '--prebuilt', '--clean-src', envdir] + if version != C.DEFAULT: + cmd.extend(['-n', version]) + cmd_output_b(*cmd) + + with in_env(prefix, version): + install = ( + 'bun', 'install', '--no-progress', '--silent', '--no-save', + ) + lang_base.setup_cmd(prefix, install) + + if prefix.exists('node_modules'): # pragma: win32 no cover + rmtree(prefix.path('node_modules')) diff --git a/tests/languages/bun_test.py b/tests/languages/bun_test.py new file mode 100644 index 00000000..055cb1e9 --- /dev/null +++ b/tests/languages/bun_test.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import json +import os +import shutil +import sys +from unittest import mock + +import pytest + +import pre_commit.constants as C +from pre_commit import envcontext +from pre_commit import parse_shebang +from pre_commit.languages import node +from pre_commit.prefix import Prefix +from pre_commit.store import _make_local_repo +from pre_commit.util import cmd_output +from testing.language_helpers import run_language +from testing.util import xfailif_windows + + +ACTUAL_GET_DEFAULT_VERSION = node.get_default_version.__wrapped__ + + +@pytest.fixture +def is_linux(): + with mock.patch.object(sys, 'platform', 'linux'): + yield + + +@pytest.fixture +def is_win32(): + with mock.patch.object(sys, 'platform', 'win32'): + yield + + +@pytest.fixture +def find_exe_mck(): + with mock.patch.object(parse_shebang, 'find_executable') as mck: + yield mck + + +@pytest.mark.usefixtures('is_linux') +def test_sets_system_when_node_and_npm_are_available(find_exe_mck): + find_exe_mck.return_value = '/path/to/exe' + assert ACTUAL_GET_DEFAULT_VERSION() == 'system' + + +@pytest.mark.usefixtures('is_linux') +def test_uses_default_when_node_and_npm_are_not_available(find_exe_mck): + find_exe_mck.return_value = None + assert ACTUAL_GET_DEFAULT_VERSION() == C.DEFAULT + + +@pytest.mark.usefixtures('is_win32') +def test_sets_default_on_windows(find_exe_mck): + find_exe_mck.return_value = '/path/to/exe' + assert ACTUAL_GET_DEFAULT_VERSION() == C.DEFAULT + + +@xfailif_windows # pragma: win32 no cover +def test_healthy_system_node(tmpdir): + tmpdir.join('package.json').write('{"name": "t", "version": "1.0.0"}') + + prefix = Prefix(str(tmpdir)) + node.install_environment(prefix, 'system', ()) + assert node.health_check(prefix, 'system') is None + + +@xfailif_windows # pragma: win32 no cover +def test_unhealthy_if_system_node_goes_missing(tmpdir): + bin_dir = tmpdir.join('bin').ensure_dir() + node_bin = bin_dir.join('node') + node_bin.mksymlinkto(shutil.which('node')) + + prefix_dir = tmpdir.join('prefix').ensure_dir() + prefix_dir.join('package.json').write('{"name": "t", "version": "1.0.0"}') + + path = ('PATH', (str(bin_dir), os.pathsep, envcontext.Var('PATH'))) + with envcontext.envcontext((path,)): + prefix = Prefix(str(prefix_dir)) + node.install_environment(prefix, 'system', ()) + assert node.health_check(prefix, 'system') is None + + node_bin.remove() + ret = node.health_check(prefix, 'system') + assert ret == '`node --version` returned 127' + + +@xfailif_windows # pragma: win32 no cover +def test_installs_without_links_outside_env(tmpdir): + tmpdir.join('bin/main.js').ensure().write( + '#!/usr/bin/env node\n' + '_ = require("lodash"); console.log("success!")\n', + ) + tmpdir.join('package.json').write( + json.dumps({ + 'name': 'foo', + 'version': '0.0.1', + 'bin': {'foo': './bin/main.js'}, + 'dependencies': {'lodash': '*'}, + }), + ) + + prefix = Prefix(str(tmpdir)) + node.install_environment(prefix, 'system', ()) + assert node.health_check(prefix, 'system') is None + + # this directory shouldn't exist, make sure we succeed without it existing + cmd_output('rm', '-rf', str(tmpdir.join('node_modules'))) + + with node.in_env(prefix, 'system'): + assert cmd_output('foo')[1] == 'success!\n' + + +def _make_hello_world(tmp_path): + package_json = '''\ +{"name": "t", "version": "0.0.1", "bin": {"node-hello": "./bin/main.js"}} +''' + tmp_path.joinpath('package.json').write_text(package_json) + bin_dir = tmp_path.joinpath('bin') + bin_dir.mkdir() + bin_dir.joinpath('main.js').write_text( + '#!/usr/bin/env node\n' + 'console.log("Hello World");\n', + ) + + +def test_node_hook_system(tmp_path): + _make_hello_world(tmp_path) + ret = run_language(tmp_path, node, 'node-hello') + assert ret == (0, b'Hello World\n') + + +def test_node_with_user_config_set(tmp_path): + cfg = tmp_path.joinpath('cfg') + cfg.write_text('cache=/dne\n') + with envcontext.envcontext((('NPM_CONFIG_USERCONFIG', str(cfg)),)): + test_node_hook_system(tmp_path) + + +@pytest.mark.parametrize('version', (C.DEFAULT, '18.14.0')) +def test_node_hook_versions(tmp_path, version): + _make_hello_world(tmp_path) + ret = run_language(tmp_path, node, 'node-hello', version=version) + assert ret == (0, b'Hello World\n') + + +def test_node_additional_deps(tmp_path): + _make_local_repo(str(tmp_path)) + ret, out = run_language(tmp_path, node, 'npm ls -g', deps=('lodash',)) + assert b' lodash@' in out