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 == []

Reply via email to