diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 7a3b380..319e698 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -64,6 +64,20 @@ entry: check-merge-conflict language: python types: [text] +- id: check-missing-inits + name: Find directories with missing __init__ files + description: |- + Verify that all Python directories contain an __init__.py + + Although __init__.py files are not required on Python 3.3+, omitting them in + one directory while having them in another directory will break in confusing + and unexpected ways: + + http://python-notes.curiousefficiency.org/en/latest/python_concepts/import_traps.html#the-init-py-trap + entry: check-missing-inits + language: python + require_serial: true + files: .*\.py$ - id: check-symlinks name: Check for broken symlinks description: Checks for symlinks which do not point to anything. diff --git a/README.md b/README.md index fd62ffa..e8c2993 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Add this to your `.pre-commit-config.yaml` proper shebang. - `check-json` - Attempts to load all json files to verify syntax. - `check-merge-conflict` - Check for files that contain merge conflict strings. +- `check-missing-inits` - Checks for missing `__init__.py` files in any directory containing Python files. - `check-symlinks` - Checks for symlinks which do not point to anything. - `check-toml` - Attempts to load all TOML files to verify syntax. - `check-vcs-permalinks` - Ensures that links to vcs websites are permalinks. diff --git a/pre_commit_hooks/check_missing_inits.py b/pre_commit_hooks/check_missing_inits.py new file mode 100644 index 0000000..feb5ed9 --- /dev/null +++ b/pre_commit_hooks/check_missing_inits.py @@ -0,0 +1,25 @@ +import os +from argparse import ArgumentParser +from typing import Optional +from typing import Sequence + + +def main(argv=None): # type: (Optional[Sequence[str]]) -> int + parser = ArgumentParser() + parser.add_argument('filenames', nargs='*', help='Filenames to check') + args = parser.parse_args(argv) + + directories = {os.path.dirname(f) for f in args.filenames} + missing_dirs = set() + for d in directories: + if not os.path.exists(os.path.join(d, '__init__.py')): + missing_dirs.add(d) + + for d in sorted(missing_dirs): + print('No __init__.py file found in: {}'.format(d)) + + return 1 if len(missing_dirs) else 0 + + +if __name__ == '__main__': + exit(main()) diff --git a/setup.cfg b/setup.cfg index 12c2b16..dd06119 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,6 +43,7 @@ console_scripts = check-executables-have-shebangs = pre_commit_hooks.check_executables_have_shebangs:main check-json = pre_commit_hooks.check_json:main check-merge-conflict = pre_commit_hooks.check_merge_conflict:main + check-missing-inits = pre_commit_hooks.check_missing_inits:main check-symlinks = pre_commit_hooks.check_symlinks:main check-toml = pre_commit_hooks.check_toml:main check-vcs-permalinks = pre_commit_hooks.check_vcs_permalinks:main diff --git a/tests/check_missing_inits_test.py b/tests/check_missing_inits_test.py new file mode 100644 index 0000000..e0b6d6b --- /dev/null +++ b/tests/check_missing_inits_test.py @@ -0,0 +1,62 @@ +import os + +import pytest + +from pre_commit_hooks.check_missing_inits import main + + +@pytest.fixture +def filepaths(tmpdir): + dirs = ['a', 'b'] + files = ['a.py', 'b.py', '__init__.py'] + paths = [] + for d in dirs: + directory = tmpdir / d + os.mkdir(str(directory)) + for f in files: + path = (directory / f) + paths.append(str(path)) + # Nested directory + if d == 'b': + directory = directory / 'c' + os.mkdir(str(directory)) + for f in files: + path = (directory / f) + paths.append(str(path)) + return paths + + +def test_has_inits(filepaths): + for f in filepaths: + with open(f, 'w'): + pass + + assert main(argv=filepaths) == 0 + + +def test_missing_inits(filepaths): + remove = '' + for f in filepaths: + # Remove the init py file from the b directory + if os.path.join('b', '__init__.py') in f: + remove = str(f) + continue + with open(f, 'w'): + pass + filepaths.remove(remove) + + assert main(argv=filepaths) == 1 + + +def test_nested_dirs_missing_inits(filepaths): + remove = '' + for f in filepaths: + # Remove the init py file from a nested directory + if os.path.join('b', 'c', '__init__.py') in f: + remove = str(f) + continue + with open(f, 'w'): + pass + filepaths.remove(remove) + + assert main(argv=filepaths) == 1