Skip to content

Commit d666e22

Browse files
committed
Add deprecated Python API report
1 parent cd6b80b commit d666e22

1 file changed

Lines changed: 145 additions & 0 deletions

File tree

scripts/list_deprecated.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Script to list all Python APIs marked as deprecated.
4+
5+
Walks the Python API source looking for functions, methods, and classes
6+
decorated with @deprecation.deprecated(...) and prints a table showing the
7+
qualified name, the version it was deprecated in, and any replacement
8+
details.
9+
10+
Usage:
11+
python list_deprecated.py [paths...]
12+
python list_deprecated.py --markdown [paths...]
13+
python list_deprecated.py --sort version [paths...]
14+
15+
If no paths are specified, defaults to ../python relative to this script.
16+
"""
17+
18+
import argparse
19+
import ast
20+
import re
21+
import sys
22+
from pathlib import Path
23+
24+
SKIP_FILES = {"deprecation.py", "_binaryninjacore.py"}
25+
SKIP_DIRECTORIES = {"__pycache__", "examples"}
26+
27+
28+
def literal_or_source(node):
29+
"""Return the value of a constant node, or its source text as a fallback."""
30+
if isinstance(node, ast.Constant):
31+
return node.value
32+
return ast.unparse(node)
33+
34+
35+
def find_deprecations(filepath):
36+
"""Yield (qualified_name, kind, lineno, deprecated_in, removed_in, details) for each deprecated API."""
37+
tree = ast.parse(filepath.read_text(encoding="utf-8"), filename=str(filepath))
38+
39+
def walk(node, scope):
40+
for child in ast.iter_child_nodes(node):
41+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
42+
qualname = scope + [child.name]
43+
for dec in child.decorator_list:
44+
if not isinstance(dec, ast.Call):
45+
continue
46+
func = dec.func
47+
name = ast.unparse(func)
48+
if name not in ("deprecation.deprecated", "deprecated"):
49+
continue
50+
kwargs = {kw.arg: literal_or_source(kw.value) for kw in dec.keywords if kw.arg}
51+
kind = "class" if isinstance(child, ast.ClassDef) else "method" if len(scope) else "function"
52+
yield (
53+
".".join(qualname), kind, child.lineno, str(kwargs.get("deprecated_in", "")),
54+
str(kwargs.get("removed_in", "")), str(kwargs.get("details", ""))
55+
)
56+
yield from walk(child, qualname)
57+
58+
yield from walk(tree, [])
59+
60+
61+
def clean_details(details):
62+
"""Strip Sphinx roles like :py:func:`foo` down to just foo."""
63+
return re.sub(r":py:\w+:`([^`]*)`", r"\1", details).strip()
64+
65+
66+
def version_key(version):
67+
"""Sort key for version strings like 4.0.4907; non-numeric parts sort last."""
68+
parts = []
69+
for p in version.split("."):
70+
parts.append(int(p) if p.isdigit() else 0)
71+
return parts
72+
73+
74+
def main():
75+
parser = argparse.ArgumentParser(description="List deprecated Python APIs")
76+
parser.add_argument("paths", nargs="*", help="Files or directories to scan (default: ../python)")
77+
parser.add_argument("--markdown", action="store_true", help="Output a markdown table")
78+
parser.add_argument("--sort", choices=["name", "version"], default="name", help="Sort order (default: name)")
79+
parser.add_argument(
80+
"--include-examples",
81+
action="store_true",
82+
help="include example directories when scanning recursively",
83+
)
84+
args = parser.parse_args()
85+
86+
if args.paths:
87+
paths = [Path(p) for p in args.paths]
88+
else:
89+
paths = [Path(__file__).resolve().parent.parent / "python"]
90+
91+
files = []
92+
for path in paths:
93+
if path.is_dir():
94+
for filepath in sorted(path.rglob("*.py")):
95+
if args.include_examples or not SKIP_DIRECTORIES.intersection(filepath.relative_to(path).parts):
96+
files.append(filepath)
97+
else:
98+
files.append(path)
99+
100+
rows = []
101+
parse_errors = []
102+
for filepath in files:
103+
if filepath.name in SKIP_FILES:
104+
continue
105+
try:
106+
deprecations = find_deprecations(filepath)
107+
for qualname, kind, lineno, deprecated_in, removed_in, details in deprecations:
108+
rows.append((qualname, kind, deprecated_in, removed_in, clean_details(details), f"{filepath.name}:{lineno}"))
109+
except (SyntaxError, UnicodeDecodeError) as error:
110+
parse_errors.append((filepath, error))
111+
112+
if parse_errors:
113+
for filepath, error in parse_errors:
114+
print(f"error: failed to parse {filepath}: {error}", file=sys.stderr)
115+
print("deprecated API report is incomplete", file=sys.stderr)
116+
return 1
117+
118+
if args.sort == "version":
119+
rows.sort(key=lambda r: (version_key(r[2]), r[0]))
120+
else:
121+
rows.sort(key=lambda r: r[0])
122+
123+
if not rows:
124+
print("No deprecated APIs found")
125+
return 0
126+
127+
headers = ("API", "Kind", "Deprecated In", "Removed In", "Details", "Location")
128+
if args.markdown:
129+
print("| " + " | ".join(headers) + " |")
130+
print("|" + "|".join("---" for _ in headers) + "|")
131+
for row in rows:
132+
print("| " + " | ".join(row) + " |")
133+
else:
134+
widths = [max(len(headers[i]), max(len(row[i]) for row in rows)) for i in range(len(headers))]
135+
print(" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)))
136+
print(" ".join("-" * w for w in widths))
137+
for row in rows:
138+
print(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)))
139+
140+
print(f"\n{len(rows)} deprecated API(s) found", file=sys.stderr)
141+
return 0
142+
143+
144+
if __name__ == "__main__":
145+
sys.exit(main())

0 commit comments

Comments
 (0)