Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-aiodns for openSUSE:Factory checked in at 2026-06-22 17:28:38 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-aiodns (Old) and /work/SRC/openSUSE:Factory/.python-aiodns.new.1956 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-aiodns" Mon Jun 22 17:28:38 2026 rev:14 rq:1360756 version:4.0.4 Changes: -------- --- /work/SRC/openSUSE:Factory/python-aiodns/python-aiodns.changes 2026-03-27 06:47:18.164519408 +0100 +++ /work/SRC/openSUSE:Factory/.python-aiodns.new.1956/python-aiodns.changes 2026-06-22 17:29:36.222089205 +0200 @@ -1,0 +2,24 @@ +Sun Jun 14 09:05:56 UTC 2026 - Dirk Müller <[email protected]> + +- update to 4.0.4: + * Raise ``DNSError(ARES_ENODATA)`` from ``query()`` when the + answer section has no records of the requested qtype, + restoring the pycares 4.x NODATA contract and avoiding + ``AttributeError`` for CNAME/SOA/PTR callers (#254). + * Add the missing ``build-backend`` entry to ``pyproject.toml`` + so PEP 517 builds from the sdist work without falling back to + the deprecated legacy setuptools backend (#252). + * Restore license metadata that was dropped during the + ``pyproject.toml`` migration in #244, so packaging tools + again detect aiodns as MIT-licensed (#250). + * Re-release of 4.0.1; the 4.0.1 wheel build failed because the + release workflow still invoked ``python setup.py`` after #244 + removed ``setup.py``, so 4.0.1 never reached PyPI. The + release workflow now uses ``python -m build`` (#248). + * Fix ``Future exception was never retrieved`` when pycares + raises ``AresError`` synchronously, e.g. for malformed + hostnames (#245, fixes #231) + * Modernized package setup using ``pyproject.toml`` instead of + ``setup.py`` (#244) + +------------------------------------------------------------------- Old: ---- aiodns-4.0.0.tar.gz New: ---- aiodns-4.0.4.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-aiodns.spec ++++++ --- /var/tmp/diff_new_pack.vlQnDH/_old 2026-06-22 17:29:36.958114845 +0200 +++ /var/tmp/diff_new_pack.vlQnDH/_new 2026-06-22 17:29:36.962114984 +0200 @@ -20,7 +20,7 @@ %bcond_with tests %{?sle15_python_module_pythons} Name: python-aiodns -Version: 4.0.0 +Version: 4.0.4 Release: 0 Summary: Simple DNS resolver for asyncio License: MIT ++++++ aiodns-4.0.0.tar.gz -> aiodns-4.0.4.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/.github/workflows/auto-merge.yml new/aiodns-4.0.4/.github/workflows/auto-merge.yml --- old/aiodns-4.0.0/.github/workflows/auto-merge.yml 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/.github/workflows/auto-merge.yml 2026-05-20 03:52:45.000000000 +0200 @@ -12,7 +12,7 @@ steps: - name: Dependabot metadata id: metadata - uses: dependabot/[email protected] + uses: dependabot/[email protected] with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for Dependabot PRs diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/.github/workflows/ci.yml new/aiodns-4.0.4/.github/workflows/ci.yml --- old/aiodns-4.0.0/.github/workflows/ci.yml 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/.github/workflows/ci.yml 2026-05-20 03:52:45.000000000 +0200 @@ -80,7 +80,7 @@ COLOR: 'yes' - run: python -m coverage xml - name: Upload coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/.github/workflows/release-wheels.yml new/aiodns-4.0.4/.github/workflows/release-wheels.yml --- old/aiodns-4.0.0/.github/workflows/release-wheels.yml 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/.github/workflows/release-wheels.yml 2026-05-20 03:52:45.000000000 +0200 @@ -16,10 +16,10 @@ name: Install Python with: python-version: '3.13' - - run: pip install setuptools wheel + - run: pip install build - name: Build wheels - run: python setup.py bdist_wheel - - uses: actions/upload-artifact@v6 + run: python -m build --wheel + - uses: actions/upload-artifact@v7 with: path: dist/*.whl name: artifact-wheels @@ -33,10 +33,10 @@ name: Install Python with: python-version: '3.13' - - run: pip install setuptools + - run: pip install build - name: Build sdist - run: python setup.py sdist - - uses: actions/upload-artifact@v6 + run: python -m build --sdist + - uses: actions/upload-artifact@v7 with: path: dist/*.tar.gz name: artifact-sdist @@ -54,7 +54,7 @@ # upload to PyPI when a GitHub Release is created if: github.event_name == 'release' && github.event.action == 'published' steps: - - uses: actions/[email protected] + - uses: actions/[email protected] with: pattern: artifact-* path: dist diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/ChangeLog new/aiodns-4.0.4/ChangeLog --- old/aiodns-4.0.0/ChangeLog 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/ChangeLog 2026-05-20 03:52:45.000000000 +0200 @@ -1,3 +1,29 @@ +4.0.4 +===== +- Raise ``DNSError(ARES_ENODATA)`` from ``query()`` when the answer section has no records of the requested qtype, restoring the pycares 4.x NODATA contract and avoiding ``AttributeError`` for CNAME/SOA/PTR callers (#254). +- Add the missing ``build-backend`` entry to ``pyproject.toml`` so PEP 517 builds from the sdist work without falling back to the deprecated legacy setuptools backend (#252). + +4.0.3 +===== +- Restore license metadata that was dropped during the ``pyproject.toml`` migration in #244, so packaging tools again detect aiodns as MIT-licensed (#250). + +4.0.2 +===== +- Re-release of 4.0.1; the 4.0.1 wheel build failed because the release workflow still invoked ``python setup.py`` after #244 removed ``setup.py``, so 4.0.1 never reached PyPI. The release workflow now uses ``python -m build`` (#248). + +4.0.1 +===== +- Fix ``Future exception was never retrieved`` when pycares raises ``AresError`` synchronously, e.g. for malformed hostnames (#245, fixes #231) +- Modernized package setup using ``pyproject.toml`` instead of ``setup.py`` (#244) +- Updated dependencies + - Bumped mypy from 1.19.1 to 2.1.0 (#236, #239, #241, #242) + - Bumped pytest from 9.0.2 to 9.0.3 (#237) + - Bumped pytest-cov from 7.0.0 to 7.1.0 (#232) + - Bumped dependabot/fetch-metadata from 2.4.0 to 3.1.0 (#227, #234, #240) + - Bumped actions/download-artifact from 7.0.0 to 8.0.1 (#228, #235) + - Bumped actions/upload-artifact from 6 to 7 (#229) + - Bumped codecov/codecov-action from 5 to 6 (#233) + 4.0.0 ===== - **Breaking change**: Requires pycares >= 5.0.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/README.rst new/aiodns-4.0.4/README.rst --- old/aiodns-4.0.0/README.rst 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/README.rst 2026-05-20 03:52:45.000000000 +0200 @@ -142,6 +142,42 @@ To run the test suite: ``python -m pytest tests/`` +Releasing (maintainers only) +============================ + +Releases are cut from ``master`` and published to PyPI automatically by the +``Release Wheels`` workflow when a GitHub Release is created. + +1. **Prepare the release PR.** Bump ``__version__`` in ``aiodns/__init__.py`` + and prepend a section to ``ChangeLog`` describing the user-facing changes + since the previous tag, in the same RST style as the existing entries + (``X.Y.Z`` header underlined with ``=``). Open the PR with the title + ``Release X.Y.Z`` and merge it once CI is green. + + Skip Dependabot bumps for dev tooling and CI actions; keep runtime + dependency bumps such as ``pycares``. + +2. **Tag and publish the release.** From a clean checkout of ``master`` that + includes the merged release PR, generate the release notes from + ``ChangeLog`` and create the GitHub release in one shot:: + + python scripts/release-notes.py --target X.Y.Z \ + | gh release create vX.Y.Z --repo aio-libs/aiodns \ + --title vX.Y.Z --notes-file - + + The helper script reads ``__version__`` and the topmost ``ChangeLog`` + section and aborts non-zero if they disagree, or if ``--target`` does + not match the current state on disk, so you can't accidentally publish + notes for a version the release PR hasn't actually landed yet. + +3. **Watch the wheel build.** Publishing the GitHub release fires + ``release-wheels.yml``, which builds wheels + sdist and pushes them to + `PyPI <https://pypi.org/project/aiodns/>`_ via trusted publishing + (no token required). Confirm the run succeeds:: + + gh run list --repo aio-libs/aiodns --workflow release-wheels.yml --limit 1 + + Author ====== diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/aiodns/__init__.py new/aiodns-4.0.4/aiodns/__init__.py --- old/aiodns-4.0.0/aiodns/__init__.py 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/aiodns/__init__.py 2026-05-20 03:52:45.000000000 +0200 @@ -1,13 +1,14 @@ from __future__ import annotations import asyncio +import contextlib import functools import logging import socket import sys import warnings import weakref -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Callable, Iterable, Iterator, Sequence from types import TracebackType from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload @@ -31,7 +32,7 @@ convert_result, ) -__version__ = '4.0.0' +__version__ = '4.0.4' __all__ = ( 'DNSResolver', @@ -158,7 +159,10 @@ def _callback( self, fut: asyncio.Future[_T], result: _T, errorno: int | None ) -> None: - if fut.cancelled(): + # The future can already be done if pycares raised synchronously + # and _capture_ares_error set the exception before c-ares delivered + # the same error through this callback. + if fut.done(): return if errorno is not None: fut.set_exception( @@ -191,14 +195,20 @@ errorno: int | None, ) -> None: """Callback for query that converts results to compatible format.""" - if fut.cancelled(): + # See _callback for why we guard on done() rather than cancelled(). + if fut.done(): return if errorno is not None: fut.set_exception( error.DNSError(errorno, pycares.errno.strerror(errorno)) ) + return + try: + converted = convert_result(result, qtype) + except error.DNSError as exc: + fut.set_exception(exc) else: - fut.set_result(convert_result(result, qtype)) + fut.set_result(converted) def _get_query_future_callback( self, qtype: int @@ -217,6 +227,25 @@ cb = functools.partial(self._query_callback, future, qtype) return future, cb + @contextlib.contextmanager + def _capture_ares_error(self, fut: asyncio.Future[_T]) -> Iterator[None]: + # When pycares raises synchronously (e.g. ARES_EBADNAME for a + # malformed hostname), c-ares may also invoke the callback first, + # leaving the future already done. Route the error through the + # future so callers can rely on `await` to raise. + try: + yield + except pycares.AresError as exc: + if fut.done(): + return + # pycares always raises (errno, message), but be defensive: + # an args-less AresError should still resolve the future to + # avoid an indefinite hang on `await`. + errno = exc.args[0] if exc.args else error.ARES_EFORMERR + fut.set_exception( + error.DNSError(errno, pycares.errno.strerror(errno)) + ) + @overload def query( self, host: str, qtype: Literal['A'], qclass: str | None = ... @@ -283,12 +312,13 @@ 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) + with self._capture_ares_error(fut): + 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( @@ -308,12 +338,13 @@ 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) + with self._capture_ares_error(fut): + 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( @@ -324,7 +355,8 @@ errorno: int | None, ) -> None: """Callback for gethostbyname that converts AddrInfoResult.""" - if fut.cancelled(): + # See _callback for why we guard on done() rather than cancelled(). + if fut.done(): return if errorno is not None: fut.set_exception( @@ -365,7 +397,8 @@ ) else: cb = functools.partial(self._gethostbyname_callback, fut, host) - self._channel.getaddrinfo(host, None, family=family, callback=cb) + with self._capture_ares_error(fut): + self._channel.getaddrinfo(host, None, family=family, callback=cb) return fut def getaddrinfo( @@ -379,15 +412,16 @@ ) -> 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, - ) + with self._capture_ares_error(fut): + self._channel.getaddrinfo( + host, + port, + family=family, + type=type, + proto=proto, + flags=flags, + callback=cb, + ) return fut def getnameinfo( @@ -397,13 +431,15 @@ ) -> asyncio.Future[pycares.NameInfoResult]: fut: asyncio.Future[pycares.NameInfoResult] fut, cb = self._get_future_callback() - self._channel.getnameinfo(sockaddr, flags, callback=cb) + with self._capture_ares_error(fut): + 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) + with self._capture_ares_error(fut): + self._channel.gethostbyaddr(name, callback=cb) return fut def cancel(self) -> None: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/aiodns/compat.py new/aiodns-4.0.4/aiodns/compat.py --- old/aiodns-4.0.0/aiodns/compat.py 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/aiodns/compat.py 2026-05-20 03:52:45.000000000 +0200 @@ -12,6 +12,16 @@ import pycares +from . import error + +_SINGLE_RESULT_QTYPES = frozenset( + { + pycares.QUERY_TYPE_CNAME, + pycares.QUERY_TYPE_SOA, + pycares.QUERY_TYPE_PTR, + } +) + def _maybe_str(data: bytes) -> str | bytes: """Decode bytes as ASCII, return bytes if decode fails (pycares 4.x).""" @@ -260,13 +270,19 @@ 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, - ): + if record_type in _SINGLE_RESULT_QTYPES: return cast(QueryResult, converted) results.append(converted) + # NOERROR/NODATA: c-ares delivered ARES_SUCCESS but the answer has no + # records of the queried type. pycares 4.x raised ARES_ENODATA here; + # without this branch single-result qtypes (CNAME/SOA/PTR) would + # resolve to [] and crash callers reading .name/.cname/.nsname. + if not results: + raise error.DNSError( + pycares.errno.ARES_ENODATA, + pycares.errno.strerror(pycares.errno.ARES_ENODATA), + ) + return results diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/pyproject.toml new/aiodns-4.0.4/pyproject.toml --- old/aiodns-4.0.0/pyproject.toml 1970-01-01 01:00:00.000000000 +0100 +++ new/aiodns-4.0.4/pyproject.toml 2026-05-20 03:52:45.000000000 +0200 @@ -0,0 +1,50 @@ +[project] +name = "aiodns" +dynamic = ["version"] +requires-python = ">=3.10" +readme.content-type = "text/x-rst" +readme.file = "README.rst" +license = "MIT" +description = "Simple DNS resolver for asyncio" +authors = [ + { name = "Saúl Ibarra Corretgé", email = "[email protected]" } +] +classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + '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', +] +dependencies = [ + "pycares>=5.0.0,<6", +] + +# TODO: Figure out how to make POSIX and Microsoft Windows +# attributes appear in the platforms informaiton. +# platfroms=["POSIX", "Microsoft Windows"] + +[tool.setuptools.dynamic] +version = {attr = "aiodns.__version__"} + +[build-system] +# pycares required because of the fact that it's in the __init__ module +requires = ["setuptools", "pycares"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +# Disables inclusion of non-package data (like tests) in the wheel if they are in the sdist +include-package-data = false + +[tool.setuptools.packages.find] +exclude = ["tests*", "test_*"] +include = ["aiodns*"] + +[project.urls] + repository = "https://github.com/aio-libs/aiodns.git" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/requirements-dev.txt new/aiodns-4.0.4/requirements-dev.txt --- old/aiodns-4.0.0/requirements-dev.txt 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/requirements-dev.txt 2026-05-20 03:52:45.000000000 +0200 @@ -1,3 +1,3 @@ -r requirements.txt -mypy==1.19.1 +mypy==2.1.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/requirements.txt new/aiodns-4.0.4/requirements.txt --- old/aiodns-4.0.0/requirements.txt 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/requirements.txt 2026-05-20 03:52:45.000000000 +0200 @@ -1,8 +1,8 @@ -e . pycares==5.0.1 -pytest==9.0.2 +pytest==9.0.3 pytest-asyncio==1.3.0 -pytest-cov==7.0.0 +pytest-cov==7.1.0 uvloop==0.22.1; platform_system != "Windows" and implementation_name == "cpython" winloop==0.3.1; platform_system == "Windows" and python_version < "3.10" winloop==0.4.0; platform_system == "Windows" and python_version >= "3.10" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/scripts/release-notes.py new/aiodns-4.0.4/scripts/release-notes.py --- old/aiodns-4.0.0/scripts/release-notes.py 1970-01-01 01:00:00.000000000 +0100 +++ new/aiodns-4.0.4/scripts/release-notes.py 2026-05-20 03:52:45.000000000 +0200 @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +r""" +Generate release notes for the current release. + +Reads the version from ``aiodns/__init__.py`` and the matching section from +``ChangeLog``. Fails if they disagree, or if a ``--target`` is supplied and +does not match the current state on disk. + +Run after the "Release X.Y.Z" PR has been merged into master, e.g.:: + + python scripts/release-notes.py --target 4.0.1 \\ + | gh release create v4.0.1 --repo aio-libs/aiodns \\ + --title v4.0.1 --notes-file - +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +VERSION_RE = re.compile(r"^__version__\s*=\s*['\"]([^'\"]+)['\"]", re.M) +HEADER_RE = re.compile(r'^(\d+\.\d+\.\d+[a-z0-9.\-]*)\s*\n=+\s*$', re.M) + + +def read_version() -> str: + text = (ROOT / 'aiodns' / '__init__.py').read_text() + match = VERSION_RE.search(text) + if not match: + sys.exit('could not find __version__ in aiodns/__init__.py') + return match.group(1) + + +def read_top_changelog_section() -> tuple[str, str]: + text = (ROOT / 'ChangeLog').read_text() + matches = list(HEADER_RE.finditer(text)) + if not matches: + sys.exit('no version headers found in ChangeLog') + top = matches[0] + body_end = matches[1].start() if len(matches) > 1 else len(text) + body = text[top.end() : body_end].strip('\n') + return top.group(1), body + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + '--target', + help='Expected version; abort if __version__/ChangeLog disagree.', + ) + args = parser.parse_args() + + version = read_version() + cl_version, cl_body = read_top_changelog_section() + + if version != cl_version: + sys.exit( + f'__version__ is {version!r} but the top ChangeLog section is ' + f'{cl_version!r}; did the release PR land?' + ) + if args.target and args.target != version: + sys.exit( + f'--target {args.target!r} does not match current release ' + f'{version!r}; check out master after the release PR merges.' + ) + + print(cl_body) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/setup.py new/aiodns-4.0.4/setup.py --- old/aiodns-4.0.0/setup.py 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/setup.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,43 +0,0 @@ -import codecs -import re - -from setuptools import setup - - -def get_version(): - return re.search( - r"""__version__\s+=\s+(?P<quote>['"])(?P<version>.+?)(?P=quote)""", - open('aiodns/__init__.py').read(), - ).group('version') - - -setup( - name='aiodns', - version=get_version(), - author='Saúl Ibarra Corretgé', - author_email='[email protected]', - 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', - ], -) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/tests/test_aiodns.py new/aiodns-4.0.4/tests/test_aiodns.py --- old/aiodns-4.0.0/tests/test_aiodns.py 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/tests/test_aiodns.py 2026-05-20 03:52:45.000000000 +0200 @@ -1398,5 +1398,106 @@ resolver._closed = True [email protected] +async def test_query_callback_empty_result_raises_enodata() -> None: + """convert_result raising must route through fut.set_exception.""" + resolver = aiodns.DNSResolver(timeout=5.0) + fut: asyncio.Future[Any] = asyncio.get_event_loop().create_future() + + empty = unittest.mock.MagicMock(spec=pycares.DNSResult) + empty.answer = [] + + resolver._query_callback(fut, pycares.QUERY_TYPE_PTR, empty, None) + + with pytest.raises(aiodns.error.DNSError) as exc_info: + fut.result() + assert exc_info.value.args[0] == pycares.errno.ARES_ENODATA + + resolver._closed = True + + +async def _assert_malformed_name_routes_through_future( + fut: asyncio.Future[Any], +) -> None: + assert isinstance(fut, asyncio.Future) + with pytest.raises(aiodns.error.DNSError) as exc_info: + await fut + assert exc_info.value.args[0] == aiodns.error.ARES_EBADNAME + + [email protected] +async def test_query_dns_malformed_name_routes_through_future() -> None: + """Synchronous pycares.AresError must be routed through the future. + + Regression test for https://github.com/aio-libs/aiodns/issues/231: + previously a malformed name raised AresError synchronously, leaving + the internally-created future orphaned with an unretrieved exception. + """ + async with aiodns.DNSResolver() as resolver: + await _assert_malformed_name_routes_through_future( + resolver.query_dns('example test.com', 'A') + ) + + [email protected] +async def test_query_malformed_name_routes_through_future() -> None: + """Same as above for the deprecated query() entry point.""" + async with aiodns.DNSResolver() as resolver: + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + fut = resolver.query('example test.com', 'A') + await _assert_malformed_name_routes_through_future(fut) + + +def _call_resolver_entry_point( + resolver: aiodns.DNSResolver, channel_method: str +) -> asyncio.Future[Any]: + if channel_method == 'getaddrinfo': + return resolver.getaddrinfo('host') + if channel_method == 'getnameinfo': + return resolver.getnameinfo(('127.0.0.1', 0)) + assert channel_method == 'gethostbyaddr' + return resolver.gethostbyaddr('127.0.0.1') + + [email protected] [email protected]( + 'channel_method', ['getaddrinfo', 'getnameinfo', 'gethostbyaddr'] +) +async def test_wrapped_entry_points_route_sync_ares_error( + channel_method: str, +) -> None: + """Each wrapper routes a synchronous AresError to the returned future. + + pycares does not currently validate inputs to these entry points + synchronously, so we inject an AresError via mock to prove the + wrapper is wired up and would not regress to a sync raise. + """ + async with aiodns.DNSResolver() as resolver: + exc = pycares.AresError( + aiodns.error.ARES_EBADNAME, 'Misformatted domain name' + ) + with unittest.mock.patch.object( + resolver._channel, channel_method, side_effect=exc + ): + fut = _call_resolver_entry_point(resolver, channel_method) + await _assert_malformed_name_routes_through_future(fut) + + [email protected] +async def test_capture_ares_error_leaves_done_future_untouched() -> None: + """When the callback already finished the future, the captured + AresError must be discarded; reaching the body of the context + manager would call set_exception() on a done future and raise + InvalidStateError, so passing through cleanly is the assertion.""" + async with aiodns.DNSResolver() as resolver: + fut: asyncio.Future[None] = asyncio.get_running_loop().create_future() + fut.set_result(None) + with resolver._capture_ares_error(fut): + raise pycares.AresError( + aiodns.error.ARES_EBADNAME, 'Misformatted domain name' + ) + + if __name__ == '__main__': # pragma: no cover unittest.main(verbosity=2) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aiodns-4.0.0/tests/test_compat.py new/aiodns-4.0.4/tests/test_compat.py --- old/aiodns-4.0.0/tests/test_compat.py 2026-01-10 23:23:14.000000000 +0100 +++ new/aiodns-4.0.4/tests/test_compat.py 2026-05-20 03:52:45.000000000 +0200 @@ -9,6 +9,7 @@ import pycares import pytest +from aiodns import error from aiodns.compat import ( AresHostResult, AresQueryAAAAResult, @@ -506,11 +507,51 @@ assert isinstance(result[0], AresQueryAResult) assert isinstance(result[1], AresQueryMXResult) - def test_convert_empty_result(self) -> None: - """Test conversion of empty DNS result.""" + @pytest.mark.parametrize( + 'qtype', + [ + pycares.QUERY_TYPE_A, + pycares.QUERY_TYPE_AAAA, + pycares.QUERY_TYPE_CAA, + pycares.QUERY_TYPE_CNAME, + pycares.QUERY_TYPE_MX, + pycares.QUERY_TYPE_NAPTR, + pycares.QUERY_TYPE_NS, + pycares.QUERY_TYPE_PTR, + pycares.QUERY_TYPE_SOA, + pycares.QUERY_TYPE_SRV, + pycares.QUERY_TYPE_TXT, + ], + ) + def test_convert_empty_result_raises_enodata(self, qtype: int) -> None: + """NOERROR/NODATA must raise ARES_ENODATA; see aiodns_bug.md.""" dns_result = make_mock_dns_result([]) - result = convert_result(dns_result, pycares.QUERY_TYPE_A) + with pytest.raises(error.DNSError) as exc_info: + convert_result(dns_result, qtype) - assert isinstance(result, list) - assert len(result) == 0 + assert exc_info.value.args[0] == pycares.errno.ARES_ENODATA + + def test_convert_ptr_with_only_non_ptr_records_raises_enodata( + self, + ) -> None: + """PTR query whose answer carries only a CNAME chain must raise.""" + cname_data = unittest.mock.MagicMock() + cname_data.cname = 'alias.example.com' + records = [ + make_mock_record(pycares.QUERY_TYPE_CNAME, cname_data, ttl=300), + ] + dns_result = make_mock_dns_result(records) + + with pytest.raises(error.DNSError) as exc_info: + convert_result(dns_result, pycares.QUERY_TYPE_PTR) + + assert exc_info.value.args[0] == pycares.errno.ARES_ENODATA + + def test_convert_any_empty_result_returns_empty_list(self) -> None: + """ANY is always list-shaped, so empty stays empty (no raise).""" + dns_result = make_mock_dns_result([]) + + result = convert_result(dns_result, pycares.QUERY_TYPE_ANY) + + assert result == []
