.pydev-tools / _ast / extract.py
.pydev-tools / _ast / extract.py
#!/usr/bin/env python3
"""Extract a selected block from a top-level function into its own definition.
Robust line-based rewrite (no ast.fix_locations, removed in Python 3.14): AST is
used only to locate the function and split point; the file is rebuilt by line
surgery, collecting full spans of each statement including nested compound bodies.
Usage: extract.py <source.py> <target_line> [new_function_name]
Emits JSON with the rewritten source and metadata.
"""
import ast, json, re, sys
src = open(sys.argv[1], "r", encoding="utf-8").read()
lines = src.split("\n")
tree = ast.parse(src)
target_line = int(sys.argv[2])
new_name = sys.argv[3] if len(sys.argv) > 3 else None
def find_innermost(node):
"""Return the innermost (deepest) function whose span contains target_line."""
matches = []
for n in ast.walk(node):
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
if n.lineno <= target_line <= getattr(n, "end_lineno", n.lineno + 1):
matches.append(n)
return max(matches, key=lambda f: f.lineno) if matches else None
def collect_indices(node):
"""All source line indices (0-based) covered by a node incl. nested blocks."""
idxs = []
def walk(n):
idxs.append(n.lineno - 1)
for field in ("body", "orelse", "finalbody", "handlers",
"decorator_list", "elif_else_branches"):
val = getattr(n, field, None)
if isinstance(val, list):
for item in val:
walk(item)
elif hasattr(val, "lineno"):
walk(val)
walk(node)
return idxs
func = find_innermost(tree)
if func is None:
json.dump({"error": "no_function_at_target", "target_line": target_line}, sys.stdout)
sys.exit(0)
def_line = func.lineno
body = list(func.body)
lend = getattr(body[-1], "end_lineno", func.lineno + 1)
# Split by statement START line: statements beginning at/before target go into the
# new function (tail); those after stay as module-level code (head).
tail_stmts = [st for st in body if getattr(st, "lineno", 0) <= target_line]
head_stmts = [st for st in body if getattr(st, "lineno", 0) > target_line]
if not new_name or not new_name.strip():
json.dump({"error": "no_new_function_name"}, sys.stdout)
sys.exit(0)
# Full line indices for each side (nested bodies included).
tail_indices = set()
for st in tail_stmts:
tail_indices.update(collect_indices(st))
head_indices = set()
for st in head_stmts:
head_indices.update(collect_indices(st))
if not new_name or not new_name.strip():
json.dump({"error": "no_new_function_name"}, sys.stdout)
sys.exit(0)
# Build new header from the original def line, swapping the function name.
orig_def_line = lines[def_line - 1]
new_header = re.sub(r"\b" + re.escape(func.name) + r"\b", new_name.strip(), orig_def_line)
# Rebuild file: before-def + new header + extracted tail + demoted head + rest.
new_lines = lines[:def_line - 1]
new_lines.append(new_header)
for idx in sorted(tail_indices):
new_lines.append(lines[idx])
# Demote head statements from the function body back to module level (strip one indent).
head_block = [lines[i] for i in sorted(head_indices)]
demoted_head = [ln[4:] if ln.startswith(" ") and ln.strip() else ln for ln in head_block]
new_lines.extend(demoted_head)
new_lines.extend(lines[lend:]) # everything after the function
# Trim trailing blank lines introduced by the move.
while new_lines and new_lines[-1] == "":
new_lines.pop()
out = {
"source": "\n".join(new_lines),
"newFunctionName": new_name.strip(),
"originalFunction": func.name,
"defLine": def_line,
"targetLine": target_line,
"extractedLines": len(sorted(tail_indices)),
}
json.dump(out, sys.stdout, indent=2)
#!/usr/bin/env python3
"""Extract a selected block from a top-level function into its own definition.
Robust line-based rewrite (no ast.fix_locations, removed in Python 3.14): AST is
used only to locate the function and split point; the file is rebuilt by line
surgery, collecting full spans of each statement including nested compound bodies.
Usage: extract.py <source.py> <target_line> [new_function_name]
Emits JSON with the rewritten source and metadata.
"""
import ast, json, re, sys
src = open(sys.argv[1], "r", encoding="utf-8").read()
lines = src.split("\n")
tree = ast.parse(src)
target_line = int(sys.argv[2])
new_name = sys.argv[3] if len(sys.argv) > 3 else None
def find_innermost(node):
"""Return the innermost (deepest) function whose span contains target_line."""
matches = []
for n in ast.walk(node):
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
if n.lineno <= target_line <= getattr(n, "end_lineno", n.lineno + 1):
matches.append(n)
return max(matches, key=lambda f: f.lineno) if matches else None
def collect_indices(node):
"""All source line indices (0-based) covered by a node incl. nested blocks."""
idxs = []
def walk(n):
idxs.append(n.lineno - 1)
for field in ("body", "orelse", "finalbody", "handlers",
"decorator_list", "elif_else_branches"):
val = getattr(n, field, None)
if isinstance(val, list):
for item in val:
walk(item)
elif hasattr(val, "lineno"):
walk(val)
walk(node)
return idxs
func = find_innermost(tree)
if func is None:
json.dump({"error": "no_function_at_target", "target_line": target_line}, sys.stdout)
sys.exit(0)
def_line = func.lineno
body = list(func.body)
lend = getattr(body[-1], "end_lineno", func.lineno + 1)
# Split by statement START line: statements beginning at/before target go into the
# new function (tail); those after stay as module-level code (head).
tail_stmts = [st for st in body if getattr(st, "lineno", 0) <= target_line]
head_stmts = [st for st in body if getattr(st, "lineno", 0) > target_line]
if not new_name or not new_name.strip():
json.dump({"error": "no_new_function_name"}, sys.stdout)
sys.exit(0)
# Full line indices for each side (nested bodies included).
tail_indices = set()
for st in tail_stmts:
tail_indices.update(collect_indices(st))
head_indices = set()
for st in head_stmts:
head_indices.update(collect_indices(st))
if not new_name or not new_name.strip():
json.dump({"error": "no_new_function_name"}, sys.stdout)
sys.exit(0)
# Build new header from the original def line, swapping the function name.
orig_def_line = lines[def_line - 1]
new_header = re.sub(r"\b" + re.escape(func.name) + r"\b", new_name.strip(), orig_def_line)
# Rebuild file: before-def + new header + extracted tail + demoted head + rest.
new_lines = lines[:def_line - 1]
new_lines.append(new_header)
for idx in sorted(tail_indices):
new_lines.append(lines[idx])
# Demote head statements from the function body back to module level (strip one indent).
head_block = [lines[i] for i in sorted(head_indices)]
demoted_head = [ln[4:] if ln.startswith(" ") and ln.strip() else ln for ln in head_block]
new_lines.extend(demoted_head)
new_lines.extend(lines[lend:]) # everything after the function
# Trim trailing blank lines introduced by the move.
while new_lines and new_lines[-1] == "":
new_lines.pop()
out = {
"source": "\n".join(new_lines),
"newFunctionName": new_name.strip(),
"originalFunction": func.name,
"defLine": def_line,
"targetLine": target_line,
"extractedLines": len(sorted(tail_indices)),
}
json.dump(out, sys.stdout, indent=2)