149 lines
5.1 KiB
Python
149 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""MAINT-20: prove a comment/docstring cut changed no code, and show the ratio.
|
|
|
|
python3 goodwe_controller/tools/maint20_same_code.py [BASE_REF] [FILE ...]
|
|
|
|
For each FILE (default: every goodwe_controller/app/*.py that differs from
|
|
BASE_REF, default release/1.0) the ast.dump() of the tree with docstrings
|
|
removed must be identical at BASE_REF and in the working tree, and so must
|
|
the `python -OO` compiled code (a wholly removed docstring leaves a no-op
|
|
there). An added or removed app/*.py file is a code difference. The diff
|
|
against BASE_REF may touch only goodwe_controller/app/*.py,
|
|
goodwe_controller/DECISIONS/*.md and this script. Also prints code lines vs
|
|
comment-only + docstring lines (whole lines, via tokenize) before and after,
|
|
and the docstrings removed entirely or added. Run from the repository root.
|
|
Exit 1 on any difference.
|
|
"""
|
|
import ast
|
|
import io
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tokenize
|
|
|
|
SCOPES = (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
|
|
|
|
def docstring(n):
|
|
if isinstance(n, SCOPES) and n.body:
|
|
e = n.body[0]
|
|
if (isinstance(e, ast.Expr) and isinstance(e.value, ast.Constant)
|
|
and isinstance(e.value.value, str)):
|
|
return e
|
|
return None
|
|
|
|
|
|
def dump(src):
|
|
tree = ast.parse(src)
|
|
for n in ast.walk(tree):
|
|
if docstring(n) is not None:
|
|
n.body = n.body[1:] or [ast.Pass()]
|
|
return ast.dump(tree)
|
|
|
|
|
|
def counts(src):
|
|
doc = set()
|
|
for n in ast.walk(ast.parse(src)):
|
|
e = docstring(n)
|
|
if e is not None:
|
|
doc.update(range(e.lineno, e.end_lineno + 1))
|
|
skip = (tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT,
|
|
tokenize.ENDMARKER, tokenize.COMMENT)
|
|
code, com = set(), set()
|
|
for tk in tokenize.generate_tokens(io.StringIO(src).readline):
|
|
if tk.type == tokenize.COMMENT:
|
|
com.add(tk.start[0])
|
|
elif tk.type not in skip:
|
|
code.update(range(tk.start[0], tk.end[0] + 1))
|
|
code -= doc
|
|
return len(code), len(doc) + len(com - code - doc)
|
|
|
|
|
|
def qualnames(src):
|
|
out = {}
|
|
|
|
def walk(node, prefix):
|
|
for n in ast.iter_child_nodes(node):
|
|
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef,
|
|
ast.ClassDef)):
|
|
q = prefix + n.name
|
|
out[q] = docstring(n) is not None
|
|
walk(n, q + ".")
|
|
out["<module>"] = docstring(ast.parse(src)) is not None
|
|
walk(ast.parse(src), "")
|
|
return out
|
|
|
|
|
|
def code_sig(co):
|
|
consts = tuple(code_sig(c) if hasattr(c, "co_code") else c
|
|
for c in co.co_consts)
|
|
return (co.co_name, co.co_code, consts, co.co_names, co.co_varnames,
|
|
co.co_freevars, co.co_cellvars, co.co_flags, co.co_argcount,
|
|
co.co_stacksize, co.co_exceptiontable)
|
|
|
|
|
|
def oo_same(old, new):
|
|
return (code_sig(compile(old, "f", "exec", optimize=2))
|
|
== code_sig(compile(new, "f", "exec", optimize=2)))
|
|
|
|
|
|
def git(*a):
|
|
return subprocess.run(["git", *a], check=True, capture_output=True,
|
|
text=True).stdout
|
|
|
|
|
|
def exists(base, f):
|
|
return subprocess.run(["git", "cat-file", "-e", f"{base}:{f}"],
|
|
capture_output=True).returncode == 0
|
|
|
|
|
|
ALLOWED = re.compile(r"^goodwe_controller/(app/[^/]+\.py|DECISIONS/[^/]+\.md|"
|
|
r"tools/maint20_same_code\.py)$")
|
|
|
|
|
|
def ratio(m, c):
|
|
return f"{m / c:.2f}" if c else "n/a"
|
|
|
|
|
|
def main(argv):
|
|
base = argv[0] if argv else "release/1.0"
|
|
bad = 0
|
|
print(f"base {base} = {git('rev-parse', base).strip()}")
|
|
changed = git("diff", "--name-only", base).split()
|
|
for f in changed:
|
|
if not ALLOWED.match(f):
|
|
print(f"FAIL: diff touches {f}, outside app/*.py, DECISIONS/*.md "
|
|
"and this script")
|
|
bad += 1
|
|
files = argv[1:] or [f for f in changed if re.match(
|
|
r"^goodwe_controller/app/[^/]+\.py$", f)]
|
|
for f in files:
|
|
in_base, in_head = exists(base, f), os.path.exists(f)
|
|
if in_base != in_head:
|
|
print(f"CODE DIFFERS {f} "
|
|
f"{'removed' if in_base else 'added'} file")
|
|
bad += 1
|
|
continue
|
|
old = git("show", f"{base}:{f}")
|
|
new = open(f, encoding="utf-8").read()
|
|
same = dump(old) == dump(new)
|
|
oo = oo_same(old, new)
|
|
(c0, m0), (c1, m1) = counts(old), counts(new)
|
|
print(f"{'SAME CODE' if same else 'CODE DIFFERS'} {f} "
|
|
f"code {c0}->{c1} comment+doc {m0}->{m1} "
|
|
f"ratio {ratio(m0, c0)}->{ratio(m1, c1)} "
|
|
f"-OO {'same' if oo else 'DIFFERS'}")
|
|
bad += (not same) + (not oo)
|
|
qo, qn = qualnames(old), qualnames(new)
|
|
for q in sorted(q for q in qo if qo[q] and not qn.get(q, True)):
|
|
print(f" docstring REMOVED: {f}:{q}")
|
|
for q in sorted(q for q in qn if qn[q] and not qo.get(q, True)):
|
|
print(f" docstring ADDED: {f}:{q}")
|
|
print("OK: no code change" if not bad else f"FAIL: {bad} problem(s)")
|
|
return 1 if bad else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|