Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-60191: Implement ast.compare #19211

Merged
merged 38 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
cfb508f
bpo-15987: Implement ast.compare
isidentical Mar 28, 2020
0441023
unwrap ifs a level
isidentical Mar 31, 2020
9572173
Merge branch 'main' into bpo-15987
AlexWaygood May 11, 2024
52f428e
A few revision to clarify some subtleties of comparing AST objects.
jeremyhylton May 20, 2024
e47fb0b
Merge remote-tracking branch 'upstream/main' into pr_19211
jeremyhylton May 20, 2024
5f37b91
Remove the compare_fields option.
jeremyhylton May 20, 2024
c0bb0e9
Revise docstring and documentation for consistency.
jeremyhylton May 20, 2024
13fbbc9
This PR now describes a feature of 3.14. Revise what's new / news docs.
jeremyhylton May 20, 2024
f8f0747
Update 3.9.rst
jeremyhylton May 20, 2024
deca2da
Remove the compare_types option, which seems unnecessary.
jeremyhylton May 21, 2024
7788744
Update Doc/whatsnew/3.14.rst
jeremyhylton May 21, 2024
05885de
Update Doc/whatsnew/3.14.rst
jeremyhylton May 21, 2024
3b7192e
Remove compare_types from doc. Change ast node to AST.
jeremyhylton May 21, 2024
b9b6c45
Change AST node to AST.
jeremyhylton May 21, 2024
c9aa69e
Merge remote-tracking branch 'upstream/main' into pr_19211
jeremyhylton May 21, 2024
adc2718
Merge branch 'bpo-15987' into pr_19211
jeremyhylton May 21, 2024
0c72337
One more change of "ast nodes" to "ASTs"
jeremyhylton May 21, 2024
6ad21c0
Merge AST comparison into the test for AST validation.
jeremyhylton May 21, 2024
ec8af39
Merge tests for AST parsing and comparison.
jeremyhylton May 21, 2024
c7ea190
Merge branch 'main' into bpo-15987
jeremyhylton May 21, 2024
75ba002
Improve description of compare_attributes arg
jeremyhylton May 21, 2024
5b01405
AP style: Spell out numbers under 10
jeremyhylton May 21, 2024
69be6d2
Improve description of compare_attributes arg
jeremyhylton May 21, 2024
bdd2d66
Improve robustness of compare() in the face of user-modification of AST.
jeremyhylton May 21, 2024
f9d4c39
Update Lib/test/test_ast.py
jeremyhylton May 21, 2024
ed0cddd
Update Lib/test/test_ast.py
jeremyhylton May 21, 2024
2b114a9
Update Lib/test/test_ast.py
jeremyhylton May 21, 2024
4687dc6
Update Lib/test/test_ast.py
jeremyhylton May 21, 2024
455bdb1
whitespace
iritkatriel May 21, 2024
af70707
Merge branch 'main' into bpo-15987
jeremyhylton May 21, 2024
b915d9c
Merge branch 'main' into bpo-15987
jeremyhylton May 21, 2024
ccf410c
Update Doc/library/ast.rst
jeremyhylton May 21, 2024
06988c1
Update Lib/ast.py
jeremyhylton May 21, 2024
0c9da18
Attributes are ints not strings.
jeremyhylton May 21, 2024
6494c23
Explain examples of what are included in attributes.
jeremyhylton May 21, 2024
bf9e403
Update Doc/library/ast.rst
jeremyhylton May 21, 2024
f34dcac
Merge branch 'main' into bpo-15987
jeremyhylton May 21, 2024
d2281f5
Merge branch 'main' into bpo-15987
jeremyhylton May 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Doc/library/ast.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2472,6 +2472,20 @@ effects on the compilation of a program:
.. versionadded:: 3.8


.. function:: compare(a, b, /, *, compare_attributes=False)

Recursively compares two ASTs.

*compare_attributes* affects whether AST attributes are considered
in the comparison. If *compare_attributes* is ``False`` (default), then
attributes are ignored. Otherwise they must all be equal. This
option is useful to check whether the ASTs are structurally equal but
differ in whitespace or similar details. Attributes include numbers
jeremyhylton marked this conversation as resolved.
Show resolved Hide resolved
and column offsets.

