From 3879b3dcdd17665b2634f77775167f9d19c0aac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:00:33 +0200 Subject: [PATCH 01/27] (a < b) != (not b < a) --- asyncstdlib/builtins.py | 4 ++-- asyncstdlib/heapq.py | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/asyncstdlib/builtins.py b/asyncstdlib/builtins.py index 5c8f54c..2d2e5cc 100644 --- a/asyncstdlib/builtins.py +++ b/asyncstdlib/builtins.py @@ -312,14 +312,14 @@ async def _min_max( raise ValueError(f"{name}() arg is an empty sequence") elif key is None: async for item in item_iter: - if invert ^ bool(item < best): + if item < best if not invert else best < item: best = item else: key = _awaitify(key) best_key = await key(best) async for item in item_iter: item_key = await key(item) - if invert ^ (item_key < best_key): + if item_key < best_key if not invert else best_key < item_key: best = item best_key = item_key return best diff --git a/asyncstdlib/heapq.py b/asyncstdlib/heapq.py index fee5959..a82473a 100644 --- a/asyncstdlib/heapq.py +++ b/asyncstdlib/heapq.py @@ -12,7 +12,7 @@ from .builtins import enumerate as a_enumerate, zip as a_zip from ._core import aiter, awaitify, ScopedIter, borrow -from ._typing import AnyIterable, ACloseable, LT, T +from ._typing import AnyIterable, ACloseable, LT, T, SupportsBool class _KeyIter(Generic[LT]): @@ -91,8 +91,12 @@ async def pull_head(self) -> bool: self.head_key = await self.key(head) if self.key is not None else head return True - def __lt__(self, other: _KeyIter[LT]) -> bool: - return self.reverse ^ bool(self.head_key < other.head_key) + def __lt__(self, other: _KeyIter[LT]) -> SupportsBool: + return ( + self.head_key < other.head_key + if not self.reverse + else other.head_key < self.head_key + ) def __eq__(self, other: _KeyIter[LT]) -> bool: # type: ignore[override] return not (self.head_key < other.head_key or other.head_key < self.head_key) From 30d89ab708974d033f989de8104ba7bdd9b5ffda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:18:17 +0200 Subject: [PATCH 02/27] test min/max with duplicates --- unittests/test_builtins.py | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/unittests/test_builtins.py b/unittests/test_builtins.py index 382fddd..37c248e 100644 --- a/unittests/test_builtins.py +++ b/unittests/test_builtins.py @@ -180,6 +180,41 @@ async def minus(x: int) -> int: assert await a.max(asyncify((4, 2, 3, 1)), key=minus) == 1 +class Tagged: + def __init__(self, value: float, tag: str) -> None: + self.value = value + self.tag = tag + + def __lt__(self, other: "Tagged"): + return self.value < other.value + + +nan = float("nan") +tagged_order_values = [ + [Tagged(v, str(v)) for v in range(5)], + [Tagged(v, str(v)) for v in range(5)[::-1]], + [Tagged(1, "small"), Tagged(2, "first"), Tagged(2, "second")], + [Tagged(2, "first"), Tagged(2, "second"), Tagged(1, "small")], + [Tagged(2, "first"), Tagged(1, "small"), Tagged(2, "second")], + [Tagged(9, "large"), Tagged(2, "first"), Tagged(2, "second")], + [Tagged(2, "first"), Tagged(2, "second"), Tagged(9, "large")], + [Tagged(2, "first"), Tagged(9, "large"), Tagged(2, "second")], + [Tagged(nan, "nan"), Tagged(2, "first"), Tagged(2, "second")], + [Tagged(2, "first"), Tagged(2, "second"), Tagged(nan, "nan")], + [Tagged(2, "first"), Tagged(nan, "nan"), Tagged(2, "second")], +] + + +@pytest.mark.parametrize("values", tagged_order_values) +@sync +async def test_max_fifo(values: "list[Tagged]") -> None: + for itr in (asyncify, iter): + assert await a.max(itr(values)) == max(values) + assert await a.max(itr(values), key=lambda t: t.value) == max( + values, key=lambda t: t.value + ) + + @sync async def test_min_default(): assert await a.min((), default=3) == 3 @@ -205,6 +240,16 @@ async def minus(x: int) -> int: assert await a.min(asyncify((4, 2, 3, 1)), key=minus) == 4 +@pytest.mark.parametrize("values", tagged_order_values) +@sync +async def test_min_fifo(values: "list[Tagged]") -> None: + for itr in (asyncify, iter): + assert await a.min(itr(values)) == min(values) + assert await a.min(itr(values), key=lambda t: t.value) == min( + values, key=lambda t: t.value + ) + + @sync async def test_filter_as(): async def map_op(value: int) -> bool: From b0a6e2bfd0f56a9f9bef8cc0483b13589bb1b172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:19:44 +0200 Subject: [PATCH 03/27] reduce chance of maintenance error --- unittests/test_builtins.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/unittests/test_builtins.py b/unittests/test_builtins.py index 37c248e..ed9b0cc 100644 --- a/unittests/test_builtins.py +++ b/unittests/test_builtins.py @@ -188,6 +188,9 @@ def __init__(self, value: float, tag: str) -> None: def __lt__(self, other: "Tagged"): return self.value < other.value + def key(self) -> float: + return self.value + nan = float("nan") tagged_order_values = [ @@ -210,9 +213,7 @@ def __lt__(self, other: "Tagged"): async def test_max_fifo(values: "list[Tagged]") -> None: for itr in (asyncify, iter): assert await a.max(itr(values)) == max(values) - assert await a.max(itr(values), key=lambda t: t.value) == max( - values, key=lambda t: t.value - ) + assert await a.max(itr(values), key=Tagged.key) == max(values, key=Tagged.key) @sync @@ -245,9 +246,7 @@ async def minus(x: int) -> int: async def test_min_fifo(values: "list[Tagged]") -> None: for itr in (asyncify, iter): assert await a.min(itr(values)) == min(values) - assert await a.min(itr(values), key=lambda t: t.value) == min( - values, key=lambda t: t.value - ) + assert await a.min(itr(values), key=Tagged.key) == min(values, key=Tagged.key) @sync From 0a0dee5577a375edf4b5a4ef15245bfb9991fc87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:34:12 +0200 Subject: [PATCH 04/27] removed mention of explicitly unsupported featuer --- asyncstdlib/builtins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asyncstdlib/builtins.py b/asyncstdlib/builtins.py index 2d2e5cc..fd22273 100644 --- a/asyncstdlib/builtins.py +++ b/asyncstdlib/builtins.py @@ -248,7 +248,7 @@ async def max( default: Any = __MIN_MAX_DEFAULT, ) -> Any: """ - Return the largest item from an (async) iterable or from two or more values + Return the largest item from an (async) iterable :raises ValueError: if ``iterable`` is empty and ``default`` is not set @@ -274,7 +274,7 @@ async def min( default: Any = __MIN_MAX_DEFAULT, ) -> Any: """ - Return the smallest item from an (async) iterable or from two or more values + Return the smallest item from an (async) iterable :raises ValueError: if ``iterable`` is empty and ``default`` is not set From 80e140711dba336c7b82aabc50c2788116fdb6ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:35:44 +0200 Subject: [PATCH 05/27] cleanup leftove impl type header --- asyncstdlib/builtins.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/asyncstdlib/builtins.py b/asyncstdlib/builtins.py index fd22273..093cad2 100644 --- a/asyncstdlib/builtins.py +++ b/asyncstdlib/builtins.py @@ -294,11 +294,11 @@ async def min( async def _min_max( - iterable: AnyIterable[LT], - key: Optional[Callable[[LT], Any]], + iterable: AnyIterable[Any], + key: Optional[Callable[[Any], LT]], invert: bool, - default: LT = __MIN_MAX_DEFAULT, # type: ignore -) -> LT: + default: Any = __MIN_MAX_DEFAULT, # type: ignore +) -> Any: """ Implementation of ``min``/``max`` From 7b4bffcf99aac4156e93c8da657b2c838a1e06ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:40:44 +0200 Subject: [PATCH 06/27] docs match code type hints --- docs/source/api/builtins.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/api/builtins.rst b/docs/source/api/builtins.rst index c5ca22b..783691d 100644 --- a/docs/source/api/builtins.rst +++ b/docs/source/api/builtins.rst @@ -20,10 +20,10 @@ Iterator reducing .. autofunction:: any(iterable: (async) iter T) -> bool :async: -.. autofunction:: max(iterable: (async) iter T, *, key: (T) → Any, default: T) -> T +.. autofunction:: max(iterable: (async) iter T, *, key: (T) → Any [, default: D]) -> T | D :async: -.. autofunction:: min(iterable: (async) iter T, *, key: (T) → Any, default: T) -> T +.. autofunction:: min(iterable: (async) iter T, *, key: (T) → Any [, default: D]) -> T | D :async: .. autofunction:: sum(iterable: (async) iter T, start: T = 0) -> T From 3b1daab48d9c44dbceefe2a896f36dbbe4c4ddca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:42:06 +0200 Subject: [PATCH 07/27] document that key is async neutral --- docs/source/api/builtins.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/api/builtins.rst b/docs/source/api/builtins.rst index 783691d..7c8967a 100644 --- a/docs/source/api/builtins.rst +++ b/docs/source/api/builtins.rst @@ -20,10 +20,10 @@ Iterator reducing .. autofunction:: any(iterable: (async) iter T) -> bool :async: -.. autofunction:: max(iterable: (async) iter T, *, key: (T) → Any [, default: D]) -> T | D +.. autofunction:: max(iterable: (async) iter T, *, key: (T) → (await) Any [, default: D]) -> T | D :async: -.. autofunction:: min(iterable: (async) iter T, *, key: (T) → Any [, default: D]) -> T | D +.. autofunction:: min(iterable: (async) iter T, *, key: (T) → (await) Any [, default: D]) -> T | D :async: .. autofunction:: sum(iterable: (async) iter T, start: T = 0) -> T From 5633528897952cee8c50dc5c7e074d7344df838f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:46:34 +0200 Subject: [PATCH 08/27] so that's why... --- asyncstdlib/builtins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asyncstdlib/builtins.py b/asyncstdlib/builtins.py index 093cad2..3662295 100644 --- a/asyncstdlib/builtins.py +++ b/asyncstdlib/builtins.py @@ -295,7 +295,7 @@ async def min( async def _min_max( iterable: AnyIterable[Any], - key: Optional[Callable[[Any], LT]], + key: Optional[Callable[[Any], Any]], invert: bool, default: Any = __MIN_MAX_DEFAULT, # type: ignore ) -> Any: From fa543404c980d7928116c12ee43c9219040926b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:50:10 +0200 Subject: [PATCH 09/27] bail directly on needing default --- asyncstdlib/builtins.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/asyncstdlib/builtins.py b/asyncstdlib/builtins.py index 3662295..1ae420c 100644 --- a/asyncstdlib/builtins.py +++ b/asyncstdlib/builtins.py @@ -305,12 +305,14 @@ async def _min_max( :param invert: compute ``max`` if ``True`` and ``min`` otherwise """ async with ScopedIter(iterable) as item_iter: - best = await anext(item_iter, default=default) - # this implies that item_iter is empty and default is __MIN_MAX_DEFAULT - if best is __MIN_MAX_DEFAULT: # type: ignore + try: + best = await anext(item_iter) + except StopAsyncIteration: + if default is not __MIN_MAX_DEFAULT: + return default name = "max" if invert else "min" raise ValueError(f"{name}() arg is an empty sequence") - elif key is None: + if key is None: async for item in item_iter: if item < best if not invert else best < item: best = item From 730c981c82590c2923fb62277ef2be39b8cbbc9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:51:10 +0200 Subject: [PATCH 10/27] min/max takes non-sequences --- asyncstdlib/builtins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asyncstdlib/builtins.py b/asyncstdlib/builtins.py index 1ae420c..c0f063f 100644 --- a/asyncstdlib/builtins.py +++ b/asyncstdlib/builtins.py @@ -311,7 +311,7 @@ async def _min_max( if default is not __MIN_MAX_DEFAULT: return default name = "max" if invert else "min" - raise ValueError(f"{name}() arg is an empty sequence") + raise ValueError(f"{name}() iterable argument is empty") from None if key is None: async for item in item_iter: if item < best if not invert else best < item: From 7c55380f3ff359d8a05883fc33c6fc82e9d447e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:54:15 +0200 Subject: [PATCH 11/27] fix typo, clarify wording --- typetests/README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typetests/README.rst b/typetests/README.rst index 361fa3e..b6ed5dd 100644 --- a/typetests/README.rst +++ b/typetests/README.rst @@ -9,7 +9,7 @@ Test Files ========== Tests MUST be organised into files, with similar tests grouped together. -Each test file SHOULD be called as per the pattern ``type_.py``, +Each test file SHOULD be named as per the pattern ``test_.py``, where ```` describes what the tests cover; for example, ``test_functools.py`` type-tests the ``functools`` package. From 0a5e92839a63c89cc5640f8ba4f7373c073f0f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Tue, 20 Oct 2026 20:58:32 +0200 Subject: [PATCH 12/27] test min/max default typing --- typetests/test_builtins.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/typetests/test_builtins.py b/typetests/test_builtins.py index f411a5f..5c39ebf 100644 --- a/typetests/test_builtins.py +++ b/typetests/test_builtins.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import TypeVar, assert_type from asyncstdlib import builtins T = TypeVar("T") @@ -15,3 +15,13 @@ async def async_identity(v: T) -> T: async def test_min_asyncneutral() -> None: await builtins.min([1, 2, 3], key=identity) await builtins.min([1, 2, 3], key=async_identity) + + +async def test_max_asyncneutral() -> None: + await builtins.max([1, 2, 3], key=identity) + await builtins.max([1, 2, 3], key=async_identity) + + +async def test_minmax_default() -> None: + assert_type(await builtins.min([1, 2], default="str"), int | str) + assert_type(await builtins.max([1, 2], default="str"), int | str) From 6d2179aa6b3c707a1962749fa4b61c3550943e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Wed, 21 Oct 2026 17:59:12 +0200 Subject: [PATCH 13/27] test default handling --- unittests/test_builtins.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/unittests/test_builtins.py b/unittests/test_builtins.py index ed9b0cc..863b7a4 100644 --- a/unittests/test_builtins.py +++ b/unittests/test_builtins.py @@ -161,6 +161,11 @@ def triple_sum(x: int, y: int, z: int) -> int: async def test_max_default(): assert await a.max((), default=3) == 3 assert await a.max((), key=lambda x: x, default=3) == 3 + # default does not override items + assert await a.max((3, 2, 1), default=1) == 3 + # default ignores key + assert await a.max([], default=1, key=str.split) == 1 + with pytest.raises(ValueError): assert await a.max(()) == 3 with pytest.raises(ValueError): @@ -222,6 +227,9 @@ async def test_min_default(): assert await a.min((), key=lambda x: x, default=3) == 3 # default does not override items assert await a.min((3, 2, 1), default=3) == 1 + # default ignores key + assert await a.min([], default=1, key=str.split) == 1 + with pytest.raises(ValueError): assert await a.min(()) == 3 with pytest.raises(ValueError): From 0bfe89f58812367a90be1ca6fe8d12d3afee9159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Wed, 21 Oct 2026 18:30:12 +0200 Subject: [PATCH 14/27] test error on unsortable input --- unittests/test_builtins.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/unittests/test_builtins.py b/unittests/test_builtins.py index 863b7a4..43139a6 100644 --- a/unittests/test_builtins.py +++ b/unittests/test_builtins.py @@ -395,3 +395,17 @@ def collision_key(x: int) -> int: assert await a.sorted(values, key=awaitify(collision_key)) == sorted( values, key=collision_key ) + + +@pytest.mark.parametrize("reverse", [True, False]) +@sync +async def test_sorted_unsortable(reverse: bool): + unsortable = [None, "None"] + with pytest.raises(TypeError): + await a.sorted(a.iter(unsortable), reverse=reverse) + with pytest.raises(TypeError): + await a.sorted(iter(unsortable), reverse=reverse) + with pytest.raises(TypeError): + await a.sorted(a.iter(unsortable), key=lambda x: x, reverse=reverse) + with pytest.raises(TypeError): + await a.sorted(iter(unsortable), key=lambda x: x, reverse=reverse) From fb1dca469bd8b54adf717f7756695ce9e7b7c09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Wed, 21 Oct 2026 18:31:20 +0200 Subject: [PATCH 15/27] remove leaky fast path --- asyncstdlib/builtins.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/asyncstdlib/builtins.py b/asyncstdlib/builtins.py index c0f063f..d2d11bc 100644 --- a/asyncstdlib/builtins.py +++ b/asyncstdlib/builtins.py @@ -448,13 +448,9 @@ async def sorted( It is guaranteed to be worst-case O(n log n) runtime. """ if key is None: - # TODO: is this a worthwhile optimisation? - try: - return _sync_builtins.sorted(iterable, reverse=reverse) # type: ignore - except TypeError: - items: _sync_builtins.list[Any] = [item async for item in aiter(iterable)] - items.sort(reverse=reverse) - return items + items: _sync_builtins.list[Any] = [item async for item in aiter(iterable)] + items.sort(reverse=reverse) + return items else: async_key = _awaitify(key) keyed_items = [(await async_key(item), item) async for item in aiter(iterable)] From b0c2f1aad4016f0f66d13ec258fe2ace115d5c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Thu, 22 Oct 2026 11:02:12 +0200 Subject: [PATCH 16/27] document test cases --- unittests/test_heapq.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/unittests/test_heapq.py b/unittests/test_heapq.py index 425e412..926e246 100644 --- a/unittests/test_heapq.py +++ b/unittests/test_heapq.py @@ -8,13 +8,20 @@ from .utility import sync, asyncify, awaitify MERGE_SAMPLES = [ + # pre-sorted [[1, 2], [3, 4]], [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + # ragged and shuffled [[1, 4, 7], [2, 5, 8], [3, 6, 9]], [[1], [2, 3], [4, 5, 6], [7, 8, 9]], [[1], [2, 4, 6], [3], [5, 7, 8, 9]], + # duplication + [[1, 2], [1, 2]], [[1, 2, 3, 4] for _ in range(3)], + [[-4, -3, -2, 1, 2, 3, 4] for _ in range(3)], + # random input [sorted(random.random() for _ in range(5)) for _ in range(5)], + # degenerate cases [[], []], [[]], ] From 0cac00fe2de47bc8029c9f4ffb95326524371ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Thu, 22 Oct 2026 11:05:31 +0200 Subject: [PATCH 17/27] make the type checker happier --- unittests/test_heapq.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/unittests/test_heapq.py b/unittests/test_heapq.py index 926e246..6926019 100644 --- a/unittests/test_heapq.py +++ b/unittests/test_heapq.py @@ -7,7 +7,7 @@ from .utility import sync, asyncify, awaitify -MERGE_SAMPLES = [ +MERGE_SAMPLES: "list[list[list[float]]]" = [ # pre-sorted [[1, 2], [3, 4]], [[1, 2, 3], [4, 5, 6], [7, 8, 9]], @@ -30,7 +30,7 @@ @pytest.mark.parametrize("samples", MERGE_SAMPLES) @pytest.mark.parametrize("reverse", [False, True]) @sync -async def test_merge_stdlib(samples, reverse): +async def test_merge_stdlib(samples: "list[list[float]]", reverse: bool) -> None: """Compare `heapq.merge` against stdlib implementation""" samples = samples if not reverse else [sample[::-1] for sample in samples] assert list(heapq.merge(*samples, reverse=reverse)) == [ @@ -41,7 +41,7 @@ async def test_merge_stdlib(samples, reverse): @pytest.mark.parametrize("samples", MERGE_SAMPLES) @pytest.mark.parametrize("reverse", [False, True]) @sync -async def test_merge_stdlib_key(samples, reverse): +async def test_merge_stdlib_key(samples: "list[list[float]]", reverse: bool) -> None: """Compare `heapq.merge` with key against stdlib implementation""" # use a key that reverses the result => must reverse input samples = samples if reverse else [sample[::-1] for sample in samples] From ec37f63b18796e9d85a01e544dd534b6503a34a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Thu, 22 Oct 2026 18:51:05 +0200 Subject: [PATCH 18/27] test nan handling against stdlib --- unittests/test_heapq.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/unittests/test_heapq.py b/unittests/test_heapq.py index 6926019..c5b59bc 100644 --- a/unittests/test_heapq.py +++ b/unittests/test_heapq.py @@ -53,6 +53,24 @@ async def test_merge_stdlib_key(samples: "list[list[float]]", reverse: bool) -> ] +# Technically, merge requires pre-sorted input which excludes unorder'able items +# Practically, `float` includes `nan` and data includes `float` +NAN_MERGE_SAMPLES: "list[list[list[float]]]" = [ + [[1, 2, float("nan")], [1, 2, float("nan")]] +] + + +@pytest.mark.parametrize("samples", NAN_MERGE_SAMPLES) +@pytest.mark.parametrize("reverse", [False, True]) +@sync +async def test_merge_unordered(samples: "list[list[float]]", reverse: bool) -> None: + r"""Compare `heapq.merge` against stdlib implementation given `nan`\ s""" + samples = samples if not reverse else [sample[::-1] for sample in samples] + assert pytest.approx(list(heapq.merge(*samples, reverse=reverse)), nan_ok=True) == [ + item async for item in a.merge(*map(asyncify, samples), reverse=reverse) + ] + + MINMAX_SAMPLES = [ [0, 3, 1, 4, 2, 5], [0, -3, 1, -4, -2, 5], From b8be0a3e207032c10a24ea6c36f8b89a9892e444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Thu, 22 Oct 2026 19:01:17 +0200 Subject: [PATCH 19/27] try to respect custom equality --- asyncstdlib/heapq.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/asyncstdlib/heapq.py b/asyncstdlib/heapq.py index a82473a..af5edf4 100644 --- a/asyncstdlib/heapq.py +++ b/asyncstdlib/heapq.py @@ -98,7 +98,12 @@ def __lt__(self, other: _KeyIter[LT]) -> SupportsBool: else other.head_key < self.head_key ) + # needed for tuple comparisons def __eq__(self, other: _KeyIter[LT]) -> bool: # type: ignore[override] + # try to respect full ordering interface... + if self.head_key.__eq__ is not object.__eq__: + return self.head_key == other.head_key + # ...but fall back to just using the `<` operator required for sorting return not (self.head_key < other.head_key or other.head_key < self.head_key) From c84fe62246e16ea032c07d871d5865c066572f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Thu, 22 Oct 2026 19:16:05 +0200 Subject: [PATCH 20/27] fetch assert_type from extensions --- typetests/README.rst | 3 +++ typetests/test_builtins.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/typetests/README.rst b/typetests/README.rst index b6ed5dd..9a9a205 100644 --- a/typetests/README.rst +++ b/typetests/README.rst @@ -27,6 +27,9 @@ Tests MUST contain statements to be type-checked: Test files MAY contain non-test functions, methods or classes for use inside tests. These SHOULD be type-consistent and not require any type assertions or expected errors. +**Note:** For compatibility with older Python versions, + `typing_extensions` may be used to provide `assert_type` and `assert_never`. + Test Execution ============== diff --git a/typetests/test_builtins.py b/typetests/test_builtins.py index 5c39ebf..f08b565 100644 --- a/typetests/test_builtins.py +++ b/typetests/test_builtins.py @@ -1,5 +1,6 @@ -from typing import TypeVar, assert_type +from typing import TypeVar from asyncstdlib import builtins +from typing_extensions import assert_type T = TypeVar("T") From 779f0181bd5155cf072b012d81362375053e53a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Thu, 22 Oct 2026 19:29:01 +0200 Subject: [PATCH 21/27] implementation specific test --- unittests/test_heapq.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/unittests/test_heapq.py b/unittests/test_heapq.py index c5b59bc..6ecaebf 100644 --- a/unittests/test_heapq.py +++ b/unittests/test_heapq.py @@ -1,5 +1,6 @@ import heapq +import platform import pytest import random @@ -60,6 +61,10 @@ async def test_merge_stdlib_key(samples: "list[list[float]]", reverse: bool) -> ] +@pytest.mark.skipif( + platform.python_implementation() != "CPython", + reason="heapq.merge for unordered items is specific to CPython", +) @pytest.mark.parametrize("samples", NAN_MERGE_SAMPLES) @pytest.mark.parametrize("reverse", [False, True]) @sync From c3f9ce9a370329b4606c9a1e5fde6de303ecd3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Thu, 22 Oct 2026 19:31:02 +0200 Subject: [PATCH 22/27] cover more float floating --- unittests/test_heapq.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/unittests/test_heapq.py b/unittests/test_heapq.py index 6ecaebf..ee69c5a 100644 --- a/unittests/test_heapq.py +++ b/unittests/test_heapq.py @@ -57,7 +57,9 @@ async def test_merge_stdlib_key(samples: "list[list[float]]", reverse: bool) -> # Technically, merge requires pre-sorted input which excludes unorder'able items # Practically, `float` includes `nan` and data includes `float` NAN_MERGE_SAMPLES: "list[list[list[float]]]" = [ - [[1, 2, float("nan")], [1, 2, float("nan")]] + [[1, 2, float("nan")], [1, 2, float("nan")]], + [[1, float("nan"), 2], [1, float("nan"), 2]], + [[float("nan"), 1, 2], [float("nan"), 1, 2]], ] From 190fd4a0e04025fec5adfd29dabecf5e13ff96d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Thu, 22 Oct 2026 19:55:27 +0200 Subject: [PATCH 23/27] compatibility with lowest target version --- typetests/test_builtins.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/typetests/test_builtins.py b/typetests/test_builtins.py index f08b565..6907d8b 100644 --- a/typetests/test_builtins.py +++ b/typetests/test_builtins.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import TypeVar, Union from asyncstdlib import builtins from typing_extensions import assert_type @@ -24,5 +24,5 @@ async def test_max_asyncneutral() -> None: async def test_minmax_default() -> None: - assert_type(await builtins.min([1, 2], default="str"), int | str) - assert_type(await builtins.max([1, 2], default="str"), int | str) + assert_type(await builtins.min([1, 2], default="str"), Union[int, str]) + assert_type(await builtins.max([1, 2], default="str"), Union[int, str]) From 8bc78f272f4fe7999a0a782a1cf96ef19c774646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Thu, 22 Oct 2026 21:58:33 +0200 Subject: [PATCH 24/27] add test for tagged sorting --- unittests/test_heapq.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/unittests/test_heapq.py b/unittests/test_heapq.py index ee69c5a..4ed9955 100644 --- a/unittests/test_heapq.py +++ b/unittests/test_heapq.py @@ -54,6 +54,31 @@ async def test_merge_stdlib_key(samples: "list[list[float]]", reverse: bool) -> ] +PAIR_MERGE_SAMPLES: "list[list[list[tuple[float, int]]]]" = [ + [[(val, idx) for val in range(5)] for idx in range(3)], +] + + +@pytest.mark.parametrize("samples", PAIR_MERGE_SAMPLES) +@pytest.mark.parametrize("reverse", [False, True]) +@sync +async def test_merge_stdlib_pairs( + samples: "list[list[tuple[float, int]]]", reverse: bool +) -> None: + """Compare `heapq.merge` with key against stdlib implementation""" + + def key(pair: "tuple[float, int]") -> float: + return pair[0] + + samples = samples if not reverse else [sample[::-1] for sample in samples] + assert list(heapq.merge(*samples, key=key, reverse=reverse)) == [ + item + async for item in a.merge( + *map(asyncify, samples), key=awaitify(key), reverse=reverse + ) + ] + + # Technically, merge requires pre-sorted input which excludes unorder'able items # Practically, `float` includes `nan` and data includes `float` NAN_MERGE_SAMPLES: "list[list[list[float]]]" = [ From b537d076bfd10251ca8bb933e0ff5b9a4b6fbc82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Thu, 22 Oct 2026 21:58:54 +0200 Subject: [PATCH 25/27] do not reverse iterator sequence as well --- asyncstdlib/heapq.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asyncstdlib/heapq.py b/asyncstdlib/heapq.py index af5edf4..1be9ae7 100644 --- a/asyncstdlib/heapq.py +++ b/asyncstdlib/heapq.py @@ -130,9 +130,9 @@ async def merge( The ``iterables`` must be pre-sorted in the same order. """ a_key = awaitify(key) if key is not None else None - # sortable iterators with (reverse) position to ensure stable sort for ties + # sortable iterators with position to ensure stable sort for ties iter_heap: "list[tuple[_KeyIter[Any], int]]" = [ - (itr, idx if not reverse else -idx) + (itr, idx) async for idx, itr in a_enumerate( _KeyIter[Any].from_iters(iterables, reverse, a_key) ) From e8c9b069f71329eaf949eef246bd1bc32f05f8bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Fri, 23 Oct 2026 21:51:18 +0200 Subject: [PATCH 26/27] factor out list building --- unittests/test_heapq.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/unittests/test_heapq.py b/unittests/test_heapq.py index 4ed9955..99a92dc 100644 --- a/unittests/test_heapq.py +++ b/unittests/test_heapq.py @@ -71,12 +71,9 @@ def key(pair: "tuple[float, int]") -> float: return pair[0] samples = samples if not reverse else [sample[::-1] for sample in samples] - assert list(heapq.merge(*samples, key=key, reverse=reverse)) == [ - item - async for item in a.merge( - *map(asyncify, samples), key=awaitify(key), reverse=reverse - ) - ] + assert list(heapq.merge(*samples, key=key, reverse=reverse)) == await a.list( + a.merge(*map(asyncify, samples), key=awaitify(key), reverse=reverse) + ) # Technically, merge requires pre-sorted input which excludes unorder'able items From 8473f7d1d746cc93e11d76f173e8db797404d9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20K=C3=BChn?= Date: Mon, 26 Oct 2026 10:43:18 +0100 Subject: [PATCH 27/27] extend pair test cases to cover desired behaviour --- unittests/test_heapq.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/unittests/test_heapq.py b/unittests/test_heapq.py index 99a92dc..5ea4478 100644 --- a/unittests/test_heapq.py +++ b/unittests/test_heapq.py @@ -54,8 +54,13 @@ async def test_merge_stdlib_key(samples: "list[list[float]]", reverse: bool) -> ] -PAIR_MERGE_SAMPLES: "list[list[list[tuple[float, int]]]]" = [ +PAIR_MERGE_SAMPLES: "list[list[list[tuple[float, int | str]]]]" = [ + [[(1, "first"), (2, "first")], [(1, "second"), (2, "second")]], + [[(0, "first"), (1, "first")], [(1, "second"), (2, "second")]], + [[(1, "first"), (2, "first")], [(0, "second"), (1, "second")]], [[(val, idx) for val in range(5)] for idx in range(3)], + [[(val - idx, idx) for val in range(5)] for idx in range(3)], + [[(val + idx, idx) for val in range(5)] for idx in range(3)], ] @@ -65,7 +70,7 @@ async def test_merge_stdlib_key(samples: "list[list[float]]", reverse: bool) -> async def test_merge_stdlib_pairs( samples: "list[list[tuple[float, int]]]", reverse: bool ) -> None: - """Compare `heapq.merge` with key against stdlib implementation""" + """Compare `heapq.merge` with (key, tag) pairs against stdlib implementation""" def key(pair: "tuple[float, int]") -> float: return pair[0]