diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1215eb6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,55 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: debug-statements + - id: double-quote-string-fixer + - id: name-tests-test + - id: requirements-txt-fixer +- repo: https://github.com/asottile/reorder_python_imports + rev: v3.9.0 + hooks: + - id: reorder-python-imports + args: [--py37-plus, --add-import, 'from __future__ import annotations'] +- repo: https://github.com/asottile/add-trailing-comma + rev: v2.4.0 + hooks: + - id: add-trailing-comma + args: [--py36-plus] +- repo: https://github.com/asottile/pyupgrade + rev: v3.3.1 + hooks: + - id: pyupgrade + args: [--py37-plus] +- repo: https://github.com/pre-commit/mirrors-autopep8 + rev: v2.0.1 + hooks: + - id: autopep8 +- repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.991 + hooks: + - id: mypy + additional_dependencies: [types-all] +- repo: local + hooks: + - id: generate-readme + name: generate readme + entry: ./generate-readme + language: python + additional_dependencies: [pyyaml] + files: ^(\.pre-commit-hooks.yaml|generate-readme)$ + pass_filenames: false + - id: run-tests + name: run tests + entry: pytest tests + language: python + additional_dependencies: [pre-commit, pytest] + always_run: true + pass_filenames: false diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..dc9dc20 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,79 @@ +- id: python-check-blanket-noqa + name: check blanket noqa + description: 'Enforce that `noqa` annotations always occur with specific codes. Sample annotations: `# noqa: F401`, `# noqa: F401,W203`' + entry: '(?i)# noqa(?!: )' + language: pygrep + types: [python] +- id: python-check-blanket-type-ignore + name: check blanket type ignore + description: 'Enforce that `# type: ignore` annotations always occur with specific codes. Sample annotations: `# type: ignore[attr-defined]`, `# type: ignore[attr-defined, name-defined]`' + entry: '# type:? *ignore(?!\[|\w)' + language: pygrep + types: [python] +- id: python-check-mock-methods + name: check for not-real mock methods + description: >- + Prevent common mistakes of `assert mck.not_called()`, `assert mck.called_once_with(...)` + and `mck.assert_called`. + language: pygrep + entry: > + (?x)( + assert .*\.( + not_called| + called_ + )| + # ''.join(rf'(?<!\b{s})' for s in dir(mock) if s.endswith('Mock'))) + (?<!\bAsyncMock)(?<!\bMagicMock)(?<!\bMock)(?<!\bNonCallableMagicMock)(?<!\bNonCallableMock)(?<!\bPropertyMock) + \.assert_( + any_call| + called| + called_once| + called_once_with| + called_with| + has_calls| + not_called + )($|[^(\w]) + ) + types: [python] +- id: python-no-eval + name: check for eval() + description: 'A quick check for the `eval()` built-in function' + entry: '\beval\(' + language: pygrep + types: [python] +- id: python-no-log-warn + name: use logger.warning( + description: 'A quick check for the deprecated `.warn()` method of python loggers' + entry: '(?<!warnings)\.warn\(' + language: pygrep + types: [python] +- id: python-use-type-annotations + name: type annotations not comments + description: 'Enforce that python3.6+ type annotations are used instead of type comments' + entry: '# type(?!: *ignore([^a-zA-Z0-9]|$))' + language: pygrep + types: [python] +- id: rst-backticks + name: rst ``code`` is two backticks + description: 'Detect common mistake of using single backticks when writing rst' + entry: '^(?! ).*(^| )`[^`]+`([^_]|$)' + language: pygrep + types: [rst] +- id: rst-directive-colons + name: rst directives end with two colons + description: 'Detect mistake of rst directive not ending with double colon or space before the double colon' + entry: '^\s*\.\. [a-z]+(| | :):$' + language: pygrep + types: [rst] +- id: rst-inline-touching-normal + name: rst ``inline code`` next to normal text + description: 'Detect mistake of inline code touching normal text in rst' + entry: '\w``\w' + language: pygrep + types: [rst] +- id: text-unicode-replacement-char + name: no unicode replacement chars + description: 'Forbid files which have a UTF-8 Unicode replacement character' + entry: "\uFFFD" + language: pygrep + types: [text] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b7af5ef --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018 Anthony Sottile + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..022e3dc --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +[](https://results.pre-commit.ci/latest/github/pre-commit/pygrep-hooks/main) + +pygrep-hooks +============ + +A collection of fast, cheap, regex based pre-commit hooks. + + +### Adding to your `.pre-commit-config.yaml` + +```yaml +- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 # Use the ref you want to point at + hooks: + - id: python-use-type-annotations + # ... +``` + +### Naming conventions + +Where possible, these hooks will be prefixed with the file types they target. +For example, a hook which targets python will be called `python-...`. + +### Provided hooks + +[generated]: # (generated) +- **`python-check-blanket-noqa`**: Enforce that `noqa` annotations always occur with specific codes. Sample annotations: `# noqa: F401`, `# noqa: F401,W203` +- **`python-check-blanket-type-ignore`**: Enforce that `# type: ignore` annotations always occur with specific codes. Sample annotations: `# type: ignore[attr-defined]`, `# type: ignore[attr-defined, name-defined]` +- **`python-check-mock-methods`**: Prevent common mistakes of `assert mck.not_called()`, `assert mck.called_once_with(...)` and `mck.assert_called`. +- **`python-no-eval`**: A quick check for the `eval()` built-in function +- **`python-no-log-warn`**: A quick check for the deprecated `.warn()` method of python loggers +- **`python-use-type-annotations`**: Enforce that python3.6+ type annotations are used instead of type comments +- **`rst-backticks`**: Detect common mistake of using single backticks when writing rst +- **`rst-directive-colons`**: Detect mistake of rst directive not ending with double colon or space before the double colon +- **`rst-inline-touching-normal`**: Detect mistake of inline code touching normal text in rst +- **`text-unicode-replacement-char`**: Forbid files which have a UTF-8 Unicode replacement character diff --git a/generate-readme b/generate-readme new file mode 100755 index 0000000..ac7dd26 --- /dev/null +++ b/generate-readme @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import yaml + +Loader = getattr(yaml, 'CSafeLoader', yaml.SafeLoader) + + +def main() -> int: + with open('.pre-commit-hooks.yaml') as f: + hooks = yaml.load(f, Loader=Loader) + + with open('README.md') as f: + contents = f.read() + before, delim, _ = contents.partition('[generated]: # (generated)\n') + + rest = '\n'.join( + f'- **`{hook["id"]}`**: {hook["description"]}' for hook in hooks + ) + + with open('README.md', 'w') as f: + f.write(before + delim + rest + '\n') + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/hooks_test.py b/tests/hooks_test.py new file mode 100644 index 0000000..6cee816 --- /dev/null +++ b/tests/hooks_test.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import re + +import pytest +from pre_commit.clientlib import load_manifest +from pre_commit.constants import MANIFEST_FILE + +HOOKS = {h['id']: re.compile(h['entry']) for h in load_manifest(MANIFEST_FILE)} + + +@pytest.mark.parametrize( + 's', + ( + 'x = 1 # type: ignoreme', + 'x = 1 # type: int', + 'x = 1 # type int', + 'x = 1 # type: int # noqa', + ), +) +def test_python_use_type_annotations_positive(s): + assert HOOKS['python-use-type-annotations'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + 'x = 1', + 'x = 1 # type:ignore', + 'x = 1 # type: ignore', + 'x = 1 # type: ignore', + 'x = 1 # type: ignore # noqa', + 'x = 1 # type: ignore # noqa', + 'x = 1 # type: ignore[type-mismatch]', + 'x = 1 # type: ignore=E123', + ), +) +def test_python_use_type_annotations_negative(s): + assert not HOOKS['python-use-type-annotations'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + '# noqa', + '# NOQA', + '# noqa:F401', + '# noqa:F401,W203', + ), +) +def test_python_check_blanket_noqa_positive(s): + assert HOOKS['python-check-blanket-noqa'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + 'x = 1', + '# noqa: F401', + '# noqa: F401, W203', + ), +) +def test_python_check_blanket_noqa_negative(s): + assert not HOOKS['python-check-blanket-noqa'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + 'x = 1 # type: ignore', + 'x = 1 # type ignore', + 'x = 1 # type:ignore', + 'x = 1 # type ignore # noqa', + ), +) +def test_python_check_blanket_type_ignore_positive(s): + assert HOOKS['python-check-blanket-type-ignore'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + 'x = 1', + 'x = 1 # type: ignore[attr-defined]', + 'x = 1 # type: ignore[attr-defined, name-defined]', + 'x = 1 # type: ignore[type-mismatch] # noqa', + 'x = 1 # type: Union[int, str]', + 'x = 1 # type: ignoreme', + ), +) +def test_python_check_blanket_type_ignore_negative(s): + assert not HOOKS['python-check-blanket-type-ignore'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + 'assert my_mock.not_called()', + 'assert my_mock.called_once_with()', + 'my_mock.assert_not_called', + 'my_mock.assert_called', + 'my_mock.assert_called_once_with', + 'my_mock.assert_called_once_with# noqa', + 'MyMock.assert_called_once_with', + ), +) +def test_python_check_mock_methods_positive(s): + assert HOOKS['python-check-mock-methods'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + 'assert my_mock.call_count == 1', + 'assert my_mock.called', + 'my_mock.assert_not_called()', + 'my_mock.assert_called()', + 'my_mock.assert_called_once_with()', + '"""like :meth:`Mock.assert_called_once_with`"""', + '"""like :meth:`MagicMock.assert_called_once_with`"""', + ), +) +def test_python_check_mock_methods_negative(s): + assert not HOOKS['python-check-mock-methods'].search(s) + + +def test_python_noeval_positive(): + assert HOOKS['python-no-eval'].search('eval("3 + 4")') + + +def test_python_noeval_negative(): + assert not HOOKS['python-no-eval'].search('literal_eval("{1: 2}")') + + +@pytest.mark.parametrize( + 's', + ( + 'log.warn("this is deprecated")', + ), +) +def test_python_no_log_warn_positive(s): + assert HOOKS['python-no-log-warn'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + "warnings.warn('this is ok')", + 'log.warning("this is ok")', + 'from warnings import warn', + 'warn("by itself is also ok")', + ), +) +def test_python_no_log_warn_negative(s): + assert not HOOKS['python-no-log-warn'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + '`[code]`', + 'i like `_kitty`', + 'i like `_`', + '`a`', + '`cd`', + ' `indented` literal block', + '> quoted `literal` block', + ), +) +def test_python_rst_backticks_positive(s): + assert HOOKS['rst-backticks'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + ' ``[code]``', + 'i like _`kitty`', + 'i like `kitty`_', + '``b``', + '``ef``', + ' indented `literal` block', + ), +) +def test_python_rst_backticks_negative(s): + assert not HOOKS['rst-backticks'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + '``PyMem_Realloc()`` indirectly call``PyObject_Malloc()`` and', + 'This PEP proposes that ``bytes`` and ``bytearray``gain an optimised', + 'Reading this we first see the``break``, which obviously applies to', + 'for using``long_description`` and a corresponding', + '``inline`` normal``inline', + '``inline``normal ``inline', + '``inline``normal', + '``inline``normal``inline', + 'normal ``inline``normal', + 'normal``inline`` normal', + 'normal``inline``', + 'normal``inline``normal', + ), +) +def test_python_rst_inline_touching_normal_positive(s): + assert HOOKS['rst-inline-touching-normal'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + '``PyMem_Realloc()`` indirectly call ``PyObject_Malloc()`` and', + 'This PEP proposes that ``bytes`` and ``bytearray`` gain an optimised', + 'Reading this we first see the ``break``, which obviously applies to', + 'for using ``long_description`` and a corresponding', + '``inline`` normal ``inline', + '``inline`` normal', + 'normal ``inline`` normal', + 'normal ``inline``', + ), +) +def test_python_rst_inline_touching_normal_negative(s): + assert not HOOKS['rst-inline-touching-normal'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + str(b'\x80abc', errors='replace'), + ), +) +def test_text_unicode_replacement_char_positive(s): + assert HOOKS['text-unicode-replacement-char'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + 'foo', + ), +) +def test_text_unicode_replacement_char_negative(s): + assert not HOOKS['text-unicode-replacement-char'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + ' .. warning:', + '.. warning:', + ' .. warning ::', + '.. warning ::', + ' .. warning :', + '.. warning :', + ), +) +def test_rst_directive_colons_positive(s): + assert HOOKS['rst-directive-colons'].search(s) + + +@pytest.mark.parametrize( + 's', + ( + '.. warning::', + '.. code:: python', + ), +) +def test_rst_directive_colons_negative(s): + assert not HOOKS['rst-directive-colons'].search(s) + + +def test_that_hooks_are_sorted(): + assert list(HOOKS) == sorted(HOOKS)