.pydev-tools / _ast / rename.py
.pydev-tools / _ast / rename.py
#!/usr/bin/env python3
"""Safely rename a function, class or variable across the project via AST rebase.
Usage:
rename.py <source_path> <old_name> <new_name> [kind] [target_path]
source_path : path to a single .py file OR a directory to scan (default: ".").
target_path : directory in which to look for call sites / references
(default: the directory of source_path, or ".").
Unlike a naive string replace this tool walks the Python AST so it only touches real
definitions and real references -- never comments, docstrings, string literals or the
definition node itself. The output preserves the original formatting by rewriting only
the affected source lines.
Output (JSON):
{
"renamedFrom": "...", "renamedTo": "...",
"renamedLines": [1-based line numbers changed],
"totalOccurrences": <number of reference/definition lines rewritten>,
"scannedFiles": <number of files examined>
}
"""
import ast
import json
import os
import re
import sys
def load_all_py(directory):
"""Return a sorted list of all .py files under directory (non-recursive)."""
if not os.path.isdir(directory):
return []
return sorted(
os.path.join(directory, fn)
for fn in os.listdir(directory)
if fn.endswith(".py")
)
def find_defs(tree, old_name):
"""Return the AST nodes that DEFINE ``old_name`` (def/class/assign/annassign)."""
defs = []
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == old_name:
defs.append(node)
elif isinstance(node, ast.ClassDef) and node.name == old_name:
defs.append(node)
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
target = node.target if isinstance(node, ast.AnnAssign) else node.targets[0]
if isinstance(target, ast.Name) and target.id == old_name:
defs.append(node)
return defs
def collect_lines(tree, old_name, new_name):
"""Return the 0-based source lines that must be rewritten.
This is an AST-driven view so it captures BOTH definitions and every reference
(call sites, attribute accesses, annotations, comprehensions, etc.) while skipping
the definition target node itself (its name is updated via the def node).
"""
# Identity set of the exact definition target Name nodes so references are not
# confused with the definition.
def_target_ids = set()
for d in find_defs(tree, old_name):
if isinstance(d, (ast.Assign, ast.AnnAssign)):
target = d.target if isinstance(d, ast.AnnAssign) else d.targets[0]
if isinstance(target, ast.Name):
def_target_ids.add(id(target))
lines = set()
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
if node.name == old_name:
lines.add(node.lineno - 1)
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
target = node.target if isinstance(node, ast.AnnAssign) else node.targets[0]
if isinstance(target, ast.Name) and target.id == old_name:
lines.add(target.lineno - 1)
elif isinstance(node, ast.Name) and node.id == old_name and id(node) not in def_target_ids:
lines.add(node.lineno - 1)
return sorted(lines)
def main():
try:
src_path = sys.argv[1]
old_name = sys.argv[2]
new_name = sys.argv[3]
except IndexError:
json.dump({"error": "usage: rename.py <source_path> <old_name> <new_name>"}, sys.stdout)
sys.exit(0)
kind = sys.argv[4] if len(sys.argv) > 4 else "auto"
# When source is a directory, scan that very directory; otherwise default to the
# directory containing the single file.
scan_dir = sys.argv[5] if len(sys.argv) > 5 else (
src_path if os.path.isdir(src_path) else (os.path.dirname(src_path) if src_path else ".")
)
files = [src_path] if os.path.isfile(src_path) else load_all_py(scan_dir)
lines_to_rewrite = set()
total = 0
for f in files:
try:
with open(f, "rb") as fh:
raw = fh.read()
tree = ast.parse(raw.decode("utf-8"))
except (SyntaxError, UnicodeDecodeError, ValueError, OSError):
# Skip binary / non-UTF8 / malformed files gracefully.
continue
for li in collect_lines(tree, old_name, new_name):
raw_lines = raw.decode("utf-8").split("\n")
if (li + 1) < len(raw_lines):
line = raw_lines[li]
if re.search(r"\b" + re.escape(old_name) + r"\b", line):
new_line = re.sub(r"\b" + re.escape(old_name) + r"\b", new_name, line)
if new_line != line:
raw_lines[li] = new_line
lines_to_rewrite.add(li + 1)
total += 1
with open(f, "w", encoding="utf-8") as fh:
fh.write("\n".join(raw_lines))
result = {
"renamedFrom": old_name,
"renamedTo": new_name,
"renamedLines": sorted(lines_to_rewrite),
"totalOccurrences": total,
"scannedFiles": len(files),
}
json.dump(result, sys.stdout, indent=2)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Safely rename a function, class or variable across the project via AST rebase.
Usage:
rename.py <source_path> <old_name> <new_name> [kind] [target_path]
source_path : path to a single .py file OR a directory to scan (default: ".").
target_path : directory in which to look for call sites / references
(default: the directory of source_path, or ".").
Unlike a naive string replace this tool walks the Python AST so it only touches real
definitions and real references -- never comments, docstrings, string literals or the
definition node itself. The output preserves the original formatting by rewriting only
the affected source lines.
Output (JSON):
{
"renamedFrom": "...", "renamedTo": "...",
"renamedLines": [1-based line numbers changed],
"totalOccurrences": <number of reference/definition lines rewritten>,
"scannedFiles": <number of files examined>
}
"""
import ast
import json
import os
import re
import sys
def load_all_py(directory):
"""Return a sorted list of all .py files under directory (non-recursive)."""
if not os.path.isdir(directory):
return []
return sorted(
os.path.join(directory, fn)
for fn in os.listdir(directory)
if fn.endswith(".py")
)
def find_defs(tree, old_name):
"""Return the AST nodes that DEFINE ``old_name`` (def/class/assign/annassign)."""
defs = []
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == old_name:
defs.append(node)
elif isinstance(node, ast.ClassDef) and node.name == old_name:
defs.append(node)
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
target = node.target if isinstance(node, ast.AnnAssign) else node.targets[0]
if isinstance(target, ast.Name) and target.id == old_name:
defs.append(node)
return defs
def collect_lines(tree, old_name, new_name):
"""Return the 0-based source lines that must be rewritten.
This is an AST-driven view so it captures BOTH definitions and every reference
(call sites, attribute accesses, annotations, comprehensions, etc.) while skipping
the definition target node itself (its name is updated via the def node).
"""
# Identity set of the exact definition target Name nodes so references are not
# confused with the definition.
def_target_ids = set()
for d in find_defs(tree, old_name):
if isinstance(d, (ast.Assign, ast.AnnAssign)):
target = d.target if isinstance(d, ast.AnnAssign) else d.targets[0]
if isinstance(target, ast.Name):
def_target_ids.add(id(target))
lines = set()
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
if node.name == old_name:
lines.add(node.lineno - 1)
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
target = node.target if isinstance(node, ast.AnnAssign) else node.targets[0]
if isinstance(target, ast.Name) and target.id == old_name:
lines.add(target.lineno - 1)
elif isinstance(node, ast.Name) and node.id == old_name and id(node) not in def_target_ids:
lines.add(node.lineno - 1)
return sorted(lines)
def main():
try:
src_path = sys.argv[1]
old_name = sys.argv[2]
new_name = sys.argv[3]
except IndexError:
json.dump({"error": "usage: rename.py <source_path> <old_name> <new_name>"}, sys.stdout)
sys.exit(0)
kind = sys.argv[4] if len(sys.argv) > 4 else "auto"
# When source is a directory, scan that very directory; otherwise default to the
# directory containing the single file.
scan_dir = sys.argv[5] if len(sys.argv) > 5 else (
src_path if os.path.isdir(src_path) else (os.path.dirname(src_path) if src_path else ".")
)
files = [src_path] if os.path.isfile(src_path) else load_all_py(scan_dir)
lines_to_rewrite = set()
total = 0
for f in files:
try:
with open(f, "rb") as fh:
raw = fh.read()
tree = ast.parse(raw.decode("utf-8"))
except (SyntaxError, UnicodeDecodeError, ValueError, OSError):
# Skip binary / non-UTF8 / malformed files gracefully.
continue
for li in collect_lines(tree, old_name, new_name):
raw_lines = raw.decode("utf-8").split("\n")
if (li + 1) < len(raw_lines):
line = raw_lines[li]
if re.search(r"\b" + re.escape(old_name) + r"\b", line):
new_line = re.sub(r"\b" + re.escape(old_name) + r"\b", new_name, line)
if new_line != line:
raw_lines[li] = new_line
lines_to_rewrite.add(li + 1)
total += 1
with open(f, "w", encoding="utf-8") as fh:
fh.write("\n".join(raw_lines))
result = {
"renamedFrom": old_name,
"renamedTo": new_name,
"renamedLines": sorted(lines_to_rewrite),
"totalOccurrences": total,
"scannedFiles": len(files),
}
json.dump(result, sys.stdout, indent=2)
if __name__ == "__main__":
main()