Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 119 additions & 11 deletions src/core/bt_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,64 @@ def module_path(self) -> str:
return None
return "/".join(self.file.split("/")[:-1])

@property
def path(self) -> str:
"""Returns the full file path (used for ViewPackage compatibility)"""
if not self.ast:
return None
return self.ast.file

@property
def depth(self) -> int:
"""Depth is module depth + 1 (file is one level deeper than its module)"""
if self.module:
return self.module.depth + 1
return 0

@property
def parent_module(self):
"""Alias for module (ViewPackage compatibility)"""
return self.module

@property
def child_module(self) -> list:
"""Files don't have children"""
return []

@property
def name(self) -> str:
"""Return file name without .py extension"""
return self.label.replace(".py", "")

def get_submodules_recursive(self) -> list:
"""Files don't have submodules"""
return []

def get_module_dependencies(self) -> set["BTFile"]:
"""Get all files this file depends on"""
return set(self.edge_to)

def get_file_dependencies(self) -> set["BTFile"]:
"""Get all files this file depends on (same as get_module_dependencies for files)"""
return set(self.edge_to)

def get_dependency_count(self, other: "BTFile") -> int:
"""Count how many times this file imports from another file"""
if isinstance(other, BTFile):
return 1 if other in self.edge_to else 0
# If other is a BTModule, count edges to files in that module
count = len([e for e in self.edge_to if e.module == other])
return count

def get_file_level_relations(self, target) -> list:
"""Get file-level relations to target (file or module)"""
if isinstance(target, BTFile):
if target in self.edge_to:
return [(self, target)]
return []
# If target is a BTModule, get relations to files in that module
return [(self, e) for e in self.edge_to if e.module == target]

