code_style.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  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. from __future__ import annotations
  5. import sys
  6. from typing import TYPE_CHECKING, Tuple, Type, cast
  7. from astroid import nodes
  8. from pylint.checkers import BaseChecker, utils
  9. from pylint.checkers.utils import only_required_for_messages, safe_infer
  10. from pylint.interfaces import INFERENCE
  11. if TYPE_CHECKING:
  12. from pylint.lint import PyLinter
  13. if sys.version_info >= (3, 10):
  14. from typing import TypeGuard
  15. else:
  16. from typing_extensions import TypeGuard
  17. class CodeStyleChecker(BaseChecker):
  18. """Checkers that can improve code consistency.
  19. As such they don't necessarily provide a performance benefit and
  20. are often times opinionated.
  21. Before adding another checker here, consider this:
  22. 1. Does the checker provide a clear benefit,
  23. i.e. detect a common issue or improve performance
  24. => it should probably be part of the core checker classes
  25. 2. Is it something that would improve code consistency,
  26. maybe because it's slightly better with regard to performance
  27. and therefore preferred => this is the right place
  28. 3. Everything else should go into another extension
  29. """
  30. name = "code_style"
  31. msgs = {
  32. "R6101": (
  33. "Consider using namedtuple or dataclass for dictionary values",
  34. "consider-using-namedtuple-or-dataclass",
  35. "Emitted when dictionary values can be replaced by namedtuples or dataclass instances.",
  36. ),
  37. "R6102": (
  38. "Consider using an in-place tuple instead of list",
  39. "consider-using-tuple",
  40. "Only for style consistency! "
  41. "Emitted where an in-place defined ``list`` can be replaced by a ``tuple``. "
  42. "Due to optimizations by CPython, there is no performance benefit from it.",
  43. ),
  44. "R6103": (
  45. "Use '%s' instead",
  46. "consider-using-assignment-expr",
  47. "Emitted when an if assignment is directly followed by an if statement and "
  48. "both can be combined by using an assignment expression ``:=``. "
  49. "Requires Python 3.8 and ``py-version >= 3.8``.",
  50. ),
  51. "R6104": (
  52. "Use '%s' to do an augmented assign directly",
  53. "consider-using-augmented-assign",
  54. "Emitted when an assignment is referring to the object that it is assigning "
  55. "to. This can be changed to be an augmented assign.\n"
  56. "Disabled by default!",
  57. {
  58. "default_enabled": False,
  59. },
  60. ),
  61. }
  62. options = (
  63. (
  64. "max-line-length-suggestions",
  65. {
  66. "type": "int",
  67. "default": 0,
  68. "metavar": "<int>",
  69. "help": (
  70. "Max line length for which to sill emit suggestions. "
  71. "Used to prevent optional suggestions which would get split "
  72. "by a code formatter (e.g., black). "
  73. "Will default to the setting for ``max-line-length``."
  74. ),
  75. },
  76. ),
  77. )
  78. def open(self) -> None:
  79. py_version = self.linter.config.py_version
  80. self._py38_plus = py_version >= (3, 8)
  81. self._max_length: int = (
  82. self.linter.config.max_line_length_suggestions
  83. or self.linter.config.max_line_length
  84. )
  85. @only_required_for_messages("consider-using-namedtuple-or-dataclass")
  86. def visit_dict(self, node: nodes.Dict) -> None:
  87. self._check_dict_consider_namedtuple_dataclass(node)
  88. @only_required_for_messages("consider-using-tuple")
  89. def visit_for(self, node: nodes.For) -> None:
  90. if isinstance(node.iter, nodes.List):
  91. self.add_message("consider-using-tuple", node=node.iter)
  92. @only_required_for_messages("consider-using-tuple")
  93. def visit_comprehension(self, node: nodes.Comprehension) -> None:
  94. if isinstance(node.iter, nodes.List):
  95. self.add_message("consider-using-tuple", node=node.iter)
  96. @only_required_for_messages("consider-using-assignment-expr")
  97. def visit_if(self, node: nodes.If) -> None:
  98. if self._py38_plus:
  99. self._check_consider_using_assignment_expr(node)
  100. def _check_dict_consider_namedtuple_dataclass(self, node: nodes.Dict) -> None:
  101. """Check if dictionary values can be replaced by Namedtuple or Dataclass."""
  102. if not (
  103. isinstance(node.parent, (nodes.Assign, nodes.AnnAssign))
  104. and isinstance(node.parent.parent, nodes.Module)
  105. or isinstance(node.parent, nodes.AnnAssign)
  106. and isinstance(node.parent.target, nodes.AssignName)
  107. and utils.is_assign_name_annotated_with(node.parent.target, "Final")
  108. ):
  109. # If dict is not part of an 'Assign' or 'AnnAssign' node in
  110. # a module context OR 'AnnAssign' with 'Final' annotation, skip check.
  111. return
  112. # All dict_values are itself dict nodes
  113. if len(node.items) > 1 and all(
  114. isinstance(dict_value, nodes.Dict) for _, dict_value in node.items
  115. ):
  116. KeyTupleT = Tuple[Type[nodes.NodeNG], str]
  117. # Makes sure all keys are 'Const' string nodes
  118. keys_checked: set[KeyTupleT] = set()
  119. for _, dict_value in node.items:
  120. dict_value = cast(nodes.Dict, dict_value)
  121. for key, _ in dict_value.items:
  122. key_tuple = (type(key), key.as_string())
  123. if key_tuple in keys_checked:
  124. continue
  125. inferred = safe_infer(key)
  126. if not (
  127. isinstance(inferred, nodes.Const)
  128. and inferred.pytype() == "builtins.str"
  129. ):
  130. return
  131. keys_checked.add(key_tuple)
  132. # Makes sure all subdicts have at least 1 common key
  133. key_tuples: list[tuple[KeyTupleT, ...]] = []
  134. for _, dict_value in node.items:
  135. dict_value = cast(nodes.Dict, dict_value)
  136. key_tuples.append(
  137. tuple((type(key), key.as_string()) for key, _ in dict_value.items)
  138. )
  139. keys_intersection: set[KeyTupleT] = set(key_tuples[0])
  140. for sub_key_tuples in key_tuples[1:]:
  141. keys_intersection.intersection_update(sub_key_tuples)
  142. if not keys_intersection:
  143. return
  144. self.add_message("consider-using-namedtuple-or-dataclass", node=node)
  145. return
  146. # All dict_values are itself either list or tuple nodes
  147. if len(node.items) > 1 and all(
  148. isinstance(dict_value, (nodes.List, nodes.Tuple))
  149. for _, dict_value in node.items
  150. ):
  151. # Make sure all sublists have the same length > 0
  152. list_length = len(node.items[0][1].elts)
  153. if list_length == 0:
  154. return
  155. for _, dict_value in node.items[1:]:
  156. if len(dict_value.elts) != list_length:
  157. return
  158. # Make sure at least one list entry isn't a dict
  159. for _, dict_value in node.items:
  160. if all(isinstance(entry, nodes.Dict) for entry in dict_value.elts):
  161. return
  162. self.add_message("consider-using-namedtuple-or-dataclass", node=node)
  163. return
  164. def _check_consider_using_assignment_expr(self, node: nodes.If) -> None:
  165. """Check if an assignment expression (walrus operator) can be used.
  166. For example if an assignment is directly followed by an if statement:
  167. >>> x = 2
  168. >>> if x:
  169. >>> ...
  170. Can be replaced by:
  171. >>> if (x := 2):
  172. >>> ...
  173. Note: Assignment expressions were added in Python 3.8
  174. """
  175. # Check if `node.test` contains a `Name` node
  176. node_name: nodes.Name | None = None
  177. if isinstance(node.test, nodes.Name):
  178. node_name = node.test
  179. elif (
  180. isinstance(node.test, nodes.UnaryOp)
  181. and node.test.op == "not"
  182. and isinstance(node.test.operand, nodes.Name)
  183. ):
  184. node_name = node.test.operand
  185. elif (
  186. isinstance(node.test, nodes.Compare)
  187. and isinstance(node.test.left, nodes.Name)
  188. and len(node.test.ops) == 1
  189. ):
  190. node_name = node.test.left
  191. else:
  192. return
  193. # Make sure the previous node is an assignment to the same name
  194. # used in `node.test`. Furthermore, ignore if assignment spans multiple lines.
  195. prev_sibling = node.previous_sibling()
  196. if CodeStyleChecker._check_prev_sibling_to_if_stmt(
  197. prev_sibling, node_name.name
  198. ):
  199. # Check if match statement would be a better fit.
  200. # I.e. multiple ifs that test the same name.
  201. if CodeStyleChecker._check_ignore_assignment_expr_suggestion(
  202. node, node_name.name
  203. ):
  204. return
  205. # Build suggestion string. Check length of suggestion
  206. # does not exceed max-line-length-suggestions
  207. test_str = node.test.as_string().replace(
  208. node_name.name,
  209. f"({node_name.name} := {prev_sibling.value.as_string()})",
  210. 1,
  211. )
  212. suggestion = f"if {test_str}:"
  213. if (
  214. node.col_offset is not None
  215. and len(suggestion) + node.col_offset > self._max_length
  216. or len(suggestion) > self._max_length
  217. ):
  218. return
  219. self.add_message(
  220. "consider-using-assignment-expr",
  221. node=node_name,
  222. args=(suggestion,),
  223. )
  224. @staticmethod
  225. def _check_prev_sibling_to_if_stmt(
  226. prev_sibling: nodes.NodeNG | None, name: str | None
  227. ) -> TypeGuard[nodes.Assign | nodes.AnnAssign]:
  228. """Check if previous sibling is an assignment with the same name.
  229. Ignore statements which span multiple lines.
  230. """
  231. if prev_sibling is None or prev_sibling.tolineno - prev_sibling.fromlineno != 0:
  232. return False
  233. if (
  234. isinstance(prev_sibling, nodes.Assign)
  235. and len(prev_sibling.targets) == 1
  236. and isinstance(prev_sibling.targets[0], nodes.AssignName)
  237. and prev_sibling.targets[0].name == name
  238. ):
  239. return True
  240. if (
  241. isinstance(prev_sibling, nodes.AnnAssign)
  242. and isinstance(prev_sibling.target, nodes.AssignName)
  243. and prev_sibling.target.name == name
  244. ):
  245. return True
  246. return False
  247. @staticmethod
  248. def _check_ignore_assignment_expr_suggestion(
  249. node: nodes.If, name: str | None
  250. ) -> bool:
  251. """Return True if suggestion for assignment expr should be ignored.
  252. E.g., in cases where a match statement would be a better fit
  253. (multiple conditions).
  254. """
  255. if isinstance(node.test, nodes.Compare):
  256. next_if_node: nodes.If | None = None
  257. next_sibling = node.next_sibling()
  258. if len(node.orelse) == 1 and isinstance(node.orelse[0], nodes.If):
  259. # elif block
  260. next_if_node = node.orelse[0]
  261. elif isinstance(next_sibling, nodes.If):
  262. # separate if block
  263. next_if_node = next_sibling
  264. if ( # pylint: disable=too-many-boolean-expressions
  265. next_if_node is not None
  266. and (
  267. isinstance(next_if_node.test, nodes.Compare)
  268. and isinstance(next_if_node.test.left, nodes.Name)
  269. and next_if_node.test.left.name == name
  270. or isinstance(next_if_node.test, nodes.Name)
  271. and next_if_node.test.name == name
  272. )
  273. ):
  274. return True
  275. return False
  276. @only_required_for_messages("consider-using-augmented-assign")
  277. def visit_assign(self, node: nodes.Assign) -> None:
  278. is_aug, op = utils.is_augmented_assign(node)
  279. if is_aug:
  280. self.add_message(
  281. "consider-using-augmented-assign",
  282. args=f"{op}=",
  283. node=node,
  284. line=node.lineno,
  285. col_offset=node.col_offset,
  286. confidence=INFERENCE,
  287. )
  288. def register(linter: PyLinter) -> None:
  289. linter.register_checker(CodeStyleChecker(linter))