From 92fa6c4e3a87918e6fb38b3e96913e13e5fc179f Mon Sep 17 00:00:00 2001 From: ForteDexe <57673412+ForteDexe@users.noreply.github.com> Date: Sun, 25 Jan 2026 19:40:48 +0000 Subject: [PATCH 1/4] Add zoom-scaled sizing for highlighted icons and connection wires. Directional link toggles (in/out per type). And configurable file path display with subfolder levels for highlighted nodes in the codegraph HTML visualization. On branch dev Your branch is up to date with 'origin/dev'. Changes to be committed: modified: .gitignore modified: codegraph/templates/index.html modified: codegraph/templates/main.js modified: test_output.html --- .gitignore | 4 +- codegraph/templates/index.html | 10 +- codegraph/templates/main.js | 157 +++++++++++++++++-- test_output.html | 277 ++++++++++++++++++++++++--------- 4 files changed, 362 insertions(+), 86 deletions(-) diff --git a/.gitignore b/.gitignore index f36439a..637d7d2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ dist/ codegraph.egg-info/ codegraph/__pycache__/ -*/__pycache__/ \ No newline at end of file +*/__pycache__/ +.vscode +.conda \ No newline at end of file diff --git a/codegraph/templates/index.html b/codegraph/templates/index.html index 16783d7..2eb01da 100644 --- a/codegraph/templates/index.html +++ b/codegraph/templates/index.html @@ -152,8 +152,16 @@
Show links
-
Font Settings
+
Highlight Settings
+ + +
+ + + + +
diff --git a/codegraph/templates/main.js b/codegraph/templates/main.js index 87d7d6c..1b6cd08 100644 --- a/codegraph/templates/main.js +++ b/codegraph/templates/main.js @@ -225,6 +225,96 @@ function updateFontSizes(scale) { }); } +function updateIconWireSizes(scale) { + // Update node sizes with scaling + node.each(function(d) { + const el = d3.select(this); + const isHighlighted = el.classed('highlighted-main') || el.classed('highlighted'); + const multiplier = isHighlighted ? iconWireScaleFactor / scale : 1; + if (d.type === "module") { + const baseSize = getNodeSize(d, 30); + const size = baseSize * multiplier; + const clampedSize = Math.max(10, Math.min(200, size)); + el.select("rect") + .attr("width", clampedSize) + .attr("height", clampedSize) + .attr("x", -clampedSize / 2) + .attr("y", -clampedSize / 2); + } else if (d.type === "entity" || d.type === "external") { + const baseR = getNodeSize(d, 10); + const r = baseR * multiplier; + const clampedR = Math.max(5, Math.min(100, r)); + el.select("circle").attr("r", clampedR); + } + }); + // Update link stroke widths + link.each(function(d) { + const el = d3.select(this); + const isHighlighted = el.classed('highlighted'); + const multiplier = isHighlighted ? iconWireScaleFactor / scale : 1; + const baseWidth = 2; + const width = baseWidth * multiplier; + const clampedWidth = Math.max(1, Math.min(10, width)); + el.style("stroke-width", clampedWidth + "px"); + }); + // Update labels position + labels.attr("dy", d => { + const el = node.filter(n => n.id === d.id); + if (d.type === "module") { + const size = +el.select("rect").attr("width"); + return size / 2 + 15; + } else { + const r = +el.select("circle").attr("r"); + return r + 10; + } + }); +} + +function isLinkHighlighted(d, highlightedNodeId) { + const sourceId = typeof d.source === 'object' ? d.source.id : d.source; + const targetId = typeof d.target === 'object' ? d.target.id : d.target; + const isOut = sourceId === highlightedNodeId; + const isIn = targetId === highlightedNodeId; + if (d.type === 'module-module') { + if (isOut && highlightLinkFilters.mmOut) return true; + if (isIn && highlightLinkFilters.mmIn) return true; + } else if (d.type === 'module-entity') { + if (isOut && highlightLinkFilters.meOut) return true; + if (isIn && highlightLinkFilters.meIn) return true; + } + return false; +} + +function formatPathForDisplay(fullPath, levels) { + if (!fullPath) return ''; + + const parts = fullPath.split(/[\/\\]/); + if (levels <= 0) { + // Return just filename + return parts[parts.length - 1] || ''; + } + + const startIndex = Math.max(0, parts.length - levels - 1); + return parts.slice(startIndex).join('/'); +} + +function updatePathDisplay() { + labels.text(function(d) { + const isHighlighted = d3.select(this).classed('highlighted-label'); + const originalLabel = d.label || d.id; + + if (!isHighlighted) { + return originalLabel; + } + + const fullPath = d.fullPath || d.parent; + if (!fullPath) return originalLabel; + + const displayPath = formatPathForDisplay(fullPath, pathSubfolderLevels); + return displayPath || originalLabel; + }); +} + const zoom = d3.zoom() .scaleExtent([0.05, 4]) .on("zoom", (event) => { @@ -232,6 +322,7 @@ const zoom = d3.zoom() currentScale = event.transform.k; // Adjust font sizes based on zoom scale updateFontSizes(currentScale); + updateIconWireSizes(currentScale); }); svg.call(zoom); @@ -391,6 +482,7 @@ function updateNodeSizes() { document.getElementById('size-by-code').addEventListener('change', function() { sizeByCode = this.checked; updateNodeSizes(); + updateIconWireSizes(currentScale); }); // Display filter state @@ -438,6 +530,31 @@ document.getElementById('max-highlight-font').addEventListener('input', function // Update font sizes immediately updateFontSizes(currentScale); }); +document.getElementById('icon-wire-scale').addEventListener('input', function() { + iconWireScaleFactor = parseFloat(this.value) || 1.0; + // Update icon and wire sizes immediately + updateIconWireSizes(currentScale); +}); +document.getElementById('highlight-link-mm-out').addEventListener('change', function() { + highlightLinkFilters.mmOut = this.checked; + if (currentHighlightedNode) highlightNode(currentHighlightedNode); +}); +document.getElementById('highlight-link-mm-in').addEventListener('change', function() { + highlightLinkFilters.mmIn = this.checked; + if (currentHighlightedNode) highlightNode(currentHighlightedNode); +}); +document.getElementById('highlight-link-me-out').addEventListener('change', function() { + highlightLinkFilters.meOut = this.checked; + if (currentHighlightedNode) highlightNode(currentHighlightedNode); +}); +document.getElementById('highlight-link-me-in').addEventListener('change', function() { + highlightLinkFilters.meIn = this.checked; + if (currentHighlightedNode) highlightNode(currentHighlightedNode); +}); +document.getElementById('path-subfolder-levels').addEventListener('input', function() { + pathSubfolderLevels = parseInt(this.value) || 0; + updatePathDisplay(); +}); // Check if node should be hidden by display filter function isNodeFilteredOut(nodeData) { @@ -718,8 +835,17 @@ const autocompleteList = document.getElementById('autocompleteList'); let selectedAutocompleteIndex = -1; let currentHighlightedNode = null; let maxHighlightFontSize = 32; +let iconWireScaleFactor = 1.0; let currentScale = 1; let filteredNodes = []; +let highlightLinkFilters = { + mmOut: true, + mmIn: true, + meOut: true, + meIn: true +}; +let showHighlightedPath = true; +let pathSubfolderLevels = 0; // Build searchable index const searchIndex = graphData.nodes.map(n => ({ @@ -798,32 +924,33 @@ function zoomToFitNodes(nodeIds) { // Highlight a node and its connections function highlightNode(nodeId) { - const connectedNodes = getConnectedNodes(nodeId); + const connectedLinks = graphData.links.filter(d => isLinkHighlighted(d, nodeId)); + const connectedNodes = new Set(); + connectedLinks.forEach(l => { + const sourceId = typeof l.source === 'object' ? l.source.id : l.source; + const targetId = typeof l.target === 'object' ? l.target.id : l.target; + if (sourceId !== nodeId) connectedNodes.add(sourceId); + if (targetId !== nodeId) connectedNodes.add(targetId); + }); currentHighlightedNode = nodeId; // Update nodes - node.classed('dimmed', d => !connectedNodes.has(d.id)) + node.classed('dimmed', d => d.id !== nodeId && !connectedNodes.has(d.id)) .classed('highlighted', d => connectedNodes.has(d.id) && d.id !== nodeId) .classed('highlighted-main', d => d.id === nodeId); // Update links - link.classed('dimmed', d => { - const sourceId = typeof d.source === 'object' ? d.source.id : d.source; - const targetId = typeof d.target === 'object' ? d.target.id : d.target; - return sourceId !== nodeId && targetId !== nodeId; - }) - .classed('highlighted', d => { - const sourceId = typeof d.source === 'object' ? d.source.id : d.source; - const targetId = typeof d.target === 'object' ? d.target.id : d.target; - return sourceId === nodeId || targetId === nodeId; - }); + link.classed('dimmed', d => !isLinkHighlighted(d, nodeId)) + .classed('highlighted', d => isLinkHighlighted(d, nodeId)); // Update labels - labels.classed('dimmed', d => !connectedNodes.has(d.id)) - .classed('highlighted-label', d => connectedNodes.has(d.id)); + labels.classed('dimmed', d => d.id !== nodeId && !connectedNodes.has(d.id)) + .classed('highlighted-label', d => d.id === nodeId || connectedNodes.has(d.id)); // Zoom to fit all connected nodes zoomToFitNodes(connectedNodes); + updateIconWireSizes(currentScale); + updatePathDisplay(); } // Clear all highlighting @@ -843,6 +970,8 @@ function clearHighlight() { searchInput.value = ''; searchClear.classList.remove('visible'); hideAutocomplete(); + updateIconWireSizes(currentScale); + updatePathDisplay(); } // Filter nodes based on search query diff --git a/test_output.html b/test_output.html index 92320dd..1f30fe0 100644 --- a/test_output.html +++ b/test_output.html @@ -729,8 +729,16 @@
Show links
-
Font Settings
+
Highlight Settings
+ + +
+ + + + +
@@ -1130,19 +1138,19 @@
Font Settings
"lines": 66 }, { - "id": "test_package_install.py:test_d3_visualization_without_matplotlib", - "label": "test_d3_visualization_without_matplotlib", + "id": "test_package_install.py:test_codegraph_on_itself_without_matplotlib", + "label": "test_codegraph_on_itself_without_matplotlib", "type": "entity", "parent": "test_package_install.py", - "lines": 33, + "lines": 12, "entityType": "function" }, { - "id": "test_package_install.py:test_codegraph_on_itself_without_matplotlib", - "label": "test_codegraph_on_itself_without_matplotlib", + "id": "test_package_install.py:test_d3_visualization_without_matplotlib", + "label": "test_d3_visualization_without_matplotlib", "type": "entity", "parent": "test_package_install.py", - "lines": 12, + "lines": 33, "entityType": "function" }, { @@ -1501,12 +1509,12 @@
Font Settings
}, { "source": "main.py:main", - "target": "core.py:CodeGraph", + "target": "vizualyzer.py:draw_graph", "type": "dependency" }, { "source": "main.py:main", - "target": "vizualyzer.py:draw_graph", + "target": "core.py:CodeGraph", "type": "dependency" }, { @@ -1856,22 +1864,22 @@
Font Settings
}, { "source": "test_package_install.py", - "target": "test_package_install.py:test_d3_visualization_without_matplotlib", + "target": "test_package_install.py:test_codegraph_on_itself_without_matplotlib", "type": "module-entity" }, { - "source": "test_package_install.py:test_d3_visualization_without_matplotlib", - "target": "vizualyzer.py:draw_graph", + "source": "test_package_install.py:test_codegraph_on_itself_without_matplotlib", + "target": "core.py:CodeGraph", "type": "dependency" }, { "source": "test_package_install.py", - "target": "test_package_install.py:test_codegraph_on_itself_without_matplotlib", + "target": "test_package_install.py:test_d3_visualization_without_matplotlib", "type": "module-entity" }, { - "source": "test_package_install.py:test_codegraph_on_itself_without_matplotlib", - "target": "core.py:CodeGraph", + "source": "test_package_install.py:test_d3_visualization_without_matplotlib", + "target": "vizualyzer.py:draw_graph", "type": "dependency" }, { @@ -1951,17 +1959,17 @@
Font Settings
}, { "source": "comma_imports.py:use_all", - "target": "module_a.py:func_a", + "target": "module_b.py:func_b", "type": "dependency" }, { "source": "comma_imports.py:use_all", - "target": "module_c.py:func_c1", + "target": "module_a.py:func_a", "type": "dependency" }, { "source": "comma_imports.py:use_all", - "target": "module_b.py:func_b", + "target": "module_c.py:func_c1", "type": "dependency" }, { @@ -1976,12 +1984,12 @@
Font Settings
}, { "source": "module_a.py:func_a", - "target": "module_c.py:func_c1", + "target": "module_b.py:func_b", "type": "dependency" }, { "source": "module_a.py:func_a", - "target": "module_b.py:func_b", + "target": "module_c.py:func_c1", "type": "dependency" }, { @@ -2040,38 +2048,38 @@
Font Settings
"type": "module-entity" }, { - "source": "alias_imports.py", - "target": "module_b.py", + "source": "core.py", + "target": "utils.py", "type": "module-module" }, { - "source": "comma_imports.py", - "target": "module_c.py", + "source": "test_codegraph.py", + "target": "core.py", "type": "module-module" }, { - "source": "core.py", - "target": "parser.py", + "source": "main.py", + "target": "__init__.py", "type": "module-module" }, { - "source": "test_codegraph.py", - "target": "core.py", + "source": "test_utils.py", + "target": "utils.py", "type": "module-module" }, { - "source": "module_a.py", - "target": "module_b.py", + "source": "test_package_install.py", + "target": "main.py", "type": "module-module" }, { - "source": "core.py", - "target": "utils.py", + "source": "module_a.py", + "target": "module_b.py", "type": "module-module" }, { - "source": "main.py", - "target": "core.py", + "source": "test_graph_generation.py", + "target": "parser.py", "type": "module-module" }, { @@ -2079,19 +2087,24 @@
Font Settings
"target": "__init__.py", "type": "module-module" }, + { + "source": "test_graph_generation.py", + "target": "core.py", + "type": "module-module" + }, { "source": "module_b.py", "target": "module_c.py", "type": "module-module" }, { - "source": "test_graph_generation.py", + "source": "main.py", "target": "core.py", "type": "module-module" }, { - "source": "alias_imports.py", - "target": "module_a.py", + "source": "core.py", + "target": "parser.py", "type": "module-module" }, { @@ -2100,43 +2113,38 @@
Font Settings
"type": "module-module" }, { - "source": "test_graph_generation.py", - "target": "vizualyzer.py", - "type": "module-module" - }, - { - "source": "module_a.py", + "source": "comma_imports.py", "target": "module_c.py", "type": "module-module" }, { - "source": "test_utils.py", - "target": "utils.py", + "source": "test_package_install.py", + "target": "core.py", "type": "module-module" }, { - "source": "comma_imports.py", - "target": "module_b.py", + "source": "test_graph_generation.py", + "target": "vizualyzer.py", "type": "module-module" }, { - "source": "main.py", - "target": "__init__.py", + "source": "alias_imports.py", + "target": "module_b.py", "type": "module-module" }, { - "source": "test_package_install.py", - "target": "core.py", + "source": "alias_imports.py", + "target": "module_a.py", "type": "module-module" }, { "source": "test_package_install.py", - "target": "main.py", + "target": "vizualyzer.py", "type": "module-module" }, { - "source": "test_graph_generation.py", - "target": "parser.py", + "source": "comma_imports.py", + "target": "module_b.py", "type": "module-module" }, { @@ -2145,8 +2153,8 @@
Font Settings
"type": "module-module" }, { - "source": "test_package_install.py", - "target": "vizualyzer.py", + "source": "module_a.py", + "target": "module_c.py", "type": "module-module" } ], @@ -2380,6 +2388,96 @@
Font Settings
}); } +function updateIconWireSizes(scale) { + // Update node sizes with scaling + node.each(function(d) { + const el = d3.select(this); + const isHighlighted = el.classed('highlighted-main') || el.classed('highlighted'); + const multiplier = isHighlighted ? iconWireScaleFactor / scale : 1; + if (d.type === "module") { + const baseSize = getNodeSize(d, 30); + const size = baseSize * multiplier; + const clampedSize = Math.max(10, Math.min(200, size)); + el.select("rect") + .attr("width", clampedSize) + .attr("height", clampedSize) + .attr("x", -clampedSize / 2) + .attr("y", -clampedSize / 2); + } else if (d.type === "entity" || d.type === "external") { + const baseR = getNodeSize(d, 10); + const r = baseR * multiplier; + const clampedR = Math.max(5, Math.min(100, r)); + el.select("circle").attr("r", clampedR); + } + }); + // Update link stroke widths + link.each(function(d) { + const el = d3.select(this); + const isHighlighted = el.classed('highlighted'); + const multiplier = isHighlighted ? iconWireScaleFactor / scale : 1; + const baseWidth = 2; + const width = baseWidth * multiplier; + const clampedWidth = Math.max(1, Math.min(10, width)); + el.style("stroke-width", clampedWidth + "px"); + }); + // Update labels position + labels.attr("dy", d => { + const el = node.filter(n => n.id === d.id); + if (d.type === "module") { + const size = +el.select("rect").attr("width"); + return size / 2 + 15; + } else { + const r = +el.select("circle").attr("r"); + return r + 10; + } + }); +} + +function isLinkHighlighted(d, highlightedNodeId) { + const sourceId = typeof d.source === 'object' ? d.source.id : d.source; + const targetId = typeof d.target === 'object' ? d.target.id : d.target; + const isOut = sourceId === highlightedNodeId; + const isIn = targetId === highlightedNodeId; + if (d.type === 'module-module') { + if (isOut && highlightLinkFilters.mmOut) return true; + if (isIn && highlightLinkFilters.mmIn) return true; + } else if (d.type === 'module-entity') { + if (isOut && highlightLinkFilters.meOut) return true; + if (isIn && highlightLinkFilters.meIn) return true; + } + return false; +} + +function formatPathForDisplay(fullPath, levels) { + if (!fullPath) return ''; + + const parts = fullPath.split(/[\/\\]/); + if (levels <= 0) { + // Return just filename + return parts[parts.length - 1] || ''; + } + + const startIndex = Math.max(0, parts.length - levels - 1); + return parts.slice(startIndex).join('/'); +} + +function updatePathDisplay() { + labels.text(function(d) { + const isHighlighted = d3.select(this).classed('highlighted-label'); + const originalLabel = d.label || d.id; + + if (!isHighlighted) { + return originalLabel; + } + + const fullPath = d.fullPath || d.parent; + if (!fullPath) return originalLabel; + + const displayPath = formatPathForDisplay(fullPath, pathSubfolderLevels); + return displayPath || originalLabel; + }); +} + const zoom = d3.zoom() .scaleExtent([0.05, 4]) .on("zoom", (event) => { @@ -2387,6 +2485,7 @@
Font Settings
currentScale = event.transform.k; // Adjust font sizes based on zoom scale updateFontSizes(currentScale); + updateIconWireSizes(currentScale); }); svg.call(zoom); @@ -2546,6 +2645,7 @@
Font Settings
document.getElementById('size-by-code').addEventListener('change', function() { sizeByCode = this.checked; updateNodeSizes(); + updateIconWireSizes(currentScale); }); // Display filter state @@ -2593,6 +2693,31 @@
Font Settings
// Update font sizes immediately updateFontSizes(currentScale); }); +document.getElementById('icon-wire-scale').addEventListener('input', function() { + iconWireScaleFactor = parseFloat(this.value) || 1.0; + // Update icon and wire sizes immediately + updateIconWireSizes(currentScale); +}); +document.getElementById('highlight-link-mm-out').addEventListener('change', function() { + highlightLinkFilters.mmOut = this.checked; + if (currentHighlightedNode) highlightNode(currentHighlightedNode); +}); +document.getElementById('highlight-link-mm-in').addEventListener('change', function() { + highlightLinkFilters.mmIn = this.checked; + if (currentHighlightedNode) highlightNode(currentHighlightedNode); +}); +document.getElementById('highlight-link-me-out').addEventListener('change', function() { + highlightLinkFilters.meOut = this.checked; + if (currentHighlightedNode) highlightNode(currentHighlightedNode); +}); +document.getElementById('highlight-link-me-in').addEventListener('change', function() { + highlightLinkFilters.meIn = this.checked; + if (currentHighlightedNode) highlightNode(currentHighlightedNode); +}); +document.getElementById('path-subfolder-levels').addEventListener('input', function() { + pathSubfolderLevels = parseInt(this.value) || 0; + updatePathDisplay(); +}); // Check if node should be hidden by display filter function isNodeFilteredOut(nodeData) { @@ -2873,8 +2998,17 @@
Font Settings
let selectedAutocompleteIndex = -1; let currentHighlightedNode = null; let maxHighlightFontSize = 32; +let iconWireScaleFactor = 1.0; let currentScale = 1; let filteredNodes = []; +let highlightLinkFilters = { + mmOut: true, + mmIn: true, + meOut: true, + meIn: true +}; +let showHighlightedPath = true; +let pathSubfolderLevels = 0; // Build searchable index const searchIndex = graphData.nodes.map(n => ({ @@ -2953,32 +3087,33 @@
Font Settings
// Highlight a node and its connections function highlightNode(nodeId) { - const connectedNodes = getConnectedNodes(nodeId); + const connectedLinks = graphData.links.filter(d => isLinkHighlighted(d, nodeId)); + const connectedNodes = new Set(); + connectedLinks.forEach(l => { + const sourceId = typeof l.source === 'object' ? l.source.id : l.source; + const targetId = typeof l.target === 'object' ? l.target.id : l.target; + if (sourceId !== nodeId) connectedNodes.add(sourceId); + if (targetId !== nodeId) connectedNodes.add(targetId); + }); currentHighlightedNode = nodeId; // Update nodes - node.classed('dimmed', d => !connectedNodes.has(d.id)) + node.classed('dimmed', d => d.id !== nodeId && !connectedNodes.has(d.id)) .classed('highlighted', d => connectedNodes.has(d.id) && d.id !== nodeId) .classed('highlighted-main', d => d.id === nodeId); // Update links - link.classed('dimmed', d => { - const sourceId = typeof d.source === 'object' ? d.source.id : d.source; - const targetId = typeof d.target === 'object' ? d.target.id : d.target; - return sourceId !== nodeId && targetId !== nodeId; - }) - .classed('highlighted', d => { - const sourceId = typeof d.source === 'object' ? d.source.id : d.source; - const targetId = typeof d.target === 'object' ? d.target.id : d.target; - return sourceId === nodeId || targetId === nodeId; - }); + link.classed('dimmed', d => !isLinkHighlighted(d, nodeId)) + .classed('highlighted', d => isLinkHighlighted(d, nodeId)); // Update labels - labels.classed('dimmed', d => !connectedNodes.has(d.id)) - .classed('highlighted-label', d => connectedNodes.has(d.id)); + labels.classed('dimmed', d => d.id !== nodeId && !connectedNodes.has(d.id)) + .classed('highlighted-label', d => d.id === nodeId || connectedNodes.has(d.id)); // Zoom to fit all connected nodes zoomToFitNodes(connectedNodes); + updateIconWireSizes(currentScale); + updatePathDisplay(); } // Clear all highlighting @@ -2998,6 +3133,8 @@
Font Settings
searchInput.value = ''; searchClear.classList.remove('visible'); hideAutocomplete(); + updateIconWireSizes(currentScale); + updatePathDisplay(); } // Filter nodes based on search query From fc0c5286b9eebc66d78f118c64f45bc8eae6a645 Mon Sep 17 00:00:00 2001 From: ForteDexe <57673412+ForteDexe@users.noreply.github.com> Date: Sun, 25 Jan 2026 19:49:53 +0000 Subject: [PATCH 2/4] Update version On branch dev Your branch is up to date with 'origin/dev'. Changes to be committed: modified: codegraph/__init__.py modified: pyproject.toml --- codegraph/__init__.py | 2 +- pyproject.toml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codegraph/__init__.py b/codegraph/__init__.py index c68196d..67bc602 100644 --- a/codegraph/__init__.py +++ b/codegraph/__init__.py @@ -1 +1 @@ -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/pyproject.toml b/pyproject.toml index e6bf148..e7b1c3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [tool.poetry] name = "codegraph" -version = "1.2.0" +version = "1.3.0" license = "MIT" readme = "docs/README.rst" -homepage = "https://github.com/xnuinside/codegraph" -repository = "https://github.com/xnuinside/codegraph" +homepage = "https://github.com/ForteDexe/codegraph" +repository = "https://github.com/ForteDexe/codegraph" description = "Tool that create a graph of code to show dependencies between code entities (methods, classes and etc)." -authors = ["xnuinside "] +authors = ["forted "] classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", From d3afc52085eac9e5f12a51b0acc599cd5d93804c Mon Sep 17 00:00:00 2001 From: ForteDexe <57673412+ForteDexe@users.noreply.github.com> Date: Wed, 28 Jan 2026 07:40:36 +0000 Subject: [PATCH 3/4] Lot of reworks and improvements, check CHANGELOG.md Changes to be committed: modified: CHANGELOG.md modified: codegraph/__init__.py modified: codegraph/core.py modified: codegraph/main.py modified: codegraph/parser.py modified: codegraph/templates/index.html modified: codegraph/templates/main.js modified: codegraph/templates/styles.css modified: codegraph/utils.py modified: codegraph/vizualyzer.py modified: pyproject.toml modified: test_output.html modified: tests/test_graph_generation.py new file: tests/test_data/duplicate_names/src/main.py new file: tests/test_data/duplicate_names/src/utils.py new file: tests/test_data/duplicate_names/tests/__pycache__/test_main.cpython-311-pytest-8.3.3.pyc new file: tests/test_data/duplicate_names/tests/sample_test.py new file: tests/test_data/duplicate_names/tests/utils.py --- CHANGELOG.md | 87 + HIDDENLOG.md | 44 + codegraph/__init__.py | 2 +- codegraph/core.py | 287 +- codegraph/main.py | 25 +- codegraph/parser.py | 10 +- codegraph/templates/index.html | 24 +- codegraph/templates/main.js | 354 ++- codegraph/templates/styles.css | 54 +- codegraph/utils.py | 43 +- codegraph/vizualyzer.py | 215 +- pyproject.toml | 2 +- test_output.html | 2634 ++++++++++++----- tests/test_data/duplicate_names/src/main.py | 7 + tests/test_data/duplicate_names/src/utils.py | 8 + .../test_main.cpython-311-pytest-8.3.3.pyc | Bin 0 -> 714 bytes .../duplicate_names/tests/sample_test.py | 7 + .../test_data/duplicate_names/tests/utils.py | 8 + tests/test_graph_generation.py | 134 +- 19 files changed, 3141 insertions(+), 804 deletions(-) create mode 100644 HIDDENLOG.md create mode 100644 tests/test_data/duplicate_names/src/main.py create mode 100644 tests/test_data/duplicate_names/src/utils.py create mode 100644 tests/test_data/duplicate_names/tests/__pycache__/test_main.cpython-311-pytest-8.3.3.pyc create mode 100644 tests/test_data/duplicate_names/tests/sample_test.py create mode 100644 tests/test_data/duplicate_names/tests/utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e6bcd74..1819133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,93 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.0] - 2026-01-28 + +### Added + +**Focus Mode** +- New "Focus Mode" checkbox in Highlight Settings panel +- When enabled, hides all background (non-highlighted) nodes and links +- Shows entire dependency chain regardless of display filters +- Improves visibility when analyzing specific code paths + +**Navigation History** +- Added back/forward navigation buttons with browser-style controls +- Keyboard shortcuts: Alt+Left (back), Alt+Right (forward) +- Tracks entity clicks for easy navigation through code exploration +- Button states update based on history availability + +**External Import Nodes** +- External imports (e.g., `os`, `sys`, `numpy`) now appear as nodes in the graph +- Distinguished with special styling and external type +- Helps visualize all project dependencies including standard library and third-party packages + +### Changed + +**Arrow Direction and Visualization** +- Reversed arrow directions to better represent code flow +- Module imports: arrows now point FROM importer TO imported module +- Entity dependencies: arrows point FROM entity TO its dependencies +- Improved arrow scaling: scales consistently with zoom level and icon size +- Unified arrow head sizes and stroke widths across all link types +- Dynamic arrow scaling in highlight mode with configurable maximum (18.75x scale factor) + +**Keyword Filtering Enhancements** +- Keyword search now filters at entity level in addition to module level +- Shows full dependency chains when entities match keywords +- Handles import aliases correctly (e.g., `import pandas as pd`) +- Improves search accuracy for finding specific functions/classes + +**UI Defaults and Behavior** +- Default font size increased to 256 for better readability +- Display panel now expanded by default +- Classes and Functions checkboxes unchecked by default (show only modules) +- Focus mode properly dims labels and icons of non-highlighted nodes +- Improved link opacity handling in highlight and focus modes + +### Fixed + +**Display Filter Issues** +- Fixed Classes/Functions nodes appearing in highlight mode when filters disabled +- Corrected entity visibility logic to respect display panel settings +- Entity links now properly hidden when corresponding filters are off + +**Import Handling** +- Fixed import statement extraction from AST +- Corrected handling of multi-line imports and comma-separated imports +- Fixed alias imports (e.g., `import x as y`) appearing in tooltips + +**Highlight Mode** +- Fixed entity-to-entity click highlighting for dependency relationships +- Corrected link dimming in focus mode +- Fixed label and icon dimming to match node highlight state + +**Arrow Scaling Consistency** +- Fixed inconsistent arrow sizes at different zoom levels +- Unified base stroke widths (2px for all link types) +- Arrows now scale proportionally with icons in highlight mode +- Applied proper clamping to prevent excessive arrow sizes + +## [1.4.0] - 2026-01-26 + +### Fixed + +**Duplicate Filename Support** +- Fixed critical issue where files with the same basename in different directories would collide +- Now uses full relative paths as unique identifiers instead of just filenames +- Module node IDs are now path-based (e.g., `src/utils`, `tests/utils`) without `.py` extension +- Entity node IDs use format `path/module:entity_name` (e.g., `src/utils:helper`) +- Module labels still display basenames for readability, with full paths in tooltips +- Search box now shows file paths for modules to distinguish duplicates +- CSV export shows basenames with `.py` extension for backward compatibility +- Added comprehensive test suite for duplicate filename scenarios + +### Changed + +- Internal node identifiers now use relative paths for uniqueness +- Module identification system refactored throughout core, parser, and visualization layers +- Import resolution improved to correctly match dependencies with path-based identifiers + ## [1.2.0] - 2026-01-18 ### Added diff --git a/HIDDENLOG.md b/HIDDENLOG.md new file mode 100644 index 0000000..9cd1d9d --- /dev/null +++ b/HIDDENLOG.md @@ -0,0 +1,44 @@ +1. Core Module (core.py) +Updated parse_code_file() to pass full paths (instead of basename) to the parser +Modified get_module_name() to return relative path-based keys (e.g., src/utils instead of utils) +Changed get_code_objects() and CodeGraph.__init__() to track and pass base_paths +Updated get_imports_and_entities_lines() to build module_names_set and names_map using relative paths +2. Visualization Module (vizualyzer.py) +Modified convert_to_d3_format() to generate unique node IDs from relative paths +Module node IDs are now path-based (e.g., src/utils, tests/utils) without .py extension +Entity node IDs use format path/module:entity_name (e.g., src/utils:helper) +Updated entity_to_module mappings to use path-based identifiers +Fixed dependency resolution loops to correctly attribute dependencies using relative paths +Updated draw_graph(), draw_graph_matplotlib(), and export_to_csv() to accept and use base_paths +CSV export now shows basenames with .py extension in the name column for backward compatibility +3. Parser Module (parser.py) +Updated create_objects_array() to accept full paths in the fname parameter +Added base_paths parameter for future path-relative calculations +4. Utilities Module (utils.py) +Added get_relative_path() helper function to calculate relative paths from a list of base paths +Normalizes path separators to forward slashes for cross-platform consistency +5. Main Module (main.py) +Updated to extract base_paths from CodeGraph instance +Passes base_paths to all visualization functions +6. Frontend (main.js) +Already handles n.label || n.id pattern correctly +Module nodes now display basename in labels while using path-based IDs internally +fullPath attribute available in tooltips for disambiguation + +For modules: Shows the full file path (e.g., utils.py or utils.py) +For entities: Shows the parent module (e.g., src/utils for src_helper function) + +Design Decisions Implemented: +Clean relative paths for readability ✓ + +Node IDs use sanitized relative paths: src/utils instead of utils.py +Easy to read and understand in visualizations +Basename for node labels + full path in tooltips ✓ + +Module nodes show just the filename (e.g., utils) in the UI +Full relative path available in the fullPath attribute for tooltips +Users see familiar names while the system maintains unique identifiers +Acceptable to break saved HTML files ✓ + +Node ID format changed from module.py to module (relative path without extension) +Existing visualizations will need to be regenerated \ No newline at end of file diff --git a/codegraph/__init__.py b/codegraph/__init__.py index 67bc602..5b60188 100644 --- a/codegraph/__init__.py +++ b/codegraph/__init__.py @@ -1 +1 @@ -__version__ = "1.3.0" +__version__ = "1.5.0" diff --git a/codegraph/core.py b/codegraph/core.py index 64ccd33..83e941d 100644 --- a/codegraph/core.py +++ b/codegraph/core.py @@ -2,7 +2,7 @@ import os from argparse import Namespace from collections import defaultdict, deque -from typing import Dict, List, Set, Text, Tuple +from typing import Dict, List, Optional, Set, Text, Tuple from codegraph.parser import Import, create_objects_array from codegraph.utils import get_python_paths_list @@ -13,35 +13,259 @@ def read_file_content(path: Text) -> Text: - with open(path, "r+") as file_read: - return file_read.read() + try: + with open(path, "r", encoding="utf-8") as file_read: + return file_read.read() + except UnicodeDecodeError: + # Try with latin-1 as fallback, which accepts all byte values + with open(path, "r", encoding="latin-1") as file_read: + return file_read.read() -def parse_code_file(path: Text) -> List: +def parse_code_file(path: Text, base_paths: Optional[List] = None) -> List: """read module source and parse to get objects array""" source = read_file_content(path) - parsed_module = create_objects_array(source=source, fname=os.path.basename(path)) + # Pass full path to parser to support duplicate filenames + parsed_module = create_objects_array(source=source, fname=path, base_paths=base_paths or []) return parsed_module -def get_code_objects(paths_list: List) -> Dict: +def get_code_objects(paths_list: List, base_paths: Optional[List] = None) -> Dict: """ get all code files data for paths list :param paths_list: list with paths to code files to parse + :param base_paths: list of base paths for calculating relative paths :return: """ all_data = {} for path in paths_list: - content = parse_code_file(path) + content = parse_code_file(path, base_paths) all_data[path] = content return all_data class CodeGraph: def __init__(self, args: Namespace): - self.paths_list = get_python_paths_list(args.paths) + self.base_paths = [os.path.abspath(p) for p in args.paths] + self.paths_list = get_python_paths_list(args.paths, max_depth=args.depth) + + # Filter by keyword if provided + if args.keyword: + self.paths_list = self._filter_by_keyword(self.paths_list, args.keyword) + if not self.paths_list: + print(f"Warning: No files found containing keyword '{args.keyword}'") + # get py modules list data - self.modules_data = get_code_objects(self.paths_list) + self.modules_data = get_code_objects(self.paths_list, self.base_paths) + + # Store raw imports before they get popped by get_imports_and_entities_lines + self.raw_imports = {} + for module_path, parsed_objects in self.modules_data.items(): + for obj in parsed_objects: + if isinstance(obj, Import): + self.raw_imports[module_path] = list(obj.modules) + break + + # Apply entity-level filtering if keyword is specified + if args.keyword: + self._filter_entities_by_keyword(args.keyword) + + def _filter_entities_by_keyword(self, keyword: str): + """Filter entities within modules to only include those that use the keyword. + + Handles import aliases like 'import h13shotgrid as shotgrid' by tracking the alias name. + Also includes entities that call other entities using the keyword (dependency chain). + """ + keyword_lower = keyword.lower() + + for module_path, parsed_objects in self.modules_data.items(): + # Extract import statements and build alias mapping + import_aliases = {} # Maps alias/module name to original module name + for obj in parsed_objects: + if isinstance(obj, Import): + for module_import in obj.modules: + # Handle "import module as alias" + if " as " in module_import: + original, alias = module_import.split(" as ") + original = original.strip() + alias = alias.strip() + # Check if original matches keyword + if keyword_lower in original.lower(): + import_aliases[alias.lower()] = original + import_aliases[original.lower()] = original + else: + # Handle "import module" or "from X import Y" + parts = module_import.split(".") + base_module = parts[0] + if keyword_lower in base_module.lower(): + import_aliases[base_module.lower()] = base_module + + # If no matching imports found, skip filtering for this module + if not import_aliases: + continue + + # Read file content to check entity usage + try: + content = read_file_content(module_path) + content_lower = content.lower() + except Exception: + continue + + # First pass: identify entities that directly use the keyword + entities_using_keyword = set() + entity_code_map = {} # Map entity name to its code + entity_objects = [] # Non-import objects + + for obj in parsed_objects: + if isinstance(obj, Import): + continue + + entity_objects.append(obj) + entity_name = obj.name + + if hasattr(obj, 'lineno') and hasattr(obj, 'endno') and obj.lineno and obj.endno: + # Extract entity's code section + lines = content.split('\n') + entity_code = '\n'.join(lines[obj.lineno - 1:obj.endno]) + entity_code_map[entity_name] = entity_code + entity_code_lower = entity_code.lower() + + # Check if entity uses any of the import aliases + for alias in import_aliases.keys(): + if alias in entity_code_lower: + entities_using_keyword.add(entity_name) + break + + # Second pass: find entities that call entities using the keyword (dependency chain) + # Keep iterating until no new entities are found + entities_to_keep = set(entities_using_keyword) + changed = True + max_iterations = 10 # Prevent infinite loops + iteration = 0 + + while changed and iteration < max_iterations: + changed = False + iteration += 1 + + for obj in entity_objects: + entity_name = obj.name + if entity_name in entities_to_keep: + continue + + if entity_name in entity_code_map: + entity_code_lower = entity_code_map[entity_name].lower() + + # Check if this entity calls any entity that's already in the keep set + for kept_entity in entities_to_keep: + # Look for function/method calls: kept_entity( or kept_entity. + if f'{kept_entity.lower()}(' in entity_code_lower or f'{kept_entity.lower()}.' in entity_code_lower: + entities_to_keep.add(entity_name) + changed = True + break + + # Build filtered objects list + filtered_objects = [] + for obj in parsed_objects: + if isinstance(obj, Import): + # Always keep Import objects + filtered_objects.append(obj) + elif hasattr(obj, 'name') and obj.name in entities_to_keep: + filtered_objects.append(obj) + + # Update modules_data with filtered entities + self.modules_data[module_path] = filtered_objects + + def _filter_by_keyword(self, paths_list: list, keyword: str) -> list: + """Filter paths to only include files containing the keyword or their direct dependencies.""" + keyword_lower = keyword.lower() + matching_files = set() + + # First pass: find files containing the keyword + for path in paths_list: + # Check if keyword is in filename + if keyword_lower in os.path.basename(path).lower(): + matching_files.add(path) + continue + + # Check if keyword is in file content or imports + try: + # Parse the file to extract imports using parse_code_file + parsed_objects = parse_code_file(path, self.base_paths) + + # Check if any import contains the keyword + for obj in parsed_objects: + if isinstance(obj, Import): + for module in obj.modules: + # Extract the actual import name from "module.name" or "name" + if " as " in module: + module = module.split(" as ")[0] + parts = module.split(".") + # Check all parts of the import for the keyword + for part in parts: + if keyword_lower in part.lower(): + matching_files.add(path) + break + if path in matching_files: + break + if path in matching_files: + break + + # Also check file content for the keyword + if path not in matching_files: + content = read_file_content(path) + if keyword_lower in content.lower(): + matching_files.add(path) + except Exception as e: + # Skip files that can't be parsed/read + logger.debug(f"Failed to parse {path}: {e}") + pass + + if not matching_files: + return [] + + # Second pass: parse all files to find direct dependencies + # Parse all files to build import mapping + all_modules_data = {} + for path in paths_list: + try: + all_modules_data[path] = parse_code_file(path, self.base_paths) + except Exception: + all_modules_data[path] = [] + + # Extract imports from parsed data + all_imports = {} + for path, parsed_objects in all_modules_data.items(): + imports_list = [] + for obj in parsed_objects: + if isinstance(obj, Import): + for module in obj.modules: + # Extract base import name + if " as " in module: + module = module.split(" as ")[0] + imports_list.append(module) + all_imports[path] = imports_list + + # Build dependency graph + result_files = set(matching_files) + for match_file in matching_files: + # Add files that import the keyword (already added in first pass) + + # Find files that the matching file depends on or that depend on it + for file_path, imports in all_imports.items(): + # Check if this file imports something from match_file + match_basename = os.path.basename(match_file).replace('.py', '') + for imp in imports: + if match_basename in imp: + result_files.add(file_path) + + # Check if match_file imports something from this file + if match_file in all_imports: + file_basename = os.path.basename(file_path).replace('.py', '') + for imp in all_imports[match_file]: + if file_basename in imp: + result_files.add(file_path) + + return list(result_files) def get_lines_numbers(self): """ @@ -109,6 +333,9 @@ def usage_graph(self) -> Dict: for method_that_used in entities_usage_in_modules[module]: method_usage_lines = entities_usage_in_modules[module][method_that_used] for method_usage_line in method_usage_lines: + # Skip if method_usage_line is None (parsing issue) + if method_usage_line is None: + continue for entity in entities_lines[module]: if entity[0] <= method_usage_line <= entity[1]: dependencies[module][entities_lines[module][entity]].append( @@ -158,9 +385,25 @@ def get_dependencies(self, file_path: str, distance: int) -> Dict[str, Set[str]] return dependencies -def get_module_name(code_path: Text) -> Text: - module_name = os.path.basename(code_path).replace(".py", "") - return module_name +def get_module_name(code_path: Text, base_paths: Optional[List] = None) -> Text: + """Get a unique module identifier using relative path. + + :param code_path: Full path to the module + :param base_paths: List of base paths for calculating relative path + :return: Relative path without .py extension (e.g., 'src/utils' or 'tests/utils') + """ + from codegraph.utils import get_relative_path + + if base_paths: + rel_path = get_relative_path(code_path, base_paths) + else: + rel_path = os.path.basename(code_path) + + # Remove .py extension + if rel_path.endswith('.py'): + rel_path = rel_path[:-3] + + return rel_path def module_name_in_imports(imports: List, module_name: Text) -> bool: @@ -182,11 +425,21 @@ def get_imports_and_entities_lines( # noqa: C901 imports = defaultdict(list) modules_ = code_objects.keys() names_map = {} - # Build a set of all module names for quick lookup - module_names_set = {os.path.basename(m).replace(".py", "") for m in modules_} + + # Get base paths from any parsed object's file attribute + # This is a bit of a workaround - ideally we'd pass base_paths as a parameter + base_paths = [] + for path in modules_: + parent_dir = os.path.dirname(path) + if parent_dir and parent_dir not in base_paths: + # Add parent directories as potential base paths + base_paths.append(parent_dir) + + # Build a set of all module names for quick lookup (now using relative paths) + module_names_set = {get_module_name(m, base_paths) for m in modules_} for path in code_objects: - names_map[get_module_name(path)] = path + names_map[get_module_name(path, base_paths)] = path # for each module in list if code_objects[path] and isinstance(code_objects[path][-1], Import): # extract imports if exist @@ -233,7 +486,9 @@ def get_imports_and_entities_lines( # noqa: C901 for entity in code_objects[path]: # create a dict with lines of start and end for each entity in module - entities_lines[path][(entity.lineno, entity.endno)] = entity.name + # Skip entities with None line numbers to prevent TypeError + if entity.lineno is not None and entity.endno is not None: + entities_lines[path][(entity.lineno, entity.endno)] = entity.name return entities_lines, imports, names_map diff --git a/codegraph/main.py b/codegraph/main.py index 93f8a91..4523a6b 100644 --- a/codegraph/main.py +++ b/codegraph/main.py @@ -40,7 +40,18 @@ type=click.Path(), help="Export graph data to CSV file (specify output path)", ) -def cli(paths, object_only, file_path, distance, matplotlib, output, csv): +@click.option( + "--depth", + type=int, + required=True, + help="Maximum subfolder depth to scan (1=original/sub1, 2=original/sub1/sub2, etc.)", +) +@click.option( + "--keyword", + type=str, + help="Filter to only show modules related to this keyword (reduces graph size for large codebases)", +) +def cli(paths, object_only, file_path, distance, matplotlib, output, csv, depth, keyword): """ Tool that creates a graph of code to show dependencies between code entities (methods, classes, etc.). CodeGraph does not execute code, it is based only on lex and syntax parsing. @@ -62,6 +73,8 @@ def cli(paths, object_only, file_path, distance, matplotlib, output, csv): matplotlib=matplotlib, output=output, csv=csv, + depth=depth, + keyword=keyword, ) main(args) @@ -70,6 +83,8 @@ def main(args): code_graph = core.CodeGraph(args) usage_graph = code_graph.usage_graph() entity_metadata = code_graph.get_entity_metadata() + base_paths = code_graph.base_paths + raw_imports = code_graph.raw_imports # Raw imports before they get popped if args.file_path and args.distance: dependencies = code_graph.get_dependencies(args.file_path, args.distance) @@ -81,14 +96,16 @@ def main(args): elif args.csv: import codegraph.vizualyzer as vz - vz.export_to_csv(usage_graph, entity_metadata=entity_metadata, output_path=args.csv) + vz.export_to_csv(usage_graph, entity_metadata=entity_metadata, + output_path=args.csv, base_paths=base_paths, raw_imports=raw_imports) else: import codegraph.vizualyzer as vz if args.matplotlib: - vz.draw_graph_matplotlib(usage_graph) + vz.draw_graph_matplotlib(usage_graph, base_paths=base_paths) else: - vz.draw_graph(usage_graph, entity_metadata=entity_metadata, output_path=args.output) + vz.draw_graph(usage_graph, entity_metadata=entity_metadata, + output_path=args.output, base_paths=base_paths, raw_imports=raw_imports) if __name__ == "__main__": diff --git a/codegraph/parser.py b/codegraph/parser.py index d58850f..060a9ec 100644 --- a/codegraph/parser.py +++ b/codegraph/parser.py @@ -88,9 +88,15 @@ def _nest_class(ob, class_name, lineno, super=None): return newclass -def create_objects_array(fname, source): # noqa: C901 +def create_objects_array(fname, source, base_paths=None): # noqa: C901 # todo: need to do optimization - """Return an object list for a particular module.""" + """Return an object list for a particular module. + + :param fname: Full path to the file being parsed + :param source: Source code content + :param base_paths: List of base paths for calculating relative paths (optional) + :return: List of parsed objects + """ tree = [] f = io.StringIO(source) diff --git a/codegraph/templates/index.html b/codegraph/templates/index.html index 2eb01da..7a7cfc2 100644 --- a/codegraph/templates/index.html +++ b/codegraph/templates/index.html @@ -16,6 +16,8 @@
-