Skip to content

gh-60191: Implement ast.compare #19211

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

Merged
merged 38 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from 16 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
13 changes: 13 additions & 0 deletions Doc/library/ast.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2472,6 +2472,19 @@ 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 look for asts that are structurally equal but
might differ in whitespace or similar details.

.. versionadded:: 3.14


.. _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 2 ASTs.
(Contributed by Batuhan Taskaya and Jeremy Hylton in :issue:`15987`)



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


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

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 look for asts that 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
for a_item, b_item in zip(a, b):
if not _compare(a_item, b_item):
return False
else:
return True
else:
return type(a) is type(b) and a == b

def _compare_fields():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _compare_fields():
def _compare_fields(a, b):

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():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _compare_attributes():
def _compare_attributes(a, b):

# Attributes are always strings.
for attr in a._attributes:
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
# a and b are guaranteed to have the same type, so they must also
# have identical values for _fields and _attributes.
if not _compare_fields():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if not _compare_fields():
if not _compare_fields(a, b):

return False
if compare_attributes and not _compare_attributes():
return False
return True


class NodeVisitor(object):
"""
A node visitor base class that walks the abstract syntax tree and calls a
Expand Down
107 changes: 102 additions & 5 deletions Lib/test/test_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import dis
import enum
import os
import random
import re
import sys
import textwrap
import tokenize
import types
import unittest
import warnings
Expand Down Expand Up @@ -38,6 +40,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 +1071,100 @@ 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_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_stdlib(self):
if support.is_resource_enabled("cpu"):
files = STDLIB_FILES
else:
files = random.sample(STDLIB_FILES, 10)

for module in files:
with self.subTest(module):
fn = os.path.join(STDLIB, module)
with tokenize.open(fn) as fp:
source = fp.read()
a = ast.parse(source, fn)
b = ast.parse(source, fn)
self.assertTrue(
ast.compare(a, b), f"{ast.dump(a)} != {ast.dump(b)}"
)

def test_compare_tests(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_options(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, compare_attributes=False))
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 +1321,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,12 +2291,9 @@ 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Implemented :func:`ast.compare` for comparing two ASTs. Patch by Batuhan
Taskaya with some help from Jeremy Hylton.

Loading