pax_global_header00006660000000000000000000000064146167414750014531gustar00rootroot0000000000000052 comment=567678ef791045c11454bcffc288eeb5f1b4b337 jsbronder-asyncio-dgram-7e0eaa5/000077500000000000000000000000001461674147500167455ustar00rootroot00000000000000jsbronder-asyncio-dgram-7e0eaa5/.github/000077500000000000000000000000001461674147500203055ustar00rootroot00000000000000jsbronder-asyncio-dgram-7e0eaa5/.github/workflows/000077500000000000000000000000001461674147500223425ustar00rootroot00000000000000jsbronder-asyncio-dgram-7e0eaa5/.github/workflows/ci.yml000066400000000000000000000025331461674147500234630ustar00rootroot00000000000000name: ci on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip make requirements - name: Lint run: | make lint - name: Format run: | make format && git diff --quiet HEAD test: runs-on: ubuntu-latest strategy: matrix: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip make requirements - name: Test run: | make test - name: Type check run: | make type-check test-legacy: runs-on: ubuntu-20.04 strategy: matrix: python-version: ['3.6', '3.7'] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip make requirements - name: Test run: | make test # vim: sw=2 jsbronder-asyncio-dgram-7e0eaa5/.github/workflows/weekly.yml000066400000000000000000000012531461674147500243660ustar00rootroot00000000000000name: weekly on: schedule: - cron: 11 4 * * 0 jobs: weekly: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11-dev", "3.12-dev"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip make requirements - name: Lint run: | make lint - name: Format run: | make format && git diff --quiet HEAD - name: Type check run: | make type-check - name: Test run: | make test # vim: sw=2 jsbronder-asyncio-dgram-7e0eaa5/.gitignore000066400000000000000000000001621461674147500207340ustar00rootroot00000000000000*.egg-info/ .pytest_cache/ build/ dist/ .venv-3* .eto-venv/ *.sw[po] .python-version __pycache__ .devrc-* .eggs/ jsbronder-asyncio-dgram-7e0eaa5/ChangeLog000066400000000000000000000027651461674147500205310ustar00rootroot000000000000002.2.0: - Typing fixes. https://github.com/jsbronder/asyncio-dgram/issues/16 https://github.com/jsbronder/asyncio-dgram/issues/15 - Add option for resue_port in bind, thaks to yoelbassin. https://github.com/jsbronder/asyncio-dgram/pull/14 - Support for python 3.11, 3.12 added. - Support for python 3.5 dropped. 2.1.2: - Do not default to AF_UNIX under windows. Thanks to jmarcet. https://github.com/jsbronder/asyncio-dgram/pull/12 2.1.1: - Fixed type-hint for DatagramStream's drained parameter. Thanks to spumer for the report. https://github.com/jsbronder/asyncio-dgram/issues/11 2.1.0: - Type hints added - DatagramStream.send() renamed to DatagramStream._send(). This should be backward compatible as both DatagramServer and DatagramClient were overloading it. 2.0.0: - TransportClosed exception raised from send/recv if the other end hung up. https://github.com/jsbronder/asyncio-dgram/issues/9 1.2.0: - Added support for python 3.5 1.1.1: - Add license to setup.py [Fabian Affolter] 1.1.0: - AF_UNIX support added for python 3.7+ 1.0.1: - bind() no longer explicitly disables SO_REUSEADDR as upstream deprecated the option. 1.0.0: - bind() no longer sets SO_REUSEADDR by default. See upstream discussion for rationale, https://bugs.python.org/issue37228 - DatagramStream.send() will now wait for the asyncio write buffer to drain below the highwater mark before returning. This mimics the forthcoming behavior from upstream, https://bugs.python.org/issue38242 jsbronder-asyncio-dgram-7e0eaa5/LICENSE000066400000000000000000000020571461674147500177560ustar00rootroot00000000000000MIT License Copyright (c) 2019 Justin Bronder Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. jsbronder-asyncio-dgram-7e0eaa5/MANIFEST.in000066400000000000000000000002461461674147500205050ustar00rootroot00000000000000include LICENSE include requirements.txt include requirements-test.txt include Makefile include dev-env.sh include asyncio_dgram/*.pyi include asyncio_dgram/py.typed jsbronder-asyncio-dgram-7e0eaa5/Makefile000066400000000000000000000034041461674147500204060ustar00rootroot00000000000000DESTDIR ?= / UT ?= SHELL = /usr/bin/env bash PYTHON := $(shell which python3) PACKAGE := $(shell $(PYTHON) setup.py --name | tr '-' '_') VERSION := $(shell $(PYTHON) setup.py --version) # List of files in the repository TEST_FILES := $(wildcard test/test_*.py) PKG_FILES := $(shell find $(PACKAGE) -type f -name '*.py') REQ_FILES := $(wildcard requirements*.txt) BIN_FILES := $(wildcard bin/*) # Inferred targets from file names LINT_TARGETS := setup.py $(PKG_FILES) $(BIN_FILES) $(shell find test -type f -name '*.py') TEST_TARGETS := $(TEST_FILES:test/test_%.py=test_%) EXT_TARGETS := $(wildcard ext/*) .PHONY: \ $(EXT_TARGETS) \ $(REQ_FILES) \ $(TEST_TARGETS) \ clean \ ext \ format \ install \ lint \ requirements \ test default: clean: @rm -rf .pytest_cache @rm -rf .eggs @rm -rf dist @rm -rf build @rm -rf $(PACKAGE).egg-info requirements: $(REQ_FILES) $(REQ_FILES): @$(PYTHON) -m pip install --disable-pip-version-check -r $@ dist: dist/$(PACKAGE)-$(VERSION).tar.gz dist/$(PACKAGE)-$(VERSION).tar.gz: $(PKG_FILES) setup.py @$(PYTHON) -m pip install --disable-pip-version-check wheel $(PYTHON) setup.py sdist bdist_wheel upload: dist @$(PYTHON) -m pip install --disable-pip-version-check wheel $(PYTHON) -m twine upload dist/*$(VERSION)* upload-test: dist @$(PYTHON) -m pip install --disable-pip-version-check twine $(PYTHON) -m twine upload --repository testpypi dist/*$(VERSION)* format: @black $(LINT_TARGETS) lint: @flake8 --filename='*' $(LINT_TARGETS) type-check: @mypy $(PACKAGE) test/ example.py test: @$(PYTHON) -m pytest --log-level=DEBUG -W default -v -s $(TEST_TARGETS): @$(PYTHON) -m pytest --log-cli-level=DEBUG -W default -v -s test/$(@).py $(if $(UT),-k $(UT),) install: $(PYTHON) setup.py install --root $(DESTDIR) --prefix . jsbronder-asyncio-dgram-7e0eaa5/README.md000066400000000000000000000045351461674147500202330ustar00rootroot00000000000000[![Build Status](https://github.com/jsbronder/asyncio-dgram/workflows/ci/badge.svg)](https://github.com/jsbronder/asyncio-dgram/actions) # Higher level Datagram support for Asyncio Simple wrappers that allow you to `await read()` from datagrams as suggested by Guido van Rossum [here](https://github.com/python/asyncio/pull/321#issuecomment-187022351). I frequently found myself having to inherit from `asyncio.DatagramProtocol` and implement this over and over. # Design The goal of this package is to make implementing common patterns that use datagrams simple and straight-forward while still supporting more esoteric options. This is done by taking an opinionated stance on the API that differs from parts of asyncio. For instance, rather than exposing a function like [create\_datagram\_endpoint](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.create_datagram_endpoint) which supports many use-cases and has conflicting parameters, `asyncio_dgram` only provides three functions for creating a stream: - `connect((host, port))`: Creates a datagram endpoint which can only communicate with the endpoint it connected to. - `bind((host, port))`: Creates a datagram endpoint that can communicate with anyone, but must specified the destination address every time it sends. - `from_socket(sock)`: If the above two functions are not sufficient, then `asyncio_dgram` simply lets the caller setup the socket as they see fit. # Example UDP echo client and server Following the example of asyncio documentation, here's what a UDP echo client and server would look like. ```python import asyncio import asyncio_dgram async def udp_echo_client(): stream = await asyncio_dgram.connect(("127.0.0.1", 8888)) await stream.send(b"Hello World!") data, remote_addr = await stream.recv() print(f"Client received: {data.decode()!r}") stream.close() async def udp_echo_server(): stream = await asyncio_dgram.bind(("127.0.0.1", 8888)) print(f"Serving on {stream.sockname}") data, remote_addr = await stream.recv() print(f"Echoing {data.decode()!r}") await stream.send(data, remote_addr) await asyncio.sleep(0.5) print(f"Shutting down server") def main(): loop = asyncio.get_event_loop() loop.run_until_complete(asyncio.gather(udp_echo_server(), udp_echo_client())) if __name__ == "__main__": main() ``` jsbronder-asyncio-dgram-7e0eaa5/asyncio_dgram/000077500000000000000000000000001461674147500215645ustar00rootroot00000000000000jsbronder-asyncio-dgram-7e0eaa5/asyncio_dgram/__init__.py000066400000000000000000000000331461674147500236710ustar00rootroot00000000000000from .aio import * # noqa jsbronder-asyncio-dgram-7e0eaa5/asyncio_dgram/aio.py000066400000000000000000000243201461674147500227070ustar00rootroot00000000000000import asyncio import pathlib import socket import sys import warnings __all__ = ("TransportClosed", "bind", "connect", "from_socket") _windows = sys.platform == "win32" class TransportClosed(Exception): """ Raised when the asyncio.DatagramTransport underlying a DatagramStream is closed. """ class DatagramStream: """ Representation of a Datagram socket attached via either bind() or connect() returned to consumers of this module. Provides simple wrappers around sending and receiving bytes. Due to the stateless nature of datagram protocols, errors are not immediately available to this class at the point an action was performed that will generate it. Rather, successive calls will raise exceptions if there are any. Checking for exceptions can be done explicitly by using the exception property. For instance, failure to connect to a remote endpoint will not be noticed until some point in time later, at which point ConnectionRefused will be raised. """ def __init__(self, transport, recvq, excq, drained): """ @param transport - asyncio transport @param recvq - asyncio queue that gets populated by the DatagramProtocol with received datagrams. @param excq - asyncio queue that gets populated with any errors detected by the DatagramProtocol. @param drained - asyncio event that is unset when writing is paused and set otherwise. """ self._transport = transport self._recvq = recvq self._excq = excq self._drained = drained def __del__(self): self._transport.close() @property def exception(self): """ If the underlying protocol detected an error, raise the first unconsumed exception it noticed, otherwise returns None. """ try: exc = self._excq.get_nowait() raise exc except asyncio.queues.QueueEmpty: pass @property def sockname(self): """ The associated socket's own address """ r = self._transport.get_extra_info("sockname") return None if r == "" else r @property def peername(self): """ The address the associated socket is connected to """ r = self._transport.get_extra_info("peername") return None if r == "" else r @property def socket(self): """ The socket instance used by the stream. In python <3.8 this is a socket.socket instance, after it is an asyncio.TransportSocket instance. """ return self._transport.get_extra_info("socket") def close(self): """ Close the underlying transport. """ self._transport.close() async def _send(self, data, addr=None): """ @param data - bytes to send @param addr - remote address to send data to, if unspecified then the underlying socket has to have been been connected to a remote address previously. @raises TransportClosed - DatagramTransport closed. """ if self._transport.is_closing(): raise TransportClosed() _ = self.exception self._transport.sendto(data, addr) await self._drained.wait() async def recv(self): """ Receive data on the local socket. @return - tuple of the bytes received and the address (ip, port) that the data was received from. @raises TransportClosed - DatagramTransport closed. """ if self._transport.is_closing(): raise TransportClosed() _ = self.exception data, addr = await self._recvq.get() if data is None: raise TransportClosed() return data, addr class DatagramServer(DatagramStream): """ Datagram socket bound to an address on the local machine. """ async def send(self, data, addr): """ @param data - bytes to send @param addr - remote address to send data to. """ await super()._send(data, addr) class DatagramClient(DatagramStream): """ Datagram socket connected to a remote address. """ async def send(self, data): """ @param data - bytes to send """ await super()._send(data) class Protocol(asyncio.DatagramProtocol): """ asyncio.DatagramProtocol for feeding received packets into the Datagram{Client,Server} which handles converting the lower level callback based asyncio into higher level coroutines. """ def __init__(self, recvq, excq, drained): """ @param recvq - asyncio.Queue for new datagrams @param excq - asyncio.Queue for exceptions @param drained - asyncio.Event set when the write buffer is below the high watermark. """ self._recvq = recvq self._excq = excq self._drained = drained self._drained.set() # Transports are connected at the time a connection is made. self._transport = None def connection_made(self, transport): if self._transport is not None: old_peer = self._transport.get_extra_info("peername") new_peer = transport.get_extra_info("peername") warnings.warn( "Reinitializing transport connection from %s to %s", old_peer, new_peer ) self._transport = transport def connection_lost(self, exc): if exc is not None: self._excq.put_nowait(exc) self._recvq.put_nowait((None, None)) if self._transport is not None: self._transport.close() self._transport = None def datagram_received(self, data, addr): self._recvq.put_nowait((data, addr)) def error_received(self, exc): self._excq.put_nowait(exc) def pause_writing(self): self._drained.clear() super().pause_writing() def resume_writing(self): self._drained.set() super().resume_writing() async def bind(addr, reuse_port=None): """ Bind a socket to a local address for datagrams. The socket will be either AF_INET, AF_INET6 or AF_UNIX depending upon the type of address specified. @param addr - For AF_INET or AF_INET6, a tuple with the the host and port to to bind; port may be set to 0 to get any free port. For AF_UNIX the path at which to bind (with a leading \0 for abstract sockets). @param reuse_port - Tells the kernel to allow this endpoint to be bound to the same port as other existing endpoints are bound to, so long as they all set this flag when being created. This option is not supported on Windows and some UNIX's. If the :py:data:`~socket.SO_REUSEPORT` constant is not defined then this capability is unsupported. @return - A DatagramServer instance """ loop = asyncio.get_event_loop() recvq = asyncio.Queue() excq = asyncio.Queue() drained = asyncio.Event() if not _windows and not isinstance(addr, tuple): family = socket.AF_UNIX if isinstance(addr, pathlib.Path): addr = str(addr) else: family = 0 transport, protocol = await loop.create_datagram_endpoint( lambda: Protocol(recvq, excq, drained), local_addr=addr, family=family, reuse_port=reuse_port, ) return DatagramServer(transport, recvq, excq, drained) async def connect(addr): """ Connect a socket to a remote address for datagrams. The socket will be either AF_INET, AF_INET6 or AF_UNIX depending upon the type of host specified. @param addr - For AF_INET or AF_INET6, a tuple with the the host and port to to connect to. For AF_UNIX the path at which to connect (with a leading \0 for abstract sockets). @return - A DatagramClient instance """ loop = asyncio.get_event_loop() recvq = asyncio.Queue() excq = asyncio.Queue() drained = asyncio.Event() if not _windows and not isinstance(addr, tuple): family = socket.AF_UNIX if isinstance(addr, pathlib.Path): addr = str(addr) else: family = 0 transport, protocol = await loop.create_datagram_endpoint( lambda: Protocol(recvq, excq, drained), remote_addr=addr, family=family, ) return DatagramClient(transport, recvq, excq, drained) async def from_socket(sock): """ Create a DatagramStream from a socket. This is meant to be used in cases where the defaults set by `bind()` and `connect()` are not desired and/or sufficient. If `socket.connect()` was previously called on the socket, then an instance of DatagramClient will be returned, otherwise an instance of DatagramServer. @param sock - socket to use in the DatagramStream. @return - A DatagramClient for connected sockets, otherwise a DatagramServer. """ loop = asyncio.get_event_loop() recvq = asyncio.Queue() excq = asyncio.Queue() drained = asyncio.Event() if not _windows: supported_families = tuple((socket.AF_INET, socket.AF_INET6, socket.AF_UNIX)) else: supported_families = tuple((socket.AF_INET, socket.AF_INET6)) if sock.family not in supported_families: raise TypeError( "socket family not one of %s" % (", ".join(str(f) for f in supported_families)) ) if sock.type != socket.SOCK_DGRAM: raise TypeError("socket type must be %s" % (socket.SOCK_DGRAM,)) transport, protocol = await loop.create_datagram_endpoint( lambda: Protocol(recvq, excq, drained), sock=sock ) if transport.get_extra_info("peername") is not None: # Workaround transport ignoring the peer address of the socket. transport._address = transport.get_extra_info("peername") return DatagramClient(transport, recvq, excq, drained) else: return DatagramServer(transport, recvq, excq, drained) jsbronder-asyncio-dgram-7e0eaa5/asyncio_dgram/aio.pyi000066400000000000000000000041221461674147500230560ustar00rootroot00000000000000import asyncio import asyncio.trsock import pathlib import socket import sys from socket import _Address, _RetAddress from typing import Any, Optional, Tuple, Union class TransportClosed(Exception): pass class DatagramStream: # Support type-checking in unittests which mock this _drained: asyncio.Event def __init__( self, transport: asyncio.DatagramProtocol, recvq: asyncio.Queue[tuple[Optional[bytes], Optional[_Address]]], excq: asyncio.Queue[Exception], drained: asyncio.Event, ) -> None: ... @property def exception(self) -> None: ... @property def sockname(self) -> _RetAddress: ... @property def peername(self) -> _RetAddress: ... @property def socket(self) -> socket.socket | asyncio.trsock.TransportSocket: ... def close(self) -> None: ... async def _send(self, data: bytes, addr: Optional[_Address]) -> None: ... async def recv(self) -> Tuple[bytes, _Address]: ... class DatagramServer(DatagramStream): async def send(self, data: bytes, addr: _Address) -> None: ... class DatagramClient(DatagramStream): async def send(self, data: bytes) -> None: ... class Protocol(asyncio.DatagramProtocol): # Support type-checking in unittests which mock this _drained: asyncio.Event def __init__( self, recvq: asyncio.Queue[tuple[Optional[bytes], Optional[_Address]]], excq: asyncio.Queue[Exception], drained: asyncio.Event ) -> None: ... def connection_made(self, transport: asyncio.BaseTransport) -> None: ... def connection_lost(self, exc: Optional[Exception]) -> None: ... def datagram_received(self, data: bytes, addr: _Address) -> None: ... def error_received(self, exc: Exception) -> None: ... def pause_writing(self) -> None: ... def resume_writing(self) -> None: ... async def bind(addr: Union[_Address, pathlib.Path, str], reuse_port: Optional[bool] = None) -> DatagramServer: ... async def connect(addr: Union[_Address, pathlib.Path, str]) -> DatagramClient: ... async def from_socket(sock: socket.socket) -> Union[DatagramServer, DatagramClient]: ... jsbronder-asyncio-dgram-7e0eaa5/asyncio_dgram/py.typed000066400000000000000000000000001461674147500232510ustar00rootroot00000000000000jsbronder-asyncio-dgram-7e0eaa5/dev-env.sh000077500000000000000000000015621461674147500206540ustar00rootroot00000000000000#!/usr/bin/env bash cleanup() { [ -n "${bashrc}" ] && rm -f "${bashrc}" } trap cleanup HUP TERM EXIT PYTHON=${PYTHON:-python3} pyversion=$(${PYTHON} --version | awk '{print $2}') topdir=$(cd "$(dirname "$0")"; pwd -P) bashrc=$(TMPDIR=${topdir} mktemp .devrc-XXXXXX) make=$(which gmake 2>/dev/null || which make) if [ -n "${VIRTUAL_ENV}" ]; then virtualenv=${VIRTUAL_ENV} else virtualenv=${topdir}/.venv-${pyversion} fi if [ ! -d "${virtualenv}" ]; then ${PYTHON} -m venv "${virtualenv}" || exit 1 fi # Instructions to run when entering new shell cat > "${bashrc}" <<-EOF [ -f ~/.bashrc ] && source ~/.bashrc source "${virtualenv}"/bin/activate EOF # Enter developmet shell if [ -z "$*" ]; then /usr/bin/env bash --rcfile "${bashrc}" -i else source "${bashrc}" "$@" fi # vim: noet # -*- indent-tabs-mode: t; tab-width: 8; sh-indentation: 8; sh-basic-offset: 8; -*- jsbronder-asyncio-dgram-7e0eaa5/example.py000066400000000000000000000014441461674147500207550ustar00rootroot00000000000000import asyncio import asyncio_dgram async def udp_echo_client() -> None: stream = await asyncio_dgram.connect(("127.0.0.1", 8888)) await stream.send(b"Hello World!") data, remote_addr = await stream.recv() print(f"Client received: {data.decode()!r}") stream.close() async def udp_echo_server() -> None: stream = await asyncio_dgram.bind(("127.0.0.1", 8888)) print(f"Serving on {stream.sockname}") data, remote_addr = await stream.recv() print(f"Echoing {data.decode()!r}") await stream.send(data, remote_addr) await asyncio.sleep(0.5) print(f"Shutting down server") def main() -> None: loop = asyncio.get_event_loop() loop.run_until_complete(asyncio.gather(udp_echo_server(), udp_echo_client())) if __name__ == "__main__": main() jsbronder-asyncio-dgram-7e0eaa5/requirements-test.txt000066400000000000000000000002211461674147500232010ustar00rootroot00000000000000black>=20.8b1 flake8>=3.8.3 mypy-extensions>=0.4.3 mypy>=0.812 pytest-asyncio>=0.14.0 pytest>=5.4.3 typed-ast>=1.4.3 typing-extensions>=3.10.0.0 jsbronder-asyncio-dgram-7e0eaa5/requirements.txt000066400000000000000000000000131461674147500222230ustar00rootroot00000000000000setuptools jsbronder-asyncio-dgram-7e0eaa5/setup.cfg000066400000000000000000000001211461674147500205600ustar00rootroot00000000000000[flake8] ignore = E126,E128,E131,W503 max-line-length = 88 [mypy] strict = True jsbronder-asyncio-dgram-7e0eaa5/setup.py000066400000000000000000000027421461674147500204640ustar00rootroot00000000000000#!/usr/bin/env python import pathlib import setuptools import sys topdir = pathlib.Path(__file__).parent def readfile(f): return (topdir / f).read_text("utf-8").strip() extra_options = {} if sys.version_info.major == 3 and sys.version_info.minor >= 7: extra_options["long_description_content_type"] = "text/markdown" setuptools.setup( name="asyncio-dgram", version="2.2.0", description="Higher level Datagram support for Asyncio", long_description=readfile("README.md"), url="https://github.com/jsbronder/asyncio-dgram", author="Justin Bronder", author_email="jsbronder@cold-front.org", license="MIT", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Framework :: AsyncIO", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ], package_data={"asyncio_dgram": ["*.pyi", "py.typed"]}, include_package_data=True, packages=["asyncio_dgram"], python_requires=">=3.6", install_requires=readfile("requirements.txt").split(), extras_require={"test": readfile("requirements-test.txt").split()}, **extra_options, ) jsbronder-asyncio-dgram-7e0eaa5/test/000077500000000000000000000000001461674147500177245ustar00rootroot00000000000000jsbronder-asyncio-dgram-7e0eaa5/test/test_aio.py000066400000000000000000000425221461674147500221120ustar00rootroot00000000000000import asyncio import contextlib import os import pathlib import socket import sys import typing import unittest.mock import pytest import asyncio_dgram if typing.TYPE_CHECKING: from socket import _Address else: _Address = None if sys.version_info < (3, 9): from typing import Generator else: from collections.abc import Generator if sys.version_info < (3, 7): asyncio.create_task = asyncio.ensure_future @contextlib.contextmanager def loop_exception_handler() -> Generator["asyncio.base_events._Context", None, None]: """ Replace the current event loop exception handler with one that simply stores exceptions in the returned dictionary. @return - dictionary that is updated with the last loop exception """ context = {} def handler( loop: asyncio.AbstractEventLoop, c: "asyncio.base_events._Context" ) -> None: context.update(c) loop = asyncio.get_event_loop() orig_handler = loop.get_exception_handler() loop.set_exception_handler(handler) yield context loop.set_exception_handler(orig_handler) @pytest.mark.asyncio @pytest.mark.parametrize( "addr,family", [ (("127.0.0.1", 0), socket.AF_INET), (("::1", 0), socket.AF_INET6), ("socket", socket.AF_UNIX), ], ids=["INET", "INET6", "UNIX"], ) async def test_connect_sync( addr: typing.Union[_Address, str], family: socket.AddressFamily, tmp_path: pathlib.Path, ) -> None: # Bind a regular socket, asyncio_dgram connect, then check asyncio send and # receive. if family == socket.AF_UNIX: assert isinstance(addr, str) if sys.version_info < (3, 7): pytest.skip() addr = str(tmp_path / addr) with socket.socket(family, socket.SOCK_DGRAM) as sock: sock.bind(addr) dest = addr if family == socket.AF_UNIX else sock.getsockname()[:2] client = await asyncio_dgram.connect(dest) assert client.peername == sock.getsockname() await client.send(b"hi") got, client_addr = sock.recvfrom(4) assert got == b"hi" assert client.peername == sock.getsockname() if family == socket.AF_UNIX: assert isinstance(addr, str) # AF_UNIX doesn't automatically bind assert client_addr is None os.unlink(addr) else: assert client_addr == client.sockname sock.sendto(b"bye", client.sockname) got, server_addr = await client.recv() assert got == b"bye" assert server_addr == sock.getsockname() with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(client.recv(), 0.05) client.close() # Same as above but reversing the flow. Bind a regular socket, asyncio_dgram # connect, then check asyncio receive and send. with socket.socket(family, socket.SOCK_DGRAM) as sock: sock.bind(addr) dest = addr if family == socket.AF_UNIX else sock.getsockname()[:2] client = await asyncio_dgram.connect(dest) with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(client.recv(), 0.05) assert client.peername == sock.getsockname() if family != socket.AF_UNIX: # AF_UNIX doesn't automatically bind sock.sendto(b"hi", client.sockname) got, server_addr = await client.recv() assert got == b"hi" assert server_addr == sock.getsockname() await client.send(b"bye") got, client_addr = sock.recvfrom(4) assert got == b"bye" assert client_addr == client.sockname client.close() @pytest.mark.asyncio @pytest.mark.parametrize( "addr,family", [ (("127.0.0.1", 0), socket.AF_INET), (("::1", 0), socket.AF_INET6), ("socket", socket.AF_UNIX), ], ids=["INET", "INET6", "UNIX"], ) async def test_bind_sync( addr: typing.Union[_Address, str], family: socket.AddressFamily, tmp_path: pathlib.Path, ) -> None: # Bind an asyncio_dgram, regular socket connect, then check asyncio send and # receive. if family == socket.AF_UNIX: assert isinstance(addr, str) if sys.version_info < (3, 7): pytest.skip() addr = str(tmp_path / addr) with socket.socket(family, socket.SOCK_DGRAM) as sock: server = await asyncio_dgram.bind(addr) sock.connect(server.sockname) assert server.peername is None if family != socket.AF_UNIX: await server.send(b"hi", sock.getsockname()) got, server_addr = sock.recvfrom(4) assert got == b"hi" assert server_addr == server.sockname sock.sendto(b"bye", server.sockname) got, client_addr = await server.recv() assert got == b"bye" if family == socket.AF_UNIX: assert client_addr is None os.unlink(addr) else: assert client_addr == sock.getsockname() with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(server.recv(), 0.05) server.close() # Same as above but reversing the flow. Bind an asyncio_dgram, regular # socket connect, then check asyncio receive and send. with socket.socket(family, socket.SOCK_DGRAM) as sock: server = await asyncio_dgram.bind(addr) sock.connect(server.sockname) assert server.peername is None with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(server.recv(), 0.05) sock.sendto(b"hi", server.sockname) got, client_addr = await server.recv() assert got == b"hi" if family == socket.AF_UNIX: # AF_UNIX doesn't automatically bind assert client_addr is None else: assert client_addr == sock.getsockname() await server.send(b"bye", sock.getsockname()) got, server_addr = sock.recvfrom(4) assert got == b"bye" assert server_addr == server.sockname server.close() @pytest.mark.asyncio @pytest.mark.parametrize( "addr,family", [ (("127.0.0.1", 0), socket.AF_INET), (("::1", 0), socket.AF_INET6), ("socket", socket.AF_UNIX), ], ids=["INET", "INET6", "UNIX"], ) async def test_from_socket_streamtype( addr: typing.Union[_Address, str], family: socket.AddressFamily, tmp_path: pathlib.Path, ) -> None: if family == socket.AF_UNIX: assert isinstance(addr, str) if sys.version_info < (3, 7): pytest.skip() addr = str(tmp_path / addr) with socket.socket(family, socket.SOCK_DGRAM) as sock: sock.bind(addr) stream = await asyncio_dgram.from_socket(sock) assert stream.sockname is not None assert sock.getsockname() == stream.sockname assert stream.peername is None assert stream.socket.fileno() == sock.fileno() assert isinstance(stream, asyncio_dgram.aio.DatagramServer) with socket.socket(family, socket.SOCK_DGRAM) as sock: if family == socket.AF_UNIX: assert isinstance(addr, str) os.unlink(addr) sock.bind(addr) with socket.socket(family, socket.SOCK_DGRAM) as tsock: tsock.connect(sock.getsockname()) stream = await asyncio_dgram.from_socket(tsock) if family == socket.AF_UNIX: assert stream.sockname is None assert tsock.getsockname() == "" else: assert stream.sockname is not None assert stream.sockname == tsock.getsockname() assert isinstance(stream, asyncio_dgram.aio.DatagramClient) assert stream.peername == sock.getsockname() assert stream.socket.fileno() == tsock.fileno() # Make sure that the transport stored the peername with loop_exception_handler() as context: await stream.send(b"abc") assert context == {} @pytest.mark.asyncio async def test_from_socket_bad_socket(monkeypatch: pytest.MonkeyPatch) -> None: class MockSocket: family = socket.AF_PACKET with monkeypatch.context() as m: m.setattr(socket, "socket", lambda _, __: MockSocket()) sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) with pytest.raises(TypeError, match="socket family not one of"): await asyncio_dgram.from_socket(sock) with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: if sys.version_info < (3, 11): msg = "must be SocketKind.SOCK_DGRAM" else: msg = "socket type must be 2" with pytest.raises(TypeError, match=msg): await asyncio_dgram.from_socket(sock) @pytest.mark.asyncio @pytest.mark.parametrize( "addr,family", [(("127.0.0.1", 0), socket.AF_INET), (("::1", 0), socket.AF_INET6)], ids=["INET", "INET6"], ) async def test_no_server(addr: _Address, family: socket.AddressFamily) -> None: with socket.socket(family, socket.SOCK_DGRAM) as sock: sock.bind(addr) free_addr = sock.getsockname() client = await asyncio_dgram.connect(free_addr[:2]) await client.send(b"hi") for _ in range(20): try: await client.send(b"hi") except ConnectionRefusedError: break await asyncio.sleep(0.01) else: pytest.fail("ConnectionRefusedError not raised") assert client.peername == free_addr client.close() @pytest.mark.asyncio @pytest.mark.parametrize("addr", [("127.0.0.1", 0), ("::1", 0)], ids=["INET", "INET6"]) async def test_echo(addr: _Address) -> None: server = await asyncio_dgram.bind(addr) client = await asyncio_dgram.connect(server.sockname[:2]) await client.send(b"hi") data, client_addr = await server.recv() assert data == b"hi" assert client_addr == client.sockname await server.send(b"bye", client_addr) data, server_addr = await client.recv() assert data == b"bye" assert server_addr == server.sockname assert server.peername is None assert client.peername == server.sockname server.close() client.close() @pytest.mark.asyncio @pytest.mark.parametrize( "addr,family", [ (("127.0.0.1", 0), socket.AF_INET), (("::1", 0), socket.AF_INET6), (None, socket.AF_UNIX), ], ids=["INET", "INET6", "UNIX"], ) async def test_echo_bind( addr: typing.Optional[_Address], family: socket.AddressFamily, tmp_path: pathlib.Path, ) -> None: if family == socket.AF_UNIX: if sys.version_info < (3, 7): pytest.skip() server = await asyncio_dgram.bind(tmp_path / "socket1") client = await asyncio_dgram.bind(tmp_path / "socket2") else: assert addr is not None server = await asyncio_dgram.bind(addr) client = await asyncio_dgram.bind(addr) await client.send(b"hi", server.sockname) data, client_addr = await server.recv() assert data == b"hi" assert client_addr == client.sockname await server.send(b"bye", client_addr) data, server_addr = await client.recv() assert data == b"bye" assert server_addr == server.sockname assert server.peername is None assert client.peername is None server.close() client.close() @pytest.mark.asyncio @pytest.mark.parametrize("addr", [("127.0.0.1", 0), ("::1", 0)], ids=["INET", "INET6"]) async def test_unconnected_sender(addr: _Address) -> None: # Bind two endpoints and connect to one. Ensure that only the endpoint # that was connected to can send. ep1 = await asyncio_dgram.bind(addr) ep2 = await asyncio_dgram.bind(addr) connected = await asyncio_dgram.connect(ep1.sockname[:2]) await ep1.send(b"from-ep1", connected.sockname) await ep2.send(b"from-ep2", connected.sockname) data, server_addr = await connected.recv() assert data == b"from-ep1" assert server_addr == ep1.sockname with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(connected.recv(), 0.05) ep1.close() ep2.close() connected.close() @pytest.mark.asyncio async def test_protocol_pause_resume( monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, ) -> None: # This is a little involved, but necessary to make sure that the Protocol # is correctly noticing when writing as been paused and resumed. In # summary: # # - Mock the Protocol with one that sets the write buffer limits to 0 and # records when pause and resume writing are called. # # - Use a mock socket so that we can inject a BlockingIOError on send. # Ideally we'd mock method itself, but it's read-only the entire object # needs to be mocked. Due to this, we need to use a temporary file that we # can write to in order to kick the event loop to consider it ready for # writing. class TestableProtocol(asyncio_dgram.aio.Protocol): pause_writing_called = 0 resume_writing_called = 0 instance = None def __init__(self, *args, **kwds) -> None: # type: ignore TestableProtocol.instance = self super().__init__(*args, **kwds) def connection_made(self, transport: asyncio.BaseTransport) -> None: assert isinstance(transport, asyncio.WriteTransport) transport.set_write_buffer_limits(low=0, high=0) super().connection_made(transport) def pause_writing(self) -> None: self.pause_writing_called += 1 super().pause_writing() def resume_writing(self) -> None: self.resume_writing_called += 1 super().resume_writing() async def passthrough() -> None: """ Used to mock the wait method on the asyncio.Event tracking if the write buffer is past the high water mark or not. Given we're testing how that case is handled, we know it's safe locally to mock it. """ pass mock_socket = unittest.mock.create_autospec(socket.socket) mock_socket.family = socket.AF_INET mock_socket.type = socket.SOCK_DGRAM with monkeypatch.context() as ctx: ctx.setattr(asyncio_dgram.aio, "Protocol", TestableProtocol) client = await asyncio_dgram.from_socket(mock_socket) assert isinstance(client, asyncio_dgram.aio.DatagramClient) assert TestableProtocol.instance is not None mock_socket.send.side_effect = BlockingIOError mock_socket.fileno.return_value = os.open( tmp_path / "socket", os.O_RDONLY | os.O_CREAT ) with monkeypatch.context() as ctx2: ctx2.setattr(client._drained, "wait", passthrough) await client.send(b"foo") assert TestableProtocol.instance.pause_writing_called == 1 assert TestableProtocol.instance.resume_writing_called == 0 assert not TestableProtocol.instance._drained.is_set() mock_socket.send.side_effect = None fd = os.open(tmp_path / "socket", os.O_WRONLY) os.write(fd, b"\n") os.close(fd) with monkeypatch.context() as ctx2: ctx2.setattr(client._drained, "wait", passthrough) await client.send(b"foo") await asyncio.sleep(0.1) assert TestableProtocol.instance.pause_writing_called == 1 assert TestableProtocol.instance.resume_writing_called == 1 assert TestableProtocol.instance._drained.is_set() os.close(mock_socket.fileno.return_value) @pytest.mark.asyncio async def test_transport_closed() -> None: stream = await asyncio_dgram.bind(("127.0.0.1", 0)) # Two tasks, both receiving. This is a bit weird and we don't handle it at # this level on purpose. The test is here to make that clear. If having # multiple recv() calls racing against each other on a single event loop is # desired, one can wrap the DatagramStream with some sort of # dispatcher/adapter. recv = asyncio.create_task(stream.recv()) recv_hung = asyncio.create_task(stream.recv()) # Make sure both tasks get past the initial check for # transport.is_closing() await asyncio.sleep(0.1) stream.close() # Recv scheduled before transport closed with pytest.raises(asyncio_dgram.TransportClosed): await recv # This task isn't going to finish. with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(recv_hung, timeout=0.01) if sys.version_info >= (3, 7): assert recv_hung.cancelled() # No recv after transport closed with pytest.raises(asyncio_dgram.TransportClosed): await stream.recv() # No send after transport closed with pytest.raises(asyncio_dgram.TransportClosed): await stream.send(b"junk", ("127.0.0.1", 0)) @pytest.mark.asyncio async def test_bind_reuse_port() -> None: async def use_socket( addr: _Address, reuse_port: typing.Optional[bool] = None ) -> None: sock = await asyncio_dgram.bind(addr, reuse_port=reuse_port) # give gather time to move to the other uses after the bind await asyncio.sleep(0.1) sock.close() addr = ("127.0.0.1", 53001) clients_count = 10 with pytest.raises(OSError, match="Address already in use"): await asyncio.gather(*[use_socket(addr) for _ in range(clients_count)]) # We use another port in case the prev socket is still active (/ is still closing) addr_2 = ("127.0.0.1", 53002) await asyncio.gather( *[use_socket(addr_2, reuse_port=True) for _ in range(clients_count)] )