Skip to content

Commit b4ac3ae

Browse files
committed
add deptry
1 parent 2f387b5 commit b4ac3ae

File tree

3 files changed

+200
-1
lines changed

3 files changed

+200
-1
lines changed

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ cff-version: 1.2.0
55
message: "If you use this software, please cite it as below."
66
title: preen
77
version: 0.1.0
8-
date-released: 2025-12-03
8+
date-released: 2025-12-07
99
url: https://github.com/gojiplus/preen
1010
repository-code: https://github.com/gojiplus/preen
1111
authors:

preen/checks/deptree.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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

preen/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .checks.tests import TestsCheck
2424
from .checks.citation import CitationCheck
2525
from .checks.deps import DepsCheck
26+
from .checks.deptree import DeptreeCheck
2627
from .checks.ci_matrix import CIMatrixCheck
2728
from .checks.structure import StructureCheck
2829
from .checks.version import VersionCheck
@@ -163,6 +164,7 @@ def check(
163164
TestsCheck,
164165
CitationCheck,
165166
DepsCheck,
167+
DeptreeCheck,
166168
CIMatrixCheck,
167169
StructureCheck,
168170
VersionCheck,

0 commit comments

Comments
 (0)