diff --git a/pcapkit/const/http/status_code.py b/pcapkit/const/http/status_code.py index 5577b13f2..37ffb4f8c 100644 --- a/pcapkit/const/http/status_code.py +++ b/pcapkit/const/http/status_code.py @@ -58,8 +58,8 @@ def __str__(self) -> 'str': CODE_103 = 103, 'Early Hints' #: Upload Resumption Supported (TEMPORARY - registered 2024-11-13, extension - #: registered 2025-09-15, expires 2026-11-13) [draft-ietf-httpbis-resumable- - #: upload-05] + #: registered 2025-09-15, expires 2026-11-13) + #: [draft-ietf-httpbis-resumable-upload-05] CODE_104 = 104, 'Upload Resumption Supported' #: OK [:rfc:`9110#section-15.3.1`] diff --git a/pcapkit/const/mh/dhcp_support_mode.py b/pcapkit/const/mh/dhcp_support_mode.py index c3721fcfd..ce03e7cb7 100644 --- a/pcapkit/const/mh/dhcp_support_mode.py +++ b/pcapkit/const/mh/dhcp_support_mode.py @@ -29,7 +29,7 @@ class DHCPSupportMode(EnumRegistry, IntEnum): #: Unassigned Unassigned_0x0 = 0x0 - #: (S) flag [:rfc:`5844`] + #: \(S) flag [:rfc:`5844`] S_flag = 0x1 @classmethod diff --git a/pcapkit/const/reg/apptype/apptype.py b/pcapkit/const/reg/apptype/apptype.py index 885445e76..38c1d58da 100644 --- a/pcapkit/const/reg/apptype/apptype.py +++ b/pcapkit/const/reg/apptype/apptype.py @@ -1391,8 +1391,8 @@ class AppType(EnumRegistry, StrEnum): - [N/A] HP Remote Repository for Build and Test Results * - ``hpr-toollnx`` - N/A - - [N/A] HP Remote System that houses compilers and tools for Linux- - based Systems + - [N/A] HP Remote System that houses compilers and tools for + Linux-based Systems * - ``hpr-toolwin`` - N/A - [N/A] HP Remote System that houses compilers and tools for Microsoft @@ -1909,8 +1909,8 @@ class AppType(EnumRegistry, StrEnum): - [N/A] Retrospect backup and restore service * - ``rfbc`` - N/A - - [N/A] Remote Frame Buffer Client (Used by VNC viewers in listen- - mode) + - [N/A] Remote Frame Buffer Client (Used by VNC viewers in + listen-mode) * - ``rfid`` - N/A - [N/A] RFID Reader Mach1(tm) Protocol diff --git a/pcapkit/const/reg/apptype/tcp.py b/pcapkit/const/reg/apptype/tcp.py index 3548a87cd..e133d05d9 100644 --- a/pcapkit/const/reg/apptype/tcp.py +++ b/pcapkit/const/reg/apptype/tcp.py @@ -76,8 +76,8 @@ class TCP(AppType): * - ``affinity-cntent`` - [TCP] Affinity content sharing * - ``ai-adp`` - - [TCP] AI Agent Discovery Protocol (ADP) [draft-pro-adp-agent- - discovery-00] + - [TCP] AI Agent Discovery Protocol (ADP) + [draft-pro-adp-agent-discovery-00] * - ``airdrop`` - [TCP] Airdrop * - ``airmate`` @@ -151,8 +151,7 @@ class TCP(AppType): - [TCP] Data transfer service * - ``boardgame`` - [TCP] Two-player board game play - [https://github.com/chentianren0/MiniXiangqi/blob/main/docs/boardgame- - protocol.md] + [https://github.com/chentianren0/MiniXiangqi/blob/main/docs/boardgame-protocol.md] * - ``boardplus`` - [TCP] board plus application transfer protocol * - ``booked-sync`` @@ -209,8 +208,8 @@ class TCP(AppType): * - ``cerebra`` - [TCP] Control the Cerebra Aquarium Controller * - ``certificates`` - - [TCP] Server for S/MIME and PGP certificates [draft- - bhjl-x509-srv-01] + - [TCP] Server for S/MIME and PGP certificates + [draft-bhjl-x509-srv-01] * - ``ciao`` - [TCP] Ciao Arduino Protocol * - ``collabio`` @@ -285,9 +284,9 @@ class TCP(AppType): "_dots-call-home._tcp" for discovering Call Home DOTS clients used to establish DOTS signal channel call home. [:rfc:`8973`]; [UDP] DOTS Signal Channel Call Home Protocol. The service name is used to - construct the SRV service names "_dots-call-home._udp" and "_dots- - call-home._tcp" for discovering Call Home DOTS clients used to - establish DOTS signal channel call home. [:rfc:`8973`] + construct the SRV service names "_dots-call-home._udp" and + "_dots-call-home._tcp" for discovering Call Home DOTS clients used + to establish DOTS signal channel call home. [:rfc:`8973`] * - ``dots-data`` - [TCP] DOTS Data Channel Protocol. The service name is used to construct the SRV service name "_dots-data._tcp" for discovering @@ -727,17 +726,17 @@ class TCP(AppType): * - ``smartsocket`` - [TCP] home control * - ``smimeca`` - - [TCP] Domain signing certificate for S/MIME keys [draft- - bhjl-x509-srv-01] + - [TCP] Domain signing certificate for S/MIME keys + [draft-bhjl-x509-srv-01] * - ``snif-cln`` - - [TCP] SNIF End-to-End TLS Relay Client Connection [draft-zubov- - snif-05, Section 4.5] + - [TCP] SNIF End-to-End TLS Relay Client Connection + [draft-zubov-snif-05, Section 4.5] * - ``snif-fifo`` - [TCP] SNIF End-to-End TLS Relay Cluster [draft-zubov-snif-05, Section 4.6] * - ``snif-srv`` - - [TCP] SNIF End-to-End TLS Relay Service Connection [draft-zubov- - snif-05, Section 4.3] + - [TCP] SNIF End-to-End TLS Relay Service Connection + [draft-zubov-snif-05, Section 4.3] * - ``soda`` - [TCP] Secure On Device API * - ``spidap`` @@ -20695,8 +20694,8 @@ class TCP(AppType): #: (Passive Optical Network) systems [TR-352][Inter-Channel-Termination #: protocol (ICTP) is a peer-to-peer protocol with a binary message format that #: is used by a set of optical line terminal (OLT) channel terminations (CT) - #: comprising a 40-Gigabit-capable next-generation passive optical network (NG- - #: PON2) system to exchange channel profile configuration and status + #: comprising a 40-Gigabit-capable next-generation passive optical network + #: (NG-PON2) system to exchange channel profile configuration and status #: information and to coordinate the operation of tunable optical network units #: (ONUs) in a multi-wavelength channel environment.] pon_ictp = 7202, 'pon-ictp', TransportProtocol.tcp @@ -23287,8 +23286,8 @@ class TCP(AppType): linogridengine = 12300, 'linogridengine', TransportProtocol.tcp #: [TCP] Remote Administration Daemon (RAD) is a system service that offers - #: secure, remote, programmatic access to Solaris system configuration and run- - #: time state + #: secure, remote, programmatic access to Solaris system configuration and + #: run-time state rads = 12302, 'rads', TransportProtocol.tcp #: - [TCP] Warehouse Monitoring Syst SSS @@ -24487,19 +24486,19 @@ class TCP(AppType): #: - [UDP] GameSmith Port gamesmith_port = 31765, 'gamesmith-port', TransportProtocol.tcp - #: - [TCP] Embedded Device Configuration Protocol TX IANA assigned this well- - #: formed service name as a replacement for "iceedcp_tx". + #: - [TCP] Embedded Device Configuration Protocol TX IANA assigned this + #: well-formed service name as a replacement for "iceedcp_tx". #: - [TCP] Embedded Device Configuration Protocol TX - #: - [UDP] Embedded Device Configuration Protocol TX IANA assigned this well- - #: formed service name as a replacement for "iceedcp_tx". + #: - [UDP] Embedded Device Configuration Protocol TX IANA assigned this + #: well-formed service name as a replacement for "iceedcp_tx". #: - [UDP] Embedded Device Configuration Protocol TX iceedcp_tx = 31948, 'iceedcp-tx', TransportProtocol.tcp - #: - [TCP] Embedded Device Configuration Protocol RX IANA assigned this well- - #: formed service name as a replacement for "iceedcp_rx". + #: - [TCP] Embedded Device Configuration Protocol RX IANA assigned this + #: well-formed service name as a replacement for "iceedcp_rx". #: - [TCP] Embedded Device Configuration Protocol RX - #: - [UDP] Embedded Device Configuration Protocol RX IANA assigned this well- - #: formed service name as a replacement for "iceedcp_rx". + #: - [UDP] Embedded Device Configuration Protocol RX IANA assigned this + #: well-formed service name as a replacement for "iceedcp_rx". #: - [UDP] Embedded Device Configuration Protocol RX iceedcp_rx = 31949, 'iceedcp-rx', TransportProtocol.tcp diff --git a/pcapkit/const/reg/apptype/udp.py b/pcapkit/const/reg/apptype/udp.py index f9af03e60..22a910d4c 100644 --- a/pcapkit/const/reg/apptype/udp.py +++ b/pcapkit/const/reg/apptype/udp.py @@ -142,9 +142,9 @@ class UDP(AppType): "_dots-call-home._tcp" for discovering Call Home DOTS clients used to establish DOTS signal channel call home. [:rfc:`8973`]; [UDP] DOTS Signal Channel Call Home Protocol. The service name is used to - construct the SRV service names "_dots-call-home._udp" and "_dots- - call-home._tcp" for discovering Call Home DOTS clients used to - establish DOTS signal channel call home. [:rfc:`8973`] + construct the SRV service names "_dots-call-home._udp" and + "_dots-call-home._tcp" for discovering Call Home DOTS clients used + to establish DOTS signal channel call home. [:rfc:`8973`] * - ``eb-sync`` - [TCP] Easy Books App data sync helper for Mac OS X and iOS; [UDP] Easy Books App data sync helper for Mac OS X and iOS @@ -23911,19 +23911,19 @@ class UDP(AppType): #: - [UDP] GameSmith Port gamesmith_port = 31765, 'gamesmith-port', TransportProtocol.udp - #: - [TCP] Embedded Device Configuration Protocol TX IANA assigned this well- - #: formed service name as a replacement for "iceedcp_tx". + #: - [TCP] Embedded Device Configuration Protocol TX IANA assigned this + #: well-formed service name as a replacement for "iceedcp_tx". #: - [TCP] Embedded Device Configuration Protocol TX - #: - [UDP] Embedded Device Configuration Protocol TX IANA assigned this well- - #: formed service name as a replacement for "iceedcp_tx". + #: - [UDP] Embedded Device Configuration Protocol TX IANA assigned this + #: well-formed service name as a replacement for "iceedcp_tx". #: - [UDP] Embedded Device Configuration Protocol TX iceedcp_tx = 31948, 'iceedcp-tx', TransportProtocol.udp - #: - [TCP] Embedded Device Configuration Protocol RX IANA assigned this well- - #: formed service name as a replacement for "iceedcp_rx". + #: - [TCP] Embedded Device Configuration Protocol RX IANA assigned this + #: well-formed service name as a replacement for "iceedcp_rx". #: - [TCP] Embedded Device Configuration Protocol RX - #: - [UDP] Embedded Device Configuration Protocol RX IANA assigned this well- - #: formed service name as a replacement for "iceedcp_rx". + #: - [UDP] Embedded Device Configuration Protocol RX IANA assigned this + #: well-formed service name as a replacement for "iceedcp_rx". #: - [UDP] Embedded Device Configuration Protocol RX iceedcp_rx = 31949, 'iceedcp-rx', TransportProtocol.udp diff --git a/pcapkit/const/reg/linktype.py b/pcapkit/const/reg/linktype.py index 7cb663bea..a1c92e4e1 100644 --- a/pcapkit/const/reg/linktype.py +++ b/pcapkit/const/reg/linktype.py @@ -527,12 +527,12 @@ class LinkType(EnumRegistry, IntEnum): #: [``DLT_NFLOG``] Linux netlink NETLINK NFLOG socket log messages. NFLOG = 239 - #: [``DLT_NETANALYZER``] Ethernet frames with Hilscher netANALYZER pseudo- - #: header. + #: [``DLT_NETANALYZER``] Ethernet frames with Hilscher netANALYZER + #: pseudo-header. NETANALYZER = 240 - #: [``DLT_NETANALYZER_TRANSPARENT``] Ethernet frames with netANALYZER pseudo- - #: header, preamble and SFD, preceded by a Hilscher. + #: [``DLT_NETANALYZER_TRANSPARENT``] Ethernet frames with netANALYZER + #: pseudo-header, preamble and SFD, preceded by a Hilscher. NETANALYZER_TRANSPARENT = 241 #: [``DLT_IPOIB``] IP-over-InfiniBand. @@ -544,8 +544,8 @@ class LinkType(EnumRegistry, IntEnum): #: [``DLT_NG40``] Frames from ng4T GmbH's ng40 protocol tester. NG40 = 244 - #: [``DLT_NFC_LLCP``] NFC Logical Link Control Protocol frames, with a pseudo- - #: header. + #: [``DLT_NFC_LLCP``] NFC Logical Link Control Protocol frames, with a + #: pseudo-header. NFC_LLCP = 245 #: Packet filter state syncing. @@ -644,8 +644,8 @@ class LinkType(EnumRegistry, IntEnum): #: for Bluetooth LE packets. NORDIC_BLE = 272 - #: [``DLT_DOCSIS31_XRA31``] DOCSIS packets and bursts, preceded by a pseudo- - #: header giving metadata about the packet. + #: [``DLT_DOCSIS31_XRA31``] DOCSIS packets and bursts, preceded by a + #: pseudo-header giving metadata about the packet. DOCSIS31_XRA31 = 273 #: [``DLT_ETHERNET_MPACKET``] IEEE 802.3 mPackets. diff --git a/pcapkit/const/reg/transtype.py b/pcapkit/const/reg/transtype.py index e1924c1fe..76e9af4ab 100644 --- a/pcapkit/const/reg/transtype.py +++ b/pcapkit/const/reg/transtype.py @@ -414,8 +414,8 @@ class TransType(EnumRegistry, IntEnum): #: Simple Message Protocol [Leif Ekblad] SMP = 121 - #: Simple Multicast Protocol (deprecated)) [Jon Crowcroft][draft-perlman- - #: simple-multicast-03] + #: Simple Multicast Protocol (deprecated)) [Jon + #: Crowcroft][draft-perlman-simple-multicast-03] SM = 122 #: Performance Transparency Protocol [Michael Welzl] diff --git a/pcapkit/const/sctp/chunk.py b/pcapkit/const/sctp/chunk.py index 4671078e8..a417b0621 100644 --- a/pcapkit/const/sctp/chunk.py +++ b/pcapkit/const/sctp/chunk.py @@ -80,8 +80,8 @@ class Chunk(EnumRegistry, IntEnum): #: Payload Data supporting Interleaving (I-DATA) [:rfc:`8260`] Payload_Data_supporting_Interleaving = 64 - #: DTLS (TEMPORARY - registered 2026-02-20, expires 2027-02-20) [draft-ietf- - #: tsvwg-sctp-dtls-chunk-01] + #: DTLS (TEMPORARY - registered 2026-02-20, expires 2027-02-20) + #: [draft-ietf-tsvwg-sctp-dtls-chunk-01] DTLS = 65 #: Reserved for IETF-defined Chunk Extensions [:rfc:`9260`] diff --git a/pcapkit/const/sctp/payload_protocol_identifier.py b/pcapkit/const/sctp/payload_protocol_identifier.py index ce1c95f99..e886da9e4 100644 --- a/pcapkit/const/sctp/payload_protocol_identifier.py +++ b/pcapkit/const/sctp/payload_protocol_identifier.py @@ -79,8 +79,8 @@ class PayloadProtocolIdentifier(EnumRegistry, IntEnum): #: published.] Q_IPC_Q_2150_3 = 14 - #: SIMCO [draft-kiesel-midcom-simco- - #: sctp-00][Sebastian Kiesel] + #: SIMCO + #: [draft-kiesel-midcom-simco-sctp-00][Sebastian Kiesel] SIMCO_draft_kiesel_midcom_simco_sctp_00_txt = 15 #: DDP Segment Chunk [:rfc:`5043`] @@ -241,7 +241,7 @@ class PayloadProtocolIdentifier(EnumRegistry, IntEnum): #: 3GPP E1 Application Protocol (E1AP) [ 3GPP TS 38.463][Yang Xudong] PayloadProtocolIdentifier_3GPP_E1_Application_Protocol = 64 - #: ELE2 Lawful Interception [http://ele2.io][Damir Franusic] + #: ELE2 Lawful Interception [http://ele2.io] [Damir Franusic] ELE2_Lawful_Interception = 65 #: 3GPP NGAP over DTLS over SCTP [ 3GPP TS 38.413][Yang Xudong] @@ -268,8 +268,8 @@ class PayloadProtocolIdentifier(EnumRegistry, IntEnum): #: 3GPP W1AP [3GPP TS 37.473][Lionel Morand] PayloadProtocolIdentifier_3GPP_W1AP = 73 - #: DTLS Chunk Key-Management Messages [draft-westerlund-tsvwg-sctp-dtls- - #: chunk-01] + #: DTLS Chunk Key-Management Messages + #: [draft-westerlund-tsvwg-sctp-dtls-chunk-01] DTLS_Chunk_Key_Management_Messages = 4242 @classmethod diff --git a/pcapkit/vendor/default.py b/pcapkit/vendor/default.py index 7f8d609dd..5c5b3d64b 100644 --- a/pcapkit/vendor/default.py +++ b/pcapkit/vendor/default.py @@ -275,6 +275,25 @@ class Vendor(metaclass=VendorMeta): def wrap_comment(text: 'str') -> 'str': """Wraps long-length text to shorter lines of comments. + Sphinx renders a ``#:`` comment as reST, and its continuation lines + join into one paragraph, so the text is made safe for that first: + + * a space goes after a URL reference that another bracket follows + directly, as in ``[http://ele2.io][Damir Franusic]``. docutils reads + ``]`` and ``[`` as URL characters, so the autolink would run on into + the next citation; + * a leading enumerator, such as the ``(S)`` of ``(S) flag``, is + backslash-escaped, since reST would render it as an enumerated list. + + A line never breaks at a hyphen, which would render ``run-time`` as + ``run- time``. Only a word longer than a whole line is split inside. + + .. versionchanged:: 1.5.0 + + Spaces a URL reference off the bracket after it and escapes a + leading enumerator (:issue:`1846`), and no longer breaks a line at a + hyphen (:issue:`1847`). + Args: text: Source text. @@ -282,7 +301,12 @@ def wrap_comment(text: 'str') -> 'str': Wrapped comments. """ - return '\n #: '.join(textwrap.wrap(text.strip(), 76)) + text = re.sub(r'(://[^\s\[\]]*\])(?=\[)', r'\1 ', text.strip()) + # NOTE: the enumerator forms docutils recognises -- ``1.``, ``(a)``, + # ``iv)``, ``#.`` and so on -- each followed by a space or nothing. + text = re.sub(r'\A(?=(?:\((?:[0-9]+|[a-zA-Z]|[ivxlcdm]+|[IVXLCDM]+|#)\)' + r'|(?:[0-9]+|[a-zA-Z]|[ivxlcdm]+|[IVXLCDM]+|#)[.)])(?: |\Z))', r'\\', text) + return '\n #: '.join(textwrap.wrap(text, 76, break_on_hyphens=False)) @staticmethod def split_references(text: 'str') -> 'list[str]': diff --git a/pcapkit/vendor/reg/apptype/apptype.py b/pcapkit/vendor/reg/apptype/apptype.py index 99110fd06..22d5e2fd3 100644 --- a/pcapkit/vendor/reg/apptype/apptype.py +++ b/pcapkit/vendor/reg/apptype/apptype.py @@ -1099,6 +1099,17 @@ def count(self, data: 'list[str]') -> 'Counter[str]': def wrap_comment(text: 'str') -> 'str': """Wraps long-length text to shorter lines of comments. + The lines are :meth:`Vendor.wrap_comment`'s, so the text is made safe + for reST and never broken at a hyphen, the same way. Each continuation + takes a hanging indent instead, which keeps it inside the bullet a + merged entry renders it in. + + .. versionchanged:: 1.5.0 + + Takes its lines from :meth:`Vendor.wrap_comment` rather than + wrapping on its own, so it no longer breaks a line at a hyphen + (:issue:`1847`) and escapes what that escapes (:issue:`1846`). + Args: text: Source text. @@ -1106,7 +1117,9 @@ def wrap_comment(text: 'str') -> 'str': Wrapped comments. """ - return '\n #: '.join(textwrap.wrap(text.strip(), 76)) + # NOTE: textwrap turns every whitespace character into a space, so the + # only newlines in the base's output are the ones it joins lines with. + return Vendor.wrap_comment(text).replace('\n #: ', '\n #: ') @staticmethod def flag(protos: 'Iterable[str]') -> 'str': @@ -1135,6 +1148,14 @@ def tabulate(title: 'str', widths: 'tuple[int, ...]', header: 'tuple[str, ...]', :mod:`sphinx.ext.autodoc` renders in place rather than as comments it drops. + A cell breaks only at whitespace, never at a hyphen or inside a word: + its continuation lines join it with a space, so a URL split at a hyphen + would render as two words and lose the rest of its link. + + .. versionchanged:: 1.5.0 + + No longer breaks a cell at a hyphen (:issue:`1847`). + Args: title: Table caption. widths: Relative column widths. @@ -1156,7 +1177,8 @@ def tabulate(title: 'str', widths: 'tuple[int, ...]', header: 'tuple[str, ...]', ] for row in [header, *rows]: for index, cell in enumerate(row): - wrapped = textwrap.wrap(cell, 68, break_long_words=False) or ['N/A'] + wrapped = textwrap.wrap(cell, 68, break_long_words=False, + break_on_hyphens=False) or ['N/A'] bullet = '* - ' if not index else ' - ' lines.append(f' {bullet}{wrapped[0]}') lines.extend(f' {part}' for part in wrapped[1:]) diff --git a/tests/_dependency_gates.py b/tests/_dependency_gates.py index 67e6c38ff..dc7fef98c 100644 --- a/tests/_dependency_gates.py +++ b/tests/_dependency_gates.py @@ -235,6 +235,16 @@ # extra carries either. 'sphinx': ('Sphinx',), 'sphinx_autodoc_typehints': ('sphinx-autodoc-typehints',), + # The reST parser Sphinx renders the const comments with, which the + # ``HAS_DOCUTILS`` gate on #1846's rendering check asks for. No extra + # declares it: Sphinx requires it, so the ``docs`` extra is what installs it. + # The bare name is listed as well, as with ``html5lib`` above, and the day an + # extra declares docutils itself this entry has to change: the bare name goes + # live alongside Sphinx, :func:`contested_imports` reports docutils, and the + # :data:`MUTUALLY_EXCLUSIVE_IMPORTS` liveness test fails until the entry is + # cut to ``('docutils',)``. If the ``test`` and ``gate`` jobs install that + # extra, its gap closes and the ``HAS_DOCUTILS`` exclusion has to go as well. + 'docutils': ('Sphinx', 'docutils'), } #: Flags whose condition is not a distribution, with what it is instead. A flag @@ -637,6 +647,25 @@ class Exclusion(NamedTuple): 'directive-name test in the same module needs nothing and runs on every leg.' ), ), + 'HAS_DOCUTILS': Exclusion( + dark={'test': ('docutils',), 'gate': ('docutils',)}, + reason=( + "The method it gates (#1846) parses Vendor.wrap_comment's output with " + 'docutils and asserts it is nothing but a plain paragraph, so it needs ' + 'docutils, which no pytest job installs. No workflow installs the docs extra; ' + 'deploy-pages.yml gets Sphinx, and with it docutils, from ' + 'docs/requirements.txt, and it builds the documentation rather than running ' + 'pytest. Installing docutils on these jobs would not run it anyway: it sits in ' + 'a class gated on ' + 'HAS_VENDOR_DEPS, which these same jobs leave dark by declining html5lib ' + "(#738, above), so closing this gap would also mean the vendor extra on a " + 'blocking leg. The cost argument of HAS_SPHINX applies too. What runs on ' + 'every leg instead is the ungated committed-const class in the same module, ' + 'which pins the re-rendered lines themselves; the string assertions on the ' + 'wrapper run wherever the vendor extra is installed, and the docutils parse ' + 'wherever docutils is importable as well.' + ), + ), } diff --git a/tests/vendor/test_vendor_wrap_comment_unit.py b/tests/vendor/test_vendor_wrap_comment_unit.py new file mode 100644 index 000000000..d419bf871 --- /dev/null +++ b/tests/vendor/test_vendor_wrap_comment_unit.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +"""The crawlers' comment wrappers emit text that renders as written. + +Sphinx renders each ``#:`` comment in :mod:`pcapkit.const` as a reST paragraph, +with its continuation lines joined by a space, and an AppType ``list-table`` cell +the same way. + +* GitHub issue #1846: ``[http://ele2.io][Damir Franusic]`` autolinked + ``http://ele2.io][Damir``, since docutils reads ``]`` and ``[`` as URL + characters, and ``(S) flag`` rendered as an enumerated list. + :meth:`~pcapkit.vendor.default.Vendor.wrap_comment` now spaces a URL reference + off the bracket after it and backslash-escapes a leading enumerator. +* GitHub issue #1847: :mod:`textwrap` broke lines at hyphens, so the docs read + ``run- time``, and a URL split that way in a cell lost the rest of its + autolink. Neither wrapper breaks at a hyphen now. + +The committed :mod:`pcapkit.const` files were re-rendered offline with the +patched wrappers, so the last cases check the tree as well as the generators. +Nothing here makes a network call. + +""" +from __future__ import annotations + +import importlib.util +import pathlib +import re +import textwrap +import unittest + +from tests._support import reimport_once_per_class + +#: Repository root, i.e. the grandparent of the directory holding this file. +ROOT = pathlib.Path(__file__).resolve().parents[2] + +#: Every distribution importing :mod:`pcapkit.vendor` needs -- see +#: :mod:`tests.vendor.test_ipx_packet_unit` for why all three are checked. +VENDOR_DEPS = ('requests', 'bs4', 'html5lib') + +#: Whether the crawlers are importable at all. +HAS_VENDOR_DEPS = all(importlib.util.find_spec(name) is not None for name in VENDOR_DEPS) + +#: Whether docutils, which Sphinx renders the comments with, is installed. +HAS_DOCUTILS = importlib.util.find_spec('docutils') is not None + +#: ``pcapkit/const/http/status_code.py``'s ``CODE_104`` text, whose draft name +#: sits across the end of the second line. +RESUMABLE = ('Upload Resumption Supported (TEMPORARY - registered 2024-11-13, extension ' + 'registered 2025-09-15, expires 2026-11-13) [draft-ietf-httpbis-resumable-upload-05]') + +#: ``pcapkit/const/reg/apptype/tcp.py``'s ``rads`` text. +RADS = ('[TCP] Remote Administration Daemon (RAD) is a system service that offers secure, ' + 'remote, programmatic access to Solaris system configuration and run-time state') + +#: ``pcapkit/const/reg/apptype/tcp.py``'s ``boardgame`` cell. +BOARDGAME = ('[TCP] Two-player board game play ' + '[https://github.com/chentianren0/MiniXiangqi/blob/main/docs/boardgame-protocol.md]') + +#: textwrap's own test for a break after a hyphen: two letters before it, and a +#: letter, an optional hyphen and a letter after it. +HYPHEN_BREAK = re.compile(r'(?:[^\d\W]{2}|[^\d\W]-[^\d\W])-\n *#:(?: - | +)?[^\d\W]-?[^\d\W]') + + +def _lines(wrapped: 'str') -> 'list[str]': + """The text of each line of a wrapped comment, without its ``#:`` prefix.""" + return [re.sub(r'^ *#: *', '', line) for line in wrapped.split('\n')] + + +@unittest.skipUnless(HAS_VENDOR_DEPS, f'vendor extra not installed ({", ".join(VENDOR_DEPS)})') +class WrapCommentTests(unittest.TestCase): + """:meth:`Vendor.wrap_comment` and AppType's wrappers.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + import pcapkit.vendor.default as default + + resolved = pathlib.Path(default.__file__).resolve() + if ROOT not in resolved.parents: + self.skipTest(f'{default.__name__} was imported from {resolved}, which is ' + f'outside {ROOT}; install this checkout with `pip install -e .`') + + def test_url_reference_is_spaced_off_the_next_bracket(self) -> None: + from pcapkit.vendor.default import Vendor + + self.assertEqual(Vendor.wrap_comment('ELE2 Lawful Interception [http://ele2.io][Damir Franusic]'), + 'ELE2 Lawful Interception [http://ele2.io] [Damir Franusic]') + self.assertEqual(Vendor.wrap_comment('X [see https://example.com/a-b][RFC 1]'), + 'X [see https://example.com/a-b] [RFC 1]') + # Narrow: a bracket that holds no URL keeps its neighbour, and so does a + # URL reference with no bracket after it. + for text in ('REQUEST [:rfc:`826`][:rfc:`5227`]', + 'ICTP [TR-352][Inter-Channel-Termination protocol]', + 'NAME [http://ele2.io], [Damir Franusic]', + 'NAME [http://ele2.io]'): + with self.subTest(text=text): + self.assertEqual(Vendor.wrap_comment(text), text) + + def test_leading_enumerator_is_escaped(self) -> None: + from pcapkit.vendor.default import Vendor + + self.assertEqual(Vendor.wrap_comment('(S) flag [:rfc:`5844`]'), r'\(S) flag [:rfc:`5844`]') + for text, expected in (('1. one', r'\1. one'), ('a) one', r'\a) one'), + ('(iv) one', r'\(iv) one'), ('#. one', r'\#. one'), ('(S)', r'\(S)')): + with self.subTest(text=text): + self.assertEqual(Vendor.wrap_comment(text), expected) + # Only an enumerator, and only at the start of the text. + for text in ('(S)flag', '(SS) flag', 'S flag', 'the (S) flag', 'Unassigned', '3GPP TS 38.463'): + with self.subTest(text=text): + self.assertEqual(Vendor.wrap_comment(text), text) + + def test_no_line_is_broken_at_a_hyphen(self) -> None: + from pcapkit.vendor.default import Vendor + + # The width is unchanged, and the old wrap did split this one there. + self.assertIn('[draft-ietf-httpbis-resumable-', textwrap.wrap(RESUMABLE, 76)[1]) + + lines = _lines(Vendor.wrap_comment(RESUMABLE)) + self.assertEqual(lines, [ + 'Upload Resumption Supported (TEMPORARY - registered 2024-11-13, extension', + 'registered 2025-09-15, expires 2026-11-13)', + '[draft-ietf-httpbis-resumable-upload-05]', + ]) + self.assertTrue(all(len(line) <= 76 for line in lines)) + + def test_apptype_comment_is_broken_only_at_whitespace(self) -> None: + from pcapkit.vendor.reg.apptype.apptype import AppType + + self.assertEqual(AppType.wrap_comment(RADS), ( + '[TCP] Remote Administration Daemon (RAD) is a system service that offers\n' + ' #: secure, remote, programmatic access to Solaris system configuration and\n' + ' #: run-time state')) + # It escapes what the base escapes. + self.assertEqual(AppType.wrap_comment('[TCP] X [http://a.example][B]'), + '[TCP] X [http://a.example] [B]') + + def test_apptype_cell_keeps_a_hyphenated_url_whole(self) -> None: + from pcapkit.vendor.reg.apptype.apptype import AppType + + table = AppType.tabulate('Title', (30, 70), ('Service Name', 'Description'), + [('``boardgame``', BOARDGAME)]) + # A continuation is indented by the docstring's four spaces and the + # cell's seven. + self.assertIn('\n' + ' ' * 11 + + '[https://github.com/chentianren0/MiniXiangqi/blob/main/docs/boardgame-protocol.md]\n', + table + '\n') + self.assertNotRegex(table, r'-\n') + + @unittest.skipUnless(HAS_DOCUTILS, 'docutils not installed') + def test_fixed_comments_render_as_plain_paragraphs(self) -> None: + from docutils import nodes + from docutils.core import publish_doctree + + from pcapkit.vendor.default import Vendor + + settings = {'report_level': 5, 'halt_level': 5} + tree = publish_doctree(Vendor.wrap_comment('(S) flag [:rfc:`5844`]'), settings_overrides=settings) + self.assertEqual([type(node) for node in tree.children], [nodes.paragraph]) + self.assertTrue(tree.astext().startswith('(S) flag')) + + tree = publish_doctree(Vendor.wrap_comment('ELE2 Lawful Interception [http://ele2.io][Damir Franusic]'), + settings_overrides=settings) + self.assertEqual([type(node) for node in tree.children], [nodes.paragraph]) + self.assertEqual([node['refuri'] for node in tree.findall(nodes.reference)], ['http://ele2.io']) + + +class CommittedConstTests(unittest.TestCase): + """The committed :mod:`pcapkit.const` files carry the re-rendered text.""" + + def _read(self, *parts: 'str') -> 'str': + return ROOT.joinpath('pcapkit', 'const', *parts).read_text(encoding='utf-8') + + def test_issue_1846_lines(self) -> None: + self.assertIn(' #: \\(S) flag [:rfc:`5844`]\n S_flag = 0x1\n', + self._read('mh', 'dhcp_support_mode.py')) + self.assertIn(' #: ELE2 Lawful Interception [http://ele2.io] [Damir Franusic]\n', + self._read('sctp', 'payload_protocol_identifier.py')) + + def test_issue_1847_lines(self) -> None: + self.assertIn(' #: [draft-ietf-httpbis-resumable-upload-05]\n', self._read('http', 'status_code.py')) + tcp = self._read('reg', 'apptype', 'tcp.py') + self.assertIn(' #: run-time state\n', tcp) + self.assertIn(' #: (NG-PON2) system', tcp) + self.assertIn(' [https://github.com/chentianren0/MiniXiangqi/blob/main/docs/boardgame-protocol.md]\n', + tcp) + + def test_no_comment_or_cell_is_broken_at_a_hyphen(self) -> None: + cell = re.compile(r'(?:[^\d\W]{2}|[^\d\W]-[^\d\W])-\n {11}[^\d\W]-?[^\d\W]') + for path in sorted(ROOT.joinpath('pcapkit', 'const').rglob('*.py')): + source = path.read_text(encoding='utf-8') + with self.subTest(path=str(path.relative_to(ROOT))): + self.assertIsNone(HYPHEN_BREAK.search(source)) + if path.parent.name == 'apptype': + self.assertIsNone(cell.search(source)) + + +if __name__ == '__main__': + unittest.main()