def __rshift__(self, other):
if isinstance(other, list):
existing_edges = set(
Expand All @@ -59,20 +117,72 @@ def __rshift__(self, other):
self.edge_to.append(other)


def _resolve_relative_import(modname: str, level: int, current_file: str) -> str:
"""
Resolve a relative import to an absolute module name.

Args:
modname: The module name from the import (e.g., "prompts" for "from .prompts import ...")
level: Number of dots (1 for ".", 2 for "..", etc.)
current_file: Path to the file containing the import

Returns:
The absolute module name
"""
import os

# Get the directory of the current file
current_dir = os.path.dirname(current_file)

# Go up 'level' directories (level=1 means current package, level=2 means parent, etc.)
for _ in range(level - 1):
current_dir = os.path.dirname(current_dir)

# Convert path to module format
# Find the package name from the directory
package_parts = []
temp_dir = current_dir
while os.path.exists(os.path.join(temp_dir, "__init__.py")):
package_parts.insert(0, os.path.basename(temp_dir))
parent = os.path.dirname(temp_dir)
if parent == temp_dir: # Reached filesystem root
break
temp_dir = parent

base_module = ".".join(package_parts)

if modname:
return f"{base_module}.{modname}" if base_module else modname
return base_module


def get_imported_modules(
ast: astroid.Module, root_location: str, am: AstroidManager
) -> list:
imported_modules = []
for sub_node in ast.body:

# Get the file path from the root module (works even when called with nested nodes)
root_module = ast.root() if hasattr(ast, 'root') else ast
current_file = root_module.file if hasattr(root_module, 'file') else None

# Use nodes_of_class to find ALL imports in the entire AST tree,
# including those inside functions and classes
for sub_node in ast.nodes_of_class((astroid.node_classes.ImportFrom, astroid.node_classes.Import)):
try:
if isinstance(sub_node, astroid.node_classes.ImportFrom):
sub_node: astroid.node_classes.ImportFrom = sub_node
modname = sub_node.modname
level = sub_node.level if sub_node.level else 0

# Handle relative imports
if level > 0 and current_file:
modname = _resolve_relative_import(modname or "", level, current_file)

module_node = am.ast_from_module_name(
sub_node.modname,
context_file=root_location,
)
imported_modules.append(module_node)
if modname:
module_node = am.ast_from_module_name(
modname,
context_file=root_location,
)
imported_modules.append(module_node)

elif isinstance(sub_node, astroid.node_classes.Import):
for name, _ in sub_node.names:
Expand All @@ -84,12 +194,10 @@ def get_imported_modules(
imported_modules.append(module_node)
except Exception:
continue
elif hasattr(sub_node, "body"):
imported_modules.extend(
get_imported_modules(sub_node, root_location, am)
)

except astroid.AstroidImportError:
continue
except Exception:
continue

return imported_modules
10 changes: 10 additions & 0 deletions src/core/bt_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ def get_all_bt_files_map(self) -> dict[str, BTFile]:
def get_all_bt_modules_map(self) -> dict[str, BTModule]:
return {btm.path: btm for btm in self.root_module.get_submodules_recursive()}

def get_all_bt_files_as_nodes_map(self) -> dict[str, BTFile]:
"""Get all files as potential graph nodes, keyed by their path (without .py extension)"""
result = {}
for btf in self.root_module.get_files_recursive():
if btf.file:
# Key is file path without .py extension (e.g., "core/llm_services/anthropic_service")
key = btf.file.replace(".py", "")
result[key] = btf
return result

def _get_files_recursive(self, path: str) -> list[str]:
file_list = []
t = list(os.walk(path))
Expand Down
41 changes: 40 additions & 1 deletion src/providers/plantuml/pu_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@
import sys


def _get_common_prefix(names: list[str]) -> str:
"""Find the common prefix of all package names (dot-separated)."""
if not names:
return ""

# Split all names by dots
split_names = [name.split(".") for name in names]

# Find common prefix parts
common_parts = []
for parts in zip(*split_names):
if len(set(parts)) == 1:
common_parts.append(parts[0])
else:
break

# Return prefix (all but the last common part, to keep some context)
if len(common_parts) > 0:
return ".".join(common_parts)
return ""


def save_plant_uml(view_graph, view_name, config):
plant_uml_str = _render_pu_graph(view_graph, view_name, config)
project_name = config["name"]
Expand All @@ -21,6 +43,17 @@ def save_plant_uml_diff(diff_graph, view_name, config):


def _render_pu_graph(view_graph: list[ViewPackage], view_name, config):
# Find common prefix to strip from all names
all_names = [pkg.name for pkg in view_graph]
common_prefix = _get_common_prefix(all_names)

# Strip prefix from names for cleaner display
prefix_with_dot = common_prefix + "." if common_prefix else ""
for pkg in view_graph:
if pkg.name.startswith(prefix_with_dot):
pkg.display_name = pkg.name[len(prefix_with_dot):]
else:
pkg.display_name = pkg.name

pu_package_string = "\n".join(
[pu_package.render_package_pu() for pu_package in view_graph]
Expand All @@ -29,7 +62,13 @@ def _render_pu_graph(view_graph: list[ViewPackage], view_name, config):
[pu_package.render_dependency_pu() for pu_package in view_graph]
)
project_name = config.get("name", "")
title = f"{project_name}-{view_name}"

# Include common prefix in title if present
if common_prefix:
title = f"{project_name}-{view_name}\\n<size:12>{common_prefix}.*</size>"
else:
title = f"{project_name}-{view_name}"

uml_str = f"""
@startuml
skinparam backgroundColor GhostWhite
Expand Down
7 changes: 6 additions & 1 deletion src/views/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from src.core.bt_module import BTModule
from src.core.bt_file import BTFile
from src.utils.path_manager_singleton import PathManagerSingleton


def get_view_package_path_from_bt_package(bt_package: BTModule) -> str:
def get_view_package_path_from_bt_package(bt_package: BTModule | BTFile) -> str:
path_manager = PathManagerSingleton()
if isinstance(bt_package, BTFile):
# For files, use the file path without .py extension
raw_name = path_manager.get_relative_path_from_project_root(bt_package.path, True)
return raw_name.replace(".py", "")
raw_name = path_manager.get_relative_path_from_project_root(bt_package.path, True)
return raw_name
Loading