Repository: agronholm/anyio

Issue:
Should we expose the user to 4-tuple IPv6 socket addresses?
The result of `sock.getsockname()` for IPv6 sockets is a 4-tuple (address, port, flowinfo, scopeid). The last two fields are specific to IPv6. The flowinfo field is useless/deprecated and the scopeid is only meaningful for link-local addresses. The important part here is that the address field can carry a scope ID by appending it to the address with a `%` separator, like `fe80::dead:beef%1`.

Most people are not used to dealing with 4-tuple socket addresses, so maybe it would make sense to make it part of the address always. On the other hand, others might *expect* the usual behavior and get confused when they get 2-tuples instead!

Discussion:

Patch:
diff --git a/src/anyio/__init__.py b/src/anyio/__init__.py
index 2b2cfec..cb1fd4b 100644
--- a/src/anyio/__init__.py
+++ b/src/anyio/__init__.py
@@ -14,6 +14,7 @@ from typing import TypeVar, Callable, Union, Optional, Awaitable, Coroutine, Any
 
 import sniffio
 
+from ._utils import convert_ipv6_sockaddr
 from .abc import (
     Lock, Condition, Event, Semaphore, CapacityLimiter, CancelScope, TaskGroup, IPAddressType,
     SocketStream, UDPSocket, ConnectedUDPSocket, IPSockAddrType, Listener, SocketListener)
@@ -32,8 +33,7 @@ IPPROTO_IPV6 = getattr(socket, 'IPPROTO_IPV6', 41)  # https://bugs.python.org/is
 T_Retval = TypeVar('T_Retval', covariant=True)
 T_Agen = TypeVar('T_Agen')
 T_Item = TypeVar('T_Item')
-GetAddrInfoReturnType = List[Tuple[AddressFamily, SocketKind, int, str,
-                             Union[Tuple[str, int], Tuple[str, int, int, int]]]]
+GetAddrInfoReturnType = List[Tuple[AddressFamily, SocketKind, int, str, Tuple[str, int]]]
 AnyIPAddressFamily = Literal[AddressFamily.AF_UNSPEC, AddressFamily.AF_INET,
                              AddressFamily.AF_INET6]
 IPAddressFamily = Literal[AddressFamily.AF_INET, AddressFamily.AF_INET6]
@@ -607,15 +607,18 @@ async def create_connected_udp_socket(
                                                    reuse_port)
 
 