.. versionadded:: 3.14
jeremyhylton marked this conversation as resolved.
Show resolved Hide resolved


.. _ast-cli:

Command-Line Usage
Expand Down
7 changes: 7 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ New Modules
Improved Modules
================

ast
---

Added :func:`ast.compare` for comparing two ASTs.
(Contributed by Batuhan Taskaya and Jeremy Hylton in :issue:`15987`)



Optimizations
=============
Expand Down
71 changes: 71 additions & 0 deletions Lib/ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,77 @@ def walk(node):
yield node


def compare(
a,
b,
/,
*,
compare_attributes=False,
):
"""Recursively compares two ASTs.

compare_attributes affects whether AST attributes are considered
in the comparison. If compare_attributes is False (default), then
attributes are ignored. Otherwise they must all be equal. This
option is useful to check whether the ASTs are structurally equal but
might differ in whitespace or similar details.
"""

def _compare(a, b):
# Compare two fields on an AST object, which may themselves be
# AST objects, lists of AST objects, or primitive ASDL types
# like identifiers and constants.
if isinstance(a, AST):
return compare(
a,
b,
compare_attributes=compare_attributes,
)
elif isinstance(a, list):
# If a field is repeated, then both objects will represent
# the value as a list.
if len(a) != len(b):
return False
jeremyhylton marked this conversation as resolved.
Show resolved Hide resolved
for a_item, b_item in zip(a, b):
if not _compare(a_item, b_item):
return False
else:
isidentical marked this conversation as resolved.
Show resolved Hide resolved
return True
else:
return type(a) is type(b) and a == b

def _compare_fields(a, b):
if a._fields != b._fields:
return False
for field in a._fields:
a_field = getattr(a, field)
b_field = getattr(b, field)
if not _compare(a_field, b_field):
return False
else:
return True

def _compare_attributes(a, b):
if a._attributes != b._attributes:
return False
# Attributes are always ints.
for attr in a._attributes:
jeremyhylton marked this conversation as resolved.
Show resolved Hide resolved
a_attr = getattr(a, attr)
b_attr = getattr(b, attr)
if a_attr != b_attr:
return False
else:
return True

if type(a) is not type(b):
return False
if not _compare_fields(a, b):
return False
if compare_attributes and not _compare_attributes(a, b):
return False
return True


