summaryrefslogtreecommitdiffstats
path: root/pre_commit/languages
diff options
context:
space:
mode:
Diffstat (limited to 'pre_commit/languages')
-rw-r--r--pre_commit/languages/__init__.py0
-rw-r--r--pre_commit/languages/conda.py77
-rw-r--r--pre_commit/languages/coursier.py76
-rw-r--r--pre_commit/languages/dart.py97
-rw-r--r--pre_commit/languages/docker.py146
-rw-r--r--pre_commit/languages/docker_image.py32
-rw-r--r--pre_commit/languages/dotnet.py111
-rw-r--r--pre_commit/languages/fail.py27
-rw-r--r--pre_commit/languages/golang.py160
-rw-r--r--pre_commit/languages/haskell.py56
-rw-r--r--pre_commit/languages/lua.py75
-rw-r--r--pre_commit/languages/node.py110
-rw-r--r--pre_commit/languages/perl.py50
-rw-r--r--pre_commit/languages/pygrep.py133
-rw-r--r--pre_commit/languages/python.py214
-rw-r--r--pre_commit/languages/r.py195
-rw-r--r--pre_commit/languages/ruby.py145
-rw-r--r--pre_commit/languages/rust.py160
-rw-r--r--pre_commit/languages/script.py32
-rw-r--r--pre_commit/languages/swift.py50
-rw-r--r--pre_commit/languages/system.py10
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