Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: init #2

Merged
merged 3 commits into from
Dec 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/aiohappyeyeballs/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
__version__ = "0.0.1"

from .impl import create_connection

__all__ = ("create_connection",)
170 changes: 170 additions & 0 deletions src/aiohappyeyeballs/impl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Base implementation."""
import asyncio
import collections
import functools
import itertools
import socket
from asyncio import staggered
from typing import List, Optional, Tuple, Union

AddrInfoType = Tuple[
int, int, int, str, Union[Tuple[str, int], Tuple[str, int, int, int]]
]


async def create_connection(
addr_infos: List[AddrInfoType],
*,
local_addr_infos: Optional[List[AddrInfoType]] = None,
happy_eyeballs_delay: Optional[float] = None,
interleave: Optional[int] = None,
all_errors: bool = False,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> socket.socket:
"""
Connect to a TCP server.

Create a streaming transport connection to a given internet host and
port: socket family AF_INET or socket.AF_INET6 depending on host (or
family if specified), socket type SOCK_STREAM. protocol_factory must be
a callable returning a protocol instance.

This method is a coroutine which will try to establish the connection
in the background. When successful, the coroutine returns a
(transport, protocol) pair.
"""
if not (current_loop := loop):
current_loop = asyncio.get_running_loop()

Check warning on line 37 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L37

Added line #L37 was not covered by tests

if happy_eyeballs_delay is not None and interleave is None:
# If using happy eyeballs, default to interleave addresses by family
interleave = 1

Check warning on line 41 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L41

Added line #L41 was not covered by tests

if interleave:
addr_infos = _interleave_addrinfos(addr_infos, interleave)

Check warning on line 44 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L44

Added line #L44 was not covered by tests

sock: Optional[socket.socket] = None
exceptions: List[List[Exception]] = []

Check warning on line 47 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L46-L47

Added lines #L46 - L47 were not covered by tests
if happy_eyeballs_delay is None:
# not using happy eyeballs
for addrinfo in addr_infos:
try:
sock = await _connect_sock(

Check warning on line 52 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L51-L52

Added lines #L51 - L52 were not covered by tests
current_loop, exceptions, addrinfo, local_addr_infos
)
break
except OSError:
continue

Check warning on line 57 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L55-L57

Added lines #L55 - L57 were not covered by tests
else: # using happy eyeballs
sock, _, _ = await staggered.staggered_race(
(
functools.partial(
_connect_sock, current_loop, exceptions, addrinfo, local_addr_infos
)
for addrinfo in addr_infos
),
happy_eyeballs_delay,
loop=current_loop,
)

if sock is None:
all_exceptions = [exc for sub in exceptions for exc in sub]
try:

Check warning on line 72 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L72

Added line #L72 was not covered by tests
if all_errors:
raise ExceptionGroup("create_connection failed", all_exceptions)

Check warning on line 74 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L74

Added line #L74 was not covered by tests
if len(all_exceptions) == 1:
raise all_exceptions[0]

Check warning on line 76 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L76

Added line #L76 was not covered by tests
else:
# If they all have the same str(), raise one.
model = str(all_exceptions[0])

Check warning on line 79 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L79

Added line #L79 was not covered by tests
if all(str(exc) == model for exc in all_exceptions):
raise all_exceptions[0]

Check warning on line 81 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L81

Added line #L81 was not covered by tests
# Raise a combined exception so the user can see all
# the various error messages.
raise OSError(
"Multiple exceptions: {}".format(
", ".join(str(exc) for exc in all_exceptions)
)
)
finally:
all_exceptions = None # type: ignore[assignment]
exceptions = None # type: ignore[assignment]

Check warning on line 91 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L90-L91

Added lines #L90 - L91 were not covered by tests

return sock

Check warning on line 93 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L93

Added line #L93 was not covered by tests


async def _connect_sock(
loop: asyncio.AbstractEventLoop,
exceptions: List[List[Exception]],
addr_info: AddrInfoType,
local_addr_infos: Optional[List[AddrInfoType]] = None,
) -> socket.socket:
"""Create, bind and connect one socket."""
my_exceptions: list[Exception] = []
exceptions.append(my_exceptions)
family, type_, proto, _, address = addr_info
sock = None
try:
sock = socket.socket(family=family, type=type_, proto=proto)
sock.setblocking(False)

Check warning on line 109 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L103-L109

Added lines #L103 - L109 were not covered by tests
if local_addr_infos is not None:
for lfamily, _, _, _, laddr in local_addr_infos:
# skip local addresses of different family
if lfamily != family:
continue
try:
sock.bind(laddr)
break
except OSError as exc:
msg = (

Check warning on line 119 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L114-L119

Added lines #L114 - L119 were not covered by tests
f"error while attempting to bind on "
f"address {laddr!r}: "
f"{exc.strerror.lower()}"
)
exc = OSError(exc.errno, msg)
my_exceptions.append(exc)

Check warning on line 125 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L124-L125

Added lines #L124 - L125 were not covered by tests
else: # all bind attempts failed
if my_exceptions:
raise my_exceptions.pop()

Check warning on line 128 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L128

Added line #L128 was not covered by tests
else:
raise OSError(f"no matching local address with {family=} found")
await loop.sock_connect(sock, address)

Check warning on line 131 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L130-L131

Added lines #L130 - L131 were not covered by tests
return sock
except OSError as exc:
my_exceptions.append(exc)

Check warning on line 134 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L134

Added line #L134 was not covered by tests
if sock is not None:
sock.close()
raise
except:

Check warning on line 138 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L136-L138

Added lines #L136 - L138 were not covered by tests
if sock is not None:
sock.close()
raise

Check warning on line 141 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L140-L141

Added lines #L140 - L141 were not covered by tests
finally:
exceptions = my_exceptions = None # type: ignore[assignment]


def _interleave_addrinfos(
addrinfos: List[AddrInfoType], first_address_family_count: int = 1
) -> List[AddrInfoType]:
"""Interleave list of addrinfo tuples by family."""
# Group addresses by family
addrinfos_by_family: collections.OrderedDict[

Check warning on line 151 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L151

Added line #L151 was not covered by tests
int, List[AddrInfoType]
] = collections.OrderedDict()
for addr in addrinfos:
family = addr[0]

Check warning on line 155 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L155

Added line #L155 was not covered by tests
if family not in addrinfos_by_family:
addrinfos_by_family[family] = []
addrinfos_by_family[family].append(addr)
addrinfos_lists = list(addrinfos_by_family.values())

Check warning on line 159 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L157-L159

Added lines #L157 - L159 were not covered by tests

reordered: List[AddrInfoType] = []

Check warning on line 161 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L161

Added line #L161 was not covered by tests
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]

Check warning on line 164 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L163-L164

Added lines #L163 - L164 were not covered by tests
reordered.extend(
a
for a in itertools.chain.from_iterable(itertools.zip_longest(*addrinfos_lists))
if a is not None
)
return reordered

Check warning on line 170 in src/aiohappyeyeballs/impl.py

View check run for this annotation

Codecov / codecov/patch

src/aiohappyeyeballs/impl.py#L170

Added line #L170 was not covered by tests
3 changes: 0 additions & 3 deletions src/aiohappyeyeballs/main.py

This file was deleted.

5 changes: 5 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from aiohappyeyeballs import create_connection


def test_init():
assert create_connection is not None
6 changes: 0 additions & 6 deletions tests/test_main.py

This file was deleted.

Loading