]> git.ipfire.org Git - thirdparty/dnspython.git/commitdiff
Provide a "styled text" mechanism to comprehensively control (#1258)
authorBob Halley <halley@dnspython.org>
Wed, 18 Feb 2026 23:49:59 +0000 (15:49 -0800)
committerGitHub <noreply@github.com>
Wed, 18 Feb 2026 23:49:59 +0000 (15:49 -0800)
text generation, and to make it easier to enhance in the future.

Styled text includes the ability to output to a (non-standard)
zonefile format which is like the normal format except that
Punycoded names are converted back to Unicode, and (optionally)
TXT-like records may use UTF-8, and if they do the text is rendered
as Unicode.

68 files changed:
Makefile
dns/__init__.py
dns/dnssec.py
dns/message.py
dns/name.py
dns/node.py
dns/rdata.py
dns/rdataset.py
dns/rdtypes/ANY/AFSDB.py
dns/rdtypes/ANY/AMTRELAY.py
dns/rdtypes/ANY/CAA.py
dns/rdtypes/ANY/CERT.py
dns/rdtypes/ANY/CSYNC.py
dns/rdtypes/ANY/DSYNC.py
dns/rdtypes/ANY/GPOS.py
dns/rdtypes/ANY/HINFO.py
dns/rdtypes/ANY/HIP.py
dns/rdtypes/ANY/ISDN.py
dns/rdtypes/ANY/L32.py
dns/rdtypes/ANY/L64.py
dns/rdtypes/ANY/LOC.py
dns/rdtypes/ANY/LP.py
dns/rdtypes/ANY/NID.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/RP.py
dns/rdtypes/ANY/SOA.py
dns/rdtypes/ANY/SSHFP.py
dns/rdtypes/ANY/TKEY.py
dns/rdtypes/ANY/TSIG.py
dns/rdtypes/ANY/URI.py
dns/rdtypes/ANY/X25.py
dns/rdtypes/ANY/ZONEMD.py
dns/rdtypes/CH/A.py
dns/rdtypes/IN/A.py
dns/rdtypes/IN/AAAA.py
dns/rdtypes/IN/APL.py
dns/rdtypes/IN/DHCID.py
dns/rdtypes/IN/IPSECKEY.py
dns/rdtypes/IN/NAPTR.py
dns/rdtypes/IN/NSAP.py
dns/rdtypes/IN/PX.py
dns/rdtypes/IN/SRV.py
dns/rdtypes/IN/WKS.py
dns/rdtypes/dnskeybase.py
dns/rdtypes/dsbase.py
dns/rdtypes/euibase.py
dns/rdtypes/mxbase.py
dns/rdtypes/nsbase.py
dns/rdtypes/rrsigbase.py
dns/rdtypes/svcbbase.py
dns/rdtypes/tlsabase.py
dns/rdtypes/txtbase.py
dns/rdtypes/util.py
dns/rrset.py
dns/style.py [new file with mode: 0644]
dns/transaction.py
dns/zone.py
dns/zonefile.py
doc/message-class.rst
doc/name-class.rst
doc/rdata-class.rst
doc/rdata-set-classes.rst
doc/zone-class.rst
tests/test_zone.py

index 3bf34c41da68592031d9c20d11667e2e80654577..f9b42afc21f55ef778d5cf3f858a42a03c70c5fb 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -26,8 +26,9 @@ clean:
        rm -rf dist
        rm -rf build
 
+.PHONY: doc
 doc:
-       cd doc; make html
+       make -C doc html
 
 test:
        pytest
index df8edbda8ac307a8ccbf9d16a12db1a12539982b..a3b0fdd713275d790f1fa504bb1683792d4d6438 100644 (file)
@@ -53,6 +53,7 @@ __all__ = [
     "rrset",
     "serial",
     "set",
+    "style",
     "tokenizer",
     "transaction",
     "tsig",
index e4cf609c165f5c5467aadabaa0f611a13aa64ea6..6675eaa57aad0ea50f4e2f6cfed940f130a7bb27 100644 (file)
@@ -114,18 +114,7 @@ def key_id(key: DNSKEY | CDNSKEY) -> int:
     Returns an ``int`` between 0 and 65535
     """
 
-    rdata = key.to_wire()
-    assert rdata is not None  # for mypy
-    if key.algorithm == Algorithm.RSAMD5:
-        return (rdata[-3] << 8) + rdata[-2]
-    else:
-        total = 0
-        for i in range(len(rdata) // 2):
-            total += (rdata[2 * i] << 8) + rdata[2 * i + 1]
-        if len(rdata) % 2 != 0:
-            total += rdata[len(rdata) - 1] << 8
-        total += (total >> 16) & 0xFFFF
-        return total & 0xFFFF
+    return key.key_id()
 
 
 class Policy:
@@ -1193,11 +1182,11 @@ def _need_pyca(*args, **kwargs):
 
 if dns._features.have("dnssec"):
     from cryptography.exceptions import InvalidSignature
-    from cryptography.hazmat.primitives.asymmetric import ec  # pylint: disable=W0611
-    from cryptography.hazmat.primitives.asymmetric import ed448  # pylint: disable=W0611
-    from cryptography.hazmat.primitives.asymmetric import rsa  # pylint: disable=W0611
     from cryptography.hazmat.primitives.asymmetric import (  # pylint: disable=W0611
+        ec,  # pylint: disable=W0611
+        ed448,  # pylint: disable=W0611
         ed25519,
+        rsa,  # pylint: disable=W0611
     )
 
     from dns.dnssecalgs import (  # pylint: disable=C0412
index 2930a23aece5ce566ee11f87fa826f5b051d0ee0..4436e114848d2dc76826b50984c9036c8982f594 100644 (file)
@@ -18,6 +18,7 @@
 """DNS Messages"""
 
 import contextlib
+import dataclasses
 import enum
 import io
 import time
@@ -33,6 +34,7 @@ import dns.opcode
 import dns.rcode
 import dns.rdata
 import dns.rdataclass
+import dns.rdataset
 import dns.rdatatype
 import dns.rdtypes.ANY.OPT
 import dns.rdtypes.ANY.SOA
@@ -140,6 +142,19 @@ IndexType = dict[IndexKeyType, dns.rrset.RRset]
 SectionType = int | str | list[dns.rrset.RRset]
 
 
+@dataclasses.dataclass(frozen=True)
+class MessageStyle(dns.rdataset.RdatasetStyle):
+    """Message text styles.
+
+    A ``MessageStyle`` is also a :py:class:`dns.name.NameStyle` and a
+    :py:class:`dns.rdata.RdataStyle`, and a :py:class:`dns.rdataset.RdatasetStyle`.
+    See those classes for a description of their options.
+
+    There are currently no message-specific style options, but if that changes they
+    will be documented here.
+    """
+
+
 class Message:
     """A DNS message."""
 
@@ -213,12 +228,31 @@ class Message:
         self,
         origin: dns.name.Name | None = None,
         relativize: bool = True,
-        **kw: dict[str, Any],
+        style: MessageStyle | None = None,
+        **kw: Any,
     ) -> str:
         """Convert the message to text.
 
         The *origin*, *relativize*, and any other keyword
-        arguments are passed to the RRset ``to_wire()`` method.
+        arguments are passed to the RRset ``to_text()`` method.
+
+        *style*, a :py:class:`dns.rdataset.RdatasetStyle` or ``None`` (the default).  If
+        specified, the style overrides the other parameters.
+
+        Returns a ``str``.
+        """
+        if style is None:
+            kw = kw.copy()
+            kw["origin"] = origin
+            kw["relativize"] = relativize
+            style = MessageStyle.from_keywords(kw)
+        return self.to_styled_text(style)
+
+    def to_styled_text(self, style: MessageStyle) -> str:
+        """Convert the message to styled text.
+
+        *style*, a :py:class:`dns.rdataset.RdatasetStyle` or ``None`` (the default).  If
+        specified, the style overrides the other parameters.
 
         Returns a ``str``.
         """
@@ -238,10 +272,10 @@ class Message:
         for name, which in self._section_enum.__members__.items():
             s.write(f";{name}\n")
             for rrset in self.section_from_number(which):
-                s.write(rrset.to_text(origin, relativize, **kw))
+                s.write(rrset.to_styled_text(style))
                 s.write("\n")
         if self.tsig is not None:
-            s.write(self.tsig.to_text(origin, relativize, **kw))
+            s.write(self.tsig.to_styled_text(style))
             s.write("\n")
         #
         # We strip off the final \n so the caller can print the result without
@@ -566,7 +600,7 @@ class Message:
         tsig_ctx: Any | None = None,
         prepend_length: bool = False,
         prefer_truncation: bool = False,
-        **kw: dict[str, Any],
+        **kw: Any,
     ) -> bytes:
         """Return a string containing the message in DNS compressed wire
         format.
index 39e7841444e9cd7bfa2d7d901d88d20de6c0d742..4fc64c85aeb657db3518e3a59013b601cfab2c68 100644 (file)
@@ -18,6 +18,7 @@
 """DNS Names."""
 
 import copy
+import dataclasses
 import encodings.idna  # pyright: ignore
 import functools
 import struct
@@ -29,6 +30,7 @@ import dns.enum
 import dns.exception
 import dns.immutable
 import dns.wirebase
+from dns.style import BaseStyle
 
 # Dnspython will never access idna if the import fails, but pyright can't figure
 # that out, so...
@@ -371,6 +373,30 @@ def _maybe_convert_to_binary(label: bytes | str) -> bytes:
         return label.encode()
 
 
+@dataclasses.dataclass(frozen=True)
+class NameStyle(BaseStyle):
+    """Name text styles
+
+    *omit_final_dot* is a ``bool``.  If True, don't emit the final
+    dot (denoting the root label) for absolute names.  The default
+    is False.
+
+    *idna_codec* specifies the IDNA decoder to use.  The default is ``None``
+    which means all text is in the standard DNS zonefile format, i.e.
+    punycode will not be decoded.
+
+    If *origin* is ``None``, the default, then the name's relativity is not
+    altered before conversion to text.  Otherwise, if *relativize* is ``True``
+    the name is relativized, and if *relativize* is ``False`` the name is
+    derelativized.
+    """
+
+    omit_final_dot: bool = False
+    idna_codec: IDNACodec | None = None
+    origin: "Name | None" = None
+    relativize: bool = False
+
+
 @dns.immutable.immutable
 class Name:
     """A DNS name.
@@ -584,54 +610,68 @@ class Name:
     def __str__(self):
         return self.to_text(False)
 
-    def to_text(self, omit_final_dot: bool = False) -> str:
+    def to_text(
+        self, omit_final_dot: bool = False, style: NameStyle | None = None
+    ) -> str:
         """Convert name to DNS text format.
 
         *omit_final_dot* is a ``bool``.  If True, don't emit the final
         dot (denoting the root label) for absolute names.  The default
         is False.
 
+        *style*, a :py:class:`dns.name.NameStyle` or ``None`` (the default).  If
+        specified, the style overrides the other parameters.
+
         Returns a ``str``.
         """
-
-        if len(self.labels) == 0:
-            return "@"
-        if len(self.labels) == 1 and self.labels[0] == b"":
-            return "."
-        if omit_final_dot and self.is_absolute():
-            l = self.labels[:-1]
-        else:
-            l = self.labels
-        s = ".".join(map(_escapify, l))
-        return s
+        if style is None:
+            style = NameStyle(omit_final_dot=omit_final_dot)
+        return self.to_styled_text(style)
 
     def to_unicode(
-        self, omit_final_dot: bool = False, idna_codec: IDNACodec | None = None
+        self,
+        omit_final_dot: bool = False,
+        idna_codec: IDNACodec | None = None,
+        style: NameStyle | None = None,
     ) -> str:
-        """Convert name to Unicode text format.
+        """Convert name to DNS text format.
 
-        IDN ACE labels are converted to Unicode.
+        IDN ACE labels are converted to Unicode using the specified codec.
 
         *omit_final_dot* is a ``bool``.  If True, don't emit the final
         dot (denoting the root label) for absolute names.  The default
         is False.
-        *idna_codec* specifies the IDNA encoder/decoder.  If None, the
-        dns.name.IDNA_DEFAULT encoder/decoder is used.
+
+        Returns a ``str``.
+        """
+        if idna_codec is None:
+            idna_codec = IDNA_DEFAULT
+        if style is None:
+            style = NameStyle(omit_final_dot=omit_final_dot, idna_codec=idna_codec)
+        return self.to_styled_text(style)
+
+    def to_styled_text(self, style: NameStyle) -> str:
+        """Convert name to text format, applying the style.
+
+        See the documentation for :py:class:`dns.name.NameStyle` for a description
+        of the style parameters.
 
         Returns a ``str``.
         """
 
-        if len(self.labels) == 0:
+        name = self.choose_relativity(style.origin, style.relativize)
+        if len(name.labels) == 0:
             return "@"
-        if len(self.labels) == 1 and self.labels[0] == b"":
+        if len(name.labels) == 1 and name.labels[0] == b"":
             return "."
-        if omit_final_dot and self.is_absolute():
-            l = self.labels[:-1]
+        if style.omit_final_dot and name.is_absolute():
+            l = name.labels[:-1]
         else:
-            l = self.labels
-        if idna_codec is None:
-            idna_codec = IDNA_DEFAULT
-        return ".".join([idna_codec.decode(x) for x in l])
+            l = name.labels
+        if style.idna_codec is None:
+            return ".".join(map(_escapify, l))
+        else:
+            return ".".join([style.idna_codec.decode(x) for x in l])
 
     def to_digestable(self, origin: "Name | None" = None) -> bytes:
         """Convert name to a format suitable for digesting in hashes.
index 84312d88266dc8d9cf57ea3e025686736c804eab..9f3fbae579cd8f7878b9df78bd6e3a3a09c24a6e 100644 (file)
@@ -17,6 +17,7 @@
 
 """DNS nodes.  A node is a set of rdatasets."""
 
+import dataclasses
 import enum
 import io
 from typing import Any
@@ -44,6 +45,19 @@ def _matches_type_or_its_signature(rdtypes, rdtype, covers):
     return rdtype in rdtypes or (rdtype == dns.rdatatype.RRSIG and covers in rdtypes)
 
 
+@dataclasses.dataclass(frozen=True)
+class NodeStyle(dns.rdataset.RdatasetStyle):
+    """Node text styles.
+
+    A ``NodeStyle`` is also a :py:class:`dns.name.NameStyle` and a
+    :py:class:`dns.rdata.RdataStyle`, and a :py:class:`dns.rdataset.RdatasetStyle`.
+    See those classes for a description of their options.
+
+    There are currently no node-specific style options, but if that changes they
+    will be documented here.
+    """
+
+
 @enum.unique
 class NodeKind(enum.Enum):
     """Rdatasets in nodes"""
@@ -90,7 +104,9 @@ class Node:
         # the set of rdatasets, represented as a list.
         self.rdatasets = []
 
-    def to_text(self, name: dns.name.Name, **kw: dict[str, Any]) -> str:
+    def to_text(
+        self, name: dns.name.Name, style: NodeStyle | None = None, **kw: Any
+    ) -> str:
         """Convert a node to text format.
 
         Each rdataset at the node is printed.  Any keyword arguments
@@ -99,15 +115,36 @@ class Node:
         *name*, a ``dns.name.Name``, the owner name of the
         rdatasets.
 
+        *style*, a :py:class:`dns.node.NodeStyle` or ``None`` (the default).  If
+        specified, the style overrides the other parameters except *name*.
+
         Returns a ``str``.
+        """
+        if style is None:
+            style = NodeStyle.from_keywords(kw)
+        return self.to_styled_text(style, name)
 
+    def to_styled_text(self, style: NodeStyle, name: dns.name.Name) -> str:
+        """Convert a node to text format.
+
+        Each rdataset at the node is printed.
+
+        *name*, a ``dns.name.Name``, the owner name of the
+        rdatasets.
+
+        See the documentation for :py:class:`dns.node.NodeStyle` for a description
+        of the style parameters.
+
+        Returns a ``str``.
         """
 
         s = io.StringIO()
         for rds in self.rdatasets:
             if len(rds) > 0:
-                s.write(rds.to_text(name, **kw))  # pyright: ignore[arg-type]
+                s.write(rds.to_styled_text(style, name))
                 s.write("\n")
+                if style.deduplicate_names and not style.first_name_is_duplicate:
+                    style = style.replace(first_name_is_duplicate=True)
         return s.getvalue()[:-1]
 
     def __repr__(self):
index 4225164aaf6e1907d1448fa30723ba8e7d0b244d..9d2bae491bc121dc7540af3209eab4f2a42eb2a9 100644 (file)
@@ -16,9 +16,9 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 """DNS rdata."""
-
 import base64
 import binascii
+import dataclasses
 import inspect
 import io
 import ipaddress
@@ -57,11 +57,55 @@ class NoRelativeRdataOrdering(dns.exception.DNSException):
     """
 
 
-def _wordbreak(data, chunksize=_chunksize, separator=b" "):
+@dataclasses.dataclass(frozen=True)
+class RdataStyle(dns.name.NameStyle):
+    """Rdata text styles
+
+    An ``RdataStyle`` is also a :py:class:`dns.name.NameStyle`; see that class
+    for a description of its options.
+
+    If *txt_is_utf8* is ``True``, then TXT-like records will be treated
+    as UTF-8 if they decode successfully, and the output string may contain any
+    Unicode codepoint.  If ``False``, the default, then TXT-like records are
+    treated according to RFC 1035 rules.
+
+    *base64_chunk_size*, an ``int`` with default 32, specifies the chunk size for
+    text representations that break base64 strings into chunks.
+
+    *base64_chunk_separator*, a ``str`` with default ``" "``, specifies the
+    chunk separator for text representations that break base64 strings into chunks.
+
+    *hex_chunk_size*, an ``int`` with default 128, specifies the chunk size for
+    text representations that break hex strings into chunks.
+
+    *hex_chunk_separator*, a ``str`` with default ``" "``, specifies the
+    chunk separator for text representations that break hex strings into chunks.
+
+    *truncate_crypto*, a ``bool``.  The default is ``False``.  If ``True``, then
+    output of crypto types (e.g. DNSKEY) is altered to be readable
+    by humans in a debugging context, but the crypto content will be removed.
+    A sample use would be a "dig" application where you wanted to see how many
+    DNSKEYs there were, and what key ids they had, without seeing the actual
+    public key data.  Use of this option will lose information.
+    """
+
+    txt_is_utf8: bool = False
+    base64_chunk_size: int = 32
+    base64_chunk_separator: str = " "
+    hex_chunk_size: int = 128
+    hex_chunk_separator: str = " "
+    truncate_crypto: bool = False
+
+
+def _wordbreak(
+    data: bytes, chunksize: int = _chunksize, separator: bytes | str = b" "
+) -> str:
     """Break a binary string into chunks of chunksize characters separated by
     a space.
     """
 
+    if isinstance(separator, str):
+        separator = separator.encode()
     if not chunksize:
         return data.decode()
     return separator.join(
@@ -72,25 +116,60 @@ def _wordbreak(data, chunksize=_chunksize, separator=b" "):
 # pylint: disable=unused-argument
 
 
-def _hexify(data, chunksize=_chunksize, separator=b" ", **kw):
+def _hexify(data, chunksize=_chunksize, separator: bytes | str = " ", **kw):
     """Convert a binary string into its hex encoding, broken up into chunks
     of chunksize characters separated by a separator.
     """
+    if isinstance(separator, bytes):
+        separator = separator.decode()
+    return _styled_hexify(
+        data, RdataStyle(hex_chunk_separator=separator, hex_chunk_size=chunksize)
+    )
+
+
+def _styled_hexify(data, style: RdataStyle, is_crypto: bool = False):
+    """Convert a binary string into its hex encoding, broken up into chunks
+    of characters separated by a separator.
+    """
 
-    return _wordbreak(binascii.hexlify(data), chunksize, separator)
+    if style.truncate_crypto and is_crypto:
+        return "[omitted]"
+    return _wordbreak(
+        binascii.hexlify(data),
+        style.hex_chunk_size,
+        style.hex_chunk_separator,
+    )
 
 
-def _base64ify(data, chunksize=_chunksize, separator=b" ", **kw):
+def _base64ify(data, chunksize=_chunksize, separator: bytes | str = " ", **kw):
     """Convert a binary string into its base64 encoding, broken up into chunks
     of chunksize characters separated by a separator.
     """
+    if isinstance(separator, bytes):
+        separator = separator.decode()
+    return _styled_base64ify(
+        data, RdataStyle(base64_chunk_separator=separator, base64_chunk_size=chunksize)
+    )
 
-    return _wordbreak(base64.b64encode(data), chunksize, separator)
+
+def _styled_base64ify(data, style: RdataStyle, is_crypto: bool = False):
+    """Convert a binary string into its base64 encoding, broken up into chunks
+    of characters separated by a separator.
+    """
+
+    if style.truncate_crypto and is_crypto:
+        return "[omitted]"
+    return _wordbreak(
+        base64.b64encode(data),
+        style.base64_chunk_size,
+        style.base64_chunk_separator,
+    )
 
 
 # pylint: enable=unused-argument
 
-__escaped = b'"\\'
+_escaped = b'"\\'
+_unicode_escaped = '"\\'
 
 
 def _escapify(qstring):
@@ -103,7 +182,7 @@ def _escapify(qstring):
 
     text = ""
     for c in qstring:
-        if c in __escaped:
+        if c in _escaped:
             text += "\\" + chr(c)
         elif c >= 0x20 and c < 0x7F:
             text += chr(c)
@@ -112,6 +191,20 @@ def _escapify(qstring):
     return text
 
 
+def _escapify_unicode(qstring):
+    """Escape the characters in a Unicode quoted string which need it."""
+
+    text = ""
+    for c in qstring:
+        if c in _unicode_escaped:
+            text += "\\" + c
+        elif ord(c) >= 0x20:
+            text += c
+        else:
+            text += f"\\{ord(c):03d}"
+    return text
+
+
 def _truncate_bitmap(what):
     """Determine the index of greatest byte that isn't all zeros, and
     return the bitmap that contains all the bytes less than that index.
@@ -133,6 +226,8 @@ class Rdata:
 
     __slots__ = ["rdclass", "rdtype", "rdcomment"]
 
+    _crypto_keep_first_n: int | None = None
+
     def __init__(
         self,
         rdclass: dns.rdataclass.RdataClass,
@@ -204,10 +299,29 @@ class Rdata:
         self,
         origin: dns.name.Name | None = None,
         relativize: bool = True,
-        **kw: dict[str, Any],
+        style: RdataStyle | None = None,
+        **kw: Any,
     ) -> str:
         """Convert an rdata to text format.
 
+        *style*, a :py:class:`dns.rdata.RdataStyle` or ``None`` (the default).  If
+        specified, the style overrides the other parameters.
+
+        Returns a ``str``.
+        """
+        if style is None:
+            kw = kw.copy()
+            kw["origin"] = origin
+            kw["relativize"] = relativize
+            style = RdataStyle.from_keywords(kw)
+        return self.to_styled_text(style)
+
+    def to_styled_text(self, style: RdataStyle) -> str:
+        """Convert an rdata to styled text format.
+
+        See the documentation for :py:class:`dns.rdata.RdataStyle` for a description
+        of the style parameters.
+
         Returns a ``str``.
         """
 
@@ -629,13 +743,11 @@ class GenericRdata(Rdata):
         super().__init__(rdclass, rdtype)
         self.data = data
 
-    def to_text(
+    def to_styled_text(
         self,
-        origin: dns.name.Name | None = None,
-        relativize: bool = True,
-        **kw: dict[str, Any],
+        style: RdataStyle,
     ) -> str:
-        return rf"\# {len(self.data)} " + _hexify(self.data, **kw)  # pyright: ignore
+        return rf"\# {len(self.data)} " + _styled_hexify(self.data, style)
 
     @classmethod
     def from_text(
index dda5bb5051311908c0e3fb80b6050ecb4e47f954..46274bdf81805aafcf6859712a8decc06d7e9aec 100644 (file)
@@ -16,7 +16,7 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 """DNS rdatasets (an rdataset is a set of rdatas of a given type and class)"""
-
+import dataclasses
 import io
 import random
 import struct
@@ -46,6 +46,84 @@ class IncompatibleTypes(dns.exception.DNSException):
     """An attempt was made to add DNS RR data of an incompatible type."""
 
 
+@dataclasses.dataclass(frozen=True)
+class RdatasetStyle(dns.rdata.RdataStyle):
+    """Rdataset text styles
+
+    An ``RdatasetStyle`` is also a :py:class:`dns.name.NameStyle` and a
+    :py:class:`dns.rdata.RdataStyle`.  See those classes
+    for a description of their options.
+
+    *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``.
+
+    *omit_rdclass*, a ``bool``.  If ``True``, do not print the RdataClass.
+    The default is ``False``.
+
+    *omit_ttl*, a ``bool``.  If ``True``, do not print the TTL.
+    The default is ``False``.  Use of this option may lose information.
+
+    *want_generic*, a ``bool``.  If ``True``, print RdataClass, RdataType,
+    and Rdatas in the generic format, a.k.a. the "unknown rdata format".
+    The default is ``False``.
+
+    *deduplicate_names*, a ``bool``.  If ``True``, print whitespace instead of the
+    owner name if the owner name of an RR is the same as the prior RR's owner name.
+    The default is ``False``.
+
+    *first_name_is_duplicate*, a ``bool``.  If ``True``, consider the first owner name
+    of the rdataset as a duplicate too, and emit whitespace for it as well.  A sample
+    use is in emitting a Node of multiple rdatasets and the current rdataset is not
+    the first to be emitted.  The default is ``False``.
+
+    *default_ttl*, an ``int`` or ``None``.  If ``None``, the default, there is no
+    default TTL.  If an integer is specified, then any TTL matching that value will
+    be omitted.  When emitting a zonefile, a setting other than ``None`` will cause
+    a ``$TTL`` directive to be emitted.
+
+    *name_just*, an ``int``.  The owner name field justification.  Negative values
+    are left justified, and positive values are right justified.  A value of zero,
+    the default, means that no justification is performed.
+
+    *ttl_just*, an ``int``.  The TTL field justification.  Negative values
+    are left justified, and positive values are right justified.  A value of zero,
+    the default, means that no justification is performed.
+
+    *rdclass_just*, an ``int``.  The RdataClass name field justification.  Negative values
+    are left justified, and positive values are right justified.  A value of zero,
+    the default, means that no justification is performed.
+
+    *rdtype_just*, an ``int``.  The RdataType field justification.  Negative values
+    are left justified, and positive values are right justified.  A value of zero,
+    the default, means that no justification is performed.
+    """
+
+    override_rdclass: dns.rdataclass.RdataClass | None = None
+    want_comments: bool = False
+    omit_rdclass: bool = False
+    omit_ttl: bool = False
+    want_generic: bool = False
+    deduplicate_names: bool = False
+    first_name_is_duplicate: bool = False
+    default_ttl: int | None = None
+    name_just: int = 0
+    ttl_just: int = 0
+    rdclass_just: int = 0
+    rdtype_just: int = 0
+
+
+def justify(text: str, amount: int):
+    if amount == 0:
+        return text
+    if amount < 0:
+        return text.ljust(-1 * amount)
+    else:
+        return text.rjust(amount)
+
+
 class Rdataset(dns.set.Set):
     """A DNS rdataset."""
 
@@ -202,7 +280,8 @@ class Rdataset(dns.set.Set):
         relativize: bool = True,
         override_rdclass: dns.rdataclass.RdataClass | None = None,
         want_comments: bool = False,
-        **kw: dict[str, Any],
+        style: RdatasetStyle | None = None,
+        **kw: Any,
     ) -> str:
         """Convert the rdataset into DNS zone file format.
 
@@ -223,47 +302,87 @@ class Rdataset(dns.set.Set):
         to *origin*.
 
         *override_rdclass*, a ``dns.rdataclass.RdataClass`` or ``None``.
-        If not ``None``, use this class instead of the Rdataset's class.
+        If not ``None``, when rendering, emit records as if they were of this class.
 
         *want_comments*, a ``bool``.  If ``True``, emit comments for rdata
         which have them.  The default is ``False``.
+
+        *style*, a :py:class:`dns.rdataset.RdatasetStyle` or ``None`` (the default).  If
+        specified, the style overrides the other parameters except for *name*.
         """
+        if style is None:
+            kw = kw.copy()
+            kw["origin"] = origin
+            kw["relativize"] = relativize
+            kw["override_rdclass"] = override_rdclass
+            kw["want_comments"] = want_comments
+            style = RdatasetStyle.from_keywords(kw)
+        return self.to_styled_text(style, name)
+
+    def to_styled_text(
+        self, style: RdatasetStyle, name: dns.name.Name | None = None
+    ) -> str:
+        """Convert the rdataset into styled text format.
 
+        See the documentation for :py:class:`dns.rdataset.RdatasetStyle` for a description
+        of the style parameters.
+        """
         if name is not None:
-            name = name.choose_relativity(origin, relativize)
-            ntext = str(name)
-            pad = " "
+            if style.deduplicate_names and style.first_name_is_duplicate:
+                ntext = "    "
+            else:
+                ntext = f"{name.to_styled_text(style)} "
+            ntext = justify(ntext, style.name_just)
         else:
             ntext = ""
-            pad = ""
         s = io.StringIO()
-        if override_rdclass is not None:
-            rdclass = override_rdclass
+        if style.override_rdclass is not None:
+            rdclass = style.override_rdclass
         else:
             rdclass = self.rdclass
+        if style.omit_rdclass:
+            rdclass_text = ""
+        elif style.want_generic:
+            rdclass_text = f"CLASS{rdclass} "
+        else:
+            rdclass_text = f"{dns.rdataclass.to_text(rdclass)} "
+        rdclass_text = justify(rdclass_text, style.rdclass_just)
+        if style.want_generic:
+            rdtype_text = f"TYPE{self.rdtype}"
+        else:
+            rdtype_text = f"{dns.rdatatype.to_text(self.rdtype)}"
+        rdtype_text = justify(rdtype_text, style.rdtype_just)
         if len(self) == 0:
             #
             # Empty rdatasets are used for the question section, and in
             # some dynamic updates, so we don't need to print out the TTL
             # (which is meaningless anyway).
             #
-            s.write(
-                f"{ntext}{pad}{dns.rdataclass.to_text(rdclass)} "
-                f"{dns.rdatatype.to_text(self.rdtype)}\n"
-            )
+            s.write(f"{ntext}{rdclass_text}{rdtype_text}\n")
         else:
+            if style.omit_ttl or (
+                style.default_ttl is not None and self.ttl == style.default_ttl
+            ):
+                ttl = ""
+            else:
+                ttl = f"{self.ttl} "
+            ttl = justify(ttl, style.ttl_just)
             for rd in self:
                 extra = ""
-                if want_comments:
+                if style.want_comments:
                     if rd.rdcomment:
                         extra = f" ;{rd.rdcomment}"
+                if style.want_generic:
+                    rdata_text = rd.to_generic().to_styled_text(style)
+                else:
+                    rdata_text = rd.to_styled_text(style)
                 s.write(
-                    f"{ntext}{pad}{self.ttl} "
-                    f"{dns.rdataclass.to_text(rdclass)} "
-                    f"{dns.rdatatype.to_text(self.rdtype)} "
-                    f"{rd.to_text(origin=origin, relativize=relativize, **kw)}"
-                    f"{extra}\n"
+                    f"{ntext}{ttl}{rdclass_text}{rdtype_text} {rdata_text}{extra}\n"
                 )
+                if style.deduplicate_names:
+                    ntext = "    "
+                    ntext = justify(ntext, style.name_just)
+
         #
         # We strip off the final \n for the caller's convenience in printing
         #
index 06a3b97013dc980bcc6ed2c13b9531164445e7ed..778df986f3755da2a9008c04c0919cb262992418 100644 (file)
@@ -16,6 +16,7 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import dns.immutable
+import dns.name
 import dns.rdtypes.mxbase
 
 
@@ -35,11 +36,11 @@ class AFSDB(dns.rdtypes.mxbase.UncompressedDowncasingMX):
     # good.
 
     @property
-    def subtype(self):
+    def subtype(self) -> int:
         "the AFSDB subtype"
         return self.preference
 
     @property
-    def hostname(self):
+    def hostname(self) -> dns.name.Name:
         "the AFSDB hostname"
         return self.exchange
index b3096347e33bd1580692dd92dd2006994d23445e..e64a60de13d280e425237e237e420437fd595153 100644 (file)
@@ -19,6 +19,7 @@ import struct
 
 import dns.exception
 import dns.immutable
+import dns.name
 import dns.rdata
 import dns.rdtypes.util
 
@@ -27,7 +28,7 @@ class Relay(dns.rdtypes.util.Gateway):
     name = "AMTRELAY relay"
 
     @property
-    def relay(self):
+    def relay(self) -> str | dns.name.Name | None:
         return self.gateway
 
 
@@ -44,13 +45,13 @@ class AMTRELAY(dns.rdata.Rdata):
     ):
         super().__init__(rdclass, rdtype)
         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
+        self.precedence: int = self._as_uint8(precedence)
+        self.discovery_optional: bool = self._as_bool(discovery_optional)
+        self.relay_type: int = relay.type
+        self.relay: str | dns.name.Name | None = relay.relay
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        relay = Relay(self.relay_type, self.relay).to_text(origin, relativize)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        relay = Relay(self.relay_type, self.relay).to_styled_text(style)
         return (
             f"{self.precedence} {self.discovery_optional:d} {self.relay_type} {relay}"
         )
@@ -58,7 +59,7 @@ class AMTRELAY(dns.rdata.Rdata):
     @classmethod
     def from_text(
         cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+    ) -> "AMTRELAY":
         precedence = tok.get_uint8()
         discovery_optional = tok.get_uint8()
         if discovery_optional > 1:
@@ -79,7 +80,7 @@ class AMTRELAY(dns.rdata.Rdata):
         Relay(self.relay_type, self.relay).to_wire(file, compress, origin, canonicalize)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None) -> "AMTRELAY":
         precedence, relay_type = parser.get_struct("!BB")
         discovery_optional = bool(relay_type >> 7)
         relay_type &= 0x7F
index 8c62e6267355a1bb6348422f8d36448d8f31d59f..c44911c12b4a05d477ef1d164295f42bb4c37342 100644 (file)
@@ -33,19 +33,19 @@ class CAA(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, flags, tag, value):
         super().__init__(rdclass, rdtype)
-        self.flags = self._as_uint8(flags)
-        self.tag = self._as_bytes(tag, True, 255)
+        self.flags: int = self._as_uint8(flags)
+        self.tag: bytes = self._as_bytes(tag, True, 255)
         if not tag.isalnum():
             raise ValueError("tag is not alphanumeric")
-        self.value = self._as_bytes(value)
+        self.value: bytes = self._as_bytes(value)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return f'{self.flags} {dns.rdata._escapify(self.tag)} "{dns.rdata._escapify(self.value)}"'
 
     @classmethod
     def from_text(
         cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+    ) -> "CAA":
         flags = tok.get_uint8()
         tag = tok.get_string().encode()
         value = tok.get_string().encode()
@@ -60,7 +60,7 @@ class CAA(dns.rdata.Rdata):
         file.write(self.value)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None) -> "CAA":
         flags = parser.get_uint8()
         tag = parser.get_counted_bytes()
         value = parser.get_remaining()
index c34febcfd3f481c114a8efe766b8ebb7138b564d..1848af7ef0f807f97d60c9005fb171a4b1b8b55a 100644 (file)
@@ -51,14 +51,14 @@ _ctype_by_name = {
 }
 
 
-def _ctype_from_text(what):
+def _ctype_from_text(what: str) -> int:
     v = _ctype_by_name.get(what)
     if v is not None:
         return v
     return int(what)
 
 
-def _ctype_to_text(what):
+def _ctype_to_text(what: int) -> str:
     v = _ctype_by_value.get(what)
     if v is not None:
         return v
@@ -77,15 +77,15 @@ class CERT(dns.rdata.Rdata):
         self, rdclass, rdtype, certificate_type, key_tag, algorithm, certificate
     ):
         super().__init__(rdclass, rdtype)
-        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)
+        self.certificate_type: int = self._as_uint16(certificate_type)
+        self.key_tag: int = self._as_uint16(key_tag)
+        self.algorithm: int = self._as_uint8(algorithm)
+        self.certificate: bytes = self._as_bytes(certificate)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         certificate_type = _ctype_to_text(self.certificate_type)
         algorithm = dns.dnssectypes.Algorithm.to_text(self.algorithm)
-        certificate = dns.rdata._base64ify(self.certificate, **kw)  # pyright: ignore
+        certificate = dns.rdata._styled_base64ify(self.certificate, style)
         return f"{certificate_type} {self.key_tag} {algorithm} {certificate}"
 
     @classmethod
index 36d80759928206370b7fbdde7a0f065ffcd529ca..e9132a5ee49818d99ad3f9a8f0e637e3dc2db219 100644 (file)
@@ -38,13 +38,13 @@ class CSYNC(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, serial, flags, windows):
         super().__init__(rdclass, rdtype)
-        self.serial = self._as_uint32(serial)
-        self.flags = self._as_uint16(flags)
+        self.serial: int = self._as_uint32(serial)
+        self.flags: int = 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):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         text = Bitmap(self.windows).to_text()
         return f"{self.serial} {self.flags}{text}"
 
index 41ce0888c807c1c71494053d384d8f0135a17501..c77c6247eea086b00bb010cfaf4ef41bf09c2a66 100644 (file)
@@ -5,6 +5,7 @@ import struct
 import dns.enum
 import dns.exception
 import dns.immutable
+import dns.name
 import dns.rdata
 import dns.rdatatype
 import dns.rdtypes.util
@@ -38,13 +39,13 @@ class DSYNC(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, rrtype, scheme, port, target):
         super().__init__(rdclass, rdtype)
-        self.rrtype = self._as_rdatatype(rrtype)
-        self.scheme = Scheme.make(scheme)
-        self.port = self._as_uint16(port)
-        self.target = self._as_name(target)
+        self.rrtype: dns.rdatatype.RdataType = self._as_rdatatype(rrtype)
+        self.scheme: Scheme = Scheme.make(scheme)
+        self.port: int = self._as_uint16(port)
+        self.target: dns.name.Name = self._as_name(target)
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        target = self.target.choose_relativity(origin, relativize)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        target = self.target.to_styled_text(style)
         return (
             f"{dns.rdatatype.to_text(self.rrtype)} {Scheme.to_text(self.scheme)} "
             f"{self.port} {target}"
index f2248ab6f732eecaf4ded41a50fb3833a400c887..8a6caea300bd3bc56a8a86261dd4fccef099a01b 100644 (file)
@@ -64,9 +64,9 @@ class GPOS(dns.rdata.Rdata):
         _validate_float_string(latitude)
         _validate_float_string(longitude)
         _validate_float_string(altitude)
-        self.latitude = latitude
-        self.longitude = longitude
-        self.altitude = altitude
+        self.latitude: bytes = latitude
+        self.longitude: bytes = longitude
+        self.altitude: bytes = altitude
         flat = self.float_latitude
         if flat < -90.0 or flat > 90.0:
             raise dns.exception.FormError("bad latitude")
@@ -74,7 +74,7 @@ class GPOS(dns.rdata.Rdata):
         if flong < -180.0 or flong > 180.0:
             raise dns.exception.FormError("bad longitude")
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return (
             f"{self.latitude.decode()} {self.longitude.decode()} "
             f"{self.altitude.decode()}"
index 06ad3487cf8c37a48f4ab53240986287798b9e79..ad2154801f8a26db69eb9516add4be10edcbb797 100644 (file)
@@ -33,10 +33,10 @@ class HINFO(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, cpu, os):
         super().__init__(rdclass, rdtype)
-        self.cpu = self._as_bytes(cpu, True, 255)
-        self.os = self._as_bytes(os, True, 255)
+        self.cpu: bytes = self._as_bytes(cpu, True, 255)
+        self.os: bytes = self._as_bytes(os, True, 255)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return f'"{dns.rdata._escapify(self.cpu)}" "{dns.rdata._escapify(self.os)}"'
 
     @classmethod
index d31d633406b4b8646641339f25ade34abb0435e9..d49847ed78255923a8a69124dab341edb051cc10 100644 (file)
@@ -21,6 +21,7 @@ import struct
 
 import dns.exception
 import dns.immutable
+import dns.name
 import dns.rdata
 import dns.rdatatype
 
@@ -35,20 +36,23 @@ class HIP(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, hit, algorithm, key, servers):
         super().__init__(rdclass, rdtype)
-        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)
+        self.hit: bytes = self._as_bytes(hit, True, 255)
+        self.algorithm: int = self._as_uint8(algorithm)
+        self.key: bytes = self._as_bytes(key, True)
+        self.servers: tuple[dns.name.Name] = self._as_tuple(servers, self._as_name)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        # "hit" is not styled.
         hit = binascii.hexlify(self.hit).decode()
-        key = base64.b64encode(self.key).replace(b"\n", b"").decode()
+        # Fixed style
+        style = style.replace(base64_chunk_size=0)
+        key = dns.rdata._styled_base64ify(self.key, style, True)
         text = ""
         servers = []
         for server in self.servers:
-            servers.append(server.choose_relativity(origin, relativize))
+            servers.append(server.to_styled_text(style))
         if len(servers) > 0:
-            text += " " + " ".join(x.to_unicode() for x in servers)
+            text += " " + " ".join(servers)
         return f"{self.algorithm} {hit} {key}{text}"
 
     @classmethod
index 6428a0a822f1808f3997a3253d24b815e56a21ec..bb68d88e354a18ba8b41baf6593d336c8c05d0d7 100644 (file)
@@ -33,10 +33,10 @@ class ISDN(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, address, subaddress):
         super().__init__(rdclass, rdtype)
-        self.address = self._as_bytes(address, True, 255)
-        self.subaddress = self._as_bytes(subaddress, True, 255)
+        self.address: bytes = self._as_bytes(address, True, 255)
+        self.subaddress: bytes = self._as_bytes(subaddress, True, 255)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         if self.subaddress:
             return (
                 f'"{dns.rdata._escapify(self.address)}" '
index f51e5c790bfd74c88d3c82acb80da66eaa0bd07e..365b9936c66b31643a247aaad7c902af599eca8c 100644 (file)
@@ -17,10 +17,10 @@ class L32(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, preference, locator32):
         super().__init__(rdclass, rdtype)
-        self.preference = self._as_uint16(preference)
-        self.locator32 = self._as_ipv4_address(locator32)
+        self.preference: int = self._as_uint16(preference)
+        self.locator32: str = self._as_ipv4_address(locator32)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return f"{self.preference} {self.locator32}"
 
     @classmethod
index a47da19e186cabbe5860909243ee9106ad362223..6d5fcf24aaea7bab8920d5e234efe5ea70f092a7 100644 (file)
@@ -17,16 +17,17 @@ class L64(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, preference, locator64):
         super().__init__(rdclass, rdtype)
-        self.preference = self._as_uint16(preference)
+        self.preference: int = self._as_uint16(preference)
         if isinstance(locator64, bytes):
             if len(locator64) != 8:
                 raise ValueError("invalid locator64")
-            self.locator64 = dns.rdata._hexify(locator64, 4, b":")
+            # Not styled
+            self.locator64: str = dns.rdata._hexify(locator64, 4, ":")
         else:
             dns.rdtypes.util.parse_formatted_hex(locator64, 4, 4, ":")
-            self.locator64 = locator64
+            self.locator64: str = locator64
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return f"{self.preference} {self.locator64}"
 
     @classmethod
index 227a257fa932dfbffa7178b03af15e663bf880ed..02b0a2c37a0ad0bbc2880009453e2acbed746adf 100644 (file)
@@ -155,7 +155,7 @@ class LOC(dns.rdata.Rdata):
         self.horizontal_precision = float(hprec)
         self.vertical_precision = float(vprec)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle):
         if self.latitude[4] > 0:
             lat_hemisphere = "N"
         else:
index 379c8627a062853e8631a2d17dd507903cf781b5..ac1d98ed16d1667b98ee3e18fca4c79be66e9fe7 100644 (file)
@@ -3,6 +3,7 @@
 import struct
 
 import dns.immutable
+import dns.name
 import dns.rdata
 
 
@@ -16,12 +17,11 @@ class LP(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, preference, fqdn):
         super().__init__(rdclass, rdtype)
-        self.preference = self._as_uint16(preference)
-        self.fqdn = self._as_name(fqdn)
+        self.preference: int = self._as_uint16(preference)
+        self.fqdn: dns.name.Name = self._as_name(fqdn)
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        fqdn = self.fqdn.choose_relativity(origin, relativize)
-        return f"{self.preference} {fqdn}"
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        return f"{self.preference} {self.fqdn.to_styled_text(style)}"
 
     @classmethod
     def from_text(
index fa0dad5cc797be9a14ea075496e59b9f2a488678..076d7a09b7ad00a461eee77cd6126c388018ed33 100644 (file)
@@ -21,12 +21,12 @@ class NID(dns.rdata.Rdata):
         if isinstance(nodeid, bytes):
             if len(nodeid) != 8:
                 raise ValueError("invalid nodeid")
-            self.nodeid = dns.rdata._hexify(nodeid, 4, b":")
+            self.nodeid = dns.rdata._hexify(nodeid, 4, ":")
         else:
             dns.rdtypes.util.parse_formatted_hex(nodeid, 4, 4, ":")
             self.nodeid = nodeid
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return f"{self.preference} {self.nodeid}"
 
     @classmethod
index 3c78b72288b75a65b7fe3eaaaf7177d296f33b5d..949c232a8dc1c6a701b5d37ff6a24efca693b48a 100644 (file)
@@ -41,10 +41,9 @@ class NSEC(dns.rdata.Rdata):
             windows = Bitmap(windows)
         self.windows = tuple(windows.windows)
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        next = self.next.choose_relativity(origin, relativize)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         text = Bitmap(self.windows).to_text()
-        return f"{next}{text}"
+        return f"{self.next.to_styled_text(style)}{text}"
 
     @classmethod
     def from_text(
index cd1d9d98ce20cf73eead9458fab4724ffadac5bf..7aa5983995e34263eece09fa0b9b15399702c389 100644 (file)
@@ -69,11 +69,12 @@ class NSEC3(dns.rdata.Rdata):
         next = next.rstrip("=")
         return next
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         next = self._next_text()
         if self.salt == b"":
             salt = "-"
         else:
+            # Not styled
             salt = binascii.hexlify(self.salt).decode()
         text = Bitmap(self.windows).to_text()
         return f"{self.algorithm} {self.flags} {self.iterations} {salt} {next}{text}"
index 3ab90ee2cd1f603d1ac10e698ba637653fd218d7..1019881d9fbabbbd897fc0225cbc608e12700182 100644 (file)
@@ -36,10 +36,11 @@ class NSEC3PARAM(dns.rdata.Rdata):
         self.iterations = self._as_uint16(iterations)
         self.salt = self._as_bytes(salt, True, 255)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         if self.salt == b"":
             salt = "-"
         else:
+            # Not styled
             salt = binascii.hexlify(self.salt).decode()
         return f"{self.algorithm} {self.flags} {self.iterations} {salt}"
 
index ac1841cce65363cf4f82f36d3cb30d724aef5992..37514f6165c1bfb2926cc382031b889e428d4cd4 100644 (file)
@@ -33,8 +33,10 @@ class OPENPGPKEY(dns.rdata.Rdata):
         super().__init__(rdclass, rdtype)
         self.key = self._as_bytes(key)
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        return dns.rdata._base64ify(self.key, chunksize=None, **kw)  # pyright: ignore
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        # Fixed style
+        style = style.replace(base64_chunk_size=0)
+        return dns.rdata._styled_base64ify(self.key, style, True)
 
     @classmethod
     def from_text(
index 94583cedbac4e71cffda15fd9b1bd3f255899ec7..57b754ee6835c32c679c8f3e55b77004ba84dce9 100644 (file)
@@ -58,7 +58,7 @@ class OPT(dns.rdata.Rdata):
             file.write(struct.pack("!HH", opt.otype, len(owire)))
             file.write(owire)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return " ".join(opt.to_text() for opt in self.options)
 
     @classmethod
index a66cfc50f5e9854f556a727544ee0114f887b45d..7cd26daff99615ca060bb13881af15a181db0b85 100644 (file)
@@ -34,10 +34,10 @@ class RP(dns.rdata.Rdata):
         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)
-        txt = self.txt.choose_relativity(origin, relativize)
-        return f"{str(mbox)} {str(txt)}"
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        mbox = self.mbox.to_styled_text(style)
+        txt = self.txt.to_styled_text(style)
+        return f"{mbox} {txt}"
 
     @classmethod
     def from_text(
index 3c7cd8c92ea21ad6a9d185bb25286348e538a742..4cabea8e97d6d39ec66fcb62b830da7710f3951f 100644 (file)
@@ -35,23 +35,23 @@ class SOA(dns.rdata.Rdata):
         self, rdclass, rdtype, mname, rname, serial, refresh, retry, expire, minimum
     ):
         super().__init__(rdclass, rdtype)
-        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)
+        self.mname: dns.name.Name = self._as_name(mname)
+        self.rname: dns.name.Name = self._as_name(rname)
+        self.serial: int = self._as_uint32(serial)
+        self.refresh: int = self._as_ttl(refresh)
+        self.retry: int = self._as_ttl(retry)
+        self.expire: int = self._as_ttl(expire)
+        self.minimum: int = self._as_ttl(minimum)
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        mname = self.mname.choose_relativity(origin, relativize)
-        rname = self.rname.choose_relativity(origin, relativize)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        mname = self.mname.to_styled_text(style)
+        rname = self.rname.to_styled_text(style)
         return f"{mname} {rname} {self.serial} {self.refresh} {self.retry} {self.expire} {self.minimum}"
 
     @classmethod
     def from_text(
         cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+    ) -> "SOA":
         mname = tok.get_name(origin, relativize, relativize_to)
         rname = tok.get_name(origin, relativize, relativize_to)
         serial = tok.get_uint32()
@@ -72,7 +72,7 @@ class SOA(dns.rdata.Rdata):
         file.write(five_ints)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None) -> "SOA":
         mname = parser.get_name(origin)
         rname = parser.get_name(origin)
         return cls(rdclass, rdtype, mname, rname, *parser.get_struct("!IIIII"))
index 3f08f3a594c2b594276ec1fcb9e296e4adce5acb..627122bc6e4b55f5ff2a5b4cd3681e25ca242b37 100644 (file)
@@ -37,12 +37,8 @@ class SSHFP(dns.rdata.Rdata):
         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)
-        fingerprint = dns.rdata._hexify(
-            self.fingerprint, chunksize=chunksize, **kw  # pyright: ignore
-        )
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        fingerprint = dns.rdata._styled_hexify(self.fingerprint, style)
         return f"{self.algorithm} {self.fp_type} {fingerprint}"
 
     @classmethod
index f9189b16c1ddda48d2184dad20a2fa1335576b8e..dd476a71dfafa5f8b916e342ad8784fdc96e5130 100644 (file)
@@ -58,13 +58,15 @@ class TKEY(dns.rdata.Rdata):
         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)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        algorithm = self.algorithm.to_styled_text(style)
+        # Not styled
         key = dns.rdata._base64ify(self.key, 0)
         other = ""
         if len(self.other) > 0:
+            # Not styled
             other = " " + dns.rdata._base64ify(self.other, 0)
-        return f"{_algorithm} {self.inception} {self.expiration} {self.mode} {self.error} {key}{other}"
+        return f"{algorithm} {self.inception} {self.expiration} {self.mode} {self.error} {key}{other}"
 
     @classmethod
     def from_text(
index c375e0a770722f5185fa7f36bf9575cbcf0567bd..a848e19d8948e4e8a490b137076e59e1c6d831dd 100644 (file)
@@ -80,8 +80,8 @@ class TSIG(dns.rdata.Rdata):
         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)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        algorithm = self.algorithm.to_styled_text(style)
         error = dns.rcode.to_text(self.error, True)
         text = (
             f"{algorithm} {self.time_signed} {self.fudge} "
index 1943e58dc305ec7cedf2e984b7e8a4b68a2d1168..24d5d08a9e0e8411d1ba602b935c9f51fe8b0820 100644 (file)
@@ -41,7 +41,7 @@ class URI(dns.rdata.Rdata):
         if len(self.target) == 0:
             raise dns.exception.SyntaxError("URI target cannot be empty")
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return f'{self.priority} {self.weight} "{self.target.decode()}"'
 
     @classmethod
index 2436ddb62ec23d52a69c092e1194b599618681f8..bd7e431d421911f7e6cf0c9e4803278bbddc2b19 100644 (file)
@@ -35,7 +35,7 @@ class X25(dns.rdata.Rdata):
         super().__init__(rdclass, rdtype)
         self.address = self._as_bytes(address, True, 255)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return f'"{dns.rdata._escapify(self.address)}"'
 
     @classmethod
index acef4f277eaaf86ecf9fb56d87a62a007da35aab..ffc034d61e0e912558ddb6480129898eb8de031c 100644 (file)
@@ -33,12 +33,8 @@ class ZONEMD(dns.rdata.Rdata):
         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)
-        digest = dns.rdata._hexify(
-            self.digest, chunksize=chunksize, **kw  # pyright: ignore
-        )
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        digest = dns.rdata._styled_hexify(self.digest, style, True)
         return f"{self.serial} {self.scheme} {self.hash_algorithm} {digest}"
 
     @classmethod
index e3e0752118bff2157c24b97453e658a79221071e..11207980f2d1cc4a4e638cb1c6994bb4770f51d3 100644 (file)
@@ -36,8 +36,8 @@ class A(dns.rdata.Rdata):
         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)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        domain = self.domain.to_styled_text(style)
         return f"{domain} {self.address:o}"
 
     @classmethod
index e09d61108466e1b3212ab7e42fb8c7cf25757219..b0c5308a619f8e49515b4916ca1ffc2bf1c8e132 100644 (file)
@@ -32,7 +32,7 @@ class A(dns.rdata.Rdata):
         super().__init__(rdclass, rdtype)
         self.address = self._as_ipv4_address(address)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return self.address
 
     @classmethod
index 0cd139e7b5c4b68a8d66e070b231eda9b0e41ca7..7fe63d94a8c8a0813a19b5a577dc8575e7c55365 100644 (file)
@@ -32,7 +32,7 @@ class AAAA(dns.rdata.Rdata):
         super().__init__(rdclass, rdtype)
         self.address = self._as_ipv6_address(address)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return self.address
 
     @classmethod
index daa1e5f90d6ce4ac1f6251b3dbebb7a840dbaec6..fa93ab0d8841618e45dba0faade71d2c645cb08a 100644 (file)
@@ -92,7 +92,7 @@ class APL(dns.rdata.Rdata):
                 raise ValueError("item not an APLItem")
         self.items = tuple(items)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         return " ".join(map(str, self.items))
 
     @classmethod
index 8de8cdf167200ffa3d89381c63998e35f197af7b..923b60919cd207cdad6a001313c1803420951eb8 100644 (file)
@@ -34,8 +34,8 @@ class DHCID(dns.rdata.Rdata):
         super().__init__(rdclass, rdtype)
         self.data = self._as_bytes(data)
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        return dns.rdata._base64ify(self.data, **kw)  # pyright: ignore
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        return dns.rdata._styled_base64ify(self.data, style)
 
     @classmethod
     def from_text(
index aef93ae140df8a17150b2aa996d023250f866662..a2c909329eaaaab71f03963610d7259dbae8e24b 100644 (file)
@@ -47,9 +47,9 @@ class IPSECKEY(dns.rdata.Rdata):
         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)
-        key = dns.rdata._base64ify(self.key, **kw)  # pyright: ignore
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        gateway = Gateway(self.gateway_type, self.gateway).to_styled_text(style)
+        key = dns.rdata._styled_base64ify(self.key, style, True)
         return f"{self.precedence} {self.gateway_type} {self.algorithm} {gateway} {key}"
 
     @classmethod
index 866a016eac9a0a38f9186367bd0fbee830e69f2a..cd7d3e9a480472c6951478342465e1a680bd0714 100644 (file)
@@ -50,8 +50,8 @@ class NAPTR(dns.rdata.Rdata):
         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)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        replacement = self.replacement.to_styled_text(style)
         return (
             f"{self.order} {self.preference} "
             f'"{dns.rdata._escapify(self.flags)}" '
index d55edb7372afb143c72665775a3755a202969fc7..18013f0917df522d98f9046bddd5f633e9df070c 100644 (file)
@@ -35,7 +35,8 @@ class NSAP(dns.rdata.Rdata):
         super().__init__(rdclass, rdtype)
         self.address = self._as_bytes(address)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        # Not styled
         return f"0x{binascii.hexlify(self.address).decode()}"
 
     @classmethod
index 20143bf6cb388d00d2b082df1a86f9d360b465a5..7fe755f4f6b00f2990b3da04e265cfc91c041a0a 100644 (file)
@@ -38,9 +38,9 @@ class PX(dns.rdata.Rdata):
         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)
-        mapx400 = self.mapx400.choose_relativity(origin, relativize)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        map822 = self.map822.to_styled_text(style)
+        mapx400 = self.mapx400.to_styled_text(style)
         return f"{self.preference} {map822} {mapx400}"
 
     @classmethod
index 50f697653e6b78d91cce0c6ce37471d4face299d..743a6753f7b7286f133aa7f2506379f41762a99e 100644 (file)
@@ -39,8 +39,8 @@ class SRV(dns.rdata.Rdata):
         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)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        target = self.target.to_styled_text(style)
         return f"{self.priority} {self.weight} {self.port} {target}"
 
     @classmethod
index cc6c3733b7e42544decc38c915a3c1d7bb094419..634386fc6c1e5f2fb23adc146b080e7b1ce5429c 100644 (file)
@@ -45,7 +45,7 @@ class WKS(dns.rdata.Rdata):
         self.protocol = self._as_uint8(protocol)
         self.bitmap = self._as_bytes(bitmap)
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         bits = []
         for i, byte in enumerate(self.bitmap):
             for j in range(0, 8):
index fb49f9220d6d1ccaca34707ec2e3a8198f6b2470..243415b0dc6fee8b7c9678689b7611e2b787fcbb 100644 (file)
@@ -18,6 +18,7 @@
 import base64
 import enum
 import struct
+from typing import TypeVar
 
 import dns.dnssectypes
 import dns.exception
@@ -34,6 +35,9 @@ class Flag(enum.IntFlag):
     ZONE = 0x0100
 
 
+T = TypeVar("T", bound="DNSKEYBase")
+
+
 @dns.immutable.immutable
 class DNSKEYBase(dns.rdata.Rdata):
     """Base class for rdata that is like a DNSKEY record"""
@@ -42,19 +46,30 @@ class DNSKEYBase(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, flags, protocol, algorithm, key):
         super().__init__(rdclass, rdtype)
-        self.flags = Flag(self._as_uint16(flags))
-        self.protocol = self._as_uint8(protocol)
-        self.algorithm = dns.dnssectypes.Algorithm.make(algorithm)
-        self.key = self._as_bytes(key)
-
-    def to_text(self, origin=None, relativize=True, **kw):
-        key = dns.rdata._base64ify(self.key, **kw)  # pyright: ignore
+        self.flags: int = Flag(self._as_uint16(flags))
+        self.protocol: int = self._as_uint8(protocol)
+        self.algorithm: dns.dnssectypes.Algorithm = dns.dnssectypes.Algorithm.make(
+            algorithm
+        )
+        self.key: bytes = self._as_bytes(key)
+
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        if style.truncate_crypto:
+            key = f"[key id = {self.key_id()}]"
+        else:
+            key = dns.rdata._styled_base64ify(self.key, style)
         return f"{self.flags} {self.protocol} {self.algorithm} {key}"
 
     @classmethod
     def from_text(
-        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+        cls: type[T],
+        rdclass,
+        rdtype,
+        tok,
+        origin=None,
+        relativize=True,
+        relativize_to=None,
+    ) -> T:
         flags = tok.get_uint16()
         protocol = tok.get_uint8()
         algorithm = tok.get_string()
@@ -68,11 +83,33 @@ class DNSKEYBase(dns.rdata.Rdata):
         file.write(self.key)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls: type[T], rdclass, rdtype, parser, origin=None) -> T:
         header = parser.get_struct("!HBB")
         key = parser.get_remaining()
         return cls(rdclass, rdtype, header[0], header[1], header[2], key)
 
+    def key_id(self) -> int:
+        """Return the key id (a 16-bit number) for the specified key.
+
+        *key*, a ``dns.rdtypes.ANY.DNSKEY.DNSKEY``
+
+        Returns an ``int`` between 0 and 65535
+        """
+
+        wire = self.to_wire()
+        assert wire is not None  # for mypy
+        if self.algorithm == dns.dnssectypes.Algorithm.RSAMD5:
+            return (wire[-3] << 8) + wire[-2]
+        else:
+            total = 0
+            for i in range(len(wire) // 2):
+                total += (wire[2 * i] << 8) + wire[2 * i + 1]
+            if len(wire) % 2 != 0:
+                total += wire[len(wire) - 1] << 8
+            total += (total >> 16) & 0xFFFF
+            return total & 0xFFFF
+            return total & 0xFFFF
+
 
 ### BEGIN generated Flag constants
 
index 8e05c2a75240a5f8aa9c630b50b7698eff3331ac..8fab0b6c3a0981cd00ef9788b6d42fdd0f2719f1 100644 (file)
 
 import binascii
 import struct
+from typing import TypeVar
 
 import dns.dnssectypes
 import dns.immutable
 import dns.rdata
 import dns.rdatatype
 
+T = TypeVar("T", bound="DSBase")
+
 
 @dns.immutable.immutable
 class DSBase(dns.rdata.Rdata):
@@ -52,18 +55,20 @@ class DSBase(dns.rdata.Rdata):
             if self.digest_type == 0:  # reserved, RFC 3658 Sec. 2.4
                 raise ValueError("digest type 0 is reserved")
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        kw = kw.copy()
-        chunksize = kw.pop("chunksize", 128)
-        digest = dns.rdata._hexify(
-            self.digest, chunksize=chunksize, **kw  # pyright: ignore
-        )
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        digest = dns.rdata._styled_hexify(self.digest, style, True)
         return f"{self.key_tag} {self.algorithm} {self.digest_type} {digest}"
 
     @classmethod
     def from_text(
-        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+        cls: type[T],
+        rdclass,
+        rdtype,
+        tok,
+        origin=None,
+        relativize=True,
+        relativize_to=None,
+    ) -> T:
         key_tag = tok.get_uint16()
         algorithm = tok.get_string()
         digest_type = tok.get_uint8()
@@ -77,7 +82,7 @@ class DSBase(dns.rdata.Rdata):
         file.write(self.digest)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls: type[T], rdclass, rdtype, parser, origin=None) -> T:
         header = parser.get_struct("!HBB")
         digest = parser.get_remaining()
         return cls(rdclass, rdtype, header[0], header[1], header[2], digest)
index 4eb82eb5e842d3b00dd880a10fbe16f40ca47c72..e7f9a92e7fcc7c366ac10fad9f33d98184620639 100644 (file)
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 import binascii
+from typing import TypeVar
 
 import dns.exception
 import dns.immutable
 import dns.rdata
 
+T = TypeVar("T", bound="EUIBase")
+
 
 @dns.immutable.immutable
 class EUIBase(dns.rdata.Rdata):
@@ -42,13 +45,20 @@ class EUIBase(dns.rdata.Rdata):
                 f"EUI{self.byte_len * 8} rdata has to have {self.byte_len} bytes"
             )
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        return dns.rdata._hexify(self.eui, chunksize=2, separator=b"-", **kw)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        # Not using style as the style of EUIs is fixed
+        return dns.rdata._hexify(self.eui, chunksize=2, separator="-")
 
     @classmethod
     def from_text(
-        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+        cls: type[T],
+        rdclass,
+        rdtype,
+        tok,
+        origin=None,
+        relativize=True,
+        relativize_to=None,
+    ) -> T:
         text = tok.get_string()
         if len(text) != cls.text_len:
             raise dns.exception.SyntaxError(
@@ -68,6 +78,6 @@ class EUIBase(dns.rdata.Rdata):
         file.write(self.eui)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls: type[T], rdclass, rdtype, parser, origin=None) -> T:
         eui = parser.get_bytes(cls.byte_len)
         return cls(rdclass, rdtype, eui)
index 5d33e61f62358845d307b81ffad47324177512d1..79f763100b3e0fde2646233a4670e082299755d0 100644 (file)
@@ -18,6 +18,7 @@
 """MX-like base classes."""
 
 import struct
+from typing import TypeVar
 
 import dns.exception
 import dns.immutable
@@ -25,6 +26,8 @@ import dns.name
 import dns.rdata
 import dns.rdtypes.util
 
+T = TypeVar("T", bound="MXBase")
+
 
 @dns.immutable.immutable
 class MXBase(dns.rdata.Rdata):
@@ -34,17 +37,22 @@ class MXBase(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, preference, exchange):
         super().__init__(rdclass, rdtype)
-        self.preference = self._as_uint16(preference)
-        self.exchange = self._as_name(exchange)
+        self.preference: int = self._as_uint16(preference)
+        self.exchange: dns.name.Name = self._as_name(exchange)
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        exchange = self.exchange.choose_relativity(origin, relativize)
-        return f"{self.preference} {exchange}"
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        return f"{self.preference} {self.exchange.to_styled_text(style)}"
 
     @classmethod
     def from_text(
-        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+        cls: type[T],
+        rdclass,
+        rdtype,
+        tok,
+        origin=None,
+        relativize=True,
+        relativize_to=None,
+    ) -> T:
         preference = tok.get_uint16()
         exchange = tok.get_name(origin, relativize, relativize_to)
         return cls(rdclass, rdtype, preference, exchange)
@@ -55,7 +63,7 @@ class MXBase(dns.rdata.Rdata):
         self.exchange.to_wire(file, compress, origin, canonicalize)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls: type[T], rdclass, rdtype, parser, origin=None) -> T:
         preference = parser.get_uint16()
         exchange = parser.get_name(origin)
         return cls(rdclass, rdtype, preference, exchange)
index 904224f0e5bef5ef19ebb56c324129c3ae3e7071..6cabb4d22de5d2b33519573507d6ca072aa1b476 100644 (file)
 
 """NS-like base classes."""
 
+from typing import TypeVar
+
 import dns.exception
 import dns.immutable
 import dns.name
 import dns.rdata
 
+T = TypeVar("T", bound="NSBase")
+
 
 @dns.immutable.immutable
 class NSBase(dns.rdata.Rdata):
@@ -31,16 +35,21 @@ class NSBase(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, target):
         super().__init__(rdclass, rdtype)
-        self.target = self._as_name(target)
+        self.target: dns.name.Name = self._as_name(target)
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        target = self.target.choose_relativity(origin, relativize)
-        return str(target)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        return self.target.to_styled_text(style)
 
     @classmethod
     def from_text(
-        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+        cls: type[T],
+        rdclass,
+        rdtype,
+        tok,
+        origin=None,
+        relativize=True,
+        relativize_to=None,
+    ) -> T:
         target = tok.get_name(origin, relativize, relativize_to)
         return cls(rdclass, rdtype, target)
 
@@ -48,7 +57,7 @@ class NSBase(dns.rdata.Rdata):
         self.target.to_wire(file, compress, origin, canonicalize)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls: type[T], rdclass, rdtype, parser, origin=None) -> T:
         target = parser.get_name(origin)
         return cls(rdclass, rdtype, target)
 
index 3961a5d8a0c8db07cebc3ab31eb6cac93e6e8ce2..2b7208fdf0477c7f9f8fe2e244d530d5f0a13e9c 100644 (file)
@@ -19,10 +19,12 @@ import base64
 import calendar
 import struct
 import time
+from typing import TypeVar
 
 import dns.dnssectypes
 import dns.exception
 import dns.immutable
+import dns.name
 import dns.rdata
 import dns.rdatatype
 
@@ -49,6 +51,9 @@ def posixtime_to_sigtime(what):
     return time.strftime("%Y%m%d%H%M%S", time.gmtime(what))
 
 
+T = TypeVar("T", bound="RRSIGBase")
+
+
 @dns.immutable.immutable
 class RRSIGBase(dns.rdata.Rdata):
     """Base class for rdata that is like a RRSIG record"""
@@ -80,25 +85,27 @@ class RRSIGBase(dns.rdata.Rdata):
         signature,
     ):
         super().__init__(rdclass, rdtype)
-        self.type_covered = self._as_rdatatype(type_covered)
-        self.algorithm = dns.dnssectypes.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)
+        self.type_covered: dns.rdatatype.RdataType = self._as_rdatatype(type_covered)
+        self.algorithm: dns.dnssectypes.Algorithm = dns.dnssectypes.Algorithm.make(
+            algorithm
+        )
+        self.labels: int = self._as_uint8(labels)
+        self.original_ttl: int = self._as_ttl(original_ttl)
+        self.expiration: int = self._as_uint32(expiration)
+        self.inception: int = self._as_uint32(inception)
+        self.key_tag: int = self._as_uint16(key_tag)
+        self.signer: dns.name.Name = self._as_name(signer)
+        self.signature: bytes = self._as_bytes(signature)
 
     def covers(self):
         return self.type_covered
 
-    def to_text(self, origin=None, relativize=True, **kw):
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         ctext = dns.rdatatype.to_text(self.type_covered)
         expiration = posixtime_to_sigtime(self.expiration)
         inception = posixtime_to_sigtime(self.inception)
-        signer = self.signer.choose_relativity(origin, relativize)
-        sig = dns.rdata._base64ify(self.signature, **kw)  # pyright: ignore
+        signer = self.signer.to_styled_text(style)
+        sig = dns.rdata._styled_base64ify(self.signature, style, True)
         return (
             f"{ctext} {self.algorithm} {self.labels} {self.original_ttl} "
             + f"{expiration} {inception} {self.key_tag} {signer} {sig}"
@@ -106,8 +113,14 @@ class RRSIGBase(dns.rdata.Rdata):
 
     @classmethod
     def from_text(
-        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+        cls: type[T],
+        rdclass,
+        rdtype,
+        tok,
+        origin=None,
+        relativize=True,
+        relativize_to=None,
+    ) -> T:
         type_covered = dns.rdatatype.from_text(tok.get_string())
         algorithm = dns.dnssectypes.Algorithm.from_text(tok.get_string())
         labels = tok.get_int()
@@ -148,7 +161,7 @@ class RRSIGBase(dns.rdata.Rdata):
         file.write(self.signature)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls: type[T], rdclass, rdtype, parser, origin=None) -> T:
         header = parser.get_struct("!HBBIIIH")
         signer = parser.get_name(origin)
         signature = parser.get_remaining()
index f06d31ada87ddab9f11a11207a27e294f2d778ad..148c263cd865b9ebd0926e45d741bb44352f317c 100644 (file)
@@ -3,13 +3,14 @@
 import base64
 import enum
 import struct
-from typing import Any
+from typing import Any, TypeVar
 
 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.renderer
@@ -457,6 +458,9 @@ def _validate_and_define(params, key, value):
     params[key] = value
 
 
+T = TypeVar("T", bound="SVCBBase")
+
+
 @dns.immutable.immutable
 class SVCBBase(dns.rdata.Rdata):
     """Base class for SVCB-like records"""
@@ -467,13 +471,13 @@ class SVCBBase(dns.rdata.Rdata):
 
     def __init__(self, rdclass, rdtype, priority, target, params):
         super().__init__(rdclass, rdtype)
-        self.priority = self._as_uint16(priority)
-        self.target = self._as_name(target)
+        self.priority: int = self._as_uint16(priority)
+        self.target: dns.name.Name = 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(f"{k:d} not a Param")
-        self.params = dns.immutable.Dict(params)
+        self.params: dns.immutable.Dict = dns.immutable.Dict(params)
         # Make sure any parameter listed as mandatory is present in the
         # record.
         mandatory = params.get(ParamKey.MANDATORY)
@@ -488,8 +492,8 @@ class SVCBBase(dns.rdata.Rdata):
             if ParamKey.ALPN not in params:
                 raise ValueError("no-default-alpn present, but alpn missing")
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        target = self.target.choose_relativity(origin, relativize)
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        target = self.target.to_styled_text(style)
         params = []
         for key in sorted(self.params.keys()):
             value = self.params[key]
@@ -506,8 +510,14 @@ class SVCBBase(dns.rdata.Rdata):
 
     @classmethod
     def from_text(
-        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+        cls: type[T],
+        rdclass,
+        rdtype,
+        tok,
+        origin=None,
+        relativize=True,
+        relativize_to=None,
+    ) -> T:
         priority = tok.get_uint16()
         target = tok.get_name(origin, relativize, relativize_to)
         if priority == 0:
@@ -558,7 +568,7 @@ class SVCBBase(dns.rdata.Rdata):
                     value.to_wire(file, origin)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls: type[T], rdclass, rdtype, parser, origin=None) -> T:
         priority = parser.get_uint16()
         target = parser.get_name(origin)
         if priority == 0 and parser.remaining() != 0:
index ddc196f1f6fca3a5ff238c729f8b5da54a20fa39..363a34aa2fbe53143faf3e86842f95c69942de4a 100644 (file)
 
 import binascii
 import struct
+from typing import TypeVar
 
 import dns.immutable
 import dns.rdata
 import dns.rdatatype
 
+T = TypeVar("T", bound="TLSABase")
+
 
 @dns.immutable.immutable
 class TLSABase(dns.rdata.Rdata):
@@ -33,23 +36,25 @@ class TLSABase(dns.rdata.Rdata):
 
     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)
+        self.usage: int = self._as_uint8(usage)
+        self.selector: int = self._as_uint8(selector)
+        self.mtype: int = self._as_uint8(mtype)
+        self.cert: bytes = self._as_bytes(cert)
 
-    def to_text(self, origin=None, relativize=True, **kw):
-        kw = kw.copy()
-        chunksize = kw.pop("chunksize", 128)
-        cert = dns.rdata._hexify(
-            self.cert, chunksize=chunksize, **kw  # pyright: ignore
-        )
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
+        cert = dns.rdata._styled_hexify(self.cert, style, True)
         return f"{self.usage} {self.selector} {self.mtype} {cert}"
 
     @classmethod
     def from_text(
-        cls, rdclass, rdtype, tok, origin=None, relativize=True, relativize_to=None
-    ):
+        cls: type[T],
+        rdclass,
+        rdtype,
+        tok,
+        origin=None,
+        relativize=True,
+        relativize_to=None,
+    ) -> T:
         usage = tok.get_uint8()
         selector = tok.get_uint8()
         mtype = tok.get_uint8()
@@ -63,7 +68,7 @@ class TLSABase(dns.rdata.Rdata):
         file.write(self.cert)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls: type[T], rdclass, rdtype, parser, origin=None) -> T:
         header = parser.get_struct("BBB")
         cert = parser.get_remaining()
         return cls(rdclass, rdtype, header[0], header[1], header[2], cert)
index b10da13b2489306f30dfae42937259097b88c55b..8722c34c51fd7e135c59ade3851a6b6d375586a7 100644 (file)
@@ -18,7 +18,7 @@
 """TXT-like base class."""
 
 from collections.abc import Iterable
-from typing import Any
+from typing import TypeVar
 
 import dns.exception
 import dns.immutable
@@ -29,6 +29,8 @@ import dns.rdatatype
 import dns.renderer
 import dns.tokenizer
 
+T = TypeVar("T", bound="TXTBase")
+
 
 @dns.immutable.immutable
 class TXTBase(dns.rdata.Rdata):
@@ -57,29 +59,32 @@ class TXTBase(dns.rdata.Rdata):
         if len(self.strings) == 0:
             raise ValueError("the list of strings must not be empty")
 
-    def to_text(
-        self,
-        origin: dns.name.Name | None = None,
-        relativize: bool = True,
-        **kw: dict[str, Any],
-    ) -> str:
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         txt = ""
         prefix = ""
         for s in self.strings:
-            txt += f'{prefix}"{dns.rdata._escapify(s)}"'
+            if style is not None and style.txt_is_utf8:
+                try:
+                    us = s.decode()
+                    element = dns.rdata._escapify_unicode(us)
+                except Exception:
+                    element = dns.rdata._escapify(s)
+            else:
+                element = dns.rdata._escapify(s)
+            txt += f'{prefix}"{element}"'
             prefix = " "
         return txt
 
     @classmethod
     def from_text(
-        cls,
+        cls: type[T],
         rdclass: dns.rdataclass.RdataClass,
         rdtype: dns.rdatatype.RdataType,
         tok: dns.tokenizer.Tokenizer,
         origin: dns.name.Name | None = None,
         relativize: bool = True,
         relativize_to: dns.name.Name | None = None,
-    ) -> dns.rdata.Rdata:
+    ) -> T:
         strings = []
         for token in tok.get_remaining():
             token = token.unescape_to_bytes()
@@ -102,7 +107,7 @@ class TXTBase(dns.rdata.Rdata):
                 file.write(s)
 
     @classmethod
-    def from_wire_parser(cls, rdclass, rdtype, parser, origin=None):
+    def from_wire_parser(cls: type[T], rdclass, rdtype, parser, origin=None) -> T:
         strings = []
         while parser.remaining() > 0:
             s = parser.get_counted_bytes()
index f0840d45cb8cf8c8f32f8b67ccdc9e14d7888c60..fc6fefe197862f51caefd46e9a65c220ea5c5734 100644 (file)
@@ -37,8 +37,8 @@ class Gateway:
     name = ""
 
     def __init__(self, type: Any, gateway: str | dns.name.Name | None = None):
-        self.type = dns.rdata.Rdata._as_uint8(type)
-        self.gateway = gateway
+        self.type: int = dns.rdata.Rdata._as_uint8(type)
+        self.gateway: str | dns.name.Name | None = gateway
         self._check()
 
     @classmethod
@@ -64,14 +64,18 @@ class Gateway:
         else:
             raise SyntaxError(self._invalid_type(self.type))
 
-    def to_text(self, origin=None, relativize=True):
+    def to_text(self, origin=None, relativize=True) -> str:
+        return self.to_styled_text(dns.rdata.RdataStyle(origin=origin, relativize=True))
+
+    def to_styled_text(self, style: dns.rdata.RdataStyle) -> str:
         if self.type == 0:
             return "."
         elif self.type in (1, 2):
+            assert isinstance(self.gateway, str)
             return self.gateway
         elif self.type == 3:
             assert isinstance(self.gateway, dns.name.Name)
-            return str(self.gateway.choose_relativity(origin, relativize))
+            return self.gateway.to_styled_text(style)
         else:
             raise ValueError(self._invalid_type(self.type))  # pragma: no cover
 
index 40982b5fdfa45050d1cb7568d746d33ba52c8055..19501c68cc547e0ffb54bc4017fe22566979022e 100644 (file)
@@ -133,7 +133,9 @@ class RRset(dns.rdataset.Rdataset):
         self,
         origin: dns.name.Name | None = None,
         relativize: bool = True,
-        **kw: dict[str, Any],
+        want_comments: bool = False,
+        style: dns.rdataset.RdatasetStyle | None = None,
+        **kw: Any,
     ) -> str:
         """Convert the RRset into DNS zone file format.
 
@@ -149,18 +151,42 @@ class RRset(dns.rdataset.Rdataset):
 
         *relativize*, a ``bool``.  If ``True``, names will be relativized
         to *origin*.
+
+        *want_comments*, a ``bool``.  If ``True``, emit comments for rdata
+        which have them.  The default is ``False``.
+
+        *style*, a :py:class:`dns.rdataset.RdatasetStyle` or ``None`` (the default).  If
+        specified, the style overrides the other parameters.
         """
+        if style is None:
+            kw = kw.copy()
+            kw["origin"] = origin
+            kw["relativize"] = relativize
+            kw["want_comments"] = want_comments
+            style = dns.rdataset.RdatasetStyle.from_keywords(kw)
+        return self.to_styled_text(style)
 
-        return super().to_text(
-            self.name, origin, relativize, self.deleting, **kw  # type: ignore
-        )
+    def to_styled_text(self, style: dns.rdataset.RdatasetStyle) -> str:  # type: ignore
+        """Convert the RRset to styled text.
+
+        A new style is made from the specified style setting the ``override_rdclass``
+        attribute appropriately for the deleting status of the RRset.
+
+        *style*, a :py:class:`dns.rdataset.RdatasetStyle` or ``None`` (the default).  If
+        specified, the style overrides the other parameters.
+
+        returns a ``str``.
+        """
+        if self.deleting is not None:
+            style = style.replace(override_rdclass=self.deleting)
+        return super().to_styled_text(style, self.name)
 
     def to_wire(  # type: ignore
         self,
         file: Any,
         compress: dns.name.CompressType | None = None,
         origin: dns.name.Name | None = None,
-        **kw: dict[str, Any],
+        **kw: Any,
     ) -> int:
         """Convert the RRset to wire format.
 
@@ -170,9 +196,7 @@ class RRset(dns.rdataset.Rdataset):
         Returns an ``int``, the number of records emitted.
         """
 
-        return super().to_wire(
-            self.name, file, compress, origin, self.deleting, **kw  # type: ignore
-        )
+        return super().to_wire(self.name, file, compress, origin, self.deleting, **kw)
 
     # pylint: enable=arguments-differ
 
diff --git a/dns/style.py b/dns/style.py
new file mode 100644 (file)
index 0000000..bf04235
--- /dev/null
@@ -0,0 +1,28 @@
+# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
+
+import dataclasses
+from typing import Any, TypeVar
+
+T = TypeVar("T", bound="BaseStyle")
+
+
+@dataclasses.dataclass(frozen=True)
+class BaseStyle:
+    """All text styles"""
+
+    def replace(self: T, /, **changes) -> T:
+        return dataclasses.replace(self, **changes)
+
+    @classmethod
+    def from_keywords(cls: type[T], kw: dict[str, Any]) -> T:
+        ok_kw: dict[str, Any] = {}
+        for k, v in kw.items():
+            if k == "chunksize":
+                ok_kw["hex_chunk_size"] = v
+                ok_kw["base64_chunk_size"] = v
+            elif k == "separator":
+                ok_kw["hex_separator"] = v
+                ok_kw["base64_separator"] = v
+            elif hasattr(cls, k):
+                ok_kw[k] = v
+        return cls(**ok_kw)
index 9b0c254a49d7ed31e359e24fbe275b125becaceb..8ce14eb40acb2cbd7dd7905feb977428bfe31f12 100644 (file)
@@ -115,6 +115,7 @@ class Transaction:
         self.manager = manager
         self.replacement = replacement
         self.read_only = read_only
+        self.unicode: set[str] = set()
         self._ended = False
         self._check_put_rdataset: list[CheckPutRdatasetType] = []
         self._check_delete_rdataset: list[CheckDeleteRdatasetType] = []
@@ -281,6 +282,9 @@ class Transaction:
         new_rdataset = dns.rdataset.from_rdata(rdataset.ttl, rdata)
         self.replace(name, new_rdataset)
 
+    def add_unicode(self, attribute: str):
+        self.unicode.add(attribute.upper())
+
     def __iter__(self):
         self._check_ended()
         return self._iterate_rdatasets()
index 06e65520f1e2a30e13b28a50a98b2a87fb489e46..62df90a37350d1600f349abcd5aa427b7371fda7 100644 (file)
@@ -16,8 +16,8 @@
 # OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
 """DNS Zones."""
-
 import contextlib
+import dataclasses
 import io
 import os
 import struct
@@ -105,6 +105,41 @@ def _validate_name(
     return name
 
 
+@dataclasses.dataclass(frozen=True)
+class ZoneStyle(dns.node.NodeStyle):
+    """Zone text styles
+
+    A ``ZoneStyle`` is also a :py:class:`dns.name.NameStyle` and a
+    :py:class:`dns.rdata.RdataStyle`, a :py:class:`dns.rdataset.RdatasetStyle`,
+    and a :py:class:`dns.node.NodeStyle`.
+    See those classes for a description of their options.
+
+    *sorted*, a ``bool``.  If True, the default, then the file
+    will be written with the names sorted in DNSSEC order from
+    least to greatest.  Otherwise the names will be written in
+    whatever order they happen to have in the zone's dictionary.
+
+    *nl*, a ``str`` or ``None`` (the default).  The end of line string,
+    or if ``None``, the output will use the platform's native
+    end-of-line marker (i.e. LF on POSIX, CRLF on Windows).
+
+    *want_origin*, a ``bool``.  If ``True``, emit a $ORIGIN line at
+    the start of the output.  If ``False``, the default, do not emit
+    one.
+
+    *want_unicode_directive*, a ``bool``.  If ``True`` and the zone
+    has a non-empty ``unicode`` attribute, then emit a ``$UNICODE``
+    line in the output.  This directive is not standard, but allows
+    dnspython and other aware software to read and write Unicode zonefiles
+    without changing the rendering of names and TXT-like records.
+    """
+
+    sorted: bool = True
+    nl: str | None = None
+    want_origin: bool = False
+    want_unicode_directive: bool = True
+
+
 class Zone(dns.transaction.TransactionManager):
     """A DNS zone.
 
@@ -154,6 +189,7 @@ class Zone(dns.transaction.TransactionManager):
         self.rdclass = rdclass
         self.nodes: MutableMapping[dns.name.Name, dns.node.Node] = self.map_factory()
         self.relativize = relativize
+        self.unicode: set[str] = set()
 
     def __eq__(self, other):
         """Two zones are equal if they have the same origin, class, and
@@ -620,6 +656,7 @@ class Zone(dns.transaction.TransactionManager):
         nl: str | bytes | None = None,
         want_comments: bool = False,
         want_origin: bool = False,
+        style: ZoneStyle | None = None,
     ) -> None:
         """Write a zone to a file.
 
@@ -647,7 +684,52 @@ class Zone(dns.transaction.TransactionManager):
         the start of the file.  If ``False``, the default, do not emit
         one.
         """
+        if style is None:
+            kw = {}
+            kw["sorted"] = sorted
+            kw["relativize"] = relativize
+            if relativize:
+                assert self.origin is not None
+                kw["origin"] = self.origin
+            kw["nl"] = nl
+            kw["want_comments"] = want_comments
+            kw["want_origin"] = want_origin
+            style = ZoneStyle.from_keywords(kw)
+        return self.to_styled_file(style, f)
+
+    def _write_line(self, output, l, l_b, nl, nl_b):
+        try:
+            bout = cast(BinaryIO, output)
+            bout.write(l_b)
+            bout.write(nl_b)
+        except TypeError:  # textual mode
+            tout = cast(TextIO, output)
+            tout.write(l)
+            tout.write(nl)
+
+    def to_styled_file(
+        self,
+        style: ZoneStyle,
+        f: Any,
+    ) -> None:
+        """Write a zone to a styled file.
+
+        *f*, a file or `str`.  If *f* is a string, it is treated
+        as the name of a file to open.
+        """
 
+        # Apply style items we learned from $UNICODE when we loaded the zone (if any).
+        if style.want_unicode_directive:
+            idna_codec: dns.name.IDNACodec | None = style.idna_codec
+            if "2008" in self.unicode:
+                idna_codec = dns.name.IDNA_2008_Practical
+            elif "2003" in self.unicode:
+                idna_codec = dns.name.IDNA_2003_Practical
+            if "TXT" in self.unicode:
+                txt_is_utf8 = True
+            else:
+                txt_is_utf8 = style.txt_is_utf8
+            style = style.replace(idna_codec=idna_codec, txt_is_utf8=txt_is_utf8)
         if isinstance(f, str):
             cm: contextlib.AbstractContextManager = open(f, "wb")
         else:
@@ -659,52 +741,44 @@ class Zone(dns.transaction.TransactionManager):
             if file_enc is None:
                 file_enc = "utf-8"
 
-            if nl is None:
+            if style.nl is None:
                 # binary mode, '\n' is not enough
                 nl_b = os.linesep.encode(file_enc)
                 nl = "\n"
-            elif isinstance(nl, str):
-                nl_b = nl.encode(file_enc)
+            elif isinstance(style.nl, str):
+                nl_b = style.nl.encode(file_enc)
+                nl = style.nl
             else:
-                nl_b = nl
-                nl = nl.decode()
+                nl_b = style.nl
+                nl = style.nl.decode()
             assert nl is not None
             assert nl_b is not None
 
-            if want_origin:
+            if style.want_unicode_directive and len(self.unicode) > 0:
+                l = "$UNICODE " + " ".join(sorted(self.unicode))
+                l_b = l.encode(file_enc)
+                self._write_line(output, l, l_b, nl, nl_b)
+            if style.want_origin:
                 assert self.origin is not None
-                l = "$ORIGIN " + self.origin.to_text()
+                # Ensure we don't relativize the origin to the origin in $ORIGIN!
+                origin_style = style.replace(origin=None)
+                l = "$ORIGIN " + self.origin.to_styled_text(origin_style)
                 l_b = l.encode(file_enc)
-                try:
-                    bout = cast(BinaryIO, output)
-                    bout.write(l_b)
-                    bout.write(nl_b)
-                except TypeError:  # textual mode
-                    tout = cast(TextIO, output)
-                    tout.write(l)
-                    tout.write(nl)
-
-            if sorted:
+                self._write_line(output, l, l_b, nl, nl_b)
+            if style.default_ttl is not None:
+                l = f"$TTL {style.default_ttl}"
+                l_b = l.encode(file_enc)
+                self._write_line(output, l, l_b, nl, nl_b)
+
+            if style.sorted:
                 names = list(self.keys())
                 names.sort()
             else:
                 names = self.keys()
             for n in names:
-                l = self[n].to_text(
-                    n,
-                    origin=self.origin,  # pyright: ignore
-                    relativize=relativize,  # pyright: ignore
-                    want_comments=want_comments,  # pyright: ignore
-                )
+                l = self[n].to_styled_text(style, n)
                 l_b = l.encode(file_enc)
-                try:
-                    bout = cast(BinaryIO, output)
-                    bout.write(l_b)
-                    bout.write(nl_b)
-                except TypeError:  # textual mode
-                    tout = cast(TextIO, output)
-                    tout.write(l)
-                    tout.write(nl)
+                self._write_line(output, l, l_b, nl, nl_b)
 
     def to_text(
         self,
@@ -713,6 +787,7 @@ class Zone(dns.transaction.TransactionManager):
         nl: str | None = None,
         want_comments: bool = False,
         want_origin: bool = False,
+        style: ZoneStyle | None = None,
     ) -> str:
         """Return a zone's text as though it were written to a file.
 
@@ -737,6 +812,9 @@ class Zone(dns.transaction.TransactionManager):
         the start of the output.  If ``False``, the default, do not emit
         one.
 
+        *style*, a :py:class:`dns.zone.ZoneStyle` or ``None`` (the default).  If specified,
+        the style overrides the other parameters.
+
         Returns a ``str``.
         """
         temp_buffer = io.StringIO()
@@ -745,6 +823,19 @@ class Zone(dns.transaction.TransactionManager):
         temp_buffer.close()
         return return_value
 
+    def to_styled_text(self, style: ZoneStyle):
+        """Return a zone's styled text as though it were written to a file.
+
+        See the documentation for :py:class:`dns.zone.ZoneStyle` for a description
+        of the style parameters.
+        """
+
+        temp_buffer = io.StringIO()
+        self.to_styled_file(style, temp_buffer)
+        return_value = temp_buffer.getvalue()
+        temp_buffer.close()
+        return return_value
+
     def check_origin(self) -> None:
         """Do some simple checking of the zone's origin.
 
@@ -1246,6 +1337,7 @@ def _from_text(
         )
         try:
             reader.read()
+            zone.unicode = txn.unicode
         except dns.zonefile.UnknownOrigin:
             # for backwards compatibility
             raise UnknownOrigin
index 590e79a134b6c8d93b6cf1937915368211d01c79..ce7ec7657ab6ecf7081d65ae7d5e7811bf38f2c7 100644 (file)
@@ -122,7 +122,7 @@ class Reader:
         self.current_file: Any | None = None
         self.allowed_directives: set[str]
         if allow_directives is True:
-            self.allowed_directives = {"$GENERATE", "$ORIGIN", "$TTL"}
+            self.allowed_directives = {"$GENERATE", "$ORIGIN", "$TTL", "$UNICODE"}
             if allow_include:
                 self.allowed_directives.add("$INCLUDE")
         elif allow_directives is False:
@@ -541,6 +541,18 @@ class Reader:
                         self.current_origin = new_origin
                     elif c == "$GENERATE":
                         self._generate_line()
+                    elif c == "$UNICODE":
+                        while True:
+                            token = self.tok.get()
+                            if token.is_eol_or_eof():
+                                break
+                            if not token.is_identifier():
+                                raise dns.exception.SyntaxError("bad $UNICODE")
+                            self.txn.add_unicode(token.value)
+                            if token.value == "2008":
+                                self.tok.idna_codec = dns.name.IDNA_2008_Practical
+                            elif token.value == "2003":
+                                self.tok.idna_codec = dns.name.IDNA_2003_Practical
                     else:
                         raise dns.exception.SyntaxError(
                             f"Unknown zone file directive '{c}'"
index 5a833691794b31db8e28067e04b7a660658a2679..8f2731df7b6853e7f8808a0e106aad81d7f699d7 100644 (file)
@@ -133,6 +133,10 @@ DNS opcodes that do not have a more specific class.
       message with ``dns.message.from_wire()`` or the output most recently generated by
       ``to_wire()``.
 
+.. autoclass:: dns.message.MessageStyle
+   :members:
+   :inherited-members:
+
 The following constants may be used to specify sections in the
 ``find_rrset()`` and ``get_rrset()`` methods:
 
index eb1611eb399d2d62a49ec600c7d5672e1840c0d1..acae087e4d45a1f2c80732725cf0a4b45b481cd3 100644 (file)
@@ -27,3 +27,6 @@ The dns.name.Name Class and Predefined Names
 
 .. autoclass:: dns.name.NameRelation
    :members:
+
+.. autoclass:: dns.name.NameStyle
+   :members:
index cdebaa80cf66cd36b47db25e0dc30310edf98571..82d61cfe3c07dbbe65f3918d53545da145dca2e2 100644 (file)
@@ -40,3 +40,7 @@ future behavior to be tested with existing code.
 .. autoclass:: dns.rdata.Rdata
    :members:
    :inherited-members:
+
+.. autoclass:: dns.rdata.RdataStyle
+   :members:
+   :inherited-members:
index ed350085248cd852c868f9daf032d1b3bf56ae73..d2963cc9e2e54107725fcce92de4859b6d54af4b 100644 (file)
@@ -19,8 +19,19 @@ tree.  Nodes are primarily used in ``Zone`` objects.
 .. autoclass:: dns.rdataset.Rdataset
    :members:
 
+.. autoclass:: dns.rdataset.RdatasetStyle
+   :members:
+   :inherited-members:
+
 .. autoclass:: dns.rrset.RRset
    :members:
 
 .. autoclass:: dns.node.Node
    :members:
+
+.. autoclass:: dns.node.NodeKind
+   :members:
+
+.. autoclass:: dns.node.NodeStyle
+   :members:
+   :inherited-members:
index 554922d8f84c0e045fc5fa068865d8d4d44d8c26..ed8a9fde3dbcefaf33c76e8a549f433b51e00b44 100644 (file)
@@ -10,7 +10,7 @@ use the :py:class:`dns.versioned.Zone` class (described below).
 
 .. autoclass:: dns.zone.Zone
    :members:
-      
+
    .. attribute:: rdclass
 
       The zone's rdata class, an ``int``; the default is class IN.
@@ -20,10 +20,10 @@ use the :py:class:`dns.versioned.Zone` class (described below).
       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.
@@ -34,6 +34,9 @@ subclassed if a different node factory is desired.
 The node factory is a class or callable that returns a subclass of
 ``dns.node.Node``.
 
+.. autoclass:: dns.zone.ZoneStyle
+   :members:
+   :inherited-members:
 
 The dns.versioned.Zone Class
 ----------------------------
@@ -68,11 +71,11 @@ Transactions are context managers, and are created with ``reader()`` or
        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.
@@ -82,10 +85,10 @@ See below for more information on the ``Transaction`` API.
       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.
@@ -105,4 +108,4 @@ The Transaction Class
 
 .. autoclass:: dns.transaction.Transaction
    :members:
-   
+
index 6b354cd40967b954eeb45086d53d6f1a3a6e3f5d..452c2c9e765b8b2fd02ffeeb334e1813aaca96fc 100644 (file)
@@ -287,6 +287,68 @@ web a 10.0.0.4
     rrsig NSEC 1 3 3600 20200101000000 20030101000000 2143 foo MxFcby9k/yvedMfQgKzhH5er0Mu/vILz 45IkskceFGgiWCn/GxHhai6VAuHAoNUz 4YoU1tVfSCSqQYn6//11U6Nld80jEeC8 aTrO+KKmCaY=
 """
 
+example_unicode = """$UNICODE 2008 TXT
+$ORIGIN example.
+$TTL 300
+@               SOA     ns1.example. hostmaster.example. 1 2 3 4 5
+                NS      ns1.example.
+                NS      ns2.example.
+                MX      10 boîte-aux-lettres
+ns1     60      A       10.53.0.1
+        60      A       10.53.1.1
+ns2     60      A       10.53.0.2
+élèves          TXT "Je peux manger du verre, ça me fait pas mal."
+                TXT "Puedo comer vidrio, no me hace daño."
+                TXT "ὕαλον ϕαγεῖν δύναμαι· τοῦτο οὔ με βλάπτει."
+                TXT "Я могу есть стекло, оно мне не вредит."
+                TXT "मैं काँच खा सकता हूँ और मुझे उससे कोई चोट नहीं पहुंचती."
+                TXT "أنا قادر على أكل الزجاج و هذا لا يؤلمني."
+                TXT "我能吞下玻璃而不伤身体。"
+                TXT "私はガラスを食べられます。それは私を傷つけません。"
+                TXT "나는 유리를 먹을 수 있어요. 그래도 아프지 않아요"
+"""
+
+example_unicode_expected = """$UNICODE 2008 TXT
+$ORIGIN example.
+@ 300 SOA ns1 hostmaster 1 2 3 4 5
+    300 NS ns1
+    300 NS ns2
+    300 MX 10 boîte-aux-lettres
+ns1 60 A 10.53.0.1
+    60 A 10.53.1.1
+ns2 60 A 10.53.0.2
+élèves 300 TXT "Je peux manger du verre, ça me fait pas mal."
+    300 TXT "Puedo comer vidrio, no me hace daño."
+    300 TXT "ὕαλον ϕαγεῖν δύναμαι· τοῦτο οὔ με βλάπτει."
+    300 TXT "Я могу есть стекло, оно мне не вредит."
+    300 TXT "मैं काँच खा सकता हूँ और मुझे उससे कोई चोट नहीं पहुंचती."
+    300 TXT "أنا قادر على أكل الزجاج و هذا لا يؤلمني."
+    300 TXT "我能吞下玻璃而不伤身体。"
+    300 TXT "私はガラスを食べられます。それは私を傷つけません。"
+    300 TXT "나는 유리를 먹을 수 있어요. 그래도 아프지 않아요"
+"""
+
+example_unicode_justified = """$UNICODE 2008 TXT
+$ORIGIN example.
+$TTL 300
+@                 SOA      ns1 hostmaster 1 2 3 4 5
+                  NS       ns1
+                  NS       ns2
+                  MX       10 boîte-aux-lettres
+ns1        60     A        10.53.0.1
+           60     A        10.53.1.1
+ns2        60     A        10.53.0.2
+élèves            TXT      "Je peux manger du verre, ça me fait pas mal."
+                  TXT      "Puedo comer vidrio, no me hace daño."
+                  TXT      "ὕαλον ϕαγεῖν δύναμαι· τοῦτο οὔ με βλάπτει."
+                  TXT      "Я могу есть стекло, оно мне не вредит."
+                  TXT      "मैं काँच खा सकता हूँ और मुझे उससे कोई चोट नहीं पहुंचती."
+                  TXT      "أنا قادر على أكل الزجاج و هذا لا يؤلمني."
+                  TXT      "我能吞下玻璃而不伤身体。"
+                  TXT      "私はガラスを食べられます。それは私を傷つけません。"
+                  TXT      "나는 유리를 먹을 수 있어요. 그래도 아프지 않아요"
+"""
+
 _keep_output = True
 
 
@@ -1188,6 +1250,38 @@ class ZoneTestCase(unittest.TestCase):
         with self.assertRaises(TypeError):
             node.replace_rdataset(None)
 
+    def testUnicodeThereAndBack(self):
+        z1 = dns.zone.from_text(example_unicode, "example")
+        s = dns.zone.ZoneStyle(
+            deduplicate_names=True, omit_rdclass=True, want_origin=True
+        )
+        t1 = z1.to_styled_text(s)
+        self.assertEqual(t1, example_unicode_expected)
+        z2 = dns.zone.from_text(t1, "example")
+        self.assertEqual(z1, z2)
+        z1.unicode = set()
+        t2 = z1.to_text()
+        z3 = dns.zone.from_text(t2, "example.")
+        self.assertEqual(z1, z3)
+
+    def testJustificationAndDefaultTTL(self):
+        z1 = dns.zone.from_text(example_unicode, "example")
+        s = dns.zone.ZoneStyle(
+            deduplicate_names=True,
+            omit_rdclass=True,
+            want_origin=True,
+            default_ttl=300,
+            name_just=-10,
+            ttl_just=4,
+            rdclass_just=-4,
+            rdtype_just=-8,
+        )
+        t1 = z1.to_styled_text(s)
+        print(t1)
+        print("-------")
+        print(example_unicode_justified)
+        self.assertEqual(t1, example_unicode_justified)
+
 
 class VersionedZoneTestCase(unittest.TestCase):
     zone_factory = dns.versioned.Zone