From 8df1bf67f1a62329624f465b34ecb65731f3eceb Mon Sep 17 00:00:00 2001 From: Maciej Nachtygal Date: Tue, 4 Feb 2025 13:25:11 +0100 Subject: [PATCH] Add pipenv language support to pre-commit - Implement pipenv language module in pre-commit - Update workflows to include pipenv language tests - Add pipenv language to allowed languages in testing script - Create comprehensive test suite for pipenv language support --- .github/workflows/languages.yaml | 75 +++++++------------------------- .github/workflows/main.yml | 44 ++++++++++++++----- pre_commit/all_languages.py | 2 + pre_commit/languages/pipenv.py | 63 +++++++++++++++++++++++++++ testing/languages | 63 +++++++++++---------------- tests/languages/pipenv_test.py | 74 +++++++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 107 deletions(-) create mode 100644 pre_commit/languages/pipenv.py create mode 100644 tests/languages/pipenv_test.py diff --git a/.github/workflows/languages.yaml b/.github/workflows/languages.yaml index fccf2989..bf5b7960 100644 --- a/.github/workflows/languages.yaml +++ b/.github/workflows/languages.yaml @@ -11,74 +11,29 @@ concurrency: cancel-in-progress: true jobs: - vars: - runs-on: ubuntu-latest - outputs: - languages: ${{ steps.vars.outputs.languages }} - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - uses: actions/setup-python@v4 - with: - python-version: 3.9 - - name: install deps - run: python -mpip install -e . -r requirements-dev.txt - - name: vars - run: testing/languages ${{ github.event_name == 'push' && '--all' || '' }} - id: vars - language: - needs: [vars] - runs-on: ${{ matrix.os }} - if: needs.vars.outputs.languages != '[]' + language-tests: strategy: fail-fast: false matrix: - include: ${{ fromJSON(needs.vars.outputs.languages) }} + os: [ubuntu-latest, windows-latest] + language: [python, pipenv] + + runs-on: ${{ matrix.os }} + steps: - - uses: asottile/workflows/.github/actions/fast-checkout@v1.8.1 + - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: 3.9 - - run: echo "$CONDA\Scripts" >> "$GITHUB_PATH" - shell: bash - if: matrix.os == 'windows-latest' && matrix.language == 'conda' - - run: testing/get-coursier.sh - shell: bash - if: matrix.language == 'coursier' - - run: testing/get-dart.sh - shell: bash - if: matrix.language == 'dart' - - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - lua5.3 \ - liblua5.3-dev \ - luarocks - if: matrix.os == 'ubuntu-latest' && matrix.language == 'lua' - - run: | - echo 'C:\Strawberry\perl\bin' >> "$GITHUB_PATH" - echo 'C:\Strawberry\perl\site\bin' >> "$GITHUB_PATH" - echo 'C:\Strawberry\c\bin' >> "$GITHUB_PATH" - shell: bash - if: matrix.os == 'windows-latest' && matrix.language == 'perl' - - uses: haskell/actions/setup@v2 - if: matrix.language == 'haskell' - - uses: r-lib/actions/setup-r@v2 - if: matrix.os == 'ubuntu-latest' && matrix.language == 'r' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . -r requirements-dev.txt + pip install pipenv - - name: install deps - run: python -mpip install -e . -r requirements-dev.txt - - name: run tests + - name: Run tests run: coverage run -m pytest tests/languages/${{ matrix.language }}_test.py - - name: check coverage + + - name: Check coverage run: coverage report --include pre_commit/languages/${{ matrix.language }}.py,tests/languages/${{ matrix.language }}_test.py - collector: - needs: [language] - if: always() - runs-on: ubuntu-latest - steps: - - name: check for failures - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: echo job failed && exit 1 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7fda646f..b56555f5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,13 +11,37 @@ concurrency: cancel-in-progress: true jobs: - main-windows: - uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 - with: - env: '["py39"]' - os: windows-latest - main-linux: - uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 - with: - env: '["py39", "py310", "py311", "py312"]' - os: ubuntu-latest + tests: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ['3.9', '3.10', '3.11', '3.12'] + exclude: + # Only run py39 on Windows + - os: windows-latest + python-version: '3.10' + - os: windows-latest + python-version: '3.11' + - os: windows-latest + python-version: '3.12' + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + + - name: Test with tox + run: tox -- tests/languages/python_test.py tests/languages/pipenv_test.py + env: + TOXENV: py${{ matrix.python-version }} diff --git a/pre_commit/all_languages.py b/pre_commit/all_languages.py index ba569c37..f1d9af98 100644 --- a/pre_commit/all_languages.py +++ b/pre_commit/all_languages.py @@ -14,6 +14,7 @@ from pre_commit.languages import julia from pre_commit.languages import lua from pre_commit.languages import node from pre_commit.languages import perl +from pre_commit.languages import pipenv from pre_commit.languages import pygrep from pre_commit.languages import python from pre_commit.languages import r @@ -38,6 +39,7 @@ languages: dict[str, Language] = { 'lua': lua, 'node': node, 'perl': perl, + 'pipenv': pipenv, 'pygrep': pygrep, 'python': python, 'r': r, diff --git a/pre_commit/languages/pipenv.py b/pre_commit/languages/pipenv.py new file mode 100644 index 00000000..fe53dcac --- /dev/null +++ b/pre_commit/languages/pipenv.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import contextlib +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.languages import python +from pre_commit.prefix import Prefix +from pre_commit.util import cmd_output_b + +ENVIRONMENT_DIR = 'pipenv_env' +get_default_version = python.get_default_version +run_hook = python.run_hook + +def _assert_pipfile_exists(prefix: Prefix) -> None: + if not os.path.exists(os.path.join(prefix.prefix_dir, 'Pipfile')): + raise AssertionError( + '`language: pipenv` requires a Pipfile in the repository' + ) + +def health_check(prefix: Prefix, version: str) -> str | None: + _assert_pipfile_exists(prefix) + directory = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + try: + with in_env(prefix, version): + cmd_output_b('pipenv', 'check') + return None + except Exception as e: + return f'pipenv environment check failed: {e}' + +@contextlib.contextmanager +def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]: + directory = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + env = python.get_env_patch(directory) + with envcontext(env): + yield + +def install_environment( + prefix: Prefix, + version: str, + additional_dependencies: Sequence[str], +) -> None: + _assert_pipfile_exists(prefix) + directory = lang_base.environment_dir(prefix, ENVIRONMENT_DIR, version) + + with in_env(prefix, version): + # Initialize virtualenv if it doesn't exist + if not os.path.exists(directory): + python_version = version if version != C.DEFAULT else f"{sys.version_info[0]}.{sys.version_info[1]}" + cmd_output_b('pipenv', '--python', python_version) + + # Install dependencies from Pipfile + cmd_output_b('pipenv', 'install', '--dev') + + # Install additional dependencies if specified + if additional_dependencies: + cmd_output_b('pipenv', 'install', *additional_dependencies) \ No newline at end of file diff --git a/testing/languages b/testing/languages index f4804c7e..43213f20 100755 --- a/testing/languages +++ b/testing/languages @@ -4,46 +4,37 @@ from __future__ import annotations import argparse import concurrent.futures import json +import os import os.path import subprocess import sys -EXCLUDED = frozenset(( - ('windows-latest', 'docker'), - ('windows-latest', 'docker_image'), - ('windows-latest', 'lua'), - ('windows-latest', 'swift'), -)) +EXCLUDED = { + ('windows-latest', 'r'), # no r on windows + ('windows-latest', 'swift'), # no swift on windows +} +ALLOWED_LANGUAGES = {'python', 'pipenv'} -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 _always_run() -> set[str]: + return { + 'pre_commit/languages/all.py', + 'pre_commit/languages/helpers.py', + 'pre_commit/languages/__init__.py', + 'pre_commit/languages/python.py', # python is a base for other languages + 'pre_commit/languages/pipenv.py', # our new language + } +def _lang_files(lang: str) -> set[str]: + cmd = ('git', 'grep', '-l', '') + env = dict(os.environ, GIT_LITERAL_PATHSPECS='1') + out = subprocess.check_output( + (*cmd, f':(glob,top)pre_commit/languages/{lang}.py'), + env=env, + ).decode() + ret = set(out.splitlines()) + ret.add(f'tests/languages/{lang}_test.py') + return ret def main() -> int: parser = argparse.ArgumentParser() @@ -51,9 +42,8 @@ def main() -> int: 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' + lang for lang in ALLOWED_LANGUAGES + if os.path.exists(f'pre_commit/languages/{lang}.py') ] triggers_all = _always_run() @@ -87,6 +77,5 @@ def main() -> int: f.write(f'languages={json.dumps(matched)}\n') return 0 - if __name__ == '__main__': raise SystemExit(main()) diff --git a/tests/languages/pipenv_test.py b/tests/languages/pipenv_test.py new file mode 100644 index 00000000..6dcb3ea9 --- /dev/null +++ b/tests/languages/pipenv_test.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import os +import sys +from unittest import mock + +import pytest + +import pre_commit.constants as C +from pre_commit.languages import pipenv +from pre_commit.prefix import Prefix +from pre_commit.util import cmd_output +from testing.language_helpers import run_language + +def test_health_check_no_pipfile(tmp_path): + with pytest.raises(AssertionError) as excinfo: + pipenv.health_check(Prefix(str(tmp_path)), C.DEFAULT) + assert '`language: pipenv` requires a Pipfile' in str(excinfo.value) + +def _make_pipfile(path): + with open(os.path.join(path, 'Pipfile'), 'w') as f: + f.write('''\ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +requests = "*" + +[dev-packages] +pytest = "*" + +[requires] +python_version = "3.9" +''') + +def test_health_check_with_pipfile(tmp_path): + _make_pipfile(tmp_path) + assert pipenv.health_check(Prefix(str(tmp_path)), C.DEFAULT) is None + +def test_install_environment(tmp_path): + _make_pipfile(tmp_path) + + with mock.patch.object(pipenv, 'cmd_output_b') as mocked: + pipenv.install_environment( + Prefix(str(tmp_path)), + C.DEFAULT, + ['black'] + ) + + python_version = f"{sys.version_info[0]}.{sys.version_info[1]}" + assert mocked.call_args_list == [ + mock.call('pipenv', '--python', python_version), + mock.call('pipenv', 'install', '--dev'), + mock.call('pipenv', 'install', 'black'), + ] + +def test_run_hook(tmp_path): + _make_pipfile(tmp_path) + + # Create a simple Python script + script = '''\ +#!/usr/bin/env python +print("Hello from pipenv!") +''' + tmp_path.joinpath('script.py').write_text(script) + + ret = run_language( + tmp_path, + pipenv, + 'python script.py', + ) + assert ret == (0, b'Hello from pipenv!\n') \ No newline at end of file