From 737a1eee497708e008a1236bf7d0839233587ec3 Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Fri, 5 Dec 2025 11:12:10 +0100 Subject: [PATCH 1/3] Improve diff visualization with colored triangles and thicker lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace parenthetical (+N)/(-N) dependency change indicators with colored triangle symbols: green ▲ for increases, red ▼ for decreases. Also increase the thickness of colored diff lines to 2px for better visibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/views/view_entities.py | 6 +++++- src/views/view_manager.py | 11 +++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/views/view_entities.py b/src/views/view_entities.py index b09241c5..f3d6bcbc 100644 --- a/src/views/view_entities.py +++ b/src/views/view_entities.py @@ -196,7 +196,11 @@ def render_pu(self) -> str: f'"{from_name}"-->"{to_name}" {self.state.value} {dependency_count_str}' ) else: - return f'"{self.render_diff["from_package"].name}"-->"{self.render_diff["to_package"].name}" {self.render_diff["color"].value} : {self.render_diff["label"]}' + color = self.render_diff["color"].value + from_name = self.render_diff["from_package"].name + to_name = self.render_diff["to_package"].name + label = self.render_diff["label"] + return f'"{from_name}" -[{color},thickness=2]-> "{to_name}" : {label}' def render_json(self) -> dict: config_manager = ConfigManagerSingleton() diff --git a/src/views/view_manager.py b/src/views/view_manager.py index 9512a48e..907d5a4f 100644 --- a/src/views/view_manager.py +++ b/src/views/view_manager.py @@ -96,7 +96,7 @@ def render_diff_views( "from_package": remote_value.from_package, "to_package": remote_value.to_package, "color": color, - "label": f"0 ({dependency_count})", + "label": f"0 ▼{abs(dependency_count)}", } view_has_changes = True continue @@ -106,10 +106,13 @@ def render_diff_views( # Check if dependency counts are different if remote_value.dependency_count != local_value.dependency_count: diff = local_value.dependency_count - remote_value.dependency_count - sign = "+" if diff > 0 else "" color = EntityState.CREATED if diff > 0 else EntityState.DELETED + if diff > 0: + diff_indicator = f"▲{diff}" + else: + diff_indicator = f"▼{abs(diff)}" dependency_count = ( - f"{local_value.dependency_count} ({sign}{diff})" + f"{local_value.dependency_count} {diff_indicator}" if diff != 0 else f"{local_value.dependency_count}" ) @@ -132,7 +135,7 @@ def render_diff_views( "from_package": dependency.from_package, "to_package": dependency.to_package, "color": color, - "label": f"{dependency_count} (+{dependency_count})", + "label": f"{dependency_count} ▲{dependency_count}", } view_has_changes = True From 6e63aa435a4abe6578f89c8e2d7a37d8e36533cf Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Fri, 5 Dec 2025 11:28:28 +0100 Subject: [PATCH 2/3] Improve diff indicator styling: add newline, smaller bold font, parentheses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move triangle indicator to new line below dependency count - Make indicator text smaller (size 10) and bold - Wrap indicator in parentheses for clarity 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/views/view_manager.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/views/view_manager.py b/src/views/view_manager.py index 907d5a4f..b95bc587 100644 --- a/src/views/view_manager.py +++ b/src/views/view_manager.py @@ -96,7 +96,7 @@ def render_diff_views( "from_package": remote_value.from_package, "to_package": remote_value.to_package, "color": color, - "label": f"0 ▼{abs(dependency_count)}", + "label": f"0\\n(▼{abs(dependency_count)})", } view_has_changes = True continue @@ -108,11 +108,11 @@ def render_diff_views( diff = local_value.dependency_count - remote_value.dependency_count color = EntityState.CREATED if diff > 0 else EntityState.DELETED if diff > 0: - diff_indicator = f"▲{diff}" + diff_indicator = f"(▲{diff})" else: - diff_indicator = f"▼{abs(diff)}" + diff_indicator = f"(▼{abs(diff)})" dependency_count = ( - f"{local_value.dependency_count} {diff_indicator}" + f"{local_value.dependency_count}\\n{diff_indicator}" if diff != 0 else f"{local_value.dependency_count}" ) @@ -135,7 +135,7 @@ def render_diff_views( "from_package": dependency.from_package, "to_package": dependency.to_package, "color": color, - "label": f"{dependency_count} ▲{dependency_count}", + "label": f"{dependency_count}\\n(▲{dependency_count})", } view_has_changes = True From b995f4248d967f1f1a07c46d7ac24f5c5386c521 Mon Sep 17 00:00:00 2001 From: Mircea Filip Lungu Date: Fri, 5 Dec 2025 13:41:44 +0100 Subject: [PATCH 3/3] Add file-level dependency visualization support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable showing individual Python files as nodes in architecture diagrams, not just packages/modules. This helps visualize dependencies at a more granular level. Key changes: - Add "files" config key to explicitly include specific files as nodes - Files render as white rectangles with .py suffix (distinct from packages) - Strip common prefix from all node labels, show in diagram title - Fix relative import resolution (from .module import) - Detect imports inside functions/classes, not just top-level - Add parent-path fallback for dependency matching Example config: "llm-services-files": { "packages": ["core.llm_services.prompts"], "files": [ "core.llm_services.anthropic_service", "core.llm_services.llm_service" ] } 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/core/bt_file.py | 130 +++++++++++++++++++++++++--- src/core/bt_graph.py | 10 +++ src/providers/plantuml/pu_render.py | 41 ++++++++- src/views/utils.py | 7 +- src/views/view_entities.py | 69 +++++++++++---- src/views/view_manager.py | 76 +++++++++------- 6 files changed, 273 insertions(+), 60 deletions(-) diff --git a/src/core/bt_file.py b/src/core/bt_file.py index 2363c909..a89f7a1d 100644 --- a/src/core/bt_file.py +++ b/src/core/bt_file.py @@ -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( @@ -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: @@ -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 diff --git a/src/core/bt_graph.py b/src/core/bt_graph.py index ba97662d..1e647fdd 100644 --- a/src/core/bt_graph.py +++ b/src/core/bt_graph.py @@ -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)) diff --git a/src/providers/plantuml/pu_render.py b/src/providers/plantuml/pu_render.py index 1866ed51..2c66cea8 100644 --- a/src/providers/plantuml/pu_render.py +++ b/src/providers/plantuml/pu_render.py @@ -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"] @@ -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] @@ -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{common_prefix}.*" + else: + title = f"{project_name}-{view_name}" + uml_str = f""" @startuml skinparam backgroundColor GhostWhite diff --git a/src/views/utils.py b/src/views/utils.py index f1f3b664..4afdc404 100644 --- a/src/views/utils.py +++ b/src/views/utils.py @@ -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 diff --git a/src/views/view_entities.py b/src/views/view_entities.py index f3d6bcbc..030a144b 100644 --- a/src/views/view_entities.py +++ b/src/views/view_entities.py @@ -1,4 +1,5 @@ -from src.core.bt_module import BTModule, BTFile +from src.core.bt_module import BTModule +from src.core.bt_file import BTFile from src.views.utils import get_view_package_path_from_bt_package from enum import Enum @@ -16,20 +17,27 @@ class EntityState(str, Enum): class ViewPackage: name = "" + display_name = "" # Name with common prefix stripped (set during rendering) parent: "ViewPackage" = None sub_modules: list["ViewPackage"] = None state: EntityState = EntityState.NEUTRAL view_dependency_list: list["ViewDependancy"] = None - bt_package: BTModule = None + bt_package: BTModule | BTFile = None - def __init__(self, bt_package: BTModule) -> None: + def __init__(self, bt_package: BTModule | BTFile) -> None: self.view_dependency_list = [] self.name = PACKAGE_NAME_SPLITTER.join( get_view_package_path_from_bt_package(bt_package).split("/") ) + self.display_name = self.name # Default to full name self.bt_package = bt_package self.sub_modules = [] + @property + def is_file(self) -> bool: + """Check if this ViewPackage wraps a file (not a module)""" + return isinstance(self.bt_package, BTFile) + @property def path(self): return get_view_package_path_from_bt_package(self.bt_package) @@ -51,8 +59,23 @@ def setup_dependencies(self, view_package_map: dict[str, "ViewPackage"]): view_path = get_view_package_path_from_bt_package(bt_package_dependency) if view_path == self.path: continue - try: - view_package_dependency = view_package_map[view_path] + + # Try to find the dependency in the view map + # If not found, try parent paths (e.g., prompts/__init__ -> prompts) + view_package_dependency = None + search_path = view_path + while search_path and "/" in search_path: + if search_path in view_package_map: + view_package_dependency = view_package_map[search_path] + break + # Try parent path (strip last component) + search_path = "/".join(search_path.split("/")[:-1]) + + # Also try exact match for leaf (handles case where search_path has no more /) + if not view_package_dependency and search_path in view_package_map: + view_package_dependency = view_package_map[search_path] + + if view_package_dependency and view_package_dependency.path != self.path: self.view_dependency_list.append( ViewDependancy( self, @@ -61,8 +84,6 @@ def setup_dependencies(self, view_package_map: dict[str, "ViewPackage"]): bt_package_dependency, ) ) - except Exception: - pass def get_parent_list(self): res = [] @@ -78,7 +99,13 @@ def render_package_pu(self) -> str: if self.state == EntityState.NEUTRAL: state_str = config_manager.package_color - return f'package "{self.name}" {state_str}' + # Use display_name (with common prefix stripped) for cleaner diagrams + label = self.display_name or self.name + + if self.is_file: + # Use rectangle with white background for files, add .py suffix + return f'rectangle "{label}.py" #White' + return f'package "{label}" {state_str}' def render_dependency_pu(self) -> str: return "\n".join( @@ -152,8 +179,8 @@ class ViewDependancy: from_package: ViewPackage = None to_package: ViewPackage = None - from_bt_package: BTModule = None - to_bt_package: BTModule = None + from_bt_package: BTModule | BTFile = None + to_bt_package: BTModule | BTFile = None edge_files: list[tuple[BTFile, BTFile]] = [] @@ -165,8 +192,8 @@ def __init__( self, from_package: ViewPackage, to_package: ViewPackage, - from_bt_package: BTModule, - to_bt_package: BTModule, + from_bt_package: BTModule | BTFile, + to_bt_package: BTModule | BTFile, ) -> None: self.from_package = from_package self.to_package = to_package @@ -183,6 +210,13 @@ def __init__( def id(self): return f"{self.from_package.name}-->{self.to_package.name}" + def _get_display_label(self, package: "ViewPackage") -> str: + """Get display label for a package, adding .py suffix for files.""" + label = package.display_name or package.name + if package.is_file: + return f"{label}.py" + return label + def render_pu(self) -> str: config_manager = ConfigManagerSingleton() @@ -190,15 +224,18 @@ def render_pu(self) -> str: dependency_count_str = "" if config_manager.show_dependency_count: dependency_count_str = f": {self.dependency_count}" - from_name = self.from_package.name - to_name = self.to_package.name + # Use display_name for cleaner diagrams (with common prefix stripped) + from_name = self._get_display_label(self.from_package) + to_name = self._get_display_label(self.to_package) return ( f'"{from_name}"-->"{to_name}" {self.state.value} {dependency_count_str}' ) else: color = self.render_diff["color"].value - from_name = self.render_diff["from_package"].name - to_name = self.render_diff["to_package"].name + from_pkg = self.render_diff["from_package"] + to_pkg = self.render_diff["to_package"] + from_name = self._get_display_label(from_pkg) + to_name = self._get_display_label(to_pkg) label = self.render_diff["label"] return f'"{from_name}" -[{color},thickness=2]-> "{to_name}" : {label}' diff --git a/src/views/view_manager.py b/src/views/view_manager.py index b95bc587..4751228f 100644 --- a/src/views/view_manager.py +++ b/src/views/view_manager.py @@ -186,24 +186,48 @@ def _handle_duplicate_name(view_graph: list[ViewPackage]): package.name = package_name_split[-1] +def _get_requested_files(view: dict) -> list[str]: + """ + Extract file patterns from view config's "files" key. + Returns list of file path patterns (with dots replaced by /). + """ + file_patterns = [] + for file_def in view.get("files", []): + if isinstance(file_def, str): + # Convert "core.llm_services.anthropic_service" to "core/llm_services/anthropic_service" + file_patterns.append(file_def.replace(".", "/")) + return file_patterns + + def _create_view_graphs( graph: BTGraph, config: dict ) -> dict[str, dict[str, ViewPackage]]: - # all the nodes at all kinds of lelves + # all the nodes at all kinds of levels bt_packages = graph.get_all_bt_modules_map() + bt_files = graph.get_all_bt_files_as_nodes_map() views = {} for view_name, view in config["views"].items(): viewpackages_by_name: dict[str, ViewPackage] = {} + # Create view nodes for modules (always) for bt_package in bt_packages.values(): - - # create a view package node for each of the nodes in the AST view_package = ViewPackage(bt_package) viewpackages_by_name[view_package.path] = view_package + # Create view nodes for files ONLY if explicitly requested in "files" key + requested_files = _get_requested_files(view) + if requested_files: + for file_path, bt_file in bt_files.items(): + # Check if this file matches any requested pattern + for pattern in requested_files: + if file_path.endswith(pattern): + view_file = ViewPackage(bt_file) + viewpackages_by_name[view_file.path] = view_file + break + for view_package in viewpackages_by_name.values(): view_package.setup_dependencies(viewpackages_by_name) @@ -231,58 +255,48 @@ def _filter_packages( all_viewpackages = list(packages_map.values()) filtered_packages_set: set[ViewPackage] = set() - # packages - for package_definition_from_config in view["packages"]: + # packages (modules only) + for package_definition_from_config in view.get("packages", []): for view_package in all_viewpackages: - filter_path = package_definition_from_config - - # e.g. - # "packages": [ - # "api", - # ], + # e.g. "packages": ["api", "core.llm_services"] if isinstance(package_definition_from_config, str): - if view_package.path.startswith(filter_path.replace(".", "/")): + filter_path = package_definition_from_config.replace(".", "/") + if view_package.path.startswith(filter_path): filtered_packages_set.add(view_package) - # e.g. - # "packages": [ - # { - # "path": "api", - # "depth": 1 - # } - # ], + # e.g. "packages": [{"path": "api", "depth": 1}] if isinstance(package_definition_from_config, dict): filter_path = package_definition_from_config["path"].replace(".", "/") - - # TODO: why are we ignoring the star - it's a regex? filter_path = filter_path.replace("*", "") view_depth = package_definition_from_config["depth"] if filter_path == "" and view_package.is_root_package(): - # This happens when config specifies {"path": "", "depth": N} - # to include all root packages up to depth N filtered_packages_set.add(view_package) - depth_filter_packages = _find_packages_with_depth( view_package, view_depth - 1, packages_map ) filtered_packages_set.update(depth_filter_packages) elif view_package.path == filter_path: - if view_depth == 0: - # if view depth is greater, that means we want to expand this path - # and this path should not be part of the view filtered_packages_set.add(view_package) - depth_filter_packages = _find_packages_with_depth( view_package, view_depth, packages_map ) filtered_packages_set.update(depth_filter_packages) - if len(view["packages"]) == 0: - # If no packages specified, only include root packages (those without a parent) + # files (explicit file nodes) + for file_definition in view.get("files", []): + for view_package in all_viewpackages: + if isinstance(file_definition, str): + file_path = file_definition.replace(".", "/") + # Use endswith for flexibility - allows specifying just "app" or full "api.app" + if view_package.path == file_path or view_package.path.endswith("/" + file_path): + filtered_packages_set.add(view_package) + + if len(view.get("packages", [])) == 0 and len(view.get("files", [])) == 0: + # If no packages or files specified, only include root packages (those without a parent) filtered_packages_set = set( - package for package in packages_map.values() + package for package in packages_map.values() if package.is_root_package() )