ai-codeindex 0.7.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.
@@ -0,0 +1,737 @@
1
+ """Smart README writer with grouping, size limits, and hierarchical levels."""
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from fnmatch import fnmatch
7
+ from pathlib import Path
8
+ from typing import Literal, Optional
9
+
10
+ from .adaptive_selector import AdaptiveSymbolSelector
11
+ from .config import IndexingConfig
12
+ from .framework_detect import RouteInfo, detect_framework
13
+ from .parser import ParseResult, Symbol
14
+ from .semantic_extractor import SemanticExtractor
15
+
16
+
17
+ @dataclass
18
+ class WriteResult:
19
+ """Result of writing a README file."""
20
+ path: Path
21
+ success: bool
22
+ error: str = ""
23
+ size_bytes: int = 0
24
+ truncated: bool = False
25
+
26
+
27
+ # Level types
28
+ LevelType = Literal["overview", "navigation", "detailed"]
29
+
30
+
31
+ class SmartWriter:
32
+ """
33
+ Smart README writer that generates appropriate content based on level.
34
+
35
+ Levels:
36
+ - overview: Project/root level, only module list with descriptions
37
+ - navigation: Module level, grouped files with key classes
38
+ - detailed: Leaf level, full symbol information
39
+ """
40
+
41
+ def __init__(self, config: IndexingConfig, docstring_processor=None):
42
+ """
43
+ Initialize SmartWriter.
44
+
45
+ Args:
46
+ config: Indexing configuration (can also accept full Config object)
47
+ docstring_processor: Optional DocstringProcessor for AI-powered
48
+ docstring extraction (Epic 9)
49
+ """
50
+ # Handle both IndexingConfig and full Config
51
+ if hasattr(config, 'indexing'):
52
+ # Full Config object passed - extract indexing config
53
+ self.config = config.indexing
54
+ else:
55
+ # IndexingConfig passed directly
56
+ self.config = config
57
+
58
+ self.max_size = self.config.max_readme_size
59
+
60
+ # Initialize adaptive symbol selector
61
+ self.adaptive_selector = AdaptiveSymbolSelector(
62
+ self.config.symbols.adaptive_symbols
63
+ )
64
+
65
+ # Initialize semantic extractor if enabled
66
+ if self.config.semantic.enabled:
67
+ # Need ai_command from parent Config (will be passed separately)
68
+ # For now, initialize with heuristic mode
69
+ self.semantic_extractor = SemanticExtractor(
70
+ use_ai=self.config.semantic.use_ai,
71
+ ai_command=None if not self.config.semantic.use_ai else None
72
+ )
73
+ else:
74
+ self.semantic_extractor = None
75
+
76
+ # Initialize route extractor registry (Epic 6)
77
+ from .extractors.thinkphp import ThinkPHPRouteExtractor
78
+ from .route_registry import RouteExtractorRegistry
79
+
80
+ self.route_registry = RouteExtractorRegistry()
81
+ self.route_registry.register(ThinkPHPRouteExtractor())
82
+
83
+ # Initialize docstring processor (Epic 9)
84
+ self.docstring_processor = docstring_processor
85
+
86
+ def write_readme(
87
+ self,
88
+ dir_path: Path,
89
+ parse_results: list[ParseResult],
90
+ level: LevelType = "detailed",
91
+ child_dirs: list[Path] | None = None,
92
+ output_file: str = "README_AI.md",
93
+ ) -> WriteResult:
94
+ """
95
+ Write README_AI.md with appropriate content based on level.
96
+
97
+ Args:
98
+ dir_path: Directory to write to
99
+ parse_results: Parsed file results for this directory
100
+ level: Content level (overview/navigation/detailed)
101
+ child_dirs: Child directories with their own README_AI.md
102
+ output_file: Output filename
103
+ """
104
+ output_path = dir_path / output_file
105
+ child_dirs = child_dirs or []
106
+
107
+ try:
108
+ if level == "overview":
109
+ content = self._generate_overview(dir_path, parse_results, child_dirs)
110
+ elif level == "navigation":
111
+ content = self._generate_navigation(dir_path, parse_results, child_dirs)
112
+ else: # detailed
113
+ content = self._generate_detailed(dir_path, parse_results, child_dirs)
114
+
115
+ # Check size limit
116
+ content_bytes = content.encode('utf-8')
117
+ truncated = False
118
+ if len(content_bytes) > self.max_size:
119
+ content, truncated = self._truncate_content(content, self.max_size)
120
+ content_bytes = content.encode('utf-8')
121
+
122
+ with open(output_path, "w", encoding="utf-8") as f:
123
+ f.write(content)
124
+
125
+ return WriteResult(
126
+ path=output_path,
127
+ success=True,
128
+ size_bytes=len(content_bytes),
129
+ truncated=truncated,
130
+ )
131
+
132
+ except Exception as e:
133
+ return WriteResult(path=output_path, success=False, error=str(e))
134
+
135
+ def _generate_overview(
136
+ self,
137
+ dir_path: Path,
138
+ parse_results: list[ParseResult],
139
+ child_dirs: list[Path],
140
+ ) -> str:
141
+ """Generate overview level README (root/project level)."""
142
+ timestamp = datetime.now().isoformat()
143
+ lines = [
144
+ f"<!-- Generated by codeindex (overview) at {timestamp} -->",
145
+ "",
146
+ f"# {dir_path.name}",
147
+ "",
148
+ ]
149
+
150
+ # Detect framework
151
+ framework = detect_framework(dir_path)
152
+ if framework != "unknown":
153
+ lines.extend([
154
+ f"**Framework**: {framework.title()}",
155
+ "",
156
+ ])
157
+
158
+ # Statistics
159
+ total_files = len(parse_results)
160
+ total_symbols = sum(len(r.symbols) for r in parse_results)
161
+ total_modules = len(child_dirs)
162
+
163
+ lines.extend([
164
+ "## Overview",
165
+ "",
166
+ f"- **Modules**: {total_modules}",
167
+ f"- **Files**: {total_files}",
168
+ f"- **Symbols**: {total_symbols}",
169
+ "",
170
+ ])
171
+
172
+ # Module structure (tree view)
173
+ if child_dirs:
174
+ lines.extend([
175
+ "## Module Structure",
176
+ "",
177
+ "```",
178
+ f"{dir_path.name}/",
179
+ ])
180
+
181
+ # Group child dirs by first-level subdirectory
182
+ for child in sorted(child_dirs):
183
+ rel_path = child.relative_to(dir_path)
184
+ lines.append(f"├── {rel_path}/")
185
+
186
+ lines.extend([
187
+ "```",
188
+ "",
189
+ ])
190
+
191
+ # Module list with descriptions
192
+ lines.extend([
193
+ "## Modules",
194
+ "",
195
+ ])
196
+
197
+ for child in sorted(child_dirs):
198
+ rel_path = child.relative_to(dir_path)
199
+ description = self._extract_module_description(child)
200
+ lines.append(f"- **{rel_path}** - {description}")
201
+
202
+ lines.append("")
203
+
204
+ return "\n".join(lines)
205
+
206
+ def _generate_navigation(
207
+ self,
208
+ dir_path: Path,
209
+ parse_results: list[ParseResult],
210
+ child_dirs: list[Path],
211
+ ) -> str:
212
+ """Generate navigation level README (module level)."""
213
+ timestamp = datetime.now().isoformat()
214
+ lines = [
215
+ f"<!-- Generated by codeindex (navigation) at {timestamp} -->",
216
+ "",
217
+ f"# {dir_path.name}",
218
+ "",
219
+ ]
220
+
221
+ # Statistics
222
+ total_files = len(parse_results)
223
+ total_symbols = sum(len(r.symbols) for r in parse_results)
224
+
225
+ lines.extend([
226
+ "## Overview",
227
+ "",
228
+ f"- **Files**: {total_files}",
229
+ f"- **Symbols**: {total_symbols}",
230
+ f"- **Subdirectories**: {len(child_dirs)}",
231
+ "",
232
+ ])
233
+
234
+ # Subdirectories (if any)
235
+ if child_dirs:
236
+ lines.extend([
237
+ "## Subdirectories",
238
+ "",
239
+ ])
240
+ for child in sorted(child_dirs):
241
+ description = self._extract_module_description(child)
242
+ lines.append(f"- **{child.name}/** - {description}")
243
+ lines.extend(["", ""])
244
+
245
+ # Grouped files
246
+ if parse_results:
247
+ grouped = self._group_files(parse_results)
248
+ lines.extend([
249
+ "## Files",
250
+ "",
251
+ ])
252
+
253
+ for group_name, group_results in grouped.items():
254
+ if group_name != "_ungrouped":
255
+ group_desc = self.config.grouping.patterns.get(group_name, "")
256
+ lines.append(f"### {group_name} ({len(group_results)} files)")
257
+ if group_desc:
258
+ lines.append(f"_{group_desc}_")
259
+ lines.append("")
260
+
261
+ for result in group_results:
262
+ if result.error:
263
+ continue
264
+ # List key classes/functions only
265
+ key_symbols = self._get_key_symbols(result.symbols)
266
+ symbol_summary = ", ".join(
267
+ s.name.split("::")[-1].split(".")[-1]
268
+ for s in key_symbols[:3]
269
+ )
270
+ if symbol_summary:
271
+ lines.append(f"- **{result.path.name}** - {symbol_summary}")
272
+ else:
273
+ lines.append(f"- {result.path.name}")
274
+
275
+ lines.append("")
276
+
277
+ return "\n".join(lines)
278
+
279
+ def _generate_detailed(
280
+ self,
281
+ dir_path: Path,
282
+ parse_results: list[ParseResult],
283
+ child_dirs: list[Path],
284
+ ) -> str:
285
+ """Generate detailed level README (leaf level)."""
286
+ timestamp = datetime.now().isoformat()
287
+ lines = [
288
+ f"<!-- Generated by codeindex (detailed) at {timestamp} -->",
289
+ "",
290
+ f"# {dir_path.name}",
291
+ "",
292
+ ]
293
+
294
+ # Statistics
295
+ total_files = len(parse_results)
296
+ total_symbols = sum(len(r.symbols) for r in parse_results)
297
+
298
+ lines.extend([
299
+ "## Overview",
300
+ "",
301
+ f"- **Files**: {total_files}",
302
+ f"- **Symbols**: {total_symbols}",
303
+ "",
304
+ ])
305
+
306
+ # Framework route tables (Epic 6: using registry)
307
+ # Try all registered extractors
308
+ from .route_extractor import ExtractionContext
309
+
310
+ for framework_name in self.route_registry.list_frameworks():
311
+ extractor = self.route_registry.get(framework_name)
312
+ if not extractor:
313
+ continue
314
+
315
+ # Create extraction context
316
+ # Note: root_path is approximated from dir_path
317
+ # In a real scenario, this would be passed from the caller
318
+ context = ExtractionContext(
319
+ root_path=dir_path, # Temporary: use current dir as root
320
+ current_dir=dir_path,
321
+ parse_results=parse_results,
322
+ )
323
+
324
+ # Check if this extractor can handle this directory
325
+ if extractor.can_extract(context):
326
+ routes = extractor.extract_routes(context)
327
+ if routes:
328
+ route_lines = self._format_route_table(routes, framework_name)
329
+ lines.extend(route_lines)
330
+ break # Only use first matching extractor
331
+
332
+ # Subdirectories (brief, just references)
333
+ if child_dirs:
334
+ lines.extend([
335
+ "## Subdirectories",
336
+ "",
337
+ ])
338
+ for child in sorted(child_dirs):
339
+ lines.append(f"- [{child.name}/]({child.name}/README_AI.md)")
340
+ lines.extend(["", ""])
341
+
342
+ # Process docstrings with AI if processor available (Epic 9)
343
+ if self.docstring_processor:
344
+ for result in parse_results:
345
+ if result.error or not result.symbols:
346
+ continue
347
+
348
+ # Get AI-enhanced docstrings for this file
349
+ try:
350
+ normalized = self.docstring_processor.process_file(
351
+ result.path, result.symbols
352
+ )
353
+
354
+ # Update symbol docstrings with AI-enhanced descriptions
355
+ for symbol in result.symbols:
356
+ if symbol.name in normalized:
357
+ symbol.docstring = normalized[symbol.name]
358
+ except Exception:
359
+ # If AI processing fails, continue with raw docstrings
360
+ # (backward compatible fallback)
361
+ pass
362
+
363
+ # Detailed file listing with symbols
364
+ if parse_results:
365
+ grouped = self._group_files(parse_results)
366
+
367
+ for group_name, group_results in grouped.items():
368
+ if group_name != "_ungrouped":
369
+ group_desc = self.config.grouping.patterns.get(group_name, "")
370
+ lines.append(f"## {group_name}")
371
+ if group_desc:
372
+ lines.append(f"_{group_desc}_")
373
+ lines.append("")
374
+ else:
375
+ lines.append("## Files")
376
+ lines.append("")
377
+
378
+ for result in group_results:
379
+ if result.error:
380
+ lines.append(f"### {result.path.name}")
381
+ lines.append(f"_Parse error: {result.error}_")
382
+ lines.append("")
383
+ continue
384
+
385
+ lines.append(f"### {result.path.name}")
386
+
387
+ # Show namespace for PHP files
388
+ if result.namespace:
389
+ lines.append(f"**Namespace:** `{result.namespace}`")
390
+
391
+ if result.module_docstring:
392
+ lines.append(f"_{result.module_docstring[:150]}_")
393
+ lines.append("")
394
+
395
+ # Filter and limit symbols
396
+ symbols = self._filter_symbols(result.symbols)
397
+ total_filtered_symbols = len(symbols) # Save count after filtering
398
+
399
+ # Calculate symbol limit: use adaptive if enabled, otherwise use max_per_file
400
+ if self.config.symbols.adaptive_symbols.enabled:
401
+ limit = self.adaptive_selector.calculate_limit(
402
+ result.file_lines, len(symbols)
403
+ )
404
+ else:
405
+ limit = self.config.symbols.max_per_file
406
+
407
+ symbols = symbols[:limit]
408
+
409
+ # Group by kind
410
+ classes = [s for s in symbols if s.kind == "class"]
411
+ methods = [s for s in symbols if s.kind == "method"]
412
+ functions = [s for s in symbols if s.kind == "function"]
413
+ properties = [s for s in symbols if s.kind == "property"]
414
+
415
+ if classes:
416
+ for cls in classes:
417
+ lines.append(f"**class** `{cls.signature}`")
418
+ if cls.docstring:
419
+ lines.append(f"> {cls.docstring[:100]}")
420
+ lines.append("")
421
+
422
+ if methods:
423
+ lines.append("**Methods:**")
424
+ for m in methods:
425
+ lines.append(f"- `{m.signature}`")
426
+ lines.append("")
427
+
428
+ if functions:
429
+ lines.append("**Functions:**")
430
+ for f in functions:
431
+ lines.append(f"- `{f.signature}`")
432
+ lines.append("")
433
+
434
+ if properties:
435
+ lines.append("**Properties:**")
436
+ for p in properties:
437
+ lines.append(f"- `{p.signature}`")
438
+ lines.append("")
439
+
440
+ # Show truncation notice
441
+ shown_symbols = len(symbols)
442
+ if shown_symbols < total_filtered_symbols:
443
+ lines.append(
444
+ f"_... and {total_filtered_symbols - shown_symbols} more symbols_"
445
+ )
446
+ lines.append("")
447
+
448
+ # Dependencies section
449
+ all_imports = []
450
+ for result in parse_results:
451
+ all_imports.extend(result.imports)
452
+
453
+ if all_imports:
454
+ lines.extend([
455
+ "## Dependencies",
456
+ "",
457
+ ])
458
+ # Deduplicate and sort
459
+ modules = sorted(set(imp.module for imp in all_imports))
460
+ for module in modules[:20]: # Limit to 20
461
+ lines.append(f"- {module}")
462
+ if len(modules) > 20:
463
+ lines.append(f"_... and {len(modules) - 20} more_")
464
+ lines.append("")
465
+
466
+ return "\n".join(lines)
467
+
468
+ def _format_route_table(
469
+ self, routes: list[RouteInfo], framework: str = "thinkphp"
470
+ ) -> list[str]:
471
+ """
472
+ Format route information as Markdown table with line numbers.
473
+
474
+ Args:
475
+ routes: List of RouteInfo objects
476
+ framework: Framework name for title (e.g., "thinkphp", "laravel")
477
+
478
+ Returns:
479
+ List of markdown lines for the route table
480
+
481
+ Epic 6, P1: Line number support
482
+ """
483
+ if not routes:
484
+ return []
485
+
486
+ # Format framework name with proper casing
487
+ framework_display = {
488
+ "thinkphp": "ThinkPHP",
489
+ "laravel": "Laravel",
490
+ "django": "Django",
491
+ "fastapi": "FastAPI",
492
+ }.get(framework.lower(), framework.title())
493
+
494
+ lines = [
495
+ f"## Routes ({framework_display})",
496
+ "",
497
+ "| URL | Controller | Action | Location | Description |",
498
+ "|-----|------------|--------|----------|-------------|",
499
+ ]
500
+
501
+ # Display up to 30 routes
502
+ for route in routes[:30]:
503
+ # Use route.location property (handles file:line format)
504
+ location = f"`{route.location}`" if route.location else ""
505
+
506
+ # Get description (already truncated to 60 chars in extractor)
507
+ description = route.description if route.description else ""
508
+
509
+ lines.append(
510
+ f"| `{route.url}` | {route.controller} | "
511
+ f"{route.action} | {location} | {description} |"
512
+ )
513
+
514
+ # Show "more" indicator if there are additional routes
515
+ if len(routes) > 30:
516
+ remaining = len(routes) - 30
517
+ lines.append(f"| ... | _{remaining} more routes_ | | | |")
518
+
519
+ lines.extend(["", ""])
520
+ return lines
521
+
522
+ def _group_files(self, results: list[ParseResult]) -> dict[str, list[ParseResult]]:
523
+ """Group files by suffix pattern."""
524
+ if not self.config.grouping.enabled:
525
+ return {"_ungrouped": results}
526
+
527
+ grouped = defaultdict(list)
528
+ ungrouped = []
529
+
530
+ for result in results:
531
+ filename = result.path.stem # Without extension
532
+ matched = False
533
+
534
+ for pattern in self.config.grouping.patterns.keys():
535
+ if filename.endswith(pattern):
536
+ grouped[pattern].append(result)
537
+ matched = True
538
+ break
539
+
540
+ if not matched:
541
+ ungrouped.append(result)
542
+
543
+ # Sort groups by pattern order, add ungrouped at end
544
+ ordered = {}
545
+ for pattern in self.config.grouping.patterns.keys():
546
+ if pattern in grouped:
547
+ ordered[pattern] = grouped[pattern]
548
+
549
+ if ungrouped:
550
+ ordered["_ungrouped"] = ungrouped
551
+
552
+ return ordered
553
+
554
+ def _filter_symbols(self, symbols: list[Symbol]) -> list[Symbol]:
555
+ """Filter symbols based on visibility and exclusion patterns."""
556
+ filtered = []
557
+
558
+ for symbol in symbols:
559
+ # Check exclusion patterns
560
+ name = symbol.name.split("::")[-1].split(".")[-1]
561
+ excluded = False
562
+ for pattern in self.config.symbols.exclude_patterns:
563
+ if fnmatch(name, pattern):
564
+ excluded = True
565
+ break
566
+
567
+ if excluded:
568
+ continue
569
+
570
+ # Check visibility (from signature)
571
+ sig_lower = symbol.signature.lower()
572
+ if self.config.symbols.include_visibility:
573
+ # If visibility config exists, check it
574
+ has_visibility = any(v in sig_lower for v in ["public", "private", "protected"])
575
+ if has_visibility:
576
+ visible = any(v in sig_lower for v in self.config.symbols.include_visibility)
577
+ if not visible:
578
+ continue
579
+
580
+ filtered.append(symbol)
581
+
582
+ return filtered
583
+
584
+ def _get_key_symbols(self, symbols: list[Symbol]) -> list[Symbol]:
585
+ """Get key symbols (classes and main functions) from a file."""
586
+ key = []
587
+
588
+ # Add all classes
589
+ for s in symbols:
590
+ if s.kind == "class":
591
+ key.append(s)
592
+
593
+ # Add public functions/methods
594
+ for s in symbols:
595
+ if s.kind in ("function", "method"):
596
+ sig_lower = s.signature.lower()
597
+ if "public" in sig_lower or s.kind == "function":
598
+ key.append(s)
599
+
600
+ return key[:5] # Limit to 5 key symbols
601
+
602
+ def _extract_module_description(self, dir_path: Path, output_file: str = "README_AI.md") -> str:
603
+ """Extract brief description from a child module's README."""
604
+ readme_path = dir_path / output_file
605
+ if not readme_path.exists():
606
+ return "Module directory"
607
+
608
+ try:
609
+ content = readme_path.read_text(encoding="utf-8")
610
+ lines = content.split("\n")
611
+
612
+ # Look for first non-empty, non-header line
613
+ for line in lines[2:15]: # Skip header, check first 15 lines
614
+ line = line.strip()
615
+ if line and not line.startswith("#") and not line.startswith("<!--"):
616
+ if line.startswith("-"):
617
+ continue # Skip list items
618
+ return line[:80]
619
+
620
+ return "Module directory"
621
+ except Exception:
622
+ return "Module directory"
623
+
624
+ def _extract_module_description_semantic(
625
+ self,
626
+ dir_path: Path,
627
+ parse_result: Optional[ParseResult] = None
628
+ ) -> str:
629
+ """
630
+ Extract module description using semantic extraction.
631
+
632
+ Args:
633
+ dir_path: Path to the directory
634
+ parse_result: Optional ParseResult with symbols/imports
635
+
636
+ Returns:
637
+ Business semantic description
638
+ """
639
+ if not self.semantic_extractor:
640
+ # Fallback to old method if semantic extraction disabled
641
+ return self._extract_module_description(dir_path)
642
+
643
+ # Build DirectoryContext from parse_result
644
+ from codeindex.semantic_extractor import DirectoryContext
645
+
646
+ # Get file names
647
+ files = []
648
+ if dir_path.is_dir():
649
+ files = [f.name for f in dir_path.iterdir() if f.is_file()]
650
+
651
+ # Get subdirectory names
652
+ subdirs = []
653
+ if dir_path.is_dir():
654
+ subdirs = [d.name for d in dir_path.iterdir() if d.is_dir()]
655
+
656
+ # Get symbols and imports from parse_result
657
+ symbols = []
658
+ imports = []
659
+ if parse_result:
660
+ symbols = [s.name for s in parse_result.symbols]
661
+ imports = [imp.module for imp in parse_result.imports]
662
+
663
+ # Create context
664
+ context = DirectoryContext(
665
+ path=str(dir_path),
666
+ files=files,
667
+ subdirs=subdirs,
668
+ symbols=symbols,
669
+ imports=imports
670
+ )
671
+
672
+ # Extract semantic
673
+ try:
674
+ semantic = self.semantic_extractor.extract_directory_semantic(context)
675
+ return semantic.description
676
+ except Exception:
677
+ # Fallback to old method on error
678
+ if self.config.semantic.fallback_to_heuristic:
679
+ return self._extract_module_description(dir_path)
680
+ return "Module directory"
681
+
682
+ def _truncate_content(self, content: str, max_size: int) -> tuple[str, bool]:
683
+ """Truncate content to fit within size limit."""
684
+ content_bytes = content.encode('utf-8')
685
+ if len(content_bytes) <= max_size:
686
+ return content, False
687
+
688
+ # Find a good truncation point
689
+ truncated = content_bytes[:max_size - 200].decode('utf-8', errors='ignore')
690
+
691
+ # Try to truncate at a section boundary
692
+ last_section = truncated.rfind("\n## ")
693
+ if last_section > len(truncated) // 2:
694
+ truncated = truncated[:last_section]
695
+
696
+ # Add truncation notice
697
+ truncated += (
698
+ "\n\n---\n"
699
+ "_Content truncated due to size limit. "
700
+ "See individual module README files for details._\n"
701
+ )
702
+
703
+ return truncated, True
704
+
705
+
706
+ def determine_level(
707
+ dir_path: Path,
708
+ root_path: Path,
709
+ has_children: bool,
710
+ config: IndexingConfig,
711
+ ) -> LevelType:
712
+ """
713
+ Determine the appropriate level for a directory.
714
+
715
+ Args:
716
+ dir_path: The directory being processed
717
+ root_path: The project root
718
+ has_children: Whether this directory has subdirectories with README_AI.md
719
+ config: Indexing configuration
720
+ """
721
+ # Calculate depth from root
722
+ try:
723
+ rel_path = dir_path.relative_to(root_path)
724
+ depth = len(rel_path.parts)
725
+ except ValueError:
726
+ depth = 0
727
+
728
+ # Root directory
729
+ if depth == 0 or dir_path == root_path:
730
+ return config.root_level
731
+
732
+ # Has children -> module level
733
+ if has_children:
734
+ return config.module_level
735
+
736
+ # Leaf directory
737
+ return config.leaf_level