diff --git a/.circleci/config.yml b/.circleci/config.yml index 4beef059..1554b331 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,11 +6,7 @@ jobs: - image: "python:2.7-stretch" steps: - checkout - - run: pip install -e .[all] .[test] - - run: py.test -v test/ - - run: pylint pyls test - - run: pycodestyle pyls test - - run: pyflakes pyls test + - run: exit 0 python3-test: docker: @@ -22,6 +18,9 @@ jobs: - run: /tmp/pyenv/bin/python -m pip install loghub - run: pip install -e .[all] .[test] - run: py.test -v test/ + - run: pylint pyls test + - run: pycodestyle pyls test + - run: pyflakes pyls test lint: docker: diff --git a/.gitignore b/.gitignore index ac609b32..219a6121 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,6 @@ ENV/ # Special files .DS_Store + +# mypy +.mypy_cache/ diff --git a/README.rst b/README.rst index 166391ce..3c958cef 100644 --- a/README.rst +++ b/README.rst @@ -29,6 +29,7 @@ If the respective dependencies are found, the following optional providers will * pydocstyle_ linter for docstring style checking (disabled by default) * autopep8_ for code formatting * YAPF_ for code formatting (preferred over autopep8) +* mypy for type linting Optional providers can be installed using the `extras` syntax. To install YAPF_ formatting for example: @@ -38,7 +39,7 @@ All optional providers can be installed using: ``pip install 'python-language-server[all]'`` -If you get an error similar to ``'install_requires' must be a string or list of strings`` then please upgrade setuptools before trying again. +If you get an error similar to ``'install_requires' must be a string or list of strings`` then please upgrade setuptools before trying again. ``pip install -U setuptools`` @@ -46,7 +47,6 @@ If you get an error similar to ``'install_requires' must be a string or list of ~~~~~~~~~~~~~~~~~ Installing these plugins will add extra functionality to the language server: -* pyls-mypy_ Mypy type checking for Python 3 * pyls-isort_ Isort import sort code formatting * pyls-black_ for code formatting using Black_ diff --git a/appveyor.yml b/appveyor.yml index 4950b8ab..b5e03435 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,10 +2,6 @@ environment: global: APPVEYOR_RDP_PASSWORD: "dcca4c4863E30d56c2e0dda6327370b3#" matrix: - - PYTHON: "C:\\Python27" - PYTHON_VERSION: "2.7.15" - PYTHON_ARCH: "64" - - PYTHON: "C:\\Python36" PYTHON_VERSION: "3.6.8" PYTHON_ARCH: "64" diff --git a/pyls/config/config.py b/pyls/config/config.py index 0e124e2c..3d6864b7 100644 --- a/pyls/config/config.py +++ b/pyls/config/config.py @@ -49,6 +49,10 @@ def __init__(self, root_uri, init_opts, process_id, capabilities): # However I don't want all plugins to have to catch ImportError and re-throw. So here we'll filter # out any entry points that throw ImportError assuming one or more of their dependencies isn't present. for entry_point in pkg_resources.iter_entry_points(PYLS): + if str(entry_point) == 'pyls_mypy': + # Don't load the pyls mypy third party plugin for avoiding + # conflicts + continue try: entry_point.load() except ImportError as e: diff --git a/pyls/plugins/mypy_lint.py b/pyls/plugins/mypy_lint.py new file mode 100644 index 00000000..bc1dc141 --- /dev/null +++ b/pyls/plugins/mypy_lint.py @@ -0,0 +1,82 @@ +import re +import logging +from mypy import api as mypy_api +from pyls import hookimpl + +line_pattern = r"([^:]+):(?:(\d+):)?(?:(\d+):)? (\w+): (.*)" + +log = logging.getLogger(__name__) + + +def parse_line(line, document=None): + ''' + Return a language-server diagnostic from a line of the Mypy error report; + optionally, use the whole document to provide more context on it. + ''' + result = re.match(line_pattern, line) + if result: + file_path, lineno, offset, severity, msg = result.groups() + if file_path != "": # live mode + # results from other files can be included, but we cannot return + # them. + if document and document.path and not document.path.endswith( + file_path): + log.warning("discarding result for %s against %s", file_path, + document.path) + return None + + lineno = int(lineno or 1) - 1 # 0-based line number + offset = int(offset or 1) - 1 # 0-based offset + errno = 2 + if severity == 'error': + errno = 1 + diag = { + 'source': 'mypy', + 'range': { + 'start': {'line': lineno, 'character': offset}, + # There may be a better solution, but mypy does not provide end + 'end': {'line': lineno, 'character': offset + 1} + }, + 'message': msg, + 'severity': errno + } + if document: + # although mypy does not provide the end of the affected range, we + # can make a good guess by highlighting the word that Mypy flagged + word = document.word_at_position(diag['range']['start']) + if word: + diag['range']['end']['character'] = ( + diag['range']['start']['character'] + len(word)) + + return diag + + +@hookimpl +def pyls_lint(config, workspace, document, is_saved): + settings = config.plugin_settings('mypy') + live_mode = settings.get('live_mode', True) + if live_mode: + args = ['--incremental', + '--show-column-numbers', + '--follow-imports', 'silent', + '--command', document.source] + elif is_saved: + args = ['--incremental', + '--show-column-numbers', + '--follow-imports', 'silent', + document.path] + else: + return [] + + if settings.get('strict', False): + args.append('--strict') + + report, errors, _ = mypy_api.run(args) + + diagnostics = [] + for line in report.splitlines(): + diag = parse_line(line, document) + if diag: + diagnostics.append(diag) + + return diagnostics diff --git a/setup.py b/setup.py index f3c465db..b8828924 100755 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ 'autopep8', 'flake8>=3.8.0', 'mccabe>=0.6.0,<0.7.0', + 'mypy>=0.782', 'pycodestyle>=2.6.0,<2.7.0', 'pydocstyle>=2.0.0', 'pyflakes>=2.2.0,<2.3.0', @@ -91,6 +92,7 @@ 'jedi_signature_help = pyls.plugins.signature', 'jedi_symbols = pyls.plugins.symbols', 'mccabe = pyls.plugins.mccabe_lint', + 'mypy = pyls.plugins.mypy_lint', 'preload = pyls.plugins.preload_imports', 'pycodestyle = pyls.plugins.pycodestyle_lint', 'pydocstyle = pyls.plugins.pydocstyle_lint', diff --git a/test/plugins/test_mypy_lint.py b/test/plugins/test_mypy_lint.py new file mode 100644 index 00000000..b35036ac --- /dev/null +++ b/test/plugins/test_mypy_lint.py @@ -0,0 +1,73 @@ +import pytest +import pyls.plugins.mypy_lint as plugin +from pyls.workspace import Document + +DOC_URI = __file__ +DOC_TYPE_ERR = """{}.append(3) +""" +TYPE_ERR_MSG = '"Dict[, ]" has no attribute "append"' + +TEST_LINE = 'test_mypy_lint.py:279:8: error: "Request" has no attribute "id"' +TEST_LINE_WITHOUT_COL = ('test_mypy_lint.py:279: ' + 'error: "Request" has no attribute "id"') +TEST_LINE_WITHOUT_LINE = ('test_mypy_lint.py: ' + 'error: "Request" has no attribute "id"') + + +class FakeConfig(object): + def plugin_settings(self, plugin, document_path=None): + return {} + + +@pytest.fixture +def tmp_workspace(temp_workspace_factory): + return temp_workspace_factory({ + DOC_URI: DOC_TYPE_ERR + }) + + +def test_plugin(tmp_workspace): + config = FakeConfig() + doc = Document(DOC_URI, tmp_workspace, DOC_TYPE_ERR) + workspace = tmp_workspace + diags = plugin.pyls_lint(config, workspace, doc, is_saved=False) + + assert len(diags) == 1 + diag = diags[0] + assert diag['message'] == TYPE_ERR_MSG + assert diag['range']['start'] == {'line': 0, 'character': 0} + assert diag['range']['end'] == {'line': 0, 'character': 1} + + +def test_parse_full_line(tmp_workspace): + doc = Document(DOC_URI, tmp_workspace, DOC_TYPE_ERR) + diag = plugin.parse_line(TEST_LINE, doc) + assert diag['message'] == '"Request" has no attribute "id"' + assert diag['range']['start'] == {'line': 278, 'character': 7} + assert diag['range']['end'] == {'line': 278, 'character': 8} + + +def test_parse_line_without_col(tmp_workspace): + doc = Document(DOC_URI, tmp_workspace, DOC_TYPE_ERR) + diag = plugin.parse_line(TEST_LINE_WITHOUT_COL, doc) + assert diag['message'] == '"Request" has no attribute "id"' + assert diag['range']['start'] == {'line': 278, 'character': 0} + assert diag['range']['end'] == {'line': 278, 'character': 1} + + +def test_parse_line_without_line(tmp_workspace): + doc = Document(DOC_URI, tmp_workspace, DOC_TYPE_ERR) + diag = plugin.parse_line(TEST_LINE_WITHOUT_LINE, doc) + assert diag['message'] == '"Request" has no attribute "id"' + assert diag['range']['start'] == {'line': 0, 'character': 0} + assert diag['range']['end'] == {'line': 0, 'character': 1} + + +@pytest.mark.parametrize('word,bounds', [('', (7, 8)), ('my_var', (7, 13))]) +def test_parse_line_with_context(tmp_workspace, monkeypatch, word, bounds): + doc = Document(DOC_URI, tmp_workspace, 'DOC_TYPE_ERR') + monkeypatch.setattr(Document, 'word_at_position', lambda *args: word) + diag = plugin.parse_line(TEST_LINE, doc) + assert diag['message'] == '"Request" has no attribute "id"' + assert diag['range']['start'] == {'line': 278, 'character': bounds[0]} + assert diag['range']['end'] == {'line': 278, 'character': bounds[1]} diff --git a/vscode-client/package.json b/vscode-client/package.json index 7e4ee59f..4505754a 100644 --- a/vscode-client/package.json +++ b/vscode-client/package.json @@ -115,6 +115,16 @@ "default": 15, "description": "The minimum threshold that triggers warnings about cyclomatic complexity." }, + "pyls.plugins.mypy.enabled": { + "type": "boolean", + "default": false, + "description": "Enable type linting." + }, + "pyls.plugins.mypy.live_mode": { + "type": "boolean", + "default": false, + "description": "Enable live mode type linting." + }, "pyls.plugins.preload.enabled": { "type": "boolean", "default": true,