diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index eff8381..615b52a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -44,14 +44,48 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest matplotlib networkx click - - name: Run tests + pip install pytest pytest-cov matplotlib networkx click + - name: Run tests with coverage run: | + pytest tests/ -v --cov=codegraph --cov-report=term-missing --cov-report=xml + - name: Upload coverage to Codecov + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false + + package-test: + runs-on: ubuntu-latest + needs: [lint] + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Build package + run: | + python -m pip install --upgrade pip build + python -m build --wheel + - name: Test package without matplotlib + run: | + pip install dist/codegraph-*.whl + pip install pytest + pytest tests/test_package_install.py -v + - name: Test package with matplotlib + run: | + pip install --force-reinstall dist/codegraph-*.whl + pip install matplotlib networkx pytest tests/ -v deploy-demo: runs-on: ubuntu-latest - needs: [tests] + needs: [tests, package-test] if: github.event_name == 'push' && github.ref == 'refs/heads/main' environment: name: github-pages @@ -65,7 +99,6 @@ jobs: - name: Install codegraph run: | python -m pip install --upgrade pip - pip install matplotlib networkx click pip install -e . - name: Clone simple-ddl-parser for demo run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 31628b0..df5b99f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ 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.1.0] - 2025-01-18 + +### Added + +**Massive Objects Detection** +- New "Massive Objects" panel to find large code entities by lines of code +- Filter by type: modules, classes, functions +- Configurable threshold (default: 50 lines) +- Click on any item to highlight it on the graph + +**Lines of Code Tracking** +- Each entity (function, class, module) now tracks lines of code +- Lines of code displayed in tooltips +- Node size scales based on lines of code (toggleable in Display panel) + +**Improved UI Panels** +- All panels are now draggable - move them anywhere on screen +- All panels are collapsible - click the toggle button to minimize +- Display panel with "Size by lines of code" toggle + +**Smart Initial Zoom** +- Graph now auto-zooms based on node count +- Large graphs start more zoomed out for better overview + +### Changed + +**matplotlib is now optional** +- D3.js visualization works without matplotlib installed +- Install with `pip install codegraph[matplotlib]` for legacy matplotlib support +- Reduces installation size and dependencies for most users + +### Testing +- Added package installation tests (`tests/test_package_install.py`) +- Tests verify package works without matplotlib +- Added CI jobs for package build and installation testing +- tox environments for testing with and without matplotlib + ## [1.0.0] - 2025-01-18 ### Added diff --git a/codegraph/__init__.py b/codegraph/__init__.py index 5becc17..6849410 100644 --- a/codegraph/__init__.py +++ b/codegraph/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/codegraph/core.py b/codegraph/core.py index d8258a8..64ccd33 100644 --- a/codegraph/core.py +++ b/codegraph/core.py @@ -60,6 +60,37 @@ def get_lines_numbers(self): data[module][func.name] = (func.lineno, func.endno) return data + def get_entity_metadata(self) -> Dict: + """ + Return metadata for all entities including line counts and types. + :return: {module_path: {entity_name: {'lines': int, 'type': 'function'|'class'}}} + """ + from codegraph.parser import Class, Function, AsyncFunction, Import + + data = {} + for module_path in self.modules_data: + data[module_path] = {} + for entity in self.modules_data[module_path]: + if isinstance(entity, Import): + continue + lines = 0 + if entity.lineno and entity.endno: + lines = entity.endno - entity.lineno + 1 + + entity_type = "function" + if isinstance(entity, Class): + entity_type = "class" + elif isinstance(entity, (Function, AsyncFunction)): + entity_type = "function" + + data[module_path][entity.name] = { + "lines": lines, + "entity_type": entity_type, + "lineno": entity.lineno, + "endno": entity.endno + } + return data + def usage_graph(self) -> Dict: """ module name: function diff --git a/codegraph/main.py b/codegraph/main.py index 712bd5c..53eb8b9 100644 --- a/codegraph/main.py +++ b/codegraph/main.py @@ -63,6 +63,7 @@ def cli(paths, object_only, file_path, distance, matplotlib, output): def main(args): code_graph = core.CodeGraph(args) usage_graph = code_graph.usage_graph() + entity_metadata = code_graph.get_entity_metadata() if args.file_path and args.distance: dependencies = code_graph.get_dependencies(args.file_path, args.distance) @@ -77,7 +78,7 @@ def main(args): if args.matplotlib: vz.draw_graph_matplotlib(usage_graph) else: - vz.draw_graph(usage_graph, output_path=args.output) + vz.draw_graph(usage_graph, entity_metadata=entity_metadata, output_path=args.output) if __name__ == "__main__": diff --git a/codegraph/vizualyzer.py b/codegraph/vizualyzer.py index 001808f..86f5018 100644 --- a/codegraph/vizualyzer.py +++ b/codegraph/vizualyzer.py @@ -4,13 +4,10 @@ import webbrowser from typing import Dict, List -import matplotlib.pyplot as plt -import networkx as nx - logger = logging.getLogger(__name__) -def process_module_in_graph(module: Dict[str, list], module_links: list, G: nx.DiGraph): +def process_module_in_graph(module: Dict[str, list], module_links: list, G): _module = os.path.basename(module) module_edges = [] @@ -30,6 +27,14 @@ def process_module_in_graph(module: Dict[str, list], module_links: list, G: nx.D def draw_graph_matplotlib(modules_entities: Dict) -> None: + try: + import matplotlib.pyplot as plt + import networkx as nx + except ImportError: + raise ImportError( + "matplotlib is required for matplotlib visualization. " + "Install it with: pip install codegraph[matplotlib]" + ) G = nx.DiGraph() @@ -109,7 +114,7 @@ def draw_graph_matplotlib(modules_entities: Dict) -> None: plt.show() -def convert_to_d3_format(modules_entities: Dict) -> Dict: +def convert_to_d3_format(modules_entities: Dict, entity_metadata: Dict = None) -> Dict: """Convert the modules_entities graph data to D3.js format.""" nodes: List[Dict] = [] links: List[Dict] = [] @@ -117,6 +122,9 @@ def convert_to_d3_format(modules_entities: Dict) -> Dict: module_links = set() # Track module-to-module links module_full_paths: Dict[str, str] = {} # Map module name to full path + if entity_metadata is None: + entity_metadata = {} + # Find common root path for relative paths all_paths = list(modules_entities.keys()) if all_paths: @@ -130,6 +138,7 @@ def convert_to_d3_format(modules_entities: Dict) -> Dict: entity_to_module: Dict[str, str] = {} for module_path, entities in modules_entities.items(): module_name = os.path.basename(module_path) + module_metadata = entity_metadata.get(module_path, {}) # Compute relative path from common root if common_root: @@ -139,13 +148,17 @@ def convert_to_d3_format(modules_entities: Dict) -> Dict: module_full_paths[module_name] = relative_path + # Calculate total lines in module + total_lines = sum(m.get("lines", 0) for m in module_metadata.values()) + # Add module node if module_name not in node_ids: nodes.append({ "id": module_name, "type": "module", "collapsed": False, - "fullPath": relative_path + "fullPath": relative_path, + "lines": total_lines }) node_ids.add(module_name) @@ -155,12 +168,19 @@ def convert_to_d3_format(modules_entities: Dict) -> Dict: entity_to_module[entity_name] = module_name entity_to_module[f"{module_name.replace('.py', '')}.{entity_name}"] = module_name + # Get entity metadata + ent_meta = module_metadata.get(entity_name, {}) + lines = ent_meta.get("lines", 0) + entity_type = ent_meta.get("entity_type", "function") + if entity_id not in node_ids: nodes.append({ "id": entity_id, "label": entity_name, "type": "entity", - "parent": module_name + "parent": module_name, + "lines": lines, + "entityType": entity_type }) node_ids.add(entity_id) @@ -374,18 +394,10 @@ def get_d3_html_template(graph_data: Dict) -> str: max-width: 300px; }} .controls {{ - position: fixed; top: 10px; left: 10px; - background: rgba(0, 0, 0, 0.8); - padding: 15px; - border-radius: 8px; - color: #fff; - font-size: 12px; - z-index: 1000; }} - .controls h3 {{ - margin-bottom: 10px; + .controls .panel-header h4 {{ color: #70b8ff; }} .controls p {{ @@ -399,18 +411,16 @@ def get_d3_html_template(graph_data: Dict) -> str: font-family: monospace; }} .legend {{ - position: fixed; - bottom: 10px; + top: 270px; left: 10px; - background: rgba(0, 0, 0, 0.8); - padding: 15px; - border-radius: 8px; - color: #fff; - font-size: 12px; }} - .legend h4 {{ + .legend .panel-header h4 {{ + color: #70b8ff; + }} + .legend .panel-content h4 {{ margin-bottom: 8px; color: #70b8ff; + margin-top: 0; }} .legend-section {{ margin-bottom: 10px; @@ -438,34 +448,19 @@ def get_d3_html_template(graph_data: Dict) -> str: .legend-link-entity {{ background: #009c2c; }} .legend-link-dep {{ background: #d94a4a; }} .stats {{ - position: fixed; top: 10px; right: 10px; - background: rgba(0, 0, 0, 0.8); - padding: 15px; - border-radius: 8px; - color: #fff; - font-size: 12px; }} - .stats h4 {{ - margin-bottom: 8px; + .stats .panel-header h4 {{ color: #70b8ff; }} .unlinked-modules {{ - position: fixed; top: 130px; right: 10px; - background: rgba(0, 0, 0, 0.8); - padding: 15px; - border-radius: 8px; - color: #fff; - font-size: 12px; max-height: 300px; max-width: 280px; - overflow-y: auto; }} - .unlinked-modules h4 {{ - margin-bottom: 8px; + .unlinked-modules .panel-header h4 {{ color: #ff9800; }} .unlinked-modules .count {{ @@ -504,6 +499,148 @@ def get_d3_html_template(graph_data: Dict) -> str: background: #555; border-radius: 3px; }} + /* Draggable panel styles */ + .panel {{ + position: fixed; + background: rgba(0, 0, 0, 0.85); + border-radius: 8px; + color: #fff; + font-size: 12px; + z-index: 100; + min-width: 150px; + }} + .panel-header {{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 15px; + cursor: move; + border-bottom: 1px solid #333; + user-select: none; + }} + .panel-header h4 {{ + margin: 0; + display: flex; + align-items: center; + gap: 8px; + }} + .panel-toggle {{ + background: none; + border: none; + color: #888; + cursor: pointer; + font-size: 16px; + padding: 0 4px; + transition: color 0.2s; + }} + .panel-toggle:hover {{ + color: #fff; + }} + .panel-content {{ + padding: 15px; + overflow-y: auto; + }} + .panel.collapsed .panel-content {{ + display: none; + }} + .panel.collapsed {{ + min-width: auto; + }} + /* Massive objects panel */ + .massive-objects {{ + bottom: 10px; + right: 10px; + max-height: 400px; + max-width: 300px; + }} + .massive-objects .panel-header h4 {{ + color: #e91e63; + }} + .massive-objects .filters {{ + margin-bottom: 10px; + display: flex; + flex-direction: column; + gap: 8px; + }} + .massive-objects .filter-row {{ + display: flex; + align-items: center; + gap: 8px; + }} + .massive-objects label {{ + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + }} + .massive-objects input[type="checkbox"] {{ + cursor: pointer; + }} + .massive-objects input[type="number"] {{ + width: 60px; + padding: 4px 8px; + border: 1px solid #555; + border-radius: 4px; + background: #333; + color: #fff; + font-size: 12px; + }} + .massive-objects .count {{ + color: #888; + font-size: 11px; + margin-left: 5px; + }} + .massive-objects ul {{ + list-style: none; + margin: 0; + padding: 0; + max-height: 250px; + overflow-y: auto; + }} + .massive-objects li {{ + padding: 4px 8px; + margin: 2px 0; + cursor: pointer; + border-radius: 4px; + transition: background 0.2s; + }} + .massive-objects li:hover {{ + background: rgba(233, 30, 99, 0.3); + }} + .massive-objects li .lines {{ + color: #e91e63; + font-weight: bold; + }} + .massive-objects li .entity-type {{ + color: #888; + font-size: 10px; + }} + .massive-objects::-webkit-scrollbar {{ + width: 6px; + }} + .massive-objects::-webkit-scrollbar-thumb {{ + background: #555; + border-radius: 3px; + }} + /* Size toggle / Display panel */ + .size-toggle {{ + bottom: 10px; + left: 10px; + }} + .size-toggle .panel-header h4 {{ + color: #9c27b0; + }} + .size-toggle label {{ + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + }} + .size-toggle input[type="checkbox"] {{ + cursor: pointer; + width: 16px; + height: 16px; + }} /* Search box styles */ .search-container {{ position: fixed; @@ -681,60 +818,159 @@ def get_d3_html_template(graph_data: Dict) -> str: -
Ctrl+F Search nodes
-Scroll Zoom in/out
-Drag on background - Pan
-Drag on node - Move node
-Click module/entity - Collapse/Expand
-Double-click - Focus on node
-Esc Clear search highlight
+Ctrl+F Search nodes
+Scroll Zoom in/out
+Drag on background - Pan
+Drag on node - Pin node position
+Click module/entity - Collapse/Expand
+Double-click - Unpin / Focus on node
+Esc Clear search highlight
+