Skip to content

Commit 2a4866d

Browse files
fix list.sort and sorted to dispatch user __lt__
1 parent 7ac22d7 commit 2a4866d

8 files changed

Lines changed: 66 additions & 47 deletions

File tree

bugs.json

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"_meta": {
33
"source": "MicroPython tests/basics differential vs CPython 3.13",
44
"verified": "each repro re-run through VM + python3",
5-
"count": 7,
6-
"fixed": "93 bugs fixed and migrated to vm.json (try/finally + with unwinding cluster; 11 builtin/parser/format divergences from rows 1-11 of the low-NEWLINE triage; generator_return, string_format_modulo_int %*d, seq_unpack list targets, class_getattr dunder-on-type, builtin_slice type identity, containment dict/set-led tuple, range sequence-unpack; yield-from-as-expression cluster: gen_yield_from + gen_yield_from_ducktype + gen_yield_from_exc + gen_yield_from_stopped via a parse_atom Yield arm plus a LoadYieldFrom opcode that carries the subiterator return/StopIteration value, 40 regression cases; range_iter_overflow: AFL sig:06 abort on a range whose step increment crossed the i64 edge, fixed by checked_add in the three range walkers (iter_to_vec_for_spread, IterFrame::next_item, IterCursor::Range::next), 10 regression cases; range_iter_truncation: for-loop/comprehension (IterFrame::next_item) and spread/unpack (iter_to_vec_for_spread) yielded Val::int past the 47-bit inline range instead of promoting, routed both through range_int (pub(crate)) so values auto-promote to LongInt, 10 regression cases; range_contains: `x in range(...)` always returned False (contains had a catch-all Ok(false) for Range) — replaced with an O(1) bounds+step check over i128 that also matches integral floats like CPython, 10 regression cases; set_binop: augmented set `|=`/`&=`/`^=` rebound to a new set instead of mutating in place — added InPlaceBitOr/And/Xor opcodes (parser maps the augmented ops, handle_bitwise routes to set_iop_and_push) that rewrite the left set in place, frozenset still rebinds, 10 regression cases; del_deref: `del` of a closed-over variable cleared only the local slot, not the shared cell, so closures still read the old value — the Del handler now also unbinds the frame's cell for that slot, 10 regression cases)"
5+
"count": 6,
6+
"fixed": "94 bugs fixed and migrated to vm.json (try/finally + with unwinding cluster; 11 builtin/parser/format divergences from rows 1-11 of the low-NEWLINE triage; generator_return, string_format_modulo_int %*d, seq_unpack list targets, class_getattr dunder-on-type, builtin_slice type identity, containment dict/set-led tuple, range sequence-unpack; yield-from-as-expression cluster: gen_yield_from + gen_yield_from_ducktype + gen_yield_from_exc + gen_yield_from_stopped via a parse_atom Yield arm plus a LoadYieldFrom opcode that carries the subiterator return/StopIteration value, 40 regression cases; range_iter_overflow: AFL sig:06 abort on a range whose step increment crossed the i64 edge, fixed by checked_add in the three range walkers (iter_to_vec_for_spread, IterFrame::next_item, IterCursor::Range::next), 10 regression cases; range_iter_truncation: for-loop/comprehension (IterFrame::next_item) and spread/unpack (iter_to_vec_for_spread) yielded Val::int past the 47-bit inline range instead of promoting, routed both through range_int (pub(crate)) so values auto-promote to LongInt, 10 regression cases; range_contains: `x in range(...)` always returned False (contains had a catch-all Ok(false) for Range) — replaced with an O(1) bounds+step check over i128 that also matches integral floats like CPython, 10 regression cases; set_binop: augmented set `|=`/`&=`/`^=` rebound to a new set instead of mutating in place — added InPlaceBitOr/And/Xor opcodes (parser maps the augmented ops, handle_bitwise routes to set_iop_and_push) that rewrite the left set in place, frozenset still rebinds, 10 regression cases; del_deref: `del` of a closed-over variable cleared only the local slot, not the shared cell, so closures still read the old value — the Del handler now also unbinds the frame's cell for that slot, 10 regression cases; list_sort: `list.sort()` and `sorted()` used a pure `lt_vals` comparator that never dispatched a user `__lt__`, raising TypeError on class instances — added `sort_lt` (tries the `__lt__` dunder, then `lt_vals`) used by both sort paths, threaded chunk/slots through `call_sorted`/`sort_by_lt`/`sort_by_key` with GC rooting, and routed all `list.sort()` through `exec_sort` (both call paths) since the builtin-method table can't carry a frame, 10 regression cases)"
77
},
88
"bugs": [
99
{
@@ -46,14 +46,6 @@
4646
"expected": "(1, 2) {'z': 3}",
4747
"actual": "__VMERR__ TypeError: object is not callable"
4848
},
49-
{
50-
"id": "list_sort",
51-
"category": "wrong-value",
52-
"summary": "list.sort() does not dispatch to a user-defined __lt__ (which works for direct < comparison), raising TypeError instead of sorting.",
53-
"repro": "class A:\n def __init__(self, x):\n self.x = x\n def __lt__(self, other):\n return self.x < other.x\n def __repr__(self):\n return str(self.x)\nl = [A(3), A(1), A(2)]\nl.sort()\nprint(l)",
54-
"expected": "[1, 2, 3]",
55-
"actual": "__VMERR__ TypeError: '<' not supported between instances of 'object' and 'object'"
56-
},
5749
{
5850
"id": "gen_next_nested_arg",
5951
"category": "stack-corruption",

compiler/src/modules/vm/builtins/sequence.rs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use alloc::{vec, vec::Vec};
44

55
use super::super::VM;
66
use super::super::types::*;
7+
use crate::modules::parser::{OpCode, SSAChunk};
78

89
/* A range element as a Val, promoting magnitudes beyond the 47-bit inline range to LongInt. */
910
pub(crate) fn range_int(heap: &mut HeapPool, i: i64) -> Result<Val, VmErr> {
@@ -86,10 +87,10 @@ impl<'a> VM<'a> {
8687
self.push(Val::int(n)); Ok(())
8788
}
8889

89-
pub fn call_sorted(&mut self, reverse: bool) -> Result<(), VmErr> {
90+
pub fn call_sorted(&mut self, reverse: bool, chunk: &SSAChunk, slots: &mut [Val]) -> Result<(), VmErr> {
9091
let o = self.pop()?;
9192
let mut items = self.extract_iter(o, false)?;
92-
self.sort_by_lt(&mut items)?;
93+
self.sort_by_lt(&mut items, chunk, slots)?;
9394
if reverse { items.reverse(); }
9495
self.alloc_and_push_list(items)
9596
}
@@ -98,7 +99,7 @@ impl<'a> VM<'a> {
9899
pub fn call_sorted_with_key(&mut self, key: Option<Val>, reverse: bool, chunk: &crate::modules::parser::SSAChunk, slots: &mut [Val]) -> Result<(), VmErr> {
99100
let key = match key {
100101
Some(k) if !k.is_none() => k,
101-
_ => return self.call_sorted(reverse),
102+
_ => return self.call_sorted(reverse, chunk, slots),
102103
};
103104
let o = self.pop()?;
104105
let items = self.extract_iter(o, false)?;
@@ -117,7 +118,7 @@ impl<'a> VM<'a> {
117118
self.sort_by_key(items, k, chunk, slots)?
118119
} else {
119120
let mut s = items;
120-
self.sort_by_lt(&mut s)?;
121+
self.sort_by_lt(&mut s, chunk, slots)?;
121122
s
122123
};
123124
if reverse { result.reverse(); }
@@ -140,39 +141,53 @@ impl<'a> VM<'a> {
140141
self.exec_call(1, chunk, slots)?;
141142
keys.push(self.pop()?);
142143
}
144+
let roots_base = self.temp_roots.len();
145+
for &v in keys.iter().chain(items.iter()) { self.temp_roots.push(v); }
143146
let mut sort_err: Option<VmErr> = None;
144147
let order = Self::stable_sort_indices(items.len(), |a, b| {
145148
if sort_err.is_some() { return core::cmp::Ordering::Equal; }
146-
match self.lt_vals(keys[a], keys[b]) {
149+
match self.sort_lt(keys[a], keys[b], chunk, slots) {
147150
Ok(true) => core::cmp::Ordering::Less,
148-
Ok(false) => match self.lt_vals(keys[b], keys[a]) {
151+
Ok(false) => match self.sort_lt(keys[b], keys[a], chunk, slots) {
149152
Ok(true) => core::cmp::Ordering::Greater,
150153
Ok(false) => core::cmp::Ordering::Equal,
151154
Err(e) => { sort_err = Some(e); core::cmp::Ordering::Equal }
152155
},
153156
Err(e) => { sort_err = Some(e); core::cmp::Ordering::Equal }
154157
}
155158
});
159+
self.temp_roots.truncate(roots_base);
156160
if let Some(e) = sort_err { return Err(e); }
157161
Ok(order.into_iter().map(|i| items[i]).collect())
158162
}
159163

160-
/* In-place sort via `lt_vals`. Stashes the first error and surfaces it after the sort. */
161-
pub(crate) fn sort_by_lt(&self, items: &mut [Val]) -> Result<(), VmErr> {
164+
// a < b via __lt__ when either side defines it, else the built-in comparison.
165+
pub(crate) fn sort_lt(&mut self, a: Val, b: Val, chunk: &SSAChunk, slots: &mut [Val]) -> Result<bool, VmErr> {
166+
if let Some(r) = self.try_compare_dunder(OpCode::Lt, a, b, chunk, slots)? {
167+
return Ok(self.truthy(r));
168+
}
169+
self.lt_vals(a, b)
170+
}
171+
172+
/* In-place sort dispatching `__lt__`; roots items since a comparison can run user code that GCs. */
173+
pub(crate) fn sort_by_lt(&mut self, items: &mut [Val], chunk: &SSAChunk, slots: &mut [Val]) -> Result<(), VmErr> {
162174
let snapshot = items.to_vec();
175+
let roots_base = self.temp_roots.len();
176+
for &v in &snapshot { self.temp_roots.push(v); }
163177
let mut sort_err: Option<VmErr> = None;
164-
let order = Self::stable_sort_indices(items.len(), |a, b| {
178+
let order = Self::stable_sort_indices(snapshot.len(), |a, b| {
165179
if sort_err.is_some() { return core::cmp::Ordering::Equal; }
166-
match self.lt_vals(snapshot[a], snapshot[b]) {
180+
match self.sort_lt(snapshot[a], snapshot[b], chunk, slots) {
167181
Ok(true) => core::cmp::Ordering::Less,
168-
Ok(false) => match self.lt_vals(snapshot[b], snapshot[a]) {
182+
Ok(false) => match self.sort_lt(snapshot[b], snapshot[a], chunk, slots) {
169183
Ok(true) => core::cmp::Ordering::Greater,
170184
Ok(false) => core::cmp::Ordering::Equal,
171185
Err(e) => { sort_err = Some(e); core::cmp::Ordering::Equal }
172186
},
173187
Err(e) => { sort_err = Some(e); core::cmp::Ordering::Equal }
174188
}
175189
});
190+
self.temp_roots.truncate(roots_base);
176191
if let Some(e) = sort_err { return Err(e); }
177192
for (dst, &src) in order.iter().enumerate() { items[dst] = snapshot[src]; }
178193
Ok(())

compiler/src/modules/vm/dispatch.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,8 @@ impl<'a> VM<'a> {
315315
self.exec_call(encoded, chunk, slots)
316316
}
317317
handlers::methods::AttrLookup::BuiltinMethod(id) => {
318+
// sort runs user __lt__, so it needs chunk/slots the builtin-method table can't carry.
319+
if id.name() == "sort" { return self.exec_sort(obj, &positional, &kw_flat, chunk, slots); }
318320
self.exec_bound_method(obj, id, &positional, &kw_flat)
319321
}
320322
handlers::methods::AttrLookup::InstanceField(field) => {

compiler/src/modules/vm/handlers/builtin_methods/list.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,7 @@ pub fn pop(vm: &mut VM, recv: Val, pos: &[Val]) -> Result<(), VmErr> {
116116
vm.push(popped); Ok(())
117117
}
118118

119-
pub fn sort(vm: &mut VM, recv: Val, _pos: &[Val]) -> Result<(), VmErr> {
120-
let mut sorted = list_clone(vm, recv)?;
121-
vm.sort_by_lt(&mut sorted)?;
122-
list_mut(vm, recv, "sort: receiver is not a list", |list| {
123-
*list = sorted; Ok(())
124-
})?;
125-
vm.push(Val::none()); Ok(())
119+
// list.sort() is intercepted in try_dispatch_non_func_callable, which has chunk/slots for __lt__.
120+
pub fn sort(_vm: &mut VM, _recv: Val, _pos: &[Val]) -> Result<(), VmErr> {
121+
Err(cold_type("list.sort dispatched without a frame"))
126122
}

compiler/src/modules/vm/handlers/function.rs

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ impl<'a> VM<'a> {
6767
OpCode::CallType => self.call_type(),
6868
OpCode::CallChr => self.call_chr(),
6969
OpCode::CallOrd => self.call_ord(),
70-
OpCode::CallSorted => self.call_sorted(false),
70+
OpCode::CallSorted => self.call_sorted(false, chunk, slots),
7171
OpCode::CallList => self.call_list(operand, chunk, slots),
7272
OpCode::CallTuple => self.call_tuple(operand, chunk, slots),
7373
OpCode::CallEnumerate => self.call_enumerate(operand),
@@ -304,26 +304,30 @@ impl<'a> VM<'a> {
304304
Ok(Some(heap.alloc(super::super::types::HeapObj::Dict(Rc::new(RefCell::new(dm))))?))
305305
}
306306

307+
/* list.sort(): parse key/reverse kwargs and sort in place. Intercepted from both call paths since it needs chunk/slots for __lt__. */
308+
pub(crate) fn exec_sort(&mut self, recv: Val, positional: &[Val], kw_flat: &[Val], chunk: &SSAChunk, slots: &mut [Val]) -> Result<(), VmErr> {
309+
if !positional.is_empty() {
310+
return Err(cold_type("list.sort() takes no positional arguments"));
311+
}
312+
let mut sort_key: Option<Val> = None;
313+
let mut sort_reverse = false;
314+
for pair in kw_flat.chunks(2) {
315+
match self.kw_name(pair[0]) {
316+
Some("key") => sort_key = Some(pair[1]),
317+
Some("reverse") => sort_reverse = self.truthy(pair[1]),
318+
_ => return Err(cold_type("list.sort() got unexpected keyword argument")),
319+
}
320+
}
321+
self.call_list_sort_keyed(recv, sort_key, sort_reverse, chunk, slots)
322+
}
323+
307324
/* Dispatch non-Func callees. Returns Ok(true) when handled here; Ok(false) means the caller falls through to the Func path. */
308325
fn try_dispatch_non_func_callable(&mut self, callee: Val, positional: &[Val], kw_flat: &[Val], num_kw: usize, chunk: &SSAChunk, slots: &mut [Val]) -> Result<bool, VmErr> {
309326
if let HeapObj::BoundMethod(recv, id) = self.heap.get(callee) {
310327
let recv = *recv;
311328
let id = *id;
312-
if id.name() == "sort" && !kw_flat.is_empty() {
313-
if !positional.is_empty() {
314-
return Err(cold_type("list.sort() takes no positional arguments"));
315-
}
316-
let mut sort_key: Option<Val> = None;
317-
let mut sort_reverse = false;
318-
for pair in kw_flat.chunks(2) {
319-
let (name_v, val_v) = (pair[0], pair[1]);
320-
match self.kw_name(name_v) {
321-
Some("key") => sort_key = Some(val_v),
322-
Some("reverse") => sort_reverse = self.truthy(val_v),
323-
_ => return Err(cold_type("list.sort() got unexpected keyword argument")),
324-
}
325-
}
326-
self.call_list_sort_keyed(recv, sort_key, sort_reverse, chunk, slots)?;
329+
if id.name() == "sort" {
330+
self.exec_sort(recv, positional, kw_flat, chunk, slots)?;
327331
return Ok(true);
328332
}
329333
self.exec_bound_method(recv, id, positional, kw_flat)?;

compiler/tests/cases/vm.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3587,5 +3587,15 @@
35873587
{"src":"def f():\n x = 1\n y = 2\n def g():\n nonlocal x, y\n try:\n print(x)\n except NameError:\n print('x gone')\n print(y)\n del x\n g()\nf()","output":["x gone","2"]},
35883588
{"src":"def f():\n x = 1\n def g():\n nonlocal x\n try:\n return x\n except NameError:\n return 'NE'\n def h():\n nonlocal x\n try:\n return x\n except NameError:\n return 'NE'\n del x\n print(g(), h())\nf()","output":["NE NE"]},
35893589
{"src":"def f():\n x = 1\n def g():\n nonlocal x\n return x\n del x\n try:\n g()\n except NameError:\n return 'caught'\nprint(f())","output":["caught"]},
3590-
{"src":"def f():\n x = 1\n def g():\n return x\n del x\n x = 99\n return g()\nprint(f())","output":["99"]}
3590+
{"src":"def f():\n x = 1\n def g():\n return x\n del x\n x = 99\n return g()\nprint(f())","output":["99"]},
3591+
{"src":"class A:\n def __init__(self, x):\n self.x = x\n def __lt__(self, other):\n return self.x < other.x\n def __repr__(self):\n return str(self.x)\nl = [A(3), A(1), A(2)]\nl.sort()\nprint(l)","output":["[1, 2, 3]"]},
3592+
{"src":"class A:\n def __init__(self, x):\n self.x = x\n def __lt__(self, other):\n return self.x < other.x\n def __repr__(self):\n return str(self.x)\nprint(sorted([A(3), A(1), A(2)]))","output":["[1, 2, 3]"]},
3593+
{"src":"class A:\n def __init__(self, x):\n self.x = x\n def __lt__(self, other):\n return self.x < other.x\n def __repr__(self):\n return str(self.x)\nl = [A(3), A(1), A(2)]\nl.sort(reverse=True)\nprint(l)","output":["[3, 2, 1]"]},
3594+
{"src":"class A:\n def __init__(self, x):\n self.x = x\n def __lt__(self, other):\n return self.x < other.x\n def __repr__(self):\n return str(self.x)\nprint(sorted([A(3), A(1), A(2)], reverse=True))","output":["[3, 2, 1]"]},
3595+
{"src":"l = [3, 1, 2]\nl.sort()\nprint(l)","output":["[1, 2, 3]"]},
3596+
{"src":"print(sorted(['banana', 'apple', 'cherry']))","output":["['apple', 'banana', 'cherry']"]},
3597+
{"src":"print(sorted([-3, 1, -2], key=abs))","output":["[1, -2, -3]"]},
3598+
{"src":"print(sorted(['ccc', 'a', 'bb'], key=len))","output":["['a', 'bb', 'ccc']"]},
3599+
{"src":"try:\n sorted([1, 'a'])\nexcept TypeError:\n print('te')","output":["te"]},
3600+
{"src":"l = [2, 1]\nr = l.sort()\nprint(r, l)","output":["None [1, 2]"]}
35913601
]

docs/content/reference/builtins.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ True
316316

317317
### sorted
318318

319-
New sorted list. Accepts `key=fn` and `reverse=True/False`. Numbers, strings, bytes, and tuples/lists order lexicographically; mixed un-orderable types raise `TypeError`.
319+
New sorted list. Accepts `key=fn` and `reverse=True/False`. Numbers, strings, bytes, and tuples/lists order lexicographically; objects defining `__lt__` sort by it; mixed un-orderable types raise `TypeError`.
320320

321321
```python
322322
print(sorted([3, 1, 4, 1, 5]))

docs/content/reference/methods.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ print(ys)
268268

269269
### Mutating
270270

271-
Return `None`, mutate in place. `extend` accepts any iterable. `sort` accepts `key=fn` and `reverse=True/False`.
271+
Return `None`, mutate in place. `extend` accepts any iterable. `sort` accepts `key=fn` and `reverse=True/False`, and orders custom objects by their `__lt__`.
272272

273273
```python
274274
xs = [1, 2, 3]

0 commit comments

Comments
 (0)