#!/usr/bin/env python3 """Analyze Flask/FastAPI endpoints and identify dead endpoints with unused code. This script: 1. Extracts all Flask/FastAPI endpoints from a service codebase 2. Compares against Datadog APM data to identify active/inactive endpoints 3. Identifies dead logic/model functions that would become unused if dead endpoints are removed Usage (standalone with CSV): python analyze_endpoints.py \ --service users \ --datadog-export /path/to/datadog_export.csv \ --service-path /path/to/ows-users \ --threshold 10 Usage (programmatic with dict data): from analyze_endpoints import run_analysis results = run_analysis(service_path, datadog_dict, threshold=100) Outputs (in data/ows-/): - all_endpoints.csv: All endpoints with Datadog matching info - dead_endpoints.csv: Dead endpoints with their dead functions - summary.json: Analysis summary statistics """ import argparse import ast import csv import json import re import sys from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path @dataclass class EndpointInfo: method: str path: str handler: str file: str line: int function_calls: list[str] = field(default_factory=list) @dataclass class EndpointAnalysis: method: str path: str handler: str file: str line: int datadog_resource_name: str | None in_datadog: bool status: str # 'used', 'unused' function_calls: list[str] = field(default_factory=list) dead_functions: list[str] = field(default_factory=list) class FunctionCallVisitor(ast.NodeVisitor): """AST visitor to extract function calls from logic/model modules.""" def __init__(self, module_aliases: dict[str, str]): self.module_aliases = module_aliases # alias -> full module path self.function_calls = [] def visit_Call(self, node): call_str = self._extract_call_string(node.func) if call_str: self.function_calls.append(call_str) self.generic_visit(node) def _extract_call_string(self, node) -> str | None: """Extract the function call as 'module.function' or 'module.class.method'.""" if isinstance(node, ast.Attribute): # Handle: module.function() or obj.method() value = node.value attr = node.attr if isinstance(value, ast.Name): # Simple case: user_info.get_users() module_name = value.id if module_name in self.module_aliases: full_path = self.module_aliases[module_name] return f'{full_path}::{attr}' elif isinstance(value, ast.Attribute): # Nested case: auth0_client.Auth0Client.something() if isinstance(value.value, ast.Name): module_name = value.value.id if module_name in self.module_aliases: full_path = self.module_aliases[module_name] return f'{full_path}::{value.attr}.{attr}' return None def extract_imports(tree: ast.AST) -> dict[str, str]: """Extract relevant imports (logic/models) and build alias mapping. Works with any service structure (users.logic, permissions.logic, etc.) """ module_aliases = {} for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): if node.module and ('.logic' in node.module or '.models' in node.module): for alias in node.names: name = alias.asname if alias.asname else alias.name # service.logic.module_name -> logic/module_name.py # Strip the service prefix (first component) parts = node.module.split('.') if len(parts) >= 2: # Remove service prefix, keep logic/models path module_path = '/'.join(parts[1:]) if alias.name != '*': module_path = f'{module_path}/{alias.name}' module_aliases[name] = module_path + '.py' return module_aliases def extract_routes_from_file( file_path: Path, constant_map: dict[str, str] | None = None, ) -> list[EndpointInfo]: """Parse a Python file and extract Flask route endpoints. Handles: - @app.route("/path") decorators - @blueprint.route("/path") decorators - blueprint.add_url_rule("/path", ...) calls - Constant-based paths via constant_map lookup """ routes = [] cmap = constant_map or {} with open(file_path) as f: content = f.read() try: tree = ast.parse(content) except SyntaxError as e: print(f'Warning: Could not parse {file_path}: {e}', file=sys.stderr) return routes # Extract imports to build module alias mapping module_aliases = extract_imports(tree) # Detect Blueprint variables and their url_prefix blueprint_prefixes: dict[str, str] = {} # var_name -> url_prefix for node in ast.walk(tree): if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and isinstance(node.value, ast.Call): func_name = _get_call_func_name(node.value) if func_name == 'Blueprint': prefix = '' for kw in node.value.keywords: if kw.arg == 'url_prefix' and isinstance(kw.value, ast.Constant): prefix = kw.value.value blueprint_prefixes[target.id] = prefix # Extract routes from decorators on functions for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): function_name = node.name function_lineno = node.lineno for decorator in node.decorator_list: route_info = extract_route_from_decorator(decorator, cmap) if route_info: visitor = FunctionCallVisitor(module_aliases) visitor.visit(node) # Apply blueprint url_prefix if the decorator is on a blueprint obj_name = _get_decorator_obj_name(decorator) bp_prefix = blueprint_prefixes.get(obj_name, '') path = bp_prefix + route_info['path'] for method in route_info['methods']: routes.append( EndpointInfo( method=method, path=path, handler=function_name, file=file_path.name, line=function_lineno, function_calls=list(set(visitor.function_calls)), ) ) # Extract routes from add_url_rule() calls for node in ast.walk(tree): if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)): continue call = node.value if not isinstance(call.func, ast.Attribute): continue if call.func.attr != 'add_url_rule': continue rule_info = _extract_add_url_rule(call, cmap) if not rule_info: continue obj_name = '' if isinstance(call.func.value, ast.Name): obj_name = call.func.value.id bp_prefix = blueprint_prefixes.get(obj_name, '') path = bp_prefix + rule_info['path'] for method in rule_info['methods']: routes.append( EndpointInfo( method=method, path=path, handler=rule_info['handler'], file=file_path.name, line=node.lineno, function_calls=[], ) ) return routes def extract_route_from_decorator( decorator, constant_map: dict[str, str] | None = None, ) -> dict | None: """Extract route path and methods from a .route() decorator. Works for @app.route(), @blueprint.route(), etc. Resolves constant references via constant_map. """ cmap = constant_map or {} if isinstance(decorator, ast.Call): if isinstance(decorator.func, ast.Attribute): if decorator.func.attr == 'route': path = None methods = ['GET'] # Default method # Get path from first positional argument if decorator.args: path = _resolve_path_arg(decorator.args[0], cmap) # Also check 'rule' keyword argument if path is None: for keyword in decorator.keywords: if keyword.arg == 'rule': path = _resolve_path_arg(keyword.value, cmap) break # Get methods from keyword arguments for keyword in decorator.keywords: if keyword.arg == 'methods': if isinstance(keyword.value, ast.List): methods = [elt.value for elt in keyword.value.elts if isinstance(elt, ast.Constant)] if path: return {'path': path, 'methods': methods} return None def _resolve_path_arg(node: ast.expr, constant_map: dict[str, str]) -> str | None: """Resolve a route path argument to a string value.""" # String literal: "/path" if isinstance(node, ast.Constant) and isinstance(node.value, str): return node.value # Attribute: config.FEEDS_PATH or config.Config.HEALTH_CHECK if isinstance(node, ast.Attribute): # Try the final attribute name (e.g., FEEDS_PATH, HEALTH_CHECK) resolved = constant_map.get(node.attr) if resolved: return resolved # Name: FEEDS_PATH (imported directly) if isinstance(node, ast.Name): resolved = constant_map.get(node.id) if resolved: return resolved return None def _get_decorator_obj_name(decorator) -> str: """Get the object name from a decorator call (e.g., 'app' from @app.route()).""" if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute): value = decorator.func.value if isinstance(value, ast.Name): return value.id return '' def _get_call_func_name(call: ast.Call) -> str | None: """Get the function name from a Call node.""" if isinstance(call.func, ast.Name): return call.func.id if isinstance(call.func, ast.Attribute): return call.func.attr return None def _extract_add_url_rule(call: ast.Call, constant_map: dict[str, str]) -> dict | None: """Extract route info from a blueprint.add_url_rule() call.""" # First positional arg is the path if not call.args: return None path = _resolve_path_arg(call.args[0], constant_map) if not path: return None methods = ['GET'] handler = 'unknown' for kw in call.keywords: if kw.arg == 'methods' and isinstance(kw.value, ast.List): methods = [elt.value for elt in kw.value.elts if isinstance(elt, ast.Constant)] elif kw.arg == 'view_func': handler = _extract_view_func_name(kw.value) return {'path': path, 'methods': methods, 'handler': handler} def _extract_view_func_name(node: ast.expr) -> str: """Extract handler name from a view_func value.""" # ClassName.as_view('name') if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): if node.func.attr == 'as_view' and isinstance(node.func.value, ast.Name): return node.func.value.id # Direct function reference if isinstance(node, ast.Name): return node.id return 'unknown' def extract_fastapi_routes_from_file(file_path: Path) -> list[EndpointInfo]: """Parse a Python file and extract all FastAPI route decorators.""" routes = [] with open(file_path) as f: content = f.read() try: tree = ast.parse(content) except SyntaxError as e: print(f'Warning: Could not parse {file_path}: {e}', file=sys.stderr) return routes module_aliases = extract_imports(tree) # Find router prefix if this file defines an APIRouter router_prefix = '' router_names: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and isinstance(node.value, ast.Call): func = node.value.func func_name = None if isinstance(func, ast.Name): func_name = func.id elif isinstance(func, ast.Attribute): func_name = func.attr if func_name == 'APIRouter': router_names.add(target.id) for kw in node.value.keywords: if kw.arg == 'prefix' and isinstance(kw.value, ast.Constant): router_prefix = kw.value.value for node in ast.walk(tree): if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef): function_name = node.name function_lineno = node.lineno for decorator in node.decorator_list: route_info = _extract_fastapi_route(decorator, router_names) if route_info: visitor = FunctionCallVisitor(module_aliases) visitor.visit(node) path = router_prefix + route_info['path'] routes.append( EndpointInfo( method=route_info['method'], path=path, handler=function_name, file=file_path.name, line=function_lineno, function_calls=list(set(visitor.function_calls)), ) ) return routes def _extract_fastapi_route(decorator, router_names: set[str]) -> dict | None: """Extract route info from FastAPI decorators like @app.get(), @router.post().""" if not isinstance(decorator, ast.Call): return None func = decorator.func if not isinstance(func, ast.Attribute): return None method = func.attr.upper() if method not in ('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'): return None # Check the object is app, router, or a known router name if isinstance(func.value, ast.Name): obj_name = func.value.id if obj_name not in ('app', 'router') and obj_name not in router_names: return None else: return None path = '' if decorator.args and isinstance(decorator.args[0], ast.Constant): path = decorator.args[0].value return {'method': method, 'path': path} def detect_framework(service_path: Path) -> str: """Detect whether a service uses Flask or FastAPI.""" for py_file in service_path.rglob('*.py'): path_str = str(py_file) if any(skip in path_str for skip in ['test_', 'tests/', '__pycache__', '/env/', '/venv/', '/.venv/', '/site-packages/']): continue try: content = py_file.read_text() except OSError: continue if 'FastAPI' in content or 'fastapi' in content: if 'from fastapi' in content or 'import fastapi' in content: return 'fastapi' return 'flask' def get_fastapi_handler_files(base_path: Path) -> list[Path]: """Auto-discover FastAPI route files.""" handler_files = [] search_patterns = [ '**/router.py', '**/routes.py', '**/routes/*.py', '**/routers.py', '**/routers/*.py', '**/endpoints.py', '**/endpoints/*.py', '**/api.py', '**/api/*.py', '**/handlers.py', '**/handlers/*.py', '**/main.py', '**/app.py', ] for pattern in search_patterns: for path in base_path.glob(pattern): path_str = str(path) if any(skip in path_str for skip in ['test_', 'tests/', '__pycache__', '/env/', '/venv/', '/.venv/', '/site-packages/']): continue if path not in handler_files: handler_files.append(path) return sorted(handler_files) def normalize_path_for_datadog(path: str) -> str: """Convert Flask path params to Datadog resource name format.""" normalized = re.sub(r'<(?:string:|int:|uuid:)?([^>]+)>', r'{\1}', path) normalized = re.sub(r'<\*>', '{path}', normalized) return normalized def normalize_code_endpoint(method: str, path: str) -> str: """Normalize code endpoint to match Datadog format.""" method_lower = method.lower() normalized_path = re.sub(r'\{([^}]+)\}', r'_\1', path) normalized_path = re.sub(r'<([^>]+)>', r'_\1', normalized_path) return f'{method_lower}_{normalized_path}' def get_handler_files(base_path: Path) -> list[Path]: """Auto-discover handler files in the service directory. Searches for Python files containing route definitions in common locations. """ handler_files = [] # Common directory patterns where handlers might live search_patterns = [ '**/handlers.py', '**/*_handlers.py', '**/*_handler.py', '**/handlers/*.py', '**/routes.py', '**/views.py', '**/blueprints/*.py', ] for pattern in search_patterns: for path in base_path.glob(pattern): # Skip test files, __pycache__, and virtual environments path_str = str(path) if any(skip in path_str for skip in ['test_', 'tests/', '__pycache__', '/env/', '/venv/', '/.venv/', '/site-packages/']): continue if path not in handler_files: handler_files.append(path) return sorted(handler_files) def parse_datadog_export(csv_path: Path) -> dict[str, dict]: """Parse the Datadog export CSV into a lookup dict.""" datadog_data = {} with open(csv_path) as f: reader = csv.DictReader(f) for row in reader: resource_name = list(row.values())[0] normalized = resource_name.lower().strip() requests_col = list(row.values())[1] try: requests = int(float(requests_col)) if requests_col and requests_col != 'null' else 0 except (ValueError, TypeError): requests = 0 datadog_data[normalized] = { 'original_name': resource_name, 'requests': requests, } return datadog_data def find_best_datadog_match(code_normalized: str, datadog_names: set[str]) -> str | None: """Find the best matching Datadog resource for a code endpoint. Returns the matched resource name, or None. Tries progressively looser matching strategies. """ # 1. Direct match if code_normalized in datadog_names: return code_normalized # 2. Without type hints (int:, string:, etc.) simplified = re.sub(r'_(int|string|uuid):', '_', code_normalized) if simplified in datadog_names: return simplified # 3. Pattern match (ignore param names) code_pattern = re.sub(r'_[a-z_]+(?=(/|$))', '_*', code_normalized) for dd_name in datadog_names: dd_pattern = re.sub(r'_[a-z_]+(?=(/|$))', '_*', dd_name) if code_pattern == dd_pattern: return dd_name # 4. Signature match: generalize dynamic segments (actual IDs, UUIDs) to wildcards code_sig = _build_path_signature(code_normalized) for dd_name in datadog_names: dd_sig = _build_path_signature(dd_name) if code_sig == dd_sig: return dd_name return None def _build_path_signature(resource: str) -> str: """Build a matching signature by replacing dynamic segments with *. Handles both template params (_identity_id, _int:product_id_) and actual values (12345, UUIDs, long hashes). """ if '_/' not in resource: return resource method_part, _, path = resource.partition('_/') segments = path.split('/') normalized = [] for seg in segments: if ( seg.startswith('_') # Template param: _identity_id, _int:product_id_ or re.match(r'^\d+$', seg) # Pure numeric ID or re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}', seg, re.I) # UUID prefix or (len(seg) > 24 and re.match(r'^[0-9a-zA-Z_-]+$', seg)) # Long hash/token ): normalized.append('*') else: normalized.append(seg) return f'{method_part}_/' + '/'.join(normalized) def analyze_endpoints( endpoints: list[EndpointInfo], datadog_names: set[str], ) -> list[EndpointAnalysis]: """Analyze endpoints against Datadog resource names.""" results = [] for ep in endpoints: normalized = normalize_code_endpoint(ep.method, ep.path) match = find_best_datadog_match(normalized, datadog_names) in_datadog = match is not None results.append( EndpointAnalysis( method=ep.method, path=ep.path, handler=ep.handler, file=ep.file, line=ep.line, datadog_resource_name=match, in_datadog=in_datadog, status='used' if in_datadog else 'unused', function_calls=ep.function_calls, ) ) return results def extract_logic_to_model_calls(service_path: Path) -> dict[str, list[str]]: """Parse logic files and extract their calls to model modules. Returns mapping: logic_function -> list of model functions it calls. """ logic_to_models: dict[str, list[str]] = defaultdict(list) # Find all logic files logic_dir = None for subdir in service_path.iterdir(): if subdir.is_dir() and not subdir.name.startswith('.'): potential_logic = subdir / 'logic' if potential_logic.exists(): logic_dir = potential_logic break if not logic_dir: return logic_to_models for logic_file in logic_dir.glob('*.py'): if logic_file.name.startswith('__'): continue try: with open(logic_file) as f: content = f.read() tree = ast.parse(content) except (SyntaxError, OSError): continue # Extract model imports model_aliases = {} for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): if node.module and '.models' in node.module: for alias in node.names: name = alias.asname if alias.asname else alias.name parts = node.module.split('.') if len(parts) >= 2: module_path = '/'.join(parts[1:]) if alias.name != '*': module_path = f'{module_path}/{alias.name}' model_aliases[name] = module_path + '.py' # For each function in this logic file, extract model calls logic_module_path = f'logic/{logic_file.stem}.py' for node in ast.walk(tree): if isinstance(node, ast.FunctionDef): func_name = node.name logic_func_key = f'{logic_module_path}::{func_name}' visitor = FunctionCallVisitor(model_aliases) visitor.visit(node) for model_call in visitor.function_calls: if 'models/' in model_call: logic_to_models[logic_func_key].append(model_call) return logic_to_models def find_dead_functions( analyses: list[EndpointAnalysis], service_path: Path | None = None, ) -> None: """Identify functions that are only used by dead endpoints. Mutates the analyses to add dead_functions list to dead/not_in_datadog endpoints. If service_path is provided, also performs transitive analysis to find model methods that are only called by dead logic methods. """ # Build mapping: function -> list of endpoints that use it function_to_endpoints: dict[str, list[EndpointAnalysis]] = defaultdict(list) for analysis in analyses: for func in analysis.function_calls: function_to_endpoints[func].append(analysis) # For each unused endpoint, find functions that are only used by unused endpoints dead_statuses = {'unused'} # First pass: identify directly dead logic/model functions dead_logic_functions: set[str] = set() for analysis in analyses: if analysis.status in dead_statuses: dead_funcs = [] for func in analysis.function_calls: using_endpoints = function_to_endpoints[func] # Check if ALL endpoints using this function are dead all_dead = all(ep.status in dead_statuses for ep in using_endpoints) if all_dead: dead_funcs.append(func) if 'logic/' in func: dead_logic_functions.add(func) analysis.dead_functions = sorted(set(dead_funcs)) # Second pass: find model methods only called by dead logic methods if service_path: logic_to_models = extract_logic_to_model_calls(service_path) # Build reverse mapping: model_function -> logic functions that call it model_to_callers: dict[str, set[str]] = defaultdict(set) # Add calls from handlers (already tracked in function_to_endpoints) for func, endpoints in function_to_endpoints.items(): if 'models/' in func: for ep in endpoints: # Track the handler as a caller model_to_callers[func].add(f'handler::{ep.handler}') # Add calls from logic functions for logic_func, model_calls in logic_to_models.items(): for model_func in model_calls: model_to_callers[model_func].add(logic_func) # Find model functions where ALL callers are dead transitively_dead_models: set[str] = set() for model_func, callers in model_to_callers.items(): all_callers_dead = True for caller in callers: if caller.startswith('handler::'): # Handler caller - check if all endpoints with this handler are dead handler_name = caller.replace('handler::', '') handler_endpoints = [a for a in analyses if a.handler == handler_name] if any(ep.status not in dead_statuses for ep in handler_endpoints): all_callers_dead = False break else: # Logic function caller - check if it's in dead_logic_functions if caller not in dead_logic_functions: all_callers_dead = False break if all_callers_dead and callers: # Has callers and all are dead transitively_dead_models.add(model_func) # Add transitively dead models to the dead_functions of relevant endpoints for analysis in analyses: if analysis.status in dead_statuses: # Find which transitively dead models are reachable from this endpoint's logic calls reachable_models = set() for logic_func in analysis.function_calls: if logic_func in logic_to_models: for model_func in logic_to_models[logic_func]: if model_func in transitively_dead_models: reachable_models.add(model_func) if reachable_models: analysis.dead_functions = sorted(set(analysis.dead_functions) | reachable_models) def write_all_endpoints_csv(analyses: list[EndpointAnalysis], output_path: Path): """Write all endpoints CSV with Datadog matching info.""" sorted_analyses = sorted(analyses, key=lambda x: (x.path, x.method)) with open(output_path, 'w', newline='') as f: writer = csv.writer(f) writer.writerow( [ 'method', 'path', 'resource_name', 'in_datadog', 'handler', 'file', 'line', ] ) for a in sorted_analyses: writer.writerow( [ a.method, a.path, a.datadog_resource_name or '', 'yes' if a.in_datadog else 'no', a.handler, a.file, a.line, ] ) def write_dead_endpoints_csv(analyses: list[EndpointAnalysis], output_path: Path): """Write unused endpoints CSV with dead functions.""" unused_analyses = [a for a in analyses if a.status == 'unused'] unused_analyses.sort(key=lambda x: (x.path, x.method)) with open(output_path, 'w', newline='') as f: writer = csv.writer(f) writer.writerow( [ 'status', 'method', 'path', 'resource_name', 'dead_functions', ] ) for a in unused_analyses: writer.writerow( [ a.status, a.method, a.path, a.datadog_resource_name or '', '; '.join(a.dead_functions) if a.dead_functions else '', ] ) def compute_summary(analyses: list[EndpointAnalysis]) -> dict: """Compute summary statistics.""" total = len(analyses) used = len([a for a in analyses if a.status == 'used']) unused = len([a for a in analyses if a.status == 'unused']) # Collect all unique functions and dead functions all_functions = set() all_dead_funcs = set() for a in analyses: all_functions.update(a.function_calls) if a.status == 'unused': all_dead_funcs.update(a.dead_functions) total_functions = len(all_functions) dead_functions_count = len(all_dead_funcs) dead_code_pct = (dead_functions_count / total_functions * 100) if total_functions > 0 else 0 # Calculate percentages safely (handle zero total) def pct(count: int) -> float: return round(count / total * 100, 1) if total > 0 else 0.0 return { 'endpoints': { 'total': total, 'used': {'count': used, 'percent': pct(used)}, 'unused': {'count': unused, 'percent': pct(unused)}, }, 'functions': { 'total': total_functions, 'dead': dead_functions_count, 'dead_percent': round(dead_code_pct, 1), }, 'dead_functions': sorted(all_dead_funcs), } def write_summary_json(summary: dict, output_path: Path): """Write summary to JSON file.""" with open(output_path, 'w') as f: json.dump(summary, f, indent=2) def print_summary(summary: dict): """Print analysis summary.""" ep = summary['endpoints'] fn = summary['functions'] print('\n' + '=' * 70) print('ENDPOINT ANALYSIS SUMMARY') print('=' * 70) print(f'\nTotal endpoints in code: {ep["total"]}') print(f'\n Used (in Datadog): {ep["used"]["count"]:3d} ({ep["used"]["percent"]}%)') print(f' Unused (not in Datadog): {ep["unused"]["count"]:3d} ({ep["unused"]["percent"]}%)') print(f'\n{"=" * 70}') print('DEAD CODE ANALYSIS') print('=' * 70) print(f'\n Total functions tracked: {fn["total"]}') print(f' Dead functions: {fn["dead"]} ({fn["dead_percent"]}%)') if summary['dead_functions']: print(f'\n{"=" * 70}') print(f'POTENTIALLY DEAD FUNCTIONS ({fn["dead"]} total)') print('=' * 70) for func in summary['dead_functions']: print(f' {func}') print('\n' + '=' * 70) def run_analysis( service_path: Path, datadog_names: set[str], framework: str = 'flask', constant_map: dict[str, str] | None = None, ) -> tuple[list[EndpointAnalysis], dict]: """Programmatic entry point for the analysis pipeline. Args: service_path: Path to the service codebase. datadog_names: Set of normalized resource names present in Datadog. framework: 'flask' or 'fastapi'. constant_map: Pre-built constant map, or None to auto-build from service config files. Returns: Tuple of (analyses list, summary dict). """ # Build constant map from config files if not provided if constant_map is None: from src.constant_resolver import build_constant_map constant_map = build_constant_map(service_path) if framework == 'fastapi': handler_files = get_fastapi_handler_files(service_path) all_endpoints = [] for handler_file in handler_files: routes = extract_fastapi_routes_from_file(handler_file) all_endpoints.extend(routes) else: handler_files = get_handler_files(service_path) all_endpoints = [] for handler_file in handler_files: routes = extract_routes_from_file(handler_file, constant_map=constant_map) all_endpoints.extend(routes) analyses = analyze_endpoints(all_endpoints, datadog_names) find_dead_functions(analyses, service_path=service_path) summary = compute_summary(analyses) return analyses, summary def main(): parser = argparse.ArgumentParser(description='Analyze endpoints and identify dead code') parser.add_argument( '--service', type=str, required=True, help='Service name. Data will be read/written in data/ows-/', ) parser.add_argument( '--datadog-export', type=Path, required=True, help='Path to CSV file exported from Datadog APM (active endpoints)', ) parser.add_argument( '--service-path', type=Path, required=True, help='Path to the service codebase (e.g., /path/to/ows-users)', ) args = parser.parse_args() script_path = Path(__file__).resolve() project_root = args.service_path # Set up data directory for this service data_dir = script_path.parent.parent / 'data' / f'ows-{args.service}' data_dir.mkdir(parents=True, exist_ok=True) # Extract routes from handler files print(f'Extracting endpoints from: {project_root}') all_endpoints = [] handler_files = get_handler_files(project_root) for handler_file in handler_files: print(f' Parsing {handler_file.name}...') routes = extract_routes_from_file(handler_file) all_endpoints.extend(routes) print(f' Found {len(all_endpoints)} endpoints in code') # Load Datadog data print(f'\nLoading Datadog export from: {args.datadog_export}') datadog_data = parse_datadog_export(args.datadog_export) datadog_names = set(datadog_data.keys()) print(f' Found {len(datadog_names)} resources in Datadog') # Analyze endpoints print('\nAnalyzing endpoints...') analyses = analyze_endpoints(all_endpoints, datadog_names) # Find dead functions (including transitive model methods) print('Identifying potentially dead functions...') find_dead_functions(analyses, service_path=project_root) # Write output files all_endpoints_csv = data_dir / 'all_endpoints.csv' dead_endpoints_csv = data_dir / 'dead_endpoints.csv' summary_json = data_dir / 'summary.json' write_all_endpoints_csv(analyses, all_endpoints_csv) print(f'\nAll endpoints written to: {all_endpoints_csv}') write_dead_endpoints_csv(analyses, dead_endpoints_csv) print(f'Dead endpoints written to: {dead_endpoints_csv}') # Compute and write summary summary = compute_summary(analyses) write_summary_json(summary, summary_json) print(f'Summary written to: {summary_json}') # Print summary print_summary(summary) if __name__ == '__main__': main()