sapling/tests/testutil/autofix.py
Jun Wu 379d5e5490 testutil/dott: implement autofix for commands without outputs
Summary:
Comparing to `.t` tests, `dott` Python tests cannot autofix commands without
outputs.  This diff makes it able to do so. It's less strict than the AST
parsing (for example, it does not handle `#` comments precisely). But
practically it might be good enough. We can update it to use real AST parsing
if it becomes an issue.

This should make `dott` Python tests easier to use.

Reviewed By: xavierd

Differential Revision: D17277285

fbshipit-source-id: 11ef6ec4327a6547d49b544c63bc000a3c351947
2019-09-10 13:01:32 -07:00

168 lines
5.7 KiB
Python

# Copyright 2019 Facebook, Inc.
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
from __future__ import absolute_import
import ast
import atexit
import os
import sys
from .argspans import argspans, parse
def eq(actual, expected, nested=0, eqfunc=None, fixfunc=None):
"""Check actual == expected.
If autofix is True, record the failure and try to fix it automatically.
If normalize is not None, and eqfunc(actual, expected) is True, treat
actual as equal to expected. This can be used for advanced matching, for
example, "(glob)" support.
For autofix to work, this function has to be called using its direct name
(ex. `eq`), without modules (ex. `testutil.eq`).
For nested use-cases (ex. having a `fooeq(...)` function that auto fixes
the last argument of `fooeq`. Set `nested` to the number of functions
wrapping this function.
The default autofix feature rewrites the right-most parameter. It works for
both function calls (ex. `f(a, b, c)`, c gets rewritten), and `__eq__`
operation (ex. `a == b`, `b` gets rewritten). If the default autofix cannot
figure out how to fix the function, `fixfunc` will be called instead with
(actual, expected, path, lineno, parso parsed file), `fixfunc` should
return either `None` meaning it cannot autofix the code, or `(((start line,
start col), (end line, end col)), code)` if it can.
"""
# For strings. Remove leading spaces and compare them again.
if isinstance(actual, str) and isinstance(expected, str) and "\n" in expected:
multiline = True
actual = _removeindent(actual.replace("\t", " ")).strip()
expected = _removeindent(expected.replace("\t", " ")).strip()
else:
multiline = False
if actual == expected:
return
elif eqfunc is not None:
try:
if eqfunc(actual, expected):
return
except Exception:
pass
if autofix:
path, lineno, indent, spans = argspans(nested)
# Use ast.literal_eval to make sure the code evaluates to the actual
# value. It can be `eval`, but less safe.
fix = None
if spans is not None and ast.literal_eval(repr(actual)) == actual:
code = _repr(actual, indent + 4)
# Assuming the last argument is "expected" and needs change.
# (if nested is not 0, the "callsite" might be calling other
# functions that take a different number of arguments).
fix = (spans[-1], code)
else:
# Try using the customized fixfunc
if fixfunc is not None:
assert path
assert lineno is not None
fix = fixfunc(actual, expected, path, lineno, parse(path))
if fix is not None:
_fixes.setdefault(path, []).append(fix)
else:
sys.stderr.write(
"Cannot auto-fix %r => %r at %s:%s\n"
% (expected, actual, os.path.basename(path), lineno)
)
else:
if multiline:
# Show the diff of multi-line content.
import difflib
diff = "".join(
difflib.unified_diff(
(expected + "\n").splitlines(True),
(actual + "\n").splitlines(True),
"expected",
"actual",
)
)
raise AssertionError("actual != expected\n%s" % diff)
else:
raise AssertionError("%r != %r" % (actual, expected))
def _repr(x, indent=0):
"""Similar to repr, but prefer multi-line strings instead of using '\n's"""
if isinstance(x, str) and "\n" in x:
# Pretty-print as a docstring with the given indentation.
quote = '"""'
if x.endswith('"') or '"""' in x:
quote = "'''"
body = ""
for line in x.splitlines(True):
if line not in {"\n", ""} and indent:
line = " " * indent + line
body += line
return "r%s\n%s%s" % (quote, body, quote)
else:
return repr(x)
@atexit.register
def _fix():
"""Apply code changes"""
for path, entries in _fixes.items():
lines = open(path, "rb").read().splitlines(True)
for span, code in sorted(entries, reverse=True):
# Note: line starts with 1, col starts with 0.
startline, startcol = span[0]
endline, endcol = span[1]
# This is not super efficient. But it's easy to write.
for i in range(startline, endline + 1):
line = lines[i - 1]
newline = ""
if i == startline:
newline += "%s%s" % (line[:startcol], code)
if i == endline:
newline += line[endcol:]
lines[i - 1] = newline
lines = "".join(lines).splitlines(True)
with open(path, "wb") as f:
f.write("".join(lines))
def _removeindent(text):
if text:
try:
indent = min(
len(l) - len(l.lstrip(" "))
for l in text.splitlines()
if l not in {"\n", ""}
)
except ValueError:
pass
else:
newtext = ""
for line in text.splitlines(True):
if line in {"\n", ""}:
newtext += line
else:
newtext += line[indent:]
text = newtext
return text
# Whether to autofix changes. This can be changed by the callsite.
# By default, it's set if `--fix` is in sys.argv. This is convenient
# for ad-hoc runs like `python test-foo.py --fix`.
autofix = "--fix" in sys.argv
_fixes = {} # {path: [(span, code)]}