profile.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import codecs
  2. import json
  3. import os
  4. import pkgutil
  5. from pathlib import Path
  6. from typing import Any, Dict, List, Optional, Tuple, Union
  7. import yaml
  8. from prospector.profiles.exceptions import CannotParseProfile, ProfileNotFound
  9. from prospector.tools import DEFAULT_TOOLS, TOOLS
  10. BUILTIN_PROFILE_PATH = (Path(__file__).parent / "profiles").absolute()
  11. class ProspectorProfile:
  12. def __init__(self, name: str, profile_dict: Dict[str, Any], inherit_order: List[str]):
  13. self.name = name
  14. self.inherit_order = inherit_order
  15. self.ignore_paths = _ensure_list(profile_dict.get("ignore-paths", []))
  16. # The 'ignore' directive is an old one which should be deprecated at some point
  17. self.ignore_patterns = _ensure_list(profile_dict.get("ignore-patterns", []) + profile_dict.get("ignore", []))
  18. self.output_format = profile_dict.get("output-format")
  19. self.output_target = profile_dict.get("output-target")
  20. self.autodetect = profile_dict.get("autodetect", True)
  21. self.uses = [
  22. uses for uses in _ensure_list(profile_dict.get("uses", [])) if uses in ("django", "celery", "flask")
  23. ]
  24. self.max_line_length = profile_dict.get("max-line-length")
  25. # informational shorthands
  26. self.strictness = profile_dict.get("strictness")
  27. self.test_warnings = profile_dict.get("test-warnings")
  28. self.doc_warnings = profile_dict.get("doc-warnings")
  29. self.member_warnings = profile_dict.get("member-warnings")
  30. # TODO: this is needed by Landscape but not by prospector; there is probably a better place for it
  31. self.requirements = _ensure_list(profile_dict.get("requirements", []))
  32. for tool in TOOLS:
  33. tool_conf = profile_dict.get(tool, {})
  34. # set the defaults for everything
  35. conf: Dict[str, Any] = {"disable": [], "enable": [], "run": None, "options": {}}
  36. # use the "old" tool name
  37. conf.update(tool_conf)
  38. if self.max_line_length is not None and tool in ("pylint", "pycodestyle"):
  39. conf["options"]["max-line-length"] = self.max_line_length
  40. setattr(self, tool, conf)
  41. def get_disabled_messages(self, tool_name):
  42. disable = getattr(self, tool_name)["disable"]
  43. enable = getattr(self, tool_name)["enable"]
  44. return list(set(disable) - set(enable))
  45. def is_tool_enabled(self, name):
  46. enabled = getattr(self, name).get("run")
  47. if enabled is not None:
  48. return enabled
  49. # this is not explicitly enabled or disabled, so use the default
  50. return name in DEFAULT_TOOLS
  51. def list_profiles(self):
  52. # this profile is itself included
  53. return [str(profile) for profile in self.inherit_order]
  54. def as_dict(self):
  55. out = {
  56. "ignore-paths": self.ignore_paths,
  57. "ignore-patterns": self.ignore_patterns,
  58. "output-format": self.output_format,
  59. "output-target": self.output_target,
  60. "autodetect": self.autodetect,
  61. "uses": self.uses,
  62. "max-line-length": self.max_line_length,
  63. "member-warnings": self.member_warnings,
  64. "doc-warnings": self.doc_warnings,
  65. "test-warnings": self.test_warnings,
  66. "strictness": self.strictness,
  67. "requirements": self.requirements,
  68. }
  69. for tool in TOOLS:
  70. out[tool] = getattr(self, tool)
  71. return out
  72. def as_json(self):
  73. return json.dumps(self.as_dict())
  74. def as_yaml(self):
  75. return yaml.safe_dump(self.as_dict())
  76. @staticmethod
  77. def load(
  78. name_or_path: Union[str, Path],
  79. profile_path: List[Path],
  80. allow_shorthand: bool = True,
  81. forced_inherits: Optional[List[str]] = None,
  82. ):
  83. # First simply load all of the profiles and those that it explicitly inherits from
  84. data, inherits = _load_and_merge(
  85. name_or_path,
  86. profile_path,
  87. allow_shorthand,
  88. forced_inherits=forced_inherits or [],
  89. )
  90. return ProspectorProfile(str(name_or_path), data, inherits)
  91. def _is_valid_extension(filename):
  92. ext = os.path.splitext(filename)[1]
  93. return ext in (".yml", ".yaml")
  94. def _load_content_package(name):
  95. name_split = name.split(":", 1)
  96. module_name = f"prospector_profile_{name_split[0]}"
  97. file_names = (
  98. ["prospector.yaml", "prospector.yml"]
  99. if len(name_split) == 1
  100. else [f"{name_split[1]}.yaml", f"{name_split[1]}.yaml"]
  101. )
  102. data = None
  103. used_name = None
  104. for file_name in file_names:
  105. used_name = f"{module_name}:{file_name}"
  106. data = pkgutil.get_data(module_name, file_name)
  107. if data is not None:
  108. break
  109. if data is None:
  110. return None
  111. try:
  112. return yaml.safe_load(data) or {}
  113. except yaml.parser.ParserError as parse_error:
  114. raise CannotParseProfile(used_name, parse_error) from parse_error
  115. def _load_content(name_or_path, profile_path):
  116. filename = None
  117. optional = False
  118. if isinstance(name_or_path, str) and name_or_path.endswith("?"):
  119. optional = True
  120. name_or_path = name_or_path[:-1]
  121. if _is_valid_extension(name_or_path):
  122. for path in profile_path:
  123. filepath = os.path.join(path, name_or_path)
  124. if os.path.exists(filepath):
  125. # this is a full path that we can load
  126. filename = filepath
  127. break
  128. else:
  129. for path in profile_path:
  130. for ext in ("yml", "yaml"):
  131. filepath = os.path.join(path, f"{name_or_path}.{ext}")
  132. if os.path.exists(filepath):
  133. filename = filepath
  134. break
  135. if filename is None:
  136. result = _load_content_package(name_or_path)
  137. if result is not None:
  138. return result
  139. if optional:
  140. return {}
  141. raise ProfileNotFound(name_or_path, profile_path)
  142. with codecs.open(filename) as fct:
  143. try:
  144. return yaml.safe_load(fct) or {}
  145. except yaml.parser.ParserError as parse_error:
  146. raise CannotParseProfile(filename, parse_error) from parse_error
  147. def _ensure_list(value):
  148. if isinstance(value, list):
  149. return value
  150. return [value]
  151. def _simple_merge_dict(priority, base):
  152. out = dict(base.items())
  153. out.update(dict(priority.items()))
  154. return out
  155. def _merge_tool_config(priority, base):
  156. out = dict(base.items())
  157. # add options that are missing, but keep existing options from the priority dictionary
  158. # TODO: write a unit test for this :-|
  159. out["options"] = _simple_merge_dict(priority.get("options", {}), base.get("options", {}))
  160. # copy in some basic pieces
  161. for key in ("run", "load-plugins"):
  162. value = priority.get(key, base.get(key))
  163. if value is not None:
  164. out[key] = value
  165. # anything enabled in the 'priority' dict is removed
  166. # from 'disabled' in the base dict and vice versa
  167. base_disabled = base.get("disable") or []
  168. base_enabled = base.get("enable") or []
  169. pri_disabled = priority.get("disable") or []
  170. pri_enabled = priority.get("enable") or []
  171. out["disable"] = list(set(pri_disabled) | (set(base_disabled) - set(pri_enabled)))
  172. out["enable"] = list(set(pri_enabled) | (set(base_enabled) - set(pri_disabled)))
  173. return out
  174. def _merge_profile_dict(priority: dict, base: dict) -> dict:
  175. # copy the base dict into our output
  176. out = dict(base.items())
  177. for key, value in priority.items():
  178. if key in (
  179. "strictness",
  180. "doc-warnings",
  181. "test-warnings",
  182. "member-warnings",
  183. "output-format",
  184. "autodetect",
  185. "max-line-length",
  186. "pep8",
  187. ):
  188. # some keys are simple values which are overwritten
  189. out[key] = value
  190. elif key in (
  191. "ignore",
  192. "ignore-patterns",
  193. "ignore-paths",
  194. "uses",
  195. "requirements",
  196. "python-targets",
  197. "output-target",
  198. ):
  199. # some keys should be appended
  200. out[key] = _ensure_list(value) + _ensure_list(base.get(key, []))
  201. elif key in TOOLS:
  202. # this is tool config!
  203. out[key] = _merge_tool_config(value, base.get(key, {}))
  204. return out
  205. def _determine_strictness(profile_dict, inherits):
  206. for profile in inherits:
  207. if profile.startswith("strictness_"):
  208. return None, False
  209. strictness = profile_dict.get("strictness")
  210. if strictness is None:
  211. return None, False
  212. return ("strictness_%s" % strictness), True
  213. def _determine_pep8(profile_dict):
  214. pep8 = profile_dict.get("pep8")
  215. if pep8 == "full":
  216. return "full_pep8", True
  217. if pep8 == "none":
  218. return "no_pep8", True
  219. if isinstance(pep8, dict) and pep8.get("full", False):
  220. return "full_pep8", False
  221. return None, False
  222. def _determine_doc_warnings(profile_dict):
  223. doc_warnings = profile_dict.get("doc-warnings")
  224. if doc_warnings is None:
  225. return None, False
  226. return ("doc_warnings" if doc_warnings else "no_doc_warnings"), True
  227. def _determine_test_warnings(profile_dict):
  228. test_warnings = profile_dict.get("test-warnings")
  229. if test_warnings is None:
  230. return None, False
  231. return (None if test_warnings else "no_test_warnings"), True
  232. def _determine_member_warnings(profile_dict):
  233. member_warnings = profile_dict.get("member-warnings")
  234. if member_warnings is None:
  235. return None, False
  236. return ("member_warnings" if member_warnings else "no_member_warnings"), True
  237. def _determine_implicit_inherits(profile_dict, already_inherits, shorthands_found):
  238. # Note: the ordering is very important here - the earlier items
  239. # in the list have precedence over the later items. The point of
  240. # the doc/test/pep8 profiles is usually to restore items which were
  241. # turned off in the strictness profile, so they must appear first.
  242. implicit = [
  243. ("pep8", _determine_pep8(profile_dict)),
  244. ("docs", _determine_doc_warnings(profile_dict)),
  245. ("tests", _determine_test_warnings(profile_dict)),
  246. ("strictness", _determine_strictness(profile_dict, already_inherits)),
  247. ("members", _determine_member_warnings(profile_dict)),
  248. ]
  249. inherits = []
  250. for shorthand_name, determined in implicit:
  251. if shorthand_name in shorthands_found:
  252. continue
  253. extra_inherits, shorthand_found = determined
  254. if not shorthand_found:
  255. continue
  256. shorthands_found.add(shorthand_name)
  257. if extra_inherits is not None:
  258. inherits.append(extra_inherits)
  259. return inherits, shorthands_found
  260. def _append_profiles(name, profile_path, data, inherit_list, allow_shorthand=False):
  261. new_data, new_il, _ = _load_profile(name, profile_path, allow_shorthand=allow_shorthand)
  262. data.update(new_data)
  263. inherit_list += new_il
  264. return data, inherit_list
  265. def _load_and_merge(
  266. name_or_path: Union[str, Path],
  267. profile_path: List[Path],
  268. allow_shorthand: bool = True,
  269. forced_inherits: List[str] = None,
  270. ) -> Tuple[Dict[str, Any], List[str]]:
  271. # First simply load all of the profiles and those that it explicitly inherits from
  272. data, inherit_list, shorthands_found = _load_profile(
  273. str(name_or_path),
  274. profile_path,
  275. allow_shorthand=allow_shorthand,
  276. forced_inherits=forced_inherits or [],
  277. )
  278. if allow_shorthand:
  279. if "docs" not in shorthands_found:
  280. data, inherit_list = _append_profiles("no_doc_warnings", profile_path, data, inherit_list)
  281. if "members" not in shorthands_found:
  282. data, inherit_list = _append_profiles("no_member_warnings", profile_path, data, inherit_list)
  283. if "tests" not in shorthands_found:
  284. data, inherit_list = _append_profiles("no_test_warnings", profile_path, data, inherit_list)
  285. if "strictness" not in shorthands_found:
  286. # if no strictness was specified, then we should manually insert the medium strictness
  287. for inherit in inherit_list:
  288. if inherit.startswith("strictness_"):
  289. break
  290. else:
  291. data, inherit_list = _append_profiles("strictness_medium", profile_path, data, inherit_list)
  292. # Now we merge all of the values together, from 'right to left' (ie, from the
  293. # top of the inheritance tree to the bottom). This means that the lower down
  294. # values overwrite those from above, meaning that the initially provided profile
  295. # has precedence.
  296. merged: dict = {}
  297. for name in inherit_list[::-1]:
  298. priority = data[name]
  299. merged = _merge_profile_dict(priority, merged)
  300. return merged, inherit_list
  301. def _transform_legacy(profile_dict):
  302. """
  303. After pep8 was renamed to pycodestyle, this pre-filter just moves profile
  304. config blocks using the old name to use the new name, merging if both are
  305. specified.
  306. Same for pep257->pydocstyle
  307. """
  308. out = {}
  309. # copy in existing pep8/pep257 using new names to start
  310. if "pycodestyle" in profile_dict:
  311. out["pycodestyle"] = profile_dict["pycodestyle"]
  312. if "pydocstyle" in profile_dict:
  313. out["pydocstyle"] = profile_dict["pydocstyle"]
  314. # pep8 is tricky as it's overloaded as a tool configuration and a shorthand
  315. # first, is this the short "pep8: full" version or a configuration of the
  316. # pycodestyle tool using the old name?
  317. if "pep8" in profile_dict:
  318. pep8conf = profile_dict["pep8"]
  319. if isinstance(pep8conf, dict):
  320. # merge in with existing config if there is any
  321. out["pycodestyle"] = _simple_merge_dict(out.get("pycodestyle", {}), pep8conf)
  322. else:
  323. # otherwise it's shortform, just copy it in directly
  324. out["pep8"] = pep8conf
  325. del profile_dict["pep8"]
  326. if "pep257" in profile_dict:
  327. out["pydocstyle"] = _simple_merge_dict(out.get("pydocstyle", {}), profile_dict["pep257"])
  328. del profile_dict["pep257"]
  329. # now just copy the rest in
  330. for key, value in profile_dict.items():
  331. if key in ("pycodestyle", "pydocstyle"):
  332. # already handled these
  333. continue
  334. out[key] = value
  335. return out
  336. def _load_profile(
  337. name_or_path,
  338. profile_path,
  339. shorthands_found=None,
  340. already_loaded=None,
  341. allow_shorthand=True,
  342. forced_inherits=None,
  343. ):
  344. # recursively get the contents of the basic profile and those it inherits from
  345. base_contents = _load_content(name_or_path, profile_path)
  346. base_contents = _transform_legacy(base_contents)
  347. inherit_order = [name_or_path]
  348. shorthands_found = shorthands_found or set()
  349. already_loaded = already_loaded or []
  350. already_loaded.append(name_or_path)
  351. inherits = _ensure_list(base_contents.get("inherits", []))
  352. if forced_inherits is not None:
  353. inherits += forced_inherits
  354. # There are some 'shorthand' options in profiles which implicitly mean that we
  355. # should inherit from some of prospector's built-in profiles
  356. if base_contents.get("allow-shorthand", True) and allow_shorthand:
  357. extra_inherits, extra_shorthands = _determine_implicit_inherits(base_contents, inherits, shorthands_found)
  358. inherits += extra_inherits
  359. shorthands_found |= extra_shorthands
  360. contents_dict = {name_or_path: base_contents}
  361. for inherit_profile in inherits:
  362. if inherit_profile in already_loaded:
  363. # we already have this loaded and in the list
  364. continue
  365. already_loaded.append(inherit_profile)
  366. new_cd, new_il, new_sh = _load_profile(
  367. inherit_profile,
  368. profile_path,
  369. shorthands_found,
  370. already_loaded,
  371. allow_shorthand,
  372. )
  373. contents_dict.update(new_cd)
  374. inherit_order += new_il
  375. shorthands_found |= new_sh
  376. # note: a new list is returned here rather than simply using inherit_order to give astroid a
  377. # clue about the type of the returned object, as otherwise it can recurse infinitely and crash,
  378. # this meaning that prospector does not run on prospector cleanly!
  379. return contents_dict, list(inherit_order), shorthands_found