diff options
-rw-r--r-- | pylint/checkers/base_checker.py | 19 | ||||
-rw-r--r-- | pylint/checkers/logging.py | 7 | ||||
-rw-r--r-- | pylint/config/__init__.py | 4 | ||||
-rw-r--r-- | pylint/config/argument.py | 76 | ||||
-rw-r--r-- | pylint/config/arguments_manager.py | 107 | ||||
-rw-r--r-- | pylint/config/config_initialization.py | 20 | ||||
-rw-r--r-- | pylint/config/exceptions.py | 13 | ||||
-rw-r--r-- | pylint/config/utils.py | 34 | ||||
-rw-r--r-- | pylint/lint/pylinter.py | 9 | ||||
-rw-r--r-- | pylintrc | 2 | ||||
-rw-r--r-- | tests/config/data/logging_format_interpolation_style.py | 13 | ||||
-rw-r--r-- | tests/config/data/logging_format_interpolation_style.rc | 2 | ||||
-rw-r--r-- | tests/config/test_argparse_config.py | 56 | ||||
-rw-r--r-- | tests/test_regr.py | 1 |
14 files changed, 353 insertions, 10 deletions
diff --git a/pylint/checkers/base_checker.py b/pylint/checkers/base_checker.py index 1cdc014fc..21c63364a 100644 --- a/pylint/checkers/base_checker.py +++ b/pylint/checkers/base_checker.py @@ -3,18 +3,25 @@ # Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt import functools +import sys from inspect import cleandoc from typing import Any, Optional from astroid import nodes from pylint.config import OptionsProviderMixIn +from pylint.config.exceptions import MissingArgumentManager from pylint.constants import _MSG_ORDER, WarningScope from pylint.exceptions import InvalidMessageError from pylint.interfaces import Confidence, IRawChecker, ITokenChecker, implements from pylint.message.message_definition import MessageDefinition from pylint.utils import get_rst_section, get_rst_title +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + @functools.total_ordering class BaseChecker(OptionsProviderMixIn): @@ -32,16 +39,26 @@ class BaseChecker(OptionsProviderMixIn): # mark this checker as enabled or not. enabled: bool = True - def __init__(self, linter=None): + def __init__( + self, linter=None, *, future_option_parsing: Literal[None, True] = None + ): """Checker instances should have the linter as argument. :param ILinter linter: is an object implementing ILinter. + :raises MissingArgumentManager: If no linter object is passed. """ if self.name is not None: self.name = self.name.lower() super().__init__() self.linter = linter + if future_option_parsing: + # We need a PyLinter object that subclasses _ArgumentsManager to register options + if not self.linter: + raise MissingArgumentManager + + self.linter._register_options_provider(self) + def __gt__(self, other): """Permit to sort a list of Checker by name.""" return f"{self.name}{self.msgs}".__gt__(f"{other.name}{other.msgs}") diff --git a/pylint/checkers/logging.py b/pylint/checkers/logging.py index 743f40db8..d9d73d3c1 100644 --- a/pylint/checkers/logging.py +++ b/pylint/checkers/logging.py @@ -144,15 +144,18 @@ class LoggingChecker(checkers.BaseChecker): ), ) + def __init__(self, linter: "PyLinter") -> None: + super().__init__(linter=linter, future_option_parsing=True) + def visit_module(self, _: nodes.Module) -> None: """Clears any state left in this checker from last module checked.""" # The code being checked can just as easily "import logging as foo", # so it is necessary to process the imports and store in this field # what name the logging module is actually given. self._logging_names: Set[str] = set() - logging_mods = self.config.logging_modules + logging_mods = self.linter.namespace.logging_modules - self._format_style = self.config.logging_format_style + self._format_style = self.linter.namespace.logging_format_style self._logging_modules = set(logging_mods) self._from_imports = {} diff --git a/pylint/config/__init__.py b/pylint/config/__init__.py index 16df5fd89..a605fd9d3 100644 --- a/pylint/config/__init__.py +++ b/pylint/config/__init__.py @@ -8,6 +8,8 @@ import pickle import sys from datetime import datetime +from pylint.config.argument import _Argument +from pylint.config.arguments_manager import _ArgumentsManager from pylint.config.configuration_mixin import ConfigurationMixIn from pylint.config.find_default_config_files import ( find_default_config_files, @@ -22,6 +24,8 @@ from pylint.constants import DEFAULT_PYLINT_HOME, OLD_DEFAULT_PYLINT_HOME from pylint.utils import LinterStats __all__ = [ + "_Argument", + "_ArgumentsManager", "ConfigurationMixIn", "find_default_config_files", "_ManHelpFormatter", diff --git a/pylint/config/argument.py b/pylint/config/argument.py new file mode 100644 index 000000000..bf35f1a85 --- /dev/null +++ b/pylint/config/argument.py @@ -0,0 +1,76 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt + +"""Definition of an Argument class and validators for various argument types. + +An Argument instance represents a pylint option to be handled by an argparse.ArgumentParser +""" + + +from typing import Any, Callable, Dict, List, Optional, Union + +from pylint import utils as pylint_utils + +_ArgumentTypes = Union[str, List[str]] +"""List of possible argument types.""" + + +def _csv_validator(value: Union[str, List[str]]) -> List[str]: + """Validates a comma separated string.""" + return pylint_utils._check_csv(value) + + +_ASSIGNMENT_VALIDATORS: Dict[str, Callable[[Any], _ArgumentTypes]] = { + "choice": str, + "csv": _csv_validator, +} +"""Validators for all assignment types.""" + + +class _Argument: + """Class representing an argument to be passed by an argparse.ArgumentsParser. + + This is based on the parameters passed to argparse.ArgumentsParser.add_message. + See: + https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument + """ + + def __init__( + self, + flags: List[str], + action: str, + default: _ArgumentTypes, + arg_type: str, + choices: Optional[List[str]], + arg_help: str, + metavar: str, + ) -> None: + self.flags = flags + """The name of the argument.""" + + self.action = action + """The action to perform with the argument.""" + + self.type = _ASSIGNMENT_VALIDATORS[arg_type] + """A validator function that returns and checks the type of the argument.""" + + self.default = self.type(default) + """The default value of the argument.""" + + self.choices = choices + """A list of possible choices for the argument. + + None if there are no restrictions. + """ + + # argparse uses % formatting on help strings, so a % needs to be escaped + self.help = arg_help.replace("%", "%%") + """The description of the argument.""" + + self.metavar = metavar + """The metavar of the argument. + + See: + https://docs.python.org/3/library/argparse.html#metavar + """ diff --git a/pylint/config/arguments_manager.py b/pylint/config/arguments_manager.py new file mode 100644 index 000000000..300ded617 --- /dev/null +++ b/pylint/config/arguments_manager.py @@ -0,0 +1,107 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt + +"""Arguments manager class used to handle command-line arguments and options.""" + +import argparse +import configparser +from typing import TYPE_CHECKING, Dict, List + +from pylint.config.argument import _Argument +from pylint.config.exceptions import UnrecognizedArgumentAction +from pylint.config.utils import _convert_option_to_argument + +if TYPE_CHECKING: + from pylint import checkers + + +class _ArgumentsManager: + """Arguments manager class used to handle command-line arguments and options.""" + + def __init__(self) -> None: + self.namespace = argparse.Namespace() + """Namespace for all options.""" + + self._arg_parser = argparse.ArgumentParser(prog="pylint") + """The command line argument parser.""" + + self._argument_groups_dict: Dict[str, argparse._ArgumentGroup] = {} + """Dictionary of all the argument groups.""" + + def _register_options_provider(self, provider: "checkers.BaseChecker") -> None: + """Register an options provider and load its defaults.""" + # pylint: disable-next=fixme + # TODO: Do something own_group parameter (see OptionsManagerMixIn.register_options_provider) + for opt, optdict in provider.options: + argument = _convert_option_to_argument(opt, optdict) + self._add_arguments_to_parser(provider.name, argument) + + # pylint: disable-next=fixme + # TODO: Do something with option provider priorities (see OptionsManagerMixIn.register_options_provider) + # pylint: disable-next=fixme + # TODO: Do something with option groups within optdicts (see OptionsManagerMixIn.register_options_provider) + + # pylint: disable-next=fixme + # TODO: Investigate performance impact of loading default arguments on every call + self._load_default_argument_values() + + def _add_arguments_to_parser(self, section: str, argument: _Argument) -> None: + """Iterates over all argument sections and add them to the parser object.""" + try: + section_group = self._argument_groups_dict[section] + except KeyError: + section_group = self._arg_parser.add_argument_group(title=section) + self._argument_groups_dict[section] = section_group + self._add_parser_option(section_group, argument) + + @staticmethod + def _add_parser_option( + section_group: argparse._ArgumentGroup, argument: _Argument + ) -> None: + """Add an argument.""" + if argument.action == "store": + section_group.add_argument( + *argument.flags, + action=argument.action, + default=argument.default, + type=argument.type, # type: ignore[arg-type] # incorrect typing in typeshed + help=argument.help, + metavar=argument.metavar, + choices=argument.choices, + ) + else: + raise UnrecognizedArgumentAction + + def _load_default_argument_values(self) -> None: + """Loads the default values of all registered options.""" + self.namespace = self._arg_parser.parse_args([], self.namespace) + + def _parse_configuration_file( + self, config_parser: configparser.ConfigParser + ) -> None: + """Parse the arguments found in a configuration file into the namespace.""" + arguments = [] + for section in config_parser.sections(): + for opt, value in config_parser.items(section): + arguments.extend([f"--{opt}", value]) + # pylint: disable-next=fixme + # TODO: This should parse_args instead of parse_known_args + self.namespace = self._arg_parser.parse_known_args(arguments, self.namespace)[0] + + def _parse_command_line_configuration(self, arguments: List[str]) -> None: + """Parse the arguments found on the command line into the namespace.""" + # pylint: disable-next=fixme + # TODO: This should parse_args instead of parse_known_args + self.namespace = self._arg_parser.parse_known_args(arguments, self.namespace)[0] + + # pylint: disable-next=fixme + # TODO: This should return a list of arguments with the option arguments removed + # just as PyLinter.load_command_line_configuration does + + def _parse_plugin_configuration(self) -> None: + # pylint: disable-next=fixme + # TODO: This is not currently implemented. + # Perhaps we should also change the name to distuingish it better? + # See PyLinter.load_plugin_configuration + pass diff --git a/pylint/config/config_initialization.py b/pylint/config/config_initialization.py index 6be0ab8b9..f431a3c8f 100644 --- a/pylint/config/config_initialization.py +++ b/pylint/config/config_initialization.py @@ -55,7 +55,7 @@ def _config_initialization( # Load command line arguments try: - args_list = linter.load_command_line_configuration(args_list) + parsed_args_list = linter.load_command_line_configuration(args_list) except SystemExit as exc: if exc.code == 2: # bad options exc.code = 32 @@ -63,7 +63,7 @@ def _config_initialization( # args_list should now only be a list of files/directories to lint. All options have # been removed from the list - if not args_list: + if not parsed_args_list: print(linter.help()) sys.exit(32) @@ -71,7 +71,21 @@ def _config_initialization( # load plugin specific configuration. linter.load_plugin_configuration() + # pylint: disable-next=fixme + # TODO: Start of the implemenation of argument parsing with argparse + # When finished this should replace the implementation based on optparse + + # First we parse any options from a configuration file + linter._parse_configuration_file(config_parser) + + # Second we parse any options from the command line, so they can override + # the configuration file + linter._parse_command_line_configuration(args_list) + + # Lastly we parse any options from plugins + linter._parse_plugin_configuration() + # Now that plugins are loaded, get list of all fail_on messages, and enable them linter.enable_fail_on_messages() - return args_list + return parsed_args_list diff --git a/pylint/config/exceptions.py b/pylint/config/exceptions.py new file mode 100644 index 000000000..4c1c4a678 --- /dev/null +++ b/pylint/config/exceptions.py @@ -0,0 +1,13 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt + + +class UnrecognizedArgumentAction(Exception): + """Raised if an ArgumentManager instance tries to add an argument for which the action + is not recognized. + """ + + +class MissingArgumentManager(Exception): + """Raised if an ArgumentManager instance to register options to is missing.""" diff --git a/pylint/config/utils.py b/pylint/config/utils.py new file mode 100644 index 000000000..352a130e6 --- /dev/null +++ b/pylint/config/utils.py @@ -0,0 +1,34 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt + +"""Utils for arguments/options parsing and handling.""" + + +from typing import Any, Dict + +from pylint.config.argument import _Argument + +IMPLEMENTED_OPTDICT_KEYS = {"action", "default", "type", "choices", "help", "metavar"} +"""This is used to track our progress on accepting all optdict keys.""" + + +def _convert_option_to_argument(opt: str, optdict: Dict[str, Any]) -> _Argument: + """Convert an optdict to an Argument class instance.""" + # See if the optdict contains any keys we don't yet implement + # pylint: disable-next=fixme + # TODO: This should be removed once the migration to argparse is finished + for key, value in optdict.items(): + if key not in IMPLEMENTED_OPTDICT_KEYS: + print("Unhandled key found in Argument creation:", key) # pragma: no cover + print("It's value is:", value) # pragma: no cover + + return _Argument( + flags=[f"--{opt}"], + action=optdict.get("action", "store"), + default=optdict["default"], + arg_type=optdict["type"], + choices=optdict.get("choices", None), + arg_help=optdict["help"], + metavar=optdict["metavar"], + ) diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py index 0bf8961c1..d54d7adfc 100644 --- a/pylint/lint/pylinter.py +++ b/pylint/lint/pylinter.py @@ -193,6 +193,7 @@ class PyLinter( config.OptionsManagerMixIn, reporters.ReportsHandlerMixIn, checkers.BaseTokenChecker, + config._ArgumentsManager, ): """Lint Python modules using external checkers. @@ -551,9 +552,11 @@ class PyLinter( option_groups: Tuple[Tuple[str, str], ...] = (), pylintrc: Optional[str] = None, ) -> None: - """Some stuff has to be done before ancestors initialization... - messages store / checkers / reporter / astroid manager - """ + config._ArgumentsManager.__init__(self) + + # Some stuff has to be done before initialization of other ancestors... + # messages store / checkers / reporter / astroid manager + # Attributes for reporters self.reporter: Union[reporters.BaseReporter, reporters.MultiReporter] if reporter: @@ -393,7 +393,7 @@ ignored-classes=SQLObject, optparse.Values, thread._local, _thread._local # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. -generated-members=REQUEST,acl_users,aq_parent +generated-members=REQUEST,acl_users,aq_parent,argparse.Namespace # List of decorators that create context managers from functions, such as # contextlib.contextmanager. diff --git a/tests/config/data/logging_format_interpolation_style.py b/tests/config/data/logging_format_interpolation_style.py new file mode 100644 index 000000000..04b357abd --- /dev/null +++ b/tests/config/data/logging_format_interpolation_style.py @@ -0,0 +1,13 @@ +"""This should raise various logging-too-many-args messages if +logging-format-style is not 'new'. +""" + +import logging + +logging.error("constant string") +logging.error("{}") +logging.error("{}", 1) +logging.error("{0}", 1) +logging.error("{named}", {"named": 1}) +logging.error("{} {named}", 1, {"named": 1}) +logging.error("{0} {named}", 1, {"named": 1}) diff --git a/tests/config/data/logging_format_interpolation_style.rc b/tests/config/data/logging_format_interpolation_style.rc new file mode 100644 index 000000000..745bbd9cf --- /dev/null +++ b/tests/config/data/logging_format_interpolation_style.rc @@ -0,0 +1,2 @@ +[LOGGING] +logging-format-style=new diff --git a/tests/config/test_argparse_config.py b/tests/config/test_argparse_config.py new file mode 100644 index 000000000..cc399d995 --- /dev/null +++ b/tests/config/test_argparse_config.py @@ -0,0 +1,56 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt + +"""Test for the (new) implementation of option parsing with argparse""" + +from os.path import abspath, dirname, join + +import pytest + +from pylint.lint import Run + +HERE = abspath(dirname(__file__)) +REGRTEST_DATA_DIR = join(HERE, "..", "regrtest_data") +EMPTY_MODULE = join(REGRTEST_DATA_DIR, "empty.py") +LOGGING_TEST = join(HERE, "data", "logging_format_interpolation_style.py") + + +class TestArgumentsManager: + """Tests for the ArgumentsManager class""" + + base_run = Run([EMPTY_MODULE], exit=False) + + def test_namespace_creation(self) -> None: + """Test that the linter object has a namespace attribute and that it is not empty""" + + assert self.base_run.linter.namespace + assert self.base_run.linter.namespace._get_kwargs() + + +class TestArgparseOptionsProviderMixin: + """Tests for the argparse implementation of OptionsProviderMixIn. + + The logger checker is used as an example checker for this implementation. + """ + + @staticmethod + def test_logger_without_options() -> None: + """Check that we raise messages when we do not supply any options.""" + with pytest.raises(SystemExit) as ex: + Run([LOGGING_TEST]) + assert ex.value.code == 2 + + @staticmethod + def test_logger_commandline() -> None: + """Check that we parse command-line options for the logging checker correctly.""" + with pytest.raises(SystemExit) as ex: + Run([LOGGING_TEST, "--logging-format-style=new"]) + assert ex.value.code == 0 + + @staticmethod + def test_logger_rcfile() -> None: + """Check that we parse the rcfile for the logging checker correctly.""" + with pytest.raises(SystemExit) as ex: + Run([LOGGING_TEST, f"--rcfile={LOGGING_TEST.replace('.py', '.rc')}"]) + assert ex.value.code == 0 diff --git a/tests/test_regr.py b/tests/test_regr.py index 76bcafd68..e3c14c8a0 100644 --- a/tests/test_regr.py +++ b/tests/test_regr.py @@ -128,6 +128,7 @@ def test_pylint_config_attr() -> None: "BaseTokenChecker", "BaseChecker", "OptionsProviderMixIn", + "_ArgumentsManager", ] assert [c.name for c in pylinter.ancestors()] == expect assert list(astroid.Instance(pylinter).getattr("config")) |