diff --git a/asyncstdlib/builtins.py b/asyncstdlib/builtins.py index 5c8f54c..280aa9d 100644 --- a/asyncstdlib/builtins.py +++ b/asyncstdlib/builtins.py @@ -11,10 +11,11 @@ Optional, Dict, Any, + overload, ) import builtins as _sync_builtins -from ._typing import T, R, HK, LT, AnyIterable +from ._typing import T, T1, R, HK, LT, AnyIterable from ._core import ( aiter, ScopedIter, @@ -241,6 +242,23 @@ async def map( __MIN_MAX_DEFAULT = Sentinel("") +@overload +async def max( + iterable: AnyIterable[T], + *, + key: Optional[Callable[[T], Any]] = None, +) -> T: ... + + +@overload +async def max( + iterable: AnyIterable[T], + *, + key: Optional[Callable[[T], Any]] = None, + default: T1, +) -> Union[T, T1]: ... + + async def max( iterable: AnyIterable[Any], *, @@ -255,7 +273,9 @@ async def max( The ``key`` argument specifies a one-argument ordering function like that used for :py:meth:`list.sort`. It may be a regular or async callable and defaults to the identity function. The ``default`` argument specifies an object to return - if the provided ``iterable`` is empty. If the ``iterable`` is empty and + if the provided ``iterable`` is empty. It is returned unchanged without + applying ``key`` and may have a different type from the iterable items. + If the ``iterable`` is empty and ``default`` is not provided, a :py:exc:`ValueError` is raised. .. note:: @@ -267,6 +287,23 @@ async def max( return await _min_max(iterable, key, True, default) +@overload +async def min( + iterable: AnyIterable[T], + *, + key: Optional[Callable[[T], Any]] = None, +) -> T: ... + + +@overload +async def min( + iterable: AnyIterable[T], + *, + key: Optional[Callable[[T], Any]] = None, + default: T1, +) -> Union[T, T1]: ... + + async def min( iterable: AnyIterable[Any], *, @@ -281,7 +318,9 @@ async def min( The ``key`` argument specifies a one-argument ordering function like that used for :py:meth:`list.sort`. It may be a regular or async callable and defaults to the identity function. The ``default`` argument specifies an object to return - if the provided ``iterable`` is empty. If the ``iterable`` is empty and + if the provided ``iterable`` is empty. It is returned unchanged without + applying ``key`` and may have a different type from the iterable items. + If the ``iterable`` is empty and ``default`` is not provided, a :py:exc:`ValueError` is raised. .. note:: @@ -297,20 +336,22 @@ async def _min_max( iterable: AnyIterable[LT], key: Optional[Callable[[LT], Any]], invert: bool, - default: LT = __MIN_MAX_DEFAULT, # type: ignore -) -> LT: + default: T1 = __MIN_MAX_DEFAULT, # type: ignore +) -> Union[LT, T1]: """ Implementation of ``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 - name = "max" if invert else "min" - raise ValueError(f"{name}() arg is an empty sequence") - elif key is None: + try: + best = await anext(item_iter) + except StopAsyncIteration: + if default is __MIN_MAX_DEFAULT: # type: ignore + name = "max" if invert else "min" + raise ValueError(f"{name}() arg is an empty sequence") from None + return default + if key is None: async for item in item_iter: if invert ^ bool(item < best): best = item diff --git a/typetests/test_builtins.py b/typetests/test_builtins.py index f411a5f..355f2cf 100644 --- a/typetests/test_builtins.py +++ b/typetests/test_builtins.py @@ -1,4 +1,5 @@ -from typing import TypeVar +from typing import TypeVar, Optional, Union, List, Dict +from typing_extensions import assert_type from asyncstdlib import builtins T = TypeVar("T") @@ -15,3 +16,26 @@ 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_extrema_independent_default_type() -> None: + records: List[Dict[str, int]] = [{"priority": 1}] + + def priority(record: Dict[str, int]) -> int: + return record["priority"] + + async def async_priority(record: Dict[str, int]) -> int: + return record["priority"] + + assert_type(await builtins.min(records, key=priority), Dict[str, int]) + assert_type(await builtins.max(records, key=async_priority), Dict[str, int]) + assert_type( + await builtins.min(records, key=priority, default=None), + Optional[Dict[str, int]], + ) + assert_type( + await builtins.max(records, key=async_priority, default=None), + Optional[Dict[str, int]], + ) + assert_type(await builtins.min([1], default="empty"), Union[int, str]) + assert_type(await builtins.max([1], default="empty"), Union[int, str]) diff --git a/unittests/test_builtins.py b/unittests/test_builtins.py index 382fddd..08346a9 100644 --- a/unittests/test_builtins.py +++ b/unittests/test_builtins.py @@ -343,3 +343,39 @@ def collision_key(x: int) -> int: assert await a.sorted(values, key=awaitify(collision_key)) == sorted( values, key=collision_key ) + + +@pytest.mark.parametrize("function", [a.min, a.max]) +@pytest.mark.parametrize("async_input", [False, True]) +@pytest.mark.parametrize("async_key", [False, True]) +@sync +async def test_extrema_return_empty_default_without_applying_key( + function, async_input, async_key +): + fallback = object() + + def priority(record): + return record["priority"] + + assert ( + await function( + asyncify([]) if async_input else [], + key=awaitify(priority) if async_key else priority, + default=fallback, + ) + is fallback + ) + + +@pytest.mark.parametrize("function", [a.min, a.max]) +@sync +async def test_extrema_apply_key_to_item_identical_to_default(function): + fallback = {"priority": 1} + calls = [] + + def priority(record): + calls.append(record) + return record["priority"] + + assert await function([fallback], key=priority, default=fallback) is fallback + assert calls == [fallback]