from __future__ import annotations import ast import re from dataclasses import dataclass, field from typing import Optional, List from enum import Enum from pathlib import Path class RouteStatus(Enum): """Status of a route when comparing versions""" UNCHANGED = "UNCHANGED" MODIFIED = "MODIFIED" ADDED = "ADDED" REMOVED = "REMOVED" @dataclass class RouteInfo: """Information about a Flask route""" path: str # File path route_path: str # URL path methods: List[str] = field(default_factory=list) decorator: str = "" docstring: str = "" function: str = "" node: Optional[ast.AST] = None decorators: List[ast.AST] = field(default_factory=list) function_def: Optional[ast.FunctionDef] = None def __post_init__(self) -> None: """Process route decorators to extract methods if not already set""" if not self.methods and self.decorators: for dec in self.decorators: if isinstance(dec, ast.Call): for keyword in dec.keywords: if keyword.arg == 'methods': if isinstance(keyword.value, ast.List): self.methods = [ m.s if isinstance(m, ast.Str) else m.value for m in keyword.value.elts ] # Default to GET if no methods specified if not self.methods: self.methods = ['GET'] if self.route_path: self.route_path = normalize_route_path(self.route_path) def normalize_route_path(path: str) -> str: """Normalize route path for comparison and matching""" # Convert various parameter patterns to a standard form path = re.sub(r'<\w+:([\w_]+)>', r'<\1>', path) # Flask typed parameters path = re.sub(r'<([\w_]+)>', r'<\1>', path) # Flask parameters path = path.rstrip('/') # Remove trailing slashes # Ensure leading slash if not path.startswith('/'): path = '/' + path return path def is_route_decorator(node: ast.AST) -> bool: """Check if an AST node is a Flask route decorator.""" if isinstance(node, ast.Call): if isinstance(node.func, ast.Attribute): return node.func.attr == 'route' elif isinstance(node.func, ast.Name): return node.func.id in {'route', 'app'} return False def get_string_value(node: ast.AST) -> Optional[str]: """Safely extract string value from different types of AST nodes.""" if isinstance(node, ast.Str): return node.s elif isinstance(node, ast.Constant) and isinstance(node.value, str): return node.value return None def extract_decorator_info(decorator: ast.Call) -> dict: """Extract route path and methods from decorator.""" route_info = {'path': None, 'methods': []} if not hasattr(decorator, 'args') or not decorator.args: return route_info path_node = decorator.args[0] if isinstance(path_node, ast.Attribute): route_info['path'] = f"" else: route_info['path'] = get_string_value(path_node) for keyword in decorator.keywords: if keyword.arg == 'methods': if isinstance(keyword.value, ast.List): methods = [] for m in keyword.value.elts: method = get_string_value(m) if method: methods.append(method) route_info['methods'] = methods elif isinstance(keyword.value, ast.Constant): method = get_string_value(keyword.value) if method: route_info['methods'] = [method] return route_info class RouteExtractor(ast.NodeVisitor): """Extract Flask routes from AST""" def __init__(self, file_path: str = "") -> None: self.routes: List[RouteInfo] = [] self.file_path = file_path def visit_FunctionDef(self, node: ast.FunctionDef) -> None: route_decorators = [ dec for dec in node.decorator_list if is_route_decorator(dec) ] if route_decorators: for decorator in route_decorators: route_info = extract_decorator_info(decorator) if route_info['path']: docstring = ast.get_docstring(node) first_line = docstring.split('\n')[0] if docstring else '' self.routes.append(RouteInfo( path=self.file_path, route_path=route_info['path'], methods=route_info['methods'], decorator=ast.unparse(decorator), docstring=first_line, function=node.name, node=node, decorators=route_decorators, function_def=node )) def extract_routes_from_content(content: str, file_path: str = "") -> List[RouteInfo]: """Extract route information from Python source code content.""" try: tree = ast.parse(content) except Exception as e: print(f"Error parsing content from {file_path}: {str(e)}") return [] extractor = RouteExtractor(file_path) extractor.visit(tree) return extractor.routes def extract_routes_from_file(file_path: str | Path) -> List[RouteInfo]: """Helper function to extract routes directly from a file.""" try: with open(file_path, 'r', encoding='utf-8') as file: content = file.read() return extract_routes_from_content(content, str(file_path)) except Exception as e: print(f"Error reading {file_path}: {str(e)}") return []