Skip to content

Commit 73214e4

Browse files
authored
Add BaseNode.equals for deep-equality testing (#14)
Nodes are deeply compared on a field by field basis. If possible, `False` is returned early. When comparing attributes, tags and variants in `SelectExpressions`, the order doesn't matter. By default, spans are not taken into account.
1 parent b7ef37c commit 73214e4

File tree

3 files changed

+310
-1
lines changed

3 files changed

+310
-1
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
## Unreleased
44

5-
-
5+
- Add BaseNode.equals for deep-equality testing.
6+
7+
Nodes are deeply compared on a field by field basis. If possible, False is
8+
returned early. When comparing attributes, tags and variants in
9+
SelectExpressions, the order doesn't matter. By default, spans are not
10+
taken into account.
611

712
## fluent 0.4.0 (June 13th, 2017)
813

fluent/syntax/ast.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ def from_json(value):
2727
return value
2828

2929

30+
def scalars_equal(node1, node2, with_spans=False):
31+
"""Compare two nodes which are not lists."""
32+
33+
if type(node1) != type(node2):
34+
return False
35+
36+
if isinstance(node1, BaseNode):
37+
return node1.equals(node2, with_spans)
38+
39+
return node1 == node2
40+
41+
3042
class BaseNode(object):
3143
"""Base class for all Fluent AST nodes.
3244
@@ -61,6 +73,58 @@ def visit(value):
6173

6274
return fun(node)
6375

76+
def equals(self, other, with_spans=False):
77+
"""Compare two nodes.
78+
79+
Nodes are deeply compared on a field by field basis. If possible, False
80+
is returned early. When comparing attributes, tags and variants in
81+
SelectExpressions, the order doesn't matter. By default, spans are not
82+
taken into account.
83+
"""
84+
85+
self_keys = set(vars(self).keys())
86+
other_keys = set(vars(other).keys())
87+
88+
if not with_spans:
89+
self_keys.discard('span')
90+
other_keys.discard('span')
91+
92+
if self_keys != other_keys:
93+
return False
94+
95+
for key in self_keys:
96+
field1 = getattr(self, key)
97+
field2 = getattr(other, key)
98+
99+
# List-typed nodes are compared item-by-item. When comparing
100+
# attributes, tags and variants, the order of items doesn't matter.
101+
if isinstance(field1, list) and isinstance(field2, list):
102+
if len(field1) != len(field2):
103+
return False
104+
105+
# These functions are used to sort lists of items for when
106+
# order doesn't matter. Annotations are also lists but they
107+
# can't be keyed on any of their fields reliably.
108+
field_sorting = {
109+
'attributes': lambda elem: elem.id.name,
110+
'tags': lambda elem: elem.name.name,
111+
'variants': lambda elem: elem.key.name,
112+
}
113+
114+
if key in field_sorting:
115+
sorting = field_sorting[key]
116+
field1 = sorted(field1, key=sorting)
117+
field2 = sorted(field2, key=sorting)
118+
119+
for elem1, elem2 in zip(field1, field2):
120+
if not scalars_equal(elem1, elem2, with_spans):
121+
return False
122+
123+
elif not scalars_equal(field1, field2, with_spans):
124+
return False
125+
126+
return True
127+
64128
def to_json(self):
65129
obj = {
66130
name: to_json(value)

tests/syntax/test_equals.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
from __future__ import unicode_literals
2+
import unittest
3+
import sys
4+
5+
sys.path.append('.')
6+
7+
from tests.syntax import dedent_ftl
8+
from fluent.syntax.parser import FluentParser
9+
10+
11+
def identity(node):
12+
return node
13+
14+
15+
class TestEntryEqualToSelf(unittest.TestCase):
16+
def setUp(self):
17+
self.parser = FluentParser()
18+
19+
def parse_ftl_entry(self, string):
20+
return self.parser.parse_entry(dedent_ftl(string))
21+
22+
def test_same_simple_message(self):
23+
message1 = self.parse_ftl_entry("""\
24+
foo = Foo
25+
""")
26+
27+
self.assertTrue(message1.equals(message1))
28+
self.assertTrue(message1.equals(message1.traverse(identity)))
29+
30+
def test_same_selector_message(self):
31+
message1 = self.parse_ftl_entry("""\
32+
foo =
33+
{ $num ->
34+
[one] One
35+
[two] Two
36+
[few] Few
37+
[many] Many
38+
*[other] Other
39+
}
40+
""")
41+
42+
self.assertTrue(message1.equals(message1))
43+
self.assertTrue(message1.equals(message1.traverse(identity)))
44+
45+
def test_same_complex_placeable_message(self):
46+
message1 = self.parse_ftl_entry("""\
47+
foo = Foo { NUMBER($num, style: "decimal") } Bar
48+
""")
49+
50+
self.assertTrue(message1.equals(message1))
51+
self.assertTrue(message1.equals(message1.traverse(identity)))
52+
53+
def test_same_message_with_attribute(self):
54+
message1 = self.parse_ftl_entry("""\
55+
foo
56+
.attr = Attr
57+
""")
58+
59+
self.assertTrue(message1.equals(message1))
60+
self.assertTrue(message1.equals(message1.traverse(identity)))
61+
62+
def test_same_message_with_attributes(self):
63+
message1 = self.parse_ftl_entry("""\
64+
foo
65+
.attr1 = Attr 1
66+
.attr2 = Attr 2
67+
""")
68+
69+
self.assertTrue(message1.equals(message1))
70+
self.assertTrue(message1.equals(message1.traverse(identity)))
71+
72+
def test_same_message_with_tag(self):
73+
message1 = self.parse_ftl_entry("""\
74+
foo = Foo
75+
#tag
76+
""")
77+
78+
self.assertTrue(message1.equals(message1))
79+
self.assertTrue(message1.equals(message1.traverse(identity)))
80+
81+
def test_same_message_with_tags(self):
82+
message1 = self.parse_ftl_entry("""\
83+
foo = Foo
84+
#tag1
85+
#tag2
86+
""")
87+
88+
self.assertTrue(message1.equals(message1))
89+
self.assertTrue(message1.equals(message1.traverse(identity)))
90+
91+
def test_same_junk(self):
92+
message1 = self.parse_ftl_entry("""\
93+
foo = Foo {
94+
""")
95+
96+
self.assertTrue(message1.equals(message1))
97+
self.assertTrue(message1.equals(message1.traverse(identity)))
98+
99+
100+
class TestOrderEquals(unittest.TestCase):
101+
def setUp(self):
102+
self.parser = FluentParser()
103+
104+
def parse_ftl_entry(self, string):
105+
return self.parser.parse_entry(dedent_ftl(string))
106+
107+
def test_attributes(self):
108+
message1 = self.parse_ftl_entry("""\
109+
foo
110+
.attr1 = Attr1
111+
.attr2 = Attr2
112+
""")
113+
message2 = self.parse_ftl_entry("""\
114+
foo
115+
.attr2 = Attr2
116+
.attr1 = Attr1
117+
""")
118+
119+
self.assertTrue(message1.equals(message2))
120+
self.assertTrue(message2.equals(message1))
121+
122+
def test_tags(self):
123+
message1 = self.parse_ftl_entry("""\
124+
foo = Foo
125+
#tag1
126+
#tag2
127+
""")
128+
message2 = self.parse_ftl_entry("""\
129+
foo = Foo
130+
#tag2
131+
#tag1
132+
""")
133+
134+
self.assertTrue(message1.equals(message2))
135+
self.assertTrue(message2.equals(message1))
136+
137+
def test_variants(self):
138+
message1 = self.parse_ftl_entry("""\
139+
foo =
140+
{ $num ->
141+
[a] A
142+
*[b] B
143+
}
144+
""")
145+
message2 = self.parse_ftl_entry("""\
146+
foo =
147+
{ $num ->
148+
*[b] B
149+
[a] A
150+
}
151+
""")
152+
153+
self.assertTrue(message1.equals(message2))
154+
self.assertTrue(message2.equals(message1))
155+
156+
157+
class TestEqualWithSpans(unittest.TestCase):
158+
def test_default_behavior(self):
159+
parser = FluentParser()
160+
161+
strings = [
162+
("foo = Foo", "foo = Foo"),
163+
("foo = Foo", "foo = Foo"),
164+
("foo = { $arg }", "foo = { $arg }"),
165+
]
166+
167+
messages = [
168+
(parser.parse_entry(a), parser.parse_entry(b))
169+
for a, b in strings
170+
]
171+
172+
for a, b in messages:
173+
self.assertTrue(a.equals(b))
174+
175+
def test_parser_without_spans(self):
176+
parser = FluentParser(with_spans=False)
177+
178+
strings = [
179+
("foo = Foo", "foo = Foo"),
180+
("foo = Foo", "foo = Foo"),
181+
("foo = { $arg }", "foo = { $arg }"),
182+
]
183+
184+
messages = [
185+
(parser.parse_entry(a), parser.parse_entry(b))
186+
for a, b in strings
187+
]
188+
189+
for a, b in messages:
190+
self.assertTrue(a.equals(b))
191+
192+
def test_equals_with_spans(self):
193+
parser = FluentParser()
194+
195+
strings = [
196+
("foo = Foo", "foo = Foo"),
197+
("foo = { $arg }", "foo = { $arg }"),
198+
]
199+
200+
messages = [
201+
(parser.parse_entry(a), parser.parse_entry(b))
202+
for a, b in strings
203+
]
204+
205+
for a, b in messages:
206+
self.assertTrue(a.equals(b, with_spans=True))
207+
208+
def test_parser_without_spans_equals_with_spans(self):
209+
parser = FluentParser(with_spans=False)
210+
211+
strings = [
212+
("foo = Foo", "foo = Foo"),
213+
("foo = Foo", "foo = Foo"),
214+
("foo = { $arg }", "foo = { $arg }"),
215+
("foo = { $arg }", "foo = { $arg }"),
216+
]
217+
218+
messages = [
219+
(parser.parse_entry(a), parser.parse_entry(b))
220+
for a, b in strings
221+
]
222+
223+
for a, b in messages:
224+
self.assertTrue(a.equals(b, with_spans=True))
225+
226+
def test_differ_with_spans(self):
227+
parser = FluentParser()
228+
229+
strings = [
230+
("foo = Foo", "foo = Foo"),
231+
("foo = { $arg }", "foo = { $arg }"),
232+
]
233+
234+
messages = [
235+
(parser.parse_entry(a), parser.parse_entry(b))
236+
for a, b in strings
237+
]
238+
239+
for a, b in messages:
240+
self.assertFalse(a.equals(b, with_spans=True))

0 commit comments

Comments
 (0)