From fb5a2a6dd70f4f8dae84c92c10d88dbe29c20dd6 Mon Sep 17 00:00:00 2001 From: Thomas Hisch Date: Sat, 2 Mar 2019 14:28:31 +0100 Subject: Add support for reading from stdin (#2746) pylint gained a new `--from-stdin` flag which activates stdin linting, useful for editors and similar use cases. Closes: #1187 --- pylint/exceptions.py | 4 ++ pylint/lint.py | 101 ++++++++++++++++++++++++++++++++++++----------- pylint/test/test_self.py | 77 ++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 22 deletions(-) (limited to 'pylint') diff --git a/pylint/exceptions.py b/pylint/exceptions.py index 57353a466..d5dd17fb2 100644 --- a/pylint/exceptions.py +++ b/pylint/exceptions.py @@ -23,3 +23,7 @@ class EmptyReportError(Exception): class InvalidReporterError(Exception): """raised when selected reporter is invalid (e.g. not found)""" + + +class InvalidArgsError(ValueError): + """raised when passed arguments are invalid, e.g., have the wrong length""" diff --git a/pylint/lint.py b/pylint/lint.py index a0c970a56..1b53b1658 100644 --- a/pylint/lint.py +++ b/pylint/lint.py @@ -62,6 +62,7 @@ from __future__ import print_function import collections import contextlib +from io import TextIOWrapper import operator import os @@ -75,6 +76,7 @@ import warnings import astroid from astroid.__pkginfo__ import version as astroid_version +from astroid.builder import AstroidBuilder from astroid import modutils from pylint import checkers from pylint import interfaces @@ -89,6 +91,21 @@ from pylint.reporters.ureports import nodes as report_nodes MANAGER = astroid.MANAGER +def _ast_from_string(data, filepath, modname): + cached = MANAGER.astroid_cache.get(modname) + if cached and cached.file == filepath: + return cached + + return AstroidBuilder(MANAGER).string_build(data, modname, filepath) + + +def _read_stdin(): + # https://mail.python.org/pipermail/python-list/2012-November/634424.html + # FIXME should this try to check the file's declared encoding? + sys.stdin = TextIOWrapper(sys.stdin.detach(), encoding="utf-8") + return sys.stdin.read() + + def _get_new_args(message): location = ( message.abspath, @@ -556,6 +573,16 @@ class PyLinter( ), }, ), + ( + "from-stdin", + { + "action": "store_true", + "help": ( + "Interpret the stdin as a python script, whose filename " + "needs to be passed as the module_or_package argument." + ), + }, + ), ) option_groups = ( @@ -1054,31 +1081,61 @@ class PyLinter( if interfaces.implements(checker, interfaces.IAstroidChecker): walker.add_checker(checker) # build ast and check modules or packages - for descr in self.expand_files(files_or_modules): - modname, filepath, is_arg = descr["name"], descr["path"], descr["isarg"] - if not self.should_analyze_file(modname, filepath, is_argument=is_arg): - continue + if self.config.from_stdin: + if len(files_or_modules) != 1: + raise exceptions.InvalidArgsError( + "Missing filename required for --from-stdin" + ) + + filepath = files_or_modules[0] + try: + # Note that this function does not really perform an + # __import__ but may raise an ImportError exception, which + # we want to catch here. + modname = ".".join(modutils.modpath_from_file(filepath)) + except ImportError: + modname = os.path.splitext(os.path.basename(filepath))[0] self.set_current_module(modname, filepath) + # get the module representation - ast_node = self.get_ast(filepath, modname) - if ast_node is None: - continue - # XXX to be correct we need to keep module_msgs_state for every - # analyzed module (the problem stands with localized messages which - # are only detected in the .close step) - self.file_state = utils.FileState(descr["basename"]) - self._ignore_file = False - # fix the current file (if the source file was not available or - # if it's actually a c extension) - self.current_file = ast_node.file # pylint: disable=maybe-no-member - self.check_astroid_module(ast_node, walker, rawcheckers, tokencheckers) - # warn about spurious inline messages handling - spurious_messages = self.file_state.iter_spurious_suppression_messages( - self.msgs_store - ) - for msgid, line, args in spurious_messages: - self.add_message(msgid, line, None, args) + ast_node = _ast_from_string(_read_stdin(), filepath, modname) + + if ast_node is not None: + self.file_state = utils.FileState(filepath) + self.check_astroid_module(ast_node, walker, rawcheckers, tokencheckers) + # warn about spurious inline messages handling + spurious_messages = self.file_state.iter_spurious_suppression_messages( + self.msgs_store + ) + for msgid, line, args in spurious_messages: + self.add_message(msgid, line, None, args) + else: + for descr in self.expand_files(files_or_modules): + modname, filepath, is_arg = descr["name"], descr["path"], descr["isarg"] + if not self.should_analyze_file(modname, filepath, is_argument=is_arg): + continue + + self.set_current_module(modname, filepath) + # get the module representation + ast_node = self.get_ast(filepath, modname) + if ast_node is None: + continue + # XXX to be correct we need to keep module_msgs_state for every + # analyzed module (the problem stands with localized messages which + # are only detected in the .close step) + self.file_state = utils.FileState(descr["basename"]) + self._ignore_file = False + # fix the current file (if the source file was not available or + # if it's actually a c extension) + self.current_file = ast_node.file # pylint: disable=maybe-no-member + self.check_astroid_module(ast_node, walker, rawcheckers, tokencheckers) + # warn about spurious inline messages handling + spurious_messages = self.file_state.iter_spurious_suppression_messages( + self.msgs_store + ) + for msgid, line, args in spurious_messages: + self.add_message(msgid, line, None, args) # notify global end self.stats["statement"] = walker.nbstatements for checker in reversed(_checkers): diff --git a/pylint/test/test_self.py b/pylint/test/test_self.py index d03566eeb..224e1e44d 100644 --- a/pylint/test/test_self.py +++ b/pylint/test/test_self.py @@ -30,6 +30,7 @@ import tempfile import textwrap import configparser from io import StringIO +from unittest import mock from pylint.lint import Run from pylint.reporters import BaseReporter @@ -568,3 +569,79 @@ class TestRunTC(object): finally: os.remove(module) os.removedirs(fake_path) + + @pytest.mark.parametrize( + "input_path,module,expected_path", + [ + (join(HERE, "mymodule.py"), "mymodule", "pylint/test/mymodule.py"), + ("mymodule.py", "mymodule", "mymodule.py"), + ], + ) + def test_stdin(self, input_path, module, expected_path): + expected_output = ( + "************* Module {module}\n" + "{path}:1:0: C0111: Missing module docstring (missing-docstring)\n" + "{path}:1:0: W0611: Unused import os (unused-import)\n\n" + ).format(path=expected_path, module=module) + + with mock.patch( + "pylint.lint._read_stdin", return_value="import os\n" + ) as mock_stdin: + self._test_output( + ["--from-stdin", input_path], expected_output=expected_output + ) + assert mock_stdin.call_count == 1 + + def test_stdin_missing_modulename(self): + self._runtest(["--from-stdin"], code=32) + + @pytest.mark.parametrize("write_bpy_to_disk", [False, True]) + def test_relative_imports(self, write_bpy_to_disk, tmpdir): + a = tmpdir.join("a") + + b_code = textwrap.dedent( + """ + from .c import foobar + from .d import bla # module does not exist + + foobar('hello') + bla() + """ + ) + + c_code = textwrap.dedent( + """ + def foobar(arg): + pass + """ + ) + + a.mkdir() + a.join("__init__.py").write("") + if write_bpy_to_disk: + a.join("b.py").write(b_code) + a.join("c.py").write(c_code) + + curdir = os.getcwd() + try: + # why don't we start pylint in a subprocess? + os.chdir(str(tmpdir)) + expected = ( + "************* Module a.b\n" + "a/b.py:1:0: C0111: Missing module docstring (missing-docstring)\n" + "a/b.py:3:0: E0401: Unable to import 'a.d' (import-error)\n\n" + ) + + if write_bpy_to_disk: + # --from-stdin is not used here + self._test_output(["a/b.py"], expected_output=expected) + + # this code needs to work w/ and w/o a file named a/b.py on the + # harddisk. + with mock.patch("pylint.lint._read_stdin", return_value=b_code): + self._test_output( + ["--from-stdin", join("a", "b.py")], expected_output=expected + ) + + finally: + os.chdir(curdir) -- cgit v1.2.1