Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-aiohappyeyeballs for 
openSUSE:Factory checked in at 2026-07-07 21:00:29
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-aiohappyeyeballs (Old)
 and      /work/SRC/openSUSE:Factory/.python-aiohappyeyeballs.new.1982 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-aiohappyeyeballs"

Tue Jul  7 21:00:29 2026 rev:9 rq:1363991 version:2.7.1

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-aiohappyeyeballs/python-aiohappyeyeballs.changes
  2025-06-26 11:34:49.058573631 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-aiohappyeyeballs.new.1982/python-aiohappyeyeballs.changes
        2026-07-07 21:00:57.569662610 +0200
@@ -1,0 +2,17 @@
+Wed Jul  1 17:47:57 UTC 2026 - Martin Hauke <[email protected]>
+
+- Update to 2.7.1
+  * Use defaultdict to help with performance.
+- Update to 2.7.0
+  * Add Python 3.14 support.
+- Update to 2.6.2
+  Bug Fixes
+  * Clear error on empty addr_infos in start_connection.
+  Refactoring
+  * Optimize obtaining event-loop down to 1 line.
+  Testing
+  * Stop verify_no_lingering_tasks from leaking an event loop.
+- Drop not longer needed patch:
+  * event_loop.patch (fixed upstream)
+
+-------------------------------------------------------------------

Old:
----
  aiohappyeyeballs-2.6.1.tar.gz
  event_loop.patch

New:
----
  aiohappyeyeballs-2.7.1.tar.gz

----------(Old B)----------
  Old:- Drop not longer needed patch:
  * event_loop.patch (fixed upstream)
----------(Old E)----------

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-aiohappyeyeballs.spec ++++++
--- /var/tmp/diff_new_pack.Ok4cBU/_old  2026-07-07 21:00:58.169683222 +0200
+++ /var/tmp/diff_new_pack.Ok4cBU/_new  2026-07-07 21:00:58.169683222 +0200
@@ -1,7 +1,7 @@
 #
 # spec file for package python-aiohappyeyeballs
 #
-# Copyright (c) 2025 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
 #
 # All modifications and additions to the file contributed by third parties
 # remain the property of their copyright owners, unless otherwise agreed
@@ -18,15 +18,12 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-aiohappyeyeballs
-Version:        2.6.1
+Version:        2.7.1
 Release:        0
 Summary:        Happy Eyeballs for asyncio
 License:        Python-2.0
 URL:            https://github.com/aio-libs/aiohappyeyeballs
 Source:         
https://files.pythonhosted.org/packages/source/a/aiohappyeyeballs/aiohappyeyeballs-%{version}.tar.gz
-# PATCH-FIX-OPENSUSE drop the offending helper function to enable tests
-# https://github.com/aio-libs/aiohappyeyeballs/pull/177 chore(deps-dev): bump 
pytest-asyncio from 0.26.0 to 1.0.0
-Patch0:         event_loop.patch
 BuildRequires:  %{python_module pip}
 BuildRequires:  %{python_module poetry-core >= 1.0.0}
 BuildRequires:  %{python_module pytest-asyncio}

++++++ aiohappyeyeballs-2.6.1.tar.gz -> aiohappyeyeballs-2.7.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/PKG-INFO 
new/aiohappyeyeballs-2.7.1/PKG-INFO
--- old/aiohappyeyeballs-2.6.1/PKG-INFO 1970-01-01 01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/PKG-INFO 1970-01-01 01:00:00.000000000 +0100
@@ -1,22 +1,23 @@
-Metadata-Version: 2.3
+Metadata-Version: 2.4
 Name: aiohappyeyeballs
-Version: 2.6.1
+Version: 2.7.1
 Summary: Happy Eyeballs for asyncio
 License: PSF-2.0
+License-File: LICENSE
 Author: J. Nick Koston
 Author-email: [email protected]
-Requires-Python: >=3.9
+Requires-Python: >=3.10
 Classifier: Development Status :: 5 - Production/Stable
 Classifier: Intended Audience :: Developers
 Classifier: Natural Language :: English
 Classifier: Operating System :: OS Independent
 Classifier: Topic :: Software Development :: Libraries
 Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.9
 Classifier: Programming Language :: Python :: 3.10
 Classifier: Programming Language :: Python :: 3.11
 Classifier: Programming Language :: Python :: 3.12
 Classifier: Programming Language :: Python :: 3.13
+Classifier: Programming Language :: Python :: 3.14
 Classifier: License :: OSI Approved :: Python Software Foundation License
 Project-URL: Bug Tracker, https://github.com/aio-libs/aiohappyeyeballs/issues
 Project-URL: Changelog, 
https://github.com/aio-libs/aiohappyeyeballs/blob/main/CHANGELOG.md
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/pyproject.toml 
new/aiohappyeyeballs-2.7.1/pyproject.toml
--- old/aiohappyeyeballs-2.6.1/pyproject.toml   1970-01-01 01:00:00.000000000 
+0100
+++ new/aiohappyeyeballs-2.7.1/pyproject.toml   1970-01-01 01:00:00.000000000 
+0100
@@ -1,10 +1,10 @@
 [project]
 name = "aiohappyeyeballs"
-version = "2.6.1"
+version = "2.7.1"
 description = "Happy Eyeballs for asyncio"
 authors = [{ name = "J. Nick Koston", email = "[email protected]" }]
 readme = "README.md"
-requires-python = ">=3.9"
+requires-python = ">=3.10"
 dynamic = ["dependencies", "optional-dependencies"]
 classifiers = [
     "Development Status :: 5 - Production/Stable",
@@ -13,11 +13,11 @@
     "Operating System :: OS Independent",
     "Topic :: Software Development :: Libraries",
     "Programming Language :: Python :: 3",
-    "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
     "Programming Language :: Python :: 3.13",
+    "Programming Language :: Python :: 3.14",
     "License :: OSI Approved :: Python Software Foundation License"
 ]
 