class NodeVisitor(object):
"""
A node visitor base class that walks the abstract syntax tree and calls a
Expand Down
121 changes: 116 additions & 5 deletions Lib/test/test_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ def to_tuple(t):
result.append(to_tuple(getattr(t, f)))
return tuple(result)

STDLIB = os.path.dirname(ast.__file__)
STDLIB_FILES = [fn for fn in os.listdir(STDLIB) if fn.endswith(".py")]
STDLIB_FILES.extend(["test/test_grammar.py", "test/test_unpack_ex.py"])

# These tests are compiled through "exec"
# There should be at least one test per statement
Expand Down Expand Up @@ -1066,6 +1069,114 @@ def test_ast_asdl_signature(self):
expressions[0] = f"expr = {ast.expr.__subclasses__()[0].__doc__}"
self.assertCountEqual(ast.expr.__doc__.split("\n"), expressions)

def test_compare_basics(self):
self.assertTrue(ast.compare(ast.parse("x = 10"), ast.parse("x = 10")))
self.assertFalse(ast.compare(ast.parse("x = 10"), ast.parse("")))
self.assertFalse(ast.compare(ast.parse("x = 10"), ast.parse("x")))
self.assertFalse(
ast.compare(ast.parse("x = 10;y = 20"), ast.parse("class C:pass"))
)

def test_compare_modified_ast(self):
# The ast API is a bit underspecified. The objects are mutable,
# and even _fields and _attributes are mutable. The compare() does
# some simple things to accommodate mutability.
a = ast.parse("m * x + b", mode="eval")
b = ast.parse("m * x + b", mode="eval")
self.assertTrue(ast.compare(a, b))

a._fields = a._fields + ("spam",)
a.spam = "Spam"
self.assertNotEqual(a._fields, b._fields)
self.assertFalse(ast.compare(a, b))
self.assertFalse(ast.compare(b, a))

b._fields = a._fields
b.spam = a.spam
self.assertTrue(ast.compare(a, b))
self.assertTrue(ast.compare(b, a))

b._attributes = b._attributes + ("eggs",)
b.eggs = "eggs"
self.assertNotEqual(a._attributes, b._attributes)
self.assertFalse(ast.compare(a, b, compare_attributes=True))
self.assertFalse(ast.compare(b, a, compare_attributes=True))

a._attributes = b._attributes
a.eggs = b.eggs
self.assertTrue(ast.compare(a, b, compare_attributes=True))
self.assertTrue(ast.compare(b, a, compare_attributes=True))

def test_compare_literals(self):
constants = (
-20,
20,
20.0,
1,
1.0,
True,
0,
False,
frozenset(),
tuple(),
"ABCD",
"abcd",
"中文字",
1e1000,
-1e1000,
)
for next_index, constant in enumerate(constants[:-1], 1):
next_constant = constants[next_index]
with self.subTest(literal=constant, next_literal=next_constant):
self.assertTrue(
ast.compare(ast.Constant(constant), ast.Constant(constant))
)
self.assertFalse(
ast.compare(
ast.Constant(constant), ast.Constant(next_constant)
)
)

same_looking_literal_cases = [
{1, 1.0, True, 1 + 0j},
{0, 0.0, False, 0 + 0j},
]
for same_looking_literals in same_looking_literal_cases:
for literal in same_looking_literals:
for same_looking_literal in same_looking_literals - {literal}:
self.assertFalse(
ast.compare(
ast.Constant(literal),
ast.Constant(same_looking_literal),
)
)

def test_compare_fieldless(self):
self.assertTrue(ast.compare(ast.Add(), ast.Add()))
self.assertFalse(ast.compare(ast.Sub(), ast.Add()))

def test_compare_modes(self):
for mode, sources in (
("exec", exec_tests),
("eval", eval_tests),
("single", single_tests),
):
for source in sources:
a = ast.parse(source, mode=mode)
b = ast.parse(source, mode=mode)
self.assertTrue(
ast.compare(a, b), f"{ast.dump(a)} != {ast.dump(b)}"
)

def test_compare_attributes_option(self):
def parse(a, b):
return ast.parse(a), ast.parse(b)

a, b = parse("2 + 2", "2+2")
self.assertTrue(ast.compare(a, b))
self.assertTrue(ast.compare(a, b, compare_attributes=False))
jeremyhylton marked this conversation as resolved.
Show resolved Hide resolved
self.assertFalse(ast.compare(a, b, compare_attributes=True))

def test_positional_only_feature_version(self):
ast.parse('def foo(x, /): ...', feature_version=(3, 8))
ast.parse('def bar(x=1, /): ...', feature_version=(3, 8))
Expand Down Expand Up @@ -1222,6 +1333,7 @@ def test_none_checks(self) -> None:
for node, attr, source in tests:
self.assert_none_check(node, attr, source)


class ASTHelpers_Test(unittest.TestCase):
maxDiff = None

Expand Down Expand Up @@ -2191,16 +2303,15 @@ def test_nameconstant(self):

@support.requires_resource('cpu')
def test_stdlib_validates(self):
stdlib = os.path.dirname(ast.__file__)
tests = [fn for fn in os.listdir(stdlib) if fn.endswith(".py")]
tests.extend(["test/test_grammar.py", "test/test_unpack_ex.py"])
for module in tests:
for module in STDLIB_FILES:
with self.subTest(module):
fn = os.path.join(stdlib, module)
fn = os.path.join(STDLIB, module)
with open(fn, "r", encoding="utf-8") as fp:
source = fp.read()
mod = ast.parse(source, fn)
compile(mod, fn, "exec")
mod2 = ast.parse(source, fn)
self.assertTrue(ast.compare(mod, mod2))

constant_1 = ast.Constant(1)
pattern_1 = ast.MatchValue(constant_1)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Implemented :func:`ast.compare` for comparing two ASTs. Patch by Batuhan
Taskaya with some help from Jeremy Hylton.
Loading