././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749831633.2651207 aiodns-3.5.0/0000755000175100001660000000000015023047721012400 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831627.0 aiodns-3.5.0/ChangeLog0000644000175100001660000001160615023047713014157 0ustar00runnerdocker3.5.0 ===== - Added explicit close method (#166) - Allows proper cleanup of resources on demand - Fixed return type signature for CNAME and SOA records (#162) - Corrected type annotations for better type checking - Improved Windows event loop documentation (#163) - Provided more accurate information on supported event loops on Windows - Added pre-commit configuration with ruff (#152) - Improved code quality and consistency - Reformatted code and normalized end-of-line characters (#155) - Updated dependencies - Bumped pycares from 4.8.0 to 4.9.0 (#168) - Bumped pytest-asyncio from 0.26.0 to 1.0.0 (#167) - Bumped pytest-cov from 6.1.1 to 6.2.1 (#164) - Bumped pytest from 8.3.5 to 8.4.0 (#160) - Bumped mypy from 1.15.0 to 1.16.0 (#158) - Bumped dependabot/fetch-metadata from 2.3.0 to 2.4.0 (#159) 3.4.0 ===== - Added fallback to `sock_state_cb` if `event_thread` creation fails (#151) - Improved reliability on systems with exhausted inotify watches - Implemented transparent fallback mechanism to ensure DNS resolution continues to work - Implemented strict typing (#138) - Added comprehensive type annotations - Improved mypy configuration - Added py.typed marker file - Updated dependencies - Bumped pycares from 4.7.0 to 4.8.0 (#149) - Added support for Python 3.13 (#153) - Updated CI configuration to test with Python 3.13 3.3.0 ===== - Used c-ares event thread when available (#145) - Significantly improved performance by using the c-ares event thread - Dropped Python 3.8 support (#129) - Updated CI infrastructure - Fixed release workflow for breaking changes in upload/download artifact (#148) - Added tests on push (#139) - Fixed test coverage (#140) - Updated CI configuration (#130) - Bumped actions/upload-artifact from 2 to 4 (#133) - Bumped actions/download-artifact from 4.1.7 to 4.2.1 (#131) - Bumped actions/download-artifact from 4.2.1 to 4.3.0 (#144) - Bumped actions/setup-python from 2 to 5 (#134) - Bumped actions/checkout from 2 to 4 (#132) - Bumped dependabot/fetch-metadata from 2.2.0 to 2.3.0 (#135) - Updated dependencies - Bumped pycares from 4.4.0 to 4.6.0 (#137) - Bumped pycares from 4.5.0 to 4.6.1 (#143) - Bumped pycares from 4.6.1 to 4.7.0 (#146) - Bumped pytest-cov from 5.0.0 to 6.1.0 (#136) - Bumped pytest-cov from 6.1.0 to 6.1.1 (#142) 3.2.0 ===== - Added support for getnameinfo - Added support for getaddrinfo (#118) - Added Winloop as a valid EventLoop (#116) - Fixed missing py.typed file for wheel - Updated test_query_ptr test to use address with PTR record 3.1.1 ===== - Fixed timeout implementation - Added tests to verify timeouts work correctly - Added PEP-561 with py.typed 3.1.0 ===== - Added exception raising if the loop is the wrong type on Windows - Fixed type annotations - Fixed return type for resolver nameservers - Updated supported Python versions - Added support for Python 3.10 - Added testing for PyPy 3.9 and 3.10 - Improved CI - Skipped some Python versions on macOS tests - Skipped test_gethostbyaddr on Windows - Used WindowsSelectorEventLoopPolicy to run Windows tests - Used latest CI runner versions 3.0.0 ===== - Release wheels and source to PyPI with GH actions - Try to make tests more resilient - Don't build universal wheels - Migrate CI to GH Actions - Fix TXT CHAOS test - Add support for CAA queries - Support Python >= 3.6 - Bump pycares dependency - Drop tasks.py - Allow specifying dnsclass for queries - Set URL to https - Add license args in setup.py - Converted Type Annotations to Py3 syntax Closes - Only run mypy on cpython versions - Also fix all type errors with latest mypy - pycares seems to have no typing / stubs so lets ignore it via `mypy.ini` - setup: typing exists since Python 3.5 - Fix type annotation of gethostbyname() - Updated README 2.0.0 ===== (changes since version 1.x) - Drop support for Python < 3.5 - Add support for ANY queries - Raise pycares dependency 2.0.0b2 ======= - Raise pycares dependency 2.0.0b1 ======= - Fix using typing on Python 3.7 2.0.0b0 ======= - Drop support for Python < 3.5 - Add support for ANY queries - Raise pycares dependency 1.2.0 ===== - Add support for Python 3.7 - Fix CNAME test - Add examples with `async` and `await` - Fix Python version check - Add gethostbyaddr 1.1.1 ===== - Use per-version requires for wheels 1.1.0 ===== - Add DNSResolver.gethostbyname() - Build universal wheels 1.0.1 ===== - Fix including tests and ChangeLog in source distributions 1.0.0 ===== - Use pycares >= 1.0.0 - Fix tests 0.3.2 ===== - setup: Fix decoding in non-UTF-8 environments 0.3.1 ===== - Adapt to Trollius package rename - Fixed stopping watching file descriptor 0.3.0 ===== - Add DNSResolver.cancel method - Handle case when the Future returned by query() is cancelled 0.2.0 ===== - Add support for Trollius - Don't make query() a coroutine, just return the future - Raise ValueError if specified query type is invalid 0.1.0 ===== - Initial release ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831627.0 aiodns-3.5.0/LICENSE0000644000175100001660000000205515023047713013410 0ustar00runnerdockerCopyright (C) 2014 by Saúl Ibarra Corretgé 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. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831627.0 aiodns-3.5.0/MANIFEST.in0000644000175100001660000000022415023047713014135 0ustar00runnerdockerinclude README.rst LICENSE ChangeLog include setup.py tests.py include aiodns/py.typed recursive-exclude * __pycache__ recursive-exclude * *.py[co] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749831633.2651207 aiodns-3.5.0/PKG-INFO0000644000175100001660000001317115023047721013500 0ustar00runnerdockerMetadata-Version: 2.4 Name: aiodns Version: 3.5.0 Summary: Simple DNS resolver for asyncio Home-page: https://github.com/saghul/aiodns Author: Saúl Ibarra Corretgé Author-email: s@saghul.net License: MIT Platform: POSIX Platform: Microsoft Windows Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: POSIX Classifier: Operating System :: Microsoft :: Windows Classifier: Programming Language :: Python 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 Requires-Python: >=3.9 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: pycares>=4.9.0 Dynamic: author Dynamic: author-email Dynamic: classifier Dynamic: description Dynamic: description-content-type Dynamic: home-page Dynamic: license Dynamic: license-file Dynamic: platform Dynamic: requires-dist Dynamic: requires-python Dynamic: summary =============================== Simple DNS resolver for asyncio =============================== .. image:: https://badge.fury.io/py/aiodns.png :target: https://pypi.org/project/aiodns/ .. image:: https://github.com/saghul/aiodns/workflows/CI/badge.svg :target: https://github.com/saghul/aiodns/actions aiodns provides a simple way for doing asynchronous DNS resolutions using `pycares `_. Example ======= .. code:: python import asyncio import aiodns loop = asyncio.get_event_loop() resolver = aiodns.DNSResolver(loop=loop) async def query(name, query_type): return await resolver.query(name, query_type) coro = query('google.com', 'A') result = loop.run_until_complete(coro) The following query types are supported: A, AAAA, ANY, CAA, CNAME, MX, NAPTR, NS, PTR, SOA, SRV, TXT. API === The API is pretty simple, the following functions are provided in the ``DNSResolver`` class: * ``query(host, type)``: Do a DNS resolution of the given type for the given hostname. It returns an instance of ``asyncio.Future``. The actual result of the DNS query is taken directly from pycares. As of version 1.0.0 of aiodns (and pycares, for that matter) results are always namedtuple-like objects with different attributes. Please check the `documentation `_ for the result fields. * ``gethostbyname(host, socket_family)``: Do a DNS resolution for the given hostname and the desired type of address family (i.e. ``socket.AF_INET``). While ``query()`` always performs a request to a DNS server, ``gethostbyname()`` first looks into ``/etc/hosts`` and thus can resolve local hostnames (such as ``localhost``). Please check `the documentation `_ for the result fields. The actual result of the call is a ``asyncio.Future``. * ``gethostbyaddr(name)``: Make a reverse lookup for an address. * ``cancel()``: Cancel all pending DNS queries. All futures will get ``DNSError`` exception set, with ``ARES_ECANCELLED`` errno. * ``close()``: Close the resolver. This releases all resources and cancels any pending queries. It must be called when the resolver is no longer needed (e.g., application shutdown). The resolver should only be closed from the event loop that created the resolver. Async Context Manager Support ============================= While not recommended for typical use cases, ``DNSResolver`` can be used as an async context manager for scenarios where automatic cleanup is desired: .. code:: python async with aiodns.DNSResolver() as resolver: result = await resolver.query('example.com', 'A') # resolver.close() is called automatically when exiting the context **Important**: This pattern is discouraged for most applications because ``DNSResolver`` instances are designed to be long-lived and reused for many queries. Creating and destroying resolvers frequently adds unnecessary overhead. Use the context manager pattern only when you specifically need automatic cleanup for short-lived resolver instances, such as in tests or one-off scripts. Note for Windows users ====================== This library requires the use of an ``asyncio.SelectorEventLoop`` or ``winloop`` on Windows **only** when using a custom build of ``pycares`` that links against a system- provided ``c-ares`` library **without** thread-safety support. This is because non-thread-safe builds of ``c-ares`` are incompatible with the default ``ProactorEventLoop`` on Windows. If you're using the official prebuilt ``pycares`` wheels on PyPI (version 4.7.0 or later), which include a thread-safe version of ``c-ares``, this limitation does **not** apply and can be safely ignored. The default event loop can be changed as follows (do this very early in your application): .. code:: python asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) This may have other implications for the rest of your codebase, so make sure to test thoroughly. Running the test suite ====================== To run the test suite: ``python tests.py`` Author ====== Saúl Ibarra Corretgé License ======= aiodns uses the MIT license, check LICENSE file. Contributing ============ If you'd like to contribute, fork the project, make a patch and send a pull request. Have a look at the surrounding code and please, make yours look alike :-) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831627.0 aiodns-3.5.0/README.rst0000644000175100001660000001070515023047713014073 0ustar00runnerdocker=============================== Simple DNS resolver for asyncio =============================== .. image:: https://badge.fury.io/py/aiodns.png :target: https://pypi.org/project/aiodns/ .. image:: https://github.com/saghul/aiodns/workflows/CI/badge.svg :target: https://github.com/saghul/aiodns/actions aiodns provides a simple way for doing asynchronous DNS resolutions using `pycares `_. Example ======= .. code:: python import asyncio import aiodns loop = asyncio.get_event_loop() resolver = aiodns.DNSResolver(loop=loop) async def query(name, query_type): return await resolver.query(name, query_type) coro = query('google.com', 'A') result = loop.run_until_complete(coro) The following query types are supported: A, AAAA, ANY, CAA, CNAME, MX, NAPTR, NS, PTR, SOA, SRV, TXT. API === The API is pretty simple, the following functions are provided in the ``DNSResolver`` class: * ``query(host, type)``: Do a DNS resolution of the given type for the given hostname. It returns an instance of ``asyncio.Future``. The actual result of the DNS query is taken directly from pycares. As of version 1.0.0 of aiodns (and pycares, for that matter) results are always namedtuple-like objects with different attributes. Please check the `documentation `_ for the result fields. * ``gethostbyname(host, socket_family)``: Do a DNS resolution for the given hostname and the desired type of address family (i.e. ``socket.AF_INET``). While ``query()`` always performs a request to a DNS server, ``gethostbyname()`` first looks into ``/etc/hosts`` and thus can resolve local hostnames (such as ``localhost``). Please check `the documentation `_ for the result fields. The actual result of the call is a ``asyncio.Future``. * ``gethostbyaddr(name)``: Make a reverse lookup for an address. * ``cancel()``: Cancel all pending DNS queries. All futures will get ``DNSError`` exception set, with ``ARES_ECANCELLED`` errno. * ``close()``: Close the resolver. This releases all resources and cancels any pending queries. It must be called when the resolver is no longer needed (e.g., application shutdown). The resolver should only be closed from the event loop that created the resolver. Async Context Manager Support ============================= While not recommended for typical use cases, ``DNSResolver`` can be used as an async context manager for scenarios where automatic cleanup is desired: .. code:: python async with aiodns.DNSResolver() as resolver: result = await resolver.query('example.com', 'A') # resolver.close() is called automatically when exiting the context **Important**: This pattern is discouraged for most applications because ``DNSResolver`` instances are designed to be long-lived and reused for many queries. Creating and destroying resolvers frequently adds unnecessary overhead. Use the context manager pattern only when you specifically need automatic cleanup for short-lived resolver instances, such as in tests or one-off scripts. Note for Windows users ====================== This library requires the use of an ``asyncio.SelectorEventLoop`` or ``winloop`` on Windows **only** when using a custom build of ``pycares`` that links against a system- provided ``c-ares`` library **without** thread-safety support. This is because non-thread-safe builds of ``c-ares`` are incompatible with the default ``ProactorEventLoop`` on Windows. If you're using the official prebuilt ``pycares`` wheels on PyPI (version 4.7.0 or later), which include a thread-safe version of ``c-ares``, this limitation does **not** apply and can be safely ignored. The default event loop can be changed as follows (do this very early in your application): .. code:: python asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) This may have other implications for the rest of your codebase, so make sure to test thoroughly. Running the test suite ====================== To run the test suite: ``python tests.py`` Author ====== Saúl Ibarra Corretgé License ======= aiodns uses the MIT license, check LICENSE file. Contributing ============ If you'd like to contribute, fork the project, make a patch and send a pull request. Have a look at the surrounding code and please, make yours look alike :-) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749831633.2641206 aiodns-3.5.0/aiodns/0000755000175100001660000000000015023047721013655 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831627.0 aiodns-3.5.0/aiodns/__init__.py0000644000175100001660000003065615023047713016001 0ustar00runnerdockerimport asyncio import functools import logging import socket import sys from collections.abc import Iterable, Sequence from types import TracebackType from typing import ( TYPE_CHECKING, Any, Callable, Literal, Optional, TypeVar, Union, overload, ) import pycares from . import error __version__ = '3.5.0' __all__ = ('DNSResolver', 'error') _T = TypeVar('_T') WINDOWS_SELECTOR_ERR_MSG = ( 'aiodns needs a SelectorEventLoop on Windows. See more: ' 'https://github.com/aio-libs/aiodns#note-for-windows-users' ) _LOGGER = logging.getLogger(__name__) READ = 1 WRITE = 2 query_type_map = { 'A': pycares.QUERY_TYPE_A, 'AAAA': pycares.QUERY_TYPE_AAAA, 'ANY': pycares.QUERY_TYPE_ANY, 'CAA': pycares.QUERY_TYPE_CAA, 'CNAME': pycares.QUERY_TYPE_CNAME, 'MX': pycares.QUERY_TYPE_MX, 'NAPTR': pycares.QUERY_TYPE_NAPTR, 'NS': pycares.QUERY_TYPE_NS, 'PTR': pycares.QUERY_TYPE_PTR, 'SOA': pycares.QUERY_TYPE_SOA, 'SRV': pycares.QUERY_TYPE_SRV, 'TXT': pycares.QUERY_TYPE_TXT, } query_class_map = { 'IN': pycares.QUERY_CLASS_IN, 'CHAOS': pycares.QUERY_CLASS_CHAOS, 'HS': pycares.QUERY_CLASS_HS, 'NONE': pycares.QUERY_CLASS_NONE, 'ANY': pycares.QUERY_CLASS_ANY, } class DNSResolver: def __init__( self, nameservers: Optional[Sequence[str]] = None, loop: Optional[asyncio.AbstractEventLoop] = None, **kwargs: Any, ) -> None: # TODO(PY311): Use Unpack for kwargs. self._closed = True self.loop = loop or asyncio.get_event_loop() if TYPE_CHECKING: assert self.loop is not None kwargs.pop('sock_state_cb', None) timeout = kwargs.pop('timeout', None) self._timeout = timeout self._event_thread, self._channel = self._make_channel(**kwargs) if nameservers: self.nameservers = nameservers self._read_fds: set[int] = set() self._write_fds: set[int] = set() self._timer: Optional[asyncio.TimerHandle] = None self._closed = False def _make_channel(self, **kwargs: Any) -> tuple[bool, pycares.Channel]: if ( hasattr(pycares, 'ares_threadsafety') and pycares.ares_threadsafety() ): # pycares is thread safe try: return True, pycares.Channel( event_thread=True, timeout=self._timeout, **kwargs ) except pycares.AresError as e: if sys.platform == 'linux': _LOGGER.warning( 'Failed to create DNS resolver channel with automatic ' 'monitoring of resolver configuration changes. This ' 'usually means the system ran out of inotify watches. ' 'Falling back to socket state callback. Consider ' 'increasing the system inotify watch limit: %s', e, ) else: _LOGGER.warning( 'Failed to create DNS resolver channel with automatic ' 'monitoring of resolver configuration changes. ' 'Falling back to socket state callback: %s', e, ) if sys.platform == 'win32' and not isinstance( self.loop, asyncio.SelectorEventLoop ): try: import winloop if not isinstance(self.loop, winloop.Loop): raise RuntimeError(WINDOWS_SELECTOR_ERR_MSG) except ModuleNotFoundError as ex: raise RuntimeError(WINDOWS_SELECTOR_ERR_MSG) from ex return False, pycares.Channel( sock_state_cb=self._sock_state_cb, timeout=self._timeout, **kwargs ) @property def nameservers(self) -> Sequence[str]: return self._channel.servers @nameservers.setter def nameservers(self, value: Iterable[Union[str, bytes]]) -> None: self._channel.servers = value @staticmethod def _callback( fut: asyncio.Future[_T], result: _T, errorno: Optional[int] ) -> None: if fut.cancelled(): return if errorno is not None: fut.set_exception( error.DNSError(errorno, pycares.errno.strerror(errorno)) ) else: fut.set_result(result) def _get_future_callback( self, ) -> tuple['asyncio.Future[_T]', Callable[[_T, int], None]]: """Return a future and a callback to set the result of the future.""" cb: Callable[[_T, int], None] future: asyncio.Future[_T] = self.loop.create_future() if self._event_thread: cb = functools.partial( # type: ignore[assignment] self.loop.call_soon_threadsafe, self._callback, # type: ignore[arg-type] future, ) else: cb = functools.partial(self._callback, future) return future, cb @overload def query( self, host: str, qtype: Literal['A'], qclass: Optional[str] = ... ) -> asyncio.Future[list[pycares.ares_query_a_result]]: ... @overload def query( self, host: str, qtype: Literal['AAAA'], qclass: Optional[str] = ... ) -> asyncio.Future[list[pycares.ares_query_aaaa_result]]: ... @overload def query( self, host: str, qtype: Literal['CAA'], qclass: Optional[str] = ... ) -> asyncio.Future[list[pycares.ares_query_caa_result]]: ... @overload def query( self, host: str, qtype: Literal['CNAME'], qclass: Optional[str] = ... ) -> asyncio.Future[pycares.ares_query_cname_result]: ... @overload def query( self, host: str, qtype: Literal['MX'], qclass: Optional[str] = ... ) -> asyncio.Future[list[pycares.ares_query_mx_result]]: ... @overload def query( self, host: str, qtype: Literal['NAPTR'], qclass: Optional[str] = ... ) -> asyncio.Future[list[pycares.ares_query_naptr_result]]: ... @overload def query( self, host: str, qtype: Literal['NS'], qclass: Optional[str] = ... ) -> asyncio.Future[list[pycares.ares_query_ns_result]]: ... @overload def query( self, host: str, qtype: Literal['PTR'], qclass: Optional[str] = ... ) -> asyncio.Future[list[pycares.ares_query_ptr_result]]: ... @overload def query( self, host: str, qtype: Literal['SOA'], qclass: Optional[str] = ... ) -> asyncio.Future[pycares.ares_query_soa_result]: ... @overload def query( self, host: str, qtype: Literal['SRV'], qclass: Optional[str] = ... ) -> asyncio.Future[list[pycares.ares_query_srv_result]]: ... @overload def query( self, host: str, qtype: Literal['TXT'], qclass: Optional[str] = ... ) -> asyncio.Future[list[pycares.ares_query_txt_result]]: ... def query( self, host: str, qtype: str, qclass: Optional[str] = None ) -> Union[asyncio.Future[list[Any]], asyncio.Future[Any]]: try: qtype = query_type_map[qtype] except KeyError as e: raise ValueError(f'invalid query type: {qtype}') from e if qclass is not None: try: qclass = query_class_map[qclass] except KeyError as e: raise ValueError(f'invalid query class: {qclass}') from e fut: Union[asyncio.Future[list[Any]], asyncio.Future[Any]] fut, cb = self._get_future_callback() self._channel.query(host, qtype, cb, query_class=qclass) return fut def gethostbyname( self, host: str, family: socket.AddressFamily ) -> asyncio.Future[pycares.ares_host_result]: fut: asyncio.Future[pycares.ares_host_result] fut, cb = self._get_future_callback() self._channel.gethostbyname(host, family, cb) return fut def getaddrinfo( self, host: str, family: socket.AddressFamily = socket.AF_UNSPEC, port: Optional[int] = None, proto: int = 0, type: int = 0, flags: int = 0, ) -> asyncio.Future[pycares.ares_addrinfo_result]: fut: asyncio.Future[pycares.ares_addrinfo_result] fut, cb = self._get_future_callback() self._channel.getaddrinfo( host, port, cb, family=family, type=type, proto=proto, flags=flags ) return fut def getnameinfo( self, sockaddr: Union[tuple[str, int], tuple[str, int, int, int]], flags: int = 0, ) -> asyncio.Future[pycares.ares_nameinfo_result]: fut: asyncio.Future[pycares.ares_nameinfo_result] fut, cb = self._get_future_callback() self._channel.getnameinfo(sockaddr, flags, cb) return fut def gethostbyaddr( self, name: str ) -> asyncio.Future[pycares.ares_host_result]: fut: asyncio.Future[pycares.ares_host_result] fut, cb = self._get_future_callback() self._channel.gethostbyaddr(name, cb) return fut def cancel(self) -> None: self._channel.cancel() def _sock_state_cb(self, fd: int, readable: bool, writable: bool) -> None: if readable or writable: if readable: self.loop.add_reader(fd, self._handle_event, fd, READ) self._read_fds.add(fd) if writable: self.loop.add_writer(fd, self._handle_event, fd, WRITE) self._write_fds.add(fd) if self._timer is None: self._start_timer() else: # socket is now closed if fd in self._read_fds: self._read_fds.discard(fd) self.loop.remove_reader(fd) if fd in self._write_fds: self._write_fds.discard(fd) self.loop.remove_writer(fd) if ( not self._read_fds and not self._write_fds and self._timer is not None ): self._timer.cancel() self._timer = None def _handle_event(self, fd: int, event: int) -> None: read_fd = pycares.ARES_SOCKET_BAD write_fd = pycares.ARES_SOCKET_BAD if event == READ: read_fd = fd elif event == WRITE: write_fd = fd self._channel.process_fd(read_fd, write_fd) def _timer_cb(self) -> None: if self._read_fds or self._write_fds: self._channel.process_fd( pycares.ARES_SOCKET_BAD, pycares.ARES_SOCKET_BAD ) self._start_timer() else: self._timer = None def _start_timer(self) -> None: timeout = self._timeout if timeout is None or timeout < 0 or timeout > 1: timeout = 1 elif timeout == 0: timeout = 0.1 self._timer = self.loop.call_later(timeout, self._timer_cb) def _cleanup(self) -> None: """Cleanup timers and file descriptors when closing resolver.""" if self._closed: return # Mark as closed first to prevent double cleanup self._closed = True # Cancel timer if running if self._timer is not None: self._timer.cancel() self._timer = None # Remove all file descriptors for fd in list(self._read_fds): self.loop.remove_reader(fd) for fd in list(self._write_fds): self.loop.remove_writer(fd) self._read_fds.clear() self._write_fds.clear() self._channel.close() async def close(self) -> None: """ Cleanly close the DNS resolver. This should be called to ensure all resources are properly released. After calling close(), the resolver should not be used again. """ self._cleanup() async def __aenter__(self) -> 'DNSResolver': """Enter the async context manager.""" return self async def __aexit__( self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: """Exit the async context manager.""" await self.close() def __del__(self) -> None: """Handle cleanup when the resolver is garbage collected.""" # Check if we have a channel to clean up # This can happen if an exception occurs during __init__ before # _channel is created (e.g., RuntimeError on Windows # without proper loop) if hasattr(self, '_channel'): self._cleanup() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831627.0 aiodns-3.5.0/aiodns/error.py0000644000175100001660000000231115023047713015356 0ustar00runnerdockerfrom pycares.errno import ( ARES_EADDRGETNETWORKPARAMS, ARES_EBADFAMILY, ARES_EBADFLAGS, ARES_EBADHINTS, ARES_EBADNAME, ARES_EBADQUERY, ARES_EBADRESP, ARES_EBADSTR, ARES_ECANCELLED, ARES_ECONNREFUSED, ARES_EDESTRUCTION, ARES_EFILE, ARES_EFORMERR, ARES_ELOADIPHLPAPI, ARES_ENODATA, ARES_ENOMEM, ARES_ENONAME, ARES_ENOTFOUND, ARES_ENOTIMP, ARES_ENOTINITIALIZED, ARES_EOF, ARES_EREFUSED, ARES_ESERVFAIL, ARES_ESERVICE, ARES_ETIMEOUT, ARES_SUCCESS, ) __all__ = [ 'ARES_EADDRGETNETWORKPARAMS', 'ARES_EBADFAMILY', 'ARES_EBADFLAGS', 'ARES_EBADHINTS', 'ARES_EBADNAME', 'ARES_EBADQUERY', 'ARES_EBADRESP', 'ARES_EBADSTR', 'ARES_ECANCELLED', 'ARES_ECONNREFUSED', 'ARES_EDESTRUCTION', 'ARES_EFILE', 'ARES_EFORMERR', 'ARES_ELOADIPHLPAPI', 'ARES_ENODATA', 'ARES_ENOMEM', 'ARES_ENONAME', 'ARES_ENOTFOUND', 'ARES_ENOTIMP', 'ARES_ENOTINITIALIZED', 'ARES_EOF', 'ARES_EREFUSED', 'ARES_ESERVFAIL', 'ARES_ESERVICE', 'ARES_ETIMEOUT', 'ARES_SUCCESS', 'DNSError', ] class DNSError(Exception): """Base class for all DNS errors.""" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831627.0 aiodns-3.5.0/aiodns/py.typed0000644000175100001660000000000015023047713015343 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749831633.2651207 aiodns-3.5.0/aiodns.egg-info/0000755000175100001660000000000015023047721015347 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831633.0 aiodns-3.5.0/aiodns.egg-info/PKG-INFO0000644000175100001660000001317115023047721016447 0ustar00runnerdockerMetadata-Version: 2.4 Name: aiodns Version: 3.5.0 Summary: Simple DNS resolver for asyncio Home-page: https://github.com/saghul/aiodns Author: Saúl Ibarra Corretgé Author-email: s@saghul.net License: MIT Platform: POSIX Platform: Microsoft Windows Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: POSIX Classifier: Operating System :: Microsoft :: Windows Classifier: Programming Language :: Python 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 Requires-Python: >=3.9 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: pycares>=4.9.0 Dynamic: author Dynamic: author-email Dynamic: classifier Dynamic: description Dynamic: description-content-type Dynamic: home-page Dynamic: license Dynamic: license-file Dynamic: platform Dynamic: requires-dist Dynamic: requires-python Dynamic: summary =============================== Simple DNS resolver for asyncio =============================== .. image:: https://badge.fury.io/py/aiodns.png :target: https://pypi.org/project/aiodns/ .. image:: https://github.com/saghul/aiodns/workflows/CI/badge.svg :target: https://github.com/saghul/aiodns/actions aiodns provides a simple way for doing asynchronous DNS resolutions using `pycares `_. Example ======= .. code:: python import asyncio import aiodns loop = asyncio.get_event_loop() resolver = aiodns.DNSResolver(loop=loop) async def query(name, query_type): return await resolver.query(name, query_type) coro = query('google.com', 'A') result = loop.run_until_complete(coro) The following query types are supported: A, AAAA, ANY, CAA, CNAME, MX, NAPTR, NS, PTR, SOA, SRV, TXT. API === The API is pretty simple, the following functions are provided in the ``DNSResolver`` class: * ``query(host, type)``: Do a DNS resolution of the given type for the given hostname. It returns an instance of ``asyncio.Future``. The actual result of the DNS query is taken directly from pycares. As of version 1.0.0 of aiodns (and pycares, for that matter) results are always namedtuple-like objects with different attributes. Please check the `documentation `_ for the result fields. * ``gethostbyname(host, socket_family)``: Do a DNS resolution for the given hostname and the desired type of address family (i.e. ``socket.AF_INET``). While ``query()`` always performs a request to a DNS server, ``gethostbyname()`` first looks into ``/etc/hosts`` and thus can resolve local hostnames (such as ``localhost``). Please check `the documentation `_ for the result fields. The actual result of the call is a ``asyncio.Future``. * ``gethostbyaddr(name)``: Make a reverse lookup for an address. * ``cancel()``: Cancel all pending DNS queries. All futures will get ``DNSError`` exception set, with ``ARES_ECANCELLED`` errno. * ``close()``: Close the resolver. This releases all resources and cancels any pending queries. It must be called when the resolver is no longer needed (e.g., application shutdown). The resolver should only be closed from the event loop that created the resolver. Async Context Manager Support ============================= While not recommended for typical use cases, ``DNSResolver`` can be used as an async context manager for scenarios where automatic cleanup is desired: .. code:: python async with aiodns.DNSResolver() as resolver: result = await resolver.query('example.com', 'A') # resolver.close() is called automatically when exiting the context **Important**: This pattern is discouraged for most applications because ``DNSResolver`` instances are designed to be long-lived and reused for many queries. Creating and destroying resolvers frequently adds unnecessary overhead. Use the context manager pattern only when you specifically need automatic cleanup for short-lived resolver instances, such as in tests or one-off scripts. Note for Windows users ====================== This library requires the use of an ``asyncio.SelectorEventLoop`` or ``winloop`` on Windows **only** when using a custom build of ``pycares`` that links against a system- provided ``c-ares`` library **without** thread-safety support. This is because non-thread-safe builds of ``c-ares`` are incompatible with the default ``ProactorEventLoop`` on Windows. If you're using the official prebuilt ``pycares`` wheels on PyPI (version 4.7.0 or later), which include a thread-safe version of ``c-ares``, this limitation does **not** apply and can be safely ignored. The default event loop can be changed as follows (do this very early in your application): .. code:: python asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) This may have other implications for the rest of your codebase, so make sure to test thoroughly. Running the test suite ====================== To run the test suite: ``python tests.py`` Author ====== Saúl Ibarra Corretgé License ======= aiodns uses the MIT license, check LICENSE file. Contributing ============ If you'd like to contribute, fork the project, make a patch and send a pull request. Have a look at the surrounding code and please, make yours look alike :-) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831633.0 aiodns-3.5.0/aiodns.egg-info/SOURCES.txt0000644000175100001660000000043015023047721017230 0ustar00runnerdockerChangeLog LICENSE MANIFEST.in README.rst setup.cfg setup.py aiodns/__init__.py aiodns/error.py aiodns/py.typed aiodns.egg-info/PKG-INFO aiodns.egg-info/SOURCES.txt aiodns.egg-info/dependency_links.txt aiodns.egg-info/requires.txt aiodns.egg-info/top_level.txt tests/test_aiodns.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831633.0 aiodns-3.5.0/aiodns.egg-info/dependency_links.txt0000644000175100001660000000000115023047721021415 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831633.0 aiodns-3.5.0/aiodns.egg-info/requires.txt0000644000175100001660000000001715023047721017745 0ustar00runnerdockerpycares>=4.9.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831633.0 aiodns-3.5.0/aiodns.egg-info/top_level.txt0000644000175100001660000000000715023047721020076 0ustar00runnerdockeraiodns ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749831633.2661207 aiodns-3.5.0/setup.cfg0000644000175100001660000000006515023047721014222 0ustar00runnerdocker[bdist_wheel] [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831627.0 aiodns-3.5.0/setup.py0000644000175100001660000000255115023047713014116 0ustar00runnerdockerimport codecs import re from setuptools import setup def get_version(): return re.search( r"""__version__\s+=\s+(?P['"])(?P.+?)(?P=quote)""", open('aiodns/__init__.py').read(), ).group('version') setup( name='aiodns', version=get_version(), author='Saúl Ibarra Corretgé', author_email='s@saghul.net', url='https://github.com/saghul/aiodns', description='Simple DNS resolver for asyncio', license='MIT', long_description=codecs.open('README.rst', encoding='utf-8').read(), long_description_content_type='text/x-rst', install_requires=['pycares>=4.9.0'], packages=['aiodns'], package_data={'aiodns': ['py.typed']}, platforms=['POSIX', 'Microsoft Windows'], python_requires='>=3.9', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: POSIX', 'Operating System :: Microsoft :: Windows', 'Programming Language :: Python', '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', ], ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1749831633.2651207 aiodns-3.5.0/tests/0000755000175100001660000000000015023047721013542 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1749831627.0 aiodns-3.5.0/tests/test_aiodns.py0000755000175100001660000006272515023047713016450 0ustar00runnerdocker#!/usr/bin/env python import asyncio import gc import ipaddress import logging import socket import sys import time import unittest import unittest.mock from typing import Any import pycares import pytest import aiodns try: if sys.platform == 'win32': import winloop as uvloop skip_uvloop = False else: import uvloop skip_uvloop = False except ModuleNotFoundError: skip_uvloop = True class DNSTest(unittest.TestCase): def setUp(self) -> None: if sys.platform == 'win32': asyncio.set_event_loop_policy( asyncio.WindowsSelectorEventLoopPolicy() ) self.loop = asyncio.new_event_loop() self.addCleanup(self.loop.close) self.resolver = aiodns.DNSResolver(loop=self.loop, timeout=5.0) self.resolver.nameservers = ['8.8.8.8'] def tearDown(self) -> None: self.loop.run_until_complete(self.resolver.close()) self.resolver = None # type: ignore[assignment] def test_query_a(self) -> None: f = self.resolver.query('google.com', 'A') self.loop.run_until_complete(f) def test_query_async_await(self) -> None: async def f() -> list[pycares.ares_query_a_result]: return await self.resolver.query('google.com', 'A') result = self.loop.run_until_complete(f()) self.assertTrue(result) def test_query_a_bad(self) -> None: f = self.resolver.query('hgf8g2od29hdohid.com', 'A') try: self.loop.run_until_complete(f) except aiodns.error.DNSError as e: self.assertEqual(e.args[0], aiodns.error.ARES_ENOTFOUND) def test_query_aaaa(self) -> None: f = self.resolver.query('ipv6.google.com', 'AAAA') result = self.loop.run_until_complete(f) self.assertTrue(result) def test_query_cname(self) -> None: f = self.resolver.query('www.amazon.com', 'CNAME') result = self.loop.run_until_complete(f) self.assertTrue(result) def test_query_mx(self) -> None: f = self.resolver.query('google.com', 'MX') result = self.loop.run_until_complete(f) self.assertTrue(result) def test_query_ns(self) -> None: f = self.resolver.query('google.com', 'NS') result = self.loop.run_until_complete(f) self.assertTrue(result) @unittest.skipIf( sys.platform == 'darwin', 'skipped on Darwin as it is flakey on CI' ) def test_query_txt(self) -> None: f = self.resolver.query('google.com', 'TXT') result = self.loop.run_until_complete(f) self.assertTrue(result) def test_query_soa(self) -> None: f = self.resolver.query('google.com', 'SOA') result = self.loop.run_until_complete(f) self.assertTrue(result) def test_query_srv(self) -> None: f = self.resolver.query('_xmpp-server._tcp.jabber.org', 'SRV') result = self.loop.run_until_complete(f) self.assertTrue(result) def test_query_naptr(self) -> None: f = self.resolver.query('sip2sip.info', 'NAPTR') result = self.loop.run_until_complete(f) self.assertTrue(result) def test_query_ptr(self) -> None: ip = '172.253.122.26' f = self.resolver.query( ipaddress.ip_address(ip).reverse_pointer, 'PTR' ) result = self.loop.run_until_complete(f) self.assertTrue(result) def test_query_bad_type(self) -> None: self.assertRaises(ValueError, self.resolver.query, 'google.com', 'XXX') def test_query_bad_class(self) -> None: self.assertRaises( ValueError, self.resolver.query, 'google.com', 'A', 'INVALIDCLASS' ) def test_query_cancel(self) -> None: f = self.resolver.query('google.com', 'A') self.resolver.cancel() try: self.loop.run_until_complete(f) except aiodns.error.DNSError as e: self.assertEqual(e.args[0], aiodns.error.ARES_ECANCELLED) def test_future_cancel(self) -> None: f = self.resolver.query('google.com', 'A') f.cancel() async def coro() -> None: await asyncio.sleep(0.1) await f try: self.loop.run_until_complete(coro()) except asyncio.CancelledError as e: self.assertTrue(e) def test_query_twice(self) -> None: async def coro(self: DNSTest) -> None: for _ in range(2): result = await self.resolver.query('gmail.com', 'MX') self.assertTrue(result) self.loop.run_until_complete(coro(self)) def test_gethostbyname(self) -> None: f = self.resolver.gethostbyname('google.com', socket.AF_INET) result = self.loop.run_until_complete(f) self.assertTrue(result) def test_getaddrinfo_address_family_0(self) -> None: f = self.resolver.getaddrinfo('google.com') result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertTrue(len(result.nodes) > 1) def test_getaddrinfo_address_family_af_inet(self) -> None: f = self.resolver.getaddrinfo('google.com', socket.AF_INET) result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertTrue( all(node.family == socket.AF_INET for node in result.nodes) ) def test_getaddrinfo_address_family_af_inet6(self) -> None: f = self.resolver.getaddrinfo('google.com', socket.AF_INET6) result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertTrue( all(node.family == socket.AF_INET6 for node in result.nodes) ) def test_getnameinfo_ipv4(self) -> None: f = self.resolver.getnameinfo(('127.0.0.1', 0)) result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertTrue(result.node) def test_getnameinfo_ipv6(self) -> None: f = self.resolver.getnameinfo(('::1', 0, 0, 0)) result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertTrue(result.node) @unittest.skipIf(sys.platform == 'win32', 'skipped on Windows') def test_gethostbyaddr(self) -> None: f = self.resolver.gethostbyaddr('127.0.0.1') result = self.loop.run_until_complete(f) self.assertTrue(result) def test_gethostbyname_ipv6(self) -> None: f = self.resolver.gethostbyname('ipv6.google.com', socket.AF_INET6) result = self.loop.run_until_complete(f) self.assertTrue(result) def test_gethostbyname_bad_family(self) -> None: f = self.resolver.gethostbyname('ipv6.google.com', -1) # type: ignore[arg-type] with self.assertRaises(aiodns.error.DNSError): self.loop.run_until_complete(f) # def test_query_bad_chars(self) -> None: # f = self.resolver.query('xn--cardeosapeluqueros-r0b.com', 'MX') # result = self.loop.run_until_complete(f) # self.assertTrue(result) class TestQueryTxtChaos(DNSTest): """Test DNS queries with CHAOS class.""" def setUp(self) -> None: if sys.platform == 'win32': asyncio.set_event_loop_policy( asyncio.WindowsSelectorEventLoopPolicy() ) self.loop = asyncio.new_event_loop() self.addCleanup(self.loop.close) self.resolver = aiodns.DNSResolver(loop=self.loop) self.resolver.nameservers = ['1.1.1.1'] def test_query_txt_chaos(self) -> None: f = self.resolver.query('id.server', 'TXT', 'CHAOS') result = self.loop.run_until_complete(f) self.assertTrue(result) class TestQueryTimeout(unittest.TestCase): """Test DNS queries with timeout configuration.""" def setUp(self) -> None: if sys.platform == 'win32': asyncio.set_event_loop_policy( asyncio.WindowsSelectorEventLoopPolicy() ) self.loop = asyncio.new_event_loop() self.addCleanup(self.loop.close) self.resolver = aiodns.DNSResolver( timeout=0.1, tries=1, loop=self.loop ) self.resolver.nameservers = ['1.2.3.4'] def tearDown(self) -> None: self.loop.run_until_complete(self.resolver.close()) self.resolver = None # type: ignore[assignment] def test_query_timeout(self) -> None: f = self.resolver.query('google.com', 'A') started = time.monotonic() try: self.loop.run_until_complete(f) except aiodns.error.DNSError as e: self.assertEqual(e.args[0], aiodns.error.ARES_ETIMEOUT) # Ensure timeout really cuts time deadline. # Limit duration to one second self.assertLess(time.monotonic() - started, 1) @unittest.skipIf(skip_uvloop, "We don't have a uvloop or winloop module") class TestUV_DNS(DNSTest): def setUp(self) -> None: asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) self.loop = asyncio.new_event_loop() self.addCleanup(self.loop.close) self.resolver = aiodns.DNSResolver(loop=self.loop, timeout=5.0) self.resolver.nameservers = ['8.8.8.8'] class TestNoEventThreadDNS(DNSTest): """Test DNSResolver with no event thread.""" def setUp(self) -> None: with unittest.mock.patch( 'aiodns.pycares.ares_threadsafety', return_value=False ): super().setUp() @unittest.skipIf(skip_uvloop, "We don't have a uvloop or winloop module") class TestUV_QueryTxtChaos(TestQueryTxtChaos): """Test DNS queries with CHAOS class using uvloop.""" def setUp(self) -> None: asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) self.loop = asyncio.new_event_loop() self.addCleanup(self.loop.close) self.resolver = aiodns.DNSResolver(loop=self.loop) self.resolver.nameservers = ['1.1.1.1'] @unittest.skipIf(skip_uvloop, "We don't have a uvloop or winloop module") class TestUV_QueryTimeout(TestQueryTimeout): """Test DNS queries with timeout configuration using uvloop.""" def setUp(self) -> None: asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) self.loop = asyncio.new_event_loop() self.addCleanup(self.loop.close) self.resolver = aiodns.DNSResolver( timeout=0.1, tries=1, loop=self.loop ) self.resolver.nameservers = ['1.2.3.4'] class TestNoEventThreadQueryTxtChaos(TestQueryTxtChaos): """Test DNS queries with CHAOS class without event thread.""" def setUp(self) -> None: with unittest.mock.patch( 'aiodns.pycares.ares_threadsafety', return_value=False ): super().setUp() class TestNoEventThreadQueryTimeout(TestQueryTimeout): """Test DNS queries with timeout configuration without event thread.""" def setUp(self) -> None: with unittest.mock.patch( 'aiodns.pycares.ares_threadsafety', return_value=False ): super().setUp() @unittest.skipIf(sys.platform != 'win32', 'Only run on Windows') def test_win32_no_selector_event_loop() -> None: """Test DNSResolver with Windows without SelectorEventLoop.""" # Create a non-SelectorEventLoop to trigger the error mock_loop = unittest.mock.MagicMock(spec=asyncio.AbstractEventLoop) mock_loop.__class__ = ( asyncio.AbstractEventLoop # type: ignore[assignment] ) with ( pytest.raises( RuntimeError, match='aiodns needs a SelectorEventLoop on Windows' ), unittest.mock.patch( 'aiodns.pycares.ares_threadsafety', return_value=False ), unittest.mock.patch('sys.platform', 'win32'), ): aiodns.DNSResolver(loop=mock_loop, timeout=5.0) @pytest.mark.parametrize( ('platform', 'expected_msg_parts', 'unexpected_msg_parts'), [ ( 'linux', [ 'automatic monitoring of', 'resolver configuration changes', 'system ran out of inotify watches', 'Falling back to socket state callback', 'Consider increasing the system inotify watch limit', ], [], ), ( 'darwin', [ 'automatic monitoring', 'resolver configuration changes', 'Falling back to socket state callback', ], [ 'system ran out of inotify watches', 'Consider increasing the system inotify watch limit', ], ), ( 'win32', [ 'automatic monitoring', 'resolver configuration changes', 'Falling back to socket state callback', ], [ 'system ran out of inotify watches', 'Consider increasing the system inotify watch limit', ], ), ], ) async def test_make_channel_ares_error( platform: str, expected_msg_parts: list[str], unexpected_msg_parts: list[str], caplog: pytest.LogCaptureFixture, ) -> None: """Test exception handling in _make_channel on different platforms.""" # Set log level to capture warnings caplog.set_level(logging.WARNING) # Create a mock loop that is a SelectorEventLoop to # avoid Windows-specific errors mock_loop = unittest.mock.MagicMock(spec=asyncio.SelectorEventLoop) mock_channel = unittest.mock.MagicMock() with ( unittest.mock.patch('sys.platform', platform), # Configure first Channel call to raise AresError, # second call to return our mock unittest.mock.patch( 'aiodns.pycares.Channel', side_effect=[ pycares.AresError('Mock error'), mock_channel, ], ), unittest.mock.patch( 'aiodns.pycares.ares_threadsafety', return_value=True ), # Also patch asyncio.get_event_loop to return our mock loop unittest.mock.patch('asyncio.get_event_loop', return_value=mock_loop), ): # Create resolver which will call _make_channel resolver = aiodns.DNSResolver(loop=mock_loop) # Check that event_thread is False due to exception assert resolver._event_thread is False # Check expected message parts in the captured log for part in expected_msg_parts: assert part in caplog.text # Check unexpected message parts aren't in the captured log for part in unexpected_msg_parts: assert part not in caplog.text # Manually set _closed to True to prevent cleanup logic from # running during the test. resolver._closed = True def test_win32_import_winloop_error() -> None: """Test winloop import error on Windows. Test handling of ModuleNotFoundError when importing winloop on Windows. """ # Create a mock event loop that is not a SelectorEventLoop mock_loop = unittest.mock.MagicMock(spec=asyncio.AbstractEventLoop) # Setup patching for this test original_import = __import__ def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: if name == 'winloop': raise ModuleNotFoundError("No module named 'winloop'") return original_import(name, *args, **kwargs) # Patch the Channel class to avoid creating real network resources mock_channel = unittest.mock.MagicMock() with ( unittest.mock.patch('sys.platform', 'win32'), unittest.mock.patch( 'aiodns.pycares.ares_threadsafety', return_value=False ), unittest.mock.patch('builtins.__import__', side_effect=mock_import), unittest.mock.patch( 'importlib.import_module', side_effect=mock_import ), # Also patch Channel creation to avoid socket resource leak unittest.mock.patch( 'aiodns.pycares.Channel', return_value=mock_channel ), pytest.raises(RuntimeError, match=aiodns.WINDOWS_SELECTOR_ERR_MSG), ): aiodns.DNSResolver(loop=mock_loop) def test_win32_winloop_not_loop_instance() -> None: """Test non-winloop.Loop instance on Windows. Test handling of a loop that is not a winloop.Loop instance on Windows. """ # Create a mock event loop that is not a SelectorEventLoop mock_loop = unittest.mock.MagicMock(spec=asyncio.AbstractEventLoop) original_import = __import__ # Create a mock winloop module with a Loop class that's an actual type class MockLoop: pass mock_winloop_module = unittest.mock.MagicMock() mock_winloop_module.Loop = MockLoop def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: if name == 'winloop': return mock_winloop_module return original_import(name, *args, **kwargs) # Patch the Channel class to avoid creating real network resources mock_channel = unittest.mock.MagicMock() with ( unittest.mock.patch('sys.platform', 'win32'), unittest.mock.patch( 'aiodns.pycares.ares_threadsafety', return_value=False ), unittest.mock.patch('builtins.__import__', side_effect=mock_import), unittest.mock.patch( 'importlib.import_module', side_effect=mock_import ), # Also patch Channel creation to avoid socket resource leak unittest.mock.patch( 'aiodns.pycares.Channel', return_value=mock_channel ), pytest.raises(RuntimeError, match=aiodns.WINDOWS_SELECTOR_ERR_MSG), ): aiodns.DNSResolver(loop=mock_loop) def test_win32_winloop_loop_instance() -> None: """Test winloop.Loop instance on Windows. Test handling of a loop that IS a winloop.Loop instance on Windows. """ # Create a mock winloop module with a Loop class class MockLoop: pass # Create a mock event loop that IS a winloop.Loop instance mock_loop = unittest.mock.MagicMock(spec=asyncio.AbstractEventLoop) # Make isinstance check pass mock_loop.__class__ = MockLoop # type: ignore[assignment] mock_winloop_module = unittest.mock.MagicMock() mock_winloop_module.Loop = MockLoop original_import = __import__ def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: if name == 'winloop': return mock_winloop_module return original_import(name, *args, **kwargs) # Mock channel creation to avoid actual DNS resolution mock_channel = unittest.mock.MagicMock() with ( unittest.mock.patch('sys.platform', 'win32'), unittest.mock.patch( 'aiodns.pycares.ares_threadsafety', return_value=False ), unittest.mock.patch('builtins.__import__', side_effect=mock_import), unittest.mock.patch( 'importlib.import_module', side_effect=mock_import ), unittest.mock.patch( 'aiodns.pycares.Channel', return_value=mock_channel ), ): # This should not raise an exception since loop # is a winloop.Loop instance aiodns.DNSResolver(loop=mock_loop) @pytest.mark.asyncio async def test_close_resolver() -> None: """Test that DNSResolver.close() properly shuts down the resolver.""" resolver = aiodns.DNSResolver() # Create a query to ensure resolver is active query_future = resolver.query('google.com', 'A') # Close the resolver await resolver.close() # Verify resolver is marked as closed assert resolver._closed # Verify timers are cancelled assert resolver._timer is None # Verify file descriptors are cleared assert len(resolver._read_fds) == 0 assert len(resolver._write_fds) == 0 # The query should fail with cancellation with pytest.raises(aiodns.error.DNSError) as exc_info: await query_future assert exc_info.value.args[0] == aiodns.error.ARES_ECANCELLED @pytest.mark.asyncio async def test_close_resolver_multiple_times() -> None: """Test that close() is idempotent and safe to call multiple times.""" resolver = aiodns.DNSResolver() # Close multiple times await resolver.close() await resolver.close() await resolver.close() # All closes should succeed without error assert resolver._closed def test_del_with_no_running_loop() -> None: """Test __del__ when there's no running event loop.""" loop = asyncio.new_event_loop() resolver = aiodns.DNSResolver(loop=loop) # Track if cleanup was called via channel.close cleanup_called = False original_close = resolver._channel.close def mock_close() -> None: nonlocal cleanup_called cleanup_called = True original_close() resolver._channel.close = mock_close # type: ignore[method-assign] loop.close() # Delete the resolver without closing it del resolver gc.collect() # Should have called cleanup assert cleanup_called def test_del_with_stopped_event_loop() -> None: """Test __del__ when event loop is not running.""" # Create a new event loop loop = asyncio.new_event_loop() # Create resolver with this loop resolver = aiodns.DNSResolver(loop=loop) # Track if cleanup was called via channel.close cleanup_called = False original_close = resolver._channel.close def mock_close() -> None: nonlocal cleanup_called cleanup_called = True original_close() resolver._channel.close = mock_close # type: ignore[method-assign] # Close the loop so it's not running loop.close() # Delete resolver when its loop is not running del resolver gc.collect() # Should have called cleanup assert cleanup_called @pytest.mark.asyncio async def test_del_with_running_event_loop() -> None: """Test __del__ when event loop is running performs cleanup.""" resolver = aiodns.DNSResolver() # Mark that cleanup was called by checking if channel.close was called original_close = resolver._channel.close cleanup_called = False def mock_close() -> None: nonlocal cleanup_called cleanup_called = True original_close() resolver._channel.close = mock_close # type: ignore[method-assign] # Delete resolver while loop is running del resolver gc.collect() # Verify cleanup was called assert cleanup_called @pytest.mark.asyncio async def test_cleanup_method() -> None: """Test that _cleanup() properly cleans up resources.""" resolver = aiodns.DNSResolver() # Mock file descriptors and timer resolver._read_fds.add(1) resolver._read_fds.add(2) resolver._write_fds.add(3) resolver._write_fds.add(4) # Mock timer mock_timer = unittest.mock.MagicMock() resolver._timer = mock_timer # Mock loop methods resolver.loop.remove_reader = unittest.mock.MagicMock() # type: ignore[method-assign] resolver.loop.remove_writer = unittest.mock.MagicMock() # type: ignore[method-assign] # Call cleanup resolver._cleanup() # Verify timer was cancelled mock_timer.cancel.assert_called_once() assert resolver._timer is None # Verify file descriptors were removed resolver.loop.remove_reader.assert_any_call(1) # type: ignore[unreachable] resolver.loop.remove_reader.assert_any_call(2) resolver.loop.remove_writer.assert_any_call(3) resolver.loop.remove_writer.assert_any_call(4) # Verify sets are cleared assert len(resolver._read_fds) == 0 assert len(resolver._write_fds) == 0 @pytest.mark.asyncio async def test_context_manager() -> None: """Test DNSResolver as async context manager.""" resolver_closed = False # Create resolver and use as context manager async with aiodns.DNSResolver() as resolver: # Check resolver is not closed assert not resolver._closed # Mock the close method to track if it's called original_close = resolver.close async def mock_close() -> None: nonlocal resolver_closed resolver_closed = True await original_close() resolver.close = mock_close # type: ignore[method-assign] # Resolver should be usable within context assert isinstance(resolver, aiodns.DNSResolver) # After exiting context, close should have been called assert resolver_closed @pytest.mark.asyncio async def test_context_manager_with_exception() -> None: """Test DNSResolver context manager handles exceptions properly.""" resolver_closed = False try: async with aiodns.DNSResolver() as resolver: # Mock the close method to track if it's called original_close = resolver.close async def mock_close() -> None: nonlocal resolver_closed resolver_closed = True await original_close() resolver.close = mock_close # type: ignore[method-assign] # Raise an exception within the context raise ValueError('Test exception') except ValueError: pass # Expected # Close should still be called even with exception assert resolver_closed @pytest.mark.asyncio async def test_context_manager_close_idempotent() -> None: """Test that close() can be called multiple times safely.""" close_count = 0 async with aiodns.DNSResolver() as resolver: original_close = resolver.close async def mock_close() -> None: nonlocal close_count close_count += 1 await original_close() resolver.close = mock_close # type: ignore[method-assign] # Manually close resolver within context await resolver.close() assert close_count == 1 # Context manager should call close again, but it should be idempotent assert close_count == 2 if __name__ == '__main__': # pragma: no cover unittest.main(verbosity=2)