@@ -35,22 +35,30 @@
 ]
 
 [tool.poetry.dependencies]
-python = ">=3.9"
+python = ">=3.10"
 
 [tool.poetry.group.dev.dependencies]
-pytest = ">=7,<9"
-pytest-cov = ">=3,<7"
-pytest-asyncio = ">=0.23.2,<0.26.0"
+pytest = ">=9.1.1,<10"
+pytest-cov = ">=7.1.0,<8"
+pytest-asyncio = ">=1.4.0,<3"
 
 [tool.poetry.group.docs]
 optional = true
 
 [tool.poetry.group.docs.dependencies]
-myst-parser = ">=0.16"
-sphinx = ">=4.0"
+myst-parser = ">=4.0.1"
+sphinx = ">=8.1.3"
 furo = ">=2023.5.20"
 sphinx-autobuild = ">=2021.3.14"
 
+# Optional so the default install and the Python 3.14 test matrix never pull in
+# pytest-codspeed; only the CodSpeed CI job installs this group.
+[tool.poetry.group.benchmarks]
+optional = true
+
+[tool.poetry.group.benchmarks.dependencies]
+pytest-codspeed = ">=4.0.0"
+
 
 [tool.poetry.group.test_build.dependencies]
 twine = ">=4.0.2,<7.0.0"
@@ -98,7 +106,7 @@
 ]
 
 [tool.ruff]
-target-version = "py38"
+target-version = "py310"
 line-length = 88
 ignore = [
     "D203", # 1 blank line required before class docstring
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/aiohappyeyeballs-2.6.1/src/aiohappyeyeballs/__init__.py 
new/aiohappyeyeballs-2.7.1/src/aiohappyeyeballs/__init__.py
--- old/aiohappyeyeballs-2.6.1/src/aiohappyeyeballs/__init__.py 1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/src/aiohappyeyeballs/__init__.py 1970-01-01 
01:00:00.000000000 +0100
@@ -1,4 +1,4 @@
-__version__ = "2.6.1"
+__version__ = "2.7.1"
 
 from .impl import start_connection
 from .types import AddrInfoType, SocketFactoryType
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/aiohappyeyeballs-2.6.1/src/aiohappyeyeballs/_staggered.py 
new/aiohappyeyeballs-2.7.1/src/aiohappyeyeballs/_staggered.py
--- old/aiohappyeyeballs-2.6.1/src/aiohappyeyeballs/_staggered.py       
1970-01-01 01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/src/aiohappyeyeballs/_staggered.py       
1970-01-01 01:00:00.000000000 +0100
@@ -1,20 +1,10 @@
 import asyncio
 import contextlib
-
-# PY3.9: Import Callable from typing until we drop Python 3.9 support
-# https://github.com/python/cpython/issues/87131
+from collections.abc import Awaitable, Callable, Iterable
 from typing import (
     TYPE_CHECKING,
     Any,
-    Awaitable,
-    Callable,
-    Iterable,
-    List,
-    Optional,
-    Set,
-    Tuple,
     TypeVar,
-    Union,
 )
 
 _T = TypeVar("_T")
@@ -51,10 +41,10 @@
 
 async def staggered_race(
     coro_fns: Iterable[Callable[[], Awaitable[_T]]],
-    delay: Optional[float],
+    delay: float | None,
     *,
-    loop: Optional[asyncio.AbstractEventLoop] = None,
-) -> Tuple[Optional[_T], Optional[int], List[Optional[BaseException]]]:
+    loop: asyncio.AbstractEventLoop | None = None,
+) -> tuple[_T | None, int | None, list[BaseException | None]]:
     """
     Run coroutines with staggered start times and take the first to finish.
 
@@ -109,14 +99,14 @@
 
     """
     loop = loop or asyncio.get_running_loop()
-    exceptions: List[Optional[BaseException]] = []
-    tasks: Set[asyncio.Task[Optional[Tuple[_T, int]]]] = set()
+    exceptions: list[BaseException | None] = []
+    tasks: set[asyncio.Task[tuple[_T, int] | None]] = set()
 
     async def run_one_coro(
         coro_fn: Callable[[], Awaitable[_T]],
         this_index: int,
         start_next: "asyncio.Future[None]",
-    ) -> Optional[Tuple[_T, int]]:
+    ) -> tuple[_T, int] | None:
         """
         Run a single coroutine.
 
@@ -139,10 +129,10 @@
 
         return result, this_index
 
-    start_next_timer: Optional[asyncio.TimerHandle] = None
-    start_next: Optional[asyncio.Future[None]]
-    task: asyncio.Task[Optional[Tuple[_T, int]]]
-    done: Union[asyncio.Future[None], asyncio.Task[Optional[Tuple[_T, int]]]]
+    start_next_timer: asyncio.TimerHandle | None = None
+    start_next: asyncio.Future[None] | None
+    task: asyncio.Task[tuple[_T, int] | None]
+    done: asyncio.Future[None] | asyncio.Task[tuple[_T, int] | None]
     coro_iter = iter(coro_fns)
     this_index = -1
     try:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/src/aiohappyeyeballs/impl.py 
new/aiohappyeyeballs-2.7.1/src/aiohappyeyeballs/impl.py
--- old/aiohappyeyeballs-2.6.1/src/aiohappyeyeballs/impl.py     1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/src/aiohappyeyeballs/impl.py     1970-01-01 
01:00:00.000000000 +0100
@@ -1,12 +1,12 @@
 """Base implementation."""
 
 import asyncio
-import collections
 import contextlib
 import functools
 import itertools
 import socket
-from typing import List, Optional, Sequence, Set, Union
+from collections import defaultdict
+from collections.abc import Sequence
 
 from . import _staggered
 from .types import AddrInfoType, SocketFactoryType
@@ -15,11 +15,11 @@
 async def start_connection(
     addr_infos: Sequence[AddrInfoType],
     *,
-    local_addr_infos: Optional[Sequence[AddrInfoType]] = None,
-    happy_eyeballs_delay: Optional[float] = None,
-    interleave: Optional[int] = None,
-    loop: Optional[asyncio.AbstractEventLoop] = None,
-    socket_factory: Optional[SocketFactoryType] = None,
+    local_addr_infos: Sequence[AddrInfoType] | None = None,
+    happy_eyeballs_delay: float | None = None,
+    interleave: int | None = None,
+    loop: asyncio.AbstractEventLoop | None = None,
+    socket_factory: SocketFactoryType | None = None,
 ) -> socket.socket:
     """
     Connect to a TCP server.
