Skip to content

Commit 1c84d97

Browse files
authored
feat: port utils from go-multiaddr-dns (#102)
* feat: port utils from go-multiaddr-dns * fix: ci * Address PR #102 review feedback - Add newsfragment for issue #101 - Guard offset_addr() against negative offsets with ValueError - Expand docstrings for all 6 public utility functions - Add resolver utilities example script and docs section * fix: indentation
1 parent 44426b6 commit 1c84d97

9 files changed

Lines changed: 710 additions & 10 deletions

File tree

docs/examples.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,21 @@ This example shows:
117117
:language: python
118118
:caption: examples/tag_only/tag_only_examples.py
119119

120+
Resolver Utility Examples
121+
--------------------------
122+
123+
The `examples/resolver_utils/` directory demonstrates the utility functions ported from go-multiaddr-dns for working with DNS-based multiaddr resolution.
124+
125+
This example shows:
126+
- Checking for DNS components with ``matches()``
127+
- FQDN detection and normalization with ``is_fqdn()`` and ``fqdn()``
128+
- Counting protocol components with ``addr_len()``
129+
- Removing leading components with ``offset_addr()``
130+
131+
.. literalinclude:: ../examples/resolver_utils/resolver_utils_example.py
132+
:language: python
133+
:caption: examples/resolver_utils/resolver_utils_example.py
134+
120135
Running the Examples
121136
--------------------
122137

@@ -145,4 +160,7 @@ All examples can be run directly with Python:
145160
# Tag-only protocol examples
146161
python examples/tag_only/tag_only_examples.py
147162
163+
# Resolver utility examples
164+
python examples/resolver_utils/resolver_utils_example.py
165+
148166
Note: Some examples require network connectivity and may take a few seconds to complete due to DNS resolution.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Resolver utility function examples for py-multiaddr.
4+
5+
This script demonstrates the utility functions available in
6+
``multiaddr.resolvers`` that were ported from go-multiaddr-dns.
7+
8+
## Overview
9+
10+
1. **matches()**: Check if a multiaddr contains DNS components
11+
2. **is_fqdn() / fqdn()**: FQDN detection and normalization
12+
3. **addr_len()**: Count protocol components in a multiaddr
13+
4. **offset_addr()**: Remove leading protocol components
14+
5. **resolve_all()**: Iteratively resolve all DNS components
15+
16+
## Expected Output
17+
18+
When you run this script, you should see output similar to:
19+
20+
```
21+
Resolver Utility Examples
22+
==================================================
23+
24+
=== matches() ===
25+
/dns4/example.com/tcp/80 -> True (contains dns4)
26+
/ip4/127.0.0.1/tcp/80 -> False (no DNS component)
27+
/dnsaddr/bootstrap.libp2p.io -> True (contains dnsaddr)
28+
29+
=== is_fqdn() / fqdn() ===
30+
is_fqdn("example.com") -> False
31+
is_fqdn("example.com.") -> True
32+
fqdn("example.com") -> example.com.
33+
fqdn("example.com.") -> example.com.
34+
35+
=== addr_len() ===
36+
/ip4/127.0.0.1 -> 1 component(s)
37+
/ip4/127.0.0.1/tcp/80 -> 2 component(s)
38+
/ip4/1.2.3.4/udp/9/quic -> 3 component(s)
39+
40+
=== offset_addr() ===
41+
offset_addr(/ip4/127.0.0.1/tcp/80/http, 0) -> /ip4/127.0.0.1/tcp/80/http
42+
offset_addr(/ip4/127.0.0.1/tcp/80/http, 1) -> /tcp/80/http
43+
offset_addr(/ip4/127.0.0.1/tcp/80/http, 2) -> /http
44+
offset_addr(/ip4/127.0.0.1/tcp/80/http, 3) -> /
45+
```
46+
"""
47+
48+
from multiaddr import Multiaddr
49+
from multiaddr.resolvers import addr_len, fqdn, is_fqdn, matches, offset_addr
50+
51+
52+
def main():
53+
print("Resolver Utility Examples")
54+
print("=" * 50)
55+
56+
# --- matches() ---
57+
print("\n=== matches() ===")
58+
examples = [
59+
("/dns4/example.com/tcp/80", "contains dns4"),
60+
("/ip4/127.0.0.1/tcp/80", "no DNS component"),
61+
("/dnsaddr/bootstrap.libp2p.io", "contains dnsaddr"),
62+
]
63+
for addr_str, desc in examples:
64+
ma = Multiaddr(addr_str)
65+
result = matches(ma)
66+
print(f" {addr_str:<35} -> {result!s:<6} ({desc})")
67+
68+
# --- is_fqdn() / fqdn() ---
69+
print("\n=== is_fqdn() / fqdn() ===")
70+
for domain in ("example.com", "example.com."):
71+
print(f' is_fqdn("{domain}") -> {is_fqdn(domain)}')
72+
for domain in ("example.com", "example.com."):
73+
print(f' fqdn("{domain}") -> {fqdn(domain)}')
74+
75+
# --- addr_len() ---
76+
print("\n=== addr_len() ===")
77+
for addr_str in ("/ip4/127.0.0.1", "/ip4/127.0.0.1/tcp/80", "/ip4/1.2.3.4/udp/9/quic"):
78+
ma = Multiaddr(addr_str)
79+
print(f" {addr_str:<30} -> {addr_len(ma)} component(s)")
80+
81+
# --- offset_addr() ---
82+
print("\n=== offset_addr() ===")
83+
ma = Multiaddr("/ip4/127.0.0.1/tcp/80/http")
84+
for n in range(4):
85+
result = offset_addr(ma, n)
86+
print(f" offset_addr({ma}, {n}) -> {result}")
87+
88+
89+
if __name__ == "__main__":
90+
main()

multiaddr/codecs/certhash.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any
1+
from typing import Any, cast
22

33
import multibase
44
import multihash
@@ -54,7 +54,7 @@ def to_bytes(self, proto: Any, string: str) -> bytes:
5454
"""
5555
try:
5656
# Decode the multibase string to get the raw multihash bytes.
57-
decoded_bytes = multibase.decode(string)
57+
decoded_bytes = cast(bytes, multibase.decode(string))
5858
except Exception as e:
5959
raise ValueError(f"Failed to decode multibase string: {string}") from e
6060

multiaddr/resolvers/__init__.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
"""DNS resolution support for multiaddr."""
22

33
from .base import Resolver
4-
from .dns import DNSResolver
4+
from .dns import DNSADDR_TXT_PREFIX, DNSResolver
5+
from .util import addr_len, fqdn, is_fqdn, matches, offset_addr, resolve_all
56

6-
__all__ = ["DNSResolver", "Resolver"]
7+
__all__ = [
8+
"DNSADDR_TXT_PREFIX",
9+
"DNSResolver",
10+
"Resolver",
11+
"addr_len",
12+
"fqdn",
13+
"is_fqdn",
14+
"matches",
15+
"offset_addr",
16+
"resolve_all",
17+
]

multiaddr/resolvers/dns.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
from ..protocols import P_DNS, P_DNS4, P_DNS6, P_DNSADDR, Protocol
3737
from .base import Resolver
3838

39+
DNSADDR_TXT_PREFIX = "dnsaddr="
40+
"""Prefix for dnsaddr TXT records (e.g. ``dnsaddr=/ip4/...``)."""
41+
3942

4043
class DNSResolver(Resolver):
4144
"""
@@ -226,8 +229,8 @@ async def _query_dnsaddr_txt_records(
226229
else:
227230
txt_data = str(txt_data_raw)
228231
logging.debug(f"{indent} TXT: {txt_data}")
229-
if txt_data.startswith("dnsaddr="):
230-
multiaddr_str = txt_data[8:]
232+
if txt_data.startswith(DNSADDR_TXT_PREFIX):
233+
multiaddr_str = txt_data[len(DNSADDR_TXT_PREFIX) :]
231234
multiaddr_str = self._clean_quotes(multiaddr_str).strip()
232235
logging.debug(f"{indent} Parsed multiaddr: {multiaddr_str}")
233236
if not multiaddr_str:
@@ -405,4 +408,4 @@ async def _resolve_dns_with_stack(
405408
return results
406409

407410

408-
__all__ = ["DNSResolver"]
411+
__all__ = ["DNSADDR_TXT_PREFIX", "DNSResolver"]

multiaddr/resolvers/util.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Utility functions for multiaddr resolution."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from ..exceptions import RecursionLimitError
8+
from ..multiaddr import Multiaddr
9+
from ..protocols import P_DNS, P_DNS4, P_DNS6, P_DNSADDR
10+
11+
if TYPE_CHECKING:
12+
from .base import Resolver
13+
14+
__all__ = ["addr_len", "fqdn", "is_fqdn", "matches", "offset_addr", "resolve_all"]
15+
16+
_DNS_PROTOCOLS = frozenset({P_DNS, P_DNS4, P_DNS6, P_DNSADDR})
17+
18+
19+
def is_fqdn(s: str) -> bool:
20+
"""Check if string is a fully qualified domain name (ends with unescaped dot).
21+
22+
Args:
23+
s: The domain name string to check.
24+
25+
Returns:
26+
``True`` if *s* ends with an unescaped ``'.'``, ``False`` otherwise.
27+
"""
28+
if not s:
29+
return False
30+
# Count trailing backslashes before the final character
31+
if s[-1] != ".":
32+
return False
33+
# Check if the trailing dot is escaped
34+
num_backslashes = 0
35+
for i in range(len(s) - 2, -1, -1):
36+
if s[i] == "\\":
37+
num_backslashes += 1
38+
else:
39+
break
40+
# Odd number of backslashes means the dot is escaped
41+
return num_backslashes % 2 == 0
42+
43+
44+
def fqdn(s: str) -> str:
45+
"""Append a trailing dot to *s* if it is not already a FQDN.
46+
47+
Args:
48+
s: The domain name string.
49+
50+
Returns:
51+
The domain name with a trailing ``'.'`` appended if needed.
52+
53+
Example::
54+
55+
>>> fqdn("example.com")
56+
'example.com.'
57+
>>> fqdn("example.com.")
58+
'example.com.'
59+
"""
60+
if is_fqdn(s):
61+
return s
62+
return s + "."
63+
64+
65+
def addr_len(maddr: Multiaddr) -> int:
66+
"""Count the number of protocol components in a multiaddr.
67+
68+
Args:
69+
maddr: The multiaddr to measure.
70+
71+
Returns:
72+
The number of protocol components.
73+
"""
74+
return len(list(maddr.protocols()))
75+
76+
77+
def offset_addr(maddr: Multiaddr, n: int) -> Multiaddr:
78+
"""Return a new multiaddr with the first *n* protocol components removed.
79+
80+
Args:
81+
maddr: The source multiaddr.
82+
n: Number of leading components to skip. Must be >= 0.
83+
84+
Returns:
85+
A new :class:`~multiaddr.Multiaddr` without the first *n* components,
86+
or ``Multiaddr("/")`` if *n* >= the total number of components.
87+
88+
Raises:
89+
ValueError: If *n* is negative.
90+
"""
91+
if n < 0:
92+
raise ValueError(f"offset must be non-negative, got {n}")
93+
parts = maddr.split(n)
94+
if len(parts) <= n:
95+
return Multiaddr("/")
96+
return parts[n]
97+
98+
99+
def matches(maddr: Multiaddr) -> bool:
100+
"""Check if a multiaddr contains any DNS protocol component.
101+
102+
Args:
103+
maddr: The multiaddr to inspect.
104+
105+
Returns:
106+
``True`` if *maddr* contains a ``dns``, ``dns4``, ``dns6``, or
107+
``dnsaddr`` component.
108+
109+
Example::
110+
111+
>>> matches(Multiaddr("/dns4/example.com/tcp/80"))
112+
True
113+
>>> matches(Multiaddr("/ip4/127.0.0.1/tcp/80"))
114+
False
115+
"""
116+
return any(p.code in _DNS_PROTOCOLS for p in maddr.protocols())
117+
118+
119+
async def resolve_all(
120+
resolver: Resolver,
121+
maddr: Multiaddr,
122+
*,
123+
max_iterations: int = 32,
124+
) -> list[Multiaddr]:
125+
"""Resolve all DNS components in a multiaddr iteratively.
126+
127+
Calls ``resolver.resolve()`` in a loop until every returned address is
128+
free of DNS components.
129+
130+
Args:
131+
resolver: A resolver instance implementing an async ``resolve()`` method.
132+
maddr: The multiaddr to resolve.
133+
max_iterations: Safety limit on resolution rounds to prevent infinite
134+
loops (default ``32``).
135+
136+
Returns:
137+
A list of fully-resolved :class:`~multiaddr.Multiaddr` instances.
138+
139+
Raises:
140+
RecursionLimitError: If DNS components remain after *max_iterations*
141+
rounds.
142+
143+
Example::
144+
145+
>>> import trio
146+
>>> result = trio.run(resolve_all, my_resolver, Multiaddr("/dns4/example.com/tcp/80"))
147+
"""
148+
queue = [maddr]
149+
resolved: list[Multiaddr] = []
150+
151+
for _ in range(max_iterations):
152+
if not queue:
153+
break
154+
next_queue: list[Multiaddr] = []
155+
for addr in queue:
156+
if not matches(addr):
157+
resolved.append(addr)
158+
continue
159+
results = await resolver.resolve(addr)
160+
for r in results:
161+
if matches(r):
162+
next_queue.append(r)
163+
else:
164+
resolved.append(r)
165+
queue = next_queue
166+
167+
if queue:
168+
raise RecursionLimitError(
169+
f"resolve_all exceeded {max_iterations} iterations; "
170+
f"{len(queue)} addresses still contain DNS components"
171+
)
172+
173+
return resolved

newsfragments/101.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add resolver utility functions ported from go-multiaddr-dns: ``matches()``, ``resolve_all()``, ``is_fqdn()``, ``fqdn()``, ``addr_len()``, and ``offset_addr()``. These utilities support DNS-based multiaddr resolution workflows including iterative resolution and FQDN handling.

tests/test_multiaddr.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,7 @@
6767
"/garlic32/566niximlxdzpanmn4qouucvua3k7neniwss47li5r6ugoertzu:80",
6868
"/garlic32/566niximlxdzpanmn4qouucvua3k7neniwss47li5r6ugoertzuq:-1",
6969
"/garlic32/566niximlxdzpanmn4qouucvua3k7neniwss47li5r6ugoertzu@",
70-
"/ip4/127.0.0.1/udp/1234/quic-v1/webtransport/certhash/b2uaraocy6yrdblb4sfptaddgimjmmpy",
71-
"/ip4/127.0.0.1/udp/1234/quic-v1/webtransport/certhash/b2uaraocy6yrdblb4sfptaddgimjmmpy/certhash/zQmbWTwYGcmdyK9CYfNBcfs9nhZs17a6FQ4Y8oea278xx41",
70+
"/ip4/127.0.0.1/udp/1234/quic-v1/webtransport/certhash/b2uaraocy6yrdblb4sfptaddgimjmmp",
7271
"/udp/1234/sctp",
7372
"/udp/1234/udt/1234",
7473
"/udp/1234/utp/1234",
@@ -142,6 +141,8 @@ def test_invalid(addr_str):
142141
"/ip4/127.0.0.1/tcp/127/webrtc",
143142
"/certhash/uEiDDq4_xNyDorZBH3TlGazyJdOWSwvo4PUo5YHFMrvDE8g"
144143
"/ip4/127.0.0.1/udp/9090/webrtc-direct/certhash/uEiDDq4_xNyDorZBH3TlGazyJdOWSwvo4PUo5YHFMrvDE8g",
144+
"/ip4/127.0.0.1/udp/1234/quic-v1/webtransport/certhash/u1QEQOFj2IjCsPJFfMAxmQxLGPw",
145+
"/ip4/127.0.0.1/udp/1234/quic-v1/webtransport/certhash/u1QEQOFj2IjCsPJFfMAxmQxLGPw/certhash/uEiDDq4_xNyDorZBH3TlGazyJdOWSwvo4PUo5YHFMrvDE8g",
145146
],
146147
) # nopep8
147148
def test_valid(addr_str):

0 commit comments

Comments
 (0)