inspector.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  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. """Visitor doing some post-processing on the astroid tree.
  5. Try to resolve definitions (namespace) dictionary, relationship...
  6. """
  7. from __future__ import annotations
  8. import collections
  9. import os
  10. import traceback
  11. from abc import ABC, abstractmethod
  12. from collections.abc import Callable, Sequence
  13. import astroid
  14. import astroid.exceptions
  15. import astroid.modutils
  16. from astroid import nodes
  17. from astroid.typing import InferenceResult
  18. from pylint import constants
  19. from pylint.checkers.utils import safe_infer
  20. from pylint.pyreverse import utils
  21. _WrapperFuncT = Callable[
  22. [Callable[[str], nodes.Module], str, bool], nodes.Module | None
  23. ]
  24. def _astroid_wrapper(
  25. func: Callable[[str], nodes.Module],
  26. modname: str,
  27. verbose: bool = False,
  28. ) -> nodes.Module | None:
  29. if verbose:
  30. print(f"parsing {modname}...")
  31. try:
  32. return func(modname)
  33. except astroid.exceptions.AstroidBuildingError as exc:
  34. print(exc)
  35. except Exception: # pylint: disable=broad-except
  36. traceback.print_exc()
  37. return None
  38. class IdGeneratorMixIn:
  39. """Mixin adding the ability to generate integer uid."""
  40. def __init__(self, start_value: int = 0) -> None:
  41. self.id_count = start_value
  42. def init_counter(self, start_value: int = 0) -> None:
  43. """Init the id counter."""
  44. self.id_count = start_value
  45. def generate_id(self) -> int:
  46. """Generate a new identifier."""
  47. self.id_count += 1
  48. return self.id_count
  49. class Project:
  50. """A project handle a set of modules / packages."""
  51. def __init__(self, name: str = ""):
  52. self.name = name
  53. self.uid: int | None = None
  54. self.path: str = ""
  55. self.modules: list[nodes.Module] = []
  56. self.locals: dict[str, nodes.Module] = {}
  57. self.__getitem__ = self.locals.__getitem__
  58. self.__iter__ = self.locals.__iter__
  59. self.values = self.locals.values
  60. self.keys = self.locals.keys
  61. self.items = self.locals.items
  62. def add_module(self, node: nodes.Module) -> None:
  63. self.locals[node.name] = node
  64. self.modules.append(node)
  65. def get_module(self, name: str) -> nodes.Module:
  66. return self.locals[name]
  67. def get_children(self) -> list[nodes.Module]:
  68. return self.modules
  69. def __repr__(self) -> str:
  70. return f"<Project {self.name!r} at {id(self)} ({len(self.modules)} modules)>"
  71. class Linker(IdGeneratorMixIn, utils.LocalsVisitor):
  72. """Walk on the project tree and resolve relationships.
  73. According to options the following attributes may be
  74. added to visited nodes:
  75. * uid,
  76. a unique identifier for the node (on astroid.Project, nodes.Module,
  77. nodes.Class and astroid.locals_type). Only if the linker
  78. has been instantiated with tag=True parameter (False by default).
  79. * Function
  80. a mapping from locals names to their bounded value, which may be a
  81. constant like a string or an integer, or an astroid node
  82. (on nodes.Module, nodes.Class and nodes.Function).
  83. * instance_attrs_type
  84. as locals_type but for klass member attributes (only on nodes.Class)
  85. * associations_type
  86. as instance_attrs_type but for association relationships
  87. * aggregations_type
  88. as instance_attrs_type but for aggregations relationships
  89. * compositions_type
  90. as instance_attrs_type but for compositions relationships
  91. """
  92. def __init__(self, project: Project, tag: bool = False) -> None:
  93. IdGeneratorMixIn.__init__(self)
  94. utils.LocalsVisitor.__init__(self)
  95. # tag nodes or not
  96. self.tag = tag
  97. # visited project
  98. self.project = project
  99. # Chain: Composition → Aggregation → Association
  100. self.compositions_handler = CompositionsHandler()
  101. aggregation_handler = AggregationsHandler()
  102. association_handler = AssociationsHandler()
  103. self.compositions_handler.set_next(aggregation_handler)
  104. aggregation_handler.set_next(association_handler)
  105. def visit_project(self, node: Project) -> None:
  106. """Visit a pyreverse.utils.Project node.
  107. * optionally tag the node with a unique id
  108. """
  109. if self.tag:
  110. node.uid = self.generate_id()
  111. for module in node.modules:
  112. self.visit(module)
  113. def visit_module(self, node: nodes.Module) -> None:
  114. """Visit an nodes.Module node.
  115. * set the locals_type mapping
  116. * set the depends mapping
  117. * optionally tag the node with a unique id
  118. """
  119. if hasattr(node, "locals_type"):
  120. return
  121. node.locals_type = collections.defaultdict(list)
  122. node.depends = []
  123. node.type_depends = []
  124. if self.tag:
  125. node.uid = self.generate_id()
  126. def visit_classdef(self, node: nodes.ClassDef) -> None:
  127. """Visit an nodes.Class node.
  128. * set the locals_type and instance_attrs_type mappings
  129. * optionally tag the node with a unique id
  130. """
  131. if hasattr(node, "locals_type"):
  132. return
  133. node.locals_type = collections.defaultdict(list)
  134. if self.tag:
  135. node.uid = self.generate_id()
  136. # resolve ancestors
  137. for baseobj in node.ancestors(recurs=False):
  138. specializations = getattr(baseobj, "specializations", [])
  139. specializations.append(node)
  140. baseobj.specializations = specializations
  141. # resolve instance attributes
  142. node.compositions_type = collections.defaultdict(list)
  143. node.instance_attrs_type = collections.defaultdict(list)
  144. node.aggregations_type = collections.defaultdict(list)
  145. node.associations_type = collections.defaultdict(list)
  146. for assignattrs in tuple(node.instance_attrs.values()):
  147. for assignattr in assignattrs:
  148. if not isinstance(assignattr, nodes.Unknown):
  149. self.compositions_handler.handle(assignattr, node)
  150. self.handle_assignattr_type(assignattr, node)
  151. # Process class attributes
  152. for local_nodes in node.locals.values():
  153. for local_node in local_nodes:
  154. if isinstance(local_node, nodes.AssignName) and isinstance(
  155. local_node.parent, nodes.Assign
  156. ):
  157. self.compositions_handler.handle(local_node, node)
  158. def visit_functiondef(self, node: nodes.FunctionDef) -> None:
  159. """Visit an nodes.Function node.
  160. * set the locals_type mapping
  161. * optionally tag the node with a unique id
  162. """
  163. if hasattr(node, "locals_type"):
  164. return
  165. node.locals_type = collections.defaultdict(list)
  166. if self.tag:
  167. node.uid = self.generate_id()
  168. def visit_assignname(self, node: nodes.AssignName) -> None:
  169. """Visit an nodes.AssignName node.
  170. handle locals_type
  171. """
  172. # avoid double parsing done by different Linkers.visit
  173. # running over the same project:
  174. if hasattr(node, "_handled"):
  175. return
  176. node._handled = True
  177. if node.name in node.frame():
  178. frame = node.frame()
  179. else:
  180. # the name has been defined as 'global' in the frame and belongs
  181. # there.
  182. frame = node.root()
  183. if not hasattr(frame, "locals_type"):
  184. # If the frame doesn't have a locals_type yet,
  185. # it means it wasn't yet visited. Visit it now
  186. # to add what's missing from it.
  187. if isinstance(frame, nodes.ClassDef):
  188. self.visit_classdef(frame)
  189. elif isinstance(frame, nodes.FunctionDef):
  190. self.visit_functiondef(frame)
  191. else:
  192. self.visit_module(frame)
  193. current = frame.locals_type[node.name]
  194. frame.locals_type[node.name] = list(set(current) | utils.infer_node(node))
  195. @staticmethod
  196. def handle_assignattr_type(node: nodes.AssignAttr, parent: nodes.ClassDef) -> None:
  197. """Handle an astroid.assignattr node.
  198. handle instance_attrs_type
  199. """
  200. current = set(parent.instance_attrs_type[node.attrname])
  201. parent.instance_attrs_type[node.attrname] = list(
  202. current | utils.infer_node(node)
  203. )
  204. def visit_import(self, node: nodes.Import) -> None:
  205. """Visit an nodes.Import node.
  206. resolve module dependencies
  207. """
  208. context_file = node.root().file
  209. for name in node.names:
  210. relative = astroid.modutils.is_relative(name[0], context_file)
  211. self._imported_module(node, name[0], relative)
  212. def visit_importfrom(self, node: nodes.ImportFrom) -> None:
  213. """Visit an nodes.ImportFrom node.
  214. resolve module dependencies
  215. """
  216. basename = node.modname
  217. context_file = node.root().file
  218. if context_file is not None:
  219. relative = astroid.modutils.is_relative(basename, context_file)
  220. else:
  221. relative = False
  222. for name in node.names:
  223. if name[0] == "*":
  224. continue
  225. # analyze dependencies
  226. fullname = f"{basename}.{name[0]}"
  227. if fullname.find(".") > -1:
  228. try:
  229. fullname = astroid.modutils.get_module_part(fullname, context_file)
  230. except ImportError:
  231. continue
  232. if fullname != basename:
  233. self._imported_module(node, fullname, relative)
  234. def compute_module(self, context_name: str, mod_path: str) -> bool:
  235. """Should the module be added to dependencies ?"""
  236. package_dir = os.path.dirname(self.project.path)
  237. if context_name == mod_path:
  238. return False
  239. # astroid does return a boolean but is not typed correctly yet
  240. return astroid.modutils.module_in_path(mod_path, (package_dir,)) # type: ignore[no-any-return]
  241. def _imported_module(
  242. self, node: nodes.Import | nodes.ImportFrom, mod_path: str, relative: bool
  243. ) -> None:
  244. """Notify an imported module, used to analyze dependencies."""
  245. module = node.root()
  246. context_name = module.name
  247. if relative:
  248. mod_path = f"{'.'.join(context_name.split('.')[:-1])}.{mod_path}"
  249. if self.compute_module(context_name, mod_path):
  250. # handle dependencies
  251. if not hasattr(module, "depends"):
  252. module.depends = []
  253. mod_paths = module.depends
  254. if mod_path not in mod_paths:
  255. mod_paths.append(mod_path)
  256. class RelationshipHandlerInterface(ABC):
  257. @abstractmethod
  258. def set_next(
  259. self, handler: RelationshipHandlerInterface
  260. ) -> RelationshipHandlerInterface:
  261. pass
  262. @abstractmethod
  263. def handle(
  264. self, node: nodes.AssignAttr | nodes.AssignName, parent: nodes.ClassDef
  265. ) -> None:
  266. pass
  267. class AbstractRelationshipHandler(RelationshipHandlerInterface):
  268. """
  269. Chain of Responsibility for handling types of relationships, useful
  270. to expand in the future if we want to add more distinct relationships.
  271. Every link of the chain checks if it's a certain type of relationship.
  272. If no relationship is found it's set as a generic relationship in `relationships_type`.
  273. The default chaining behavior is implemented inside the base handler
  274. class.
  275. """
  276. _next_handler: RelationshipHandlerInterface
  277. def set_next(
  278. self, handler: RelationshipHandlerInterface
  279. ) -> RelationshipHandlerInterface:
  280. self._next_handler = handler
  281. return handler
  282. @abstractmethod
  283. def handle(
  284. self, node: nodes.AssignAttr | nodes.AssignName, parent: nodes.ClassDef
  285. ) -> None:
  286. if self._next_handler:
  287. self._next_handler.handle(node, parent)
  288. class CompositionsHandler(AbstractRelationshipHandler):
  289. """Handle composition relationships where parent creates child objects."""
  290. def handle(
  291. self, node: nodes.AssignAttr | nodes.AssignName, parent: nodes.ClassDef
  292. ) -> None:
  293. # If the node is not part of an assignment, pass to next handler
  294. if not isinstance(node.parent, (nodes.AnnAssign, nodes.Assign)):
  295. super().handle(node, parent)
  296. return
  297. value = node.parent.value
  298. # Extract the name to handle both AssignAttr and AssignName nodes
  299. name = node.attrname if isinstance(node, nodes.AssignAttr) else node.name
  300. # Composition: direct object creation (self.x = P())
  301. if isinstance(value, nodes.Call):
  302. inferred_types = utils.infer_node(node)
  303. element_types = extract_element_types(inferred_types)
  304. # Resolve nodes to actual class definitions
  305. resolved_types = resolve_to_class_def(element_types)
  306. current = set(parent.compositions_type[name])
  307. parent.compositions_type[name] = list(current | resolved_types)
  308. return
  309. # Composition: comprehensions with object creation (self.x = [P() for ...])
  310. if isinstance(
  311. value, (nodes.ListComp, nodes.DictComp, nodes.SetComp, nodes.GeneratorExp)
  312. ):
  313. if isinstance(value, nodes.DictComp):
  314. element = value.value
  315. else:
  316. element = value.elt
  317. # If the element is a Call (object creation), it's composition
  318. if isinstance(element, nodes.Call):
  319. inferred_types = utils.infer_node(node)
  320. element_types = extract_element_types(inferred_types)
  321. # Resolve nodes to actual class definitions
  322. resolved_types = resolve_to_class_def(element_types)
  323. current = set(parent.compositions_type[name])
  324. parent.compositions_type[name] = list(current | resolved_types)
  325. return
  326. # Not a composition, pass to next handler
  327. super().handle(node, parent)
  328. class AggregationsHandler(AbstractRelationshipHandler):
  329. """Handle aggregation relationships where parent receives child objects."""
  330. def handle(
  331. self, node: nodes.AssignAttr | nodes.AssignName, parent: nodes.ClassDef
  332. ) -> None:
  333. # If the node is not part of an assignment, pass to next handler
  334. if not isinstance(node.parent, (nodes.AnnAssign, nodes.Assign)):
  335. super().handle(node, parent)
  336. return
  337. value = node.parent.value
  338. # Extract the name to handle both AssignAttr and AssignName nodes
  339. name = node.attrname if isinstance(node, nodes.AssignAttr) else node.name
  340. # Aggregation: direct assignment (self.x = x)
  341. if isinstance(value, nodes.Name):
  342. inferred_types = utils.infer_node(node)
  343. element_types = extract_element_types(inferred_types)
  344. # Resolve nodes to actual class definitions
  345. resolved_types = resolve_to_class_def(element_types)
  346. current = set(parent.aggregations_type[name])
  347. parent.aggregations_type[name] = list(current | resolved_types)
  348. return
  349. # Aggregation: comprehensions without object creation (self.x = [existing_obj for ...])
  350. if isinstance(
  351. value, (nodes.ListComp, nodes.DictComp, nodes.SetComp, nodes.GeneratorExp)
  352. ):
  353. if isinstance(value, nodes.DictComp):
  354. element = value.value
  355. else:
  356. element = value.elt
  357. # If the element is a Name, it means it's an existing object, so it's aggregation
  358. if isinstance(element, nodes.Name):
  359. inferred_types = utils.infer_node(node)
  360. element_types = extract_element_types(inferred_types)
  361. # Resolve nodes to actual class definitions
  362. resolved_types = resolve_to_class_def(element_types)
  363. current = set(parent.aggregations_type[name])
  364. parent.aggregations_type[name] = list(current | resolved_types)
  365. return
  366. # Not an aggregation, pass to next handler
  367. super().handle(node, parent)
  368. class AssociationsHandler(AbstractRelationshipHandler):
  369. """Handle regular association relationships."""
  370. def handle(
  371. self, node: nodes.AssignAttr | nodes.AssignName, parent: nodes.ClassDef
  372. ) -> None:
  373. # Extract the name to handle both AssignAttr and AssignName nodes
  374. name = node.attrname if isinstance(node, nodes.AssignAttr) else node.name
  375. # Type annotation only (x: P) -> Association
  376. # BUT only if there's no actual assignment (to avoid duplicates)
  377. if isinstance(node.parent, nodes.AnnAssign) and node.parent.value is None:
  378. inferred_types = utils.infer_node(node)
  379. element_types = extract_element_types(inferred_types)
  380. # Resolve nodes to actual class definitions
  381. resolved_types = resolve_to_class_def(element_types)
  382. current = set(parent.associations_type[name])
  383. parent.associations_type[name] = list(current | resolved_types)
  384. return
  385. # Everything else is also association (fallback)
  386. current = set(parent.associations_type[name])
  387. inferred_types = utils.infer_node(node)
  388. element_types = extract_element_types(inferred_types)
  389. # Resolve Name nodes to actual class definitions
  390. resolved_types = resolve_to_class_def(element_types)
  391. parent.associations_type[name] = list(current | resolved_types)
  392. def resolve_to_class_def(types: set[nodes.NodeNG]) -> set[nodes.ClassDef]:
  393. """Resolve a set of nodes to ClassDef nodes."""
  394. class_defs = set()
  395. for node in types:
  396. if isinstance(node, nodes.ClassDef):
  397. class_defs.add(node)
  398. elif isinstance(node, nodes.Name):
  399. inferred = safe_infer(node)
  400. if isinstance(inferred, nodes.ClassDef):
  401. class_defs.add(inferred)
  402. elif isinstance(node, astroid.Instance):
  403. # Instance of a class -> get the actual class
  404. class_defs.add(node._proxied)
  405. return class_defs
  406. def extract_element_types(inferred_types: set[InferenceResult]) -> set[nodes.NodeNG]:
  407. """Extract element types in case the inferred type is a container.
  408. This function checks if the inferred type is a container type (like list, dict, etc.)
  409. and extracts the element type(s) from it. If the inferred type is a direct type (like a class),
  410. it adds that type directly to the set of element types it returns.
  411. """
  412. element_types = set()
  413. for inferred_type in inferred_types:
  414. if isinstance(inferred_type, nodes.Subscript):
  415. slice_node = inferred_type.slice
  416. # Handle both Tuple (dict[K,V]) and single element (list[T])
  417. elements = (
  418. slice_node.elts if isinstance(slice_node, nodes.Tuple) else [slice_node]
  419. )
  420. for elt in elements:
  421. if isinstance(elt, (nodes.Name, nodes.ClassDef)):
  422. element_types.add(elt)
  423. else:
  424. element_types.add(inferred_type)
  425. return element_types
  426. def project_from_files(
  427. files: Sequence[str],
  428. func_wrapper: _WrapperFuncT = _astroid_wrapper,
  429. project_name: str = "no name",
  430. black_list: tuple[str, ...] = constants.DEFAULT_IGNORE_LIST,
  431. verbose: bool = False,
  432. ) -> Project:
  433. """Return a Project from a list of files or modules."""
  434. # build the project representation
  435. astroid_manager = astroid.MANAGER
  436. project = Project(project_name)
  437. for something in files:
  438. if not os.path.exists(something):
  439. fpath = astroid.modutils.file_from_modpath(something.split("."))
  440. elif os.path.isdir(something):
  441. fpath = os.path.join(something, "__init__.py")
  442. else:
  443. fpath = something
  444. ast = func_wrapper(astroid_manager.ast_from_file, fpath, verbose)
  445. if ast is None:
  446. continue
  447. project.path = project.path or ast.file
  448. project.add_module(ast)
  449. base_name = ast.name
  450. # recurse in package except if __init__ was explicitly given
  451. if ast.package and something.find("__init__") == -1:
  452. # recurse on others packages / modules if this is a package
  453. for fpath in astroid.modutils.get_module_files(
  454. os.path.dirname(ast.file), black_list
  455. ):
  456. ast = func_wrapper(astroid_manager.ast_from_file, fpath, verbose)
  457. if ast is None or ast.name == base_name:
  458. continue
  459. project.add_module(ast)
  460. return project