option_manager_mixin.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. # Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
  2. # For details: https://github.com/PyCQA/pylint/blob/main/LICENSE
  3. import collections
  4. import configparser
  5. import contextlib
  6. import copy
  7. import functools
  8. import optparse # pylint: disable=deprecated-module
  9. import os
  10. import sys
  11. from pathlib import Path
  12. from types import ModuleType
  13. from typing import Dict, List, Optional, TextIO, Tuple, Union
  14. import toml
  15. from pylint import utils
  16. from pylint.config.man_help_formatter import _ManHelpFormatter
  17. from pylint.config.option import Option
  18. from pylint.config.option_parser import OptionParser
  19. def _expand_default(self, option):
  20. """Patch OptionParser.expand_default with custom behaviour
  21. This will handle defaults to avoid overriding values in the
  22. configuration file.
  23. """
  24. if self.parser is None or not self.default_tag:
  25. return option.help
  26. optname = option._long_opts[0][2:]
  27. try:
  28. provider = self.parser.options_manager._all_options[optname]
  29. except KeyError:
  30. value = None
  31. else:
  32. optdict = provider.get_option_def(optname)
  33. optname = provider.option_attrname(optname, optdict)
  34. value = getattr(provider.config, optname, optdict)
  35. value = utils._format_option_value(optdict, value)
  36. if value is optparse.NO_DEFAULT or not value:
  37. value = self.NO_DEFAULT_VALUE
  38. return option.help.replace(self.default_tag, str(value))
  39. @contextlib.contextmanager
  40. def _patch_optparse():
  41. # pylint: disable = redefined-variable-type
  42. orig_default = optparse.HelpFormatter
  43. try:
  44. optparse.HelpFormatter.expand_default = _expand_default
  45. yield
  46. finally:
  47. optparse.HelpFormatter.expand_default = orig_default
  48. class OptionsManagerMixIn:
  49. """Handle configuration from both a configuration file and command line options"""
  50. def __init__(self, usage, config_file=None):
  51. self.config_file = config_file
  52. self.reset_parsers(usage)
  53. # list of registered options providers
  54. self.options_providers = []
  55. # dictionary associating option name to checker
  56. self._all_options = collections.OrderedDict()
  57. self._short_options = {}
  58. self._nocallback_options = {}
  59. self._mygroups = {}
  60. # verbosity
  61. self._maxlevel = 0
  62. def reset_parsers(self, usage=""):
  63. # configuration file parser
  64. self.cfgfile_parser = configparser.ConfigParser(
  65. inline_comment_prefixes=("#", ";")
  66. )
  67. # command line parser
  68. self.cmdline_parser = OptionParser(Option, usage=usage)
  69. self.cmdline_parser.options_manager = self
  70. self._optik_option_attrs = set(self.cmdline_parser.option_class.ATTRS)
  71. def register_options_provider(self, provider, own_group=True):
  72. """register an options provider"""
  73. assert provider.priority <= 0, "provider's priority can't be >= 0"
  74. for i, options_provider in enumerate(self.options_providers):
  75. if provider.priority > options_provider.priority:
  76. self.options_providers.insert(i, provider)
  77. break
  78. else:
  79. self.options_providers.append(provider)
  80. non_group_spec_options = [
  81. option for option in provider.options if "group" not in option[1]
  82. ]
  83. groups = getattr(provider, "option_groups", ())
  84. if own_group and non_group_spec_options:
  85. self.add_option_group(
  86. provider.name.upper(),
  87. provider.__doc__,
  88. non_group_spec_options,
  89. provider,
  90. )
  91. else:
  92. for opt, optdict in non_group_spec_options:
  93. self.add_optik_option(provider, self.cmdline_parser, opt, optdict)
  94. for gname, gdoc in groups:
  95. gname = gname.upper()
  96. goptions = [
  97. option
  98. for option in provider.options
  99. if option[1].get("group", "").upper() == gname
  100. ]
  101. self.add_option_group(gname, gdoc, goptions, provider)
  102. def add_option_group(self, group_name, _, options, provider):
  103. # add option group to the command line parser
  104. if group_name in self._mygroups:
  105. group = self._mygroups[group_name]
  106. else:
  107. group = optparse.OptionGroup(
  108. self.cmdline_parser, title=group_name.capitalize()
  109. )
  110. self.cmdline_parser.add_option_group(group)
  111. group.level = provider.level
  112. self._mygroups[group_name] = group
  113. # add section to the config file
  114. if (
  115. group_name != "DEFAULT"
  116. and group_name not in self.cfgfile_parser._sections
  117. ):
  118. self.cfgfile_parser.add_section(group_name)
  119. # add provider's specific options
  120. for opt, optdict in options:
  121. self.add_optik_option(provider, group, opt, optdict)
  122. def add_optik_option(self, provider, optikcontainer, opt, optdict):
  123. args, optdict = self.optik_option(provider, opt, optdict)
  124. option = optikcontainer.add_option(*args, **optdict)
  125. self._all_options[opt] = provider
  126. self._maxlevel = max(self._maxlevel, option.level or 0)
  127. def optik_option(self, provider, opt, optdict):
  128. """get our personal option definition and return a suitable form for
  129. use with optik/optparse
  130. """
  131. optdict = copy.copy(optdict)
  132. if "action" in optdict:
  133. self._nocallback_options[provider] = opt
  134. else:
  135. optdict["action"] = "callback"
  136. optdict["callback"] = self.cb_set_provider_option
  137. # default is handled here and *must not* be given to optik if you
  138. # want the whole machinery to work
  139. if "default" in optdict:
  140. if (
  141. "help" in optdict
  142. and optdict.get("default") is not None
  143. and optdict["action"] not in ("store_true", "store_false")
  144. ):
  145. optdict["help"] += " [current: %default]"
  146. del optdict["default"]
  147. args = ["--" + str(opt)]
  148. if "short" in optdict:
  149. self._short_options[optdict["short"]] = opt
  150. args.append("-" + optdict["short"])
  151. del optdict["short"]
  152. # cleanup option definition dict before giving it to optik
  153. for key in list(optdict.keys()):
  154. if key not in self._optik_option_attrs:
  155. optdict.pop(key)
  156. return args, optdict
  157. def cb_set_provider_option(self, option, opt, value, parser):
  158. """optik callback for option setting"""
  159. if opt.startswith("--"):
  160. # remove -- on long option
  161. opt = opt[2:]
  162. else:
  163. # short option, get its long equivalent
  164. opt = self._short_options[opt[1:]]
  165. # trick since we can't set action='store_true' on options
  166. if value is None:
  167. value = 1
  168. self.global_set_option(opt, value)
  169. def global_set_option(self, opt, value):
  170. """set option on the correct option provider"""
  171. self._all_options[opt].set_option(opt, value)
  172. def generate_config(
  173. self, stream: Optional[TextIO] = None, skipsections: Tuple[str, ...] = ()
  174. ) -> None:
  175. """write a configuration file according to the current configuration
  176. into the given stream or stdout
  177. """
  178. options_by_section: Dict[str, List[Tuple]] = {}
  179. sections = []
  180. for provider in self.options_providers:
  181. for section, options in provider.options_by_section():
  182. if section is None:
  183. section = provider.name
  184. if section in skipsections:
  185. continue
  186. options = [
  187. (n, d, v)
  188. for (n, d, v) in options
  189. if d.get("type") is not None and not d.get("deprecated")
  190. ]
  191. if not options:
  192. continue
  193. if section not in sections:
  194. sections.append(section)
  195. alloptions = options_by_section.setdefault(section, [])
  196. alloptions += options
  197. stream = stream or sys.stdout
  198. printed = False
  199. for section in sections:
  200. if printed:
  201. print("\n", file=stream)
  202. utils.format_section(
  203. stream, section.upper(), sorted(options_by_section[section])
  204. )
  205. printed = True
  206. def generate_manpage(
  207. self, pkginfo: ModuleType, section: int = 1, stream: TextIO = sys.stdout
  208. ) -> None:
  209. with _patch_optparse():
  210. formatter = _ManHelpFormatter()
  211. formatter.output_level = self._maxlevel
  212. formatter.parser = self.cmdline_parser
  213. print(
  214. formatter.format_head(self.cmdline_parser, pkginfo, section),
  215. file=stream,
  216. )
  217. print(self.cmdline_parser.format_option_help(formatter), file=stream)
  218. print(formatter.format_tail(pkginfo), file=stream)
  219. def load_provider_defaults(self):
  220. """initialize configuration using default values"""
  221. for provider in self.options_providers:
  222. provider.load_defaults()
  223. def read_config_file(self, config_file=None, verbose=None):
  224. """Read the configuration file but do not load it (i.e. dispatching
  225. values to each options provider)
  226. """
  227. for help_level in range(1, self._maxlevel + 1):
  228. opt = "-".join(["long"] * help_level) + "-help"
  229. if opt in self._all_options:
  230. break # already processed
  231. help_function = functools.partial(self.helpfunc, level=help_level)
  232. help_msg = f"{' '.join(['more'] * help_level)} verbose help."
  233. optdict = {
  234. "action": "callback",
  235. "callback": help_function,
  236. "help": help_msg,
  237. }
  238. provider = self.options_providers[0]
  239. self.add_optik_option(provider, self.cmdline_parser, opt, optdict)
  240. provider.options += ((opt, optdict),)
  241. if config_file is None:
  242. config_file = self.config_file
  243. if config_file is not None:
  244. config_file = os.path.expandvars(os.path.expanduser(config_file))
  245. if not os.path.exists(config_file):
  246. raise OSError(f"The config file {config_file} doesn't exist!")
  247. use_config_file = config_file and os.path.exists(config_file)
  248. if use_config_file:
  249. self.set_current_module(config_file)
  250. parser = self.cfgfile_parser
  251. if config_file.endswith(".toml"):
  252. try:
  253. self._parse_toml(config_file, parser)
  254. except toml.TomlDecodeError as e:
  255. self.add_message("config-parse-error", line=0, args=str(e))
  256. else:
  257. # Use this encoding in order to strip the BOM marker, if any.
  258. with open(config_file, encoding="utf_8_sig") as fp:
  259. parser.read_file(fp)
  260. # normalize sections'title
  261. for sect, values in list(parser._sections.items()):
  262. if sect.startswith("pylint."):
  263. sect = sect[len("pylint.") :]
  264. if not sect.isupper() and values:
  265. parser._sections[sect.upper()] = values
  266. if not verbose:
  267. return
  268. if use_config_file:
  269. msg = f"Using config file {os.path.abspath(config_file)}"
  270. else:
  271. msg = "No config file found, using default configuration"
  272. print(msg, file=sys.stderr)
  273. def _parse_toml(
  274. self, config_file: Union[Path, str], parser: configparser.ConfigParser
  275. ) -> None:
  276. """Parse and handle errors of a toml configuration file."""
  277. with open(config_file, encoding="utf-8") as fp:
  278. content = toml.load(fp)
  279. try:
  280. sections_values = content["tool"]["pylint"]
  281. except KeyError:
  282. return
  283. for section, values in sections_values.items():
  284. section_name = section.upper()
  285. # TOML has rich types, convert values to
  286. # strings as ConfigParser expects.
  287. if not isinstance(values, dict):
  288. # This class is a mixin: add_message comes from the `PyLinter` class
  289. self.add_message( # type: ignore[attr-defined]
  290. "bad-configuration-section", line=0, args=(section, values)
  291. )
  292. continue
  293. for option, value in values.items():
  294. if isinstance(value, bool):
  295. values[option] = "yes" if value else "no"
  296. elif isinstance(value, list):
  297. values[option] = ",".join(value)
  298. else:
  299. values[option] = str(value)
  300. for option, value in values.items():
  301. try:
  302. parser.set(section_name, option, value=value)
  303. except configparser.NoSectionError:
  304. parser.add_section(section_name)
  305. parser.set(section_name, option, value=value)
  306. def load_config_file(self):
  307. """Dispatch values previously read from a configuration file to each
  308. options provider)"""
  309. parser = self.cfgfile_parser
  310. for section in parser.sections():
  311. for option, value in parser.items(section):
  312. try:
  313. self.global_set_option(option, value)
  314. except (KeyError, optparse.OptionError):
  315. continue
  316. def load_configuration(self, **kwargs):
  317. """override configuration according to given parameters"""
  318. return self.load_configuration_from_config(kwargs)
  319. def load_configuration_from_config(self, config):
  320. for opt, opt_value in config.items():
  321. opt = opt.replace("_", "-")
  322. provider = self._all_options[opt]
  323. provider.set_option(opt, opt_value)
  324. def load_command_line_configuration(self, args=None) -> List[str]:
  325. """Override configuration according to command line parameters
  326. return additional arguments
  327. """
  328. with _patch_optparse():
  329. args = sys.argv[1:] if args is None else list(args)
  330. (options, args) = self.cmdline_parser.parse_args(args=args)
  331. for provider in self._nocallback_options:
  332. config = provider.config
  333. for attr in config.__dict__.keys():
  334. value = getattr(options, attr, None)
  335. if value is None:
  336. continue
  337. setattr(config, attr, value)
  338. return args
  339. def add_help_section(self, title, description, level=0):
  340. """add a dummy option section for help purpose"""
  341. group = optparse.OptionGroup(
  342. self.cmdline_parser, title=title.capitalize(), description=description
  343. )
  344. group.level = level
  345. self._maxlevel = max(self._maxlevel, level)
  346. self.cmdline_parser.add_option_group(group)
  347. def help(self, level=0):
  348. """return the usage string for available options"""
  349. self.cmdline_parser.formatter.output_level = level
  350. with _patch_optparse():
  351. return self.cmdline_parser.format_help()
  352. def helpfunc(self, option, opt, val, p, level): # pylint: disable=unused-argument
  353. print(self.help(level))
  354. sys.exit(0)