# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html # For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE # Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt """Arguments manager class used to handle command-line arguments and options.""" from __future__ import annotations import argparse import re import sys import textwrap import warnings from collections.abc import Sequence from typing import TYPE_CHECKING, Any, TextIO import tomlkit from pylint import utils from pylint.config.argument import ( _Argument, _CallableArgument, _ExtendArgument, _StoreArgument, _StoreNewNamesArgument, _StoreOldNamesArgument, _StoreTrueArgument, ) from pylint.config.exceptions import ( UnrecognizedArgumentAction, _UnrecognizedOptionError, ) from pylint.config.help_formatter import _HelpFormatter from pylint.config.utils import _convert_option_to_argument, _parse_rich_type_value from pylint.constants import MAIN_CHECKER_NAME from pylint.typing import DirectoryNamespaceDict, OptionDict if sys.version_info >= (3, 11): import tomllib else: import tomli as tomllib if TYPE_CHECKING: from pylint.config.arguments_provider import _ArgumentsProvider class _ArgumentsManager: """Arguments manager class used to handle command-line arguments and options.""" def __init__( self, prog: str, usage: str | None = None, description: str | None = None ) -> None: self._config = argparse.Namespace() """Namespace for all options.""" self._base_config = self._config """Fall back Namespace object created during initialization. This is necessary for the per-directory configuration support. Whenever we fail to match a file with a directory we fall back to the Namespace object created during initialization. """ self._arg_parser = argparse.ArgumentParser( prog=prog, usage=usage or "%(prog)s [options]", description=description, formatter_class=_HelpFormatter, # Needed to let 'pylint-config' overwrite the -h command conflict_handler="resolve", ) """The command line argument parser.""" self._argument_groups_dict: dict[str, argparse._ArgumentGroup] = {} """Dictionary of all the argument groups.""" self._option_dicts: dict[str, OptionDict] = {} """All option dictionaries that have been registered.""" self._directory_namespaces: DirectoryNamespaceDict = {} """Mapping of directories and their respective namespace objects.""" @property def config(self) -> argparse.Namespace: """Namespace for all options.""" return self._config @config.setter def config(self, value: argparse.Namespace) -> None: self._config = value def _register_options_provider(self, provider: _ArgumentsProvider) -> None: """Register an options provider and load its defaults.""" for opt, optdict in provider.options: self._option_dicts[opt] = optdict argument = _convert_option_to_argument(opt, optdict) section = argument.section or provider.name.capitalize() section_desc = provider.option_groups_descs.get(section, None) # We exclude main since its docstring comes from PyLinter if provider.name != MAIN_CHECKER_NAME and provider.__doc__: section_desc = provider.__doc__.split("\n\n")[0] self._add_arguments_to_parser(section, section_desc, argument) self._load_default_argument_values() def _add_arguments_to_parser( self, section: str, section_desc: str | None, argument: _Argument ) -> None: """Add an argument to the correct argument section/group.""" try: section_group = self._argument_groups_dict[section] except KeyError: if section_desc: section_group = self._arg_parser.add_argument_group( section, section_desc ) else: 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 isinstance(argument, _StoreArgument): 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, ) elif isinstance(argument, _StoreOldNamesArgument): section_group.add_argument( *argument.flags, **argument.kwargs, 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, ) # We add the old name as hidden option to make it's default value gets loaded when # argparse initializes all options from the checker assert argument.kwargs["old_names"] for old_name in argument.kwargs["old_names"]: section_group.add_argument( f"--{old_name}", action="store", default=argument.default, type=argument.type, # type: ignore[arg-type] # incorrect typing in typeshed help=argparse.SUPPRESS, metavar=argument.metavar, choices=argument.choices, ) elif isinstance(argument, _StoreNewNamesArgument): section_group.add_argument( *argument.flags, **argument.kwargs, 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, ) elif isinstance(argument, _StoreTrueArgument): section_group.add_argument( *argument.flags, action=argument.action, default=argument.default, help=argument.help, ) elif isinstance(argument, _CallableArgument): section_group.add_argument( *argument.flags, **argument.kwargs, action=argument.action, help=argument.help, metavar=argument.metavar, ) elif isinstance(argument, _ExtendArgument): 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, dest=argument.dest, ) else: raise UnrecognizedArgumentAction def _load_default_argument_values(self) -> None: """Loads the default values of all registered options.""" self.config = self._arg_parser.parse_args([], self.config) def _parse_configuration_file(self, arguments: list[str]) -> None: """Parse the arguments found in a configuration file into the namespace.""" try: self.config, parsed_args = self._arg_parser.parse_known_args( arguments, self.config ) except SystemExit: sys.exit(32) unrecognized_options: list[str] = [] for opt in parsed_args: if opt.startswith("--"): unrecognized_options.append(opt[2:]) if unrecognized_options: raise _UnrecognizedOptionError(options=unrecognized_options) def _parse_command_line_configuration( self, arguments: Sequence[str] | None = None ) -> list[str]: """Parse the arguments found on the command line into the namespace.""" arguments = sys.argv[1:] if arguments is None else arguments self.config, parsed_args = self._arg_parser.parse_known_args( arguments, self.config ) return parsed_args def _generate_config( self, stream: TextIO | None = None, skipsections: tuple[str, ...] = () ) -> None: """Write a configuration file according to the current configuration into the given stream or stdout. """ options_by_section = {} sections = [] for group in sorted( self._arg_parser._action_groups, key=lambda x: (x.title != "Main", x.title), ): group_name = group.title assert group_name if group_name in skipsections: continue options = [] option_actions = [ i for i in group._group_actions if not isinstance(i, argparse._SubParsersAction) ] for opt in sorted(option_actions, key=lambda x: x.option_strings[0][2:]): if "--help" in opt.option_strings: continue optname = opt.option_strings[0][2:] try: optdict = self._option_dicts[optname] except KeyError: continue options.append( ( optname, optdict, getattr(self.config, optname.replace("-", "_")), ) ) options = [ (n, d, v) for (n, d, v) in options if not d.get("deprecated") ] if options: sections.append(group_name) options_by_section[group_name] = options stream = stream or sys.stdout printed = False for section in sections: if printed: print("\n", file=stream) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning) utils.format_section( stream, section.upper(), sorted(options_by_section[section]) ) printed = True def help(self) -> str: """Return the usage string based on the available options.""" return self._arg_parser.format_help() def _generate_config_file(self, *, minimal: bool = False) -> str: """Write a configuration file according to the current configuration into stdout. """ toml_doc = tomlkit.document() tool_table = tomlkit.table(is_super_table=True) toml_doc.add(tomlkit.key("tool"), tool_table) pylint_tool_table = tomlkit.table(is_super_table=True) tool_table.add(tomlkit.key("pylint"), pylint_tool_table) for group in sorted( self._arg_parser._action_groups, key=lambda x: (x.title != "Main", x.title), ): # Skip the options section with the --help option if group.title in {"options", "optional arguments", "Commands"}: continue # Skip sections without options such as "positional arguments" if not group._group_actions: continue group_table = tomlkit.table() option_actions = [ i for i in group._group_actions if not isinstance(i, argparse._SubParsersAction) ] for action in sorted(option_actions, key=lambda x: x.option_strings[0][2:]): optname = action.option_strings[0][2:] # We skip old name options that don't have their own optdict try: optdict = self._option_dicts[optname] except KeyError: continue if optdict.get("hide_from_config_file"): continue # Add help comment if not minimal: help_msg = optdict.get("help", "") assert isinstance(help_msg, str) help_text = textwrap.wrap(help_msg, width=79) for line in help_text: group_table.add(tomlkit.comment(line)) # Get current value of option value = getattr(self.config, optname.replace("-", "_")) # Create a comment if the option has no value if not value: if not minimal: group_table.add(tomlkit.comment(f"{optname} =")) group_table.add(tomlkit.nl()) continue # Skip deprecated options if "kwargs" in optdict: assert isinstance(optdict["kwargs"], dict) if "new_names" in optdict["kwargs"]: continue # Tomlkit doesn't support regular expressions if isinstance(value, re.Pattern): value = value.pattern elif isinstance(value, (list, tuple)) and isinstance( value[0], re.Pattern ): value = [i.pattern for i in value] # Handle tuples that should be strings if optdict.get("type") == "py_version": value = ".".join(str(i) for i in value) # Check if it is default value if we are in minimal mode if minimal and value == optdict.get("default"): continue # Add to table group_table.add(optname, value) group_table.add(tomlkit.nl()) assert group.title if group_table: pylint_tool_table.add(group.title.lower(), group_table) toml_string = tomlkit.dumps(toml_doc) # Make sure the string we produce is valid toml and can be parsed tomllib.loads(toml_string) return str(toml_string) def set_option(self, optname: str, value: Any) -> None: """Set an option on the namespace object.""" self.config = self._arg_parser.parse_known_args( [f"--{optname.replace('_', '-')}", _parse_rich_type_value(value)], self.config, )[0]