Coverage for circular_deps / parsers / python.py: 64%
88 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 15:04 -0800
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 15:04 -0800
1from __future__ import annotations
3import tree_sitter_python as tspython
4from tree_sitter import Language
5from tree_sitter import Parser
7from circular_deps.models import ImportInfo
8from circular_deps.parsers.base import ImportParser
10PY_LANGUAGE = Language(tspython.language())
12_IMPORT_NODE_TYPES = {"import_statement", "import_from_statement"}
13_DYNAMIC_IMPORTS = {"__import__", "importlib.import_module"}
16def parse(code):
17 parser_obj = Parser(PY_LANGUAGE)
18 tree = parser_obj.parse(code.encode())
19 return tree.root_node
22class PythonImportParser(ImportParser):
23 def extract_imports(self, source, filepath):
24 root = parse(source)
25 imports = []
27 for node in root.children:
28 if node.type == "import_statement":
29 imports.extend(self._process_import_statement(node))
30 elif node.type == "import_from_statement":
31 imports.extend(self._process_import_from_statement(node))
33 return self._filter_dynamic(imports, root)
35 def _process_import_statement(self, node):
36 imports = []
37 has_aliased = False
39 for child in node.children:
40 if child.type == "aliased_import":
41 has_aliased = True
42 name = ""
43 for nc in child.children:
44 if nc.type == "dotted_name":
45 name = nc.text.decode()
46 elif nc.type == "identifier":
47 name = nc.text.decode()
48 if name:
49 imports.append(
50 ImportInfo(
51 raw_module=name,
52 resolved_path=None,
53 import_type="absolute",
54 line=node.start_point[0] + 1,
55 is_dynamic=False,
56 node_type="import_statement",
57 )
58 )
59 elif not has_aliased and child.type == "dotted_name":
60 name = child.text.decode()
61 if name:
62 imports.append(
63 ImportInfo(
64 raw_module=name,
65 resolved_path=None,
66 import_type="absolute",
67 line=node.start_point[0] + 1,
68 is_dynamic=False,
69 node_type="import_statement",
70 )
71 )
72 return imports
74 def _process_import_from_statement(self, node):
75 imports = []
76 module_name = ""
77 is_relative = False
78 found_import = False
80 for child in node.children:
81 if child.type == "import":
82 found_import = True
83 break
84 if not found_import:
85 if child.type == "relative_import":
86 is_relative = True
87 module_name = child.text.decode()
88 elif child.type == "dotted_name" and not module_name:
89 module_name = child.text.decode()
90 elif child.type == "module_name":
91 for mc in child.children:
92 if mc.type in ("dotted_name", "relative_import"):
93 module_name = mc.text.decode()
95 if module_name:
96 imports.append(
97 ImportInfo(
98 raw_module=module_name,
99 resolved_path=None,
100 import_type="relative" if is_relative else "absolute",
101 line=node.start_point[0] + 1,
102 is_dynamic=False,
103 node_type="import_from_statement",
104 )
105 )
106 return imports
108 def _filter_dynamic(self, imports, root):
109 for node in root.children:
110 if node.type == "expression_statement":
111 for expr in node.children:
112 if expr.type == "call":
113 func_name = self._get_function_name(expr)
114 if func_name in _DYNAMIC_IMPORTS:
115 for imp in imports:
116 if imp.line == node.start_point[0] + 1:
117 imp.is_dynamic = True
119 return [imp for imp in imports if not imp.is_dynamic]
121 def _get_function_name(self, call_node):
122 for child in call_node.children:
123 if child.type == "attribute":
124 for gc in child.children:
125 if gc.type == "identifier":
126 name = gc.text.decode()
127 for nc in child.children:
128 if nc.type == "identifier" and nc != gc:
129 parent_name = nc.text.decode()
130 return f"{parent_name}.{name}"
131 elif child.type == "identifier":
132 return child.text.decode()
133 return ""