private_import.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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 imports on private external modules and names."""
  5. from __future__ import annotations
  6. from pathlib import Path
  7. from typing import TYPE_CHECKING
  8. from astroid import nodes
  9. from pylint.checkers import BaseChecker, utils
  10. from pylint.interfaces import HIGH
  11. if TYPE_CHECKING:
  12. from pylint.lint.pylinter import PyLinter
  13. class PrivateImportChecker(BaseChecker):
  14. name = "import-private-name"
  15. msgs = {
  16. "C2701": (
  17. "Imported private %s (%s)",
  18. "import-private-name",
  19. "Used when a private module or object prefixed with _ is imported. "
  20. "PEP8 guidance on Naming Conventions states that public attributes with "
  21. "leading underscores should be considered private.",
  22. ),
  23. }
  24. def __init__(self, linter: PyLinter) -> None:
  25. BaseChecker.__init__(self, linter)
  26. # A mapping of private names used as a type annotation to whether it is an acceptable import
  27. self.all_used_type_annotations: dict[str, bool] = {}
  28. self.populated_annotations = False
  29. @utils.only_required_for_messages("import-private-name")
  30. def visit_import(self, node: nodes.Import) -> None:
  31. if utils.in_type_checking_block(node):
  32. return
  33. names = [name[0] for name in node.names]
  34. private_names = self._get_private_imports(names)
  35. private_names = self._get_type_annotation_names(node, private_names)
  36. if private_names:
  37. imported_identifier = "modules" if len(private_names) > 1 else "module"
  38. private_name_string = ", ".join(private_names)
  39. self.add_message(
  40. "import-private-name",
  41. node=node,
  42. args=(imported_identifier, private_name_string),
  43. confidence=HIGH,
  44. )
  45. @utils.only_required_for_messages("import-private-name")
  46. def visit_importfrom(self, node: nodes.ImportFrom) -> None:
  47. if utils.in_type_checking_block(node):
  48. return
  49. # Only check imported names if the module is external
  50. if self.same_root_dir(node, node.modname):
  51. return
  52. names = [n[0] for n in node.names]
  53. # Check the imported objects first. If they are all valid type annotations,
  54. # the package can be private
  55. private_names = self._get_type_annotation_names(node, names)
  56. if not private_names:
  57. return
  58. # There are invalid imported objects, so check the name of the package
  59. private_module_imports = self._get_private_imports([node.modname])
  60. private_module_imports = self._get_type_annotation_names(
  61. node, private_module_imports
  62. )
  63. if private_module_imports:
  64. self.add_message(
  65. "import-private-name",
  66. node=node,
  67. args=("module", private_module_imports[0]),
  68. confidence=HIGH,
  69. )
  70. return # Do not emit messages on the objects if the package is private
  71. private_names = self._get_private_imports(private_names)
  72. if private_names:
  73. imported_identifier = "objects" if len(private_names) > 1 else "object"
  74. private_name_string = ", ".join(private_names)
  75. self.add_message(
  76. "import-private-name",
  77. node=node,
  78. args=(imported_identifier, private_name_string),
  79. confidence=HIGH,
  80. )
  81. def _get_private_imports(self, names: list[str]) -> list[str]:
  82. """Returns the private names from input names by a simple string check."""
  83. return [name for name in names if self._name_is_private(name)]
  84. @staticmethod
  85. def _name_is_private(name: str) -> bool:
  86. """Returns true if the name exists, starts with `_`, and if len(name) > 4
  87. it is not a dunder, i.e. it does not begin and end with two underscores.
  88. """
  89. return (
  90. bool(name)
  91. and name[0] == "_"
  92. and not (len(name) > 4 and name[1] == "_" and name[-2:] == "__")
  93. )
  94. def _get_type_annotation_names(
  95. self, node: nodes.Import | nodes.ImportFrom, names: list[str]
  96. ) -> list[str]:
  97. """Removes from names any names that are used as type annotations with no other
  98. illegal usages.
  99. """
  100. if names and not self.populated_annotations:
  101. self._populate_type_annotations(node.root(), self.all_used_type_annotations)
  102. self.populated_annotations = True
  103. return [
  104. n
  105. for n in names
  106. if n not in self.all_used_type_annotations
  107. or (
  108. n in self.all_used_type_annotations
  109. and not self.all_used_type_annotations[n]
  110. )
  111. ]
  112. def _populate_type_annotations(
  113. self, node: nodes.LocalsDictNodeNG, all_used_type_annotations: dict[str, bool]
  114. ) -> None:
  115. """Adds to `all_used_type_annotations` all names ever used as a type annotation
  116. in the node's (nested) scopes and whether they are only used as annotation.
  117. """
  118. for name in node.locals:
  119. # If we find a private type annotation, make sure we do not mask illegal usages
  120. private_name = None
  121. # All the assignments using this variable that we might have to check for
  122. # illegal usages later
  123. name_assignments = []
  124. for usage_node in node.locals[name]:
  125. if isinstance(usage_node, nodes.AssignName) and isinstance(
  126. usage_node.parent, (nodes.AnnAssign, nodes.Assign)
  127. ):
  128. match assign_parent := usage_node.parent:
  129. case nodes.AnnAssign():
  130. name_assignments.append(assign_parent)
  131. private_name = self._populate_type_annotations_annotation(
  132. assign_parent.annotation,
  133. all_used_type_annotations,
  134. )
  135. case nodes.Assign():
  136. name_assignments.append(assign_parent)
  137. if isinstance(usage_node, nodes.FunctionDef):
  138. self._populate_type_annotations_function(
  139. usage_node, all_used_type_annotations
  140. )
  141. if isinstance(usage_node, nodes.LocalsDictNodeNG):
  142. self._populate_type_annotations(
  143. usage_node, all_used_type_annotations
  144. )
  145. if private_name is not None:
  146. # Found a new private annotation, make sure we are not accessing it elsewhere
  147. all_used_type_annotations[private_name] = (
  148. self._assignments_call_private_name(name_assignments, private_name)
  149. )
  150. def _populate_type_annotations_function(
  151. self, node: nodes.FunctionDef, all_used_type_annotations: dict[str, bool]
  152. ) -> None:
  153. """Adds all names used as type annotation in the arguments and return type of
  154. the function node into the dict `all_used_type_annotations`.
  155. """
  156. if node.args and node.args.annotations:
  157. for annotation in node.args.annotations:
  158. self._populate_type_annotations_annotation(
  159. annotation, all_used_type_annotations
  160. )
  161. if node.returns:
  162. self._populate_type_annotations_annotation(
  163. node.returns, all_used_type_annotations
  164. )
  165. def _populate_type_annotations_annotation(
  166. self,
  167. node: nodes.Attribute | nodes.Subscript | nodes.Name | None,
  168. all_used_type_annotations: dict[str, bool],
  169. ) -> str | None:
  170. """Handles the possibility of an annotation either being a Name, i.e. just type,
  171. or a Subscript e.g. `Optional[type]` or an Attribute, e.g. `pylint.lint.linter`.
  172. """
  173. match node:
  174. case nodes.Name(name=name) if name not in all_used_type_annotations:
  175. all_used_type_annotations[name] = True
  176. return name # type: ignore[no-any-return]
  177. case nodes.Subscript(): # e.g. Optional[List[str]]
  178. # slice is the next nested type
  179. self._populate_type_annotations_annotation(
  180. node.slice, all_used_type_annotations
  181. )
  182. # value is the current type name: could be a Name or Attribute
  183. return self._populate_type_annotations_annotation(
  184. node.value, all_used_type_annotations
  185. )
  186. case nodes.Attribute():
  187. # An attribute is a type like `pylint.lint.pylinter`. node.expr is the next level
  188. # up, could be another attribute
  189. return self._populate_type_annotations_annotation(
  190. node.expr, all_used_type_annotations
  191. )
  192. return None
  193. @staticmethod
  194. def _assignments_call_private_name(
  195. assignments: list[nodes.AnnAssign | nodes.Assign], private_name: str
  196. ) -> bool:
  197. """Returns True if no assignments involve accessing `private_name`."""
  198. if all(not assignment.value for assignment in assignments):
  199. # Variable annotated but unassigned is not allowed because there may be
  200. # possible illegal access elsewhere
  201. return False
  202. for assignment in assignments:
  203. match assignment.value:
  204. case (
  205. nodes.Call(func=current_attribute)
  206. | (nodes.Attribute() as current_attribute)
  207. | nodes.Name(name=current_attribute)
  208. ):
  209. pass
  210. case _:
  211. continue
  212. while isinstance(current_attribute, (nodes.Attribute, nodes.Call)):
  213. if isinstance(current_attribute, nodes.Call):
  214. current_attribute = current_attribute.func
  215. if not isinstance(current_attribute, nodes.Name):
  216. current_attribute = current_attribute.expr
  217. if (
  218. isinstance(current_attribute, nodes.Name)
  219. and current_attribute.name == private_name
  220. ):
  221. return False
  222. return True
  223. @staticmethod
  224. def same_root_dir(
  225. node: nodes.Import | nodes.ImportFrom, import_mod_name: str
  226. ) -> bool:
  227. """Does the node's file's path contain the base name of `import_mod_name`?"""
  228. if not import_mod_name: # from . import ...
  229. return True
  230. if node.level: # from .foo import ..., from ..bar import ...
  231. return True
  232. base_import_package = import_mod_name.split(".")[0]
  233. return base_import_package in Path(node.root().file).parent.parts
  234. def register(linter: PyLinter) -> None:
  235. linter.register_checker(PrivateImportChecker(linter))