From 1e959c9cc66740517209b726dc600ac851ebba68 Mon Sep 17 00:00:00 2001 From: Peter Ebden Date: Mon, 9 Nov 2026 09:41:33 +0000 Subject: [PATCH 1/3] Fix equality comparisons on frozen dicts / lists --- src/parse/asp/equality_test.go | 34 +++++++++++ src/parse/asp/interpreter.go | 5 +- src/parse/asp/objects.go | 101 ++++++++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 src/parse/asp/equality_test.go diff --git a/src/parse/asp/equality_test.go b/src/parse/asp/equality_test.go new file mode 100644 index 000000000..6e9c8fa25 --- /dev/null +++ b/src/parse/asp/equality_test.go @@ -0,0 +1,34 @@ +package asp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEqualsFrozen(t *testing.T) { + l := pyList{pyString("a"), pyDict{"b": pyList{pyInt(1)}}} + d := pyDict{"x": l, "y": None} + + assert.True(t, l.Equals(l.Freeze())) + assert.True(t, l.Freeze().Equals(l)) + assert.True(t, d.Equals(d.Freeze())) + assert.True(t, d.Freeze().Equals(d)) + assert.True(t, (pyList{}).Equals(pyList(nil))) + assert.True(t, (pyDict{}).Equals(pyFrozenDict{})) + + assert.False(t, l.Equals(l[:1].Freeze())) + assert.False(t, d.Equals(pyDict{"x": l}.Freeze())) + assert.False(t, d.Equals(pyDict{"x": l, "z": None})) + assert.False(t, (pyList{pyInt(1)}).Equals(pyList{pyString("1")})) + assert.False(t, (pyDict{}).Equals(pyList{})) + assert.False(t, (pyInt(1)).Equals(pyString("1"))) +} + +func TestFreezeIsDeep(t *testing.T) { + l := pyList{pyDict{"a": pyList{}}}.Freeze().(pyFrozenList) + d, ok := l.pyList[0].(pyFrozenDict) + assert.True(t, ok, "nested dict should be frozen") + _, ok = d.pyDict["a"].(pyFrozenList) + assert.True(t, ok, "nested list should be frozen") +} diff --git a/src/parse/asp/interpreter.go b/src/parse/asp/interpreter.go index 2f1373ea1..10eda42ab 100644 --- a/src/parse/asp/interpreter.go +++ b/src/parse/asp/interpreter.go @@ -6,7 +6,6 @@ import ( "iter" "path" "path/filepath" - "reflect" "regexp" "runtime/pprof" "strings" @@ -726,9 +725,9 @@ func (s *scope) interpretOp(obj pyObject, op OpExpression) pyObject { case Not: return s.negate(obj) case Equal: - return newPyBool(reflect.DeepEqual(obj, s.interpretExpression(op.Expr))) + return newPyBool(obj.Equals(s.interpretExpression(op.Expr))) case NotEqual: - return newPyBool(!reflect.DeepEqual(obj, s.interpretExpression(op.Expr))) + return newPyBool(!obj.Equals(s.interpretExpression(op.Expr))) case Is: return s.interpretIs(obj, op) case IsNot: diff --git a/src/parse/asp/objects.go b/src/parse/asp/objects.go index cd23bb3d2..8abc33633 100644 --- a/src/parse/asp/objects.go +++ b/src/parse/asp/objects.go @@ -5,6 +5,7 @@ import ( "fmt" "iter" "math" + "reflect" "slices" "sort" "strconv" @@ -23,6 +24,8 @@ type pyObject interface { TypeTag() int32 // Returns true if this object evaluates to something truthy. IsTruthy() bool + // Returns true if this object is equal to the given one (i.e. implements the == operator). + Equals(other pyObject) bool } // A freezable represents an object that can be frozen into a readonly state. @@ -94,6 +97,11 @@ func (b pyBool) IsTruthy() bool { return b == True } +func (b pyBool) Equals(other pyObject) bool { + o, ok := other.(pyBool) + return ok && b == o +} + func (b pyBool) String() string { if b == True { return "True" @@ -125,6 +133,11 @@ func (n pyNone) IsTruthy() bool { return false } +func (n pyNone) Equals(other pyObject) bool { + _, ok := other.(pyNone) + return ok +} + func (n pyNone) String() string { return "None" } @@ -155,6 +168,11 @@ func (s pySentinel) IsTruthy() bool { return false } +func (s pySentinel) Equals(other pyObject) bool { + o, ok := other.(pySentinel) + return ok && s == o +} + func (s pySentinel) String() string { panic("non stringable type " + string(s)) } @@ -210,6 +228,11 @@ func (i pyInt) IsTruthy() bool { return i != 0 } +func (i pyInt) Equals(other pyObject) bool { + o, ok := other.(pyInt) + return ok && i == o +} + func (i pyInt) Operator(operator Operator, operand pyObject) pyObject { switch o := operand.(type) { case pyInt: @@ -268,6 +291,11 @@ func (s pyString) IsTruthy() bool { return s != "" } +func (s pyString) Equals(other pyObject) bool { + o, ok := other.(pyString) + return ok && s == o +} + func (s pyString) Property(scope *scope, name string) pyObject { if prop, present := scope.interpreter.stringMethods[name]; present { return prop.Member(s) @@ -349,6 +377,29 @@ func (l pyList) IsTruthy() bool { return len(l) > 0 } +// Equals compares this list to another, element by element. Frozen lists compare equal to unfrozen +// ones with the same contents. +func (l pyList) Equals(other pyObject) bool { + var o pyList + switch other := other.(type) { + case pyList: + o = other + case pyFrozenList: + o = other.pyList + default: + return false + } + if len(l) != len(o) { + return false + } + for i, v := range l { + if !v.Equals(o[i]) { + return false + } + } + return true +} + func (l pyList) Operator(operator Operator, operand pyObject) pyObject { switch operator { case Add: @@ -436,7 +487,7 @@ func (l pyList) Freeze() pyObject { frozen[i] = v } } - return pyFrozenList{pyList: l} + return pyFrozenList{pyList: frozen} } // Repeat returns a copy of this list, repeated n times @@ -483,6 +534,29 @@ func (d pyDict) IsTruthy() bool { return len(d) > 0 } +// Equals compares this dict to another. Frozen dicts compare equal to unfrozen ones with the +// same contents. +func (d pyDict) Equals(other pyObject) bool { + var o pyDict + switch other := other.(type) { + case pyDict: + o = other + case pyFrozenDict: + o = other.pyDict + default: + return false + } + if len(d) != len(o) { + return false + } + for k, v := range d { + if v2, present := o[k]; !present || !v.Equals(v2) { + return false + } + } + return true +} + func (d pyDict) Property(scope *scope, name string) pyObject { // We allow looking up dict members by . as well as by indexing in order to facilitate the config map. if obj, present := d[name]; present { @@ -687,6 +761,11 @@ func (f *pyFunc) IsTruthy() bool { return true } +func (f *pyFunc) Equals(other pyObject) bool { + o, ok := other.(*pyFunc) + return ok && f == o +} + func (f *pyFunc) String() string { return fmt.Sprintf("", f.name) } @@ -916,6 +995,21 @@ func (c *pyConfig) IsTruthy() bool { return true // sure, why not } +// Equals compares this config to another. Frozen configs compare equal to unfrozen ones with the +// same contents. +func (c *pyConfig) Equals(other pyObject) bool { + var o *pyConfig + switch other := other.(type) { + case *pyConfig: + o = other + case *pyFrozenConfig: + o = &other.pyConfig + default: + return false + } + return c == o || (reflect.DeepEqual(c.base, o.base) && c.overlay.Equals(o.overlay)) +} + func (c *pyConfig) Property(scope *scope, name string) pyObject { if obj := c.Get(name, nil); obj != nil { return obj @@ -1057,6 +1151,11 @@ func (r *pyRange) IsTruthy() bool { return true } +func (r *pyRange) Equals(other pyObject) bool { + o, ok := other.(*pyRange) + return ok && *r == *o +} + func (r *pyRange) Operator(operator Operator, operand pyObject) pyObject { if l, ok := operand.(pyList); ok && operator == Add { return append(r.toList(len(l)), l...) From b0e5f35247171034f386d96b205125b41cf4cc17 Mon Sep 17 00:00:00 2001 From: Peter Ebden Date: Mon, 9 Nov 2026 11:01:33 +0000 Subject: [PATCH 2/3] PR comms --- src/parse/asp/equality_test.go | 19 +++++++++++++++++++ src/parse/asp/objects.go | 3 +-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/parse/asp/equality_test.go b/src/parse/asp/equality_test.go index 6e9c8fa25..f2bf78e06 100644 --- a/src/parse/asp/equality_test.go +++ b/src/parse/asp/equality_test.go @@ -15,8 +15,15 @@ func TestEqualsFrozen(t *testing.T) { assert.True(t, d.Equals(d.Freeze())) assert.True(t, d.Freeze().Equals(d)) assert.True(t, (pyList{}).Equals(pyList(nil))) + assert.True(t, (pyList{}).Equals(pyFrozenList{})) assert.True(t, (pyDict{}).Equals(pyFrozenDict{})) + // Deep equality of independently constructed nested structures + assert.True(t, (pyList{pyDict{"b": pyList{pyInt(1)}}}).Equals(pyList{pyDict{"b": pyList{pyInt(1)}}})) + assert.True(t, (pyDict{"a": pyList{pyDict{"b": None}}}).Equals(pyDict{"a": pyList{pyDict{"b": None}}.Freeze()})) + assert.False(t, (pyList{pyDict{"b": pyList{pyInt(1)}}}).Equals(pyList{pyDict{"b": pyList{pyInt(2)}}})) + assert.False(t, (pyDict{"a": pyList{pyDict{"b": None}}}).Equals(pyDict{"a": pyList{pyDict{"c": None}}})) + assert.False(t, l.Equals(l[:1].Freeze())) assert.False(t, d.Equals(pyDict{"x": l}.Freeze())) assert.False(t, d.Equals(pyDict{"x": l, "z": None})) @@ -32,3 +39,15 @@ func TestFreezeIsDeep(t *testing.T) { _, ok = d.pyDict["a"].(pyFrozenList) assert.True(t, ok, "nested list should be frozen") } + +func TestEqualsConfig(t *testing.T) { + c1 := &pyConfig{base: &pyConfigBase{dict: pyDict{"A": pyList{pyString("x")}}}, overlay: pyDict{"B": pyInt(1)}} + c2 := &pyConfig{base: &pyConfigBase{dict: pyDict{"A": pyList{pyString("x")}.Freeze()}}, overlay: pyDict{"B": pyInt(1)}} + c3 := &pyConfig{base: &pyConfigBase{dict: pyDict{"A": pyList{pyString("y")}}}, overlay: pyDict{"B": pyInt(1)}} + + assert.True(t, c1.Equals(c1)) + assert.True(t, c1.Equals(c2), "configs differing only in frozenness of nested values should be equal") + assert.True(t, c2.Equals(c1)) + assert.False(t, c1.Equals(c3)) + assert.False(t, c1.Equals(pyDict{"A": pyList{pyString("x")}, "B": pyInt(1)})) +} diff --git a/src/parse/asp/objects.go b/src/parse/asp/objects.go index 8abc33633..0f843328c 100644 --- a/src/parse/asp/objects.go +++ b/src/parse/asp/objects.go @@ -5,7 +5,6 @@ import ( "fmt" "iter" "math" - "reflect" "slices" "sort" "strconv" @@ -1007,7 +1006,7 @@ func (c *pyConfig) Equals(other pyObject) bool { default: return false } - return c == o || (reflect.DeepEqual(c.base, o.base) && c.overlay.Equals(o.overlay)) + return c == o || (c.base.dict.Equals(o.base.dict) && c.overlay.Equals(o.overlay)) } func (c *pyConfig) Property(scope *scope, name string) pyObject { From bb42334569ef98ae80692a9af77f4a17555d5e22 Mon Sep 17 00:00:00 2001 From: Peter Ebden Date: Mon, 9 Nov 2026 18:06:27 +0000 Subject: [PATCH 3/3] Undo list freezing change --- src/parse/asp/equality_test.go | 8 -------- src/parse/asp/objects.go | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/parse/asp/equality_test.go b/src/parse/asp/equality_test.go index f2bf78e06..01ff825e6 100644 --- a/src/parse/asp/equality_test.go +++ b/src/parse/asp/equality_test.go @@ -32,14 +32,6 @@ func TestEqualsFrozen(t *testing.T) { assert.False(t, (pyInt(1)).Equals(pyString("1"))) } -func TestFreezeIsDeep(t *testing.T) { - l := pyList{pyDict{"a": pyList{}}}.Freeze().(pyFrozenList) - d, ok := l.pyList[0].(pyFrozenDict) - assert.True(t, ok, "nested dict should be frozen") - _, ok = d.pyDict["a"].(pyFrozenList) - assert.True(t, ok, "nested list should be frozen") -} - func TestEqualsConfig(t *testing.T) { c1 := &pyConfig{base: &pyConfigBase{dict: pyDict{"A": pyList{pyString("x")}}}, overlay: pyDict{"B": pyInt(1)}} c2 := &pyConfig{base: &pyConfigBase{dict: pyDict{"A": pyList{pyString("x")}.Freeze()}}, overlay: pyDict{"B": pyInt(1)}} diff --git a/src/parse/asp/objects.go b/src/parse/asp/objects.go index 0f843328c..d49e52341 100644 --- a/src/parse/asp/objects.go +++ b/src/parse/asp/objects.go @@ -486,7 +486,7 @@ func (l pyList) Freeze() pyObject { frozen[i] = v } } - return pyFrozenList{pyList: frozen} + return pyFrozenList{pyList: l} } // Repeat returns a copy of this list, repeated n times