depcycle 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- depcycle/__init__.py +28 -0
- depcycle/__main__.py +7 -0
- depcycle/cli.py +213 -0
- depcycle/config.py +89 -0
- depcycle/graph/__init__.py +7 -0
- depcycle/graph/dependency_graph.py +341 -0
- depcycle/graph/module_node.py +53 -0
- depcycle/parsing/__init__.py +7 -0
- depcycle/parsing/ast_parser.py +134 -0
- depcycle/parsing/project.py +115 -0
- depcycle/rendering/__init__.py +7 -0
- depcycle/rendering/interface.py +33 -0
- depcycle/rendering/visualizers.py +369 -0
- depcycle-0.2.0.dist-info/METADATA +264 -0
- depcycle-0.2.0.dist-info/RECORD +19 -0
- depcycle-0.2.0.dist-info/WHEEL +5 -0
- depcycle-0.2.0.dist-info/entry_points.txt +2 -0
- depcycle-0.2.0.dist-info/licenses/LICENSE +21 -0
- depcycle-0.2.0.dist-info/top_level.txt +1 -0
depcycle/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DepCycle - A dependency graph visualization tool for Python projects.
|
|
3
|
+
|
|
4
|
+
DepCycle helps developers understand complex codebases by automatically
|
|
5
|
+
generating visual maps of module dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
|
|
10
|
+
from .cli import DepCycleCLI
|
|
11
|
+
from .config import Config
|
|
12
|
+
from .graph import DependencyGraph, ModuleNode, ModuleType
|
|
13
|
+
from .parsing import Project, ASTParser
|
|
14
|
+
from .rendering import IGraphVisualizer, GraphvizVisualizer, HtmlVisualizer
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'DepCycleCLI',
|
|
18
|
+
'Config',
|
|
19
|
+
'DependencyGraph',
|
|
20
|
+
'ModuleNode',
|
|
21
|
+
'ModuleType',
|
|
22
|
+
'Project',
|
|
23
|
+
'ASTParser',
|
|
24
|
+
'IGraphVisualizer',
|
|
25
|
+
'GraphvizVisualizer',
|
|
26
|
+
'HtmlVisualizer',
|
|
27
|
+
]
|
|
28
|
+
|
depcycle/__main__.py
ADDED
depcycle/cli.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
this module contains the command-line interface logic for DepCycle.
|
|
3
|
+
it defines the user-facing commands, parses arguments, and orchestrates
|
|
4
|
+
the dependency analysis and visualization workflow.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from .config import Config
|
|
12
|
+
from .parsing.project import Project
|
|
13
|
+
from .parsing.ast_parser import ASTParser
|
|
14
|
+
from .graph.dependency_graph import DependencyGraph
|
|
15
|
+
from .rendering.interface import IGraphVisualizer
|
|
16
|
+
from .rendering.visualizers import GraphvizVisualizer, HtmlVisualizer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DepCycleCLI:
|
|
20
|
+
"""
|
|
21
|
+
The Conductor: Parses arguments and orchestrates the workflow.
|
|
22
|
+
|
|
23
|
+
This class handles all command-line interaction, from parsing user
|
|
24
|
+
arguments to coordinating the analysis and visualization pipeline.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def main(args: list = None):
|
|
29
|
+
"""
|
|
30
|
+
Main entry point for the DepCycle CLI.
|
|
31
|
+
|
|
32
|
+
Parses command-line arguments, creates configuration, and runs
|
|
33
|
+
the dependency analysis workflow.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
args: Command-line arguments (defaults to sys.argv).
|
|
37
|
+
"""
|
|
38
|
+
if args is None:
|
|
39
|
+
args = sys.argv[1:]
|
|
40
|
+
|
|
41
|
+
# Parse arguments
|
|
42
|
+
parser = DepCycleCLI._create_parser()
|
|
43
|
+
parsed_args = parser.parse_args(args)
|
|
44
|
+
|
|
45
|
+
# Validate arguments
|
|
46
|
+
if not parsed_args.project_path:
|
|
47
|
+
parser.error("Project path is required")
|
|
48
|
+
|
|
49
|
+
# Build configuration
|
|
50
|
+
config = Config(
|
|
51
|
+
project_path=Path(parsed_args.project_path),
|
|
52
|
+
output_file=Path(parsed_args.output) if parsed_args.output else Path("dependencies.png"),
|
|
53
|
+
output_format=parsed_args.format,
|
|
54
|
+
exclude_patterns=parsed_args.exclude,
|
|
55
|
+
show_third_party=not parsed_args.no_third_party,
|
|
56
|
+
show_stdlib=not parsed_args.no_stdlib,
|
|
57
|
+
include_all=parsed_args.include_all
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Validate project path
|
|
61
|
+
if not config.project_path.exists():
|
|
62
|
+
print(f"Error: Project path does not exist: {config.project_path}")
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
# Run the analysis
|
|
66
|
+
try:
|
|
67
|
+
DepCycleCLI.run(config)
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
print("\nInterrupted by user")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def run(config: Config):
|
|
77
|
+
"""
|
|
78
|
+
Execute the dependency analysis and visualization workflow.
|
|
79
|
+
|
|
80
|
+
This method orchestrates the entire process:
|
|
81
|
+
1. Discover Python files in the project
|
|
82
|
+
2. Parse imports using AST
|
|
83
|
+
3. Build the dependency graph
|
|
84
|
+
4. Render the visualization
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
config: Configuration object containing all settings.
|
|
88
|
+
"""
|
|
89
|
+
print(f"Analyzing project: {config.project_path}")
|
|
90
|
+
|
|
91
|
+
# Step 1: Discover files
|
|
92
|
+
project = Project(config.project_path)
|
|
93
|
+
parser = ASTParser()
|
|
94
|
+
|
|
95
|
+
# Step 2 & 3: Build graph
|
|
96
|
+
print("Building dependency graph...")
|
|
97
|
+
graph = DependencyGraph()
|
|
98
|
+
graph.build(project, parser, config)
|
|
99
|
+
|
|
100
|
+
print(f"Found {len(graph)} modules")
|
|
101
|
+
|
|
102
|
+
# Step 4: Detect cycles
|
|
103
|
+
cycles = graph.find_cycles()
|
|
104
|
+
if cycles:
|
|
105
|
+
print(f"\n⚠️ Warning: Found {len(cycles)} circular dependency cycles!")
|
|
106
|
+
for i, cycle in enumerate(cycles[:5], 1): # Show first 5
|
|
107
|
+
cycle_names = [node.name for node in cycle]
|
|
108
|
+
print(f" Cycle {i}: {' → '.join(cycle_names)}")
|
|
109
|
+
if len(cycles) > 5:
|
|
110
|
+
print(f" ... and {len(cycles) - 5} more cycles")
|
|
111
|
+
else:
|
|
112
|
+
print("✓ No circular dependencies detected")
|
|
113
|
+
|
|
114
|
+
# Step 5: Render visualization
|
|
115
|
+
print(f"\nGenerating {config.output_format.upper()} visualization...")
|
|
116
|
+
visualizer = DepCycleCLI._create_visualizer(config.output_format)
|
|
117
|
+
visualizer.render(graph, config)
|
|
118
|
+
|
|
119
|
+
print(f"✓ Visualization saved to: {config.output_file}")
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _create_parser() -> argparse.ArgumentParser:
|
|
123
|
+
"""
|
|
124
|
+
Create and configure the argument parser.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Configured ArgumentParser instance.
|
|
128
|
+
"""
|
|
129
|
+
parser = argparse.ArgumentParser(
|
|
130
|
+
prog='depcycle',
|
|
131
|
+
description='Visualize Python project dependencies',
|
|
132
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
133
|
+
epilog="""
|
|
134
|
+
Examples:
|
|
135
|
+
%(prog)s /path/to/project
|
|
136
|
+
%(prog)s /path/to/project -o output.png
|
|
137
|
+
%(prog)s /path/to/project --format svg --exclude tests
|
|
138
|
+
%(prog)s /path/to/project --no-third-party --no-stdlib
|
|
139
|
+
%(prog)s /path/to/project --include-all # Include venv, __pycache__, etc.
|
|
140
|
+
"""
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
'project_path',
|
|
145
|
+
nargs='?',
|
|
146
|
+
help='Path to the Python project to analyze'
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
'-o', '--output',
|
|
151
|
+
help='Output file path (default: dependencies.png)',
|
|
152
|
+
default=None
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
'-f', '--format',
|
|
157
|
+
choices=['png', 'svg', 'html'],
|
|
158
|
+
help='Output format (default: png)',
|
|
159
|
+
default='png'
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
parser.add_argument(
|
|
163
|
+
'-e', '--exclude',
|
|
164
|
+
action='append',
|
|
165
|
+
help='Glob patterns to exclude (e.g., venv, tests/*.py). Can be specified multiple times.',
|
|
166
|
+
default=[]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
parser.add_argument(
|
|
170
|
+
'--no-third-party',
|
|
171
|
+
action='store_true',
|
|
172
|
+
help='Exclude third-party dependencies from the graph'
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
parser.add_argument(
|
|
176
|
+
'--no-stdlib',
|
|
177
|
+
action='store_true',
|
|
178
|
+
help='Exclude standard library modules from the graph'
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
'--include-all',
|
|
183
|
+
action='store_true',
|
|
184
|
+
help='Include files normally excluded by default (venv, __pycache__, etc.)'
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return parser
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _create_visualizer(output_format: str) -> IGraphVisualizer:
|
|
191
|
+
"""
|
|
192
|
+
Create the appropriate visualizer based on output format.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
output_format: Desired output format ('png', 'svg', 'html').
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
An instance of the appropriate visualizer.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
ValueError: If the format is not supported.
|
|
202
|
+
"""
|
|
203
|
+
if output_format in ['png', 'svg']:
|
|
204
|
+
return GraphvizVisualizer()
|
|
205
|
+
elif output_format == 'html':
|
|
206
|
+
return HtmlVisualizer()
|
|
207
|
+
else:
|
|
208
|
+
raise ValueError(f"Unsupported output format: {output_format}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# Entry point for running as a script
|
|
212
|
+
if __name__ == '__main__':
|
|
213
|
+
DepCycleCLI.main()
|
depcycle/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Configuration class for DepCycle settings."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
# Default exclusion patterns that are automatically applied
|
|
7
|
+
DEFAULT_EXCLUDE_PATTERNS = [
|
|
8
|
+
'venv',
|
|
9
|
+
'.venv',
|
|
10
|
+
'env',
|
|
11
|
+
'.env',
|
|
12
|
+
'__pycache__',
|
|
13
|
+
'*.pyc',
|
|
14
|
+
'*.pyo',
|
|
15
|
+
'*.pyd',
|
|
16
|
+
'.git',
|
|
17
|
+
'.hg',
|
|
18
|
+
'.svn',
|
|
19
|
+
'node_modules',
|
|
20
|
+
'.pytest_cache',
|
|
21
|
+
'.mypy_cache',
|
|
22
|
+
'.tox',
|
|
23
|
+
'dist',
|
|
24
|
+
'build',
|
|
25
|
+
'*.egg-info',
|
|
26
|
+
'migrations', # Django/ORM migration directories
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Config:
|
|
31
|
+
"""
|
|
32
|
+
Holds all configuration settings for a DepCycle run.
|
|
33
|
+
|
|
34
|
+
This class encapsulates all the options that control how DepCycle
|
|
35
|
+
analyzes and visualizes a Python project.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
project_path (Path): Path to the project root directory.
|
|
39
|
+
output_file (Path): Path where the output visualization should be saved.
|
|
40
|
+
output_format (str): Format for the output ('png', 'svg', 'html').
|
|
41
|
+
exclude_patterns (List[str]): Glob patterns for files to exclude.
|
|
42
|
+
show_third_party (bool): Whether to include third-party modules.
|
|
43
|
+
show_stdlib (bool): Whether to include standard library modules.
|
|
44
|
+
include_all (bool): Whether to include files normally excluded by default patterns.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
project_path: Path,
|
|
50
|
+
output_file: Path,
|
|
51
|
+
output_format: str = "png",
|
|
52
|
+
exclude_patterns: Optional[List[str]] = None,
|
|
53
|
+
show_third_party: bool = True,
|
|
54
|
+
show_stdlib: bool = True,
|
|
55
|
+
include_all: bool = False
|
|
56
|
+
):
|
|
57
|
+
self.project_path = project_path
|
|
58
|
+
self.output_file = output_file
|
|
59
|
+
self.output_format = output_format
|
|
60
|
+
self.show_third_party = show_third_party
|
|
61
|
+
self.show_stdlib = show_stdlib
|
|
62
|
+
self.include_all = include_all
|
|
63
|
+
|
|
64
|
+
# Merge default exclusions with user-specified ones
|
|
65
|
+
user_patterns = exclude_patterns if exclude_patterns is not None else []
|
|
66
|
+
if include_all:
|
|
67
|
+
# If include_all is True, only use user-specified patterns
|
|
68
|
+
self.exclude_patterns = user_patterns
|
|
69
|
+
else:
|
|
70
|
+
# Merge defaults with user patterns, avoiding duplicates
|
|
71
|
+
self.exclude_patterns = list(DEFAULT_EXCLUDE_PATTERNS)
|
|
72
|
+
for pattern in user_patterns:
|
|
73
|
+
if pattern not in self.exclude_patterns:
|
|
74
|
+
self.exclude_patterns.append(pattern)
|
|
75
|
+
|
|
76
|
+
def __repr__(self) -> str:
|
|
77
|
+
"""Provides a developer-friendly string representation of the Config object."""
|
|
78
|
+
return (
|
|
79
|
+
f"{self.__class__.__name__}("
|
|
80
|
+
f"project_path={self.project_path!r}, "
|
|
81
|
+
f"output_file={self.output_file!r}, "
|
|
82
|
+
f"output_format={self.output_format!r}, "
|
|
83
|
+
f"exclude_patterns={self.exclude_patterns!r}, "
|
|
84
|
+
f"show_third_party={self.show_third_party!r}, "
|
|
85
|
+
f"show_stdlib={self.show_stdlib!r}, "
|
|
86
|
+
f"include_all={self.include_all!r}"
|
|
87
|
+
")"
|
|
88
|
+
)
|
|
89
|
+
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""DependencyGraph class representing the core graph data structure."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, List, Set, Optional
|
|
5
|
+
|
|
6
|
+
from .module_node import ModuleNode, ModuleType
|
|
7
|
+
from ..parsing.project import Project
|
|
8
|
+
from ..parsing.ast_parser import ASTParser
|
|
9
|
+
from ..config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DependencyGraph:
|
|
13
|
+
"""
|
|
14
|
+
The Blueprint: The central data structure holding the dependency graph.
|
|
15
|
+
|
|
16
|
+
This class orchestrates the process of building a dependency graph from
|
|
17
|
+
a Python project, resolving import relationships, and detecting cycles.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
"""Initialize an empty dependency graph."""
|
|
22
|
+
self.nodes: Dict[str, ModuleNode] = {}
|
|
23
|
+
self._project_root: Optional[Path] = None
|
|
24
|
+
|
|
25
|
+
def build(self, project: Project, parser: ASTParser, config: Config):
|
|
26
|
+
"""
|
|
27
|
+
Build the dependency graph by scanning the project.
|
|
28
|
+
|
|
29
|
+
This is the main orchestration method that:
|
|
30
|
+
1. Discovers all Python files in the project
|
|
31
|
+
2. Creates ModuleNode objects for each file
|
|
32
|
+
3. Extracts imports using the AST parser
|
|
33
|
+
4. Resolves dependencies between nodes
|
|
34
|
+
5. Classifies modules by type
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
project: The Project instance containing the files to analyze.
|
|
38
|
+
parser: The ASTParser for extracting imports.
|
|
39
|
+
config: Configuration settings for filtering.
|
|
40
|
+
"""
|
|
41
|
+
self._project_root = project.root_path
|
|
42
|
+
|
|
43
|
+
# Step 1: Discover all Python files
|
|
44
|
+
python_files = project.get_python_files(config.exclude_patterns)
|
|
45
|
+
|
|
46
|
+
# Step 2: Create nodes for each Python file
|
|
47
|
+
for file_path in python_files:
|
|
48
|
+
node = self._create_module_node(file_path, project.root_path)
|
|
49
|
+
self.nodes[node.name] = node
|
|
50
|
+
|
|
51
|
+
# Extract imports using AST
|
|
52
|
+
raw_imports = parser.get_imports_from_file(file_path)
|
|
53
|
+
node.raw_imports = raw_imports
|
|
54
|
+
|
|
55
|
+
# Step 3: Resolve dependencies
|
|
56
|
+
self._resolve_dependencies()
|
|
57
|
+
|
|
58
|
+
# Step 4: Classify modules
|
|
59
|
+
self._classify_modules()
|
|
60
|
+
|
|
61
|
+
# Step 5: Filter based on config
|
|
62
|
+
self._apply_filters(config)
|
|
63
|
+
|
|
64
|
+
def _create_module_node(self, file_path: Path, project_root: Path) -> ModuleNode:
|
|
65
|
+
"""
|
|
66
|
+
Create a ModuleNode from a file path.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
file_path: Absolute path to the Python file.
|
|
70
|
+
project_root: Root directory of the project.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A new ModuleNode instance.
|
|
74
|
+
"""
|
|
75
|
+
# Convert absolute path to module name
|
|
76
|
+
# e.g., /project/src/app/models/user.py -> app.models.user
|
|
77
|
+
relative_path = file_path.relative_to(project_root)
|
|
78
|
+
|
|
79
|
+
# Remove .py extension and convert path separators to dots
|
|
80
|
+
module_name = str(relative_path.with_suffix('')).replace('/', '.').replace('\\', '.')
|
|
81
|
+
|
|
82
|
+
return ModuleNode(name=module_name, file_path=file_path, module_type=ModuleType.LOCAL)
|
|
83
|
+
|
|
84
|
+
def _resolve_dependencies(self):
|
|
85
|
+
"""
|
|
86
|
+
Resolve raw import strings to actual ModuleNode dependencies.
|
|
87
|
+
|
|
88
|
+
For each node, this method tries to match its raw_imports to other
|
|
89
|
+
nodes in the graph. It handles both absolute and relative imports.
|
|
90
|
+
"""
|
|
91
|
+
for node in self.nodes.values():
|
|
92
|
+
node.dependencies = set()
|
|
93
|
+
|
|
94
|
+
for import_str in node.raw_imports:
|
|
95
|
+
# Try to resolve the import
|
|
96
|
+
dependency_node = self._resolve_import(import_str, node.name)
|
|
97
|
+
if dependency_node is not None:
|
|
98
|
+
node.dependencies.add(dependency_node)
|
|
99
|
+
|
|
100
|
+
def _resolve_import(self, import_str: str, current_module: str) -> Optional[ModuleNode]:
|
|
101
|
+
"""
|
|
102
|
+
Resolve an import string to a ModuleNode if it exists in the graph.
|
|
103
|
+
|
|
104
|
+
Handles various import patterns:
|
|
105
|
+
- Absolute imports: 'os' -> looks for 'os' in graph
|
|
106
|
+
- Relative imports: '.utils' -> resolves relative to current_module
|
|
107
|
+
- Dotted imports: 'app.models' -> tries to find exact or parent module
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
import_str: The raw import string.
|
|
111
|
+
current_module: The module where this import appears.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The matching ModuleNode, or None if not found.
|
|
115
|
+
"""
|
|
116
|
+
# Handle relative imports
|
|
117
|
+
if import_str.startswith('.'):
|
|
118
|
+
absolute_name = self._resolve_relative_import(import_str, current_module)
|
|
119
|
+
if absolute_name in self.nodes:
|
|
120
|
+
return self.nodes[absolute_name]
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# Handle absolute imports - try exact match first
|
|
124
|
+
if import_str in self.nodes:
|
|
125
|
+
return self.nodes[import_str]
|
|
126
|
+
|
|
127
|
+
# Try to find the parent module of the import
|
|
128
|
+
# e.g., 'os.path.join' -> look for 'os' or 'os.path' or 'os.path.join'
|
|
129
|
+
# e.g., 'config.Config' -> look for 'config'
|
|
130
|
+
for potential_module in self._get_import_variants(import_str):
|
|
131
|
+
if potential_module in self.nodes:
|
|
132
|
+
return self.nodes[potential_module]
|
|
133
|
+
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def _get_import_variants(self, import_str: str) -> List[str]:
|
|
137
|
+
"""
|
|
138
|
+
Generate potential module names from an import string.
|
|
139
|
+
|
|
140
|
+
When you have an import like 'os.path.join', we should try to match
|
|
141
|
+
against the full path, but also against parent modules like 'os.path' and 'os'.
|
|
142
|
+
However, we should NOT match partial strings incorrectly.
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
'config.Config' -> ['config.Config', 'config']
|
|
146
|
+
'os.path.join' -> ['os.path.join', 'os.path', 'os']
|
|
147
|
+
'sys' -> ['sys']
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
import_str: The import string to generate variants for.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of potential module names to try, in order of specificity.
|
|
154
|
+
"""
|
|
155
|
+
variants = [import_str] # Try the full import first
|
|
156
|
+
|
|
157
|
+
# For dotted names, try progressively shorter prefixes
|
|
158
|
+
# Split on dots and build up variants
|
|
159
|
+
parts = import_str.split('.')
|
|
160
|
+
|
|
161
|
+
# Generate variants: full -> parts[0].parts[1] -> parts[0]
|
|
162
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
163
|
+
variant = '.'.join(parts[:i])
|
|
164
|
+
if variant not in variants:
|
|
165
|
+
variants.append(variant)
|
|
166
|
+
|
|
167
|
+
return variants
|
|
168
|
+
|
|
169
|
+
def _resolve_relative_import(self, relative_str: str, current_module: str) -> str:
|
|
170
|
+
"""
|
|
171
|
+
Convert a relative import to an absolute module name.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
relative_str: The relative import (e.g., '.utils', '..models', '.TimeAccount.TimeAccount').
|
|
175
|
+
current_module: The module where this import appears.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The absolute module name.
|
|
179
|
+
"""
|
|
180
|
+
# Count the number of leading dots
|
|
181
|
+
dots = 0
|
|
182
|
+
for char in relative_str:
|
|
183
|
+
if char == '.':
|
|
184
|
+
dots += 1
|
|
185
|
+
else:
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
# Remove the dots to get the remaining path
|
|
189
|
+
remaining = relative_str[dots:]
|
|
190
|
+
|
|
191
|
+
# Navigate up the module hierarchy
|
|
192
|
+
parts = current_module.split('.')
|
|
193
|
+
target_depth = len(parts) - dots
|
|
194
|
+
|
|
195
|
+
if target_depth < 0:
|
|
196
|
+
return relative_str # Can't resolve, return as-is
|
|
197
|
+
|
|
198
|
+
# Build the absolute module name
|
|
199
|
+
result_parts = parts[:target_depth]
|
|
200
|
+
if remaining:
|
|
201
|
+
# Append the remaining path
|
|
202
|
+
result_parts.append(remaining)
|
|
203
|
+
|
|
204
|
+
return '.'.join(result_parts)
|
|
205
|
+
|
|
206
|
+
def _classify_modules(self):
|
|
207
|
+
"""
|
|
208
|
+
Classify each module as LOCAL, THIRD_PARTY, or STDLIB.
|
|
209
|
+
|
|
210
|
+
This is currently simplified - in a full implementation, you'd want
|
|
211
|
+
to check against standard library and installed packages.
|
|
212
|
+
"""
|
|
213
|
+
# Python standard library modules (partial list)
|
|
214
|
+
stdlib_modules = {
|
|
215
|
+
'os', 'sys', 'pathlib', 'json', 'xml', 'csv', 'datetime',
|
|
216
|
+
'time', 'random', 'hashlib', 'uuid', 'collections', 'itertools',
|
|
217
|
+
'functools', 'operator', 'math', 'statistics', 're', 'string',
|
|
218
|
+
'io', 'pickle', 'copy', 'abc', 'enum', 'dataclasses', 'typing',
|
|
219
|
+
'inspect', 'ast', 'traceback', 'logging', 'argparse', 'getopt',
|
|
220
|
+
'socket', 'http', 'urllib', 'email', 'html', 'textwrap', 'pprint',
|
|
221
|
+
'subprocess', 'threading', 'multiprocessing', 'asyncio', 'queue',
|
|
222
|
+
'weakref', 'gc', 'contextlib', 'decorator', 'profile', 'cProfile',
|
|
223
|
+
'doctest', 'unittest', 'pdb', 'dis', 'compileall', 'py_compile',
|
|
224
|
+
'cmd', 'shlex', 'configparser', 'fileinput', 'locale', 'gettext',
|
|
225
|
+
'argparse', 'calendar', 'codecs', 'difflib', 'fnmatch', 'glob',
|
|
226
|
+
'linecache', 'shutil', 'tempfile', 'mmap', 'readline', 'rlcompleter',
|
|
227
|
+
'sqlite3', 'zlib', 'gzip', 'bz2', 'lzma', 'zipfile', 'tarfile',
|
|
228
|
+
'csv', 'configparser', 'netrc', 'xdrlib', 'plistlib', 'hashlib',
|
|
229
|
+
'hmac', 'secrets', 'base64', 'binascii', 'struct', 'codecs',
|
|
230
|
+
'unicodedata', 'stringprep', 'readline', 'rlcompleter', 'lib2to3'
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for node in self.nodes.values():
|
|
234
|
+
# Skip if already classified as LOCAL (from file path)
|
|
235
|
+
if node.module_type == ModuleType.LOCAL:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# Check if it's a standard library module
|
|
239
|
+
module_base = node.name.split('.')[0]
|
|
240
|
+
if module_base in stdlib_modules:
|
|
241
|
+
node.module_type = ModuleType.STDLIB
|
|
242
|
+
else:
|
|
243
|
+
# Assume third-party for now
|
|
244
|
+
# In production, you might check installed packages
|
|
245
|
+
node.module_type = ModuleType.THIRD_PARTY
|
|
246
|
+
|
|
247
|
+
def _apply_filters(self, config: Config):
|
|
248
|
+
"""
|
|
249
|
+
Apply filtering based on configuration options.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
config: Configuration settings.
|
|
253
|
+
"""
|
|
254
|
+
if config.show_third_party and config.show_stdlib:
|
|
255
|
+
return # Nothing to filter
|
|
256
|
+
|
|
257
|
+
# Build set of modules to keep
|
|
258
|
+
keep_modules = set()
|
|
259
|
+
for node in self.nodes.values():
|
|
260
|
+
if node.module_type == ModuleType.LOCAL:
|
|
261
|
+
keep_modules.add(node.name)
|
|
262
|
+
elif node.module_type == ModuleType.THIRD_PARTY and config.show_third_party:
|
|
263
|
+
keep_modules.add(node.name)
|
|
264
|
+
elif node.module_type == ModuleType.STDLIB and config.show_stdlib:
|
|
265
|
+
keep_modules.add(node.name)
|
|
266
|
+
|
|
267
|
+
# Remove filtered nodes
|
|
268
|
+
filtered_out = set(self.nodes.keys()) - keep_modules
|
|
269
|
+
for module_name in filtered_out:
|
|
270
|
+
del self.nodes[module_name]
|
|
271
|
+
|
|
272
|
+
# Clean up dependencies pointing to removed nodes
|
|
273
|
+
for node in self.nodes.values():
|
|
274
|
+
node.dependencies = {
|
|
275
|
+
dep for dep in node.dependencies
|
|
276
|
+
if dep.name in self.nodes
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
def add_node(self, node: ModuleNode):
|
|
280
|
+
"""
|
|
281
|
+
Add a node to the graph.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
node: The ModuleNode to add.
|
|
285
|
+
"""
|
|
286
|
+
self.nodes[node.name] = node
|
|
287
|
+
|
|
288
|
+
def find_cycles(self) -> List[List[ModuleNode]]:
|
|
289
|
+
"""
|
|
290
|
+
Detect all cycles in the dependency graph.
|
|
291
|
+
|
|
292
|
+
Uses Depth-First Search (DFS) to find strongly connected components
|
|
293
|
+
or cycles. Returns all circular dependencies found.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
List of cycles, where each cycle is a list of ModuleNodes.
|
|
297
|
+
"""
|
|
298
|
+
cycles = []
|
|
299
|
+
visited = set()
|
|
300
|
+
rec_stack = set()
|
|
301
|
+
path = []
|
|
302
|
+
|
|
303
|
+
def dfs(node: ModuleNode):
|
|
304
|
+
"""Recursive DFS helper for cycle detection."""
|
|
305
|
+
visited.add(node.name)
|
|
306
|
+
rec_stack.add(node.name)
|
|
307
|
+
path.append(node)
|
|
308
|
+
|
|
309
|
+
for dependency in node.dependencies:
|
|
310
|
+
if dependency.name not in visited:
|
|
311
|
+
if dfs(dependency):
|
|
312
|
+
return True
|
|
313
|
+
elif dependency.name in rec_stack:
|
|
314
|
+
# Found a cycle! Extract the cycle path
|
|
315
|
+
cycle_start = path.index(dependency)
|
|
316
|
+
cycles.append(path[cycle_start:] + [dependency])
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
rec_stack.remove(node.name)
|
|
320
|
+
path.pop()
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
# Run DFS on all nodes
|
|
324
|
+
for node in self.nodes.values():
|
|
325
|
+
if node.name not in visited:
|
|
326
|
+
dfs(node)
|
|
327
|
+
|
|
328
|
+
return cycles
|
|
329
|
+
|
|
330
|
+
def __repr__(self) -> str:
|
|
331
|
+
"""Provide a developer-friendly representation."""
|
|
332
|
+
return f"DependencyGraph(nodes={len(self.nodes)})"
|
|
333
|
+
|
|
334
|
+
def __len__(self) -> int:
|
|
335
|
+
"""Return the number of nodes in the graph."""
|
|
336
|
+
return len(self.nodes)
|
|
337
|
+
|
|
338
|
+
def __iter__(self):
|
|
339
|
+
"""Allow iteration over nodes."""
|
|
340
|
+
return iter(self.nodes.values())
|
|
341
|
+
|