-def getaddrinfo(host: Union[bytearray, bytes, str], port: Union[str, int, None], *,
-                family: Union[int, AddressFamily] = 0, type: Union[int, SocketKind] = 0,
-                proto: int = 0, flags: int = 0) -> Awaitable[GetAddrInfoReturnType]:
+async def getaddrinfo(host: Union[bytearray, bytes, str], port: Union[str, int, None], *,
+                      family: Union[int, AddressFamily] = 0, type: Union[int, SocketKind] = 0,
+                      proto: int = 0, flags: int = 0) -> GetAddrInfoReturnType:
     """
     Look up a numeric IP address given a host name.
 
     Internationalized domain names are translated according to the (non-transitional) IDNA 2008
     standard.
 
+    .. note:: 4-tuple IPv6 socket addresses are automatically converted to 2-tuples of
+        (host, port), unlike what :func:`socket.getaddrinfo` does.
+
     :param host: host name
     :param port: port number
     :param family: socket family (`'AF_INET``, ...)
@@ -637,8 +640,10 @@ def getaddrinfo(host: Union[bytearray, bytes, str], port: Union[str, int, None],
     else:
         encoded_host = host
 
-    return _get_asynclib().getaddrinfo(encoded_host, port, family=family, type=type, proto=proto,
-                                       flags=flags)
+    gai_res = await _get_asynclib().getaddrinfo(encoded_host, port, family=family, type=type,
+                                                proto=proto, flags=flags)
+    return [(family, type, proto, canonname, convert_ipv6_sockaddr(sockaddr))
+            for family, type, proto, canonname, sockaddr in gai_res]
 
 
 def getnameinfo(sockaddr: IPSockAddrType, flags: int = 0) -> Awaitable[Tuple[str, str]]:
diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py
index 03d3093..d85e45d 100644
--- a/src/anyio/_backends/_asyncio.py
+++ b/src/anyio/_backends/_asyncio.py
@@ -854,23 +854,12 @@ class ConnectedUDPSocket(abc.ConnectedUDPSocket):
                 self._transport.sendto(item)
 
 
-def _convert_ipv6_sockaddr(sockaddr: Optional[IPSockAddrType]) -> Optional[Tuple[str, int]]:
-    # This is more complicated than it should be because of MyPy
-    if sockaddr is None:
-        return None
-    elif len(sockaddr) == 4 and sockaddr[-1]:
-        # Add scopeid to the address
-        return sockaddr[0] + '%' + str(sockaddr[-1]), sockaddr[1]
-    else:
-        return sockaddr[:2]
-
-
 async def connect_tcp(host: str, port: int,
-                      local_addr: Optional[IPSockAddrType] = None) -> SocketStream:
+                      local_addr: Optional[Tuple[str, int]] = None) -> SocketStream:
     transport, protocol = cast(
         Tuple[asyncio.Transport, StreamProtocol],
         await get_running_loop().create_connection(StreamProtocol, host, port,
-                                                   local_addr=_convert_ipv6_sockaddr(local_addr))
+                                                   local_addr=local_addr)
     )
     transport.pause_reading()
     return SocketStream(transport, protocol)
@@ -892,8 +881,8 @@ async def create_udp_socket(
     reuse_port: bool
 ) -> Union[UDPSocket, ConnectedUDPSocket]:
     result = await get_running_loop().create_datagram_endpoint(
-        DatagramProtocol, local_addr=_convert_ipv6_sockaddr(local_address),
-        remote_addr=_convert_ipv6_sockaddr(remote_address), family=family, reuse_port=reuse_port)
+        DatagramProtocol, local_addr=local_address, remote_addr=remote_address, family=family,
+        reuse_port=reuse_port)
     transport = cast(asyncio.DatagramTransport, result[0])
     protocol = cast(DatagramProtocol, result[1])
     if protocol.exception:
diff --git a/src/anyio/_utils.py b/src/anyio/_utils.py
index ff68d2c..0f7bdac 100644
--- a/src/anyio/_utils.py
+++ b/src/anyio/_utils.py
@@ -16,3 +16,26 @@ class ResourceGuard:
 
     def __exit__(self, exc_type, exc_val, exc_tb):
         self._guarded = False
+
+
+def convert_ipv6_sockaddr(sockaddr):
+    """
+    Convert a 4-tuple IPv6 socket address to a 2-tuple (address, port) format.
+
+    If the scope ID is nonzero, it is added to the address, separated with ``%``.
+    Otherwise the flow id and scope id are simply cut off from the tuple.
+    Any other kinds of socket addresses are returned as-is.
+
+    :param sockaddr: the result of :meth:`~socket.socket.getsockname`
+    :return: the converted socket address
+
+    """
+    # This is more complicated than it should be because of MyPy
+    if isinstance(sockaddr, tuple) and len(sockaddr) == 4:
+        if sockaddr[3]:
+            # Add scopeid to the address
+            return sockaddr[0] + '%' + str(sockaddr[3]), sockaddr[1]
+        else:
+            return sockaddr[:2]
+    else:
+        return sockaddr
diff --git a/src/anyio/abc/sockets.py b/src/anyio/abc/sockets.py
index f02d1c8..50d8a4d 100644
--- a/src/anyio/abc/sockets.py
+++ b/src/anyio/abc/sockets.py
@@ -6,7 +6,7 @@ from typing import TypeVar, Tuple, Union, Generic
 from .streams import UnreliableObjectStream, ByteStream, Listener
 
 IPAddressType = Union[str, IPv4Address, IPv6Address]
-IPSockAddrType = Union[Tuple[str, int], Tuple[str, int, int, int]]
+IPSockAddrType = Tuple[str, int]
 SockAddrType = Union[IPSockAddrType, str]
 UDPPacketType = Tuple[bytes, IPSockAddrType]
 T_Retval = TypeVar('T_Retval')
@@ -41,8 +41,7 @@ class _SocketMixin(Generic[T_SockAddr]):
         """
         The bound address of the underlying local socket.
 
-        For IPv4 TCP streams, this is a tuple of (IP address, port).
-        For IPv6 TCP streams, this is a tuple of (IP address, port, flowinfo, scopeid).
+        For TCP streams, this is a tuple of (IP address, port).
         For UNIX socket streams, this is the path to the socket.
         """
 
@@ -56,8 +55,7 @@ class SocketStream(Generic[T_SockAddr], ByteStream, _SocketMixin[T_SockAddr]):
         """
         The address this socket is connected to.
 
-        For IPv4 TCP streams, this is a tuple of (IP address, port).
-        For IPv6 TCP streams, this is a tuple of (IP address, port, flowinfo, scopeid).
+        For TCP streams, this is a tuple of (IP address, port).
         For UNIX socket streams, this is the path to the socket.
         """
 
@@ -80,9 +78,4 @@ class ConnectedUDPSocket(UnreliableObjectStream[bytes], _SocketMixin[IPSockAddrT
     @property
     @abstractmethod
     def remote_address(self) -> IPSockAddrType:
-        """
-        The address this socket is connected to.
-
-        For IPv4 sockets, this is a tuple of (IP address, port).
-        For IPv6 sockets, this is a tuple of (IP address, port, flowinfo, scopeid).
-        """
+        """The address this socket is connected to."""


Test patch:
diff --git a/tests/test_sockets.py b/tests/test_sockets.py
index 24fb5de..7f47bb6 100644
--- a/tests/test_sockets.py
+++ b/tests/test_sockets.py
@@ -747,8 +747,7 @@ async def test_getaddrinfo_ipv6addr(sock_type):
     # IDNA trips up over raw IPv6 addresses
     proto = 0 if platform.system() == 'Windows' else 6
     assert await getaddrinfo('::1', 0, type=sock_type) == [
-        (socket.AddressFamily.AF_INET6, socket.SocketKind.SOCK_STREAM, proto, '',
-         ('::1', 0, 0, 0))
+        (socket.AddressFamily.AF_INET6, socket.SocketKind.SOCK_STREAM, proto, '', ('::1', 0))
     ]