@@ -51,8 +51,10 @@
             transport, protocol = await loop.create_connection(
                 MyProtocol, sock=socket, ...)
     """
-    if not (current_loop := loop):
-        current_loop = asyncio.get_running_loop()
+    if not addr_infos:
+        raise ValueError("addr_infos must not be empty")
+
+    current_loop = loop or asyncio.get_running_loop()
 
     single_addr_info = len(addr_infos) == 1
 
@@ -63,9 +65,9 @@
     if interleave and not single_addr_info:
         addr_infos = _interleave_addrinfos(addr_infos, interleave)
 
-    sock: Optional[socket.socket] = None
+    sock: socket.socket | None = None
     # uvloop can raise RuntimeError instead of OSError
-    exceptions: List[List[Union[OSError, RuntimeError]]] = []
+    exceptions: list[list[OSError | RuntimeError]] = []
     if happy_eyeballs_delay is None or single_addr_info:
         # not using happy eyeballs
         for addrinfo in addr_infos:
@@ -82,7 +84,7 @@
             except (RuntimeError, OSError):
                 continue
     else:  # using happy eyeballs
-        open_sockets: Set[socket.socket] = set()
+        open_sockets: set[socket.socket] = set()
         try:
             sock, _, _ = await _staggered.staggered_race(
                 (
@@ -156,11 +158,11 @@
 
 async def _connect_sock(
     loop: asyncio.AbstractEventLoop,
-    exceptions: List[List[Union[OSError, RuntimeError]]],
+    exceptions: list[list[OSError | RuntimeError]],
     addr_info: AddrInfoType,
-    local_addr_infos: Optional[Sequence[AddrInfoType]] = None,
-    open_sockets: Optional[Set[socket.socket]] = None,
-    socket_factory: Optional[SocketFactoryType] = None,
+    local_addr_infos: Sequence[AddrInfoType] | None = None,
+    open_sockets: set[socket.socket] | None = None,
+    socket_factory: SocketFactoryType | None = None,
 ) -> socket.socket:
     """
     Create, bind and connect one socket.
@@ -172,7 +174,7 @@
     of all staggered tasks in the result there are runner up sockets aka
     multiple winners.
     """
-    my_exceptions: List[Union[OSError, RuntimeError]] = []
+    my_exceptions: list[OSError | RuntimeError] = []
     exceptions.append(my_exceptions)
     family, type_, proto, _, address = addr_info
     sock = None
@@ -207,18 +209,9 @@
                     raise OSError(f"no matching local address with {family=} 
found")
         await loop.sock_connect(sock, address)
         return sock
-    except (RuntimeError, OSError) as exc:
-        my_exceptions.append(exc)
-        if sock is not None:
-            if open_sockets is not None:
-                open_sockets.remove(sock)
-            try:
-                sock.close()
-            except OSError as e:
-                my_exceptions.append(e)
-                raise
-        raise
-    except:
+    except BaseException as exc:
+        if isinstance(exc, (RuntimeError, OSError)):
+            my_exceptions.append(exc)
         if sock is not None:
             if open_sockets is not None:
                 open_sockets.remove(sock)
@@ -234,20 +227,15 @@
 
 def _interleave_addrinfos(
     addrinfos: Sequence[AddrInfoType], first_address_family_count: int = 1
-) -> List[AddrInfoType]:
+) -> list[AddrInfoType]:
     """Interleave list of addrinfo tuples by family."""
     # Group addresses by family
-    addrinfos_by_family: collections.OrderedDict[int, List[AddrInfoType]] = (
-        collections.OrderedDict()
-    )
+    addrinfos_by_family: defaultdict[int, list[AddrInfoType]] = 
defaultdict(list)
     for addr in addrinfos:
-        family = addr[0]
-        if family not in addrinfos_by_family:
-            addrinfos_by_family[family] = []
-        addrinfos_by_family[family].append(addr)
+        addrinfos_by_family[addr[0]].append(addr)
     addrinfos_lists = list(addrinfos_by_family.values())
 
-    reordered: List[AddrInfoType] = []
+    reordered: list[AddrInfoType] = []
     if first_address_family_count > 1:
         reordered.extend(addrinfos_lists[0][: first_address_family_count - 1])
         del addrinfos_lists[0][: first_address_family_count - 1]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/src/aiohappyeyeballs/types.py 
new/aiohappyeyeballs-2.7.1/src/aiohappyeyeballs/types.py
--- old/aiohappyeyeballs-2.6.1/src/aiohappyeyeballs/types.py    1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/src/aiohappyeyeballs/types.py    1970-01-01 
01:00:00.000000000 +0100
@@ -1,17 +1,14 @@
 """Types for aiohappyeyeballs."""
 
 import socket
+from collections.abc import Callable
 
-# PY3.9: Import Callable from typing until we drop Python 3.9 support
-# https://github.com/python/cpython/issues/87131
-from typing import Callable, Tuple, Union
-
-AddrInfoType = Tuple[
-    Union[int, socket.AddressFamily],
-    Union[int, socket.SocketKind],
+AddrInfoType = tuple[
+    int | socket.AddressFamily,
+    int | socket.SocketKind,
     int,
     str,
-    Tuple,  # type: ignore[type-arg]
+    tuple,  # type: ignore[type-arg]
 ]
 
 SocketFactoryType = Callable[[AddrInfoType], socket.socket]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/src/aiohappyeyeballs/utils.py 
new/aiohappyeyeballs-2.7.1/src/aiohappyeyeballs/utils.py
--- old/aiohappyeyeballs-2.6.1/src/aiohappyeyeballs/utils.py    1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/src/aiohappyeyeballs/utils.py    1970-01-01 
01:00:00.000000000 +0100
@@ -2,16 +2,13 @@
 
 import ipaddress
 import socket
-from typing import Dict, List, Optional, Tuple, Union
 
 from .types import AddrInfoType
 
 
 def addr_to_addr_infos(
-    addr: Optional[
-        Union[Tuple[str, int, int, int], Tuple[str, int, int], Tuple[str, int]]
-    ],
-) -> Optional[List[AddrInfoType]]:
+    addr: tuple[str, int, int, int] | tuple[str, int, int] | tuple[str, int] | 
None,
+) -> list[AddrInfoType] | None:
     """Convert an address tuple to a list of addr_info tuples."""
     if addr is None:
         return None
@@ -35,7 +32,7 @@
 
 
 def pop_addr_infos_interleave(
-    addr_infos: List[AddrInfoType], interleave: Optional[int] = None
+    addr_infos: list[AddrInfoType], interleave: int | None = None
 ) -> None:
     """
     Pop addr_info from the list of addr_infos by family up to interleave times.
