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 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
@@ -0,0 +1,7 @@
1
+ """Entry point for running depcycle as a module: python -m depcycle"""
2
+
3
+ from .cli import DepCycleCLI
4
+
5
+ if __name__ == '__main__':
6
+ DepCycleCLI.main()
7
+
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,7 @@
1
+ """Graph package containing the core dependency graph data structures."""
2
+
3
+ from .module_node import ModuleNode, ModuleType
4
+ from .dependency_graph import DependencyGraph
5
+
6
+ __all__ = ["ModuleNode", "ModuleType", "DependencyGraph"]
7
+
@@ -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
+