diff --git a/.gitignore b/.gitignore index ae552f4a..5428b0ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,8 @@ *.egg-info -*.iml *.py[co] -.*.sw[a-z] -.coverage -.idea -.project -.pydevproject -.tox -.venv.touch +/.coverage +/.mypy_cache +/.pytest_cache +/.tox +/dist /venv* -coverage-html -dist -.pytest_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aa540e82..e7c441f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,6 +42,11 @@ repos: rev: v1.6.0 hooks: - id: setup-cfg-fmt +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.761 + hooks: + - id: mypy + exclude: ^testing/resources/ - repo: meta hooks: - id: check-hooks-apply diff --git a/pre_commit/color.py b/pre_commit/color.py index 667609b4..01034275 100644 --- a/pre_commit/color.py +++ b/pre_commit/color.py @@ -2,7 +2,7 @@ import os import sys terminal_supports_color = True -if os.name == 'nt': # pragma: no cover (windows) +if sys.platform == 'win32': # pragma: no cover (windows) from pre_commit.color_windows import enable_virtual_terminal_processing try: enable_virtual_terminal_processing() diff --git a/pre_commit/color_windows.py b/pre_commit/color_windows.py index 3e6e3ca9..4cbb1341 100644 --- a/pre_commit/color_windows.py +++ b/pre_commit/color_windows.py @@ -1,10 +1,14 @@ -from ctypes import POINTER -from ctypes import windll -from ctypes import WinError -from ctypes import WINFUNCTYPE -from ctypes.wintypes import BOOL -from ctypes.wintypes import DWORD -from ctypes.wintypes import HANDLE +import sys +assert sys.platform == 'win32' + +from ctypes import POINTER # noqa: E402 +from ctypes import windll # noqa: E402 +from ctypes import WinError # noqa: E402 +from ctypes import WINFUNCTYPE # noqa: E402 +from ctypes.wintypes import BOOL # noqa: E402 +from ctypes.wintypes import DWORD # noqa: E402 +from ctypes.wintypes import HANDLE # noqa: E402 + STD_OUTPUT_HANDLE = -11 ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 diff --git a/pre_commit/commands/autoupdate.py b/pre_commit/commands/autoupdate.py index 12e67dce..def0899a 100644 --- a/pre_commit/commands/autoupdate.py +++ b/pre_commit/commands/autoupdate.py @@ -1,6 +1,8 @@ import collections import os.path import re +from typing import List +from typing import Optional from aspy.yaml import ordered_dump from aspy.yaml import ordered_load @@ -121,7 +123,7 @@ def autoupdate(config_file, store, tags_only, freeze, repos=()): """Auto-update the pre-commit config to the latest versions of repos.""" migrate_config(config_file, quiet=True) retv = 0 - rev_infos = [] + rev_infos: List[Optional[RevInfo]] = [] changed = False config = load_config(config_file) diff --git a/pre_commit/envcontext.py b/pre_commit/envcontext.py index b3f770cc..d5e5b803 100644 --- a/pre_commit/envcontext.py +++ b/pre_commit/envcontext.py @@ -1,13 +1,26 @@ -import collections import contextlib +import enum import os +from typing import NamedTuple +from typing import Tuple +from typing import Union -UNSET = collections.namedtuple('UNSET', ())() +class _Unset(enum.Enum): + UNSET = 1 -Var = collections.namedtuple('Var', ('name', 'default')) -Var.__new__.__defaults__ = ('',) +UNSET = _Unset.UNSET + + +class Var(NamedTuple): + name: str + default: str = '' + + +SubstitutionT = Tuple[Union[str, Var], ...] +ValueT = Union[str, _Unset, SubstitutionT] +PatchesT = Tuple[Tuple[str, ValueT], ...] def format_env(parts, env): diff --git a/pre_commit/error_handler.py b/pre_commit/error_handler.py index 7f5b7634..5817695f 100644 --- a/pre_commit/error_handler.py +++ b/pre_commit/error_handler.py @@ -2,6 +2,7 @@ import contextlib import os.path import sys import traceback +from typing import Union import pre_commit.constants as C from pre_commit import five @@ -32,8 +33,8 @@ def _log_and_exit(msg, exc, formatted): output.write_line(f'Check the log at {log_path}') with open(log_path, 'wb') as log: - def _log_line(*s): # type: (*str) -> None - output.write_line(*s, stream=log) + def _log_line(s: Union[None, str, bytes] = None) -> None: + output.write_line(s, stream=log) _log_line('### version information') _log_line() diff --git a/pre_commit/file_lock.py b/pre_commit/file_lock.py index cd7ad043..9aaf93f5 100644 --- a/pre_commit/file_lock.py +++ b/pre_commit/file_lock.py @@ -1,8 +1,9 @@ import contextlib import errno +import os -try: # pragma: no cover (windows) +if os.name == 'nt': # pragma: no cover (windows) import msvcrt # https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/locking @@ -14,12 +15,14 @@ try: # pragma: no cover (windows) @contextlib.contextmanager def _locked(fileno, blocked_cb): try: - msvcrt.locking(fileno, msvcrt.LK_NBLCK, _region) + # TODO: https://github.com/python/typeshed/pull/3607 + msvcrt.locking(fileno, msvcrt.LK_NBLCK, _region) # type: ignore except OSError: blocked_cb() while True: try: - msvcrt.locking(fileno, msvcrt.LK_LOCK, _region) + # TODO: https://github.com/python/typeshed/pull/3607 + msvcrt.locking(fileno, msvcrt.LK_LOCK, _region) # type: ignore # noqa: E501 except OSError as e: # Locking violation. Returned when the _LK_LOCK or _LK_RLCK # flag is specified and the file cannot be locked after 10 @@ -37,8 +40,9 @@ try: # pragma: no cover (windows) # The documentation however states: # "Regions should be locked only briefly and should be unlocked # before closing a file or exiting the program." - msvcrt.locking(fileno, msvcrt.LK_UNLCK, _region) -except ImportError: # pragma: windows no cover + # TODO: https://github.com/python/typeshed/pull/3607 + msvcrt.locking(fileno, msvcrt.LK_UNLCK, _region) # type: ignore +else: # pramga: windows no cover import fcntl @contextlib.contextmanager diff --git a/pre_commit/languages/all.py b/pre_commit/languages/all.py index bf7bb295..b2584655 100644 --- a/pre_commit/languages/all.py +++ b/pre_commit/languages/all.py @@ -1,3 +1,6 @@ +from typing import Any +from typing import Dict + from pre_commit.languages import conda from pre_commit.languages import docker from pre_commit.languages import docker_image @@ -13,6 +16,7 @@ from pre_commit.languages import script from pre_commit.languages import swift from pre_commit.languages import system + # A language implements the following constant and functions in its module: # # # Use None for no environment @@ -49,7 +53,7 @@ from pre_commit.languages import system # (returncode, output) # """ -languages = { +languages: Dict[str, Any] = { 'conda': conda, 'docker': docker, 'docker_image': docker_image, diff --git a/pre_commit/languages/conda.py b/pre_commit/languages/conda.py index fe391c05..d90009cc 100644 --- a/pre_commit/languages/conda.py +++ b/pre_commit/languages/conda.py @@ -2,6 +2,7 @@ import contextlib import os from pre_commit.envcontext import envcontext +from pre_commit.envcontext import SubstitutionT from pre_commit.envcontext import UNSET from pre_commit.envcontext import Var from pre_commit.languages import helpers @@ -18,7 +19,7 @@ def get_env_patch(env): # 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 = (os.path.join(env, 'bin'), os.pathsep, Var('PATH')) + path: SubstitutionT = (os.path.join(env, 'bin'), os.pathsep, Var('PATH')) if os.name == 'nt': # pragma: no cover (platform specific) path = (env, os.pathsep) + path path = (os.path.join(env, 'Scripts'), os.pathsep) + path diff --git a/pre_commit/languages/docker.py b/pre_commit/languages/docker.py index eae9eec9..5a2b65ff 100644 --- a/pre_commit/languages/docker.py +++ b/pre_commit/languages/docker.py @@ -1,5 +1,6 @@ import hashlib import os +from typing import Tuple import pre_commit.constants as C from pre_commit import five @@ -42,7 +43,7 @@ def assert_docker_available(): # pragma: windows no cover def build_docker_image(prefix, **kwargs): # pragma: windows no cover pull = kwargs.pop('pull') assert not kwargs, kwargs - cmd = ( + cmd: Tuple[str, ...] = ( 'docker', 'build', '--tag', docker_tag(prefix), '--label', PRE_COMMIT_LABEL, diff --git a/pre_commit/languages/python.py b/pre_commit/languages/python.py index f7ff3aa2..96ff976e 100644 --- a/pre_commit/languages/python.py +++ b/pre_commit/languages/python.py @@ -1,4 +1,5 @@ import contextlib +import functools import os import sys @@ -64,7 +65,8 @@ def _find_by_sys_executable(): return None -def _get_default_version(): # pragma: no cover (platform dependent) +@functools.lru_cache(maxsize=1) +def get_default_version(): # pragma: no cover (platform dependent) # First attempt from `sys.executable` (or the realpath) exe = _find_by_sys_executable() if exe: @@ -86,15 +88,6 @@ def _get_default_version(): # pragma: no cover (platform dependent) return C.DEFAULT -def get_default_version(): - # TODO: when dropping python2, use `functools.lru_cache(maxsize=1)` - try: - return get_default_version.cached_version - except AttributeError: - get_default_version.cached_version = _get_default_version() - return get_default_version() - - def _sys_executable_matches(version): if version == 'python': return True diff --git a/pre_commit/languages/ruby.py b/pre_commit/languages/ruby.py index 85d9cedc..3ac47e98 100644 --- a/pre_commit/languages/ruby.py +++ b/pre_commit/languages/ruby.py @@ -5,6 +5,7 @@ import tarfile import pre_commit.constants as C from pre_commit.envcontext import envcontext +from pre_commit.envcontext import PatchesT from pre_commit.envcontext import Var from pre_commit.languages import helpers from pre_commit.util import CalledProcessError @@ -18,7 +19,7 @@ healthy = helpers.basic_healthy def get_env_patch(venv, language_version): # pragma: windows no cover - patches = ( + patches: PatchesT = ( ('GEM_HOME', os.path.join(venv, 'gems')), ('RBENV_ROOT', venv), ('BUNDLE_IGNORE_CONFIG', '1'), diff --git a/pre_commit/languages/rust.py b/pre_commit/languages/rust.py index de3f6fdd..0e6e7407 100644 --- a/pre_commit/languages/rust.py +++ b/pre_commit/languages/rust.py @@ -1,5 +1,7 @@ import contextlib import os.path +from typing import Set +from typing import Tuple import toml @@ -71,7 +73,7 @@ def install_environment(prefix, version, additional_dependencies): _add_dependencies(prefix.path('Cargo.toml'), lib_deps) with clean_path_on_failure(directory): - packages_to_install = {('--path', '.')} + packages_to_install: Set[Tuple[str, ...]] = {('--path', '.')} for cli_dep in cli_deps: cli_dep = cli_dep[len('cli:'):] package, _, version = cli_dep.partition(':') diff --git a/pre_commit/output.py b/pre_commit/output.py index 6ca0b378..045999ae 100644 --- a/pre_commit/output.py +++ b/pre_commit/output.py @@ -1,8 +1,8 @@ +import contextlib import sys from pre_commit import color from pre_commit import five -from pre_commit.util import noop_context def get_hook_message( @@ -71,14 +71,12 @@ def write(s, stream=stdout_byte_stream): def write_line(s=None, stream=stdout_byte_stream, logfile_name=None): - output_streams = [stream] - if logfile_name: - ctx = open(logfile_name, 'ab') - output_streams.append(ctx) - else: - ctx = noop_context() + with contextlib.ExitStack() as exit_stack: + output_streams = [stream] + if logfile_name: + stream = exit_stack.enter_context(open(logfile_name, 'ab')) + output_streams.append(stream) - with ctx: for output_stream in output_streams: if s is not None: output_stream.write(five.to_bytes(s)) diff --git a/pre_commit/repository.py b/pre_commit/repository.py index 186f1e4e..57d6116c 100644 --- a/pre_commit/repository.py +++ b/pre_commit/repository.py @@ -1,8 +1,10 @@ -import collections import json import logging import os import shlex +from typing import NamedTuple +from typing import Sequence +from typing import Set import pre_commit.constants as C from pre_commit import five @@ -49,8 +51,29 @@ def _write_state(prefix, venv, state): _KEYS = tuple(item.key for item in MANIFEST_HOOK_DICT.items) -class Hook(collections.namedtuple('Hook', ('src', 'prefix') + _KEYS)): - __slots__ = () +class Hook(NamedTuple): + src: str + prefix: Prefix + id: str + name: str + entry: str + language: str + alias: str + files: str + exclude: str + types: Sequence[str] + exclude_types: Sequence[str] + additional_dependencies: Sequence[str] + args: Sequence[str] + always_run: bool + pass_filenames: bool + description: str + language_version: str + log_file: str + minimum_pre_commit_version: str + require_serial: bool + stages: Sequence[str] + verbose: bool @property def cmd(self): @@ -201,7 +224,7 @@ def _repository_hooks(repo_config, store, root_config): def install_hook_envs(hooks, store): def _need_installed(): - seen = set() + seen: Set[Hook] = set() ret = [] for hook in hooks: if hook.install_key not in seen and not hook.installed(): diff --git a/pre_commit/resources/hook-tmpl b/pre_commit/resources/hook-tmpl index e83c126a..8e6b17b5 100755 --- a/pre_commit/resources/hook-tmpl +++ b/pre_commit/resources/hook-tmpl @@ -4,6 +4,7 @@ import distutils.spawn import os import subprocess import sys +from typing import Tuple # work around https://github.com/Homebrew/homebrew-core/issues/30445 os.environ.pop('__PYVENV_LAUNCHER__', None) @@ -12,10 +13,10 @@ HERE = os.path.dirname(os.path.abspath(__file__)) Z40 = '0' * 40 ID_HASH = '138fd403232d2ddd5efb44317e38bf03' # start templated -CONFIG = None -HOOK_TYPE = None -INSTALL_PYTHON = None -SKIP_ON_MISSING_CONFIG = None +CONFIG = '' +HOOK_TYPE = '' +INSTALL_PYTHON = '' +SKIP_ON_MISSING_CONFIG = False # end templated @@ -123,7 +124,7 @@ def _rev_exists(rev): def _pre_push(stdin): remote = sys.argv[1] - opts = () + opts: Tuple[str, ...] = () for line in stdin.decode('UTF-8').splitlines(): _, local_sha, _, remote_sha = line.split() if local_sha == Z40: @@ -146,8 +147,8 @@ def _pre_push(stdin): # pushing the whole tree including root commit opts = ('--all-files',) else: - cmd = ('git', 'rev-parse', f'{first_ancestor}^') - source = subprocess.check_output(cmd).decode().strip() + rev_cmd = ('git', 'rev-parse', f'{first_ancestor}^') + source = subprocess.check_output(rev_cmd).decode().strip() opts = ('--origin', local_sha, '--source', source) if opts: diff --git a/pre_commit/util.py b/pre_commit/util.py index 8c9751b4..cf067cba 100644 --- a/pre_commit/util.py +++ b/pre_commit/util.py @@ -152,6 +152,7 @@ if os.name != 'nt': # pragma: windows no cover # tty flags normally change \n to \r\n attrs = termios.tcgetattr(self.r) + assert isinstance(attrs[1], int) attrs[1] &= ~(termios.ONLCR | termios.OPOST) termios.tcsetattr(self.r, termios.TCSANOW, attrs) diff --git a/pre_commit/xargs.py b/pre_commit/xargs.py index d5d13746..ed171dc9 100644 --- a/pre_commit/xargs.py +++ b/pre_commit/xargs.py @@ -4,6 +4,7 @@ import math import os import subprocess import sys +from typing import List from pre_commit import parse_shebang from pre_commit.util import cmd_output_b @@ -56,7 +57,7 @@ def partition(cmd, varargs, target_concurrency, _max_length=None): cmd = tuple(cmd) ret = [] - ret_cmd = [] + ret_cmd: List[str] = [] # Reversed so arguments are in order varargs = list(reversed(varargs)) diff --git a/setup.cfg b/setup.cfg index bf666de6..5126c83a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,3 +52,15 @@ exclude = [bdist_wheel] universal = True + +[mypy] +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +no_implicit_optional = true + +[mypy-testing.*] +disallow_untyped_defs = false + +[mypy-tests.*] +disallow_untyped_defs = false diff --git a/tests/languages/all_test.py b/tests/languages/all_test.py index 5e8c8253..6f58e2fd 100644 --- a/tests/languages/all_test.py +++ b/tests/languages/all_test.py @@ -18,7 +18,7 @@ def test_install_environment_argspec(language): expected_argspec = ArgSpec( args=['prefix', 'version', 'additional_dependencies'], ) - argspec = inspect.getfullargpsec(languages[language].install_environment) + argspec = inspect.getfullargspec(languages[language].install_environment) assert argspec == expected_argspec @@ -28,21 +28,21 @@ def test_ENVIRONMENT_DIR(language): @pytest.mark.parametrize('language', all_languages) -def test_run_hook_argpsec(language): +def test_run_hook_argspec(language): expected_argspec = ArgSpec(args=['hook', 'file_args', 'color']) - argspec = inspect.getfullargpsec(languages[language].run_hook) + argspec = inspect.getfullargspec(languages[language].run_hook) assert argspec == expected_argspec @pytest.mark.parametrize('language', all_languages) def test_get_default_version_argspec(language): expected_argspec = ArgSpec(args=[]) - argspec = inspect.getfullargpsec(languages[language].get_default_version) + argspec = inspect.getfullargspec(languages[language].get_default_version) assert argspec == expected_argspec @pytest.mark.parametrize('language', all_languages) def test_healthy_argspec(language): expected_argspec = ArgSpec(args=['prefix', 'language_version']) - argspec = inspect.getfullargpsec(languages[language].healthy) + argspec = inspect.getfullargspec(languages[language].healthy) assert argspec == expected_argspec diff --git a/tests/main_test.py b/tests/main_test.py index caccc9a6..1ddc7c6c 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -1,5 +1,8 @@ import argparse import os.path +from typing import NamedTuple +from typing import Optional +from typing import Sequence from unittest import mock import pytest @@ -24,11 +27,11 @@ def test_append_replace_default(argv, expected): assert parser.parse_args(argv).f == expected -class Args: - def __init__(self, **kwargs): - kwargs.setdefault('command', 'help') - kwargs.setdefault('config', C.CONFIG_FILE) - self.__dict__.update(kwargs) +class Args(NamedTuple): + command: str = 'help' + config: str = C.CONFIG_FILE + files: Sequence[str] = [] + repo: Optional[str] = None def test_adjust_args_and_chdir_not_in_git_dir(in_tmpdir): @@ -73,6 +76,7 @@ def test_adjust_args_try_repo_repo_relative(in_git_dir): in_git_dir.join('foo').ensure_dir().chdir() args = Args(command='try-repo', repo='../foo', files=[]) + assert args.repo is not None assert os.path.exists(args.repo) main._adjust_args_and_chdir(args) assert os.getcwd() == in_git_dir diff --git a/tests/parse_shebang_test.py b/tests/parse_shebang_test.py index 5798c4e2..7a958b01 100644 --- a/tests/parse_shebang_test.py +++ b/tests/parse_shebang_test.py @@ -11,6 +11,12 @@ from pre_commit.envcontext import Var from pre_commit.util import make_executable +def _echo_exe() -> str: + exe = distutils.spawn.find_executable('echo') + assert exe is not None + return exe + + def test_file_doesnt_exist(): assert parse_shebang.parse_filename('herp derp derp') == () @@ -27,8 +33,7 @@ def test_find_executable_full_path(): def test_find_executable_on_path(): - expected = distutils.spawn.find_executable('echo') - assert parse_shebang.find_executable('echo') == expected + assert parse_shebang.find_executable('echo') == _echo_exe() def test_find_executable_not_found_none(): @@ -110,30 +115,29 @@ def test_normexe_already_full_path(): def test_normexe_gives_full_path(): - expected = distutils.spawn.find_executable('echo') - assert parse_shebang.normexe('echo') == expected - assert os.sep in expected + assert parse_shebang.normexe('echo') == _echo_exe() + assert os.sep in _echo_exe() def test_normalize_cmd_trivial(): - cmd = (distutils.spawn.find_executable('echo'), 'hi') + cmd = (_echo_exe(), 'hi') assert parse_shebang.normalize_cmd(cmd) == cmd def test_normalize_cmd_PATH(): cmd = ('echo', '--version') - expected = (distutils.spawn.find_executable('echo'), '--version') + expected = (_echo_exe(), '--version') assert parse_shebang.normalize_cmd(cmd) == expected def test_normalize_cmd_shebang(in_tmpdir): - echo = distutils.spawn.find_executable('echo').replace(os.sep, '/') + echo = _echo_exe().replace(os.sep, '/') path = write_executable(echo) assert parse_shebang.normalize_cmd((path,)) == (echo, path) def test_normalize_cmd_PATH_shebang_full_path(in_tmpdir): - echo = distutils.spawn.find_executable('echo').replace(os.sep, '/') + echo = _echo_exe().replace(os.sep, '/') path = write_executable(echo) with bin_on_path(): ret = parse_shebang.normalize_cmd(('run',)) @@ -141,7 +145,7 @@ def test_normalize_cmd_PATH_shebang_full_path(in_tmpdir): def test_normalize_cmd_PATH_shebang_PATH(in_tmpdir): - echo = distutils.spawn.find_executable('echo') + echo = _echo_exe() path = write_executable('/usr/bin/env echo') with bin_on_path(): ret = parse_shebang.normalize_cmd(('run',)) diff --git a/tests/repository_test.py b/tests/repository_test.py index 43e0362c..dc4acdc0 100644 --- a/tests/repository_test.py +++ b/tests/repository_test.py @@ -2,6 +2,8 @@ import os.path import re import shutil import sys +from typing import Any +from typing import Dict from unittest import mock import cfgv @@ -763,7 +765,7 @@ def test_local_python_repo(store, local_python_config): def test_default_language_version(store, local_python_config): - config = { + config: Dict[str, Any] = { 'default_language_version': {'python': 'fake'}, 'default_stages': ['commit'], 'repos': [local_python_config], @@ -780,7 +782,7 @@ def test_default_language_version(store, local_python_config): def test_default_stages(store, local_python_config): - config = { + config: Dict[str, Any] = { 'default_language_version': {'python': C.DEFAULT}, 'default_stages': ['commit'], 'repos': [local_python_config], diff --git a/tests/staged_files_only_test.py b/tests/staged_files_only_test.py index 46e350e1..be9de395 100644 --- a/tests/staged_files_only_test.py +++ b/tests/staged_files_only_test.py @@ -24,7 +24,8 @@ def patch_dir(tempdir_factory): def get_short_git_status(): git_status = cmd_output('git', 'status', '-s')[1] - return dict(reversed(line.split()) for line in git_status.splitlines()) + line_parts = [line.split() for line in git_status.splitlines()] + return {v: k for k, v in line_parts} @pytest.fixture