design_analysis.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  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. """Check for signs of poor design."""
  5. from __future__ import annotations
  6. import re
  7. from collections import defaultdict
  8. from collections.abc import Iterator
  9. from typing import TYPE_CHECKING
  10. import astroid
  11. from astroid import nodes
  12. from pylint.checkers import BaseChecker
  13. from pylint.checkers.utils import is_enum, only_required_for_messages
  14. from pylint.typing import MessageDefinitionTuple
  15. if TYPE_CHECKING:
  16. from pylint.lint import PyLinter
  17. MSGS: dict[
  18. str, MessageDefinitionTuple
  19. ] = { # pylint: disable=consider-using-namedtuple-or-dataclass
  20. "R0901": (
  21. "Too many ancestors (%s/%s)",
  22. "too-many-ancestors",
  23. "Used when class has too many parent classes, try to reduce "
  24. "this to get a simpler (and so easier to use) class.",
  25. ),
  26. "R0902": (
  27. "Too many instance attributes (%s/%s)",
  28. "too-many-instance-attributes",
  29. "Used when class has too many instance attributes, try to reduce "
  30. "this to get a simpler (and so easier to use) class.",
  31. ),
  32. "R0903": (
  33. "Too few public methods (%s/%s)",
  34. "too-few-public-methods",
  35. "Used when class has too few public methods, so be sure it's "
  36. "really worth it.",
  37. ),
  38. "R0904": (
  39. "Too many public methods (%s/%s)",
  40. "too-many-public-methods",
  41. "Used when class has too many public methods, try to reduce "
  42. "this to get a simpler (and so easier to use) class.",
  43. ),
  44. "R0911": (
  45. "Too many return statements (%s/%s)",
  46. "too-many-return-statements",
  47. "Used when a function or method has too many return statement, "
  48. "making it hard to follow.",
  49. ),
  50. "R0912": (
  51. "Too many branches (%s/%s)",
  52. "too-many-branches",
  53. "Used when a function or method has too many branches, "
  54. "making it hard to follow.",
  55. ),
  56. "R0913": (
  57. "Too many arguments (%s/%s)",
  58. "too-many-arguments",
  59. "Used when a function or method takes too many arguments.",
  60. ),
  61. "R0914": (
  62. "Too many local variables (%s/%s)",
  63. "too-many-locals",
  64. "Used when a function or method has too many local variables.",
  65. ),
  66. "R0915": (
  67. "Too many statements (%s/%s)",
  68. "too-many-statements",
  69. "Used when a function or method has too many statements. You "
  70. "should then split it in smaller functions / methods.",
  71. ),
  72. "R0916": (
  73. "Too many boolean expressions in if statement (%s/%s)",
  74. "too-many-boolean-expressions",
  75. "Used when an if statement contains too many boolean expressions.",
  76. ),
  77. }
  78. SPECIAL_OBJ = re.compile("^_{2}[a-z]+_{2}$")
  79. DATACLASSES_DECORATORS = frozenset({"dataclass", "attrs"})
  80. DATACLASS_IMPORT = "dataclasses"
  81. TYPING_NAMEDTUPLE = "typing.NamedTuple"
  82. TYPING_TYPEDDICT = "typing.TypedDict"
  83. # Set of stdlib classes to ignore when calculating number of ancestors
  84. STDLIB_CLASSES_IGNORE_ANCESTOR = frozenset(
  85. (
  86. "builtins.object",
  87. "builtins.tuple",
  88. "builtins.dict",
  89. "builtins.list",
  90. "builtins.set",
  91. "bulitins.frozenset",
  92. "collections.ChainMap",
  93. "collections.Counter",
  94. "collections.OrderedDict",
  95. "collections.UserDict",
  96. "collections.UserList",
  97. "collections.UserString",
  98. "collections.defaultdict",
  99. "collections.deque",
  100. "collections.namedtuple",
  101. "_collections_abc.Awaitable",
  102. "_collections_abc.Coroutine",
  103. "_collections_abc.AsyncIterable",
  104. "_collections_abc.AsyncIterator",
  105. "_collections_abc.AsyncGenerator",
  106. "_collections_abc.Hashable",
  107. "_collections_abc.Iterable",
  108. "_collections_abc.Iterator",
  109. "_collections_abc.Generator",
  110. "_collections_abc.Reversible",
  111. "_collections_abc.Sized",
  112. "_collections_abc.Container",
  113. "_collections_abc.Collection",
  114. "_collections_abc.Set",
  115. "_collections_abc.MutableSet",
  116. "_collections_abc.Mapping",
  117. "_collections_abc.MutableMapping",
  118. "_collections_abc.MappingView",
  119. "_collections_abc.KeysView",
  120. "_collections_abc.ItemsView",
  121. "_collections_abc.ValuesView",
  122. "_collections_abc.Sequence",
  123. "_collections_abc.MutableSequence",
  124. "_collections_abc.ByteString",
  125. "typing.Tuple",
  126. "typing.List",
  127. "typing.Dict",
  128. "typing.Set",
  129. "typing.FrozenSet",
  130. "typing.Deque",
  131. "typing.DefaultDict",
  132. "typing.OrderedDict",
  133. "typing.Counter",
  134. "typing.ChainMap",
  135. "typing.Awaitable",
  136. "typing.Coroutine",
  137. "typing.AsyncIterable",
  138. "typing.AsyncIterator",
  139. "typing.AsyncGenerator",
  140. "typing.Iterable",
  141. "typing.Iterator",
  142. "typing.Generator",
  143. "typing.Reversible",
  144. "typing.Container",
  145. "typing.Collection",
  146. "typing.AbstractSet",
  147. "typing.MutableSet",
  148. "typing.Mapping",
  149. "typing.MutableMapping",
  150. "typing.Sequence",
  151. "typing.MutableSequence",
  152. "typing.ByteString",
  153. "typing.MappingView",
  154. "typing.KeysView",
  155. "typing.ItemsView",
  156. "typing.ValuesView",
  157. "typing.ContextManager",
  158. "typing.AsyncContextManager",
  159. "typing.Hashable",
  160. "typing.Sized",
  161. )
  162. )
  163. def _is_exempt_from_public_methods(node: astroid.ClassDef) -> bool:
  164. """Check if a class is exempt from too-few-public-methods."""
  165. # If it's a typing.Namedtuple, typing.TypedDict or an Enum
  166. for ancestor in node.ancestors():
  167. if is_enum(ancestor):
  168. return True
  169. if ancestor.qname() in (TYPING_NAMEDTUPLE, TYPING_TYPEDDICT):
  170. return True
  171. # Or if it's a dataclass
  172. if not node.decorators:
  173. return False
  174. root_locals = set(node.root().locals)
  175. for decorator in node.decorators.nodes:
  176. if isinstance(decorator, astroid.Call):
  177. decorator = decorator.func
  178. if not isinstance(decorator, (astroid.Name, astroid.Attribute)):
  179. continue
  180. if isinstance(decorator, astroid.Name):
  181. name = decorator.name
  182. else:
  183. name = decorator.attrname
  184. if name in DATACLASSES_DECORATORS and (
  185. root_locals.intersection(DATACLASSES_DECORATORS)
  186. or DATACLASS_IMPORT in root_locals
  187. ):
  188. return True
  189. return False
  190. def _count_boolean_expressions(bool_op: nodes.BoolOp) -> int:
  191. """Counts the number of boolean expressions in BoolOp `bool_op` (recursive).
  192. example: a and (b or c or (d and e)) ==> 5 boolean expressions
  193. """
  194. nb_bool_expr = 0
  195. for bool_expr in bool_op.get_children():
  196. if isinstance(bool_expr, astroid.BoolOp):
  197. nb_bool_expr += _count_boolean_expressions(bool_expr)
  198. else:
  199. nb_bool_expr += 1
  200. return nb_bool_expr
  201. def _count_methods_in_class(node: nodes.ClassDef) -> int:
  202. all_methods = sum(1 for method in node.methods() if not method.name.startswith("_"))
  203. # Special methods count towards the number of public methods,
  204. # but don't count towards there being too many methods.
  205. for method in node.mymethods():
  206. if SPECIAL_OBJ.search(method.name) and method.name != "__init__":
  207. all_methods += 1
  208. return all_methods
  209. def _get_parents_iter(
  210. node: nodes.ClassDef, ignored_parents: frozenset[str]
  211. ) -> Iterator[nodes.ClassDef]:
  212. r"""Get parents of ``node``, excluding ancestors of ``ignored_parents``.
  213. If we have the following inheritance diagram:
  214. F
  215. /
  216. D E
  217. \/
  218. B C
  219. \/
  220. A # class A(B, C): ...
  221. And ``ignored_parents`` is ``{"E"}``, then this function will return
  222. ``{A, B, C, D}`` -- both ``E`` and its ancestors are excluded.
  223. """
  224. parents: set[nodes.ClassDef] = set()
  225. to_explore = list(node.ancestors(recurs=False))
  226. while to_explore:
  227. parent = to_explore.pop()
  228. if parent.qname() in ignored_parents:
  229. continue
  230. if parent not in parents:
  231. # This guard might appear to be performing the same function as
  232. # adding the resolved parents to a set to eliminate duplicates
  233. # (legitimate due to diamond inheritance patterns), but its
  234. # additional purpose is to prevent cycles (not normally possible,
  235. # but potential due to inference) and thus guarantee termination
  236. # of the while-loop
  237. yield parent
  238. parents.add(parent)
  239. to_explore.extend(parent.ancestors(recurs=False))
  240. def _get_parents(
  241. node: nodes.ClassDef, ignored_parents: frozenset[str]
  242. ) -> set[nodes.ClassDef]:
  243. return set(_get_parents_iter(node, ignored_parents))
  244. class MisdesignChecker(BaseChecker):
  245. """Checker of potential misdesigns.
  246. Checks for sign of poor/misdesign:
  247. * number of methods, attributes, local variables...
  248. * size, complexity of functions, methods
  249. """
  250. # configuration section name
  251. name = "design"
  252. # messages
  253. msgs = MSGS
  254. # configuration options
  255. options = (
  256. (
  257. "max-args",
  258. {
  259. "default": 5,
  260. "type": "int",
  261. "metavar": "<int>",
  262. "help": "Maximum number of arguments for function / method.",
  263. },
  264. ),
  265. (
  266. "max-locals",
  267. {
  268. "default": 15,
  269. "type": "int",
  270. "metavar": "<int>",
  271. "help": "Maximum number of locals for function / method body.",
  272. },
  273. ),
  274. (
  275. "max-returns",
  276. {
  277. "default": 6,
  278. "type": "int",
  279. "metavar": "<int>",
  280. "help": "Maximum number of return / yield for function / "
  281. "method body.",
  282. },
  283. ),
  284. (
  285. "max-branches",
  286. {
  287. "default": 12,
  288. "type": "int",
  289. "metavar": "<int>",
  290. "help": "Maximum number of branch for function / method body.",
  291. },
  292. ),
  293. (
  294. "max-statements",
  295. {
  296. "default": 50,
  297. "type": "int",
  298. "metavar": "<int>",
  299. "help": "Maximum number of statements in function / method body.",
  300. },
  301. ),
  302. (
  303. "max-parents",
  304. {
  305. "default": 7,
  306. "type": "int",
  307. "metavar": "<num>",
  308. "help": "Maximum number of parents for a class (see R0901).",
  309. },
  310. ),
  311. (
  312. "ignored-parents",
  313. {
  314. "default": (),
  315. "type": "csv",
  316. "metavar": "<comma separated list of class names>",
  317. "help": "List of qualified class names to ignore when counting class parents (see R0901)",
  318. },
  319. ),
  320. (
  321. "max-attributes",
  322. {
  323. "default": 7,
  324. "type": "int",
  325. "metavar": "<num>",
  326. "help": "Maximum number of attributes for a class \
  327. (see R0902).",
  328. },
  329. ),
  330. (
  331. "min-public-methods",
  332. {
  333. "default": 2,
  334. "type": "int",
  335. "metavar": "<num>",
  336. "help": "Minimum number of public methods for a class \
  337. (see R0903).",
  338. },
  339. ),
  340. (
  341. "max-public-methods",
  342. {
  343. "default": 20,
  344. "type": "int",
  345. "metavar": "<num>",
  346. "help": "Maximum number of public methods for a class \
  347. (see R0904).",
  348. },
  349. ),
  350. (
  351. "max-bool-expr",
  352. {
  353. "default": 5,
  354. "type": "int",
  355. "metavar": "<num>",
  356. "help": "Maximum number of boolean expressions in an if "
  357. "statement (see R0916).",
  358. },
  359. ),
  360. (
  361. "exclude-too-few-public-methods",
  362. {
  363. "default": [],
  364. "type": "regexp_csv",
  365. "metavar": "<pattern>[,<pattern>...]",
  366. "help": "List of regular expressions of class ancestor names "
  367. "to ignore when counting public methods (see R0903)",
  368. },
  369. ),
  370. )
  371. def __init__(self, linter: PyLinter) -> None:
  372. super().__init__(linter)
  373. self._returns: list[int]
  374. self._branches: defaultdict[nodes.LocalsDictNodeNG, int]
  375. self._stmts: list[int]
  376. def open(self) -> None:
  377. """Initialize visit variables."""
  378. self.linter.stats.reset_node_count()
  379. self._returns = []
  380. self._branches = defaultdict(int)
  381. self._stmts = []
  382. self._exclude_too_few_public_methods = (
  383. self.linter.config.exclude_too_few_public_methods
  384. )
  385. def _inc_all_stmts(self, amount: int) -> None:
  386. for i, _ in enumerate(self._stmts):
  387. self._stmts[i] += amount
  388. @only_required_for_messages(
  389. "too-many-ancestors",
  390. "too-many-instance-attributes",
  391. "too-few-public-methods",
  392. "too-many-public-methods",
  393. )
  394. def visit_classdef(self, node: nodes.ClassDef) -> None:
  395. """Check size of inheritance hierarchy and number of instance attributes."""
  396. parents = _get_parents(
  397. node,
  398. STDLIB_CLASSES_IGNORE_ANCESTOR.union(self.linter.config.ignored_parents),
  399. )
  400. nb_parents = len(parents)
  401. if nb_parents > self.linter.config.max_parents:
  402. self.add_message(
  403. "too-many-ancestors",
  404. node=node,
  405. args=(nb_parents, self.linter.config.max_parents),
  406. )
  407. if len(node.instance_attrs) > self.linter.config.max_attributes:
  408. self.add_message(
  409. "too-many-instance-attributes",
  410. node=node,
  411. args=(len(node.instance_attrs), self.linter.config.max_attributes),
  412. )
  413. @only_required_for_messages("too-few-public-methods", "too-many-public-methods")
  414. def leave_classdef(self, node: nodes.ClassDef) -> None:
  415. """Check number of public methods."""
  416. my_methods = sum(
  417. 1 for method in node.mymethods() if not method.name.startswith("_")
  418. )
  419. # Does the class contain less than n public methods ?
  420. # This checks only the methods defined in the current class,
  421. # since the user might not have control over the classes
  422. # from the ancestors. It avoids some false positives
  423. # for classes such as unittest.TestCase, which provides
  424. # a lot of assert methods. It doesn't make sense to warn
  425. # when the user subclasses TestCase to add his own tests.
  426. if my_methods > self.linter.config.max_public_methods:
  427. self.add_message(
  428. "too-many-public-methods",
  429. node=node,
  430. args=(my_methods, self.linter.config.max_public_methods),
  431. )
  432. # Stop here if the class is excluded via configuration.
  433. if node.type == "class" and self._exclude_too_few_public_methods:
  434. for ancestor in node.ancestors():
  435. if any(
  436. pattern.match(ancestor.qname())
  437. for pattern in self._exclude_too_few_public_methods
  438. ):
  439. return
  440. # Stop here for exception, metaclass, interface classes and other
  441. # classes for which we don't need to count the methods.
  442. if node.type != "class" or _is_exempt_from_public_methods(node):
  443. return
  444. # Does the class contain more than n public methods ?
  445. # This checks all the methods defined by ancestors and
  446. # by the current class.
  447. all_methods = _count_methods_in_class(node)
  448. if all_methods < self.linter.config.min_public_methods:
  449. self.add_message(
  450. "too-few-public-methods",
  451. node=node,
  452. args=(all_methods, self.linter.config.min_public_methods),
  453. )
  454. @only_required_for_messages(
  455. "too-many-return-statements",
  456. "too-many-branches",
  457. "too-many-arguments",
  458. "too-many-locals",
  459. "too-many-statements",
  460. "keyword-arg-before-vararg",
  461. )
  462. def visit_functiondef(self, node: nodes.FunctionDef) -> None:
  463. """Check function name, docstring, arguments, redefinition,
  464. variable names, max locals.
  465. """
  466. # init branch and returns counters
  467. self._returns.append(0)
  468. # check number of arguments
  469. args = node.args.args
  470. ignored_argument_names = self.linter.config.ignored_argument_names
  471. if args is not None:
  472. ignored_args_num = 0
  473. if ignored_argument_names:
  474. ignored_args_num = sum(
  475. 1 for arg in args if ignored_argument_names.match(arg.name)
  476. )
  477. argnum = len(args) - ignored_args_num
  478. if argnum > self.linter.config.max_args:
  479. self.add_message(
  480. "too-many-arguments",
  481. node=node,
  482. args=(len(args), self.linter.config.max_args),
  483. )
  484. else:
  485. ignored_args_num = 0
  486. # check number of local variables
  487. locnum = len(node.locals) - ignored_args_num
  488. # decrement number of local variables if '_' is one of them
  489. if "_" in node.locals:
  490. locnum -= 1
  491. if locnum > self.linter.config.max_locals:
  492. self.add_message(
  493. "too-many-locals",
  494. node=node,
  495. args=(locnum, self.linter.config.max_locals),
  496. )
  497. # init new statements counter
  498. self._stmts.append(1)
  499. visit_asyncfunctiondef = visit_functiondef
  500. @only_required_for_messages(
  501. "too-many-return-statements",
  502. "too-many-branches",
  503. "too-many-arguments",
  504. "too-many-locals",
  505. "too-many-statements",
  506. )
  507. def leave_functiondef(self, node: nodes.FunctionDef) -> None:
  508. """Most of the work is done here on close:
  509. checks for max returns, branch, return in __init__.
  510. """
  511. returns = self._returns.pop()
  512. if returns > self.linter.config.max_returns:
  513. self.add_message(
  514. "too-many-return-statements",
  515. node=node,
  516. args=(returns, self.linter.config.max_returns),
  517. )
  518. branches = self._branches[node]
  519. if branches > self.linter.config.max_branches:
  520. self.add_message(
  521. "too-many-branches",
  522. node=node,
  523. args=(branches, self.linter.config.max_branches),
  524. )
  525. # check number of statements
  526. stmts = self._stmts.pop()
  527. if stmts > self.linter.config.max_statements:
  528. self.add_message(
  529. "too-many-statements",
  530. node=node,
  531. args=(stmts, self.linter.config.max_statements),
  532. )
  533. leave_asyncfunctiondef = leave_functiondef
  534. def visit_return(self, _: nodes.Return) -> None:
  535. """Count number of returns."""
  536. if not self._returns:
  537. return # return outside function, reported by the base checker
  538. self._returns[-1] += 1
  539. def visit_default(self, node: nodes.NodeNG) -> None:
  540. """Default visit method -> increments the statements counter if
  541. necessary.
  542. """
  543. if node.is_statement:
  544. self._inc_all_stmts(1)
  545. def visit_tryexcept(self, node: nodes.TryExcept) -> None:
  546. """Increments the branches counter."""
  547. branches = len(node.handlers)
  548. if node.orelse:
  549. branches += 1
  550. self._inc_branch(node, branches)
  551. self._inc_all_stmts(branches)
  552. def visit_tryfinally(self, node: nodes.TryFinally) -> None:
  553. """Increments the branches counter."""
  554. self._inc_branch(node, 2)
  555. self._inc_all_stmts(2)
  556. @only_required_for_messages("too-many-boolean-expressions", "too-many-branches")
  557. def visit_if(self, node: nodes.If) -> None:
  558. """Increments the branches counter and checks boolean expressions."""
  559. self._check_boolean_expressions(node)
  560. branches = 1
  561. # don't double count If nodes coming from some 'elif'
  562. if node.orelse and (
  563. len(node.orelse) > 1 or not isinstance(node.orelse[0], astroid.If)
  564. ):
  565. branches += 1
  566. self._inc_branch(node, branches)
  567. self._inc_all_stmts(branches)
  568. def _check_boolean_expressions(self, node: nodes.If) -> None:
  569. """Go through "if" node `node` and count its boolean expressions
  570. if the 'if' node test is a BoolOp node.
  571. """
  572. condition = node.test
  573. if not isinstance(condition, astroid.BoolOp):
  574. return
  575. nb_bool_expr = _count_boolean_expressions(condition)
  576. if nb_bool_expr > self.linter.config.max_bool_expr:
  577. self.add_message(
  578. "too-many-boolean-expressions",
  579. node=condition,
  580. args=(nb_bool_expr, self.linter.config.max_bool_expr),
  581. )
  582. def visit_while(self, node: nodes.While) -> None:
  583. """Increments the branches counter."""
  584. branches = 1
  585. if node.orelse:
  586. branches += 1
  587. self._inc_branch(node, branches)
  588. visit_for = visit_while
  589. def _inc_branch(self, node: nodes.NodeNG, branchesnum: int = 1) -> None:
  590. """Increments the branches counter."""
  591. self._branches[node.scope()] += branchesnum
  592. def register(linter: PyLinter) -> None:
  593. linter.register_checker(MisdesignChecker(linter))