@@ -43,33 +40,29 @@
     The interleave parameter is used to know how many addr_infos for
     each family should be popped of the top of the list.
     """
-    seen: Dict[int, int] = {}
     if interleave is None:
         interleave = 1
-    to_remove: List[AddrInfoType] = []
+    seen: dict[int, int] = {}
+    kept: list[AddrInfoType] = []
     for addr_info in addr_infos:
         family = addr_info[0]
-        if family not in seen:
-            seen[family] = 0
-        if seen[family] < interleave:
-            to_remove.append(addr_info)
-        seen[family] += 1
-    for addr_info in to_remove:
-        addr_infos.remove(addr_info)
+        count = seen.get(family, 0)
+        if count >= interleave:
+            kept.append(addr_info)
+        seen[family] = count + 1
+    addr_infos[:] = kept
 
 
 def _addr_tuple_to_ip_address(
-    addr: Union[Tuple[str, int], Tuple[str, int, int, int]],
-) -> Union[
-    Tuple[ipaddress.IPv4Address, int], Tuple[ipaddress.IPv6Address, int, int, 
int]
-]:
+    addr: tuple[str, int] | tuple[str, int, int, int],
+) -> tuple[ipaddress.IPv4Address, int] | tuple[ipaddress.IPv6Address, int, 
int, int]:
     """Convert an address tuple to an IPv4Address."""
     return (ipaddress.ip_address(addr[0]), *addr[1:])
 
 
 def remove_addr_infos(
-    addr_infos: List[AddrInfoType],
-    addr: Union[Tuple[str, int], Tuple[str, int, int, int]],
+    addr_infos: list[AddrInfoType],
+    addr: tuple[str, int] | tuple[str, int, int, int],
 ) -> None:
     """
     Remove an address from the list of addr_infos.
