241 lines
8.4 KiB
Python
Executable file
241 lines
8.4 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
|
|
import ast
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Dict, Set
|
|
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent / "bouquin"
|
|
LOCALES_DIR = BASE_DIR / "locales"
|
|
|
|
DEFAULT_LOCALE = "en"
|
|
|
|
|
|
def load_json_keys(locale: str = DEFAULT_LOCALE) -> Set[str]:
|
|
"""Load all keys from the given locale JSON file."""
|
|
path = LOCALES_DIR / f"{locale}.json"
|
|
with path.open(encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
return set(data.keys())
|
|
|
|
|
|
class KeyParamFinder(ast.NodeVisitor):
|
|
"""
|
|
First pass:
|
|
For each function/method, figure out which parameters are later passed
|
|
into _(), translated(), or strings._().
|
|
|
|
Example: in your _prompt_name, it discovers that title_key and label_key
|
|
are translation-key parameters.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
# func_name -> {"param_positions": {param: arg_index}, "key_param_positions": set[arg_index]}
|
|
self.func_info: Dict[str, dict] = {}
|
|
self.current_func_name_stack: list[str] = []
|
|
self.current_param_positions_stack: list[Dict[str, int]] = []
|
|
self.current_class_stack: list[str] = []
|
|
|
|
# Track when we're inside a class so we can treat "self" specially
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
self.current_class_stack.append(node.name)
|
|
self.generic_visit(node)
|
|
self.current_class_stack.pop()
|
|
|
|
def _enter_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
funcname = node.name
|
|
params = [arg.arg for arg in node.args.args]
|
|
|
|
# If we're inside a class and there is at least one param,
|
|
# assume the first one is "self"/"cls" and is implicit at call sites.
|
|
is_method = bool(self.current_class_stack) and len(params) > 0
|
|
|
|
param_positions: Dict[str, int] = {}
|
|
for i, name in enumerate(params):
|
|
if is_method and i == 0:
|
|
# skip self/cls; it doesn't correspond to an explicit arg in calls like self.method(...)
|
|
continue
|
|
call_index = i - 1 if is_method else i
|
|
param_positions[name] = call_index
|
|
|
|
self.current_func_name_stack.append(funcname)
|
|
self.current_param_positions_stack.append(param_positions)
|
|
|
|
self.func_info.setdefault(funcname, {
|
|
"param_positions": param_positions,
|
|
"key_param_positions": set(),
|
|
})
|
|
# If the function name is reused, last definition wins
|
|
self.func_info[funcname]["param_positions"] = param_positions
|
|
|
|
def _exit_function(self) -> None:
|
|
self.current_func_name_stack.pop()
|
|
self.current_param_positions_stack.pop()
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
self._enter_function(node)
|
|
self.generic_visit(node)
|
|
self._exit_function()
|
|
|
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
self._enter_function(node)
|
|
self.generic_visit(node)
|
|
self._exit_function()
|
|
|
|
def visit_Call(self, node: ast.Call) -> None:
|
|
# Only care about calls *inside* functions
|
|
if not self.current_func_name_stack:
|
|
return self.generic_visit(node)
|
|
|
|
func = node.func
|
|
func_name: str | None = None
|
|
|
|
if isinstance(func, ast.Name):
|
|
func_name = func.id
|
|
elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
# e.g. strings._(...)
|
|
func_name = f"{func.value.id}.{func.attr}"
|
|
|
|
# Is this a translation call?
|
|
if func_name in {"_", "translated", "strings._"}:
|
|
cur_name = self.current_func_name_stack[-1]
|
|
param_positions = self.current_param_positions_stack[-1]
|
|
|
|
# Positional first arg
|
|
if node.args:
|
|
first = node.args[0]
|
|
if isinstance(first, ast.Name):
|
|
pname = first.id
|
|
if pname in param_positions:
|
|
idx = param_positions[pname]
|
|
self.func_info[cur_name]["key_param_positions"].add(idx)
|
|
|
|
# Keyword args, e.g. strings._(key=title_key)
|
|
for kw in node.keywords or []:
|
|
if isinstance(kw.value, ast.Name):
|
|
pname = kw.value.id
|
|
if pname in param_positions:
|
|
idx = param_positions[pname]
|
|
self.func_info[cur_name]["key_param_positions"].add(idx)
|
|
|
|
self.generic_visit(node)
|
|
|
|
|
|
class UsedKeyCollector(ast.NodeVisitor):
|
|
"""
|
|
Second pass:
|
|
- Collect string literals passed directly to _()/translated()/strings._()
|
|
- Collect string literals passed into parameters that we know are
|
|
"translation-key parameters" of wrapper functions/methods.
|
|
"""
|
|
|
|
def __init__(self, func_info: Dict[str, dict]) -> None:
|
|
self.func_info = func_info
|
|
self.used_keys: Set[str] = set()
|
|
|
|
def visit_Call(self, node: ast.Call) -> None:
|
|
func = node.func
|
|
|
|
def full_name(f: ast.expr) -> str | None:
|
|
if isinstance(f, ast.Name):
|
|
return f.id
|
|
if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name):
|
|
return f"{f.value.id}.{f.attr}"
|
|
return None
|
|
|
|
func_full = full_name(func)
|
|
|
|
# 1) Direct translation calls like _("key") or strings._("key")
|
|
if func_full in {"_", "translated", "strings._"}:
|
|
if node.args:
|
|
first = node.args[0]
|
|
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
self.used_keys.add(first.value)
|
|
for kw in node.keywords or []:
|
|
if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
self.used_keys.add(kw.value.value)
|
|
|
|
# 2) Wrapper calls: functions whose params we know are translation-key params
|
|
called_base_name: str | None = None
|
|
if isinstance(func, ast.Name):
|
|
called_base_name = func.id
|
|
elif isinstance(func, ast.Attribute):
|
|
called_base_name = func.attr # e.g. self._prompt_name -> "_prompt_name"
|
|
|
|
if called_base_name in self.func_info:
|
|
info = self.func_info[called_base_name]
|
|
param_positions: Dict[str, int] = info["param_positions"]
|
|
key_positions: Set[int] = info["key_param_positions"]
|
|
|
|
# positional args
|
|
for idx, arg in enumerate(node.args):
|
|
if idx in key_positions and isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
self.used_keys.add(arg.value)
|
|
|
|
# keyword args
|
|
for kw in node.keywords or []:
|
|
if kw.arg is None:
|
|
continue # **kwargs, ignore
|
|
param_name = kw.arg
|
|
if param_name in param_positions:
|
|
idx = param_positions[param_name]
|
|
if idx in key_positions:
|
|
val = kw.value
|
|
if isinstance(val, ast.Constant) and isinstance(val.value, str):
|
|
self.used_keys.add(val.value)
|
|
|
|
self.generic_visit(node)
|
|
|
|
|
|
def collect_used_keys() -> Set[str]:
|
|
"""Parse all .py files and collect all translation keys used."""
|
|
trees: list[ast.AST] = []
|
|
|
|
# Read and parse all Python files in this folder
|
|
for path in BASE_DIR.glob("*.py"):
|
|
# Optionally skip this script itself
|
|
if path.name == Path(__file__).name:
|
|
continue
|
|
src = path.read_text(encoding="utf-8")
|
|
tree = ast.parse(src, filename=str(path))
|
|
trees.append(tree)
|
|
|
|
# First pass: find which parameters are translation-key params
|
|
finder = KeyParamFinder()
|
|
for tree in trees:
|
|
finder.visit(tree)
|
|
|
|
# Second pass: collect string literals passed to those parameters
|
|
collector = UsedKeyCollector(finder.func_info)
|
|
for tree in trees:
|
|
collector.visit(tree)
|
|
|
|
return collector.used_keys
|
|
|
|
|
|
def main() -> None:
|
|
json_keys = load_json_keys()
|
|
used_keys = collect_used_keys()
|
|
|
|
unused_keys = sorted(json_keys - used_keys)
|
|
missing_in_json = sorted(used_keys - json_keys)
|
|
|
|
print("=== Unused keys in JSON (present in locales but never used in code) ===")
|
|
if unused_keys:
|
|
for k in unused_keys:
|
|
print(" ", k)
|
|
else:
|
|
print(" (none)")
|
|
|
|
print("\n=== Keys used in code but missing from JSON ===")
|
|
if missing_in_json:
|
|
for k in missing_in_json:
|
|
print(" ", k)
|
|
else:
|
|
print(" (none)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|