diff --git a/.flake8 b/.flake8
index a294638..3b1f77d 100644
--- a/.flake8
+++ b/.flake8
@@ -6,6 +6,8 @@ ignore =
        E129,
        # Whitespace round parameter '=' can be excessive
        E252,
+       # Multiple # in a comment is OK
+       E266,
        # Not excited by the "two blank lines" rule
        E302,
        E305,
diff --git a/.travis.yml b/.travis.yml
index 584f4a7..69223a0 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,6 +3,7 @@ python:
   - "3.6"
   - "3.7"
   - "3.8"
+  - "3.9"
 branches:
   except:
     - python3
diff --git a/MANIFEST.in b/MANIFEST.in
index 8fdc68c..5f7b272 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,3 +1,3 @@
 include LICENSE ChangeLog README.md
 recursive-include examples *.txt *.py
-recursive-include tests *.txt *.py Makefile *.good example query
+recursive-include tests *.txt *.py Makefile *.good example query *.pickle
diff --git a/Makefile b/Makefile
index 4b0e80f..2e120ae 100644
--- a/Makefile
+++ b/Makefile
@@ -58,11 +58,14 @@ potestlf:
 potype:
 	poetry run python -m mypy examples tests dns/*.py
 
+polint:
+	poetry run pylint dns
+
 poflake:
 	poetry run flake8 dns
 
 pocov:
-	poetry run coverage run -m pytest
+	poetry run coverage run --branch -m pytest
 	poetry run coverage html --include 'dns*'
 	poetry run coverage report --include 'dns*'
 
diff --git a/PKG-INFO b/PKG-INFO
new file mode 100644
index 0000000..8c2345f
--- /dev/null
+++ b/PKG-INFO
@@ -0,0 +1,39 @@
+Metadata-Version: 2.1
+Name: dnspython
+Version: 1.15.1.dev1197+g6a53ddf
+Summary: DNS toolkit
+Home-page: http://www.dnspython.org
+Author: Bob Halley
+Author-email: halley@dnspython.org
+License: ISC
+Description: dnspython is a DNS toolkit for Python. It supports almost all
+        record types. It can be used for queries, zone transfers, and dynamic
+        updates.  It supports TSIG authenticated messages and EDNS0.
+        
+        dnspython provides both high and low level access to DNS. The high
+        level classes perform queries for data of a given name, type, and
+        class, and return an answer set.  The low level classes allow
+        direct manipulation of DNS zones, messages, names, and records.
+Platform: UNKNOWN
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Intended Audience :: Developers
+Classifier: Intended Audience :: System Administrators
+Classifier: License :: OSI Approved :: ISC License (ISCL)
+Classifier: Operating System :: POSIX
+Classifier: Operating System :: Microsoft :: Windows
+Classifier: Programming Language :: Python
+Classifier: Topic :: Internet :: Name Service (DNS)
+Classifier: Topic :: Software Development :: Libraries :: Python Modules
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
+Classifier: Programming Language :: Python :: 3.8
+Classifier: Programming Language :: Python :: 3.9
+Provides: dns
+Requires-Python: >=3.6
+Description-Content-Type: text/plain
+Provides-Extra: curio
+Provides-Extra: dnssec
+Provides-Extra: doh
+Provides-Extra: idna
+Provides-Extra: trio
diff --git a/README.md b/README.md
index 7c6bd2e..4a3913c 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,6 @@
 [![Build Status](https://travis-ci.org/rthalley/dnspython.svg?branch=master)](https://travis-ci.org/rthalley/dnspython)
 [![Documentation Status](https://readthedocs.org/projects/dnspython/badge/?version=latest)](https://dnspython.readthedocs.io/en/latest/?badge=latest)
 [![PyPI version](https://badge.fury.io/py/dnspython.svg)](https://badge.fury.io/py/dnspython)
-[![PyPI Statistics](https://img.shields.io/pypi/dm/dnspython.svg)](https://pypistats.org/packages/dnspython)
-[![Build Status](https://dev.azure.com/halley0415/halley/_apis/build/status/rthalley.dnspython?branchName=master)](https://dev.azure.com/halley0415/halley/_build/latest?definitionId=1&branchName=master)
 [![Coverage](https://codecov.io/github/rthalley/dnspython/coverage.svg?branch=master)](https://codecov.io/gh/rthalley/dnspython)
 [![License: ISC](https://img.shields.io/badge/License-ISC-brightgreen.svg)](https://opensource.org/licenses/ISC)
 
@@ -31,9 +29,9 @@ to facilitate the testing of DNS software.
 
 ## ABOUT THIS RELEASE
 
-This is dnspython 2.0.0.
+This is the development version of dnspython 2.2.0.
 Please read
-[What's New](https://dnspython.readthedocs.io/en/latest/whatsnew.html) for
+[What's New](https://dnspython.readthedocs.io/en/stable/whatsnew.html) for
 information about the changes in this release.
 
 ## INSTALLATION
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 5185c7c..35a3404 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -12,26 +12,38 @@ jobs:
     vmImage: 'vs2017-win2016'
   strategy:
     matrix:
-      Python37:
-        python.version: '3.7'
+      Python38:
+        python.version: '3.8'
   steps:
   - task: UsePythonVersion@0
     inputs:
       versionSpec: '$(python.version)'
     displayName: 'Use Python $(python.version)'
 
+#  - script: |
+#      python -m pip install --upgrade pip wheel setuptools
+#    displayName: 'Install pip and wheel'
+
+  - powershell:
+      (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py -UseBasicParsing).Content | python -
+    displayName: 'Install Poetry'
+
   - script: |
-      python -m pip install --upgrade pip
-      pip install -e .[dnssec,idna,doh,trio,curio]
+      %USERPROFILE%\.poetry\bin\poetry install -E dnssec -E doh -E idna -E trio -E curio
     displayName: 'Install python dependencies'
 
+#  - script: |
+#      python -m pip install requests requests-toolbelt idna cryptography
+#      python -m pip install trio sniffio curio
+#    displayName: 'Install python dependencies'
+
   - script: |
       dotnet tool install --global Codecov.Tool
     displayName: 'Install Codecov.Tool'
 
   - script: |
-      pip install pytest pytest-cov pytest-azurepipelines
-      pytest --junitxml=junit/test-results.xml --cov=. --cov-report=xml --cov-report=html
+      %USERPROFILE%\.poetry\bin\poetry run python -m pip install pytest-azurepipelines
+      %USERPROFILE%\.poetry\bin\poetry run pytest --junitxml=junit/test-results.xml --cov=. --cov-report=xml --cov-report=html
     displayName: 'pytest'
 
   - task: PublishTestResults@2
@@ -46,5 +58,5 @@ jobs:
 #      summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml'
 
   - script: |
-      codecov -f coverage.xml
+      %USERPROFILE%\.dotnet\tools\codecov -f coverage.xml
     displayName: 'Upload to codecov'
diff --git a/debian/changelog b/debian/changelog
index b3ef7f3..d22405b 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,4 +1,4 @@
-dnspython (2.0.0-2) UNRELEASED; urgency=medium
+dnspython (2.1.0rc1+git20210322.1.6a53ddf-1) UNRELEASED; urgency=medium
 
   [ Ondřej Nový ]
   * d/control: Update Maintainer field with new Debian Python Team
@@ -12,7 +12,7 @@ dnspython (2.0.0-2) UNRELEASED; urgency=medium
     Repository-Browse.
   * Set upstream metadata fields: Security-Contact.
 
- -- Ondřej Nový <onovy@debian.org>  Thu, 24 Sep 2020 08:59:55 +0200
+ -- Ondřej Nový <onovy@debian.org>  Wed, 07 Apr 2021 01:53:04 -0000
 
 dnspython (2.0.0-1) unstable; urgency=medium
 
diff --git a/dns/__init__.py b/dns/__init__.py
index b944701..0473ca1 100644
--- a/dns/__init__.py
+++ b/dns/__init__.py
@@ -27,6 +27,7 @@ __all__ = [
     'entropy',
     'exception',
     'flags',
+    'immutable',
     'inet',
     'ipv4',
     'ipv6',
@@ -48,14 +49,18 @@ __all__ = [
     'serial',
     'set',
     'tokenizer',
+    'transaction',
     'tsig',
     'tsigkeyring',
     'ttl',
     'rdtypes',
     'update',
     'version',
+    'versioned',
     'wire',
+    'xfr',
     'zone',
+    'zonefile',
 ]
 
 from dns.version import version as __version__  # noqa
diff --git a/dns/_asyncbackend.py b/dns/_asyncbackend.py
index c7ecfad..69411df 100644
--- a/dns/_asyncbackend.py
+++ b/dns/_asyncbackend.py
@@ -27,6 +27,12 @@ class Socket:  # pragma: no cover
     async def close(self):
         pass
 
+    async def getpeername(self):
+        raise NotImplementedError
+
+    async def getsockname(self):
+        raise NotImplementedError
+
     async def __aenter__(self):
         return self
 
@@ -36,18 +42,18 @@ class Socket:  # pragma: no cover
 
 class DatagramSocket(Socket):  # pragma: no cover
     async def sendto(self, what, destination, timeout):
-        pass
+        raise NotImplementedError
 
     async def recvfrom(self, size, timeout):
-        pass
+        raise NotImplementedError
 
 
 class StreamSocket(Socket):  # pragma: no cover
     async def sendall(self, what, destination, timeout):
-        pass
+        raise NotImplementedError
 
     async def recv(self, size, timeout):
-        pass
+        raise NotImplementedError
 
 
 class Backend:    # pragma: no cover
@@ -58,3 +64,6 @@ class Backend:    # pragma: no cover
                           source=None, destination=None, timeout=None,
                           ssl_context=None, server_hostname=None):
         raise NotImplementedError
+
+    def datagram_connection_required(self):
+        return False
diff --git a/dns/_asyncio_backend.py b/dns/_asyncio_backend.py
index 3af34ff..80c31dc 100644
--- a/dns/_asyncio_backend.py
+++ b/dns/_asyncio_backend.py
@@ -4,11 +4,14 @@
 
 import socket
 import asyncio
+import sys
 
 import dns._asyncbackend
 import dns.exception
 
 
+_is_win32 = sys.platform == 'win32'
+
 def _get_running_loop():
     try:
         return asyncio.get_running_loop()
@@ -30,11 +33,11 @@ class _DatagramProtocol:
             self.recvfrom = None
 
     def error_received(self, exc):  # pragma: no cover
-        if self.recvfrom:
+        if self.recvfrom and not self.recvfrom.done():
             self.recvfrom.set_exception(exc)
 
     def connection_lost(self, exc):
-        if self.recvfrom:
+        if self.recvfrom and not self.recvfrom.done():
             self.recvfrom.set_exception(exc)
 
     def close(self):
@@ -79,21 +82,19 @@ class DatagramSocket(dns._asyncbackend.DatagramSocket):
         return self.transport.get_extra_info('sockname')
 
 
-class StreamSocket(dns._asyncbackend.DatagramSocket):
+class StreamSocket(dns._asyncbackend.StreamSocket):
     def __init__(self, af, reader, writer):
         self.family = af
         self.reader = reader
         self.writer = writer
 
     async def sendall(self, what, timeout):
-        self.writer.write(what),
+        self.writer.write(what)
         return await _maybe_wait_for(self.writer.drain(), timeout)
-        raise dns.exception.Timeout(timeout=timeout)
 
     async def recv(self, count, timeout):
         return await _maybe_wait_for(self.reader.read(count),
                                      timeout)
-        raise dns.exception.Timeout(timeout=timeout)
 
     async def close(self):
         self.writer.close()
@@ -116,11 +117,16 @@ class Backend(dns._asyncbackend.Backend):
     async def make_socket(self, af, socktype, proto=0,
                           source=None, destination=None, timeout=None,
                           ssl_context=None, server_hostname=None):
+        if destination is None and socktype == socket.SOCK_DGRAM and \
+           _is_win32:
+            raise NotImplementedError('destinationless datagram sockets '
+                                      'are not supported by asyncio '
+                                      'on Windows')
         loop = _get_running_loop()
         if socktype == socket.SOCK_DGRAM:
             transport, protocol = await loop.create_datagram_endpoint(
                 _DatagramProtocol, source, family=af,
-                proto=proto)
+                proto=proto, remote_addr=destination)
             return DatagramSocket(af, transport, protocol)
         elif socktype == socket.SOCK_STREAM:
             (r, w) = await _maybe_wait_for(
@@ -138,3 +144,7 @@ class Backend(dns._asyncbackend.Backend):
 
     async def sleep(self, interval):
         await asyncio.sleep(interval)
+
+    def datagram_connection_required(self):
+        return _is_win32
+        
diff --git a/dns/_curio_backend.py b/dns/_curio_backend.py
index 300e1b8..6fa7b3a 100644
--- a/dns/_curio_backend.py
+++ b/dns/_curio_backend.py
@@ -21,6 +21,8 @@ def _maybe_timeout(timeout):
 # for brevity
 _lltuple = dns.inet.low_level_address_tuple
 
+# pylint: disable=redefined-outer-name
+
 
 class DatagramSocket(dns._asyncbackend.DatagramSocket):
     def __init__(self, socket):
@@ -47,7 +49,7 @@ class DatagramSocket(dns._asyncbackend.DatagramSocket):
         return self.socket.getsockname()
 
 
-class StreamSocket(dns._asyncbackend.DatagramSocket):
+class StreamSocket(dns._asyncbackend.StreamSocket):
     def __init__(self, socket):
         self.socket = socket
         self.family = socket.family
diff --git a/dns/_immutable_attr.py b/dns/_immutable_attr.py
new file mode 100644
index 0000000..f7b9f8b
--- /dev/null
+++ b/dns/_immutable_attr.py
@@ -0,0 +1,84 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# This implementation of the immutable decorator is for python 3.6,
+# which doesn't have Context Variables.  This implementation is somewhat
+# costly for classes with slots, as it adds a __dict__ to them.
+
+
+import inspect
+
+
+class _Immutable:
+    """Immutable mixin class"""
+
+    # Note we MUST NOT have __slots__ as that causes
+    #
+    #    TypeError: multiple bases have instance lay-out conflict
+    #
+    # when we get mixed in with another class with slots.  When we
+    # get mixed into something with slots, it effectively adds __dict__ to
+    # the slots of the other class, which allows attribute setting to work,
+    # albeit at the cost of the dictionary.
+
+    def __setattr__(self, name, value):
+        if not hasattr(self, '_immutable_init') or \
+           self._immutable_init is not self:
+            raise TypeError("object doesn't support attribute assignment")
+        else:
+            super().__setattr__(name, value)
+
+    def __delattr__(self, name):
+        if not hasattr(self, '_immutable_init') or \
+           self._immutable_init is not self:
+            raise TypeError("object doesn't support attribute assignment")
+        else:
+            super().__delattr__(name)
+
+
+def _immutable_init(f):
+    def nf(*args, **kwargs):
+        try:
+            # Are we already initializing an immutable class?
+            previous = args[0]._immutable_init
+        except AttributeError:
+            # We are the first!
+            previous = None
+            object.__setattr__(args[0], '_immutable_init', args[0])
+        try:
+            # call the actual __init__
+            f(*args, **kwargs)
+        finally:
+            if not previous:
+                # If we started the initialzation, establish immutability
+                # by removing the attribute that allows mutation
+                object.__delattr__(args[0], '_immutable_init')
+    nf.__signature__ = inspect.signature(f)
+    return nf
+
+
+def immutable(cls):
+    if _Immutable in cls.__mro__:
+        # Some ancestor already has the mixin, so just make sure we keep
+        # following the __init__ protocol.
+        cls.__init__ = _immutable_init(cls.__init__)
+        if hasattr(cls, '__setstate__'):
+            cls.__setstate__ = _immutable_init(cls.__setstate__)
+        ncls = cls
+    else:
+        # Mixin the Immutable class and follow the __init__ protocol.
+        class ncls(_Immutable, cls):
+
+            @_immutable_init
+            def __init__(self, *args, **kwargs):
+                super().__init__(*args, **kwargs)
+
+            if hasattr(cls, '__setstate__'):
+                @_immutable_init
+                def __setstate__(self, *args, **kwargs):
+                    super().__setstate__(*args, **kwargs)
+
+        # make ncls have the same name and module as cls
+        ncls.__name__ = cls.__name__
+        ncls.__qualname__ = cls.__qualname__
+        ncls.__module__ = cls.__module__
+    return ncls
diff --git a/dns/_immutable_ctx.py b/dns/_immutable_ctx.py
new file mode 100644
index 0000000..ececdbe
--- /dev/null
+++ b/dns/_immutable_ctx.py
@@ -0,0 +1,75 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# This implementation of the immutable decorator requires python >=
+# 3.7, and is significantly more storage efficient when making classes
+# with slots immutable.  It's also faster.
+
+import contextvars
+import inspect
+
+
+_in__init__ = contextvars.ContextVar('_immutable_in__init__', default=False)
+
+
+class _Immutable:
+    """Immutable mixin class"""
+
+    # We set slots to the empty list to say "we don't have any attributes".
+    # We do this so that if we're mixed in with a class with __slots__, we
+    # don't cause a __dict__ to be added which would waste space.
+
+    __slots__ = ()
+
+    def __setattr__(self, name, value):
+        if _in__init__.get() is not self:
+            raise TypeError("object doesn't support attribute assignment")
+        else:
+            super().__setattr__(name, value)
+
+    def __delattr__(self, name):
+        if _in__init__.get() is not self:
+            raise TypeError("object doesn't support attribute assignment")
+        else:
+            super().__delattr__(name)
+
+
+def _immutable_init(f):
+    def nf(*args, **kwargs):
+        previous = _in__init__.set(args[0])
+        try:
+            # call the actual __init__
+            f(*args, **kwargs)
+        finally:
+            _in__init__.reset(previous)
+    nf.__signature__ = inspect.signature(f)
+    return nf
+
+
+def immutable(cls):
+    if _Immutable in cls.__mro__:
+        # Some ancestor already has the mixin, so just make sure we keep
+        # following the __init__ protocol.
+        cls.__init__ = _immutable_init(cls.__init__)
+        if hasattr(cls, '__setstate__'):
+            cls.__setstate__ = _immutable_init(cls.__setstate__)
+        ncls = cls
+    else:
+        # Mixin the Immutable class and follow the __init__ protocol.
+        class ncls(_Immutable, cls):
+            # We have to do the __slots__ declaration here too!
+            __slots__ = ()
+
+            @_immutable_init
+            def __init__(self, *args, **kwargs):
+                super().__init__(*args, **kwargs)
+
+            if hasattr(cls, '__setstate__'):
+                @_immutable_init
+                def __setstate__(self, *args, **kwargs):
+                    super().__setstate__(*args, **kwargs)
+
+        # make ncls have the same name and module as cls
+        ncls.__name__ = cls.__name__
+        ncls.__qualname__ = cls.__qualname__
+        ncls.__module__ = cls.__module__
+    return ncls
diff --git a/dns/_trio_backend.py b/dns/_trio_backend.py
index 92ea879..a00d4a4 100644
--- a/dns/_trio_backend.py
+++ b/dns/_trio_backend.py
@@ -21,6 +21,8 @@ def _maybe_timeout(timeout):
 # for brevity
 _lltuple = dns.inet.low_level_address_tuple
 
+# pylint: disable=redefined-outer-name
+
 
 class DatagramSocket(dns._asyncbackend.DatagramSocket):
     def __init__(self, socket):
@@ -47,7 +49,7 @@ class DatagramSocket(dns._asyncbackend.DatagramSocket):
         return self.socket.getsockname()
 
 
-class StreamSocket(dns._asyncbackend.DatagramSocket):
+class StreamSocket(dns._asyncbackend.StreamSocket):
     def __init__(self, family, stream, tls=False):
         self.family = family
         self.stream = stream
diff --git a/dns/asyncbackend.py b/dns/asyncbackend.py
index 9582a6f..e6a42ce 100644
--- a/dns/asyncbackend.py
+++ b/dns/asyncbackend.py
@@ -2,9 +2,12 @@
 
 import dns.exception
 
+# pylint: disable=unused-import
+
 from dns._asyncbackend import Socket, DatagramSocket, \
     StreamSocket, Backend  # noqa:
 
+# pylint: enable=unused-import
 
 _default_backend = None
 
@@ -25,6 +28,7 @@ def get_backend(name):
 
     Raises NotImplementError if an unknown backend name is specified.
     """
+    # pylint: disable=import-outside-toplevel,redefined-outer-name
     backend = _backends.get(name)
     if backend:
         return backend
@@ -50,6 +54,7 @@ def sniff():
     Returns the name of the library, or raises AsyncLibraryNotFoundError
     if the library cannot be determined.
     """
+    # pylint: disable=import-outside-toplevel
     try:
         if _no_sniffio:
             raise ImportError
diff --git a/dns/asyncquery.py b/dns/asyncquery.py
index b792648..0e353e8 100644
--- a/dns/asyncquery.py
+++ b/dns/asyncquery.py
@@ -30,7 +30,8 @@ import dns.rcode
 import dns.rdataclass
 import dns.rdatatype
 
-from dns.query import _compute_times, _matches_destination, BadResponse, ssl
+from dns.query import _compute_times, _matches_destination, BadResponse, ssl, \
+    UDPMode
 
 
 # for brevity
@@ -94,36 +95,8 @@ async def receive_udp(sock, destination=None, expiration=None,
 
     *sock*, a ``dns.asyncbackend.DatagramSocket``.
 
-    *destination*, a destination tuple appropriate for the address family
-    of the socket, specifying where the message is expected to arrive from.
-    When receiving a response, this would be where the associated query was
-    sent.
-
-    *expiration*, a ``float`` or ``None``, the absolute time at which
-    a timeout exception should be raised.  If ``None``, no timeout will
-    occur.
-
-    *ignore_unexpected*, a ``bool``.  If ``True``, ignore responses from
-    unexpected sources.
-
-    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
-    RRset.
-
-    *keyring*, a ``dict``, the keyring to use for TSIG.
-
-    *request_mac*, a ``bytes``, the MAC of the request (for TSIG).
-
-    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
-    junk at end of the received message.
-
-    *raise_on_truncation*, a ``bool``.  If ``True``, raise an exception if
-    the TC bit is set.
-
-    Raises if the message is malformed, if network errors occur, of if
-    there is a timeout.
-
-    Returns a ``(dns.message.Message, float, tuple)`` tuple of the received
-    message, the received time, and the address where the message arrived from.
+    See :py:func:`dns.query.receive_udp()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
     """
 
     wire = b''
@@ -145,34 +118,6 @@ async def udp(q, where, timeout=None, port=53, source=None, source_port=0,
               backend=None):
     """Return the response obtained after sending a query via UDP.
 
-    *q*, a ``dns.message.Message``, the query to send
-
-    *where*, a ``str`` containing an IPv4 or IPv6 address,  where
-    to send the message.
-
-    *timeout*, a ``float`` or ``None``, the number of seconds to wait before the
-    query times out.  If ``None``, the default, wait forever.
-
-    *port*, an ``int``, the port send the message to.  The default is 53.
-
-    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying
-    the source address.  The default is the wildcard address.
-
-    *source_port*, an ``int``, the port from which to send the message.
-    The default is 0.
-
-    *ignore_unexpected*, a ``bool``.  If ``True``, ignore responses from
-    unexpected sources.
-
-    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
-    RRset.
-
-    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
-    junk at end of the received message.
-
-    *raise_on_truncation*, a ``bool``.  If ``True``, raise an exception if
-    the TC bit is set.
-
     *sock*, a ``dns.asyncbackend.DatagramSocket``, or ``None``,
     the socket to use for the query.  If ``None``, the default, a
     socket is created.  Note that if a socket is provided, the
@@ -181,7 +126,8 @@ async def udp(q, where, timeout=None, port=53, source=None, source_port=0,
     *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
     the default, then dnspython will use the default backend.
 
-    Returns a ``dns.message.Message``.
+    See :py:func:`dns.query.udp()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
     """
     wire = q.to_wire()
     (begin_time, expiration) = _compute_times(timeout)
@@ -196,7 +142,12 @@ async def udp(q, where, timeout=None, port=53, source=None, source_port=0,
             if not backend:
                 backend = dns.asyncbackend.get_default_backend()
             stuple = _source_tuple(af, source, source_port)
-            s = await backend.make_socket(af, socket.SOCK_DGRAM, 0, stuple)
+            if backend.datagram_connection_required():
+                dtuple = (where, port)
+            else:
+                dtuple = None
+            s = await backend.make_socket(af, socket.SOCK_DGRAM, 0, stuple,
+                                          dtuple)
         await send_udp(s, wire, destination, expiration)
         (r, received_time, _) = await receive_udp(s, destination, expiration,
                                                   ignore_unexpected,
@@ -219,31 +170,6 @@ async def udp_with_fallback(q, where, timeout=None, port=53, source=None,
     """Return the response to the query, trying UDP first and falling back
     to TCP if UDP results in a truncated response.
 
-    *q*, a ``dns.message.Message``, the query to send
-
-    *where*, a ``str`` containing an IPv4 or IPv6 address,  where
-    to send the message.
-
-    *timeout*, a ``float`` or ``None``, the number of seconds to wait before the
-    query times out.  If ``None``, the default, wait forever.
-
-    *port*, an ``int``, the port send the message to.  The default is 53.
-
-    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying
-    the source address.  The default is the wildcard address.
-
-    *source_port*, an ``int``, the port from which to send the message.
-    The default is 0.
-
-    *ignore_unexpected*, a ``bool``.  If ``True``, ignore responses from
-    unexpected sources.
-
-    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
-    RRset.
-
-    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
-    junk at end of the received message.
-
     *udp_sock*, a ``dns.asyncbackend.DatagramSocket``, or ``None``,
     the socket to use for the UDP query.  If ``None``, the default, a
     socket is created.  Note that if a socket is provided the *source*,
@@ -257,8 +183,9 @@ async def udp_with_fallback(q, where, timeout=None, port=53, source=None,
     *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
     the default, then dnspython will use the default backend.
 
-    Returns a (``dns.message.Message``, tcp) tuple where tcp is ``True``
-    if and only if TCP was used.
+    See :py:func:`dns.query.udp_with_fallback()` for the documentation
+    of the other parameters, exceptions, and return type of this
+    method.
     """
     try:
         response = await udp(q, where, timeout, port, source, source_port,
@@ -275,15 +202,10 @@ async def udp_with_fallback(q, where, timeout=None, port=53, source=None,
 async def send_tcp(sock, what, expiration=None):
     """Send a DNS message to the specified TCP socket.
 
-    *sock*, a ``socket``.
-
-    *what*, a ``bytes`` or ``dns.message.Message``, the message to send.
-
-    *expiration*, a ``float`` or ``None``, the absolute time at which
-    a timeout exception should be raised.  If ``None``, no timeout will
-    occur.
+    *sock*, a ``dns.asyncbackend.StreamSocket``.
 
-    Returns an ``(int, float)`` tuple of bytes sent and the sent time.
+    See :py:func:`dns.query.send_tcp()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
     """
 
     if isinstance(what, dns.message.Message):
@@ -294,7 +216,7 @@ async def send_tcp(sock, what, expiration=None):
     # onto the net
     tcpmsg = struct.pack("!H", l) + what
     sent_time = time.time()
-    await sock.sendall(tcpmsg, expiration)
+    await sock.sendall(tcpmsg, _timeout(expiration, sent_time))
     return (len(tcpmsg), sent_time)
 
 
@@ -316,27 +238,10 @@ async def receive_tcp(sock, expiration=None, one_rr_per_rrset=False,
                       keyring=None, request_mac=b'', ignore_trailing=False):
     """Read a DNS message from a TCP socket.
 
-    *sock*, a ``socket``.
-
-    *expiration*, a ``float`` or ``None``, the absolute time at which
-    a timeout exception should be raised.  If ``None``, no timeout will
-    occur.
-
-    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
-    RRset.
-
-    *keyring*, a ``dict``, the keyring to use for TSIG.
-
-    *request_mac*, a ``bytes``, the MAC of the request (for TSIG).
-
-    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
-    junk at end of the received message.
-
-    Raises if the message is malformed, if network errors occur, of if
-    there is a timeout.
+    *sock*, a ``dns.asyncbackend.StreamSocket``.
 
-    Returns a ``(dns.message.Message, float)`` tuple of the received message
-    and the received time.
+    See :py:func:`dns.query.receive_tcp()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
     """
 
     ldata = await _read_exactly(sock, 2, expiration)
@@ -354,28 +259,6 @@ async def tcp(q, where, timeout=None, port=53, source=None, source_port=0,
               backend=None):
     """Return the response obtained after sending a query via TCP.
 
-    *q*, a ``dns.message.Message``, the query to send
-
-    *where*, a ``str`` containing an IPv4 or IPv6 address, where
-    to send the message.
-
-    *timeout*, a ``float`` or ``None``, the number of seconds to wait before the
-    query times out.  If ``None``, the default, wait forever.
-
-    *port*, an ``int``, the port send the message to.  The default is 53.
-
-    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying
-    the source address.  The default is the wildcard address.
-
-    *source_port*, an ``int``, the port from which to send the message.
-    The default is 0.
-
-    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
-    RRset.
-
-    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
-    junk at end of the received message.
-
     *sock*, a ``dns.asyncbacket.StreamSocket``, or ``None``, the
     socket to use for the query.  If ``None``, the default, a socket
     is created.  Note that if a socket is provided
@@ -384,7 +267,8 @@ async def tcp(q, where, timeout=None, port=53, source=None, source_port=0,
     *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
     the default, then dnspython will use the default backend.
 
-    Returns a ``dns.message.Message``.
+    See :py:func:`dns.query.tcp()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
     """
 
     wire = q.to_wire()
@@ -426,28 +310,6 @@ async def tls(q, where, timeout=None, port=853, source=None, source_port=0,
               backend=None, ssl_context=None, server_hostname=None):
     """Return the response obtained after sending a query via TLS.
 
-    *q*, a ``dns.message.Message``, the query to send
-
-    *where*, a ``str`` containing an IPv4 or IPv6 address,  where
-    to send the message.
-
-    *timeout*, a ``float`` or ``None``, the number of seconds to wait before the
-    query times out.  If ``None``, the default, wait forever.
-
-    *port*, an ``int``, the port send the message to.  The default is 853.
-
-    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying
-    the source address.  The default is the wildcard address.
-
-    *source_port*, an ``int``, the port from which to send the message.
-    The default is 0.
-
-    *one_rr_per_rrset*, a ``bool``.  If ``True``, put each RR into its own
-    RRset.
-
-    *ignore_trailing*, a ``bool``.  If ``True``, ignore trailing
-    junk at end of the received message.
-
     *sock*, an ``asyncbackend.StreamSocket``, or ``None``, the socket
     to use for the query.  If ``None``, the default, a socket is
     created.  Note that if a socket is provided, it must be a
@@ -458,15 +320,8 @@ async def tls(q, where, timeout=None, port=853, source=None, source_port=0,
     *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
     the default, then dnspython will use the default backend.
 
-    *ssl_context*, an ``ssl.SSLContext``, the context to use when establishing
-    a TLS connection. If ``None``, the default, creates one with the default
-    configuration.
-
-    *server_hostname*, a ``str`` containing the server's hostname.  The
-    default is ``None``, which means that no hostname is known, and if an
-    SSL context is created, hostname checking will be disabled.
-
-    Returns a ``dns.message.Message``.
+    See :py:func:`dns.query.tls()` for the documentation of the other
+    parameters, exceptions, and return type of this method.
     """
     # After 3.6 is no longer supported, this can use an AsyncExitStack.
     (begin_time, expiration) = _compute_times(timeout)
@@ -498,3 +353,87 @@ async def tls(q, where, timeout=None, port=853, source=None, source_port=0,
     finally:
         if not sock and s:
             await s.close()
+
+async def inbound_xfr(where, txn_manager, query=None,
+                      port=53, timeout=None, lifetime=None, source=None,
+                      source_port=0, udp_mode=UDPMode.NEVER,
+                      backend=None):
+    """Conduct an inbound transfer and apply it via a transaction from the
+    txn_manager.
+
+    *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
+    the default, then dnspython will use the default backend.
+
+    See :py:func:`dns.query.inbound_xfr()` for the documentation of
+    the other parameters, exceptions, and return type of this method.
+    """
+    if query is None:
+        (query, serial) = dns.xfr.make_query(txn_manager)
+    rdtype = query.question[0].rdtype
+    is_ixfr = rdtype == dns.rdatatype.IXFR
+    origin = txn_manager.from_wire_origin()
+    wire = query.to_wire()
+    af = dns.inet.af_for_address(where)
+    stuple = _source_tuple(af, source, source_port)
+    dtuple = (where, port)
+    (_, expiration) = _compute_times(lifetime)
+    retry = True
+    while retry:
+        retry = False
+        if is_ixfr and udp_mode != UDPMode.NEVER:
+            sock_type = socket.SOCK_DGRAM
+            is_udp = True
+        else:
+            sock_type = socket.SOCK_STREAM
+            is_udp = False
+        if not backend:
+            backend = dns.asyncbackend.get_default_backend()
+        s = await backend.make_socket(af, sock_type, 0, stuple, dtuple,
+                                      _timeout(expiration))
+        async with s:
+            if is_udp:
+                await s.sendto(wire, dtuple, _timeout(expiration))
+            else:
+                tcpmsg = struct.pack("!H", len(wire)) + wire
+                await s.sendall(tcpmsg, expiration)
+            with dns.xfr.Inbound(txn_manager, rdtype, serial,
+                                 is_udp) as inbound:
+                done = False
+                tsig_ctx = None
+                while not done:
+                    (_, mexpiration) = _compute_times(timeout)
+                    if mexpiration is None or \
+                       (expiration is not None and mexpiration > expiration):
+                        mexpiration = expiration
+                    if is_udp:
+                        destination = _lltuple((where, port), af)
+                        while True:
+                            timeout = _timeout(mexpiration)
+                            (rwire, from_address) = await s.recvfrom(65535,
+                                                                     timeout)
+                            if _matches_destination(af, from_address,
+                                                    destination, True):
+                                break
+                    else:
+                        ldata = await _read_exactly(s, 2, mexpiration)
+                        (l,) = struct.unpack("!H", ldata)
+                        rwire = await _read_exactly(s, l, mexpiration)
+                    is_ixfr = (rdtype == dns.rdatatype.IXFR)
+                    r = dns.message.from_wire(rwire, keyring=query.keyring,
+                                              request_mac=query.mac, xfr=True,
+                                              origin=origin, tsig_ctx=tsig_ctx,
+                                              multi=(not is_udp),
+                                              one_rr_per_rrset=is_ixfr)
+                    try:
+                        done = inbound.process_message(r)
+                    except dns.xfr.UseTCP:
+                        assert is_udp  # should not happen if we used TCP!
+                        if udp_mode == UDPMode.ONLY:
+                            raise
+                        done = True
+                        retry = True
+                        udp_mode = UDPMode.NEVER
+                        continue
+                    tsig_ctx = r.tsig_ctx
+                if not retry and query.keyring and not r.had_tsig:
+                    raise dns.exception.FormError("missing TSIG")
diff --git a/dns/asyncresolver.py b/dns/asyncresolver.py
index 3ac334f..a60cf77 100644
--- a/dns/asyncresolver.py
+++ b/dns/asyncresolver.py
@@ -34,7 +34,8 @@ _udp = dns.asyncquery.udp
 _tcp = dns.asyncquery.tcp
 
 
-class Resolver(dns.resolver.Resolver):
+class Resolver(dns.resolver.BaseResolver):
+    """Asynchronous DNS stub resolver."""
 
     async def resolve(self, qname, rdtype=dns.rdatatype.A,
                       rdclass=dns.rdataclass.IN,
@@ -43,53 +44,12 @@ class Resolver(dns.resolver.Resolver):
                       backend=None):
         """Query nameservers asynchronously to find the answer to the question.
 
-        The *qname*, *rdtype*, and *rdclass* parameters may be objects
-        of the appropriate type, or strings that can be converted into objects
-        of the appropriate type.
-
-        *qname*, a ``dns.name.Name`` or ``str``, the query name.
-
-        *rdtype*, an ``int`` or ``str``,  the query type.
-
-        *rdclass*, an ``int`` or ``str``,  the query class.
-
-        *tcp*, a ``bool``.  If ``True``, use TCP to make the query.
-
-        *source*, a ``str`` or ``None``.  If not ``None``, bind to this IP
-        address when making queries.
-
-        *raise_on_no_answer*, a ``bool``.  If ``True``, raise
-        ``dns.resolver.NoAnswer`` if there's no answer to the question.
-
-        *source_port*, an ``int``, the port from which to send the message.
-
-        *lifetime*, a ``float``, how many seconds a query should run
-         before timing out.
-
-        *search*, a ``bool`` or ``None``, determines whether the
-        search list configured in the system's resolver configuration
-        are used for relative names, and whether the resolver's domain
-        may be added to relative names.  The default is ``None``,
-        which causes the value of the resolver's
-        ``use_search_by_default`` attribute to be used.
-
         *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
         the default, then dnspython will use the default backend.
 
-        Raises ``dns.resolver.NXDOMAIN`` if the query name does not exist.
-
-        Raises ``dns.resolver.YXDOMAIN`` if the query name is too long after
-        DNAME substitution.
-
-        Raises ``dns.resolver.NoAnswer`` if *raise_on_no_answer* is
-        ``True`` and the query name exists but has no RRset of the
-        desired type and class.
-
-        Raises ``dns.resolver.NoNameservers`` if no non-broken
-        nameservers are available to answer the question.
-
-        Returns a ``dns.resolver.Answer`` instance.
-
+        See :py:func:`dns.resolver.Resolver.resolve()` for the
+        documentation of the other parameters, exceptions, and return
+        type of this method.
         """
 
         resolution = dns.resolver._Resolution(self, qname, rdtype, rdclass, tcp,
@@ -139,11 +99,6 @@ class Resolver(dns.resolver.Resolver):
                 if answer is not None:
                     return answer
 
-    async def query(self, *args, **kwargs):
-        # We have to define something here as we don't want to inherit the
-        # parent's query().
-        raise NotImplementedError
-
     async def resolve_address(self, ipaddr, *args, **kwargs):
         """Use an asynchronous resolver to run a reverse query for PTR
         records.
@@ -165,6 +120,30 @@ class Resolver(dns.resolver.Resolver):
                                   rdclass=dns.rdataclass.IN,
                                   *args, **kwargs)
 
+    # pylint: disable=redefined-outer-name
+
+    async def canonical_name(self, name):
+        """Determine the canonical name of *name*.
+
+        The canonical name is the name the resolver uses for queries
+        after all CNAME and DNAME renamings have been applied.
+
+        *name*, a ``dns.name.Name`` or ``str``, the query name.
+
+        This method can raise any exception that ``resolve()`` can
+        raise, other than ``dns.resolver.NoAnswer`` and
+        ``dns.resolver.NXDOMAIN``.
+
+        Returns a ``dns.name.Name``.
+        """
+        try:
+            answer = await self.resolve(name, raise_on_no_answer=False)
+            canonical_name = answer.canonical_name
+        except dns.resolver.NXDOMAIN as e:
+            canonical_name = e.canonical_name
+        return canonical_name
+
+
 default_resolver = None
 
 
@@ -188,52 +167,46 @@ def reset_default_resolver():
 
 async def resolve(qname, rdtype=dns.rdatatype.A, rdclass=dns.rdataclass.IN,
                   tcp=False, source=None, raise_on_no_answer=True,
-                  source_port=0, search=None, backend=None):
+                  source_port=0, lifetime=None, search=None, backend=None):
     """Query nameservers asynchronously to find the answer to the question.
 
     This is a convenience function that uses the default resolver
     object to make the query.
 
-    See ``dns.asyncresolver.Resolver.resolve`` for more information on the
-    parameters.
+    See :py:func:`dns.asyncresolver.Resolver.resolve` for more
+    information on the parameters.
     """
 
     return await get_default_resolver().resolve(qname, rdtype, rdclass, tcp,
                                                 source, raise_on_no_answer,
-                                                source_port, search, backend)
+                                                source_port, lifetime, search,
+                                                backend)
 
 
 async def resolve_address(ipaddr, *args, **kwargs):
     """Use a resolver to run a reverse query for PTR records.
 
-    See ``dns.asyncresolver.Resolver.resolve_address`` for more
+    See :py:func:`dns.asyncresolver.Resolver.resolve_address` for more
     information on the parameters.
     """
 
     return await get_default_resolver().resolve_address(ipaddr, *args, **kwargs)
 
+async def canonical_name(name):
+    """Determine the canonical name of *name*.
+
+    See :py:func:`dns.resolver.Resolver.canonical_name` for more
+    information on the parameters and possible exceptions.
+    """
+
+    return await get_default_resolver().canonical_name(name)
 
 async def zone_for_name(name, rdclass=dns.rdataclass.IN, tcp=False,
                         resolver=None, backend=None):
     """Find the name of the zone which contains the specified name.
 
-    *name*, an absolute ``dns.name.Name`` or ``str``, the query name.
-
-    *rdclass*, an ``int``, the query class.
-
-    *tcp*, a ``bool``.  If ``True``, use TCP to make the query.
-
-    *resolver*, a ``dns.asyncresolver.Resolver`` or ``None``, the
-    resolver to use.  If ``None``, the default resolver is used.
-
-    *backend*, a ``dns.asyncbackend.Backend``, or ``None``.  If ``None``,
-    the default, then dnspython will use the default backend.
-
-    Raises ``dns.resolver.NoRootSOA`` if there is no SOA RR at the DNS
-    root.  (This is only likely to happen if you're using non-default
-    root servers in your network and they are misconfigured.)
-
-    Returns a ``dns.name.Name``.
+    See :py:func:`dns.resolver.Resolver.zone_for_name` for more
+    information on the parameters and possible exceptions.
     """
 
     if isinstance(name, str):
diff --git a/dns/dnssec.py b/dns/dnssec.py
index c50abf8..f09ecd6 100644
--- a/dns/dnssec.py
+++ b/dns/dnssec.py
@@ -64,9 +64,6 @@ class Algorithm(dns.enum.IntEnum):
         return 255
 
 
-globals().update(Algorithm.__members__)
-
-
 def algorithm_from_text(text):
     """Convert text into a DNSSEC algorithm value.
 
@@ -169,23 +166,15 @@ def make_ds(name, key, algorithm, origin=None):
 
 
 def _find_candidate_keys(keys, rrsig):
-    candidate_keys = []
     value = keys.get(rrsig.signer)
-    if value is None:
-        return None
     if isinstance(value, dns.node.Node):
-        try:
-            rdataset = value.find_rdataset(dns.rdataclass.IN,
-                                           dns.rdatatype.DNSKEY)
-        except KeyError:
-            return None
+        rdataset = value.get_rdataset(dns.rdataclass.IN, dns.rdatatype.DNSKEY)
     else:
         rdataset = value
-    for rdata in rdataset:
-        if rdata.algorithm == rrsig.algorithm and \
-                key_id(rdata) == rrsig.key_tag:
-            candidate_keys.append(rdata)
-    return candidate_keys
+    if rdataset is None:
+        return None
+    return [rd for rd in rdataset if
+            rd.algorithm == rrsig.algorithm and key_id(rd) == rrsig.key_tag]
 
 
 def _is_rsa(algorithm):
@@ -254,6 +243,82 @@ def _bytes_to_long(b):
     return int.from_bytes(b, 'big')
 
 
+def _validate_signature(sig, data, key, chosen_hash):
+    if _is_rsa(key.algorithm):
+        keyptr = key.key
+        (bytes_,) = struct.unpack('!B', keyptr[0:1])
+        keyptr = keyptr[1:]
+        if bytes_ == 0:
+            (bytes_,) = struct.unpack('!H', keyptr[0:2])
+            keyptr = keyptr[2:]
+        rsa_e = keyptr[0:bytes_]
+        rsa_n = keyptr[bytes_:]
+        try:
+            public_key = rsa.RSAPublicNumbers(
+                _bytes_to_long(rsa_e),
+                _bytes_to_long(rsa_n)).public_key(default_backend())
+        except ValueError:
+            raise ValidationFailure('invalid public key')
+        public_key.verify(sig, data, padding.PKCS1v15(), chosen_hash)
+    elif _is_dsa(key.algorithm):
+        keyptr = key.key
+        (t,) = struct.unpack('!B', keyptr[0:1])
+        keyptr = keyptr[1:]
+        octets = 64 + t * 8
+        dsa_q = keyptr[0:20]
+        keyptr = keyptr[20:]
+        dsa_p = keyptr[0:octets]
+        keyptr = keyptr[octets:]
+        dsa_g = keyptr[0:octets]
+        keyptr = keyptr[octets:]
+        dsa_y = keyptr[0:octets]
+        try:
+            public_key = dsa.DSAPublicNumbers(
+                _bytes_to_long(dsa_y),
+                dsa.DSAParameterNumbers(
+                    _bytes_to_long(dsa_p),
+                    _bytes_to_long(dsa_q),
+                    _bytes_to_long(dsa_g))).public_key(default_backend())
+        except ValueError:
+            raise ValidationFailure('invalid public key')
+        public_key.verify(sig, data, chosen_hash)
+    elif _is_ecdsa(key.algorithm):
+        keyptr = key.key
+        if key.algorithm == Algorithm.ECDSAP256SHA256:
+            curve = ec.SECP256R1()
+            octets = 32
+        else:
+            curve = ec.SECP384R1()
+            octets = 48
+        ecdsa_x = keyptr[0:octets]
+        ecdsa_y = keyptr[octets:octets * 2]
+        try:
+            public_key = ec.EllipticCurvePublicNumbers(
+                curve=curve,
+                x=_bytes_to_long(ecdsa_x),
+                y=_bytes_to_long(ecdsa_y)).public_key(default_backend())
+        except ValueError:
+            raise ValidationFailure('invalid public key')
+        public_key.verify(sig, data, ec.ECDSA(chosen_hash))
+    elif _is_eddsa(key.algorithm):
+        keyptr = key.key
+        if key.algorithm == Algorithm.ED25519:
+            loader = ed25519.Ed25519PublicKey
+        else:
+            loader = ed448.Ed448PublicKey
+        try:
+            public_key = loader.from_public_bytes(keyptr)
+        except ValueError:
+            raise ValidationFailure('invalid public key')
+        public_key.verify(sig, data)
+    elif _is_gost(key.algorithm):
+        raise UnsupportedAlgorithm(
+            'algorithm "%s" not supported by dnspython' %
+            algorithm_to_text(key.algorithm))
+    else:
+        raise ValidationFailure('unknown algorithm %u' % key.algorithm)
+
+
 def _validate_rrsig(rrset, rrsig, keys, origin=None, now=None):
     """Validate an RRset against a single signature rdata, throwing an
     exception if validation is not successful.
@@ -291,143 +356,69 @@ def _validate_rrsig(rrset, rrsig, keys, origin=None, now=None):
     if candidate_keys is None:
         raise ValidationFailure('unknown key')
 
-    for candidate_key in candidate_keys:
-        # For convenience, allow the rrset to be specified as a (name,
-        # rdataset) tuple as well as a proper rrset
-        if isinstance(rrset, tuple):
-            rrname = rrset[0]
-            rdataset = rrset[1]
-        else:
-            rrname = rrset.name
-            rdataset = rrset
-
-        if now is None:
-            now = time.time()
-        if rrsig.expiration < now:
-            raise ValidationFailure('expired')
-        if rrsig.inception > now:
-            raise ValidationFailure('not yet valid')
-
-        if _is_rsa(rrsig.algorithm):
-            keyptr = candidate_key.key
-            (bytes_,) = struct.unpack('!B', keyptr[0:1])
-            keyptr = keyptr[1:]
-            if bytes_ == 0:
-                (bytes_,) = struct.unpack('!H', keyptr[0:2])
-                keyptr = keyptr[2:]
-            rsa_e = keyptr[0:bytes_]
-            rsa_n = keyptr[bytes_:]
-            try:
-                public_key = rsa.RSAPublicNumbers(
-                    _bytes_to_long(rsa_e),
-                    _bytes_to_long(rsa_n)).public_key(default_backend())
-            except ValueError:
-                raise ValidationFailure('invalid public key')
-            sig = rrsig.signature
-        elif _is_dsa(rrsig.algorithm):
-            keyptr = candidate_key.key
-            (t,) = struct.unpack('!B', keyptr[0:1])
-            keyptr = keyptr[1:]
-            octets = 64 + t * 8
-            dsa_q = keyptr[0:20]
-            keyptr = keyptr[20:]
-            dsa_p = keyptr[0:octets]
-            keyptr = keyptr[octets:]
-            dsa_g = keyptr[0:octets]
-            keyptr = keyptr[octets:]
-            dsa_y = keyptr[0:octets]
-            try:
-                public_key = dsa.DSAPublicNumbers(
-                    _bytes_to_long(dsa_y),
-                    dsa.DSAParameterNumbers(
-                        _bytes_to_long(dsa_p),
-                        _bytes_to_long(dsa_q),
-                        _bytes_to_long(dsa_g))).public_key(default_backend())
-            except ValueError:
-                raise ValidationFailure('invalid public key')
-            sig_r = rrsig.signature[1:21]
-            sig_s = rrsig.signature[21:]
-            sig = utils.encode_dss_signature(_bytes_to_long(sig_r),
-                                             _bytes_to_long(sig_s))
-        elif _is_ecdsa(rrsig.algorithm):
-            keyptr = candidate_key.key
-            if rrsig.algorithm == Algorithm.ECDSAP256SHA256:
-                curve = ec.SECP256R1()
-                octets = 32
-            else:
-                curve = ec.SECP384R1()
-                octets = 48
-            ecdsa_x = keyptr[0:octets]
-            ecdsa_y = keyptr[octets:octets * 2]
-            try:
-                public_key = ec.EllipticCurvePublicNumbers(
-                    curve=curve,
-                    x=_bytes_to_long(ecdsa_x),
-                    y=_bytes_to_long(ecdsa_y)).public_key(default_backend())
-            except ValueError:
-                raise ValidationFailure('invalid public key')
-            sig_r = rrsig.signature[0:octets]
-            sig_s = rrsig.signature[octets:]
-            sig = utils.encode_dss_signature(_bytes_to_long(sig_r),
-                                             _bytes_to_long(sig_s))
-
-        elif _is_eddsa(rrsig.algorithm):
-            keyptr = candidate_key.key
-            if rrsig.algorithm == Algorithm.ED25519:
-                loader = ed25519.Ed25519PublicKey
-            else:
-                loader = ed448.Ed448PublicKey
-            try:
-                public_key = loader.from_public_bytes(keyptr)
-            except ValueError:
-                raise ValidationFailure('invalid public key')
-            sig = rrsig.signature
-        elif _is_gost(rrsig.algorithm):
-            raise UnsupportedAlgorithm(
-                'algorithm "%s" not supported by dnspython' %
-                algorithm_to_text(rrsig.algorithm))
+    # For convenience, allow the rrset to be specified as a (name,
+    # rdataset) tuple as well as a proper rrset
+    if isinstance(rrset, tuple):
+        rrname = rrset[0]
+        rdataset = rrset[1]
+    else:
+        rrname = rrset.name
+        rdataset = rrset
+
+    if now is None:
+        now = time.time()
+    if rrsig.expiration < now:
+        raise ValidationFailure('expired')
+    if rrsig.inception > now:
+        raise ValidationFailure('not yet valid')
+
+    if _is_dsa(rrsig.algorithm):
+        sig_r = rrsig.signature[1:21]
+        sig_s = rrsig.signature[21:]
+        sig = utils.encode_dss_signature(_bytes_to_long(sig_r),
+                                         _bytes_to_long(sig_s))
+    elif _is_ecdsa(rrsig.algorithm):
+        if rrsig.algorithm == Algorithm.ECDSAP256SHA256:
+            octets = 32
         else:
-            raise ValidationFailure('unknown algorithm %u' % rrsig.algorithm)
-
-        data = b''
-        data += rrsig.to_wire(origin=origin)[:18]
-        data += rrsig.signer.to_digestable(origin)
-
-        if rrsig.labels < len(rrname) - 1:
-            suffix = rrname.split(rrsig.labels + 1)[1]
-            rrname = dns.name.from_text('*', suffix)
-        rrnamebuf = rrname.to_digestable(origin)
-        rrfixed = struct.pack('!HHI', rdataset.rdtype, rdataset.rdclass,
-                              rrsig.original_ttl)
-        rrlist = sorted(rdataset)
-        for rr in rrlist:
-            data += rrnamebuf
-            data += rrfixed
-            rrdata = rr.to_digestable(origin)
-            rrlen = struct.pack('!H', len(rrdata))
-            data += rrlen
-            data += rrdata
-
-        chosen_hash = _make_hash(rrsig.algorithm)
+            octets = 48
+        sig_r = rrsig.signature[0:octets]
+        sig_s = rrsig.signature[octets:]
+        sig = utils.encode_dss_signature(_bytes_to_long(sig_r),
+                                         _bytes_to_long(sig_s))
+    else:
+        sig = rrsig.signature
+
+    data = b''
+    data += rrsig.to_wire(origin=origin)[:18]
+    data += rrsig.signer.to_digestable(origin)
+
+    # Derelativize the name before considering labels.
+    rrname = rrname.derelativize(origin)
+
+    if len(rrname) - 1 < rrsig.labels:
+        raise ValidationFailure('owner name longer than RRSIG labels')
+    elif rrsig.labels < len(rrname) - 1:
+        suffix = rrname.split(rrsig.labels + 1)[1]
+        rrname = dns.name.from_text('*', suffix)
+    rrnamebuf = rrname.to_digestable()
+    rrfixed = struct.pack('!HHI', rdataset.rdtype, rdataset.rdclass,
+                          rrsig.original_ttl)
+    for rr in sorted(rdataset):
+        data += rrnamebuf
+        data += rrfixed
+        rrdata = rr.to_digestable(origin)
+        rrlen = struct.pack('!H', len(rrdata))
+        data += rrlen
+        data += rrdata
+
+    chosen_hash = _make_hash(rrsig.algorithm)
+
+    for candidate_key in candidate_keys:
         try:
-            if _is_rsa(rrsig.algorithm):
-                public_key.verify(sig, data, padding.PKCS1v15(), chosen_hash)
-            elif _is_dsa(rrsig.algorithm):
-                public_key.verify(sig, data, chosen_hash)
-            elif _is_ecdsa(rrsig.algorithm):
-                public_key.verify(sig, data, ec.ECDSA(chosen_hash))
-            elif _is_eddsa(rrsig.algorithm):
-                public_key.verify(sig, data)
-            else:
-                # Raise here for code clarity; this won't actually ever happen
-                # since if the algorithm is really unknown we'd already have
-                # raised an exception above
-                raise ValidationFailure('unknown algorithm %u' %
-                                        rrsig.algorithm)  # pragma: no cover
-            # If we got here, we successfully verified so we can return
-            # without error
+            _validate_signature(sig, data, candidate_key, chosen_hash)
             return
-        except InvalidSignature:
+        except (InvalidSignature, ValidationFailure):
             # this happens on an individual validation failure
             continue
     # nothing verified -- raise failure:
@@ -546,7 +537,7 @@ def nsec3_hash(domain, salt, iterations, algorithm):
     domain_encoded = domain.canonicalize().to_wire()
 
     digest = hashlib.sha1(domain_encoded + salt_encoded).digest()
-    for i in range(iterations):
+    for _ in range(iterations):
         digest = hashlib.sha1(digest + salt_encoded).digest()
 
     output = base64.b32encode(digest).decode("utf-8")
@@ -579,3 +570,25 @@ else:
     validate = _validate                # type: ignore
     validate_rrsig = _validate_rrsig    # type: ignore
     _have_pyca = True
+
+### BEGIN generated Algorithm constants
+
+RSAMD5 = Algorithm.RSAMD5
+DH = Algorithm.DH
+DSA = Algorithm.DSA
+ECC = Algorithm.ECC
+RSASHA1 = Algorithm.RSASHA1
+DSANSEC3SHA1 = Algorithm.DSANSEC3SHA1
+RSASHA1NSEC3SHA1 = Algorithm.RSASHA1NSEC3SHA1
+RSASHA256 = Algorithm.RSASHA256
+RSASHA512 = Algorithm.RSASHA512
+ECCGOST = Algorithm.ECCGOST
+ECDSAP256SHA256 = Algorithm.ECDSAP256SHA256
+ECDSAP384SHA384 = Algorithm.ECDSAP384SHA384
+ED25519 = Algorithm.ED25519
+ED448 = Algorithm.ED448
+INDIRECT = Algorithm.INDIRECT
+PRIVATEDNS = Algorithm.PRIVATEDNS
+PRIVATEOID = Algorithm.PRIVATEOID
+
+### END generated Algorithm constants
diff --git a/dns/edns.py b/dns/edns.py
index 28718d5..237178f 100644
--- a/dns/edns.py
+++ b/dns/edns.py
@@ -23,6 +23,8 @@ import struct
 
 import dns.enum
 import dns.inet
+import dns.rdata
+
 
 class OptionType(dns.enum.IntEnum):
     #: NSID
@@ -50,7 +52,6 @@ class OptionType(dns.enum.IntEnum):
     def _maximum(cls):
         return 65535
 
-globals().update(OptionType.__members__)
 
 class Option:
 
@@ -61,7 +62,7 @@ class Option:
 
         *otype*, an ``int``, is the option type.
         """
-        self.otype = otype
+        self.otype = OptionType.make(otype)
 
     def to_wire(self, file=None):
         """Convert an option to wire format.
@@ -149,7 +150,7 @@ class GenericOption(Option):
 
     def __init__(self, otype, data):
         super().__init__(otype)
-        self.data = data
+        self.data = dns.rdata.Rdata._as_bytes(data, True)
 
     def to_wire(self, file=None):
         if file:
@@ -186,12 +187,18 @@ class ECSOption(Option):
             self.family = 2
             if srclen is None:
                 srclen = 56
+            address = dns.rdata.Rdata._as_ipv6_address(address)
+            srclen = dns.rdata.Rdata._as_int(srclen, 0, 128)
+            scopelen = dns.rdata.Rdata._as_int(scopelen, 0, 128)
         elif af == socket.AF_INET:
             self.family = 1
             if srclen is None:
                 srclen = 24
-        else:
-            raise ValueError('Bad ip family')
+            address = dns.rdata.Rdata._as_ipv4_address(address)
+            srclen = dns.rdata.Rdata._as_int(srclen, 0, 32)
+            scopelen = dns.rdata.Rdata._as_int(scopelen, 0, 32)
+        else:  # pragma: no cover   (this will never happen)
+            raise ValueError('Bad address family')
 
         self.address = address
         self.srclen = srclen
@@ -342,3 +349,28 @@ def option_from_wire(otype, wire, current, olen):
     parser = dns.wire.Parser(wire, current)
     with parser.restrict_to(olen):
         return option_from_wire_parser(otype, parser)
+
+def register_type(implementation, otype):
+    """Register the implementation of an option type.
+
+    *implementation*, a ``class``, is a subclass of ``dns.edns.Option``.
+
+    *otype*, an ``int``, is the option type.
+    """
+
+    _type_to_class[otype] = implementation
+
+### BEGIN generated OptionType constants
+
+NSID = OptionType.NSID
+DAU = OptionType.DAU
+DHU = OptionType.DHU
+N3U = OptionType.N3U
+ECS = OptionType.ECS
+EXPIRE = OptionType.EXPIRE
+COOKIE = OptionType.COOKIE
+KEEPALIVE = OptionType.KEEPALIVE
+PADDING = OptionType.PADDING
+CHAIN = OptionType.CHAIN
+
+### END generated OptionType constants
diff --git a/dns/enum.py b/dns/enum.py
index 11536f2..b822dd5 100644
--- a/dns/enum.py
+++ b/dns/enum.py
@@ -75,7 +75,7 @@ class IntEnum(enum.IntEnum):
 
     @classmethod
     def _maximum(cls):
-        raise NotImplementedError
+        raise NotImplementedError  # pragma: no cover
 
     @classmethod
     def _short_name(cls):
diff --git a/dns/exception.py b/dns/exception.py
index 8f1d488..9392373 100644
--- a/dns/exception.py
+++ b/dns/exception.py
@@ -126,3 +126,17 @@ class Timeout(DNSException):
     """The DNS operation timed out."""
     supp_kwargs = {'timeout'}
     fmt = "The DNS operation timed out after {timeout} seconds"
+
+
+class ExceptionWrapper:
+    def __init__(self, exception_class):
+        self.exception_class = exception_class
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if exc_type is not None and not isinstance(exc_val,
+                                                   self.exception_class):
+            raise self.exception_class(str(exc_val)) from exc_val
+        return False
diff --git a/dns/flags.py b/dns/flags.py
index 4eb6d90..9652287 100644
--- a/dns/flags.py
+++ b/dns/flags.py
@@ -37,8 +37,6 @@ class Flag(enum.IntFlag):
     #: Checking Disabled
     CD = 0x0010
 
-globals().update(Flag.__members__)
-
 
 # EDNS flags
 
@@ -47,9 +45,6 @@ class EDNSFlag(enum.IntFlag):
     DO = 0x8000
 
 
-globals().update(EDNSFlag.__members__)
-
-
 def _from_text(text, enum_class):
     flags = 0
     tokens = text.split()
@@ -104,3 +99,21 @@ def edns_to_text(flags):
     """
 
     return _to_text(flags, EDNSFlag)
+
+### BEGIN generated Flag constants
+
+QR = Flag.QR
+AA = Flag.AA
+TC = Flag.TC
+RD = Flag.RD
+RA = Flag.RA
+AD = Flag.AD
+CD = Flag.CD
+
+### END generated Flag constants
+
+### BEGIN generated EDNSFlag constants
+
+DO = EDNSFlag.DO
+
+### END generated EDNSFlag constants
diff --git a/dns/grange.py b/dns/grange.py
index ffe8be7..112ede4 100644
--- a/dns/grange.py
+++ b/dns/grange.py
@@ -28,11 +28,12 @@ def from_text(text):
     Returns a tuple of three ``int`` values ``(start, stop, step)``.
     """
 
-    # TODO, figure out the bounds on start, stop and step.
+    start = -1
+    stop = -1
     step = 1
     cur = ''
     state = 0
-    # state   0 1 2 3 4
+    # state   0   1   2
     #         x - y / z
 
     if text and text[0] == '-':
@@ -42,28 +43,27 @@ def from_text(text):
         if c == '-' and state == 0:
             start = int(cur)
             cur = ''
-            state = 2
+            state = 1
         elif c == '/':
             stop = int(cur)
             cur = ''
-            state = 4
+            state = 2
         elif c.isdigit():
             cur += c
         else:
             raise dns.exception.SyntaxError("Could not parse %s" % (c))
 
-    if state in (1, 3):
-        raise dns.exception.SyntaxError()
-
-    if state == 2:
+    if state == 0:
+        raise dns.exception.SyntaxError("no stop value specified")
+    elif state == 1:
         stop = int(cur)
-
-    if state == 4:
+    else:
+        assert state == 2
         step = int(cur)
 
     assert step >= 1
     assert start >= 0
-    assert start <= stop
-    # TODO, can start == stop?
+    if start > stop:
+        raise dns.exception.SyntaxError('start must be <= stop')
 
     return (start, stop, step)
diff --git a/dns/immutable.py b/dns/immutable.py
new file mode 100644
index 0000000..db7abbc
--- /dev/null
+++ b/dns/immutable.py
@@ -0,0 +1,70 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import collections.abc
+import sys
+
+# pylint: disable=unused-import
+if sys.version_info >= (3, 7):
+    odict = dict
+    from dns._immutable_ctx import immutable
+else:
+    # pragma: no cover
+    from collections import OrderedDict as odict
+    from dns._immutable_attr import immutable  # noqa
+# pylint: enable=unused-import
+
+
+@immutable
+class Dict(collections.abc.Mapping):
+    def __init__(self, dictionary, no_copy=False):
+        """Make an immutable dictionary from the specified dictionary.
+
+        If *no_copy* is `True`, then *dictionary* will be wrapped instead
+        of copied.  Only set this if you are sure there will be no external
+        references to the dictionary.
+        """
+        if no_copy and isinstance(dictionary, odict):
+            self._odict = dictionary
+        else:
+            self._odict = odict(dictionary)
+        self._hash = None
+
+    def __getitem__(self, key):
+        return self._odict.__getitem__(key)
+
+    def __hash__(self):  # pylint: disable=invalid-hash-returned
+        if self._hash is None:
+            h = 0
+            for key in sorted(self._odict.keys()):
+                h ^= hash(key)
+            object.__setattr__(self, '_hash', h)
+        # this does return an int, but pylint doesn't figure that out
+        return self._hash
+
+    def __len__(self):
+        return len(self._odict)
+
+    def __iter__(self):
+        return iter(self._odict)
+
+
+def constify(o):
+    """
+    Convert mutable types to immutable types.
+    """
+    if isinstance(o, bytearray):
+        return bytes(o)
+    if isinstance(o, tuple):
+        try:
+            hash(o)
+            return o
+        except Exception:
+            return tuple(constify(elt) for elt in o)
+    if isinstance(o, list):
+        return tuple(constify(elt) for elt in o)
+    if isinstance(o, dict):
+        cdict = odict()
+        for k, v in o.items():
+            cdict[k] = constify(v)
+        return Dict(cdict, True)
+    return o
diff --git a/dns/inet.py b/dns/inet.py
index 25d99c2..d3bdc64 100644
--- a/dns/inet.py
+++ b/dns/inet.py
@@ -162,7 +162,7 @@ def low_level_address_tuple(high_tuple, af=None):
             return (addrpart, port, 0, int(scope))
         try:
             return (addrpart, port, 0, socket.if_nametoindex(scope))
-        except AttributeError:
+        except AttributeError:  # pragma: no cover  (we can't really test this)
             ai_flags = socket.AI_NUMERICHOST
             ((*_, tup), *_) = socket.getaddrinfo(address, port, flags=ai_flags)
             return tup
diff --git a/dns/ipv6.py b/dns/ipv6.py
index 5424fce..f0e522c 100644
--- a/dns/ipv6.py
+++ b/dns/ipv6.py
@@ -121,7 +121,13 @@ def inet_aton(text, ignore_scope=False):
         elif l > 2:
             raise dns.exception.SyntaxError
 
-    if text == b'::':
+    if text == b'':
+        raise dns.exception.SyntaxError
+    elif text.endswith(b':') and not text.endswith(b'::'):
+        raise dns.exception.SyntaxError
+    elif text.startswith(b':') and not text.startswith(b'::'):
+        raise dns.exception.SyntaxError
+    elif text == b'::':
         text = b'0::'
     #
     # Get rid of the icky dot-quad syntax if we have it.
@@ -157,7 +163,7 @@ def inet_aton(text, ignore_scope=False):
             if seen_empty:
                 raise dns.exception.SyntaxError
             seen_empty = True
-            for i in range(0, 8 - l + 1):
+            for _ in range(0, 8 - l + 1):
                 canonical.append(b'0000')
         else:
             lc = len(c)
diff --git a/dns/message.py b/dns/message.py
index 60b74c1..2a7565a 100644
--- a/dns/message.py
+++ b/dns/message.py
@@ -35,6 +35,7 @@ import dns.rdataclass
 import dns.rdatatype
 import dns.rrset
 import dns.renderer
+import dns.ttl
 import dns.tsig
 import dns.rdtypes.ANY.OPT
 import dns.rdtypes.ANY.TSIG
@@ -80,6 +81,21 @@ class Truncated(dns.exception.DNSException):
         return self.kwargs['message']
 
 
+class NotQueryResponse(dns.exception.DNSException):
+    """Message is not a response to a query."""
+
+
+class ChainTooLong(dns.exception.DNSException):
+    """The CNAME chain is too long."""
+
+
+class AnswerForNXDOMAIN(dns.exception.DNSException):
+    """The rcode is NXDOMAIN but an answer was found."""
+
+class NoPreviousName(dns.exception.SyntaxError):
+    """No previous name was known."""
+
+
 class MessageSection(dns.enum.IntEnum):
     """Message sections"""
     QUESTION = 0
@@ -91,8 +107,9 @@ class MessageSection(dns.enum.IntEnum):
     def _maximum(cls):
         return 3
 
-globals().update(MessageSection.__members__)
 
+DEFAULT_EDNS_PAYLOAD = 1232
+MAX_CHAIN = 16
 
 class Message:
     """A DNS message."""
@@ -169,10 +186,8 @@ class Message:
 
         s = io.StringIO()
         s.write('id %d\n' % self.id)
-        s.write('opcode %s\n' %
-                dns.opcode.to_text(dns.opcode.from_flags(self.flags)))
-        rc = dns.rcode.from_flags(self.flags, self.ednsflags)
-        s.write('rcode %s\n' % dns.rcode.to_text(rc))
+        s.write('opcode %s\n' % dns.opcode.to_text(self.opcode()))
+        s.write('rcode %s\n' % dns.rcode.to_text(self.rcode()))
         s.write('flags %s\n' % dns.flags.to_text(self.flags))
         if self.edns >= 0:
             s.write('edns %s\n' % self.edns)
@@ -231,9 +246,13 @@ class Message:
            dns.opcode.from_flags(self.flags) != \
            dns.opcode.from_flags(other.flags):
             return False
-        if dns.rcode.from_flags(other.flags, other.ednsflags) != \
-                dns.rcode.NOERROR:
-            return True
+        if other.rcode() in {dns.rcode.FORMERR, dns.rcode.SERVFAIL,
+                             dns.rcode.NOTIMP, dns.rcode.REFUSED}:
+            # We don't check the question section in these cases if
+            # the other question section is empty, even though they
+            # still really ought to have a question section.
+            if len(other.question) == 0:
+                return True
         if dns.opcode.is_update(self.flags):
             # This is assuming the "sender doesn't include anything
             # from the update", but we don't care to check the other
@@ -330,7 +349,8 @@ class Message:
                     return rrset
             else:
                 for rrset in section:
-                    if rrset.match(name, rdclass, rdtype, covers, deleting):
+                    if rrset.full_match(name, rdclass, rdtype, covers,
+                                        deleting):
                         return rrset
         if not create:
             raise KeyError
@@ -403,8 +423,8 @@ class Message:
         *multi*, a ``bool``, should be set to ``True`` if this message is
         part of a multiple message sequence.
 
-        *tsig_ctx*, a ``hmac.HMAC`` object, the ongoing TSIG context, used
-        when signing zone transfers.
+        *tsig_ctx*, a ``dns.tsig.HMACTSig`` or ``dns.tsig.GSSTSig`` object, the
+        ongoing TSIG context, used when signing zone transfers.
 
         Raises ``dns.exception.TooBig`` if *max_size* was exceeded.
 
@@ -467,8 +487,8 @@ class Message:
         *key*, a ``dns.tsig.Key`` is the key to use.  If a key is specified,
         the *keyring* and *algorithm* fields are not used.
 
-        *keyring*, a ``dict`` or ``dns.tsig.Key``, is either the TSIG
-        keyring or key to use.
+        *keyring*, a ``dict``, ``callable`` or ``dns.tsig.Key``, is either
+        the TSIG keyring or key to use.
 
         The format of a keyring dict is a mapping from TSIG key name, as
         ``dns.name.Name`` to ``dns.tsig.Key`` or a TSIG secret, a ``bytes``.
@@ -476,7 +496,9 @@ class Message:
         used will be the first key in the *keyring*.  Note that the order of
         keys in a dictionary is not defined, so applications should supply a
         keyname when a ``dict`` keyring is used, unless they know the keyring
-        contains only one key.
+        contains only one key.  If a ``callable`` keyring is specified, the
+        callable will be called with the message and the keyname, and is
+        expected to return a key.
 
         *keyname*, a ``dns.name.Name``, ``str`` or ``None``, the name of
         thes TSIG key to use; defaults to ``None``.  If *keyring* is a
@@ -497,7 +519,10 @@ class Message:
         """
 
         if isinstance(keyring, dns.tsig.Key):
-            self.keyring = keyring
+            key = keyring
+            keyname = key.name
+        elif callable(keyring):
+            key = keyring(self, keyname)
         else:
             if isinstance(keyname, str):
                 keyname = dns.name.from_text(keyname)
@@ -506,7 +531,7 @@ class Message:
             key = keyring[keyname]
             if isinstance(key, bytes):
                 key = dns.tsig.Key(keyname, key, algorithm)
-            self.keyring = key
+        self.keyring = key
         if original_id is None:
             original_id = self.id
         self.tsig = self._make_tsig(keyname, self.keyring.algorithm, 0, fudge,
@@ -545,13 +570,13 @@ class Message:
         return bool(self.tsig)
 
     @staticmethod
-    def _make_opt(flags=0, payload=1280, options=None):
+    def _make_opt(flags=0, payload=DEFAULT_EDNS_PAYLOAD, options=None):
         opt = dns.rdtypes.ANY.OPT.OPT(payload, dns.rdatatype.OPT,
                                       options or ())
         return dns.rrset.from_rdata(dns.name.root, int(flags), opt)
 
-    def use_edns(self, edns=0, ednsflags=0, payload=1280, request_payload=None,
-                 options=None):
+    def use_edns(self, edns=0, ednsflags=0, payload=DEFAULT_EDNS_PAYLOAD,
+                 request_payload=None, options=None):
         """Configure EDNS behavior.
 
         *edns*, an ``int``, is the EDNS level to use.  Specifying
@@ -575,26 +600,21 @@ class Message:
 
         if edns is None or edns is False:
             edns = -1
-        if edns is True:
+        elif edns is True:
             edns = 0
-        if request_payload is None:
-            request_payload = payload
         if edns < 0:
-            ednsflags = 0
-            payload = 0
-            request_payload = 0
-            options = []
+            self.opt = None
+            self.request_payload = 0
         else:
             # make sure the EDNS version in ednsflags agrees with edns
             ednsflags &= 0xFF00FFFF
             ednsflags |= (edns << 16)
             if options is None:
                 options = []
-        if edns >= 0:
             self.opt = self._make_opt(ednsflags, payload, options)
-        else:
-            self.opt = None
-        self.request_payload = request_payload
+            if request_payload is None:
+                request_payload = payload
+            self.request_payload = request_payload
 
     @property
     def edns(self):
@@ -650,7 +670,7 @@ class Message:
 
         Returns an ``int``.
         """
-        return dns.rcode.from_flags(self.flags, self.ednsflags)
+        return dns.rcode.from_flags(int(self.flags), int(self.ednsflags))
 
     def set_rcode(self, rcode):
         """Set the rcode.
@@ -668,7 +688,7 @@ class Message:
 
         Returns an ``int``.
         """
-        return dns.opcode.from_flags(self.flags)
+        return dns.opcode.from_flags(int(self.flags))
 
     def set_opcode(self, opcode):
         """Set the opcode.
@@ -682,9 +702,13 @@ class Message:
         # What the caller picked is fine.
         return value
 
+    # pylint: disable=unused-argument
+
     def _parse_rr_header(self, section, name, rdclass, rdtype):
         return (rdclass, rdtype, None, False)
 
+    # pylint: enable=unused-argument
+
     def _parse_special_rr_header(self, section, count, position,
                                  name, rdclass, rdtype):
         if rdtype == dns.rdatatype.OPT:
@@ -699,14 +723,129 @@ class Message:
         return (rdclass, rdtype, None, False)
 
 
+class ChainingResult:
+    """The result of a call to dns.message.QueryMessage.resolve_chaining().
+
+    The ``answer`` attribute is the answer RRSet, or ``None`` if it doesn't
+    exist.
+
+    The ``canonical_name`` attribute is the canonical name after all
+    chaining has been applied (this is the name as ``rrset.name`` in cases
+    where rrset is not ``None``).
+
+    The ``minimum_ttl`` attribute is the minimum TTL, i.e. the TTL to
+    use if caching the data.  It is the smallest of all the CNAME TTLs
+    and either the answer TTL if it exists or the SOA TTL and SOA
+    minimum values for negative answers.
+
+    The ``cnames`` attribute is a list of all the CNAME RRSets followed to
+    get to the canonical name.
+    """
+    def __init__(self, canonical_name, answer, minimum_ttl, cnames):
+        self.canonical_name = canonical_name
+        self.answer = answer
+        self.minimum_ttl = minimum_ttl
+        self.cnames = cnames
+
+
 class QueryMessage(Message):
-    pass
+    def resolve_chaining(self):
+        """Follow the CNAME chain in the response to determine the answer
+        RRset.
+
+        Raises ``dns.message.NotQueryResponse`` if the message is not
+        a response.
+
+        Raises ``dns.message.ChainTooLong`` if the CNAME chain is too long.
+
+        Raises ``dns.message.AnswerForNXDOMAIN`` if the rcode is NXDOMAIN
+        but an answer was found.
+
+        Raises ``dns.exception.FormError`` if the question count is not 1.
+
+        Returns a ChainingResult object.
+        """
+        if self.flags & dns.flags.QR == 0:
+            raise NotQueryResponse
+        if len(self.question) != 1:
+            raise dns.exception.FormError
+        question = self.question[0]
+        qname = question.name
+        min_ttl = dns.ttl.MAX_TTL
+        answer = None
+        count = 0
+        cnames = []
+        while count < MAX_CHAIN:
+            try:
+                answer = self.find_rrset(self.answer, qname, question.rdclass,
+                                         question.rdtype)
+                min_ttl = min(min_ttl, answer.ttl)
+                break
+            except KeyError:
+                if question.rdtype != dns.rdatatype.CNAME:
+                    try:
+                        crrset = self.find_rrset(self.answer, qname,
+                                                 question.rdclass,
+                                                 dns.rdatatype.CNAME)
+                        cnames.append(crrset)
+                        min_ttl = min(min_ttl, crrset.ttl)
+                        for rd in crrset:
+                            qname = rd.target
+                            break
+                        count += 1
+                        continue
+                    except KeyError:
+                        # Exit the chaining loop
+                        break
+                else:
+                    # Exit the chaining loop
+                    break
+        if count >= MAX_CHAIN:
+            raise ChainTooLong
+        if self.rcode() == dns.rcode.NXDOMAIN and answer is not None:
+            raise AnswerForNXDOMAIN
+        if answer is None:
+            # Further minimize the TTL with NCACHE.
+            auname = qname
+            while True:
+                # Look for an SOA RR whose owner name is a superdomain
+                # of qname.
+                try:
+                    srrset = self.find_rrset(self.authority, auname,
+                                             question.rdclass,
+                                             dns.rdatatype.SOA)
+                    min_ttl = min(min_ttl, srrset.ttl, srrset[0].minimum)
+                    break
+                except KeyError:
+                    try:
+                        auname = auname.parent()
+                    except dns.name.NoParent:
+                        break
+        return ChainingResult(qname, answer, min_ttl, cnames)
+
+    def canonical_name(self):
+        """Return the canonical name of the first name in the question
+        section.
+
+        Raises ``dns.message.NotQueryResponse`` if the message is not
+        a response.
+
+        Raises ``dns.message.ChainTooLong`` if the CNAME chain is too long.
+
+        Raises ``dns.message.AnswerForNXDOMAIN`` if the rcode is NXDOMAIN
+        but an answer was found.
+
+        Raises ``dns.exception.FormError`` if the question count is not 1.
+        """
+        return self.resolve_chaining().canonical_name
 
 
 def _maybe_import_update():
     # We avoid circular imports by doing this here.  We do it in another
     # function as doing it in _message_factory_from_opcode() makes "dns"
     # a local symbol, and the first line fails :)
+
+    # pylint: disable=redefined-outer-name,import-outside-toplevel,unused-import
     import dns.update  # noqa: F401
 
 
@@ -753,7 +892,7 @@ class _WireReader:
         """
 
         section = self.message.sections[section_number]
-        for i in range(qcount):
+        for _ in range(qcount):
             qname = self.parser.get_name(self.message.origin)
             (rdtype, rdclass) = self.parser.get_struct('!HH')
             (rdclass, rdtype, _, _) = \
@@ -811,6 +950,8 @@ class _WireReader:
                     key = self.keyring.get(absolute_name)
                     if isinstance(key, bytes):
                         key = dns.tsig.Key(absolute_name, key, rd.algorithm)
+                elif callable(self.keyring):
+                    key = self.keyring(self.message, absolute_name)
                 else:
                     key = self.keyring
                 if key is None:
@@ -847,13 +988,13 @@ class _WireReader:
             self.parser.get_struct('!HHHHHH')
         factory = _message_factory_from_opcode(dns.opcode.from_flags(flags))
         self.message = factory(id=id)
-        self.message.flags = flags
+        self.message.flags = dns.flags.Flag(flags)
         self.initialize_message(self.message)
         self.one_rr_per_rrset = \
             self.message._get_one_rr_per_rrset(self.one_rr_per_rrset)
         self._get_question(MessageSection.QUESTION, qcount)
         if self.question_only:
-            return
+            return self.message
         self._get_section(MessageSection.ANSWER, ancount)
         self._get_section(MessageSection.AUTHORITY, aucount)
         self._get_section(MessageSection.ADDITIONAL, adcount)
@@ -885,8 +1026,8 @@ def from_wire(wire, keyring=None, request_mac=b'', xfr=False, origin=None,
     of a zone transfer, *origin* should be the origin name of the
     zone.  If not ``None``, names will be relativized to the origin.
 
-    *tsig_ctx*, a ``hmac.HMAC`` object, the ongoing TSIG context, used
-    when validating zone transfers.
+    *tsig_ctx*, a ``dns.tsig.HMACTSig`` or ``dns.tsig.GSSTSig`` object, the
+    ongoing TSIG context, used when validating zone transfers.
 
     *multi*, a ``bool``, should be set to ``True`` if this message is
     part of a multiple message sequence.
@@ -971,12 +1112,12 @@ class _TextReader:
         self.id = None
         self.edns = -1
         self.ednsflags = 0
-        self.payload = None
+        self.payload = DEFAULT_EDNS_PAYLOAD
         self.rcode = None
         self.opcode = dns.opcode.QUERY
         self.flags = 0
 
-    def _header_line(self, section):
+    def _header_line(self, _):
         """Process one line from the text format header section."""
 
         token = self.tok.get()
@@ -1028,6 +1169,8 @@ class _TextReader:
                                               self.relativize,
                                               self.relativize_to)
         name = self.last_name
+        if name is None:
+            raise NoPreviousName
         token = self.tok.get()
         if not token.is_identifier():
             raise dns.exception.SyntaxError
@@ -1062,6 +1205,8 @@ class _TextReader:
                                               self.relativize,
                                               self.relativize_to)
         name = self.last_name
+        if name is None:
+            raise NoPreviousName
         token = self.tok.get()
         if not token.is_identifier():
             raise dns.exception.SyntaxError
@@ -1092,6 +1237,8 @@ class _TextReader:
         token = self.tok.get()
         if empty and not token.is_eol_or_eof():
             raise dns.exception.SyntaxError
+        if not empty and token.is_eol_or_eof():
+            raise dns.exception.UnexpectedEnd
         if not token.is_eol_or_eof():
             self.tok.unget(token)
             rd = dns.rdata.from_text(rdclass, rdtype, self.tok,
@@ -1292,20 +1439,14 @@ def make_query(qname, rdtype, rdclass=dns.rdataclass.IN, use_edns=None,
     kwargs = {}
     if ednsflags is not None:
         kwargs['ednsflags'] = ednsflags
-        if use_edns is None:
-            use_edns = 0
     if payload is not None:
         kwargs['payload'] = payload
-        if use_edns is None:
-            use_edns = 0
     if request_payload is not None:
         kwargs['request_payload'] = request_payload
-        if use_edns is None:
-            use_edns = 0
     if options is not None:
         kwargs['options'] = options
-        if use_edns is None:
-            use_edns = 0
+    if kwargs and use_edns is None:
+        use_edns = 0
     kwargs['edns'] = use_edns
     m.use_edns(**kwargs)
     m.want_dnssec(want_dnssec)
@@ -1355,3 +1496,12 @@ def make_response(query, recursion_available=False, our_payload=8192,
                           tsig_error, b'', query.keyalgorithm)
         response.request_mac = query.mac
     return response
+
+### BEGIN generated MessageSection constants
+
+QUESTION = MessageSection.QUESTION
+ANSWER = MessageSection.ANSWER
+AUTHORITY = MessageSection.AUTHORITY
+ADDITIONAL = MessageSection.ADDITIONAL
+
+### END generated MessageSection constants
diff --git a/dns/message.pyi b/dns/message.pyi
index c939b52..252a411 100644
--- a/dns/message.pyi
+++ b/dns/message.pyi
@@ -1,5 +1,5 @@
 from typing import Optional, Dict, List, Tuple, Union
-from . import name, rrset, tsig, rdatatype, entropy, edns, rdataclass
+from . import name, rrset, tsig, rdatatype, entropy, edns, rdataclass, rcode
 import hmac
 
 class Message:
@@ -26,11 +26,14 @@ class Message:
     def is_response(self, other : Message) -> bool:
         ...
 
+    def set_rcode(self, rcode : rcode.Rcode):
+        ...
+
 def from_text(a : str, idna_codec : Optional[name.IDNACodec] = None) -> Message:
     ...
 
 def from_wire(wire, keyring : Optional[Dict[name.Name,bytes]] = None, request_mac = b'', xfr=False, origin=None,
-              tsig_ctx : Optional[hmac.HMAC] = None, multi=False,
+              tsig_ctx : Optional[Union[dns.tsig.HMACTSig, dns.tsig.GSSTSig]] = None, multi=False,
               question_only=False, one_rr_per_rrset=False,
               ignore_trailing=False) -> Message:
     ...
diff --git a/dns/name.py b/dns/name.py
index 529ae7f..8905d70 100644
--- a/dns/name.py
+++ b/dns/name.py
@@ -30,6 +30,7 @@ except ImportError:  # pragma: no cover
 
 import dns.wire
 import dns.exception
+import dns.immutable
 
 # fullcompare() result values
 
@@ -215,9 +216,10 @@ class IDNA2008Codec(IDNACodec):
         if not have_idna_2008:
             raise NoIDNA2008
         try:
+            ulabel = idna.ulabel(label)
             if self.uts_46:
-                label = idna.uts46_remap(label, False, False)
-            return _escapify(idna.ulabel(label))
+                ulabel = idna.uts46_remap(ulabel, False, self.transitional)
+            return _escapify(ulabel)
         except (idna.IDNAError, UnicodeError) as e:
             raise IDNAException(idna_exception=e)
 
@@ -304,6 +306,7 @@ def _maybe_convert_to_binary(label):
     raise ValueError  # pragma: no cover
 
 
+@dns.immutable.immutable
 class Name:
 
     """A DNS name.
@@ -320,17 +323,9 @@ class Name:
         """
 
         labels = [_maybe_convert_to_binary(x) for x in labels]
-        super().__setattr__('labels', tuple(labels))
+        self.labels = tuple(labels)
         _validate_labels(self.labels)
 
-    def __setattr__(self, name, value):
-        # Names are immutable
-        raise TypeError("object doesn't support attribute assignment")
-
-    def __delattr__(self, name):
-        # Names are immutable
-        raise TypeError("object doesn't support attribute deletion")
-
     def __copy__(self):
         return Name(self.labels)
 
@@ -458,7 +453,7 @@ class Name:
         Returns a ``bool``.
         """
 
-        (nr, o, nl) = self.fullcompare(other)
+        (nr, _, _) = self.fullcompare(other)
         if nr == NAMERELN_SUBDOMAIN or nr == NAMERELN_EQUAL:
             return True
         return False
@@ -472,7 +467,7 @@ class Name:
         Returns a ``bool``.
         """
 
-        (nr, o, nl) = self.fullcompare(other)
+        (nr, _, _) = self.fullcompare(other)
         if nr == NAMERELN_SUPERDOMAIN or nr == NAMERELN_EQUAL:
             return True
         return False
diff --git a/dns/name.pyi b/dns/name.pyi
index 446600b..c48d4bd 100644
--- a/dns/name.pyi
+++ b/dns/name.pyi
@@ -11,22 +11,24 @@ class Name:
     def is_wild(self) -> bool: ...
     def fullcompare(self, other) -> Tuple[int,int,int]: ...
     def canonicalize(self) -> Name: ...
-    def __lt__(self, other : Name): ...
-    def __le__(self, other : Name): ...
-    def __ge__(self, other : Name): ...
-    def __gt__(self, other : Name): ...
+    def __eq__(self, other) -> bool: ...
+    def __ne__(self, other) -> bool: ...
+    def __lt__(self, other : Name) -> bool: ...
+    def __le__(self, other : Name) -> bool: ...
+    def __ge__(self, other : Name) -> bool: ...
+    def __gt__(self, other : Name) -> bool: ...
     def to_text(self, omit_final_dot=False) -> str: ...
     def to_unicode(self, omit_final_dot=False, idna_codec=None) -> str: ...
     def to_digestable(self, origin=None) -> bytes: ...
     def to_wire(self, file=None, compress=None, origin=None,
                 canonicalize=False) -> Optional[bytes]: ...
-    def __add__(self, other : Name): ...
-    def __sub__(self, other : Name): ...
+    def __add__(self, other : Name) -> Name: ...
+    def __sub__(self, other : Name) -> Name: ...
     def split(self, depth) -> List[Tuple[str,str]]: ...
     def concatenate(self, other : Name) -> Name: ...
-    def relativize(self, origin): ...
-    def derelativize(self, origin): ...
-    def choose_relativity(self, origin : Optional[Name] = None, relativize=True): ...
+    def relativize(self, origin) -> Name: ...
+    def derelativize(self, origin) -> Name: ...
+    def choose_relativity(self, origin : Optional[Name] = None, relativize=True) -> Name: ...
     def parent(self) -> Name: ...
 
 class IDNACodec:
diff --git a/dns/namedict.py b/dns/namedict.py
index 4c8f9ab..ec0750c 100644
--- a/dns/namedict.py
+++ b/dns/namedict.py
@@ -85,7 +85,7 @@ class NameDict(MutableMapping):
         return key in self.__store
 
     def get_deepest_match(self, name):
-        """Find the deepest match to *fname* in the dictionary.
+        """Find the deepest match to *name* in the dictionary.
 
         The deepest match is the longest name in the dictionary which is
         a superdomain of *name*.  Note that *superdomain* includes matching
diff --git a/dns/node.py b/dns/node.py
index b7e21b5..b1baf27 100644
--- a/dns/node.py
+++ b/dns/node.py
@@ -180,6 +180,10 @@ class Node:
 
         if not isinstance(replacement, dns.rdataset.Rdataset):
             raise ValueError('replacement is not an rdataset')
+        if isinstance(replacement, dns.rrset.RRset):
+            # RRsets are not good replacements as the match() method
+            # is not compatible.
+            replacement = replacement.to_rdataset()
         self.delete_rdataset(replacement.rdclass, replacement.rdtype,
                              replacement.covers)
         self.rdatasets.append(replacement)
diff --git a/dns/opcode.py b/dns/opcode.py
index 5a76326..5cf6143 100644
--- a/dns/opcode.py
+++ b/dns/opcode.py
@@ -40,8 +40,6 @@ class Opcode(dns.enum.IntEnum):
     def _unknown_exception_class(cls):
         return UnknownOpcode
 
-globals().update(Opcode.__members__)
-
 
 class UnknownOpcode(dns.exception.DNSException):
     """An DNS opcode is unknown."""
@@ -105,3 +103,13 @@ def is_update(flags):
     """
 
     return from_flags(flags) == Opcode.UPDATE
+
+### BEGIN generated Opcode constants
+
+QUERY = Opcode.QUERY
+IQUERY = Opcode.IQUERY
+STATUS = Opcode.STATUS
+NOTIFY = Opcode.NOTIFY
+UPDATE = Opcode.UPDATE
+
+### END generated Opcode constants
diff --git a/dns/query.py b/dns/query.py
index 7df565d..bd62a7a 100644
--- a/dns/query.py
+++ b/dns/query.py
@@ -18,9 +18,10 @@
 """Talk to a DNS server."""
 
 import contextlib
+import enum
 import errno
 import os
-import select
+import selectors
 import socket
 import struct
 import time
@@ -35,6 +36,7 @@ import dns.rcode
 import dns.rdataclass
 import dns.rdatatype
 import dns.serial
+import dns.xfr
 
 try:
     import requests
@@ -73,20 +75,15 @@ class BadResponse(dns.exception.FormError):
     """A DNS query response does not respond to the question asked."""
 
 
-class TransferError(dns.exception.DNSException):
-    """A zone transfer response got a non-zero rcode."""
-
-    def __init__(self, rcode):
-        message = 'Zone transfer error: %s' % dns.rcode.to_text(rcode)
-        super().__init__(message)
-        self.rcode = rcode
-
-
 class NoDOH(dns.exception.DNSException):
     """DNS over HTTPS (DOH) was requested but the requests module is not
     available."""
 
 
+# for backwards compatibility
+TransferError = dns.xfr.TransferError
+
+
 def _compute_times(timeout):
     now = time.time()
     if timeout is None:
@@ -94,91 +91,49 @@ def _compute_times(timeout):
     else:
         return (now, now + timeout)
 
-# This module can use either poll() or select() as the "polling backend".
-#
-# A backend function takes an fd, bools for readability, writablity, and
-# error detection, and a timeout.
-
-def _poll_for(fd, readable, writable, error, timeout):
-    """Poll polling backend."""
-
-    event_mask = 0
-    if readable:
-        event_mask |= select.POLLIN
-    if writable:
-        event_mask |= select.POLLOUT
-    if error:
-        event_mask |= select.POLLERR
-
-    pollable = select.poll()
-    pollable.register(fd, event_mask)
-
-    if timeout:
-        event_list = pollable.poll(timeout * 1000)
-    else:
-        event_list = pollable.poll()
-
-    return bool(event_list)
-
 
-def _select_for(fd, readable, writable, error, timeout):
-    """Select polling backend."""
-
-    rset, wset, xset = [], [], []
+def _wait_for(fd, readable, writable, _, expiration):
+    # Use the selected selector class to wait for any of the specified
+    # events.  An "expiration" absolute time is converted into a relative
+    # timeout.
+    #
+    # The unused parameter is 'error', which is always set when
+    # selecting for read or write, and we have no error-only selects.
 
+    if readable and isinstance(fd, ssl.SSLSocket) and fd.pending() > 0:
+        return True
+    sel = _selector_class()
+    events = 0
     if readable:
-        rset = [fd]
+        events |= selectors.EVENT_READ
     if writable:
-        wset = [fd]
-    if error:
-        xset = [fd]
-
-    if timeout is None:
-        (rcount, wcount, xcount) = select.select(rset, wset, xset)
+        events |= selectors.EVENT_WRITE
+    if events:
+        sel.register(fd, events)
+    if expiration is None:
+        timeout = None
     else:
-        (rcount, wcount, xcount) = select.select(rset, wset, xset, timeout)
-
-    return bool((rcount or wcount or xcount))
-
-
-def _wait_for(fd, readable, writable, error, expiration):
-    # Use the selected polling backend to wait for any of the specified
-    # events.  An "expiration" absolute time is converted into a relative
-    # timeout.
-
-    done = False
-    while not done:
-        if expiration is None:
-            timeout = None
-        else:
-            timeout = expiration - time.time()
-            if timeout <= 0.0:
-                raise dns.exception.Timeout
-        try:
-            if isinstance(fd, ssl.SSLSocket) and readable and fd.pending() > 0:
-                return True
-            if not _polling_backend(fd, readable, writable, error, timeout):
-                raise dns.exception.Timeout
-        except OSError as e:  # pragma: no cover
-            if e.args[0] != errno.EINTR:
-                raise e
-        done = True
+        timeout = expiration - time.time()
+        if timeout <= 0.0:
+            raise dns.exception.Timeout
+    if not sel.select(timeout):
+        raise dns.exception.Timeout
 
 
-def _set_polling_backend(fn):
+def _set_selector_class(selector_class):
     # Internal API. Do not use.
 
-    global _polling_backend
+    global _selector_class
 
-    _polling_backend = fn
+    _selector_class = selector_class
 
-if hasattr(select, 'poll'):
+if hasattr(selectors, 'PollSelector'):
     # Prefer poll() on platforms that support it because it has no
     # limits on the maximum value of a file descriptor (plus it will
     # be more efficient for high values).
-    _polling_backend = _poll_for
+    _selector_class = selectors.PollSelector
 else:
-    _polling_backend = _select_for  # pragma: no cover
+    _selector_class = selectors.SelectSelector  # pragma: no cover
 
 
 def _wait_for_readable(s, expiration):
@@ -323,27 +278,24 @@ def https(q, where, timeout=None, port=443, source=None, source_port=0,
         raise NoDOH  # pragma: no cover
 
     wire = q.to_wire()
-    (af, destination, source) = _destination_and_source(where, port,
-                                                        source, source_port,
-                                                        False)
+    (af, _, source) = _destination_and_source(where, port, source, source_port,
+                                              False)
     transport_adapter = None
     headers = {
         "accept": "application/dns-message"
     }
-    try:
-        where_af = dns.inet.af_for_address(where)
-        if where_af == socket.AF_INET:
+    if af is not None:
+        if af == socket.AF_INET:
             url = 'https://{}:{}{}'.format(where, port, path)
-        elif where_af == socket.AF_INET6:
+        elif af == socket.AF_INET6:
             url = 'https://[{}]:{}{}'.format(where, port, path)
-    except ValueError:
-        if bootstrap_address is not None:
-            split_url = urllib.parse.urlsplit(where)
-            headers['Host'] = split_url.hostname
-            url = where.replace(split_url.hostname, bootstrap_address)
-            transport_adapter = HostHeaderSSLAdapter()
-        else:
-            url = where
+    elif bootstrap_address is not None:
+        split_url = urllib.parse.urlsplit(where)
+        headers['Host'] = split_url.hostname
+        url = where.replace(split_url.hostname, bootstrap_address)
+        transport_adapter = HostHeaderSSLAdapter()
+    else:
+        url = where
     if source is not None:
         # set source port and source address
         transport_adapter = SourceAddressAdapter(source)
@@ -387,6 +339,33 @@ def https(q, where, timeout=None, port=443, source=None, source_port=0,
         raise BadResponse
     return r
 
+def _udp_recv(sock, max_size, expiration):
+    """Reads a datagram from the socket.
+    A Timeout exception will be raised if the operation is not completed
+    by the expiration time.
+    """
+    while True:
+        try:
+            return sock.recvfrom(max_size)
+        except BlockingIOError:
+            _wait_for_readable(sock, expiration)
+
+
+def _udp_send(sock, data, destination, expiration):
+    """Sends the specified datagram to destination over the socket.
+    A Timeout exception will be raised if the operation is not completed
+    by the expiration time.
+    """
+    while True:
+        try:
+            if destination:
+                return sock.sendto(data, destination)
+            else:
+                return sock.send(data)
+        except BlockingIOError:  # pragma: no cover
+            _wait_for_writable(sock, expiration)
+
+
 def send_udp(sock, what, destination, expiration=None):
     """Send a DNS message to the specified UDP socket.
 
@@ -406,9 +385,8 @@ def send_udp(sock, what, destination, expiration=None):
 
     if isinstance(what, dns.message.Message):
         what = what.to_wire()
-    _wait_for_writable(sock, expiration)
     sent_time = time.time()
-    n = sock.sendto(what, destination)
+    n = _udp_send(sock, what, destination, expiration)
     return (n, sent_time)
 
 
@@ -458,9 +436,8 @@ def receive_udp(sock, destination=None, expiration=None,
     """
 
     wire = b''
-    while 1:
-        _wait_for_readable(sock, expiration)
-        (wire, from_address) = sock.recvfrom(65535)
+    while True:
+        (wire, from_address) = _udp_recv(sock, 65535, expiration)
         if _matches_destination(sock.family, from_address, destination,
                                 ignore_unexpected):
             break
@@ -598,18 +575,16 @@ def _net_read(sock, count, expiration):
     """
     s = b''
     while count > 0:
-        _wait_for_readable(sock, expiration)
         try:
             n = sock.recv(count)
-        except ssl.SSLWantReadError:  # pragma: no cover
-            continue
+            if n == b'':
+                raise EOFError
+            count -= len(n)
+            s += n
+        except (BlockingIOError, ssl.SSLWantReadError):
+            _wait_for_readable(sock, expiration)
         except ssl.SSLWantWriteError:  # pragma: no cover
             _wait_for_writable(sock, expiration)
-            continue
-        if n == b'':
-            raise EOFError
-        count = count - len(n)
-        s = s + n
     return s
 
 
@@ -621,14 +596,12 @@ def _net_write(sock, data, expiration):
     current = 0
     l = len(data)
     while current < l:
-        _wait_for_writable(sock, expiration)
         try:
             current += sock.send(data[current:])
+        except (BlockingIOError, ssl.SSLWantWriteError):
+            _wait_for_writable(sock, expiration)
         except ssl.SSLWantReadError:  # pragma: no cover
             _wait_for_readable(sock, expiration)
-            continue
-        except ssl.SSLWantWriteError:  # pragma: no cover
-            continue
 
 
 def send_tcp(sock, what, expiration=None):
@@ -652,7 +625,6 @@ def send_tcp(sock, what, expiration=None):
     # avoid writev() or doing a short write that would get pushed
     # onto the net
     tcpmsg = struct.pack("!H", l) + what
-    _wait_for_writable(sock, expiration)
     sent_time = time.time()
     _net_write(sock, tcpmsg, expiration)
     return (len(tcpmsg), sent_time)
@@ -742,11 +714,6 @@ def tcp(q, where, timeout=None, port=53, source=None, source_port=0,
     (begin_time, expiration) = _compute_times(timeout)
     with contextlib.ExitStack() as stack:
         if sock:
-            #
-            # Verify that the socket is connected, as if it's not connected,
-            # it's not writable, and the polling in send_tcp() will time out or
-            # hang forever.
-            sock.getpeername()
             s = sock
         else:
             (af, destination, source) = _destination_and_source(where, port,
@@ -926,8 +893,7 @@ def xfr(where, zone, rdtype=dns.rdatatype.AXFR, rdclass=dns.rdataclass.IN,
         _connect(s, destination, expiration)
         l = len(wire)
         if use_udp:
-            _wait_for_writable(s, expiration)
-            s.send(wire)
+            _udp_send(s, wire, None, expiration)
         else:
             tcpmsg = struct.pack("!H", l) + wire
             _net_write(s, tcpmsg, expiration)
@@ -948,8 +914,7 @@ def xfr(where, zone, rdtype=dns.rdatatype.AXFR, rdclass=dns.rdataclass.IN,
                (expiration is not None and mexpiration > expiration):
                 mexpiration = expiration
             if use_udp:
-                _wait_for_readable(s, expiration)
-                (wire, from_address) = s.recvfrom(65535)
+                (wire, _) = _udp_recv(s, 65535, mexpiration)
             else:
                 ldata = _net_read(s, 2, mexpiration)
                 (l,) = struct.unpack("!H", ldata)
@@ -1016,3 +981,114 @@ def xfr(where, zone, rdtype=dns.rdatatype.AXFR, rdclass=dns.rdataclass.IN,
             if done and q.keyring and not r.had_tsig:
                 raise dns.exception.FormError("missing TSIG")
             yield r
+
+
+class UDPMode(enum.IntEnum):
+    """How should UDP be used in an IXFR from :py:func:`inbound_xfr()`?
+
+    NEVER means "never use UDP; always use TCP"
+    TRY_FIRST means "try to use UDP but fall back to TCP if needed"
+    ONLY means "raise ``dns.xfr.UseTCP`` if trying UDP does not succeed"
+    """
+    NEVER = 0
+    TRY_FIRST = 1
+    ONLY = 2
+
+
+def inbound_xfr(where, txn_manager, query=None,
+                port=53, timeout=None, lifetime=None, source=None,
+                source_port=0, udp_mode=UDPMode.NEVER):
+    """Conduct an inbound transfer and apply it via a transaction from the
+    txn_manager.
+
+    *where*, a ``str`` containing an IPv4 or IPv6 address,  where
+    to send the message.
+
+    *txn_manager*, a ``dns.transaction.TransactionManager``, the txn_manager
+    for this transfer (typically a ``dns.zone.Zone``).
+
+    *query*, the query to send.  If not supplied, a default query is
+    constructed using information from the *txn_manager*.
+
+    *port*, an ``int``, the port send the message to.  The default is 53.
+
+    *timeout*, a ``float``, the number of seconds to wait for each
+    response message.  If None, the default, wait forever.
+
+    *lifetime*, a ``float``, the total number of seconds to spend
+    doing the transfer.  If ``None``, the default, then there is no
+    limit on the time the transfer may take.
+
+    *source*, a ``str`` containing an IPv4 or IPv6 address, specifying
+    the source address.  The default is the wildcard address.
+
+    *source_port*, an ``int``, the port from which to send the message.
+    The default is 0.
+
+    *udp_mode*, a ``dns.query.UDPMode``, determines how UDP is used
+    for IXFRs.  The default is ``dns.UDPMode.NEVER``, i.e. only use
+    TCP.  Other possibilites are ``dns.UDPMode.TRY_FIRST``, which
+    means "try UDP but fallback to TCP if needed", and
+    ``dns.UDPMode.ONLY``, which means "try UDP and raise
+    ``dns.xfr.UseTCP`` if it does not succeeed.
+
+    Raises on errors.
+    """
+    if query is None:
+        (query, serial) = dns.xfr.make_query(txn_manager)
+    rdtype = query.question[0].rdtype
+    is_ixfr = rdtype == dns.rdatatype.IXFR
+    origin = txn_manager.from_wire_origin()
+    wire = query.to_wire()
+    (af, destination, source) = _destination_and_source(where, port,
+                                                        source, source_port)
+    (_, expiration) = _compute_times(lifetime)
+    retry = True
+    while retry:
+        retry = False
+        if is_ixfr and udp_mode != UDPMode.NEVER:
+            sock_type = socket.SOCK_DGRAM
+            is_udp = True
+        else:
+            sock_type = socket.SOCK_STREAM
+            is_udp = False
+        with _make_socket(af, sock_type, source) as s:
+            _connect(s, destination, expiration)
+            if is_udp:
+                _udp_send(s, wire, None, expiration)
+            else:
+                tcpmsg = struct.pack("!H", len(wire)) + wire
+                _net_write(s, tcpmsg, expiration)
+            with dns.xfr.Inbound(txn_manager, rdtype, serial,
+                                 is_udp) as inbound:
+                done = False
+                tsig_ctx = None
+                while not done:
+                    (_, mexpiration) = _compute_times(timeout)
+                    if mexpiration is None or \
+                       (expiration is not None and mexpiration > expiration):
+                        mexpiration = expiration
+                    if is_udp:
+                        (rwire, _) = _udp_recv(s, 65535, mexpiration)
+                    else:
+                        ldata = _net_read(s, 2, mexpiration)
+                        (l,) = struct.unpack("!H", ldata)
+                        rwire = _net_read(s, l, mexpiration)
+                    r = dns.message.from_wire(rwire, keyring=query.keyring,
+                                              request_mac=query.mac, xfr=True,
+                                              origin=origin, tsig_ctx=tsig_ctx,
+                                              multi=(not is_udp),
+                                              one_rr_per_rrset=is_ixfr)
+                    try:
+                        done = inbound.process_message(r)
+                    except dns.xfr.UseTCP:
+                        assert is_udp  # should not happen if we used TCP!
+                        if udp_mode == UDPMode.ONLY:
+                            raise
+                        done = True
+                        retry = True
+                        udp_mode = UDPMode.NEVER
+                        continue
+                    tsig_ctx = r.tsig_ctx
+                if not retry and query.keyring and not r.had_tsig:
+                    raise dns.exception.FormError("missing TSIG")
diff --git a/dns/rcode.py b/dns/rcode.py
index d9ea005..49fee69 100644
--- a/dns/rcode.py
+++ b/dns/rcode.py
@@ -72,7 +72,6 @@ class Rcode(dns.enum.IntEnum):
     def _unknown_exception_class(cls):
         return UnknownRcode
 
-globals().update(Rcode.__members__)
 
 class UnknownRcode(dns.exception.DNSException):
     """A DNS rcode is unknown."""
@@ -104,8 +103,6 @@ def from_flags(flags, ednsflags):
     """
 
     value = (flags & 0x000f) | ((ednsflags >> 20) & 0xff0)
-    if value < 0 or value > 4095:
-        raise ValueError('rcode must be >= 0 and <= 4095')
     return value
 
 
@@ -139,3 +136,29 @@ def to_text(value, tsig=False):
     if tsig and value == Rcode.BADVERS:
         return 'BADSIG'
     return Rcode.to_text(value)
+
+### BEGIN generated Rcode constants
+
+NOERROR = Rcode.NOERROR
+FORMERR = Rcode.FORMERR
+SERVFAIL = Rcode.SERVFAIL
+NXDOMAIN = Rcode.NXDOMAIN
+NOTIMP = Rcode.NOTIMP
+REFUSED = Rcode.REFUSED
+YXDOMAIN = Rcode.YXDOMAIN
+YXRRSET = Rcode.YXRRSET
+NXRRSET = Rcode.NXRRSET
+NOTAUTH = Rcode.NOTAUTH
+NOTZONE = Rcode.NOTZONE
+DSOTYPENI = Rcode.DSOTYPENI
+BADVERS = Rcode.BADVERS
+BADSIG = Rcode.BADSIG
+BADKEY = Rcode.BADKEY
+BADTIME = Rcode.BADTIME
+BADMODE = Rcode.BADMODE
+BADNAME = Rcode.BADNAME
+BADALG = Rcode.BADALG
+BADTRUNC = Rcode.BADTRUNC
+BADCOOKIE = Rcode.BADCOOKIE
+
+### END generated Rcode constants
diff --git a/dns/rdata.py b/dns/rdata.py
index e114fe3..12f3b6f 100644
--- a/dns/rdata.py
+++ b/dns/rdata.py
@@ -23,13 +23,18 @@ import binascii
 import io
 import inspect
 import itertools
+import random
 
 import dns.wire
 import dns.exception
+import dns.immutable
+import dns.ipv4
+import dns.ipv6
 import dns.name
 import dns.rdataclass
 import dns.rdatatype
 import dns.tokenizer
+import dns.ttl
 
 _chunksize = 32
 
@@ -46,7 +51,7 @@ def _wordbreak(data, chunksize=_chunksize):
                       in range(0, len(data), chunksize)]).decode()
 
 
-def _hexify(data, chunksize=_chunksize):
+def _hexify(data, chunksize=_chunksize, **kw):
     """Convert a binary string into its hex encoding, broken up into chunks
     of chunksize characters separated by a space.
     """
@@ -54,13 +59,14 @@ def _hexify(data, chunksize=_chunksize):
     return _wordbreak(binascii.hexlify(data), chunksize)
 
 
-def _base64ify(data, chunksize=_chunksize):
+def _base64ify(data, chunksize=_chunksize, **kw):
     """Convert a binary string into its base64 encoding, broken up into chunks
     of chunksize characters separated by a space.
     """
 
     return _wordbreak(base64.b64encode(data), chunksize)
 
+
 __escaped = b'"\\'
 
 def _escapify(qstring):
@@ -92,26 +98,15 @@ def _truncate_bitmap(what):
             return what[0: i + 1]
     return what[0:1]
 
-def _constify(o):
-    """
-    Convert mutable types to immutable types.
-    """
-    if isinstance(o, bytearray):
-        return bytes(o)
-    if isinstance(o, tuple):
-        try:
-            hash(o)
-            return o
-        except Exception:
-            return tuple(_constify(elt) for elt in o)
-    if isinstance(o, list):
-        return tuple(_constify(elt) for elt in o)
-    return o
+# So we don't have to edit all the rdata classes...
+_constify = dns.immutable.constify
+
 
+@dns.immutable.immutable
 class Rdata:
     """Base class for all DNS rdata types."""
 
-    __slots__ = ['rdclass', 'rdtype']
+    __slots__ = ['rdclass', 'rdtype', 'rdcomment']
 
     def __init__(self, rdclass, rdtype):
         """Initialize an rdata.
@@ -121,16 +116,9 @@ class Rdata:
         *rdtype*, an ``int`` is the rdatatype of the Rdata.
         """
 
-        object.__setattr__(self, 'rdclass', rdclass)
-        object.__setattr__(self, 'rdtype', rdtype)
-
-    def __setattr__(self, name, value):
-        # Rdatas are immutable
-        raise TypeError("object doesn't support attribute assignment")
-
-    def __delattr__(self, name):
-        # Rdatas are immutable
-        raise TypeError("object doesn't support attribute deletion")
+        self.rdclass = self._as_rdataclass(rdclass)
+        self.rdtype = self._as_rdatatype(rdtype)
+        self.rdcomment = None
 
     def _get_all_slots(self):
         return itertools.chain.from_iterable(getattr(cls, '__slots__', [])
@@ -153,6 +141,10 @@ class Rdata:
     def __setstate__(self, state):
         for slot, val in state.items():
             object.__setattr__(self, slot, val)
+        if not hasattr(self, 'rdcomment'):
+            # Pickled rdata from 2.0.x might not have a rdcomment, so add
+            # it if needed.
+            object.__setattr__(self, 'rdcomment', None)
 
     def covers(self):
         """Return the type a Rdata covers.
@@ -184,10 +176,10 @@ class Rdata:
         Returns a ``str``.
         """
 
-        raise NotImplementedError
+        raise NotImplementedError  # pragma: no cover
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
-        raise NotImplementedError
+        raise NotImplementedError  # pragma: no cover
 
     def to_wire(self, file=None, compress=None, origin=None,
                 canonicalize=False):
@@ -295,11 +287,11 @@ class Rdata:
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
-        raise NotImplementedError
+        raise NotImplementedError  # pragma: no cover
 
     @classmethod
-    def from_wire(cls, rdclass, rdtype, wire, current, rdlen, origin=None):
-        raise NotImplementedError
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        raise NotImplementedError  # pragma: no cover
 
     def replace(self, **kwargs):
         """
@@ -319,6 +311,8 @@ class Rdata:
         # Ensure that all of the arguments correspond to valid fields.
         # Don't allow rdclass or rdtype to be changed, though.
         for key in kwargs:
+            if key == 'rdcomment':
+                continue
             if key not in parameters:
                 raise AttributeError("'{}' object has no attribute '{}'"
                                      .format(self.__class__.__name__, key))
@@ -331,13 +325,149 @@ class Rdata:
         args = (kwargs.get(key, getattr(self, key)) for key in parameters)
 
         # Create, validate, and return the new object.
-        #
-        # Note that if we make constructors do validation in the future,
-        # this validation can go away.
         rd = self.__class__(*args)
-        dns.rdata.from_text(rd.rdclass, rd.rdtype, rd.to_text())
+        # The comment is not set in the constructor, so give it special
+        # handling.
+        rdcomment = kwargs.get('rdcomment', self.rdcomment)
+        if rdcomment is not None:
+            object.__setattr__(rd, 'rdcomment', rdcomment)
         return rd
 
+    # Type checking and conversion helpers.  These are class methods as
+    # they don't touch object state and may be useful to others.
+
+    @classmethod
+    def _as_rdataclass(cls, value):
+        return dns.rdataclass.RdataClass.make(value)
+
+    @classmethod
+    def _as_rdatatype(cls, value):
+        return dns.rdatatype.RdataType.make(value)
+
+    @classmethod
+    def _as_bytes(cls, value, encode=False, max_length=None, empty_ok=True):
+        if encode and isinstance(value, str):
+            value = value.encode()
+        elif isinstance(value, bytearray):
+            value = bytes(value)
+        elif not isinstance(value, bytes):
+            raise ValueError('not bytes')
+        if max_length is not None and len(value) > max_length:
+            raise ValueError('too long')
+        if not empty_ok and len(value) == 0:
+            raise ValueError('empty bytes not allowed')
+        return value
+
+    @classmethod
+    def _as_name(cls, value):
+        # Note that proper name conversion (e.g. with origin and IDNA
+        # awareness) is expected to be done via from_text.  This is just
+        # a simple thing for people invoking the constructor directly.
+        if isinstance(value, str):
+            return dns.name.from_text(value)
+        elif not isinstance(value, dns.name.Name):
+            raise ValueError('not a name')
+        return value
+
+    @classmethod
+    def _as_uint8(cls, value):
+        if not isinstance(value, int):
+            raise ValueError('not an integer')
+        if value < 0 or value > 255:
+            raise ValueError('not a uint8')
+        return value
+
+    @classmethod
+    def _as_uint16(cls, value):
+        if not isinstance(value, int):
+            raise ValueError('not an integer')
+        if value < 0 or value > 65535:
+            raise ValueError('not a uint16')
+        return value
+
+    @classmethod
+    def _as_uint32(cls, value):
+        if not isinstance(value, int):
+            raise ValueError('not an integer')
+        if value < 0 or value > 4294967295:
+            raise ValueError('not a uint32')
+        return value
+
+    @classmethod
+    def _as_uint48(cls, value):
+        if not isinstance(value, int):
+            raise ValueError('not an integer')
+        if value < 0 or value > 281474976710655:
+            raise ValueError('not a uint48')
+        return value
+
+    @classmethod
+    def _as_int(cls, value, low=None, high=None):
+        if not isinstance(value, int):
+            raise ValueError('not an integer')
+        if low is not None and value < low:
+            raise ValueError('value too small')
+        if high is not None and value > high:
+            raise ValueError('value too large')
+        return value
+
+    @classmethod
+    def _as_ipv4_address(cls, value):
+        if isinstance(value, str):
+            # call to check validity
+            dns.ipv4.inet_aton(value)
+            return value
+        elif isinstance(value, bytes):
+            return dns.ipv4.inet_ntoa(value)
+        else:
+            raise ValueError('not an IPv4 address')
+
+    @classmethod
+    def _as_ipv6_address(cls, value):
+        if isinstance(value, str):
+            # call to check validity
+            dns.ipv6.inet_aton(value)
+            return value
+        elif isinstance(value, bytes):
+            return dns.ipv6.inet_ntoa(value)
+        else:
+            raise ValueError('not an IPv6 address')
+
+    @classmethod
+    def _as_bool(cls, value):
+        if isinstance(value, bool):
+            return value
+        else:
+            raise ValueError('not a boolean')
+
+    @classmethod
+    def _as_ttl(cls, value):
+        if isinstance(value, int):
+            return cls._as_int(value, 0, dns.ttl.MAX_TTL)
+        elif isinstance(value, str):
+            return dns.ttl.from_text(value)
+        else:
+            raise ValueError('not a TTL')
+
+    @classmethod
+    def _as_tuple(cls, value, as_value):
+        try:
+            # For user convenience, if value is a singleton of the list
+            # element type, wrap it in a tuple.
+            return (as_value(value),)
+        except Exception:
+            # Otherwise, check each element of the iterable *value*
+            # against *as_value*.
+            return tuple(as_value(v) for v in value)
+
+    # Processing order
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        items = list(iterable)
+        random.shuffle(items)
+        return items
+
 
 class GenericRdata(Rdata):
 
@@ -354,7 +484,7 @@ class GenericRdata(Rdata):
         object.__setattr__(self, 'data', data)
 
     def to_text(self, origin=None, relativize=True, **kw):
-        return r'\# %d ' % len(self.data) + _hexify(self.data)
+        return r'\# %d ' % len(self.data) + _hexify(self.data, **kw)
 
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
@@ -364,13 +494,7 @@ class GenericRdata(Rdata):
             raise dns.exception.SyntaxError(
                 r'generic rdata does not start with \#')
         length = tok.get_int()
-        chunks = []
-        while 1:
-            token = tok.get()
-            if token.is_eol_or_eof():
-                break
-            chunks.append(token.value.encode())
-        hex = b''.join(chunks)
+        hex = tok.concatenate_remaining_identifiers().encode()
         data = binascii.unhexlify(hex)
         if len(data) != length:
             raise dns.exception.SyntaxError(
@@ -453,29 +577,45 @@ def from_text(rdclass, rdtype, tok, origin=None, relativize=True,
     Returns an instance of the chosen Rdata subclass.
 
     """
-
     if isinstance(tok, str):
         tok = dns.tokenizer.Tokenizer(tok, idna_codec=idna_codec)
     rdclass = dns.rdataclass.RdataClass.make(rdclass)
     rdtype = dns.rdatatype.RdataType.make(rdtype)
     cls = get_rdata_class(rdclass, rdtype)
-    if cls != GenericRdata:
-        # peek at first token
-        token = tok.get()
-        tok.unget(token)
-        if token.is_identifier() and \
-           token.value == r'\#':
-            #
-            # Known type using the generic syntax.  Extract the
-            # wire form from the generic syntax, and then run
-            # from_wire on it.
-            #
-            rdata = GenericRdata.from_text(rdclass, rdtype, tok, origin,
-                                           relativize, relativize_to)
-            return from_wire(rdclass, rdtype, rdata.data, 0, len(rdata.data),
-                             origin)
-    return cls.from_text(rdclass, rdtype, tok, origin, relativize,
-                         relativize_to)
+    with dns.exception.ExceptionWrapper(dns.exception.SyntaxError):
+        rdata = None
+        if cls != GenericRdata:
+            # peek at first token
+            token = tok.get()
+            tok.unget(token)
+            if token.is_identifier() and \
+               token.value == r'\#':
+                #
+                # Known type using the generic syntax.  Extract the
+                # wire form from the generic syntax, and then run
+                # from_wire on it.
+                #
+                grdata = GenericRdata.from_text(rdclass, rdtype, tok, origin,
+                                                relativize, relativize_to)
+                rdata = from_wire(rdclass, rdtype, grdata.data, 0,
+                                  len(grdata.data), origin)
+                #
+                # If this comparison isn't equal, then there must have been
+                # compressed names in the wire format, which is an error,
+                # there being no reasonable context to decompress with.
+                #
+                rwire = rdata.to_wire()
+                if rwire != grdata.data:
+                    raise dns.exception.SyntaxError('compressed data in '
+                                                    'generic syntax form '
+                                                    'of known rdatatype')
+        if rdata is None:
+            rdata = cls.from_text(rdclass, rdtype, tok, origin, relativize,
+                                  relativize_to)
+        token = tok.get_eol_as_token()
+        if token.comment is not None:
+            object.__setattr__(rdata, 'rdcomment', token.comment)
+        return rdata
 
 
 def from_wire_parser(rdclass, rdtype, parser, origin=None):
@@ -505,7 +645,8 @@ def from_wire_parser(rdclass, rdtype, parser, origin=None):
     rdclass = dns.rdataclass.RdataClass.make(rdclass)
     rdtype = dns.rdatatype.RdataType.make(rdtype)
     cls = get_rdata_class(rdclass, rdtype)
-    return cls.from_wire_parser(rdclass, rdtype, parser, origin)
+    with dns.exception.ExceptionWrapper(dns.exception.FormError):
+        return cls.from_wire_parser(rdclass, rdtype, parser, origin)
 
 
 def from_wire(rdclass, rdtype, wire, current, rdlen, origin=None):
diff --git a/dns/rdataclass.py b/dns/rdataclass.py
index 7943a95..41bba69 100644
--- a/dns/rdataclass.py
+++ b/dns/rdataclass.py
@@ -48,7 +48,6 @@ class RdataClass(dns.enum.IntEnum):
     def _unknown_exception_class(cls):
         return UnknownRdataclass
 
-globals().update(RdataClass.__members__)
 
 _metaclasses = {RdataClass.NONE, RdataClass.ANY}
 
@@ -100,3 +99,17 @@ def is_metaclass(rdclass):
     if rdclass in _metaclasses:
         return True
     return False
+
+### BEGIN generated RdataClass constants
+
+RESERVED0 = RdataClass.RESERVED0
+IN = RdataClass.IN
+INTERNET = RdataClass.INTERNET
+CH = RdataClass.CH
+CHAOS = RdataClass.CHAOS
+HS = RdataClass.HS
+HESIOD = RdataClass.HESIOD
+NONE = RdataClass.NONE
+ANY = RdataClass.ANY
+
+### END generated RdataClass constants
diff --git a/dns/rdataset.py b/dns/rdataset.py
index 660415e..e69ee23 100644
--- a/dns/rdataset.py
+++ b/dns/rdataset.py
@@ -22,6 +22,7 @@ import random
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdatatype
 import dns.rdataclass
 import dns.rdata
@@ -79,15 +80,15 @@ class Rdataset(dns.set.Set):
         TTL or the specified TTL.  If the set contains no rdatas, set the TTL
         to the specified TTL.
 
-        *ttl*, an ``int``.
+        *ttl*, an ``int`` or ``str``.
         """
-
+        ttl = dns.ttl.make(ttl)
         if len(self) == 0:
             self.ttl = ttl
         elif ttl < self.ttl:
             self.ttl = ttl
 
-    def add(self, rd, ttl=None):
+    def add(self, rd, ttl=None):  # pylint: disable=arguments-differ
         """Add the specified rdata to the rdataset.
 
         If the optional *ttl* parameter is supplied, then
@@ -176,8 +177,8 @@ class Rdataset(dns.set.Set):
         return not self.__eq__(other)
 
     def to_text(self, name=None, origin=None, relativize=True,
-                override_rdclass=None, **kw):
-        """Convert the rdataset into DNS master file format.
+                override_rdclass=None, want_comments=False, **kw):
+        """Convert the rdataset into DNS zone file format.
 
         See ``dns.name.Name.choose_relativity`` for more information
         on how *origin* and *relativize* determine the way names
@@ -194,6 +195,12 @@ class Rdataset(dns.set.Set):
 
         *relativize*, a ``bool``.  If ``True``, names will be relativized
         to *origin*.
+
+        *override_rdclass*, a ``dns.rdataclass.RdataClass`` or ``None``.
+        If not ``None``, use this class instead of the Rdataset's class.
+
+        *want_comments*, a ``bool``.  If ``True``, emit comments for rdata
+        which have them.  The default is ``False``.
         """
 
         if name is not None:
@@ -219,11 +226,16 @@ class Rdataset(dns.set.Set):
                                          dns.rdatatype.to_text(self.rdtype)))
         else:
             for rd in self:
-                s.write('%s%s%d %s %s %s\n' %
+                extra = ''
+                if want_comments:
+                    if rd.rdcomment:
+                        extra = f' ;{rd.rdcomment}'
+                s.write('%s%s%d %s %s %s%s\n' %
                         (ntext, pad, self.ttl, dns.rdataclass.to_text(rdclass),
                          dns.rdatatype.to_text(self.rdtype),
                          rd.to_text(origin=origin, relativize=relativize,
-                         **kw)))
+                                    **kw),
+                         extra))
         #
         # We strip off the final \n for the caller's convenience in printing
         #
@@ -260,7 +272,7 @@ class Rdataset(dns.set.Set):
             want_shuffle = False
         else:
             rdclass = self.rdclass
-        file.seek(0, 2)
+        file.seek(0, io.SEEK_END)
         if len(self) == 0:
             name.to_wire(file, compress, origin)
             stuff = struct.pack("!HHIH", self.rdtype, rdclass, 0, 0)
@@ -284,7 +296,7 @@ class Rdataset(dns.set.Set):
                 file.seek(start - 2)
                 stuff = struct.pack("!H", end - start)
                 file.write(stuff)
-                file.seek(0, 2)
+                file.seek(0, io.SEEK_END)
             return len(self)
 
     def match(self, rdclass, rdtype, covers):
@@ -297,8 +309,86 @@ class Rdataset(dns.set.Set):
             return True
         return False
 
+    def processing_order(self):
+        """Return rdatas in a valid processing order according to the type's
+        specification.  For example, MX records are in preference order from
+        lowest to highest preferences, with items of the same perference
+        shuffled.
+
+        For types that do not define a processing order, the rdatas are
+        simply shuffled.
+        """
+        if len(self) == 0:
+            return []
+        else:
+            return self[0]._processing_order(iter(self))
+
+
+@dns.immutable.immutable
+class ImmutableRdataset(Rdataset):
+
+    """An immutable DNS rdataset."""
+
+    _clone_class = Rdataset
+
+    def __init__(self, rdataset):
+        """Create an immutable rdataset from the specified rdataset."""
+
+        super().__init__(rdataset.rdclass, rdataset.rdtype, rdataset.covers,
+                         rdataset.ttl)
+        self.items = dns.immutable.Dict(rdataset.items)
+
+    def update_ttl(self, ttl):
+        raise TypeError('immutable')
+
+    def add(self, rd, ttl=None):
+        raise TypeError('immutable')
+
+    def union_update(self, other):
+        raise TypeError('immutable')
 
-def from_text_list(rdclass, rdtype, ttl, text_rdatas, idna_codec=None):
+    def intersection_update(self, other):
+        raise TypeError('immutable')
+
+    def update(self, other):
+        raise TypeError('immutable')
+
+    def __delitem__(self, i):
+        raise TypeError('immutable')
+
+    def __ior__(self, other):
+        raise TypeError('immutable')
+
+    def __iand__(self, other):
+        raise TypeError('immutable')
+
+    def __iadd__(self, other):
+        raise TypeError('immutable')
+
+    def __isub__(self, other):
+        raise TypeError('immutable')
+
+    def clear(self):
+        raise TypeError('immutable')
+
+    def __copy__(self):
+        return ImmutableRdataset(super().copy())
+
+    def copy(self):
+        return ImmutableRdataset(super().copy())
+
+    def union(self, other):
+        return ImmutableRdataset(super().union(other))
+
+    def intersection(self, other):
+        return ImmutableRdataset(super().intersection(other))
+
+    def difference(self, other):
+        return ImmutableRdataset(super().difference(other))
+
+
+def from_text_list(rdclass, rdtype, ttl, text_rdatas, idna_codec=None,
+                   origin=None, relativize=True, relativize_to=None):
     """Create an rdataset with the specified class, type, and TTL, and with
     the specified list of rdatas in text format.
 
@@ -306,6 +396,14 @@ def from_text_list(rdclass, rdtype, ttl, text_rdatas, idna_codec=None):
     encoder/decoder to use; if ``None``, the default IDNA 2003
     encoder/decoder is used.
 
+    *origin*, a ``dns.name.Name`` (or ``None``), the
+    origin to use for relative names.
+
+    *relativize*, a ``bool``.  If true, name will be relativized.
+
+    *relativize_to*, a ``dns.name.Name`` (or ``None``), the origin to use
+    when relativizing names.  If not set, the *origin* value will be used.
+
     Returns a ``dns.rdataset.Rdataset`` object.
     """
 
@@ -314,7 +412,8 @@ def from_text_list(rdclass, rdtype, ttl, text_rdatas, idna_codec=None):
     r = Rdataset(rdclass, rdtype)
     r.update_ttl(ttl)
     for t in text_rdatas:
-        rd = dns.rdata.from_text(r.rdclass, r.rdtype, t, idna_codec=idna_codec)
+        rd = dns.rdata.from_text(r.rdclass, r.rdtype, t, origin, relativize,
+                                 relativize_to, idna_codec)
         r.add(rd)
     return r
 
diff --git a/dns/rdatatype.py b/dns/rdatatype.py
index c793d5a..b79b68c 100644
--- a/dns/rdatatype.py
+++ b/dns/rdatatype.py
@@ -72,12 +72,16 @@ class RdataType(dns.enum.IntEnum):
     NSEC3 = 50
     NSEC3PARAM = 51
     TLSA = 52
+    SMIMEA = 53
     HIP = 55
     NINFO = 56
     CDS = 59
     CDNSKEY = 60
     OPENPGPKEY = 61
     CSYNC = 62
+    ZONEMD = 63
+    SVCB = 64
+    HTTPS = 65
     SPF = 99
     UNSPEC = 103
     EUI48 = 108
@@ -92,7 +96,7 @@ class RdataType(dns.enum.IntEnum):
     URI = 256
     CAA = 257
     AVC = 258
-    AMTRELAY = 259
+    AMTRELAY = 260
     TA = 32768
     DLV = 32769
 
@@ -115,8 +119,6 @@ class RdataType(dns.enum.IntEnum):
 _registered_by_text = {}
 _registered_by_value = {}
 
-globals().update(RdataType.__members__)
-
 _metatypes = {RdataType.OPT}
 
 _singletons = {RdataType.SOA, RdataType.NXT, RdataType.DNAME,
@@ -219,3 +221,85 @@ def register_type(rdtype, rdtype_text, is_singleton=False):
     _registered_by_value[rdtype] = rdtype_text
     if is_singleton:
         _singletons.add(rdtype)
+
+### BEGIN generated RdataType constants
+
+TYPE0 = RdataType.TYPE0
+NONE = RdataType.NONE
+A = RdataType.A
+NS = RdataType.NS
+MD = RdataType.MD
+MF = RdataType.MF
+CNAME = RdataType.CNAME
+SOA = RdataType.SOA
+MB = RdataType.MB
+MG = RdataType.MG
+MR = RdataType.MR
+NULL = RdataType.NULL
+WKS = RdataType.WKS
+PTR = RdataType.PTR
+HINFO = RdataType.HINFO
+MINFO = RdataType.MINFO
+MX = RdataType.MX
+TXT = RdataType.TXT
+RP = RdataType.RP
+AFSDB = RdataType.AFSDB
+X25 = RdataType.X25
+ISDN = RdataType.ISDN
+RT = RdataType.RT
+NSAP = RdataType.NSAP
+NSAP_PTR = RdataType.NSAP_PTR
+SIG = RdataType.SIG
+KEY = RdataType.KEY
+PX = RdataType.PX
+GPOS = RdataType.GPOS
+AAAA = RdataType.AAAA
+LOC = RdataType.LOC
+NXT = RdataType.NXT
+SRV = RdataType.SRV
+NAPTR = RdataType.NAPTR
+KX = RdataType.KX
+CERT = RdataType.CERT
+A6 = RdataType.A6
+DNAME = RdataType.DNAME
+OPT = RdataType.OPT
+APL = RdataType.APL
+DS = RdataType.DS
+SSHFP = RdataType.SSHFP
+IPSECKEY = RdataType.IPSECKEY
+RRSIG = RdataType.RRSIG
+NSEC = RdataType.NSEC
+DNSKEY = RdataType.DNSKEY
+DHCID = RdataType.DHCID
+NSEC3 = RdataType.NSEC3
+NSEC3PARAM = RdataType.NSEC3PARAM
+TLSA = RdataType.TLSA
+SMIMEA = RdataType.SMIMEA
+HIP = RdataType.HIP
+NINFO = RdataType.NINFO
+CDS = RdataType.CDS
+CDNSKEY = RdataType.CDNSKEY
+OPENPGPKEY = RdataType.OPENPGPKEY
+CSYNC = RdataType.CSYNC
+ZONEMD = RdataType.ZONEMD
+SVCB = RdataType.SVCB
+HTTPS = RdataType.HTTPS
+SPF = RdataType.SPF
+UNSPEC = RdataType.UNSPEC
+EUI48 = RdataType.EUI48
+EUI64 = RdataType.EUI64
+TKEY = RdataType.TKEY
+TSIG = RdataType.TSIG
+IXFR = RdataType.IXFR
+AXFR = RdataType.AXFR
+MAILB = RdataType.MAILB
+MAILA = RdataType.MAILA
+ANY = RdataType.ANY
+URI = RdataType.URI
+CAA = RdataType.CAA
+AVC = RdataType.AVC
+AMTRELAY = RdataType.AMTRELAY
+TA = RdataType.TA
+DLV = RdataType.DLV
+
+### END generated RdataType constants
diff --git a/dns/rdtypes/ANY/AFSDB.py b/dns/rdtypes/ANY/AFSDB.py
index 4087890..d7838e7 100644
--- a/dns/rdtypes/ANY/AFSDB.py
+++ b/dns/rdtypes/ANY/AFSDB.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.mxbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class AFSDB(dns.rdtypes.mxbase.UncompressedDowncasingMX):
 
     """AFSDB record"""
diff --git a/dns/rdtypes/ANY/AMTRELAY.py b/dns/rdtypes/ANY/AMTRELAY.py
index 4e012a2..9f093de 100644
--- a/dns/rdtypes/ANY/AMTRELAY.py
+++ b/dns/rdtypes/ANY/AMTRELAY.py
@@ -18,12 +18,19 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdtypes.util
 
 
 class Relay(dns.rdtypes.util.Gateway):
     name = 'AMTRELAY relay'
 
+    @property
+    def relay(self):
+        return self.gateway
+
+
+@dns.immutable.immutable
 class AMTRELAY(dns.rdata.Rdata):
 
     """AMTRELAY record"""
@@ -35,11 +42,11 @@ class AMTRELAY(dns.rdata.Rdata):
     def __init__(self, rdclass, rdtype, precedence, discovery_optional,
                  relay_type, relay):
         super().__init__(rdclass, rdtype)
-        Relay(relay_type, relay).check()
-        object.__setattr__(self, 'precedence', precedence)
-        object.__setattr__(self, 'discovery_optional', discovery_optional)
-        object.__setattr__(self, 'relay_type', relay_type)
-        object.__setattr__(self, 'relay', relay)
+        relay = Relay(relay_type, relay)
+        self.precedence = self._as_uint8(precedence)
+        self.discovery_optional = self._as_bool(discovery_optional)
+        self.relay_type = relay.type
+        self.relay = relay.relay
 
     def to_text(self, origin=None, relativize=True, **kw):
         relay = Relay(self.relay_type, self.relay).to_text(origin, relativize)
@@ -57,10 +64,10 @@ class AMTRELAY(dns.rdata.Rdata):
         relay_type = tok.get_uint8()
         if relay_type > 0x7f:
             raise dns.exception.SyntaxError('expecting an integer <= 127')
-        relay = Relay(relay_type).from_text(tok, origin, relativize,
-                                            relativize_to)
+        relay = Relay.from_text(relay_type, tok, origin, relativize,
+                                relativize_to)
         return cls(rdclass, rdtype, precedence, discovery_optional, relay_type,
-                   relay)
+                   relay.relay)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
         relay_type = self.relay_type | (self.discovery_optional << 7)
@@ -74,6 +81,6 @@ class AMTRELAY(dns.rdata.Rdata):
         (precedence, relay_type) = parser.get_struct('!BB')
         discovery_optional = bool(relay_type >> 7)
         relay_type &= 0x7f
-        relay = Relay(relay_type).from_wire_parser(parser, origin)
+        relay = Relay.from_wire_parser(relay_type, parser, origin)
         return cls(rdclass, rdtype, precedence, discovery_optional, relay_type,
-                   relay)
+                   relay.relay)
diff --git a/dns/rdtypes/ANY/AVC.py b/dns/rdtypes/ANY/AVC.py
index 1fa5ecf..11e026d 100644
--- a/dns/rdtypes/ANY/AVC.py
+++ b/dns/rdtypes/ANY/AVC.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.txtbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class AVC(dns.rdtypes.txtbase.TXTBase):
 
     """AVC record"""
diff --git a/dns/rdtypes/ANY/CAA.py b/dns/rdtypes/ANY/CAA.py
index b7edae8..c86b45e 100644
--- a/dns/rdtypes/ANY/CAA.py
+++ b/dns/rdtypes/ANY/CAA.py
@@ -18,10 +18,12 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.tokenizer
 
 
+@dns.immutable.immutable
 class CAA(dns.rdata.Rdata):
 
     """CAA (Certification Authority Authorization) record"""
@@ -32,9 +34,11 @@ class CAA(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, flags, tag, value):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'flags', flags)
-        object.__setattr__(self, 'tag', tag)
-        object.__setattr__(self, 'value', value)
+        self.flags = self._as_uint8(flags)
+        self.tag = self._as_bytes(tag, True, 255)
+        if not tag.isalnum():
+            raise ValueError("tag is not alphanumeric")
+        self.value = self._as_bytes(value)
 
     def to_text(self, origin=None, relativize=True, **kw):
         return '%u %s "%s"' % (self.flags,
@@ -46,10 +50,6 @@ class CAA(dns.rdata.Rdata):
                   relativize_to=None):
         flags = tok.get_uint8()
         tag = tok.get_string().encode()
-        if len(tag) > 255:
-            raise dns.exception.SyntaxError("tag too long")
-        if not tag.isalnum():
-            raise dns.exception.SyntaxError("tag is not alphanumeric")
         value = tok.get_string().encode()
         return cls(rdclass, rdtype, flags, tag, value)
 
diff --git a/dns/rdtypes/ANY/CDNSKEY.py b/dns/rdtypes/ANY/CDNSKEY.py
index 7225318..14b1941 100644
--- a/dns/rdtypes/ANY/CDNSKEY.py
+++ b/dns/rdtypes/ANY/CDNSKEY.py
@@ -16,9 +16,13 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.dnskeybase
-from dns.rdtypes.dnskeybase import SEP, REVOKE, ZONE  # noqa: F401
+import dns.immutable
 
+# pylint: disable=unused-import
+from dns.rdtypes.dnskeybase import SEP, REVOKE, ZONE  # noqa: F401
+# pylint: enable=unused-import
 
+@dns.immutable.immutable
 class CDNSKEY(dns.rdtypes.dnskeybase.DNSKEYBase):
 
     """CDNSKEY record"""
diff --git a/dns/rdtypes/ANY/CDS.py b/dns/rdtypes/ANY/CDS.py
index a63041d..39e3556 100644
--- a/dns/rdtypes/ANY/CDS.py
+++ b/dns/rdtypes/ANY/CDS.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.dsbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class CDS(dns.rdtypes.dsbase.DSBase):
 
     """CDS record"""
diff --git a/dns/rdtypes/ANY/CERT.py b/dns/rdtypes/ANY/CERT.py
index 62df241..5f26dde 100644
--- a/dns/rdtypes/ANY/CERT.py
+++ b/dns/rdtypes/ANY/CERT.py
@@ -19,6 +19,7 @@ import struct
 import base64
 
 import dns.exception
+import dns.immutable
 import dns.dnssec
 import dns.rdata
 import dns.tokenizer
@@ -54,6 +55,7 @@ def _ctype_to_text(what):
     return str(what)
 
 
+@dns.immutable.immutable
 class CERT(dns.rdata.Rdata):
 
     """CERT record"""
@@ -65,16 +67,16 @@ class CERT(dns.rdata.Rdata):
     def __init__(self, rdclass, rdtype, certificate_type, key_tag, algorithm,
                  certificate):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'certificate_type', certificate_type)
-        object.__setattr__(self, 'key_tag', key_tag)
-        object.__setattr__(self, 'algorithm', algorithm)
-        object.__setattr__(self, 'certificate', certificate)
+        self.certificate_type = self._as_uint16(certificate_type)
+        self.key_tag = self._as_uint16(key_tag)
+        self.algorithm = self._as_uint8(algorithm)
+        self.certificate = self._as_bytes(certificate)
 
     def to_text(self, origin=None, relativize=True, **kw):
         certificate_type = _ctype_to_text(self.certificate_type)
         return "%s %d %s %s" % (certificate_type, self.key_tag,
                                 dns.dnssec.algorithm_to_text(self.algorithm),
-                                dns.rdata._base64ify(self.certificate))
+                                dns.rdata._base64ify(self.certificate, **kw))
 
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
@@ -82,8 +84,6 @@ class CERT(dns.rdata.Rdata):
         certificate_type = _ctype_from_text(tok.get_string())
         key_tag = tok.get_uint16()
         algorithm = dns.dnssec.algorithm_from_text(tok.get_string())
-        if algorithm < 0 or algorithm > 255:
-            raise dns.exception.SyntaxError("bad algorithm type")
         b64 = tok.concatenate_remaining_identifiers().encode()
         certificate = base64.b64decode(b64)
         return cls(rdclass, rdtype, certificate_type, key_tag,
diff --git a/dns/rdtypes/ANY/CNAME.py b/dns/rdtypes/ANY/CNAME.py
index 11d42aa..a4fcfa8 100644
--- a/dns/rdtypes/ANY/CNAME.py
+++ b/dns/rdtypes/ANY/CNAME.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.nsbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class CNAME(dns.rdtypes.nsbase.NSBase):
 
     """CNAME record
diff --git a/dns/rdtypes/ANY/CSYNC.py b/dns/rdtypes/ANY/CSYNC.py
index 9cba5fa..979028a 100644
--- a/dns/rdtypes/ANY/CSYNC.py
+++ b/dns/rdtypes/ANY/CSYNC.py
@@ -18,16 +18,19 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.rdatatype
 import dns.name
 import dns.rdtypes.util
 
 
+@dns.immutable.immutable
 class Bitmap(dns.rdtypes.util.Bitmap):
     type_name = 'CSYNC'
 
 
+@dns.immutable.immutable
 class CSYNC(dns.rdata.Rdata):
 
     """CSYNC record"""
@@ -36,9 +39,11 @@ class CSYNC(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, serial, flags, windows):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'serial', serial)
-        object.__setattr__(self, 'flags', flags)
-        object.__setattr__(self, 'windows', dns.rdata._constify(windows))
+        self.serial = self._as_uint32(serial)
+        self.flags = self._as_uint16(flags)
+        if not isinstance(windows, Bitmap):
+            windows = Bitmap(windows)
+        self.windows = tuple(windows.windows)
 
     def to_text(self, origin=None, relativize=True, **kw):
         text = Bitmap(self.windows).to_text()
@@ -49,8 +54,8 @@ class CSYNC(dns.rdata.Rdata):
                   relativize_to=None):
         serial = tok.get_uint32()
         flags = tok.get_uint16()
-        windows = Bitmap().from_text(tok)
-        return cls(rdclass, rdtype, serial, flags, windows)
+        bitmap = Bitmap.from_text(tok)
+        return cls(rdclass, rdtype, serial, flags, bitmap)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
         file.write(struct.pack('!IH', self.serial, self.flags))
@@ -59,5 +64,5 @@ class CSYNC(dns.rdata.Rdata):
     @classmethod
     def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
         (serial, flags) = parser.get_struct("!IH")
-        windows = Bitmap().from_wire_parser(parser)
-        return cls(rdclass, rdtype, serial, flags, windows)
+        bitmap = Bitmap.from_wire_parser(parser)
+        return cls(rdclass, rdtype, serial, flags, bitmap)
diff --git a/dns/rdtypes/ANY/DLV.py b/dns/rdtypes/ANY/DLV.py
index 1635212..947dc42 100644
--- a/dns/rdtypes/ANY/DLV.py
+++ b/dns/rdtypes/ANY/DLV.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.dsbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class DLV(dns.rdtypes.dsbase.DSBase):
 
     """DLV record"""
diff --git a/dns/rdtypes/ANY/DNAME.py b/dns/rdtypes/ANY/DNAME.py
index 2000d9b..f4984b5 100644
--- a/dns/rdtypes/ANY/DNAME.py
+++ b/dns/rdtypes/ANY/DNAME.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.nsbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class DNAME(dns.rdtypes.nsbase.UncompressedNS):
 
     """DNAME record"""
diff --git a/dns/rdtypes/ANY/DNSKEY.py b/dns/rdtypes/ANY/DNSKEY.py
index 2ee3798..e69a7c1 100644
--- a/dns/rdtypes/ANY/DNSKEY.py
+++ b/dns/rdtypes/ANY/DNSKEY.py
@@ -16,9 +16,13 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.dnskeybase
-from dns.rdtypes.dnskeybase import SEP, REVOKE, ZONE  # noqa: F401
+import dns.immutable
 
+# pylint: disable=unused-import
+from dns.rdtypes.dnskeybase import SEP, REVOKE, ZONE  # noqa: F401
+# pylint: enable=unused-import
 
+@dns.immutable.immutable
 class DNSKEY(dns.rdtypes.dnskeybase.DNSKEYBase):
 
     """DNSKEY record"""
diff --git a/dns/rdtypes/ANY/DS.py b/dns/rdtypes/ANY/DS.py
index 7d457b2..3f6c3ee 100644
--- a/dns/rdtypes/ANY/DS.py
+++ b/dns/rdtypes/ANY/DS.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.dsbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class DS(dns.rdtypes.dsbase.DSBase):
 
     """DS record"""
diff --git a/dns/rdtypes/ANY/EUI48.py b/dns/rdtypes/ANY/EUI48.py
index b16e81f..0ab88ad 100644
--- a/dns/rdtypes/ANY/EUI48.py
+++ b/dns/rdtypes/ANY/EUI48.py
@@ -17,8 +17,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.euibase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class EUI48(dns.rdtypes.euibase.EUIBase):
 
     """EUI48 record"""
diff --git a/dns/rdtypes/ANY/EUI64.py b/dns/rdtypes/ANY/EUI64.py
index cc08076..c42957e 100644
--- a/dns/rdtypes/ANY/EUI64.py
+++ b/dns/rdtypes/ANY/EUI64.py
@@ -17,8 +17,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.euibase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class EUI64(dns.rdtypes.euibase.EUIBase):
 
     """EUI64 record"""
diff --git a/dns/rdtypes/ANY/GPOS.py b/dns/rdtypes/ANY/GPOS.py
index 03677fd..29fa8f8 100644
--- a/dns/rdtypes/ANY/GPOS.py
+++ b/dns/rdtypes/ANY/GPOS.py
@@ -18,6 +18,7 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.tokenizer
 
@@ -41,12 +42,7 @@ def _validate_float_string(what):
         raise dns.exception.FormError
 
 
-def _sanitize(value):
-    if isinstance(value, str):
-        return value.encode()
-    return value
-
-
+@dns.immutable.immutable
 class GPOS(dns.rdata.Rdata):
 
     """GPOS record"""
@@ -66,15 +62,15 @@ class GPOS(dns.rdata.Rdata):
         if isinstance(altitude, float) or \
            isinstance(altitude, int):
             altitude = str(altitude)
-        latitude = _sanitize(latitude)
-        longitude = _sanitize(longitude)
-        altitude = _sanitize(altitude)
+        latitude = self._as_bytes(latitude, True, 255)
+        longitude = self._as_bytes(longitude, True, 255)
+        altitude = self._as_bytes(altitude, True, 255)
         _validate_float_string(latitude)
         _validate_float_string(longitude)
         _validate_float_string(altitude)
-        object.__setattr__(self, 'latitude', latitude)
-        object.__setattr__(self, 'longitude', longitude)
-        object.__setattr__(self, 'altitude', altitude)
+        self.latitude = latitude
+        self.longitude = longitude
+        self.altitude = altitude
         flat = self.float_latitude
         if flat < -90.0 or flat > 90.0:
             raise dns.exception.FormError('bad latitude')
@@ -93,7 +89,6 @@ class GPOS(dns.rdata.Rdata):
         latitude = tok.get_string()
         longitude = tok.get_string()
         altitude = tok.get_string()
-        tok.get_eol()
         return cls(rdclass, rdtype, latitude, longitude, altitude)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
diff --git a/dns/rdtypes/ANY/HINFO.py b/dns/rdtypes/ANY/HINFO.py
index 587e0ad..cd04969 100644
--- a/dns/rdtypes/ANY/HINFO.py
+++ b/dns/rdtypes/ANY/HINFO.py
@@ -18,10 +18,12 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.tokenizer
 
 
+@dns.immutable.immutable
 class HINFO(dns.rdata.Rdata):
 
     """HINFO record"""
@@ -32,14 +34,8 @@ class HINFO(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, cpu, os):
         super().__init__(rdclass, rdtype)
-        if isinstance(cpu, str):
-            object.__setattr__(self, 'cpu', cpu.encode())
-        else:
-            object.__setattr__(self, 'cpu', cpu)
-        if isinstance(os, str):
-            object.__setattr__(self, 'os', os.encode())
-        else:
-            object.__setattr__(self, 'os', os)
+        self.cpu = self._as_bytes(cpu, True, 255)
+        self.os = self._as_bytes(os, True, 255)
 
     def to_text(self, origin=None, relativize=True, **kw):
         return '"{}" "{}"'.format(dns.rdata._escapify(self.cpu),
@@ -50,7 +46,6 @@ class HINFO(dns.rdata.Rdata):
                   relativize_to=None):
         cpu = tok.get_string(max_length=255)
         os = tok.get_string(max_length=255)
-        tok.get_eol()
         return cls(rdclass, rdtype, cpu, os)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
diff --git a/dns/rdtypes/ANY/HIP.py b/dns/rdtypes/ANY/HIP.py
index 1c774bb..e887359 100644
--- a/dns/rdtypes/ANY/HIP.py
+++ b/dns/rdtypes/ANY/HIP.py
@@ -20,10 +20,12 @@ import base64
 import binascii
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.rdatatype
 
 
+@dns.immutable.immutable
 class HIP(dns.rdata.Rdata):
 
     """HIP record"""
@@ -34,10 +36,10 @@ class HIP(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, hit, algorithm, key, servers):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'hit', hit)
-        object.__setattr__(self, 'algorithm', algorithm)
-        object.__setattr__(self, 'key', key)
-        object.__setattr__(self, 'servers', dns.rdata._constify(servers))
+        self.hit = self._as_bytes(hit, True, 255)
+        self.algorithm = self._as_uint8(algorithm)
+        self.key = self._as_bytes(key, True)
+        self.servers = self._as_tuple(servers, self._as_name)
 
     def to_text(self, origin=None, relativize=True, **kw):
         hit = binascii.hexlify(self.hit).decode()
@@ -55,14 +57,9 @@ class HIP(dns.rdata.Rdata):
                   relativize_to=None):
         algorithm = tok.get_uint8()
         hit = binascii.unhexlify(tok.get_string().encode())
-        if len(hit) > 255:
-            raise dns.exception.SyntaxError("HIT too long")
         key = base64.b64decode(tok.get_string().encode())
         servers = []
-        while 1:
-            token = tok.get()
-            if token.is_eol_or_eof():
-                break
+        for token in tok.get_remaining():
             server = tok.as_name(token, origin, relativize, relativize_to)
             servers.append(server)
         return cls(rdclass, rdtype, hit, algorithm, key, servers)
diff --git a/dns/rdtypes/ANY/ISDN.py b/dns/rdtypes/ANY/ISDN.py
index 6834b3c..b9a49ad 100644
--- a/dns/rdtypes/ANY/ISDN.py
+++ b/dns/rdtypes/ANY/ISDN.py
@@ -18,10 +18,12 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.tokenizer
 
 
+@dns.immutable.immutable
 class ISDN(dns.rdata.Rdata):
 
     """ISDN record"""
@@ -32,14 +34,8 @@ class ISDN(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, address, subaddress):
         super().__init__(rdclass, rdtype)
-        if isinstance(address, str):
-            object.__setattr__(self, 'address', address.encode())
-        else:
-            object.__setattr__(self, 'address', address)
-        if isinstance(address, str):
-            object.__setattr__(self, 'subaddress', subaddress.encode())
-        else:
-            object.__setattr__(self, 'subaddress', subaddress)
+        self.address = self._as_bytes(address, True, 255)
+        self.subaddress = self._as_bytes(subaddress, True, 255)
 
     def to_text(self, origin=None, relativize=True, **kw):
         if self.subaddress:
@@ -52,14 +48,11 @@ class ISDN(dns.rdata.Rdata):
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         address = tok.get_string()
-        t = tok.get()
-        if not t.is_eol_or_eof():
-            tok.unget(t)
-            subaddress = tok.get_string()
+        tokens = tok.get_remaining(max_tokens=1)
+        if len(tokens) >= 1:
+            subaddress = tokens[0].unescape().value
         else:
-            tok.unget(t)
             subaddress = ''
-        tok.get_eol()
         return cls(rdclass, rdtype, address, subaddress)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
diff --git a/dns/rdtypes/ANY/LOC.py b/dns/rdtypes/ANY/LOC.py
index eb00a1c..1a4ee2b 100644
--- a/dns/rdtypes/ANY/LOC.py
+++ b/dns/rdtypes/ANY/LOC.py
@@ -18,6 +18,7 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 
 
@@ -34,17 +35,13 @@ _MIN_LATITUDE = 0x80000000 - 90 * 3600000
 _MAX_LONGITUDE = 0x80000000 + 180 * 3600000
 _MIN_LONGITUDE = 0x80000000 - 180 * 3600000
 
-# pylint complains about division since we don't have a from __future__ for
-# it, but we don't care about python 2 warnings, so turn them off.
-#
-# pylint: disable=old-division
 
 def _exponent_of(what, desc):
     if what == 0:
         return 0
     exp = None
     for (i, pow) in enumerate(_pows):
-        if what // pow == 0:
+        if what < pow:
             exp = i - 1
             break
     if exp is None or exp < 0:
@@ -94,6 +91,20 @@ def _decode_size(what, desc):
     return base * pow(10, exponent)
 
 
+def _check_coordinate_list(value, low, high):
+    if value[0] < low or value[0] > high:
+        raise ValueError(f'not in range [{low}, {high}]')
+    if value[1] < 0 or value[1] > 59:
+        raise ValueError('bad minutes value')
+    if value[2] < 0 or value[2] > 59:
+        raise ValueError('bad seconds value')
+    if value[3] < 0 or value[3] > 999:
+        raise ValueError('bad milliseconds value')
+    if value[4] != 1 and value[4] != -1:
+        raise ValueError('bad hemisphere value')
+
+
+@dns.immutable.immutable
 class LOC(dns.rdata.Rdata):
 
     """LOC record"""
@@ -119,16 +130,18 @@ class LOC(dns.rdata.Rdata):
             latitude = float(latitude)
         if isinstance(latitude, float):
             latitude = _float_to_tuple(latitude)
-        object.__setattr__(self, 'latitude', dns.rdata._constify(latitude))
+        _check_coordinate_list(latitude, -90, 90)
+        self.latitude = tuple(latitude)
         if isinstance(longitude, int):
             longitude = float(longitude)
         if isinstance(longitude, float):
             longitude = _float_to_tuple(longitude)
-        object.__setattr__(self, 'longitude', dns.rdata._constify(longitude))
-        object.__setattr__(self, 'altitude', float(altitude))
-        object.__setattr__(self, 'size', float(size))
-        object.__setattr__(self, 'horizontal_precision', float(hprec))
-        object.__setattr__(self, 'vertical_precision', float(vprec))
+        _check_coordinate_list(longitude, -180, 180)
+        self.longitude = tuple(longitude)
+        self.altitude = float(altitude)
+        self.size = float(size)
+        self.horizontal_precision = float(hprec)
+        self.vertical_precision = float(vprec)
 
     def to_text(self, origin=None, relativize=True, **kw):
         if self.latitude[4] > 0:
@@ -167,13 +180,9 @@ class LOC(dns.rdata.Rdata):
         vprec = _default_vprec
 
         latitude[0] = tok.get_int()
-        if latitude[0] > 90:
-            raise dns.exception.SyntaxError('latitude >= 90')
         t = tok.get_string()
         if t.isdigit():
             latitude[1] = int(t)
-            if latitude[1] >= 60:
-                raise dns.exception.SyntaxError('latitude minutes >= 60')
             t = tok.get_string()
             if '.' in t:
                 (seconds, milliseconds) = t.split('.')
@@ -181,8 +190,6 @@ class LOC(dns.rdata.Rdata):
                     raise dns.exception.SyntaxError(
                         'bad latitude seconds value')
                 latitude[2] = int(seconds)
-                if latitude[2] >= 60:
-                    raise dns.exception.SyntaxError('latitude seconds >= 60')
                 l = len(milliseconds)
                 if l == 0 or l > 3 or not milliseconds.isdigit():
                     raise dns.exception.SyntaxError(
@@ -204,13 +211,9 @@ class LOC(dns.rdata.Rdata):
             raise dns.exception.SyntaxError('bad latitude hemisphere value')
 
         longitude[0] = tok.get_int()
-        if longitude[0] > 180:
-            raise dns.exception.SyntaxError('longitude > 180')
         t = tok.get_string()
         if t.isdigit():
             longitude[1] = int(t)
-            if longitude[1] >= 60:
-                raise dns.exception.SyntaxError('longitude minutes >= 60')
             t = tok.get_string()
             if '.' in t:
                 (seconds, milliseconds) = t.split('.')
@@ -218,8 +221,6 @@ class LOC(dns.rdata.Rdata):
                     raise dns.exception.SyntaxError(
                         'bad longitude seconds value')
                 longitude[2] = int(seconds)
-                if longitude[2] >= 60:
-                    raise dns.exception.SyntaxError('longitude seconds >= 60')
                 l = len(milliseconds)
                 if l == 0 or l > 3 or not milliseconds.isdigit():
                     raise dns.exception.SyntaxError(
@@ -245,25 +246,22 @@ class LOC(dns.rdata.Rdata):
             t = t[0: -1]
         altitude = float(t) * 100.0        # m -> cm
 
-        token = tok.get().unescape()
-        if not token.is_eol_or_eof():
-            value = token.value
+        tokens = tok.get_remaining(max_tokens=3)
+        if len(tokens) >= 1:
+            value = tokens[0].unescape().value
             if value[-1] == 'm':
                 value = value[0: -1]
             size = float(value) * 100.0        # m -> cm
-            token = tok.get().unescape()
-            if not token.is_eol_or_eof():
-                value = token.value
+            if len(tokens) >= 2:
+                value = tokens[1].unescape().value
                 if value[-1] == 'm':
                     value = value[0: -1]
                 hprec = float(value) * 100.0        # m -> cm
-                token = tok.get().unescape()
-                if not token.is_eol_or_eof():
-                    value = token.value
+                if len(tokens) >= 3:
+                    value = tokens[2].unescape().value
                     if value[-1] == 'm':
                         value = value[0: -1]
                     vprec = float(value) * 100.0        # m -> cm
-                    tok.get_eol()
 
         # Try encoding these now so we raise if they are bad
         _encode_size(size, "size")
@@ -296,6 +294,8 @@ class LOC(dns.rdata.Rdata):
     def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
         (version, size, hprec, vprec, latitude, longitude, altitude) = \
             parser.get_struct("!BBBBIII")
+        if version != 0:
+            raise dns.exception.FormError("LOC version not zero")
         if latitude < _MIN_LATITUDE or latitude > _MAX_LATITUDE:
             raise dns.exception.FormError("bad latitude")
         if latitude > 0x80000000:
diff --git a/dns/rdtypes/ANY/MX.py b/dns/rdtypes/ANY/MX.py
index 0a06494..a697ea4 100644
--- a/dns/rdtypes/ANY/MX.py
+++ b/dns/rdtypes/ANY/MX.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.mxbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class MX(dns.rdtypes.mxbase.MXBase):
 
     """MX record"""
diff --git a/dns/rdtypes/ANY/NINFO.py b/dns/rdtypes/ANY/NINFO.py
index d4c8572..d53e967 100644
--- a/dns/rdtypes/ANY/NINFO.py
+++ b/dns/rdtypes/ANY/NINFO.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.txtbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class NINFO(dns.rdtypes.txtbase.TXTBase):
 
     """NINFO record"""
diff --git a/dns/rdtypes/ANY/NS.py b/dns/rdtypes/ANY/NS.py
index f9fcf63..a0cc232 100644
--- a/dns/rdtypes/ANY/NS.py
+++ b/dns/rdtypes/ANY/NS.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.nsbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class NS(dns.rdtypes.nsbase.NSBase):
 
     """NS record"""
diff --git a/dns/rdtypes/ANY/NSEC.py b/dns/rdtypes/ANY/NSEC.py
index 626d339..dc31f4c 100644
--- a/dns/rdtypes/ANY/NSEC.py
+++ b/dns/rdtypes/ANY/NSEC.py
@@ -16,16 +16,19 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.rdatatype
 import dns.name
 import dns.rdtypes.util
 
 
+@dns.immutable.immutable
 class Bitmap(dns.rdtypes.util.Bitmap):
     type_name = 'NSEC'
 
 
+@dns.immutable.immutable
 class NSEC(dns.rdata.Rdata):
 
     """NSEC record"""
@@ -34,8 +37,10 @@ class NSEC(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, next, windows):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'next', next)
-        object.__setattr__(self, 'windows', dns.rdata._constify(windows))
+        self.next = self._as_name(next)
+        if not isinstance(windows, Bitmap):
+            windows = Bitmap(windows)
+        self.windows = tuple(windows.windows)
 
     def to_text(self, origin=None, relativize=True, **kw):
         next = self.next.choose_relativity(origin, relativize)
@@ -46,15 +51,17 @@ class NSEC(dns.rdata.Rdata):
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         next = tok.get_name(origin, relativize, relativize_to)
-        windows = Bitmap().from_text(tok)
+        windows = Bitmap.from_text(tok)
         return cls(rdclass, rdtype, next, windows)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        # Note that NSEC downcasing, originally mandated by RFC 4034
+        # section 6.2 was removed by RFC 6840 section 5.1.
         self.next.to_wire(file, None, origin, False)
         Bitmap(self.windows).to_wire(file)
 
     @classmethod
     def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
         next = parser.get_name(origin)
-        windows = Bitmap().from_wire_parser(parser)
-        return cls(rdclass, rdtype, next, windows)
+        bitmap = Bitmap.from_wire_parser(parser)
+        return cls(rdclass, rdtype, next, bitmap)
diff --git a/dns/rdtypes/ANY/NSEC3.py b/dns/rdtypes/ANY/NSEC3.py
index 91471f0..14242bd 100644
--- a/dns/rdtypes/ANY/NSEC3.py
+++ b/dns/rdtypes/ANY/NSEC3.py
@@ -20,6 +20,7 @@ import binascii
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.rdatatype
 import dns.rdtypes.util
@@ -37,10 +38,12 @@ SHA1 = 1
 OPTOUT = 1
 
 
+@dns.immutable.immutable
 class Bitmap(dns.rdtypes.util.Bitmap):
     type_name = 'NSEC3'
 
 
+@dns.immutable.immutable
 class NSEC3(dns.rdata.Rdata):
 
     """NSEC3 record"""
@@ -50,15 +53,14 @@ class NSEC3(dns.rdata.Rdata):
     def __init__(self, rdclass, rdtype, algorithm, flags, iterations, salt,
                  next, windows):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'algorithm', algorithm)
-        object.__setattr__(self, 'flags', flags)
-        object.__setattr__(self, 'iterations', iterations)
-        if isinstance(salt, str):
-            object.__setattr__(self, 'salt', salt.encode())
-        else:
-            object.__setattr__(self, 'salt', salt)
-        object.__setattr__(self, 'next', next)
-        object.__setattr__(self, 'windows', dns.rdata._constify(windows))
+        self.algorithm = self._as_uint8(algorithm)
+        self.flags = self._as_uint8(flags)
+        self.iterations = self._as_uint16(iterations)
+        self.salt = self._as_bytes(salt, True, 255)
+        self.next = self._as_bytes(next, True, 255)
+        if not isinstance(windows, Bitmap):
+            windows = Bitmap(windows)
+        self.windows = tuple(windows.windows)
 
     def to_text(self, origin=None, relativize=True, **kw):
         next = base64.b32encode(self.next).translate(
@@ -85,9 +87,9 @@ class NSEC3(dns.rdata.Rdata):
         next = tok.get_string().encode(
             'ascii').upper().translate(b32_hex_to_normal)
         next = base64.b32decode(next)
-        windows = Bitmap().from_text(tok)
+        bitmap = Bitmap.from_text(tok)
         return cls(rdclass, rdtype, algorithm, flags, iterations, salt, next,
-                   windows)
+                   bitmap)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
         l = len(self.salt)
@@ -104,6 +106,6 @@ class NSEC3(dns.rdata.Rdata):
         (algorithm, flags, iterations) = parser.get_struct('!BBH')
         salt = parser.get_counted_bytes()
         next = parser.get_counted_bytes()
-        windows = Bitmap().from_wire_parser(parser)
+        bitmap = Bitmap.from_wire_parser(parser)
         return cls(rdclass, rdtype, algorithm, flags, iterations, salt, next,
-                   windows)
+                   bitmap)
diff --git a/dns/rdtypes/ANY/NSEC3PARAM.py b/dns/rdtypes/ANY/NSEC3PARAM.py
index 8ac7627..299bf6e 100644
--- a/dns/rdtypes/ANY/NSEC3PARAM.py
+++ b/dns/rdtypes/ANY/NSEC3PARAM.py
@@ -19,9 +19,11 @@ import struct
 import binascii
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 
 
+@dns.immutable.immutable
 class NSEC3PARAM(dns.rdata.Rdata):
 
     """NSEC3PARAM record"""
@@ -30,13 +32,10 @@ class NSEC3PARAM(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, algorithm, flags, iterations, salt):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'algorithm', algorithm)
-        object.__setattr__(self, 'flags', flags)
-        object.__setattr__(self, 'iterations', iterations)
-        if isinstance(salt, str):
-            object.__setattr__(self, 'salt', salt.encode())
-        else:
-            object.__setattr__(self, 'salt', salt)
+        self.algorithm = self._as_uint8(algorithm)
+        self.flags = self._as_uint8(flags)
+        self.iterations = self._as_uint16(iterations)
+        self.salt = self._as_bytes(salt, True, 255)
 
     def to_text(self, origin=None, relativize=True, **kw):
         if self.salt == b'':
@@ -57,7 +56,6 @@ class NSEC3PARAM(dns.rdata.Rdata):
             salt = ''
         else:
             salt = binascii.unhexlify(salt.encode())
-        tok.get_eol()
         return cls(rdclass, rdtype, algorithm, flags, iterations, salt)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
diff --git a/dns/rdtypes/ANY/OPENPGPKEY.py b/dns/rdtypes/ANY/OPENPGPKEY.py
index f632132..dcfa028 100644
--- a/dns/rdtypes/ANY/OPENPGPKEY.py
+++ b/dns/rdtypes/ANY/OPENPGPKEY.py
@@ -18,9 +18,11 @@
 import base64
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.tokenizer
 
+@dns.immutable.immutable
 class OPENPGPKEY(dns.rdata.Rdata):
 
     """OPENPGPKEY record"""
@@ -29,10 +31,10 @@ class OPENPGPKEY(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, key):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'key', key)
+        self.key = self._as_bytes(key)
 
     def to_text(self, origin=None, relativize=True, **kw):
-        return dns.rdata._base64ify(self.key)
+        return dns.rdata._base64ify(self.key, chunksize=None, **kw)
 
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
diff --git a/dns/rdtypes/ANY/OPT.py b/dns/rdtypes/ANY/OPT.py
index c48aa12..69b8fe7 100644
--- a/dns/rdtypes/ANY/OPT.py
+++ b/dns/rdtypes/ANY/OPT.py
@@ -18,10 +18,15 @@
 import struct
 
 import dns.edns
+import dns.immutable
 import dns.exception
 import dns.rdata
 
 
+# We don't implement from_text, and that's ok.
+# pylint: disable=abstract-method
+
+@dns.immutable.immutable
 class OPT(dns.rdata.Rdata):
 
     """OPT record"""
@@ -40,7 +45,11 @@ class OPT(dns.rdata.Rdata):
         """
 
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'options', dns.rdata._constify(options))
+        def as_option(option):
+            if not isinstance(option, dns.edns.Option):
+                raise ValueError('option is not a dns.edns.option')
+            return option
+        self.options = self._as_tuple(options, as_option)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
         for opt in self.options:
diff --git a/dns/rdtypes/ANY/PTR.py b/dns/rdtypes/ANY/PTR.py
index 20cd507..265bed0 100644
--- a/dns/rdtypes/ANY/PTR.py
+++ b/dns/rdtypes/ANY/PTR.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.nsbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class PTR(dns.rdtypes.nsbase.NSBase):
 
     """PTR record"""
diff --git a/dns/rdtypes/ANY/RP.py b/dns/rdtypes/ANY/RP.py
index 7446de6..a4e2297 100644
--- a/dns/rdtypes/ANY/RP.py
+++ b/dns/rdtypes/ANY/RP.py
@@ -16,10 +16,12 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.name
 
 
+@dns.immutable.immutable
 class RP(dns.rdata.Rdata):
 
     """RP record"""
@@ -30,8 +32,8 @@ class RP(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, mbox, txt):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'mbox', mbox)
-        object.__setattr__(self, 'txt', txt)
+        self.mbox = self._as_name(mbox)
+        self.txt = self._as_name(txt)
 
     def to_text(self, origin=None, relativize=True, **kw):
         mbox = self.mbox.choose_relativity(origin, relativize)
@@ -43,7 +45,6 @@ class RP(dns.rdata.Rdata):
                   relativize_to=None):
         mbox = tok.get_name(origin, relativize, relativize_to)
         txt = tok.get_name(origin, relativize, relativize_to)
-        tok.get_eol()
         return cls(rdclass, rdtype, mbox, txt)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
diff --git a/dns/rdtypes/ANY/RRSIG.py b/dns/rdtypes/ANY/RRSIG.py
index 2077d90..d050ccc 100644
--- a/dns/rdtypes/ANY/RRSIG.py
+++ b/dns/rdtypes/ANY/RRSIG.py
@@ -21,6 +21,7 @@ import struct
 import time
 
 import dns.dnssec
+import dns.immutable
 import dns.exception
 import dns.rdata
 import dns.rdatatype
@@ -50,6 +51,7 @@ def posixtime_to_sigtime(what):
     return time.strftime('%Y%m%d%H%M%S', time.gmtime(what))
 
 
+@dns.immutable.immutable
 class RRSIG(dns.rdata.Rdata):
 
     """RRSIG record"""
@@ -62,15 +64,15 @@ class RRSIG(dns.rdata.Rdata):
                  original_ttl, expiration, inception, key_tag, signer,
                  signature):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'type_covered', type_covered)
-        object.__setattr__(self, 'algorithm', algorithm)
-        object.__setattr__(self, 'labels', labels)
-        object.__setattr__(self, 'original_ttl', original_ttl)
-        object.__setattr__(self, 'expiration', expiration)
-        object.__setattr__(self, 'inception', inception)
-        object.__setattr__(self, 'key_tag', key_tag)
-        object.__setattr__(self, 'signer', signer)
-        object.__setattr__(self, 'signature', signature)
+        self.type_covered = self._as_rdatatype(type_covered)
+        self.algorithm = dns.dnssec.Algorithm.make(algorithm)
+        self.labels = self._as_uint8(labels)
+        self.original_ttl = self._as_ttl(original_ttl)
+        self.expiration = self._as_uint32(expiration)
+        self.inception = self._as_uint32(inception)
+        self.key_tag = self._as_uint16(key_tag)
+        self.signer = self._as_name(signer)
+        self.signature = self._as_bytes(signature)
 
     def covers(self):
         return self.type_covered
@@ -85,7 +87,7 @@ class RRSIG(dns.rdata.Rdata):
             posixtime_to_sigtime(self.inception),
             self.key_tag,
             self.signer.choose_relativity(origin, relativize),
-            dns.rdata._base64ify(self.signature)
+            dns.rdata._base64ify(self.signature, **kw)
         )
 
     @classmethod
diff --git a/dns/rdtypes/ANY/RT.py b/dns/rdtypes/ANY/RT.py
index d0feb79..8d9c6bd 100644
--- a/dns/rdtypes/ANY/RT.py
+++ b/dns/rdtypes/ANY/RT.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.mxbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class RT(dns.rdtypes.mxbase.UncompressedDowncasingMX):
 
     """RT record"""
diff --git a/dns/rdtypes/ANY/SMIMEA.py b/dns/rdtypes/ANY/SMIMEA.py
new file mode 100644
index 0000000..55d87bf
--- /dev/null
+++ b/dns/rdtypes/ANY/SMIMEA.py
@@ -0,0 +1,9 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import dns.immutable
+import dns.rdtypes.tlsabase
+
+
+@dns.immutable.immutable
+class SMIMEA(dns.rdtypes.tlsabase.TLSABase):
+    """SMIMEA record"""
diff --git a/dns/rdtypes/ANY/SOA.py b/dns/rdtypes/ANY/SOA.py
index e93274e..7ce8865 100644
--- a/dns/rdtypes/ANY/SOA.py
+++ b/dns/rdtypes/ANY/SOA.py
@@ -18,10 +18,12 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.name
 
 
+@dns.immutable.immutable
 class SOA(dns.rdata.Rdata):
 
     """SOA record"""
@@ -34,13 +36,13 @@ class SOA(dns.rdata.Rdata):
     def __init__(self, rdclass, rdtype, mname, rname, serial, refresh, retry,
                  expire, minimum):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'mname', mname)
-        object.__setattr__(self, 'rname', rname)
-        object.__setattr__(self, 'serial', serial)
-        object.__setattr__(self, 'refresh', refresh)
-        object.__setattr__(self, 'retry', retry)
-        object.__setattr__(self, 'expire', expire)
-        object.__setattr__(self, 'minimum', minimum)
+        self.mname = self._as_name(mname)
+        self.rname = self._as_name(rname)
+        self.serial = self._as_uint32(serial)
+        self.refresh = self._as_ttl(refresh)
+        self.retry = self._as_ttl(retry)
+        self.expire = self._as_ttl(expire)
+        self.minimum = self._as_ttl(minimum)
 
     def to_text(self, origin=None, relativize=True, **kw):
         mname = self.mname.choose_relativity(origin, relativize)
@@ -59,7 +61,6 @@ class SOA(dns.rdata.Rdata):
         retry = tok.get_ttl()
         expire = tok.get_ttl()
         minimum = tok.get_ttl()
-        tok.get_eol()
         return cls(rdclass, rdtype, mname, rname, serial, refresh, retry,
                    expire, minimum)
 
diff --git a/dns/rdtypes/ANY/SPF.py b/dns/rdtypes/ANY/SPF.py
index f1f6834..1190e0d 100644
--- a/dns/rdtypes/ANY/SPF.py
+++ b/dns/rdtypes/ANY/SPF.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.txtbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class SPF(dns.rdtypes.txtbase.TXTBase):
 
     """SPF record"""
diff --git a/dns/rdtypes/ANY/SSHFP.py b/dns/rdtypes/ANY/SSHFP.py
index a3cc003..cc03519 100644
--- a/dns/rdtypes/ANY/SSHFP.py
+++ b/dns/rdtypes/ANY/SSHFP.py
@@ -19,9 +19,11 @@ import struct
 import binascii
 
 import dns.rdata
+import dns.immutable
 import dns.rdatatype
 
 
+@dns.immutable.immutable
 class SSHFP(dns.rdata.Rdata):
 
     """SSHFP record"""
@@ -33,15 +35,18 @@ class SSHFP(dns.rdata.Rdata):
     def __init__(self, rdclass, rdtype, algorithm, fp_type,
                  fingerprint):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'algorithm', algorithm)
-        object.__setattr__(self, 'fp_type', fp_type)
-        object.__setattr__(self, 'fingerprint', fingerprint)
+        self.algorithm = self._as_uint8(algorithm)
+        self.fp_type = self._as_uint8(fp_type)
+        self.fingerprint = self._as_bytes(fingerprint, True)
 
     def to_text(self, origin=None, relativize=True, **kw):
+        kw = kw.copy()
+        chunksize = kw.pop('chunksize', 128)
         return '%d %d %s' % (self.algorithm,
                              self.fp_type,
                              dns.rdata._hexify(self.fingerprint,
-                                               chunksize=128))
+                                               chunksize=chunksize,
+                                               **kw))
 
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
diff --git a/dns/rdtypes/ANY/TKEY.py b/dns/rdtypes/ANY/TKEY.py
new file mode 100644
index 0000000..f8c4737
--- /dev/null
+++ b/dns/rdtypes/ANY/TKEY.py
@@ -0,0 +1,118 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2004-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import base64
+import struct
+
+import dns.dnssec
+import dns.immutable
+import dns.exception
+import dns.rdata
+
+
+@dns.immutable.immutable
+class TKEY(dns.rdata.Rdata):
+
+    """TKEY Record"""
+
+    __slots__ = ['algorithm', 'inception', 'expiration', 'mode', 'error',
+                 'key', 'other']
+
+    def __init__(self, rdclass, rdtype, algorithm, inception, expiration,
+                 mode, error, key, other=b''):
+        super().__init__(rdclass, rdtype)
+        self.algorithm = self._as_name(algorithm)
+        self.inception = self._as_uint32(inception)
+        self.expiration = self._as_uint32(expiration)
+        self.mode = self._as_uint16(mode)
+        self.error = self._as_uint16(error)
+        self.key = self._as_bytes(key)
+        self.other = self._as_bytes(other)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        _algorithm = self.algorithm.choose_relativity(origin, relativize)
+        text = '%s %u %u %u %u %s' % (str(_algorithm), self.inception,
+                                      self.expiration, self.mode, self.error,
+                                      dns.rdata._base64ify(self.key, 0))
+        if len(self.other) > 0:
+            text += ' %s' % (dns.rdata._base64ify(self.other, 0))
+
+        return text
+
+    @classmethod
+    def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
+                  relativize_to=None):
+        algorithm = tok.get_name(relativize=False)
+        inception = tok.get_uint32()
+        expiration = tok.get_uint32()
+        mode = tok.get_uint16()
+        error = tok.get_uint16()
+        key_b64 = tok.get_string().encode()
+        key = base64.b64decode(key_b64)
+        other_b64 = tok.concatenate_remaining_identifiers().encode()
+        other = base64.b64decode(other_b64)
+
+        return cls(rdclass, rdtype, algorithm, inception, expiration, mode,
+                   error, key, other)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        self.algorithm.to_wire(file, compress, origin)
+        file.write(struct.pack("!IIHH", self.inception, self.expiration,
+                               self.mode, self.error))
+        file.write(struct.pack("!H", len(self.key)))
+        file.write(self.key)
+        file.write(struct.pack("!H", len(self.other)))
+        if len(self.other) > 0:
+            file.write(self.other)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        algorithm = parser.get_name(origin)
+        inception, expiration, mode, error = parser.get_struct("!IIHH")
+        key = parser.get_counted_bytes(2)
+        other = parser.get_counted_bytes(2)
+
+        return cls(rdclass, rdtype, algorithm, inception, expiration, mode,
+                   error, key, other)
+
+    # Constants for the mode field - from RFC 2930:
+    # 2.5 The Mode Field
+    #
+    #    The mode field specifies the general scheme for key agreement or
+    #    the purpose of the TKEY DNS message.  Servers and resolvers
+    #    supporting this specification MUST implement the Diffie-Hellman key
+    #    agreement mode and the key deletion mode for queries.  All other
+    #    modes are OPTIONAL.  A server supporting TKEY that receives a TKEY
+    #    request with a mode it does not support returns the BADMODE error.
+    #    The following values of the Mode octet are defined, available, or
+    #    reserved:
+    #
+    #          Value    Description
+    #          -----    -----------
+    #           0        - reserved, see section 7
+    #           1       server assignment
+    #           2       Diffie-Hellman exchange
+    #           3       GSS-API negotiation
+    #           4       resolver assignment
+    #           5       key deletion
+    #          6-65534   - available, see section 7
+    #          65535     - reserved, see section 7
+    SERVER_ASSIGNMENT = 1
+    DIFFIE_HELLMAN_EXCHANGE = 2
+    GSSAPI_NEGOTIATION = 3
+    RESOLVER_ASSIGNMENT = 4
+    KEY_DELETION = 5
diff --git a/dns/rdtypes/ANY/TLSA.py b/dns/rdtypes/ANY/TLSA.py
index 9c9c866..c9ba199 100644
--- a/dns/rdtypes/ANY/TLSA.py
+++ b/dns/rdtypes/ANY/TLSA.py
@@ -1,67 +1,10 @@
 # Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
 
-# Copyright (C) 2005-2007, 2009-2011 Nominum, Inc.
-#
-# Permission to use, copy, modify, and distribute this software and its
-# documentation for any purpose with or without fee is hereby granted,
-# provided that the above copyright notice and this permission notice
-# appear in all copies.
-#
-# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
-# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
-# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
-# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
-# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
-# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
-# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+import dns.immutable
+import dns.rdtypes.tlsabase
 
-import struct
-import binascii
 
-import dns.rdata
-import dns.rdatatype
-
-
-class TLSA(dns.rdata.Rdata):
+@dns.immutable.immutable
+class TLSA(dns.rdtypes.tlsabase.TLSABase):
 
     """TLSA record"""
-
-    # see: RFC 6698
-
-    __slots__ = ['usage', 'selector', 'mtype', 'cert']
-
-    def __init__(self, rdclass, rdtype, usage, selector,
-                 mtype, cert):
-        super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'usage', usage)
-        object.__setattr__(self, 'selector', selector)
-        object.__setattr__(self, 'mtype', mtype)
-        object.__setattr__(self, 'cert', cert)
-
-    def to_text(self, origin=None, relativize=True, **kw):
-        return '%d %d %d %s' % (self.usage,
-                                self.selector,
-                                self.mtype,
-                                dns.rdata._hexify(self.cert,
-                                                  chunksize=128))
-
-    @classmethod
-    def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
-                  relativize_to=None):
-        usage = tok.get_uint8()
-        selector = tok.get_uint8()
-        mtype = tok.get_uint8()
-        cert = tok.concatenate_remaining_identifiers().encode()
-        cert = binascii.unhexlify(cert)
-        return cls(rdclass, rdtype, usage, selector, mtype, cert)
-
-    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
-        header = struct.pack("!BBB", self.usage, self.selector, self.mtype)
-        file.write(header)
-        file.write(self.cert)
-
-    @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
-        header = parser.get_struct("BBB")
-        cert = parser.get_remaining()
-        return cls(rdclass, rdtype, header[0], header[1], header[2], cert)
diff --git a/dns/rdtypes/ANY/TSIG.py b/dns/rdtypes/ANY/TSIG.py
index 18db4c9..b43a78f 100644
--- a/dns/rdtypes/ANY/TSIG.py
+++ b/dns/rdtypes/ANY/TSIG.py
@@ -15,12 +15,16 @@
 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
+import base64
 import struct
 
 import dns.exception
+import dns.immutable
+import dns.rcode
 import dns.rdata
 
 
+@dns.immutable.immutable
 class TSIG(dns.rdata.Rdata):
 
     """TSIG record"""
@@ -52,20 +56,45 @@ class TSIG(dns.rdata.Rdata):
         """
 
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'algorithm', algorithm)
-        object.__setattr__(self, 'time_signed', time_signed)
-        object.__setattr__(self, 'fudge', fudge)
-        object.__setattr__(self, 'mac', dns.rdata._constify(mac))
-        object.__setattr__(self, 'original_id', original_id)
-        object.__setattr__(self, 'error', error)
-        object.__setattr__(self, 'other', dns.rdata._constify(other))
+        self.algorithm = self._as_name(algorithm)
+        self.time_signed = self._as_uint48(time_signed)
+        self.fudge = self._as_uint16(fudge)
+        self.mac = self._as_bytes(mac)
+        self.original_id = self._as_uint16(original_id)
+        self.error = dns.rcode.Rcode.make(error)
+        self.other = self._as_bytes(other)
 
     def to_text(self, origin=None, relativize=True, **kw):
         algorithm = self.algorithm.choose_relativity(origin, relativize)
-        return f"{algorithm} {self.fudge} {self.time_signed} " + \
+        error = dns.rcode.to_text(self.error, True)
+        text = f"{algorithm} {self.time_signed} {self.fudge} " + \
                f"{len(self.mac)} {dns.rdata._base64ify(self.mac, 0)} " + \
-               f"{self.original_id} {self.error} " + \
-               f"{len(self.other)} {dns.rdata._base64ify(self.other, 0)}"
+               f"{self.original_id} {error} {len(self.other)}"
+        if self.other:
+            text += f" {dns.rdata._base64ify(self.other, 0)}"
+        return text
+
+    @classmethod
+    def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
+                  relativize_to=None):
+        algorithm = tok.get_name(relativize=False)
+        time_signed = tok.get_uint48()
+        fudge = tok.get_uint16()
+        mac_len = tok.get_uint16()
+        mac = base64.b64decode(tok.get_string())
+        if len(mac) != mac_len:
+            raise SyntaxError('invalid MAC')
+        original_id = tok.get_uint16()
+        error = dns.rcode.from_text(tok.get_string())
+        other_len = tok.get_uint16()
+        if other_len > 0:
+            other = base64.b64decode(tok.get_string())
+            if len(other) != other_len:
+                raise SyntaxError('invalid other data')
+        else:
+            other = b''
+        return cls(rdclass, rdtype, algorithm, time_signed, fudge, mac,
+                   original_id, error, other)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
         self.algorithm.to_wire(file, None, origin, False)
@@ -81,9 +110,9 @@ class TSIG(dns.rdata.Rdata):
 
     @classmethod
     def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
-        algorithm = parser.get_name(origin)
-        (time_hi, time_lo, fudge) = parser.get_struct('!HIH')
-        time_signed = (time_hi << 32) + time_lo
+        algorithm = parser.get_name()
+        time_signed = parser.get_uint48()
+        fudge = parser.get_uint16()
         mac = parser.get_counted_bytes(2)
         (original_id, error) = parser.get_struct('!HH')
         other = parser.get_counted_bytes(2)
diff --git a/dns/rdtypes/ANY/TXT.py b/dns/rdtypes/ANY/TXT.py
index c5ae919..cc4b661 100644
--- a/dns/rdtypes/ANY/TXT.py
+++ b/dns/rdtypes/ANY/TXT.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.txtbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class TXT(dns.rdtypes.txtbase.TXTBase):
 
     """TXT record"""
diff --git a/dns/rdtypes/ANY/URI.py b/dns/rdtypes/ANY/URI.py
index 84296f5..524fa1b 100644
--- a/dns/rdtypes/ANY/URI.py
+++ b/dns/rdtypes/ANY/URI.py
@@ -19,10 +19,13 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
+import dns.rdtypes.util
 import dns.name
 
 
+@dns.immutable.immutable
 class URI(dns.rdata.Rdata):
 
     """URI record"""
@@ -33,14 +36,11 @@ class URI(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, priority, weight, target):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'priority', priority)
-        object.__setattr__(self, 'weight', weight)
-        if len(target) < 1:
+        self.priority = self._as_uint16(priority)
+        self.weight = self._as_uint16(weight)
+        self.target = self._as_bytes(target, True)
+        if len(self.target) == 0:
             raise dns.exception.SyntaxError("URI target cannot be empty")
-        if isinstance(target, str):
-            object.__setattr__(self, 'target', target.encode())
-        else:
-            object.__setattr__(self, 'target', target)
 
     def to_text(self, origin=None, relativize=True, **kw):
         return '%d %d "%s"' % (self.priority, self.weight,
@@ -54,7 +54,6 @@ class URI(dns.rdata.Rdata):
         target = tok.get().unescape()
         if not (target.is_quoted_string() or target.is_identifier()):
             raise dns.exception.SyntaxError("URI target must be a string")
-        tok.get_eol()
         return cls(rdclass, rdtype, priority, weight, target.value)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
@@ -69,3 +68,13 @@ class URI(dns.rdata.Rdata):
         if len(target) == 0:
             raise dns.exception.FormError('URI target may not be empty')
         return cls(rdclass, rdtype, priority, weight, target)
+
+    def _processing_priority(self):
+        return self.priority
+
+    def _processing_weight(self):
+        return self.weight
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.weighted_processing_order(iterable)
diff --git a/dns/rdtypes/ANY/X25.py b/dns/rdtypes/ANY/X25.py
index 214f1dc..4f7230c 100644
--- a/dns/rdtypes/ANY/X25.py
+++ b/dns/rdtypes/ANY/X25.py
@@ -18,10 +18,12 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.tokenizer
 
 
+@dns.immutable.immutable
 class X25(dns.rdata.Rdata):
 
     """X25 record"""
@@ -32,10 +34,7 @@ class X25(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, address):
         super().__init__(rdclass, rdtype)
-        if isinstance(address, str):
-            object.__setattr__(self, 'address', address.encode())
-        else:
-            object.__setattr__(self, 'address', address)
+        self.address = self._as_bytes(address, True, 255)
 
     def to_text(self, origin=None, relativize=True, **kw):
         return '"%s"' % dns.rdata._escapify(self.address)
@@ -44,7 +43,6 @@ class X25(dns.rdata.Rdata):
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         address = tok.get_string()
-        tok.get_eol()
         return cls(rdclass, rdtype, address)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
diff --git a/dns/rdtypes/ANY/ZONEMD.py b/dns/rdtypes/ANY/ZONEMD.py
new file mode 100644
index 0000000..0d9eedc
--- /dev/null
+++ b/dns/rdtypes/ANY/ZONEMD.py
@@ -0,0 +1,66 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import hashlib
+import struct
+import binascii
+
+import dns.immutable
+import dns.rdata
+import dns.rdatatype
+import dns.zone
+
+
+@dns.immutable.immutable
+class ZONEMD(dns.rdata.Rdata):
+
+    """ZONEMD record"""
+
+    # See RFC 8976
+
+    __slots__ = ['serial', 'scheme', 'hash_algorithm', 'digest']
+
+    def __init__(self, rdclass, rdtype, serial, scheme, hash_algorithm, digest):
+        super().__init__(rdclass, rdtype)
+        self.serial = self._as_uint32(serial)
+        self.scheme = dns.zone.DigestScheme.make(scheme)
+        self.hash_algorithm = dns.zone.DigestHashAlgorithm.make(hash_algorithm)
+        self.digest = self._as_bytes(digest)
+
+        if self.scheme == 0:  # reserved, RFC 8976 Sec. 5.2
+            raise ValueError('scheme 0 is reserved')
+        if self.hash_algorithm == 0:  # reserved, RFC 8976 Sec. 5.3
+            raise ValueError('hash_algorithm 0 is reserved')
+
+        hasher = dns.zone._digest_hashers.get(self.hash_algorithm)
+        if hasher and hasher().digest_size != len(self.digest):
+            raise ValueError('digest length inconsistent with hash algorithm')
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        kw = kw.copy()
+        chunksize = kw.pop('chunksize', 128)
+        return '%d %d %d %s' % (self.serial, self.scheme, self.hash_algorithm,
+                                dns.rdata._hexify(self.digest,
+                                                  chunksize=chunksize,
+                                                  **kw))
+
+    @classmethod
+    def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
+                  relativize_to=None):
+        serial = tok.get_uint32()
+        scheme = tok.get_uint8()
+        hash_algorithm = tok.get_uint8()
+        digest = tok.concatenate_remaining_identifiers().encode()
+        digest = binascii.unhexlify(digest)
+        return cls(rdclass, rdtype, serial, scheme, hash_algorithm, digest)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        header = struct.pack("!IBB", self.serial, self.scheme,
+                             self.hash_algorithm)
+        file.write(header)
+        file.write(self.digest)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        header = parser.get_struct("!IBB")
+        digest = parser.get_remaining()
+        return cls(rdclass, rdtype, header[0], header[1], header[2], digest)
diff --git a/dns/rdtypes/ANY/__init__.py b/dns/rdtypes/ANY/__init__.py
index ea704c8..6c56baf 100644
--- a/dns/rdtypes/ANY/__init__.py
+++ b/dns/rdtypes/ANY/__init__.py
@@ -19,6 +19,7 @@
 
 __all__ = [
     'AFSDB',
+    'AMTRELAY',
     'AVC',
     'CAA',
     'CDNSKEY',
@@ -38,6 +39,7 @@ __all__ = [
     'ISDN',
     'LOC',
     'MX',
+    'NINFO',
     'NS',
     'NSEC',
     'NSEC3',
@@ -48,12 +50,15 @@ __all__ = [
     'RP',
     'RRSIG',
     'RT',
+    'SMIMEA',
     'SOA',
     'SPF',
     'SSHFP',
+    'TKEY',
     'TLSA',
     'TSIG',
     'TXT',
     'URI',
     'X25',
+    'ZONEMD',
 ]
diff --git a/dns/rdtypes/CH/A.py b/dns/rdtypes/CH/A.py
index b738ac6..828701b 100644
--- a/dns/rdtypes/CH/A.py
+++ b/dns/rdtypes/CH/A.py
@@ -15,9 +15,12 @@
 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
-import dns.rdtypes.mxbase
 import struct
 
+import dns.rdtypes.mxbase
+import dns.immutable
+
+@dns.immutable.immutable
 class A(dns.rdata.Rdata):
 
     """A record for Chaosnet"""
@@ -29,8 +32,8 @@ class A(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, domain, address):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'domain', domain)
-        object.__setattr__(self, 'address', address)
+        self.domain = self._as_name(domain)
+        self.address = self._as_uint16(address)
 
     def to_text(self, origin=None, relativize=True, **kw):
         domain = self.domain.choose_relativity(origin, relativize)
@@ -41,7 +44,6 @@ class A(dns.rdata.Rdata):
                   relativize_to=None):
         domain = tok.get_name(origin, relativize, relativize_to)
         address = tok.get_uint16(base=8)
-        tok.get_eol()
         return cls(rdclass, rdtype, domain, address)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
diff --git a/dns/rdtypes/IN/A.py b/dns/rdtypes/IN/A.py
index 8b71e32..74b591e 100644
--- a/dns/rdtypes/IN/A.py
+++ b/dns/rdtypes/IN/A.py
@@ -16,11 +16,13 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.exception
+import dns.immutable
 import dns.ipv4
 import dns.rdata
 import dns.tokenizer
 
 
+@dns.immutable.immutable
 class A(dns.rdata.Rdata):
 
     """A record."""
@@ -29,9 +31,7 @@ class A(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, address):
         super().__init__(rdclass, rdtype)
-        # check that it's OK
-        dns.ipv4.inet_aton(address)
-        object.__setattr__(self, 'address', address)
+        self.address = self._as_ipv4_address(address)
 
     def to_text(self, origin=None, relativize=True, **kw):
         return self.address
@@ -40,7 +40,6 @@ class A(dns.rdata.Rdata):
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         address = tok.get_identifier()
-        tok.get_eol()
         return cls(rdclass, rdtype, address)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
@@ -48,5 +47,5 @@ class A(dns.rdata.Rdata):
 
     @classmethod
     def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
-        address = dns.ipv4.inet_ntoa(parser.get_remaining())
+        address = parser.get_remaining()
         return cls(rdclass, rdtype, address)
diff --git a/dns/rdtypes/IN/AAAA.py b/dns/rdtypes/IN/AAAA.py
index 08f9d67..2d3ec90 100644
--- a/dns/rdtypes/IN/AAAA.py
+++ b/dns/rdtypes/IN/AAAA.py
@@ -16,11 +16,13 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.exception
+import dns.immutable
 import dns.ipv6
 import dns.rdata
 import dns.tokenizer
 
 
+@dns.immutable.immutable
 class AAAA(dns.rdata.Rdata):
 
     """AAAA record."""
@@ -29,9 +31,7 @@ class AAAA(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, address):
         super().__init__(rdclass, rdtype)
-        # check that it's OK
-        dns.ipv6.inet_aton(address)
-        object.__setattr__(self, 'address', address)
+        self.address = self._as_ipv6_address(address)
 
     def to_text(self, origin=None, relativize=True, **kw):
         return self.address
@@ -40,7 +40,6 @@ class AAAA(dns.rdata.Rdata):
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         address = tok.get_identifier()
-        tok.get_eol()
         return cls(rdclass, rdtype, address)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
@@ -48,5 +47,5 @@ class AAAA(dns.rdata.Rdata):
 
     @classmethod
     def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
-        address = dns.ipv6.inet_ntoa(parser.get_remaining())
+        address = parser.get_remaining()
         return cls(rdclass, rdtype, address)
diff --git a/dns/rdtypes/IN/APL.py b/dns/rdtypes/IN/APL.py
index ab7fe4b..5cfdc34 100644
--- a/dns/rdtypes/IN/APL.py
+++ b/dns/rdtypes/IN/APL.py
@@ -20,11 +20,13 @@ import codecs
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.ipv4
 import dns.ipv6
 import dns.rdata
 import dns.tokenizer
 
+@dns.immutable.immutable
 class APLItem:
 
     """An APL list item."""
@@ -32,10 +34,17 @@ class APLItem:
     __slots__ = ['family', 'negation', 'address', 'prefix']
 
     def __init__(self, family, negation, address, prefix):
-        self.family = family
-        self.negation = negation
-        self.address = address
-        self.prefix = prefix
+        self.family = dns.rdata.Rdata._as_uint16(family)
+        self.negation = dns.rdata.Rdata._as_bool(negation)
+        if self.family == 1:
+            self.address = dns.rdata.Rdata._as_ipv4_address(address)
+            self.prefix = dns.rdata.Rdata._as_int(prefix, 0, 32)
+        elif self.family == 2:
+            self.address = dns.rdata.Rdata._as_ipv6_address(address)
+            self.prefix = dns.rdata.Rdata._as_int(prefix, 0, 128)
+        else:
+            self.address = dns.rdata.Rdata._as_bytes(address)
+            self.prefix = dns.rdata.Rdata._as_uint8(prefix)
 
     def __str__(self):
         if self.negation:
@@ -68,6 +77,7 @@ class APLItem:
         file.write(address)
 
 
+@dns.immutable.immutable
 class APL(dns.rdata.Rdata):
 
     """APL record."""
@@ -78,7 +88,10 @@ class APL(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, items):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'items', dns.rdata._constify(items))
+        for item in items:
+            if not isinstance(item, APLItem):
+                raise ValueError('item not an APLItem')
+        self.items = tuple(items)
 
     def to_text(self, origin=None, relativize=True, **kw):
         return ' '.join(map(str, self.items))
@@ -87,11 +100,8 @@ class APL(dns.rdata.Rdata):
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         items = []
-        while True:
-            token = tok.get().unescape()
-            if token.is_eol_or_eof():
-                break
-            item = token.value
+        for token in tok.get_remaining():
+            item = token.unescape().value
             if item[0] == '!':
                 negation = True
                 item = item[1:]
@@ -127,11 +137,9 @@ class APL(dns.rdata.Rdata):
             if header[0] == 1:
                 if l < 4:
                     address += b'\x00' * (4 - l)
-                address = dns.ipv4.inet_ntoa(address)
             elif header[0] == 2:
                 if l < 16:
                     address += b'\x00' * (16 - l)
-                address = dns.ipv6.inet_ntoa(address)
             else:
                 #
                 # This isn't really right according to the RFC, but it
diff --git a/dns/rdtypes/IN/DHCID.py b/dns/rdtypes/IN/DHCID.py
index 6f66eb8..a918598 100644
--- a/dns/rdtypes/IN/DHCID.py
+++ b/dns/rdtypes/IN/DHCID.py
@@ -18,8 +18,10 @@
 import base64
 
 import dns.exception
+import dns.immutable
 
 
+@dns.immutable.immutable
 class DHCID(dns.rdata.Rdata):
 
     """DHCID record"""
@@ -30,10 +32,10 @@ class DHCID(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, data):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'data', data)
+        self.data = self._as_bytes(data)
 
     def to_text(self, origin=None, relativize=True, **kw):
-        return dns.rdata._base64ify(self.data)
+        return dns.rdata._base64ify(self.data, **kw)
 
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
diff --git a/dns/rdtypes/IN/HTTPS.py b/dns/rdtypes/IN/HTTPS.py
new file mode 100644
index 0000000..6a67e8e
--- /dev/null
+++ b/dns/rdtypes/IN/HTTPS.py
@@ -0,0 +1,8 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import dns.rdtypes.svcbbase
+import dns.immutable
+
+@dns.immutable.immutable
+class HTTPS(dns.rdtypes.svcbbase.SVCBBase):
+    """HTTPS record"""
diff --git a/dns/rdtypes/IN/IPSECKEY.py b/dns/rdtypes/IN/IPSECKEY.py
index 182ad2c..d1d3943 100644
--- a/dns/rdtypes/IN/IPSECKEY.py
+++ b/dns/rdtypes/IN/IPSECKEY.py
@@ -19,12 +19,14 @@ import struct
 import base64
 
 import dns.exception
+import dns.immutable
 import dns.rdtypes.util
 
 
 class Gateway(dns.rdtypes.util.Gateway):
     name = 'IPSECKEY gateway'
 
+@dns.immutable.immutable
 class IPSECKEY(dns.rdata.Rdata):
 
     """IPSECKEY record"""
@@ -36,19 +38,19 @@ class IPSECKEY(dns.rdata.Rdata):
     def __init__(self, rdclass, rdtype, precedence, gateway_type, algorithm,
                  gateway, key):
         super().__init__(rdclass, rdtype)
-        Gateway(gateway_type, gateway).check()
-        object.__setattr__(self, 'precedence', precedence)
-        object.__setattr__(self, 'gateway_type', gateway_type)
-        object.__setattr__(self, 'algorithm', algorithm)
-        object.__setattr__(self, 'gateway', gateway)
-        object.__setattr__(self, 'key', key)
+        gateway = Gateway(gateway_type, gateway)
+        self.precedence = self._as_uint8(precedence)
+        self.gateway_type = gateway.type
+        self.algorithm = self._as_uint8(algorithm)
+        self.gateway = gateway.gateway
+        self.key = self._as_bytes(key)
 
     def to_text(self, origin=None, relativize=True, **kw):
         gateway = Gateway(self.gateway_type, self.gateway).to_text(origin,
                                                                    relativize)
         return '%d %d %d %s %s' % (self.precedence, self.gateway_type,
                                    self.algorithm, gateway,
-                                   dns.rdata._base64ify(self.key))
+                                   dns.rdata._base64ify(self.key, **kw))
 
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
@@ -56,12 +58,12 @@ class IPSECKEY(dns.rdata.Rdata):
         precedence = tok.get_uint8()
         gateway_type = tok.get_uint8()
         algorithm = tok.get_uint8()
-        gateway = Gateway(gateway_type).from_text(tok, origin, relativize,
-                                                  relativize_to)
+        gateway = Gateway.from_text(gateway_type, tok, origin, relativize,
+                                    relativize_to)
         b64 = tok.concatenate_remaining_identifiers().encode()
         key = base64.b64decode(b64)
         return cls(rdclass, rdtype, precedence, gateway_type, algorithm,
-                   gateway, key)
+                   gateway.gateway, key)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
         header = struct.pack("!BBB", self.precedence, self.gateway_type,
@@ -75,7 +77,7 @@ class IPSECKEY(dns.rdata.Rdata):
     def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
         header = parser.get_struct('!BBB')
         gateway_type = header[1]
-        gateway = Gateway(gateway_type).from_wire_parser(parser, origin)
+        gateway = Gateway.from_wire_parser(gateway_type, parser, origin)
         key = parser.get_remaining()
         return cls(rdclass, rdtype, header[0], gateway_type, header[2],
-                   gateway, key)
+                   gateway.gateway, key)
diff --git a/dns/rdtypes/IN/KX.py b/dns/rdtypes/IN/KX.py
index ebf8fd7..c27e921 100644
--- a/dns/rdtypes/IN/KX.py
+++ b/dns/rdtypes/IN/KX.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.mxbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class KX(dns.rdtypes.mxbase.UncompressedDowncasingMX):
 
     """KX record"""
diff --git a/dns/rdtypes/IN/NAPTR.py b/dns/rdtypes/IN/NAPTR.py
index 48d4356..b107974 100644
--- a/dns/rdtypes/IN/NAPTR.py
+++ b/dns/rdtypes/IN/NAPTR.py
@@ -18,8 +18,10 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.name
 import dns.rdata
+import dns.rdtypes.util
 
 
 def _write_string(file, s):
@@ -29,12 +31,7 @@ def _write_string(file, s):
     file.write(s)
 
 
-def _sanitize(value):
-    if isinstance(value, str):
-        return value.encode()
-    return value
-
-
+@dns.immutable.immutable
 class NAPTR(dns.rdata.Rdata):
 
     """NAPTR record"""
@@ -47,12 +44,12 @@ class NAPTR(dns.rdata.Rdata):
     def __init__(self, rdclass, rdtype, order, preference, flags, service,
                  regexp, replacement):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'flags', _sanitize(flags))
-        object.__setattr__(self, 'service', _sanitize(service))
-        object.__setattr__(self, 'regexp', _sanitize(regexp))
-        object.__setattr__(self, 'order', order)
-        object.__setattr__(self, 'preference', preference)
-        object.__setattr__(self, 'replacement', replacement)
+        self.flags = self._as_bytes(flags, True, 255)
+        self.service = self._as_bytes(service, True, 255)
+        self.regexp = self._as_bytes(regexp, True, 255)
+        self.order = self._as_uint16(order)
+        self.preference = self._as_uint16(preference)
+        self.replacement = self._as_name(replacement)
 
     def to_text(self, origin=None, relativize=True, **kw):
         replacement = self.replacement.choose_relativity(origin, relativize)
@@ -72,7 +69,6 @@ class NAPTR(dns.rdata.Rdata):
         service = tok.get_string()
         regexp = tok.get_string()
         replacement = tok.get_name(origin, relativize, relativize_to)
-        tok.get_eol()
         return cls(rdclass, rdtype, order, preference, flags, service,
                    regexp, replacement)
 
@@ -88,9 +84,16 @@ class NAPTR(dns.rdata.Rdata):
     def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
         (order, preference) = parser.get_struct('!HH')
         strings = []
-        for i in range(3):
+        for _ in range(3):
             s = parser.get_counted_bytes()
             strings.append(s)
         replacement = parser.get_name(origin)
         return cls(rdclass, rdtype, order, preference, strings[0], strings[1],
                    strings[2], replacement)
+
+    def _processing_priority(self):
+        return (self.order, self.preference)
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.priority_processing_order(iterable)
diff --git a/dns/rdtypes/IN/NSAP.py b/dns/rdtypes/IN/NSAP.py
index 227465f..23ae9b1 100644
--- a/dns/rdtypes/IN/NSAP.py
+++ b/dns/rdtypes/IN/NSAP.py
@@ -18,10 +18,12 @@
 import binascii
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.tokenizer
 
 
+@dns.immutable.immutable
 class NSAP(dns.rdata.Rdata):
 
     """NSAP record."""
@@ -32,7 +34,7 @@ class NSAP(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, address):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'address', address)
+        self.address = self._as_bytes(address)
 
     def to_text(self, origin=None, relativize=True, **kw):
         return "0x%s" % binascii.hexlify(self.address).decode()
@@ -41,7 +43,6 @@ class NSAP(dns.rdata.Rdata):
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         address = tok.get_string()
-        tok.get_eol()
         if address[0:2] != '0x':
             raise dns.exception.SyntaxError('string does not start with 0x')
         address = address[2:].replace('.', '')
diff --git a/dns/rdtypes/IN/NSAP_PTR.py b/dns/rdtypes/IN/NSAP_PTR.py
index a5b66c8..57dadd4 100644
--- a/dns/rdtypes/IN/NSAP_PTR.py
+++ b/dns/rdtypes/IN/NSAP_PTR.py
@@ -16,8 +16,10 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.rdtypes.nsbase
+import dns.immutable
 
 
+@dns.immutable.immutable
 class NSAP_PTR(dns.rdtypes.nsbase.UncompressedNS):
 
     """NSAP-PTR record"""
diff --git a/dns/rdtypes/IN/PX.py b/dns/rdtypes/IN/PX.py
index 946d79f..113d409 100644
--- a/dns/rdtypes/IN/PX.py
+++ b/dns/rdtypes/IN/PX.py
@@ -18,10 +18,13 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
+import dns.rdtypes.util
 import dns.name
 
 
+@dns.immutable.immutable
 class PX(dns.rdata.Rdata):
 
     """PX record."""
@@ -32,9 +35,9 @@ class PX(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, preference, map822, mapx400):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'preference', preference)
-        object.__setattr__(self, 'map822', map822)
-        object.__setattr__(self, 'mapx400', mapx400)
+        self.preference = self._as_uint16(preference)
+        self.map822 = self._as_name(map822)
+        self.mapx400 = self._as_name(mapx400)
 
     def to_text(self, origin=None, relativize=True, **kw):
         map822 = self.map822.choose_relativity(origin, relativize)
@@ -47,7 +50,6 @@ class PX(dns.rdata.Rdata):
         preference = tok.get_uint16()
         map822 = tok.get_name(origin, relativize, relativize_to)
         mapx400 = tok.get_name(origin, relativize, relativize_to)
-        tok.get_eol()
         return cls(rdclass, rdtype, preference, map822, mapx400)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
@@ -62,3 +64,10 @@ class PX(dns.rdata.Rdata):
         map822 = parser.get_name(origin)
         mapx400 = parser.get_name(origin)
         return cls(rdclass, rdtype, preference, map822, mapx400)
+
+    def _processing_priority(self):
+        return self.preference
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.priority_processing_order(iterable)
diff --git a/dns/rdtypes/IN/SRV.py b/dns/rdtypes/IN/SRV.py
index 485153f..5b5ff42 100644
--- a/dns/rdtypes/IN/SRV.py
+++ b/dns/rdtypes/IN/SRV.py
@@ -18,10 +18,13 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
+import dns.rdtypes.util
 import dns.name
 
 
+@dns.immutable.immutable
 class SRV(dns.rdata.Rdata):
 
     """SRV record"""
@@ -32,10 +35,10 @@ class SRV(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, priority, weight, port, target):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'priority', priority)
-        object.__setattr__(self, 'weight', weight)
-        object.__setattr__(self, 'port', port)
-        object.__setattr__(self, 'target', target)
+        self.priority = self._as_uint16(priority)
+        self.weight = self._as_uint16(weight)
+        self.port = self._as_uint16(port)
+        self.target = self._as_name(target)
 
     def to_text(self, origin=None, relativize=True, **kw):
         target = self.target.choose_relativity(origin, relativize)
@@ -49,7 +52,6 @@ class SRV(dns.rdata.Rdata):
         weight = tok.get_uint16()
         port = tok.get_uint16()
         target = tok.get_name(origin, relativize, relativize_to)
-        tok.get_eol()
         return cls(rdclass, rdtype, priority, weight, port, target)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
@@ -62,3 +64,13 @@ class SRV(dns.rdata.Rdata):
         (priority, weight, port) = parser.get_struct('!HHH')
         target = parser.get_name(origin)
         return cls(rdclass, rdtype, priority, weight, port, target)
+
+    def _processing_priority(self):
+        return self.priority
+
+    def _processing_weight(self):
+        return self.weight
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.weighted_processing_order(iterable)
diff --git a/dns/rdtypes/IN/SVCB.py b/dns/rdtypes/IN/SVCB.py
new file mode 100644
index 0000000..14838e1
--- /dev/null
+++ b/dns/rdtypes/IN/SVCB.py
@@ -0,0 +1,8 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import dns.rdtypes.svcbbase
+import dns.immutable
+
+@dns.immutable.immutable
+class SVCB(dns.rdtypes.svcbbase.SVCBBase):
+    """SVCB record"""
diff --git a/dns/rdtypes/IN/WKS.py b/dns/rdtypes/IN/WKS.py
index d66d858..0d36281 100644
--- a/dns/rdtypes/IN/WKS.py
+++ b/dns/rdtypes/IN/WKS.py
@@ -19,12 +19,14 @@ import socket
 import struct
 
 import dns.ipv4
+import dns.immutable
 import dns.rdata
 
 _proto_tcp = socket.getprotobyname('tcp')
 _proto_udp = socket.getprotobyname('udp')
 
 
+@dns.immutable.immutable
 class WKS(dns.rdata.Rdata):
 
     """WKS record"""
@@ -35,9 +37,9 @@ class WKS(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, address, protocol, bitmap):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'address', address)
-        object.__setattr__(self, 'protocol', protocol)
-        object.__setattr__(self, 'bitmap', dns.rdata._constify(bitmap))
+        self.address = self._as_ipv4_address(address)
+        self.protocol = self._as_uint8(protocol)
+        self.bitmap = self._as_bytes(bitmap)
 
     def to_text(self, origin=None, relativize=True, **kw):
         bits = []
@@ -59,12 +61,10 @@ class WKS(dns.rdata.Rdata):
         else:
             protocol = socket.getprotobyname(protocol)
         bitmap = bytearray()
-        while 1:
-            token = tok.get().unescape()
-            if token.is_eol_or_eof():
-                break
-            if token.value.isdigit():
-                serv = int(token.value)
+        for token in tok.get_remaining():
+            value = token.unescape().value
+            if value.isdigit():
+                serv = int(value)
             else:
                 if protocol != _proto_udp and protocol != _proto_tcp:
                     raise NotImplementedError("protocol must be TCP or UDP")
@@ -72,11 +72,11 @@ class WKS(dns.rdata.Rdata):
                     protocol_text = "udp"
                 else:
                     protocol_text = "tcp"
-                serv = socket.getservbyname(token.value, protocol_text)
+                serv = socket.getservbyname(value, protocol_text)
             i = serv // 8
             l = len(bitmap)
             if l < i + 1:
-                for j in range(l, i + 1):
+                for _ in range(l, i + 1):
                     bitmap.append(0)
             bitmap[i] = bitmap[i] | (0x80 >> (serv % 8))
         bitmap = dns.rdata._truncate_bitmap(bitmap)
@@ -90,7 +90,7 @@ class WKS(dns.rdata.Rdata):
 
     @classmethod
     def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
-        address = dns.ipv4.inet_ntoa(parser.get_bytes(4))
+        address = parser.get_bytes(4)
         protocol = parser.get_uint8()
         bitmap = parser.get_remaining()
         return cls(rdclass, rdtype, address, protocol, bitmap)
diff --git a/dns/rdtypes/IN/__init__.py b/dns/rdtypes/IN/__init__.py
index d7e69c9..d51b99e 100644
--- a/dns/rdtypes/IN/__init__.py
+++ b/dns/rdtypes/IN/__init__.py
@@ -22,6 +22,7 @@ __all__ = [
     'AAAA',
     'APL',
     'DHCID',
+    'HTTPS',
     'IPSECKEY',
     'KX',
     'NAPTR',
@@ -29,5 +30,6 @@ __all__ = [
     'NSAP_PTR',
     'PX',
     'SRV',
+    'SVCB',
     'WKS',
 ]
diff --git a/dns/rdtypes/__init__.py b/dns/rdtypes/__init__.py
index ccc848c..c3af264 100644
--- a/dns/rdtypes/__init__.py
+++ b/dns/rdtypes/__init__.py
@@ -21,8 +21,13 @@ __all__ = [
     'ANY',
     'IN',
     'CH',
+    'dnskeybase',
+    'dsbase',
     'euibase',
     'mxbase',
     'nsbase',
+    'svcbbase',
+    'tlsabase',
+    'txtbase',
     'util'
 ]
diff --git a/dns/rdtypes/dnskeybase.py b/dns/rdtypes/dnskeybase.py
index 0243d6f..788bb2b 100644
--- a/dns/rdtypes/dnskeybase.py
+++ b/dns/rdtypes/dnskeybase.py
@@ -20,6 +20,7 @@ import enum
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.dnssec
 import dns.rdata
 
@@ -31,9 +32,8 @@ class Flag(enum.IntFlag):
     REVOKE = 0x0080
     ZONE = 0x0100
 
-globals().update(Flag.__members__)
-
 
+@dns.immutable.immutable
 class DNSKEYBase(dns.rdata.Rdata):
 
     """Base class for rdata that is like a DNSKEY record"""
@@ -42,21 +42,21 @@ class DNSKEYBase(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, flags, protocol, algorithm, key):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'flags', flags)
-        object.__setattr__(self, 'protocol', protocol)
-        object.__setattr__(self, 'algorithm', algorithm)
-        object.__setattr__(self, 'key', key)
+        self.flags = self._as_uint16(flags)
+        self.protocol = self._as_uint8(protocol)
+        self.algorithm = dns.dnssec.Algorithm.make(algorithm)
+        self.key = self._as_bytes(key)
 
     def to_text(self, origin=None, relativize=True, **kw):
         return '%d %d %d %s' % (self.flags, self.protocol, self.algorithm,
-                                dns.rdata._base64ify(self.key))
+                                dns.rdata._base64ify(self.key, **kw))
 
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         flags = tok.get_uint16()
         protocol = tok.get_uint8()
-        algorithm = dns.dnssec.algorithm_from_text(tok.get_string())
+        algorithm = tok.get_string()
         b64 = tok.concatenate_remaining_identifiers().encode()
         key = base64.b64decode(b64)
         return cls(rdclass, rdtype, flags, protocol, algorithm, key)
@@ -72,3 +72,11 @@ class DNSKEYBase(dns.rdata.Rdata):
         key = parser.get_remaining()
         return cls(rdclass, rdtype, header[0], header[1], header[2],
                    key)
+
+### BEGIN generated Flag constants
+
+SEP = Flag.SEP
+REVOKE = Flag.REVOKE
+ZONE = Flag.ZONE
+
+### END generated Flag constants
diff --git a/dns/rdtypes/dsbase.py b/dns/rdtypes/dsbase.py
index d7850be..d125db2 100644
--- a/dns/rdtypes/dsbase.py
+++ b/dns/rdtypes/dsbase.py
@@ -19,10 +19,21 @@ import struct
 import binascii
 
 import dns.dnssec
+import dns.immutable
 import dns.rdata
 import dns.rdatatype
 
 
+# Digest types registry: https://www.iana.org/assignments/ds-rr-types/ds-rr-types.xhtml
+_digest_length_by_type = {
+    1: 20,  # SHA-1, RFC 3658 Sec. 2.4
+    2: 32,  # SHA-256, RFC 4509 Sec. 2.2
+    3: 32,  # GOST R 34.11-94, RFC 5933 Sec. 4 in conjunction with RFC 4490 Sec. 2.1
+    4: 48,  # SHA-384, RFC 6605 Sec. 2
+}
+
+
+@dns.immutable.immutable
 class DSBase(dns.rdata.Rdata):
 
     """Base class for rdata that is like a DS record"""
@@ -32,22 +43,34 @@ class DSBase(dns.rdata.Rdata):
     def __init__(self, rdclass, rdtype, key_tag, algorithm, digest_type,
                  digest):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'key_tag', key_tag)
-        object.__setattr__(self, 'algorithm', algorithm)
-        object.__setattr__(self, 'digest_type', digest_type)
-        object.__setattr__(self, 'digest', digest)
+        self.key_tag = self._as_uint16(key_tag)
+        self.algorithm = dns.dnssec.Algorithm.make(algorithm)
+        self.digest_type = self._as_uint8(digest_type)
+        self.digest = self._as_bytes(digest)
+
+        try:
+            if self.digest_type == 0:  # reserved, RFC 3658 Sec. 2.4
+                raise ValueError('digest type 0 is reserved')
+            expected_length = _digest_length_by_type[self.digest_type]
+        except KeyError:
+            raise ValueError('unknown digest type')
+        if len(self.digest) != expected_length:
+            raise ValueError('digest length inconsistent with digest type')
 
     def to_text(self, origin=None, relativize=True, **kw):
+        kw = kw.copy()
+        chunksize = kw.pop('chunksize', 128)
         return '%d %d %d %s' % (self.key_tag, self.algorithm,
                                 self.digest_type,
                                 dns.rdata._hexify(self.digest,
-                                                  chunksize=128))
+                                                  chunksize=chunksize,
+                                                  **kw))
 
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         key_tag = tok.get_uint16()
-        algorithm = dns.dnssec.algorithm_from_text(tok.get_string())
+        algorithm = tok.get_string()
         digest_type = tok.get_uint8()
         digest = tok.concatenate_remaining_identifiers().encode()
         digest = binascii.unhexlify(digest)
diff --git a/dns/rdtypes/euibase.py b/dns/rdtypes/euibase.py
index c1677a8..60ab56d 100644
--- a/dns/rdtypes/euibase.py
+++ b/dns/rdtypes/euibase.py
@@ -17,8 +17,10 @@
 import binascii
 
 import dns.rdata
+import dns.immutable
 
 
+@dns.immutable.immutable
 class EUIBase(dns.rdata.Rdata):
 
     """EUIxx record"""
@@ -32,19 +34,18 @@ class EUIBase(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, eui):
         super().__init__(rdclass, rdtype)
-        if len(eui) != self.byte_len:
+        self.eui = self._as_bytes(eui)
+        if len(self.eui) != self.byte_len:
             raise dns.exception.FormError('EUI%s rdata has to have %s bytes'
                                           % (self.byte_len * 8, self.byte_len))
-        object.__setattr__(self, 'eui', eui)
 
     def to_text(self, origin=None, relativize=True, **kw):
-        return dns.rdata._hexify(self.eui, chunksize=2).replace(' ', '-')
+        return dns.rdata._hexify(self.eui, chunksize=2, **kw).replace(' ', '-')
 
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         text = tok.get_string()
-        tok.get_eol()
         if len(text) != cls.text_len:
             raise dns.exception.SyntaxError(
                 'Input text must have %s characters' % cls.text_len)
diff --git a/dns/rdtypes/mxbase.py b/dns/rdtypes/mxbase.py
index d6a6efe..5641823 100644
--- a/dns/rdtypes/mxbase.py
+++ b/dns/rdtypes/mxbase.py
@@ -20,10 +20,13 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.name
+import dns.rdtypes.util
 
 
+@dns.immutable.immutable
 class MXBase(dns.rdata.Rdata):
 
     """Base class for rdata that is like an MX record."""
@@ -32,8 +35,8 @@ class MXBase(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, preference, exchange):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'preference', preference)
-        object.__setattr__(self, 'exchange', exchange)
+        self.preference = self._as_uint16(preference)
+        self.exchange = self._as_name(exchange)
 
     def to_text(self, origin=None, relativize=True, **kw):
         exchange = self.exchange.choose_relativity(origin, relativize)
@@ -44,7 +47,6 @@ class MXBase(dns.rdata.Rdata):
                   relativize_to=None):
         preference = tok.get_uint16()
         exchange = tok.get_name(origin, relativize, relativize_to)
-        tok.get_eol()
         return cls(rdclass, rdtype, preference, exchange)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
@@ -58,7 +60,15 @@ class MXBase(dns.rdata.Rdata):
         exchange = parser.get_name(origin)
         return cls(rdclass, rdtype, preference, exchange)
 
+    def _processing_priority(self):
+        return self.preference
 
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.priority_processing_order(iterable)
+
+
+@dns.immutable.immutable
 class UncompressedMX(MXBase):
 
     """Base class for rdata that is like an MX record, but whose name
@@ -69,6 +79,7 @@ class UncompressedMX(MXBase):
         super()._to_wire(file, None, origin, False)
 
 
+@dns.immutable.immutable
 class UncompressedDowncasingMX(MXBase):
 
     """Base class for rdata that is like an MX record, but whose name
diff --git a/dns/rdtypes/nsbase.py b/dns/rdtypes/nsbase.py
index 93d3ee5..b3e2550 100644
--- a/dns/rdtypes/nsbase.py
+++ b/dns/rdtypes/nsbase.py
@@ -18,10 +18,12 @@
 """NS-like base classes."""
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.name
 
 
+@dns.immutable.immutable
 class NSBase(dns.rdata.Rdata):
 
     """Base class for rdata that is like an NS record."""
@@ -30,7 +32,7 @@ class NSBase(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, target):
         super().__init__(rdclass, rdtype)
-        object.__setattr__(self, 'target', target)
+        self.target = self._as_name(target)
 
     def to_text(self, origin=None, relativize=True, **kw):
         target = self.target.choose_relativity(origin, relativize)
@@ -40,7 +42,6 @@ class NSBase(dns.rdata.Rdata):
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         target = tok.get_name(origin, relativize, relativize_to)
-        tok.get_eol()
         return cls(rdclass, rdtype, target)
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
@@ -52,6 +53,7 @@ class NSBase(dns.rdata.Rdata):
         return cls(rdclass, rdtype, target)
 
 
+@dns.immutable.immutable
 class UncompressedNS(NSBase):
 
     """Base class for rdata that is like an NS record, but whose name
diff --git a/dns/rdtypes/svcbbase.py b/dns/rdtypes/svcbbase.py
new file mode 100644
index 0000000..80e67e0
--- /dev/null
+++ b/dns/rdtypes/svcbbase.py
@@ -0,0 +1,544 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import base64
+import enum
+import io
+import struct
+
+import dns.enum
+import dns.exception
+import dns.immutable
+import dns.ipv4
+import dns.ipv6
+import dns.name
+import dns.rdata
+import dns.rdtypes.util
+import dns.tokenizer
+import dns.wire
+
+# Until there is an RFC, this module is experimental and may be changed in
+# incompatible ways.
+
+
+class UnknownParamKey(dns.exception.DNSException):
+    """Unknown SVCB ParamKey"""
+
+
+class ParamKey(dns.enum.IntEnum):
+    """SVCB ParamKey"""
+
+    MANDATORY = 0
+    ALPN = 1
+    NO_DEFAULT_ALPN = 2
+    PORT = 3
+    IPV4HINT = 4
+    ECHCONFIG = 5
+    IPV6HINT = 6
+
+    @classmethod
+    def _maximum(cls):
+        return 65535
+
+    @classmethod
+    def _short_name(cls):
+        return "SVCBParamKey"
+
+    @classmethod
+    def _prefix(cls):
+        return "KEY"
+
+    @classmethod
+    def _unknown_exception_class(cls):
+        return UnknownParamKey
+
+
+class Emptiness(enum.IntEnum):
+    NEVER = 0
+    ALWAYS = 1
+    ALLOWED = 2
+
+
+def _validate_key(key):
+    force_generic = False
+    if isinstance(key, bytes):
+        # We decode to latin-1 so we get 0-255 as valid and do NOT interpret
+        # UTF-8 sequences
+        key = key.decode('latin-1')
+    if isinstance(key, str):
+        if key.lower().startswith('key'):
+            force_generic = True
+            if key[3:].startswith('0') and len(key) != 4:
+                # key has leading zeros
+                raise ValueError('leading zeros in key')
+        key = key.replace('-', '_')
+    return (ParamKey.make(key), force_generic)
+
+def key_to_text(key):
+    return ParamKey.to_text(key).replace('_', '-').lower()
+
+# Like rdata escapify, but escapes ',' too.
+
+_escaped = b'",\\'
+
+def _escapify(qstring):
+    text = ''
+    for c in qstring:
+        if c in _escaped:
+            text += '\\' + chr(c)
+        elif c >= 0x20 and c < 0x7F:
+            text += chr(c)
+        else:
+            text += '\\%03d' % c
+    return text
+
+def _unescape(value, list_mode=False):
+    if value == '':
+        return value
+    items = []
+    unescaped = b''
+    l = len(value)
+    i = 0
+    while i < l:
+        c = value[i]
+        i += 1
+        if c == ',' and list_mode:
+            if len(unescaped) == 0:
+                raise ValueError('list item cannot be empty')
+            items.append(unescaped)
+            unescaped = b''
+            continue
+        if c == '\\':
+            if i >= l:  # pragma: no cover   (can't happen via tokenizer get())
+                raise dns.exception.UnexpectedEnd
+            c = value[i]
+            i += 1
+            if c.isdigit():
+                if i >= l:
+                    raise dns.exception.UnexpectedEnd
+                c2 = value[i]
+                i += 1
+                if i >= l:
+                    raise dns.exception.UnexpectedEnd
+                c3 = value[i]
+                i += 1
+                if not (c2.isdigit() and c3.isdigit()):
+                    raise dns.exception.SyntaxError
+                codepoint = int(c) * 100 + int(c2) * 10 + int(c3)
+                if codepoint > 255:
+                    raise dns.exception.SyntaxError
+                c = chr(codepoint)
+        unescaped += c.encode()
+    if len(unescaped) > 0:
+        items.append(unescaped)
+    else:
+        # This can't happen outside of list_mode because that would
+        # require the value parameter to the function to be empty, but
+        # we special case that at the beginning.
+        assert list_mode
+        raise ValueError('trailing comma')
+    if list_mode:
+        return items
+    else:
+        return items[0]
+
+
+@dns.immutable.immutable
+class Param:
+    """Abstract base class for SVCB parameters"""
+
+    @classmethod
+    def emptiness(cls):
+        return Emptiness.NEVER
+
+
+@dns.immutable.immutable
+class GenericParam(Param):
+    """Generic SVCB parameter
+    """
+    def __init__(self, value):
+        self.value = dns.rdata.Rdata._as_bytes(value, True)
+
+    @classmethod
+    def emptiness(cls):
+        return Emptiness.ALLOWED
+
+    @classmethod
+    def from_value(cls, value):
+        if value is None or len(value) == 0:
+            return None
+        else:
+            return cls(_unescape(value))
+
+    def to_text(self):
+        return '"' + _escapify(self.value) + '"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        value = parser.get_bytes(parser.remaining())
+        if len(value) == 0:
+            return None
+        else:
+            return cls(value)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        file.write(self.value)
+
+
+@dns.immutable.immutable
+class MandatoryParam(Param):
+    def __init__(self, keys):
+        # check for duplicates
+        keys = sorted([_validate_key(key)[0] for key in keys])
+        prior_k = None
+        for k in keys:
+            if k == prior_k:
+                raise ValueError(f'duplicate key {k}')
+            prior_k = k
+            if k == ParamKey.MANDATORY:
+                raise ValueError('listed the mandatory key as mandatory')
+        self.keys = tuple(keys)
+
+    @classmethod
+    def from_value(cls, value):
+        keys = [k.encode() for k in value.split(',')]
+        return cls(keys)
+
+    def to_text(self):
+        return '"' + ','.join([key_to_text(key) for key in self.keys]) + '"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        keys = []
+        last_key = -1
+        while parser.remaining() > 0:
+            key = parser.get_uint16()
+            if key < last_key:
+                raise dns.exception.FormError('manadatory keys not ascending')
+            last_key = key
+            keys.append(key)
+        return cls(keys)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        for key in self.keys:
+            file.write(struct.pack('!H', key))
+
+
+@dns.immutable.immutable
+class ALPNParam(Param):
+    def __init__(self, ids):
+        self.ids = dns.rdata.Rdata._as_tuple(
+            ids, lambda x: dns.rdata.Rdata._as_bytes(x, True, 255, False))
+
+    @classmethod
+    def from_value(cls, value):
+        return cls(_unescape(value, True))
+
+    def to_text(self):
+        return '"' + ','.join([_escapify(id) for id in self.ids]) + '"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        ids = []
+        while parser.remaining() > 0:
+            id = parser.get_counted_bytes()
+            ids.append(id)
+        return cls(ids)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        for id in self.ids:
+            file.write(struct.pack('!B', len(id)))
+            file.write(id)
+
+
+@dns.immutable.immutable
+class NoDefaultALPNParam(Param):
+    # We don't ever expect to instantiate this class, but we need
+    # a from_value() and a from_wire_parser(), so we just return None
+    # from the class methods when things are OK.
+
+    @classmethod
+    def emptiness(cls):
+        return Emptiness.ALWAYS
+
+    @classmethod
+    def from_value(cls, value):
+        if value is None or value == '':
+            return None
+        else:
+            raise ValueError('no-default-alpn with non-empty value')
+
+    def to_text(self):
+        raise NotImplementedError  # pragma: no cover
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        if parser.remaining() != 0:
+            raise dns.exception.FormError
+        return None
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        raise NotImplementedError  # pragma: no cover
+
+
+@dns.immutable.immutable
+class PortParam(Param):
+    def __init__(self, port):
+        self.port = dns.rdata.Rdata._as_uint16(port)
+
+    @classmethod
+    def from_value(cls, value):
+        value = int(value)
+        return cls(value)
+
+    def to_text(self):
+        return f'"{self.port}"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        port = parser.get_uint16()
+        return cls(port)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        file.write(struct.pack('!H', self.port))
+
+
+@dns.immutable.immutable
+class IPv4HintParam(Param):
+    def __init__(self, addresses):
+        self.addresses = dns.rdata.Rdata._as_tuple(
+            addresses, dns.rdata.Rdata._as_ipv4_address)
+
+    @classmethod
+    def from_value(cls, value):
+        addresses = value.split(',')
+        return cls(addresses)
+
+    def to_text(self):
+        return '"' + ','.join(self.addresses) + '"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        addresses = []
+        while parser.remaining() > 0:
+            ip = parser.get_bytes(4)
+            addresses.append(dns.ipv4.inet_ntoa(ip))
+        return cls(addresses)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        for address in self.addresses:
+            file.write(dns.ipv4.inet_aton(address))
+
+
+@dns.immutable.immutable
+class IPv6HintParam(Param):
+    def __init__(self, addresses):
+        self.addresses = dns.rdata.Rdata._as_tuple(
+            addresses, dns.rdata.Rdata._as_ipv6_address)
+
+    @classmethod
+    def from_value(cls, value):
+        addresses = value.split(',')
+        return cls(addresses)
+
+    def to_text(self):
+        return '"' + ','.join(self.addresses) + '"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        addresses = []
+        while parser.remaining() > 0:
+            ip = parser.get_bytes(16)
+            addresses.append(dns.ipv6.inet_ntoa(ip))
+        return cls(addresses)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        for address in self.addresses:
+            file.write(dns.ipv6.inet_aton(address))
+
+
+@dns.immutable.immutable
+class ECHConfigParam(Param):
+    def __init__(self, echconfig):
+        self.echconfig = dns.rdata.Rdata._as_bytes(echconfig, True)
+
+    @classmethod
+    def from_value(cls, value):
+        if '\\' in value:
+            raise ValueError('escape in ECHConfig value')
+        value = base64.b64decode(value.encode())
+        return cls(value)
+
+    def to_text(self):
+        b64 = base64.b64encode(self.echconfig).decode('ascii')
+        return f'"{b64}"'
+
+    @classmethod
+    def from_wire_parser(cls, parser, origin=None):  # pylint: disable=W0613
+        value = parser.get_bytes(parser.remaining())
+        return cls(value)
+
+    def to_wire(self, file, origin=None):  # pylint: disable=W0613
+        file.write(self.echconfig)
+
+
+_class_for_key = {
+    ParamKey.MANDATORY: MandatoryParam,
+    ParamKey.ALPN: ALPNParam,
+    ParamKey.NO_DEFAULT_ALPN: NoDefaultALPNParam,
+    ParamKey.PORT: PortParam,
+    ParamKey.IPV4HINT: IPv4HintParam,
+    ParamKey.ECHCONFIG: ECHConfigParam,
+    ParamKey.IPV6HINT: IPv6HintParam,
+}
+
+
+def _validate_and_define(params, key, value):
+    (key, force_generic) = _validate_key(_unescape(key))
+    if key in params:
+        raise SyntaxError(f'duplicate key "{key}"')
+    cls = _class_for_key.get(key, GenericParam)
+    emptiness = cls.emptiness()
+    if value is None:
+        if emptiness == Emptiness.NEVER:
+            raise SyntaxError('value cannot be empty')
+        value = cls.from_value(value)
+    else:
+        if force_generic:
+            value = cls.from_wire_parser(dns.wire.Parser(_unescape(value)))
+        else:
+            value = cls.from_value(value)
+    params[key] = value
+
+
+@dns.immutable.immutable
+class SVCBBase(dns.rdata.Rdata):
+
+    """Base class for SVCB-like records"""
+
+    # see: draft-ietf-dnsop-svcb-https-01
+
+    __slots__ = ['priority', 'target', 'params']
+
+    def __init__(self, rdclass, rdtype, priority, target, params):
+        super().__init__(rdclass, rdtype)
+        self.priority = self._as_uint16(priority)
+        self.target = self._as_name(target)
+        for k, v in params.items():
+            k = ParamKey.make(k)
+            if not isinstance(v, Param) and v is not None:
+                raise ValueError("not a Param")
+        self.params = dns.immutable.Dict(params)
+        # Make sure any paramater listed as mandatory is present in the
+        # record.
+        mandatory = params.get(ParamKey.MANDATORY)
+        if mandatory:
+            for key in mandatory.keys:
+                # Note we have to say "not in" as we have None as a value
+                # so a get() and a not None test would be wrong.
+                if key not in params:
+                    raise ValueError(f'key {key} declared mandatory but not'
+                                     'present')
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        target = self.target.choose_relativity(origin, relativize)
+        params = []
+        for key in sorted(self.params.keys()):
+            value = self.params[key]
+            if value is None:
+                params.append(key_to_text(key))
+            else:
+                kv = key_to_text(key) + '=' + value.to_text()
+                params.append(kv)
+        if len(params) > 0:
+            space = ' '
+        else:
+            space = ''
+        return '%d %s%s%s' % (self.priority, target, space, ' '.join(params))
+
+    @classmethod
+    def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
+                  relativize_to=None):
+        priority = tok.get_uint16()
+        target = tok.get_name(origin, relativize, relativize_to)
+        if priority == 0:
+            token = tok.get()
+            if not token.is_eol_or_eof():
+                raise SyntaxError('parameters in AliasMode')
+            tok.unget(token)
+        params = {}
+        while True:
+            token = tok.get()
+            if token.is_eol_or_eof():
+                tok.unget(token)
+                break
+            if token.ttype != dns.tokenizer.IDENTIFIER:
+                raise SyntaxError('parameter is not an identifier')
+            equals = token.value.find('=')
+            if equals == len(token.value) - 1:
+                # 'key=', so next token should be a quoted string without
+                # any intervening whitespace.
+                key = token.value[:-1]
+                token = tok.get(want_leading=True)
+                if token.ttype != dns.tokenizer.QUOTED_STRING:
+                    raise SyntaxError('whitespace after =')
+                value = token.value
+            elif equals > 0:
+                # key=value
+                key = token.value[:equals]
+                value = token.value[equals + 1:]
+            elif equals == 0:
+                # =key
+                raise SyntaxError('parameter cannot start with "="')
+            else:
+                # key
+                key = token.value
+                value = None
+            _validate_and_define(params, key, value)
+        return cls(rdclass, rdtype, priority, target, params)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        file.write(struct.pack("!H", self.priority))
+        self.target.to_wire(file, None, origin, False)
+        for key in sorted(self.params):
+            file.write(struct.pack("!H", key))
+            value = self.params[key]
+            # placeholder for length (or actual length of empty values)
+            file.write(struct.pack("!H", 0))
+            if value is None:
+                continue
+            else:
+                start = file.tell()
+                value.to_wire(file, origin)
+                end = file.tell()
+                assert end - start < 65536
+                file.seek(start - 2)
+                stuff = struct.pack("!H", end - start)
+                file.write(stuff)
+                file.seek(0, io.SEEK_END)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        priority = parser.get_uint16()
+        target = parser.get_name(origin)
+        if priority == 0 and parser.remaining() != 0:
+            raise dns.exception.FormError('parameters in AliasMode')
+        params = {}
+        prior_key = -1
+        while parser.remaining() > 0:
+            key = parser.get_uint16()
+            if key < prior_key:
+                raise dns.exception.FormError('keys not in order')
+            prior_key = key
+            vlen = parser.get_uint16()
+            pcls = _class_for_key.get(key, GenericParam)
+            with parser.restrict_to(vlen):
+                value = pcls.from_wire_parser(parser, origin)
+            params[key] = value
+        return cls(rdclass, rdtype, priority, target, params)
+
+    def _processing_priority(self):
+        return self.priority
+
+    @classmethod
+    def _processing_order(cls, iterable):
+        return dns.rdtypes.util.priority_processing_order(iterable)
diff --git a/dns/rdtypes/tlsabase.py b/dns/rdtypes/tlsabase.py
new file mode 100644
index 0000000..786fca5
--- /dev/null
+++ b/dns/rdtypes/tlsabase.py
@@ -0,0 +1,72 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2005-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import struct
+import binascii
+
+import dns.rdata
+import dns.immutable
+import dns.rdatatype
+
+
+@dns.immutable.immutable
+class TLSABase(dns.rdata.Rdata):
+
+    """Base class for TLSA and SMIMEA records"""
+
+    # see: RFC 6698
+
+    __slots__ = ['usage', 'selector', 'mtype', 'cert']
+
+    def __init__(self, rdclass, rdtype, usage, selector,
+                 mtype, cert):
+        super().__init__(rdclass, rdtype)
+        self.usage = self._as_uint8(usage)
+        self.selector = self._as_uint8(selector)
+        self.mtype = self._as_uint8(mtype)
+        self.cert = self._as_bytes(cert)
+
+    def to_text(self, origin=None, relativize=True, **kw):
+        kw = kw.copy()
+        chunksize = kw.pop('chunksize', 128)
+        return '%d %d %d %s' % (self.usage,
+                                self.selector,
+                                self.mtype,
+                                dns.rdata._hexify(self.cert,
+                                                  chunksize=chunksize,
+                                                  **kw))
+
+    @classmethod
+    def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
+                  relativize_to=None):
+        usage = tok.get_uint8()
+        selector = tok.get_uint8()
+        mtype = tok.get_uint8()
+        cert = tok.concatenate_remaining_identifiers().encode()
+        cert = binascii.unhexlify(cert)
+        return cls(rdclass, rdtype, usage, selector, mtype, cert)
+
+    def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
+        header = struct.pack("!BBB", self.usage, self.selector, self.mtype)
+        file.write(header)
+        file.write(self.cert)
+
+    @classmethod
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+        header = parser.get_struct("BBB")
+        cert = parser.get_remaining()
+        return cls(rdclass, rdtype, header[0], header[1], header[2], cert)
diff --git a/dns/rdtypes/txtbase.py b/dns/rdtypes/txtbase.py
index ad0093d..68071ee 100644
--- a/dns/rdtypes/txtbase.py
+++ b/dns/rdtypes/txtbase.py
@@ -20,10 +20,12 @@
 import struct
 
 import dns.exception
+import dns.immutable
 import dns.rdata
 import dns.tokenizer
 
 
+@dns.immutable.immutable
 class TXTBase(dns.rdata.Rdata):
 
     """Base class for rdata that is like a TXT record (see RFC 1035)."""
@@ -40,16 +42,8 @@ class TXTBase(dns.rdata.Rdata):
         *strings*, a tuple of ``bytes``
         """
         super().__init__(rdclass, rdtype)
-        if isinstance(strings, (bytes, str)):
-            strings = (strings,)
-        encoded_strings = []
-        for string in strings:
-            if isinstance(string, str):
-                string = string.encode()
-            else:
-                string = dns.rdata._constify(string)
-            encoded_strings.append(string)
-        object.__setattr__(self, 'strings', tuple(encoded_strings))
+        self.strings = self._as_tuple(strings,
+                                      lambda x: self._as_bytes(x, True, 255))
 
     def to_text(self, origin=None, relativize=True, **kw):
         txt = ''
@@ -63,11 +57,12 @@ class TXTBase(dns.rdata.Rdata):
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True,
                   relativize_to=None):
         strings = []
-        while 1:
-            token = tok.get().unescape_to_bytes()
-            if token.is_eol_or_eof():
-                break
-            if not (token.is_quoted_string() or token.is_identifier()):
+        for token in tok.get_remaining():
+            token = token.unescape_to_bytes()
+            # The 'if' below is always true in the current code, but we
+            # are leaving this check in in case things change some day.
+            if not (token.is_quoted_string() or
+                    token.is_identifier()):  # pragma: no cover
                 raise dns.exception.SyntaxError("expected a string")
             if len(token.value) > 255:
                 raise dns.exception.SyntaxError("string too long")
diff --git a/dns/rdtypes/util.py b/dns/rdtypes/util.py
index a63d1a0..7fc08cd 100644
--- a/dns/rdtypes/util.py
+++ b/dns/rdtypes/util.py
@@ -15,25 +15,31 @@
 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
+import collections
+import random
 import struct
 
 import dns.exception
-import dns.name
 import dns.ipv4
 import dns.ipv6
+import dns.name
+import dns.rdata
+
 
 class Gateway:
     """A helper class for the IPSECKEY gateway and AMTRELAY relay fields"""
     name = ""
 
     def __init__(self, type, gateway=None):
-        self.type = type
+        self.type = dns.rdata.Rdata._as_uint8(type)
         self.gateway = gateway
+        self._check()
 
-    def _invalid_type(self):
-        return f"invalid {self.name} type: {self.type}"
+    @classmethod
+    def _invalid_type(cls, gateway_type):
+        return f"invalid {cls.name} type: {gateway_type}"
 
-    def check(self):
+    def _check(self):
         if self.type == 0:
             if self.gateway not in (".", None):
                 raise SyntaxError(f"invalid {self.name} for type 0")
@@ -48,7 +54,7 @@ class Gateway:
             if not isinstance(self.gateway, dns.name.Name):
                 raise SyntaxError(f"invalid {self.name}; not a name")
         else:
-            raise SyntaxError(self._invalid_type())
+            raise SyntaxError(self._invalid_type(self.type))
 
     def to_text(self, origin=None, relativize=True):
         if self.type == 0:
@@ -58,16 +64,21 @@ class Gateway:
         elif self.type == 3:
             return str(self.gateway.choose_relativity(origin, relativize))
         else:
-            raise ValueError(self._invalid_type())
-
-    def from_text(self, tok, origin=None, relativize=True, relativize_to=None):
-        if self.type in (0, 1, 2):
-            return tok.get_string()
-        elif self.type == 3:
-            return tok.get_name(origin, relativize, relativize_to)
+            raise ValueError(self._invalid_type(self.type))  # pragma: no cover
+
+    @classmethod
+    def from_text(cls, gateway_type, tok, origin=None, relativize=True,
+                  relativize_to=None):
+        if gateway_type in (0, 1, 2):
+            gateway = tok.get_string()
+        elif gateway_type == 3:
+            gateway = tok.get_name(origin, relativize, relativize_to)
         else:
-            raise dns.exception.SyntaxError(self._invalid_type())
+            raise dns.exception.SyntaxError(
+                cls._invalid_type(gateway_type))  # pragma: no cover
+        return cls(gateway_type, gateway)
 
+    # pylint: disable=unused-argument
     def to_wire(self, file, compress=None, origin=None, canonicalize=False):
         if self.type == 0:
             pass
@@ -78,26 +89,43 @@ class Gateway:
         elif self.type == 3:
             self.gateway.to_wire(file, None, origin, False)
         else:
-            raise ValueError(self._invalid_type())
-
-    def from_wire_parser(self, parser, origin=None):
-        if self.type == 0:
-            return None
-        elif self.type == 1:
-            return dns.ipv4.inet_ntoa(parser.get_bytes(4))
-        elif self.type == 2:
-            return dns.ipv6.inet_ntoa(parser.get_bytes(16))
-        elif self.type == 3:
-            return parser.get_name(origin)
+            raise ValueError(self._invalid_type(self.type))  # pragma: no cover
+    # pylint: enable=unused-argument
+
+    @classmethod
+    def from_wire_parser(cls, gateway_type, parser, origin=None):
+        if gateway_type == 0:
+            gateway = None
+        elif gateway_type == 1:
+            gateway = dns.ipv4.inet_ntoa(parser.get_bytes(4))
+        elif gateway_type == 2:
+            gateway = dns.ipv6.inet_ntoa(parser.get_bytes(16))
+        elif gateway_type == 3:
+            gateway = parser.get_name(origin)
         else:
-            raise dns.exception.FormError(self._invalid_type())
+            raise dns.exception.FormError(cls._invalid_type(gateway_type))
+        return cls(gateway_type, gateway)
+
 
 class Bitmap:
     """A helper class for the NSEC/NSEC3/CSYNC type bitmaps"""
     type_name = ""
 
     def __init__(self, windows=None):
+        last_window = -1
         self.windows = windows
+        for (window, bitmap) in self.windows:
+            if not isinstance(window, int):
+                raise ValueError(f"bad {self.type_name} window type")
+            if window <= last_window:
+                raise ValueError(f"bad {self.type_name} window order")
+            if window > 256:
+                raise ValueError(f"bad {self.type_name} window number")
+            last_window = window
+            if not isinstance(bitmap, bytes):
+                raise ValueError(f"bad {self.type_name} octets type")
+            if len(bitmap) == 0 or len(bitmap) > 32:
+                raise ValueError(f"bad {self.type_name} octets")
 
     def to_text(self):
         text = ""
@@ -111,15 +139,13 @@ class Bitmap:
             text += (' ' + ' '.join(bits))
         return text
 
-    def from_text(self, tok):
+    @classmethod
+    def from_text(cls, tok):
         rdtypes = []
-        while True:
-            token = tok.get().unescape()
-            if token.is_eol_or_eof():
-                break
-            rdtype = dns.rdatatype.from_text(token.value)
+        for token in tok.get_remaining():
+            rdtype = dns.rdatatype.from_text(token.unescape().value)
             if rdtype == 0:
-                raise dns.exception.SyntaxError(f"{self.type_name} with bit 0")
+                raise dns.exception.SyntaxError(f"{cls.type_name} with bit 0")
             rdtypes.append(rdtype)
         rdtypes.sort()
         window = 0
@@ -134,7 +160,7 @@ class Bitmap:
             new_window = rdtype // 256
             if new_window != window:
                 if octets != 0:
-                    windows.append((window, bitmap[0:octets]))
+                    windows.append((window, bytes(bitmap[0:octets])))
                 bitmap = bytearray(b'\0' * 32)
                 window = new_window
             offset = rdtype % 256
@@ -143,24 +169,63 @@ class Bitmap:
             octets = byte + 1
             bitmap[byte] = bitmap[byte] | (0x80 >> bit)
         if octets != 0:
-            windows.append((window, bitmap[0:octets]))
-        return windows
+            windows.append((window, bytes(bitmap[0:octets])))
+        return cls(windows)
 
     def to_wire(self, file):
         for (window, bitmap) in self.windows:
             file.write(struct.pack('!BB', window, len(bitmap)))
             file.write(bitmap)
 
-    def from_wire_parser(self, parser):
+    @classmethod
+    def from_wire_parser(cls, parser):
         windows = []
-        last_window = -1
         while parser.remaining() > 0:
             window = parser.get_uint8()
-            if window <= last_window:
-                raise dns.exception.FormError(f"bad {self.type_name} bitmap")
             bitmap = parser.get_counted_bytes()
-            if len(bitmap) == 0 or len(bitmap) > 32:
-                raise dns.exception.FormError(f"bad {self.type_name} octets")
             windows.append((window, bitmap))
-            last_window = window
-        return windows
+        return cls(windows)
+
+
+def _priority_table(items):
+    by_priority = collections.defaultdict(list)
+    for rdata in items:
+        by_priority[rdata._processing_priority()].append(rdata)
+    return by_priority
+
+def priority_processing_order(iterable):
+    items = list(iterable)
+    if len(items) == 1:
+        return items
+    by_priority = _priority_table(items)
+    ordered = []
+    for k in sorted(by_priority.keys()):
+        rdatas = by_priority[k]
+        random.shuffle(rdatas)
+        ordered.extend(rdatas)
+    return ordered
+
+_no_weight = 0.1
+
+def weighted_processing_order(iterable):
+    items = list(iterable)
+    if len(items) == 1:
+        return items
+    by_priority = _priority_table(items)
+    ordered = []
+    for k in sorted(by_priority.keys()):
+        rdatas = by_priority[k]
+        total = sum(rdata._processing_weight() or _no_weight
+                    for rdata in rdatas)
+        while len(rdatas) > 1:
+            r = random.uniform(0, total)
+            for (n, rdata) in enumerate(rdatas):
+                weight = rdata._processing_weight() or _no_weight
+                if weight > r:
+                    break
+                r -= weight
+            total -= weight
+            ordered.append(rdata)
+            del rdatas[n]
+        ordered.append(rdatas[0])
+    return ordered
diff --git a/dns/resolver.py b/dns/resolver.py
index 4f630e4..7bdfd91 100644
--- a/dns/resolver.py
+++ b/dns/resolver.py
@@ -43,14 +43,18 @@ import dns.reversename
 import dns.tsig
 
 if sys.platform == 'win32':
-    import winreg  # pragma: no cover
+    # pylint: disable=import-error
+    import winreg
 
 class NXDOMAIN(dns.exception.DNSException):
     """The DNS query name does not exist."""
     supp_kwargs = {'qnames', 'responses'}
     fmt = None  # we have our own __str__ implementation
 
-    def _check_kwargs(self, qnames, responses=None):
+    # pylint: disable=arguments-differ
+
+    def _check_kwargs(self, qnames,
+                      responses=None):
         if not isinstance(qnames, (list, tuple, set)):
             raise AttributeError("qnames must be a list, tuple or set")
         if len(qnames) == 0:
@@ -78,17 +82,16 @@ class NXDOMAIN(dns.exception.DNSException):
         """Return the unresolved canonical name."""
         if 'qnames' not in self.kwargs:
             raise TypeError("parametrized exception required")
-        IN = dns.rdataclass.IN
-        CNAME = dns.rdatatype.CNAME
-        cname = None
         for qname in self.kwargs['qnames']:
             response = self.kwargs['responses'][qname]
-            for answer in response.answer:
-                if answer.rdtype != CNAME or answer.rdclass != IN:
-                    continue
-                cname = answer[0].target.to_text()
-            if cname is not None:
-                return dns.name.from_text(cname)
+            try:
+                cname = response.canonical_name()
+                if cname != qname:
+                    return cname
+            except Exception:
+                # We can just eat this exception as it means there was
+                # something wrong with the response.
+                pass
         return self.kwargs['qnames'][0]
 
     def __add__(self, e_nx):
@@ -162,6 +165,7 @@ class NoNameservers(dns.exception.DNSException):
     def _fmt_kwargs(self, **kwargs):
         srv_msgs = []
         for err in kwargs['errors']:
+            # pylint: disable=bad-continuation
             srv_msgs.append('Server {} {} port {} answered {}'.format(err[0],
                             'TCP' if err[1] else 'UDP', err[2], err[3]))
         return super()._fmt_kwargs(query=kwargs['request'].question,
@@ -206,51 +210,12 @@ class Answer:
         self.response = response
         self.nameserver = nameserver
         self.port = port
-        min_ttl = -1
-        rrset = None
-        for count in range(0, 15):
-            try:
-                rrset = response.find_rrset(response.answer, qname,
-                                            rdclass, rdtype)
-                if min_ttl == -1 or rrset.ttl < min_ttl:
-                    min_ttl = rrset.ttl
-                break
-            except KeyError:
-                if rdtype != dns.rdatatype.CNAME:
-                    try:
-                        crrset = response.find_rrset(response.answer,
-                                                     qname,
-                                                     rdclass,
-                                                     dns.rdatatype.CNAME)
-                        if min_ttl == -1 or crrset.ttl < min_ttl:
-                            min_ttl = crrset.ttl
-                        for rd in crrset:
-                            qname = rd.target
-                            break
-                        continue
-                    except KeyError:
-                        # Exit the chaining loop
-                        break
-        self.canonical_name = qname
-        self.rrset = rrset
-        if rrset is None:
-            while 1:
-                # Look for a SOA RR whose owner name is a superdomain
-                # of qname.
-                try:
-                    srrset = response.find_rrset(response.authority, qname,
-                                                 rdclass, dns.rdatatype.SOA)
-                    if min_ttl == -1 or srrset.ttl < min_ttl:
-                        min_ttl = srrset.ttl
-                    if srrset[0].minimum < min_ttl:
-                        min_ttl = srrset[0].minimum
-                    break
-                except KeyError:
-                    try:
-                        qname = qname.parent()
-                    except dns.name.NoParent:
-                        break
-        self.expiration = time.time() + min_ttl
+        self.chaining_result = response.resolve_chaining()
+        # Copy some attributes out of chaining_result for backwards
+        # compatibility and convenience.
+        self.canonical_name = self.chaining_result.canonical_name
+        self.rrset = self.chaining_result.answer
+        self.expiration = time.time() + self.chaining_result.minimum_ttl
 
     def __getattr__(self, attr):  # pragma: no cover
         if attr == 'name':
@@ -283,7 +248,54 @@ class Answer:
         del self.rrset[i]
 
 
-class Cache:
+class CacheStatistics:
+    """Cache Statistics
+    """
+
+    def __init__(self, hits=0, misses=0):
+        self.hits = hits
+        self.misses = misses
+
+    def reset(self):
+        self.hits = 0
+        self.misses = 0
+
+    def clone(self):
+        return CacheStatistics(self.hits, self.misses)
+
+
+class CacheBase:
+    def __init__(self):
+        self.lock = _threading.Lock()
+        self.statistics = CacheStatistics()
+
+    def reset_statistics(self):
+        """Reset all statistics to zero."""
+        with self.lock:
+            self.statistics.reset()
+
+    def hits(self):
+        """How many hits has the cache had?"""
+        with self.lock:
+            return self.statistics.hits
+
+    def misses(self):
+        """How many misses has the cache had?"""
+        with self.lock:
+            return self.statistics.misses
+
+    def get_statistics_snapshot(self):
+        """Return a consistent snapshot of all the statistics.
+
+        If running with multiple threads, it's better to take a
+        snapshot than to call statistics methods such as hits() and
+        misses() individually.
+        """
+        with self.lock:
+            return self.statistics.clone()
+
+
+class Cache(CacheBase):
     """Simple thread-safe DNS answer cache."""
 
     def __init__(self, cleaning_interval=300.0):
@@ -291,10 +303,10 @@ class Cache:
         periodic cleanings.
         """
 
+        super().__init__()
         self.data = {}
         self.cleaning_interval = cleaning_interval
         self.next_cleaning = time.time() + self.cleaning_interval
-        self.lock = _threading.Lock()
 
     def _maybe_clean(self):
         """Clean the cache if it's time to do so."""
@@ -325,7 +337,9 @@ class Cache:
             self._maybe_clean()
             v = self.data.get(key)
             if v is None or v.expiration <= time.time():
+                self.statistics.misses += 1
                 return None
+            self.statistics.hits += 1
             return v
 
     def put(self, key, value):
@@ -366,6 +380,7 @@ class LRUCacheNode:
     def __init__(self, key, value):
         self.key = key
         self.value = value
+        self.hits = 0
         self.prev = self
         self.next = self
 
@@ -380,7 +395,7 @@ class LRUCacheNode:
         self.prev.next = self.next
 
 
-class LRUCache:
+class LRUCache(CacheBase):
     """Thread-safe, bounded, least-recently-used DNS answer cache.
 
     This cache is better than the simple cache (above) if you're
@@ -395,12 +410,12 @@ class LRUCache:
         it must be greater than 0.
         """
 
+        super().__init__()
         self.data = {}
         self.set_max_size(max_size)
         self.sentinel = LRUCacheNode(None, None)
         self.sentinel.prev = self.sentinel
         self.sentinel.next = self.sentinel
-        self.lock = _threading.Lock()
 
     def set_max_size(self, max_size):
         if max_size < 1:
@@ -421,16 +436,29 @@ class LRUCache:
         with self.lock:
             node = self.data.get(key)
             if node is None:
+                self.statistics.misses += 1
                 return None
             # Unlink because we're either going to move the node to the front
             # of the LRU list or we're going to free it.
             node.unlink()
             if node.value.expiration <= time.time():
                 del self.data[node.key]
+                self.statistics.misses += 1
                 return None
             node.link_after(self.sentinel)
+            self.statistics.hits += 1
+            node.hits += 1
             return node.value
 
+    def get_hits_for_key(self, key):
+        """Return the number of cache hits associated with the specified key."""
+        with self.lock:
+            node = self.data.get(key)
+            if node is None or node.value.expiration <= time.time():
+                return 0
+            else:
+                return node.hits
+
     def put(self, key, value):
         """Associate key and value in the cache.
 
@@ -632,8 +660,13 @@ class _Resolution:
         assert response is not None
         rcode = response.rcode()
         if rcode == dns.rcode.NOERROR:
-            answer = Answer(self.qname, self.rdtype, self.rdclass, response,
-                            self.nameserver, self.port)
+            try:
+                answer = Answer(self.qname, self.rdtype, self.rdclass, response,
+                                self.nameserver, self.port)
+            except Exception:
+                # The nameserver is no good, take it out of the mix.
+                self.nameservers.remove(self.nameserver)
+                return (None, False)
             if self.resolver.cache:
                 self.resolver.cache.put((self.qname, self.rdtype,
                                          self.rdclass), answer)
@@ -641,16 +674,22 @@ class _Resolution:
                 raise NoAnswer(response=answer.response)
             return (answer, True)
         elif rcode == dns.rcode.NXDOMAIN:
-            self.nxdomain_responses[self.qname] = response
-            # Make next_nameserver() return None, so caller breaks its
-            # inner loop and calls next_request().
-            if self.resolver.cache:
+            # Further validate the response by making an Answer, even
+            # if we aren't going to cache it.
+            try:
                 answer = Answer(self.qname, dns.rdatatype.ANY,
                                 dns.rdataclass.IN, response)
+            except Exception:
+                # The nameserver is no good, take it out of the mix.
+                self.nameservers.remove(self.nameserver)
+                return (None, False)
+            self.nxdomain_responses[self.qname] = response
+            if self.resolver.cache:
                 self.resolver.cache.put((self.qname,
                                          dns.rdatatype.ANY,
                                          self.rdclass), answer)
-
+            # Make next_nameserver() return None, so caller breaks its
+            # inner loop and calls next_request().
             return (None, True)
         elif rcode == dns.rcode.YXDOMAIN:
             yex = YXDOMAIN()
@@ -668,7 +707,7 @@ class _Resolution:
                                 dns.rcode.to_text(rcode), response))
             return (None, False)
 
-class Resolver:
+class BaseResolver:
     """DNS stub resolver."""
 
     # We initialize in reset()
@@ -690,7 +729,7 @@ class Resolver:
         self.reset()
         if configure:
             if sys.platform == 'win32':
-                self.read_registry()  # pragma: no cover
+                self.read_registry()
             elif filename:
                 self.read_resolv_conf(filename)
 
@@ -758,15 +797,21 @@ class Resolver:
                     self.nameservers.append(tokens[1])
                 elif tokens[0] == 'domain':
                     self.domain = dns.name.from_text(tokens[1])
+                    # domain and search are exclusive
+                    self.search = []
                 elif tokens[0] == 'search':
+                    # the last search wins
+                    self.search = []
                     for suffix in tokens[1:]:
                         self.search.append(dns.name.from_text(suffix))
+                    # We don't set domain as it is not used if
+                    # len(self.search) > 0
                 elif tokens[0] == 'options':
                     for opt in tokens[1:]:
                         if opt == 'rotate':
                             self.rotate = True
                         elif opt == 'edns0':
-                            self.use_edns(0, 0, 0)
+                            self.use_edns()
                         elif 'timeout' in opt:
                             try:
                                 self.timeout = int(opt.split(':')[1])
@@ -818,35 +863,37 @@ class Resolver:
                 self.search.append(dns.name.from_text(s))
 
     def _config_win32_fromkey(self, key, always_try_domain):
+        # pylint: disable=undefined-variable
+        # (disabled for WindowsError)
         try:
-            servers, rtype = winreg.QueryValueEx(key, 'NameServer')
-        except WindowsError:  # pylint: disable=undefined-variable
+            servers, _ = winreg.QueryValueEx(key, 'NameServer')
+        except WindowsError:  # pragma: no cover
             servers = None
         if servers:
             self._config_win32_nameservers(servers)
         if servers or always_try_domain:
             try:
-                dom, rtype = winreg.QueryValueEx(key, 'Domain')
+                dom, _ = winreg.QueryValueEx(key, 'Domain')
                 if dom:
-                    self._config_win32_domain(dom)
+                    self._config_win32_domain(dom)  # pragma: no cover
             except WindowsError:  # pragma: no cover
                 pass
         else:
             try:
-                servers, rtype = winreg.QueryValueEx(key, 'DhcpNameServer')
+                servers, _ = winreg.QueryValueEx(key, 'DhcpNameServer')
             except WindowsError:  # pragma: no cover
                 servers = None
-            if servers:  # pragma: no cover
+            if servers:
                 self._config_win32_nameservers(servers)
                 try:
-                    dom, rtype = winreg.QueryValueEx(key, 'DhcpDomain')
-                    if dom:  # pragma: no cover
+                    dom, _ = winreg.QueryValueEx(key, 'DhcpDomain')
+                    if dom:
                         self._config_win32_domain(dom)
                 except WindowsError:  # pragma: no cover
                     pass
         try:
-            search, rtype = winreg.QueryValueEx(key, 'SearchList')
-        except WindowsError:  # pylint: disable=undefined-variable
+            search, _ = winreg.QueryValueEx(key, 'SearchList')
+        except WindowsError:  # pragma: no cover
             search = None
         if search:  # pragma: no cover
             self._config_win32_search(search)
@@ -873,6 +920,7 @@ class Resolver:
                     try:
                         guid = winreg.EnumKey(interfaces, i)
                         i += 1
+                        # XXXRTH why do we get this key and then not use it?
                         key = winreg.OpenKey(interfaces, guid)
                         if not self._win32_is_nic_enabled(lm, guid, key):
                             continue
@@ -887,8 +935,7 @@ class Resolver:
         finally:
             lm.Close()
 
-    def _win32_is_nic_enabled(self, lm, guid,
-                              interface_key):
+    def _win32_is_nic_enabled(self, lm, guid, _):
         # Look in the Windows Registry to determine whether the network
         # interface corresponding to the given guid is enabled.
         #
@@ -908,8 +955,8 @@ class Resolver:
                 (pnp_id, ttype) = winreg.QueryValueEx(
                     connection_key, 'PnpInstanceID')
 
-                if ttype != winreg.REG_SZ:  # pragma: no cover
-                    raise ValueError
+                if ttype != winreg.REG_SZ:
+                    raise ValueError  # pragma: no cover
 
                 device_key = winreg.OpenKey(
                     lm, r'SYSTEM\CurrentControlSet\Enum\%s' % pnp_id)
@@ -919,8 +966,8 @@ class Resolver:
                     (flags, ttype) = winreg.QueryValueEx(
                         device_key, 'ConfigFlags')
 
-                    if ttype != winreg.REG_DWORD:  # pragma: no cover
-                        raise ValueError
+                    if ttype != winreg.REG_DWORD:
+                        raise ValueError  # pragma: no cover
 
                     # Based on experimentation, bit 0x1 indicates that the
                     # device is disabled.
@@ -959,19 +1006,105 @@ class Resolver:
         if qname.is_absolute():
             qnames_to_try.append(qname)
         else:
-            if len(qname) > 1 or not search:
-                qnames_to_try.append(qname.concatenate(dns.name.root))
-            if search and self.search:
-                for suffix in self.search:
-                    if self.ndots is None or len(qname.labels) >= self.ndots:
-                        qnames_to_try.append(qname.concatenate(suffix))
-            elif search:
-                qnames_to_try.append(qname.concatenate(self.domain))
+            abs_qname = qname.concatenate(dns.name.root)
+            if search:
+                if len(self.search) > 0:
+                    # There is a search list, so use it exclusively
+                    search_list = self.search[:]
+                elif self.domain != dns.name.root and self.domain is not None:
+                    # We have some notion of a domain that isn't the root, so
+                    # use it as the search list.
+                    search_list = [self.domain]
+                else:
+                    search_list = []
+                # Figure out the effective ndots (default is 1)
+                if self.ndots is None:
+                    ndots = 1
+                else:
+                    ndots = self.ndots
+                for suffix in search_list:
+                    qnames_to_try.append(qname + suffix)
+                if len(qname) > ndots:
+                    # The name has at least ndots dots, so we should try an
+                    # absolute query first.
+                    qnames_to_try.insert(0, abs_qname)
+                else:
+                    # The name has less than ndots dots, so we should search
+                    # first, then try the absolute name.
+                    qnames_to_try.append(abs_qname)
+            else:
+                qnames_to_try.append(abs_qname)
         return qnames_to_try
 
+    def use_tsig(self, keyring, keyname=None,
+                 algorithm=dns.tsig.default_algorithm):
+        """Add a TSIG signature to each query.
+
+        The parameters are passed to ``dns.message.Message.use_tsig()``;
+        see its documentation for details.
+        """
+
+        self.keyring = keyring
+        self.keyname = keyname
+        self.keyalgorithm = algorithm
+
+    def use_edns(self, edns=0, ednsflags=0,
+                 payload=dns.message.DEFAULT_EDNS_PAYLOAD):
+        """Configure EDNS behavior.
+
+        *edns*, an ``int``, is the EDNS level to use.  Specifying
+        ``None``, ``False``, or ``-1`` means "do not use EDNS", and in this case
+        the other parameters are ignored.  Specifying ``True`` is
+        equivalent to specifying 0, i.e. "use EDNS0".
+
+        *ednsflags*, an ``int``, the EDNS flag values.
+
+        *payload*, an ``int``, is the EDNS sender's payload field, which is the
+        maximum size of UDP datagram the sender can handle.  I.e. how big
+        a response to this message can be.
+        """
+
+        if edns is None or edns is False:
+            edns = -1
+        elif edns is True:
+            edns = 0
+        self.edns = edns
+        self.ednsflags = ednsflags
+        self.payload = payload
+
+    def set_flags(self, flags):
+        """Overrides the default flags with your own.
+
+        *flags*, an ``int``, the message flags to use.
+        """
+
+        self.flags = flags
+
+    @property
+    def nameservers(self):
+        return self._nameservers
+
+    @nameservers.setter
+    def nameservers(self, nameservers):
+        """
+        *nameservers*, a ``list`` of nameservers.
+
+        Raises ``ValueError`` if *nameservers* is anything other than a
+        ``list``.
+        """
+        if isinstance(nameservers, list):
+            self._nameservers = nameservers
+        else:
+            raise ValueError('nameservers must be a list'
+                             ' (not a {})'.format(type(nameservers)))
+
+
+class Resolver(BaseResolver):
+    """DNS stub resolver."""
+
     def resolve(self, qname, rdtype=dns.rdatatype.A, rdclass=dns.rdataclass.IN,
                 tcp=False, source=None, raise_on_no_answer=True, source_port=0,
-                lifetime=None, search=None):
+                lifetime=None, search=None):  # pylint: disable=arguments-differ
         """Query nameservers to find the answer to the question.
 
         The *qname*, *rdtype*, and *rdclass* parameters may be objects
@@ -1109,64 +1242,31 @@ class Resolver:
                             rdclass=dns.rdataclass.IN,
                             *args, **kwargs)
 
-    def use_tsig(self, keyring, keyname=None,
-                 algorithm=dns.tsig.default_algorithm):
-        """Add a TSIG signature to each query.
+    # pylint: disable=redefined-outer-name
 
-        The parameters are passed to ``dns.message.Message.use_tsig()``;
-        see its documentation for details.
-        """
+    def canonical_name(self, name):
+        """Determine the canonical name of *name*.
 
-        self.keyring = keyring
-        self.keyname = keyname
-        self.keyalgorithm = algorithm
+        The canonical name is the name the resolver uses for queries
+        after all CNAME and DNAME renamings have been applied.
 
-    def use_edns(self, edns, ednsflags, payload):
-        """Configure EDNS behavior.
+        *name*, a ``dns.name.Name`` or ``str``, the query name.
 
-        *edns*, an ``int``, is the EDNS level to use.  Specifying
-        ``None``, ``False``, or ``-1`` means "do not use EDNS", and in this case
-        the other parameters are ignored.  Specifying ``True`` is
-        equivalent to specifying 0, i.e. "use EDNS0".
-
-        *ednsflags*, an ``int``, the EDNS flag values.
+        This method can raise any exception that ``resolve()`` can
+        raise, other than ``dns.resolver.NoAnswer`` and
+        ``dns.resolver.NXDOMAIN``.
 
-        *payload*, an ``int``, is the EDNS sender's payload field, which is the
-        maximum size of UDP datagram the sender can handle.  I.e. how big
-        a response to this message can be.
+        Returns a ``dns.name.Name``.
         """
+        try:
+            answer = self.resolve(name, raise_on_no_answer=False)
+            canonical_name = answer.canonical_name
+        except dns.resolver.NXDOMAIN as e:
+            canonical_name = e.canonical_name
+        return canonical_name
 
-        if edns is None:
-            edns = -1
-        self.edns = edns
-        self.ednsflags = ednsflags
-        self.payload = payload
-
-    def set_flags(self, flags):
-        """Overrides the default flags with your own.
-
-        *flags*, an ``int``, the message flags to use.
-        """
-
-        self.flags = flags
-
-    @property
-    def nameservers(self):
-        return self._nameservers
-
-    @nameservers.setter
-    def nameservers(self, nameservers):
-        """
-        *nameservers*, a ``list`` of nameservers.
+    # pylint: enable=redefined-outer-name
 
-        Raises ``ValueError`` if *nameservers* is anything other than a
-        ``list``.
-        """
-        if isinstance(nameservers, list):
-            self._nameservers = nameservers
-        else:
-            raise ValueError('nameservers must be a list'
-                             ' (not a {})'.format(type(nameservers)))
 
 #: The default resolver.
 default_resolver = None
@@ -1233,6 +1333,16 @@ def resolve_address(ipaddr, *args, **kwargs):
     return get_default_resolver().resolve_address(ipaddr, *args, **kwargs)
 
 
+def canonical_name(name):
+    """Determine the canonical name of *name*.
+
+    See ``dns.resolver.Resolver.canonical_name`` for more information on the
+    parameters and possible exceptions.
+    """
+
+    return get_default_resolver().canonical_name(name)
+
+
 def zone_for_name(name, rdclass=dns.rdataclass.IN, tcp=False, resolver=None):
     """Find the name of the zone which contains the specified name.
 
@@ -1315,7 +1425,7 @@ def _getaddrinfo(host=None, service=None, family=socket.AF_UNSPEC, socktype=0,
         raise socket.gaierror(socket.EAI_NONAME, 'Name or service not known')
     v6addrs = []
     v4addrs = []
-    canonical_name = None
+    canonical_name = None  # pylint: disable=redefined-outer-name
     # Is host None or an address literal?  If so, use the system's
     # getaddrinfo().
     if host is None:
@@ -1352,8 +1462,7 @@ def _getaddrinfo(host=None, service=None, family=socket.AF_UNSPEC, socktype=0,
                     v4addrs.append(rdata.address)
     except dns.resolver.NXDOMAIN:
         raise socket.gaierror(socket.EAI_NONAME, 'Name or service not known')
-    except Exception as e:
-        print(e)
+    except Exception:
         # We raise EAI_AGAIN here as the failure may be temporary
         # (e.g. a timeout) and EAI_SYSTEM isn't defined on Windows.
         # [Issue #416]
@@ -1482,7 +1591,7 @@ def _gethostbyaddr(ip):
                                   'Name or service not known')
         sockaddr = (ip, 80)
         family = socket.AF_INET
-    (name, port) = _getnameinfo(sockaddr, socket.NI_NAMEREQD)
+    (name, _) = _getnameinfo(sockaddr, socket.NI_NAMEREQD)
     aliases = []
     addresses = []
     tuples = _getaddrinfo(name, 0, family, socket.SOCK_STREAM, socket.SOL_TCP,
diff --git a/dns/rrset.py b/dns/rrset.py
index 68136f4..a71d457 100644
--- a/dns/rrset.py
+++ b/dns/rrset.py
@@ -69,25 +69,45 @@ class RRset(dns.rdataset.Rdataset):
         return self.to_text()
 
     def __eq__(self, other):
-        if not isinstance(other, RRset):
-            return False
-        if self.name != other.name:
+        if isinstance(other, RRset):
+            if self.name != other.name:
+                return False
+        elif not isinstance(other, dns.rdataset.Rdataset):
             return False
         return super().__eq__(other)
 
-    def match(self, name, rdclass, rdtype, covers, deleting=None):
-        """Returns ``True`` if this rrset matches the specified class, type,
-        covers, and deletion state.
+    def match(self, *args, **kwargs):
+        """Does this rrset match the specified attributes?
+
+        Behaves as :py:func:`full_match()` if the first argument is a
+        ``dns.name.Name``, and as :py:func:`dns.rdataset.Rdataset.match()`
+        otherwise.
+
+        (This behavior fixes a design mistake where the signature of this
+        method became incompatible with that of its superclass.  The fix
+        makes RRsets matchable as Rdatasets while preserving backwards
+        compatibility.)
         """
+        if isinstance(args[0], dns.name.Name):
+            return self.full_match(*args, **kwargs)
+        else:
+            return super().match(*args, **kwargs)
 
+    def full_match(self, name, rdclass, rdtype, covers,
+                    deleting=None):
+        """Returns ``True`` if this rrset matches the specified name, class,
+        type, covers, and deletion state.
+        """
         if not super().match(rdclass, rdtype, covers):
             return False
         if self.name != name or self.deleting != deleting:
             return False
         return True
 
+    # pylint: disable=arguments-differ
+
     def to_text(self, origin=None, relativize=True, **kw):
-        """Convert the RRset into DNS master file format.
+        """Convert the RRset into DNS zone file format.
 
         See ``dns.name.Name.choose_relativity`` for more information
         on how *origin* and *relativize* determine the way names
@@ -106,7 +126,8 @@ class RRset(dns.rdataset.Rdataset):
         return super().to_text(self.name, origin, relativize,
                                self.deleting, **kw)
 
-    def to_wire(self, file, compress=None, origin=None, **kw):
+    def to_wire(self, file, compress=None, origin=None,
+                **kw):
         """Convert the RRset to wire format.
 
         All keyword arguments are passed to ``dns.rdataset.to_wire()``; see
@@ -118,6 +139,8 @@ class RRset(dns.rdataset.Rdataset):
         return super().to_wire(self.name, file, compress, origin,
                                self.deleting, **kw)
 
+    # pylint: enable=arguments-differ
+
     def to_rdataset(self):
         """Convert an RRset into an Rdataset.
 
@@ -127,7 +150,8 @@ class RRset(dns.rdataset.Rdataset):
 
 
 def from_text_list(name, ttl, rdclass, rdtype, text_rdatas,
-                   idna_codec=None):
+                   idna_codec=None, origin=None, relativize=True,
+                   relativize_to=None):
     """Create an RRset with the specified name, TTL, class, and type, and with
     the specified list of rdatas in text format.
 
@@ -135,6 +159,14 @@ def from_text_list(name, ttl, rdclass, rdtype, text_rdatas,
     encoder/decoder to use; if ``None``, the default IDNA 2003
     encoder/decoder is used.
 
+    *origin*, a ``dns.name.Name`` (or ``None``), the
+    origin to use for relative names.
+
+    *relativize*, a ``bool``.  If true, name will be relativized.
+
+    *relativize_to*, a ``dns.name.Name`` (or ``None``), the origin to use
+    when relativizing names.  If not set, the *origin* value will be used.
+
     Returns a ``dns.rrset.RRset`` object.
     """
 
@@ -145,7 +177,8 @@ def from_text_list(name, ttl, rdclass, rdtype, text_rdatas,
     r = RRset(name, rdclass, rdtype)
     r.update_ttl(ttl)
     for t in text_rdatas:
-        rd = dns.rdata.from_text(r.rdclass, r.rdtype, t, idna_codec=idna_codec)
+        rd = dns.rdata.from_text(r.rdclass, r.rdtype, t, origin, relativize,
+                                 relativize_to, idna_codec)
         r.add(rd)
     return r
 
diff --git a/dns/set.py b/dns/set.py
index 0982d78..1fd4d0a 100644
--- a/dns/set.py
+++ b/dns/set.py
@@ -84,9 +84,13 @@ class Set:
         subclasses.
         """
 
-        cls = self.__class__
+        if hasattr(self, '_clone_class'):
+            cls = self._clone_class
+        else:
+            cls = self.__class__
         obj = cls.__new__(cls)
-        obj.items = self.items.copy()
+        obj.items = odict()
+        obj.items.update(self.items)
         return obj
 
     def __copy__(self):
diff --git a/dns/tokenizer.py b/dns/tokenizer.py
index 3e5d2ba..7ddc7a9 100644
--- a/dns/tokenizer.py
+++ b/dns/tokenizer.py
@@ -15,7 +15,7 @@
 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
-"""Tokenize DNS master file format"""
+"""Tokenize DNS zone file format"""
 
 import io
 import sys
@@ -41,19 +41,20 @@ class UngetBufferFull(dns.exception.DNSException):
 
 
 class Token:
-    """A DNS master file format token.
+    """A DNS zone file format token.
 
     ttype: The token type
     value: The token value
     has_escape: Does the token value contain escapes?
     """
 
-    def __init__(self, ttype, value='', has_escape=False):
+    def __init__(self, ttype, value='', has_escape=False, comment=None):
         """Initialize a token instance."""
 
         self.ttype = ttype
         self.value = value
         self.has_escape = has_escape
+        self.comment = comment
 
     def is_eof(self):
         return self.ttype == EOF
@@ -104,7 +105,7 @@ class Token:
             c = self.value[i]
             i += 1
             if c == '\\':
-                if i >= l:
+                if i >= l:  # pragma: no cover   (can't happen via get())
                     raise dns.exception.UnexpectedEnd
                 c = self.value[i]
                 i += 1
@@ -119,7 +120,10 @@ class Token:
                     i += 1
                     if not (c2.isdigit() and c3.isdigit()):
                         raise dns.exception.SyntaxError
-                    c = chr(int(c) * 100 + int(c2) * 10 + int(c3))
+                    codepoint = int(c) * 100 + int(c2) * 10 + int(c3)
+                    if codepoint > 255:
+                        raise dns.exception.SyntaxError
+                    c = chr(codepoint)
             unescaped += c
         return Token(self.ttype, unescaped)
 
@@ -155,7 +159,7 @@ class Token:
             c = self.value[i]
             i += 1
             if c == '\\':
-                if i >= l:
+                if i >= l:  # pragma: no cover   (can't happen via get())
                     raise dns.exception.UnexpectedEnd
                 c = self.value[i]
                 i += 1
@@ -170,7 +174,10 @@ class Token:
                     i += 1
                     if not (c2.isdigit() and c3.isdigit()):
                         raise dns.exception.SyntaxError
-                    unescaped += b'%c' % (int(c) * 100 + int(c2) * 10 + int(c3))
+                    codepoint = int(c) * 100 + int(c2) * 10 + int(c3)
+                    if codepoint > 255:
+                        raise dns.exception.SyntaxError
+                    unescaped += b'%c' % (codepoint)
                 else:
                     # Note that as mentioned above, if c is a Unicode
                     # code point outside of the ASCII range, then this
@@ -184,7 +191,7 @@ class Token:
 
 
 class Tokenizer:
-    """A DNS master file format tokenizer.
+    """A DNS zone file format tokenizer.
 
     A token object is basically a (type, value) tuple.  The valid
     types are EOF, EOL, WHITESPACE, IDENTIFIER, QUOTED_STRING,
@@ -396,13 +403,13 @@ class Tokenizer:
                             if self.multiline:
                                 raise dns.exception.SyntaxError(
                                     'unbalanced parentheses')
-                            return Token(EOF)
+                            return Token(EOF, comment=token)
                         elif self.multiline:
                             self.skip_whitespace()
                             token = ''
                             continue
                         else:
-                            return Token(EOL, '\n')
+                            return Token(EOL, '\n', comment=token)
                     else:
                         # This code exists in case we ever want a
                         # delimiter to be returned.  It never produces
@@ -422,7 +429,7 @@ class Tokenizer:
                 token += c
                 has_escape = True
                 c = self._get_char()
-                if c == '' or c == '\n':
+                if c == '' or (c == '\n' and not self.quoting):
                     raise dns.exception.UnexpectedEnd
             token += c
         if token == '' and ttype != QUOTED_STRING:
@@ -529,6 +536,21 @@ class Tokenizer:
                 '%d is not an unsigned 32-bit integer' % value)
         return value
 
+    def get_uint48(self, base=10):
+        """Read the next token and interpret it as a 48-bit unsigned
+        integer.
+
+        Raises dns.exception.SyntaxError if not a 48-bit unsigned integer.
+
+        Returns an int.
+        """
+
+        value = self.get_int(base=base)
+        if value < 0 or value > 281474976710655:
+            raise dns.exception.SyntaxError(
+                '%d is not an unsigned 48-bit integer' % value)
+        return value
+
     def get_string(self, max_length=None):
         """Read the next token and interpret it as a string.
 
@@ -559,6 +581,25 @@ class Tokenizer:
             raise dns.exception.SyntaxError('expecting an identifier')
         return token.value
 
+    def get_remaining(self, max_tokens=None):
+        """Return the remaining tokens on the line, until an EOL or EOF is seen.
+
+        max_tokens: If not None, stop after this number of tokens.
+
+        Returns a list of tokens.
+        """
+
+        tokens = []
+        while True:
+            token = self.get()
+            if token.is_eol_or_eof():
+                self.unget(token)
+                break
+            tokens.append(token)
+            if len(tokens) == max_tokens:
+                break
+        return tokens
+
     def concatenate_remaining_identifiers(self):
         """Read the remaining tokens on the line, which should be identifiers.
 
@@ -572,6 +613,7 @@ class Tokenizer:
         while True:
             token = self.get().unescape()
             if token.is_eol_or_eof():
+                self.unget(token)
                 break
             if not token.is_identifier():
                 raise dns.exception.SyntaxError
@@ -601,7 +643,7 @@ class Tokenizer:
         token = self.get()
         return self.as_name(token, origin, relativize, relativize_to)
 
-    def get_eol(self):
+    def get_eol_as_token(self):
         """Read the next token and raise an exception if it isn't EOL or
         EOF.
 
@@ -613,7 +655,10 @@ class Tokenizer:
             raise dns.exception.SyntaxError(
                 'expected EOL or EOF, got %d "%s"' % (token.ttype,
                                                       token.value))
-        return token.value
+        return token
+
+    def get_eol(self):
+        return self.get_eol_as_token().value
 
     def get_ttl(self):
         """Read the next token and interpret it as a DNS TTL.
diff --git a/dns/transaction.py b/dns/transaction.py
new file mode 100644
index 0000000..8aec2e8
--- /dev/null
+++ b/dns/transaction.py
@@ -0,0 +1,512 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import collections
+
+import dns.exception
+import dns.name
+import dns.rdataclass
+import dns.rdataset
+import dns.rdatatype
+import dns.rrset
+import dns.serial
+import dns.ttl
+
+
+class TransactionManager:
+    def reader(self):
+        """Begin a read-only transaction."""
+        raise NotImplementedError  # pragma: no cover
+
+    def writer(self, replacement=False):
+        """Begin a writable transaction.
+
+        *replacement*, a ``bool``.  If `True`, the content of the
+        transaction completely replaces any prior content.  If False,
+        the default, then the content of the transaction updates the
+        existing content.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def origin_information(self):
+        """Returns a tuple
+
+            (absolute_origin, relativize, effective_origin)
+
+        giving the absolute name of the default origin for any
+        relative domain names, the "effective origin", and whether
+        names should be relativized.  The "effective origin" is the
+        absolute origin if relativize is False, and the empty name if
+        relativize is true.  (The effective origin is provided even
+        though it can be computed from the absolute_origin and
+        relativize setting because it avoids a lot of code
+        duplication.)
+
+        If the returned names are `None`, then no origin information is
+        available.
+
+        This information is used by code working with transactions to
+        allow it to coordinate relativization.  The transaction code
+        itself takes what it gets (i.e. does not change name
+        relativity).
+
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def get_class(self):
+        """The class of the transaction manager.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def from_wire_origin(self):
+        """Origin to use in from_wire() calls.
+        """
+        (absolute_origin, relativize, _) = self.origin_information()
+        if relativize:
+            return absolute_origin
+        else:
+            return None
+
+
+class DeleteNotExact(dns.exception.DNSException):
+    """Existing data did not match data specified by an exact delete."""
+
+
+class ReadOnly(dns.exception.DNSException):
+    """Tried to write to a read-only transaction."""
+
+
+class AlreadyEnded(dns.exception.DNSException):
+    """Tried to use an already-ended transaction."""
+
+
+class Transaction:
+
+    def __init__(self, manager, replacement=False, read_only=False):
+        self.manager = manager
+        self.replacement = replacement
+        self.read_only = read_only
+        self._ended = False
+
+    #
+    # This is the high level API
+    #
+
+    def get(self, name, rdtype, covers=dns.rdatatype.NONE):
+        """Return the rdataset associated with *name*, *rdtype*, and *covers*,
+        or `None` if not found.
+
+        Note that the returned rdataset is immutable.
+        """
+        self._check_ended()
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        rdtype = dns.rdatatype.RdataType.make(rdtype)
+        rdataset = self._get_rdataset(name, rdtype, covers)
+        if rdataset is not None and \
+           not isinstance(rdataset, dns.rdataset.ImmutableRdataset):
+            rdataset = dns.rdataset.ImmutableRdataset(rdataset)
+        return rdataset
+
+    def _check_read_only(self):
+        if self.read_only:
+            raise ReadOnly
+
+    def add(self, *args):
+        """Add records.
+
+        The arguments may be:
+
+            - rrset
+
+            - name, rdataset...
+
+            - name, ttl, rdata...
+        """
+        self._check_ended()
+        self._check_read_only()
+        return self._add(False, args)
+
+    def replace(self, *args):
+        """Replace the existing rdataset at the name with the specified
+        rdataset, or add the specified rdataset if there was no existing
+        rdataset.
+
+        The arguments may be:
+
+            - rrset
+
+            - name, rdataset...
+
+            - name, ttl, rdata...
+
+        Note that if you want to replace the entire node, you should do
+        a delete of the name followed by one or more calls to add() or
+        replace().
+        """
+        self._check_ended()
+        self._check_read_only()
+        return self._add(True, args)
+
+    def delete(self, *args):
+        """Delete records.
+
+        It is not an error if some of the records are not in the existing
+        set.
+
+        The arguments may be:
+
+            - rrset
+
+            - name
+
+            - name, rdataclass, rdatatype, [covers]
+
+            - name, rdataset...
+
+            - name, rdata...
+        """
+        self._check_ended()
+        self._check_read_only()
+        return self._delete(False, args)
+
+    def delete_exact(self, *args):
+        """Delete records.
+
+        The arguments may be:
+
+            - rrset
+
+            - name
+
+            - name, rdataclass, rdatatype, [covers]
+
+            - name, rdataset...
+
+            - name, rdata...
+
+        Raises dns.transaction.DeleteNotExact if some of the records
+        are not in the existing set.
+
+        """
+        self._check_ended()
+        self._check_read_only()
+        return self._delete(True, args)
+
+    def name_exists(self, name):
+        """Does the specified name exist?"""
+        self._check_ended()
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        return self._name_exists(name)
+
+    def update_serial(self, value=1, relative=True, name=dns.name.empty):
+        """Update the serial number.
+
+        *value*, an `int`, is an increment if *relative* is `True`, or the
+        actual value to set if *relative* is `False`.
+
+        Raises `KeyError` if there is no SOA rdataset at *name*.
+
+        Raises `ValueError` if *value* is negative or if the increment is
+        so large that it would cause the new serial to be less than the
+        prior value.
+        """
+        self._check_ended()
+        if value < 0:
+            raise ValueError('negative update_serial() value')
+        if isinstance(name, str):
+            name = dns.name.from_text(name, None)
+        rdataset = self._get_rdataset(name, dns.rdatatype.SOA,
+                                      dns.rdatatype.NONE)
+        if rdataset is None or len(rdataset) == 0:
+            raise KeyError
+        if relative:
+            serial = dns.serial.Serial(rdataset[0].serial) + value
+        else:
+            serial = dns.serial.Serial(value)
+        serial = serial.value  # convert back to int
+        if serial == 0:
+            serial = 1
+        rdata = rdataset[0].replace(serial=serial)
+        new_rdataset = dns.rdataset.from_rdata(rdataset.ttl, rdata)
+        self.replace(name, new_rdataset)
+
+    def __iter__(self):
+        self._check_ended()
+        return self._iterate_rdatasets()
+
+    def changed(self):
+        """Has this transaction changed anything?
+
+        For read-only transactions, the result is always `False`.
+
+        For writable transactions, the result is `True` if at some time
+        during the life of the transaction, the content was changed.
+        """
+        self._check_ended()
+        return self._changed()
+
+    def commit(self):
+        """Commit the transaction.
+
+        Normally transactions are used as context managers and commit
+        or rollback automatically, but it may be done explicitly if needed.
+        A ``dns.transaction.Ended`` exception will be raised if you try
+        to use a transaction after it has been committed or rolled back.
+
+        Raises an exception if the commit fails (in which case the transaction
+        is also rolled back.
+        """
+        self._end(True)
+
+    def rollback(self):
+        """Rollback the transaction.
+
+        Normally transactions are used as context managers and commit
+        or rollback automatically, but it may be done explicitly if needed.
+        A ``dns.transaction.AlreadyEnded`` exception will be raised if you try
+        to use a transaction after it has been committed or rolled back.
+
+        Rollback cannot otherwise fail.
+        """
+        self._end(False)
+
+    #
+    # Helper methods
+    #
+
+    def _raise_if_not_empty(self, method, args):
+        if len(args) != 0:
+            raise TypeError(f'extra parameters to {method}')
+
+    def _rdataset_from_args(self, method, deleting, args):
+        try:
+            arg = args.popleft()
+            if isinstance(arg, dns.rrset.RRset):
+                rdataset = arg.to_rdataset()
+            elif isinstance(arg, dns.rdataset.Rdataset):
+                rdataset = arg
+            else:
+                if deleting:
+                    ttl = 0
+                else:
+                    if isinstance(arg, int):
+                        ttl = arg
+                        if ttl > dns.ttl.MAX_TTL:
+                            raise ValueError(f'{method}: TTL value too big')
+                    else:
+                        raise TypeError(f'{method}: expected a TTL')
+                    arg = args.popleft()
+                if isinstance(arg, dns.rdata.Rdata):
+                    rdataset = dns.rdataset.from_rdata(ttl, arg)
+                else:
+                    raise TypeError(f'{method}: expected an Rdata')
+            return rdataset
+        except IndexError:
+            if deleting:
+                return None
+            else:
+                # reraise
+                raise TypeError(f'{method}: expected more arguments')
+
+    def _add(self, replace, args):
+        try:
+            args = collections.deque(args)
+            if replace:
+                method = 'replace()'
+            else:
+                method = 'add()'
+            arg = args.popleft()
+            if isinstance(arg, str):
+                arg = dns.name.from_text(arg, None)
+            if isinstance(arg, dns.name.Name):
+                name = arg
+                rdataset = self._rdataset_from_args(method, False, args)
+            elif isinstance(arg, dns.rrset.RRset):
+                rrset = arg
+                name = rrset.name
+                # rrsets are also rdatasets, but they don't print the
+                # same and can't be stored in nodes, so convert.
+                rdataset = rrset.to_rdataset()
+            else:
+                raise TypeError(f'{method} requires a name or RRset ' +
+                                'as the first argument')
+            if rdataset.rdclass != self.manager.get_class():
+                raise ValueError(f'{method} has objects of wrong RdataClass')
+            if rdataset.rdtype == dns.rdatatype.SOA:
+                (_, _, origin) = self.manager.origin_information()
+                if name != origin:
+                    raise ValueError(f'{method} has non-origin SOA')
+            self._raise_if_not_empty(method, args)
+            if not replace:
+                existing = self._get_rdataset(name, rdataset.rdtype,
+                                              rdataset.covers)
+                if existing is not None:
+                    if isinstance(existing, dns.rdataset.ImmutableRdataset):
+                        trds = dns.rdataset.Rdataset(existing.rdclass,
+                                                     existing.rdtype,
+                                                     existing.covers)
+                        trds.update(existing)
+                        existing = trds
+                    rdataset = existing.union(rdataset)
+            self._put_rdataset(name, rdataset)
+        except IndexError:
+            raise TypeError(f'not enough parameters to {method}')
+
+    def _delete(self, exact, args):
+        try:
+            args = collections.deque(args)
+            if exact:
+                method = 'delete_exact()'
+            else:
+                method = 'delete()'
+            arg = args.popleft()
+            if isinstance(arg, str):
+                arg = dns.name.from_text(arg, None)
+            if isinstance(arg, dns.name.Name):
+                name = arg
+                if len(args) > 0 and (isinstance(args[0], int) or
+                                      isinstance(args[0], str)):
+                    # deleting by type and (optionally) covers
+                    rdtype = dns.rdatatype.RdataType.make(args.popleft())
+                    if len(args) > 0:
+                        covers = dns.rdatatype.RdataType.make(args.popleft())
+                    else:
+                        covers = dns.rdatatype.NONE
+                    self._raise_if_not_empty(method, args)
+                    existing = self._get_rdataset(name, rdtype, covers)
+                    if existing is None:
+                        if exact:
+                            raise DeleteNotExact(f'{method}: missing rdataset')
+                    else:
+                        self._delete_rdataset(name, rdtype, covers)
+                    return
+                else:
+                    rdataset = self._rdataset_from_args(method, True, args)
+            elif isinstance(arg, dns.rrset.RRset):
+                rdataset = arg  # rrsets are also rdatasets
+                name = rdataset.name
+            else:
+                raise TypeError(f'{method} requires a name or RRset ' +
+                                'as the first argument')
+            self._raise_if_not_empty(method, args)
+            if rdataset:
+                if rdataset.rdclass != self.manager.get_class():
+                    raise ValueError(f'{method} has objects of wrong '
+                                     'RdataClass')
+                existing = self._get_rdataset(name, rdataset.rdtype,
+                                              rdataset.covers)
+                if existing is not None:
+                    if exact:
+                        intersection = existing.intersection(rdataset)
+                        if intersection != rdataset:
+                            raise DeleteNotExact(f'{method}: missing rdatas')
+                    rdataset = existing.difference(rdataset)
+                    if len(rdataset) == 0:
+                        self._delete_rdataset(name, rdataset.rdtype,
+                                              rdataset.covers)
+                    else:
+                        self._put_rdataset(name, rdataset)
+                elif exact:
+                    raise DeleteNotExact(f'{method}: missing rdataset')
+            else:
+                if exact and not self._name_exists(name):
+                    raise DeleteNotExact(f'{method}: name not known')
+                self._delete_name(name)
+        except IndexError:
+            raise TypeError(f'not enough parameters to {method}')
+
+    def _check_ended(self):
+        if self._ended:
+            raise AlreadyEnded
+
+    def _end(self, commit):
+        self._check_ended()
+        if self._ended:
+            raise AlreadyEnded
+        try:
+            self._end_transaction(commit)
+        finally:
+            self._ended = True
+
+    #
+    # Transactions are context managers.
+    #
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if not self._ended:
+            if exc_type is None:
+                self.commit()
+            else:
+                self.rollback()
+        return False
+
+    #
+    # This is the low level API, which must be implemented by subclasses
+    # of Transaction.
+    #
+
+    def _get_rdataset(self, name, rdtype, covers):
+        """Return the rdataset associated with *name*, *rdtype*, and *covers*,
+        or `None` if not found.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _put_rdataset(self, name, rdataset):
+        """Store the rdataset."""
+        raise NotImplementedError  # pragma: no cover
+
+    def _delete_name(self, name):
+        """Delete all data associated with *name*.
+
+        It is not an error if the rdataset does not exist.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _delete_rdataset(self, name, rdtype, covers):
+        """Delete all data associated with *name*, *rdtype*, and *covers*.
+
+        It is not an error if the rdataset does not exist.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _name_exists(self, name):
+        """Does name exist?
+
+        Returns a bool.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _changed(self):
+        """Has this transaction changed anything?"""
+        raise NotImplementedError  # pragma: no cover
+
+    def _end_transaction(self, commit):
+        """End the transaction.
+
+        *commit*, a bool.  If ``True``, commit the transaction, otherwise
+        roll it back.
+
+        If committing adn the commit fails, then roll back and raise an
+        exception.
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _set_origin(self, origin):
+        """Set the origin.
+
+        This method is called when reading a possibly relativized
+        source, and an origin setting operation occurs (e.g. $ORIGIN
+        in a zone file).
+        """
+        raise NotImplementedError  # pragma: no cover
+
+    def _iterate_rdatasets(self):
+        """Return an iterator that yields (name, rdataset) tuples.
+
+        Not all Transaction subclasses implement this.
+        """
+        raise NotImplementedError  # pragma: no cover
diff --git a/dns/tsig.py b/dns/tsig.py
index 8f34fe6..5c773ff 100644
--- a/dns/tsig.py
+++ b/dns/tsig.py
@@ -71,31 +71,142 @@ class PeerBadTruncation(PeerError):
 
     """The peer didn't like amount of truncation in the TSIG we sent"""
 
+
 # TSIG Algorithms
 
 HMAC_MD5 = dns.name.from_text("HMAC-MD5.SIG-ALG.REG.INT")
 HMAC_SHA1 = dns.name.from_text("hmac-sha1")
 HMAC_SHA224 = dns.name.from_text("hmac-sha224")
 HMAC_SHA256 = dns.name.from_text("hmac-sha256")
+HMAC_SHA256_128 = dns.name.from_text("hmac-sha256-128")
 HMAC_SHA384 = dns.name.from_text("hmac-sha384")
+HMAC_SHA384_192 = dns.name.from_text("hmac-sha384-192")
 HMAC_SHA512 = dns.name.from_text("hmac-sha512")
-
-_hashes = {
-    HMAC_SHA224: hashlib.sha224,
-    HMAC_SHA256: hashlib.sha256,
-    HMAC_SHA384: hashlib.sha384,
-    HMAC_SHA512: hashlib.sha512,
-    HMAC_SHA1: hashlib.sha1,
-    HMAC_MD5: hashlib.md5,
-}
+HMAC_SHA512_256 = dns.name.from_text("hmac-sha512-256")
+GSS_TSIG = dns.name.from_text("gss-tsig")
 
 default_algorithm = HMAC_SHA256
 
 
+class GSSTSig:
+    """
+    GSS-TSIG TSIG implementation.  This uses the GSS-API context established
+    in the TKEY message handshake to sign messages using GSS-API message
+    integrity codes, per the RFC.
+
+    In order to avoid a direct GSSAPI dependency, the keyring holds a ref
+    to the GSSAPI object required, rather than the key itself.
+    """
+    def __init__(self, gssapi_context):
+        self.gssapi_context = gssapi_context
+        self.data = b''
+        self.name = 'gss-tsig'
+
+    def update(self, data):
+        self.data += data
+
+    def sign(self):
+        # defer to the GSSAPI function to sign
+        return self.gssapi_context.get_signature(self.data)
+
+    def verify(self, expected):
+        try:
+            # defer to the GSSAPI function to verify
+            return self.gssapi_context.verify_signature(self.data, expected)
+        except Exception:
+            # note the usage of a bare exception
+            raise BadSignature
+
+
+class GSSTSigAdapter:
+    def __init__(self, keyring):
+        self.keyring = keyring
+
+    def __call__(self, message, keyname):
+        if keyname in self.keyring:
+            key = self.keyring[keyname]
+            if isinstance(key, Key) and key.algorithm == GSS_TSIG:
+                if message:
+                    GSSTSigAdapter.parse_tkey_and_step(key, message, keyname)
+            return key
+        else:
+            return None
+
+    @classmethod
+    def parse_tkey_and_step(cls, key, message, keyname):
+        # if the message is a TKEY type, absorb the key material
+        # into the context using step(); this is used to allow the
+        # client to complete the GSSAPI negotiation before attempting
+        # to verify the signed response to a TKEY message exchange
+        try:
+            rrset = message.find_rrset(message.answer, keyname,
+                                       dns.rdataclass.ANY,
+                                       dns.rdatatype.TKEY)
+            if rrset:
+                token = rrset[0].key
+                gssapi_context = key.secret
+                return gssapi_context.step(token)
+        except KeyError:
+            pass
+
+
+class HMACTSig:
+    """
+    HMAC TSIG implementation.  This uses the HMAC python module to handle the
+    sign/verify operations.
+    """
+
+    _hashes = {
+        HMAC_SHA1: hashlib.sha1,
+        HMAC_SHA224: hashlib.sha224,
+        HMAC_SHA256: hashlib.sha256,
+        HMAC_SHA256_128: (hashlib.sha256, 128),
+        HMAC_SHA384: hashlib.sha384,
+        HMAC_SHA384_192: (hashlib.sha384, 192),
+        HMAC_SHA512: hashlib.sha512,
+        HMAC_SHA512_256: (hashlib.sha512, 256),
+        HMAC_MD5: hashlib.md5,
+    }
+
+    def __init__(self, key, algorithm):
+        try:
+            hashinfo = self._hashes[algorithm]
+        except KeyError:
+            raise NotImplementedError(f"TSIG algorithm {algorithm} " +
+                                      "is not supported")
+
+        # create the HMAC context
+        if isinstance(hashinfo, tuple):
+            self.hmac_context = hmac.new(key, digestmod=hashinfo[0])
+            self.size = hashinfo[1]
+        else:
+            self.hmac_context = hmac.new(key, digestmod=hashinfo)
+            self.size = None
+        self.name = self.hmac_context.name
+        if self.size:
+            self.name += f'-{self.size}'
+
+    def update(self, data):
+        return self.hmac_context.update(data)
+
+    def sign(self):
+        # defer to the HMAC digest() function for that digestmod
+        digest = self.hmac_context.digest()
+        if self.size:
+            digest = digest[: (self.size // 8)]
+        return digest
+
+    def verify(self, expected):
+        # re-digest and compare the results
+        mac = self.sign()
+        if not hmac.compare_digest(mac, expected):
+            raise BadSignature
+
+
 def _digest(wire, key, rdata, time=None, request_mac=None, ctx=None,
             multi=None):
     """Return a context containing the TSIG rdata for the input parameters
-    @rtype: hmac.HMAC object
+    @rtype: dns.tsig.HMACTSig or dns.tsig.GSSTSig object
     @raises ValueError: I{other_data} is too long
     @raises NotImplementedError: I{algorithm} is not supported
     """
@@ -131,7 +242,7 @@ def _digest(wire, key, rdata, time=None, request_mac=None, ctx=None,
 def _maybe_start_digest(key, mac, multi):
     """If this is the first message in a multi-message sequence,
     start a new context.
-    @rtype: hmac.HMAC object
+    @rtype: dns.tsig.HMACTSig or dns.tsig.GSSTSig object
     """
     if multi:
         ctx = get_context(key)
@@ -146,17 +257,14 @@ def sign(wire, key, rdata, time=None, request_mac=None, ctx=None, multi=False):
     """Return a (tsig_rdata, mac, ctx) tuple containing the HMAC TSIG rdata
     for the input parameters, the HMAC MAC calculated by applying the
     TSIG signature algorithm, and the TSIG digest context.
-    @rtype: (string, hmac.HMAC object)
+    @rtype: (string, dns.tsig.HMACTSig or dns.tsig.GSSTSig object)
     @raises ValueError: I{other_data} is too long
     @raises NotImplementedError: I{algorithm} is not supported
     """
 
     ctx = _digest(wire, key, rdata, time, request_mac, ctx, multi)
-    mac = ctx.digest()
-    tsig = dns.rdtypes.ANY.TSIG.TSIG(dns.rdataclass.ANY, dns.rdatatype.TSIG,
-                                     key.algorithm, time, rdata.fudge, mac,
-                                     rdata.original_id, rdata.error,
-                                     rdata.other)
+    mac = ctx.sign()
+    tsig = rdata.replace(time_signed=time, mac=mac)
 
     return (tsig, _maybe_start_digest(key, mac, multi))
 
@@ -169,7 +277,7 @@ def validate(wire, key, owner, rdata, now, request_mac, tsig_start, ctx=None,
     @raises BadTime: There is too much time skew between the client and the
     server.
     @raises BadSignature: The TSIG signature did not validate
-    @rtype: hmac.HMAC object"""
+    @rtype: dns.tsig.HMACTSig or dns.tsig.GSSTSig object"""
 
     (adcount,) = struct.unpack("!H", wire[10:12])
     if adcount == 0:
@@ -194,25 +302,21 @@ def validate(wire, key, owner, rdata, now, request_mac, tsig_start, ctx=None,
     if key.algorithm != rdata.algorithm:
         raise BadAlgorithm
     ctx = _digest(new_wire, key, rdata, None, request_mac, ctx, multi)
-    mac = ctx.digest()
-    if not hmac.compare_digest(mac, rdata.mac):
-        raise BadSignature
-    return _maybe_start_digest(key, mac, multi)
+    ctx.verify(rdata.mac)
+    return _maybe_start_digest(key, rdata.mac, multi)
 
 
 def get_context(key):
-    """Returns an HMAC context foe the specified key.
+    """Returns an HMAC context for the specified key.
 
     @rtype: HMAC context
     @raises NotImplementedError: I{algorithm} is not supported
     """
 
-    try:
-        digestmod = _hashes[key.algorithm]
-    except KeyError:
-        raise NotImplementedError(f"TSIG algorithm {key.algorithm} " +
-                                  "is not supported")
-    return hmac.new(key.secret, digestmod=digestmod)
+    if key.algorithm == GSS_TSIG:
+        return GSSTSig(key.secret)
+    else:
+        return HMACTSig(key.secret, key.algorithm)
 
 
 class Key:
@@ -232,3 +336,8 @@ class Key:
                 self.name == other.name and
                 self.secret == other.secret and
                 self.algorithm == other.algorithm)
+
+    def __repr__(self):
+        return f"<DNS key name='{self.name}', " + \
+               f"algorithm='{self.algorithm}', " + \
+               f"secret='{base64.b64encode(self.secret).decode()}'>"
diff --git a/dns/tsigkeyring.py b/dns/tsigkeyring.py
index 4afee57..47a1f79 100644
--- a/dns/tsigkeyring.py
+++ b/dns/tsigkeyring.py
@@ -55,5 +55,10 @@ def to_text(keyring):
         if isinstance(key, bytes):
             textring[name] = b64encode(key)
         else:
-            textring[name] = (key.algorithm.to_text(), b64encode(key.secret))
+            if isinstance(key.secret, bytes):
+                text_secret = b64encode(key.secret)
+            else:
+                text_secret = str(key.secret)
+
+            textring[name] = (key.algorithm.to_text(), text_secret)
     return textring
diff --git a/dns/ttl.py b/dns/ttl.py
index 55ae5e1..8ea5213 100644
--- a/dns/ttl.py
+++ b/dns/ttl.py
@@ -19,6 +19,7 @@
 
 import dns.exception
 
+MAX_TTL = 2147483647
 
 class BadTTL(dns.exception.SyntaxError):
     """DNS TTL value is not well-formed."""
@@ -38,16 +39,20 @@ def from_text(text):
 
     if text.isdigit():
         total = int(text)
+    elif len(text) == 0:
+        raise BadTTL
     else:
-        if not text[0].isdigit():
-            raise BadTTL
         total = 0
         current = 0
+        need_digit = True
         for c in text:
             if c.isdigit():
                 current *= 10
                 current += int(c)
+                need_digit = False
             else:
+                if need_digit:
+                    raise BadTTL
                 c = c.lower()
                 if c == 'w':
                     total += current * 604800
@@ -62,8 +67,18 @@ def from_text(text):
                 else:
                     raise BadTTL("unknown unit '%s'" % c)
                 current = 0
+                need_digit = True
         if not current == 0:
             raise BadTTL("trailing integer")
-    if total < 0 or total > 2147483647:
+    if total < 0 or total > MAX_TTL:
         raise BadTTL("TTL should be between 0 and 2^31 - 1 (inclusive)")
     return total
+
+
+def make(value):
+    if isinstance(value, int):
+        return value
+    elif isinstance(value, str):
+        return dns.ttl.from_text(value)
+    else:
+        raise ValueError('cannot convert value to TTL')
diff --git a/dns/update.py b/dns/update.py
index 8e79650..a541af2 100644
--- a/dns/update.py
+++ b/dns/update.py
@@ -38,8 +38,6 @@ class UpdateSection(dns.enum.IntEnum):
     def _maximum(cls):
         return 3
 
-globals().update(UpdateSection.__members__)
-
 
 class UpdateMessage(dns.message.Message):
 
@@ -310,3 +308,12 @@ class UpdateMessage(dns.message.Message):
 
 # backwards compatibility
 Update = UpdateMessage
+
+### BEGIN generated UpdateSection constants
+
+ZONE = UpdateSection.ZONE
+PREREQ = UpdateSection.PREREQ
+UPDATE = UpdateSection.UPDATE
+ADDITIONAL = UpdateSection.ADDITIONAL
+
+### END generated UpdateSection constants
diff --git a/dns/version.py b/dns/version.py
index 0b7c1d1..b84c41a 100644
--- a/dns/version.py
+++ b/dns/version.py
@@ -20,11 +20,11 @@
 #: MAJOR
 MAJOR = 2
 #: MINOR
-MINOR = 0
+MINOR = 2
 #: MICRO
 MICRO = 0
 #: RELEASELEVEL
-RELEASELEVEL = 0x0f
+RELEASELEVEL = 0x00
 #: SERIAL
 SERIAL = 0
 
diff --git a/dns/versioned.py b/dns/versioned.py
new file mode 100644
index 0000000..686a83b
--- /dev/null
+++ b/dns/versioned.py
@@ -0,0 +1,455 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+"""DNS Versioned Zones."""
+
+import collections
+try:
+    import threading as _threading
+except ImportError:  # pragma: no cover
+    import dummy_threading as _threading    # type: ignore
+
+import dns.exception
+import dns.immutable
+import dns.name
+import dns.node
+import dns.rdataclass
+import dns.rdatatype
+import dns.rdata
+import dns.rdtypes.ANY.SOA
+import dns.transaction
+import dns.zone
+
+
+class UseTransaction(dns.exception.DNSException):
+    """To alter a versioned zone, use a transaction."""
+
+
+class Version:
+    def __init__(self, zone, id):
+        self.zone = zone
+        self.id = id
+        self.nodes = {}
+
+    def _validate_name(self, name):
+        if name.is_absolute():
+            if not name.is_subdomain(self.zone.origin):
+                raise KeyError("name is not a subdomain of the zone origin")
+            if self.zone.relativize:
+                name = name.relativize(self.origin)
+        return name
+
+    def get_node(self, name):
+        name = self._validate_name(name)
+        return self.nodes.get(name)
+
+    def get_rdataset(self, name, rdtype, covers):
+        node = self.get_node(name)
+        if node is None:
+            return None
+        return node.get_rdataset(self.zone.rdclass, rdtype, covers)
+
+    def items(self):
+        return self.nodes.items()  # pylint: disable=dict-items-not-iterating
+
+
+class WritableVersion(Version):
+    def __init__(self, zone, replacement=False):
+        # The zone._versions_lock must be held by our caller.
+        if len(zone._versions) > 0:
+            id = zone._versions[-1].id + 1
+        else:
+            id = 1
+        super().__init__(zone, id)
+        if not replacement:
+            # We copy the map, because that gives us a simple and thread-safe
+            # way of doing versions, and we have a garbage collector to help
+            # us.  We only make new node objects if we actually change the
+            # node.
+            self.nodes.update(zone.nodes)
+        # We have to copy the zone origin as it may be None in the first
+        # version, and we don't want to mutate the zone until we commit.
+        self.origin = zone.origin
+        self.changed = set()
+
+    def _maybe_cow(self, name):
+        name = self._validate_name(name)
+        node = self.nodes.get(name)
+        if node is None or node.id != self.id:
+            new_node = self.zone.node_factory()
+            new_node.id = self.id
+            if node is not None:
+                # moo!  copy on write!
+                new_node.rdatasets.extend(node.rdatasets)
+            self.nodes[name] = new_node
+            self.changed.add(name)
+            return new_node
+        else:
+            return node
+
+    def delete_node(self, name):
+        name = self._validate_name(name)
+        if name in self.nodes:
+            del self.nodes[name]
+            self.changed.add(name)
+
+    def put_rdataset(self, name, rdataset):
+        node = self._maybe_cow(name)
+        node.replace_rdataset(rdataset)
+
+    def delete_rdataset(self, name, rdtype, covers):
+        node = self._maybe_cow(name)
+        node.delete_rdataset(self.zone.rdclass, rdtype, covers)
+        if len(node) == 0:
+            del self.nodes[name]
+
+
+@dns.immutable.immutable
+class ImmutableVersion(Version):
+    def __init__(self, version):
+        # We tell super() that it's a replacement as we don't want it
+        # to copy the nodes, as we're about to do that with an
+        # immutable Dict.
+        super().__init__(version.zone, True)
+        # set the right id!
+        self.id = version.id
+        # Make changed nodes immutable
+        for name in version.changed:
+            node = version.nodes.get(name)
+            # it might not exist if we deleted it in the version
+            if node:
+                version.nodes[name] = ImmutableNode(node)
+        self.nodes = dns.immutable.Dict(version.nodes, True)
+
+
+# A node with a version id.
+
+class Node(dns.node.Node):
+    __slots__ = ['id']
+
+    def __init__(self):
+        super().__init__()
+        # A proper id will get set by the Version
+        self.id = 0
+
+
+@dns.immutable.immutable
+class ImmutableNode(Node):
+    __slots__ = ['id']
+
+    def __init__(self, node):
+        super().__init__()
+        self.id = node.id
+        self.rdatasets = tuple(
+            [dns.rdataset.ImmutableRdataset(rds) for rds in node.rdatasets]
+        )
+
+    def find_rdataset(self, rdclass, rdtype, covers=dns.rdatatype.NONE,
+                      create=False):
+        if create:
+            raise TypeError("immutable")
+        return super().find_rdataset(rdclass, rdtype, covers, False)
+
+    def get_rdataset(self, rdclass, rdtype, covers=dns.rdatatype.NONE,
+                     create=False):
+        if create:
+            raise TypeError("immutable")
+        return super().get_rdataset(rdclass, rdtype, covers, False)
+
+    def delete_rdataset(self, rdclass, rdtype, covers=dns.rdatatype.NONE):
+        raise TypeError("immutable")
+
+    def replace_rdataset(self, replacement):
+        raise TypeError("immutable")
+
+
+class Zone(dns.zone.Zone):
+
+    __slots__ = ['_versions', '_versions_lock', '_write_txn',
+                 '_write_waiters', '_write_event', '_pruning_policy',
+                 '_readers']
+
+    node_factory = Node
+
+    def __init__(self, origin, rdclass=dns.rdataclass.IN, relativize=True,
+                 pruning_policy=None):
+        """Initialize a versioned zone object.
+
+        *origin* is the origin of the zone.  It may be a ``dns.name.Name``,
+        a ``str``, or ``None``.  If ``None``, then the zone's origin will
+        be set by the first ``$ORIGIN`` line in a zone file.
+
+        *rdclass*, an ``int``, the zone's rdata class; the default is class IN.
+
+        *relativize*, a ``bool``, determine's whether domain names are
+        relativized to the zone's origin.  The default is ``True``.
+
+        *pruning policy*, a function taking a `Version` and returning
+        a `bool`, or `None`.  Should the version be pruned?  If `None`,
+        the default policy, which retains one version is used.
+        """
+        super().__init__(origin, rdclass, relativize)
+        self._versions = collections.deque()
+        self._version_lock = _threading.Lock()
+        if pruning_policy is None:
+            self._pruning_policy = self._default_pruning_policy
+        else:
+            self._pruning_policy = pruning_policy
+        self._write_txn = None
+        self._write_event = None
+        self._write_waiters = collections.deque()
+        self._readers = set()
+        self._commit_version_unlocked(None, WritableVersion(self), origin)
+
+    def reader(self, id=None, serial=None):  # pylint: disable=arguments-differ
+        if id is not None and serial is not None:
+            raise ValueError('cannot specify both id and serial')
+        with self._version_lock:
+            if id is not None:
+                version = None
+                for v in reversed(self._versions):
+                    if v.id == id:
+                        version = v
+                        break
+                if version is None:
+                    raise KeyError('version not found')
+            elif serial is not None:
+                if self.relativize:
+                    oname = dns.name.empty
+                else:
+                    oname = self.origin
+                version = None
+                for v in reversed(self._versions):
+                    n = v.nodes.get(oname)
+                    if n:
+                        rds = n.get_rdataset(self.rdclass, dns.rdatatype.SOA)
+                        if rds and rds[0].serial == serial:
+                            version = v
+                            break
+                if version is None:
+                    raise KeyError('serial not found')
+            else:
+                version = self._versions[-1]
+            txn = Transaction(self, False, version)
+            self._readers.add(txn)
+            return txn
+
+    def writer(self, replacement=False):
+        event = None
+        while True:
+            with self._version_lock:
+                # Checking event == self._write_event ensures that either
+                # no one was waiting before we got lucky and found no write
+                # txn, or we were the one who was waiting and got woken up.
+                # This prevents "taking cuts" when creating a write txn.
+                if self._write_txn is None and event == self._write_event:
+                    # Creating the transaction defers version setup
+                    # (i.e.  copying the nodes dictionary) until we
+                    # give up the lock, so that we hold the lock as
+                    # short a time as possible.  This is why we call
+                    # _setup_version() below.
+                    self._write_txn = Transaction(self, replacement)
+                    # give up our exclusive right to make a Transaction
+                    self._write_event = None
+                    break
+                # Someone else is writing already, so we will have to
+                # wait, but we want to do the actual wait outside the
+                # lock.
+                event = _threading.Event()
+                self._write_waiters.append(event)
+            # wait (note we gave up the lock!)
+            #
+            # We only wake one sleeper at a time, so it's important
+            # that no event waiter can exit this method (e.g. via
+            # cancelation) without returning a transaction or waking
+            # someone else up.
+            #
+            # This is not a problem with Threading module threads as
+            # they cannot be canceled, but could be an issue with trio
+            # or curio tasks when we do the async version of writer().
+            # I.e. we'd need to do something like:
+            #
+            # try:
+            #     event.wait()
+            # except trio.Cancelled:
+            #     with self._version_lock:
+            #         self._maybe_wakeup_one_waiter_unlocked()
+            #     raise
+            #
+            event.wait()
+        # Do the deferred version setup.
+        self._write_txn._setup_version()
+        return self._write_txn
+
+    def _maybe_wakeup_one_waiter_unlocked(self):
+        if len(self._write_waiters) > 0:
+            self._write_event = self._write_waiters.popleft()
+            self._write_event.set()
+
+    # pylint: disable=unused-argument
+    def _default_pruning_policy(self, zone, version):
+        return True
+    # pylint: enable=unused-argument
+
+    def _prune_versions_unlocked(self):
+        assert len(self._versions) > 0
+        # Don't ever prune a version greater than or equal to one that
+        # a reader has open.  This pins versions in memory while the
+        # reader is open, and importantly lets the reader open a txn on
+        # a successor version (e.g. if generating an IXFR).
+        #
+        # Note our definition of least_kept also ensures we do not try to
+        # delete the greatest version.
+        if len(self._readers) > 0:
+            least_kept = min(txn.version.id for txn in self._readers)
+        else:
+            least_kept = self._versions[-1].id
+        while self._versions[0].id < least_kept and \
+              self._pruning_policy(self, self._versions[0]):
+            self._versions.popleft()
+
+    def set_max_versions(self, max_versions):
+        """Set a pruning policy that retains up to the specified number
+        of versions
+        """
+        if max_versions is not None and max_versions < 1:
+            raise ValueError('max versions must be at least 1')
+        if max_versions is None:
+            def policy(*_):
+                return False
+        else:
+            def policy(zone, _):
+                return len(zone._versions) > max_versions
+        self.set_pruning_policy(policy)
+
+    def set_pruning_policy(self, policy):
+        """Set the pruning policy for the zone.
+
+        The *policy* function takes a `Version` and returns `True` if
+        the version should be pruned, and `False` otherwise.  `None`
+        may also be specified for policy, in which case the default policy
+        is used.
+
+        Pruning checking proceeds from the least version and the first
+        time the function returns `False`, the checking stops.  I.e. the
+        retained versions are always a consecutive sequence.
+        """
+        if policy is None:
+            policy = self._default_pruning_policy
+        with self._version_lock:
+            self._pruning_policy = policy
+            self._prune_versions_unlocked()
+
+    def _end_read(self, txn):
+        with self._version_lock:
+            self._readers.remove(txn)
+            self._prune_versions_unlocked()
+
+    def _end_write_unlocked(self, txn):
+        assert self._write_txn == txn
+        self._write_txn = None
+        self._maybe_wakeup_one_waiter_unlocked()
+
+    def _end_write(self, txn):
+        with self._version_lock:
+            self._end_write_unlocked(txn)
+
+    def _commit_version_unlocked(self, txn, version, origin):
+        self._versions.append(version)
+        self._prune_versions_unlocked()
+        self.nodes = version.nodes
+        if self.origin is None:
+            self.origin = origin
+        # txn can be None in __init__ when we make the empty version.
+        if txn is not None:
+            self._end_write_unlocked(txn)
+
+    def _commit_version(self, txn, version, origin):
+        with self._version_lock:
+            self._commit_version_unlocked(txn, version, origin)
+
+    def find_node(self, name, create=False):
+        if create:
+            raise UseTransaction
+        return super().find_node(name)
+
+    def delete_node(self, name):
+        raise UseTransaction
+
+    def find_rdataset(self, name, rdtype, covers=dns.rdatatype.NONE,
+                      create=False):
+        if create:
+            raise UseTransaction
+        rdataset = super().find_rdataset(name, rdtype, covers)
+        return dns.rdataset.ImmutableRdataset(rdataset)
+
+    def get_rdataset(self, name, rdtype, covers=dns.rdatatype.NONE,
+                     create=False):
+        if create:
+            raise UseTransaction
+        rdataset = super().get_rdataset(name, rdtype, covers)
+        return dns.rdataset.ImmutableRdataset(rdataset)
+
+    def delete_rdataset(self, name, rdtype, covers=dns.rdatatype.NONE):
+        raise UseTransaction
+
+    def replace_rdataset(self, name, replacement):
+        raise UseTransaction
+
+
+class Transaction(dns.transaction.Transaction):
+
+    def __init__(self, zone, replacement, version=None):
+        read_only = version is not None
+        super().__init__(zone, replacement, read_only)
+        self.version = version
+
+    @property
+    def zone(self):
+        return self.manager
+
+    def _setup_version(self):
+        assert self.version is None
+        self.version = WritableVersion(self.zone, self.replacement)
+
+    def _get_rdataset(self, name, rdtype, covers):
+        return self.version.get_rdataset(name, rdtype, covers)
+
+    def _put_rdataset(self, name, rdataset):
+        assert not self.read_only
+        self.version.put_rdataset(name, rdataset)
+
+    def _delete_name(self, name):
+        assert not self.read_only
+        self.version.delete_node(name)
+
+    def _delete_rdataset(self, name, rdtype, covers):
+        assert not self.read_only
+        self.version.delete_rdataset(name, rdtype, covers)
+
+    def _name_exists(self, name):
+        return self.version.get_node(name) is not None
+
+    def _changed(self):
+        if self.read_only:
+            return False
+        else:
+            return len(self.version.changed) > 0
+
+    def _end_transaction(self, commit):
+        if self.read_only:
+            self.zone._end_read(self)
+        elif commit and len(self.version.changed) > 0:
+            self.zone._commit_version(self, ImmutableVersion(self.version),
+                                      self.version.origin)
+        else:
+            # rollback
+            self.zone._end_write(self)
+
+    def _set_origin(self, origin):
+        if self.version.origin is None:
+            self.version.origin = origin
+
+    def _iterate_rdatasets(self):
+        for (name, node) in self.version.items():
+            for rdataset in node:
+                yield (name, rdataset)
diff --git a/dns/wire.py b/dns/wire.py
index a314960..572e27e 100644
--- a/dns/wire.py
+++ b/dns/wire.py
@@ -42,6 +42,9 @@ class Parser:
     def get_uint32(self):
         return struct.unpack('!I', self.get_bytes(4))[0]
 
+    def get_uint48(self):
+        return int.from_bytes(self.get_bytes(6), 'big')
+
     def get_struct(self, format):
         return struct.unpack(format, self.get_bytes(struct.calcsize(format)))
 
diff --git a/dns/xfr.py b/dns/xfr.py
new file mode 100644
index 0000000..b07f8b9
--- /dev/null
+++ b/dns/xfr.py
@@ -0,0 +1,295 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2017 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import dns.exception
+import dns.message
+import dns.name
+import dns.rcode
+import dns.serial
+import dns.rdatatype
+import dns.zone
+
+
+class TransferError(dns.exception.DNSException):
+    """A zone transfer response got a non-zero rcode."""
+
+    def __init__(self, rcode):
+        message = 'Zone transfer error: %s' % dns.rcode.to_text(rcode)
+        super().__init__(message)
+        self.rcode = rcode
+
+
+class SerialWentBackwards(dns.exception.FormError):
+    """The current serial number is less than the serial we know."""
+
+
+class UseTCP(dns.exception.DNSException):
+    """This IXFR cannot be completed with UDP."""
+
+
+class Inbound:
+    """
+    State machine for zone transfers.
+    """
+
+    def __init__(self, txn_manager, rdtype=dns.rdatatype.AXFR,
+                 serial=None, is_udp=False):
+        """Initialize an inbound zone transfer.
+
+        *txn_manager* is a :py:class:`dns.transaction.TransactionManager`.
+
+        *rdtype* can be `dns.rdatatype.AXFR` or `dns.rdatatype.IXFR`
+
+        *serial* is the base serial number for IXFRs, and is required in
+        that case.
+
+        *is_udp*, a ``bool`` indidicates if UDP is being used for this
+        XFR.
+        """
+        self.txn_manager = txn_manager
+        self.txn = None
+        self.rdtype = rdtype
+        if rdtype == dns.rdatatype.IXFR:
+            if serial is None:
+                raise ValueError('a starting serial must be supplied for IXFRs')
+        elif is_udp:
+            raise ValueError('is_udp specified for AXFR')
+        self.serial = serial
+        self.is_udp = is_udp
+        (_, _, self.origin) = txn_manager.origin_information()
+        self.soa_rdataset = None
+        self.done = False
+        self.expecting_SOA = False
+        self.delete_mode = False
+
+    def process_message(self, message):
+        """Process one message in the transfer.
+
+        The message should have the same relativization as was specified when
+        the `dns.xfr.Inbound` was created.  The message should also have been
+        created with `one_rr_per_rrset=True` because order matters.
+
+        Returns `True` if the transfer is complete, and `False` otherwise.
+        """
+        if self.txn is None:
+            replacement = self.rdtype == dns.rdatatype.AXFR
+            self.txn = self.txn_manager.writer(replacement)
+        rcode = message.rcode()
+        if rcode != dns.rcode.NOERROR:
+            raise TransferError(rcode)
+        #
+        # We don't require a question section, but if it is present is
+        # should be correct.
+        #
+        if len(message.question) > 0:
+            if message.question[0].name != self.origin:
+                raise dns.exception.FormError("wrong question name")
+            if message.question[0].rdtype != self.rdtype:
+                raise dns.exception.FormError("wrong question rdatatype")
+        answer_index = 0
+        if self.soa_rdataset is None:
+            #
+            # This is the first message.  We're expecting an SOA at
+            # the origin.
+            #
+            if not message.answer or message.answer[0].name != self.origin:
+                raise dns.exception.FormError("No answer or RRset not "
+                                              "for zone origin")
+            rrset = message.answer[0]
+            name = rrset.name
+            rdataset = rrset
+            if rdataset.rdtype != dns.rdatatype.SOA:
+                raise dns.exception.FormError("first RRset is not an SOA")
+            answer_index = 1
+            self.soa_rdataset = rdataset.copy()
+            if self.rdtype == dns.rdatatype.IXFR:
+                if self.soa_rdataset[0].serial == self.serial:
+                    #
+                    # We're already up-to-date.
+                    #
+                    self.done = True
+                elif dns.serial.Serial(self.soa_rdataset[0].serial) < \
+                     self.serial:
+                    # It went backwards!
+                    print(dns.serial.Serial(self.soa_rdataset[0].serial),
+                          self.serial)
+                    raise SerialWentBackwards
+                else:
+                    if self.is_udp and len(message.answer[answer_index:]) == 0:
+                        #
+                        # There are no more records, so this is the
+                        # "truncated" response.  Say to use TCP
+                        #
+                        raise UseTCP
+                    #
+                    # Note we're expecting another SOA so we can detect
+                    # if this IXFR response is an AXFR-style response.
+                    #
+                    self.expecting_SOA = True
+        #
+        # Process the answer section (other than the initial SOA in
+        # the first message).
+        #
+        for rrset in message.answer[answer_index:]:
+            name = rrset.name
+            rdataset = rrset
+            if self.done:
+                raise dns.exception.FormError("answers after final SOA")
+            if rdataset.rdtype == dns.rdatatype.SOA and \
+               name == self.origin:
+                #
+                # Every time we see an origin SOA delete_mode inverts
+                #
+                if self.rdtype == dns.rdatatype.IXFR:
+                    self.delete_mode = not self.delete_mode
+                #
+                # If this SOA Rdataset is equal to the first we saw
+                # then we're finished. If this is an IXFR we also
+                # check that we're seeing the record in the expected
+                # part of the response.
+                #
+                if rdataset == self.soa_rdataset and \
+                        (self.rdtype == dns.rdatatype.AXFR or
+                         (self.rdtype == dns.rdatatype.IXFR and
+                          self.delete_mode)):
+                    #
+                    # This is the final SOA
+                    #
+                    if self.expecting_SOA:
+                        # We got an empty IXFR sequence!
+                        raise dns.exception.FormError('empty IXFR sequence')
+                    if self.rdtype == dns.rdatatype.IXFR \
+                       and self.serial != rdataset[0].serial:
+                        raise dns.exception.FormError('unexpected end of IXFR '
+                                                      'sequence')
+                    self.txn.replace(name, rdataset)
+                    self.txn.commit()
+                    self.txn = None
+                    self.done = True
+                else:
+                    #
+                    # This is not the final SOA
+                    #
+                    self.expecting_SOA = False
+                    if self.rdtype == dns.rdatatype.IXFR:
+                        if self.delete_mode:
+                            # This is the start of an IXFR deletion set
+                            if rdataset[0].serial != self.serial:
+                                raise dns.exception.FormError(
+                                    "IXFR base serial mismatch")
+                        else:
+                            # This is the start of an IXFR addition set
+                            self.serial = rdataset[0].serial
+                            self.txn.replace(name, rdataset)
+                    else:
+                        # We saw a non-final SOA for the origin in an AXFR.
+                        raise dns.exception.FormError('unexpected origin SOA '
+                                                      'in AXFR')
+                continue
+            if self.expecting_SOA:
+                #
+                # We made an IXFR request and are expecting another
+                # SOA RR, but saw something else, so this must be an
+                # AXFR response.
+                #
+                self.rdtype = dns.rdatatype.AXFR
+                self.expecting_SOA = False
+                self.delete_mode = False
+                self.txn.rollback()
+                self.txn = self.txn_manager.writer(True)
+                #
+                # Note we are falling through into the code below
+                # so whatever rdataset this was gets written.
+                #
+            # Add or remove the data
+            if self.delete_mode:
+                self.txn.delete_exact(name, rdataset)
+            else:
+                self.txn.add(name, rdataset)
+        if self.is_udp and not self.done:
+            #
+            # This is a UDP IXFR and we didn't get to done, and we didn't
+            # get the proper "truncated" response
+            #
+            raise dns.exception.FormError('unexpected end of UDP IXFR')
+        return self.done
+
+    #
+    # Inbounds are context managers.
+    #
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if self.txn:
+            self.txn.rollback()
+        return False
+
+
+def make_query(txn_manager, serial=0,
+               use_edns=None, ednsflags=None, payload=None,
+               request_payload=None, options=None,
+               keyring=None, keyname=None,
+               keyalgorithm=dns.tsig.default_algorithm):
+    """Make an AXFR or IXFR query.
+
+    *txn_manager* is a ``dns.transaction.TransactionManager``, typically a
+    ``dns.zone.Zone``.
+
+    *serial* is an ``int`` or ``None``.  If 0, then IXFR will be
+    attempted using the most recent serial number from the
+    *txn_manager*; it is the caller's responsibility to ensure there
+    are no write transactions active that could invalidate the
+    retrieved serial.  If a serial cannot be determined, AXFR will be
+    forced.  Other integer values are the starting serial to use.
+    ``None`` forces an AXFR.
+
+    Please see the documentation for :py:func:`dns.message.make_query` and
+    :py:func:`dns.message.Message.use_tsig` for details on the other parameters
+    to this function.
+
+    Returns a `(query, serial)` tuple.
+    """
+    (zone_origin, _, origin) = txn_manager.origin_information()
+    if serial is None:
+        rdtype = dns.rdatatype.AXFR
+    elif not isinstance(serial, int):
+        raise ValueError('serial is not an integer')
+    elif serial == 0:
+        with txn_manager.reader() as txn:
+            rdataset = txn.get(origin, 'SOA')
+            if rdataset:
+                serial = rdataset[0].serial
+                rdtype = dns.rdatatype.IXFR
+            else:
+                serial = None
+                rdtype = dns.rdatatype.AXFR
+    elif serial > 0 and serial < 4294967296:
+        rdtype = dns.rdatatype.IXFR
+    else:
+        raise ValueError('serial out-of-range')
+    q = dns.message.make_query(zone_origin, rdtype, txn_manager.get_class(),
+                               use_edns, False, ednsflags, payload,
+                               request_payload, options)
+    if serial is not None:
+        rrset = dns.rrset.from_text(zone_origin, 0, 'IN', 'SOA',
+                                    f'. . {serial} 0 0 0 0')
+        q.authority.append(rrset)
+    if keyring is not None:
+        q.use_tsig(keyring, keyname, algorithm=keyalgorithm)
+    return (q, serial)
diff --git a/dns/zone.py b/dns/zone.py
index e8413c0..86f1298 100644
--- a/dns/zone.py
+++ b/dns/zone.py
@@ -18,10 +18,10 @@
 """DNS Zones."""
 
 import contextlib
+import hashlib
 import io
 import os
-import re
-import sys
+import struct
 
 import dns.exception
 import dns.name
@@ -30,10 +30,13 @@ import dns.rdataclass
 import dns.rdatatype
 import dns.rdata
 import dns.rdtypes.ANY.SOA
+import dns.rdtypes.ANY.ZONEMD
 import dns.rrset
 import dns.tokenizer
+import dns.transaction
 import dns.ttl
 import dns.grange
+import dns.zonefile
 
 
 class BadZone(dns.exception.DNSException):
@@ -56,7 +59,54 @@ class UnknownOrigin(BadZone):
     """The DNS zone's origin is unknown."""
 
 
-class Zone:
+class UnsupportedDigestScheme(dns.exception.DNSException):
+
+    """The zone digest's scheme is unsupported."""
+
+
+class UnsupportedDigestHashAlgorithm(dns.exception.DNSException):
+
+    """The zone digest's origin is unsupported."""
+
+
+class NoDigest(dns.exception.DNSException):
+
+    """The DNS zone has no ZONEMD RRset at its origin."""
+
+
+class DigestVerificationFailure(dns.exception.DNSException):
+
+    """The ZONEMD digest failed to verify."""
+
+
+class DigestScheme(dns.enum.IntEnum):
+    """ZONEMD Scheme"""
+
+    SIMPLE = 1
+
+    @classmethod
+    def _maximum(cls):
+        return 255
+
+
+class DigestHashAlgorithm(dns.enum.IntEnum):
+    """ZONEMD Hash Algorithm"""
+
+    SHA384 = 1
+    SHA512 = 2
+
+    @classmethod
+    def _maximum(cls):
+        return 255
+
+
+_digest_hashers = {
+    DigestHashAlgorithm.SHA384: hashlib.sha384,
+    DigestHashAlgorithm.SHA512 : hashlib.sha512
+}
+
+
+class Zone(dns.transaction.TransactionManager):
 
     """A DNS zone.
 
@@ -77,7 +127,7 @@ class Zone:
 
         *origin* is the origin of the zone.  It may be a ``dns.name.Name``,
         a ``str``, or ``None``.  If ``None``, then the zone's origin will
-        be set by the first ``$ORIGIN`` line in a masterfile.
+        be set by the first ``$ORIGIN`` line in a zone file.
 
         *rdclass*, an ``int``, the zone's rdata class; the default is class IN.
 
@@ -162,8 +212,9 @@ class Zone:
         key = self._validate_name(key)
         return self.nodes.get(key)
 
-    def __contains__(self, other):
-        return other in self.nodes
+    def __contains__(self, key):
+        key = self._validate_name(key)
+        return key in self.nodes
 
     def find_node(self, name, create=False):
         """Find a node in the zone, possibly creating it.
@@ -532,7 +583,8 @@ class Zone:
                     for rdata in rds:
                         yield (name, rds.ttl, rdata)
 
-    def to_file(self, f, sorted=True, relativize=True, nl=None):
+    def to_file(self, f, sorted=True, relativize=True, nl=None,
+                want_comments=False):
         """Write a zone to a file.
 
         *f*, a file or `str`.  If *f* is a string, it is treated
@@ -550,6 +602,10 @@ class Zone:
         *nl*, a ``str`` or None.  The end of line string.  If not
         ``None``, the output will use the platform's native
         end-of-line marker (i.e. LF on POSIX, CRLF on Windows).
+
+        *want_comments*, a ``bool``.  If ``True``, emit end-of-line comments
+        as part of writing the file.  If ``False``, the default, do not
+        emit them.
         """
 
         with contextlib.ExitStack() as stack:
@@ -579,12 +635,9 @@ class Zone:
                 names = self.keys()
             for n in names:
                 l = self[n].to_text(n, origin=self.origin,
-                                    relativize=relativize)
-                if isinstance(l, str):
-                    l_b = l.encode(file_enc)
-                else:
-                    l_b = l
-                    l = l.decode()
+                                    relativize=relativize,
+                                    want_comments=want_comments)
+                l_b = l.encode(file_enc)
 
                 try:
                     f.write(l_b)
@@ -593,7 +646,8 @@ class Zone:
                     f.write(l)
                     f.write(nl)
 
-    def to_text(self, sorted=True, relativize=True, nl=None):
+    def to_text(self, sorted=True, relativize=True, nl=None,
+                want_comments=False):
         """Return a zone's text as though it were written to a file.
 
         *sorted*, a ``bool``.  If True, the default, then the file
@@ -609,10 +663,14 @@ class Zone:
         ``None``, the output will use the platform's native
         end-of-line marker (i.e. LF on POSIX, CRLF on Windows).
 
+        *want_comments*, a ``bool``.  If ``True``, emit end-of-line comments
+        as part of writing the file.  If ``False``, the default, do not
+        emit them.
+
         Returns a ``str``.
         """
         temp_buffer = io.StringIO()
-        self.to_file(temp_buffer, sorted, relativize, nl)
+        self.to_file(temp_buffer, sorted, relativize, nl, want_comments)
         return_value = temp_buffer.getvalue()
         temp_buffer.close()
         return return_value
@@ -635,425 +693,188 @@ class Zone:
         if self.get_rdataset(name, dns.rdatatype.NS) is None:
             raise NoNS
 
+    def _compute_digest(self, hash_algorithm, scheme=DigestScheme.SIMPLE):
+        hashinfo = _digest_hashers.get(hash_algorithm)
+        if not hashinfo:
+            raise UnsupportedDigestHashAlgorithm
+        if scheme != DigestScheme.SIMPLE:
+            raise UnsupportedDigestScheme
 
-class _MasterReader:
-
-    """Read a DNS master file
-
-    @ivar tok: The tokenizer
-    @type tok: dns.tokenizer.Tokenizer object
-    @ivar last_ttl: The last seen explicit TTL for an RR
-    @type last_ttl: int
-    @ivar last_ttl_known: Has last TTL been detected
-    @type last_ttl_known: bool
-    @ivar default_ttl: The default TTL from a $TTL directive or SOA RR
-    @type default_ttl: int
-    @ivar default_ttl_known: Has default TTL been detected
-    @type default_ttl_known: bool
-    @ivar last_name: The last name read
-    @type last_name: dns.name.Name object
-    @ivar current_origin: The current origin
-    @type current_origin: dns.name.Name object
-    @ivar relativize: should names in the zone be relativized?
-    @type relativize: bool
-    @ivar zone: the zone
-    @type zone: dns.zone.Zone object
-    @ivar saved_state: saved reader state (used when processing $INCLUDE)
-    @type saved_state: list of (tokenizer, current_origin, last_name, file,
-    last_ttl, last_ttl_known, default_ttl, default_ttl_known) tuples.
-    @ivar current_file: the file object of the $INCLUDed file being parsed
-    (None if no $INCLUDE is active).
-    @ivar allow_include: is $INCLUDE allowed?
-    @type allow_include: bool
-    @ivar check_origin: should sanity checks of the origin node be done?
-    The default is True.
-    @type check_origin: bool
-    """
-
-    def __init__(self, tok, origin, rdclass, relativize, zone_factory=Zone,
-                 allow_include=False, check_origin=True):
-        if isinstance(origin, str):
-            origin = dns.name.from_text(origin)
-        self.tok = tok
-        self.current_origin = origin
-        self.relativize = relativize
-        self.last_ttl = 0
-        self.last_ttl_known = False
-        self.default_ttl = 0
-        self.default_ttl_known = False
-        self.last_name = self.current_origin
-        self.zone = zone_factory(origin, rdclass, relativize=relativize)
-        self.saved_state = []
-        self.current_file = None
-        self.allow_include = allow_include
-        self.check_origin = check_origin
-
-    def _eat_line(self):
-        while 1:
-            token = self.tok.get()
-            if token.is_eol_or_eof():
-                break
-
-    def _rr_line(self):
-        """Process one line from a DNS master file."""
-        # Name
-        if self.current_origin is None:
-            raise UnknownOrigin
-        token = self.tok.get(want_leading=True)
-        if not token.is_whitespace():
-            self.last_name = self.tok.as_name(token, self.current_origin)
+        if self.relativize:
+            origin_name = dns.name.empty
         else:
-            token = self.tok.get()
-            if token.is_eol_or_eof():
-                # treat leading WS followed by EOL/EOF as if they were EOL/EOF.
-                return
-            self.tok.unget(token)
-        name = self.last_name
-        if not name.is_subdomain(self.zone.origin):
-            self._eat_line()
-            return
+            origin_name = self.origin
+        hasher = hashinfo()
+        for (name, node) in sorted(self.items()):
+            rrnamebuf = name.to_digestable(self.origin)
+            for rdataset in sorted(node,
+                                   key=lambda rds: (rds.rdtype, rds.covers)):
+                if name == origin_name and \
+                   dns.rdatatype.ZONEMD in (rdataset.rdtype, rdataset.covers):
+                    continue
+                rrfixed = struct.pack('!HHI', rdataset.rdtype,
+                                      rdataset.rdclass, rdataset.ttl)
+                for rr in sorted(rdataset):
+                    rrdata = rr.to_digestable(self.origin)
+                    rrlen = struct.pack('!H', len(rrdata))
+                    hasher.update(rrnamebuf + rrfixed + rrlen + rrdata)
+        return hasher.digest()
+
+    def compute_digest(self, hash_algorithm, scheme=DigestScheme.SIMPLE):
         if self.relativize:
-            name = name.relativize(self.zone.origin)
-        token = self.tok.get()
-        if not token.is_identifier():
-            raise dns.exception.SyntaxError
+            origin_name = dns.name.empty
+        else:
+            origin_name = self.origin
+        serial = self.get_rdataset(origin_name, dns.rdatatype.SOA)[0].serial
+        digest = self._compute_digest(hash_algorithm, scheme)
+        return dns.rdtypes.ANY.ZONEMD.ZONEMD(self.rdclass,
+                                             dns.rdatatype.ZONEMD,
+                                             serial, scheme, hash_algorithm,
+                                             digest)
+
+    def verify_digest(self, zonemd=None):
+        if zonemd:
+            digests = [zonemd]
+        else:
+            digests = self.get_rdataset(self.origin, dns.rdatatype.ZONEMD)
+            if digests is None:
+                raise NoDigest
+        for digest in digests:
+            try:
+                computed = self._compute_digest(digest.hash_algorithm,
+                                                digest.scheme)
+                if computed == digest.digest:
+                    return
+            except Exception as e:
+                pass
+        raise DigestVerificationFailure
 
-        # TTL
-        ttl = None
-        try:
-            ttl = dns.ttl.from_text(token.value)
-            self.last_ttl = ttl
-            self.last_ttl_known = True
-            token = self.tok.get()
-            if not token.is_identifier():
-                raise dns.exception.SyntaxError
-        except dns.ttl.BadTTL:
-            if self.default_ttl_known:
-                ttl = self.default_ttl
-            elif self.last_ttl_known:
-                ttl = self.last_ttl
-
-        # Class
-        try:
-            rdclass = dns.rdataclass.from_text(token.value)
-            token = self.tok.get()
-            if not token.is_identifier():
-                raise dns.exception.SyntaxError
-        except dns.exception.SyntaxError:
-            raise
-        except Exception:
-            rdclass = self.zone.rdclass
-        if rdclass != self.zone.rdclass:
-            raise dns.exception.SyntaxError("RR class is not zone's class")
-        # Type
-        try:
-            rdtype = dns.rdatatype.from_text(token.value)
-        except Exception:
-            raise dns.exception.SyntaxError(
-                "unknown rdatatype '%s'" % token.value)
-        n = self.zone.nodes.get(name)
-        if n is None:
-            n = self.zone.node_factory()
-            self.zone.nodes[name] = n
-        try:
-            rd = dns.rdata.from_text(rdclass, rdtype, self.tok,
-                                     self.current_origin, self.relativize,
-                                     self.zone.origin)
-        except dns.exception.SyntaxError:
-            # Catch and reraise.
-            raise
-        except Exception:
-            # All exceptions that occur in the processing of rdata
-            # are treated as syntax errors.  This is not strictly
-            # correct, but it is correct almost all of the time.
-            # We convert them to syntax errors so that we can emit
-            # helpful filename:line info.
-            (ty, va) = sys.exc_info()[:2]
-            raise dns.exception.SyntaxError(
-                "caught exception {}: {}".format(str(ty), str(va)))
-
-        if not self.default_ttl_known and rdtype == dns.rdatatype.SOA:
-            # The pre-RFC2308 and pre-BIND9 behavior inherits the zone default
-            # TTL from the SOA minttl if no $TTL statement is present before the
-            # SOA is parsed.
-            self.default_ttl = rd.minimum
-            self.default_ttl_known = True
-            if ttl is None:
-                # if we didn't have a TTL on the SOA, set it!
-                ttl = rd.minimum
-
-        # TTL check.  We had to wait until now to do this as the SOA RR's
-        # own TTL can be inferred from its minimum.
-        if ttl is None:
-            raise dns.exception.SyntaxError("Missing default TTL value")
-
-        covers = rd.covers()
-        rds = n.find_rdataset(rdclass, rdtype, covers, True)
-        rds.add(rd, ttl)
-
-    def _parse_modify(self, side):
-        # Here we catch everything in '{' '}' in a group so we can replace it
-        # with ''.
-        is_generate1 = re.compile(r"^.*\$({(\+|-?)(\d+),(\d+),(.)}).*$")
-        is_generate2 = re.compile(r"^.*\$({(\+|-?)(\d+)}).*$")
-        is_generate3 = re.compile(r"^.*\$({(\+|-?)(\d+),(\d+)}).*$")
-        # Sometimes there are modifiers in the hostname. These come after
-        # the dollar sign. They are in the form: ${offset[,width[,base]]}.
-        # Make names
-        g1 = is_generate1.match(side)
-        if g1:
-            mod, sign, offset, width, base = g1.groups()
-            if sign == '':
-                sign = '+'
-        g2 = is_generate2.match(side)
-        if g2:
-            mod, sign, offset = g2.groups()
-            if sign == '':
-                sign = '+'
-            width = 0
-            base = 'd'
-        g3 = is_generate3.match(side)
-        if g3:
-            mod, sign, offset, width = g3.groups()
-            if sign == '':
-                sign = '+'
-            base = 'd'
-
-        if not (g1 or g2 or g3):
-            mod = ''
-            sign = '+'
-            offset = 0
-            width = 0
-            base = 'd'
-
-        if base != 'd':
-            raise NotImplementedError()
-
-        return mod, sign, offset, width, base
-
-    def _generate_line(self):
-        # range lhs [ttl] [class] type rhs [ comment ]
-        """Process one line containing the GENERATE statement from a DNS
-        master file."""
-        if self.current_origin is None:
-            raise UnknownOrigin
-
-        token = self.tok.get()
-        # Range (required)
-        try:
-            start, stop, step = dns.grange.from_text(token.value)
-            token = self.tok.get()
-            if not token.is_identifier():
-                raise dns.exception.SyntaxError
-        except Exception:
-            raise dns.exception.SyntaxError
-
-        # lhs (required)
-        try:
-            lhs = token.value
-            token = self.tok.get()
-            if not token.is_identifier():
-                raise dns.exception.SyntaxError
-        except Exception:
-            raise dns.exception.SyntaxError
-
-        # TTL
-        try:
-            ttl = dns.ttl.from_text(token.value)
-            self.last_ttl = ttl
-            self.last_ttl_known = True
-            token = self.tok.get()
-            if not token.is_identifier():
-                raise dns.exception.SyntaxError
-        except dns.ttl.BadTTL:
-            if not (self.last_ttl_known or self.default_ttl_known):
-                raise dns.exception.SyntaxError("Missing default TTL value")
-            if self.default_ttl_known:
-                ttl = self.default_ttl
-            elif self.last_ttl_known:
-                ttl = self.last_ttl
-        # Class
-        try:
-            rdclass = dns.rdataclass.from_text(token.value)
-            token = self.tok.get()
-            if not token.is_identifier():
-                raise dns.exception.SyntaxError
-        except dns.exception.SyntaxError:
-            raise dns.exception.SyntaxError
-        except Exception:
-            rdclass = self.zone.rdclass
-        if rdclass != self.zone.rdclass:
-            raise dns.exception.SyntaxError("RR class is not zone's class")
-        # Type
-        try:
-            rdtype = dns.rdatatype.from_text(token.value)
-            token = self.tok.get()
-            if not token.is_identifier():
-                raise dns.exception.SyntaxError
-        except Exception:
-            raise dns.exception.SyntaxError("unknown rdatatype '%s'" %
-                                            token.value)
-
-        # rhs (required)
-        rhs = token.value
-
-        lmod, lsign, loffset, lwidth, lbase = self._parse_modify(lhs)
-        rmod, rsign, roffset, rwidth, rbase = self._parse_modify(rhs)
-        for i in range(start, stop + 1, step):
-            # +1 because bind is inclusive and python is exclusive
-
-            if lsign == '+':
-                lindex = i + int(loffset)
-            elif lsign == '-':
-                lindex = i - int(loffset)
-
-            if rsign == '-':
-                rindex = i - int(roffset)
-            elif rsign == '+':
-                rindex = i + int(roffset)
-
-            lzfindex = str(lindex).zfill(int(lwidth))
-            rzfindex = str(rindex).zfill(int(rwidth))
-
-            name = lhs.replace('$%s' % (lmod), lzfindex)
-            rdata = rhs.replace('$%s' % (rmod), rzfindex)
-
-            self.last_name = dns.name.from_text(name, self.current_origin,
-                                                self.tok.idna_codec)
-            name = self.last_name
-            if not name.is_subdomain(self.zone.origin):
-                self._eat_line()
-                return
-            if self.relativize:
-                name = name.relativize(self.zone.origin)
+    # TransactionManager methods
 
-            n = self.zone.nodes.get(name)
-            if n is None:
-                n = self.zone.node_factory()
-                self.zone.nodes[name] = n
-            try:
-                rd = dns.rdata.from_text(rdclass, rdtype, rdata,
-                                         self.current_origin, self.relativize,
-                                         self.zone.origin)
-            except dns.exception.SyntaxError:
-                # Catch and reraise.
-                raise
-            except Exception:
-                # All exceptions that occur in the processing of rdata
-                # are treated as syntax errors.  This is not strictly
-                # correct, but it is correct almost all of the time.
-                # We convert them to syntax errors so that we can emit
-                # helpful filename:line info.
-                (ty, va) = sys.exc_info()[:2]
-                raise dns.exception.SyntaxError("caught exception %s: %s" %
-                                                (str(ty), str(va)))
-
-            covers = rd.covers()
-            rds = n.find_rdataset(rdclass, rdtype, covers, True)
-            rds.add(rd, ttl)
-
-    def read(self):
-        """Read a DNS master file and build a zone object.
-
-        @raises dns.zone.NoSOA: No SOA RR was found at the zone origin
-        @raises dns.zone.NoNS: No NS RRset was found at the zone origin
-        """
+    def reader(self):
+        return Transaction(self, False, True)
+
+    def writer(self, replacement=False):
+        return Transaction(self, replacement, False)
+
+    def origin_information(self):
+        if self.relativize:
+            effective = dns.name.empty
+        else:
+            effective = self.origin
+        return (self.origin, self.relativize, effective)
+
+    def get_class(self):
+        return self.rdclass
+
+
+class Transaction(dns.transaction.Transaction):
+
+    _deleted_rdataset = dns.rdataset.Rdataset(dns.rdataclass.ANY,
+                                              dns.rdatatype.ANY)
 
+    def __init__(self, zone, replacement, read_only):
+        super().__init__(zone, replacement, read_only)
+        self.rdatasets = {}
+
+    @property
+    def zone(self):
+        return self.manager
+
+    def _get_rdataset(self, name, rdtype, covers):
+        rdataset = self.rdatasets.get((name, rdtype, covers))
+        if rdataset is self._deleted_rdataset:
+            return None
+        elif rdataset is None:
+            rdataset = self.zone.get_rdataset(name, rdtype, covers)
+        return rdataset
+
+    def _put_rdataset(self, name, rdataset):
+        assert not self.read_only
+        self.zone._validate_name(name)
+        self.rdatasets[(name, rdataset.rdtype, rdataset.covers)] = rdataset
+
+    def _delete_name(self, name):
+        assert not self.read_only
+        # First remove any changes involving the name
+        remove = []
+        for key in self.rdatasets:
+            if key[0] == name:
+                remove.append(key)
+        if len(remove) > 0:
+            for key in remove:
+                del self.rdatasets[key]
+        # Next add deletion records for any rdatasets matching the
+        # name in the zone
+        node = self.zone.get_node(name)
+        if node is not None:
+            for rdataset in node.rdatasets:
+                self.rdatasets[(name, rdataset.rdtype, rdataset.covers)] = \
+                    self._deleted_rdataset
+
+    def _delete_rdataset(self, name, rdtype, covers):
+        assert not self.read_only
         try:
-            while 1:
-                token = self.tok.get(True, True)
-                if token.is_eof():
-                    if self.current_file is not None:
-                        self.current_file.close()
-                    if len(self.saved_state) > 0:
-                        (self.tok,
-                         self.current_origin,
-                         self.last_name,
-                         self.current_file,
-                         self.last_ttl,
-                         self.last_ttl_known,
-                         self.default_ttl,
-                         self.default_ttl_known) = self.saved_state.pop(-1)
-                        continue
-                    break
-                elif token.is_eol():
-                    continue
-                elif token.is_comment():
-                    self.tok.get_eol()
-                    continue
-                elif token.value[0] == '$':
-                    c = token.value.upper()
-                    if c == '$TTL':
-                        token = self.tok.get()
-                        if not token.is_identifier():
-                            raise dns.exception.SyntaxError("bad $TTL")
-                        self.default_ttl = dns.ttl.from_text(token.value)
-                        self.default_ttl_known = True
-                        self.tok.get_eol()
-                    elif c == '$ORIGIN':
-                        self.current_origin = self.tok.get_name()
-                        self.tok.get_eol()
-                        if self.zone.origin is None:
-                            self.zone.origin = self.current_origin
-                    elif c == '$INCLUDE' and self.allow_include:
-                        token = self.tok.get()
-                        filename = token.value
-                        token = self.tok.get()
-                        if token.is_identifier():
-                            new_origin =\
-                                dns.name.from_text(token.value,
-                                                   self.current_origin,
-                                                   self.tok.idna_codec)
-                            self.tok.get_eol()
-                        elif not token.is_eol_or_eof():
-                            raise dns.exception.SyntaxError(
-                                "bad origin in $INCLUDE")
-                        else:
-                            new_origin = self.current_origin
-                        self.saved_state.append((self.tok,
-                                                 self.current_origin,
-                                                 self.last_name,
-                                                 self.current_file,
-                                                 self.last_ttl,
-                                                 self.last_ttl_known,
-                                                 self.default_ttl,
-                                                 self.default_ttl_known))
-                        self.current_file = open(filename, 'r')
-                        self.tok = dns.tokenizer.Tokenizer(self.current_file,
-                                                           filename)
-                        self.current_origin = new_origin
-                    elif c == '$GENERATE':
-                        self._generate_line()
-                    else:
-                        raise dns.exception.SyntaxError(
-                            "Unknown master file directive '" + c + "'")
-                    continue
-                self.tok.unget(token)
-                self._rr_line()
-        except dns.exception.SyntaxError as detail:
-            (filename, line_number) = self.tok.where()
-            if detail is None:
-                detail = "syntax error"
-            ex = dns.exception.SyntaxError(
-                "%s:%d: %s" % (filename, line_number, detail))
-            tb = sys.exc_info()[2]
-            raise ex.with_traceback(tb) from None
-
-        # Now that we're done reading, do some basic checking of the zone.
-        if self.check_origin:
-            self.zone.check_origin()
+            del self.rdatasets[(name, rdtype, covers)]
+        except KeyError:
+            pass
+        rdataset = self.zone.get_rdataset(name, rdtype, covers)
+        if rdataset is not None:
+            self.rdatasets[(name, rdataset.rdtype, rdataset.covers)] = \
+                self._deleted_rdataset
+
+    def _name_exists(self, name):
+        for key, rdataset in self.rdatasets.items():
+            if key[0] == name:
+                if rdataset != self._deleted_rdataset:
+                    return True
+                else:
+                    return None
+        self.zone._validate_name(name)
+        if self.zone.get_node(name):
+            return True
+        return False
+
+    def _changed(self):
+        if self.read_only:
+            return False
+        else:
+            return len(self.rdatasets) > 0
+
+    def _end_transaction(self, commit):
+        if commit and self._changed():
+            for (name, rdtype, covers), rdataset in \
+                self.rdatasets.items():
+                if rdataset is self._deleted_rdataset:
+                    self.zone.delete_rdataset(name, rdtype, covers)
+                else:
+                    self.zone.replace_rdataset(name, rdataset)
+
+    def _set_origin(self, origin):
+        if self.zone.origin is None:
+            self.zone.origin = origin
+
+    def _iterate_rdatasets(self):
+        # Expensive but simple!  Use a versioned zone for efficient txn
+        # iteration.
+        rdatasets = {}
+        for (name, rdataset) in self.zone.iterate_rdatasets():
+            rdatasets[(name, rdataset.rdtype, rdataset.covers)] = rdataset
+        rdatasets.update(self.rdatasets)
+        for (name, _, _), rdataset in rdatasets.items():
+            yield (name, rdataset)
 
 
 def from_text(text, origin=None, rdclass=dns.rdataclass.IN,
               relativize=True, zone_factory=Zone, filename=None,
               allow_include=False, check_origin=True, idna_codec=None):
-    """Build a zone object from a master file format string.
+    """Build a zone object from a zone file format string.
 
-    *text*, a ``str``, the master file format input.
+    *text*, a ``str``, the zone file format input.
 
     *origin*, a ``dns.name.Name``, a ``str``, or ``None``.  The origin
     of the zone; if not specified, the first ``$ORIGIN`` statement in the
-    masterfile will determine the origin of the zone.
+    zone file will determine the origin of the zone.
 
     *rdclass*, an ``int``, the zone's rdata class; the default is class IN.
 
@@ -1094,25 +915,33 @@ def from_text(text, origin=None, rdclass=dns.rdataclass.IN,
 
     if filename is None:
         filename = '<string>'
-    tok = dns.tokenizer.Tokenizer(text, filename, idna_codec=idna_codec)
-    reader = _MasterReader(tok, origin, rdclass, relativize, zone_factory,
-                           allow_include=allow_include,
-                           check_origin=check_origin)
-    reader.read()
-    return reader.zone
+    zone = zone_factory(origin, rdclass, relativize=relativize)
+    with zone.writer(True) as txn:
+        tok = dns.tokenizer.Tokenizer(text, filename, idna_codec=idna_codec)
+        reader = dns.zonefile.Reader(tok, rdclass, txn,
+                                     allow_include=allow_include)
+        try:
+            reader.read()
+        except dns.zonefile.UnknownOrigin:
+            # for backwards compatibility
+            raise dns.zone.UnknownOrigin
+    # Now that we're done reading, do some basic checking of the zone.
+    if check_origin:
+        zone.check_origin()
+    return zone
 
 
 def from_file(f, origin=None, rdclass=dns.rdataclass.IN,
               relativize=True, zone_factory=Zone, filename=None,
               allow_include=True, check_origin=True):
-    """Read a master file and build a zone object.
+    """Read a zone file and build a zone object.
 
     *f*, a file or ``str``.  If *f* is a string, it is treated
     as the name of a file to open.
 
     *origin*, a ``dns.name.Name``, a ``str``, or ``None``.  The origin
     of the zone; if not specified, the first ``$ORIGIN`` statement in the
-    masterfile will determine the origin of the zone.
+    zone file will determine the origin of the zone.
 
     *rdclass*, an ``int``, the zone's rdata class; the default is class IN.
 
diff --git a/dns/zonefile.py b/dns/zonefile.py
new file mode 100644
index 0000000..92e2f0c
--- /dev/null
+++ b/dns/zonefile.py
@@ -0,0 +1,401 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""DNS Zones."""
+
+import re
+import sys
+
+import dns.exception
+import dns.name
+import dns.node
+import dns.rdataclass
+import dns.rdatatype
+import dns.rdata
+import dns.rdtypes.ANY.SOA
+import dns.rrset
+import dns.tokenizer
+import dns.transaction
+import dns.ttl
+import dns.grange
+
+
+class UnknownOrigin(dns.exception.DNSException):
+    """Unknown origin"""
+
+
+class Reader:
+
+    """Read a DNS zone file into a transaction."""
+
+    def __init__(self, tok, rdclass, txn, allow_include=False):
+        self.tok = tok
+        (self.zone_origin, self.relativize, _) = \
+            txn.manager.origin_information()
+        self.current_origin = self.zone_origin
+        self.last_ttl = 0
+        self.last_ttl_known = False
+        self.default_ttl = 0
+        self.default_ttl_known = False
+        self.last_name = self.current_origin
+        self.zone_rdclass = rdclass
+        self.txn = txn
+        self.saved_state = []
+        self.current_file = None
+        self.allow_include = allow_include
+
+    def _eat_line(self):
+        while 1:
+            token = self.tok.get()
+            if token.is_eol_or_eof():
+                break
+
+    def _rr_line(self):
+        """Process one line from a DNS zone file."""
+        # Name
+        if self.current_origin is None:
+            raise UnknownOrigin
+        token = self.tok.get(want_leading=True)
+        if not token.is_whitespace():
+            self.last_name = self.tok.as_name(token, self.current_origin)
+        else:
+            token = self.tok.get()
+            if token.is_eol_or_eof():
+                # treat leading WS followed by EOL/EOF as if they were EOL/EOF.
+                return
+            self.tok.unget(token)
+        name = self.last_name
+        if not name.is_subdomain(self.zone_origin):
+            self._eat_line()
+            return
+        if self.relativize:
+            name = name.relativize(self.zone_origin)
+        token = self.tok.get()
+        if not token.is_identifier():
+            raise dns.exception.SyntaxError
+
+        # TTL
+        ttl = None
+        try:
+            ttl = dns.ttl.from_text(token.value)
+            self.last_ttl = ttl
+            self.last_ttl_known = True
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except dns.ttl.BadTTL:
+            if self.default_ttl_known:
+                ttl = self.default_ttl
+            elif self.last_ttl_known:
+                ttl = self.last_ttl
+
+        # Class
+        try:
+            rdclass = dns.rdataclass.from_text(token.value)
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except dns.exception.SyntaxError:
+            raise
+        except Exception:
+            rdclass = self.zone_rdclass
+        if rdclass != self.zone_rdclass:
+            raise dns.exception.SyntaxError("RR class is not zone's class")
+        # Type
+        try:
+            rdtype = dns.rdatatype.from_text(token.value)
+        except Exception:
+            raise dns.exception.SyntaxError(
+                "unknown rdatatype '%s'" % token.value)
+        try:
+            rd = dns.rdata.from_text(rdclass, rdtype, self.tok,
+                                     self.current_origin, self.relativize,
+                                     self.zone_origin)
+        except dns.exception.SyntaxError:
+            # Catch and reraise.
+            raise
+        except Exception:
+            # All exceptions that occur in the processing of rdata
+            # are treated as syntax errors.  This is not strictly
+            # correct, but it is correct almost all of the time.
+            # We convert them to syntax errors so that we can emit
+            # helpful filename:line info.
+            (ty, va) = sys.exc_info()[:2]
+            raise dns.exception.SyntaxError(
+                "caught exception {}: {}".format(str(ty), str(va)))
+
+        if not self.default_ttl_known and rdtype == dns.rdatatype.SOA:
+            # The pre-RFC2308 and pre-BIND9 behavior inherits the zone default
+            # TTL from the SOA minttl if no $TTL statement is present before the
+            # SOA is parsed.
+            self.default_ttl = rd.minimum
+            self.default_ttl_known = True
+            if ttl is None:
+                # if we didn't have a TTL on the SOA, set it!
+                ttl = rd.minimum
+
+        # TTL check.  We had to wait until now to do this as the SOA RR's
+        # own TTL can be inferred from its minimum.
+        if ttl is None:
+            raise dns.exception.SyntaxError("Missing default TTL value")
+
+        self.txn.add(name, ttl, rd)
+
+    def _parse_modify(self, side):
+        # Here we catch everything in '{' '}' in a group so we can replace it
+        # with ''.
+        is_generate1 = re.compile(r"^.*\$({(\+|-?)(\d+),(\d+),(.)}).*$")
+        is_generate2 = re.compile(r"^.*\$({(\+|-?)(\d+)}).*$")
+        is_generate3 = re.compile(r"^.*\$({(\+|-?)(\d+),(\d+)}).*$")
+        # Sometimes there are modifiers in the hostname. These come after
+        # the dollar sign. They are in the form: ${offset[,width[,base]]}.
+        # Make names
+        g1 = is_generate1.match(side)
+        if g1:
+            mod, sign, offset, width, base = g1.groups()
+            if sign == '':
+                sign = '+'
+        g2 = is_generate2.match(side)
+        if g2:
+            mod, sign, offset = g2.groups()
+            if sign == '':
+                sign = '+'
+            width = 0
+            base = 'd'
+        g3 = is_generate3.match(side)
+        if g3:
+            mod, sign, offset, width = g3.groups()
+            if sign == '':
+                sign = '+'
+            base = 'd'
+
+        if not (g1 or g2 or g3):
+            mod = ''
+            sign = '+'
+            offset = 0
+            width = 0
+            base = 'd'
+
+        if base != 'd':
+            raise NotImplementedError()
+
+        return mod, sign, offset, width, base
+
+    def _generate_line(self):
+        # range lhs [ttl] [class] type rhs [ comment ]
+        """Process one line containing the GENERATE statement from a DNS
+        zone file."""
+        if self.current_origin is None:
+            raise UnknownOrigin
+
+        token = self.tok.get()
+        # Range (required)
+        try:
+            start, stop, step = dns.grange.from_text(token.value)
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except Exception:
+            raise dns.exception.SyntaxError
+
+        # lhs (required)
+        try:
+            lhs = token.value
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except Exception:
+            raise dns.exception.SyntaxError
+
+        # TTL
+        try:
+            ttl = dns.ttl.from_text(token.value)
+            self.last_ttl = ttl
+            self.last_ttl_known = True
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except dns.ttl.BadTTL:
+            if not (self.last_ttl_known or self.default_ttl_known):
+                raise dns.exception.SyntaxError("Missing default TTL value")
+            if self.default_ttl_known:
+                ttl = self.default_ttl
+            elif self.last_ttl_known:
+                ttl = self.last_ttl
+        # Class
+        try:
+            rdclass = dns.rdataclass.from_text(token.value)
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except dns.exception.SyntaxError:
+            raise dns.exception.SyntaxError
+        except Exception:
+            rdclass = self.zone_rdclass
+        if rdclass != self.zone_rdclass:
+            raise dns.exception.SyntaxError("RR class is not zone's class")
+        # Type
+        try:
+            rdtype = dns.rdatatype.from_text(token.value)
+            token = self.tok.get()
+            if not token.is_identifier():
+                raise dns.exception.SyntaxError
+        except Exception:
+            raise dns.exception.SyntaxError("unknown rdatatype '%s'" %
+                                            token.value)
+
+        # rhs (required)
+        rhs = token.value
+
+        # The code currently only supports base 'd', so the last value
+        # in the tuple _parse_modify returns is ignored
+        lmod, lsign, loffset, lwidth, _ = self._parse_modify(lhs)
+        rmod, rsign, roffset, rwidth, _ = self._parse_modify(rhs)
+        for i in range(start, stop + 1, step):
+            # +1 because bind is inclusive and python is exclusive
+
+            if lsign == '+':
+                lindex = i + int(loffset)
+            elif lsign == '-':
+                lindex = i - int(loffset)
+
+            if rsign == '-':
+                rindex = i - int(roffset)
+            elif rsign == '+':
+                rindex = i + int(roffset)
+
+            lzfindex = str(lindex).zfill(int(lwidth))
+            rzfindex = str(rindex).zfill(int(rwidth))
+
+            name = lhs.replace('$%s' % (lmod), lzfindex)
+            rdata = rhs.replace('$%s' % (rmod), rzfindex)
+
+            self.last_name = dns.name.from_text(name, self.current_origin,
+                                                self.tok.idna_codec)
+            name = self.last_name
+            if not name.is_subdomain(self.zone_origin):
+                self._eat_line()
+                return
+            if self.relativize:
+                name = name.relativize(self.zone_origin)
+
+            try:
+                rd = dns.rdata.from_text(rdclass, rdtype, rdata,
+                                         self.current_origin, self.relativize,
+                                         self.zone_origin)
+            except dns.exception.SyntaxError:
+                # Catch and reraise.
+                raise
+            except Exception:
+                # All exceptions that occur in the processing of rdata
+                # are treated as syntax errors.  This is not strictly
+                # correct, but it is correct almost all of the time.
+                # We convert them to syntax errors so that we can emit
+                # helpful filename:line info.
+                (ty, va) = sys.exc_info()[:2]
+                raise dns.exception.SyntaxError("caught exception %s: %s" %
+                                                (str(ty), str(va)))
+
+            self.txn.add(name, ttl, rd)
+
+    def read(self):
+        """Read a DNS zone file and build a zone object.
+
+        @raises dns.zone.NoSOA: No SOA RR was found at the zone origin
+        @raises dns.zone.NoNS: No NS RRset was found at the zone origin
+        """
+
+        try:
+            while 1:
+                token = self.tok.get(True, True)
+                if token.is_eof():
+                    if self.current_file is not None:
+                        self.current_file.close()
+                    if len(self.saved_state) > 0:
+                        (self.tok,
+                         self.current_origin,
+                         self.last_name,
+                         self.current_file,
+                         self.last_ttl,
+                         self.last_ttl_known,
+                         self.default_ttl,
+                         self.default_ttl_known) = self.saved_state.pop(-1)
+                        continue
+                    break
+                elif token.is_eol():
+                    continue
+                elif token.is_comment():
+                    self.tok.get_eol()
+                    continue
+                elif token.value[0] == '$':
+                    c = token.value.upper()
+                    if c == '$TTL':
+                        token = self.tok.get()
+                        if not token.is_identifier():
+                            raise dns.exception.SyntaxError("bad $TTL")
+                        self.default_ttl = dns.ttl.from_text(token.value)
+                        self.default_ttl_known = True
+                        self.tok.get_eol()
+                    elif c == '$ORIGIN':
+                        self.current_origin = self.tok.get_name()
+                        self.tok.get_eol()
+                        if self.zone_origin is None:
+                            self.zone_origin = self.current_origin
+                        self.txn._set_origin(self.current_origin)
+                    elif c == '$INCLUDE' and self.allow_include:
+                        token = self.tok.get()
+                        filename = token.value
+                        token = self.tok.get()
+                        if token.is_identifier():
+                            new_origin =\
+                                dns.name.from_text(token.value,
+                                                   self.current_origin,
+                                                   self.tok.idna_codec)
+                            self.tok.get_eol()
+                        elif not token.is_eol_or_eof():
+                            raise dns.exception.SyntaxError(
+                                "bad origin in $INCLUDE")
+                        else:
+                            new_origin = self.current_origin
+                        self.saved_state.append((self.tok,
+                                                 self.current_origin,
+                                                 self.last_name,
+                                                 self.current_file,
+                                                 self.last_ttl,
+                                                 self.last_ttl_known,
+                                                 self.default_ttl,
+                                                 self.default_ttl_known))
+                        self.current_file = open(filename, 'r')
+                        self.tok = dns.tokenizer.Tokenizer(self.current_file,
+                                                           filename)
+                        self.current_origin = new_origin
+                    elif c == '$GENERATE':
+                        self._generate_line()
+                    else:
+                        raise dns.exception.SyntaxError(
+                            "Unknown zone file directive '" + c + "'")
+                    continue
+                self.tok.unget(token)
+                self._rr_line()
+        except dns.exception.SyntaxError as detail:
+            (filename, line_number) = self.tok.where()
+            if detail is None:
+                detail = "syntax error"
+            ex = dns.exception.SyntaxError(
+                "%s:%d: %s" % (filename, line_number, detail))
+            tb = sys.exc_info()[2]
+            raise ex.with_traceback(tb) from None
diff --git a/dnspython.egg-info/PKG-INFO b/dnspython.egg-info/PKG-INFO
new file mode 100644
index 0000000..8c2345f
--- /dev/null
+++ b/dnspython.egg-info/PKG-INFO
@@ -0,0 +1,39 @@
+Metadata-Version: 2.1
+Name: dnspython
+Version: 1.15.1.dev1197+g6a53ddf
+Summary: DNS toolkit
+Home-page: http://www.dnspython.org
+Author: Bob Halley
+Author-email: halley@dnspython.org
+License: ISC
+Description: dnspython is a DNS toolkit for Python. It supports almost all
+        record types. It can be used for queries, zone transfers, and dynamic
+        updates.  It supports TSIG authenticated messages and EDNS0.
+        
+        dnspython provides both high and low level access to DNS. The high
+        level classes perform queries for data of a given name, type, and
+        class, and return an answer set.  The low level classes allow
+        direct manipulation of DNS zones, messages, names, and records.
+Platform: UNKNOWN
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Intended Audience :: Developers
+Classifier: Intended Audience :: System Administrators
+Classifier: License :: OSI Approved :: ISC License (ISCL)
+Classifier: Operating System :: POSIX
+Classifier: Operating System :: Microsoft :: Windows
+Classifier: Programming Language :: Python
+Classifier: Topic :: Internet :: Name Service (DNS)
+Classifier: Topic :: Software Development :: Libraries :: Python Modules
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
+Classifier: Programming Language :: Python :: 3.8
+Classifier: Programming Language :: Python :: 3.9
+Provides: dns
+Requires-Python: >=3.6
+Description-Content-Type: text/plain
+Provides-Extra: curio
+Provides-Extra: dnssec
+Provides-Extra: doh
+Provides-Extra: idna
+Provides-Extra: trio
diff --git a/dnspython.egg-info/SOURCES.txt b/dnspython.egg-info/SOURCES.txt
new file mode 100644
index 0000000..0324585
--- /dev/null
+++ b/dnspython.egg-info/SOURCES.txt
@@ -0,0 +1,300 @@
+.flake8
+.gitignore
+.readthedocs.yml
+.travis.yml
+LICENSE
+MANIFEST.in
+Makefile
+README.md
+SECURITY.md
+azure-pipelines.yml
+codecov.yml
+mypy.ini
+pylintrc
+pyproject.toml
+pytest.ini
+setup.cfg
+setup.py
+.github/dependabot.yml
+dns/__init__.py
+dns/_asyncbackend.py
+dns/_asyncio_backend.py
+dns/_curio_backend.py
+dns/_immutable_attr.py
+dns/_immutable_ctx.py
+dns/_trio_backend.py
+dns/asyncbackend.py
+dns/asyncbackend.pyi
+dns/asyncquery.py
+dns/asyncquery.pyi
+dns/asyncresolver.py
+dns/asyncresolver.pyi
+dns/dnssec.py
+dns/dnssec.pyi
+dns/e164.py
+dns/e164.pyi
+dns/edns.py
+dns/entropy.py
+dns/entropy.pyi
+dns/enum.py
+dns/exception.py
+dns/exception.pyi
+dns/flags.py
+dns/grange.py
+dns/immutable.py
+dns/inet.py
+dns/inet.pyi
+dns/ipv4.py
+dns/ipv6.py
+dns/message.py
+dns/message.pyi
+dns/name.py
+dns/name.pyi
+dns/namedict.py
+dns/node.py
+dns/node.pyi
+dns/opcode.py
+dns/py.typed
+dns/query.py
+dns/query.pyi
+dns/rcode.py
+dns/rdata.py
+dns/rdata.pyi
+dns/rdataclass.py
+dns/rdataset.py
+dns/rdataset.pyi
+dns/rdatatype.py
+dns/renderer.py
+dns/resolver.py
+dns/resolver.pyi
+dns/reversename.py
+dns/reversename.pyi
+dns/rrset.py
+dns/rrset.pyi
+dns/serial.py
+dns/set.py
+dns/tokenizer.py
+dns/transaction.py
+dns/tsig.py
+dns/tsigkeyring.py
+dns/tsigkeyring.pyi
+dns/ttl.py
+dns/update.py
+dns/update.pyi
+dns/version.py
+dns/versioned.py
+dns/wire.py
+dns/xfr.py
+dns/zone.py
+dns/zone.pyi
+dns/zonefile.py
+dns/rdtypes/__init__.py
+dns/rdtypes/dnskeybase.py
+dns/rdtypes/dnskeybase.pyi
+dns/rdtypes/dsbase.py
+dns/rdtypes/euibase.py
+dns/rdtypes/mxbase.py
+dns/rdtypes/nsbase.py
+dns/rdtypes/svcbbase.py
+dns/rdtypes/tlsabase.py
+dns/rdtypes/txtbase.py
+dns/rdtypes/txtbase.pyi
+dns/rdtypes/util.py
+dns/rdtypes/ANY/AFSDB.py
+dns/rdtypes/ANY/AMTRELAY.py
+dns/rdtypes/ANY/AVC.py
+dns/rdtypes/ANY/CAA.py
+dns/rdtypes/ANY/CDNSKEY.py
+dns/rdtypes/ANY/CDS.py
+dns/rdtypes/ANY/CERT.py
+dns/rdtypes/ANY/CNAME.py
+dns/rdtypes/ANY/CSYNC.py
+dns/rdtypes/ANY/DLV.py
+dns/rdtypes/ANY/DNAME.py
+dns/rdtypes/ANY/DNSKEY.py
+dns/rdtypes/ANY/DS.py
+dns/rdtypes/ANY/EUI48.py
+dns/rdtypes/ANY/EUI64.py
+dns/rdtypes/ANY/GPOS.py
+dns/rdtypes/ANY/HINFO.py
+dns/rdtypes/ANY/HIP.py
+dns/rdtypes/ANY/ISDN.py
+dns/rdtypes/ANY/LOC.py
+dns/rdtypes/ANY/MX.py
+dns/rdtypes/ANY/NINFO.py
+dns/rdtypes/ANY/NS.py
+dns/rdtypes/ANY/NSEC.py
+dns/rdtypes/ANY/NSEC3.py
+dns/rdtypes/ANY/NSEC3PARAM.py
+dns/rdtypes/ANY/OPENPGPKEY.py
+dns/rdtypes/ANY/OPT.py
+dns/rdtypes/ANY/PTR.py
+dns/rdtypes/ANY/RP.py
+dns/rdtypes/ANY/RRSIG.py
+dns/rdtypes/ANY/RT.py
+dns/rdtypes/ANY/SMIMEA.py
+dns/rdtypes/ANY/SOA.py
+dns/rdtypes/ANY/SPF.py
+dns/rdtypes/ANY/SSHFP.py
+dns/rdtypes/ANY/TKEY.py
+dns/rdtypes/ANY/TLSA.py
+dns/rdtypes/ANY/TSIG.py
+dns/rdtypes/ANY/TXT.py
+dns/rdtypes/ANY/URI.py
+dns/rdtypes/ANY/X25.py
+dns/rdtypes/ANY/ZONEMD.py
+dns/rdtypes/ANY/__init__.py
+dns/rdtypes/CH/A.py
+dns/rdtypes/CH/__init__.py
+dns/rdtypes/IN/A.py
+dns/rdtypes/IN/AAAA.py
+dns/rdtypes/IN/APL.py
+dns/rdtypes/IN/DHCID.py
+dns/rdtypes/IN/HTTPS.py
+dns/rdtypes/IN/IPSECKEY.py
+dns/rdtypes/IN/KX.py
+dns/rdtypes/IN/NAPTR.py
+dns/rdtypes/IN/NSAP.py
+dns/rdtypes/IN/NSAP_PTR.py
+dns/rdtypes/IN/PX.py
+dns/rdtypes/IN/SRV.py
+dns/rdtypes/IN/SVCB.py
+dns/rdtypes/IN/WKS.py
+dns/rdtypes/IN/__init__.py
+dnspython.egg-info/PKG-INFO
+dnspython.egg-info/SOURCES.txt
+dnspython.egg-info/dependency_links.txt
+dnspython.egg-info/requires.txt
+dnspython.egg-info/top_level.txt
+doc/.gitignore
+doc/Makefile
+doc/async-backend.rst
+doc/async-query.rst
+doc/async-resolver-class.rst
+doc/async-resolver-functions.rst
+doc/async-resolver.rst
+doc/async.rst
+doc/community.rst
+doc/conf.py
+doc/dnssec.rst
+doc/exceptions.rst
+doc/inbound-xfr-class.rst
+doc/index.rst
+doc/installation.rst
+doc/license.rst
+doc/manual.rst
+doc/message-class.rst
+doc/message-edns.rst
+doc/message-flags.rst
+doc/message-make.rst
+doc/message-opcode.rst
+doc/message-query.rst
+doc/message-rcode.rst
+doc/message-update.rst
+doc/message.rst
+doc/name-class.rst
+doc/name-codecs.rst
+doc/name-dict.rst
+doc/name-helpers.rst
+doc/name-make.rst
+doc/name.rst
+doc/query.rst
+doc/rdata-class.rst
+doc/rdata-make.rst
+doc/rdata-set-classes.rst
+doc/rdata-set-make.rst
+doc/rdata-subclasses.rst
+doc/rdata-types.rst
+doc/rdata.rst
+doc/rdataclass-list.rst
+doc/rdatatype-list.rst
+doc/resolver-caching.rst
+doc/resolver-class.rst
+doc/resolver-functions.rst
+doc/resolver-override.rst
+doc/resolver.rst
+doc/rfc.rst
+doc/typing.rst
+doc/utilities.rst
+doc/whatsnew.rst
+doc/zone-class.rst
+doc/zone-make.rst
+doc/zone.rst
+doc/util/auto-values.py
+examples/async_dns.py
+examples/ddns.py
+examples/doh-json.py
+examples/doh.py
+examples/e164.py
+examples/ecs.py
+examples/mx.py
+examples/name.py
+examples/query_specific.py
+examples/receive_notify.py
+examples/reverse.py
+examples/reverse_name.py
+examples/xfr.py
+examples/zonediff.py
+tests/Makefile
+tests/__init__.py
+tests/example
+tests/example1.good
+tests/example2.good
+tests/example3.good
+tests/md_module.py
+tests/mx-2-0.pickle
+tests/nanonameserver.py
+tests/query
+tests/stxt_module.py
+tests/test_address.py
+tests/test_async.py
+tests/test_bugs.py
+tests/test_constants.py
+tests/test_dnssec.py
+tests/test_doh.py
+tests/test_edns.py
+tests/test_entropy.py
+tests/test_exceptions.py
+tests/test_flags.py
+tests/test_generate.py
+tests/test_grange.py
+tests/test_immutable.py
+tests/test_message.py
+tests/test_name.py
+tests/test_namedict.py
+tests/test_nsec3.py
+tests/test_nsec3_hash.py
+tests/test_ntoaaton.py
+tests/test_processing_order.py
+tests/test_query.py
+tests/test_rdata.py
+tests/test_rdataset.py
+tests/test_rdtypeandclass.py
+tests/test_rdtypeanydnskey.py
+tests/test_rdtypeanyeui.py
+tests/test_rdtypeanyloc.py
+tests/test_rdtypeanytkey.py
+tests/test_renderer.py
+tests/test_resolution.py
+tests/test_resolver.py
+tests/test_resolver_override.py
+tests/test_rrset.py
+tests/test_serial.py
+tests/test_set.py
+tests/test_svcb.py
+tests/test_tokenizer.py
+tests/test_transaction.py
+tests/test_tsig.py
+tests/test_tsigkeyring.py
+tests/test_ttl.py
+tests/test_update.py
+tests/test_wire.py
+tests/test_xfr.py
+tests/test_zone.py
+tests/test_zonedigest.py
+tests/ttxt_module.py
+tests/utest.py
+tests/util.py
+util/constants-tool
+util/generate-mx-pickle.py
+util/generate-rdatatype-doc.py
\ No newline at end of file
diff --git a/dnspython.egg-info/dependency_links.txt b/dnspython.egg-info/dependency_links.txt
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/dnspython.egg-info/dependency_links.txt
@@ -0,0 +1 @@
+
diff --git a/dnspython.egg-info/requires.txt b/dnspython.egg-info/requires.txt
new file mode 100644
index 0000000..f81bcec
--- /dev/null
+++ b/dnspython.egg-info/requires.txt
@@ -0,0 +1,18 @@
+
+[curio]
+curio>=1.2
+sniffio>=1.1
+
+[dnssec]
+cryptography>=2.6
+
+[doh]
+requests
+requests-toolbelt
+
+[idna]
+idna>=2.1
+
+[trio]
+sniffio>=1.1
+trio>=0.14.0
diff --git a/dnspython.egg-info/top_level.txt b/dnspython.egg-info/top_level.txt
new file mode 100644
index 0000000..2e7065c
--- /dev/null
+++ b/dnspython.egg-info/top_level.txt
@@ -0,0 +1 @@
+dns
diff --git a/doc/async-query.rst b/doc/async-query.rst
index e2466ea..7202bdf 100644
--- a/doc/async-query.rst
+++ b/doc/async-query.rst
@@ -9,8 +9,8 @@ processing their responses.  If you want "stub resolver" behavior, then
 you should use the higher level ``dns.asyncresolver`` module; see
 :ref:`async_resolver`.
 
-There is currently no support for zone transfers or DNS-over-HTTPS
-using asynchronous I/O but we hope to offer this in the future.
+There is currently no support for DNS-over-HTTPS using asynchronous
+I/O but we hope to offer this in the future.
 
 UDP
 ---
@@ -31,3 +31,8 @@ TLS
 ---
 
 .. autofunction:: dns.asyncquery.tls
+
+Zone Transfers
+--------------
+
+.. autofunction:: dns.asyncquery.inbound_xfr
diff --git a/doc/async-resolver-functions.rst b/doc/async-resolver-functions.rst
index fc5798a..b2669f3 100644
--- a/doc/async-resolver-functions.rst
+++ b/doc/async-resolver-functions.rst
@@ -5,4 +5,5 @@ Asynchronous Resolver Functions
 
 .. autofunction:: dns.asyncresolver.resolve
 .. autofunction:: dns.asyncresolver.resolve_address
+.. autofunction:: dns.asyncresolver.canonical_name
 .. autofunction:: dns.asyncresolver.zone_for_name
diff --git a/doc/inbound-xfr-class.rst b/doc/inbound-xfr-class.rst
new file mode 100644
index 0000000..c5b747f
--- /dev/null
+++ b/doc/inbound-xfr-class.rst
@@ -0,0 +1,20 @@
+.. _inbound-xfr-class:
+
+The dns.xfr.Inbound Class and make_query() function
+---------------------------------------------------
+
+The ``Inbound`` class provides support for inbound DNS zone transfers,
+both AXFR and IXFR.  It is invoked by I/O code, i.e.
+:py:func:`dns.query.inbound_xfr` or
+:py:func:`dns.asyncquery.inbound_xfr`.  When a message related to the
+transfer arrives, the I/O code calls the ``process_message()`` method
+which adds the content to the pending transaction.
+
+The ``make_query()`` function is used to making the query message for
+the query methods to use in more complex situations, e.g. with TSIG or
+EDNS.
+
+.. autoclass:: dns.xfr.Inbound
+   :members:
+
+.. autofunction:: dns.xfr.make_query
diff --git a/doc/manual.rst b/doc/manual.rst
index b82b7e1..ecf4167 100644
--- a/doc/manual.rst
+++ b/doc/manual.rst
@@ -15,3 +15,4 @@ Dnspython Manual
    async
    exceptions
    utilities
+   typing
diff --git a/doc/message-edns.rst b/doc/message-edns.rst
index 7317a02..b46cf54 100644
--- a/doc/message-edns.rst
+++ b/doc/message-edns.rst
@@ -32,3 +32,4 @@ will create a ``dns.edns.ECSOption`` object to represent it.
 .. autofunction:: dns.edns.get_option_class
 .. autofunction:: dns.edns.option_from_wire_parser
 .. autofunction:: dns.edns.option_from_wire
+.. autofunction:: dns.edns.register_type
diff --git a/doc/message-query.rst b/doc/message-query.rst
index 462f7b4..03c5031 100644
--- a/doc/message-query.rst
+++ b/doc/message-query.rst
@@ -3,7 +3,16 @@
 The dns.message.QueryMessage Class
 ----------------------------------
 
-The ``dns.update.QueryMessage`` class is used for ordinary DNS query messages.
+The ``dns.message.QueryMessage`` class is used for ordinary DNS query messages.
 
 .. autoclass:: dns.message.QueryMessage
    :members:
+
+The dns.message.ChainingResult Class
+------------------------------------
+
+Objects of the ``dns.message.ChainingResult`` class are returned by the
+``dns.message.QueryMessage.resolve_chaining()`` method.
+
+.. autoclass:: dns.message.ChainingResult
+   :members:
diff --git a/doc/name.rst b/doc/name.rst
index d97c041..117691d 100644
--- a/doc/name.rst
+++ b/doc/name.rst
@@ -29,9 +29,9 @@ order is the DNSSEC canonical ordering.  Relative names always sort before
 absolute names.
 
 Names may also be compared according to the DNS tree hierarchy with
-the ``fullcompare()`` method.  For example ```www.dnspython.org.`` is
-a subdomain of ``dnspython.org.``.  See the method description for
-full details.
+the :py:func:`dns.name.Name.fullcompare` method.  For example
+```www.dnspython.org.`` is a subdomain of ``dnspython.org.``.  See the
+method description for full details.
 
 .. toctree::
 
diff --git a/doc/query.rst b/doc/query.rst
index 08940b4..0fe3ccb 100644
--- a/doc/query.rst
+++ b/doc/query.rst
@@ -41,4 +41,11 @@ HTTPS
 Zone Transfers
 --------------
 
+As of dnspython 2.1, :py:func:`dns.query.xfr` is deprecated.  Please use
+:py:func:`dns.query.inbound_xfr` instead.
+
+.. autoclass:: dns.query.UDPMode
+
+.. autofunction:: dns.query.inbound_xfr
+
 .. autofunction:: dns.query.xfr
diff --git a/doc/rdata-subclasses.rst b/doc/rdata-subclasses.rst
index 347610e..17a537a 100644
--- a/doc/rdata-subclasses.rst
+++ b/doc/rdata-subclasses.rst
@@ -433,6 +433,25 @@ Rdata Subclass Reference
 
       A ``dns.name.Name``, the exchange name.
       
+.. autoclass:: dns.rdtypes.ANY.SMIMEA.SMIMEA
+   :members:
+
+   .. attribute:: usage
+
+      An ``int``, the certificate usage.
+
+   .. attribute:: selector
+
+      An ``int``, the selector.
+
+   .. attribute:: mtype
+
+      An ``int``, the matching type.
+
+   .. attribute:: cert
+
+      A ``bytes``, the certificate association data.
+
 .. autoclass:: dns.rdtypes.ANY.SOA.SOA
    :members:
 
diff --git a/doc/rdatatype-list.rst b/doc/rdatatype-list.rst
index df21370..3a6f69e 100644
--- a/doc/rdatatype-list.rst
+++ b/doc/rdatatype-list.rst
@@ -1,5 +1,5 @@
 Rdatatypes
-==========
+----------
 
 .. py:data:: dns.rdatatype.A
    :annotation: = 1
@@ -9,6 +9,8 @@ Rdatatypes
    :annotation: = 28
 .. py:data:: dns.rdatatype.AFSDB
    :annotation: = 18
+.. py:data:: dns.rdatatype.AMTRELAY
+   :annotation: = 259
 .. py:data:: dns.rdatatype.ANY
    :annotation: = 255
 .. py:data:: dns.rdatatype.APL
@@ -49,6 +51,8 @@ Rdatatypes
    :annotation: = 13
 .. py:data:: dns.rdatatype.HIP
    :annotation: = 55
+.. py:data:: dns.rdatatype.HTTPS
+   :annotation: = 65
 .. py:data:: dns.rdatatype.IPSECKEY
    :annotation: = 45
 .. py:data:: dns.rdatatype.ISDN
@@ -81,13 +85,13 @@ Rdatatypes
    :annotation: = 15
 .. py:data:: dns.rdatatype.NAPTR
    :annotation: = 35
-.. py:data:: dns.rdatatype.NONE
-   :annotation: = 0
+.. py:data:: dns.rdatatype.NINFO
+   :annotation: = 56
 .. py:data:: dns.rdatatype.NS
    :annotation: = 2
 .. py:data:: dns.rdatatype.NSAP
    :annotation: = 22
-.. py:data:: dns.rdatatype.NSAP-PTR
+.. py:data:: dns.rdatatype.NSAP_PTR
    :annotation: = 23
 .. py:data:: dns.rdatatype.NSEC
    :annotation: = 47
@@ -99,6 +103,8 @@ Rdatatypes
    :annotation: = 10
 .. py:data:: dns.rdatatype.NXT
    :annotation: = 30
+.. py:data:: dns.rdatatype.OPENPGPKEY
+   :annotation: = 61
 .. py:data:: dns.rdatatype.OPT
    :annotation: = 41
 .. py:data:: dns.rdatatype.PTR
@@ -113,6 +119,8 @@ Rdatatypes
    :annotation: = 21
 .. py:data:: dns.rdatatype.SIG
    :annotation: = 24
+.. py:data:: dns.rdatatype.SMIMEA
+   :annotation: = 53
 .. py:data:: dns.rdatatype.SOA
    :annotation: = 6
 .. py:data:: dns.rdatatype.SPF
@@ -121,6 +129,8 @@ Rdatatypes
    :annotation: = 33
 .. py:data:: dns.rdatatype.SSHFP
    :annotation: = 44
+.. py:data:: dns.rdatatype.SVCB
+   :annotation: = 64
 .. py:data:: dns.rdatatype.TA
    :annotation: = 32768
 .. py:data:: dns.rdatatype.TKEY
@@ -131,6 +141,8 @@ Rdatatypes
    :annotation: = 250
 .. py:data:: dns.rdatatype.TXT
    :annotation: = 16
+.. py:data:: dns.rdatatype.TYPE0
+   :annotation: = 0
 .. py:data:: dns.rdatatype.UNSPEC
    :annotation: = 103
 .. py:data:: dns.rdatatype.URI
diff --git a/doc/resolver-caching.rst b/doc/resolver-caching.rst
index f39b8ad..c2150e8 100644
--- a/doc/resolver-caching.rst
+++ b/doc/resolver-caching.rst
@@ -11,7 +11,12 @@ the data, and will not return expired entries.
 
 Two thread-safe cache implementations are provided, a simple
 dictionary-based Cache, and an LRUCache which provides cache size
-control suitable for use in web crawlers.
+control suitable for use in web crawlers.  Both are subclasses of
+a common base class which provides basic statistics.  The LRUCache can
+also provide a hits count per cache entry.
+
+.. autoclass:: dns.resolver.CacheBase
+   :members:
 
 .. autoclass:: dns.resolver.Cache
    :members:
@@ -19,3 +24,5 @@ control suitable for use in web crawlers.
 .. autoclass:: dns.resolver.LRUCache
    :members:
 
+.. autoclass:: dns.resolver.CacheStatistics
+   :members:
diff --git a/doc/resolver-functions.rst b/doc/resolver-functions.rst
index 6e57957..179484c 100644
--- a/doc/resolver-functions.rst
+++ b/doc/resolver-functions.rst
@@ -5,6 +5,7 @@ Resolver Functions and The Default Resolver
 
 .. autofunction:: dns.resolver.resolve
 .. autofunction:: dns.resolver.resolve_address
+.. autofunction:: dns.resolver.canonical_name
 .. autofunction:: dns.resolver.zone_for_name
 .. autofunction:: dns.resolver.query
 .. autodata:: dns.resolver.default_resolver
diff --git a/doc/rfc.rst b/doc/rfc.rst
index 257790c..6f11a54 100644
--- a/doc/rfc.rst
+++ b/doc/rfc.rst
@@ -42,7 +42,7 @@ Core RFCs
     Negative Caching.
 
 `RFC 2845 <https://tools.ietf.org/html/rfc2845>`_
-    Transaction Sigatures (TSIG)
+    Transaction Signatures (TSIG)
 
 `RFC 3007 <https://tools.ietf.org/html/rfc3007>`_
     Dynamic Updates
@@ -141,6 +141,8 @@ PTR
     `RFC 1035 <https://tools.ietf.org/html/rfc1035>`_
 RRSIG
     `RFC 4034 <https://tools.ietf.org/html/rfc4034>`_
+SMIMEA
+    `RFC 8162 <https://tools.ietf.org/html/rfc8162>`_
 SOA
     `RFC 1035 <https://tools.ietf.org/html/rfc1035>`_
 SPF
diff --git a/doc/typing.rst b/doc/typing.rst
new file mode 100644
index 0000000..1325f10
--- /dev/null
+++ b/doc/typing.rst
@@ -0,0 +1,10 @@
+.. _typing:
+
+A Note on Typing
+----------------
+
+Dnspython has partial support for type annotations in separate .pyi
+files.  Type information will not be integrated into the main files
+until major LTS versions of various Linux distributions containing 3.6
+are beyond their support times.  Improvements to the .pyi files are
+welcome during this time.
diff --git a/doc/whatsnew.rst b/doc/whatsnew.rst
index dd7e48d..99f0492 100644
--- a/doc/whatsnew.rst
+++ b/doc/whatsnew.rst
@@ -1,7 +1,52 @@
 .. _whatsnew:
 
-What's New in dnspython 2.0.0
-=============================
+What's New in dnspython
+=======================
+
+2.2.0
+----------------------
+
+Nothing yet!
+
+2.1.0
+----------------------
+
+* End-of-line comments are now associated with rdata when read from text.
+  For backwards compatibility with prior versions of dnspython, they are
+  only emitted in to_text() when requested.
+
+* Synchronous I/O is a bit more efficient, as we now try the I/O and only
+  use poll() or select() if the I/O would block.
+
+* The resolver cache classes now offer basic hit and miss statistics, and
+  the LRUCache can also provide hits for every cache key.
+
+* The resolver has a canonical_name() method.
+
+* There is now a registration mechanism for EDNS option types.
+
+* The default EDNS payload size has changed from 1280 to 1232.
+
+* The SVCB, HTTPS, and SMIMEA RR types are now supported.
+
+* TSIG has been enhanced with TKEY and GSS-TSIG support.  Thanks to
+  Nick Hall for writing this.
+
+* Zones now can be updated via transactions.
+
+* A new zone subclass, dns.versioned.Zone is available which has a
+  thread-safe transaction implementation and support for keeping many
+  versions of a zone.
+
+* The zone file reading code has been adapted to use transactions, and
+  is now a public API.
+
+* Inbound zone transfer support has been rewritten and is available as
+  dns.query.inbound_xfr() and dns.asyncquery.inbound_xfr().  It uses
+  the transaction mechanism, and fully supports IXFR and AXFR.
+
+2.0.0
+-----
 
 * Python 3.6 or newer is required.
 
diff --git a/doc/zone-class.rst b/doc/zone-class.rst
index b4d9869..554922d 100644
--- a/doc/zone-class.rst
+++ b/doc/zone-class.rst
@@ -3,6 +3,11 @@
 The dns.zone.Zone Class
 -----------------------
 
+The ``Zone`` class provides a non-thread-safe implementation of a DNS zone,
+as well as a lightweight translation mechanism that allows it to be atomically
+updated.  For more complicated transactional needs, or for concurrency, please
+use the :py:class:`dns.versioned.Zone` class (described below).
+
 .. autoclass:: dns.zone.Zone
    :members:
       
@@ -28,3 +33,76 @@ create new nodes and defaults to ``dns.node.Node``.  ``Zone`` may be
 subclassed if a different node factory is desired.
 The node factory is a class or callable that returns a subclass of
 ``dns.node.Node``.
+
+
+The dns.versioned.Zone Class
+----------------------------
+
+A versioned Zone is a subclass of ``Zone`` that provides a thread-safe
+multiversioned transactional API.  There can be many concurrent
+readers, of possibly different versions, and at most one active
+writer.  Others cannot see the changes being made by the writer until
+it commits.  Versions are immutable once committed.
+
+The read-only parts of the standard zone API continue to be available, and
+are equivalent to doing a single-query read-only transaction.  Note that
+unless reading is done through a transaction, version stability is not
+guaranteed between successive calls.  Attempts to use zone API methods
+that directly manipulate the zone, e.g. ``replace_rdataset`` will result
+in a ``UseTransaction`` exception.
+
+Transactions are context managers, and are created with ``reader()`` or
+``writer()``.  For example:
+
+::
+
+   # Print the SOA serial number of the most recent version
+   with zone.reader() as txn:
+       rdataset = txn.get('@', 'in', 'soa')
+       print('The most recent serial number is', rdataset[0].serial)
+
+   # Write an A RR and increment the SOA serial number to the next value.
+   with zone.writer() as txn:
+       txn.replace('node1', dns.rdataset.from_text('in', 'a', 300,
+                   '10.0.0.1'))
+       txn.set_serial()
+
+See below for more information on the ``Transaction`` API.
+       
+.. autoclass:: dns.versioned.Zone
+   :exclude-members: delete_node, delete_rdataset, replace_rdataset
+   :members:
+      
+   .. attribute:: rdclass
+
+      The zone's rdata class, an ``int``; the default is class IN.
+
+   .. attribute:: origin
+
+      The origin of the zone, a ``dns.name.Name``.
+
+   .. attribute:: nodes
+                   
+   A dictionary mapping the names of nodes in the zone to the nodes
+   themselves.
+   
+   .. attribute:: relativize
+
+   A ``bool``, which is ``True`` if names in the zone should be relativized.
+
+
+The TransactionManager Class
+----------------------------
+
+This is the abstract base class of all objects that support transactions.
+
+.. autoclass:: dns.transaction.TransactionManager
+   :members:
+
+
+The Transaction Class
+---------------------
+
+.. autoclass:: dns.transaction.Transaction
+   :members:
+   
diff --git a/doc/zone.rst b/doc/zone.rst
index 777f08b..17d9e9d 100644
--- a/doc/zone.rst
+++ b/doc/zone.rst
@@ -8,3 +8,4 @@ DNS Zones
 
    zone-class
    zone-make
+   inbound-xfr-class
diff --git a/examples/trio.py b/examples/async_dns.py
old mode 100755
new mode 100644
similarity index 57%
rename from examples/trio.py
rename to examples/async_dns.py
index 4f65e44..c42defc
--- a/examples/trio.py
+++ b/examples/async_dns.py
@@ -1,10 +1,11 @@
 
 import sys
+
 import trio
 
 import dns.message
-import dns.trio.query
-import dns.trio.resolver
+import dns.asyncquery
+import dns.asyncresolver
 
 async def main():
     if len(sys.argv) > 1:
@@ -12,17 +13,17 @@ async def main():
     else:
         host = 'www.dnspython.org'
     q = dns.message.make_query(host, 'A')
-    r = await dns.trio.query.udp(q, '8.8.8.8')
+    r = await dns.asyncquery.udp(q, '8.8.8.8')
     print(r)
     q = dns.message.make_query(host, 'A')
-    r = await dns.trio.query.stream(q, '8.8.8.8')
+    r = await dns.asyncquery.tcp(q, '8.8.8.8')
     print(r)
     q = dns.message.make_query(host, 'A')
-    r = await dns.trio.query.stream(q, '8.8.8.8', tls=True)
+    r = await dns.asyncquery.tls(q, '8.8.8.8')
     print(r)
-    a = await dns.trio.resolver.resolve(host, 'A')
+    a = await dns.asyncresolver.resolve(host, 'A')
     print(a.response)
-    zn = await dns.trio.resolver.zone_for_name(host)
+    zn = await dns.asyncresolver.zone_for_name(host)
     print(zn)
 
 if __name__ == '__main__':
diff --git a/examples/doh.py b/examples/doh.py
index eff9ae7..e789bf1 100755
--- a/examples/doh.py
+++ b/examples/doh.py
@@ -3,7 +3,7 @@
 # This is an example of sending DNS queries over HTTPS (DoH) with dnspython.
 # Requires use of the requests module's Session object.
 #
-# See https://2.python-requests.org//en/latest/user/advanced/#session-objects
+# See https://2.python-requests.org/en/latest/user/advanced/#session-objects
 # for more details about Session objects
 import requests
 
diff --git a/examples/mx.py b/examples/mx.py
index c8eaa10..2c310ea 100755
--- a/examples/mx.py
+++ b/examples/mx.py
@@ -2,6 +2,6 @@
 
 import dns.resolver
 
-answers = dns.resolver.query('nominum.com', 'MX')
+answers = dns.resolver.resolve('nominum.com', 'MX')
 for rdata in answers:
     print('Host', rdata.exchange, 'has preference', rdata.preference)
diff --git a/examples/query_specific.py b/examples/query_specific.py
index 5d8aeb9..f0121fb 100644
--- a/examples/query_specific.py
+++ b/examples/query_specific.py
@@ -31,7 +31,7 @@ import dns.resolver
 
 resolver = dns.resolver.Resolver(configure=False)
 resolver.nameservers = ['8.8.8.8']
-answer = resolver.query('amazon.com', 'NS')
+answer = resolver.resolve('amazon.com', 'NS')
 print('The nameservers are:')
 for rr in answer:
     print(rr.target)
diff --git a/examples/xfr.py b/examples/xfr.py
index 41731e3..a20cae3 100755
--- a/examples/xfr.py
+++ b/examples/xfr.py
@@ -4,8 +4,8 @@ import dns.query
 import dns.resolver
 import dns.zone
 
-soa_answer = dns.resolver.query('dnspython.org', 'SOA')
-master_answer = dns.resolver.query(soa_answer[0].mname, 'A')
+soa_answer = dns.resolver.resolve('dnspython.org', 'SOA')
+master_answer = dns.resolver.resolve(soa_answer[0].mname, 'A')
 
 z = dns.zone.from_xfr(dns.query.xfr(master_answer[0].address, 'dnspython.org'))
 for n in sorted(z.nodes.keys()):
diff --git a/pylintrc b/pylintrc
index 2a7f718..809d654 100644
--- a/pylintrc
+++ b/pylintrc
@@ -9,7 +9,6 @@ jobs=0
 
 enable=
     all,
-    python3
 
 # It's worth looking at len-as-condition for optimization, but it's disabled
 # here as it is not a correctness thing.  Similarly eq-without-hash is
@@ -18,36 +17,26 @@ enable=
 disable=
     R,
     I,
-    anomalous-backslash-in-string,
-    arguments-differ,
-    assigning-non-slot,
-    bad-builtin,
-    bad-continuation,
     broad-except,
-    deprecated-method,
     fixme,
     global-statement,
     invalid-name,
-    missing-docstring,
+    missing-module-docstring,
+    missing-class-docstring,
+    missing-function-docstring,
     no-absolute-import,
-    no-member,
+    no-member,  # We'd like to use this, but our immutability is too hard for it
     protected-access,
     redefined-builtin,
     too-many-lines,
-    unused-argument,
-    unused-variable,
-    wrong-import-order,
-    wrong-import-position,
-    len-as-condition,
-    eq-without-hash,
-    next-method-defined,
 
 [REPORTS]
 
 # Set the output format. Available formats are text, parseable, colorized, msvs
 # (visual studio) and html. You can also give a reporter class, eg
 # mypackage.mymodule.MyReporterClass.
-output-format=colorized
+#output-format=colorized
+output-format=parseable
 
 # Tells whether to display a full report or only the messages
 reports=no
diff --git a/pyproject.toml b/pyproject.toml
index 053b79c..e3c228a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "dnspython"
-version = "2.0.0"
+version = "2.2.0"
 description = "DNS toolkit"
 authors = ["Bob Halley <halley@dnspython.org>"]
 license = "ISC"
@@ -13,20 +13,20 @@ python = "^3.6"
 requests-toolbelt = {version="^0.9.1", optional=true}
 requests = {version="^2.23.0", optional=true}
 idna = {version="^2.1", optional=true}
-cryptography = {version="^2.6", optional=true}
-trio = {version=">=0.14,<0.17", optional=true}
+cryptography = {version=">=2.6,<4.0", optional=true}
+trio = {version=">=0.14,<0.19", optional=true}
 curio = {version="^1.2", optional=true}
 sniffio = {version="^1.1", optional=true}
 
 [tool.poetry.dev-dependencies]
-mypy = "^0.782"
-pytest = "^5.4.1"
+mypy = "^0.812"
+pytest = ">=5.4.1,<7"
 pytest-cov = "^2.10.0"
 flake8 = "^3.7.9"
 sphinx = "^3.0.0"
 coverage = "^5.1"
 twine = "^3.1.1"
-wheel = "^0.34.2"
+wheel = "^0.35.0"
 
 [tool.poetry.extras]
 doh = ['requests', 'requests-toolbelt']
@@ -36,5 +36,7 @@ trio = ['trio']
 curio = ['curio', 'sniffio']
 
 [build-system]
-requires = ["poetry>=0.12"]
-build-backend = "poetry.masonry.api"
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.setuptools_scm]
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index 0c9e0fc..e8ee3df 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,2 +1,59 @@
 [metadata]
+name = dnspython
+author = Bob Halley
+author_email = halley@dnspython.org
+license = ISC
 license_file = LICENSE
+description = DNS toolkit
+url = http://www.dnspython.org
+long_description = dnspython is a DNS toolkit for Python. It supports almost all
+	record types. It can be used for queries, zone transfers, and dynamic
+	updates.  It supports TSIG authenticated messages and EDNS0.
+	
+	dnspython provides both high and low level access to DNS. The high
+	level classes perform queries for data of a given name, type, and
+	class, and return an answer set.  The low level classes allow
+	direct manipulation of DNS zones, messages, names, and records.
+long_description_content_type = text/plain
+classifiers = 
+	Development Status :: 5 - Production/Stable
+	Intended Audience :: Developers
+	Intended Audience :: System Administrators
+	License :: OSI Approved :: ISC License (ISCL)
+	Operating System :: POSIX
+	Operating System :: Microsoft :: Windows
+	Programming Language :: Python
+	Topic :: Internet :: Name Service (DNS)
+	Topic :: Software Development :: Libraries :: Python Modules
+	Programming Language :: Python :: 3
+	Programming Language :: Python :: 3.6
+	Programming Language :: Python :: 3.7
+	Programming Language :: Python :: 3.8
+	Programming Language :: Python :: 3.9
+provides = dns
+
+[options]
+packages = 
+	dns
+	dns.rdtypes
+	dns.rdtypes.IN
+	dns.rdtypes.ANY
+	dns.rdtypes.CH
+python_requires = >=3.6
+test_suite = tests
+setup_requires = setuptools>=44; wheel; setuptools_scm[toml]>=3.4.3
+
+[options.extras_require]
+doh = requests; requests-toolbelt
+idna = idna>=2.1
+dnssec = cryptography>=2.6
+trio = trio>=0.14.0; sniffio>=1.1
+curio = curio>=1.2; sniffio>=1.1
+
+[options.package_data]
+dns = py.typed
+
+[egg_info]
+tag_build = 
+tag_date = 0
+
diff --git a/setup.py b/setup.py
index cd1be21..42e794b 100755
--- a/setup.py
+++ b/setup.py
@@ -20,12 +20,11 @@
 import sys
 from setuptools import setup
 
-version = '2.0.0'
 
 try:
-    sys.argv.remove("--cython-compile")
+   sys.argv.remove("--cython-compile")
 except ValueError:
-    compile_cython = False
+   compile_cython = False
 else:
     compile_cython = True
     from Cython.Build import cythonize
@@ -33,52 +32,6 @@ else:
                             language_level='3')
 
 kwargs = {
-    'name' : 'dnspython',
-    'version' : version,
-    'description' : 'DNS toolkit',
-    'long_description' : \
-    """dnspython is a DNS toolkit for Python. It supports almost all
-record types. It can be used for queries, zone transfers, and dynamic
-updates.  It supports TSIG authenticated messages and EDNS0.
-
-dnspython provides both high and low level access to DNS. The high
-level classes perform queries for data of a given name, type, and
-class, and return an answer set.  The low level classes allow
-direct manipulation of DNS zones, messages, names, and records.""",
-    'author' : 'Bob Halley',
-    'author_email' : 'halley@dnspython.org',
-    'license' : 'ISC',
-    'url' : 'http://www.dnspython.org',
-    'packages' : ['dns', 'dns.rdtypes', 'dns.rdtypes.IN', 'dns.rdtypes.ANY',
-                  'dns.rdtypes.CH'],
-    'package_data' : {'dns': ['py.typed']},
-    'download_url' : \
-    'http://www.dnspython.org/kits/{}/dnspython-{}.tar.gz'.format(version, version),
-    'classifiers' : [
-        "Development Status :: 5 - Production/Stable",
-        "Intended Audience :: Developers",
-        "Intended Audience :: System Administrators",
-        "License :: OSI Approved :: ISC License (ISCL)",
-        "Operating System :: POSIX",
-        "Operating System :: Microsoft :: Windows",
-        "Programming Language :: Python",
-        "Topic :: Internet :: Name Service (DNS)",
-        "Topic :: Software Development :: Libraries :: Python Modules",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        ],
-    'python_requires': '>=3.6',
-    'test_suite': 'tests',
-    'provides': ['dns'],
-    'extras_require': {
-        'DOH': ['requests', 'requests-toolbelt'],
-        'IDNA': ['idna>=2.1'],
-        'DNSSEC': ['cryptography>=2.6'],
-        'trio': ['trio>=0.14.0', 'sniffio>=1.1'],
-        'curio': ['curio>=1.2', 'sniffio>=1.1'],
-        },
     'ext_modules': ext_modules if compile_cython else None,
     'zip_safe': False if compile_cython else None,
     }
diff --git a/tests/example b/tests/example
index 2cf5878..86af9dd 100644
--- a/tests/example
+++ b/tests/example
@@ -79,19 +79,19 @@ hinfo01			HINFO	"Generic PC clone" "NetBSD-1.4"
 hinfo02			HINFO	"PC" "NetBSD"
 isdn01			ISDN	"isdn-address"
 isdn02			ISDN	"isdn-address" "subaddress"
-isdn03			ISDN	"isdn-address"
-isdn04			ISDN	"isdn-address" "subaddress"
+isdn03			ISDN	isdn-address
+isdn04			ISDN	isdn-address subaddress
 ;key01			KEY	512 255 1 AQMFD5raczCJHViKtLYhWGz8hMY9UGRuniJDBzC7w0aR yzWZriO6i2odGWWQVucZqKVsENW91IOW4vqudngPZsY3 GvQ/xVA8/7pyFj6b7Esga60zyGW6LFe9r8n6paHrlG5o jqf0BaqHT+8= 
 ;key02			KEY	HOST|FLAG4 DNSSEC RSAMD5 AQMFD5raczCJHViKtLYhWGz8hMY9UGRuniJDBzC7w0aR yzWZriO6i2odGWWQVucZqKVsENW91IOW4vqudngPZsY3 GvQ/xVA8/7pyFj6b7Esga60zyGW6LFe9r8n6paHrlG5o jqf0BaqHT+8= 
 kx01			KX	10 kdc
 kx02			KX	10 .
-loc01			LOC	60 9 0.000 N 24 39 0.000 E 10.00m 20m 2000m 20m
-loc02			LOC	60 9 0.000 N 24 39 0.000 E 10.00m 20m 2000m 20m
+loc01			LOC	60 9 N 24 39 E 10 20 2000 20
+loc02			LOC	60 09 00.000 N 24 39 00.000 E 10.00m 20.00m 2000.00m 20.00m
 loc03			LOC	60 9 0.000 N 24 39 0.000 E 10.00m 90000000.00m 2000m 20m
 loc04			LOC	60 9 1.5 N 24 39 0.000 E 10.00m 90000000.00m 2000m 20m
 loc05			LOC	60 9 1.51 N 24 39 0.000 E 10.00m 90000000.00m 2000m 20m
 loc06			LOC	60 9 1 N 24 39 0.000 E 10.00m 90000000.00m 2000m 20m
-loc07			LOC	0 9 1 N 24 39 0.000 E 10.00m 90000000.00m 2000m 20m
+loc07			LOC	0 9 1 N 24 39 E 10 90000000 2000 20
 loc08			LOC	0 9 1 S 24 39 0.000 E 10.00m 90000000.00m 2000m 20m
 ;;
 ;; XXXRTH  These are all obsolete and unused.  dnspython doesn't implement
@@ -138,6 +138,9 @@ $TTL 3600	; 1 hour
 tlsa1			TLSA    3 1 1 a9cdf989b504fe5dca90c0d2167b6550570734f7c763e09fdf88904e06157065
 tlsa2			TLSA    1 0 1 efddf0d915c7bdc5782c0881e1b2a95ad099fbdd06d7b1f77982d9364338d955
 tlsa3			TLSA    1 0 2 81ee7f6c0ecc6b09b7785a9418f54432de630dd54dc6ee9e3c49de547708d236d4c413c3e97e44f969e635958aa410495844127c04883503e5b024cf7a8f6a94
+smimea1			SMIMEA    3 1 1 a9cdf989b504fe5dca90c0d2167b6550570734f7c763e09fdf88904e06157065
+smimea2			SMIMEA    1 0 1 efddf0d915c7bdc5782c0881e1b2a95ad099fbdd06d7b1f77982d9364338d955
+smimea3			SMIMEA    1 0 2 81ee7f6c0ecc6b09b7785a9418f54432de630dd54dc6ee9e3c49de547708d236d4c413c3e97e44f969e635958aa410495844127c04883503e5b024cf7a8f6a94
 txt01			TXT	"foo"
 txt02			TXT	"foo" "bar"
 txt03			TXT	foo
@@ -165,6 +168,7 @@ wks02			WKS	10.0.0.1 17 ( 0 1 2 53 )
 wks03			WKS	10.0.0.2 6 ( 65535 )
 x2501			X25	"123456789"
 ds01			DS	12345 3 1 123456789abcdef67890123456789abcdef67890
+dlv01			DLV	12345 3 1 123456789abcdef67890123456789abcdef67890
 apl01			APL	1:192.168.32.0/21 !1:192.168.38.0/28
 apl02			APL	1:224.0.0.0/4 2:FF00:0:0:0:0:0:0:0/8
 unknown2		TYPE999	\# 8 0a0000010a000001
@@ -232,3 +236,13 @@ amtrelay04              AMTRELAY  10 0 2 2001:db8::15
 amtrelay05              AMTRELAY 128 1 3 amtrelays.example.com.
 csync0			CSYNC 12345 0 A MX RRSIG NSEC TYPE1234
 avc01			AVC "app-name:WOLFGANG|app-class:OAM|business=yes"
+zonemd01                ZONEMD 2018031900 1 1 62e6cf51b02e54b9 b5f967d547ce4313 6792901f9f88e637 493daaf401c92c27 9dd10f0edb1c56f8 080211f8480ee306
+zonemd02                ZONEMD 2018031900 1 2 08cfa1115c7b948c 4163a901270395ea 226a930cd2cbcf2f a9a5e6eb85f37c8a 4e114d884e66f176 eab121cb02db7d65 2e0cc4827e7a3204 f166b47e5613fd27
+zonemd03                ZONEMD 2018031900 1 240 e2d523f654b9422a 96c5a8f44607bbee
+zonemd04                ZONEMD 2018031900 241 1 e1846540e33a9e41 89792d18d5d131f6 05fc283e aaaaaaaa aaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaa aaaaaaaaaaaaaaa
+svcb01                  SVCB (
+100 foo.com. mandatory="alpn,port" alpn="h2,h3" no-default-alpn port="12345"
+echconfig="abcd" ipv4hint=1.2.3.4,4.3.2.1 ipv6hint=1::2,3::4 key12345="foo"
+)
+https01                 HTTPS 0 svc
+https02                 HTTPS 1 . port=8002 echconfig="abcd"
diff --git a/tests/example1.good b/tests/example1.good
index beb57af..c1ddfd4 100644
--- a/tests/example1.good
+++ b/tests/example1.good
@@ -39,6 +39,7 @@ d 300 IN A 73.80.65.49
 dhcid01 3600 IN DHCID AAIBY2/AuCccgoJbsaxcQc9TUapptP69 lOjxfNuVAA2kjEA=
 dhcid02 3600 IN DHCID AAEBOSD+XR3Os/0LozeXVqcNc7FwCfQd WL3b/NaiUDlW2No=
 dhcid03 3600 IN DHCID AAABxLmlskllE0MVjd57zHcWmEH3pCQ6 VytcKD//7es/deY=
+dlv01 3600 IN DLV 12345 3 1 123456789abcdef67890123456789abcdef67890
 dname01 3600 IN DNAME dname-target.
 dname02 3600 IN DNAME dname-target
 dname03 3600 IN DNAME .
@@ -60,6 +61,8 @@ hinfo02 3600 IN HINFO "PC" "NetBSD"
 hip01 3600 IN HIP 2 200100107b1a74df365639cc39f1d578 AwEAAbdxyhNuSutc5EMzxTs9LBPCIkOFH8cIvM4p9+LrV4e19WzK00+CI6zBCQTdtWsuxKbWIy87UOoJTwkUs7lBu+Upr1gsNrut79ryra+bSRGQb1slImA8YVJyuIDsj7kwzG7jnERNqnWxZ48AWkskmdHaVDP4BcelrTI3rMXdXF5D
 hip02 3600 IN HIP 2 200100107b1a74df365639cc39f1d578 AwEAAbdxyhNuSutc5EMzxTs9LBPCIkOFH8cIvM4p9+LrV4e19WzK00+CI6zBCQTdtWsuxKbWIy87UOoJTwkUs7lBu+Upr1gsNrut79ryra+bSRGQb1slImA8YVJyuIDsj7kwzG7jnERNqnWxZ48AWkskmdHaVDP4BcelrTI3rMXdXF5D rvs.example.com.
 hip03 3600 IN HIP 2 200100107b1a74df365639cc39f1d578 AwEAAbdxyhNuSutc5EMzxTs9LBPCIkOFH8cIvM4p9+LrV4e19WzK00+CI6zBCQTdtWsuxKbWIy87UOoJTwkUs7lBu+Upr1gsNrut79ryra+bSRGQb1slImA8YVJyuIDsj7kwzG7jnERNqnWxZ48AWkskmdHaVDP4BcelrTI3rMXdXF5D rvs1.example.com. rvs2.example.com.
+https01 3600 IN HTTPS 0 svc
+https02 3600 IN HTTPS 1 . port="8002" echconfig="abcd"
 ipseckey01 3600 IN IPSECKEY 10 1 2 192.0.2.38 AQNRU3mG7TVTO2BkR47usntb102uFJtu gbo6BSGvgqt4AQ==
 ipseckey02 3600 IN IPSECKEY 10 0 2 . AQNRU3mG7TVTO2BkR47usntb102uFJtu gbo6BSGvgqt4AQ==
 ipseckey03 3600 IN IPSECKEY 10 3 2 mygateway.example.com. AQNRU3mG7TVTO2BkR47usntb102uFJtu gbo6BSGvgqt4AQ==
@@ -94,7 +97,7 @@ nsec02 3600 IN NSEC . NSAP-PTR NSEC
 nsec03 3600 IN NSEC . NSEC TYPE65535
 nsec301 3600 IN NSEC3 1 1 12 aabbccdd 2t7b4g4vsa5smi47k61mv5bv1a22bojr NS SOA MX RRSIG DNSKEY NSEC3PARAM
 nsec302 3600 IN NSEC3 1 1 12 - 2t7b4g4vsa5smi47k61mv5bv1a22bojr NS SOA MX RRSIG DNSKEY NSEC3PARAM
-openpgpkey 3600 IN OPENPGPKEY mQENBEteQDsBCADYnatn9+5t43AdJlVk 9dZC2RM0idPQcmrrKcjeAWDnISqoJzkv Q8ifX6mefquTBsDZC279uXShyTffYzQt vP2r9ewkK7zmSv52Ar563TSULAMwiLpe 0gGQE0ex20mX5ggtYn6czdbEtcKpW0t+ AfDqRk5YcpgqfZKXapKQ+A3CwWJKP9i3 ldx2Jz//kuru4YqROLBYyB8D6V2jNUFO daP6j5C5prh9dxfYFp2O/xFeAKLWlWuH 9o96INUoIhgdEyj9PHPT3c821NMZu8tC vsZgUB+QPbHA/QYGa+aollcdGkJpVxXo Hhbu6aMx/B+pXg55WM5pqOxmoVjyViHI UYfPABEBAAG0IUJvYiBIYWxsZXkgPGhh bGxleUBkbnNweXRob24ub3JnPokBPgQT AQIAKAUCS15AOwIbAwUJA8JnAAYLCQgH AwIGFQgCCQoLBBYCAwECHgECF4AACgkQ 6o6Gb8yUnXaflQgAhlhIqZGncRw3LV3d 24JmPD+UEcEGiVh2b/Ic/1TMec46Ts7Z qRXAcOATNteQmpzqexx+BRKDWU8ZgYx1 2J4GZmC06jABr2JDWxgvbMX9qjkUUgDG ZZgAS/B2x5AmKgy2ZnCUlaKfePcKmtKT B9yNJ8v/WERlFdGaUveEUiFU8g75xp1H j9Wp9sXCg9yeG1K2RwQ3RQd5tLudhyE6 7EQdFGgqQFynR53md7cmVhAGopKLwMkp CtToKUlxxlfnDfpKZhhXThmhA0PsUQUk JptfGwYwH3O2N3KzfUw3wXRvLa3hona3 TlHk3kfg7Qyd7oP4AZGbJKp97YHnfqo1 kp8rObkBDQRLXkA7AQgA0ePG7g5GgZ/1 SdtGZlJJiE2X15vTUc3KGfmx/kI5NaUD u4fXb+XK+yFy9I/X+UJ46JSkyhj6QvUx poI+A7WWk9ThfjbynoZxRD820Kbqidqx BSgtFF36SRWzmX8DZfKKAskT9ZGU1ode SKDXLCJF7qAbZVRTuFRiDFGwtoVIICeE 6Xd65JO6ufhad+ELhgFt95vRwTiFvVrB RjwF7ZgN/nOXfYncxZ/2mpFqfwsnB2eu 0A2XZBm8IngsSmr/Wrz1RQ7+SNMqt77E 7CKwBX7UIAZgyoJxIRxWirJoOt1rIm5V UqRR25ubXLuzx9PaHYiC5GiQIU45pWAd 0IWcTI/MJQARAQABiQElBBgBAgAPBQJL XkA7AhsMBQkDwmcAAAoJEOqOhm/MlJ12 HRsIAKrB9E++9X9W6VTXBfdkShCFv0yk ZVn2eVs6tkqzoub9s4f+Z5ylWw+a5nkM DMdGVe6bn4A3oIAbf0Tjykq1AetZLVPs Hl/QosTbSQluis/PEvJkTQXHaKHB3bFh wA90c/3HNhrLGugt9AmcfLf9LAynXDgN LV5eYdPYqfKE+27qjEBARf6PYh/8WQ8C PKS8DILFbwCZbRxUogyrZf/7AiHAGdJi 8dmpR1WPQYef2hF3kqGX6NngLBPzZ6CQ RaHBhD4pHU1S/IRSlx9/3Ytww32PYD9A yO732NmCUcq3bmvqcOWy4Cc1NkEwU0Vg 0qzwVBNGb84v/ex2MouwtAYScwc=
+openpgpkey 3600 IN OPENPGPKEY mQENBEteQDsBCADYnatn9+5t43AdJlVk9dZC2RM0idPQcmrrKcjeAWDnISqoJzkvQ8ifX6mefquTBsDZC279uXShyTffYzQtvP2r9ewkK7zmSv52Ar563TSULAMwiLpe0gGQE0ex20mX5ggtYn6czdbEtcKpW0t+AfDqRk5YcpgqfZKXapKQ+A3CwWJKP9i3ldx2Jz//kuru4YqROLBYyB8D6V2jNUFOdaP6j5C5prh9dxfYFp2O/xFeAKLWlWuH9o96INUoIhgdEyj9PHPT3c821NMZu8tCvsZgUB+QPbHA/QYGa+aollcdGkJpVxXoHhbu6aMx/B+pXg55WM5pqOxmoVjyViHIUYfPABEBAAG0IUJvYiBIYWxsZXkgPGhhbGxleUBkbnNweXRob24ub3JnPokBPgQTAQIAKAUCS15AOwIbAwUJA8JnAAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ6o6Gb8yUnXaflQgAhlhIqZGncRw3LV3d24JmPD+UEcEGiVh2b/Ic/1TMec46Ts7ZqRXAcOATNteQmpzqexx+BRKDWU8ZgYx12J4GZmC06jABr2JDWxgvbMX9qjkUUgDGZZgAS/B2x5AmKgy2ZnCUlaKfePcKmtKTB9yNJ8v/WERlFdGaUveEUiFU8g75xp1Hj9Wp9sXCg9yeG1K2RwQ3RQd5tLudhyE67EQdFGgqQFynR53md7cmVhAGopKLwMkpCtToKUlxxlfnDfpKZhhXThmhA0PsUQUkJptfGwYwH3O2N3KzfUw3wXRvLa3hona3TlHk3kfg7Qyd7oP4AZGbJKp97YHnfqo1kp8rObkBDQRLXkA7AQgA0ePG7g5GgZ/1SdtGZlJJiE2X15vTUc3KGfmx/kI5NaUDu4fXb+XK+yFy9I/X+UJ46JSkyhj6QvUxpoI+A7WWk9ThfjbynoZxRD820KbqidqxBSgtFF36SRWzmX8DZfKKAskT9ZGU1odeSKDXLCJF7qAbZVRTuFRiDFGwtoVIICeE6Xd65JO6ufhad+ELhgFt95vRwTiFvVrBRjwF7ZgN/nOXfYncxZ/2mpFqfwsnB2eu0A2XZBm8IngsSmr/Wrz1RQ7+SNMqt77E7CKwBX7UIAZgyoJxIRxWirJoOt1rIm5VUqRR25ubXLuzx9PaHYiC5GiQIU45pWAd0IWcTI/MJQARAQABiQElBBgBAgAPBQJLXkA7AhsMBQkDwmcAAAoJEOqOhm/MlJ12HRsIAKrB9E++9X9W6VTXBfdkShCFv0ykZVn2eVs6tkqzoub9s4f+Z5ylWw+a5nkMDMdGVe6bn4A3oIAbf0Tjykq1AetZLVPsHl/QosTbSQluis/PEvJkTQXHaKHB3bFhwA90c/3HNhrLGugt9AmcfLf9LAynXDgNLV5eYdPYqfKE+27qjEBARf6PYh/8WQ8CPKS8DILFbwCZbRxUogyrZf/7AiHAGdJi8dmpR1WPQYef2hF3kqGX6NngLBPzZ6CQRaHBhD4pHU1S/IRSlx9/3Ytww32PYD9AyO732NmCUcq3bmvqcOWy4Cc1NkEwU0Vg0qzwVBNGb84v/ex2MouwtAYScwc=
 ptr01 3600 IN PTR @
 px01 3600 IN PX 65535 foo. bar.
 px02 3600 IN PX 65535 . .
@@ -106,10 +109,14 @@ rt01 3600 IN RT 0 intermediate-host
 rt02 3600 IN RT 65535 .
 s 300 IN NS ns.s
 ns.s 300 IN A 73.80.65.49
+smimea1 3600 IN SMIMEA 3 1 1 a9cdf989b504fe5dca90c0d2167b6550570734f7c763e09fdf88904e06157065
+smimea2 3600 IN SMIMEA 1 0 1 efddf0d915c7bdc5782c0881e1b2a95ad099fbdd06d7b1f77982d9364338d955
+smimea3 3600 IN SMIMEA 1 0 2 81ee7f6c0ecc6b09b7785a9418f54432de630dd54dc6ee9e3c49de547708d236d4c413c3e97e44f969e635958aa410495844127c04883503e5b024cf7a8f6a94
 spf 3600 IN SPF "v=spf1 mx -all"
 srv01 3600 IN SRV 0 0 0 .
 srv02 3600 IN SRV 65535 65535 65535 old-slow-box.example.com.
 sshfp1 3600 IN SSHFP 1 1 aa549bfe898489c02d1715d97d79c57ba2fa76ab
+svcb01 3600 IN SVCB 100 foo.com. mandatory="alpn,port" alpn="h2,h3" no-default-alpn port="12345" ipv4hint="1.2.3.4,4.3.2.1" echconfig="abcd" ipv6hint="1::2,3::4" key12345="foo"
 t 301 IN A 73.80.65.49
 tlsa1 3600 IN TLSA 3 1 1 a9cdf989b504fe5dca90c0d2167b6550570734f7c763e09fdf88904e06157065
 tlsa2 3600 IN TLSA 1 0 1 efddf0d915c7bdc5782c0881e1b2a95ad099fbdd06d7b1f77982d9364338d955
@@ -140,3 +147,7 @@ wks01 3600 IN WKS 10.0.0.1 6 0 1 2 21 23
 wks02 3600 IN WKS 10.0.0.1 17 0 1 2 53
 wks03 3600 IN WKS 10.0.0.2 6 65535
 x2501 3600 IN X25 "123456789"
+zonemd01 3600 IN ZONEMD 2018031900 1 1 62e6cf51b02e54b9b5f967d547ce43136792901f9f88e637493daaf401c92c279dd10f0edb1c56f8080211f8480ee306
+zonemd02 3600 IN ZONEMD 2018031900 1 2 08cfa1115c7b948c4163a901270395ea226a930cd2cbcf2fa9a5e6eb85f37c8a4e114d884e66f176eab121cb02db7d652e0cc4827e7a3204f166b47e5613fd27
+zonemd03 3600 IN ZONEMD 2018031900 1 240 e2d523f654b9422a96c5a8f44607bbee
+zonemd04 3600 IN ZONEMD 2018031900 241 1 e1846540e33a9e4189792d18d5d131f605fc283eaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
diff --git a/tests/example2.good b/tests/example2.good
index 75c787c..ac14e20 100644
--- a/tests/example2.good
+++ b/tests/example2.good
@@ -39,6 +39,7 @@ d.example. 300 IN A 73.80.65.49
 dhcid01.example. 3600 IN DHCID AAIBY2/AuCccgoJbsaxcQc9TUapptP69 lOjxfNuVAA2kjEA=
 dhcid02.example. 3600 IN DHCID AAEBOSD+XR3Os/0LozeXVqcNc7FwCfQd WL3b/NaiUDlW2No=
 dhcid03.example. 3600 IN DHCID AAABxLmlskllE0MVjd57zHcWmEH3pCQ6 VytcKD//7es/deY=
+dlv01.example. 3600 IN DLV 12345 3 1 123456789abcdef67890123456789abcdef67890
 dname01.example. 3600 IN DNAME dname-target.
 dname02.example. 3600 IN DNAME dname-target.example.
 dname03.example. 3600 IN DNAME .
@@ -60,6 +61,8 @@ hinfo02.example. 3600 IN HINFO "PC" "NetBSD"
 hip01.example. 3600 IN HIP 2 200100107b1a74df365639cc39f1d578 AwEAAbdxyhNuSutc5EMzxTs9LBPCIkOFH8cIvM4p9+LrV4e19WzK00+CI6zBCQTdtWsuxKbWIy87UOoJTwkUs7lBu+Upr1gsNrut79ryra+bSRGQb1slImA8YVJyuIDsj7kwzG7jnERNqnWxZ48AWkskmdHaVDP4BcelrTI3rMXdXF5D
 hip02.example. 3600 IN HIP 2 200100107b1a74df365639cc39f1d578 AwEAAbdxyhNuSutc5EMzxTs9LBPCIkOFH8cIvM4p9+LrV4e19WzK00+CI6zBCQTdtWsuxKbWIy87UOoJTwkUs7lBu+Upr1gsNrut79ryra+bSRGQb1slImA8YVJyuIDsj7kwzG7jnERNqnWxZ48AWkskmdHaVDP4BcelrTI3rMXdXF5D rvs.example.com.
 hip03.example. 3600 IN HIP 2 200100107b1a74df365639cc39f1d578 AwEAAbdxyhNuSutc5EMzxTs9LBPCIkOFH8cIvM4p9+LrV4e19WzK00+CI6zBCQTdtWsuxKbWIy87UOoJTwkUs7lBu+Upr1gsNrut79ryra+bSRGQb1slImA8YVJyuIDsj7kwzG7jnERNqnWxZ48AWkskmdHaVDP4BcelrTI3rMXdXF5D rvs1.example.com. rvs2.example.com.
+https01.example. 3600 IN HTTPS 0 svc.example.
+https02.example. 3600 IN HTTPS 1 . port="8002" echconfig="abcd"
 ipseckey01.example. 3600 IN IPSECKEY 10 1 2 192.0.2.38 AQNRU3mG7TVTO2BkR47usntb102uFJtu gbo6BSGvgqt4AQ==
 ipseckey02.example. 3600 IN IPSECKEY 10 0 2 . AQNRU3mG7TVTO2BkR47usntb102uFJtu gbo6BSGvgqt4AQ==
 ipseckey03.example. 3600 IN IPSECKEY 10 3 2 mygateway.example.com. AQNRU3mG7TVTO2BkR47usntb102uFJtu gbo6BSGvgqt4AQ==
@@ -94,7 +97,7 @@ nsec02.example. 3600 IN NSEC . NSAP-PTR NSEC
 nsec03.example. 3600 IN NSEC . NSEC TYPE65535
 nsec301.example. 3600 IN NSEC3 1 1 12 aabbccdd 2t7b4g4vsa5smi47k61mv5bv1a22bojr NS SOA MX RRSIG DNSKEY NSEC3PARAM
 nsec302.example. 3600 IN NSEC3 1 1 12 - 2t7b4g4vsa5smi47k61mv5bv1a22bojr NS SOA MX RRSIG DNSKEY NSEC3PARAM
-openpgpkey.example. 3600 IN OPENPGPKEY mQENBEteQDsBCADYnatn9+5t43AdJlVk 9dZC2RM0idPQcmrrKcjeAWDnISqoJzkv Q8ifX6mefquTBsDZC279uXShyTffYzQt vP2r9ewkK7zmSv52Ar563TSULAMwiLpe 0gGQE0ex20mX5ggtYn6czdbEtcKpW0t+ AfDqRk5YcpgqfZKXapKQ+A3CwWJKP9i3 ldx2Jz//kuru4YqROLBYyB8D6V2jNUFO daP6j5C5prh9dxfYFp2O/xFeAKLWlWuH 9o96INUoIhgdEyj9PHPT3c821NMZu8tC vsZgUB+QPbHA/QYGa+aollcdGkJpVxXo Hhbu6aMx/B+pXg55WM5pqOxmoVjyViHI UYfPABEBAAG0IUJvYiBIYWxsZXkgPGhh bGxleUBkbnNweXRob24ub3JnPokBPgQT AQIAKAUCS15AOwIbAwUJA8JnAAYLCQgH AwIGFQgCCQoLBBYCAwECHgECF4AACgkQ 6o6Gb8yUnXaflQgAhlhIqZGncRw3LV3d 24JmPD+UEcEGiVh2b/Ic/1TMec46Ts7Z qRXAcOATNteQmpzqexx+BRKDWU8ZgYx1 2J4GZmC06jABr2JDWxgvbMX9qjkUUgDG ZZgAS/B2x5AmKgy2ZnCUlaKfePcKmtKT B9yNJ8v/WERlFdGaUveEUiFU8g75xp1H j9Wp9sXCg9yeG1K2RwQ3RQd5tLudhyE6 7EQdFGgqQFynR53md7cmVhAGopKLwMkp CtToKUlxxlfnDfpKZhhXThmhA0PsUQUk JptfGwYwH3O2N3KzfUw3wXRvLa3hona3 TlHk3kfg7Qyd7oP4AZGbJKp97YHnfqo1 kp8rObkBDQRLXkA7AQgA0ePG7g5GgZ/1 SdtGZlJJiE2X15vTUc3KGfmx/kI5NaUD u4fXb+XK+yFy9I/X+UJ46JSkyhj6QvUx poI+A7WWk9ThfjbynoZxRD820Kbqidqx BSgtFF36SRWzmX8DZfKKAskT9ZGU1ode SKDXLCJF7qAbZVRTuFRiDFGwtoVIICeE 6Xd65JO6ufhad+ELhgFt95vRwTiFvVrB RjwF7ZgN/nOXfYncxZ/2mpFqfwsnB2eu 0A2XZBm8IngsSmr/Wrz1RQ7+SNMqt77E 7CKwBX7UIAZgyoJxIRxWirJoOt1rIm5V UqRR25ubXLuzx9PaHYiC5GiQIU45pWAd 0IWcTI/MJQARAQABiQElBBgBAgAPBQJL XkA7AhsMBQkDwmcAAAoJEOqOhm/MlJ12 HRsIAKrB9E++9X9W6VTXBfdkShCFv0yk ZVn2eVs6tkqzoub9s4f+Z5ylWw+a5nkM DMdGVe6bn4A3oIAbf0Tjykq1AetZLVPs Hl/QosTbSQluis/PEvJkTQXHaKHB3bFh wA90c/3HNhrLGugt9AmcfLf9LAynXDgN LV5eYdPYqfKE+27qjEBARf6PYh/8WQ8C PKS8DILFbwCZbRxUogyrZf/7AiHAGdJi 8dmpR1WPQYef2hF3kqGX6NngLBPzZ6CQ RaHBhD4pHU1S/IRSlx9/3Ytww32PYD9A yO732NmCUcq3bmvqcOWy4Cc1NkEwU0Vg 0qzwVBNGb84v/ex2MouwtAYScwc=
+openpgpkey.example. 3600 IN OPENPGPKEY mQENBEteQDsBCADYnatn9+5t43AdJlVk9dZC2RM0idPQcmrrKcjeAWDnISqoJzkvQ8ifX6mefquTBsDZC279uXShyTffYzQtvP2r9ewkK7zmSv52Ar563TSULAMwiLpe0gGQE0ex20mX5ggtYn6czdbEtcKpW0t+AfDqRk5YcpgqfZKXapKQ+A3CwWJKP9i3ldx2Jz//kuru4YqROLBYyB8D6V2jNUFOdaP6j5C5prh9dxfYFp2O/xFeAKLWlWuH9o96INUoIhgdEyj9PHPT3c821NMZu8tCvsZgUB+QPbHA/QYGa+aollcdGkJpVxXoHhbu6aMx/B+pXg55WM5pqOxmoVjyViHIUYfPABEBAAG0IUJvYiBIYWxsZXkgPGhhbGxleUBkbnNweXRob24ub3JnPokBPgQTAQIAKAUCS15AOwIbAwUJA8JnAAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ6o6Gb8yUnXaflQgAhlhIqZGncRw3LV3d24JmPD+UEcEGiVh2b/Ic/1TMec46Ts7ZqRXAcOATNteQmpzqexx+BRKDWU8ZgYx12J4GZmC06jABr2JDWxgvbMX9qjkUUgDGZZgAS/B2x5AmKgy2ZnCUlaKfePcKmtKTB9yNJ8v/WERlFdGaUveEUiFU8g75xp1Hj9Wp9sXCg9yeG1K2RwQ3RQd5tLudhyE67EQdFGgqQFynR53md7cmVhAGopKLwMkpCtToKUlxxlfnDfpKZhhXThmhA0PsUQUkJptfGwYwH3O2N3KzfUw3wXRvLa3hona3TlHk3kfg7Qyd7oP4AZGbJKp97YHnfqo1kp8rObkBDQRLXkA7AQgA0ePG7g5GgZ/1SdtGZlJJiE2X15vTUc3KGfmx/kI5NaUDu4fXb+XK+yFy9I/X+UJ46JSkyhj6QvUxpoI+A7WWk9ThfjbynoZxRD820KbqidqxBSgtFF36SRWzmX8DZfKKAskT9ZGU1odeSKDXLCJF7qAbZVRTuFRiDFGwtoVIICeE6Xd65JO6ufhad+ELhgFt95vRwTiFvVrBRjwF7ZgN/nOXfYncxZ/2mpFqfwsnB2eu0A2XZBm8IngsSmr/Wrz1RQ7+SNMqt77E7CKwBX7UIAZgyoJxIRxWirJoOt1rIm5VUqRR25ubXLuzx9PaHYiC5GiQIU45pWAd0IWcTI/MJQARAQABiQElBBgBAgAPBQJLXkA7AhsMBQkDwmcAAAoJEOqOhm/MlJ12HRsIAKrB9E++9X9W6VTXBfdkShCFv0ykZVn2eVs6tkqzoub9s4f+Z5ylWw+a5nkMDMdGVe6bn4A3oIAbf0Tjykq1AetZLVPsHl/QosTbSQluis/PEvJkTQXHaKHB3bFhwA90c/3HNhrLGugt9AmcfLf9LAynXDgNLV5eYdPYqfKE+27qjEBARf6PYh/8WQ8CPKS8DILFbwCZbRxUogyrZf/7AiHAGdJi8dmpR1WPQYef2hF3kqGX6NngLBPzZ6CQRaHBhD4pHU1S/IRSlx9/3Ytww32PYD9AyO732NmCUcq3bmvqcOWy4Cc1NkEwU0Vg0qzwVBNGb84v/ex2MouwtAYScwc=
 ptr01.example. 3600 IN PTR example.
 px01.example. 3600 IN PX 65535 foo. bar.
 px02.example. 3600 IN PX 65535 . .
@@ -106,10 +109,14 @@ rt01.example. 3600 IN RT 0 intermediate-host.example.
 rt02.example. 3600 IN RT 65535 .
 s.example. 300 IN NS ns.s.example.
 ns.s.example. 300 IN A 73.80.65.49
+smimea1.example. 3600 IN SMIMEA 3 1 1 a9cdf989b504fe5dca90c0d2167b6550570734f7c763e09fdf88904e06157065
+smimea2.example. 3600 IN SMIMEA 1 0 1 efddf0d915c7bdc5782c0881e1b2a95ad099fbdd06d7b1f77982d9364338d955
+smimea3.example. 3600 IN SMIMEA 1 0 2 81ee7f6c0ecc6b09b7785a9418f54432de630dd54dc6ee9e3c49de547708d236d4c413c3e97e44f969e635958aa410495844127c04883503e5b024cf7a8f6a94
 spf.example. 3600 IN SPF "v=spf1 mx -all"
 srv01.example. 3600 IN SRV 0 0 0 .
 srv02.example. 3600 IN SRV 65535 65535 65535 old-slow-box.example.com.
 sshfp1.example. 3600 IN SSHFP 1 1 aa549bfe898489c02d1715d97d79c57ba2fa76ab
+svcb01.example. 3600 IN SVCB 100 foo.com. mandatory="alpn,port" alpn="h2,h3" no-default-alpn port="12345" ipv4hint="1.2.3.4,4.3.2.1" echconfig="abcd" ipv6hint="1::2,3::4" key12345="foo"
 t.example. 301 IN A 73.80.65.49
 tlsa1.example. 3600 IN TLSA 3 1 1 a9cdf989b504fe5dca90c0d2167b6550570734f7c763e09fdf88904e06157065
 tlsa2.example. 3600 IN TLSA 1 0 1 efddf0d915c7bdc5782c0881e1b2a95ad099fbdd06d7b1f77982d9364338d955
@@ -140,3 +147,7 @@ wks01.example. 3600 IN WKS 10.0.0.1 6 0 1 2 21 23
 wks02.example. 3600 IN WKS 10.0.0.1 17 0 1 2 53
 wks03.example. 3600 IN WKS 10.0.0.2 6 65535
 x2501.example. 3600 IN X25 "123456789"
+zonemd01.example. 3600 IN ZONEMD 2018031900 1 1 62e6cf51b02e54b9b5f967d547ce43136792901f9f88e637493daaf401c92c279dd10f0edb1c56f8080211f8480ee306
+zonemd02.example. 3600 IN ZONEMD 2018031900 1 2 08cfa1115c7b948c4163a901270395ea226a930cd2cbcf2fa9a5e6eb85f37c8a4e114d884e66f176eab121cb02db7d652e0cc4827e7a3204f166b47e5613fd27
+zonemd03.example. 3600 IN ZONEMD 2018031900 1 240 e2d523f654b9422a96c5a8f44607bbee
+zonemd04.example. 3600 IN ZONEMD 2018031900 241 1 e1846540e33a9e4189792d18d5d131f605fc283eaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
diff --git a/tests/example3.good b/tests/example3.good
index beb57af..c1ddfd4 100644
--- a/tests/example3.good
+++ b/tests/example3.good
@@ -39,6 +39,7 @@ d 300 IN A 73.80.65.49
 dhcid01 3600 IN DHCID AAIBY2/AuCccgoJbsaxcQc9TUapptP69 lOjxfNuVAA2kjEA=
 dhcid02 3600 IN DHCID AAEBOSD+XR3Os/0LozeXVqcNc7FwCfQd WL3b/NaiUDlW2No=
 dhcid03 3600 IN DHCID AAABxLmlskllE0MVjd57zHcWmEH3pCQ6 VytcKD//7es/deY=
+dlv01 3600 IN DLV 12345 3 1 123456789abcdef67890123456789abcdef67890
 dname01 3600 IN DNAME dname-target.
 dname02 3600 IN DNAME dname-target
 dname03 3600 IN DNAME .
@@ -60,6 +61,8 @@ hinfo02 3600 IN HINFO "PC" "NetBSD"
 hip01 3600 IN HIP 2 200100107b1a74df365639cc39f1d578 AwEAAbdxyhNuSutc5EMzxTs9LBPCIkOFH8cIvM4p9+LrV4e19WzK00+CI6zBCQTdtWsuxKbWIy87UOoJTwkUs7lBu+Upr1gsNrut79ryra+bSRGQb1slImA8YVJyuIDsj7kwzG7jnERNqnWxZ48AWkskmdHaVDP4BcelrTI3rMXdXF5D
 hip02 3600 IN HIP 2 200100107b1a74df365639cc39f1d578 AwEAAbdxyhNuSutc5EMzxTs9LBPCIkOFH8cIvM4p9+LrV4e19WzK00+CI6zBCQTdtWsuxKbWIy87UOoJTwkUs7lBu+Upr1gsNrut79ryra+bSRGQb1slImA8YVJyuIDsj7kwzG7jnERNqnWxZ48AWkskmdHaVDP4BcelrTI3rMXdXF5D rvs.example.com.
 hip03 3600 IN HIP 2 200100107b1a74df365639cc39f1d578 AwEAAbdxyhNuSutc5EMzxTs9LBPCIkOFH8cIvM4p9+LrV4e19WzK00+CI6zBCQTdtWsuxKbWIy87UOoJTwkUs7lBu+Upr1gsNrut79ryra+bSRGQb1slImA8YVJyuIDsj7kwzG7jnERNqnWxZ48AWkskmdHaVDP4BcelrTI3rMXdXF5D rvs1.example.com. rvs2.example.com.
+https01 3600 IN HTTPS 0 svc
+https02 3600 IN HTTPS 1 . port="8002" echconfig="abcd"
 ipseckey01 3600 IN IPSECKEY 10 1 2 192.0.2.38 AQNRU3mG7TVTO2BkR47usntb102uFJtu gbo6BSGvgqt4AQ==
 ipseckey02 3600 IN IPSECKEY 10 0 2 . AQNRU3mG7TVTO2BkR47usntb102uFJtu gbo6BSGvgqt4AQ==
 ipseckey03 3600 IN IPSECKEY 10 3 2 mygateway.example.com. AQNRU3mG7TVTO2BkR47usntb102uFJtu gbo6BSGvgqt4AQ==
@@ -94,7 +97,7 @@ nsec02 3600 IN NSEC . NSAP-PTR NSEC
 nsec03 3600 IN NSEC . NSEC TYPE65535
 nsec301 3600 IN NSEC3 1 1 12 aabbccdd 2t7b4g4vsa5smi47k61mv5bv1a22bojr NS SOA MX RRSIG DNSKEY NSEC3PARAM
 nsec302 3600 IN NSEC3 1 1 12 - 2t7b4g4vsa5smi47k61mv5bv1a22bojr NS SOA MX RRSIG DNSKEY NSEC3PARAM
-openpgpkey 3600 IN OPENPGPKEY mQENBEteQDsBCADYnatn9+5t43AdJlVk 9dZC2RM0idPQcmrrKcjeAWDnISqoJzkv Q8ifX6mefquTBsDZC279uXShyTffYzQt vP2r9ewkK7zmSv52Ar563TSULAMwiLpe 0gGQE0ex20mX5ggtYn6czdbEtcKpW0t+ AfDqRk5YcpgqfZKXapKQ+A3CwWJKP9i3 ldx2Jz//kuru4YqROLBYyB8D6V2jNUFO daP6j5C5prh9dxfYFp2O/xFeAKLWlWuH 9o96INUoIhgdEyj9PHPT3c821NMZu8tC vsZgUB+QPbHA/QYGa+aollcdGkJpVxXo Hhbu6aMx/B+pXg55WM5pqOxmoVjyViHI UYfPABEBAAG0IUJvYiBIYWxsZXkgPGhh bGxleUBkbnNweXRob24ub3JnPokBPgQT AQIAKAUCS15AOwIbAwUJA8JnAAYLCQgH AwIGFQgCCQoLBBYCAwECHgECF4AACgkQ 6o6Gb8yUnXaflQgAhlhIqZGncRw3LV3d 24JmPD+UEcEGiVh2b/Ic/1TMec46Ts7Z qRXAcOATNteQmpzqexx+BRKDWU8ZgYx1 2J4GZmC06jABr2JDWxgvbMX9qjkUUgDG ZZgAS/B2x5AmKgy2ZnCUlaKfePcKmtKT B9yNJ8v/WERlFdGaUveEUiFU8g75xp1H j9Wp9sXCg9yeG1K2RwQ3RQd5tLudhyE6 7EQdFGgqQFynR53md7cmVhAGopKLwMkp CtToKUlxxlfnDfpKZhhXThmhA0PsUQUk JptfGwYwH3O2N3KzfUw3wXRvLa3hona3 TlHk3kfg7Qyd7oP4AZGbJKp97YHnfqo1 kp8rObkBDQRLXkA7AQgA0ePG7g5GgZ/1 SdtGZlJJiE2X15vTUc3KGfmx/kI5NaUD u4fXb+XK+yFy9I/X+UJ46JSkyhj6QvUx poI+A7WWk9ThfjbynoZxRD820Kbqidqx BSgtFF36SRWzmX8DZfKKAskT9ZGU1ode SKDXLCJF7qAbZVRTuFRiDFGwtoVIICeE 6Xd65JO6ufhad+ELhgFt95vRwTiFvVrB RjwF7ZgN/nOXfYncxZ/2mpFqfwsnB2eu 0A2XZBm8IngsSmr/Wrz1RQ7+SNMqt77E 7CKwBX7UIAZgyoJxIRxWirJoOt1rIm5V UqRR25ubXLuzx9PaHYiC5GiQIU45pWAd 0IWcTI/MJQARAQABiQElBBgBAgAPBQJL XkA7AhsMBQkDwmcAAAoJEOqOhm/MlJ12 HRsIAKrB9E++9X9W6VTXBfdkShCFv0yk ZVn2eVs6tkqzoub9s4f+Z5ylWw+a5nkM DMdGVe6bn4A3oIAbf0Tjykq1AetZLVPs Hl/QosTbSQluis/PEvJkTQXHaKHB3bFh wA90c/3HNhrLGugt9AmcfLf9LAynXDgN LV5eYdPYqfKE+27qjEBARf6PYh/8WQ8C PKS8DILFbwCZbRxUogyrZf/7AiHAGdJi 8dmpR1WPQYef2hF3kqGX6NngLBPzZ6CQ RaHBhD4pHU1S/IRSlx9/3Ytww32PYD9A yO732NmCUcq3bmvqcOWy4Cc1NkEwU0Vg 0qzwVBNGb84v/ex2MouwtAYScwc=
+openpgpkey 3600 IN OPENPGPKEY mQENBEteQDsBCADYnatn9+5t43AdJlVk9dZC2RM0idPQcmrrKcjeAWDnISqoJzkvQ8ifX6mefquTBsDZC279uXShyTffYzQtvP2r9ewkK7zmSv52Ar563TSULAMwiLpe0gGQE0ex20mX5ggtYn6czdbEtcKpW0t+AfDqRk5YcpgqfZKXapKQ+A3CwWJKP9i3ldx2Jz//kuru4YqROLBYyB8D6V2jNUFOdaP6j5C5prh9dxfYFp2O/xFeAKLWlWuH9o96INUoIhgdEyj9PHPT3c821NMZu8tCvsZgUB+QPbHA/QYGa+aollcdGkJpVxXoHhbu6aMx/B+pXg55WM5pqOxmoVjyViHIUYfPABEBAAG0IUJvYiBIYWxsZXkgPGhhbGxleUBkbnNweXRob24ub3JnPokBPgQTAQIAKAUCS15AOwIbAwUJA8JnAAYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ6o6Gb8yUnXaflQgAhlhIqZGncRw3LV3d24JmPD+UEcEGiVh2b/Ic/1TMec46Ts7ZqRXAcOATNteQmpzqexx+BRKDWU8ZgYx12J4GZmC06jABr2JDWxgvbMX9qjkUUgDGZZgAS/B2x5AmKgy2ZnCUlaKfePcKmtKTB9yNJ8v/WERlFdGaUveEUiFU8g75xp1Hj9Wp9sXCg9yeG1K2RwQ3RQd5tLudhyE67EQdFGgqQFynR53md7cmVhAGopKLwMkpCtToKUlxxlfnDfpKZhhXThmhA0PsUQUkJptfGwYwH3O2N3KzfUw3wXRvLa3hona3TlHk3kfg7Qyd7oP4AZGbJKp97YHnfqo1kp8rObkBDQRLXkA7AQgA0ePG7g5GgZ/1SdtGZlJJiE2X15vTUc3KGfmx/kI5NaUDu4fXb+XK+yFy9I/X+UJ46JSkyhj6QvUxpoI+A7WWk9ThfjbynoZxRD820KbqidqxBSgtFF36SRWzmX8DZfKKAskT9ZGU1odeSKDXLCJF7qAbZVRTuFRiDFGwtoVIICeE6Xd65JO6ufhad+ELhgFt95vRwTiFvVrBRjwF7ZgN/nOXfYncxZ/2mpFqfwsnB2eu0A2XZBm8IngsSmr/Wrz1RQ7+SNMqt77E7CKwBX7UIAZgyoJxIRxWirJoOt1rIm5VUqRR25ubXLuzx9PaHYiC5GiQIU45pWAd0IWcTI/MJQARAQABiQElBBgBAgAPBQJLXkA7AhsMBQkDwmcAAAoJEOqOhm/MlJ12HRsIAKrB9E++9X9W6VTXBfdkShCFv0ykZVn2eVs6tkqzoub9s4f+Z5ylWw+a5nkMDMdGVe6bn4A3oIAbf0Tjykq1AetZLVPsHl/QosTbSQluis/PEvJkTQXHaKHB3bFhwA90c/3HNhrLGugt9AmcfLf9LAynXDgNLV5eYdPYqfKE+27qjEBARf6PYh/8WQ8CPKS8DILFbwCZbRxUogyrZf/7AiHAGdJi8dmpR1WPQYef2hF3kqGX6NngLBPzZ6CQRaHBhD4pHU1S/IRSlx9/3Ytww32PYD9AyO732NmCUcq3bmvqcOWy4Cc1NkEwU0Vg0qzwVBNGb84v/ex2MouwtAYScwc=
 ptr01 3600 IN PTR @
 px01 3600 IN PX 65535 foo. bar.
 px02 3600 IN PX 65535 . .
@@ -106,10 +109,14 @@ rt01 3600 IN RT 0 intermediate-host
 rt02 3600 IN RT 65535 .
 s 300 IN NS ns.s
 ns.s 300 IN A 73.80.65.49
+smimea1 3600 IN SMIMEA 3 1 1 a9cdf989b504fe5dca90c0d2167b6550570734f7c763e09fdf88904e06157065
+smimea2 3600 IN SMIMEA 1 0 1 efddf0d915c7bdc5782c0881e1b2a95ad099fbdd06d7b1f77982d9364338d955
+smimea3 3600 IN SMIMEA 1 0 2 81ee7f6c0ecc6b09b7785a9418f54432de630dd54dc6ee9e3c49de547708d236d4c413c3e97e44f969e635958aa410495844127c04883503e5b024cf7a8f6a94
 spf 3600 IN SPF "v=spf1 mx -all"
 srv01 3600 IN SRV 0 0 0 .
 srv02 3600 IN SRV 65535 65535 65535 old-slow-box.example.com.
 sshfp1 3600 IN SSHFP 1 1 aa549bfe898489c02d1715d97d79c57ba2fa76ab
+svcb01 3600 IN SVCB 100 foo.com. mandatory="alpn,port" alpn="h2,h3" no-default-alpn port="12345" ipv4hint="1.2.3.4,4.3.2.1" echconfig="abcd" ipv6hint="1::2,3::4" key12345="foo"
 t 301 IN A 73.80.65.49
 tlsa1 3600 IN TLSA 3 1 1 a9cdf989b504fe5dca90c0d2167b6550570734f7c763e09fdf88904e06157065
 tlsa2 3600 IN TLSA 1 0 1 efddf0d915c7bdc5782c0881e1b2a95ad099fbdd06d7b1f77982d9364338d955
@@ -140,3 +147,7 @@ wks01 3600 IN WKS 10.0.0.1 6 0 1 2 21 23
 wks02 3600 IN WKS 10.0.0.1 17 0 1 2 53
 wks03 3600 IN WKS 10.0.0.2 6 65535
 x2501 3600 IN X25 "123456789"
+zonemd01 3600 IN ZONEMD 2018031900 1 1 62e6cf51b02e54b9b5f967d547ce43136792901f9f88e637493daaf401c92c279dd10f0edb1c56f8080211f8480ee306
+zonemd02 3600 IN ZONEMD 2018031900 1 2 08cfa1115c7b948c4163a901270395ea226a930cd2cbcf2fa9a5e6eb85f37c8a4e114d884e66f176eab121cb02db7d652e0cc4827e7a3204f166b47e5613fd27
+zonemd03 3600 IN ZONEMD 2018031900 1 240 e2d523f654b9422a96c5a8f44607bbee
+zonemd04 3600 IN ZONEMD 2018031900 241 1 e1846540e33a9e4189792d18d5d131f605fc283eaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
diff --git a/tests/md_module.py b/tests/md_module.py
new file mode 100644
index 0000000..19568bd
--- /dev/null
+++ b/tests/md_module.py
@@ -0,0 +1,4 @@
+import dns.rdtypes.nsbase
+
+class MD(dns.rdtypes.nsbase.NSBase):
+    """Test MD record."""
diff --git a/tests/mx-2-0.pickle b/tests/mx-2-0.pickle
new file mode 100644
index 0000000..53d094c
Binary files /dev/null and b/tests/mx-2-0.pickle differ
diff --git a/tests/test_address.py b/tests/test_address.py
new file mode 100644
index 0000000..1ee7022
--- /dev/null
+++ b/tests/test_address.py
@@ -0,0 +1,581 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import socket
+import sys
+import unittest
+
+import dns.exception
+import dns.ipv4
+import dns.ipv6
+
+class IPv4Tests(unittest.TestCase):
+    def test_valid(self):
+        valid = (
+            "1.2.3.4",
+            "11.22.33.44",
+            "254.7.237.98",
+            "192.168.1.26",
+            "192.168.1.1",
+            "13.1.68.3",
+            "129.144.52.38",
+            "254.157.241.86",
+            "12.34.56.78",
+            "192.0.2.128",
+        )
+        for s in valid:
+            self.assertEqual(dns.ipv4.inet_aton(s),
+                             socket.inet_pton(socket.AF_INET, s))
+
+    def test_invalid(self):
+        invalid = (
+            "",
+            ".",
+            "..",
+            "400.2.3.4",
+            "260.2.3.4",
+            "256.2.3.4",
+            "1.256.3.4",
+            "1.2.256.4",
+            "1.2.3.256",
+            "300.2.3.4",
+            "1.300.3.4",
+            "1.2.300.4",
+            "1.2.3.300",
+            "900.2.3.4",
+            "1.900.3.4",
+            "1.2.900.4",
+            "1.2.3.900",
+            "300.300.300.300",
+            "3000.30.30.30",
+            "255Z255X255Y255",
+            "192x168.1.26",
+            "2.3.4",
+            "257.1.2.3",
+            "00.00.00.00",
+            "000.000.000.000",
+            "256.256.256.256",
+            "255255.255.255",
+            "255.255255.255",
+            "255.255.255255",
+            "1...",
+            "1.2..",
+            "1.2.3.",
+            ".2..",
+            ".2.3.",
+            ".2.3.4",
+            "..3.",
+            "..3.4",
+            "...4",
+            ".1.2.3.4",
+            "1.2.3.4.",
+            " 1.2.3.4",
+            "1.2.3.4 ",
+            " 1.2.3.4 ",
+            "::",
+        )
+        for s in invalid:
+            with self.assertRaises(dns.exception.SyntaxError,
+                                   msg=f'invalid IPv4 address: "{s}"'):
+                dns.ipv4.inet_aton(s)
+
+class IPv6Tests(unittest.TestCase):
+    def test_valid(self):
+        valid = (
+            "::1",
+            "::",
+            "0:0:0:0:0:0:0:1",
+            "0:0:0:0:0:0:0:0",
+            "2001:DB8:0:0:8:800:200C:417A",
+            "FF01:0:0:0:0:0:0:101",
+            "2001:DB8::8:800:200C:417A",
+            "FF01::101",
+            "fe80::217:f2ff:fe07:ed62",
+            "2001:0000:1234:0000:0000:C1C0:ABCD:0876",
+            "3ffe:0b00:0000:0000:0001:0000:0000:000a",
+            "FF02:0000:0000:0000:0000:0000:0000:0001",
+            "0000:0000:0000:0000:0000:0000:0000:0001",
+            "0000:0000:0000:0000:0000:0000:0000:0000",
+            "2::10",
+            "ff02::1",
+            "fe80::",
+            "2002::",
+            "2001:db8::",
+            "2001:0db8:1234::",
+            "::ffff:0:0",
+            "1:2:3:4:5:6:7:8",
+            "1:2:3:4:5:6::8",
+            "1:2:3:4:5::8",
+            "1:2:3:4::8",
+            "1:2:3::8",
+            "1:2::8",
+            "1::8",
+            "1::2:3:4:5:6:7",
+            "1::2:3:4:5:6",
+            "1::2:3:4:5",
+            "1::2:3:4",
+            "1::2:3",
+            "::2:3:4:5:6:7:8",
+            "::2:3:4:5:6:7",
+            "::2:3:4:5:6",
+            "::2:3:4:5",
+            "::2:3:4",
+            "::2:3",
+            "::8",
+            "1:2:3:4:5:6::",
+            "1:2:3:4:5::",
+            "1:2:3:4::",
+            "1:2:3::",
+            "1:2::",
+            "1::",
+            "1:2:3:4:5::7:8",
+            "1:2:3:4::7:8",
+            "1:2:3::7:8",
+            "1:2::7:8",
+            "1::7:8",
+            "1:2:3:4:5:6:1.2.3.4",
+            "1:2:3:4:5::1.2.3.4",
+            "1:2:3:4::1.2.3.4",
+            "1:2:3::1.2.3.4",
+            "1:2::1.2.3.4",
+            "1::1.2.3.4",
+            "1:2:3:4::5:1.2.3.4",
+            "1:2:3::5:1.2.3.4",
+            "1:2:3::5:1.2.3.4",
+            "1:2::5:1.2.3.4",
+            "1::5:1.2.3.4",
+            "1::5:11.22.33.44",
+            "fe80::217:f2ff:254.7.237.98",
+            "::ffff:192.168.1.26",
+            "::ffff:192.168.1.1",
+            "0:0:0:0:0:0:13.1.68.3",
+            "0:0:0:0:0:FFFF:129.144.52.38",
+            "::13.1.68.3",
+            "::FFFF:129.144.52.38",
+            "fe80:0:0:0:204:61ff:254.157.241.86",
+            "fe80::204:61ff:254.157.241.86",
+            "::ffff:12.34.56.78",
+            "::ffff:192.0.2.128",
+            "fe80:0000:0000:0000:0204:61ff:fe9d:f156",
+            "fe80:0:0:0:204:61ff:fe9d:f156",
+            "fe80::204:61ff:fe9d:f156",
+            "fe80::1",
+            "::ffff:c000:280",
+            "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
+            "2001:db8:85a3:0:0:8a2e:370:7334",
+            "2001:db8:85a3::8a2e:370:7334",
+            "2001:0db8:0000:0000:0000:0000:1428:57ab",
+            "2001:0db8:0000:0000:0000::1428:57ab",
+            "2001:0db8:0:0:0:0:1428:57ab",
+            "2001:0db8:0:0::1428:57ab",
+            "2001:0db8::1428:57ab",
+            "2001:db8::1428:57ab",
+            "::ffff:0c22:384e",
+            "2001:0db8:1234:0000:0000:0000:0000:0000",
+            "2001:0db8:1234:ffff:ffff:ffff:ffff:ffff",
+            "2001:db8:a::123",
+            "1111:2222:3333:4444:5555:6666:7777:8888",
+            "1111:2222:3333:4444:5555:6666:7777::",
+            "1111:2222:3333:4444:5555:6666::",
+            "1111:2222:3333:4444:5555::",
+            "1111:2222:3333:4444::",
+            "1111:2222:3333::",
+            "1111:2222::",
+            "1111::",
+            "1111:2222:3333:4444:5555:6666::8888",
+            "1111:2222:3333:4444:5555::8888",
+            "1111:2222:3333:4444::8888",
+            "1111:2222:3333::8888",
+            "1111:2222::8888",
+            "1111::8888",
+            "::8888",
+            "1111:2222:3333:4444:5555::7777:8888",
+            "1111:2222:3333:4444::7777:8888",
+            "1111:2222:3333::7777:8888",
+            "1111:2222::7777:8888",
+            "1111::7777:8888",
+            "::7777:8888",
+            "1111:2222:3333:4444::6666:7777:8888",
+            "1111:2222:3333::6666:7777:8888",
+            "1111:2222::6666:7777:8888",
+            "1111::6666:7777:8888",
+            "::6666:7777:8888",
+            "1111:2222:3333::5555:6666:7777:8888",
+            "1111:2222::5555:6666:7777:8888",
+            "1111::5555:6666:7777:8888",
+            "::5555:6666:7777:8888",
+            "1111:2222::4444:5555:6666:7777:8888",
+            "1111::4444:5555:6666:7777:8888",
+            "::4444:5555:6666:7777:8888",
+            "1111::3333:4444:5555:6666:7777:8888",
+            "::3333:4444:5555:6666:7777:8888",
+            "::2222:3333:4444:5555:6666:7777:8888",
+            "1111:2222:3333:4444:5555:6666:123.123.123.123",
+            "1111:2222:3333:4444:5555::123.123.123.123",
+            "1111:2222:3333:4444::123.123.123.123",
+            "1111:2222:3333::123.123.123.123",
+            "1111:2222::123.123.123.123",
+            "1111::123.123.123.123",
+            "::123.123.123.123",
+            "1111:2222:3333:4444::6666:123.123.123.123",
+            "1111:2222:3333::6666:123.123.123.123",
+            "1111:2222::6666:123.123.123.123",
+            "1111::6666:123.123.123.123",
+            "::6666:123.123.123.123",
+            "1111:2222:3333::5555:6666:123.123.123.123",
+            "1111:2222::5555:6666:123.123.123.123",
+            "1111::5555:6666:123.123.123.123",
+            "::5555:6666:123.123.123.123",
+            "1111:2222::4444:5555:6666:123.123.123.123",
+            "1111::4444:5555:6666:123.123.123.123",
+            "::4444:5555:6666:123.123.123.123",
+            "1111::3333:4444:5555:6666:123.123.123.123",
+            "::2222:3333:4444:5555:6666:123.123.123.123",
+            "::0:0:0:0:0:0:0",
+            "::0:0:0:0:0:0",
+            "::0:0:0:0:0",
+            "::0:0:0:0",
+            "::0:0:0",
+            "::0:0",
+            "::0",
+            "0:0:0:0:0:0:0::",
+            "0:0:0:0:0:0::",
+            "0:0:0:0:0::",
+            "0:0:0:0::",
+            "0:0:0::",
+            "0:0::",
+            "0::",
+            "0:a:b:c:d:e:f::",
+            "::0:a:b:c:d:e:f",
+            "a:b:c:d:e:f:0::",
+        )
+
+        win32_invalid = {
+            "::2:3:4:5:6:7:8",
+            "::2222:3333:4444:5555:6666:7777:8888",
+            "::2222:3333:4444:5555:6666:123.123.123.123",
+            "::0:0:0:0:0:0:0",
+            "::0:a:b:c:d:e:f",
+        }
+
+        for s in valid:
+            if sys.platform == 'win32' and s in win32_invalid:
+                # socket.inet_pton() on win32 rejects some valid (as
+                # far as we can tell) IPv6 addresses.  Skip them.
+                continue
+            self.assertEqual(dns.ipv6.inet_aton(s),
+                             socket.inet_pton(socket.AF_INET6, s))
+
+    def test_invalid(self):
+        invalid = (
+            "",
+            ":",
+            ":::",
+            "2001:DB8:0:0:8:800:200C:417A:221",
+            "FF01::101::2",
+            "02001:0000:1234:0000:0000:C1C0:ABCD:0876",
+            "2001:0000:1234:0000:00001:C1C0:ABCD:0876",
+            " 2001:0000:1234:0000:0000:C1C0:ABCD:0876",
+            "2001:0000:1234:0000:0000:C1C0:ABCD:0876 ",
+            " 2001:0000:1234:0000:0000:C1C0:ABCD:0876  ",
+            "2001:0000:1234:0000:0000:C1C0:ABCD:0876  0",
+            "2001:0000:1234: 0000:0000:C1C0:ABCD:0876",
+            "3ffe:0b00:0000:0001:0000:0000:000a",
+            "FF02:0000:0000:0000:0000:0000:0000:0000:0001",
+            "3ffe:b00::1::a",
+            "::1111:2222:3333:4444:5555:6666::",
+            "1:2:3::4:5::7:8",
+            "12345::6:7:8",
+            "1::5:400.2.3.4",
+            "1::5:260.2.3.4",
+            "1::5:256.2.3.4",
+            "1::5:1.256.3.4",
+            "1::5:1.2.256.4",
+            "1::5:1.2.3.256",
+            "1::5:300.2.3.4",
+            "1::5:1.300.3.4",
+            "1::5:1.2.300.4",
+            "1::5:1.2.3.300",
+            "1::5:900.2.3.4",
+            "1::5:1.900.3.4",
+            "1::5:1.2.900.4",
+            "1::5:1.2.3.900",
+            "1::5:300.300.300.300",
+            "1::5:3000.30.30.30",
+            "1::400.2.3.4",
+            "1::260.2.3.4",
+            "1::256.2.3.4",
+            "1::1.256.3.4",
+            "1::1.2.256.4",
+            "1::1.2.3.256",
+            "1::300.2.3.4",
+            "1::1.300.3.4",
+            "1::1.2.300.4",
+            "1::1.2.3.300",
+            "1::900.2.3.4",
+            "1::1.900.3.4",
+            "1::1.2.900.4",
+            "1::1.2.3.900",
+            "1::300.300.300.300",
+            "1::3000.30.30.30",
+            "::400.2.3.4",
+            "::260.2.3.4",
+            "::256.2.3.4",
+            "::1.256.3.4",
+            "::1.2.256.4",
+            "::1.2.3.256",
+            "::300.2.3.4",
+            "::1.300.3.4",
+            "::1.2.300.4",
+            "::1.2.3.300",
+            "::900.2.3.4",
+            "::1.900.3.4",
+            "::1.2.900.4",
+            "::1.2.3.900",
+            "::300.300.300.300",
+            "::3000.30.30.30",
+            "::1.2.3.4.",
+            "2001:1:1:1:1:1:255Z255X255Y255",
+            "::ffff:192x168.1.26",
+            "::ffff:2.3.4",
+            "::ffff:257.1.2.3",
+            "1.2.3.4",
+            "1.2.3.4:1111:2222:3333:4444::5555",
+            "1.2.3.4:1111:2222:3333::5555",
+            "1.2.3.4:1111:2222::5555",
+            "1.2.3.4:1111::5555",
+            "1.2.3.4::5555",
+            "1.2.3.4::",
+            "fe80:0000:0000:0000:0204:61ff:254.157.241.086",
+            "XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:1.2.3.4",
+            "1111:2222:3333:4444:5555:6666:00.00.00.00",
+            "1111:2222:3333:4444:5555:6666:000.000.000.000",
+            "1111:2222:3333:4444:5555:6666:256.256.256.256",
+            "1111:2222:3333:4444::5555:",
+            "1111:2222:3333::5555:",
+            "1111:2222::5555:",
+            "1111::5555:",
+            "::5555:",
+            "1111:",
+            ":1111:2222:3333:4444::5555",
+            ":1111:2222:3333::5555",
+            ":1111:2222::5555",
+            ":1111::5555",
+            ":::5555",
+            "123",
+            "ldkfj",
+            "2001::FFD3::57ab",
+            "2001:db8:85a3::8a2e:37023:7334",
+            "2001:db8:85a3::8a2e:370k:7334",
+            "1:2:3:4:5:6:7:8:9",
+            "1::2::3",
+            "1:::3:4:5",
+            "1:2:3::4:5:6:7:8:9",
+            "XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX",
+            "1111:2222:3333:4444:5555:6666:7777:8888:9999",
+            "1111:2222:3333:4444:5555:6666:7777:8888::",
+            "::2222:3333:4444:5555:6666:7777:8888:9999",
+            "1111:2222:3333:4444:5555:6666:7777",
+            "1111:2222:3333:4444:5555:6666",
+            "1111:2222:3333:4444:5555",
+            "1111:2222:3333:4444",
+            "1111:2222:3333",
+            "1111:2222",
+            "1111",
+            "11112222:3333:4444:5555:6666:7777:8888",
+            "1111:22223333:4444:5555:6666:7777:8888",
+            "1111:2222:33334444:5555:6666:7777:8888",
+            "1111:2222:3333:44445555:6666:7777:8888",
+            "1111:2222:3333:4444:55556666:7777:8888",
+            "1111:2222:3333:4444:5555:66667777:8888",
+            "1111:2222:3333:4444:5555:6666:77778888",
+            "1111:2222:3333:4444:5555:6666:7777:8888:",
+            "1111:2222:3333:4444:5555:6666:7777:",
+            "1111:2222:3333:4444:5555:6666:",
+            "1111:2222:3333:4444:5555:",
+            "1111:2222:3333:4444:",
+            "1111:2222:3333:",
+            "1111:2222:",
+            ":8888",
+            ":7777:8888",
+            ":6666:7777:8888",
+            ":5555:6666:7777:8888",
+            ":4444:5555:6666:7777:8888",
+            ":3333:4444:5555:6666:7777:8888",
+            ":2222:3333:4444:5555:6666:7777:8888",
+            ":1111:2222:3333:4444:5555:6666:7777:8888",
+            ":::2222:3333:4444:5555:6666:7777:8888",
+            "1111:::3333:4444:5555:6666:7777:8888",
+            "1111:2222:::4444:5555:6666:7777:8888",
+            "1111:2222:3333:::5555:6666:7777:8888",
+            "1111:2222:3333:4444:::6666:7777:8888",
+            "1111:2222:3333:4444:5555:::7777:8888",
+            "1111:2222:3333:4444:5555:6666:::8888",
+            "::2222::4444:5555:6666:7777:8888",
+            "::2222:3333::5555:6666:7777:8888",
+            "::2222:3333:4444::6666:7777:8888",
+            "::2222:3333:4444:5555::7777:8888",
+            "::2222:3333:4444:5555:7777::8888",
+            "::2222:3333:4444:5555:7777:8888::",
+            "1111::3333::5555:6666:7777:8888",
+            "1111::3333:4444::6666:7777:8888",
+            "1111::3333:4444:5555::7777:8888",
+            "1111::3333:4444:5555:6666::8888",
+            "1111::3333:4444:5555:6666:7777::",
+            "1111:2222::4444::6666:7777:8888",
+            "1111:2222::4444:5555::7777:8888",
+            "1111:2222::4444:5555:6666::8888",
+            "1111:2222::4444:5555:6666:7777::",
+            "1111:2222:3333::5555::7777:8888",
+            "1111:2222:3333::5555:6666::8888",
+            "1111:2222:3333::5555:6666:7777::",
+            "1111:2222:3333:4444::6666::8888",
+            "1111:2222:3333:4444::6666:7777::",
+            "1111:2222:3333:4444:5555::7777::",
+            "1111:2222:3333:4444:5555:6666:7777:8888:1.2.3.4",
+            "1111:2222:3333:4444:5555:6666:7777:1.2.3.4",
+            "1111:2222:3333:4444:5555:6666::1.2.3.4",
+            "::2222:3333:4444:5555:6666:7777:1.2.3.4",
+            "1111:2222:3333:4444:5555:6666:1.2.3.4.5",
+            "1111:2222:3333:4444:5555:1.2.3.4",
+            "1111:2222:3333:4444:1.2.3.4",
+            "1111:2222:3333:1.2.3.4",
+            "1111:2222:1.2.3.4",
+            "1111:1.2.3.4",
+            "11112222:3333:4444:5555:6666:1.2.3.4",
+            "1111:22223333:4444:5555:6666:1.2.3.4",
+            "1111:2222:33334444:5555:6666:1.2.3.4",
+            "1111:2222:3333:44445555:6666:1.2.3.4",
+            "1111:2222:3333:4444:55556666:1.2.3.4",
+            "1111:2222:3333:4444:5555:66661.2.3.4",
+            "1111:2222:3333:4444:5555:6666:255255.255.255",
+            "1111:2222:3333:4444:5555:6666:255.255255.255",
+            "1111:2222:3333:4444:5555:6666:255.255.255255",
+            ":1.2.3.4",
+            ":6666:1.2.3.4",
+            ":5555:6666:1.2.3.4",
+            ":4444:5555:6666:1.2.3.4",
+            ":3333:4444:5555:6666:1.2.3.4",
+            ":2222:3333:4444:5555:6666:1.2.3.4",
+            ":1111:2222:3333:4444:5555:6666:1.2.3.4",
+            ":::2222:3333:4444:5555:6666:1.2.3.4",
+            "1111:::3333:4444:5555:6666:1.2.3.4",
+            "1111:2222:::4444:5555:6666:1.2.3.4",
+            "1111:2222:3333:::5555:6666:1.2.3.4",
+            "1111:2222:3333:4444:::6666:1.2.3.4",
+            "1111:2222:3333:4444:5555:::1.2.3.4",
+            "::2222::4444:5555:6666:1.2.3.4",
+            "::2222:3333::5555:6666:1.2.3.4",
+            "::2222:3333:4444::6666:1.2.3.4",
+            "::2222:3333:4444:5555::1.2.3.4",
+            "1111::3333::5555:6666:1.2.3.4",
+            "1111::3333:4444::6666:1.2.3.4",
+            "1111::3333:4444:5555::1.2.3.4",
+            "1111:2222::4444::6666:1.2.3.4",
+            "1111:2222::4444:5555::1.2.3.4",
+            "1111:2222:3333::5555::1.2.3.4",
+            "::.",
+            "::..",
+            "::...",
+            "::1...",
+            "::1.2..",
+            "::1.2.3.",
+            "::.2..",
+            "::.2.3.",
+            "::.2.3.4",
+            "::..3.",
+            "::..3.4",
+            "::...4",
+            ":1111:2222:3333:4444:5555:6666:7777::",
+            ":1111:2222:3333:4444:5555:6666::",
+            ":1111:2222:3333:4444:5555::",
+            ":1111:2222:3333:4444::",
+            ":1111:2222:3333::",
+            ":1111:2222::",
+            ":1111::",
+            ":1111:2222:3333:4444:5555:6666::8888",
+            ":1111:2222:3333:4444:5555::8888",
+            ":1111:2222:3333:4444::8888",
+            ":1111:2222:3333::8888",
+            ":1111:2222::8888",
+            ":1111::8888",
+            ":::8888",
+            ":1111:2222:3333:4444:5555::7777:8888",
+            ":1111:2222:3333:4444::7777:8888",
+            ":1111:2222:3333::7777:8888",
+            ":1111:2222::7777:8888",
+            ":1111::7777:8888",
+            ":::7777:8888",
+            ":1111:2222:3333:4444::6666:7777:8888",
+            ":1111:2222:3333::6666:7777:8888",
+            ":1111:2222::6666:7777:8888",
+            ":1111::6666:7777:8888",
+            ":::6666:7777:8888",
+            ":1111:2222:3333::5555:6666:7777:8888",
+            ":1111:2222::5555:6666:7777:8888",
+            ":1111::5555:6666:7777:8888",
+            ":::5555:6666:7777:8888",
+            ":1111:2222::4444:5555:6666:7777:8888",
+            ":1111::4444:5555:6666:7777:8888",
+            ":::4444:5555:6666:7777:8888",
+            ":1111::3333:4444:5555:6666:7777:8888",
+            ":::3333:4444:5555:6666:7777:8888",
+            ":1111:2222:3333:4444:5555::1.2.3.4",
+            ":1111:2222:3333:4444::1.2.3.4",
+            ":1111:2222:3333::1.2.3.4",
+            ":1111:2222::1.2.3.4",
+            ":1111::1.2.3.4",
+            ":::1.2.3.4",
+            ":1111:2222:3333:4444::6666:1.2.3.4",
+            ":1111:2222:3333::6666:1.2.3.4",
+            ":1111:2222::6666:1.2.3.4",
+            ":1111::6666:1.2.3.4",
+            ":::6666:1.2.3.4",
+            ":1111:2222:3333::5555:6666:1.2.3.4",
+            ":1111:2222::5555:6666:1.2.3.4",
+            ":1111::5555:6666:1.2.3.4",
+            ":::5555:6666:1.2.3.4",
+            ":1111:2222::4444:5555:6666:1.2.3.4",
+            ":1111::4444:5555:6666:1.2.3.4",
+            ":::4444:5555:6666:1.2.3.4",
+            ":1111::3333:4444:5555:6666:1.2.3.4",
+            "1111:2222:3333:4444:5555:6666:7777:::",
+            "1111:2222:3333:4444:5555:6666:::",
+            "1111:2222:3333:4444:5555:::",
+            "1111:2222:3333:4444:::",
+            "1111:2222:3333:::",
+            "1111:2222:::",
+            "1111:::",
+            "1111:2222:3333:4444:5555:6666::8888:",
+            "1111:2222:3333:4444:5555::8888:",
+            "1111:2222:3333:4444::8888:",
+            "1111:2222:3333::8888:",
+            "1111:2222::8888:",
+            "1111::8888:",
+            "::8888:",
+            "1111:2222:3333:4444:5555::7777:8888:",
+            "1111:2222:3333:4444::7777:8888:",
+            "1111:2222:3333::7777:8888:",
+            "1111:2222::7777:8888:",
+            "1111::7777:8888:",
+            "::7777:8888:",
+            "1111:2222:3333:4444::6666:7777:8888:",
+            "1111:2222:3333::6666:7777:8888:",
+            "1111:2222::6666:7777:8888:",
+            "1111::6666:7777:8888:",
+            "::6666:7777:8888:",
+            "1111:2222:3333::5555:6666:7777:8888:",
+            "1111:2222::5555:6666:7777:8888:",
+            "1111::5555:6666:7777:8888:",
+            "::5555:6666:7777:8888:",
+            "1111:2222::4444:5555:6666:7777:8888:",
+            "1111::4444:5555:6666:7777:8888:",
+            "::4444:5555:6666:7777:8888:",
+            "1111::3333:4444:5555:6666:7777:8888:",
+            "::3333:4444:5555:6666:7777:8888:",
+            "::2222:3333:4444:5555:6666:7777:8888:",
+            "':10.0.0.1",
+        )
+        for s in invalid:
+            with self.assertRaises(dns.exception.SyntaxError,
+                                   msg=f'invalid IPv6 address: "{s}"'):
+                dns.ipv6.inet_aton(s)
diff --git a/tests/test_async.py b/tests/test_async.py
index db108c8..0252f22 100644
--- a/tests/test_async.py
+++ b/tests/test_async.py
@@ -17,6 +17,7 @@
 
 import asyncio
 import socket
+import sys
 import time
 import unittest
 
@@ -152,6 +153,7 @@ class MiscQuery(unittest.TestCase):
 
 @unittest.skipIf(not _network_available, "Internet not reachable")
 class AsyncTests(unittest.TestCase):
+    connect_udp = sys.platform == 'win32'
 
     def setUp(self):
         self.backend = dns.asyncbackend.set_default_backend('asyncio')
@@ -182,6 +184,26 @@ class AsyncTests(unittest.TestCase):
         dnsgoogle = dns.name.from_text('dns.google.')
         self.assertEqual(answer[0].target, dnsgoogle)
 
+    def testCanonicalNameNoCNAME(self):
+        cname = dns.name.from_text('www.google.com')
+        async def run():
+            return await dns.asyncresolver.canonical_name('www.google.com')
+        self.assertEqual(self.async_run(run), cname)
+
+    def testCanonicalNameCNAME(self):
+        name = dns.name.from_text('www.dnspython.org')
+        cname = dns.name.from_text('dmfrjf4ips8xa.cloudfront.net')
+        async def run():
+            return await dns.asyncresolver.canonical_name(name)
+        self.assertEqual(self.async_run(run), cname)
+
+    def testCanonicalNameDangling(self):
+        name = dns.name.from_text('dangling-cname.dnspython.org')
+        cname = dns.name.from_text('dangling-target.dnspython.org')
+        async def run():
+            return await dns.asyncresolver.canonical_name(name)
+        self.assertEqual(self.async_run(run), cname)
+
     def testResolverBadScheme(self):
         res = dns.asyncresolver.Resolver(configure=False)
         res.nameservers = ['bogus://dns.google/dns-query']
@@ -228,7 +250,7 @@ class AsyncTests(unittest.TestCase):
             qname = dns.name.from_text('dns.google.')
             async def run():
                 q = dns.message.make_query(qname, dns.rdatatype.A)
-                return await dns.asyncquery.udp(q, address)
+                return await dns.asyncquery.udp(q, address, timeout=2)
             response = self.async_run(run)
             rrs = response.get_rrset(response.answer, qname,
                                      dns.rdataclass.IN, dns.rdatatype.A)
@@ -241,11 +263,16 @@ class AsyncTests(unittest.TestCase):
         for address in query_addresses:
             qname = dns.name.from_text('dns.google.')
             async def run():
+                if self.connect_udp:
+                    dtuple=(address, 53)
+                else:
+                    dtuple=None
                 async with await self.backend.make_socket(
                         dns.inet.af_for_address(address),
-                        socket.SOCK_DGRAM) as s:
+                        socket.SOCK_DGRAM, 0, None, dtuple) as s:
                     q = dns.message.make_query(qname, dns.rdatatype.A)
-                    return await dns.asyncquery.udp(q, address, sock=s)
+                    return await dns.asyncquery.udp(q, address, sock=s,
+                                                    timeout=2)
             response = self.async_run(run)
             rrs = response.get_rrset(response.answer, qname,
                                      dns.rdataclass.IN, dns.rdatatype.A)
@@ -259,7 +286,7 @@ class AsyncTests(unittest.TestCase):
             qname = dns.name.from_text('dns.google.')
             async def run():
                 q = dns.message.make_query(qname, dns.rdatatype.A)
-                return await dns.asyncquery.tcp(q, address)
+                return await dns.asyncquery.tcp(q, address, timeout=2)
             response = self.async_run(run)
             rrs = response.get_rrset(response.answer, qname,
                                      dns.rdataclass.IN, dns.rdatatype.A)
@@ -276,11 +303,12 @@ class AsyncTests(unittest.TestCase):
                         dns.inet.af_for_address(address),
                         socket.SOCK_STREAM, 0,
                         None,
-                        (address, 53)) as s:
+                        (address, 53), 2) as s:
                     # for basic coverage
                     await s.getsockname()
                     q = dns.message.make_query(qname, dns.rdatatype.A)
-                    return await dns.asyncquery.tcp(q, address, sock=s)
+                    return await dns.asyncquery.tcp(q, address, sock=s,
+                                                    timeout=2)
             response = self.async_run(run)
             rrs = response.get_rrset(response.answer, qname,
                                      dns.rdataclass.IN, dns.rdatatype.A)
@@ -295,7 +323,7 @@ class AsyncTests(unittest.TestCase):
             qname = dns.name.from_text('dns.google.')
             async def run():
                 q = dns.message.make_query(qname, dns.rdatatype.A)
-                return await dns.asyncquery.tls(q, address)
+                return await dns.asyncquery.tls(q, address, timeout=2)
             response = self.async_run(run)
             rrs = response.get_rrset(response.answer, qname,
                                      dns.rdataclass.IN, dns.rdatatype.A)
@@ -315,12 +343,13 @@ class AsyncTests(unittest.TestCase):
                         dns.inet.af_for_address(address),
                         socket.SOCK_STREAM, 0,
                         None,
-                        (address, 853), None,
+                        (address, 853), 2,
                         ssl_context, None) as s:
                     # for basic coverage
                     await s.getsockname()
                     q = dns.message.make_query(qname, dns.rdatatype.A)
-                    return await dns.asyncquery.tls(q, '8.8.8.8', sock=s)
+                    return await dns.asyncquery.tls(q, '8.8.8.8', sock=s,
+                                                    timeout=2)
             response = self.async_run(run)
             rrs = response.get_rrset(response.answer, qname,
                                      dns.rdataclass.IN, dns.rdatatype.A)
@@ -334,7 +363,8 @@ class AsyncTests(unittest.TestCase):
             qname = dns.name.from_text('.')
             async def run():
                 q = dns.message.make_query(qname, dns.rdatatype.DNSKEY)
-                return await dns.asyncquery.udp_with_fallback(q, address)
+                return await dns.asyncquery.udp_with_fallback(q, address,
+                                                              timeout=2)
             (_, tcp) = self.async_run(run)
             self.assertTrue(tcp)
 
@@ -343,11 +373,14 @@ class AsyncTests(unittest.TestCase):
             qname = dns.name.from_text('dns.google.')
             async def run():
                 q = dns.message.make_query(qname, dns.rdatatype.A)
-                return await dns.asyncquery.udp_with_fallback(q, address)
+                return await dns.asyncquery.udp_with_fallback(q, address,
+                                                              timeout=2)
             (_, tcp) = self.async_run(run)
             self.assertFalse(tcp)
 
     def testUDPReceiveQuery(self):
+        if self.connect_udp:
+            self.skipTest('test needs connectionless sockets')
         async def run():
             async with await self.backend.make_socket(
                     socket.AF_INET, socket.SOCK_DGRAM,
@@ -367,6 +400,8 @@ class AsyncTests(unittest.TestCase):
         self.assertEqual(sender_address, recv_address)
 
     def testUDPReceiveTimeout(self):
+        if self.connect_udp:
+            self.skipTest('test needs connectionless sockets')
         async def arun():
             async with await self.backend.make_socket(socket.AF_INET,
                                                       socket.SOCK_DGRAM, 0,
@@ -405,6 +440,7 @@ try:
             return trio.run(afunc)
 
     class TrioAsyncTests(AsyncTests):
+        connect_udp = False
         def setUp(self):
             self.backend = dns.asyncbackend.set_default_backend('trio')
 
@@ -428,6 +464,7 @@ try:
             return curio.run(afunc)
 
     class CurioAsyncTests(AsyncTests):
+        connect_udp = False
         def setUp(self):
             self.backend = dns.asyncbackend.set_default_backend('curio')
 
diff --git a/tests/test_constants.py b/tests/test_constants.py
new file mode 100644
index 0000000..e818bb9
--- /dev/null
+++ b/tests/test_constants.py
@@ -0,0 +1,38 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import unittest
+
+import dns.dnssec
+import dns.rdtypes.dnskeybase
+import dns.flags
+import dns.rcode
+import dns.opcode
+import dns.message
+import dns.update
+import dns.edns
+
+import tests.util
+
+
+class ConstantsTestCase(unittest.TestCase):
+
+    def test_dnssec_constants(self):
+        tests.util.check_enum_exports(dns.dnssec, self.assertEqual,
+                                      only={dns.dnssec.Algorithm})
+        tests.util.check_enum_exports(dns.rdtypes.dnskeybase, self.assertEqual)
+
+    def test_flags_constants(self):
+        tests.util.check_enum_exports(dns.flags, self.assertEqual)
+        tests.util.check_enum_exports(dns.rcode, self.assertEqual)
+        tests.util.check_enum_exports(dns.opcode, self.assertEqual)
+
+    def test_message_constants(self):
+        tests.util.check_enum_exports(dns.message, self.assertEqual)
+        tests.util.check_enum_exports(dns.update, self.assertEqual)
+
+    def test_rdata_constants(self):
+        tests.util.check_enum_exports(dns.rdataclass, self.assertEqual)
+        tests.util.check_enum_exports(dns.rdatatype, self.assertEqual)
+
+    def test_edns_constants(self):
+        tests.util.check_enum_exports(dns.edns, self.assertEqual)
diff --git a/tests/test_dnssec.py b/tests/test_dnssec.py
index e99d3be..6ea51dc 100644
--- a/tests/test_dnssec.py
+++ b/tests/test_dnssec.py
@@ -192,6 +192,7 @@ abs_ed448_mx_rrsig_2 = dns.rrset.from_text('example.com.', 3600, 'IN', 'RRSIG',
                                            'MX 16 2 3600 1440021600 1438207200 38353 example.com. E1/oLjSGIbmLny/4fcgM1z4oL6aqo+izT3urCyHyvEp4Sp8Syg1eI+lJ57CSnZqjJP41O/9l4m0AsQ4f7qI1gVnML8vWWiyW2KXhT9kuAICUSxv5OWbf81Rq7Yu60npabODB0QFPb/rkW3kUZmQ0YQUA')
 
 when5 = 1440021600
+when5_start = 1438207200
 
 wildcard_keys = {
     abs_example_com : dns.rrset.from_text(
@@ -205,16 +206,71 @@ wildcard_txt_rrsig = dns.rrset.from_text('*.example.com.', 3600, 'IN', 'RRSIG',
 
 wildcard_when = 1593541048
 
-class DNSSECMakeDSTestCase(unittest.TestCase):
-    def testMnemonicParser(self):
-        good_ds_mnemonic = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS,
-                              '57349 RSASHA1 2 53A79A3E7488AB44FFC56B2D1109F0699D1796DD977E72108B841F96 E47D7013')
-        self.assertEqual(good_ds, good_ds_mnemonic)
+
+rsamd5_keys = {
+    abs_example: dns.rrset.from_text(
+        'example', 3600, 'in', 'dnskey',
+        '257 3 1 AwEAAewnoEWe+AVEnQzcZTwpl8K/QKuScYIX 9xHOhejAL1enMjE0j97Gq3XXJJPWF7eQQGHs 1De4Srv2UT0zRCLkH9r36lOR/ggANvthO/Ub Es0hlD3A58LumEPudgIDwEkxGvQAXMFTMw0x 1d/a82UtzmNoPVzFOl2r+OCXx9Jbdh/L; KSK; alg = RSAMD5; key id = 30239',
+        '256 3 1 AwEAAb8OJM5YcqaYG0fenUdRlrhBQ6LuwCvr 5BRlrVbVzadSDBpq+yIiklfdGNBg3WZztDy1 du62NWC/olMfc6uRe/SjqTa7IJ3MdEuZQXQw	MedGdNSF73zbokx8wg7zBBr74xHczJcEpQhr ZLzwCDmIPu0yoVi3Yqdl4dm4vNBj9hAD; ZSK; alg = RSAMD5; key id = 62992')
+}
+
+rsamd5_ns = dns.rrset.from_text('example.', 3600, 'in', 'ns',
+                                'ns1.example.', 'ns2.example.')
+rsamd5_ns_rrsig = dns.rrset.from_text('example.', 3600, 'in', 'rrsig',
+                                      'NS 1 1 3600 20200825153103 20200726153103 62992 example. YPv0WVqzQBDH45mFcYGo9psCVoMoeeHeAugh 9RZuO2NmdwfQ3mmiQm7WJ3AYnzYIozFGf7CL nwn3vN8/fjsfcQgEv5xfhFTSd4IoAzJJiZAa vrI4L5590C/+aXQ8tjRmbMTPiqoudaXvsevE jP2lTFg5DCruJyFq5dnAY5b90RY=')
+
+rsamd5_when = 1595781671
+
+rsasha512_keys = {
+    abs_example: dns.rrset.from_text(
+        'example', 3600, 'in', 'dnskey',
+        '256 3 10 AwEAAb2JvKjZ6l5qg2ab3qqUQhLGGjsiMIuQ 2zhaXJHdTntS+8LgUXo5yLFn7YF9YL1VX9V4 5ASGxUpz0u0chjWqBNtUO3Ymzas/vck9o21M 2Ce/LrpfYsqvJaLvGf/dozW9uSeMQq1mPKYG xo4uxyhZBhZewX8znXZySrAIozBPH3yp ; ZSK; alg = RSASHA512 ; key id = 5957',
+        '257 3 10 AwEAAc7Lnoe+mHijJ8OOHgyJHKYantQGKx5t rIs267gOePyAL7cUt9HO1Sm3vABSGNsoHL6w 8/542SxGbT21osVISamtq7kUPTgDU9iKqCBq VdXEdzXYbhBKVoQkGPl4PflfbOgg/45xAiTi 7qOUERuRCPdKEkd4FW0tg6VfZmm7QjP1 ; KSK; alg = RSASHA512 ; key id = 53212')
+}
+
+rsasha512_ns = dns.rrset.from_text('example.', 3600, 'in', 'ns',
+                                   'ns1.example.', 'ns2.example.')
+rsasha512_ns_rrsig = dns.rrset.from_text(
+    'example.', 3600, 'in', 'rrsig',
+    'NS 10 1 3600 20200825161255 20200726161255 5957 example. P9A+1zYke7yIiKEnxFMm+UIW2CIwy2WDvbx6 g8hHiI8qISe6oeKveFW23OSk9+VwFgBiOpeM ygzzFbckY7RkGbOr4TR8ogDRANt6LhV402Hu SXTV9hCLVFWU4PS+/fxxfOHCetsY5tWWSxZi zSHfgpGfsHWzQoAamag4XYDyykc=')
+
+rsasha512_when = 1595783997
+
+
+unknown_alg_keys = {
+    abs_example: dns.rrset.from_text(
+        'example', 3600, 'in', 'dnskey',
+        '256 3 100 Ym9ndXM=',
+        '257 3 100 Ym9ndXM=')
+}
+
+unknown_alg_ns_rrsig = dns.rrset.from_text(
+    'example.', 3600, 'in', 'rrsig',
+    'NS 100 1 3600 20200825161255 20200726161255 16713 example. P9A+1zYke7yIiKEnxFMm+UIW2CIwy2WDvbx6 g8hHiI8qISe6oeKveFW23OSk9+VwFgBiOpeM ygzzFbckY7RkGbOr4TR8ogDRANt6LhV402Hu SXTV9hCLVFWU4PS+/fxxfOHCetsY5tWWSxZi zSHfgpGfsHWzQoAamag4XYDyykc=')
+
+fake_gost_keys = {
+    abs_example: dns.rrset.from_text(
+        'example', 3600, 'in', 'dnskey',
+        '256 3 12 Ym9ndXM=',
+        '257 3 12 Ym9ndXM=')
+}
+
+fake_gost_ns_rrsig = dns.rrset.from_text(
+    'example.', 3600, 'in', 'rrsig',
+    'NS 12 1 3600 20200825161255 20200726161255 16625 example. P9A+1zYke7yIiKEnxFMm+UIW2CIwy2WDvbx6 g8hHiI8qISe6oeKveFW23OSk9+VwFgBiOpeM ygzzFbckY7RkGbOr4TR8ogDRANt6LhV402Hu SXTV9hCLVFWU4PS+/fxxfOHCetsY5tWWSxZi zSHfgpGfsHWzQoAamag4XYDyykc=')
 
 @unittest.skipUnless(dns.dnssec._have_pyca,
                      "Python Cryptography cannot be imported")
 class DNSSECValidatorTestCase(unittest.TestCase):
 
+    def testAbsoluteRSAMD5Good(self):  # type: () -> None
+        dns.dnssec.validate(rsamd5_ns, rsamd5_ns_rrsig, rsamd5_keys, None,
+                            rsamd5_when)
+
+    def testRSAMD5Keyid(self):
+        self.assertEqual(dns.dnssec.key_id(rsamd5_keys[abs_example][0]), 30239)
+        self.assertEqual(dns.dnssec.key_id(rsamd5_keys[abs_example][1]), 62992)
+
     def testAbsoluteRSAGood(self):  # type: () -> None
         dns.dnssec.validate(abs_soa, abs_soa_rrsig, abs_keys, None, when)
 
@@ -230,6 +286,9 @@ class DNSSECValidatorTestCase(unittest.TestCase):
     def testRelativeRSAGood(self):  # type: () -> None
         dns.dnssec.validate(rel_soa, rel_soa_rrsig, rel_keys,
                             abs_dnspython_org, when)
+        # test the text conversion for origin too
+        dns.dnssec.validate(rel_soa, rel_soa_rrsig, rel_keys,
+                            'dnspython.org', when)
 
     def testRelativeRSABad(self):  # type: () -> None
         def bad():  # type: () -> None
@@ -295,7 +354,11 @@ class DNSSECValidatorTestCase(unittest.TestCase):
             dns.dnssec.validate(abs_other_ed448_mx, abs_ed448_mx_rrsig_2,
                                 abs_ed448_keys_2, None, when5)
 
-    def testWildcardGood(self): # type: () -> None
+    def testAbsoluteRSASHA512Good(self):
+        dns.dnssec.validate(rsasha512_ns, rsasha512_ns_rrsig, rsasha512_keys,
+                            None, rsasha512_when)
+
+    def testWildcardGoodAndBad(self):
         dns.dnssec.validate(wildcard_txt, wildcard_txt_rrsig,
                             wildcard_keys, None, wildcard_when)
 
@@ -314,6 +377,13 @@ class DNSSECValidatorTestCase(unittest.TestCase):
         dns.dnssec.validate(abc_txt, abc_txt_rrsig, wildcard_keys, None,
                             wildcard_when)
 
+        com_name = dns.name.from_text('com.')
+        com_txt = clone_rrset(wildcard_txt, com_name)
+        com_txt_rrsig = clone_rrset(wildcard_txt_rrsig, abc_name)
+        with self.assertRaises(dns.dnssec.ValidationFailure):
+            dns.dnssec.validate_rrsig(com_txt, com_txt_rrsig[0], wildcard_keys,
+                                      None, wildcard_when)
+
     def testAlternateParameterFormats(self):  # type: () -> None
         # Pass rrset and rrsigset as (name, rdataset) tuples, not rrsets
         rrset = (abs_soa.name, abs_soa.to_rdataset())
@@ -326,17 +396,87 @@ class DNSSECValidatorTestCase(unittest.TestCase):
             keys[name] = dns.node.Node()
             keys[name].rdatasets.append(key_rrset.to_rdataset())
         dns.dnssec.validate(abs_soa, abs_soa_rrsig, keys, None, when)
+        # test key not found.
+        keys = {}
+        for (name, key_rrset) in abs_keys.items():
+            keys[name] = dns.node.Node()
+        with self.assertRaises(dns.dnssec.ValidationFailure):
+            dns.dnssec.validate(abs_soa, abs_soa_rrsig, keys, None, when)
 
         # Pass origin as a string, not a name.
         dns.dnssec.validate(rel_soa, rel_soa_rrsig, rel_keys,
                             'dnspython.org', when)
+        dns.dnssec.validate_rrsig(rel_soa, rel_soa_rrsig[0], rel_keys,
+                                  'dnspython.org', when)
+
+    def testAbsoluteKeyNotFound(self):
+        with self.assertRaises(dns.dnssec.ValidationFailure):
+            dns.dnssec.validate(abs_ed448_mx, abs_ed448_mx_rrsig_1, {}, None,
+                                when5)
+
+    def testTimeBounds(self):
+        # not yet valid
+        with self.assertRaises(dns.dnssec.ValidationFailure):
+            dns.dnssec.validate(abs_ed448_mx, abs_ed448_mx_rrsig_1,
+                                abs_ed448_keys_1, None, when5_start - 1)
+        # expired
+        with self.assertRaises(dns.dnssec.ValidationFailure):
+            dns.dnssec.validate(abs_ed448_mx, abs_ed448_mx_rrsig_1,
+                                abs_ed448_keys_1, None, when5 + 1)
+        # expired using the current time (to test the "get the time" code
+        # path)
+        with self.assertRaises(dns.dnssec.ValidationFailure):
+            dns.dnssec.validate(abs_ed448_mx, abs_ed448_mx_rrsig_1,
+                                abs_ed448_keys_1, None)
+
+    def testOwnerNameMismatch(self):
+        bogus = dns.name.from_text('example.bogus')
+        with self.assertRaises(dns.dnssec.ValidationFailure):
+            dns.dnssec.validate((bogus, abs_ed448_mx), abs_ed448_mx_rrsig_1,
+                                abs_ed448_keys_1, None, when5 + 1)
+
+    def testGOSTNotSupported(self):
+        with self.assertRaises(dns.dnssec.ValidationFailure):
+            dns.dnssec.validate(rsasha512_ns, fake_gost_ns_rrsig,
+                                fake_gost_keys, None, rsasha512_when)
+
+    def testUnknownAlgorithm(self):
+        with self.assertRaises(dns.dnssec.ValidationFailure):
+            dns.dnssec.validate(rsasha512_ns, unknown_alg_ns_rrsig,
+                                unknown_alg_keys, None, rsasha512_when)
+
+
+class DNSSECMiscTestCase(unittest.TestCase):
+    def testDigestToBig(self):
+        with self.assertRaises(ValueError):
+            dns.dnssec.DSDigest.make(256)
+
+    def testNSEC3HashTooBig(self):
+        with self.assertRaises(ValueError):
+            dns.dnssec.NSEC3Hash.make(256)
+
+    def testIsNotGOST(self):
+        self.assertTrue(dns.dnssec._is_gost(dns.dnssec.Algorithm.ECCGOST))
+
+    def testUnknownHash(self):
+        with self.assertRaises(dns.dnssec.ValidationFailure):
+            dns.dnssec._make_hash(100)
+
 
 class DNSSECMakeDSTestCase(unittest.TestCase):
 
+    def testMnemonicParser(self):
+        good_ds_mnemonic = dns.rdata.from_text(dns.rdataclass.IN,
+                                               dns.rdatatype.DS,
+                                               '57349 RSASHA1 2 53A79A3E7488AB44FFC56B2D1109F0699D1796DD977E72108B841F96 E47D7013')
+        self.assertEqual(good_ds, good_ds_mnemonic)
+
     def testMakeExampleSHA1DS(self):  # type: () -> None
         for algorithm in ('SHA1', 'sha1', dns.dnssec.DSDigest.SHA1):
             ds = dns.dnssec.make_ds(abs_example, example_sep_key, algorithm)
             self.assertEqual(ds, example_ds_sha1)
+            ds = dns.dnssec.make_ds('example.', example_sep_key, algorithm)
+            self.assertEqual(ds, example_ds_sha1)
 
     def testMakeExampleSHA256DS(self):  # type: () -> None
         for algorithm in ('SHA256', 'sha256', dns.dnssec.DSDigest.SHA256):
@@ -357,5 +497,35 @@ class DNSSECMakeDSTestCase(unittest.TestCase):
             with self.assertRaises(dns.dnssec.UnsupportedAlgorithm):
                 ds = dns.dnssec.make_ds(abs_example, example_sep_key, algorithm)
 
+    def testInvalidDigestType(self):  # type: () -> None
+        digest_type_errors = {
+            0: 'digest type 0 is reserved',
+            5: 'unknown digest type',
+        }
+        for digest_type, msg in digest_type_errors.items():
+            with self.assertRaises(dns.exception.SyntaxError) as cm:
+                dns.rdata.from_text(dns.rdataclass.IN,
+                                    dns.rdatatype.DS,
+                                    f'18673 3 {digest_type} 71b71d4f3e11bbd71b4eff12cde69f7f9215bbe7')
+            self.assertEqual(msg, str(cm.exception))
+
+    def testInvalidDigestLength(self):  # type: () -> None
+        test_records = []
+        for rdata in [example_ds_sha1, example_ds_sha256, example_ds_sha384]:
+            flags, digest = rdata.to_text().rsplit(' ', 1)
+
+            # Make sure the construction is working
+            dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, f'{flags} {digest}')
+
+            test_records.append(f'{flags} {digest[:len(digest)//2]}')  # too short digest
+            test_records.append(f'{flags} {digest*2}')  # too long digest
+
+        for record in test_records:
+            with self.assertRaises(dns.exception.SyntaxError) as cm:
+                dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DS, record)
+
+            self.assertEqual('digest length inconsistent with digest type', str(cm.exception))
+
+
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_doh.py b/tests/test_doh.py
index 4c72c24..793a500 100644
--- a/tests/test_doh.py
+++ b/tests/test_doh.py
@@ -32,8 +32,13 @@ resolver_v4_addresses = []
 resolver_v6_addresses = []
 try:
     with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
+        s.settimeout(4)
         s.connect(('8.8.8.8', 53))
-    resolver_v4_addresses = ['1.1.1.1', '8.8.8.8', '9.9.9.9']
+    resolver_v4_addresses = [
+        '1.1.1.1',
+        '8.8.8.8',
+        # '9.9.9.9',
+    ]
 except Exception:
     pass
 try:
@@ -43,14 +48,15 @@ try:
         '2606:4700:4700::1111',
         # Google says 404
         # '2001:4860:4860::8888',
-        '2620:fe::11'
+        # '2620:fe::fe',
     ]
 except Exception:
     pass
 
 KNOWN_ANYCAST_DOH_RESOLVER_URLS = ['https://cloudflare-dns.com/dns-query',
                                    'https://dns.google/dns-query',
-                                   'https://dns11.quad9.net/dns-query']
+                                   # 'https://dns11.quad9.net/dns-query',
+                                   ]
 
 # Some tests require the internet to be available to run, so let's
 # skip those if it's not there.
@@ -72,13 +78,15 @@ class DNSOverHTTPSTestCase(unittest.TestCase):
     def test_get_request(self):
         nameserver_url = random.choice(KNOWN_ANYCAST_DOH_RESOLVER_URLS)
         q = dns.message.make_query('example.com.', dns.rdatatype.A)
-        r = dns.query.https(q, nameserver_url, session=self.session, post=False)
+        r = dns.query.https(q, nameserver_url, session=self.session, post=False,
+                            timeout=4)
         self.assertTrue(q.is_response(r))
 
     def test_post_request(self):
         nameserver_url = random.choice(KNOWN_ANYCAST_DOH_RESOLVER_URLS)
         q = dns.message.make_query('example.com.', dns.rdatatype.A)
-        r = dns.query.https(q, nameserver_url, session=self.session, post=True)
+        r = dns.query.https(q, nameserver_url, session=self.session, post=True,
+                            timeout=4)
         self.assertTrue(q.is_response(r))
 
     def test_build_url_from_ip(self):
@@ -90,14 +98,14 @@ class DNSOverHTTPSTestCase(unittest.TestCase):
             # https://8.8.8.8/dns-query
             # So we're just going to do GET requests here
             r = dns.query.https(q, nameserver_ip, session=self.session,
-                                post=False)
+                                post=False, timeout=4)
 
             self.assertTrue(q.is_response(r))
         if resolver_v6_addresses:
             nameserver_ip = random.choice(resolver_v6_addresses)
             q = dns.message.make_query('example.com.', dns.rdatatype.A)
             r = dns.query.https(q, nameserver_ip, session=self.session,
-                                post=False)
+                                post=False, timeout=4)
             self.assertTrue(q.is_response(r))
 
     def test_bootstrap_address(self):
@@ -110,16 +118,17 @@ class DNSOverHTTPSTestCase(unittest.TestCase):
             # make sure CleanBrowsing's IP address will fail TLS certificate
             # check
             with self.assertRaises(SSLError):
-                dns.query.https(q, invalid_tls_url, session=self.session)
+                dns.query.https(q, invalid_tls_url, session=self.session,
+                                timeout=4)
             # use host header
             r = dns.query.https(q, valid_tls_url, session=self.session,
-                                bootstrap_address=ip)
+                                bootstrap_address=ip, timeout=4)
             self.assertTrue(q.is_response(r))
 
     def test_new_session(self):
         nameserver_url = random.choice(KNOWN_ANYCAST_DOH_RESOLVER_URLS)
         q = dns.message.make_query('example.com.', dns.rdatatype.A)
-        r = dns.query.https(q, nameserver_url)
+        r = dns.query.https(q, nameserver_url, timeout=4)
         self.assertTrue(q.is_response(r))
 
     def test_resolver(self):
diff --git a/tests/test_edns.py b/tests/test_edns.py
index a640a74..6ba0c99 100644
--- a/tests/test_edns.py
+++ b/tests/test_edns.py
@@ -17,11 +17,13 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import operator
+import struct
 import unittest
 
 from io import BytesIO
 
 import dns.edns
+import dns.wire
 
 class OptionTestCase(unittest.TestCase):
     def testGenericOption(self):
@@ -31,6 +33,7 @@ class OptionTestCase(unittest.TestCase):
         data = io.getvalue()
         self.assertEqual(data, b'data')
         self.assertEqual(dns.edns.option_from_wire(3, data, 0, len(data)), opt)
+        self.assertEqual(str(opt), 'Generic 3')
 
     def testECSOption_prefix_length(self):
         opt = dns.edns.ECSOption('1.2.255.33', 20)
@@ -45,6 +48,13 @@ class OptionTestCase(unittest.TestCase):
         opt.to_wire(io)
         data = io.getvalue()
         self.assertEqual(data, b'\x00\x01\x18\x00\x01\x02\x03')
+        # default srclen
+        opt = dns.edns.ECSOption('1.2.3.4')
+        io = BytesIO()
+        opt.to_wire(io)
+        data = io.getvalue()
+        self.assertEqual(data, b'\x00\x01\x18\x00\x01\x02\x03')
+        self.assertEqual(opt.to_text(), 'ECS 1.2.3.4/24 scope/0')
 
     def testECSOption25(self):
         opt = dns.edns.ECSOption('1.2.3.255', 25)
@@ -104,6 +114,12 @@ class OptionTestCase(unittest.TestCase):
         with self.assertRaises(ValueError):
             dns.edns.ECSOption.from_text('1.2.3.4/twentyfour')
 
+        with self.assertRaises(ValueError):
+            dns.edns.ECSOption.from_text('BOGUS 1.2.3.4/5/6/7')
+
+        with self.assertRaises(ValueError):
+            dns.edns.ECSOption.from_text('1.2.3.4/5/6/7')
+
         with self.assertRaises(ValueError):
             dns.edns.ECSOption.from_text('1.2.3.4/24/O') # <-- that's not a zero
 
@@ -113,6 +129,12 @@ class OptionTestCase(unittest.TestCase):
         with self.assertRaises(ValueError):
             dns.edns.ECSOption.from_text('1.2.3.4/2001:4b98::1/24')
 
+    def testECSOption_from_wire_invalid(self):
+        with self.assertRaises(ValueError):
+            opt = dns.edns.option_from_wire(dns.edns.ECS,
+                                            b'\x00\xff\x18\x00\x01\x02\x03',
+                                            0, 7)
+
     def test_basic_relations(self):
         o1 = dns.edns.ECSOption.from_text('1.2.3.0/24/0')
         o2 = dns.edns.ECSOption.from_text('1.2.4.0/24/0')
@@ -138,3 +160,39 @@ class OptionTestCase(unittest.TestCase):
         self.assertTrue(o1 != o2)
         self.assertFalse(o1 == 123)
         self.assertTrue(o1 != 123)
+
+    def test_option_registration(self):
+        U32OptionType = 9999
+
+        class U32Option(dns.edns.Option):
+            def __init__(self, value=None):
+                super().__init__(U32OptionType)
+                self.value = value
+
+            def to_wire(self, file=None):
+                data = struct.pack('!I', self.value)
+                if file:
+                    file.write(data)
+                else:
+                    return data
+
+            @classmethod
+            def from_wire_parser(cls, otype, parser):
+                (value,) = parser.get_struct('!I')
+                return cls(value)
+
+        try:
+            dns.edns.register_type(U32Option, U32OptionType)
+            generic = dns.edns.GenericOption(U32OptionType, b'\x00\x00\x00\x01')
+            wire1 = generic.to_wire()
+            u32 = dns.edns.option_from_wire_parser(U32OptionType,
+                                                   dns.wire.Parser(wire1))
+            self.assertEqual(u32.value, 1)
+            wire2 = u32.to_wire()
+            self.assertEqual(wire1, wire2)
+            self.assertEqual(u32, generic)
+        finally:
+            dns.edns._type_to_class.pop(U32OptionType, None)
+
+        opt = dns.edns.option_from_wire_parser(9999, dns.wire.Parser(wire1))
+        self.assertEqual(opt, generic)
diff --git a/tests/test_entropy.py b/tests/test_entropy.py
index 828bb68..74092e7 100644
--- a/tests/test_entropy.py
+++ b/tests/test_entropy.py
@@ -13,6 +13,8 @@ class EntropyTestCase(unittest.TestCase):
         self.assertEqual(pool.random_16(), 61532)
         self.assertEqual(pool.random_32(), 4226376065)
         self.assertEqual(pool.random_between(10, 50), 29)
+        # stir in some not-really-entropy to exercise the stir API
+        pool.stir(b'not-really-entropy')
 
     def test_pool_random(self):
         pool = dns.entropy.EntropyPool()
diff --git a/tests/test_flags.py b/tests/test_flags.py
index 479e384..3f5fc69 100644
--- a/tests/test_flags.py
+++ b/tests/test_flags.py
@@ -72,6 +72,10 @@ class FlagsTestCase(unittest.TestCase):
         # In TSIG text mode, it should be BADSIG
         self.assertEqual(dns.rcode.to_text(rcode, True), 'BADSIG')
 
+    def test_unknown_rcode(self):
+        with self.assertRaises(dns.rcode.UnknownRcode):
+            dns.rcode.Rcode.make('BOGUS')
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_grange.py b/tests/test_grange.py
index d52b855..9b5ddd2 100644
--- a/tests/test_grange.py
+++ b/tests/test_grange.py
@@ -64,28 +64,30 @@ class GRangeTestCase(unittest.TestCase):
         self.assertEqual(step, 77)
 
     def testFailFromText1(self):
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             start = 2
             stop = 1
             step = 1
             dns.grange.from_text('%d-%d/%d' % (start, stop, step))
-        self.assertRaises(AssertionError, bad)
+            self.assertTrue(False)
 
     def testFailFromText2(self):
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             start = '-1'
             stop = 3
             step = 1
             dns.grange.from_text('%s-%d/%d' % (start, stop, step))
-        self.assertRaises(dns.exception.SyntaxError, bad)
 
     def testFailFromText3(self):
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             start = 1
             stop = 4
             step = '-2'
             dns.grange.from_text('%d-%d/%s' % (start, stop, step))
-        self.assertRaises(dns.exception.SyntaxError, bad)
+
+    def testFailFromText4(self):
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.grange.from_text('1')
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_immutable.py b/tests/test_immutable.py
new file mode 100644
index 0000000..1a70e3d
--- /dev/null
+++ b/tests/test_immutable.py
@@ -0,0 +1,160 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import unittest
+
+import dns.immutable
+import dns._immutable_attr
+
+try:
+    import dns._immutable_ctx as immutable_ctx
+    _have_contextvars = True
+except ImportError:
+    _have_contextvars = False
+
+    class immutable_ctx:
+        pass
+
+
+class ImmutableTestCase(unittest.TestCase):
+
+    def test_immutable_dict_hash(self):
+        d1 = dns.immutable.Dict({'a': 1, 'b': 2})
+        d2 = dns.immutable.Dict({'b': 2, 'a': 1})
+        d3 = {'b': 2, 'a': 1}
+        self.assertEqual(d1, d2)
+        self.assertEqual(d2, d3)
+        self.assertEqual(hash(d1), hash(d2))
+
+    def test_immutable_dict_hash_cache(self):
+        d = dns.immutable.Dict({'a': 1, 'b': 2})
+        self.assertEqual(d._hash, None)
+        h1 = hash(d)
+        self.assertEqual(d._hash, h1)
+        h2 = hash(d)
+        self.assertEqual(h1, h2)
+
+    def test_constify(self):
+        items = (
+            (bytearray([1, 2, 3]), b'\x01\x02\x03'),
+            ((1, 2, 3), (1, 2, 3)),
+            ((1, [2], 3), (1, (2,), 3)),
+            ([1, 2, 3], (1, 2, 3)),
+            ([1, {'a': [1, 2]}],
+             (1, dns.immutable.Dict({'a': (1, 2)}))),
+            ('hi', 'hi'),
+            (b'hi', b'hi'),
+        )
+        for input, expected in items:
+            self.assertEqual(dns.immutable.constify(input), expected)
+        self.assertIsInstance(dns.immutable.constify({'a': 1}),
+                              dns.immutable.Dict)
+
+
+class DecoratorTestCase(unittest.TestCase):
+
+    immutable_module = dns._immutable_attr
+
+    def make_classes(self):
+        class A:
+            def __init__(self, a, akw=10):
+                self.a = a
+                self.akw = akw
+
+        class B(A):
+            def __init__(self, a, b):
+                super().__init__(a, akw=20)
+                self.b = b
+        B = self.immutable_module.immutable(B)
+
+        # note C is immutable by inheritance
+        class C(B):
+            def __init__(self, a, b, c):
+                super().__init__(a, b)
+                self.c = c
+        C = self.immutable_module.immutable(C)
+
+        class SA:
+            __slots__ = ('a', 'akw')
+            def __init__(self, a, akw=10):
+                self.a = a
+                self.akw = akw
+
+        class SB(A):
+            __slots__ = ('b')
+            def __init__(self, a, b):
+                super().__init__(a, akw=20)
+                self.b = b
+        SB = self.immutable_module.immutable(SB)
+
+        # note SC is immutable by inheritance and has no slots of its own
+        class SC(SB):
+            def __init__(self, a, b, c):
+                super().__init__(a, b)
+                self.c = c
+        SC = self.immutable_module.immutable(SC)
+
+        return ((A, B, C), (SA, SB, SC))
+
+    def test_basic(self):
+        for A, B, C in self.make_classes():
+            a = A(1)
+            self.assertEqual(a.a, 1)
+            self.assertEqual(a.akw, 10)
+            b = B(11, 21)
+            self.assertEqual(b.a, 11)
+            self.assertEqual(b.akw, 20)
+            self.assertEqual(b.b, 21)
+            c = C(111, 211, 311)
+            self.assertEqual(c.a, 111)
+            self.assertEqual(c.akw, 20)
+            self.assertEqual(c.b, 211)
+            self.assertEqual(c.c, 311)
+            # changing A is ok!
+            a.a = 11
+            self.assertEqual(a.a, 11)
+            # changing B is not!
+            with self.assertRaises(TypeError):
+                b.a = 11
+            with self.assertRaises(TypeError):
+                del b.a
+
+    def test_constructor_deletes_attribute(self):
+        class A:
+            def __init__(self, a):
+                self.a = a
+                self.b = a
+                del self.b
+        A = self.immutable_module.immutable(A)
+        a = A(10)
+        self.assertEqual(a.a, 10)
+        self.assertFalse(hasattr(a, 'b'))
+
+    def test_no_collateral_damage(self):
+
+        # A and B are immutable but not related.  The magic that lets
+        # us write to immutable things while initializing B should not let
+        # B mess with A.
+
+        class A:
+            def __init__(self, a):
+                self.a = a
+        A = self.immutable_module.immutable(A)
+
+        class B:
+            def __init__(self, a, b):
+                self.b = a.a + b
+                # rudely attempt to mutate innocent immutable bystander 'a'
+                a.a = 1000
+        B = self.immutable_module.immutable(B)
+
+        a = A(10)
+        self.assertEqual(a.a, 10)
+        with self.assertRaises(TypeError):
+            B(a, 20)
+        self.assertEqual(a.a, 10)
+
+
+@unittest.skipIf(not _have_contextvars, "contextvars not available")
+class CtxDecoratorTestCase(DecoratorTestCase):
+
+    immutable_module = immutable_ctx
diff --git a/tests/test_message.py b/tests/test_message.py
index 4eb48d3..19738e6 100644
--- a/tests/test_message.py
+++ b/tests/test_message.py
@@ -16,7 +16,6 @@
 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
-import os
 import unittest
 import binascii
 
@@ -27,10 +26,12 @@ import dns.name
 import dns.rdataclass
 import dns.rdatatype
 import dns.rrset
+import dns.tsig
 import dns.update
+import dns.rdtypes.ANY.OPT
+import dns.rdtypes.ANY.TSIG
 
-def here(filename):
-    return os.path.join(os.path.dirname(__file__), filename)
+from tests.util import here
 
 query_text = """id 1234
 opcode QUERY
@@ -338,6 +339,11 @@ class MessageTestCase(unittest.TestCase):
         self.assertEqual(m.edns, 0)
         self.assertTrue(m.ednsflags & dns.flags.DO)
 
+    def test_EDNS_default_payload_is_1232(self):
+        m = dns.message.make_query('foo', 'A')
+        m.use_edns()
+        self.assertEqual(m.payload, dns.message.DEFAULT_EDNS_PAYLOAD)
+
     def test_from_file(self):
         m = dns.message.from_file(here('query'))
         expected = dns.message.from_text(query_text)
@@ -365,6 +371,13 @@ class MessageTestCase(unittest.TestCase):
         q.additional = [rrset]
         self.assertEqual(q.sections[3], [rrset])
 
+    def test_is_a_response_empty_question(self):
+        q = dns.message.make_query('www.dnspython.org.', 'a')
+        r = dns.message.make_response(q)
+        r.question = []
+        r.set_rcode(dns.rcode.FORMERR)
+        self.assertTrue(q.is_response(r))
+
     def test_not_a_response(self):
         q = dns.message.QueryMessage(id=1)
         self.assertFalse(q.is_response(q))
@@ -381,6 +394,11 @@ class MessageTestCase(unittest.TestCase):
         q2.id = 1
         r = dns.message.make_response(q2)
         self.assertFalse(q1.is_response(r))
+        # Now set rcode to FORMERR and check again.  It should still
+        # not be a response as we check the question section for FORMERR
+        # if it is present.
+        r.set_rcode(dns.rcode.FORMERR)
+        self.assertFalse(q1.is_response(r))
         # Test the other case of differing questions, where there is
         # something in the response's question section that is not in
         # the question's.  We have to do multiple questions to test
@@ -433,5 +451,236 @@ class MessageTestCase(unittest.TestCase):
         self.assertFalse(isinstance(q2, dns.update.UpdateMessage))
         self.assertEqual(q1, q2)
 
+    def test_truncated_exception_message(self):
+        q = dns.message.Message(id=1)
+        q.flags |= dns.flags.TC
+        te = dns.message.Truncated(message=q)
+        self.assertEqual(te.message(), q)
+
+    def test_bad_opt(self):
+        # Not in addtional
+        q = dns.message.Message(id=1)
+        opt = dns.rdtypes.ANY.OPT.OPT(1200, dns.rdatatype.OPT, ())
+        rrs = dns.rrset.from_rdata(dns.name.root, 0, opt)
+        q.answer.append(rrs)
+        wire = q.to_wire()
+        with self.assertRaises(dns.message.BadEDNS):
+            dns.message.from_wire(wire)
+        # Owner name not root name
+        q = dns.message.Message(id=1)
+        rrs = dns.rrset.from_rdata('foo.', 0, opt)
+        q.additional.append(rrs)
+        wire = q.to_wire()
+        with self.assertRaises(dns.message.BadEDNS):
+            dns.message.from_wire(wire)
+        # Multiple opts
+        q = dns.message.Message(id=1)
+        rrs = dns.rrset.from_rdata(dns.name.root, 0, opt)
+        q.additional.append(rrs)
+        q.additional.append(rrs)
+        wire = q.to_wire()
+        with self.assertRaises(dns.message.BadEDNS):
+            dns.message.from_wire(wire)
+
+    def test_bad_tsig(self):
+        keyname = dns.name.from_text('key.')
+        # Not in addtional
+        q = dns.message.Message(id=1)
+        tsig = dns.rdtypes.ANY.TSIG.TSIG(dns.rdataclass.ANY, dns.rdatatype.TSIG,
+                                         dns.tsig.HMAC_SHA256, 0, 300, b'1234',
+                                         0, 0, b'')
+        rrs = dns.rrset.from_rdata(keyname, 0, tsig)
+        q.answer.append(rrs)
+        wire = q.to_wire()
+        with self.assertRaises(dns.message.BadTSIG):
+            dns.message.from_wire(wire)
+        # Multiple tsigs
+        q = dns.message.Message(id=1)
+        q.additional.append(rrs)
+        q.additional.append(rrs)
+        wire = q.to_wire()
+        with self.assertRaises(dns.message.BadTSIG):
+            dns.message.from_wire(wire)
+        # Class not ANY
+        tsig = dns.rdtypes.ANY.TSIG.TSIG(dns.rdataclass.IN, dns.rdatatype.TSIG,
+                                         dns.tsig.HMAC_SHA256, 0, 300, b'1234',
+                                         0, 0, b'')
+        rrs = dns.rrset.from_rdata(keyname, 0, tsig)
+        wire = q.to_wire()
+        with self.assertRaises(dns.message.BadTSIG):
+            dns.message.from_wire(wire)
+
+    def test_read_no_content_message(self):
+        m = dns.message.from_text(';comment')
+        self.assertIsInstance(m, dns.message.QueryMessage)
+
+    def test_eflags_turns_on_edns(self):
+        m = dns.message.from_text('eflags DO')
+        self.assertIsInstance(m, dns.message.QueryMessage)
+        self.assertEqual(m.edns, 0)
+
+    def test_payload_turns_on_edns(self):
+        m = dns.message.from_text('payload 1200')
+        self.assertIsInstance(m, dns.message.QueryMessage)
+        self.assertEqual(m.payload, 1200)
+
+    def test_bogus_header(self):
+        with self.assertRaises(dns.message.UnknownHeaderField):
+            dns.message.from_text('bogus foo')
+
+    def test_question_only(self):
+        m = dns.message.from_text(answer_text)
+        w = m.to_wire()
+        r = dns.message.from_wire(w, question_only=True)
+        self.assertEqual(r.id, m.id)
+        self.assertEqual(r.question[0], m.question[0])
+        self.assertEqual(len(r.answer), 0)
+        self.assertEqual(len(r.authority), 0)
+        self.assertEqual(len(r.additional), 0)
+
+    def test_bad_resolve_chaining(self):
+        r = dns.message.make_query('www.dnspython.org.', 'a')
+        with self.assertRaises(dns.message.NotQueryResponse):
+            r.resolve_chaining()
+        r.flags |= dns.flags.QR
+        r.id = 1
+        r.find_rrset(r.question, dns.name.from_text('example'),
+                     dns.rdataclass.IN, dns.rdatatype.A, create=True,
+                     force_unique=True)
+        with self.assertRaises(dns.exception.FormError):
+            r.resolve_chaining()
+
+    def test_resolve_chaining_no_infinite_loop(self):
+        r = dns.message.from_text('''id 1
+flags QR
+;QUESTION
+www.example. IN CNAME
+;AUTHORITY
+example. 300 IN SOA . . 1 2 3 4 5
+''')
+        # passing is actuall not going into an infinite loop in this call
+        result = r.resolve_chaining()
+        self.assertEqual(result.canonical_name,
+                         dns.name.from_text('www.example.'))
+        self.assertEqual(result.minimum_ttl, 5)
+        self.assertIsNone(result.answer)
+
+    def test_bad_text_questions(self):
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.message.from_text('''id 1
+;QUESTION
+example.
+''')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.message.from_text('''id 1
+;QUESTION
+example. IN
+''')
+        with self.assertRaises(dns.rdatatype.UnknownRdatatype):
+            dns.message.from_text('''id 1
+;QUESTION
+example. INA
+''')
+        with self.assertRaises(dns.rdatatype.UnknownRdatatype):
+            dns.message.from_text('''id 1
+;QUESTION
+example. IN BOGUS
+''')
+
+    def test_bad_text_rrs(self):
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.message.from_text('''id 1
+flags QR
+;QUESTION
+example. IN A
+;ANSWER
+example.
+''')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.message.from_text('''id 1
+flags QR
+;QUESTION
+example. IN A
+;ANSWER
+example. IN
+''')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.message.from_text('''id 1
+flags QR
+;QUESTION
+example. IN A
+;ANSWER
+example. 300
+''')
+        with self.assertRaises(dns.rdatatype.UnknownRdatatype):
+            dns.message.from_text('''id 1
+flags QR
+;QUESTION
+example. IN A
+;ANSWER
+example. 30a IN A
+''')
+        with self.assertRaises(dns.rdatatype.UnknownRdatatype):
+            dns.message.from_text('''id 1
+flags QR
+;QUESTION
+example. IN A
+;ANSWER
+example. 300 INA A
+''')
+        with self.assertRaises(dns.exception.UnexpectedEnd):
+            dns.message.from_text('''id 1
+flags QR
+;QUESTION
+example. IN A
+;ANSWER
+example. 300 IN A
+''')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.message.from_text('''id 1
+flags QR
+opcode UPDATE
+;ZONE
+example. IN SOA
+;UPDATE
+example. 300 IN A
+''')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.message.from_text('''id 1
+flags QR
+opcode UPDATE
+;ZONE
+example. IN SOA
+;UPDATE
+example. 300 NONE A
+''')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.message.from_text('''id 1
+flags QR
+opcode UPDATE
+;ZONE
+example. IN SOA
+;PREREQ
+example. 300 NONE A 10.0.0.1
+''')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.message.from_text('''id 1
+flags QR
+;ANSWER
+            300 IN A 10.0.0.1
+''')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.message.from_text('''id 1
+flags QR
+;QUESTION
+            IN SOA
+''')
+
+    def test_from_wire_makes_Flag(self):
+        m = dns.message.from_wire(goodwire)
+        self.assertIsInstance(m.flags, dns.flags.Flag)
+        self.assertEqual(m.flags, dns.flags.Flag.RD)
+
+
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_name.py b/tests/test_name.py
index dec8c5f..f91d7e6 100644
--- a/tests/test_name.py
+++ b/tests/test_name.py
@@ -19,6 +19,7 @@
 from typing import Dict # pylint: disable=unused-import
 import copy
 import operator
+import pickle
 import unittest
 
 from io import BytesIO
@@ -465,6 +466,30 @@ class NameTestCase(unittest.TestCase):
             n.to_wire(f, compress)
         self.assertRaises(dns.name.NeedAbsoluteNameOrOrigin, bad)
 
+    def testGiantCompressionTable(self):
+        # Only the first 16KiB of a message can have compression pointers.
+        f = BytesIO()
+        compress = {}  # type: Dict[dns.name.Name,int]
+        # exactly 16 bytes encoded
+        n = dns.name.from_text('0000000000.com.')
+        n.to_wire(f, compress)
+        # There are now two entries in the compression table (for the full
+        # name, and for the com. suffix.
+        self.assertEqual(len(compress), 2)
+        for i in range(1023):
+            # exactly 16 bytes encoded with compression
+            n = dns.name.from_text(f'{i:013d}.com')
+            n.to_wire(f, compress)
+        # There are now 1025 entries in the compression table with
+        # the last entry at offset 16368.
+        self.assertEqual(len(compress), 1025)
+        self.assertEqual(compress[n], 16368)
+        # Adding another name should not increase the size of the compression
+        # table, as the pointer would be at offset 16384, which is too big.
+        n = dns.name.from_text('toobig.com.')
+        n.to_wire(f, compress)
+        self.assertEqual(len(compress), 1025)
+
     def testSplit1(self):
         n = dns.name.from_text('foo.bar.')
         (prefix, suffix) = n.split(2)
@@ -781,12 +806,30 @@ class NameTestCase(unittest.TestCase):
     @unittest.skipUnless(dns.name.have_idna_2008,
                          'Python idna cannot be imported; no IDNA2008')
     def testToUnicode4(self):
-        if dns.name.have_idna_2008:
-            n = dns.name.from_text('ドメイン.テスト',
-                                   idna_codec=dns.name.IDNA_2008)
-            s = n.to_unicode()
-            self.assertEqual(str(n), 'xn--eckwd4c7c.xn--zckzah.')
-            self.assertEqual(s, 'ドメイン.テスト.')
+        n = dns.name.from_text('ドメイン.テスト',
+                               idna_codec=dns.name.IDNA_2008)
+        s = n.to_unicode()
+        self.assertEqual(str(n), 'xn--eckwd4c7c.xn--zckzah.')
+        self.assertEqual(s, 'ドメイン.テスト.')
+
+    @unittest.skipUnless(dns.name.have_idna_2008,
+                         'Python idna cannot be imported; no IDNA2008')
+    def testToUnicode5(self):
+        # Exercise UTS 46 remapping in decode.  This doesn't normally happen
+        # as you can see from us having to instantiate the codec as
+        # transitional with strict decoding, not one of our usual choices.
+        codec = dns.name.IDNA2008Codec(True, True, False, True)
+        n = dns.name.from_text('xn--gro-7ka.com')
+        self.assertEqual(n.to_unicode(idna_codec=codec),
+                         'gross.com.')
+
+    @unittest.skipUnless(dns.name.have_idna_2008,
+                         'Python idna cannot be imported; no IDNA2008')
+    def testToUnicode6(self):
+        # Test strict 2008 decoding without UTS 46
+        n = dns.name.from_text('xn--gro-7ka.com')
+        self.assertEqual(n.to_unicode(idna_codec=dns.name.IDNA_2008_Strict),
+                         'groß.com.')
 
     def testDefaultDecodeIsJustPunycode(self):
         # groß.com. in IDNA2008 form, pre-encoded.
@@ -892,6 +935,11 @@ class NameTestCase(unittest.TestCase):
         text = dns.reversename.to_address(n, v6_origin=origin)
         self.assertEqual(text, e)
 
+    def testUnknownReverseOrigin(self):
+        n = dns.name.from_text('1.2.3.4.unknown.')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.reversename.to_address(n)
+
     def testE164ToEnum(self):
         text = '+1 650 555 1212'
         e = dns.name.from_text('2.1.2.1.5.5.5.0.5.6.1.e164.arpa.')
@@ -1032,5 +1080,11 @@ class NameTestCase(unittest.TestCase):
         n = dns.name.from_unicode('Königsgäßchen;\ttext')
         self.assertEqual(n.to_unicode(), 'königsgässchen\\;\\009text.')
 
+    def test_pickle(self):
+        n1 = dns.name.from_text('foo.example')
+        p = pickle.dumps(n1)
+        n2 = pickle.loads(p)
+        self.assertEqual(n1, n2)
+
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_nsec3_hash.py b/tests/test_nsec3_hash.py
index 6f4eee6..f7c4337 100644
--- a/tests/test_nsec3_hash.py
+++ b/tests/test_nsec3_hash.py
@@ -1,3 +1,5 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
 import unittest
 
 from dns import dnssec, name
diff --git a/tests/test_ntoaaton.py b/tests/test_ntoaaton.py
index d256c1c..7e30bce 100644
--- a/tests/test_ntoaaton.py
+++ b/tests/test_ntoaaton.py
@@ -163,6 +163,12 @@ class NtoAAtoNTestCase(unittest.TestCase):
         t = ntoa6(b)
         self.assertEqual(t, '::0.1.255.255')
 
+    def test_ntoa15(self):
+        # This exercises the current_len > best_len branch in the <= case.
+        b = binascii.unhexlify(b'0000ffff00000000ffff00000000ffff')
+        t = ntoa6(b)
+        self.assertEqual(t, '0:ffff::ffff:0:0:ffff')
+
     def test_bad_ntoa1(self):
         def bad():
             ntoa6(b'')
diff --git a/tests/test_processing_order.py b/tests/test_processing_order.py
new file mode 100644
index 0000000..45a90cf
--- /dev/null
+++ b/tests/test_processing_order.py
@@ -0,0 +1,132 @@
+
+import dns.rdata
+import dns.rdataset
+
+
+def test_processing_order_shuffle():
+    rds = dns.rdataset.from_text('in', 'a', 300,
+                                 '10.0.0.1', '10.0.0.2', '10.0.0.3')
+    seen = set()
+    for i in range(100):
+        po = rds.processing_order()
+        assert len(po) == 3
+        for j in range(3):
+            assert rds[j] in po
+        seen.add(tuple(po))
+    assert len(seen) == 6
+
+
+def test_processing_order_priority_mx():
+    rds = dns.rdataset.from_text('in', 'mx', 300,
+                                 '10 a', '20 b', '20 c')
+    seen = set()
+    for i in range(100):
+        po = rds.processing_order()
+        assert len(po) == 3
+        for j in range(3):
+            assert rds[j] in po
+        assert rds[0] == po[0]
+        seen.add(tuple(po))
+    assert len(seen) == 2
+
+
+def test_processing_order_priority_weighted():
+    rds = dns.rdataset.from_text('in', 'srv', 300,
+                                 '1 10 1234 a', '2 90 1234 b', '2 10 1234 c')
+    seen = set()
+    weight_90_count = 0
+    weight_10_count = 0
+    for i in range(100):
+        po = rds.processing_order()
+        assert len(po) == 3
+        for j in range(3):
+            assert rds[j] in po
+        assert rds[0] == po[0]
+        if po[1].weight == 90:
+            weight_90_count += 1
+        else:
+            assert po[1].weight == 10
+            weight_10_count += 1
+        seen.add(tuple(po))
+    assert len(seen) == 2
+    # We can't assert anything with certainty given these are random
+    # draws, but it's super likely that weight_90_count > weight_10_count,
+    # so we just assert that.
+    assert weight_90_count > weight_10_count
+
+
+def test_processing_order_priority_naptr():
+    rds = dns.rdataset.from_text('in', 'naptr', 300,
+                                 '1 10 a b c foo.', '1 20 a b c foo.',
+                                 '2 10 a b c foo.', '2 10 d e f bar.')
+    seen = set()
+    for i in range(100):
+        po = rds.processing_order()
+        assert len(po) == 4
+        for j in range(4):
+            assert rds[j] in po
+        assert rds[0] == po[0]
+        assert rds[1] == po[1]
+        seen.add(tuple(po))
+    assert len(seen) == 2
+
+
+def test_processing_order_empty():
+    rds = dns.rdataset.from_text('in', 'naptr', 300)
+    po = rds.processing_order()
+    assert po == []
+
+
+def test_processing_singleton_priority():
+    rds = dns.rdataset.from_text('in', 'mx', 300, '10 a')
+    po = rds.processing_order()
+    print(po)
+    assert po == [rds[0]]
+
+
+def test_processing_singleton_weighted():
+    rds = dns.rdataset.from_text('in', 'srv', 300, '1 10 1234 a')
+    po = rds.processing_order()
+    print(po)
+    assert po == [rds[0]]
+
+
+def test_processing_all_zero_weight_srv():
+    rds = dns.rdataset.from_text('in', 'srv', 300,
+                                 '1 0 1234 a', '1 0 1234 b', '1 0 1234 c')
+    seen = set()
+    for i in range(100):
+        po = rds.processing_order()
+        assert len(po) == 3
+        for j in range(3):
+            assert rds[j] in po
+        seen.add(tuple(po))
+    assert len(seen) == 6
+
+
+def test_processing_order_uri():
+    # We're testing here just to provide coverage for URI methods; the
+    # testing of the weighting algorithm is done above in tests with
+    # SRV.
+    rds = dns.rdataset.from_text('in', 'uri', 300,
+                                 '1 1 "ftp://ftp1.example.com/public"',
+                                 '2 2 "ftp://ftp2.example.com/public"',
+                                 '3 3 "ftp://ftp3.example.com/public"')
+    po = rds.processing_order()
+    assert len(po) == 3
+    for i in range(3):
+        assert po[i] == rds[i]
+
+
+def test_processing_order_svcb():
+    # We're testing here just to provide coverage for SVCB methods; the
+    # testing of the priority algorithm is done above in tests with
+    # MX and NAPTR.
+    rds = dns.rdataset.from_text('in', 'svcb', 300,
+                                 "1 . mandatory=alpn alpn=h2",
+                                 "2 . mandatory=alpn alpn=h2",
+                                 "3 . mandatory=alpn alpn=h2")
+    po = rds.processing_order()
+    assert len(po) == 3
+    for i in range(3):
+        assert po[i] == rds[i]
diff --git a/tests/test_query.py b/tests/test_query.py
index 498128d..2cff377 100644
--- a/tests/test_query.py
+++ b/tests/test_query.py
@@ -68,7 +68,7 @@ for (af, address) in ((socket.AF_INET, '8.8.8.8'),
     except Exception:
         pass
 
-keyring = dns.tsigkeyring.from_text({'name' : 'tDz6cfXXGtNivRpQ98hr6A=='})
+keyring = dns.tsigkeyring.from_text({'name': 'tDz6cfXXGtNivRpQ98hr6A=='})
 
 @unittest.skipIf(not _network_available, "Internet not reachable")
 class QueryTests(unittest.TestCase):
@@ -77,7 +77,7 @@ class QueryTests(unittest.TestCase):
         for address in query_addresses:
             qname = dns.name.from_text('dns.google.')
             q = dns.message.make_query(qname, dns.rdatatype.A)
-            response = dns.query.udp(q, address)
+            response = dns.query.udp(q, address, timeout=2)
             rrs = response.get_rrset(response.answer, qname,
                                      dns.rdataclass.IN, dns.rdatatype.A)
             self.assertTrue(rrs is not None)
@@ -92,7 +92,7 @@ class QueryTests(unittest.TestCase):
                 s.setblocking(0)
                 qname = dns.name.from_text('dns.google.')
                 q = dns.message.make_query(qname, dns.rdatatype.A)
-                response = dns.query.udp(q, address, sock=s)
+                response = dns.query.udp(q, address, sock=s, timeout=2)
                 rrs = response.get_rrset(response.answer, qname,
                                          dns.rdataclass.IN, dns.rdatatype.A)
                 self.assertTrue(rrs is not None)
@@ -104,7 +104,7 @@ class QueryTests(unittest.TestCase):
         for address in query_addresses:
             qname = dns.name.from_text('dns.google.')
             q = dns.message.make_query(qname, dns.rdatatype.A)
-            response = dns.query.tcp(q, address)
+            response = dns.query.tcp(q, address, timeout=2)
             rrs = response.get_rrset(response.answer, qname,
                                      dns.rdataclass.IN, dns.rdatatype.A)
             self.assertTrue(rrs is not None)
@@ -117,11 +117,12 @@ class QueryTests(unittest.TestCase):
             with socket.socket(dns.inet.af_for_address(address),
                                socket.SOCK_STREAM) as s:
                 ll = dns.inet.low_level_address_tuple((address, 53))
+                s.settimeout(2)
                 s.connect(ll)
                 s.setblocking(0)
                 qname = dns.name.from_text('dns.google.')
                 q = dns.message.make_query(qname, dns.rdatatype.A)
-                response = dns.query.tcp(q, None, sock=s)
+                response = dns.query.tcp(q, None, sock=s, timeout=2)
                 rrs = response.get_rrset(response.answer, qname,
                                          dns.rdataclass.IN, dns.rdatatype.A)
                 self.assertTrue(rrs is not None)
@@ -133,7 +134,7 @@ class QueryTests(unittest.TestCase):
         for address in query_addresses:
             qname = dns.name.from_text('dns.google.')
             q = dns.message.make_query(qname, dns.rdatatype.A)
-            response = dns.query.tls(q, address)
+            response = dns.query.tls(q, address, timeout=2)
             rrs = response.get_rrset(response.answer, qname,
                                      dns.rdataclass.IN, dns.rdatatype.A)
             self.assertTrue(rrs is not None)
@@ -147,13 +148,14 @@ class QueryTests(unittest.TestCase):
             with socket.socket(dns.inet.af_for_address(address),
                                socket.SOCK_STREAM) as base_s:
                 ll = dns.inet.low_level_address_tuple((address, 853))
+                base_s.settimeout(2)
                 base_s.connect(ll)
                 ctx = ssl.create_default_context()
                 with ctx.wrap_socket(base_s, server_hostname='dns.google') as s:
                     s.setblocking(0)
                     qname = dns.name.from_text('dns.google.')
                     q = dns.message.make_query(qname, dns.rdatatype.A)
-                    response = dns.query.tls(q, None, sock=s)
+                    response = dns.query.tls(q, None, sock=s, timeout=2)
                     rrs = response.get_rrset(response.answer, qname,
                                              dns.rdataclass.IN, dns.rdatatype.A)
                     self.assertTrue(rrs is not None)
@@ -165,7 +167,7 @@ class QueryTests(unittest.TestCase):
         for address in query_addresses:
             qname = dns.name.from_text('.')
             q = dns.message.make_query(qname, dns.rdatatype.DNSKEY)
-            (_, tcp) = dns.query.udp_with_fallback(q, address)
+            (_, tcp) = dns.query.udp_with_fallback(q, address, timeout=2)
             self.assertTrue(tcp)
 
     def testQueryUDPFallbackWithSocket(self):
@@ -175,20 +177,22 @@ class QueryTests(unittest.TestCase):
                 udp_s.setblocking(0)
                 with socket.socket(af, socket.SOCK_STREAM) as tcp_s:
                     ll = dns.inet.low_level_address_tuple((address, 53))
+                    tcp_s.settimeout(2)
                     tcp_s.connect(ll)
                     tcp_s.setblocking(0)
                     qname = dns.name.from_text('.')
                     q = dns.message.make_query(qname, dns.rdatatype.DNSKEY)
                     (_, tcp) = dns.query.udp_with_fallback(q, address,
-                                                          udp_sock=udp_s,
-                                                          tcp_sock=tcp_s)
+                                                           udp_sock=udp_s,
+                                                           tcp_sock=tcp_s,
+                                                           timeout=2)
                     self.assertTrue(tcp)
 
     def testQueryUDPFallbackNoFallback(self):
         for address in query_addresses:
             qname = dns.name.from_text('dns.google.')
             q = dns.message.make_query(qname, dns.rdatatype.A)
-            (_, tcp) = dns.query.udp_with_fallback(q, address)
+            (_, tcp) = dns.query.udp_with_fallback(q, address, timeout=2)
             self.assertFalse(tcp)
 
     def testUDPReceiveQuery(self):
@@ -276,7 +280,6 @@ class AddressesEqualTestCase(unittest.TestCase):
 
 
 axfr_zone = '''
-$ORIGIN example.
 $TTL 300
 @ SOA ns1 root 1 7200 900 1209600 86400
 @ NS ns1
@@ -288,7 +291,7 @@ ns2 A 10.0.0.1
 class AXFRNanoNameserver(Server):
 
     def handle(self, request):
-        self.zone = dns.zone.from_text(axfr_zone, origin='example')
+        self.zone = dns.zone.from_text(axfr_zone, origin=self.origin)
         self.origin = self.zone.origin
         items = []
         soa = self.zone.find_rrset(dns.name.empty, dns.rdatatype.SOA)
@@ -381,7 +384,7 @@ class XfrTests(unittest.TestCase):
 
     def test_axfr(self):
         expected = dns.zone.from_text(axfr_zone, origin='example')
-        with AXFRNanoNameserver() as ns:
+        with AXFRNanoNameserver(origin='example') as ns:
             xfr = dns.query.xfr(ns.tcp_address[0], 'example',
                                 port=ns.tcp_address[1])
             zone = dns.zone.from_xfr(xfr)
@@ -389,16 +392,25 @@ class XfrTests(unittest.TestCase):
 
     def test_axfr_tsig(self):
         expected = dns.zone.from_text(axfr_zone, origin='example')
-        with AXFRNanoNameserver(keyring=keyring) as ns:
+        with AXFRNanoNameserver(origin='example', keyring=keyring) as ns:
             xfr = dns.query.xfr(ns.tcp_address[0], 'example',
                                 port=ns.tcp_address[1],
                                 keyring=keyring, keyname='name')
             zone = dns.zone.from_xfr(xfr)
             self.assertEqual(zone, expected)
 
+    def test_axfr_root_tsig(self):
+        expected = dns.zone.from_text(axfr_zone, origin='.')
+        with AXFRNanoNameserver(origin='.', keyring=keyring) as ns:
+            xfr = dns.query.xfr(ns.tcp_address[0], '.',
+                                port=ns.tcp_address[1],
+                                keyring=keyring, keyname='name')
+            zone = dns.zone.from_xfr(xfr)
+            self.assertEqual(zone, expected)
+
     def test_axfr_udp(self):
         def bad():
-            with AXFRNanoNameserver() as ns:
+            with AXFRNanoNameserver(origin='example') as ns:
                 xfr = dns.query.xfr(ns.udp_address[0], 'example',
                                     port=ns.udp_address[1], use_udp=True)
                 l = list(xfr)
@@ -541,16 +553,31 @@ class LowLevelWaitTests(unittest.TestCase):
             l.close()
             r.close()
 
-    def test_select_for(self):
-        # we test this explicitly in case _wait_for didn't test it (i.e.
-        # if the default polling backing is _poll_for)
-        try:
-            (l, r) = socket.socketpair()
-            # simple timeout
-            self.assertFalse(dns.query._select_for(l, False, False, False,
-                                                   0.05))
-            # writable no timeout
-            self.assertTrue(dns.query._select_for(l, False, True, False, None))
-        finally:
-            l.close()
-            r.close()
+
+class MiscTests(unittest.TestCase):
+    def test_matches_destination(self):
+        self.assertTrue(dns.query._matches_destination(socket.AF_INET,
+                                                       ('10.0.0.1', 1234),
+                                                       ('10.0.0.1', 1234),
+                                                       True))
+        self.assertTrue(dns.query._matches_destination(socket.AF_INET6,
+                                                       ('1::2', 1234),
+                                                       ('0001::2', 1234),
+                                                       True))
+        self.assertTrue(dns.query._matches_destination(socket.AF_INET,
+                                                       ('10.0.0.1', 1234),
+                                                       None,
+                                                       True))
+        self.assertFalse(dns.query._matches_destination(socket.AF_INET,
+                                                        ('10.0.0.1', 1234),
+                                                        ('10.0.0.2', 1234),
+                                                        True))
+        self.assertFalse(dns.query._matches_destination(socket.AF_INET,
+                                                        ('10.0.0.1', 1234),
+                                                        ('10.0.0.1', 1235),
+                                                        True))
+        with self.assertRaises(dns.query.UnexpectedSource):
+            dns.query._matches_destination(socket.AF_INET,
+                                           ('10.0.0.1', 1234),
+                                           ('10.0.0.1', 1235),
+                                           False)
diff --git a/tests/test_rdata.py b/tests/test_rdata.py
index 022642f..45ceb29 100644
--- a/tests/test_rdata.py
+++ b/tests/test_rdata.py
@@ -16,7 +16,6 @@
 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
-import binascii
 import io
 import operator
 import pickle
@@ -31,9 +30,18 @@ import dns.rdataclass
 import dns.rdataset
 import dns.rdatatype
 from dns.rdtypes.ANY.OPT import OPT
+from dns.rdtypes.ANY.LOC import LOC
+from dns.rdtypes.ANY.GPOS import GPOS
+import dns.rdtypes.ANY.RRSIG
+import dns.rdtypes.util
+import dns.tokenizer
+import dns.ttl
+import dns.wire
 
 import tests.stxt_module
 import tests.ttxt_module
+import tests.md_module
+from tests.util import here
 
 class RdataTestCase(unittest.TestCase):
 
@@ -90,9 +98,24 @@ class RdataTestCase(unittest.TestCase):
 
     def test_invalid_replace(self):
         a1 = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, "1.2.3.4")
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             a1.replace(address="bogus")
-        self.assertRaises(dns.exception.SyntaxError, bad)
+
+    def test_replace_comment(self):
+        a1 = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A,
+                                 "1.2.3.4 ;foo")
+        self.assertEqual(a1.rdcomment, "foo")
+        a2 = a1.replace(rdcomment="bar")
+        self.assertEqual(a1, a2)
+        self.assertEqual(a1.rdcomment, "foo")
+        self.assertEqual(a2.rdcomment, "bar")
+
+    def test_no_replace_class_or_type(self):
+        a1 = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, "1.2.3.4")
+        with self.assertRaises(AttributeError):
+            a1.replace(rdclass=255)
+        with self.assertRaises(AttributeError):
+            a1.replace(rdtype=2)
 
     def test_to_generic(self):
         a = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, "1.2.3.4")
@@ -268,6 +291,11 @@ class RdataTestCase(unittest.TestCase):
         self.assertEqual(rda, rdb)
 
     def test_misc_good_LOC_text(self):
+        # test just degrees
+        self.equal_loc('60 N 24 39 0.000 E 10.00m 20m 2000m 20m',
+                       '60 0 0 N 24 39 0.000 E 10.00m 20m 2000m 20m')
+        self.equal_loc('60 0 0 N 24 E 10.00m 20m 2000m 20m',
+                       '60 0 0 N 24 0 0 E 10.00m 20m 2000m 20m')
         # test variable length latitude
         self.equal_loc('60 9 0.510 N 24 39 0.000 E 10.00m 20m 2000m 20m',
                        '60 9 0.51 N 24 39 0.000 E 10.00m 20m 2000m 20m')
@@ -282,6 +310,27 @@ class RdataTestCase(unittest.TestCase):
                        '60 9 0.000 N 24 39 0.5 E 10.00m 20m 2000m 20m')
         self.equal_loc('60 9 0.000 N 24 39 1.000 E 10.00m 20m 2000m 20m',
                        '60 9 0.000 N 24 39 1 E 10.00m 20m 2000m 20m')
+        # test siz, hp, vp defaults
+        self.equal_loc('60 9 0.510 N 24 39 0.000 E 10.00m',
+                       '60 9 0.51 N 24 39 0.000 E 10.00m 1m 10000m 10m')
+        self.equal_loc('60 9 0.510 N 24 39 0.000 E 10.00m 2m',
+                       '60 9 0.51 N 24 39 0.000 E 10.00m 2m 10000m 10m')
+        self.equal_loc('60 9 0.510 N 24 39 0.000 E 10.00m 2m 2000m',
+                       '60 9 0.51 N 24 39 0.000 E 10.00m 2m 2000m 10m')
+        # test siz, hp, vp optional units
+        self.equal_loc('60 9 0.510 N 24 39 0.000 E 1m 20m 2000m 20m',
+                       '60 9 0.51 N 24 39 0.000 E 1 20 2000 20')
+
+    def test_LOC_to_text_SW_hemispheres(self):
+        # As an extra, we test int->float conversion in the constructor
+        loc = LOC(dns.rdataclass.IN, dns.rdatatype.LOC, -60, -24, 1)
+        text = '60 0 0.000 S 24 0 0.000 W 0.01m'
+        self.assertEqual(loc.to_text(), text)
+
+    def test_zero_size(self):
+        # This is to exercise the 0 path in _exponent_of.
+        loc = dns.rdata.from_text('in', 'loc', '60 S 24 W 1 0')
+        self.assertEqual(loc.size, 0.0)
 
     def test_bad_LOC_text(self):
         bad_locs = ['60 9 a.000 N 24 39 0.000 E 10.00m 20m 2000m 20m',
@@ -304,12 +353,9 @@ class RdataTestCase(unittest.TestCase):
                     '60 9 0.000 N 24 39 0.000 E 10.00m 20m 100000000m 20m',
                     '60 9 0.000 N 24 39 0.000 E 10.00m 20m 20m 100000000m',
                     ]
-        def bad(text):
-            rd = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.LOC,
-                                     text)
         for loc in bad_locs:
-            self.assertRaises(dns.exception.SyntaxError,
-                              lambda: bad(loc))
+            with self.assertRaises(dns.exception.SyntaxError):
+                dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.LOC, loc)
 
     def test_bad_LOC_wire(self):
         bad_locs = [(0, 0, 0, 0x934fd901, 0x80000000, 100),
@@ -324,12 +370,15 @@ class RdataTestCase(unittest.TestCase):
                     (0, 0, 0x0a, 0x80000000, 0x80000000, 100),
                     ]
         for t in bad_locs:
-            wire = struct.pack('!BBBBIII', 0, t[0], t[1], t[2],
-                               t[3], t[4], t[5])
-            self.assertRaises(dns.exception.FormError,
-                              lambda: dns.rdata.from_wire(dns.rdataclass.IN,
-                                                          dns.rdatatype.LOC,
-                                                          wire, 0, len(wire)))
+            with self.assertRaises(dns.exception.FormError):
+                wire = struct.pack('!BBBBIII', 0, t[0], t[1], t[2],
+                                   t[3], t[4], t[5])
+                dns.rdata.from_wire(dns.rdataclass.IN, dns.rdatatype.LOC,
+                                    wire, 0, len(wire))
+            with self.assertRaises(dns.exception.FormError):
+                wire = struct.pack('!BBBBIII', 1, 0, 0, 0, 0, 0, 0)
+                dns.rdata.from_wire(dns.rdataclass.IN, dns.rdatatype.LOC,
+                                    wire, 0, len(wire))
 
     def equal_wks(self, a, b):
         rda = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.WKS, a)
@@ -341,10 +390,28 @@ class RdataTestCase(unittest.TestCase):
         self.equal_wks('10.0.0.1 udp ( domain )', '10.0.0.1 17 ( 53 )')
 
     def test_misc_bad_WKS_text(self):
-        def bad():
+        try:
             dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.WKS,
                                 '10.0.0.1 132 ( domain )')
-        self.assertRaises(NotImplementedError, bad)
+            self.assertTrue(False)  # should not happen
+        except dns.exception.SyntaxError as e:
+            self.assertIsInstance(e.__cause__, NotImplementedError)
+
+    def test_GPOS_float_converters(self):
+        rd = dns.rdata.from_text('in', 'gpos', '49 0 0')
+        self.assertEqual(rd.float_latitude, 49.0)
+        self.assertEqual(rd.float_longitude, 0.0)
+        self.assertEqual(rd.float_altitude, 0.0)
+
+    def test_GPOS_constructor_conversion(self):
+        rd = GPOS(dns.rdataclass.IN, dns.rdatatype.GPOS, 49.0, 0.0, 0.0)
+        self.assertEqual(rd.float_latitude, 49.0)
+        self.assertEqual(rd.float_longitude, 0.0)
+        self.assertEqual(rd.float_altitude, 0.0)
+        rd = GPOS(dns.rdataclass.IN, dns.rdatatype.GPOS, 49, 0, 0)
+        self.assertEqual(rd.float_latitude, 49.0)
+        self.assertEqual(rd.float_longitude, 0.0)
+        self.assertEqual(rd.float_altitude, 0.0)
 
     def test_bad_GPOS_text(self):
         bad_gpos = ['"-" "116.8652" "250"',
@@ -365,12 +432,9 @@ class RdataTestCase(unittest.TestCase):
                     '"0" "180.1" "0"',
                     '"0" "-180.1" "0"',
                     ]
-        def bad(text):
-            rd = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.GPOS,
-                                     text)
         for gpos in bad_gpos:
-            self.assertRaises(dns.exception.FormError,
-                              lambda: bad(gpos))
+            with self.assertRaises(dns.exception.SyntaxError):
+                dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.GPOS, gpos)
 
     def test_bad_GPOS_wire(self):
         bad_gpos = [b'\x01',
@@ -401,19 +465,406 @@ class RdataTestCase(unittest.TestCase):
         self.assertEqual(repr(opt), '<DNS CLASS4096 OPT rdata: >')
 
     def test_opt_short_lengths(self):
-        def bad1():
+        with self.assertRaises(dns.exception.FormError):
             parser = dns.wire.Parser(bytes.fromhex('f00102'))
-            opt = OPT.from_wire_parser(4096, dns.rdatatype.OPT, parser)
-        self.assertRaises(dns.exception.FormError, bad1)
-        def bad2():
+            OPT.from_wire_parser(4096, dns.rdatatype.OPT, parser)
+        with self.assertRaises(dns.exception.FormError):
             parser = dns.wire.Parser(bytes.fromhex('f00100030000'))
-            opt = OPT.from_wire_parser(4096, dns.rdatatype.OPT, parser)
-        self.assertRaises(dns.exception.FormError, bad2)
+            OPT.from_wire_parser(4096, dns.rdatatype.OPT, parser)
 
     def test_from_wire_parser(self):
         wire = bytes.fromhex('01020304')
         rdata = dns.rdata.from_wire('in', 'a', wire, 0, 4)
         self.assertEqual(rdata, dns.rdata.from_text('in', 'a', '1.2.3.4'))
 
+    def test_unpickle(self):
+        expected_mx = dns.rdata.from_text('in', 'mx', '10 mx.example.')
+        with open(here('mx-2-0.pickle'), 'rb') as f:
+            mx = pickle.load(f)
+        self.assertEqual(mx, expected_mx)
+        self.assertIsNone(mx.rdcomment)
+
+    def test_escaped_newline_in_quoted_string(self):
+        rd = dns.rdata.from_text('in', 'txt', '"foo\\\nbar"')
+        self.assertEqual(rd.strings, (b'foo\nbar',))
+        self.assertEqual(rd.to_text(), '"foo\\010bar"')
+
+    def test_escaped_newline_in_nonquoted_string(self):
+        with self.assertRaises(dns.exception.UnexpectedEnd):
+            dns.rdata.from_text('in', 'txt', 'foo\\\nbar')
+
+    def test_wordbreak(self):
+        text = b'abcdefgh'
+        self.assertEqual(dns.rdata._wordbreak(text, 4), 'abcd efgh')
+        self.assertEqual(dns.rdata._wordbreak(text, 0), 'abcdefgh')
+
+    def test_escapify(self):
+        self.assertEqual(dns.rdata._escapify('abc'), 'abc')
+        self.assertEqual(dns.rdata._escapify(b'abc'), 'abc')
+        self.assertEqual(dns.rdata._escapify(bytearray(b'abc')), 'abc')
+        self.assertEqual(dns.rdata._escapify(b'ab"c'), 'ab\\"c')
+        self.assertEqual(dns.rdata._escapify(b'ab\\c'), 'ab\\\\c')
+        self.assertEqual(dns.rdata._escapify(b'ab\x01c'), 'ab\\001c')
+
+    def test_truncate_bitmap(self):
+        self.assertEqual(dns.rdata._truncate_bitmap(b'\x00\x01\x00\x00'),
+                         b'\x00\x01')
+        self.assertEqual(dns.rdata._truncate_bitmap(b'\x00\x01\x00\x01'),
+                         b'\x00\x01\x00\x01')
+        self.assertEqual(dns.rdata._truncate_bitmap(b'\x00\x00\x00\x00'),
+                         b'\x00')
+
+    def test_covers_and_extended_rdatatype(self):
+        rd = dns.rdata.from_text('in', 'a', '10.0.0.1')
+        self.assertEqual(rd.covers(), dns.rdatatype.NONE)
+        self.assertEqual(rd.extended_rdatatype(), 0x00000001)
+        rd = dns.rdata.from_text('in', 'rrsig',
+                                 'NSEC 1 3 3600 ' +
+                                 '20200101000000 20030101000000 ' +
+                                 '2143 foo Ym9ndXM=')
+        self.assertEqual(rd.covers(), dns.rdatatype.NSEC)
+        self.assertEqual(rd.extended_rdatatype(), 0x002f002e)
+
+    def test_uncomparable(self):
+        rd = dns.rdata.from_text('in', 'a', '10.0.0.1')
+        self.assertFalse(rd == 'a')
+        self.assertTrue(rd != 'a')
+
+    def test_bad_generic(self):
+        # does not start with \#
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'type45678', '# 7 000a03666f6f00')
+        # wrong length
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'type45678', '\\# 6 000a03666f6f00')
+
+    def test_covered_repr(self):
+        text = 'NSEC 1 3 3600 20190101000000 20030101000000 ' + \
+            '2143 foo Ym9ndXM='
+        rd = dns.rdata.from_text('in', 'rrsig', text)
+        self.assertEqual(repr(rd), '<DNS IN RRSIG(NSEC) rdata: ' + text + '>')
+
+    def test_bad_registration_implementing_known_type_with_wrong_name(self):
+        # Try to register an implementation at the MG codepoint that isn't
+        # called "MG"
+        with self.assertRaises(dns.rdata.RdatatypeExists):
+            dns.rdata.register_type(None, dns.rdatatype.MG, 'NOTMG')
+
+    def test_registration_implementing_known_type_with_right_name(self):
+        # Try to register an implementation at the MD codepoint
+        dns.rdata.register_type(tests.md_module, dns.rdatatype.MD, 'MD')
+        rd = dns.rdata.from_text('in', 'md', 'foo.')
+        self.assertEqual(rd.target, dns.name.from_text('foo.'))
+
+    def test_CERT_with_string_type(self):
+        rd = dns.rdata.from_text('in', 'cert', 'SPKI 1 PRIVATEOID Ym9ndXM=')
+        self.assertEqual(rd.to_text(), 'SPKI 1 PRIVATEOID Ym9ndXM=')
+
+    def test_CERT_algorithm(self):
+        rd = dns.rdata.from_text('in', 'cert', 'SPKI 1 0 Ym9ndXM=')
+        self.assertEqual(rd.algorithm, 0)
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'cert', 'SPKI 1 -1 Ym9ndXM=')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'cert', 'SPKI 1 256 Ym9ndXM=')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'cert', 'SPKI 1 BOGUS Ym9ndXM=')
+
+    def test_bad_URI_text(self):
+        # empty target
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'uri', '10 1 ""')
+        # no target
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'uri', '10 1')
+
+    def test_bad_URI_wire(self):
+        wire = bytes.fromhex('000a0001')
+        with self.assertRaises(dns.exception.FormError):
+            dns.rdata.from_wire('in', 'uri', wire, 0, 4)
+
+    def test_bad_NSAP_text(self):
+        # does not start with 0x
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'nsap', '0y4700')
+        # odd hex string length
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'nsap', '0x470')
+
+    def test_bad_CAA_text(self):
+        # tag too long
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'caa',
+                                '0 ' + 'a' * 256 + ' "ca.example.net"')
+        # tag not alphanumeric
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'caa',
+                                '0 a-b "ca.example.net"')
+
+    def test_bad_HIP_text(self):
+        # hit too long
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'hip',
+                                '2 ' +
+                                '00' * 256 +
+                                ' Ym9ndXM=')
+
+    def test_bad_sigtime(self):
+        try:
+            dns.rdata.from_text('in', 'rrsig',
+                                'NSEC 1 3 3600 ' +
+                                '202001010000000 20030101000000 ' +
+                                '2143 foo Ym9ndXM=')
+            self.assertTrue(False)  # should not happen
+        except dns.exception.SyntaxError as e:
+            self.assertIsInstance(e.__cause__,
+                                  dns.rdtypes.ANY.RRSIG.BadSigTime)
+        try:
+            dns.rdata.from_text('in', 'rrsig',
+                                'NSEC 1 3 3600 ' +
+                                '20200101000000 2003010100000 ' +
+                                '2143 foo Ym9ndXM=')
+            self.assertTrue(False)  # should not happen
+        except dns.exception.SyntaxError as e:
+            self.assertIsInstance(e.__cause__,
+                                  dns.rdtypes.ANY.RRSIG.BadSigTime)
+
+    def test_empty_TXT(self):
+        # hit too long
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'txt', '')
+
+    def test_too_long_TXT(self):
+        # hit too long
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'txt', 'a' * 256)
+
+    def equal_smimea(self, a, b):
+        a = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SMIMEA, a)
+        b = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SMIMEA, b)
+        self.assertEqual(a, b)
+
+    def test_good_SMIMEA(self):
+        self.equal_smimea('3 0 1 aabbccddeeff', '3 0 01 AABBCCDDEEFF')
+
+    def test_bad_SMIMEA(self):
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SMIMEA, '1 1 1 aGVsbG8gd29ybGQh')
+
+    def test_DNSKEY_chunking(self):
+        inputs = (  # each with chunking as given by dig, unusual chunking, and no chunking
+            # example 1
+            (
+                '257 3 13 aCoEWYBBVsP9Fek2oC8yqU8ocKmnS1iDSFZNORnQuHKtJ9Wpyz+kNryq uB78Pyk/NTEoai5bxoipVQQXzHlzyg==',
+                '257 3 13 aCoEWYBBVsP9Fek2oC8yqU8ocK mnS1iDSFZNORnQuHKtJ9Wpyz+kNryquB78Pyk/   NTEoai5bxoipVQQXzHlzyg==',
+                '257 3 13 aCoEWYBBVsP9Fek2oC8yqU8ocKmnS1iDSFZNORnQuHKtJ9Wpyz+kNryquB78Pyk/NTEoai5bxoipVQQXzHlzyg==',
+            ),
+            # example 2
+            (
+                '257 3 8 AwEAAcw5QLr0IjC0wKbGoBPQv4qmeqHy9mvL5qGQTuaG5TSrNqEAR6b/ qvxDx6my4JmEmjUPA1JeEI9YfTUieMr2UZflu7aIbZFLw0vqiYrywCGr CHXLalOrEOmrvAxLvq4vHtuTlH7JIszzYBSes8g1vle6KG7xXiP3U5Ll 96Qiu6bZ31rlMQSPB20xbqJJh6psNSrQs41QvdcXAej+K2Hl1Wd8kPri ec4AgiBEh8sk5Pp8W9ROLQ7PcbqqttFaW2m7N/Wy4qcFU13roWKDEAst bxH5CHPoBfZSbIwK4KM6BK/uDHpSPIbiOvOCW+lvu9TAiZPc0oysY6as lO7jXv16Gws=',
+                '257 3 8 AwEAAcw5QLr0IjC0wKbGoBPQv4qmeq Hy9mvL5qGQTuaG5TSrNqEA R6b/qvxDx6my4JmEmjUPA1JeEI9Y  fTUieMr2UZflu7aIbZFLw0vqiYrywCGrC HXLalOrEOmrvAxLvq4vHtuTlH7JIszzYBSes8g1vle6KG7 xXiP3U5Ll 96Qiu6bZ31rlMQSPB20xbqJJh6psNSrQs41QvdcXAej+K2Hl1Wd8kPriec4AgiBEh8sk5Pp8W9ROLQ7PcbqqttFaW2m7N/Wy4qcFU13roWKDEAst bxH5CHPoBfZSbIwK4KM6BK/uDHpSPIbiOvOCW+lvu9TAiZPc0oysY6as lO7jXv16Gws=',
+                '257 3 8 AwEAAcw5QLr0IjC0wKbGoBPQv4qmeqHy9mvL5qGQTuaG5TSrNqEAR6b/qvxDx6my4JmEmjUPA1JeEI9YfTUieMr2UZflu7aIbZFLw0vqiYrywCGrCHXLalOrEOmrvAxLvq4vHtuTlH7JIszzYBSes8g1vle6KG7xXiP3U5Ll96Qiu6bZ31rlMQSPB20xbqJJh6psNSrQs41QvdcXAej+K2Hl1Wd8kPriec4AgiBEh8sk5Pp8W9ROLQ7PcbqqttFaW2m7N/Wy4qcFU13roWKDEAstbxH5CHPoBfZSbIwK4KM6BK/uDHpSPIbiOvOCW+lvu9TAiZPc0oysY6aslO7jXv16Gws=',
+            ),
+            # example 3
+            (
+                '256 3 8 AwEAAday3UX323uVzQqtOMQ7EHQYfD5Ofv4akjQGN2zY5AgB/2jmdR/+ 1PvXFqzKCAGJv4wjABEBNWLLFm7ew1hHMDZEKVL17aml0EBKI6Dsz6Mx t6n7ScvLtHaFRKaxT4i2JxiuVhKdQR9XGMiWAPQKrRM5SLG0P+2F+TLK l3D0L/cD',
+                '256 3 8 AwEAAday3UX323uVzQqtOMQ7EHQYfD5Ofv4akjQGN2zY5    AgB/2jmdR/+1PvXFqzKCAGJv4wjABEBNWLLFm7ew1hHMDZEKVL17aml0EBKI6Dsz6Mxt6n7ScvLtHaFRKaxT4i2JxiuVhKdQR9XGMiWAPQKrRM5SLG0P+2F+ TLKl3D0L/cD',
+                '256 3 8 AwEAAday3UX323uVzQqtOMQ7EHQYfD5Ofv4akjQGN2zY5AgB/2jmdR/+1PvXFqzKCAGJv4wjABEBNWLLFm7ew1hHMDZEKVL17aml0EBKI6Dsz6Mxt6n7ScvLtHaFRKaxT4i2JxiuVhKdQR9XGMiWAPQKrRM5SLG0P+2F+TLKl3D0L/cD',
+            ),
+        )
+        output_map = {
+            32: (
+                '257 3 13 aCoEWYBBVsP9Fek2oC8yqU8ocKmnS1iD SFZNORnQuHKtJ9Wpyz+kNryquB78Pyk/ NTEoai5bxoipVQQXzHlzyg==',
+                '257 3 8 AwEAAcw5QLr0IjC0wKbGoBPQv4qmeqHy 9mvL5qGQTuaG5TSrNqEAR6b/qvxDx6my 4JmEmjUPA1JeEI9YfTUieMr2UZflu7aI bZFLw0vqiYrywCGrCHXLalOrEOmrvAxL vq4vHtuTlH7JIszzYBSes8g1vle6KG7x XiP3U5Ll96Qiu6bZ31rlMQSPB20xbqJJ h6psNSrQs41QvdcXAej+K2Hl1Wd8kPri ec4AgiBEh8sk5Pp8W9ROLQ7PcbqqttFa W2m7N/Wy4qcFU13roWKDEAstbxH5CHPo BfZSbIwK4KM6BK/uDHpSPIbiOvOCW+lv u9TAiZPc0oysY6aslO7jXv16Gws=',
+                '256 3 8 AwEAAday3UX323uVzQqtOMQ7EHQYfD5O fv4akjQGN2zY5AgB/2jmdR/+1PvXFqzK CAGJv4wjABEBNWLLFm7ew1hHMDZEKVL1 7aml0EBKI6Dsz6Mxt6n7ScvLtHaFRKax T4i2JxiuVhKdQR9XGMiWAPQKrRM5SLG0 P+2F+TLKl3D0L/cD',
+            ),
+            56: (t[0] for t in inputs),
+            0: (t[0][:12] + t[0][12:].replace(' ', '') for t in inputs)
+        }
+
+        for chunksize, outputs in output_map.items():
+            for input, output in zip(inputs, outputs):
+                for input_variation in input:
+                    rr = dns.rdata.from_text('IN', 'DNSKEY', input_variation)
+                    new_text = rr.to_text(chunksize=chunksize)
+                    self.assertEqual(output, new_text)
+
+
+class UtilTestCase(unittest.TestCase):
+
+    def test_Gateway_bad_type0(self):
+        with self.assertRaises(SyntaxError):
+            dns.rdtypes.util.Gateway(0, 'bad.')
+
+    def test_Gateway_bad_type3(self):
+        with self.assertRaises(SyntaxError):
+            dns.rdtypes.util.Gateway(3, 'bad.')
+
+    def test_Gateway_type4(self):
+        with self.assertRaises(SyntaxError):
+            dns.rdtypes.util.Gateway(4)
+        with self.assertRaises(dns.exception.FormError):
+            dns.rdtypes.util.Gateway.from_wire_parser(4, None)
+
+    def test_Bitmap(self):
+        b = dns.rdtypes.util.Bitmap
+        tok = dns.tokenizer.Tokenizer('A MX')
+        windows = b.from_text(tok).windows
+        ba = bytearray()
+        ba.append(0x40)  # bit 1, for A
+        ba.append(0x01)  # bit 15, for MX
+        self.assertEqual(windows, [(0, bytes(ba))])
+
+    def test_Bitmap_with_duplicate_types(self):
+        b = dns.rdtypes.util.Bitmap
+        tok = dns.tokenizer.Tokenizer('A MX A A MX')
+        windows = b.from_text(tok).windows
+        ba = bytearray()
+        ba.append(0x40)  # bit 1, for A
+        ba.append(0x01)  # bit 15, for MX
+        self.assertEqual(windows, [(0, bytes(ba))])
+
+    def test_Bitmap_with_out_of_order_types(self):
+        b = dns.rdtypes.util.Bitmap
+        tok = dns.tokenizer.Tokenizer('MX A')
+        windows = b.from_text(tok).windows
+        ba = bytearray()
+        ba.append(0x40)  # bit 1, for A
+        ba.append(0x01)  # bit 15, for MX
+        self.assertEqual(windows, [(0, bytes(ba))])
+
+    def test_Bitmap_zero_padding_works(self):
+        b = dns.rdtypes.util.Bitmap
+        tok = dns.tokenizer.Tokenizer('SRV')
+        windows = b.from_text(tok).windows
+        ba = bytearray()
+        ba.append(0)
+        ba.append(0)
+        ba.append(0)
+        ba.append(0)
+        ba.append(0x40)  # bit 33, for SRV
+        self.assertEqual(windows, [(0, bytes(ba))])
+
+    def test_Bitmap_has_type_0_set(self):
+        b = dns.rdtypes.util.Bitmap
+        with self.assertRaises(dns.exception.SyntaxError):
+            tok = dns.tokenizer.Tokenizer('NONE A MX')
+            b.from_text(tok)
+
+    def test_Bitmap_empty_window_not_written(self):
+        b = dns.rdtypes.util.Bitmap
+        tok = dns.tokenizer.Tokenizer('URI CAA')  # types 256 and 257
+        windows = b.from_text(tok).windows
+        ba = bytearray()
+        ba.append(0xc0)  # bits 0 and 1 in window 1
+        self.assertEqual(windows, [(1, bytes(ba))])
+
+    def test_Bitmap_ok_parse(self):
+        parser = dns.wire.Parser(b'\x00\x01\x40')
+        b = dns.rdtypes.util.Bitmap([])
+        windows = b.from_wire_parser(parser).windows
+        self.assertEqual(windows, [(0, b'@')])
+
+    def test_Bitmap_0_length_window_parse(self):
+        parser = dns.wire.Parser(b'\x00\x00')
+        with self.assertRaises(ValueError):
+            b = dns.rdtypes.util.Bitmap([])
+            b.from_wire_parser(parser)
+
+    def test_Bitmap_too_long_parse(self):
+        parser = dns.wire.Parser(b'\x00\x21' + b'\x01' * 33)
+        with self.assertRaises(ValueError):
+            b = dns.rdtypes.util.Bitmap([])
+            b.from_wire_parser(parser)
+
+    def test_compressed_in_generic_is_bad(self):
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.MX,
+                                r'\# 4 000aC000')
+
+    def test_rdataset_ttl_conversion(self):
+        rds1 = dns.rdataset.from_text('in', 'a', 300, '10.0.0.1')
+        self.assertEqual(rds1.ttl, 300)
+        rds2 = dns.rdataset.from_text('in', 'a', '5m', '10.0.0.1')
+        self.assertEqual(rds2.ttl, 300)
+        with self.assertRaises(ValueError):
+            dns.rdataset.from_text('in', 'a', 1.6, '10.0.0.1')
+        with self.assertRaises(dns.ttl.BadTTL):
+            dns.rdataset.from_text('in', 'a', '10.0.0.1', '10.0.0.2')
+
+
+Rdata = dns.rdata.Rdata
+
+
+class RdataConvertersTestCase(unittest.TestCase):
+    def test_as_name(self):
+        n = dns.name.from_text('hi')
+        self.assertEqual(Rdata._as_name(n), n)
+        self.assertEqual(Rdata._as_name('hi'), n)
+        with self.assertRaises(ValueError):
+            Rdata._as_name(100)
+
+    def test_as_uint8(self):
+        self.assertEqual(Rdata._as_uint8(0), 0)
+        with self.assertRaises(ValueError):
+            Rdata._as_uint8('hi')
+        with self.assertRaises(ValueError):
+            Rdata._as_uint8(-1)
+        with self.assertRaises(ValueError):
+            Rdata._as_uint8(256)
+
+    def test_as_uint16(self):
+        self.assertEqual(Rdata._as_uint16(0), 0)
+        with self.assertRaises(ValueError):
+            Rdata._as_uint16('hi')
+        with self.assertRaises(ValueError):
+            Rdata._as_uint16(-1)
+        with self.assertRaises(ValueError):
+            Rdata._as_uint16(65536)
+
+    def test_as_uint32(self):
+        self.assertEqual(Rdata._as_uint32(0), 0)
+        with self.assertRaises(ValueError):
+            Rdata._as_uint32('hi')
+        with self.assertRaises(ValueError):
+            Rdata._as_uint32(-1)
+        with self.assertRaises(ValueError):
+            Rdata._as_uint32(2 ** 32)
+
+    def test_as_uint48(self):
+        self.assertEqual(Rdata._as_uint48(0), 0)
+        with self.assertRaises(ValueError):
+            Rdata._as_uint48('hi')
+        with self.assertRaises(ValueError):
+            Rdata._as_uint48(-1)
+        with self.assertRaises(ValueError):
+            Rdata._as_uint48(2 ** 48)
+
+    def test_as_int(self):
+        self.assertEqual(Rdata._as_int(0, 0, 10), 0)
+        with self.assertRaises(ValueError):
+            Rdata._as_int('hi', 0, 10)
+        with self.assertRaises(ValueError):
+            Rdata._as_int(-1, 0, 10)
+        with self.assertRaises(ValueError):
+            Rdata._as_int(11, 0, 10)
+
+    def test_as_bool(self):
+        self.assertEqual(Rdata._as_bool(True), True)
+        self.assertEqual(Rdata._as_bool(False), False)
+        with self.assertRaises(ValueError):
+            Rdata._as_bool('hi')
+
+    def test_as_ttl(self):
+        self.assertEqual(Rdata._as_ttl(300), 300)
+        self.assertEqual(Rdata._as_ttl('5m'), 300)
+        self.assertEqual(Rdata._as_ttl(dns.ttl.MAX_TTL), dns.ttl.MAX_TTL)
+        with self.assertRaises(dns.ttl.BadTTL):
+            Rdata._as_ttl('hi')
+        with self.assertRaises(ValueError):
+            Rdata._as_ttl(1.9)
+        with self.assertRaises(ValueError):
+            Rdata._as_ttl(dns.ttl.MAX_TTL + 1)
+
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_rdataset.py b/tests/test_rdataset.py
index ad6b557..4710e2a 100644
--- a/tests/test_rdataset.py
+++ b/tests/test_rdataset.py
@@ -81,5 +81,84 @@ class RdatasetTestCase(unittest.TestCase):
         self.assertRaises(ValueError,
                           lambda: dns.rdataset.from_rdata_list(300, []))
 
+    def testToTextNoName(self):
+        rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.1')
+        text = rds.to_text()
+        self.assertEqual(text, '300 IN A 10.0.0.1')
+
+    def testToTextOverrideClass(self):
+        rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.1')
+        text = rds.to_text(override_rdclass=dns.rdataclass.NONE)
+        self.assertEqual(text, '300 NONE A 10.0.0.1')
+
+    def testRepr(self):
+        rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.1')
+        self.assertEqual(repr(rds), "<DNS IN A rdataset: [<10.0.0.1>]>")
+
+    def testTruncatedRepr(self):
+        rds = dns.rdataset.from_text('in', 'txt', 300,
+                                     'a' * 200)
+        # * 99 not * 100 below as the " counts as one of the 100 chars
+        self.assertEqual(repr(rds),
+                         '<DNS IN TXT rdataset: [<"' + 'a' * 99 + '...>]>')
+
+    def testStr(self):
+        rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.1')
+        self.assertEqual(str(rds), "300 IN A 10.0.0.1")
+
+    def testMultilineToText(self):
+        rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.1', '10.0.0.2')
+        self.assertEqual(rds.to_text(), "300 IN A 10.0.0.1\n300 IN A 10.0.0.2")
+
+    def testCoveredRepr(self):
+        rds = dns.rdataset.from_text('in', 'rrsig', 300,
+                                     'NSEC 1 3 3600 ' +
+                                     '20190101000000 20030101000000 ' +
+                                     '2143 foo Ym9ndXM=')
+        # Using startswith as I don't care about the repr of the rdata,
+        # just the covers
+        print(repr(rds))
+        self.assertTrue(repr(rds).startswith(
+            '<DNS IN RRSIG(NSEC) rdataset:'))
+
+
+class ImmutableRdatasetTestCase(unittest.TestCase):
+
+    def test_basic(self):
+        rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.1', '10.0.0.2')
+        rd = dns.rdata.from_text('in', 'a', '10.0.0.3')
+        irds = dns.rdataset.ImmutableRdataset(rds)
+        with self.assertRaises(TypeError):
+            irds.update_ttl(100)
+        with self.assertRaises(TypeError):
+            irds.add(rd, 300)
+        with self.assertRaises(TypeError):
+            irds.union_update(rds)
+        with self.assertRaises(TypeError):
+            irds.intersection_update(rds)
+        with self.assertRaises(TypeError):
+            irds.update(rds)
+        with self.assertRaises(TypeError):
+            irds += rds
+        with self.assertRaises(TypeError):
+            irds -= rds
+        with self.assertRaises(TypeError):
+            irds &= rds
+        with self.assertRaises(TypeError):
+            irds |= rds
+        with self.assertRaises(TypeError):
+            del irds[0]
+        with self.assertRaises(TypeError):
+            irds.clear()
+
+    def test_cloning(self):
+        rds1 = dns.rdataset.from_text('in', 'a', 300, '10.0.0.1', '10.0.0.2')
+        rds1 = dns.rdataset.ImmutableRdataset(rds1)
+        rds2 = dns.rdataset.from_text('in', 'a', 300, '10.0.0.2', '10.0.0.3')
+        rds2 = dns.rdataset.ImmutableRdataset(rds2)
+        expected = dns.rdataset.from_text('in', 'a', 300, '10.0.0.2')
+        intersection = rds1.intersection(rds2)
+        self.assertEqual(intersection, expected)
+
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_rdtypeanytkey.py b/tests/test_rdtypeanytkey.py
new file mode 100644
index 0000000..3a3ca57
--- /dev/null
+++ b/tests/test_rdtypeanytkey.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+# Copyright (C) 2003-2007, 2009-2011 Nominum, Inc.
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose with or without fee is hereby granted,
+# provided that the above copyright notice and this permission notice
+# appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND NOMINUM DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NOMINUM BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import unittest
+import base64
+
+import dns.name
+import dns.zone
+import dns.rdtypes.ANY.TKEY
+from dns.rdataclass import RdataClass
+from dns.rdatatype import RdataType
+
+
+class RdtypeAnyTKeyTestCase(unittest.TestCase):
+    tkey_rdata_text = 'gss-tsig. 1594203795 1594206664 3 0 KEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEY OTHEROTHEROTHEROTHEROTHEROTHEROT'
+    tkey_rdata_text_no_other = 'gss-tsig. 1594203795 1594206664 3 0 KEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEY'
+
+    def testTextOptionalData(self):
+        # construct the rdata from text and extract the TKEY
+        tkey = dns.rdata.from_text(
+            RdataClass.ANY, RdataType.TKEY,
+            RdtypeAnyTKeyTestCase.tkey_rdata_text, origin='.')
+        self.assertEqual(type(tkey), dns.rdtypes.ANY.TKEY.TKEY)
+
+        # go to text and compare
+        tkey_out_text = tkey.to_text(relativize=False)
+        self.assertEqual(tkey_out_text,
+                         RdtypeAnyTKeyTestCase.tkey_rdata_text)
+
+    def testTextNoOptionalData(self):
+        # construct the rdata from text and extract the TKEY
+        tkey = dns.rdata.from_text(
+            RdataClass.ANY, RdataType.TKEY,
+            RdtypeAnyTKeyTestCase.tkey_rdata_text_no_other, origin='.')
+        self.assertEqual(type(tkey), dns.rdtypes.ANY.TKEY.TKEY)
+
+        # go to text and compare
+        tkey_out_text = tkey.to_text(relativize=False)
+        self.assertEqual(tkey_out_text,
+                         RdtypeAnyTKeyTestCase.tkey_rdata_text_no_other)
+
+    def testWireOptionalData(self):
+        key = base64.b64decode('KEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEY')
+        other = base64.b64decode('OTHEROTHEROTHEROTHEROTHEROTHEROT')
+
+        # construct the TKEY and compare the text output
+        tkey = dns.rdtypes.ANY.TKEY.TKEY(dns.rdataclass.ANY,
+                                         dns.rdatatype.TKEY,
+                                         dns.name.from_text('gss-tsig.'),
+                                         1594203795, 1594206664,
+                                         3, 0, key, other)
+        self.assertEqual(tkey.to_text(relativize=False),
+                         RdtypeAnyTKeyTestCase.tkey_rdata_text)
+
+        # go to/from wire and compare the text output
+        wire = tkey.to_wire()
+        tkey_out_wire = dns.rdata.from_wire(dns.rdataclass.ANY,
+                                            dns.rdatatype.TKEY,
+                                            wire, 0, len(wire))
+        self.assertEqual(tkey_out_wire.to_text(relativize=False),
+                         RdtypeAnyTKeyTestCase.tkey_rdata_text)
+
+    def testWireNoOptionalData(self):
+        key = base64.b64decode('KEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEY')
+
+        # construct the TKEY with no 'other' data and compare the text output
+        tkey = dns.rdtypes.ANY.TKEY.TKEY(dns.rdataclass.ANY,
+                                         dns.rdatatype.TKEY,
+                                         dns.name.from_text('gss-tsig.'),
+                                         1594203795, 1594206664,
+                                         3, 0, key)
+        self.assertEqual(tkey.to_text(relativize=False),
+                         RdtypeAnyTKeyTestCase.tkey_rdata_text_no_other)
+
+        # go to/from wire and compare the text output
+        wire = tkey.to_wire()
+        tkey_out_wire = dns.rdata.from_wire(dns.rdataclass.ANY,
+                                            dns.rdatatype.TKEY,
+                                            wire, 0, len(wire))
+        self.assertEqual(tkey_out_wire.to_text(relativize=False),
+                         RdtypeAnyTKeyTestCase.tkey_rdata_text_no_other)
diff --git a/tests/test_resolution.py b/tests/test_resolution.py
index 9145f16..731090b 100644
--- a/tests/test_resolution.py
+++ b/tests/test_resolution.py
@@ -1,3 +1,5 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
 import unittest
 
 import dns.flags
@@ -83,6 +85,22 @@ class ResolutionTestCase(unittest.TestCase):
             r.set_rcode(dns.rcode.NXDOMAIN)
         return r
 
+    def make_long_chain_response(self, q, count):
+        r = dns.message.make_response(q)
+        name = self.qname
+        for i in range(count):
+            rrs = r.get_rrset(r.answer, name, dns.rdataclass.IN,
+                              dns.rdatatype.CNAME, create=True)
+            tname = dns.name.from_text(f'target{i}.')
+            rrs.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME,
+                                        str(tname)), 300)
+            name = tname
+        rrs = r.get_rrset(r.answer, name, dns.rdataclass.IN,
+                          dns.rdatatype.A, create=True)
+        rrs.add(dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A,
+                                    '10.0.0.1'), 300)
+        return r
+
     def test_next_request_cache_hit(self):
         self.resolver.cache = dns.resolver.Cache()
         q = dns.message.make_query(self.qname, dns.rdatatype.A)
@@ -353,6 +371,36 @@ class ResolutionTestCase(unittest.TestCase):
         self.assertTrue(answer is None)
         self.assertTrue(done)
 
+    def test_query_result_nxdomain_but_has_answer(self):
+        q = dns.message.make_query(self.qname, dns.rdatatype.A)
+        r = self.make_address_response(q)
+        r.set_rcode(dns.rcode.NXDOMAIN)
+        (_, _) = self.resn.next_request()
+        (nameserver, _, _, _) = self.resn.next_nameserver()
+        (answer, done) = self.resn.query_result(r, None)
+        self.assertIsNone(answer)
+        self.assertFalse(done)
+        self.assertTrue(nameserver not in self.resn.nameservers)
+
+    def test_query_result_chain_not_too_long(self):
+        q = dns.message.make_query(self.qname, dns.rdatatype.A)
+        r = self.make_long_chain_response(q, 15)
+        (_, _) = self.resn.next_request()
+        (_, _, _, _) = self.resn.next_nameserver()
+        (answer, done) = self.resn.query_result(r, None)
+        self.assertIsNotNone(answer)
+        self.assertTrue(done)
+
+    def test_query_result_chain_too_long(self):
+        q = dns.message.make_query(self.qname, dns.rdatatype.A)
+        r = self.make_long_chain_response(q, 16)
+        (_, _) = self.resn.next_request()
+        (nameserver, _, _, _) = self.resn.next_nameserver()
+        (answer, done) = self.resn.query_result(r, None)
+        self.assertIsNone(answer)
+        self.assertFalse(done)
+        self.assertTrue(nameserver not in self.resn.nameservers)
+
     def test_query_result_nxdomain_cached(self):
         self.resolver.cache = dns.resolver.Cache()
         q = dns.message.make_query(self.qname, dns.rdatatype.A)
diff --git a/tests/test_resolver.py b/tests/test_resolver.py
index a6ab473..4f5643d 100644
--- a/tests/test_resolver.py
+++ b/tests/test_resolver.py
@@ -16,7 +16,7 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 from io import StringIO
-import select
+import selectors
 import sys
 import socket
 import time
@@ -28,6 +28,8 @@ import dns.name
 import dns.rdataclass
 import dns.rdatatype
 import dns.resolver
+import dns.tsig
+import dns.tsigkeyring
 
 # Some tests require the internet to be available to run, so let's
 # skip those if it's not there.
@@ -92,6 +94,17 @@ no_nameservers = """
 options rotate
 """
 
+unknown_and_bad_directives = """
+nameserver 10.0.0.1
+foo bar
+bad
+"""
+
+unknown_option = """
+nameserver 10.0.0.1
+option foobar
+"""
+
 message_text = """id 1234
 opcode QUERY
 rcode NOERROR
@@ -104,6 +117,18 @@ example. 1 IN A 10.0.0.1
 ;ADDITIONAL
 """
 
+message_text_mx = """id 1234
+opcode QUERY
+rcode NOERROR
+flags QR AA RD
+;QUESTION
+example. IN MX
+;ANSWER
+example. 1 IN A 10.0.0.1
+;AUTHORITY
+;ADDITIONAL
+"""
+
 dangling_cname_0_message_text = """id 10000
 opcode QUERY
 rcode NOERROR
@@ -148,6 +173,38 @@ class FakeAnswer(object):
         self.expiration = expiration
 
 
+class FakeTime:
+    # Mock the clock!
+    def __init__(self, now=None, want_fake=True):
+        if now is None:
+            now = time.time()
+        self.now = now
+        self.saved_time = time.time
+        self.want_fake = want_fake
+
+    def __enter__(self):
+        if self.want_fake:
+            time.time = self.time
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if self.want_fake:
+            time.time = self.saved_time
+        return False
+
+    def time(self):
+        if self.want_fake:
+            return self.now
+        else:
+            return time.time()
+
+    def sleep(self, offset):
+        if self.want_fake:
+            self.now += offset
+        else:
+            time.sleep(offset)
+
+
 class BaseResolverTests(unittest.TestCase):
 
     def testRead(self):
@@ -166,6 +223,7 @@ class BaseResolverTests(unittest.TestCase):
         self.assertEqual(r.timeout, 1)
         self.assertEqual(r.ndots, 2)
         self.assertEqual(r.edns, 0)
+        self.assertEqual(r.payload, dns.message.DEFAULT_EDNS_PAYLOAD)
 
     def testReadOptionsBadTimeouts(self):
         f = StringIO(bad_timeout_1)
@@ -197,48 +255,88 @@ class BaseResolverTests(unittest.TestCase):
         with self.assertRaises(dns.resolver.NoResolverConfiguration):
             r.read_resolv_conf(f)
 
+    def testReadUnknownDirective(self):
+        # The real test here is ignoring the unknown directive and the bad
+        # directive.
+        f = StringIO(unknown_and_bad_directives)
+        r = dns.resolver.Resolver(configure=False)
+        r.read_resolv_conf(f)
+        self.assertEqual(r.nameservers, ['10.0.0.1'])
+
+    def testReadUnknownOption(self):
+        # The real test here is ignoring the unknown option
+        f = StringIO(unknown_option)
+        r = dns.resolver.Resolver(configure=False)
+        r.read_resolv_conf(f)
+        self.assertEqual(r.nameservers, ['10.0.0.1'])
+
     def testCacheExpiration(self):
-        message = dns.message.from_text(message_text)
-        name = dns.name.from_text('example.')
-        answer = dns.resolver.Answer(name, dns.rdatatype.A, dns.rdataclass.IN,
-                                     message)
-        cache = dns.resolver.Cache()
-        cache.put((name, dns.rdatatype.A, dns.rdataclass.IN), answer)
-        time.sleep(2)
-        self.assertTrue(cache.get((name, dns.rdatatype.A, dns.rdataclass.IN))
-                        is None)
+        with FakeTime() as fake_time:
+            message = dns.message.from_text(message_text)
+            name = dns.name.from_text('example.')
+            answer = dns.resolver.Answer(name, dns.rdatatype.A,
+                                         dns.rdataclass.IN, message)
+            cache = dns.resolver.Cache()
+            cache.put((name, dns.rdatatype.A, dns.rdataclass.IN), answer)
+            fake_time.sleep(2)
+            self.assertTrue(cache.get((name, dns.rdatatype.A,
+                                       dns.rdataclass.IN))
+                            is None)
 
     def testCacheCleaning(self):
-        message = dns.message.from_text(message_text)
-        name = dns.name.from_text('example.')
-        answer = dns.resolver.Answer(name, dns.rdatatype.A, dns.rdataclass.IN,
-                                     message)
-        cache = dns.resolver.Cache(cleaning_interval=1.0)
-        cache.put((name, dns.rdatatype.A, dns.rdataclass.IN), answer)
-        time.sleep(2)
-        self.assertTrue(cache.get((name, dns.rdatatype.A, dns.rdataclass.IN))
-                        is None)
+        with FakeTime() as fake_time:
+            message = dns.message.from_text(message_text)
+            name = dns.name.from_text('example.')
+            answer = dns.resolver.Answer(name, dns.rdatatype.A,
+                                         dns.rdataclass.IN, message)
+            cache = dns.resolver.Cache(cleaning_interval=1.0)
+            cache.put((name, dns.rdatatype.A, dns.rdataclass.IN), answer)
+            fake_time.sleep(2)
+            cache._maybe_clean()
+            self.assertTrue(cache.data.get((name, dns.rdatatype.A,
+                                            dns.rdataclass.IN))
+                            is None)
+
+    def testCacheNonCleaning(self):
+        with FakeTime() as fake_time:
+            message = dns.message.from_text(message_text)
+            name = dns.name.from_text('example.')
+            answer = dns.resolver.Answer(name, dns.rdatatype.A,
+                                         dns.rdataclass.IN, message)
+            # override TTL as we're testing non-cleaning
+            answer.expiration = fake_time.time() + 100
+            cache = dns.resolver.Cache(cleaning_interval=1.0)
+            cache.put((name, dns.rdatatype.A, dns.rdataclass.IN), answer)
+            fake_time.sleep(1.1)
+            self.assertEqual(cache.get((name, dns.rdatatype.A,
+                                        dns.rdataclass.IN)), answer)
 
     def testIndexErrorOnEmptyRRsetAccess(self):
         def bad():
-            message = dns.message.from_text(message_text)
+            message = dns.message.from_text(message_text_mx)
             name = dns.name.from_text('example.')
             answer = dns.resolver.Answer(name, dns.rdatatype.MX,
-                                         dns.rdataclass.IN, message,
-                                         False)
+                                         dns.rdataclass.IN, message)
             return answer[0]
         self.assertRaises(IndexError, bad)
 
     def testIndexErrorOnEmptyRRsetDelete(self):
         def bad():
-            message = dns.message.from_text(message_text)
+            message = dns.message.from_text(message_text_mx)
             name = dns.name.from_text('example.')
             answer = dns.resolver.Answer(name, dns.rdatatype.MX,
-                                         dns.rdataclass.IN, message,
-                                         False)
+                                         dns.rdataclass.IN, message)
             del answer[0]
         self.assertRaises(IndexError, bad)
 
+    def testRRsetDelete(self):
+        message = dns.message.from_text(message_text)
+        name = dns.name.from_text('example.')
+        answer = dns.resolver.Answer(name, dns.rdatatype.A,
+                                     dns.rdataclass.IN, message)
+        del answer[0]
+        self.assertEqual(len(answer), 0)
+
     def testLRUReplace(self):
         cache = dns.resolver.LRUCache(4)
         for i in range(0, 5):
@@ -280,21 +378,23 @@ class BaseResolverTests(unittest.TestCase):
                                 is None)
 
     def testLRUExpiration(self):
-        cache = dns.resolver.LRUCache(4)
-        for i in range(0, 4):
-            name = dns.name.from_text('example%d.' % i)
-            answer = FakeAnswer(time.time() + 1)
-            cache.put((name, dns.rdatatype.A, dns.rdataclass.IN), answer)
-        time.sleep(2)
-        for i in range(0, 4):
-            name = dns.name.from_text('example%d.' % i)
-            self.assertTrue(cache.get((name, dns.rdatatype.A,
-                                       dns.rdataclass.IN))
-                            is None)
+        with FakeTime() as fake_time:
+            cache = dns.resolver.LRUCache(4)
+            for i in range(0, 4):
+                name = dns.name.from_text('example%d.' % i)
+                answer = FakeAnswer(time.time() + 1)
+                cache.put((name, dns.rdatatype.A, dns.rdataclass.IN), answer)
+            fake_time.sleep(2)
+            for i in range(0, 4):
+                name = dns.name.from_text('example%d.' % i)
+                self.assertTrue(cache.get((name, dns.rdatatype.A,
+                                           dns.rdataclass.IN))
+                                is None)
 
     def test_cache_flush(self):
         name1 = dns.name.from_text('name1')
         name2 = dns.name.from_text('name2')
+        name3 = dns.name.from_text('name3')
         basic_cache = dns.resolver.Cache()
         lru_cache = dns.resolver.LRUCache(100)
         for cache in [basic_cache, lru_cache]:
@@ -306,6 +406,12 @@ class BaseResolverTests(unittest.TestCase):
             self.assertTrue(canswer is answer1)
             canswer = cache.get((name2, dns.rdatatype.A, dns.rdataclass.IN))
             self.assertTrue(canswer is answer2)
+            # explicit flush of nonexistent key, just to exercise the branch
+            cache.flush((name3, dns.rdatatype.A, dns.rdataclass.IN))
+            canswer = cache.get((name1, dns.rdatatype.A, dns.rdataclass.IN))
+            self.assertTrue(canswer is answer1)
+            canswer = cache.get((name2, dns.rdatatype.A, dns.rdataclass.IN))
+            self.assertTrue(canswer is answer2)
             # explicit flush
             cache.flush((name1, dns.rdatatype.A, dns.rdataclass.IN))
             canswer = cache.get((name1, dns.rdatatype.A, dns.rdataclass.IN))
@@ -347,6 +453,41 @@ class BaseResolverTests(unittest.TestCase):
         self.assertFalse(on_lru_list(cache, key, answer1))
         self.assertTrue(on_lru_list(cache, key, answer2))
 
+    def test_cache_stats(self):
+        caches = [dns.resolver.Cache(), dns.resolver.LRUCache(4)]
+        key1 = (dns.name.from_text('key1.'), dns.rdatatype.A, dns.rdataclass.IN)
+        key2 = (dns.name.from_text('key2.'), dns.rdatatype.A, dns.rdataclass.IN)
+        for cache in caches:
+            answer1 = FakeAnswer(time.time() + 10)
+            answer2 = FakeAnswer(10)  # expired!
+            a = cache.get(key1)
+            self.assertIsNone(a)
+            self.assertEqual(cache.hits(), 0)
+            self.assertEqual(cache.misses(), 1)
+            if isinstance(cache, dns.resolver.LRUCache):
+                self.assertEqual(cache.get_hits_for_key(key1), 0)
+            cache.put(key1, answer1)
+            a = cache.get(key1)
+            self.assertIs(a, answer1)
+            self.assertEqual(cache.hits(), 1)
+            self.assertEqual(cache.misses(), 1)
+            if isinstance(cache, dns.resolver.LRUCache):
+                self.assertEqual(cache.get_hits_for_key(key1), 1)
+            cache.put(key2, answer2)
+            a = cache.get(key2)
+            self.assertIsNone(a)
+            self.assertEqual(cache.hits(), 1)
+            self.assertEqual(cache.misses(), 2)
+            if isinstance(cache, dns.resolver.LRUCache):
+                self.assertEqual(cache.get_hits_for_key(key2), 0)
+            stats = cache.get_statistics_snapshot()
+            self.assertEqual(stats.hits, 1)
+            self.assertEqual(stats.misses, 2)
+            cache.reset_statistics()
+            stats = cache.get_statistics_snapshot()
+            self.assertEqual(stats.hits, 0)
+            self.assertEqual(stats.misses, 0)
+
     def testEmptyAnswerSection(self):
         # TODO: dangling_cname_0_message_text was the only sample message
         #       with an empty answer section. Other than that it doesn't
@@ -373,7 +514,7 @@ class BaseResolverTests(unittest.TestCase):
         qnames = res._get_qnames_to_try(qname, True)
         self.assertEqual(qnames,
                          [dns.name.from_text(x) for x in
-                          ['www.dnspython.org', 'www.dnspython.net']])
+                          ['www.dnspython.org', 'www.dnspython.net', 'www.']])
         qnames = res._get_qnames_to_try(qname, False)
         self.assertEqual(qnames,
                          [dns.name.from_text('www.')])
@@ -387,7 +528,27 @@ class BaseResolverTests(unittest.TestCase):
         qnames = res._get_qnames_to_try(qname, None)
         self.assertEqual(qnames,
                          [dns.name.from_text(x) for x in
-                          ['www.dnspython.org', 'www.dnspython.net']])
+                          ['www.dnspython.org', 'www.dnspython.net', 'www.']])
+        #
+        # Now test ndots
+        #
+        qname = dns.name.from_text('a.b', None)
+        res.ndots = 1
+        qnames = res._get_qnames_to_try(qname, True)
+        self.assertEqual(qnames,
+                         [dns.name.from_text(x) for x in
+                          ['a.b', 'a.b.dnspython.org', 'a.b.dnspython.net']])
+        res.ndots = 2
+        qnames = res._get_qnames_to_try(qname, True)
+        self.assertEqual(qnames,
+                         [dns.name.from_text(x) for x in
+                          ['a.b.dnspython.org', 'a.b.dnspython.net', 'a.b']])
+        qname = dns.name.from_text('a.b.c', None)
+        qnames = res._get_qnames_to_try(qname, True)
+        self.assertEqual(qnames,
+                         [dns.name.from_text(x) for x in
+                          ['a.b.c', 'a.b.c.dnspython.org',
+                           'a.b.c.dnspython.net']])
 
     def testSearchListsAbsolute(self):
         res = dns.resolver.Resolver(configure=False)
@@ -399,6 +560,37 @@ class BaseResolverTests(unittest.TestCase):
         qnames = res._get_qnames_to_try(qname, None)
         self.assertEqual(qnames, [qname])
 
+    def testUseEDNS(self):
+        r = dns.resolver.Resolver(configure=False)
+        r.use_edns(None)
+        self.assertEqual(r.edns, -1)
+        r.use_edns(False)
+        self.assertEqual(r.edns, -1)
+        r.use_edns(True)
+        self.assertEqual(r.edns, 0)
+
+    def testSetFlags(self):
+        flags = dns.flags.CD | dns.flags.RD
+        r = dns.resolver.Resolver(configure=False)
+        r.set_flags(flags)
+        self.assertEqual(r.flags, flags)
+
+    def testUseTSIG(self):
+        keyring = dns.tsigkeyring.from_text(
+            {
+                'keyname.': 'NjHwPsMKjdN++dOfE5iAiQ=='
+            }
+        )
+        r = dns.resolver.Resolver(configure=False)
+        r.use_tsig(keyring)
+        self.assertEqual(r.keyring, keyring)
+        self.assertEqual(r.keyname, None)
+        self.assertEqual(r.keyalgorithm, dns.tsig.default_algorithm)
+
+keyname = dns.name.from_text('keyname')
+
+
+
 @unittest.skipIf(not _network_available, "Internet not reachable")
 class LiveResolverTests(unittest.TestCase):
     def testZoneForName1(self):
@@ -414,9 +606,8 @@ class LiveResolverTests(unittest.TestCase):
         self.assertEqual(zname, ezname)
 
     def testZoneForName3(self):
-        name = dns.name.from_text('dnspython.org.')
         ezname = dns.name.from_text('dnspython.org.')
-        zname = dns.resolver.zone_for_name(name)
+        zname = dns.resolver.zone_for_name('dnspython.org.')
         self.assertEqual(zname, ezname)
 
     def testZoneForName4(self):
@@ -462,7 +653,12 @@ class LiveResolverTests(unittest.TestCase):
         qtype = dns.rdatatype.from_text('A')
         def bad():
             answer = dns.resolver.resolve(qname, qtype)
-        self.assertRaises(dns.resolver.NXDOMAIN, bad)
+        try:
+            dns.resolver.resolve(qname, qtype)
+            self.assertTrue(False)  # should not happen!
+        except dns.resolver.NXDOMAIN as nx:
+            self.assertIn(qname, nx.qnames())
+            self.assertGreaterEqual(len(nx.responses()), 1)
 
     def testResolveCacheHit(self):
         res = dns.resolver.Resolver(configure=False)
@@ -475,28 +671,42 @@ class LiveResolverTests(unittest.TestCase):
         answer2 = res.resolve('dns.google.', 'A')
         self.assertIs(answer2, answer1)
 
+    def testCanonicalNameNoCNAME(self):
+        cname = dns.name.from_text('www.google.com')
+        self.assertEqual(dns.resolver.canonical_name('www.google.com'), cname)
+
+    def testCanonicalNameCNAME(self):
+        name = dns.name.from_text('www.dnspython.org')
+        cname = dns.name.from_text('dmfrjf4ips8xa.cloudfront.net')
+        self.assertEqual(dns.resolver.canonical_name(name), cname)
+
+    def testCanonicalNameDangling(self):
+        name = dns.name.from_text('dangling-cname.dnspython.org')
+        cname = dns.name.from_text('dangling-target.dnspython.org')
+        self.assertEqual(dns.resolver.canonical_name(name), cname)
+
 class PollingMonkeyPatchMixin(object):
     def setUp(self):
-        self.__native_polling_backend = dns.query._polling_backend
-        dns.query._set_polling_backend(self.polling_backend())
+        self.__native_selector_class = dns.query._selector_class
+        dns.query._set_selector_class(self.selector_class())
 
         unittest.TestCase.setUp(self)
 
     def tearDown(self):
-        dns.query._set_polling_backend(self.__native_polling_backend)
+        dns.query._set_selector_class(self.__native_selector_class)
 
         unittest.TestCase.tearDown(self)
 
 
 class SelectResolverTestCase(PollingMonkeyPatchMixin, LiveResolverTests, unittest.TestCase):
-    def polling_backend(self):
-        return dns.query._select_for
+    def selector_class(self):
+        return selectors.SelectSelector
 
 
-if hasattr(select, 'poll'):
+if hasattr(selectors, 'PollSelector'):
     class PollResolverTestCase(PollingMonkeyPatchMixin, LiveResolverTests, unittest.TestCase):
-        def polling_backend(self):
-            return dns.query._poll_for
+        def selector_class(self):
+            return selectors.PollSelector
 
 
 class NXDOMAINExceptionTestCase(unittest.TestCase):
@@ -702,7 +912,7 @@ class NanoTests(unittest.TestCase):
         with NaptrNanoNameserver() as na:
             res = dns.resolver.Resolver(configure=False)
             res.port = na.udp_address[1]
-            res.nameservers = [ na.udp_address[0] ]
+            res.nameservers = [na.udp_address[0]]
             answer = dns.e164.query('1650551212', ['e164.arpa'], res)
             self.assertEqual(answer[0].order, 0)
             self.assertEqual(answer[0].preference, 0)
@@ -710,6 +920,26 @@ class NanoTests(unittest.TestCase):
             self.assertEqual(answer[0].service, b'')
             self.assertEqual(answer[0].regexp, b'')
             self.assertEqual(answer[0].replacement, dns.name.root)
-            def nxdomain():
-                answer = dns.e164.query('0123456789', ['e164.arpa'], res)
-            self.assertRaises(dns.resolver.NXDOMAIN, nxdomain)
+            with self.assertRaises(dns.resolver.NXDOMAIN):
+                dns.e164.query('0123456789', ['e164.arpa'], res)
+
+
+class AlwaysType3NXDOMAINNanoNameserver(Server):
+
+    def handle(self, request):
+        response = dns.message.make_response(request.message)
+        response.set_rcode(dns.rcode.NXDOMAIN)
+        response.flags |= dns.flags.RA
+        return response
+
+@unittest.skipIf(not (_network_available and _nanonameserver_available),
+                 "Internet and NanoAuth required")
+class ZoneForNameNoParentTest(unittest.TestCase):
+
+    def testNoRootSOA(self):
+        with AlwaysType3NXDOMAINNanoNameserver() as na:
+            res = dns.resolver.Resolver(configure=False)
+            res.port = na.udp_address[1]
+            res.nameservers = [na.udp_address[0]]
+            with self.assertRaises(dns.resolver.NoRootSOA):
+                dns.resolver.zone_for_name('www.foo.bar.', resolver=res)
diff --git a/tests/test_resolver_override.py b/tests/test_resolver_override.py
index acb8f87..ac93316 100644
--- a/tests/test_resolver_override.py
+++ b/tests/test_resolver_override.py
@@ -17,6 +17,7 @@ try:
 except socket.gaierror:
     _network_available = False
 
+
 @unittest.skipIf(not _network_available, "Internet not reachable")
 class OverrideSystemResolverTestCase(unittest.TestCase):
 
@@ -118,6 +119,30 @@ class OverrideSystemResolverTestCase(unittest.TestCase):
         except socket.gaierror as e:
             self.assertEqual(e.errno, socket.EAI_NONAME)
 
+    def test_getaddrinfo_only_service(self):
+        infos = socket.getaddrinfo(service=53, family=socket.AF_INET,
+                                   socktype=socket.SOCK_DGRAM,
+                                   proto=socket.IPPROTO_UDP)
+        self.assertEqual(len(infos), 1)
+        info = infos[0]
+        self.assertEqual(info[0], socket.AF_INET)
+        self.assertEqual(info[1], socket.SOCK_DGRAM)
+        self.assertEqual(info[2], socket.IPPROTO_UDP)
+        self.assertEqual(info[4], ('127.0.0.1', 53))
+
+    def test_unknown_service_fails(self):
+        with self.assertRaises(socket.gaierror):
+            socket.getaddrinfo('dns.google.', 'bogus-service')
+
+    def test_getnameinfo_tcp(self):
+        info = socket.getnameinfo(('8.8.8.8', 53))
+        self.assertEqual(info, ('dns.google', 'domain'))
+
+    def test_getnameinfo_udp(self):
+        info = socket.getnameinfo(('8.8.8.8', 53), socket.NI_DGRAM)
+        self.assertEqual(info, ('dns.google', 'domain'))
+
+
 # Give up on testing this for now as all of the names I've considered
 # using for testing are part of CDNs and there is deep magic in
 # gethostbyaddr() that python's getfqdn() is using.  At any rate,
@@ -145,3 +170,53 @@ class OverrideSystemResolverTestCase(unittest.TestCase):
         b = socket.gethostbyaddr('2001:4860:4860::8888')
         self.assertEqual(a[0], b[0])
         self.assertEqual(a[2], b[2])
+
+
+class FakeResolver:
+    def resolve(self, *args, **kwargs):
+        raise dns.exception.Timeout
+
+
+class OverrideSystemResolverUsingFakeResolverTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.res = FakeResolver()
+        dns.resolver.override_system_resolver(self.res)
+
+    def tearDown(self):
+        dns.resolver.restore_system_resolver()
+        self.res = None
+
+    def test_temporary_failure(self):
+        with self.assertRaises(socket.gaierror):
+            socket.getaddrinfo('dns.google')
+
+    # We don't need the fake resolver for the following tests, but we
+    # don't need the live network either, so we're testing here.
+
+    def test_no_host_or_service_fails(self):
+        with self.assertRaises(socket.gaierror):
+            socket.getaddrinfo()
+
+    def test_AI_ADDRCONFIG_fails(self):
+        with self.assertRaises(socket.gaierror):
+            socket.getaddrinfo('dns.google', flags=socket.AI_ADDRCONFIG)
+
+    def test_gethostbyaddr_of_name_fails(self):
+        with self.assertRaises(socket.gaierror):
+            socket.gethostbyaddr('bogus')
+
+
+@unittest.skipIf(not _network_available, "Internet not reachable")
+class OverrideSystemResolverUsingDefaultResolverTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.res = FakeResolver()
+        dns.resolver.override_system_resolver()
+
+    def tearDown(self):
+        dns.resolver.restore_system_resolver()
+        self.res = None
+
+    def test_override(self):
+        self.assertEqual(dns.resolver._resolver, dns.resolver.default_resolver)
diff --git a/tests/test_rrset.py b/tests/test_rrset.py
index 7835ba1..5c3f17d 100644
--- a/tests/test_rrset.py
+++ b/tests/test_rrset.py
@@ -79,42 +79,54 @@ class RRsetTestCase(unittest.TestCase):
         self.assertFalse(r1 is r2)
         self.assertTrue(r1 == r2)
 
-    def testMatch1(self):
+    def testFullMatch1(self):
         r1 = dns.rrset.from_text_list('foo', 30, 'in', 'a',
                                       ['10.0.0.1', '10.0.0.2'])
-        self.assertTrue(r1.match(r1.name, dns.rdataclass.IN,
-                                 dns.rdatatype.A, dns.rdatatype.NONE))
+        self.assertTrue(r1.full_match(r1.name, dns.rdataclass.IN,
+                                      dns.rdatatype.A, dns.rdatatype.NONE))
 
-    def testMatch2(self):
+    def testFullMatch2(self):
         r1 = dns.rrset.from_text_list('foo', 30, 'in', 'a',
                                       ['10.0.0.1', '10.0.0.2'])
         r1.deleting = dns.rdataclass.NONE
-        self.assertTrue(r1.match(r1.name, dns.rdataclass.IN,
-                                 dns.rdatatype.A, dns.rdatatype.NONE,
-                                 dns.rdataclass.NONE))
+        self.assertTrue(r1.full_match(r1.name, dns.rdataclass.IN,
+                                      dns.rdatatype.A, dns.rdatatype.NONE,
+                                      dns.rdataclass.NONE))
 
-    def testNoMatch1(self):
+    def testNoFullMatch1(self):
         n = dns.name.from_text('bar', None)
         r1 = dns.rrset.from_text_list('foo', 30, 'in', 'a',
                                       ['10.0.0.1', '10.0.0.2'])
-        self.assertFalse(r1.match(n, dns.rdataclass.IN,
-                                  dns.rdatatype.A, dns.rdatatype.NONE,
-                                  dns.rdataclass.ANY))
+        self.assertFalse(r1.full_match(n, dns.rdataclass.IN,
+                                       dns.rdatatype.A, dns.rdatatype.NONE,
+                                       dns.rdataclass.ANY))
 
-    def testNoMatch2(self):
+    def testNoFullMatch2(self):
         r1 = dns.rrset.from_text_list('foo', 30, 'in', 'a',
                                       ['10.0.0.1', '10.0.0.2'])
         r1.deleting = dns.rdataclass.NONE
-        self.assertFalse(r1.match(r1.name, dns.rdataclass.IN,
-                                  dns.rdatatype.A, dns.rdatatype.NONE,
-                                  dns.rdataclass.ANY))
+        self.assertFalse(r1.full_match(r1.name, dns.rdataclass.IN,
+                                       dns.rdatatype.A, dns.rdatatype.NONE,
+                                       dns.rdataclass.ANY))
+
+    def testNoFullMatch3(self):
+        r1 = dns.rrset.from_text_list('foo', 30, 'in', 'a',
+                                      ['10.0.0.1', '10.0.0.2'])
+        self.assertFalse(r1.full_match(r1.name, dns.rdataclass.IN,
+                                       dns.rdatatype.MX, dns.rdatatype.NONE,
+                                       dns.rdataclass.ANY))
+
+    def testMatchCompatibilityWithFullMatch(self):
+        r1 = dns.rrset.from_text_list('foo', 30, 'in', 'a',
+                                      ['10.0.0.1', '10.0.0.2'])
+        self.assertTrue(r1.match(r1.name, dns.rdataclass.IN,
+                                 dns.rdatatype.A, dns.rdatatype.NONE))
 
-    def testNoMatch3(self):
+    def testMatchCompatibilityWithRdatasetMatch(self):
         r1 = dns.rrset.from_text_list('foo', 30, 'in', 'a',
                                       ['10.0.0.1', '10.0.0.2'])
-        self.assertFalse(r1.match(r1.name, dns.rdataclass.IN,
-                                  dns.rdatatype.MX, dns.rdatatype.NONE,
-                                  dns.rdataclass.ANY))
+        self.assertTrue(r1.match(dns.rdataclass.IN, dns.rdatatype.A,
+                                 dns.rdatatype.NONE))
 
     def testToRdataset(self):
         r1 = dns.rrset.from_text_list('foo', 30, 'in', 'a',
diff --git a/tests/test_serial.py b/tests/test_serial.py
index d632a46..a9ef2df 100644
--- a/tests/test_serial.py
+++ b/tests/test_serial.py
@@ -64,9 +64,6 @@ class SerialTestCase(unittest.TestCase):
     def test_sub(self):
         self.assertEqual(S8(0) - S8(1), S8(255))
 
-    def test_sub(self):
-        self.assertEqual(S8(0) - S8(1), S8(255))
-
     def test_addition_bounds(self):
         self.assertRaises(ValueError, lambda: S8(0) + 128)
         self.assertRaises(ValueError, lambda: S8(0) - 128)
@@ -100,6 +97,7 @@ class SerialTestCase(unittest.TestCase):
         self.assertRaises(ValueError, bad2)
 
     def test_uncomparable(self):
+        self.assertFalse(S8(0) == S2(0))
         self.assertFalse(S8(0) == 'a')
         self.assertTrue(S8(0) != 'a')
         self.assertRaises(TypeError, lambda: S8(0) < 'a')
@@ -113,3 +111,7 @@ class SerialTestCase(unittest.TestCase):
 
     def test_repr(self):
         self.assertEqual(repr(S8(1)), 'dns.serial.Serial(1, 8)')
+
+    def test_not_equal(self):
+        self.assertNotEqual(S8(0), S8(1))
+        self.assertNotEqual(S8(0), S2(0))
diff --git a/tests/test_svcb.py b/tests/test_svcb.py
new file mode 100644
index 0000000..7cd7768
--- /dev/null
+++ b/tests/test_svcb.py
@@ -0,0 +1,326 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import io
+import unittest
+
+import dns.rdata
+import dns.rdtypes.svcbbase
+import dns.rrset
+
+class SVCBTestCase(unittest.TestCase):
+    def check_valid_inputs(self, inputs):
+        expected = inputs[0]
+        for text in inputs:
+            rr = dns.rdata.from_text('IN', 'SVCB', text)
+            new_text = rr.to_text()
+            self.assertEqual(expected, new_text)
+
+    def check_invalid_inputs(self, inputs):
+        for text in inputs:
+            with self.assertRaises(dns.exception.SyntaxError):
+                dns.rdata.from_text('IN', 'SVCB', text)
+
+    def test_svcb_general_invalid(self):
+        invalid_inputs = (
+            # Duplicate keys
+            "1 . alpn=h2 alpn=h3",
+            "1 . alpn=h2 key1=h3",
+            # Quoted keys
+            "1 . \"alpn=h2\"",
+            # Invalid space
+            "1 . alpn= h2",
+            "1 . alpn =h2",
+            "1 . alpn = h2",
+            "1 . alpn= \"h2\"",
+            "1 . =alpn",
+        )
+        self.check_invalid_inputs(invalid_inputs)
+
+    def test_svcb_mandatory(self):
+        valid_inputs = (
+            "1 . mandatory=\"alpn,no-default-alpn\" alpn=\"h2\" no-default-alpn",
+            "1 . mandatory=alpn,no-default-alpn alpn=h2 no-default-alpn",
+            "1 . mandatory=key1,key2 alpn=h2 no-default-alpn",
+            "1 . mandatory=alpn,no-default-alpn key1=\\002h2 key2",
+            "1 . key0=\\000\\001\\000\\002 alpn=h2 no-default-alpn",
+            "1 . alpn=h2 no-default-alpn mandatory=alpn,no-default-alpn",
+        )
+        self.check_valid_inputs(valid_inputs)
+
+        invalid_inputs = (
+            # empty
+            "1 . mandatory=",
+            "1 . mandatory",
+            # unknown key
+            "1 . mandatory=foo",
+            # key 0
+            "1 . mandatory=key0",
+            "1 . mandatory=key0,alpn",
+            # missing key
+            "1 . mandatory=alpn",
+            # duplicate
+            "1 . mandatory=alpn,alpn alpn=h2",
+            # invalid escaping
+            "1 . mandatory=\\alpn alpn=h2",
+            # empty wire format
+            "1 . key0",
+            "1 . key0=",
+            # 0 in wire format
+            "1 . key0=\\000\\000",
+            # invalid length in wire format
+            "1 . key0=\\000",
+            # out of order in wire format
+            "1 . key0=\\000\\002\\000\\001 alpn=h2 no-default-alpn",
+            # leading zeros
+            "1 . mandatory=key1,key002 alpn=h2 no-default-alpn",
+        )
+        self.check_invalid_inputs(invalid_inputs)
+
+    def test_svcb_alpn(self):
+        valid_inputs_two_items = (
+            "1 . alpn=\"h2,h3\"",
+            "1 . alpn=h2,h3",
+            "1 . alpn=h\\050,h3",
+            "1 . alpn=\"h\\050,h3\"",
+            "1 . alpn=\\h2,h3",
+            "1 . key1=\\002h2\\002h3",
+        )
+        self.check_valid_inputs(valid_inputs_two_items)
+
+        valid_inputs_one_item = (
+            "1 . alpn=\"h2\\,h3\"",
+            "1 . alpn=h2\\,h3",
+            "1 . alpn=h2\\044h3",
+        )
+        self.check_valid_inputs(valid_inputs_one_item)
+
+        invalid_inputs = (
+            "1 . alpn",
+            "1 . alpn=",
+            "1 . alpn=h2,,h3",
+            "1 . alpn=01234567890abcdef01234567890abcdef01234567890abcdef"
+                     "01234567890abcdef01234567890abcdef01234567890abcdef"
+                     "01234567890abcdef01234567890abcdef01234567890abcdef"
+                     "01234567890abcdef01234567890abcdef01234567890abcdef"
+                     "01234567890abcdef01234567890abcdef01234567890abcdef"
+                     "01234567890abcdef",
+            "1 . alpn=\",h2,h3\"",
+            "1 . alpn=\"h2,h3,\"",
+            "1 . key1",
+            "1 . key1=",
+            "1 . key1=\\000",
+            "1 . key1=\\002x",
+        )
+        self.check_invalid_inputs(invalid_inputs)
+
+    def test_svcb_no_default_alpn(self):
+        valid_inputs = (
+            "1 . no-default-alpn",
+            "1 . no-default-alpn=\"\"",
+            "1 . key2",
+            "1 . key2=\"\"",
+        )
+        self.check_valid_inputs(valid_inputs)
+
+        invalid_inputs = (
+            "1 . no-default-alpn=foo",
+            "1 . no-default-alpn=",
+            "1 . key2=foo",
+            "1 . key2=",
+        )
+        self.check_invalid_inputs(invalid_inputs)
+
+    def test_svcb_port(self):
+        valid_inputs = (
+            "1 . port=\"53\"",
+            "1 . port=53",
+            "1 . key3=\\000\\053",
+        )
+        self.check_valid_inputs(valid_inputs)
+
+        invalid_inputs = (
+            "1 . port",
+            "1 . port=",
+            "1 . port=53x",
+            "1 . port=x53",
+            "1 . port=53,54",
+            "1 . port=53\\,54",
+            "1 . port=65536",
+            "1 . key3",
+            "1 . key3=",
+            "1 . key3=\\000",
+        )
+        self.check_invalid_inputs(invalid_inputs)
+
+    def test_svcb_ipv4hint(self):
+        valid_inputs = (
+            "1 . ipv4hint=\"0.0.0.0,1.1.1.1\"",
+            "1 . ipv4hint=0.0.0.0,1.1.1.1",
+            "1 . key4=\\000\\000\\000\\000\\001\\001\\001\\001",
+        )
+        self.check_valid_inputs(valid_inputs)
+
+        invalid_inputs = (
+            "1 . ipv4hint",
+            "1 . ipv4hint=",
+            "1 . ipv4hint=1234",
+            "1 . ipv4hint=1\\.2.3.4",
+            "1 . ipv4hint=1.2.3.4\\,2.3.4.5",
+            "1 . key4=",
+            "1 . key4=123",
+        )
+        self.check_invalid_inputs(invalid_inputs)
+
+    def test_svcb_echconfig(self):
+        valid_inputs = (
+            "1 . echconfig=\"Zm9vMA==\"",
+            "1 . echconfig=Zm9vMA==",
+            "1 . key5=foo0",
+            "1 . key5=\\102\\111\\111\\048",
+        )
+        self.check_valid_inputs(valid_inputs)
+
+        invalid_inputs = (
+            "1 . echconfig",
+            "1 . echconfig=",
+            "1 . echconfig=Zm9vMA",
+            "1 . echconfig=\\090m9vMA==",
+            "1 . key5",
+            "1 . key5=",
+        )
+        self.check_invalid_inputs(invalid_inputs)
+
+    def test_svcb_ipv6hint(self):
+        valid_inputs = (
+            "1 . ipv6hint=\"::4,1::\"",
+            "1 . ipv6hint=::4,1::",
+            "1 . key6=\\000\\000\\000\\000\\000\\000\\000\\000"
+                     "\\000\\000\\000\\000\\000\\000\\000\\004"
+                     "\\000\\001\\000\\000\\000\\000\\000\\000"
+                     "\\000\\000\\000\\000\\000\\000\\000\\000",
+        )
+        self.check_valid_inputs(valid_inputs)
+
+        invalid_inputs = (
+            "1 . ipv6hint",
+            "1 . ipv6hint=",
+            "1 . ipv6hint=1234",
+            "1 . ipv6hint=1\\::2",
+            "1 . ipv6hint=::1\\,::2",
+            "1 . ipv6hint",
+            "1 . key6",
+            "1 . key6=",
+            "1 . key6=123",
+        )
+        self.check_invalid_inputs(invalid_inputs)
+
+    def test_svcb_unknown(self):
+        valid_inputs_one_key = (
+            "1 . key23=\"key45\"",
+            "1 . key23=key45",
+            "1 . key23=key\\052\\053",
+            "1 . key23=\"key\\052\\053\"",
+            "1 . key23=\\107\\101\\121\\052\\053",
+        )
+        self.check_valid_inputs(valid_inputs_one_key)
+
+        valid_inputs_one_key_empty = (
+            "1 . key23",
+            "1 . key23=\"\"",
+        )
+        self.check_valid_inputs(valid_inputs_one_key_empty)
+
+        invalid_inputs_one_key = (
+            "1 . key65536=foo",
+            "1 . key24= key48",
+        )
+        self.check_invalid_inputs(invalid_inputs_one_key)
+
+        valid_inputs_two_keys = (
+            "1 . key24 key48",
+            "1 . key24=\"\" key48",
+        )
+        self.check_valid_inputs(valid_inputs_two_keys)
+
+    def test_svcb_wire(self):
+        valid_inputs = (
+            "1 . mandatory=\"alpn,port\" alpn=\"h2\" port=\"257\"",
+            "\\# 24 0001 00 0000000400010003 00010003026832 000300020101",
+        )
+        self.check_valid_inputs(valid_inputs)
+
+        everything = \
+            "100 foo.com. mandatory=\"alpn,port\" alpn=\"h2,h3\" " \
+            "             no-default-alpn port=\"12345\" echconfig=\"abcd\" " \
+            "             ipv4hint=1.2.3.4,4.3.2.1 ipv6hint=1::2,3::4" \
+            "             key12345=\"foo\""
+        rr = dns.rdata.from_text('IN', 'SVCB', everything)
+        rr2 = dns.rdata.from_text('IN', 'SVCB', rr.to_generic().to_text())
+        self.assertEqual(rr, rr2)
+
+        invalid_inputs = (
+            # As above, but the keys are out of order.
+            "\\# 24 0001 00 0000000400010003 000300020101 00010003026832",
+            # As above, but the mandatory keys don't match
+            "\\# 24 0001 00 0000000400010002 000300020101 00010003026832",
+            "\\# 24 0001 00 0000000400010004 000300020101 00010003026832",
+            # Alias form shouldn't have parameters.
+            "\\# 08 0000 000300020101",
+        )
+        self.check_invalid_inputs(invalid_inputs)
+
+    def test_misc_escape(self):
+        rdata = dns.rdata.from_text('in', 'svcb', '1 . alpn=\\010\\010')
+        expected = '1 . alpn="\\010\\010"'
+        self.assertEqual(rdata.to_text(), expected)
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'svcb', '1 . alpn=\\0')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'svcb', '1 . alpn=\\00')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'svcb', '1 . alpn=\\00q')
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'svcb', '1 . alpn=\\256')
+        # This doesn't usually get exercised, so we do it directly.
+        gp = dns.rdtypes.svcbbase.GenericParam.from_value('\\001\\002')
+        expected = '"\\001\\002"'
+        self.assertEqual(gp.to_text(), expected)
+
+    def test_alias_mode(self):
+        rd = dns.rdata.from_text('in', 'svcb', '0 .')
+        self.assertEqual(len(rd.params), 0)
+        self.assertEqual(rd.target, dns.name.root)
+        self.assertEqual(rd.to_text(), '0 .')
+        rd = dns.rdata.from_text('in', 'svcb', '0 elsewhere.')
+        self.assertEqual(rd.target, dns.name.from_text('elsewhere.'))
+        self.assertEqual(len(rd.params), 0)
+        # provoke 'parameters in AliasMode' from text.
+        with self.assertRaises(dns.exception.SyntaxError):
+            dns.rdata.from_text('in', 'svcb', '0 elsewhere. alpn=h2')
+        # provoke 'parameters in AliasMode' from wire too.
+        wire = bytes.fromhex('0000000000000400010003')
+        with self.assertRaises(dns.exception.FormError):
+            dns.rdata.from_wire('in', 'svcb', wire, 0, len(wire))
+
+    def test_immutability(self):
+        alpn = dns.rdtypes.svcbbase.ALPNParam.from_value(['h2', 'h3'])
+        with self.assertRaises(TypeError):
+            alpn.ids[0] = 'foo'
+        with self.assertRaises(TypeError):
+            del alpn.ids[0]
+        with self.assertRaises(TypeError):
+            alpn.ids = 'foo'
+        with self.assertRaises(TypeError):
+            del alpn.ids
+
+    def test_alias_not_compressed(self):
+        rrs = dns.rrset.from_text('elsewhere.', 300, 'in', 'svcb',
+                                  '0 elseWhere.')
+        output = io.BytesIO()
+        compress = {}
+        rrs.to_wire(output, compress)
+        wire = output.getvalue()
+        # Just one of these assertions is enough, but we do both to show
+        # the bug we're checking is fixed.
+        assert not wire.endswith(b'\xc0\x00')
+        assert wire.endswith(b'\x09elseWhere\x00')
diff --git a/tests/test_tokenizer.py b/tests/test_tokenizer.py
index ff189dd..6134d4b 100644
--- a/tests/test_tokenizer.py
+++ b/tests/test_tokenizer.py
@@ -51,22 +51,19 @@ class TokenizerTestCase(unittest.TestCase):
                                       'foo\\010bar'))
 
     def testQuotedString5(self):
-        def bad():
+        with self.assertRaises(dns.exception.UnexpectedEnd):
             tok = dns.tokenizer.Tokenizer(r'"foo')
             tok.get()
-        self.assertRaises(dns.exception.UnexpectedEnd, bad)
 
     def testQuotedString6(self):
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer(r'"foo\01')
             tok.get()
-        self.assertRaises(dns.exception.SyntaxError, bad)
 
     def testQuotedString7(self):
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('"foo\nbar"')
             tok.get()
-        self.assertRaises(dns.exception.SyntaxError, bad)
 
     def testEmpty1(self):
         tok = dns.tokenizer.Tokenizer('')
@@ -126,17 +123,16 @@ class TokenizerTestCase(unittest.TestCase):
         self.assertEqual(tokens, [Token(dns.tokenizer.IDENTIFIER, 'foo'),
                                   Token(dns.tokenizer.IDENTIFIER, 'bar'),
                                   Token(dns.tokenizer.EOL, '\n')])
+
     def testMultiline3(self):
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('foo)')
             list(iter(tok))
-        self.assertRaises(dns.exception.SyntaxError, bad)
 
     def testMultiline4(self):
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('((foo)')
             list(iter(tok))
-        self.assertRaises(dns.exception.SyntaxError, bad)
 
     def testUnget1(self):
         tok = dns.tokenizer.Tokenizer('foo')
@@ -148,12 +144,11 @@ class TokenizerTestCase(unittest.TestCase):
         self.assertEqual(t1.value, 'foo')
 
     def testUnget2(self):
-        def bad():
+        with self.assertRaises(dns.tokenizer.UngetBufferFull):
             tok = dns.tokenizer.Tokenizer('foo')
             t1 = tok.get()
             tok.unget(t1)
             tok.unget(t1)
-        self.assertRaises(dns.tokenizer.UngetBufferFull, bad)
 
     def testGetEOL1(self):
         tok = dns.tokenizer.Tokenizer('\n')
@@ -205,31 +200,35 @@ class TokenizerTestCase(unittest.TestCase):
         tok = dns.tokenizer.Tokenizer('1234')
         v = tok.get_int()
         self.assertEqual(v, 1234)
-        def bad1():
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('"1234"')
-            v = tok.get_int()
-        self.assertRaises(dns.exception.SyntaxError, bad1)
-        def bad2():
+            tok.get_int()
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('q1234')
-            v = tok.get_int()
-        self.assertRaises(dns.exception.SyntaxError, bad2)
-        def bad3():
+            tok.get_int()
+        with self.assertRaises(dns.exception.SyntaxError):
+            tok = dns.tokenizer.Tokenizer('281474976710656')
+            tok.get_uint48()
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('4294967296')
-            v = tok.get_uint32()
-        self.assertRaises(dns.exception.SyntaxError, bad3)
-        def bad4():
+            tok.get_uint32()
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('65536')
-            v = tok.get_uint16()
-        self.assertRaises(dns.exception.SyntaxError, bad4)
-        def bad5():
+            tok.get_uint16()
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('256')
-            v = tok.get_uint8()
-        self.assertRaises(dns.exception.SyntaxError, bad5)
+            tok.get_uint8()
         # Even though it is badly named get_int(), it's really get_unit!
-        def bad6():
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('-1234')
-            v = tok.get_int()
-        self.assertRaises(dns.exception.SyntaxError, bad5)
+            tok.get_int()
+        # get_uint16 can do other bases too, and has a custom error
+        # for base 8.
+        tok = dns.tokenizer.Tokenizer('177777')
+        self.assertEqual(tok.get_uint16(base=8), 65535)
+        with self.assertRaises(dns.exception.SyntaxError):
+            tok = dns.tokenizer.Tokenizer('200000')
+            tok.get_uint16(base=8)
 
     def testGetString(self):
         tok = dns.tokenizer.Tokenizer('foo')
@@ -241,10 +240,12 @@ class TokenizerTestCase(unittest.TestCase):
         tok = dns.tokenizer.Tokenizer('abcdefghij')
         v = tok.get_string(max_length=10)
         self.assertEqual(v, 'abcdefghij')
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('abcdefghij')
-            v = tok.get_string(max_length=9)
-        self.assertRaises(dns.exception.SyntaxError, bad)
+            tok.get_string(max_length=9)
+        tok = dns.tokenizer.Tokenizer('')
+        with self.assertRaises(dns.exception.SyntaxError):
+            tok.get_string()
 
     def testMultiLineWithComment(self):
         tok = dns.tokenizer.Tokenizer('( ; abc\n)')
@@ -263,16 +264,22 @@ class TokenizerTestCase(unittest.TestCase):
         self.assertTrue(t.is_eof())
 
     def testMultiLineWithEOFAfterComment(self):
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('( ; abc')
             tok.get_eol()
-        self.assertRaises(dns.exception.SyntaxError, bad)
 
     def testEscapeUnexpectedEnd(self):
-        def bad():
+        with self.assertRaises(dns.exception.UnexpectedEnd):
             tok = dns.tokenizer.Tokenizer('\\')
             tok.get()
-        self.assertRaises(dns.exception.UnexpectedEnd, bad)
+
+    def testEscapeBounds(self):
+        with self.assertRaises(dns.exception.SyntaxError):
+            tok = dns.tokenizer.Tokenizer('\\256')
+            tok.get().unescape()
+        with self.assertRaises(dns.exception.SyntaxError):
+            tok = dns.tokenizer.Tokenizer('\\256')
+            tok.get().unescape_to_bytes()
 
     def testGetUngetRegetComment(self):
         tok = dns.tokenizer.Tokenizer(';comment')
@@ -282,51 +289,74 @@ class TokenizerTestCase(unittest.TestCase):
         self.assertEqual(t1, t2)
 
     def testBadAsName(self):
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('"not an identifier"')
             t = tok.get()
             tok.as_name(t)
-        self.assertRaises(dns.exception.SyntaxError, bad)
 
     def testBadGetTTL(self):
-        def bad():
+        with self.assertRaises(dns.exception.SyntaxError):
+            tok = dns.tokenizer.Tokenizer('"not an identifier"')
+            tok.get_ttl()
+
+    def testBadGetEOL(self):
+        with self.assertRaises(dns.exception.SyntaxError):
             tok = dns.tokenizer.Tokenizer('"not an identifier"')
-            v = tok.get_ttl()
-        self.assertRaises(dns.exception.SyntaxError, bad)
+            tok.get_eol_as_token()
 
     def testDanglingEscapes(self):
-        def bad1():
-            tok = dns.tokenizer.Tokenizer('"\\"')
-            t = tok.get().unescape()
-        self.assertRaises(dns.exception.SyntaxError, bad1)
-        def bad2():
-            tok = dns.tokenizer.Tokenizer('"\\0"')
-            t = tok.get().unescape()
-        self.assertRaises(dns.exception.SyntaxError, bad2)
-        def bad3():
-            tok = dns.tokenizer.Tokenizer('"\\00"')
-            t = tok.get().unescape()
-        self.assertRaises(dns.exception.SyntaxError, bad3)
-        def bad4():
-            tok = dns.tokenizer.Tokenizer('"\\"')
-            t = tok.get().unescape_to_bytes()
-        self.assertRaises(dns.exception.SyntaxError, bad4)
-        def bad5():
-            tok = dns.tokenizer.Tokenizer('"\\0"')
-            t = tok.get().unescape_to_bytes()
-        self.assertRaises(dns.exception.SyntaxError, bad5)
-        def bad6():
-            tok = dns.tokenizer.Tokenizer('"\\00"')
-            t = tok.get().unescape_to_bytes()
-        self.assertRaises(dns.exception.SyntaxError, bad6)
-        def bad7():
-            tok = dns.tokenizer.Tokenizer('"\\00a"')
-            t = tok.get().unescape()
-        self.assertRaises(dns.exception.SyntaxError, bad7)
-        def bad8():
-            tok = dns.tokenizer.Tokenizer('"\\00a"')
-            t = tok.get().unescape_to_bytes()
-        self.assertRaises(dns.exception.SyntaxError, bad8)
+        for text in ['"\\"', '"\\0"', '"\\00"', '"\\00a"']:
+            with self.assertRaises(dns.exception.SyntaxError):
+                tok = dns.tokenizer.Tokenizer(text)
+                tok.get().unescape()
+            with self.assertRaises(dns.exception.SyntaxError):
+                tok = dns.tokenizer.Tokenizer(text)
+                tok.get().unescape_to_bytes()
+
+    def testTokenMisc(self):
+        t1 = dns.tokenizer.Token(dns.tokenizer.IDENTIFIER, 'hi')
+        t2 = dns.tokenizer.Token(dns.tokenizer.IDENTIFIER, 'hi')
+        t3 = dns.tokenizer.Token(dns.tokenizer.IDENTIFIER, 'there')
+        self.assertEqual(t1, t2)
+        self.assertFalse(t1 == 'hi')  # not NotEqual because we want to use ==
+        self.assertNotEqual(t1, 'hi')
+        self.assertNotEqual(t1, t3)
+        self.assertEqual(str(t1), '3 "hi"')
+
+    def testBadConcatenateRemaining(self):
+        with self.assertRaises(dns.exception.SyntaxError):
+            tok = dns.tokenizer.Tokenizer('a b "not an identifer" c')
+            tok.concatenate_remaining_identifiers()
+
+    def testStdinFilename(self):
+        tok = dns.tokenizer.Tokenizer()
+        self.assertEqual(tok.filename, '<stdin>')
+
+    def testBytesLiteral(self):
+        tok = dns.tokenizer.Tokenizer(b'this is input')
+        self.assertEqual(tok.get().value, 'this')
+        self.assertEqual(tok.filename, '<string>')
+        tok = dns.tokenizer.Tokenizer(b'this is input', 'myfilename')
+        self.assertEqual(tok.filename, 'myfilename')
+
+    def testUngetBranches(self):
+        tok = dns.tokenizer.Tokenizer(b'    this is input')
+        t = tok.get(want_leading=True)
+        tok.unget(t)
+        t = tok.get(want_leading=True)
+        self.assertEqual(t.ttype, dns.tokenizer.WHITESPACE)
+        tok.unget(t)
+        t = tok.get()
+        self.assertEqual(t.ttype, dns.tokenizer.IDENTIFIER)
+        self.assertEqual(t.value, 'this')
+        tok = dns.tokenizer.Tokenizer(b';    this is input\n')
+        t = tok.get(want_comment=True)
+        tok.unget(t)
+        t = tok.get(want_comment=True)
+        self.assertEqual(t.ttype, dns.tokenizer.COMMENT)
+        tok.unget(t)
+        t = tok.get()
+        self.assertEqual(t.ttype, dns.tokenizer.EOL)
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_transaction.py b/tests/test_transaction.py
new file mode 100644
index 0000000..bb69b71
--- /dev/null
+++ b/tests/test_transaction.py
@@ -0,0 +1,610 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import time
+
+import pytest
+
+import dns.name
+import dns.rdataclass
+import dns.rdatatype
+import dns.rdataset
+import dns.rrset
+import dns.transaction
+import dns.versioned
+import dns.zone
+
+
+class DB(dns.transaction.TransactionManager):
+    def __init__(self):
+        self.rdatasets = {}
+
+    def reader(self):
+        return Transaction(self, False, True)
+
+    def writer(self, replacement=False):
+        return Transaction(self, replacement, False)
+
+    def origin_information(self):
+        return (dns.name.from_text('example'), True, dns.name.empty)
+
+    def get_class(self):
+        return dns.rdataclass.IN
+
+
+class Transaction(dns.transaction.Transaction):
+    def __init__(self, db, replacement, read_only):
+        super().__init__(db, replacement, read_only)
+        self.rdatasets = {}
+        if not replacement:
+            self.rdatasets.update(db.rdatasets)
+
+    @property
+    def db(self):
+        return self.manager
+
+    def _get_rdataset(self, name, rdtype, covers):
+        return self.rdatasets.get((name, rdtype, covers))
+
+    def _put_rdataset(self, name, rdataset):
+        self.rdatasets[(name, rdataset.rdtype, rdataset.covers)] = rdataset
+
+    def _delete_name(self, name):
+        remove = []
+        for key in self.rdatasets.keys():
+            if key[0] == name:
+                remove.append(key)
+        if len(remove) > 0:
+            for key in remove:
+                del self.rdatasets[key]
+
+    def _delete_rdataset(self, name, rdtype, covers):
+        del self.rdatasets[(name, rdtype, covers)]
+
+    def _name_exists(self, name):
+        for key in self.rdatasets.keys():
+            if key[0] == name:
+                return True
+        return False
+
+    def _changed(self):
+        if self.read_only:
+            return False
+        else:
+            return len(self.rdatasets) > 0
+
+    def _end_transaction(self, commit):
+        if commit:
+            self.db.rdatasets = self.rdatasets
+
+    def _set_origin(self, origin):
+        pass
+
+@pytest.fixture
+def db():
+    db = DB()
+    rrset = dns.rrset.from_text('content', 300, 'in', 'txt', 'content')
+    db.rdatasets[(rrset.name, rrset.rdtype, 0)] = rrset
+    return db
+
+def test_basic(db):
+    # successful txn
+    with db.writer() as txn:
+        rrset = dns.rrset.from_text('foo', 300, 'in', 'a',
+                                    '10.0.0.1', '10.0.0.2')
+        txn.add(rrset)
+        assert txn.name_exists(rrset.name)
+    assert db.rdatasets[(rrset.name, rrset.rdtype, 0)] == \
+        rrset
+    # rollback
+    with pytest.raises(Exception):
+        with db.writer() as txn:
+            rrset2 = dns.rrset.from_text('foo', 300, 'in', 'a',
+                                         '10.0.0.3', '10.0.0.4')
+            txn.add(rrset2)
+            raise Exception()
+    assert db.rdatasets[(rrset.name, rrset.rdtype, 0)] == \
+        rrset
+    with db.writer() as txn:
+        txn.delete(rrset.name)
+    assert db.rdatasets.get((rrset.name, rrset.rdtype, 0)) \
+        is None
+
+def test_get(db):
+    with db.writer() as txn:
+        content = dns.name.from_text('content', None)
+        rdataset = txn.get(content, dns.rdatatype.TXT)
+        assert rdataset is not None
+        assert rdataset[0].strings == (b'content',)
+        assert isinstance(rdataset, dns.rdataset.ImmutableRdataset)
+
+def test_add(db):
+    with db.writer() as txn:
+        rrset = dns.rrset.from_text('foo', 300, 'in', 'a',
+                                    '10.0.0.1', '10.0.0.2')
+        txn.add(rrset)
+        rrset2 = dns.rrset.from_text('foo', 300, 'in', 'a',
+                                     '10.0.0.3', '10.0.0.4')
+        txn.add(rrset2)
+    expected = dns.rrset.from_text('foo', 300, 'in', 'a',
+                                   '10.0.0.1', '10.0.0.2',
+                                   '10.0.0.3', '10.0.0.4')
+    assert db.rdatasets[(rrset.name, rrset.rdtype, 0)] == \
+        expected
+
+def test_replacement(db):
+    with db.writer() as txn:
+        rrset = dns.rrset.from_text('foo', 300, 'in', 'a',
+                                    '10.0.0.1', '10.0.0.2')
+        txn.add(rrset)
+        rrset2 = dns.rrset.from_text('foo', 300, 'in', 'a',
+                                     '10.0.0.3', '10.0.0.4')
+        txn.replace(rrset2)
+    assert db.rdatasets[(rrset.name, rrset.rdtype, 0)] == \
+        rrset2
+
+def test_delete(db):
+    with db.writer() as txn:
+        txn.delete(dns.name.from_text('nonexistent', None))
+        content = dns.name.from_text('content', None)
+        content2 = dns.name.from_text('content2', None)
+        txn.delete(content)
+        assert not txn.name_exists(content)
+        txn.delete(content2, dns.rdatatype.TXT)
+        rrset = dns.rrset.from_text('content', 300, 'in', 'txt', 'new-content')
+        txn.add(rrset)
+        assert txn.name_exists(content)
+        txn.delete(content, dns.rdatatype.TXT)
+        assert not txn.name_exists(content)
+        rrset = dns.rrset.from_text('content2', 300, 'in', 'txt', 'new-content')
+        txn.delete(rrset)
+    content_keys = [k for k in db.rdatasets if k[0] == content]
+    assert len(content_keys) == 0
+
+def test_delete_exact(db):
+    with db.writer() as txn:
+        rrset = dns.rrset.from_text('content', 300, 'in', 'txt', 'bad-content')
+        with pytest.raises(dns.transaction.DeleteNotExact):
+            txn.delete_exact(rrset)
+        rrset = dns.rrset.from_text('content2', 300, 'in', 'txt', 'bad-content')
+        with pytest.raises(dns.transaction.DeleteNotExact):
+            txn.delete_exact(rrset)
+        with pytest.raises(dns.transaction.DeleteNotExact):
+            txn.delete_exact(rrset.name)
+        with pytest.raises(dns.transaction.DeleteNotExact):
+            txn.delete_exact(rrset.name, dns.rdatatype.TXT)
+        rrset = dns.rrset.from_text('content', 300, 'in', 'txt', 'content')
+        txn.delete_exact(rrset)
+    assert db.rdatasets.get((rrset.name, rrset.rdtype, 0)) \
+        is None
+
+def test_parameter_forms(db):
+    with db.writer() as txn:
+        foo = dns.name.from_text('foo', None)
+        rdataset = dns.rdataset.from_text('in', 'a', 300,
+                                          '10.0.0.1', '10.0.0.2')
+        rdata1 = dns.rdata.from_text('in', 'a', '10.0.0.3')
+        rdata2 = dns.rdata.from_text('in', 'a', '10.0.0.4')
+        txn.add(foo, rdataset)
+        txn.add(foo, 100, rdata1)
+        txn.add(foo, 30, rdata2)
+    expected = dns.rrset.from_text('foo', 30, 'in', 'a',
+                                   '10.0.0.1', '10.0.0.2',
+                                   '10.0.0.3', '10.0.0.4')
+    assert db.rdatasets[(foo, rdataset.rdtype, 0)] == \
+        expected
+    with db.writer() as txn:
+        txn.delete(foo, rdataset)
+        txn.delete(foo, rdata1)
+        txn.delete(foo, rdata2)
+    assert db.rdatasets.get((foo, rdataset.rdtype, 0)) \
+        is None
+
+def test_bad_parameters(db):
+    with db.writer() as txn:
+        with pytest.raises(TypeError):
+            txn.add(1)
+        with pytest.raises(TypeError):
+            rrset = dns.rrset.from_text('bar', 300, 'in', 'txt', 'bar')
+            txn.add(rrset, 1)
+        with pytest.raises(ValueError):
+            foo = dns.name.from_text('foo', None)
+            rdata = dns.rdata.from_text('in', 'a', '10.0.0.3')
+            txn.add(foo, 0x80000000, rdata)
+        with pytest.raises(TypeError):
+            txn.add(foo)
+        with pytest.raises(TypeError):
+            txn.add()
+        with pytest.raises(TypeError):
+            txn.add(foo, 300)
+        with pytest.raises(TypeError):
+            txn.add(foo, 300, 'hi')
+        with pytest.raises(TypeError):
+            txn.add(foo, 'hi')
+        with pytest.raises(TypeError):
+            txn.delete()
+        with pytest.raises(TypeError):
+            txn.delete(1)
+
+def test_cannot_store_non_origin_soa(db):
+    with pytest.raises(ValueError):
+        with db.writer() as txn:
+            rrset = dns.rrset.from_text('foo', 300, 'in', 'SOA',
+                                        '. . 1 2 3 4 5')
+            txn.add(rrset)
+
+example_text = """$TTL 3600
+$ORIGIN example.
+@ soa foo bar 1 2 3 4 5
+@ ns ns1
+@ ns ns2
+ns1 a 10.0.0.1
+ns2 a 10.0.0.2
+$TTL 300
+$ORIGIN foo.example.
+bar mx 0 blaz
+"""
+
+example_text_output = """@ 3600 IN SOA foo bar 1 2 3 4 5
+@ 3600 IN NS ns1
+@ 3600 IN NS ns2
+@ 3600 IN NS ns3
+ns1 3600 IN A 10.0.0.1
+ns2 3600 IN A 10.0.0.2
+ns3 3600 IN A 10.0.0.3
+"""
+
+@pytest.fixture(params=[dns.zone.Zone, dns.versioned.Zone])
+def zone(request):
+    return dns.zone.from_text(example_text, zone_factory=request.param)
+
+def test_zone_basic(zone):
+    with zone.writer() as txn:
+        txn.delete(dns.name.from_text('bar.foo', None))
+        rd = dns.rdata.from_text('in', 'ns', 'ns3')
+        txn.add(dns.name.empty, 3600, rd)
+        rd = dns.rdata.from_text('in', 'a', '10.0.0.3')
+        txn.add(dns.name.from_text('ns3', None), 3600, rd)
+    output = zone.to_text()
+    assert output == example_text_output
+
+def test_explicit_rollback_and_commit(zone):
+    with zone.writer() as txn:
+        assert not txn.changed()
+        txn.delete(dns.name.from_text('bar.foo', None))
+        txn.rollback()
+    assert zone.get_node('bar.foo') is not None
+    with zone.writer() as txn:
+        assert not txn.changed()
+        txn.delete(dns.name.from_text('bar.foo', None))
+        txn.commit()
+    assert zone.get_node('bar.foo') is None
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.writer() as txn:
+            txn.rollback()
+            txn.delete(dns.name.from_text('bar.foo', None))
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.writer() as txn:
+            txn.rollback()
+            txn.add('bar.foo', 300, dns.rdata.from_text('in', 'txt', 'hi'))
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.writer() as txn:
+            txn.rollback()
+            txn.replace('bar.foo', 300, dns.rdata.from_text('in', 'txt', 'hi'))
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.reader() as txn:
+            txn.rollback()
+            txn.get('bar.foo', 'in', 'mx')
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.writer() as txn:
+            txn.rollback()
+            txn.delete_exact('bar.foo')
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.writer() as txn:
+            txn.rollback()
+            txn.name_exists('bar.foo')
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.writer() as txn:
+            txn.rollback()
+            txn.update_serial()
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.writer() as txn:
+            txn.rollback()
+            txn.changed()
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.writer() as txn:
+            txn.rollback()
+            txn.rollback()
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.writer() as txn:
+            txn.rollback()
+            txn.commit()
+    with pytest.raises(dns.transaction.AlreadyEnded):
+        with zone.writer() as txn:
+            txn.rollback()
+            for rdataset in txn:
+                print(rdataset)
+
+def test_zone_changed(zone):
+    # Read-only is not changed!
+    with zone.reader() as txn:
+        assert not txn.changed()
+    # delete an existing name
+    with zone.writer() as txn:
+        assert not txn.changed()
+        txn.delete(dns.name.from_text('bar.foo', None))
+        assert txn.changed()
+    # delete a nonexistent name
+    with zone.writer() as txn:
+        assert not txn.changed()
+        txn.delete(dns.name.from_text('unknown.bar.foo', None))
+        assert not txn.changed()
+    # delete a nonexistent rdataset from an extant node
+    with zone.writer() as txn:
+        assert not txn.changed()
+        txn.delete(dns.name.from_text('bar.foo', None), 'txt')
+        assert not txn.changed()
+    # add an rdataset to an extant Node
+    with zone.writer() as txn:
+        assert not txn.changed()
+        txn.add('bar.foo', 300, dns.rdata.from_text('in', 'txt', 'hi'))
+        assert txn.changed()
+    # add an rdataset to a nonexistent Node
+    with zone.writer() as txn:
+        assert not txn.changed()
+        txn.add('foo.foo', 300, dns.rdata.from_text('in', 'txt', 'hi'))
+        assert txn.changed()
+
+def test_zone_base_layer(zone):
+    with zone.writer() as txn:
+        # Get a set from the zone layer
+        rdataset = txn.get(dns.name.empty, dns.rdatatype.NS, dns.rdatatype.NONE)
+        expected = dns.rdataset.from_text('in', 'ns', 300, 'ns1', 'ns2')
+        assert rdataset == expected
+
+def test_zone_transaction_layer(zone):
+    with zone.writer() as txn:
+        # Make a change
+        rd = dns.rdata.from_text('in', 'ns', 'ns3')
+        txn.add(dns.name.empty, 3600, rd)
+        # Get a set from the transaction layer
+        expected = dns.rdataset.from_text('in', 'ns', 300, 'ns1', 'ns2', 'ns3')
+        rdataset = txn.get(dns.name.empty, dns.rdatatype.NS, dns.rdatatype.NONE)
+        assert rdataset == expected
+        assert txn.name_exists(dns.name.empty)
+        ns1 = dns.name.from_text('ns1', None)
+        assert txn.name_exists(ns1)
+        ns99 = dns.name.from_text('ns99', None)
+        assert not txn.name_exists(ns99)
+
+def test_zone_add_and_delete(zone):
+    with zone.writer() as txn:
+        a99 = dns.name.from_text('a99', None)
+        a100 = dns.name.from_text('a100', None)
+        a101 = dns.name.from_text('a101', None)
+        rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.99')
+        txn.add(a99, rds)
+        txn.delete(a99, dns.rdatatype.A)
+        txn.delete(a100, dns.rdatatype.A)
+        txn.delete(a101)
+        assert not txn.name_exists(a99)
+        assert not txn.name_exists(a100)
+        assert not txn.name_exists(a101)
+        ns1 = dns.name.from_text('ns1', None)
+        txn.delete(ns1, dns.rdatatype.A)
+        assert not txn.name_exists(ns1)
+    with zone.writer() as txn:
+        txn.add(a99, rds)
+        txn.delete(a99)
+        assert not txn.name_exists(a99)
+    with zone.writer() as txn:
+        txn.add(a100, rds)
+        txn.delete(a99)
+        assert not txn.name_exists(a99)
+        assert txn.name_exists(a100)
+
+def test_write_after_rollback(zone):
+    with pytest.raises(ExpectedException):
+        with zone.writer() as txn:
+            a99 = dns.name.from_text('a99', None)
+            rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.99')
+            txn.add(a99, rds)
+            raise ExpectedException
+    with zone.writer() as txn:
+        a99 = dns.name.from_text('a99', None)
+        rds = dns.rdataset.from_text('in', 'a', 300, '10.99.99.99')
+        txn.add(a99, rds)
+    assert zone.get_rdataset('a99', 'a') == rds
+
+def test_zone_get_deleted(zone):
+    with zone.writer() as txn:
+        print(zone.to_text())
+        ns1 = dns.name.from_text('ns1', None)
+        assert txn.get(ns1, dns.rdatatype.A) is not None
+        txn.delete(ns1)
+        assert txn.get(ns1, dns.rdatatype.A) is None
+        ns2 = dns.name.from_text('ns2', None)
+        txn.delete(ns2, dns.rdatatype.A)
+        assert txn.get(ns2, dns.rdatatype.A) is None
+
+def test_zone_bad_class(zone):
+    with zone.writer() as txn:
+        rds = dns.rdataset.from_text('ch', 'ns', 300, 'ns1', 'ns2')
+        with pytest.raises(ValueError):
+            txn.add(dns.name.empty, rds)
+        with pytest.raises(ValueError):
+            txn.replace(dns.name.empty, rds)
+        with pytest.raises(ValueError):
+            txn.delete(dns.name.empty, rds)
+
+def test_update_serial(zone):
+    # basic
+    with zone.writer() as txn:
+        txn.update_serial()
+    rdataset = zone.find_rdataset('@', 'soa')
+    assert rdataset[0].serial == 2
+    # max
+    with zone.writer() as txn:
+        txn.update_serial(0xffffffff, False)
+    rdataset = zone.find_rdataset('@', 'soa')
+    assert rdataset[0].serial == 0xffffffff
+    # wraparound to 1
+    with zone.writer() as txn:
+        txn.update_serial()
+    rdataset = zone.find_rdataset('@', 'soa')
+    assert rdataset[0].serial == 1
+    # trying to set to zero sets to 1
+    with zone.writer() as txn:
+        txn.update_serial(0, False)
+    rdataset = zone.find_rdataset('@', 'soa')
+    assert rdataset[0].serial == 1
+    with pytest.raises(KeyError):
+        with zone.writer() as txn:
+            txn.update_serial(name=dns.name.from_text('unknown', None))
+    with pytest.raises(ValueError):
+        with zone.writer() as txn:
+            txn.update_serial(-1)
+    with pytest.raises(ValueError):
+        with zone.writer() as txn:
+            txn.update_serial(2**31)
+
+class ExpectedException(Exception):
+    pass
+
+def test_zone_rollback(zone):
+    try:
+        with zone.writer() as txn:
+            a99 = dns.name.from_text('a99.example.')
+            rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.99')
+            txn.add(a99, rds)
+            assert txn.name_exists(a99)
+            raise ExpectedException
+    except ExpectedException:
+        pass
+    assert not zone.get_node(a99)
+
+def test_zone_ooz_name(zone):
+    with zone.writer() as txn:
+        with pytest.raises(KeyError):
+            a99 = dns.name.from_text('a99.not-example.')
+            assert txn.name_exists(a99)
+
+def test_zone_iteration(zone):
+    expected = {}
+    for (name, rdataset) in zone.iterate_rdatasets():
+        expected[(name, rdataset.rdtype, rdataset.covers)] = rdataset
+    with zone.writer() as txn:
+        actual = {}
+        for (name, rdataset) in txn:
+            actual[(name, rdataset.rdtype, rdataset.covers)] = rdataset
+    assert actual == expected
+
+@pytest.fixture
+def vzone():
+    return dns.zone.from_text(example_text, zone_factory=dns.versioned.Zone)
+
+def test_vzone_read_only(vzone):
+    with vzone.reader() as txn:
+        rdataset = txn.get(dns.name.empty, dns.rdatatype.NS, dns.rdatatype.NONE)
+        expected = dns.rdataset.from_text('in', 'ns', 300, 'ns1', 'ns2')
+        assert rdataset == expected
+        with pytest.raises(dns.transaction.ReadOnly):
+            txn.replace(dns.name.empty, expected)
+
+def test_vzone_multiple_versions(vzone):
+    assert len(vzone._versions) == 1
+    vzone.set_max_versions(None)  # unlimited!
+    with vzone.writer() as txn:
+        txn.update_serial()
+    with vzone.writer() as txn:
+        txn.update_serial()
+    with vzone.writer() as txn:
+        txn.update_serial(1000, False)
+    rdataset = vzone.find_rdataset('@', 'soa')
+    assert rdataset[0].serial == 1000
+    assert len(vzone._versions) == 4
+    with vzone.reader(id=5) as txn:
+        assert txn.version.id == 5
+        rdataset = txn.get('@', 'soa')
+        assert rdataset[0].serial == 1000
+    with vzone.reader(serial=1000) as txn:
+        assert txn.version.id == 5
+        rdataset = txn.get('@', 'soa')
+        assert rdataset[0].serial == 1000
+    vzone.set_max_versions(2)
+    assert len(vzone._versions) == 2
+    # The ones that survived should be 3 and 1000
+    rdataset = vzone._versions[0].get_rdataset(dns.name.empty,
+                                               dns.rdatatype.SOA,
+                                               dns.rdatatype.NONE)
+    assert rdataset[0].serial == 3
+    rdataset = vzone._versions[1].get_rdataset(dns.name.empty,
+                                               dns.rdatatype.SOA,
+                                               dns.rdatatype.NONE)
+    assert rdataset[0].serial == 1000
+    with pytest.raises(ValueError):
+        vzone.set_max_versions(0)
+
+# for debugging if needed
+def _dump(zone):
+    for v in zone._versions:
+        print('VERSION', v.id)
+        for (name, n) in v.nodes.items():
+            for rdataset in n:
+                print(rdataset.to_text(name))
+
+def test_vzone_open_txn_pins_versions(vzone):
+    assert len(vzone._versions) == 1
+    vzone.set_max_versions(None)  # unlimited!
+    with vzone.writer() as txn:
+        txn.update_serial()
+    with vzone.writer() as txn:
+        txn.update_serial()
+    with vzone.writer() as txn:
+        txn.update_serial()
+    with vzone.reader(id=2) as txn:
+        vzone.set_max_versions(1)
+        with vzone.reader(id=3) as txn:
+            rdataset = txn.get('@', 'soa')
+            assert rdataset[0].serial == 2
+            assert len(vzone._versions) == 4
+    assert len(vzone._versions) == 1
+    rdataset = vzone.find_rdataset('@', 'soa')
+    assert vzone._versions[0].id == 5
+    assert rdataset[0].serial == 4
+
+
+try:
+    import threading
+
+    one_got_lock = threading.Event()
+
+    def run_one(zone):
+        with zone.writer() as txn:
+            one_got_lock.set()
+            # wait until two blocks
+            while len(zone._write_waiters) == 0:
+                time.sleep(0.01)
+            rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.98')
+            txn.add('a98', rds)
+
+    def run_two(zone):
+        # wait until one has the lock so we know we will block if we
+        # get the call done before the sleep in one completes
+        one_got_lock.wait()
+        with zone.writer() as txn:
+            rds = dns.rdataset.from_text('in', 'a', 300, '10.0.0.99')
+            txn.add('a99', rds)
+
+    def test_vzone_concurrency(vzone):
+        t1 = threading.Thread(target=run_one, args=(vzone,))
+        t1.start()
+        t2 = threading.Thread(target=run_two, args=(vzone,))
+        t2.start()
+        t1.join()
+        t2.join()
+        with vzone.reader() as txn:
+            assert txn.name_exists('a98')
+            assert txn.name_exists('a99')
+
+except ImportError:  # pragma: no cover
+    pass
diff --git a/tests/test_tsig.py b/tests/test_tsig.py
index 4b8a395..a016cf8 100644
--- a/tests/test_tsig.py
+++ b/tests/test_tsig.py
@@ -1,13 +1,15 @@
 # Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
 
-import hashlib
 import unittest
+from unittest.mock import Mock
 import time
+import base64
 
 import dns.rcode
 import dns.tsig
 import dns.tsigkeyring
 import dns.message
+import dns.rdtypes.ANY.TKEY
 
 keyring = dns.tsigkeyring.from_text(
     {
@@ -17,6 +19,7 @@ keyring = dns.tsigkeyring.from_text(
 
 keyname = dns.name.from_text('keyname')
 
+
 class TSIGTestCase(unittest.TestCase):
 
     def test_get_context(self):
@@ -30,6 +33,143 @@ class TSIGTestCase(unittest.TestCase):
         with self.assertRaises(NotImplementedError):
             dns.tsig.get_context(bogus)
 
+    def test_tsig_message_properties(self):
+        m = dns.message.make_query('example', 'a')
+        self.assertIsNone(m.keyname)
+        self.assertIsNone(m.keyalgorithm)
+        self.assertIsNone(m.tsig_error)
+        m.use_tsig(keyring, keyname)
+        self.assertEqual(m.keyname, keyname)
+        self.assertEqual(m.keyalgorithm, dns.tsig.default_algorithm)
+        self.assertEqual(m.tsig_error, dns.rcode.NOERROR)
+        m = dns.message.make_query('example', 'a')
+        m.use_tsig(keyring, keyname, tsig_error=dns.rcode.BADKEY)
+        self.assertEqual(m.tsig_error, dns.rcode.BADKEY)
+
+    def test_verify_mac_for_context(self):
+        key = dns.tsig.Key('foo.com', 'abcd', 'hmac-sha512')
+        ctx = dns.tsig.get_context(key)
+        bad_expected = b'xxxxxxxxxx'
+        with self.assertRaises(dns.tsig.BadSignature):
+            ctx.verify(bad_expected)
+
+    def test_validate(self):
+        # make message and grab the TSIG
+        m = dns.message.make_query('example', 'a')
+        m.use_tsig(keyring, keyname, algorithm=dns.tsig.HMAC_SHA256)
+        w = m.to_wire()
+        tsig = m.tsig[0]
+
+        # get the time and create a key with matching characteristics
+        now = int(time.time())
+        key = dns.tsig.Key('foo.com', 'abcd', 'hmac-sha256')
+
+        # add enough to the time to take it over the fudge amount
+        with self.assertRaises(dns.tsig.BadTime):
+            dns.tsig.validate(w, key, dns.name.from_text('foo.com'),
+                              tsig, now + 1000, b'', 0)
+
+        # change the key name
+        with self.assertRaises(dns.tsig.BadKey):
+            dns.tsig.validate(w, key, dns.name.from_text('bar.com'),
+                              tsig, now, b'', 0)
+
+        # change the key algorithm
+        key = dns.tsig.Key('foo.com', 'abcd', 'hmac-sha512')
+        with self.assertRaises(dns.tsig.BadAlgorithm):
+            dns.tsig.validate(w, key, dns.name.from_text('foo.com'),
+                              tsig, now, b'', 0)
+
+    def test_gssapi_context(self):
+        def verify_signature(data, mac):
+            if data == b'throw':
+                raise Exception
+            return None
+
+        # mock out the gssapi context to return some dummy values
+        gssapi_context_mock = Mock()
+        gssapi_context_mock.get_signature.return_value = b'xxxxxxxxxxx'
+        gssapi_context_mock.verify_signature.side_effect = verify_signature
+
+        # create the key and add it to the keyring
+        keyname = 'gsstsigtest'
+        key = dns.tsig.Key(keyname, gssapi_context_mock, 'gss-tsig')
+        ctx = dns.tsig.get_context(key)
+        self.assertEqual(ctx.name, 'gss-tsig')
+        gsskeyname = dns.name.from_text(keyname)
+        keyring[gsskeyname] = key
+
+        # make sure we can get the keyring (no exception == success)
+        text = dns.tsigkeyring.to_text(keyring)
+        self.assertNotEqual(text, '')
+
+        # test exceptional case for _verify_mac_for_context
+        with self.assertRaises(dns.tsig.BadSignature):
+            ctx.update(b'throw')
+            ctx.verify(b'bogus')
+        gssapi_context_mock.verify_signature.assert_called()
+        self.assertEqual(gssapi_context_mock.verify_signature.call_count, 1)
+
+        # simulate case where TKEY message is used to establish the context;
+        # first, the query from the client
+        tkey_message = dns.message.make_query(keyname, 'tkey', 'any')
+
+        # test existent/non-existent keys in the keyring
+        adapted_keyring = dns.tsig.GSSTSigAdapter(keyring)
+
+        fetched_key = adapted_keyring(tkey_message, gsskeyname)
+        self.assertEqual(fetched_key, key)
+        key = adapted_keyring(None, gsskeyname)
+        self.assertEqual(fetched_key, key)
+        key = adapted_keyring(tkey_message, "dummy")
+        self.assertEqual(key, None)
+
+        # create a response, TKEY and turn it into bytes, simulating the server
+        # sending the response to the query
+        tkey_response = dns.message.make_response(tkey_message)
+        key = base64.b64decode('KEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEYKEY')
+        tkey = dns.rdtypes.ANY.TKEY.TKEY(dns.rdataclass.ANY,
+                                         dns.rdatatype.TKEY,
+                                         dns.name.from_text('gss-tsig.'),
+                                         1594203795, 1594206664,
+                                         3, 0, key)
+
+        # add the TKEY answer and sign it
+        tkey_response.set_rcode(dns.rcode.NOERROR)
+        tkey_response.answer = [
+            dns.rrset.from_rdata(dns.name.from_text(keyname), 0, tkey)]
+        tkey_response.use_tsig(keyring=dns.tsig.GSSTSigAdapter(keyring),
+                               keyname=gsskeyname,
+                               algorithm=dns.tsig.GSS_TSIG)
+
+        # "send" it to the client
+        tkey_wire = tkey_response.to_wire()
+
+        # grab the response from the "server" and simulate the client side
+        dns.message.from_wire(tkey_wire, dns.tsig.GSSTSigAdapter(keyring))
+
+        # assertions to make sure the "gssapi" functions were called
+        gssapi_context_mock.get_signature.assert_called()
+        self.assertEqual(gssapi_context_mock.get_signature.call_count, 1)
+        gssapi_context_mock.verify_signature.assert_called()
+        self.assertEqual(gssapi_context_mock.verify_signature.call_count, 2)
+        gssapi_context_mock.step.assert_called()
+        self.assertEqual(gssapi_context_mock.step.call_count, 1)
+
+        # create example message and go to/from wire to simulate sign/verify
+        # of regular messages
+        a_message = dns.message.make_query('example', 'a')
+        a_message.use_tsig(dns.tsig.GSSTSigAdapter(keyring), gsskeyname)
+        a_wire = a_message.to_wire()
+        # not raising is passing
+        dns.message.from_wire(a_wire, dns.tsig.GSSTSigAdapter(keyring))
+
+        # assertions to make sure the "gssapi" functions were called again
+        gssapi_context_mock.get_signature.assert_called()
+        self.assertEqual(gssapi_context_mock.get_signature.call_count, 2)
+        gssapi_context_mock.verify_signature.assert_called()
+        self.assertEqual(gssapi_context_mock.verify_signature.call_count, 3)
+
     def test_sign_and_validate(self):
         m = dns.message.make_query('example', 'a')
         m.use_tsig(keyring, keyname)
@@ -37,14 +177,35 @@ class TSIGTestCase(unittest.TestCase):
         # not raising is passing
         dns.message.from_wire(w, keyring)
 
+    def test_validate_with_bad_keyring(self):
+        m = dns.message.make_query('example', 'a')
+        m.use_tsig(keyring, keyname)
+        w = m.to_wire()
+
+        # keyring == None is an error
+        with self.assertRaises(dns.message.UnknownTSIGKey):
+            dns.message.from_wire(w, None)
+        # callable keyring that returns None is an error
+        with self.assertRaises(dns.message.UnknownTSIGKey):
+            dns.message.from_wire(w, lambda m, n: None)
+
     def test_sign_and_validate_with_other_data(self):
         m = dns.message.make_query('example', 'a')
-        other = b'other data'
         m.use_tsig(keyring, keyname, other_data=b'other')
         w = m.to_wire()
         # not raising is passing
         dns.message.from_wire(w, keyring)
 
+    def test_sign_respond_and_validate(self):
+        mq = dns.message.make_query('example', 'a')
+        mq.use_tsig(keyring, keyname)
+        wq = mq.to_wire()
+        mq_with_tsig = dns.message.from_wire(wq, keyring)
+        mr = dns.message.make_response(mq)
+        mr.use_tsig(keyring, keyname)
+        wr = mr.to_wire()
+        dns.message.from_wire(wr, keyring, request_mac=mq_with_tsig.mac)
+
     def make_message_pair(self, qname='example', rdtype='A', tsig_error=0):
         q = dns.message.make_query(qname, rdtype)
         q.use_tsig(keyring=keyring, keyname=keyname)
@@ -65,3 +226,50 @@ class TSIGTestCase(unittest.TestCase):
             def bad():
                 dns.message.from_wire(w, keyring=keyring, request_mac=q.mac)
             self.assertRaises(ex, bad)
+
+    def _test_truncated_algorithm(self, alg, length):
+        key = dns.tsig.Key('foo', b'abcdefg', algorithm=alg)
+        q = dns.message.make_query('example', 'a')
+        q.use_tsig(key)
+        q2 = dns.message.from_wire(q.to_wire(), keyring=key)
+
+        self.assertTrue(q2.had_tsig)
+        self.assertEqual(q2.tsig[0].algorithm, q.tsig[0].algorithm)
+        self.assertEqual(len(q2.tsig[0].mac), length // 8)
+
+    def test_hmac_sha256_128(self):
+        self._test_truncated_algorithm(dns.tsig.HMAC_SHA256_128, 128)
+
+    def test_hmac_sha384_192(self):
+        self._test_truncated_algorithm(dns.tsig.HMAC_SHA384_192, 192)
+
+    def test_hmac_sha512_256(self):
+        self._test_truncated_algorithm(dns.tsig.HMAC_SHA512_256, 256)
+
+    def _test_text_format(self, alg):
+        key = dns.tsig.Key('foo', b'abcdefg', algorithm=alg)
+        q = dns.message.make_query('example', 'a')
+        q.use_tsig(key)
+        _ = q.to_wire()
+
+        text = q.tsig[0].to_text()
+        tsig2 = dns.rdata.from_text('ANY', 'TSIG', text)
+        self.assertEqual(tsig2, q.tsig[0])
+
+        q = dns.message.make_query('example', 'a')
+        q.use_tsig(key, other_data=b'abc')
+        q.use_tsig(key)
+        _ = q.to_wire()
+
+        text = q.tsig[0].to_text()
+        tsig2 = dns.rdata.from_text('ANY', 'TSIG', text)
+        self.assertEqual(tsig2, q.tsig[0])
+
+    def test_text_hmac_sha256_128(self):
+        self._test_text_format(dns.tsig.HMAC_SHA256_128)
+
+    def test_text_hmac_sha384_192(self):
+        self._test_text_format(dns.tsig.HMAC_SHA384_192)
+
+    def test_text_hmac_sha512_256(self):
+        self._test_text_format(dns.tsig.HMAC_SHA512_256)
diff --git a/tests/test_ttl.py b/tests/test_ttl.py
index 07c512b..2bf298e 100644
--- a/tests/test_ttl.py
+++ b/tests/test_ttl.py
@@ -22,3 +22,15 @@ class TTLTestCase(unittest.TestCase):
     def te