arguments_manager.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. # Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
  2. # For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE
  3. # Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt
  4. """Arguments manager class used to handle command-line arguments and options."""
  5. from __future__ import annotations
  6. import argparse
  7. import re
  8. import sys
  9. import textwrap
  10. import warnings
  11. from collections.abc import Sequence
  12. from typing import TYPE_CHECKING, Any, TextIO
  13. import tomlkit
  14. from pylint import utils
  15. from pylint.config.argument import (
  16. _Argument,
  17. _CallableArgument,
  18. _ExtendArgument,
  19. _StoreArgument,
  20. _StoreNewNamesArgument,
  21. _StoreOldNamesArgument,
  22. _StoreTrueArgument,
  23. )
  24. from pylint.config.exceptions import (
  25. UnrecognizedArgumentAction,
  26. _UnrecognizedOptionError,
  27. )
  28. from pylint.config.help_formatter import _HelpFormatter
  29. from pylint.config.utils import _convert_option_to_argument, _parse_rich_type_value
  30. from pylint.constants import MAIN_CHECKER_NAME
  31. from pylint.typing import DirectoryNamespaceDict, OptionDict
  32. if sys.version_info >= (3, 11):
  33. import tomllib
  34. else:
  35. import tomli as tomllib
  36. if TYPE_CHECKING:
  37. from pylint.config.arguments_provider import _ArgumentsProvider
  38. class _ArgumentsManager:
  39. """Arguments manager class used to handle command-line arguments and options."""
  40. def __init__(
  41. self, prog: str, usage: str | None = None, description: str | None = None
  42. ) -> None:
  43. self._config = argparse.Namespace()
  44. """Namespace for all options."""
  45. self._base_config = self._config
  46. """Fall back Namespace object created during initialization.
  47. This is necessary for the per-directory configuration support. Whenever we
  48. fail to match a file with a directory we fall back to the Namespace object
  49. created during initialization.
  50. """
  51. self._arg_parser = argparse.ArgumentParser(
  52. prog=prog,
  53. usage=usage or "%(prog)s [options]",
  54. description=description,
  55. formatter_class=_HelpFormatter,
  56. # Needed to let 'pylint-config' overwrite the -h command
  57. conflict_handler="resolve",
  58. )
  59. """The command line argument parser."""
  60. self._argument_groups_dict: dict[str, argparse._ArgumentGroup] = {}
  61. """Dictionary of all the argument groups."""
  62. self._option_dicts: dict[str, OptionDict] = {}
  63. """All option dictionaries that have been registered."""
  64. self._directory_namespaces: DirectoryNamespaceDict = {}
  65. """Mapping of directories and their respective namespace objects."""
  66. @property
  67. def config(self) -> argparse.Namespace:
  68. """Namespace for all options."""
  69. return self._config
  70. @config.setter
  71. def config(self, value: argparse.Namespace) -> None:
  72. self._config = value
  73. def _register_options_provider(self, provider: _ArgumentsProvider) -> None:
  74. """Register an options provider and load its defaults."""
  75. for opt, optdict in provider.options:
  76. self._option_dicts[opt] = optdict
  77. argument = _convert_option_to_argument(opt, optdict)
  78. section = argument.section or provider.name.capitalize()
  79. section_desc = provider.option_groups_descs.get(section, None)
  80. # We exclude main since its docstring comes from PyLinter
  81. if provider.name != MAIN_CHECKER_NAME and provider.__doc__:
  82. section_desc = provider.__doc__.split("\n\n")[0]
  83. self._add_arguments_to_parser(section, section_desc, argument)
  84. self._load_default_argument_values()
  85. def _add_arguments_to_parser(
  86. self, section: str, section_desc: str | None, argument: _Argument
  87. ) -> None:
  88. """Add an argument to the correct argument section/group."""
  89. try:
  90. section_group = self._argument_groups_dict[section]
  91. except KeyError:
  92. if section_desc:
  93. section_group = self._arg_parser.add_argument_group(
  94. section, section_desc
  95. )
  96. else:
  97. section_group = self._arg_parser.add_argument_group(title=section)
  98. self._argument_groups_dict[section] = section_group
  99. self._add_parser_option(section_group, argument)
  100. @staticmethod
  101. def _add_parser_option(
  102. section_group: argparse._ArgumentGroup, argument: _Argument
  103. ) -> None:
  104. """Add an argument."""
  105. if isinstance(argument, _StoreArgument):
  106. section_group.add_argument(
  107. *argument.flags,
  108. action=argument.action,
  109. default=argument.default,
  110. type=argument.type,
  111. help=argument.help,
  112. metavar=argument.metavar,
  113. choices=argument.choices,
  114. )
  115. elif isinstance(argument, _StoreOldNamesArgument):
  116. section_group.add_argument(
  117. *argument.flags,
  118. **argument.kwargs,
  119. action=argument.action,
  120. default=argument.default,
  121. type=argument.type,
  122. help=argument.help,
  123. metavar=argument.metavar,
  124. choices=argument.choices,
  125. )
  126. # We add the old name as hidden option to make its default value get loaded when
  127. # argparse initializes all options from the checker
  128. assert argument.kwargs["old_names"]
  129. for old_name in argument.kwargs["old_names"]:
  130. section_group.add_argument(
  131. f"--{old_name}",
  132. action="store",
  133. default=argument.default,
  134. type=argument.type,
  135. help=argparse.SUPPRESS,
  136. metavar=argument.metavar,
  137. choices=argument.choices,
  138. )
  139. elif isinstance(argument, _StoreNewNamesArgument):
  140. section_group.add_argument(
  141. *argument.flags,
  142. **argument.kwargs,
  143. action=argument.action,
  144. default=argument.default,
  145. type=argument.type,
  146. help=argument.help,
  147. metavar=argument.metavar,
  148. choices=argument.choices,
  149. )
  150. elif isinstance(argument, _StoreTrueArgument):
  151. section_group.add_argument(
  152. *argument.flags,
  153. action=argument.action,
  154. default=argument.default,
  155. help=argument.help,
  156. )
  157. elif isinstance(argument, _CallableArgument):
  158. section_group.add_argument(
  159. *argument.flags,
  160. **argument.kwargs,
  161. action=argument.action,
  162. help=argument.help,
  163. metavar=argument.metavar,
  164. )
  165. elif isinstance(argument, _ExtendArgument):
  166. section_group.add_argument(
  167. *argument.flags,
  168. action=argument.action,
  169. default=argument.default,
  170. type=argument.type,
  171. help=argument.help,
  172. metavar=argument.metavar,
  173. choices=argument.choices,
  174. dest=argument.dest,
  175. )
  176. else:
  177. raise UnrecognizedArgumentAction
  178. def _load_default_argument_values(self) -> None:
  179. """Loads the default values of all registered options."""
  180. self.config = self._arg_parser.parse_args([], self.config)
  181. def _parse_configuration_file(self, arguments: list[str]) -> None:
  182. """Parse the arguments found in a configuration file into the namespace."""
  183. try:
  184. self.config, parsed_args = self._arg_parser.parse_known_args(
  185. arguments, self.config
  186. )
  187. except SystemExit:
  188. sys.exit(32)
  189. unrecognized_options: list[str] = []
  190. for opt in parsed_args:
  191. if opt.startswith("--"):
  192. unrecognized_options.append(opt[2:])
  193. if unrecognized_options:
  194. raise _UnrecognizedOptionError(options=unrecognized_options)
  195. def _parse_command_line_configuration(
  196. self, arguments: Sequence[str] | None = None
  197. ) -> list[str]:
  198. """Parse the arguments found on the command line into the namespace."""
  199. arguments = sys.argv[1:] if arguments is None else arguments
  200. self.config, parsed_args = self._arg_parser.parse_known_args(
  201. arguments, self.config
  202. )
  203. return parsed_args
  204. def _generate_config(
  205. self, stream: TextIO | None = None, skipsections: tuple[str, ...] = ()
  206. ) -> None:
  207. """Write a configuration file according to the current configuration
  208. into the given stream or stdout.
  209. """
  210. options_by_section = {}
  211. sections = []
  212. for group in sorted(
  213. self._arg_parser._action_groups,
  214. key=lambda x: (x.title != "Main", x.title),
  215. ):
  216. group_name = group.title
  217. assert group_name
  218. if group_name in skipsections:
  219. continue
  220. options = []
  221. option_actions = [
  222. i
  223. for i in group._group_actions
  224. if not isinstance(i, argparse._SubParsersAction)
  225. ]
  226. for opt in sorted(option_actions, key=lambda x: x.option_strings[0][2:]):
  227. if "--help" in opt.option_strings:
  228. continue
  229. optname = opt.option_strings[0][2:]
  230. try:
  231. optdict = self._option_dicts[optname]
  232. except KeyError:
  233. continue
  234. options.append(
  235. (
  236. optname,
  237. optdict,
  238. getattr(self.config, optname.replace("-", "_")),
  239. )
  240. )
  241. options = [
  242. (n, d, v) for (n, d, v) in options if not d.get("deprecated")
  243. ]
  244. if options:
  245. sections.append(group_name)
  246. options_by_section[group_name] = options
  247. stream = stream or sys.stdout
  248. printed = False
  249. for section in sections:
  250. if printed:
  251. print("\n", file=stream)
  252. with warnings.catch_warnings():
  253. warnings.filterwarnings("ignore", category=DeprecationWarning)
  254. utils.format_section(
  255. stream, section.upper(), sorted(options_by_section[section])
  256. )
  257. printed = True
  258. def help(self) -> str:
  259. """Return the usage string based on the available options."""
  260. return self._arg_parser.format_help()
  261. def _generate_config_file(self, *, minimal: bool = False) -> str:
  262. """Write a configuration file according to the current configuration into
  263. stdout.
  264. """
  265. toml_doc = tomlkit.document()
  266. tool_table = tomlkit.table(is_super_table=True)
  267. toml_doc.add(tomlkit.key("tool"), tool_table)
  268. pylint_tool_table = tomlkit.table(is_super_table=True)
  269. tool_table.add(tomlkit.key("pylint"), pylint_tool_table)
  270. for group in sorted(
  271. self._arg_parser._action_groups,
  272. key=lambda x: (x.title != "Main", x.title),
  273. ):
  274. # Skip the options section with the --help option
  275. if group.title in {"options", "optional arguments", "Commands"}:
  276. continue
  277. # Skip sections without options such as "positional arguments"
  278. if not group._group_actions:
  279. continue
  280. group_table = tomlkit.table()
  281. option_actions = [
  282. i
  283. for i in group._group_actions
  284. if not isinstance(i, argparse._SubParsersAction)
  285. ]
  286. for action in sorted(option_actions, key=lambda x: x.option_strings[0][2:]):
  287. optname = action.option_strings[0][2:]
  288. # We skip old name options that don't have their own optdict
  289. try:
  290. optdict = self._option_dicts[optname]
  291. except KeyError:
  292. continue
  293. if optdict.get("hide_from_config_file"):
  294. continue
  295. # Add help comment
  296. if not minimal:
  297. help_msg = optdict.get("help", "")
  298. assert isinstance(help_msg, str)
  299. help_text = textwrap.wrap(help_msg, width=79)
  300. for line in help_text:
  301. group_table.add(tomlkit.comment(line))
  302. # Get current value of option
  303. value = getattr(self.config, optname.replace("-", "_"))
  304. # Create a comment if the option has no value
  305. if not value:
  306. if not minimal:
  307. group_table.add(tomlkit.comment(f"{optname} ="))
  308. group_table.add(tomlkit.nl())
  309. continue
  310. # Skip deprecated options
  311. if "kwargs" in optdict:
  312. assert isinstance(optdict["kwargs"], dict)
  313. if "new_names" in optdict["kwargs"]:
  314. continue
  315. # Tomlkit doesn't support regular expressions
  316. if isinstance(value, re.Pattern):
  317. value = value.pattern
  318. elif isinstance(value, (list, tuple)) and isinstance(
  319. value[0], re.Pattern
  320. ):
  321. value = [i.pattern for i in value]
  322. # Handle tuples that should be strings
  323. if optdict.get("type") == "py_version":
  324. value = ".".join(str(i) for i in value)
  325. # Check if it is default value if we are in minimal mode
  326. if minimal and value == optdict.get("default"):
  327. continue
  328. # Add to table
  329. group_table.add(optname, value)
  330. group_table.add(tomlkit.nl())
  331. assert group.title
  332. if group_table:
  333. pylint_tool_table.add(group.title.lower(), group_table)
  334. toml_string = tomlkit.dumps(toml_doc)
  335. # Make sure the string we produce is valid toml and can be parsed
  336. tomllib.loads(toml_string)
  337. return str(toml_string)
  338. def set_option(self, optname: str, value: Any) -> None:
  339. """Set an option on the namespace object."""
  340. self.config = self._arg_parser.parse_known_args(
  341. [f"--{optname.replace('_', '-')}", _parse_rich_type_value(value)],
  342. self.config,
  343. )[0]