#!/usr/bin/env python3 import os from dataclasses import dataclass from prettytable import PrettyTable from pathlib import Path from route_extractor import ( RouteInfo, RouteStatus, normalize_route_path, extract_routes_from_file ) @dataclass class RouteComparison: path: str route: str methods: list[str] status: RouteStatus old_methods: list[str] = None decorator: str = "" docstring: str = "" function: str = "" DEBUG = False def get_python_files(path: str | Path) -> list[str]: """Recursively get all Python files from a directory.""" python_files = [] path = Path(path) def is_handler_file(filename: str) -> bool: handler_keywords = ['handler', 'route', 'view', 'endpoint', 'api'] return (filename.endswith('.py') and not filename.startswith('__') and any(keyword in filename.lower() for keyword in handler_keywords)) if path.is_file(): if path.suffix == '.py': python_files.append(str(path)) else: for file_path in path.rglob('*.py'): if is_handler_file(file_path.name): python_files.append(str(file_path)) return python_files def compare_routes(old_routes: list[RouteInfo], new_routes: list[RouteInfo], debug: bool = False) -> list[ RouteComparison]: """Compare two sets of routes and identify changes.""" if debug: print("\nDebug Information:") print(f"Number of old routes: {len(old_routes)}") print(f"Number of new routes: {len(new_routes)}") comparisons = [] # Create dictionaries with method+path as key old_routes_dict = { f"{method}:{r.route_path}": r for r in old_routes for method in r.methods } new_routes_dict = { f"{method}:{r.route_path}": r for r in new_routes for method in r.methods } if debug: print("\nOld routes:") for key in sorted(old_routes_dict.keys()): method, path = key.split(':', 1) print(f" {method} {path}") print("\nNew routes:") for key in sorted(new_routes_dict.keys()): method, path = key.split(':', 1) print(f" {method} {path}") # Check for duplicates in old routes route_counts = {} for route in old_routes: for method in route.methods: key = f"{method}:{normalize_route_path(route.route_path)}" route_counts[key] = route_counts.get(key, []) + [(route.path, method)] duplicates = {path: files for path, files in route_counts.items() if len(files) > 1} if duplicates: print("\nDuplicate method+path combinations found in old_routes:") for route_key, occurrences in duplicates.items(): print(f"\nRoute: {route_key}") for file_path, method in occurrences: print(f" - Found in {file_path}") # Check for duplicates in new routes route_counts = {} for route in new_routes: for method in route.methods: key = f"{method}:{normalize_route_path(route.route_path)}" route_counts[key] = route_counts.get(key, []) + [(route.path, method)] duplicates = {path: files for path, files in route_counts.items() if len(files) > 1} if duplicates: print("\nDuplicate method+path combinations found in new_routes:") for route_key, occurrences in duplicates.items(): print(f"\nRoute: {route_key}") for file_path, method in occurrences: print(f" - Found in {file_path}") # Track processed routes to catch duplicates processed_new_routes = set() processed_old_routes = set() # Check for modified and unchanged routes for route_key, new_route in new_routes_dict.items(): if route_key in processed_new_routes: continue new_method = route_key.split(':', 1)[0] if route_key in old_routes_dict: old_route = old_routes_dict[route_key] processed_new_routes.add(route_key) processed_old_routes.add(route_key) comparisons.append(RouteComparison( path=new_route.path, route=f"{new_method} {new_route.route_path}", methods=[new_method], status=RouteStatus.UNCHANGED, decorator=new_route.decorator, docstring=new_route.docstring, function=new_route.function )) else: if debug: print(f"\nNew route found: {route_key}") processed_new_routes.add(route_key) comparisons.append(RouteComparison( path=new_route.path, route=f"{new_method} {new_route.route_path}", methods=[new_method], status=RouteStatus.ADDED, decorator=new_route.decorator, docstring=new_route.docstring, function=new_route.function )) # Check for removed routes for route_key, old_route in old_routes_dict.items(): if route_key in processed_old_routes: continue old_method = route_key.split(':', 1)[0] if route_key not in new_routes_dict: if debug: print(f"\nRemoved route found: {route_key}") processed_old_routes.add(route_key) comparisons.append(RouteComparison( path=old_route.path, route=f"{old_method} {old_route.route_path}", methods=[old_method], status=RouteStatus.REMOVED, decorator=old_route.decorator, docstring=old_route.docstring, function=old_route.function )) return sorted(comparisons, key=lambda x: (x.status.value, x.route.lower())) def display_comparison_table(comparisons: list[RouteComparison]) -> PrettyTable | None: """Display route comparisons in a formatted table.""" if not comparisons: print("No routes to compare.") return None table = PrettyTable() table.field_names = ["Status", "Route", "Methods", "File"] table.align = "l" status_colors = { RouteStatus.UNCHANGED: "", RouteStatus.MODIFIED: "\033[33m", # Yellow RouteStatus.ADDED: "\033[32m", # Green RouteStatus.REMOVED: "\033[31m", # Red } reset_color = "\033[0m" for comp in comparisons: status_color = status_colors[comp.status] if comp.status == RouteStatus.MODIFIED: methods_display = f"Old: {', '.join(comp.old_methods)}\nNew: {', '.join(comp.methods)}" else: methods_display = ', '.join(comp.methods) # Truncate the path for better display display_path = comp.path if len(display_path) > 40: display_path = '...' + display_path[-37:] table.add_row([ f"{status_color}{comp.status.value}{reset_color}", comp.route, methods_display, display_path ]) return table def main(path1: str | Path, path2: str | Path = None) -> None: """Main function to process and compare routes.""" path1 = Path(path1) if not path1.exists(): print(f"Error: Path not found - {path1}") return if path2: path2 = Path(path2) if not path2.exists(): print(f"Error: Path not found - {path2}") return # Resolve full paths to avoid any ambiguity path1 = path1.resolve() if path2: path2 = path2.resolve() # Determine which path is the handlers.py file and which is the directory if path1.suffix == '.py' and path2 and not path2.suffix == '.py': old_path, new_path = path1, path2 elif path2 and path2.suffix == '.py' and not path1.suffix == '.py': old_path, new_path = path2, path1 else: old_path, new_path = path1, path2 print("\nPath Resolution:") print(f"Old path (handlers.py): {old_path}") print(f"New path (handlers/): {new_path}") # Extract routes from the first path files1 = get_python_files(old_path) print(f"\nFiles in old path:") for f in files1: print(f" {f}") routes1 = [] for file_path in files1: file_routes = extract_routes_from_file(file_path) print(f" Found {len(file_routes)} routes in {file_path}") routes1.extend(file_routes) # If no second path is provided, just display the routes if not new_path: print(f"\nFound {len(routes1)} routes in {len(files1)} files:") routes1.sort(key=lambda x: x.route_path.lower()) table = PrettyTable() table.field_names = ["Methods", "Route", "File"] table.align = "l" for route in routes1: table.add_row([ ', '.join(route.methods), route.route_path, os.path.basename(route.path) ]) print(table) return # Extract routes from the second path files2 = get_python_files(new_path) print(f"\nFiles in new path:") for f in files2: print(f" {f}") routes2 = [] for file_path in files2: file_routes = extract_routes_from_file(file_path) print(f" Found {len(file_routes)} routes in {file_path}") routes2.extend(file_routes) # Compare routes with debug info print(f"\nComparing routes between:") print(f"Old: {old_path} ({len(routes1)} routes)") print(f"New: {new_path} ({len(routes2)} routes)") # Verify we're not comparing the same file if old_path == new_path: print("\nERROR: Both paths resolve to the same location!") return comparisons = compare_routes(routes1, routes2, debug=DEBUG) # Verify comparison count matches route count total_routes = len(comparisons) if total_routes != max(len(routes1), len(routes2)): print(f"\nWARNING: Route count mismatch!") print(f"Routes in old path: {len(routes1)}") print(f"Routes in new path: {len(routes2)}") print(f"Routes in comparison: {total_routes}") table = display_comparison_table(comparisons) print("\nComparison Results:") print(table) # Print summary changes = {status: len([c for c in comparisons if c.status == status]) for status in RouteStatus} print("\nSummary:") print(f"- Unchanged: {changes[RouteStatus.UNCHANGED]}") print(f"- Modified: {changes[RouteStatus.MODIFIED]}") print(f"- Added: {changes[RouteStatus.ADDED]}") print(f"- Removed: {changes[RouteStatus.REMOVED]}") if __name__ == "__main__": import sys if len(sys.argv) == 2: main(sys.argv[1]) elif len(sys.argv) == 3: main(sys.argv[1], sys.argv[2]) else: print("Usage:") print(" Single path: python route_lister.py ") print(" Compare: python route_lister.py ") sys.exit(1)