././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768083858.0328388 aiodns-4.0.0/0000755000175100017510000000000015130550622012435 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768083854.0 aiodns-4.0.0/ChangeLog0000644000175100017510000001514515130550616014220 0ustar00runnerrunner4.0.0 ===== - **Breaking change**: Requires pycares >= 5.0.0 - Added new ``query_dns()`` method returning native pycares 5.x ``DNSResult`` types - Deprecated ``query()`` method - still works with backward-compatible result types - Deprecated ``gethostbyname()`` method - use ``getaddrinfo()`` instead - Added compatibility layer for pycares 4.x result types to ease migration - Updated dependencies - Bumped pycares from 4.11.0 to 5.0.1 (#220) - Bumped pytest from 8.4.2 to 9.0.2 (#224) - Bumped pytest-asyncio from 1.2.0 to 1.3.0 (#223) - Bumped mypy from 1.19.0 to 1.19.1 (#219) - Bumped winloop from 0.3.1 to 0.4.0 (#210) - Bumped actions/upload-artifact from 5 to 6 (#222) - Bumped actions/download-artifact from 6.0.0 to 7.0.0 (#221) 3.6.1 ===== - Pin pycares to < 5 3.6.0 ===== - Fix resolver garbage collection during pending queries (#211) - Prevents resolver from being garbage collected while queries are in progress - Socket callback optimizations (#172) - Improved performance for socket state handling - Fixed RTD links (#176) - Added Python 3.14 to the CI (#212) - Updated dependencies - Bumped pycares from 4.9.0 to 4.11.0 (#186, #194) - Bumped pytest-asyncio from 1.0.0 to 1.2.0 (#181, #196) - Bumped pytest-cov from 6.2.1 to 7.0.0 (#193) - Bumped pytest from 8.4.0 to 8.4.2 (#171, #190) - Bumped mypy from 1.16.0 to 1.19.0 (#170, #179, #185, #195, #197, #207) - Bumped uvloop from 0.21.0 to 0.22.1 (#202) - Bumped winloop from 0.1.8 to 0.3.1 (#182, #183, #184, #187, #200, #201, #203) - Bumped actions/setup-python from 5 to 6 (#199) - Bumped actions/checkout from 4 to 6 (#188, #208) - Bumped actions/upload-artifact from 4 to 5 (#204) - Bumped actions/download-artifact from 4.3.0 to 6.0.0 (#205) 3.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=1768083854.0 aiodns-4.0.0/LICENSE0000644000175100017510000000205515130550616013447 0ustar00runnerrunnerCopyright (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=1768083854.0 aiodns-4.0.0/MANIFEST.in0000644000175100017510000000022415130550616014174 0ustar00runnerrunnerinclude README.rst LICENSE ChangeLog include setup.py tests.py include aiodns/py.typed recursive-exclude * __pycache__ recursive-exclude * *.py[co] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768083858.0328388 aiodns-4.0.0/PKG-INFO0000644000175100017510000001675015130550622013543 0ustar00runnerrunnerMetadata-Version: 2.4 Name: aiodns Version: 4.0.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.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Requires-Python: >=3.10 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: pycares<6,>=5.0.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 async def main(): resolver = aiodns.DNSResolver() result = await resolver.query_dns('google.com', 'A') for record in result.answer: print(record.data.addr) asyncio.run(main()) 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_dns(host, type)``: Do a DNS resolution of the given type for the given hostname. It returns an instance of ``asyncio.Future``. The result is a ``pycares.DNSResult`` object with ``answer``, ``authority``, and ``additional`` attributes containing lists of ``pycares.DNSRecord`` objects. Each record has ``type``, ``ttl``, and ``data`` attributes. Check the `pycares documentation `_ for details on the data attributes for each record type. * ``query(host, type)``: **Deprecated** - use ``query_dns()`` instead. This method returns results in a legacy format compatible with aiodns 3.x for backward compatibility. * ``gethostbyname(host, socket_family)``: **Deprecated** - use ``getaddrinfo()`` instead. Do a DNS resolution for the given hostname and the desired type of address family (i.e. ``socket.AF_INET``). The actual result of the call is a ``asyncio.Future``. * ``gethostbyaddr(name)``: Make a reverse lookup for an address. * ``getaddrinfo(host, family, port, proto, type, flags)``: Resolve a host and port into a list of address info entries. * ``getnameinfo(sockaddr, flags)``: Resolve a socket address to a host and port. * ``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. Migrating from aiodns 3.x ========================= aiodns 4.x introduces a new ``query_dns()`` method that returns native pycares 5.x result types. See the `pycares documentation `_ for details on the result types. The old ``query()`` method is deprecated but continues to work for backward compatibility. .. code:: python # Old API (deprecated) result = await resolver.query('example.com', 'MX') for record in result: print(record.host, record.priority) # New API (recommended) result = await resolver.query_dns('example.com', 'MX') for record in result.answer: print(record.data.exchange, record.data.priority) Future migration to aiodns 5.x ------------------------------ The temporary ``query_dns()`` naming allows gradual migration without breaking changes: +-----------+---------------------------------------+--------------------------------------------+ | Version | ``query()`` | ``query_dns()`` | +===========+=======================================+============================================+ | **4.x** | Deprecated, returns compat types | New API, returns pycares 5.x types | +-----------+---------------------------------------+--------------------------------------------+ | **5.x** | New API, returns pycares 5.x types | Alias to ``query()`` for back compat | +-----------+---------------------------------------+--------------------------------------------+ In aiodns 5.x, ``query()`` will become the primary API returning native pycares 5.x types, and ``query_dns()`` will remain as an alias for backward compatibility. This allows downstream projects to migrate at their own pace. 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_dns('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 -m pytest tests/`` 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=1768083854.0 aiodns-4.0.0/README.rst0000644000175100017510000001445715130550616014142 0ustar00runnerrunner=============================== 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 async def main(): resolver = aiodns.DNSResolver() result = await resolver.query_dns('google.com', 'A') for record in result.answer: print(record.data.addr) asyncio.run(main()) 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_dns(host, type)``: Do a DNS resolution of the given type for the given hostname. It returns an instance of ``asyncio.Future``. The result is a ``pycares.DNSResult`` object with ``answer``, ``authority``, and ``additional`` attributes containing lists of ``pycares.DNSRecord`` objects. Each record has ``type``, ``ttl``, and ``data`` attributes. Check the `pycares documentation `_ for details on the data attributes for each record type. * ``query(host, type)``: **Deprecated** - use ``query_dns()`` instead. This method returns results in a legacy format compatible with aiodns 3.x for backward compatibility. * ``gethostbyname(host, socket_family)``: **Deprecated** - use ``getaddrinfo()`` instead. Do a DNS resolution for the given hostname and the desired type of address family (i.e. ``socket.AF_INET``). The actual result of the call is a ``asyncio.Future``. * ``gethostbyaddr(name)``: Make a reverse lookup for an address. * ``getaddrinfo(host, family, port, proto, type, flags)``: Resolve a host and port into a list of address info entries. * ``getnameinfo(sockaddr, flags)``: Resolve a socket address to a host and port. * ``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. Migrating from aiodns 3.x ========================= aiodns 4.x introduces a new ``query_dns()`` method that returns native pycares 5.x result types. See the `pycares documentation `_ for details on the result types. The old ``query()`` method is deprecated but continues to work for backward compatibility. .. code:: python # Old API (deprecated) result = await resolver.query('example.com', 'MX') for record in result: print(record.host, record.priority) # New API (recommended) result = await resolver.query_dns('example.com', 'MX') for record in result.answer: print(record.data.exchange, record.data.priority) Future migration to aiodns 5.x ------------------------------ The temporary ``query_dns()`` naming allows gradual migration without breaking changes: +-----------+---------------------------------------+--------------------------------------------+ | Version | ``query()`` | ``query_dns()`` | +===========+=======================================+============================================+ | **4.x** | Deprecated, returns compat types | New API, returns pycares 5.x types | +-----------+---------------------------------------+--------------------------------------------+ | **5.x** | New API, returns pycares 5.x types | Alias to ``query()`` for back compat | +-----------+---------------------------------------+--------------------------------------------+ In aiodns 5.x, ``query()`` will become the primary API returning native pycares 5.x types, and ``query_dns()`` will remain as an alias for backward compatibility. This allows downstream projects to migrate at their own pace. 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_dns('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 -m pytest tests/`` 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 :-) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1768083858.031839 aiodns-4.0.0/aiodns/0000755000175100017510000000000015130550622013712 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768083854.0 aiodns-4.0.0/aiodns/__init__.py0000644000175100017510000004234515130550616016036 0ustar00runnerrunnerfrom __future__ import annotations import asyncio import functools import logging import socket import sys import warnings import weakref from collections.abc import Callable, Iterable, Sequence from types import TracebackType from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload import pycares from . import error from .compat import ( AresHostResult, AresQueryAAAAResult, AresQueryAResult, AresQueryCAAResult, AresQueryCNAMEResult, AresQueryMXResult, AresQueryNAPTRResult, AresQueryNSResult, AresQueryPTRResult, AresQuerySOAResult, AresQuerySRVResult, AresQueryTXTResult, QueryResult, convert_result, ) __version__ = '4.0.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__) 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: Sequence[str] | None = None, loop: asyncio.AbstractEventLoop | None = 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: asyncio.TimerHandle | None = None self._closed = False def _make_channel(self, **kwargs: Any) -> tuple[bool, pycares.Channel]: # pycares 5+ uses event_thread by default when sock_state_cb # is not provided try: return True, pycares.Channel(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, ) # Fall back to sock_state_cb (needs SelectorEventLoop on Windows) 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 # Use weak reference for deterministic cleanup. Without it there's a # reference cycle (DNSResolver -> _channel -> callback -> DNSResolver). # Python 3.4+ can handle cycles with __del__, but weak ref ensures # cleanup happens immediately when last reference is dropped. weak_self = weakref.ref(self) def sock_state_cb_wrapper( fd: int, readable: bool, writable: bool ) -> None: this = weak_self() if this is not None: this._sock_state_cb(fd, readable, writable) return False, pycares.Channel( sock_state_cb=sock_state_cb_wrapper, timeout=self._timeout, **kwargs, ) @property def nameservers(self) -> Sequence[str]: # pycares 5.x returns servers with port (e.g., '8.8.8.8:53') # Strip port for backward compatibility with pycares 4.x return [s.rsplit(':', 1)[0] for s in self._channel.servers] @nameservers.setter def nameservers(self, value: Iterable[str | bytes]) -> None: self._channel.servers = value def _callback( self, fut: asyncio.Future[_T], result: _T, errorno: int | None ) -> 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], None]]: """Return a future and a callback to set the result of the future.""" cb: Callable[[_T, int | None], 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 def _query_callback( self, fut: asyncio.Future[QueryResult], qtype: int, result: pycares.DNSResult, errorno: int | None, ) -> None: """Callback for query that converts results to compatible format.""" if fut.cancelled(): return if errorno is not None: fut.set_exception( error.DNSError(errorno, pycares.errno.strerror(errorno)) ) else: fut.set_result(convert_result(result, qtype)) def _get_query_future_callback( self, qtype: int ) -> tuple[asyncio.Future[QueryResult], Callable[..., None]]: """Return a future and callback for query with result conversion.""" future: asyncio.Future[QueryResult] = self.loop.create_future() cb: Callable[..., None] if self._event_thread: cb = functools.partial( # type: ignore[assignment] self.loop.call_soon_threadsafe, self._query_callback, # type: ignore[arg-type] future, qtype, ) else: cb = functools.partial(self._query_callback, future, qtype) return future, cb @overload def query( self, host: str, qtype: Literal['A'], qclass: str | None = ... ) -> asyncio.Future[list[AresQueryAResult]]: ... @overload def query( self, host: str, qtype: Literal['AAAA'], qclass: str | None = ... ) -> asyncio.Future[list[AresQueryAAAAResult]]: ... @overload def query( self, host: str, qtype: Literal['CAA'], qclass: str | None = ... ) -> asyncio.Future[list[AresQueryCAAResult]]: ... @overload def query( self, host: str, qtype: Literal['CNAME'], qclass: str | None = ... ) -> asyncio.Future[AresQueryCNAMEResult]: ... @overload def query( self, host: str, qtype: Literal['MX'], qclass: str | None = ... ) -> asyncio.Future[list[AresQueryMXResult]]: ... @overload def query( self, host: str, qtype: Literal['NAPTR'], qclass: str | None = ... ) -> asyncio.Future[list[AresQueryNAPTRResult]]: ... @overload def query( self, host: str, qtype: Literal['NS'], qclass: str | None = ... ) -> asyncio.Future[list[AresQueryNSResult]]: ... @overload def query( self, host: str, qtype: Literal['PTR'], qclass: str | None = ... ) -> asyncio.Future[AresQueryPTRResult]: ... @overload def query( self, host: str, qtype: Literal['SOA'], qclass: str | None = ... ) -> asyncio.Future[AresQuerySOAResult]: ... @overload def query( self, host: str, qtype: Literal['SRV'], qclass: str | None = ... ) -> asyncio.Future[list[AresQuerySRVResult]]: ... @overload def query( self, host: str, qtype: Literal['TXT'], qclass: str | None = ... ) -> asyncio.Future[list[AresQueryTXTResult]]: ... def query( self, host: str, qtype: str, qclass: str | None = None ) -> asyncio.Future[list[Any]] | asyncio.Future[Any]: """Query DNS records (deprecated, use query_dns instead).""" warnings.warn( 'query() is deprecated, use query_dns() instead', DeprecationWarning, stacklevel=2, ) try: qtype_int = query_type_map[qtype] except KeyError as e: raise ValueError(f'invalid query type: {qtype}') from e qclass_int: int | None = None if qclass is not None: try: qclass_int = query_class_map[qclass] except KeyError as e: raise ValueError(f'invalid query class: {qclass}') from e fut, cb = self._get_query_future_callback(qtype_int) if qclass_int is not None: self._channel.query( host, qtype_int, query_class=qclass_int, callback=cb ) else: self._channel.query(host, qtype_int, callback=cb) return fut def query_dns( self, host: str, qtype: str, qclass: str | None = None ) -> asyncio.Future[pycares.DNSResult]: """Query DNS records, returning native pycares 5.x DNSResult.""" try: qtype_int = query_type_map[qtype] except KeyError as e: raise ValueError(f'invalid query type: {qtype}') from e qclass_int: int | None = None if qclass is not None: try: qclass_int = query_class_map[qclass] except KeyError as e: raise ValueError(f'invalid query class: {qclass}') from e fut: asyncio.Future[pycares.DNSResult] fut, cb = self._get_future_callback() if qclass_int is not None: self._channel.query( host, qtype_int, query_class=qclass_int, callback=cb ) else: self._channel.query(host, qtype_int, callback=cb) return fut def _gethostbyname_callback( self, fut: asyncio.Future[AresHostResult], host: str, result: pycares.AddrInfoResult | None, errorno: int | None, ) -> None: """Callback for gethostbyname that converts AddrInfoResult.""" if fut.cancelled(): return if errorno is not None: fut.set_exception( error.DNSError(errorno, pycares.errno.strerror(errorno)) ) else: assert result is not None # noqa: S101 # node.addr is (address_bytes, port) - extract and decode addresses = [node.addr[0].decode() for node in result.nodes] # Get canonical name from cnames if available name = result.cnames[0].name if result.cnames else host fut.set_result( AresHostResult(name=name, aliases=[], addresses=addresses) ) def gethostbyname( self, host: str, family: socket.AddressFamily ) -> asyncio.Future[AresHostResult]: """ Resolve hostname to addresses. Deprecated: Use getaddrinfo() instead. This is implemented using getaddrinfo as pycares 5.x removed the gethostbyname method. """ warnings.warn( 'gethostbyname() is deprecated, use getaddrinfo() instead', DeprecationWarning, stacklevel=2, ) fut: asyncio.Future[AresHostResult] = self.loop.create_future() cb: Callable[..., None] if self._event_thread: cb = functools.partial( # type: ignore[assignment] self.loop.call_soon_threadsafe, self._gethostbyname_callback, # type: ignore[arg-type] fut, host, ) else: cb = functools.partial(self._gethostbyname_callback, fut, host) self._channel.getaddrinfo(host, None, family=family, callback=cb) return fut def getaddrinfo( self, host: str, family: socket.AddressFamily = socket.AF_UNSPEC, port: int | None = None, proto: int = 0, type: int = 0, flags: int = 0, ) -> asyncio.Future[pycares.AddrInfoResult]: fut: asyncio.Future[pycares.AddrInfoResult] fut, cb = self._get_future_callback() self._channel.getaddrinfo( host, port, family=family, type=type, proto=proto, flags=flags, callback=cb, ) return fut def getnameinfo( self, sockaddr: tuple[str, int] | tuple[str, int, int, int], flags: int = 0, ) -> asyncio.Future[pycares.NameInfoResult]: fut: asyncio.Future[pycares.NameInfoResult] fut, cb = self._get_future_callback() self._channel.getnameinfo(sockaddr, flags, callback=cb) return fut def gethostbyaddr(self, name: str) -> asyncio.Future[pycares.HostResult]: fut: asyncio.Future[pycares.HostResult] fut, cb = self._get_future_callback() self._channel.gethostbyaddr(name, callback=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._channel.process_fd, fd, pycares.ARES_SOCKET_BAD ) self._read_fds.add(fd) if writable: self.loop.add_writer( fd, self._channel.process_fd, pycares.ARES_SOCKET_BAD, fd ) 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 _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 self._read_fds: self.loop.remove_reader(fd) for fd in 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. """ if not self._closed: self._channel.cancel() self._cleanup() async def __aenter__(self) -> DNSResolver: """Enter the async context manager.""" return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> 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=1768083854.0 aiodns-4.0.0/aiodns/compat.py0000644000175100017510000001637715130550616015570 0ustar00runnerrunner""" Compatibility layer for pycares 5.x API. This module provides result types compatible with pycares 4.x API to maintain backward compatibility with existing code. """ from __future__ import annotations from dataclasses import dataclass from typing import Union, cast import pycares def _maybe_str(data: bytes) -> str | bytes: """Decode bytes as ASCII, return bytes if decode fails (pycares 4.x).""" try: return data.decode('ascii') except UnicodeDecodeError: return data @dataclass(frozen=True, slots=True) class AresQueryAResult: """A record result (compatible with pycares 4.x ares_query_a_result).""" host: str ttl: int @dataclass(frozen=True, slots=True) class AresQueryAAAAResult: """AAAA record result (pycares 4.x compat).""" host: str ttl: int @dataclass(frozen=True, slots=True) class AresQueryCNAMEResult: """CNAME record result (pycares 4.x compat).""" cname: str ttl: int @dataclass(frozen=True, slots=True) class AresQueryMXResult: """MX record result (pycares 4.x compat).""" host: str priority: int ttl: int @dataclass(frozen=True, slots=True) class AresQueryNSResult: """NS record result (pycares 4.x compat).""" host: str ttl: int @dataclass(frozen=True, slots=True) class AresQueryTXTResult: """TXT record result (pycares 4.x compat).""" text: str | bytes # str if ASCII, bytes otherwise (pycares 4.x behavior) ttl: int @dataclass(frozen=True, slots=True) class AresQuerySOAResult: """SOA record result (pycares 4.x compat).""" nsname: str hostmaster: str serial: int refresh: int retry: int expires: int minttl: int ttl: int @dataclass(frozen=True, slots=True) class AresQuerySRVResult: """SRV record result (pycares 4.x compat).""" host: str port: int priority: int weight: int ttl: int @dataclass(frozen=True, slots=True) class AresQueryNAPTRResult: """NAPTR record result (pycares 4.x compat).""" order: int preference: int flags: str service: str regex: str replacement: str ttl: int @dataclass(frozen=True, slots=True) class AresQueryCAAResult: """CAA record result (pycares 4.x compat).""" critical: int property: str value: str ttl: int @dataclass(frozen=True, slots=True) class AresQueryPTRResult: """PTR record result (pycares 4.x compat).""" name: str ttl: int aliases: list[str] @dataclass(frozen=True, slots=True) class AresHostResult: """Host result (compatible with pycares 4.x ares_host_result).""" name: str aliases: list[str] addresses: list[str] # Type alias for a single converted record ConvertedRecord = Union[ AresQueryAResult, AresQueryAAAAResult, AresQueryCNAMEResult, AresQueryMXResult, AresQueryNSResult, AresQueryTXTResult, AresQuerySOAResult, AresQuerySRVResult, AresQueryNAPTRResult, AresQueryCAAResult, AresQueryPTRResult, pycares.DNSRecord, # Unknown types returned as-is ] # Type alias for query results QueryResult = Union[ list[AresQueryAResult], list[AresQueryAAAAResult], AresQueryCNAMEResult, list[AresQueryMXResult], list[AresQueryNSResult], list[AresQueryTXTResult], AresQuerySOAResult, list[AresQuerySRVResult], list[AresQueryNAPTRResult], list[AresQueryCAAResult], AresQueryPTRResult, list[ConvertedRecord], # For ANY query type ] def _convert_record(record: pycares.DNSRecord) -> ConvertedRecord: """Convert a single DNS record to pycares 4.x compatible format.""" ttl = record.ttl record_type = record.type if record_type == pycares.QUERY_TYPE_A: a_data = cast(pycares.ARecordData, record.data) return AresQueryAResult(host=a_data.addr, ttl=ttl) if record_type == pycares.QUERY_TYPE_AAAA: aaaa_data = cast(pycares.AAAARecordData, record.data) return AresQueryAAAAResult(host=aaaa_data.addr, ttl=ttl) if record_type == pycares.QUERY_TYPE_CNAME: cname_data = cast(pycares.CNAMERecordData, record.data) return AresQueryCNAMEResult(cname=cname_data.cname, ttl=ttl) if record_type == pycares.QUERY_TYPE_MX: mx_data = cast(pycares.MXRecordData, record.data) return AresQueryMXResult( host=mx_data.exchange, priority=mx_data.priority, ttl=ttl ) if record_type == pycares.QUERY_TYPE_NS: ns_data = cast(pycares.NSRecordData, record.data) return AresQueryNSResult(host=ns_data.nsdname, ttl=ttl) if record_type == pycares.QUERY_TYPE_TXT: txt_data = cast(pycares.TXTRecordData, record.data) return AresQueryTXTResult(text=_maybe_str(txt_data.data), ttl=ttl) if record_type == pycares.QUERY_TYPE_SOA: soa_data = cast(pycares.SOARecordData, record.data) return AresQuerySOAResult( nsname=soa_data.mname, hostmaster=soa_data.rname, serial=soa_data.serial, refresh=soa_data.refresh, retry=soa_data.retry, expires=soa_data.expire, minttl=soa_data.minimum, ttl=ttl, ) if record_type == pycares.QUERY_TYPE_SRV: srv_data = cast(pycares.SRVRecordData, record.data) return AresQuerySRVResult( host=srv_data.target, port=srv_data.port, priority=srv_data.priority, weight=srv_data.weight, ttl=ttl, ) if record_type == pycares.QUERY_TYPE_NAPTR: naptr_data = cast(pycares.NAPTRRecordData, record.data) return AresQueryNAPTRResult( order=naptr_data.order, preference=naptr_data.preference, flags=naptr_data.flags, service=naptr_data.service, regex=naptr_data.regexp, replacement=naptr_data.replacement, ttl=ttl, ) if record_type == pycares.QUERY_TYPE_CAA: caa_data = cast(pycares.CAARecordData, record.data) return AresQueryCAAResult( critical=caa_data.critical, property=caa_data.tag, value=caa_data.value, ttl=ttl, ) if record_type == pycares.QUERY_TYPE_PTR: ptr_data = cast(pycares.PTRRecordData, record.data) return AresQueryPTRResult(name=ptr_data.dname, ttl=ttl, aliases=[]) # Return raw record for unknown types return record def convert_result(dns_result: pycares.DNSResult, qtype: int) -> QueryResult: """Convert pycares 5.x DNSResult to pycares 4.x compatible format.""" # For ANY - convert all records and return mixed list if qtype == pycares.QUERY_TYPE_ANY: return [_convert_record(record) for record in dns_result.answer] results: list[ConvertedRecord] = [] for record in dns_result.answer: record_type = record.type # Filter by query type since answer can contain other types # (e.g., CNAME records when querying for A/AAAA) if record_type != qtype: continue converted = _convert_record(record) # CNAME, SOA, and PTR return single result, not list if record_type in ( pycares.QUERY_TYPE_CNAME, pycares.QUERY_TYPE_SOA, pycares.QUERY_TYPE_PTR, ): return cast(QueryResult, converted) results.append(converted) return results ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768083854.0 aiodns-4.0.0/aiodns/error.py0000644000175100017510000000231115130550616015415 0ustar00runnerrunnerfrom 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=1768083854.0 aiodns-4.0.0/aiodns/py.typed0000644000175100017510000000000015130550616015402 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768083858.0328388 aiodns-4.0.0/aiodns.egg-info/0000755000175100017510000000000015130550622015404 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768083858.0 aiodns-4.0.0/aiodns.egg-info/PKG-INFO0000644000175100017510000001675015130550622016512 0ustar00runnerrunnerMetadata-Version: 2.4 Name: aiodns Version: 4.0.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.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Requires-Python: >=3.10 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: pycares<6,>=5.0.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 async def main(): resolver = aiodns.DNSResolver() result = await resolver.query_dns('google.com', 'A') for record in result.answer: print(record.data.addr) asyncio.run(main()) 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_dns(host, type)``: Do a DNS resolution of the given type for the given hostname. It returns an instance of ``asyncio.Future``. The result is a ``pycares.DNSResult`` object with ``answer``, ``authority``, and ``additional`` attributes containing lists of ``pycares.DNSRecord`` objects. Each record has ``type``, ``ttl``, and ``data`` attributes. Check the `pycares documentation `_ for details on the data attributes for each record type. * ``query(host, type)``: **Deprecated** - use ``query_dns()`` instead. This method returns results in a legacy format compatible with aiodns 3.x for backward compatibility. * ``gethostbyname(host, socket_family)``: **Deprecated** - use ``getaddrinfo()`` instead. Do a DNS resolution for the given hostname and the desired type of address family (i.e. ``socket.AF_INET``). The actual result of the call is a ``asyncio.Future``. * ``gethostbyaddr(name)``: Make a reverse lookup for an address. * ``getaddrinfo(host, family, port, proto, type, flags)``: Resolve a host and port into a list of address info entries. * ``getnameinfo(sockaddr, flags)``: Resolve a socket address to a host and port. * ``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. Migrating from aiodns 3.x ========================= aiodns 4.x introduces a new ``query_dns()`` method that returns native pycares 5.x result types. See the `pycares documentation `_ for details on the result types. The old ``query()`` method is deprecated but continues to work for backward compatibility. .. code:: python # Old API (deprecated) result = await resolver.query('example.com', 'MX') for record in result: print(record.host, record.priority) # New API (recommended) result = await resolver.query_dns('example.com', 'MX') for record in result.answer: print(record.data.exchange, record.data.priority) Future migration to aiodns 5.x ------------------------------ The temporary ``query_dns()`` naming allows gradual migration without breaking changes: +-----------+---------------------------------------+--------------------------------------------+ | Version | ``query()`` | ``query_dns()`` | +===========+=======================================+============================================+ | **4.x** | Deprecated, returns compat types | New API, returns pycares 5.x types | +-----------+---------------------------------------+--------------------------------------------+ | **5.x** | New API, returns pycares 5.x types | Alias to ``query()`` for back compat | +-----------+---------------------------------------+--------------------------------------------+ In aiodns 5.x, ``query()`` will become the primary API returning native pycares 5.x types, and ``query_dns()`` will remain as an alias for backward compatibility. This allows downstream projects to migrate at their own pace. 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_dns('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 -m pytest tests/`` 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=1768083858.0 aiodns-4.0.0/aiodns.egg-info/SOURCES.txt0000644000175100017510000000047615130550622017277 0ustar00runnerrunnerChangeLog LICENSE MANIFEST.in README.rst setup.cfg setup.py aiodns/__init__.py aiodns/compat.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 tests/test_compat.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768083858.0 aiodns-4.0.0/aiodns.egg-info/dependency_links.txt0000644000175100017510000000000115130550622021452 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768083858.0 aiodns-4.0.0/aiodns.egg-info/requires.txt0000644000175100017510000000002215130550622017776 0ustar00runnerrunnerpycares<6,>=5.0.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768083858.0 aiodns-4.0.0/aiodns.egg-info/top_level.txt0000644000175100017510000000000715130550622020133 0ustar00runnerrunneraiodns ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768083858.0338387 aiodns-4.0.0/setup.cfg0000644000175100017510000000006515130550622014257 0ustar00runnerrunner[bdist_wheel] [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768083854.0 aiodns-4.0.0/setup.py0000644000175100017510000000255615130550616014162 0ustar00runnerrunnerimport 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>=5.0.0,<6'], packages=['aiodns'], package_data={'aiodns': ['py.typed']}, platforms=['POSIX', 'Microsoft Windows'], python_requires='>=3.10', 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.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', ], ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1768083858.0328388 aiodns-4.0.0/tests/0000755000175100017510000000000015130550622013577 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768083854.0 aiodns-4.0.0/tests/test_aiodns.py0000755000175100017510000013411315130550616016476 0ustar00runnerrunner#!/usr/bin/env python import asyncio import gc import ipaddress import logging import socket import sys import time import unittest import unittest.mock import warnings from typing import Any, cast import pycares import pytest import aiodns from aiodns.compat import ( AresHostResult, AresQueryAAAAResult, AresQueryAResult, AresQueryCNAMEResult, AresQueryMXResult, AresQueryNAPTRResult, AresQueryNSResult, AresQueryPTRResult, AresQuerySOAResult, AresQuerySRVResult, AresQueryTXTResult, ) try: if sys.platform == 'win32': import winloop as uvloop skip_uvloop = False else: import uvloop skip_uvloop = False except ModuleNotFoundError: skip_uvloop = True # Skip uvloop tests on Python 3.14+ due to EventLoopPolicy deprecation if sys.version_info >= (3, 14): skip_uvloop = True class DNSTest(unittest.TestCase): def setUp(self) -> None: if sys.platform == 'win32': if sys.version_info >= (3, 14): # Policy deprecated in 3.14, create SelectorEventLoop directly self.loop = asyncio.SelectorEventLoop() else: asyncio.set_event_loop_policy( asyncio.WindowsSelectorEventLoopPolicy() ) self.loop = asyncio.new_event_loop() else: 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') result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertIsInstance(result, list) self.assertIsInstance(result[0], AresQueryAResult) def test_query_async_await(self) -> None: async def f() -> list[AresQueryAResult]: return await self.resolver.query('google.com', 'A') result = self.loop.run_until_complete(f()) self.assertTrue(result) self.assertIsInstance(result, list) self.assertIsInstance(result[0], AresQueryAResult) 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) self.assertIsInstance(result, list) self.assertIsInstance(result[0], AresQueryAAAAResult) def test_query_cname(self) -> None: f = self.resolver.query('www.amazon.com', 'CNAME') result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertIsInstance(result, AresQueryCNAMEResult) def test_query_mx(self) -> None: f = self.resolver.query('google.com', 'MX') result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertIsInstance(result, list) self.assertIsInstance(result[0], AresQueryMXResult) def test_query_ns(self) -> None: f = self.resolver.query('google.com', 'NS') result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertIsInstance(result, list) self.assertIsInstance(result[0], AresQueryNSResult) @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) self.assertIsInstance(result, list) self.assertIsInstance(result[0], AresQueryTXTResult) def test_query_soa(self) -> None: f = self.resolver.query('google.com', 'SOA') result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertIsInstance(result, AresQuerySOAResult) 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) self.assertIsInstance(result, list) self.assertIsInstance(result[0], AresQuerySRVResult) def test_query_naptr(self) -> None: f = self.resolver.query('sip2sip.info', 'NAPTR') result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertIsInstance(result, list) self.assertIsInstance(result[0], AresQueryNAPTRResult) 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) self.assertIsInstance(result, AresQueryPTRResult) 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: with warnings.catch_warnings(): warnings.simplefilter('ignore', DeprecationWarning) f = self.resolver.gethostbyname('google.com', socket.AF_INET) result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertIsInstance(result, AresHostResult) self.assertGreater(len(result.addresses), 0) 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: with warnings.catch_warnings(): warnings.simplefilter('ignore', DeprecationWarning) f = self.resolver.gethostbyname('ipv6.google.com', socket.AF_INET6) result = self.loop.run_until_complete(f) self.assertTrue(result) self.assertGreater(len(result.addresses), 0) def test_gethostbyname_bad_family(self) -> None: with warnings.catch_warnings(): warnings.simplefilter('ignore', DeprecationWarning) 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': if sys.version_info >= (3, 14): self.loop = asyncio.SelectorEventLoop() else: asyncio.set_event_loop_policy( asyncio.WindowsSelectorEventLoopPolicy() ) self.loop = asyncio.new_event_loop() else: 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') # CHAOS queries may be refused by some servers try: result = self.loop.run_until_complete(f) self.assertTrue(result) except aiodns.error.DNSError: # CHAOS queries are often refused, that's ok pass class TestQueryTimeout(unittest.TestCase): """Test DNS queries with timeout configuration.""" def setUp(self) -> None: if sys.platform == 'win32': if sys.version_info >= (3, 14): self.loop = asyncio.SelectorEventLoop() else: asyncio.set_event_loop_policy( asyncio.WindowsSelectorEventLoopPolicy() ) self.loop = asyncio.new_event_loop() else: 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, 'uvloop/winloop unavailable or Python 3.14+') 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'] @unittest.skipIf(skip_uvloop, 'uvloop/winloop unavailable or Python 3.14+') 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, 'uvloop/winloop unavailable or Python 3.14+') 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'] @unittest.skipIf(sys.platform != 'win32', 'Only run on Windows') def test_win32_no_selector_event_loop() -> None: """Test DNSResolver with Windows without SelectorEventLoop. With pycares 5, event_thread is used by default. The SelectorEventLoop check only triggers when event_thread creation fails and we fall back to sock_state_cb mode. """ # Create a non-SelectorEventLoop to trigger the error mock_loop = unittest.mock.MagicMock(spec=asyncio.AbstractEventLoop) mock_loop.__class__ = ( asyncio.AbstractEventLoop # type: ignore[assignment] ) # Mock channel creation to fail on first call (event_thread), # triggering the fallback path where SelectorEventLoop is required mock_channel = unittest.mock.MagicMock() with ( pytest.raises( RuntimeError, match='aiodns needs a SelectorEventLoop on Windows' ), unittest.mock.patch('sys.platform', 'win32'), unittest.mock.patch( 'aiodns.pycares.Channel', side_effect=[ pycares.AresError(1, 'mock error'), # First call fails mock_channel, # Second call would succeed ], ), ): 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, ], ), # 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 fallback 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: # 1. First call (event_thread) raises AresError to trigger fallback # 2. Second call (sock_state_cb) would succeed but we should hit # RuntimeError before that mock_channel = unittest.mock.MagicMock() channel_side_effect = [ pycares.AresError(1, 'mock error'), # First call fails mock_channel, # Second call would succeed ] with ( unittest.mock.patch('sys.platform', 'win32'), 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', side_effect=channel_side_effect ), 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: # 1. First call (event_thread) raises AresError to trigger fallback # 2. Second call (sock_state_cb) would succeed but we should hit # RuntimeError before that mock_channel = unittest.mock.MagicMock() channel_side_effect = [ pycares.AresError(1, 'mock error'), # First call fails mock_channel, # Second call would succeed ] with ( unittest.mock.patch('sys.platform', 'win32'), 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', side_effect=channel_side_effect ), 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('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.""" # Use a non-routable IP to ensure the query doesn't complete before close resolver = aiodns.DNSResolver() resolver.nameservers = ['192.0.2.1'] # TEST-NET-1, non-routable # 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 @pytest.mark.asyncio async def test_temporary_resolver_not_garbage_collected() -> None: """Test temporary resolver is not garbage collected before query completes. Regression test for https://github.com/aio-libs/aiodns/issues/209 When calling query() on a temporary resolver (not stored in a variable), the resolver should not be garbage collected before the query completes. Previously, the callback was a @staticmethod which didn't hold a reference to self, causing the resolver to be garbage collected and the query cancelled. """ # Force garbage collection to ensure any weak references are cleared gc.collect() # This pattern previously failed with DNSError(24, 'DNS query cancelled') # because the resolver was garbage collected before the query completed result = await aiodns.DNSResolver(nameservers=['8.8.8.8']).query( 'google.com', 'A' ) # Query should succeed assert result assert len(result) > 0 assert isinstance(result[0], AresQueryAResult) def test_sock_state_cb_fallback_with_real_query() -> None: """Test that sock_state_cb fallback path works for actual DNS queries. This test forces the event_thread channel creation to fail, triggering the sock_state_cb fallback, then performs a real DNS query to verify the fallback path works correctly. """ loop = asyncio.SelectorEventLoop() original_channel = pycares.Channel call_count = 0 def patched_channel(*args: Any, **kwargs: Any) -> pycares.Channel: nonlocal call_count call_count += 1 if call_count == 1: # First call (event_thread) fails raise pycares.AresError(1, 'Simulated failure') # Second call (sock_state_cb) succeeds with real channel return original_channel(*args, **kwargs) async def run_test() -> None: with unittest.mock.patch( 'aiodns.pycares.Channel', side_effect=patched_channel ): resolver = aiodns.DNSResolver(loop=loop, timeout=5.0) resolver.nameservers = ['8.8.8.8'] # Verify we're using the fallback path assert resolver._event_thread is False # Perform a real DNS query through the sock_state_cb path result = await resolver.query('google.com', 'A') # Query should succeed assert result assert len(result) > 0 assert isinstance(result[0], AresQueryAResult) await resolver.close() try: loop.run_until_complete(run_test()) finally: loop.close() @pytest.mark.asyncio async def test_gethostbyname_cancelled_future() -> None: """Test _gethostbyname_callback handles cancelled future.""" resolver = aiodns.DNSResolver(timeout=5.0) resolver.nameservers = ['192.0.2.1'] # Non-routable # Start a query with warnings.catch_warnings(): warnings.simplefilter('ignore', DeprecationWarning) fut = resolver.gethostbyname('example.com', socket.AF_INET) # Cancel the future fut.cancel() # Manually invoke the callback with a cancelled future # This should not raise and should return early resolver._gethostbyname_callback(fut, 'example.com', None, None) await resolver.close() def test_gethostbyname_with_sock_state_cb_fallback() -> None: """Test gethostbyname works with sock_state_cb fallback path.""" loop = asyncio.SelectorEventLoop() original_channel = pycares.Channel call_count = 0 def patched_channel(*args: Any, **kwargs: Any) -> pycares.Channel: nonlocal call_count call_count += 1 if call_count == 1: # First call (event_thread) fails raise pycares.AresError(1, 'Simulated failure') # Second call (sock_state_cb) succeeds with real channel return original_channel(*args, **kwargs) async def run_test() -> None: with unittest.mock.patch( 'aiodns.pycares.Channel', side_effect=patched_channel ): resolver = aiodns.DNSResolver(loop=loop, timeout=5.0) resolver.nameservers = ['8.8.8.8'] # Verify we're using the fallback path assert resolver._event_thread is False # Perform gethostbyname through the sock_state_cb path with warnings.catch_warnings(): warnings.simplefilter('ignore', DeprecationWarning) result = await resolver.gethostbyname( 'google.com', socket.AF_INET ) # Query should succeed assert isinstance(result, AresHostResult) assert len(result.addresses) > 0 await resolver.close() try: loop.run_until_complete(run_test()) finally: loop.close() def test_sock_state_cb_wrapper_with_dead_weak_ref() -> None: """Test sock_state_cb_wrapper handles dead weak reference. When the resolver is garbage collected but the callback is still referenced by pycares, calling the callback should not raise an error. The weak reference will return None and the callback should exit early. """ call_count = 0 captured_callback: Any = None def patched_channel(*args: Any, **kwargs: Any) -> Any: nonlocal call_count, captured_callback call_count += 1 if call_count == 1: # First call (event_thread) fails raise pycares.AresError(1, 'Simulated failure') # Second call - capture the sock_state_cb and return a mock captured_callback = kwargs.get('sock_state_cb') return unittest.mock.MagicMock() # Use a mock loop to avoid any real socket operations mock_loop = unittest.mock.MagicMock(spec=asyncio.SelectorEventLoop) # Create a mock weak ref that returns None (simulating dead resolver) mock_dead_weak_ref = unittest.mock.MagicMock(return_value=None) with unittest.mock.patch( 'aiodns.pycares.Channel', side_effect=patched_channel ): with unittest.mock.patch( 'aiodns.weakref.ref', return_value=mock_dead_weak_ref ): resolver = aiodns.DNSResolver(loop=mock_loop, timeout=5.0) # Verify we captured the callback and are using fallback path assert resolver._event_thread is False assert captured_callback is not None # Mark as closed to prevent cleanup issues resolver._closed = True # Call the captured callback - should not raise since weak ref returns None # This exercises the "if this is not None:" branch when this IS None captured_callback(5, True, False) def test_nameservers_property_getter() -> None: """Test that nameservers property getter returns channel servers.""" loop = asyncio.new_event_loop() resolver = aiodns.DNSResolver(loop=loop, timeout=5.0) # Get nameservers through the property (covers _channel.servers getter) servers = resolver.nameservers # Should return a sequence (might be empty or have system defaults) assert isinstance(servers, (list, tuple)) resolver._closed = True loop.close() def test_nameservers_strips_port() -> None: """Test that nameservers getter strips port suffix.""" loop = asyncio.new_event_loop() resolver = aiodns.DNSResolver(loop=loop, timeout=5.0) # Set nameservers - pycares 5.x will store them with :53 suffix resolver.nameservers = ['8.8.8.8', '8.8.4.4'] # Getter should return without port suffix for backward compatibility servers = resolver.nameservers assert servers == ['8.8.8.8', '8.8.4.4'] # Verify no port suffix in any server for server in servers: assert ':' not in server resolver._closed = True loop.close() @pytest.mark.asyncio async def test_query_dns() -> None: """Test query_dns returns native pycares DNSResult.""" resolver = aiodns.DNSResolver(timeout=5.0) resolver.nameservers = ['8.8.8.8'] result = await resolver.query_dns('google.com', 'A') # Should return pycares.DNSResult assert isinstance(result, pycares.DNSResult) assert hasattr(result, 'answer') assert hasattr(result, 'authority') assert hasattr(result, 'additional') # Answer should contain DNSRecord objects assert len(result.answer) > 0 record = result.answer[0] assert hasattr(record, 'type') assert hasattr(record, 'ttl') assert hasattr(record, 'data') assert record.type == pycares.QUERY_TYPE_A await resolver.close() @pytest.mark.asyncio async def test_query_deprecation_warning() -> None: """Test that query() emits deprecation warning.""" resolver = aiodns.DNSResolver(timeout=5.0) resolver.nameservers = ['8.8.8.8'] with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') await resolver.query('google.com', 'A') assert len(w) == 1 assert issubclass(w[0].category, DeprecationWarning) assert 'query() is deprecated' in str(w[0].message) await resolver.close() @pytest.mark.asyncio async def test_gethostbyname_deprecation_warning() -> None: """Test that gethostbyname() emits deprecation warning.""" resolver = aiodns.DNSResolver(timeout=5.0) resolver.nameservers = ['8.8.8.8'] with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') await resolver.gethostbyname('google.com', socket.AF_INET) assert len(w) == 1 assert issubclass(w[0].category, DeprecationWarning) assert 'gethostbyname() is deprecated' in str(w[0].message) await resolver.close() @pytest.mark.asyncio @pytest.mark.skipif(sys.platform == 'win32', reason='CHAOS class unreliable') async def test_query_dns_with_qclass() -> None: """Test query_dns with qclass parameter.""" resolver = aiodns.DNSResolver(timeout=5.0) resolver.nameservers = ['1.1.1.1'] # CHAOS class queries may be refused by some servers try: result = await resolver.query_dns('id.server', 'TXT', 'CHAOS') assert isinstance(result, pycares.DNSResult) assert len(result.answer) > 0 except aiodns.error.DNSError: # CHAOS queries are often refused, that's ok pass await resolver.close() @pytest.mark.asyncio @pytest.mark.skipif( sys.platform == 'darwin', reason='skipped on Darwin as it is flakey on CI' ) async def test_compat_txt_returns_str() -> None: """Test deprecated query() TXT returns str for ASCII text.""" resolver = aiodns.DNSResolver(timeout=5.0) resolver.nameservers = ['8.8.8.8'] with warnings.catch_warnings(): warnings.simplefilter('ignore', DeprecationWarning) result = await resolver.query('google.com', 'TXT') assert len(result) > 0 # pycares 4.x returned str for ASCII TXT records assert isinstance(result[0].text, str) await resolver.close() @pytest.mark.asyncio async def test_compat_naptr_returns_str() -> None: """Test deprecated query() NAPTR returns str fields.""" resolver = aiodns.DNSResolver(timeout=5.0) resolver.nameservers = ['8.8.8.8'] with warnings.catch_warnings(): warnings.simplefilter('ignore', DeprecationWarning) result = await resolver.query('sip2sip.info', 'NAPTR') assert len(result) > 0 # pycares 4.x returned str for these fields assert isinstance(result[0].flags, str) assert isinstance(result[0].service, str) assert isinstance(result[0].regex, str) await resolver.close() @pytest.mark.asyncio async def test_compat_caa_returns_str() -> None: """Test deprecated query() CAA returns str fields.""" resolver = aiodns.DNSResolver(timeout=5.0) resolver.nameservers = ['8.8.8.8'] with warnings.catch_warnings(): warnings.simplefilter('ignore', DeprecationWarning) try: result = await resolver.query('google.com', 'CAA') except aiodns.error.DNSError: # CAA may not exist, skip test await resolver.close() return if len(result) > 0: # pycares 4.x returned str for these fields assert isinstance(result[0].property, str) assert isinstance(result[0].value, str) await resolver.close() def test_getaddrinfo_with_sock_state_cb_fallback() -> None: """Test getaddrinfo with sock_state_cb fallback. This covers the non-event_thread callback path in _get_future_callback. """ loop = asyncio.SelectorEventLoop() original_channel = pycares.Channel call_count = 0 def patched_channel(*args: Any, **kwargs: Any) -> pycares.Channel: nonlocal call_count call_count += 1 if call_count == 1: # First call (event_thread) fails raise pycares.AresError(1, 'Simulated failure') # Second call (sock_state_cb) succeeds with real channel return original_channel(*args, **kwargs) async def run_test() -> None: with unittest.mock.patch( 'aiodns.pycares.Channel', side_effect=patched_channel ): resolver = aiodns.DNSResolver(loop=loop, timeout=5.0) resolver.nameservers = ['8.8.8.8'] # Verify we're using the fallback path assert resolver._event_thread is False # Call getaddrinfo - this uses _get_future_callback # which exercises line 190 (non-event_thread cb path) result = await resolver.getaddrinfo( 'google.com', family=socket.AF_INET ) # Query should succeed assert result is not None assert result.nodes await resolver.close() try: loop.run_until_complete(run_test()) finally: loop.close() def test_sock_state_cb_and_timer_cb() -> None: """Test _sock_state_cb and _timer_cb with real file descriptors.""" loop = asyncio.SelectorEventLoop() original_channel = pycares.Channel call_count = 0 def patched_channel(*args: Any, **kwargs: Any) -> pycares.Channel: nonlocal call_count call_count += 1 if call_count == 1: raise pycares.AresError(1, 'Simulated failure') return original_channel(*args, **kwargs) # Create real socket pairs for testing sock1, sock2 = socket.socketpair() sock3, sock4 = socket.socketpair() fd1 = sock1.fileno() fd2 = sock3.fileno() try: with unittest.mock.patch( 'aiodns.pycares.Channel', side_effect=patched_channel ): resolver = aiodns.DNSResolver(loop=loop, timeout=0) assert resolver._event_thread is False # Test writable only (readable=False, writable=True) resolver._sock_state_cb(fd1, False, True) assert fd1 in resolver._write_fds assert fd1 not in resolver._read_fds assert resolver._timer is not None # Test _timer_cb with active fds - should restart timer resolver._timer_cb() assert resolver._timer is not None # Test socket close for write fd resolver._sock_state_cb(fd1, False, False) assert fd1 not in resolver._write_fds # Test readable and writable together resolver._sock_state_cb(fd2, True, True) assert fd2 in resolver._read_fds assert fd2 in resolver._write_fds # Test socket close for both resolver._sock_state_cb(fd2, False, False) assert fd2 not in resolver._read_fds assert fd2 not in resolver._write_fds # Timer should be cancelled when no fds left assert resolver._timer is None # Test _timer_cb without active fds - should clear timer resolver._timer = loop.call_later(1, lambda: None) # type: ignore[unreachable] resolver._timer_cb() assert resolver._timer is None resolver._closed = True finally: sock1.close() sock2.close() sock3.close() sock4.close() loop.close() @pytest.mark.asyncio async def test_callback_cancelled_future() -> None: """Test _callback handles cancelled future.""" resolver = aiodns.DNSResolver(timeout=5.0) fut: asyncio.Future[str] = asyncio.get_event_loop().create_future() fut.cancel() # Directly call _callback with cancelled future - should return early resolver._callback(fut, 'result', None) # Also test with errorno - should still return early # Pass empty string as result (ignored when errorno is set) resolver._callback(fut, '', 1) resolver._closed = True @pytest.mark.asyncio async def test_callback_error() -> None: """Test _callback handles error.""" resolver = aiodns.DNSResolver(timeout=5.0) fut: asyncio.Future[str] = asyncio.get_event_loop().create_future() # Call _callback with an error # Pass empty string as result (ignored when errorno is set) resolver._callback(fut, '', pycares.errno.ARES_ENOTFOUND) # Future should have exception set with pytest.raises(aiodns.error.DNSError): fut.result() resolver._closed = True @pytest.mark.asyncio async def test_query_callback_cancelled_future() -> None: """Test _query_callback handles cancelled future.""" resolver = aiodns.DNSResolver(timeout=5.0) fut: asyncio.Future[Any] = asyncio.get_event_loop().create_future() fut.cancel() # Directly call _query_callback with cancelled future - should return early # Cast None to DNSResult since the result is not used when cancelled resolver._query_callback( fut, pycares.QUERY_TYPE_A, cast(pycares.DNSResult, None), None ) resolver._closed = True @pytest.mark.asyncio async def test_query_callback_error() -> None: """Test _query_callback handles error.""" resolver = aiodns.DNSResolver(timeout=5.0) fut: asyncio.Future[Any] = asyncio.get_event_loop().create_future() # Call _query_callback with an error # Cast None to DNSResult since the result is not used when errorno is set resolver._query_callback( fut, pycares.QUERY_TYPE_A, cast(pycares.DNSResult, None), pycares.errno.ARES_ENOTFOUND, ) # Future should have exception set with pytest.raises(aiodns.error.DNSError): fut.result() resolver._closed = True if __name__ == '__main__': # pragma: no cover unittest.main(verbosity=2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1768083854.0 aiodns-4.0.0/tests/test_compat.py0000644000175100017510000004241115130550616016500 0ustar00runnerrunner"""Tests for aiodns.compat module.""" from __future__ import annotations import unittest.mock from dataclasses import fields from typing import Any import pycares import pytest from aiodns.compat import ( AresHostResult, AresQueryAAAAResult, AresQueryAResult, AresQueryCAAResult, AresQueryCNAMEResult, AresQueryMXResult, AresQueryNAPTRResult, AresQueryNSResult, AresQueryPTRResult, AresQuerySOAResult, AresQuerySRVResult, AresQueryTXTResult, _convert_record, convert_result, ) # Expected field names from pycares 4.x (in order) # These were extracted from pycares 4.11.0 __slots__ PYCARES4_SLOTS = { 'ares_query_a_result': ('host', 'ttl'), 'ares_query_aaaa_result': ('host', 'ttl'), 'ares_query_cname_result': ('cname', 'ttl'), 'ares_query_mx_result': ('host', 'priority', 'ttl'), 'ares_query_ns_result': ('host', 'ttl'), 'ares_query_txt_result': ('text', 'ttl'), 'ares_query_soa_result': ( 'nsname', 'hostmaster', 'serial', 'refresh', 'retry', 'expires', 'minttl', 'ttl', ), 'ares_query_srv_result': ('host', 'port', 'priority', 'weight', 'ttl'), 'ares_query_naptr_result': ( 'order', 'preference', 'flags', 'service', 'regex', 'replacement', 'ttl', ), 'ares_query_caa_result': ('critical', 'property', 'value', 'ttl'), 'ares_query_ptr_result': ('name', 'ttl', 'aliases'), 'ares_host_result': ('name', 'aliases', 'addresses'), } # Map pycares 4 type names to our compat types COMPAT_TYPE_MAP = { 'ares_query_a_result': AresQueryAResult, 'ares_query_aaaa_result': AresQueryAAAAResult, 'ares_query_cname_result': AresQueryCNAMEResult, 'ares_query_mx_result': AresQueryMXResult, 'ares_query_ns_result': AresQueryNSResult, 'ares_query_txt_result': AresQueryTXTResult, 'ares_query_soa_result': AresQuerySOAResult, 'ares_query_srv_result': AresQuerySRVResult, 'ares_query_naptr_result': AresQueryNAPTRResult, 'ares_query_caa_result': AresQueryCAAResult, 'ares_query_ptr_result': AresQueryPTRResult, 'ares_host_result': AresHostResult, } @pytest.mark.parametrize( 'pycares4_name,expected_slots', list(PYCARES4_SLOTS.items()), ids=list(PYCARES4_SLOTS.keys()), ) def test_compat_type_matches_pycares4_slots( pycares4_name: str, expected_slots: tuple[str, ...] ) -> None: """Verify compat types have same fields as pycares 4.x types.""" compat_type = COMPAT_TYPE_MAP[pycares4_name] actual_fields = tuple(f.name for f in fields(compat_type)) assert actual_fields == expected_slots, ( f'{compat_type.__name__} fields {actual_fields} ' f'do not match pycares 4 {pycares4_name} slots {expected_slots}' ) def make_mock_record(record_type: int, data: Any, ttl: int = 300) -> Any: """Create a mock DNS record.""" record = unittest.mock.MagicMock() record.type = record_type record.data = data record.ttl = ttl return record def make_mock_dns_result(records: list[Any]) -> Any: """Create a mock DNSResult.""" result = unittest.mock.MagicMock(spec=pycares.DNSResult) result.answer = records return result class TestResultDataclasses: """Test that result dataclasses have correct structure.""" def test_ares_query_a_result(self) -> None: result = AresQueryAResult(host='192.168.1.1', ttl=300) assert result.host == '192.168.1.1' assert result.ttl == 300 def test_ares_query_aaaa_result(self) -> None: result = AresQueryAAAAResult(host='::1', ttl=300) assert result.host == '::1' assert result.ttl == 300 def test_ares_query_cname_result(self) -> None: result = AresQueryCNAMEResult(cname='www.example.com', ttl=300) assert result.cname == 'www.example.com' assert result.ttl == 300 def test_ares_query_mx_result(self) -> None: result = AresQueryMXResult( host='mail.example.com', priority=10, ttl=300 ) assert result.host == 'mail.example.com' assert result.priority == 10 assert result.ttl == 300 def test_ares_query_ns_result(self) -> None: result = AresQueryNSResult(host='ns1.example.com', ttl=300) assert result.host == 'ns1.example.com' assert result.ttl == 300 def test_ares_query_txt_result(self) -> None: result = AresQueryTXTResult(text=b'v=spf1 -all', ttl=300) assert result.text == b'v=spf1 -all' assert result.ttl == 300 def test_ares_query_soa_result(self) -> None: result = AresQuerySOAResult( nsname='ns1.example.com', hostmaster='admin.example.com', serial=2021010101, refresh=3600, retry=600, expires=604800, minttl=86400, ttl=300, ) assert result.nsname == 'ns1.example.com' assert result.hostmaster == 'admin.example.com' assert result.serial == 2021010101 assert result.refresh == 3600 assert result.retry == 600 assert result.expires == 604800 assert result.minttl == 86400 assert result.ttl == 300 def test_ares_query_srv_result(self) -> None: result = AresQuerySRVResult( host='sip.example.com', port=5060, priority=10, weight=50, ttl=300 ) assert result.host == 'sip.example.com' assert result.port == 5060 assert result.priority == 10 assert result.weight == 50 assert result.ttl == 300 def test_ares_query_naptr_result(self) -> None: result = AresQueryNAPTRResult( order=100, preference=10, flags='S', service='SIP+D2U', regex='', replacement='_sip._udp.example.com', ttl=300, ) assert result.order == 100 assert result.preference == 10 assert result.flags == 'S' assert result.service == 'SIP+D2U' assert result.regex == '' assert result.replacement == '_sip._udp.example.com' assert result.ttl == 300 def test_ares_query_caa_result(self) -> None: result = AresQueryCAAResult( critical=0, property='issue', value='letsencrypt.org', ttl=300 ) assert result.critical == 0 assert result.property == 'issue' assert result.value == 'letsencrypt.org' assert result.ttl == 300 def test_ares_query_ptr_result(self) -> None: result = AresQueryPTRResult( name='host.example.com', ttl=300, aliases=['alias.example.com'] ) assert result.name == 'host.example.com' assert result.ttl == 300 assert result.aliases == ['alias.example.com'] def test_ares_host_result(self) -> None: result = AresHostResult( name='example.com', aliases=['www.example.com'], addresses=['192.168.1.1', '192.168.1.2'], ) assert result.name == 'example.com' assert result.aliases == ['www.example.com'] assert result.addresses == ['192.168.1.1', '192.168.1.2'] def test_dataclasses_are_frozen(self) -> None: """Test that dataclasses are immutable.""" result = AresQueryAResult(host='192.168.1.1', ttl=300) with pytest.raises(AttributeError): result.host = '10.0.0.1' # type: ignore[misc] class TestConvertRecord: """Test _convert_record function.""" def test_convert_a_record(self) -> None: data = unittest.mock.MagicMock() data.addr = '192.168.1.1' record = make_mock_record(pycares.QUERY_TYPE_A, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQueryAResult) assert result.host == '192.168.1.1' assert result.ttl == 300 def test_convert_aaaa_record(self) -> None: data = unittest.mock.MagicMock() data.addr = '2001:db8::1' record = make_mock_record(pycares.QUERY_TYPE_AAAA, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQueryAAAAResult) assert result.host == '2001:db8::1' assert result.ttl == 300 def test_convert_cname_record(self) -> None: data = unittest.mock.MagicMock() data.cname = 'www.example.com' record = make_mock_record(pycares.QUERY_TYPE_CNAME, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQueryCNAMEResult) assert result.cname == 'www.example.com' assert result.ttl == 300 def test_convert_mx_record(self) -> None: data = unittest.mock.MagicMock() data.exchange = 'mail.example.com' data.priority = 10 record = make_mock_record(pycares.QUERY_TYPE_MX, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQueryMXResult) assert result.host == 'mail.example.com' assert result.priority == 10 assert result.ttl == 300 def test_convert_ns_record(self) -> None: data = unittest.mock.MagicMock() data.nsdname = 'ns1.example.com' record = make_mock_record(pycares.QUERY_TYPE_NS, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQueryNSResult) assert result.host == 'ns1.example.com' assert result.ttl == 300 def test_convert_txt_record(self) -> None: data = unittest.mock.MagicMock() data.data = b'v=spf1 -all' record = make_mock_record(pycares.QUERY_TYPE_TXT, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQueryTXTResult) # ASCII text is decoded to str (pycares 4.x behavior) assert result.text == 'v=spf1 -all' assert result.ttl == 300 def test_convert_soa_record(self) -> None: data = unittest.mock.MagicMock() data.mname = 'ns1.example.com' data.rname = 'admin.example.com' data.serial = 2021010101 data.refresh = 3600 data.retry = 600 data.expire = 604800 data.minimum = 86400 record = make_mock_record(pycares.QUERY_TYPE_SOA, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQuerySOAResult) assert result.nsname == 'ns1.example.com' assert result.hostmaster == 'admin.example.com' assert result.serial == 2021010101 assert result.refresh == 3600 assert result.retry == 600 assert result.expires == 604800 assert result.minttl == 86400 assert result.ttl == 300 def test_convert_srv_record(self) -> None: data = unittest.mock.MagicMock() data.target = 'sip.example.com' data.port = 5060 data.priority = 10 data.weight = 50 record = make_mock_record(pycares.QUERY_TYPE_SRV, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQuerySRVResult) assert result.host == 'sip.example.com' assert result.port == 5060 assert result.priority == 10 assert result.weight == 50 assert result.ttl == 300 def test_convert_naptr_record_with_string_fields(self) -> None: data = unittest.mock.MagicMock() data.order = 100 data.preference = 10 data.flags = 'S' data.service = 'SIP+D2U' data.regexp = '!^.*$!sip:info@example.com!' data.replacement = '_sip._udp.example.com' record = make_mock_record(pycares.QUERY_TYPE_NAPTR, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQueryNAPTRResult) assert result.order == 100 assert result.preference == 10 assert result.flags == 'S' assert result.service == 'SIP+D2U' assert result.regex == '!^.*$!sip:info@example.com!' assert result.replacement == '_sip._udp.example.com' assert result.ttl == 300 def test_convert_caa_record_with_string_value(self) -> None: data = unittest.mock.MagicMock() data.critical = 0 data.tag = 'issue' data.value = 'letsencrypt.org' record = make_mock_record(pycares.QUERY_TYPE_CAA, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQueryCAAResult) assert result.critical == 0 assert result.property == 'issue' assert result.value == 'letsencrypt.org' assert result.ttl == 300 def test_convert_ptr_record(self) -> None: data = unittest.mock.MagicMock() data.dname = 'host.example.com' record = make_mock_record(pycares.QUERY_TYPE_PTR, data, ttl=300) result = _convert_record(record) assert isinstance(result, AresQueryPTRResult) assert result.name == 'host.example.com' assert result.ttl == 300 assert result.aliases == [] # pycares 5 doesn't provide aliases def test_convert_unknown_record_type(self) -> None: data = unittest.mock.MagicMock() record = make_mock_record(9999, data, ttl=300) result = _convert_record(record) # Unknown types return the raw record assert result is record class TestConvertResult: """Test convert_result function.""" def test_convert_a_query_result(self) -> None: data1 = unittest.mock.MagicMock() data1.addr = '192.168.1.1' data2 = unittest.mock.MagicMock() data2.addr = '192.168.1.2' records = [ make_mock_record(pycares.QUERY_TYPE_A, data1, ttl=300), make_mock_record(pycares.QUERY_TYPE_A, data2, ttl=300), ] dns_result = make_mock_dns_result(records) result = convert_result(dns_result, pycares.QUERY_TYPE_A) assert isinstance(result, list) assert len(result) == 2 first, second = result[0], result[1] assert isinstance(first, AresQueryAResult) assert isinstance(second, AresQueryAResult) assert first.host == '192.168.1.1' assert second.host == '192.168.1.2' def test_convert_cname_query_returns_single_result(self) -> None: data = unittest.mock.MagicMock() data.cname = 'www.example.com' records = [make_mock_record(pycares.QUERY_TYPE_CNAME, data, ttl=300)] dns_result = make_mock_dns_result(records) result = convert_result(dns_result, pycares.QUERY_TYPE_CNAME) assert isinstance(result, AresQueryCNAMEResult) assert result.cname == 'www.example.com' def test_convert_soa_query_returns_single_result(self) -> None: data = unittest.mock.MagicMock() data.mname = 'ns1.example.com' data.rname = 'admin.example.com' data.serial = 2021010101 data.refresh = 3600 data.retry = 600 data.expire = 604800 data.minimum = 86400 records = [make_mock_record(pycares.QUERY_TYPE_SOA, data, ttl=300)] dns_result = make_mock_dns_result(records) result = convert_result(dns_result, pycares.QUERY_TYPE_SOA) assert isinstance(result, AresQuerySOAResult) assert result.nsname == 'ns1.example.com' def test_convert_ptr_query_returns_single_result(self) -> None: data = unittest.mock.MagicMock() data.dname = 'host.example.com' records = [make_mock_record(pycares.QUERY_TYPE_PTR, data, ttl=300)] dns_result = make_mock_dns_result(records) result = convert_result(dns_result, pycares.QUERY_TYPE_PTR) assert isinstance(result, AresQueryPTRResult) assert result.name == 'host.example.com' def test_convert_filters_by_query_type(self) -> None: """Test that convert_result filters out non-matching record types.""" a_data = unittest.mock.MagicMock() a_data.addr = '192.168.1.1' cname_data = unittest.mock.MagicMock() cname_data.cname = 'www.example.com' records = [ make_mock_record(pycares.QUERY_TYPE_CNAME, cname_data, ttl=300), make_mock_record(pycares.QUERY_TYPE_A, a_data, ttl=300), ] dns_result = make_mock_dns_result(records) result = convert_result(dns_result, pycares.QUERY_TYPE_A) assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], AresQueryAResult) assert result[0].host == '192.168.1.1' def test_convert_any_query_returns_all_records(self) -> None: """Test that ANY query converts all records.""" a_data = unittest.mock.MagicMock() a_data.addr = '192.168.1.1' mx_data = unittest.mock.MagicMock() mx_data.exchange = 'mail.example.com' mx_data.priority = 10 records = [ make_mock_record(pycares.QUERY_TYPE_A, a_data, ttl=300), make_mock_record(pycares.QUERY_TYPE_MX, mx_data, ttl=300), ] dns_result = make_mock_dns_result(records) result = convert_result(dns_result, pycares.QUERY_TYPE_ANY) assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], AresQueryAResult) assert isinstance(result[1], AresQueryMXResult) def test_convert_empty_result(self) -> None: """Test conversion of empty DNS result.""" dns_result = make_mock_dns_result([]) result = convert_result(dns_result, pycares.QUERY_TYPE_A) assert isinstance(result, list) assert len(result) == 0