diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 18:05:20 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 18:05:20 +0000 |
commit | c86df75ab11643fa4649cfe6ed5c4692d4ee342b (patch) | |
tree | de847f47ec2669e74b9a3459319579346b7c99df /pre_commit/languages | |
parent | Initial commit. (diff) | |
download | pre-commit-c86df75ab11643fa4649cfe6ed5c4692d4ee342b.tar.xz pre-commit-c86df75ab11643fa4649cfe6ed5c4692d4ee342b.zip |
Adding upstream version 3.6.2.upstream/3.6.2
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | pre_commit/languages/__init__.py | 0 | ||||
-rw-r--r-- | pre_commit/languages/conda.py | 77 | ||||
-rw-r--r-- | pre_commit/languages/coursier.py | 76 | ||||
-rw-r--r-- | pre_commit/languages/dart.py | 97 | ||||
-rw-r--r-- | pre_commit/languages/docker.py | 146 | ||||
-rw-r--r-- | pre_commit/languages/docker_image.py | 32 | ||||
-rw-r--r-- | pre_commit/languages/dotnet.py | 111 | ||||
-rw-r--r-- | pre_commit/languages/fail.py | 27 | ||||
-rw-r--r-- | pre_commit/languages/golang.py | 160 | ||||
-rw-r--r-- | pre_commit/languages/haskell.py | 56 | ||||
-rw-r--r-- | pre_commit/languages/lua.py | 75 | ||||
-rw-r--r-- | pre_commit/languages/node.py | 110 | ||||
-rw-r--r-- | pre_commit/languages/perl.py | 50 | ||||
-rw-r--r-- | pre_commit/languages/pygrep.py | 133 | ||||
-rw-r--r-- | pre_commit/languages/python.py | 214 | ||||
-rw-r--r-- | pre_commit/languages/r.py | 195 | ||||
-rw-r--r-- | pre_commit/languages/ruby.py | 145 | ||||
-rw-r--r-- | pre_commit/languages/rust.py | 160 | ||||
-rw-r--r-- | pre_commit/languages/script.py | 32 | ||||
-rw-r--r-- | pre_commit/languages/swift.py | 50 | ||||
-rw-r--r-- | pre_commit/languages/system.py | 10 |
21 files changed, 1956 insertions, 0 deletions
diff --git a/pre_commit/languages/__init__.py b/pre_commit/languages/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pre_commit/languages/__init__.py diff --git a/pre_commit/languages/conda.py b/pre_commit/languages/conda.py new file mode 100644 index 0000000..80b3e15 --- /dev/null +++ b/pre_commit/languages/conda.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import contextlib +import os +import sys +from collections.abc import Generator +from collections.abc import Sequence + +from pre_commit import lang_base +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import SubstitutionT +from pre_commit.envcontext import UNSET +from pre_commit.envcontext import Var +from pre_commit.prefix import Prefix +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'conda' +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + + +def get_env_patch(env: str) -> PatchesT: + # On non-windows systems executable live in $CONDA_PREFIX/bin, on Windows + # they can be in $CONDA_PREFIX/bin, $CONDA_PREFIX/Library/bin, + # $CONDA_PREFIX/Scripts and $CONDA_PREFIX. Whereas the latter only + # seems to be used for python.exe. + path: SubstitutionT = (os.path.join(env, 'bin'), os.pathsep, Var('PATH')) + if sys.platform == 'win32': # pragma: win32 cover + path = (env, os.pathsep, *path) + path = (os.path.join(env, 'Scripts'), os.pathsep, *path) + path = (os.path.join(env, 'Library', 'bin'), os.pathsep, *path) + + return ( + ('PYTHONHOME', UNSET), + ('VIRTUAL_ENV', UNSET), + ('CONDA_PREFIX', env), + ('PATH', path), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir)): + yield + + +def _conda_exe() -> str: + if os.environ.get('PRE_COMMIT_USE_MICROMAMBA'): + return 'micromamba' + elif os.environ.get('PRE_COMMIT_USE_MAMBA'): + return 'mamba' + else: + return 'conda' + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + lang_base.assert_version_default('conda', version) + + conda_exe = _conda_exe() + + env_dir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + cmd_output_b( + conda_exe, 'env', 'create', '-p', env_dir, '--file', + 'environment.yml', cwd=prefix.prefix_dir, + ) + if additional_dependencies: + cmd_output_b( + conda_exe, 'install', '-p', env_dir, *additional_dependencies, + cwd=prefix.prefix_dir, + ) diff --git a/pre_commit/languages/coursier.py b/pre_commit/languages/coursier.py new file mode 100644 index 0000000..6558bf6 --- /dev/null +++ b/pre_commit/languages/coursier.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import contextlib +import os.path +from collections.abc import Generator +from collections.abc import Sequence + +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.errors import FatalError +from pre_commit.parse_shebang import find_executable +from pre_commit.prefix import Prefix + +ENVIRONMENT_DIR = 'coursier' + +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + lang_base.assert_version_default('coursier', version) + + # Support both possible executable names (either "cs" or "coursier") + cs = find_executable('cs') or find_executable('coursier') + if cs is None: + raise AssertionError( + 'pre-commit requires system-installed "cs" or "coursier" ' + 'executables in the application search path', + ) + + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + def _install(*opts: str) -> None: + assert cs is not None + lang_base.setup_cmd(prefix, (cs, 'fetch', *opts)) + lang_base.setup_cmd(prefix, (cs, 'install', '--dir', envdir, *opts)) + + with in_env(prefix, version): + channel = prefix.path('.pre-commit-channel') + if os.path.isdir(channel): + for app_descriptor in os.listdir(channel): + _, app_file = os.path.split(app_descriptor) + app, _ = os.path.splitext(app_file) + _install( + '--default-channels=false', + '--channel', channel, + app, + ) + elif not additional_dependencies: + raise FatalError( + 'expected .pre-commit-channel dir or additional_dependencies', + ) + + if additional_dependencies: + _install(*additional_dependencies) + + +def get_env_patch(target_dir: str) -> PatchesT: + return ( + ('PATH', (target_dir, os.pathsep, Var('PATH'))), + ('COURSIER_CACHE', os.path.join(target_dir, '.cs-cache')), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir)): + yield diff --git a/pre_commit/languages/dart.py b/pre_commit/languages/dart.py new file mode 100644 index 0000000..129ac59 --- /dev/null +++ b/pre_commit/languages/dart.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import contextlib +import os.path +import shutil +import tempfile +from collections.abc import Generator +from collections.abc import Sequence + +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.prefix import Prefix +from pre_commit.util import win_exe +from pre_commit.yaml import yaml_load + +ENVIRONMENT_DIR = 'dartenv' + +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + + +def get_env_patch(venv: str) -> PatchesT: + return ( + ('PATH', (os.path.join(venv, 'bin'), os.pathsep, Var('PATH'))), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir)): + yield + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + lang_base.assert_version_default('dart', version) + + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + bin_dir = os.path.join(envdir, 'bin') + + def _install_dir(prefix_p: Prefix, pub_cache: str) -> None: + dart_env = {**os.environ, 'PUB_CACHE': pub_cache} + + with open(prefix_p.path('pubspec.yaml')) as f: + pubspec_contents = yaml_load(f) + + lang_base.setup_cmd(prefix_p, ('dart', 'pub', 'get'), env=dart_env) + + for executable in pubspec_contents['executables']: + lang_base.setup_cmd( + prefix_p, + ( + 'dart', 'compile', 'exe', + '--output', os.path.join(bin_dir, win_exe(executable)), + prefix_p.path('bin', f'{executable}.dart'), + ), + env=dart_env, + ) + + os.makedirs(bin_dir) + + with tempfile.TemporaryDirectory() as tmp: + _install_dir(prefix, tmp) + + for dep_s in additional_dependencies: + with tempfile.TemporaryDirectory() as dep_tmp: + dep, _, version = dep_s.partition(':') + if version: + dep_cmd: tuple[str, ...] = (dep, '--version', version) + else: + dep_cmd = (dep,) + + lang_base.setup_cmd( + prefix, + ('dart', 'pub', 'cache', 'add', *dep_cmd), + env={**os.environ, 'PUB_CACHE': dep_tmp}, + ) + + # try and find the 'pubspec.yaml' that just got added + for root, _, filenames in os.walk(dep_tmp): + if 'pubspec.yaml' in filenames: + with tempfile.TemporaryDirectory() as copied: + pkg = os.path.join(copied, 'pkg') + shutil.copytree(root, pkg) + _install_dir(Prefix(pkg), dep_tmp) + break + else: + raise AssertionError( + f'could not find pubspec.yaml for {dep_s}', + ) diff --git a/pre_commit/languages/docker.py b/pre_commit/languages/docker.py new file mode 100644 index 0000000..2632851 --- /dev/null +++ b/pre_commit/languages/docker.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import hashlib +import json +import os +from collections.abc import Sequence + +from pre_commit import lang_base +from pre_commit.prefix import Prefix +from pre_commit.util import CalledProcessError +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'docker' +PRE_COMMIT_LABEL = 'PRE_COMMIT' +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +in_env = lang_base.no_env # no special environment for docker + + +def _is_in_docker() -> bool: + try: + with open('/proc/1/cgroup', 'rb') as f: + return b'docker' in f.read() + except FileNotFoundError: + return False + + +def _get_container_id() -> str: + # It's assumed that we already check /proc/1/cgroup in _is_in_docker. The + # cpuset cgroup controller existed since cgroups were introduced so this + # way of getting the container ID is pretty reliable. + with open('/proc/1/cgroup', 'rb') as f: + for line in f.readlines(): + if line.split(b':')[1] == b'cpuset': + return os.path.basename(line.split(b':')[2]).strip().decode() + raise RuntimeError('Failed to find the container ID in /proc/1/cgroup.') + + +def _get_docker_path(path: str) -> str: + if not _is_in_docker(): + return path + + container_id = _get_container_id() + + try: + _, out, _ = cmd_output_b('docker', 'inspect', container_id) + except CalledProcessError: + # self-container was not visible from here (perhaps docker-in-docker) + return path + + container, = json.loads(out) + for mount in container['Mounts']: + src_path = mount['Source'] + to_path = mount['Destination'] + if os.path.commonpath((path, to_path)) == to_path: + # So there is something in common, + # and we can proceed remapping it + return path.replace(to_path, src_path) + # we're in Docker, but the path is not mounted, cannot really do anything, + # so fall back to original path + return path + + +def md5(s: str) -> str: # pragma: win32 no cover + return hashlib.md5(s.encode()).hexdigest() + + +def docker_tag(prefix: Prefix) -> str: # pragma: win32 no cover + md5sum = md5(os.path.basename(prefix.prefix_dir)).lower() + return f'pre-commit-{md5sum}' + + +def build_docker_image( + prefix: Prefix, + *, + pull: bool, +) -> None: # pragma: win32 no cover + cmd: tuple[str, ...] = ( + 'docker', 'build', + '--tag', docker_tag(prefix), + '--label', PRE_COMMIT_LABEL, + ) + if pull: + cmd += ('--pull',) + # This must come last for old versions of docker. See #477 + cmd += ('.',) + lang_base.setup_cmd(prefix, cmd) + + +def install_environment( + prefix: Prefix, version: str, additional_dependencies: Sequence[str], +) -> None: # pragma: win32 no cover + lang_base.assert_version_default('docker', version) + lang_base.assert_no_additional_deps('docker', additional_dependencies) + + directory = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + # Docker doesn't really have relevant disk environment, but pre-commit + # still needs to cleanup its state files on failure + build_docker_image(prefix, pull=True) + os.mkdir(directory) + + +def get_docker_user() -> tuple[str, ...]: # pragma: win32 no cover + try: + return ('-u', f'{os.getuid()}:{os.getgid()}') + except AttributeError: + return () + + +def docker_cmd() -> tuple[str, ...]: # pragma: win32 no cover + return ( + 'docker', 'run', + '--rm', + *get_docker_user(), + # https://docs.docker.com/engine/reference/commandline/run/#mount-volumes-from-container-volumes-from + # The `Z` option tells Docker to label the content with a private + # unshared label. Only the current container can use a private volume. + '-v', f'{_get_docker_path(os.getcwd())}:/src:rw,Z', + '--workdir', '/src', + ) + + +def run_hook( + prefix: Prefix, + entry: str, + args: Sequence[str], + file_args: Sequence[str], + *, + is_local: bool, + require_serial: bool, + color: bool, +) -> tuple[int, bytes]: # pragma: win32 no cover + # Rebuild the docker image in case it has gone missing, as many people do + # automated cleanup of docker images. + build_docker_image(prefix, pull=False) + + entry_exe, *cmd_rest = lang_base.hook_cmd(entry, args) + + entry_tag = ('--entrypoint', entry_exe, docker_tag(prefix)) + return lang_base.run_xargs( + (*docker_cmd(), *entry_tag, *cmd_rest), + file_args, + require_serial=require_serial, + color=color, + ) diff --git a/pre_commit/languages/docker_image.py b/pre_commit/languages/docker_image.py new file mode 100644 index 0000000..a1a2c16 --- /dev/null +++ b/pre_commit/languages/docker_image.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from pre_commit import lang_base +from pre_commit.languages.docker import docker_cmd +from pre_commit.prefix import Prefix + +ENVIRONMENT_DIR = None +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +install_environment = lang_base.no_install +in_env = lang_base.no_env + + +def run_hook( + prefix: Prefix, + entry: str, + args: Sequence[str], + file_args: Sequence[str], + *, + is_local: bool, + require_serial: bool, + color: bool, +) -> tuple[int, bytes]: # pragma: win32 no cover + cmd = docker_cmd() + lang_base.hook_cmd(entry, args) + return lang_base.run_xargs( + cmd, + file_args, + require_serial=require_serial, + color=color, + ) diff --git a/pre_commit/languages/dotnet.py b/pre_commit/languages/dotnet.py new file mode 100644 index 0000000..e1202c4 --- /dev/null +++ b/pre_commit/languages/dotnet.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import contextlib +import os.path +import re +import tempfile +import xml.etree.ElementTree +import zipfile +from collections.abc import Generator +from collections.abc import Sequence + +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.prefix import Prefix + +ENVIRONMENT_DIR = 'dotnetenv' +BIN_DIR = 'bin' + +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + + +def get_env_patch(venv: str) -> PatchesT: + return ( + ('PATH', (os.path.join(venv, BIN_DIR), os.pathsep, Var('PATH'))), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir)): + yield + + +@contextlib.contextmanager +def _nuget_config_no_sources() -> Generator[str, None, None]: + with tempfile.TemporaryDirectory() as tmpdir: + nuget_config = os.path.join(tmpdir, 'nuget.config') + with open(nuget_config, 'w') as f: + f.write( + '<?xml version="1.0" encoding="utf-8"?>' + '<configuration>' + ' <packageSources>' + ' <clear />' + ' </packageSources>' + '</configuration>', + ) + yield nuget_config + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + lang_base.assert_version_default('dotnet', version) + lang_base.assert_no_additional_deps('dotnet', additional_dependencies) + + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + build_dir = prefix.path('pre-commit-build') + + # Build & pack nupkg file + lang_base.setup_cmd( + prefix, + ( + 'dotnet', 'pack', + '--configuration', 'Release', + '--property', f'PackageOutputPath={build_dir}', + ), + ) + + nupkg_dir = prefix.path(build_dir) + nupkgs = [x for x in os.listdir(nupkg_dir) if x.endswith('.nupkg')] + + if not nupkgs: + raise AssertionError('could not find any build outputs to install') + + for nupkg in nupkgs: + with zipfile.ZipFile(os.path.join(nupkg_dir, nupkg)) as f: + nuspec, = (x for x in f.namelist() if x.endswith('.nuspec')) + with f.open(nuspec) as spec: + tree = xml.etree.ElementTree.parse(spec) + + namespace = re.match(r'{.*}', tree.getroot().tag) + if not namespace: + raise AssertionError('could not parse namespace from nuspec') + + tool_id_element = tree.find(f'.//{namespace[0]}id') + if tool_id_element is None: + raise AssertionError('expected to find an "id" element') + + tool_id = tool_id_element.text + if not tool_id: + raise AssertionError('"id" element missing tool name') + + # Install to bin dir + with _nuget_config_no_sources() as nuget_config: + lang_base.setup_cmd( + prefix, + ( + 'dotnet', 'tool', 'install', + '--configfile', nuget_config, + '--tool-path', os.path.join(envdir, BIN_DIR), + '--add-source', build_dir, + tool_id, + ), + ) diff --git a/pre_commit/languages/fail.py b/pre_commit/languages/fail.py new file mode 100644 index 0000000..6ac4d76 --- /dev/null +++ b/pre_commit/languages/fail.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from pre_commit import lang_base +from pre_commit.prefix import Prefix + +ENVIRONMENT_DIR = None +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +install_environment = lang_base.no_install +in_env = lang_base.no_env + + +def run_hook( + prefix: Prefix, + entry: str, + args: Sequence[str], + file_args: Sequence[str], + *, + is_local: bool, + require_serial: bool, + color: bool, +) -> tuple[int, bytes]: + out = f'{entry}\n\n'.encode() + out += b'\n'.join(f.encode() for f in file_args) + b'\n' + return 1, out diff --git a/pre_commit/languages/golang.py b/pre_commit/languages/golang.py new file mode 100644 index 0000000..66e07cf --- /dev/null +++ b/pre_commit/languages/golang.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import contextlib +import functools +import json +import os.path +import platform +import shutil +import sys +import tarfile +import tempfile +import urllib.error +import urllib.request +import zipfile +from collections.abc import Generator +from collections.abc import Sequence +from typing import ContextManager +from typing import IO +from typing import Protocol + +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.git import no_git_env +from pre_commit.prefix import Prefix +from pre_commit.util import cmd_output +from pre_commit.util import rmtree + +ENVIRONMENT_DIR = 'golangenv' +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + +_ARCH_ALIASES = { + 'x86_64': 'amd64', + 'i386': '386', + 'aarch64': 'arm64', + 'armv8': 'arm64', + 'armv7l': 'armv6l', +} +_ARCH = platform.machine().lower() +_ARCH = _ARCH_ALIASES.get(_ARCH, _ARCH) + + +class ExtractAll(Protocol): + def extractall(self, path: str) -> None: ... + + +if sys.platform == 'win32': # pragma: win32 cover + _EXT = 'zip' + + def _open_archive(bio: IO[bytes]) -> ContextManager[ExtractAll]: + return zipfile.ZipFile(bio) +else: # pragma: win32 no cover + _EXT = 'tar.gz' + + def _open_archive(bio: IO[bytes]) -> ContextManager[ExtractAll]: + return tarfile.open(fileobj=bio) + + +@functools.lru_cache(maxsize=1) +def get_default_version() -> str: + if lang_base.exe_exists('go'): + return 'system' + else: + return C.DEFAULT + + +def get_env_patch(venv: str, version: str) -> PatchesT: + if version == 'system': + return ( + ('PATH', (os.path.join(venv, 'bin'), os.pathsep, Var('PATH'))), + ) + + return ( + ('GOROOT', os.path.join(venv, '.go')), + ( + 'PATH', ( + os.path.join(venv, 'bin'), os.pathsep, + os.path.join(venv, '.go', 'bin'), os.pathsep, Var('PATH'), + ), + ), + ) + + +@functools.lru_cache +def _infer_go_version(version: str) -> str: + if version != C.DEFAULT: + return version + resp = urllib.request.urlopen('https://go.dev/dl/?mode=json') + # TODO: 3.9+ .removeprefix('go') + return json.load(resp)[0]['version'][2:] + + +def _get_url(version: str) -> str: + os_name = platform.system().lower() + version = _infer_go_version(version) + return f'https://dl.google.com/go/go{version}.{os_name}-{_ARCH}.{_EXT}' + + +def _install_go(version: str, dest: str) -> None: + try: + resp = urllib.request.urlopen(_get_url(version)) + except urllib.error.HTTPError as e: # pragma: no cover + if e.code == 404: + raise ValueError( + f'Could not find a version matching your system requirements ' + f'(os={platform.system().lower()}; arch={_ARCH})', + ) from e + else: + raise + else: + with tempfile.TemporaryFile() as f: + shutil.copyfileobj(resp, f) + f.seek(0) + + with _open_archive(f) as archive: + archive.extractall(dest) + shutil.move(os.path.join(dest, 'go'), os.path.join(dest, '.go')) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir, version)): + yield + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + env_dir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + if version != 'system': + _install_go(version, env_dir) + + if sys.platform == 'cygwin': # pragma: no cover + gopath = cmd_output('cygpath', '-w', env_dir)[1].strip() + else: + gopath = env_dir + + env = no_git_env(dict(os.environ, GOPATH=gopath)) + env.pop('GOBIN', None) + if version != 'system': + env['GOROOT'] = os.path.join(env_dir, '.go') + env['PATH'] = os.pathsep.join(( + os.path.join(env_dir, '.go', 'bin'), os.environ['PATH'], + )) + + lang_base.setup_cmd(prefix, ('go', 'install', './...'), env=env) + for dependency in additional_dependencies: + lang_base.setup_cmd(prefix, ('go', 'install', dependency), env=env) + + # save some disk space -- we don't need this after installation + pkgdir = os.path.join(env_dir, 'pkg') + if os.path.exists(pkgdir): # pragma: no branch (always true on windows?) + rmtree(pkgdir) diff --git a/pre_commit/languages/haskell.py b/pre_commit/languages/haskell.py new file mode 100644 index 0000000..c6945c8 --- /dev/null +++ b/pre_commit/languages/haskell.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import contextlib +import os.path +from collections.abc import Generator +from collections.abc import Sequence + +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.errors import FatalError +from pre_commit.prefix import Prefix + +ENVIRONMENT_DIR = 'hs_env' +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + + +def get_env_patch(target_dir: str) -> PatchesT: + bin_path = os.path.join(target_dir, 'bin') + return (('PATH', (bin_path, os.pathsep, Var('PATH'))),) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir)): + yield + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + lang_base.assert_version_default('haskell', version) + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + pkgs = [*prefix.star('.cabal'), *additional_dependencies] + if not pkgs: + raise FatalError('Expected .cabal files or additional_dependencies') + + bindir = os.path.join(envdir, 'bin') + os.makedirs(bindir, exist_ok=True) + lang_base.setup_cmd(prefix, ('cabal', 'update')) + lang_base.setup_cmd( + prefix, + ( + 'cabal', 'install', + '--install-method', 'copy', + '--installdir', bindir, + *pkgs, + ), + ) diff --git a/pre_commit/languages/lua.py b/pre_commit/languages/lua.py new file mode 100644 index 0000000..a475ec9 --- /dev/null +++ b/pre_commit/languages/lua.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import contextlib +import os +import sys +from collections.abc import Generator +from collections.abc import Sequence + +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.prefix import Prefix +from pre_commit.util import cmd_output + +ENVIRONMENT_DIR = 'lua_env' +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + + +def _get_lua_version() -> str: # pragma: win32 no cover + """Get the Lua version used in file paths.""" + _, stdout, _ = cmd_output('luarocks', 'config', '--lua-ver') + return stdout.strip() + + +def get_env_patch(d: str) -> PatchesT: # pragma: win32 no cover + version = _get_lua_version() + so_ext = 'dll' if sys.platform == 'win32' else 'so' + return ( + ('PATH', (os.path.join(d, 'bin'), os.pathsep, Var('PATH'))), + ( + 'LUA_PATH', ( + os.path.join(d, 'share', 'lua', version, '?.lua;'), + os.path.join(d, 'share', 'lua', version, '?', 'init.lua;;'), + ), + ), + ( + 'LUA_CPATH', + (os.path.join(d, 'lib', 'lua', version, f'?.{so_ext};;'),), + ), + ) + + +@contextlib.contextmanager # pragma: win32 no cover +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir)): + yield + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: # pragma: win32 no cover + lang_base.assert_version_default('lua', version) + + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with in_env(prefix, version): + # luarocks doesn't bootstrap a tree prior to installing + # so ensure the directory exists. + os.makedirs(envdir, exist_ok=True) + + # Older luarocks (e.g., 2.4.2) expect the rockspec as an arg + for rockspec in prefix.star('.rockspec'): + make_cmd = ('luarocks', '--tree', envdir, 'make', rockspec) + lang_base.setup_cmd(prefix, make_cmd) + + # luarocks can't install multiple packages at once + # so install them individually. + for dependency in additional_dependencies: + cmd = ('luarocks', '--tree', envdir, 'install', dependency) + lang_base.setup_cmd(prefix, cmd) diff --git a/pre_commit/languages/node.py b/pre_commit/languages/node.py new file mode 100644 index 0000000..d49c0e3 --- /dev/null +++ b/pre_commit/languages/node.py @@ -0,0 +1,110 @@ +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 = 'node_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 all(lang_base.exe_exists(exe) for exe in ('node', 'npm')): + 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, None, 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('node', '--version', check=False) + if retcode != 0: # pragma: win32 no cover + return f'`node --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) + + # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx?f=255&MSPPError=-2147217396#maxpath + 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): + # https://npm.community/t/npm-install-g-git-vs-git-clone-cd-npm-install-g/5449 + # install as if we installed from git + + local_install_cmd = ( + 'npm', 'install', '--include=dev', '--include=prod', + '--ignore-prepublish', '--no-progress', '--no-save', + ) + lang_base.setup_cmd(prefix, local_install_cmd) + + _, pkg, _ = cmd_output('npm', 'pack', cwd=prefix.prefix_dir) + pkg = prefix.path(pkg.strip()) + + install = ('npm', 'install', '-g', pkg, *additional_dependencies) + lang_base.setup_cmd(prefix, install) + + # clean these up after installation + if prefix.exists('node_modules'): # pragma: win32 no cover + rmtree(prefix.path('node_modules')) + os.remove(pkg) diff --git a/pre_commit/languages/perl.py b/pre_commit/languages/perl.py new file mode 100644 index 0000000..61b1d11 --- /dev/null +++ b/pre_commit/languages/perl.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import contextlib +import os +import shlex +from collections.abc import Generator +from collections.abc import Sequence + +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.prefix import Prefix + +ENVIRONMENT_DIR = 'perl_env' +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + + +def get_env_patch(venv: str) -> PatchesT: + return ( + ('PATH', (os.path.join(venv, 'bin'), os.pathsep, Var('PATH'))), + ('PERL5LIB', os.path.join(venv, 'lib', 'perl5')), + ('PERL_MB_OPT', f'--install_base {shlex.quote(venv)}'), + ( + 'PERL_MM_OPT', ( + f'INSTALL_BASE={shlex.quote(venv)} ' + f'INSTALLSITEMAN1DIR=none INSTALLSITEMAN3DIR=none' + ), + ), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir)): + yield + + +def install_environment( + prefix: Prefix, version: str, additional_dependencies: Sequence[str], +) -> None: + lang_base.assert_version_default('perl', version) + + with in_env(prefix, version): + lang_base.setup_cmd( + prefix, ('cpan', '-T', '.', *additional_dependencies), + ) diff --git a/pre_commit/languages/pygrep.py b/pre_commit/languages/pygrep.py new file mode 100644 index 0000000..72a9345 --- /dev/null +++ b/pre_commit/languages/pygrep.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import argparse +import re +import sys +from collections.abc import Sequence +from re import Pattern +from typing import NamedTuple + +from pre_commit import lang_base +from pre_commit import output +from pre_commit.prefix import Prefix +from pre_commit.xargs import xargs + +ENVIRONMENT_DIR = None +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +install_environment = lang_base.no_install +in_env = lang_base.no_env + + +def _process_filename_by_line(pattern: Pattern[bytes], filename: str) -> int: + retv = 0 + with open(filename, 'rb') as f: + for line_no, line in enumerate(f, start=1): + if pattern.search(line): + retv = 1 + output.write(f'{filename}:{line_no}:') + output.write_line_b(line.rstrip(b'\r\n')) + return retv + + +def _process_filename_at_once(pattern: Pattern[bytes], filename: str) -> int: + retv = 0 + with open(filename, 'rb') as f: + contents = f.read() + match = pattern.search(contents) + if match: + retv = 1 + line_no = contents[:match.start()].count(b'\n') + output.write(f'{filename}:{line_no + 1}:') + + matched_lines = match[0].split(b'\n') + matched_lines[0] = contents.split(b'\n')[line_no] + + output.write_line_b(b'\n'.join(matched_lines)) + return retv + + +def _process_filename_by_line_negated( + pattern: Pattern[bytes], + filename: str, +) -> int: + with open(filename, 'rb') as f: + for line in f: + if pattern.search(line): + return 0 + else: + output.write_line(filename) + return 1 + + +def _process_filename_at_once_negated( + pattern: Pattern[bytes], + filename: str, +) -> int: + with open(filename, 'rb') as f: + contents = f.read() + match = pattern.search(contents) + if match: + return 0 + else: + output.write_line(filename) + return 1 + + +class Choice(NamedTuple): + multiline: bool + negate: bool + + +FNS = { + Choice(multiline=True, negate=True): _process_filename_at_once_negated, + Choice(multiline=True, negate=False): _process_filename_at_once, + Choice(multiline=False, negate=True): _process_filename_by_line_negated, + Choice(multiline=False, negate=False): _process_filename_by_line, +} + + +def run_hook( + prefix: Prefix, + entry: str, + args: Sequence[str], + file_args: Sequence[str], + *, + is_local: bool, + require_serial: bool, + color: bool, +) -> tuple[int, bytes]: + cmd = (sys.executable, '-m', __name__, *args, entry) + return xargs(cmd, file_args, color=color) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=( + 'grep-like finder using python regexes. Unlike grep, this tool ' + 'returns nonzero when it finds a match and zero otherwise. The ' + 'idea here being that matches are "problems".' + ), + ) + parser.add_argument('-i', '--ignore-case', action='store_true') + parser.add_argument('--multiline', action='store_true') + parser.add_argument('--negate', action='store_true') + parser.add_argument('pattern', help='python regex pattern.') + parser.add_argument('filenames', nargs='*') + args = parser.parse_args(argv) + + flags = re.IGNORECASE if args.ignore_case else 0 + if args.multiline: + flags |= re.MULTILINE | re.DOTALL + + pattern = re.compile(args.pattern.encode(), flags) + + retv = 0 + process_fn = FNS[Choice(multiline=args.multiline, negate=args.negate)] + for filename in args.filenames: + retv |= process_fn(pattern, filename) + return retv + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/pre_commit/languages/python.py b/pre_commit/languages/python.py new file mode 100644 index 0000000..9f4bf69 --- /dev/null +++ b/pre_commit/languages/python.py @@ -0,0 +1,214 @@ +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.parse_shebang import find_executable +from pre_commit.prefix import Prefix +from pre_commit.util import CalledProcessError +from pre_commit.util import cmd_output +from pre_commit.util import cmd_output_b +from pre_commit.util import win_exe + +ENVIRONMENT_DIR = 'py_env' +run_hook = lang_base.basic_run_hook + + +@functools.cache +def _version_info(exe: str) -> str: + prog = 'import sys;print(".".join(str(p) for p in sys.version_info))' + try: + return cmd_output(exe, '-S', '-c', prog)[1].strip() + except CalledProcessError: + return f'<<error retrieving version from {exe}>>' + + +def _read_pyvenv_cfg(filename: str) -> dict[str, str]: + ret = {} + with open(filename, encoding='UTF-8') as f: + for line in f: + try: + k, v = line.split('=') + except ValueError: # blank line / comment / etc. + continue + else: + ret[k.strip()] = v.strip() + return ret + + +def bin_dir(venv: str) -> str: + """On windows there's a different directory for the virtualenv""" + bin_part = 'Scripts' if sys.platform == 'win32' else 'bin' + return os.path.join(venv, bin_part) + + +def get_env_patch(venv: str) -> PatchesT: + return ( + ('PIP_DISABLE_PIP_VERSION_CHECK', '1'), + ('PYTHONHOME', UNSET), + ('VIRTUAL_ENV', venv), + ('PATH', (bin_dir(venv), os.pathsep, Var('PATH'))), + ) + + +def _find_by_py_launcher( + version: str, +) -> str | None: # pragma: no cover (windows only) + if version.startswith('python'): + num = version.removeprefix('python') + cmd = ('py', f'-{num}', '-c', 'import sys; print(sys.executable)') + env = dict(os.environ, PYTHONIOENCODING='UTF-8') + try: + return cmd_output(*cmd, env=env)[1].strip() + except CalledProcessError: + pass + return None + + +def _find_by_sys_executable() -> str | None: + def _norm(path: str) -> str | None: + _, exe = os.path.split(path.lower()) + exe, _, _ = exe.partition('.exe') + if exe not in {'python', 'pythonw'} and find_executable(exe): + return exe + return None + + # On linux, I see these common sys.executables: + # + # system `python`: /usr/bin/python -> python2.7 + # system `python2`: /usr/bin/python2 -> python2.7 + # virtualenv v: v/bin/python (will not return from this loop) + # virtualenv v -ppython2: v/bin/python -> python2 + # virtualenv v -ppython2.7: v/bin/python -> python2.7 + # virtualenv v -ppypy: v/bin/python -> v/bin/pypy + for path in (sys.executable, os.path.realpath(sys.executable)): + exe = _norm(path) + if exe: + return exe + return None + + +@functools.lru_cache(maxsize=1) +def get_default_version() -> str: # pragma: no cover (platform dependent) + # First attempt from `sys.executable` (or the realpath) + exe = _find_by_sys_executable() + if exe: + return exe + + # Next try the `pythonX.X` executable + exe = f'python{sys.version_info[0]}.{sys.version_info[1]}' + if find_executable(exe): + return exe + + if _find_by_py_launcher(exe): + return exe + + # We tried! + return C.DEFAULT + + +def _sys_executable_matches(version: str) -> bool: + if version == 'python': + return True + elif not version.startswith('python'): + return False + + try: + info = tuple(int(p) for p in version.removeprefix('python').split('.')) + except ValueError: + return False + + return sys.version_info[:len(info)] == info + + +def norm_version(version: str) -> str | None: + if version == C.DEFAULT: # use virtualenv's default + return None + elif _sys_executable_matches(version): # virtualenv defaults to our exe + return None + + if sys.platform == 'win32': # pragma: no cover (windows) + version_exec = _find_by_py_launcher(version) + if version_exec: + return version_exec + + # Try looking up by name + version_exec = find_executable(version) + if version_exec and version_exec != version: + return version_exec + + # Otherwise assume it is a path + return os.path.expanduser(version) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, 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: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + pyvenv_cfg = os.path.join(envdir, 'pyvenv.cfg') + + # created with "old" virtualenv + if not os.path.exists(pyvenv_cfg): + return 'pyvenv.cfg does not exist (old virtualenv?)' + + exe_name = win_exe('python') + py_exe = prefix.path(bin_dir(envdir), exe_name) + cfg = _read_pyvenv_cfg(pyvenv_cfg) + + if 'version_info' not in cfg: + return "created virtualenv's pyvenv.cfg is missing `version_info`" + + # always use uncached lookup here in case we replaced an unhealthy env + virtualenv_version = _version_info.__wrapped__(py_exe) + if virtualenv_version != cfg['version_info']: + return ( + f'virtualenv python version did not match created version:\n' + f'- actual version: {virtualenv_version}\n' + f'- expected version: {cfg["version_info"]}\n' + ) + + # made with an older version of virtualenv? skip `base-executable` check + if 'base-executable' not in cfg: + return None + + base_exe_version = _version_info(cfg['base-executable']) + if base_exe_version != cfg['version_info']: + return ( + f'base executable python version does not match created version:\n' + f'- base-executable version: {base_exe_version}\n' + f'- expected version: {cfg["version_info"]}\n' + ) + else: + return None + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + venv_cmd = [sys.executable, '-mvirtualenv', envdir] + python = norm_version(version) + if python is not None: + venv_cmd.extend(('-p', python)) + install_cmd = ('python', '-mpip', 'install', '.', *additional_dependencies) + + cmd_output_b(*venv_cmd, cwd='/') + with in_env(prefix, version): + lang_base.setup_cmd(prefix, install_cmd) diff --git a/pre_commit/languages/r.py b/pre_commit/languages/r.py new file mode 100644 index 0000000..93b62bd --- /dev/null +++ b/pre_commit/languages/r.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import contextlib +import os +import shlex +import shutil +import tempfile +import textwrap +from collections.abc import Generator +from collections.abc import Sequence + +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.prefix import Prefix +from pre_commit.util import cmd_output_b +from pre_commit.util import win_exe + +ENVIRONMENT_DIR = 'renv' +RSCRIPT_OPTS = ('--no-save', '--no-restore', '--no-site-file', '--no-environ') +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check + + +@contextlib.contextmanager +def _r_code_in_tempfile(code: str) -> Generator[str, None, None]: + """ + To avoid quoting and escaping issues, avoid `Rscript [options] -e {expr}` + but use `Rscript [options] path/to/file_with_expr.R` + """ + with tempfile.TemporaryDirectory() as tmpdir: + fname = os.path.join(tmpdir, 'script.R') + with open(fname, 'w') as f: + f.write(_inline_r_setup(textwrap.dedent(code))) + yield fname + + +def get_env_patch(venv: str) -> PatchesT: + return ( + ('R_PROFILE_USER', os.path.join(venv, 'activate.R')), + ('RENV_PROJECT', UNSET), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir)): + yield + + +def _prefix_if_file_entry( + entry: list[str], + prefix: Prefix, + *, + is_local: bool, +) -> Sequence[str]: + if entry[1] == '-e' or is_local: + return entry[1:] + else: + return (prefix.path(entry[1]),) + + +def _rscript_exec() -> str: + r_home = os.environ.get('R_HOME') + if r_home is None: + return 'Rscript' + else: + return os.path.join(r_home, 'bin', win_exe('Rscript')) + + +def _entry_validate(entry: list[str]) -> None: + """ + Allowed entries: + # Rscript -e expr + # Rscript path/to/file + """ + if entry[0] != 'Rscript': + raise ValueError('entry must start with `Rscript`.') + + if entry[1] == '-e': + if len(entry) > 3: + raise ValueError('You can supply at most one expression.') + elif len(entry) > 2: + raise ValueError( + 'The only valid syntax is `Rscript -e {expr}`' + 'or `Rscript path/to/hook/script`', + ) + + +def _cmd_from_hook( + prefix: Prefix, + entry: str, + args: Sequence[str], + *, + is_local: bool, +) -> tuple[str, ...]: + cmd = shlex.split(entry) + _entry_validate(cmd) + + cmd_part = _prefix_if_file_entry(cmd, prefix, is_local=is_local) + return (cmd[0], *RSCRIPT_OPTS, *cmd_part, *args) + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + lang_base.assert_version_default('r', version) + + env_dir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + os.makedirs(env_dir, exist_ok=True) + shutil.copy(prefix.path('renv.lock'), env_dir) + shutil.copytree(prefix.path('renv'), os.path.join(env_dir, 'renv')) + + r_code_inst_environment = f"""\ + prefix_dir <- {prefix.prefix_dir!r} + options( + repos = c(CRAN = "https://cran.rstudio.com"), + renv.consent = TRUE + ) + source("renv/activate.R") + renv::restore() + activate_statement <- paste0( + 'suppressWarnings({{', + 'old <- setwd("', getwd(), '"); ', + 'source("renv/activate.R"); ', + 'setwd(old); ', + 'renv::load("', getwd(), '");}})' + ) + writeLines(activate_statement, 'activate.R') + is_package <- tryCatch( + {{ + path_desc <- file.path(prefix_dir, 'DESCRIPTION') + suppressWarnings(desc <- read.dcf(path_desc)) + "Package" %in% colnames(desc) + }}, + error = function(...) FALSE + ) + if (is_package) {{ + renv::install(prefix_dir) + }} + """ + + with _r_code_in_tempfile(r_code_inst_environment) as f: + cmd_output_b(_rscript_exec(), '--vanilla', f, cwd=env_dir) + + if additional_dependencies: + r_code_inst_add = 'renv::install(commandArgs(trailingOnly = TRUE))' + with in_env(prefix, version): + with _r_code_in_tempfile(r_code_inst_add) as f: + cmd_output_b( + _rscript_exec(), *RSCRIPT_OPTS, + f, + *additional_dependencies, + cwd=env_dir, + ) + + +def _inline_r_setup(code: str) -> str: + """ + Some behaviour of R cannot be configured via env variables, but can + only be configured via R options once R has started. These are set here. + """ + with_option = [ + textwrap.dedent("""\ + options( + install.packages.compile.from.source = "never", + pkgType = "binary" + ) + """), + code, + ] + return '\n'.join(with_option) + + +def run_hook( + prefix: Prefix, + entry: str, + args: Sequence[str], + file_args: Sequence[str], + *, + is_local: bool, + require_serial: bool, + color: bool, +) -> tuple[int, bytes]: + cmd = _cmd_from_hook(prefix, entry, args, is_local=is_local) + return lang_base.run_xargs( + cmd, + file_args, + require_serial=require_serial, + color=color, + ) diff --git a/pre_commit/languages/ruby.py b/pre_commit/languages/ruby.py new file mode 100644 index 0000000..0438ae0 --- /dev/null +++ b/pre_commit/languages/ruby.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import contextlib +import functools +import importlib.resources +import os.path +import shutil +import tarfile +from collections.abc import Generator +from collections.abc import Sequence +from typing import IO + +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.prefix import Prefix +from pre_commit.util import CalledProcessError + +ENVIRONMENT_DIR = 'rbenv' +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + + +def _resource_bytesio(filename: str) -> IO[bytes]: + files = importlib.resources.files('pre_commit.resources') + return files.joinpath(filename).open('rb') + + +@functools.lru_cache(maxsize=1) +def get_default_version() -> str: + if all(lang_base.exe_exists(exe) for exe in ('ruby', 'gem')): + return 'system' + else: + return C.DEFAULT + + +def get_env_patch( + venv: str, + language_version: str, +) -> PatchesT: + patches: PatchesT = ( + ('GEM_HOME', os.path.join(venv, 'gems')), + ('GEM_PATH', UNSET), + ('BUNDLE_IGNORE_CONFIG', '1'), + ) + if language_version == 'system': + patches += ( + ( + 'PATH', ( + os.path.join(venv, 'gems', 'bin'), os.pathsep, + Var('PATH'), + ), + ), + ) + else: # pragma: win32 no cover + patches += ( + ('RBENV_ROOT', venv), + ( + 'PATH', ( + os.path.join(venv, 'gems', 'bin'), os.pathsep, + os.path.join(venv, 'shims'), os.pathsep, + os.path.join(venv, 'bin'), os.pathsep, Var('PATH'), + ), + ), + ) + if language_version not in {'system', 'default'}: # pragma: win32 no cover + patches += (('RBENV_VERSION', language_version),) + + return patches + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir, version)): + yield + + +def _extract_resource(filename: str, dest: str) -> None: + with _resource_bytesio(filename) as bio: + with tarfile.open(fileobj=bio) as tf: + tf.extractall(dest) + + +def _install_rbenv( + prefix: Prefix, + version: str, +) -> None: # pragma: win32 no cover + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + _extract_resource('rbenv.tar.gz', prefix.path('.')) + shutil.move(prefix.path('rbenv'), envdir) + + # Only install ruby-build if the version is specified + if version != C.DEFAULT: + plugins_dir = os.path.join(envdir, 'plugins') + _extract_resource('ruby-download.tar.gz', plugins_dir) + _extract_resource('ruby-build.tar.gz', plugins_dir) + + +def _install_ruby( + prefix: Prefix, + version: str, +) -> None: # pragma: win32 no cover + try: + lang_base.setup_cmd(prefix, ('rbenv', 'download', version)) + except CalledProcessError: # pragma: no cover (usually find with download) + # Failed to download from mirror for some reason, build it instead + lang_base.setup_cmd(prefix, ('rbenv', 'install', version)) + + +def install_environment( + prefix: Prefix, version: str, additional_dependencies: Sequence[str], +) -> None: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + if version != 'system': # pragma: win32 no cover + _install_rbenv(prefix, version) + with in_env(prefix, version): + # Need to call this before installing so rbenv's directories + # are set up + lang_base.setup_cmd(prefix, ('rbenv', 'init', '-')) + if version != C.DEFAULT: + _install_ruby(prefix, version) + # Need to call this after installing to set up the shims + lang_base.setup_cmd(prefix, ('rbenv', 'rehash')) + + with in_env(prefix, version): + lang_base.setup_cmd( + prefix, ('gem', 'build', *prefix.star('.gemspec')), + ) + lang_base.setup_cmd( + prefix, + ( + 'gem', 'install', + '--no-document', '--no-format-executable', + '--no-user-install', + '--install-dir', os.path.join(envdir, 'gems'), + '--bindir', os.path.join(envdir, 'gems', 'bin'), + *prefix.star('.gem'), *additional_dependencies, + ), + ) diff --git a/pre_commit/languages/rust.py b/pre_commit/languages/rust.py new file mode 100644 index 0000000..7b04d6c --- /dev/null +++ b/pre_commit/languages/rust.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import contextlib +import functools +import os.path +import shutil +import sys +import tempfile +import urllib.request +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 import parse_shebang +from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT +from pre_commit.envcontext import Var +from pre_commit.prefix import Prefix +from pre_commit.util import cmd_output_b +from pre_commit.util import make_executable +from pre_commit.util import win_exe + +ENVIRONMENT_DIR = 'rustenv' +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + + +@functools.lru_cache(maxsize=1) +def get_default_version() -> str: + # If rust is already installed, we can save a bunch of setup time by + # using the installed version. + # + # Just detecting the executable does not suffice, because if rustup is + # installed but no toolchain is available, then `cargo` exists but + # cannot be used without installing a toolchain first. + if cmd_output_b('cargo', '--version', check=False)[0] == 0: + return 'system' + else: + return C.DEFAULT + + +def _rust_toolchain(language_version: str) -> str: + """Transform the language version into a rust toolchain version.""" + if language_version == C.DEFAULT: + return 'stable' + else: + return language_version + + +def get_env_patch(target_dir: str, version: str) -> PatchesT: + return ( + ('PATH', (os.path.join(target_dir, 'bin'), os.pathsep, Var('PATH'))), + # Only set RUSTUP_TOOLCHAIN if we don't want use the system's default + # toolchain + *( + (('RUSTUP_TOOLCHAIN', _rust_toolchain(version)),) + if version != 'system' else () + ), + ) + + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir, version)): + yield + + +def _add_dependencies( + prefix: Prefix, + additional_dependencies: set[str], +) -> None: + crates = [] + for dep in additional_dependencies: + name, _, spec = dep.partition(':') + crate = f'{name}@{spec or "*"}' + crates.append(crate) + + lang_base.setup_cmd(prefix, ('cargo', 'add', *crates)) + + +def install_rust_with_toolchain(toolchain: str, envdir: str) -> None: + with tempfile.TemporaryDirectory() as rustup_dir: + with envcontext((('CARGO_HOME', envdir), ('RUSTUP_HOME', rustup_dir))): + # acquire `rustup` if not present + if parse_shebang.find_executable('rustup') is None: + # We did not detect rustup and need to download it first. + if sys.platform == 'win32': # pragma: win32 cover + url = 'https://win.rustup.rs/x86_64' + else: # pragma: win32 no cover + url = 'https://sh.rustup.rs' + + resp = urllib.request.urlopen(url) + + rustup_init = os.path.join(rustup_dir, win_exe('rustup-init')) + with open(rustup_init, 'wb') as f: + shutil.copyfileobj(resp, f) + make_executable(rustup_init) + + # install rustup into `$CARGO_HOME/bin` + cmd_output_b( + rustup_init, '-y', '--quiet', '--no-modify-path', + '--default-toolchain', 'none', + ) + + cmd_output_b( + 'rustup', 'toolchain', 'install', '--no-self-update', + toolchain, + ) + + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + # There are two cases where we might want to specify more dependencies: + # as dependencies for the library being built, and as binary packages + # to be `cargo install`'d. + # + # Unlike e.g. Python, if we just `cargo install` a library, it won't be + # used for compilation. And if we add a crate providing a binary to the + # `Cargo.toml`, the binary won't be built. + # + # Because of this, we allow specifying "cli" dependencies by prefixing + # with 'cli:'. + cli_deps = { + dep for dep in additional_dependencies if dep.startswith('cli:') + } + lib_deps = set(additional_dependencies) - cli_deps + + packages_to_install: set[tuple[str, ...]] = {('--path', '.')} + for cli_dep in cli_deps: + cli_dep = cli_dep.removeprefix('cli:') + package, _, crate_version = cli_dep.partition(':') + if crate_version != '': + packages_to_install.add((package, '--version', crate_version)) + else: + packages_to_install.add((package,)) + + with contextlib.ExitStack() as ctx: + ctx.enter_context(in_env(prefix, version)) + + if version != 'system': + install_rust_with_toolchain(_rust_toolchain(version), envdir) + + tmpdir = ctx.enter_context(tempfile.TemporaryDirectory()) + ctx.enter_context(envcontext((('RUSTUP_HOME', tmpdir),))) + + if len(lib_deps) > 0: + _add_dependencies(prefix, lib_deps) + + for args in packages_to_install: + cmd_output_b( + 'cargo', 'install', '--bins', '--root', envdir, *args, + cwd=prefix.prefix_dir, + ) diff --git a/pre_commit/languages/script.py b/pre_commit/languages/script.py new file mode 100644 index 0000000..1eaa1e2 --- /dev/null +++ b/pre_commit/languages/script.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from pre_commit import lang_base +from pre_commit.prefix import Prefix + +ENVIRONMENT_DIR = None +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +install_environment = lang_base.no_install +in_env = lang_base.no_env + + +def run_hook( + prefix: Prefix, + entry: str, + args: Sequence[str], + file_args: Sequence[str], + *, + is_local: bool, + require_serial: bool, + color: bool, +) -> tuple[int, bytes]: + cmd = lang_base.hook_cmd(entry, args) + cmd = (prefix.path(cmd[0]), *cmd[1:]) + return lang_base.run_xargs( + cmd, + file_args, + require_serial=require_serial, + color=color, + ) diff --git a/pre_commit/languages/swift.py b/pre_commit/languages/swift.py new file mode 100644 index 0000000..f7bfe84 --- /dev/null +++ b/pre_commit/languages/swift.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import contextlib +import os +from collections.abc import Generator +from collections.abc import Sequence + +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.prefix import Prefix +from pre_commit.util import cmd_output_b + +BUILD_DIR = '.build' +BUILD_CONFIG = 'release' + +ENVIRONMENT_DIR = 'swift_env' +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +run_hook = lang_base.basic_run_hook + + +def get_env_patch(venv: str) -> PatchesT: # pragma: win32 no cover + bin_path = os.path.join(venv, BUILD_DIR, BUILD_CONFIG) + return (('PATH', (bin_path, os.pathsep, Var('PATH'))),) + + +@contextlib.contextmanager # pragma: win32 no cover +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + with envcontext(get_env_patch(envdir)): + yield + + +def install_environment( + prefix: Prefix, version: str, additional_dependencies: Sequence[str], +) -> None: # pragma: win32 no cover + lang_base.assert_version_default('swift', version) + lang_base.assert_no_additional_deps('swift', additional_dependencies) + envdir = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + # Build the swift package + os.mkdir(envdir) + cmd_output_b( + 'swift', 'build', + '--package-path', prefix.prefix_dir, + '-c', BUILD_CONFIG, + '--build-path', os.path.join(envdir, BUILD_DIR), + ) diff --git a/pre_commit/languages/system.py b/pre_commit/languages/system.py new file mode 100644 index 0000000..f6ad688 --- /dev/null +++ b/pre_commit/languages/system.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pre_commit import lang_base + +ENVIRONMENT_DIR = None +get_default_version = lang_base.basic_get_default_version +health_check = lang_base.basic_health_check +install_environment = lang_base.no_install +in_env = lang_base.no_env +run_hook = lang_base.basic_run_hook |