@@ -77,21 +70,13 @@
     The addr value is typically the return value of
     sock.getpeername().
     """
-    bad_addrs_infos: List[AddrInfoType] = []
-    for addr_info in addr_infos:
-        if addr_info[-1] == addr:
-            bad_addrs_infos.append(addr_info)
-    if bad_addrs_infos:
-        for bad_addr_info in bad_addrs_infos:
-            addr_infos.remove(bad_addr_info)
-        return
-    # Slow path in case addr is formatted differently
-    match_addr = _addr_tuple_to_ip_address(addr)
-    for addr_info in addr_infos:
-        if match_addr == _addr_tuple_to_ip_address(addr_info[-1]):
-            bad_addrs_infos.append(addr_info)
-    if bad_addrs_infos:
-        for bad_addr_info in bad_addrs_infos:
-            addr_infos.remove(bad_addr_info)
-        return
-    raise ValueError(f"Address {addr} not found in addr_infos")
+    kept = [ai for ai in addr_infos if ai[-1] != addr]
+    if len(kept) == len(addr_infos):
+        # Slow path in case addr is formatted differently
+        match_addr = _addr_tuple_to_ip_address(addr)
+        kept = [
+            ai for ai in addr_infos if _addr_tuple_to_ip_address(ai[-1]) != 
match_addr
+        ]
+    if len(kept) == len(addr_infos):
+        raise ValueError(f"Address {addr} not found in addr_infos")
+    addr_infos[:] = kept
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/tests/benchmarks/__init__.py 
new/aiohappyeyeballs-2.7.1/tests/benchmarks/__init__.py
--- old/aiohappyeyeballs-2.6.1/tests/benchmarks/__init__.py     1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/tests/benchmarks/__init__.py     1970-01-01 
01:00:00.000000000 +0100
@@ -0,0 +1 @@
+"""CodSpeed benchmark suite for aiohappyeyeballs."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/tests/benchmarks/_data.py 
new/aiohappyeyeballs-2.7.1/tests/benchmarks/_data.py
--- old/aiohappyeyeballs-2.6.1/tests/benchmarks/_data.py        1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/tests/benchmarks/_data.py        1970-01-01 
01:00:00.000000000 +0100
@@ -0,0 +1,44 @@
+"""
+Sample addrinfo data for the CodSpeed benchmark suite.
+
+Reuses the addrinfo tuple shapes exercised in ``tests/test_utils.py`` and
+``tests/test_impl.py`` so the benchmarks measure the same objects the
+production hot paths handle.
+"""
+
+from __future__ import annotations
+
+import socket
+
+from aiohappyeyeballs.types import AddrInfoType
+
+
+def _v6(host: str, port: int = 80) -> AddrInfoType:
+    return (
+        socket.AF_INET6,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        (host, port, 0, 0),
+    )
+
+
+def _v4(host: str, port: int = 80) -> AddrInfoType:
+    return (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (host, 
port))
+
+
+def make_addrinfos(n_v6: int, n_v4: int) -> list[AddrInfoType]:
+    """
+    Build an addrinfo list of ``n_v6`` IPv6 entries followed by ``n_v4`` IPv4 
entries.
+
+    Each entry gets a distinct address so ``remove_addr_infos`` benchmarks can
+    target a single one.
+    """
+    infos: list[AddrInfoType] = [_v6(f"dead:beef::{i:x}") for i in range(n_v6)]
+    infos += [_v4(f"107.6.106.{i}") for i in range(n_v4)]
+    return infos
+
+
+# (n_v6, n_v4) per scenario, with matching human-readable ids.
+SCENARIOS = [(1, 1), (4, 4), (16, 16), (0, 8)]
+SCENARIO_IDS = ["small_mixed", "mixed", "large_mixed", "single_family"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/aiohappyeyeballs-2.6.1/tests/benchmarks/test_interleave.py 
new/aiohappyeyeballs-2.7.1/tests/benchmarks/test_interleave.py
--- old/aiohappyeyeballs-2.6.1/tests/benchmarks/test_interleave.py      
1970-01-01 01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/tests/benchmarks/test_interleave.py      
1970-01-01 01:00:00.000000000 +0100
@@ -0,0 +1,40 @@
+"""
+CodSpeed benchmarks for ``_interleave_addrinfos``.
+
+This is the hot path PR #239 rewrites (dict ``try/except KeyError`` ->
+``defaultdict``); the benchmark exists so that change can be measured against
+the baseline on ``main``.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from aiohappyeyeballs.impl import _interleave_addrinfos
+
+from ._data import SCENARIO_IDS, SCENARIOS, make_addrinfos
+
+try:
+    from pytest_codspeed import BenchmarkFixture
+except ImportError:  # pragma: no cover - only when pytest-codspeed is absent
+    pytestmark = pytest.mark.skip("pytest-codspeed not installed")
+
+
[email protected]("first_address_family_count", (1, 2))
[email protected](("n_v6", "n_v4"), SCENARIOS, ids=SCENARIO_IDS)
+def test_interleave_addrinfos(
+    benchmark: BenchmarkFixture,
+    n_v6: int,
+    n_v4: int,
+    first_address_family_count: int,
+) -> None:
+    sample = make_addrinfos(n_v6, n_v4)
+
+    # Interleaving is a pure reordering, so nothing is dropped. Asserting once
+    # outside the measured loop keeps a no-op refactor from reporting a fake
+    # speedup against a function that stopped doing its job.
+    assert len(_interleave_addrinfos(sample, first_address_family_count)) == 
len(sample)
+
+    @benchmark
+    def run() -> None:
+        _interleave_addrinfos(sample, first_address_family_count)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/aiohappyeyeballs-2.6.1/tests/benchmarks/test_reorder_utils.py 
new/aiohappyeyeballs-2.7.1/tests/benchmarks/test_reorder_utils.py
--- old/aiohappyeyeballs-2.6.1/tests/benchmarks/test_reorder_utils.py   
1970-01-01 01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/tests/benchmarks/test_reorder_utils.py   
1970-01-01 01:00:00.000000000 +0100
@@ -0,0 +1,70 @@
+"""
+CodSpeed benchmarks for the in-place addrinfo reducers in ``utils``.
+
+``pop_addr_infos_interleave`` and ``remove_addr_infos`` both mutate their input
+list (``addr_infos[:] = kept``), so each measured call operates on a fresh
+``sample.copy()``. The copy cost is identical for the base and the PR, so the
+relative comparison CodSpeed reports stays valid.
+"""
+
+from __future__ import annotations
+
+import ipaddress
+
+import pytest
+
+from aiohappyeyeballs.utils import pop_addr_infos_interleave, remove_addr_infos
+
+from ._data import SCENARIO_IDS, SCENARIOS, make_addrinfos
+
+try:
+    from pytest_codspeed import BenchmarkFixture
+except ImportError:  # pragma: no cover - only when pytest-codspeed is absent
+    pytestmark = pytest.mark.skip("pytest-codspeed not installed")
+
+
[email protected](("n_v6", "n_v4"), SCENARIOS, ids=SCENARIO_IDS)
+def test_pop_addr_infos_interleave(
+    benchmark: BenchmarkFixture, n_v6: int, n_v4: int
+) -> None:
+    sample = make_addrinfos(n_v6, n_v4)
+    # interleave=1 drops the first entry of each family present.
+    families = (n_v6 > 0) + (n_v4 > 0)
+
+    probe = sample.copy()
+    pop_addr_infos_interleave(probe, 1)
+    assert len(probe) == len(sample) - families
+
+    @benchmark
+    def run() -> None:
+        pop_addr_infos_interleave(sample.copy(), 1)
+
+
+def test_remove_addr_infos_fast_path(benchmark: BenchmarkFixture) -> None:
+    sample = make_addrinfos(4, 4)
+    # Exact sockaddr of an entry -> the ``ai[-1] != addr`` fast path removes 
it.
+    target = sample[-1][-1]
+
+    probe = sample.copy()
+    remove_addr_infos(probe, target)
+    assert len(probe) == len(sample) - 1
+
+    @benchmark
+    def run() -> None:
+        remove_addr_infos(sample.copy(), target)
+
+
+def test_remove_addr_infos_slow_path(benchmark: BenchmarkFixture) -> None:
+    sample = make_addrinfos(4, 4)
+    # sample[0] is a v6 entry; target the same IP in fully-expanded form so the
+    # tuple compare misses and the ipaddress() normalization slow path runs.
+    v6_host = sample[0][-1][0]
+    target = (ipaddress.ip_address(v6_host).exploded, 80, 0, 0)
+
+    probe = sample.copy()
+    remove_addr_infos(probe, target)
+    assert len(probe) == len(sample) - 1
+
+    @benchmark
+    def run() -> None:
+        remove_addr_infos(sample.copy(), target)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/tests/conftest.py 
new/aiohappyeyeballs-2.7.1/tests/conftest.py
--- old/aiohappyeyeballs-2.6.1/tests/conftest.py        1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/tests/conftest.py        1970-01-01 
01:00:00.000000000 +0100
@@ -4,8 +4,8 @@
 import reprlib
 import threading
 from asyncio.events import AbstractEventLoop, TimerHandle
+from collections.abc import Generator
 from contextlib import contextmanager
-from typing import Generator
 
 import pytest
 
@@ -41,10 +41,17 @@
 
 
 @pytest.fixture(autouse=True)
-def verify_no_lingering_tasks(
-    event_loop: asyncio.AbstractEventLoop,
-) -> Generator[None, None, None]:
+def verify_no_lingering_tasks() -> Generator[None, None, None]:
     """Verify that all tasks are cleaned up."""
+    try:
+        event_loop = asyncio.get_running_loop()
+    except RuntimeError:
+        # No running loop (e.g. a synchronous test). Calling
+        # asyncio.get_event_loop() here would implicitly create and leak a
+        # new loop on Python >= 3.10, raising a DeprecationWarning and a
+        # ResourceWarning. There is nothing to verify, so just skip.
+        yield
+        return
     tasks_before = asyncio.all_tasks(event_loop)
     yield
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/tests/test_impl.py 
new/aiohappyeyeballs-2.7.1/tests/test_impl.py
--- old/aiohappyeyeballs-2.6.1/tests/test_impl.py       1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/tests/test_impl.py       1970-01-01 
01:00:00.000000000 +0100
@@ -1,7 +1,7 @@
 import asyncio
 import socket
+from collections.abc import Sequence
 from types import ModuleType
-from typing import List, Optional, Sequence, Set, Tuple, Union
 from unittest import mock
 
 import pytest
@@ -59,6 +59,13 @@
 
 
 @pytest.mark.asyncio
+async def test_empty_addr_infos() -> None:
+    """An empty addr_infos raises a clear ValueError, not an opaque 
IndexError."""
+    with pytest.raises(ValueError, match="addr_infos must not be empty"):
+        await start_connection([])
+
+
[email protected]
 @patch_socket
 async def test_single_addr_info_errors(m_socket: ModuleType) -> None:
     idx = -1
@@ -230,11 +237,11 @@
 
     async def _connect_sock(
         loop: asyncio.AbstractEventLoop,
-        exceptions: List[List[Union[OSError, RuntimeError]]],
+        exceptions: list[list[OSError | RuntimeError]],
         addr_info: AddrInfoType,
-        local_addr_infos: Optional[Sequence[AddrInfoType]] = None,
-        sockets: Optional[Set[socket.socket]] = None,
-        socket_factory: Optional[SocketFactoryType] = None,
+        local_addr_infos: Sequence[AddrInfoType] | None = None,
+        sockets: set[socket.socket] | None = None,
+        socket_factory: SocketFactoryType | None = None,
     ) -> socket.socket:
         await finish
         sock = _socket()
@@ -479,16 +486,16 @@
         return mock_socket
 
     m_socket.socket = _socket  # type: ignore
-    ipv6_addr: Tuple[str, int, int, int] = ("dead:beef::", 80, 0, 0)
-    ipv6_addr_info: Tuple[int, int, int, str, Tuple[str, int, int, int]] = (
+    ipv6_addr: tuple[str, int, int, int] = ("dead:beef::", 80, 0, 0)
+    ipv6_addr_info: tuple[int, int, int, str, tuple[str, int, int, int]] = (
         socket.AF_INET6,
         socket.SOCK_STREAM,
         socket.IPPROTO_TCP,
         "",
         ipv6_addr,
     )
-    ipv4_addr: Tuple[str, int] = ("107.6.106.83", 80)
-    ipv4_addr_info: Tuple[int, int, int, str, Tuple[str, int]] = (
+    ipv4_addr: tuple[str, int] = ("107.6.106.83", 80)
+    ipv4_addr_info: tuple[int, int, int, str, tuple[str, int]] = (
         socket.AF_INET,
         socket.SOCK_STREAM,
         socket.IPPROTO_TCP,
@@ -523,7 +530,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -584,7 +591,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -647,7 +654,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -701,7 +708,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -778,7 +785,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -863,7 +870,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -911,8 +918,9 @@
         ),
     ]
     loop = asyncio.get_running_loop()
-    with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
-        OSError, match="ipv6 fail"
+    with (
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        pytest.raises(OSError, match="ipv6 fail"),
     ):
         assert (
             await start_connection(
@@ -950,7 +958,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -1032,7 +1040,7 @@
         raise Exception("Something really went wrong")
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -1080,8 +1088,9 @@
         ),
     ]
     loop = asyncio.get_running_loop()
-    with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
-        Exception, match="Something really went wrong"
+    with (
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        pytest.raises(Exception, match="Something really went wrong"),
     ):
         assert (
             await start_connection(
@@ -1115,7 +1124,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -1163,8 +1172,9 @@
         ),
     ]
     loop = asyncio.get_running_loop()
-    with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
-        Exception, match="Something really went wrong"
+    with (
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        pytest.raises(Exception, match="Something really went wrong"),
     ):
         assert (
             await start_connection(
@@ -1197,7 +1207,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -1274,7 +1284,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         if address[0] == "dead:beef::":
@@ -1322,8 +1332,9 @@
         ),
     ]
     loop = asyncio.get_running_loop()
-    with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
-        OSError, match="Multiple exceptions"
+    with (
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        pytest.raises(OSError, match="Multiple exceptions"),
     ):
         assert (
             await start_connection(
@@ -1359,7 +1370,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         raise OSError(5, "all fail")
@@ -1405,9 +1416,10 @@
     ]
     loop = asyncio.get_running_loop()
     # We should get the same exception raised if they are all the same
-    with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
-        OSError, match="all fail"
-    ) as exc_info:
+    with (
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        pytest.raises(OSError, match="all fail") as exc_info,
+    ):
         assert (
             await start_connection(
                 addr_info,
@@ -1448,7 +1460,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         raise OSError(len(create_calls), "all fail")
@@ -1494,9 +1506,10 @@
     ]
     loop = asyncio.get_running_loop()
     # We should get the same exception raised if they are all the same
-    with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
-        OSError, match="all fail"
-    ) as exc_info:
+    with (
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        pytest.raises(OSError, match="all fail") as exc_info,
+    ):
         assert (
             await start_connection(
                 addr_info,
@@ -1552,7 +1565,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         raise RuntimeError("all fail")
@@ -1598,8 +1611,9 @@
     ]
     loop = asyncio.get_running_loop()
     # We should get the same exception raised if they are all the same
-    with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
-        RuntimeError, match="all fail"
+    with (
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        pytest.raises(RuntimeError, match="all fail"),
     ):
         assert (
             await start_connection(
@@ -1640,7 +1654,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         nonlocal counter
@@ -1688,8 +1702,9 @@
     ]
     loop = asyncio.get_running_loop()
     # We should get the same exception raised if they are all the same
-    with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
-        RuntimeError, match="Multiple exceptions: 1, 2, 3"
+    with (
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        pytest.raises(RuntimeError, match="Multiple exceptions: 1, 2, 3"),
     ):
         assert (
             await start_connection(
@@ -1730,7 +1745,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         nonlocal counter
@@ -1780,8 +1795,9 @@
     ]
     loop = asyncio.get_running_loop()
     # We should get the same exception raised if they are all the same
-    with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
-        OSError, match="Multiple exceptions: 1"
+    with (
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        pytest.raises(OSError, match="Multiple exceptions: 1"),
     ):
         assert (
             await start_connection(
@@ -1825,7 +1841,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         raise MockSystemExit
@@ -1870,9 +1886,11 @@
         ),
     ]
     loop = asyncio.get_running_loop()
-    with pytest.raises(MockSystemExit), mock.patch.object(
-        loop, "sock_connect", _sock_connect
-    ), mock.patch.object(_staggered, "RE_RAISE_EXCEPTIONS", (MockSystemExit,)):
+    with (
+        pytest.raises(MockSystemExit),
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        mock.patch.object(_staggered, "RE_RAISE_EXCEPTIONS", 
(MockSystemExit,)),
+    ):
         await start_connection(
             addr_info,
             happy_eyeballs_delay=0.3,
@@ -1906,7 +1924,7 @@
         return mock_socket
 
     async def _sock_connect(
-        sock: socket.socket, address: Tuple[str, int, int, int]
+        sock: socket.socket, address: tuple[str, int, int, int]
     ) -> None:
         create_calls.append(address)
         await asyncio.sleep(1000)
@@ -1952,8 +1970,9 @@
     ]
     loop = asyncio.get_running_loop()
     # We should get the same exception raised if they are all the same
-    with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
-        asyncio.CancelledError
+    with (
+        mock.patch.object(loop, "sock_connect", _sock_connect),
+        pytest.raises(asyncio.CancelledError),
     ):
         task = asyncio.create_task(
             start_connection(
@@ -2014,3 +2033,112 @@
     ]
     with pytest.raises(OSError, match="during close"):
         await start_connection(addr_info)
+
+
+def test_interleave_addrinfos():
+    """_interleave_addrinfos groups by family and round-robins across 
families."""
+    ipv6_1 = (
+        socket.AF_INET6,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("dead:beef::1", 80, 0, 0),
+    )
+    ipv6_2 = (
+        socket.AF_INET6,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("dead:beef::2", 80, 0, 0),
+    )
+    ipv6_3 = (
+        socket.AF_INET6,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("dead:beef::3", 80, 0, 0),
+    )
+    ipv4_1 = (
+        socket.AF_INET,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("107.6.106.1", 80),
+    )
+    ipv4_2 = (
+        socket.AF_INET,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("107.6.106.2", 80),
+    )
+
+    # Even split: strict round-robin, IPv6 first because it appears first.
+    assert impl._interleave_addrinfos([ipv6_1, ipv6_2, ipv4_1, ipv4_2]) == [
+        ipv6_1,
+        ipv4_1,
+        ipv6_2,
+        ipv4_2,
+    ]
+
+    # Uneven split: leftover IPv6 entries trail once IPv4 is exhausted.
+    assert impl._interleave_addrinfos([ipv6_1, ipv6_2, ipv6_3, ipv4_1]) == [
+        ipv6_1,
+        ipv4_1,
+        ipv6_2,
+        ipv6_3,
+    ]
+
+    # Family order follows first appearance: IPv4 leads here.
+    assert impl._interleave_addrinfos([ipv4_1, ipv6_1, ipv4_2]) == [
+        ipv4_1,
+        ipv6_1,
+        ipv4_2,
+    ]
+
+    # A single family keeps its original order.
+    assert impl._interleave_addrinfos([ipv4_1, ipv4_2]) == [ipv4_1, ipv4_2]
+
+    # No input, no output.
+    assert impl._interleave_addrinfos([]) == []
+
+
+def test_interleave_addrinfos_first_address_family_count():
+    """first_address_family_count keeps N of the first family up front."""
+    ipv6_1 = (
+        socket.AF_INET6,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("dead:beef::1", 80, 0, 0),
+    )
+    ipv6_2 = (
+        socket.AF_INET6,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("dead:beef::2", 80, 0, 0),
+    )
+    ipv6_3 = (
+        socket.AF_INET6,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("dead:beef::3", 80, 0, 0),
+    )
+    ipv4_1 = (
+        socket.AF_INET,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("107.6.106.1", 80),
+    )
+
+    result = impl._interleave_addrinfos(
+        [ipv6_1, ipv6_2, ipv6_3, ipv4_1], first_address_family_count=2
+    )
+    # The first two IPv6 entries stay ahead of the interleaved remainder.
+    assert result == [ipv6_1, ipv6_2, ipv4_1, ipv6_3]
+    # Nothing is dropped or duplicated by the reshuffle.
+    assert set(result) == {ipv6_1, ipv6_2, ipv6_3, ipv4_1}
+    assert len(result) == 4
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/tests/test_staggered.py 
new/aiohappyeyeballs-2.7.1/tests/test_staggered.py
--- old/aiohappyeyeballs-2.6.1/tests/test_staggered.py  1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/tests/test_staggered.py  1970-01-01 
01:00:00.000000000 +0100
@@ -1,12 +1,10 @@
 import asyncio
 import sys
-from collections.abc import Callable as CallableABC
 from functools import partial
-from typing import Callable as CallableTyping
 
 import pytest
 
-from aiohappyeyeballs._staggered import Callable, staggered_race
+from aiohappyeyeballs._staggered import staggered_race
 
 
 @pytest.mark.asyncio
@@ -87,15 +85,3 @@
 
     loop.run_until_complete(run())
     loop.close()
-
-
-def test_callable_import_from_typing():
-    """
-    Test that Callable is imported from typing.
-
-    PY3.9: https://github.com/python/cpython/issues/87131
-
-    Drop this test when we drop support for Python 3.9.
-    """
-    assert Callable is CallableTyping
-    assert Callable is not CallableABC
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/aiohappyeyeballs-2.6.1/tests/test_staggered_cpython.py 
new/aiohappyeyeballs-2.7.1/tests/test_staggered_cpython.py
--- old/aiohappyeyeballs-2.6.1/tests/test_staggered_cpython.py  1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/tests/test_staggered_cpython.py  1970-01-01 
01:00:00.000000000 +0100
@@ -133,7 +133,7 @@
             return "coro"
 
         async def main():
-            winner, index, excs = await staggered_race([coro], delay=0.1, 
loop=loop)
+            winner, index, _excs = await staggered_race([coro], delay=0.1, 
loop=loop)
 
             self.assertEqual(winner, "coro")
             self.assertEqual(index, 0)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/tests/test_types.py 
new/aiohappyeyeballs-2.7.1/tests/test_types.py
--- old/aiohappyeyeballs-2.6.1/tests/test_types.py      1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/tests/test_types.py      1970-01-01 
01:00:00.000000000 +0100
@@ -1,14 +0,0 @@
-from collections.abc import Callable as CallableABC
-from typing import Callable as CallableTyping
-
-from aiohappyeyeballs.types import Callable
-
-
-def test_callable_import_from_typing():
-    """
-    Test that Callable is imported from typing.
-
-    PY3.9: https://github.com/python/cpython/issues/87131
-    """
-    assert Callable is CallableTyping
-    assert Callable is not CallableABC
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/aiohappyeyeballs-2.6.1/tests/test_utils.py 
new/aiohappyeyeballs-2.7.1/tests/test_utils.py
--- old/aiohappyeyeballs-2.6.1/tests/test_utils.py      1970-01-01 
01:00:00.000000000 +0100
+++ new/aiohappyeyeballs-2.7.1/tests/test_utils.py      1970-01-01 
01:00:00.000000000 +0100
@@ -1,5 +1,4 @@
 import socket
-from typing import List
 
 import pytest
 
@@ -34,7 +33,7 @@
         "",
         ("107.6.106.83", 80),
     )
-    addr_info: List[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, 
ipv4_addr_info]
+    addr_info: list[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, 
ipv4_addr_info]
     addr_info_copy = addr_info.copy()
     pop_addr_infos_interleave(addr_info_copy, 1)
     assert addr_info_copy == [ipv6_addr_info_2]
@@ -48,6 +47,32 @@
     assert addr_info_copy == [ipv6_addr_info_2]
 
 
+def test_pop_addr_infos_interleave_with_duplicates():
+    """Duplicate entries past the interleave window must be kept."""
+    ipv6_addr_info = (
+        socket.AF_INET6,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("dead:beef::", 80, 0, 0),
+    )
+    ipv4_addr_info = (
+        socket.AF_INET,
+        socket.SOCK_STREAM,
+        socket.IPPROTO_TCP,
+        "",
+        ("107.6.106.83", 80),
+    )
+    addr_info: list[AddrInfoType] = [
+        ipv6_addr_info,
+        ipv6_addr_info,
+        ipv4_addr_info,
+        ipv4_addr_info,
+    ]
+    pop_addr_infos_interleave(addr_info, 1)
+    assert addr_info == [ipv6_addr_info, ipv4_addr_info]
+
+
 def test_remove_addr_infos():
     """Test remove_addr_infos."""
     ipv6_addr_info = (
@@ -71,7 +96,7 @@
         "",
         ("107.6.106.83", 80),
     )
-    addr_info: List[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, 
ipv4_addr_info]
+    addr_info: list[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, 
ipv4_addr_info]
     addr_info_copy = addr_info.copy()
     remove_addr_infos(
         addr_info_copy,
@@ -107,7 +132,7 @@
         "",
         ("107.6.106.83", 80),
     )
-    addr_info: List[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, 
ipv4_addr_info]
+    addr_info: list[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, 
ipv4_addr_info]
     addr_info_copy = addr_info.copy()
     remove_addr_infos(
         addr_info_copy, ("dead:beef:0000:0000:0000:0000:0000:0000", 80, 0, 0)

Reply via email to