diff --git a/README.md b/README.md index 450dffe..0ca8763 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ Following is a list of *additional* settings specific to this linter: |Setting|Description| |:------|:----------| -|cache-dir|The directory to store the cache in. Creates a sub-folder in your temporary directory if not specified.| -|follow-imports|Whether imports should be followed and linted. The default is `"silent"`, but `"skip"` may also be used. The other options are not interesting.| -|incremental|By default, we use incremental mode to speed up lint passes. Set this to `false` to disable.| +|cache-dir|The directory to store the cache in. Creates a sub-folder in your temporary directory if not specified. Set it to `false` to disable this automatic behavior, for example if the cache location is set in your mypy.ini file.| +|follow-imports|Whether imports should be followed and linted. The default is `"silent"` for speed, but `"normal"` or `"skip"` may also be used.| +|show-error-codes|Set to `false` for older mypy versions, or better yet update mypy.| All other args to mypy should be specified in the `args` list directly. diff --git a/linter.py b/linter.py index 2edde4b..0b10729 100644 --- a/linter.py +++ b/linter.py @@ -11,14 +11,27 @@ """This module exports the Mypy plugin class.""" +from collections import defaultdict +import hashlib import logging import os import shutil import tempfile +import time +import threading import getpass -from SublimeLinter.lint import const from SublimeLinter.lint import PythonLinter +from SublimeLinter.lint.linter import PermanentError + + +MYPY = False +if MYPY: + from typing import Dict, DefaultDict, Optional, Protocol + + class TemporaryDirectory(Protocol): + name = None # type: str + USER = getpass.getuser() TMPDIR_PREFIX = "SublimeLinter-contrib-mypy-%s" % USER @@ -28,16 +41,22 @@ # Mapping for our created temporary directories. # For smarter caching purposes, # we index different cache folders based on the working dir. -tmpdirs = {} +try: + tmpdirs +except NameError: + tmpdirs = {} # type: Dict[str, TemporaryDirectory] +locks = defaultdict(lambda: threading.Lock()) # type: DefaultDict[Optional[str], threading.Lock] class Mypy(PythonLinter): """Provides an interface to mypy.""" - regex = r'^(\w:)?[^:]+:(?P\d+):((?P\d+):)?\s*(?P[^:]+):\s*(?P.+)' + regex = ( + r'^(?P.+?):(?P\d+):((?P\d+):)?\s*' + r'(?P[^:]+):\s*(?P.+?)(\s\s\[(?P.+)\])?$' + ) line_col_base = (1, 1) tempfile_suffix = 'py' - default_type = const.WARNING # Pretty much all interesting options don't expect a value, # so you'll have to specify those in "args" anyway. @@ -45,11 +64,10 @@ class Mypy(PythonLinter): defaults = { 'selector': "source.python", # Will default to tempfile.TemporaryDirectory if empty. - "--cache-dir:": "", - # Allow users to disable this - "--incremental": True, + "--cache-dir": "", + "--show-error-codes": True, # Need this to silent lints for other files. Alternatively: 'skip' - "--follow-imports:": "silent", + "--follow-imports": "silent", } def cmd(self): @@ -59,7 +77,6 @@ def cmd(self): '${args}', '--show-column-numbers', '--hide-error-context', - # '--incremental', ] if self.filename: cmd.extend([ @@ -75,42 +92,95 @@ def cmd(self): else: cmd.append('${temp_file}') - # Add a temporary cache dir to the command if none was specified. - # Helps keep the environment clean - # by not littering everything with `.mypy_cache` folders. - if not self.settings.get('cache-dir'): + # Compare against `''` so the user can set just `False`, + # for example if the cache is configured in "mypy.ini". + if self.settings.get('cache-dir') == '': cwd = self.get_working_dir() - if cwd in tmpdirs: - cache_dir = tmpdirs[cwd].name + if not cwd: # abort silently + self.notify_unassign() + raise PermanentError() + + if os.path.exists(os.path.join(cwd, '.mypy_cache')): + self.settings.set('cache-dir', False) # do not set it as arg else: - tmp_dir = tempfile.TemporaryDirectory(prefix=TMPDIR_PREFIX) - tmpdirs[cwd] = tmp_dir - cache_dir = tmp_dir.name - logger.info("Created temporary cache dir at: %s", cache_dir) - cmd[1:1] = ["--cache-dir", cache_dir] + # Add a temporary cache dir to the command if none was specified. + # Helps keep the environment clean by not littering everything + # with `.mypy_cache` folders. + try: + cache_dir = tmpdirs[cwd].name + except KeyError: + tmpdirs[cwd] = tmp_dir = _get_tmpdir(cwd) + cache_dir = tmp_dir.name + + self.settings.set('cache-dir', cache_dir) return cmd + def run(self, cmd, code): + with locks[self.get_working_dir()]: + return super().run(cmd, code) + + +class FakeTemporaryDirectory: + def __init__(self, name): + # type: (str) -> None + self.name = name + + +def _get_tmpdir(folder): + # type: (str) -> TemporaryDirectory + folder_hash = hashlib.sha256(folder.encode('utf-8')).hexdigest()[:7] + tmpdir = tempfile.gettempdir() + for dirname in os.listdir(tmpdir): + if dirname.startswith(TMPDIR_PREFIX) and dirname.endswith(folder_hash): + path = os.path.join(tmpdir, dirname) + tmp_dir = FakeTemporaryDirectory(path) # type: TemporaryDirectory + try: # touch it so `_cleanup_tmpdirs` doesn't catch it + os.utime(path) + except OSError: + pass + logger.info("Reuse temporary cache dir at: %s", path) + return tmp_dir + else: + tmp_dir = tempfile.TemporaryDirectory(prefix=TMPDIR_PREFIX, suffix=folder_hash) + logger.info("Created temporary cache dir at: %s", tmp_dir.name) + return tmp_dir + + +def _cleanup_tmpdirs(keep_recent=False): -def _cleanup_tmpdirs(): def _onerror(function, path, exc_info): logger.exception("Unable to delete '%s' while cleaning up temporary directory", path, exc_info=exc_info) + tmpdir = tempfile.gettempdir() for dirname in os.listdir(tmpdir): if dirname.startswith(TMPDIR_PREFIX): - shutil.rmtree(os.path.join(tmpdir, dirname), onerror=_onerror) + full_path = os.path.join(tmpdir, dirname) + if keep_recent: + try: + atime = os.stat(full_path).st_atime + except OSError: + pass + else: + if (time.time() - atime) / 60 / 60 / 24 < 14: + continue + + shutil.rmtree(full_path, onerror=_onerror) def plugin_loaded(): """Attempt to clean up temporary directories from previous runs.""" - _cleanup_tmpdirs() + _cleanup_tmpdirs(keep_recent=True) def plugin_unloaded(): - """Clear references to TemporaryDirectory instances. + try: + from package_control import events + + if events.remove('SublimeLinter-contrib-mypy'): + logger.info("Cleanup temporary directories.") + _cleanup_tmpdirs() - They should then be removed automatically. - """ - # (Actually, do we even need to do this?) - tmpdirs.clear() + except ImportError: + pass diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..fb90e81 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,10 @@ +[mypy] +check_untyped_defs = True +warn_redundant_casts = True +warn_unused_ignores = True +mypy_path = + ../ +sqlite_cache = True + +[mypy-package_control] +ignore_missing_imports = True