blob: aca8f5f8781a78cc749db0aa471b321a3fdaf998 [file] [log] [blame]
# 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 its default value get 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]