diff --git a/doc/exts/pylint_extensions.py b/doc/exts/pylint_extensions.py index dd660767c9..157850837b 100755 --- a/doc/exts/pylint_extensions.py +++ b/doc/exts/pylint_extensions.py @@ -8,10 +8,11 @@ import re import sys -import pkg_resources import sphinx +from pylint.constants import MAIN_CHECKER_NAME from pylint.lint import PyLinter +from pylint.utils import get_rst_title # Some modules have been renamed and deprecated under their old names. # Skip documenting these modules since: @@ -50,8 +51,9 @@ def builder_inited(app): base_path, "doc", "technical_reference", "extensions.rst" ) with open(extensions_doc, "w") as stream: - stream.write("Optional Pylint checkers in the extensions module\n") - stream.write("=================================================\n\n") + stream.write( + get_rst_title("Optional Pylint checkers in the extensions module", "=") + ) stream.write("Pylint provides the following optional plugins:\n\n") for module in modules: stream.write("- :ref:`{}`\n".format(module)) @@ -65,47 +67,43 @@ def builder_inited(app): "\n load-plugins=pylint.extensions.docparams," "pylint.extensions.docstyle\n\n" ) - by_module = get_plugins_info(linter, doc_files) - for module, info in sorted(by_module.items()): - linter._print_checker_doc(info["name"], info, stream=stream) + by_checker = get_plugins_info(linter, doc_files) + for checker, information in sorted(by_checker.items()): + linter._print_checker_doc(information, stream=stream) def get_plugins_info(linter, doc_files): - by_module = {} - + by_checker = {} for checker in linter.get_checkers(): - if checker.name == "master": + if checker.name == MAIN_CHECKER_NAME: continue module = checker.__module__ # Plugins only - skip over core checkers if re.match("pylint.checkers", module): continue - # Find any .rst documentation associated with this plugin doc = "" doc_file = doc_files.get(module) if doc_file: with open(doc_file, "r") as f: doc = f.read() - try: - by_module[module]["options"] += checker.options_and_values() - by_module[module]["msgs"].update(checker.msgs) - by_module[module]["reports"] += checker.reports - by_module[module]["doc"] += doc - by_module[module]["name"] += checker.name - by_module[module]["module"] += module + by_checker[checker]["checker"] = checker + by_checker[checker]["options"] += checker.options_and_values() + by_checker[checker]["msgs"].update(checker.msgs) + by_checker[checker]["reports"] += checker.reports + by_checker[checker]["doc"] += doc + by_checker[checker]["module"] += module except KeyError: - by_module[module] = { + by_checker[checker] = { + "checker": checker, "options": list(checker.options_and_values()), "msgs": dict(checker.msgs), "reports": list(checker.reports), "doc": doc, - "name": checker.name, "module": module, } - - return by_module + return by_checker def setup(app): diff --git a/pylint/checkers/base_checker.py b/pylint/checkers/base_checker.py index 6ce8ecf9f1..8229aab056 100644 --- a/pylint/checkers/base_checker.py +++ b/pylint/checkers/base_checker.py @@ -11,12 +11,15 @@ # Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html # For details: https://github.com/PyCQA/pylint/blob/master/COPYING +from inspect import cleandoc from typing import Any from pylint.config import OptionsProviderMixIn +from pylint.constants import _MSG_ORDER, WarningScope from pylint.exceptions import InvalidMessageError -from pylint.interfaces import UNDEFINED -from pylint.message import build_message_definition +from pylint.interfaces import UNDEFINED, IRawChecker, ITokenChecker, implements +from pylint.message.message_definition import MessageDefinition +from pylint.utils import get_rst_section, get_rst_title class BaseChecker(OptionsProviderMixIn): @@ -25,7 +28,7 @@ class BaseChecker(OptionsProviderMixIn): name = None # type: str # options level (0 will be displaying in --help, 1 in --long-help) level = 1 - # ordered list of options to control the ckecker behaviour + # ordered list of options to control the checker behaviour options = () # type: Any # messages issued by this checker msgs = {} # type: Any @@ -43,18 +46,68 @@ def __init__(self, linter=None): OptionsProviderMixIn.__init__(self) self.linter = linter + def __gt__(self, other): + """Permit to sort a list of Checker by name.""" + return "{}{}".format(self.name, self.msgs).__gt__( + "{}{}".format(other.name, other.msgs) + ) + + def __repr__(self): + status = "Checker" if self.enabled else "Disabled checker" + return "{} '{}' responsible for {}".format( + status, self.name, ", ".join(self.msgs.keys()) + ) + + def __str__(self): + """This might be incomplete because multiple class inheriting BaseChecker + can have the same name. Cf MessageHandlerMixIn.get_full_documentation()""" + return self.get_full_documentation( + msgs=self.msgs, options=self.options_and_values(), reports=self.reports + ) + + def get_full_documentation(self, msgs, options, reports, doc=None, module=None): + result = "" + checker_title = "%s checker" % (self.name.replace("_", " ").title()) + if module: + # Provide anchor to link against + result += ".. _%s:\n\n" % module + result += "%s\n" % get_rst_title(checker_title, "~") + if module: + result += "This checker is provided by ``%s``.\n" % module + result += "Verbatim name of the checker is ``%s``.\n\n" % self.name + if doc: + # Provide anchor to link against + result += get_rst_title("{} Documentation".format(checker_title), "^") + result += "%s\n\n" % cleandoc(doc) + # options might be an empty generator and not be False when casted to boolean + options = list(options) + if options: + result += get_rst_title("{} Options".format(checker_title), "^") + result += "%s\n" % get_rst_section(None, options) + if msgs: + result += get_rst_title("{} Messages".format(checker_title), "^") + for msgid, msg in sorted( + msgs.items(), key=lambda kv: (_MSG_ORDER.index(kv[0][0]), kv[1]) + ): + msg = self.create_message_definition_from_tuple(msgid, msg) + result += "%s\n" % msg.format_help(checkerref=False) + result += "\n" + if reports: + result += get_rst_title("{} Reports".format(checker_title), "^") + for report in reports: + result += ":%s: %s\n" % report[:2] + result += "\n" + result += "\n" + return result + def add_message( - self, - msgid, - line=None, - node=None, - args=None, - confidence=UNDEFINED, - col_offset=None, + self, msgid, line=None, node=None, args=None, confidence=None, col_offset=None ): + if not confidence: + confidence = UNDEFINED self.linter.add_message(msgid, line, node, args, confidence, col_offset) - def check_consistency(self) -> None: + def check_consistency(self): """Check the consistency of msgid. msg ids for a checker should be a string of len 4, where the two first @@ -78,15 +131,47 @@ def check_consistency(self) -> None: checker_id = message.msgid[1:3] existing_ids.append(message.msgid) + def create_message_definition_from_tuple(self, msgid, msg_tuple): + if implements(self, (IRawChecker, ITokenChecker)): + default_scope = WarningScope.LINE + else: + default_scope = WarningScope.NODE + options = {} + if len(msg_tuple) > 3: + (msg, symbol, descr, options) = msg_tuple + elif len(msg_tuple) > 2: + (msg, symbol, descr) = msg_tuple + else: + error_msg = """Messages should have a msgid and a symbol. Something like this : + +"W1234": ( + "message", + "message-symbol", + "Message description with detail.", + ... +), +""" + raise InvalidMessageError(error_msg) + options.setdefault("scope", default_scope) + return MessageDefinition(self, msgid, msg, descr, symbol, **options) + @property def messages(self) -> list: return [ - build_message_definition(self, msgid, msg_tuple) + self.create_message_definition_from_tuple(msgid, msg_tuple) for msgid, msg_tuple in sorted(self.msgs.items()) ] # dummy methods implementing the IChecker interface + def get_message_definition(self, msgid): + for message_definition in self.messages: + if message_definition.msgid == msgid: + return message_definition + error_msg = "MessageDefinition for '{}' does not exists. ".format(msgid) + error_msg += "Choose from {}.".format([m.msgid for m in self.messages]) + raise InvalidMessageError(error_msg) + def open(self): """called before visiting project (i.e set of modules)""" diff --git a/pylint/constants.py b/pylint/constants.py index 07d1727ab2..852fc15108 100644 --- a/pylint/constants.py +++ b/pylint/constants.py @@ -32,6 +32,11 @@ MSG_TYPES_STATUS = {"I": 0, "C": 16, "R": 8, "W": 4, "E": 2, "F": 1} +# You probably don't want to change the MAIN_CHECKER_NAME +# This would affect rcfile generation and retro-compatibility +# on all project using [MASTER] in their rcfile. +MAIN_CHECKER_NAME = "master" + class WarningScope: LINE = "line-based-msg" diff --git a/pylint/lint.py b/pylint/lint.py index f599196490..a19865343d 100644 --- a/pylint/lint.py +++ b/pylint/lint.py @@ -77,7 +77,7 @@ from pylint import checkers, config, exceptions, interfaces, reporters from pylint.__pkginfo__ import version -from pylint.constants import MSG_TYPES, OPTION_RGX +from pylint.constants import MAIN_CHECKER_NAME, MSG_TYPES, OPTION_RGX from pylint.message import Message, MessagesHandlerMixIn, MessagesStore from pylint.reporters.ureports import nodes as report_nodes from pylint.utils import ASTWalker, FileState, utils @@ -326,7 +326,7 @@ class PyLinter( __implements__ = (interfaces.ITokenChecker,) - name = "master" + name = MAIN_CHECKER_NAME priority = 0 level = 0 msgs = MSGS @@ -922,7 +922,11 @@ def get_checker_names(self): """Get all the checker names that this linter knows about.""" current_checkers = self.get_checkers() return sorted( - {check.name for check in current_checkers if check.name != "master"} + { + checker.name + for checker in current_checkers + if checker.name != MAIN_CHECKER_NAME + } ) def prepare_checkers(self): diff --git a/pylint/message/__init__.py b/pylint/message/__init__.py index 430d066539..1616eeea0c 100644 --- a/pylint/message/__init__.py +++ b/pylint/message/__init__.py @@ -39,7 +39,6 @@ """All the classes related to Message handling.""" -from pylint.message.build_message_definition import build_message_definition from pylint.message.message import Message from pylint.message.message_definition import MessageDefinition from pylint.message.message_handler_mix_in import MessagesHandlerMixIn diff --git a/pylint/message/build_message_definition.py b/pylint/message/build_message_definition.py deleted file mode 100644 index 4de204d048..0000000000 --- a/pylint/message/build_message_definition.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- - -# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html -# For details: https://github.com/PyCQA/pylint/blob/master/COPYING - -import warnings - -from pylint.constants import WarningScope -from pylint.interfaces import IRawChecker, ITokenChecker, implements -from pylint.message.message_definition import MessageDefinition - - -def build_message_definition(checker, msgid, msg_tuple): - if implements(checker, (IRawChecker, ITokenChecker)): - default_scope = WarningScope.LINE - else: - default_scope = WarningScope.NODE - options = {} - if len(msg_tuple) > 3: - (msg, symbol, descr, options) = msg_tuple - elif len(msg_tuple) > 2: - (msg, symbol, descr) = msg_tuple - else: - # messages should have a symbol, but for backward compatibility - # they may not. - (msg, descr) = msg_tuple - warnings.warn( - "[pylint 0.26] description of message %s doesn't include " - "a symbolic name" % msgid, - DeprecationWarning, - ) - symbol = None - options.setdefault("scope", default_scope) - return MessageDefinition(checker, msgid, msg, descr, symbol, **options) diff --git a/pylint/message/message_handler_mix_in.py b/pylint/message/message_handler_mix_in.py index 53eb86870a..06e1368bac 100644 --- a/pylint/message/message_handler_mix_in.py +++ b/pylint/message/message_handler_mix_in.py @@ -6,11 +6,10 @@ from __future__ import print_function import sys -from inspect import cleandoc from pylint.constants import ( - _MSG_ORDER, _SCOPE_EXEMPT, + MAIN_CHECKER_NAME, MSG_STATE_CONFIDENCE, MSG_STATE_SCOPE_CONFIG, MSG_STATE_SCOPE_MODULE, @@ -20,35 +19,15 @@ ) from pylint.exceptions import InvalidMessageError, UnknownMessageError from pylint.interfaces import UNDEFINED -from pylint.message.build_message_definition import build_message_definition from pylint.message.message import Message -from pylint.utils.utils import ( - _format_option_value, +from pylint.utils import ( category_id, get_module_and_frameid, - normalize_text, + get_rst_section, + get_rst_title, ) -def _rest_format_section(stream, section, options, doc=None): - """format an options section using as ReST formatted output""" - if section: - print("%s\n%s" % (section, "'" * len(section)), file=stream) - if doc: - print(normalize_text(doc, line_len=79, indent=""), file=stream) - print(file=stream) - for optname, optdict, value in options: - help_opt = optdict.get("help") - print(":%s:" % optname, file=stream) - if help_opt: - help_opt = normalize_text(help_opt, line_len=79, indent=" ") - print(help_opt, file=stream) - if value: - value = str(_format_option_value(optdict, value)) - print(file=stream) - print(" Default: ``%s``" % value.replace("`` ", "```` ``"), file=stream) - - class MessagesHandlerMixIn: """a mix-in class containing all the messages related methods for the main lint class @@ -345,113 +324,76 @@ def add_one_message( ) ) - def print_full_documentation(self, stream=None): - """output a full documentation in ReST format""" - if not stream: - stream = sys.stdout - - print("Pylint global options and switches", file=stream) - print("----------------------------------", file=stream) - print("", file=stream) - print("Pylint provides global options and switches.", file=stream) - print("", file=stream) - + def _get_checkers_infos(self): by_checker = {} for checker in self.get_checkers(): - if checker.name == "master": - if checker.options: - for section, options in checker.options_by_section(): - if section is None: - title = "General options" - else: - title = "%s options" % section.capitalize() - print(title, file=stream) - print("~" * len(title), file=stream) - _rest_format_section(stream, None, options) - print("", file=stream) - else: - name = checker.name + name = checker.name + if name != "master": try: + by_checker[name]["checker"] = checker by_checker[name]["options"] += checker.options_and_values() by_checker[name]["msgs"].update(checker.msgs) by_checker[name]["reports"] += checker.reports except KeyError: by_checker[name] = { + "checker": checker, "options": list(checker.options_and_values()), "msgs": dict(checker.msgs), "reports": list(checker.reports), } + return by_checker - print("Pylint checkers' options and switches", file=stream) - print("-------------------------------------", file=stream) - print("", file=stream) - print("Pylint checkers can provide three set of features:", file=stream) - print("", file=stream) - print("* options that control their execution,", file=stream) - print("* messages that they can raise,", file=stream) - print("* reports that they can generate.", file=stream) - print("", file=stream) - print("Below is a list of all checkers and their features.", file=stream) - print("", file=stream) - - for checker, info in sorted(by_checker.items()): - self._print_checker_doc(checker, info, stream=stream) + def get_checkers_documentation(self): + result = get_rst_title("Pylint global options and switches", "-") + result += """ +Pylint provides global options and switches. + +""" + for checker in self.get_checkers(): + name = checker.name + if name == MAIN_CHECKER_NAME: + if checker.options: + for section, options in checker.options_by_section(): + if section is None: + title = "General options" + else: + title = "%s options" % section.capitalize() + result += get_rst_title(title, "~") + result += "%s\n" % get_rst_section(None, options) + result += get_rst_title("Pylint checkers' options and switches", "-") + result += """\ + +Pylint checkers can provide three set of features: + +* options that control their execution, +* messages that they can raise, +* reports that they can generate. + +Below is a list of all checkers and their features. + +""" + by_checker = self._get_checkers_infos() + for checker in sorted(by_checker): + information = by_checker[checker] + checker = information["checker"] + del information["checker"] + result += checker.get_full_documentation(**information) + return result + + def print_full_documentation(self, stream=None): + """output a full documentation in ReST format""" + if not stream: + stream = sys.stdout + print(self.get_checkers_documentation()[:-1], file=stream) @staticmethod - def _print_checker_doc(checker_name, info, stream=None): + def _print_checker_doc(information, stream=None): """Helper method for print_full_documentation. Also used by doc/exts/pylint_extensions.py. """ if not stream: stream = sys.stdout - - doc = info.get("doc") - module = info.get("module") - msgs = info.get("msgs") - options = info.get("options") - reports = info.get("reports") - - checker_title = "%s checker" % (checker_name.replace("_", " ").title()) - - if module: - # Provide anchor to link against - print(".. _%s:\n" % module, file=stream) - print(checker_title, file=stream) - print("~" * len(checker_title), file=stream) - print("", file=stream) - if module: - print("This checker is provided by ``%s``." % module, file=stream) - print("Verbatim name of the checker is ``%s``." % checker_name, file=stream) - print("", file=stream) - if doc: - # Provide anchor to link against - title = "{} Documentation".format(checker_title) - print(title, file=stream) - print("^" * len(title), file=stream) - print(cleandoc(doc), file=stream) - print("", file=stream) - if options: - title = "{} Options".format(checker_title) - print(title, file=stream) - print("^" * len(title), file=stream) - _rest_format_section(stream, None, options) - print("", file=stream) - if msgs: - title = "{} Messages".format(checker_title) - print(title, file=stream) - print("^" * len(title), file=stream) - for msgid, msg in sorted( - msgs.items(), key=lambda kv: (_MSG_ORDER.index(kv[0][0]), kv[1]) - ): - msg = build_message_definition(checker_name, msgid, msg) - print(msg.format_help(checkerref=False), file=stream) - print("", file=stream) - if reports: - title = "{} Reports".format(checker_title) - print(title, file=stream) - print("^" * len(title), file=stream) - for report in reports: - print(":%s: %s" % report[:2], file=stream) - print("", file=stream) - print("", file=stream) + checker = information["checker"] + del information["checker"] + print(checker.get_full_documentation(**information)[:-1], file=stream) diff --git a/pylint/utils/__init__.py b/pylint/utils/__init__.py index 67b56db3a8..afde963925 100644 --- a/pylint/utils/__init__.py +++ b/pylint/utils/__init__.py @@ -56,6 +56,8 @@ format_section, get_global_option, get_module_and_frameid, + get_rst_section, + get_rst_title, normalize_text, register_plugins, safe_decode, diff --git a/pylint/utils/utils.py b/pylint/utils/utils.py index ab28a07b5b..81cbc34484 100644 --- a/pylint/utils/utils.py +++ b/pylint/utils/utils.py @@ -51,6 +51,31 @@ def category_id(cid): return MSG_TYPES_LONG.get(cid) +def get_rst_title(title, character): + """Permit to get a title formatted as ReStructuredText test (underlined with a chosen character).""" + return "%s\n%s\n" % (title, character * len(title)) + + +def get_rst_section(section, options, doc=None): + """format an options section using as a ReStructuredText formatted output""" + result = "" + if section: + result += get_rst_title(section, "'") + if doc: + formatted_doc = normalize_text(doc, line_len=79, indent="") + result += "%s\n\n" % formatted_doc + for optname, optdict, value in options: + help_opt = optdict.get("help") + result += ":%s:\n" % optname + if help_opt: + formatted_help = normalize_text(help_opt, line_len=79, indent=" ") + result += "%s\n" % formatted_help + if value: + value = str(_format_option_value(optdict, value)) + result += "\n Default: ``%s``\n" % value.replace("`` ", "```` ``") + return result + + def safe_decode(line, encoding, *args, **kwargs): """return decoded line from encoding or decode with default encoding""" try: diff --git a/tests/message/unittest_message_store.py b/tests/message/unittest_message_store.py index 053bfca500..732cb7872a 100644 --- a/tests/message/unittest_message_store.py +++ b/tests/message/unittest_message_store.py @@ -38,6 +38,33 @@ class Checker(BaseChecker): return store +def test_format_help(capsys, store): + store.help_message([]) + captured = capsys.readouterr() + assert captured.out == "" + store.help_message(["W1234", "E1234", "C1234"]) + captured = capsys.readouterr() + assert ( + captured.out + == """:msg-symbol (W1234): *message* + msg description. This message belongs to the achecker checker. + +:duplicate-keyword-arg (E1234): *Duplicate keyword argument %r in %s call* + Used when a function call passes the same keyword argument multiple times. + This message belongs to the achecker checker. It can't be emitted when using + Python >= 2.6. + +No such message id or symbol 'C1234'. + +""" + ) + + +def test_get_msg_display_string(store): + assert store.get_msg_display_string("W1234") == "'msg-symbol'" + assert store.get_msg_display_string("E1234") == "'duplicate-keyword-arg'" + + class TestMessagesStore(object): def _compare_messages(self, desc, msg, checkerref=False): assert desc == msg.format_help(checkerref=checkerref) diff --git a/tests/test_self.py b/tests/test_self.py index 3cf11ad6b3..a1e3bb4465 100644 --- a/tests/test_self.py +++ b/tests/test_self.py @@ -34,6 +34,7 @@ import pytest +from pylint.constants import MAIN_CHECKER_NAME from pylint.lint import Run from pylint.reporters import JSONReporter from pylint.reporters.text import * @@ -183,7 +184,9 @@ def test_generate_config_disable_symbolic_names(self): output = out.getvalue() # Get rid of the pesky messages that pylint emits if the # configuration file is not found. - master = re.search(r"\[MASTER", output) + pattern = r"\[{}".format(MAIN_CHECKER_NAME.upper()) + master = re.search(pattern, output) + assert master is not None, "{} not found in {}".format(pattern, output) out = StringIO(output[master.start() :]) parser = configparser.RawConfigParser() parser.read_file(out) diff --git a/tests/unittest_checker_base.py b/tests/unittest_checker_base.py index 1feae5b7ac..b50fbf1a65 100644 --- a/tests/unittest_checker_base.py +++ b/tests/unittest_checker_base.py @@ -23,7 +23,7 @@ import astroid -from pylint.checkers import base +from pylint.checkers import BaseChecker, base from pylint.testutils import CheckerTestCase, Message, set_config @@ -526,3 +526,63 @@ def test_pascal_case(self): self._test_name_is_incorrect_for_all_name_types(naming_style, name) self._test_should_always_pass(naming_style) + + +class TestBaseChecker(unittest.TestCase): + def test_doc(self): + class OtherBasicChecker(BaseChecker): + name = "basic" + msgs = { + "W0001": ( + "Basic checker has an example.", + "basic-checker-example", + "Used nowhere and serves no purpose.", + ) + } + + class LessBasicChecker(OtherBasicChecker): + options = ( + ( + "example-args", + { + "default": 42, + "type": "int", + "metavar": "", + "help": "Example of integer argument for the checker.", + }, + ), + ) + + basic = OtherBasicChecker() + expected_beginning = """\ +Basic checker +~~~~~~~~~~~~~ + +Verbatim name of the checker is ``basic``. + +""" + expected_middle = """\ +Basic checker Options +^^^^^^^^^^^^^^^^^^^^^ +:example-args: + Example of integer argument for the checker. + + Default: ``42`` + +""" + expected_end = """\ +Basic checker Messages +^^^^^^^^^^^^^^^^^^^^^^ +:basic-checker-example (W0001): *Basic checker has an example.* + Used nowhere and serves no purpose. + + +""" + self.assertEqual(str(basic), expected_beginning + expected_end) + self.assertEqual(repr(basic), "Checker 'basic' responsible for W0001") + less_basic = LessBasicChecker() + + self.assertEqual( + str(less_basic), expected_beginning + expected_middle + expected_end + ) + self.assertEqual(repr(less_basic), repr(basic))