"""Resolve route path constants from config files. Handles patterns like: FEEDS_PATH = "/feeds" ACCOUNT_METRICS_PATH = "/account/" ACCOUNT_PRODUCTS_PATH = _account_metrics("/products") # → /account//products Where helpers follow: def _account_metrics(path): return _concat(ACCOUNT_METRICS_PATH, path) """ import ast from pathlib import Path SKIP_DIRS = {'test_', 'tests/', '__pycache__', '/env/', '/venv/', '/.venv/', '/site-packages/'} def build_constant_map(service_path: Path) -> dict[str, str]: """Find all config/constants files in a service and resolve string constants.""" combined: dict[str, str] = {} for py_file in service_path.rglob('*.py'): path_str = str(py_file) if any(skip in path_str for skip in SKIP_DIRS): continue # Only parse files likely to contain config constants name = py_file.stem if name not in ('config', 'constants', 'urls', 'settings', 'conf'): continue resolved = resolve_constants(py_file) combined.update(resolved) return combined def resolve_constants(config_path: Path) -> dict[str, str]: """Parse a Python config file and resolve path constants to string values. Processes nodes top-to-bottom so that constants defined earlier are available when resolving later ones that reference them via helper functions. """ try: content = config_path.read_text() tree = ast.parse(content) except (SyntaxError, OSError): return {} constants: dict[str, str] = {} helpers: dict[str, str] = {} # func_name -> base_constant_name for node in ast.iter_child_nodes(tree): if isinstance(node, ast.FunctionDef): base = _extract_helper_base(node) if base is not None: helpers[node.name] = base elif isinstance(node, ast.Assign): _process_assignment(node, constants, helpers) elif isinstance(node, ast.ClassDef): # Handle class-level constants: class Config: HEALTH_CHECK = '/hello/' for class_node in ast.iter_child_nodes(node): if isinstance(class_node, ast.Assign): _process_assignment(class_node, constants, helpers) elif isinstance(class_node, ast.FunctionDef): base = _extract_helper_base(class_node) if base is not None: helpers[class_node.name] = base return constants def _process_assignment(node: ast.Assign, constants: dict[str, str], helpers: dict[str, str]): """Process a single assignment node, resolving to a string if possible.""" for target in node.targets: if not isinstance(target, ast.Name): continue name = target.id # Simple string: NAME = "/path" if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): constants[name] = node.value.value # Function call: NAME = _helper("/suffix") or _concat(A, B) elif isinstance(node.value, ast.Call): resolved = _resolve_call(node.value, constants, helpers) if resolved is not None: constants[name] = resolved # JoinedStr (f-string): NAME = f"{BASE}{path}" elif isinstance(node.value, ast.JoinedStr): resolved = _resolve_fstring(node.value, constants) if resolved is not None: constants[name] = resolved def _resolve_call(call: ast.Call, constants: dict[str, str], helpers: dict[str, str]) -> str | None: """Try to resolve a function call to a string value.""" func_name = None if isinstance(call.func, ast.Name): func_name = call.func.id if not func_name: return None # Known helper: def _X(path): return _concat(BASE_CONST, path) if func_name in helpers: base_const = helpers[func_name] base_value = constants.get(base_const) if base_value is not None and call.args: arg_value = _resolve_arg(call.args[0], constants) if arg_value is not None: return base_value + arg_value return None # Direct _concat(a, b) call if func_name == '_concat' and len(call.args) == 2: a = _resolve_arg(call.args[0], constants) b = _resolve_arg(call.args[1], constants) if a is not None and b is not None: return a + b return None def _resolve_arg(node: ast.expr, constants: dict[str, str]) -> str | None: """Resolve a function argument to a string value.""" if isinstance(node, ast.Constant) and isinstance(node.value, str): return node.value if isinstance(node, ast.Name) and node.id in constants: return constants[node.id] return None def _resolve_fstring(node: ast.JoinedStr, constants: dict[str, str]) -> str | None: """Try to resolve an f-string to a static string value.""" parts = [] for value in node.values: if isinstance(value, ast.Constant) and isinstance(value.value, str): parts.append(value.value) elif isinstance(value, ast.FormattedValue): inner = value.value if isinstance(inner, ast.Name) and inner.id in constants: parts.append(constants[inner.id]) else: return None # Can't resolve dynamic parts else: return None return ''.join(parts) def _extract_helper_base(func_def: ast.FunctionDef) -> str | None: """Check if a function follows: def _X(path): return _concat(BASE, path). Returns the BASE constant name, or None. """ # Must have exactly one parameter if len(func_def.args.args) != 1: return None param_name = func_def.args.args[0].arg # Body must be a single return statement if len(func_def.body) != 1 or not isinstance(func_def.body[0], ast.Return): return None ret_val = func_def.body[0].value if not isinstance(ret_val, ast.Call): return None func = ret_val.func if not (isinstance(func, ast.Name) and func.id == '_concat'): return None if len(ret_val.args) != 2: return None # First arg: a constant name reference first_arg = ret_val.args[0] if not isinstance(first_arg, ast.Name): return None # Second arg: the function parameter second_arg = ret_val.args[1] if not (isinstance(second_arg, ast.Name) and second_arg.id == param_name): return None return first_arg.id