1+ """Circular dependency check using a simple Python implementation."""
2+
3+ from __future__ import annotations
4+
5+ import ast
6+ from pathlib import Path
7+ from typing import Dict , Set , List
8+
9+ from .base import Check , CheckResult , Issue , Severity
10+
11+
12+ class DeptreeCheck (Check ):
13+ """Check for circular dependencies in Python code."""
14+
15+ @property
16+ def name (self ) -> str :
17+ return "deptree"
18+
19+ @property
20+ def description (self ) -> str :
21+ return "Check for circular dependencies in Python code"
22+
23+ def _get_python_files (self ) -> List [Path ]:
24+ """Get all Python files in the project."""
25+ python_files = []
26+ for path in self .project_dir .rglob ("*.py" ):
27+ # Skip __pycache__, .git, and test directories
28+ if any (skip in str (path ) for skip in ["__pycache__" , ".git" , "/.tox/" ]):
29+ continue
30+ python_files .append (path )
31+ return python_files
32+
33+ def _extract_imports (self , file_path : Path ) -> Set [str ]:
34+ """Extract import statements from a Python file."""
35+ imports = set ()
36+ try :
37+ with open (file_path , 'r' , encoding = 'utf-8' ) as f :
38+ content = f .read ()
39+
40+ tree = ast .parse (content , filename = str (file_path ))
41+
42+ for node in ast .walk (tree ):
43+ if isinstance (node , ast .Import ):
44+ for alias in node .names :
45+ imports .add (alias .name )
46+ elif isinstance (node , ast .ImportFrom ):
47+ if node .module :
48+ imports .add (node .module )
49+
50+ except (SyntaxError , UnicodeDecodeError , FileNotFoundError ):
51+ pass # Skip files that can't be parsed
52+
53+ return imports
54+
55+ def _module_name_from_path (self , file_path : Path ) -> str :
56+ """Convert file path to module name."""
57+ try :
58+ relative_path = file_path .relative_to (self .project_dir )
59+ except ValueError :
60+ return str (file_path )
61+
62+ # Remove .py extension
63+ if relative_path .suffix == '.py' :
64+ relative_path = relative_path .with_suffix ('' )
65+
66+ # Convert path separators to dots
67+ module_name = str (relative_path ).replace ('/' , '.' ).replace ('\\ ' , '.' )
68+
69+ # Handle __init__.py files
70+ if module_name .endswith ('.__init__' ):
71+ module_name = module_name [:- 9 ]
72+
73+ return module_name
74+
75+ def _detect_cycles (self , graph : Dict [str , Set [str ]]) -> List [List [str ]]:
76+ """Detect cycles using DFS."""
77+ visited = set ()
78+ rec_stack = set ()
79+ cycles = []
80+
81+ def dfs (node : str , path : List [str ]) -> None :
82+ if node in rec_stack :
83+ # Found cycle
84+ try :
85+ cycle_start = path .index (node )
86+ cycle = path [cycle_start :] + [node ]
87+ cycles .append (cycle )
88+ except ValueError :
89+ cycles .append ([node ])
90+ return
91+
92+ if node in visited :
93+ return
94+
95+ visited .add (node )
96+ rec_stack .add (node )
97+
98+ for neighbor in graph .get (node , set ()):
99+ if neighbor in graph : # Only follow internal modules
100+ dfs (neighbor , path + [node ])
101+
102+ rec_stack .remove (node )
103+
104+ for module in graph :
105+ if module not in visited :
106+ dfs (module , [])
107+
108+ return cycles
109+
110+ def run (self ) -> CheckResult :
111+ """Run circular dependency detection."""
112+ issues = []
113+
114+ python_files = self ._get_python_files ()
115+
116+ if not python_files :
117+ return CheckResult (check = self .name , passed = True , issues = [])
118+
119+ # Build import graph
120+ graph = {}
121+ all_modules = set ()
122+
123+ # First pass: get all module names
124+ for file_path in python_files :
125+ module_name = self ._module_name_from_path (file_path )
126+ all_modules .add (module_name )
127+
128+ # Second pass: build import graph with only internal imports
129+ for file_path in python_files :
130+ module_name = self ._module_name_from_path (file_path )
131+ imports = self ._extract_imports (file_path )
132+
133+ # Filter to only internal imports
134+ internal_imports = set ()
135+ for imp in imports :
136+ # Check for relative imports or imports that match our modules
137+ if imp .startswith ('.' ):
138+ # Handle relative imports
139+ if module_name :
140+ parts = module_name .split ('.' )
141+ if imp .startswith ('..' ):
142+ # Go up one level
143+ if len (parts ) > 1 :
144+ base = '.' .join (parts [:- 1 ])
145+ target = imp [2 :] # Remove '..'
146+ if target :
147+ full_import = f"{ base } .{ target } "
148+ else :
149+ full_import = base
150+ else :
151+ full_import = imp [2 :] # Just remove '..'
152+ else :
153+ # Single dot - same package
154+ base = '.' .join (parts [:- 1 ]) if '.' in module_name else ''
155+ target = imp [1 :] # Remove '.'
156+ if base and target :
157+ full_import = f"{ base } .{ target } "
158+ else :
159+ full_import = target or base
160+
161+ if full_import in all_modules :
162+ internal_imports .add (full_import )
163+ else :
164+ # Check if it's an internal module
165+ if imp in all_modules :
166+ internal_imports .add (imp )
167+ else :
168+ # Check if it's a submodule of any internal module
169+ for mod in all_modules :
170+ if imp .startswith (mod + '.' ) or mod .startswith (imp + '.' ):
171+ internal_imports .add (imp )
172+ break
173+
174+ graph [module_name ] = internal_imports
175+
176+ # Detect cycles
177+ cycles = self ._detect_cycles (graph )
178+
179+ for cycle in cycles :
180+ if len (cycle ) > 1 : # Only report actual cycles
181+ cycle_str = " -> " .join (cycle )
182+ issues .append (
183+ Issue (
184+ check = self .name ,
185+ severity = Severity .ERROR ,
186+ description = f"Circular import detected: { cycle_str } " ,
187+ )
188+ )
189+
190+ return CheckResult (
191+ check = self .name ,
192+ passed = len (issues ) == 0 ,
193+ issues = issues ,
194+ )
195+
196+ def can_fix (self ) -> bool :
197+ return False # Circular dependencies require manual refactoring
0 commit comments