diff options
Diffstat (limited to '')
48 files changed, 972 insertions, 0 deletions
diff --git a/testing/__init__.py b/testing/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/testing/__init__.py diff --git a/testing/auto_namedtuple.py b/testing/auto_namedtuple.py new file mode 100644 index 0000000..d5a4377 --- /dev/null +++ b/testing/auto_namedtuple.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import collections + + +def auto_namedtuple(classname='auto_namedtuple', **kwargs): + """Returns an automatic namedtuple object. + + Args: + classname - The class name for the returned object. + **kwargs - Properties to give the returned object. + """ + return (collections.namedtuple(classname, kwargs.keys())(**kwargs)) diff --git a/testing/fixtures.py b/testing/fixtures.py new file mode 100644 index 0000000..79a1160 --- /dev/null +++ b/testing/fixtures.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import contextlib +import os.path +import shutil + +from cfgv import apply_defaults +from cfgv import validate + +import pre_commit.constants as C +from pre_commit import git +from pre_commit.clientlib import CONFIG_SCHEMA +from pre_commit.clientlib import load_manifest +from pre_commit.util import cmd_output +from pre_commit.yaml import yaml_dump +from pre_commit.yaml import yaml_load +from testing.util import get_resource_path +from testing.util import git_commit + + +def copy_tree_to_path(src_dir, dest_dir): + """Copies all of the things inside src_dir to an already existing dest_dir. + + This looks eerily similar to shutil.copytree, but copytree has no option + for not creating dest_dir. + """ + names = os.listdir(src_dir) + + for name in names: + srcname = os.path.join(src_dir, name) + destname = os.path.join(dest_dir, name) + + if os.path.isdir(srcname): + shutil.copytree(srcname, destname) + else: + shutil.copy(srcname, destname) + + +def git_dir(tempdir_factory): + path = tempdir_factory.get() + cmd_output('git', '-c', 'init.defaultBranch=master', 'init', path) + return path + + +def make_repo(tempdir_factory, repo_source): + path = git_dir(tempdir_factory) + copy_tree_to_path(get_resource_path(repo_source), path) + cmd_output('git', 'add', '.', cwd=path) + git_commit(msg=make_repo.__name__, cwd=path) + return path + + +@contextlib.contextmanager +def modify_manifest(path, commit=True): + """Modify the manifest yielded by this context to write to + .pre-commit-hooks.yaml. + """ + manifest_path = os.path.join(path, C.MANIFEST_FILE) + with open(manifest_path) as f: + manifest = yaml_load(f.read()) + yield manifest + with open(manifest_path, 'w') as manifest_file: + manifest_file.write(yaml_dump(manifest)) + if commit: + git_commit(msg=modify_manifest.__name__, cwd=path) + + +@contextlib.contextmanager +def modify_config(path='.', commit=True): + """Modify the config yielded by this context to write to + .pre-commit-config.yaml + """ + config_path = os.path.join(path, C.CONFIG_FILE) + with open(config_path) as f: + config = yaml_load(f.read()) + yield config + with open(config_path, 'w', encoding='UTF-8') as config_file: + config_file.write(yaml_dump(config)) + if commit: + git_commit(msg=modify_config.__name__, cwd=path) + + +def sample_local_config(): + return { + 'repo': 'local', + 'hooks': [{ + 'id': 'do_not_commit', + 'name': 'Block if "DO NOT COMMIT" is found', + 'entry': 'DO NOT COMMIT', + 'language': 'pygrep', + }], + } + + +def sample_meta_config(): + return {'repo': 'meta', 'hooks': [{'id': 'check-useless-excludes'}]} + + +def make_config_from_repo(repo_path, rev=None, hooks=None, check=True): + manifest = load_manifest(os.path.join(repo_path, C.MANIFEST_FILE)) + config = { + 'repo': f'file://{repo_path}', + 'rev': rev or git.head_rev(repo_path), + 'hooks': hooks or [{'id': hook['id']} for hook in manifest], + } + + if check: + wrapped = validate({'repos': [config]}, CONFIG_SCHEMA) + wrapped = apply_defaults(wrapped, CONFIG_SCHEMA) + config, = wrapped['repos'] + return config + else: + return config + + +def read_config(directory, config_file=C.CONFIG_FILE): + config_path = os.path.join(directory, config_file) + with open(config_path) as f: + config = yaml_load(f.read()) + return config + + +def write_config(directory, config, config_file=C.CONFIG_FILE): + if type(config) is not list and 'repos' not in config: + assert isinstance(config, dict), config + config = {'repos': [config]} + with open(os.path.join(directory, config_file), 'w') as outfile: + outfile.write(yaml_dump(config)) + + +def add_config_to_repo(git_path, config, config_file=C.CONFIG_FILE): + write_config(git_path, config, config_file=config_file) + cmd_output('git', 'add', config_file, cwd=git_path) + git_commit(msg=add_config_to_repo.__name__, cwd=git_path) + return git_path + + +def remove_config_from_repo(git_path, config_file=C.CONFIG_FILE): + cmd_output('git', 'rm', config_file, cwd=git_path) + git_commit(msg=remove_config_from_repo.__name__, cwd=git_path) + return git_path + + +def make_consuming_repo(tempdir_factory, repo_source): + path = make_repo(tempdir_factory, repo_source) + config = make_config_from_repo(path) + git_path = git_dir(tempdir_factory) + return add_config_to_repo(git_path, config) diff --git a/testing/get-coursier.sh b/testing/get-coursier.sh new file mode 100755 index 0000000..958e73b --- /dev/null +++ b/testing/get-coursier.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$OSTYPE" = msys ]; then + URL='https://github.com/coursier/coursier/releases/download/v2.1.0-RC4/cs-x86_64-pc-win32.zip' + SHA256='0d07386ff0f337e3e6264f7dde29d137dda6eaa2385f29741435e0b93ccdb49d' + TARGET='/tmp/coursier/cs.zip' + + unpack() { + unzip "$TARGET" -d /tmp/coursier + mv /tmp/coursier/cs-*.exe /tmp/coursier/cs.exe + cygpath -w /tmp/coursier >> "$GITHUB_PATH" + } +else + URL='https://github.com/coursier/coursier/releases/download/v2.1.0-RC4/cs-x86_64-pc-linux.gz' + SHA256='176e92e08ab292531aa0c4993dbc9f2c99dec79578752f3b9285f54f306db572' + TARGET=/tmp/coursier/cs.gz + + unpack() { + gunzip "$TARGET" + chmod +x /tmp/coursier/cs + echo /tmp/coursier >> "$GITHUB_PATH" + } +fi + +mkdir -p /tmp/coursier +curl --location --silent --output "$TARGET" "$URL" +echo "$SHA256 $TARGET" | sha256sum --check +unpack diff --git a/testing/get-dart.sh b/testing/get-dart.sh new file mode 100755 index 0000000..998b9d9 --- /dev/null +++ b/testing/get-dart.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION=2.13.4 + +if [ "$OSTYPE" = msys ]; then + URL="https://storage.googleapis.com/dart-archive/channels/stable/release/${VERSION}/sdk/dartsdk-windows-x64-release.zip" + cygpath -w /tmp/dart-sdk/bin >> "$GITHUB_PATH" +else + URL="https://storage.googleapis.com/dart-archive/channels/stable/release/${VERSION}/sdk/dartsdk-linux-x64-release.zip" + echo '/tmp/dart-sdk/bin' >> "$GITHUB_PATH" +fi + +curl --silent --location --output /tmp/dart.zip "$URL" + +unzip -q -d /tmp /tmp/dart.zip +rm /tmp/dart.zip diff --git a/testing/language_helpers.py b/testing/language_helpers.py new file mode 100644 index 0000000..05c94eb --- /dev/null +++ b/testing/language_helpers.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import os +from collections.abc import Sequence + +from pre_commit.lang_base import Language +from pre_commit.prefix import Prefix + + +def run_language( + path: os.PathLike[str], + language: Language, + exe: str, + args: Sequence[str] = (), + file_args: Sequence[str] = (), + version: str | None = None, + deps: Sequence[str] = (), + is_local: bool = False, + require_serial: bool = True, + color: bool = False, +) -> tuple[int, bytes]: + prefix = Prefix(str(path)) + version = version or language.get_default_version() + + if language.ENVIRONMENT_DIR is not None: + language.install_environment(prefix, version, deps) + health_error = language.health_check(prefix, version) + assert health_error is None, health_error + with language.in_env(prefix, version): + ret, out = language.run_hook( + prefix, + exe, + args, + file_args, + is_local=is_local, + require_serial=require_serial, + color=color, + ) + out = out.replace(b'\r\n', b'\n') + return ret, out diff --git a/testing/languages b/testing/languages new file mode 100755 index 0000000..f4804c7 --- /dev/null +++ b/testing/languages @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import concurrent.futures +import json +import os.path +import subprocess +import sys + +EXCLUDED = frozenset(( + ('windows-latest', 'docker'), + ('windows-latest', 'docker_image'), + ('windows-latest', 'lua'), + ('windows-latest', 'swift'), +)) + + +def _always_run() -> frozenset[str]: + ret = ['.github/workflows/languages.yaml', 'testing/languages'] + ret.extend( + os.path.join('pre_commit/resources', fname) + for fname in os.listdir('pre_commit/resources') + ) + return frozenset(ret) + + +def _lang_files(lang: str) -> frozenset[str]: + prog = f'''\ +import json +import os.path +import sys + +import pre_commit.languages.{lang} +import tests.languages.{lang}_test + +modules = sorted( + os.path.relpath(v.__file__) + for k, v in sys.modules.items() + if k.startswith(('pre_commit.', 'tests.', 'testing.')) +) +print(json.dumps(modules)) +''' + out = json.loads(subprocess.check_output((sys.executable, '-c', prog))) + return frozenset(out) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument('--all', action='store_true') + args = parser.parse_args() + + langs = [ + os.path.splitext(fname)[0] + for fname in sorted(os.listdir('pre_commit/languages')) + if fname.endswith('.py') and fname != '__init__.py' + ] + + triggers_all = _always_run() + for fname in triggers_all: + assert os.path.exists(fname), fname + + if not args.all: + with concurrent.futures.ThreadPoolExecutor(os.cpu_count()) as exe: + by_lang = { + lang: files | triggers_all + for lang, files in zip(langs, exe.map(_lang_files, langs)) + } + + diff_cmd = ('git', 'diff', '--name-only', 'origin/main...HEAD') + files = set(subprocess.check_output(diff_cmd).decode().splitlines()) + + langs = [ + lang + for lang, lang_files in by_lang.items() + if lang_files & files + ] + + matched = [ + {'os': os, 'language': lang} + for os in ('windows-latest', 'ubuntu-latest') + for lang in langs + if (os, lang) not in EXCLUDED + ] + + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f'languages={json.dumps(matched)}\n') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/testing/make-archives b/testing/make-archives new file mode 100755 index 0000000..3c7ab9d --- /dev/null +++ b/testing/make-archives @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import gzip +import os.path +import shutil +import subprocess +import tarfile +import tempfile +from collections.abc import Sequence + + +# This is a script for generating the tarred resources for git repo +# dependencies. Currently it's just for "vendoring" ruby support packages. + + +REPOS = ( + ('rbenv', 'https://github.com/rbenv/rbenv', '38e1fbb'), + ('ruby-build', 'https://github.com/rbenv/ruby-build', '855b963'), + ( + 'ruby-download', + 'https://github.com/garnieretienne/rvm-download', + '09bd7c6', + ), +) + + +def reset(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo: + tarinfo.uid = tarinfo.gid = 0 + tarinfo.uname = tarinfo.gname = 'root' + tarinfo.mtime = 0 + return tarinfo + + +def make_archive(name: str, repo: str, ref: str, destdir: str) -> str: + output_path = os.path.join(destdir, f'{name}.tar.gz') + with tempfile.TemporaryDirectory() as tmpdir: + # this ensures that the root directory has umask permissions + gitdir = os.path.join(tmpdir, 'root') + + # Clone the repository to the temporary directory + subprocess.check_call(('git', 'clone', repo, gitdir)) + subprocess.check_call(('git', '-C', gitdir, 'checkout', ref)) + + # We don't want the '.git' directory + # It adds a bunch of size to the archive and we don't use it at + # runtime + shutil.rmtree(os.path.join(gitdir, '.git')) + + arcs = [(name, gitdir)] + for root, dirs, filenames in os.walk(gitdir): + for filename in dirs + filenames: + abspath = os.path.abspath(os.path.join(root, filename)) + relpath = os.path.relpath(abspath, gitdir) + arcs.append((os.path.join(name, relpath), abspath)) + arcs.sort() + + with gzip.GzipFile(output_path, 'wb', mtime=0) as gzipf: + # https://github.com/python/typeshed/issues/5491 + with tarfile.open(fileobj=gzipf, mode='w') as tf: # type: ignore + for arcname, abspath in arcs: + tf.add( + abspath, + arcname=arcname, + recursive=False, + filter=reset, + ) + + return output_path + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument('--dest', default='pre_commit/resources') + args = parser.parse_args(argv) + for archive_name, repo, ref in REPOS: + print(f'Making {archive_name}.tar.gz for {repo}@{ref}') + make_archive(archive_name, repo, ref, args.dest) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/testing/resources/arbitrary_bytes_repo/.pre-commit-hooks.yaml b/testing/resources/arbitrary_bytes_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..c2aec9b --- /dev/null +++ b/testing/resources/arbitrary_bytes_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: hook + name: hook + entry: ./hook.sh + language: script + files: \.py$ diff --git a/testing/resources/arbitrary_bytes_repo/hook.sh b/testing/resources/arbitrary_bytes_repo/hook.sh new file mode 100755 index 0000000..9df0c5a --- /dev/null +++ b/testing/resources/arbitrary_bytes_repo/hook.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Intentionally write mixed encoding to the output. This should not crash +# pre-commit and should write bytes to the output. +# '☃'.encode() + '²'.encode('latin1') +echo -e '\xe2\x98\x83\xb2' +# exit 1 to trigger printing +exit 1 diff --git a/testing/resources/arg_per_line_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/arg_per_line_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..4c101db --- /dev/null +++ b/testing/resources/arg_per_line_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: arg-per-line + name: Args per line hook + entry: bin/hook.sh + language: script + files: '' + args: [hello, world] diff --git a/testing/resources/arg_per_line_hooks_repo/bin/hook.sh b/testing/resources/arg_per_line_hooks_repo/bin/hook.sh new file mode 100755 index 0000000..47fd21d --- /dev/null +++ b/testing/resources/arg_per_line_hooks_repo/bin/hook.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +for i in "$@"; do + echo "arg: $i" +done diff --git a/testing/resources/exclude_types_repo/.pre-commit-hooks.yaml b/testing/resources/exclude_types_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..ed8794f --- /dev/null +++ b/testing/resources/exclude_types_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: python-files + name: Python files + entry: bin/hook.sh + language: script + types: [python] + exclude_types: [python3] diff --git a/testing/resources/exclude_types_repo/bin/hook.sh b/testing/resources/exclude_types_repo/bin/hook.sh new file mode 100755 index 0000000..a828db4 --- /dev/null +++ b/testing/resources/exclude_types_repo/bin/hook.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +echo "$@" +exit 1 diff --git a/testing/resources/failing_hook_repo/.pre-commit-hooks.yaml b/testing/resources/failing_hook_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..118cc8b --- /dev/null +++ b/testing/resources/failing_hook_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: failing_hook + name: Failing hook + entry: bin/hook.sh + language: script + files: . diff --git a/testing/resources/failing_hook_repo/bin/hook.sh b/testing/resources/failing_hook_repo/bin/hook.sh new file mode 100755 index 0000000..7dcffeb --- /dev/null +++ b/testing/resources/failing_hook_repo/bin/hook.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +echo 'Fail' +echo "$@" +exit 1 diff --git a/testing/resources/img1.jpg b/testing/resources/img1.jpg Binary files differnew file mode 100644 index 0000000..dea4262 --- /dev/null +++ b/testing/resources/img1.jpg diff --git a/testing/resources/img2.jpg b/testing/resources/img2.jpg Binary files differnew file mode 100644 index 0000000..68568e5 --- /dev/null +++ b/testing/resources/img2.jpg diff --git a/testing/resources/img3.jpg b/testing/resources/img3.jpg Binary files differnew file mode 100644 index 0000000..392d2cf --- /dev/null +++ b/testing/resources/img3.jpg diff --git a/testing/resources/logfile_repo/.pre-commit-hooks.yaml b/testing/resources/logfile_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..dcaba2e --- /dev/null +++ b/testing/resources/logfile_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: logfile test hook + name: Logfile test hook + entry: bin/hook.sh + language: script + files: . + log_file: test.log diff --git a/testing/resources/logfile_repo/bin/hook.sh b/testing/resources/logfile_repo/bin/hook.sh new file mode 100755 index 0000000..890d941 --- /dev/null +++ b/testing/resources/logfile_repo/bin/hook.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +echo "This is STDOUT output" +echo "This is STDERR output" 1>&2 + +exit 1 diff --git a/testing/resources/modified_file_returns_zero_repo/.pre-commit-hooks.yaml b/testing/resources/modified_file_returns_zero_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..8d79ef3 --- /dev/null +++ b/testing/resources/modified_file_returns_zero_repo/.pre-commit-hooks.yaml @@ -0,0 +1,15 @@ +- id: bash_hook + name: Bash hook + entry: bin/hook.sh + language: script + files: 'foo.py' +- id: bash_hook2 + name: Bash hook + entry: bin/hook2.sh + language: script + files: '' +- id: bash_hook3 + name: Bash hook + entry: bin/hook3.sh + language: script + files: 'bar.py' diff --git a/testing/resources/modified_file_returns_zero_repo/bin/hook.sh b/testing/resources/modified_file_returns_zero_repo/bin/hook.sh new file mode 100755 index 0000000..98b05f9 --- /dev/null +++ b/testing/resources/modified_file_returns_zero_repo/bin/hook.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +for f in $@; do + # Non UTF-8 bytes + echo -e '\x01\x97' > "$f" + echo "Modified: $f!" +done diff --git a/testing/resources/modified_file_returns_zero_repo/bin/hook2.sh b/testing/resources/modified_file_returns_zero_repo/bin/hook2.sh new file mode 100755 index 0000000..a9f1dcd --- /dev/null +++ b/testing/resources/modified_file_returns_zero_repo/bin/hook2.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "$@" diff --git a/testing/resources/modified_file_returns_zero_repo/bin/hook3.sh b/testing/resources/modified_file_returns_zero_repo/bin/hook3.sh new file mode 100755 index 0000000..3180eb3 --- /dev/null +++ b/testing/resources/modified_file_returns_zero_repo/bin/hook3.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +for f in $@; do + # Non UTF-8 bytes + echo -e '\x01\x97' > "$f" +done diff --git a/testing/resources/not_found_exe/.pre-commit-hooks.yaml b/testing/resources/not_found_exe/.pre-commit-hooks.yaml new file mode 100644 index 0000000..566f3c1 --- /dev/null +++ b/testing/resources/not_found_exe/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: not-found-exe + name: Not found exe + entry: i-dont-exist-lol + language: system + files: '' diff --git a/testing/resources/prints_cwd_repo/.pre-commit-hooks.yaml b/testing/resources/prints_cwd_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..7092379 --- /dev/null +++ b/testing/resources/prints_cwd_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: prints_cwd + name: Prints Cwd + entry: pwd + language: system + files: \.sh$ diff --git a/testing/resources/python3_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/python3_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..2c23700 --- /dev/null +++ b/testing/resources/python3_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: python3-hook + name: Python 3 Hook + entry: python3-hook + language: python + language_version: python3 + files: \.py$ diff --git a/testing/resources/python3_hooks_repo/py3_hook.py b/testing/resources/python3_hooks_repo/py3_hook.py new file mode 100644 index 0000000..8c9cda4 --- /dev/null +++ b/testing/resources/python3_hooks_repo/py3_hook.py @@ -0,0 +1,8 @@ +import sys + + +def main(): + print(sys.version_info[0]) + print(repr(sys.argv[1:])) + print('Hello World') + return 0 diff --git a/testing/resources/python3_hooks_repo/setup.py b/testing/resources/python3_hooks_repo/setup.py new file mode 100644 index 0000000..9125dc1 --- /dev/null +++ b/testing/resources/python3_hooks_repo/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup + +setup( + name='python3_hook', + version='0.0.0', + py_modules=['py3_hook'], + entry_points={'console_scripts': ['python3-hook = py3_hook:main']}, +) diff --git a/testing/resources/python_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/python_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..e10ad50 --- /dev/null +++ b/testing/resources/python_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: foo + name: Foo + entry: foo + language: python + files: \.py$ diff --git a/testing/resources/python_hooks_repo/foo.py b/testing/resources/python_hooks_repo/foo.py new file mode 100644 index 0000000..40efde3 --- /dev/null +++ b/testing/resources/python_hooks_repo/foo.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import sys + + +def main(): + print(repr(sys.argv[1:])) + print('Hello World') + return 0 diff --git a/testing/resources/python_hooks_repo/setup.py b/testing/resources/python_hooks_repo/setup.py new file mode 100644 index 0000000..cff6cad --- /dev/null +++ b/testing/resources/python_hooks_repo/setup.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from setuptools import setup + +setup( + name='foo', + version='0.0.0', + py_modules=['foo'], + entry_points={'console_scripts': ['foo = foo:main']}, +) diff --git a/testing/resources/script_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/script_hooks_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..21cad4a --- /dev/null +++ b/testing/resources/script_hooks_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: bash_hook + name: Bash hook + entry: bin/hook.sh + language: script + files: '' diff --git a/testing/resources/script_hooks_repo/bin/hook.sh b/testing/resources/script_hooks_repo/bin/hook.sh new file mode 100755 index 0000000..cbc4b35 --- /dev/null +++ b/testing/resources/script_hooks_repo/bin/hook.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +echo "$@" +echo 'Hello World' diff --git a/testing/resources/stdout_stderr_repo/.pre-commit-hooks.yaml b/testing/resources/stdout_stderr_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..6800d25 --- /dev/null +++ b/testing/resources/stdout_stderr_repo/.pre-commit-hooks.yaml @@ -0,0 +1,8 @@ +- id: stdout-stderr + name: stdout-stderr + language: script + entry: ./stdout-stderr-entry +- id: tty-check + name: tty-check + language: script + entry: ./tty-check-entry diff --git a/testing/resources/stdout_stderr_repo/stdout-stderr-entry b/testing/resources/stdout_stderr_repo/stdout-stderr-entry new file mode 100755 index 0000000..7563df5 --- /dev/null +++ b/testing/resources/stdout_stderr_repo/stdout-stderr-entry @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +echo 0 +echo 1 1>&2 +echo 2 +echo 3 1>&2 +echo 4 +echo 5 1>&2 diff --git a/testing/resources/stdout_stderr_repo/tty-check-entry b/testing/resources/stdout_stderr_repo/tty-check-entry new file mode 100755 index 0000000..01a9d38 --- /dev/null +++ b/testing/resources/stdout_stderr_repo/tty-check-entry @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +t() { + if [ -t "$1" ]; then + echo "$2: True" + else + echo "$2: False" + fi +} +t 0 stdin +t 1 stdout +t 2 stderr diff --git a/testing/resources/system_hook_with_spaces_repo/.pre-commit-hooks.yaml b/testing/resources/system_hook_with_spaces_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..b2c347c --- /dev/null +++ b/testing/resources/system_hook_with_spaces_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: system-hook-with-spaces + name: System hook with spaces + entry: bash -c 'echo "Hello World"' + language: system + files: \.sh$ diff --git a/testing/resources/types_or_repo/.pre-commit-hooks.yaml b/testing/resources/types_or_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..a4ea920 --- /dev/null +++ b/testing/resources/types_or_repo/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: python-cython-files + name: Python and Cython files + entry: bin/hook.sh + language: script + types: [file] + types_or: [python, cython] diff --git a/testing/resources/types_or_repo/bin/hook.sh b/testing/resources/types_or_repo/bin/hook.sh new file mode 100755 index 0000000..a828db4 --- /dev/null +++ b/testing/resources/types_or_repo/bin/hook.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +echo "$@" +exit 1 diff --git a/testing/resources/types_repo/.pre-commit-hooks.yaml b/testing/resources/types_repo/.pre-commit-hooks.yaml new file mode 100644 index 0000000..2e5e4a6 --- /dev/null +++ b/testing/resources/types_repo/.pre-commit-hooks.yaml @@ -0,0 +1,5 @@ +- id: python-files + name: Python files + entry: bin/hook.sh + language: script + types: [python] diff --git a/testing/resources/types_repo/bin/hook.sh b/testing/resources/types_repo/bin/hook.sh new file mode 100755 index 0000000..a828db4 --- /dev/null +++ b/testing/resources/types_repo/bin/hook.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +echo "$@" +exit 1 diff --git a/testing/util.py b/testing/util.py new file mode 100644 index 0000000..08d52cb --- /dev/null +++ b/testing/util.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import contextlib +import os.path +import subprocess +import sys + +import pytest + +from pre_commit.util import cmd_output +from testing.auto_namedtuple import auto_namedtuple + + +TESTING_DIR = os.path.abspath(os.path.dirname(__file__)) + + +def get_resource_path(path): + return os.path.join(TESTING_DIR, 'resources', path) + + +def cmd_output_mocked_pre_commit_home( + *args, tempdir_factory, pre_commit_home=None, env=None, **kwargs, +): + if pre_commit_home is None: + pre_commit_home = tempdir_factory.get() + env = env if env is not None else os.environ + kwargs.setdefault('stderr', subprocess.STDOUT) + # Don't want to write to the home directory + env = dict(env, PRE_COMMIT_HOME=pre_commit_home) + ret, out, _ = cmd_output(*args, env=env, **kwargs) + return ret, out.replace('\r\n', '\n'), None + + +xfailif_windows = pytest.mark.xfail(sys.platform == 'win32', reason='windows') + + +def run_opts( + all_files=False, + files=(), + color=False, + verbose=False, + hook=None, + remote_branch='', + local_branch='', + from_ref='', + to_ref='', + pre_rebase_upstream='', + pre_rebase_branch='', + remote_name='', + remote_url='', + hook_stage='pre-commit', + show_diff_on_failure=False, + commit_msg_filename='', + prepare_commit_message_source='', + commit_object_name='', + checkout_type='', + is_squash_merge='', + rewrite_command='', +): + # These are mutually exclusive + assert not (all_files and files) + return auto_namedtuple( + all_files=all_files, + files=files, + color=color, + verbose=verbose, + hook=hook, + remote_branch=remote_branch, + local_branch=local_branch, + from_ref=from_ref, + to_ref=to_ref, + pre_rebase_upstream=pre_rebase_upstream, + pre_rebase_branch=pre_rebase_branch, + remote_name=remote_name, + remote_url=remote_url, + hook_stage=hook_stage, + show_diff_on_failure=show_diff_on_failure, + commit_msg_filename=commit_msg_filename, + prepare_commit_message_source=prepare_commit_message_source, + commit_object_name=commit_object_name, + checkout_type=checkout_type, + is_squash_merge=is_squash_merge, + rewrite_command=rewrite_command, + ) + + +@contextlib.contextmanager +def cwd(path): + original_cwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(original_cwd) + + +def git_commit(*args, fn=cmd_output, msg='commit!', all_files=True, **kwargs): + kwargs.setdefault('stderr', subprocess.STDOUT) + + cmd = ('git', 'commit', '--allow-empty', '--no-gpg-sign', *args) + if all_files: # allow skipping `-a` with `all_files=False` + cmd += ('-a',) + if msg is not None: # allow skipping `-m` with `msg=None` + cmd += ('-m', msg) + ret, out, _ = fn(*cmd, **kwargs) + return ret, out.replace('\r\n', '\n') diff --git a/testing/zipapp/Dockerfile b/testing/zipapp/Dockerfile new file mode 100644 index 0000000..ea967e3 --- /dev/null +++ b/testing/zipapp/Dockerfile @@ -0,0 +1,14 @@ +FROM ubuntu:jammy +RUN : \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + python3 \ + python3-distutils \ + python3-venv \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +ENV LANG=C.UTF-8 PATH=/venv/bin:$PATH +RUN : \ + && python3 -mvenv /venv \ + && pip install --no-cache-dir pip distlib no-manylinux --upgrade diff --git a/testing/zipapp/entry b/testing/zipapp/entry new file mode 100755 index 0000000..15758d9 --- /dev/null +++ b/testing/zipapp/entry @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os.path +import shutil +import stat +import sys +import tempfile +import zipfile + +from pre_commit.file_lock import lock + +CACHE_DIR = os.path.expanduser('~/.cache/pre-commit-zipapp') + + +def _make_executable(filename: str) -> None: + os.chmod(filename, os.stat(filename).st_mode | stat.S_IXUSR) + + +def _ensure_cache(zipf: zipfile.ZipFile, cache_key: str) -> str: + os.makedirs(CACHE_DIR, exist_ok=True) + + cache_dest = os.path.join(CACHE_DIR, cache_key) + lock_filename = os.path.join(CACHE_DIR, f'{cache_key}.lock') + + if os.path.exists(cache_dest): + return cache_dest + + with lock(lock_filename, blocked_cb=lambda: None): + # another process may have completed this work + if os.path.exists(cache_dest): + return cache_dest + + tmpdir = tempfile.mkdtemp(prefix=os.path.join(CACHE_DIR, '')) + try: + zipf.extractall(tmpdir) + # zip doesn't maintain permissions + _make_executable(os.path.join(tmpdir, 'python')) + _make_executable(os.path.join(tmpdir, 'python.exe')) + os.rename(tmpdir, cache_dest) + except BaseException: + shutil.rmtree(tmpdir) + raise + + return cache_dest + + +def main() -> int: + with zipfile.ZipFile(os.path.dirname(__file__)) as zipf: + with zipf.open('CACHE_KEY') as f: + cache_key = f.read().decode().strip() + + cache_dest = _ensure_cache(zipf, cache_key) + + if sys.platform != 'win32': + exe = os.path.join(cache_dest, 'python') + else: + exe = os.path.join(cache_dest, 'python.exe') + + cmd = (exe, '-mpre_commit', *sys.argv[1:]) + if sys.platform == 'win32': # https://bugs.python.org/issue19124 + import subprocess + + return subprocess.call(cmd) + else: + os.execvp(cmd[0], cmd) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/testing/zipapp/make b/testing/zipapp/make new file mode 100755 index 0000000..165046f --- /dev/null +++ b/testing/zipapp/make @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import base64 +import hashlib +import io +import os.path +import shutil +import subprocess +import tempfile +import zipapp +import zipfile + +HERE = os.path.dirname(os.path.realpath(__file__)) +IMG = 'make-pre-commit-zipapp' + + +def _msg(s: str) -> None: + print(f'\033[7m{s}\033[m') + + +def _exit_if_retv(*cmd: str) -> None: + if subprocess.call(cmd): + raise SystemExit(1) + + +def _check_no_shared_objects(wheeldir: str) -> None: + for zip_filename in os.listdir(wheeldir): + with zipfile.ZipFile(os.path.join(wheeldir, zip_filename)) as zipf: + for filename in zipf.namelist(): + if filename.endswith('.so') or '.so.' in filename: + raise AssertionError(zip_filename, filename) + + +def _add_shim(dest: str) -> None: + shim = os.path.join(HERE, 'python') + shutil.copy(shim, dest) + + bio = io.BytesIO() + with zipfile.ZipFile(bio, 'w') as zipf: + zipf.write(shim, arcname='__main__.py') + + with tempfile.TemporaryDirectory() as tmpdir: + _exit_if_retv( + 'podman', 'run', '--rm', '--volume', f'{tmpdir}:/out:rw', IMG, + 'cp', '/venv/lib/python3.10/site-packages/distlib/t32.exe', '/out', + ) + + with open(os.path.join(dest, 'python.exe'), 'wb') as f: + with open(os.path.join(tmpdir, 't32.exe'), 'rb') as t32: + f.write(t32.read()) + f.write(b'#!py.exe -3\n') + f.write(bio.getvalue()) + + +def _write_cache_key(version: str, wheeldir: str, dest: str) -> None: + cache_hash = hashlib.sha256(f'{version}\n'.encode()) + for filename in sorted(os.listdir(wheeldir)): + cache_hash.update(f'{filename}\n'.encode()) + with open(os.path.join(HERE, 'python'), 'rb') as f: + cache_hash.update(f.read()) + with open(os.path.join(dest, 'CACHE_KEY'), 'wb') as f: + f.write(base64.urlsafe_b64encode(cache_hash.digest()).rstrip(b'=')) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument('version') + args = parser.parse_args() + + with tempfile.TemporaryDirectory() as tmpdir: + wheeldir = os.path.join(tmpdir, 'wheels') + os.mkdir(wheeldir) + + _msg('building podman image...') + _exit_if_retv('podman', 'build', '-q', '-t', IMG, HERE) + + _msg('populating wheels...') + _exit_if_retv( + 'podman', 'run', '--rm', '--volume', f'{wheeldir}:/wheels:rw', IMG, + 'pip', 'wheel', f'pre_commit=={args.version}', 'setuptools', + '--wheel-dir', '/wheels', + ) + + _msg('validating wheels...') + _check_no_shared_objects(wheeldir) + + _msg('adding __main__.py...') + mainfile = os.path.join(tmpdir, '__main__.py') + shutil.copy(os.path.join(HERE, 'entry'), mainfile) + + _msg('adding shim...') + _add_shim(tmpdir) + + _msg('copying file_lock.py...') + file_lock_py = os.path.join(HERE, '../../pre_commit/file_lock.py') + file_lock_py_dest = os.path.join(tmpdir, 'pre_commit/file_lock.py') + os.makedirs(os.path.dirname(file_lock_py_dest)) + shutil.copy(file_lock_py, file_lock_py_dest) + + _msg('writing CACHE_KEY...') + _write_cache_key(args.version, wheeldir, tmpdir) + + filename = f'pre-commit-{args.version}.pyz' + _msg(f'writing {filename}...') + shebang = '/usr/bin/env python3' + zipapp.create_archive(tmpdir, filename, interpreter=shebang) + + with open(f'{filename}.sha256sum', 'w') as f: + subprocess.check_call(('sha256sum', filename), stdout=f) + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/testing/zipapp/python b/testing/zipapp/python new file mode 100755 index 0000000..67910fc --- /dev/null +++ b/testing/zipapp/python @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""A shim executable to put dependencies on sys.path""" +from __future__ import annotations + +import argparse +import os.path +import runpy +import sys + +# an exe-zipapp will have a __file__ of shim.exe/__main__.py +EXE = __file__ if os.path.isfile(__file__) else os.path.dirname(__file__) +EXE = os.path.realpath(EXE) +HERE = os.path.dirname(EXE) +WHEELDIR = os.path.join(HERE, 'wheels') +SITE_DIRS = frozenset(('dist-packages', 'site-packages')) + + +def main() -> int: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument('-m') + args, rest = parser.parse_known_args() + + if args.m: + # try and remove site-packages from sys.path so our packages win + sys.path[:] = [ + p for p in sys.path + if os.path.split(p)[1] not in SITE_DIRS + ] + for wheel in sorted(os.listdir(WHEELDIR)): + sys.path.append(os.path.join(WHEELDIR, wheel)) + if args.m == 'pre_commit' or args.m.startswith('pre_commit.'): + sys.executable = EXE + sys.argv[1:] = rest + runpy.run_module(args.m, run_name='__main__', alter_sys=True) + return 0 + else: + cmd = (sys.executable, *sys.argv[1:]) + if sys.platform == 'win32': # https://bugs.python.org/issue19124 + import subprocess + + return subprocess.call(cmd) + else: + os.execvp(cmd[0], cmd) + + +if __name__ == '__main__': + raise SystemExit(main()) |