From 9e1fedb4fc526f2d11a74a51b0aab50245aadc5d Mon Sep 17 00:00:00 2001 From: acul71 Date: Sun, 6 Sep 2026 23:36:08 +0200 Subject: [PATCH] feat: add from_net_addr/to_net_addr helpers (fixes #115) Co-authored-by: Cursor --- README.rst | 20 ++++++++++++ docs/examples.rst | 12 +++++++ examples/net_addr/net_addr_example.py | 32 ++++++++++++++++++ multiaddr/__init__.py | 4 +++ multiaddr/utils.py | 47 +++++++++++++++++++++++++++ newsfragments/115.feature.rst | 1 + tests/test_net_addr.py | 31 ++++++++++++++++++ tests/test_package_exports.py | 2 ++ 8 files changed, 149 insertions(+) create mode 100644 examples/net_addr/net_addr_example.py create mode 100644 newsfragments/115.feature.rst create mode 100644 tests/test_net_addr.py diff --git a/README.rst b/README.rst index 2f97a4b..53503dd 100644 --- a/README.rst +++ b/README.rst @@ -226,6 +226,26 @@ Multiaddr supports DNS-based address resolution using the DNSADDR protocol. This For comprehensive examples including bootstrap node resolution, protocol comparison, and py-libp2p integration, see the `DNS examples `_ in the examples directory. +Socket address conversion +------------------------- + +Convert between Python socket address tuples and multiaddrs: + + +.. code-block:: python + + from multiaddr import from_net_addr, to_net_addr + + ma = from_net_addr(("1.2.3.4", 80)) + print(ma) + # /ip4/1.2.3.4/tcp/80 + print(to_net_addr(ma)) + # ('1.2.3.4', 80) + print(from_net_addr(("::1", 53), transport="udp")) + # /ip6/::1/udp/53 + +See ``examples/net_addr/net_addr_example.py`` for a printable demo. + Thin Waist Address Validation ----------------------------- diff --git a/docs/examples.rst b/docs/examples.rst index 80e42be..f9cb2d3 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -120,6 +120,15 @@ This example shows: Resolver Utility Examples -------------------------- +Socket address conversion +------------------------- + +The `examples/net_addr/` directory demonstrates ``from_net_addr`` / ``to_net_addr``. + +.. literalinclude:: ../examples/net_addr/net_addr_example.py + :language: python + :caption: examples/net_addr/net_addr_example.py + The `examples/resolver_utils/` directory demonstrates the utility functions ported from go-multiaddr-dns for working with DNS-based multiaddr resolution. This example shows: @@ -163,4 +172,7 @@ All examples can be run directly with Python: # Resolver utility examples python examples/resolver_utils/resolver_utils_example.py + # net_addr examples + python examples/net_addr/net_addr_example.py + Note: Some examples require network connectivity and may take a few seconds to complete due to DNS resolution. diff --git a/examples/net_addr/net_addr_example.py b/examples/net_addr/net_addr_example.py new file mode 100644 index 0000000..f86df5b --- /dev/null +++ b/examples/net_addr/net_addr_example.py @@ -0,0 +1,32 @@ +""" +from_net_addr / to_net_addr demo. + +Usage: + python examples/net_addr/net_addr_example.py +""" + +from multiaddr import from_net_addr, to_net_addr + + +def main() -> None: + print("=== from_net_addr() ===") + samples = [ + (("1.2.3.4", 80), "tcp"), + (("::1", 443), "tcp"), + (("8.8.8.8", 53), "udp"), + ] + for addr, transport in samples: + ma = from_net_addr(addr, transport=transport) + print(f"{addr} transport={transport} -> {ma}") + + print() + print("=== to_net_addr() ===") + for ma in ( + from_net_addr(("1.2.3.4", 80)), + from_net_addr(("::1", 443)), + ): + print(f"{ma} -> {to_net_addr(ma)}") + + +if __name__ == "__main__": + main() diff --git a/multiaddr/__init__.py b/multiaddr/__init__.py index 456004f..1b102d0 100755 --- a/multiaddr/__init__.py +++ b/multiaddr/__init__.py @@ -33,6 +33,7 @@ IP6_UNSPECIFIED, PRIVATE4, PRIVATE6, + from_net_addr, get_multiaddr_options, get_network_addrs, get_thin_waist_addresses, @@ -45,6 +46,7 @@ is_public_addr, is_thin_waist, is_wildcard, + to_net_addr, ) __author__ = "Steven Buss" @@ -80,6 +82,7 @@ "RecursionLimitError", "ResolutionError", "StringParseError", + "from_net_addr", "get_multiaddr_options", "get_network_addrs", "get_thin_waist_addresses", @@ -94,4 +97,5 @@ "is_wildcard", "protocol_with_code", "protocol_with_name", + "to_net_addr", ] diff --git a/multiaddr/utils.py b/multiaddr/utils.py index 8ff19dc..09fc39a 100644 --- a/multiaddr/utils.py +++ b/multiaddr/utils.py @@ -219,3 +219,50 @@ def get_thin_waist_addresses( # Return the specific address addr_str = f"/{ip_proto}/{options['host']}/{options['transport']}/{target_port}" return [Multiaddr(addr_str)] + + +def from_net_addr( + addr: tuple[Any, ...], + *, + transport: str = "tcp", +) -> Multiaddr: + """Convert a socket address tuple to a Multiaddr. + + Args: + addr: A socket address tuple such as ``(host, port)`` or an IPv6 + ``(host, port, flowinfo, scope_id)`` tuple. + transport: ``"tcp"`` or ``"udp"`` (default ``"tcp"``). + + Examples: + >>> from_net_addr(("1.2.3.4", 80)) + Multiaddr('/ip4/1.2.3.4/tcp/80') + >>> from_net_addr(("::1", 53), transport="udp") + Multiaddr('/ip6/::1/udp/53') + """ + if transport not in ("tcp", "udp"): + raise ValueError(f"unsupported transport: {transport!r}") + if not addr or len(addr) < 2: + raise ValueError("addr must be a (host, port[, ...]) tuple") + + host, port = addr[0], addr[1] + if not isinstance(host, str): + raise TypeError("host must be a string") + if not isinstance(port, int): + raise TypeError("port must be an integer") + + ip = ipaddress.ip_address(host) + ip_proto = "ip4" if isinstance(ip, ipaddress.IPv4Address) else "ip6" + return Multiaddr(f"/{ip_proto}/{host}/{transport}/{port}") + + +def to_net_addr(ma: Multiaddr) -> tuple[str, int]: + """Convert a thin-waist Multiaddr to a ``(host, port)`` socket address tuple. + + Examples: + >>> to_net_addr(Multiaddr("/ip4/1.2.3.4/tcp/80")) + ('1.2.3.4', 80) + """ + opts = get_multiaddr_options(ma) + if opts is None: + raise ValueError(f"{ma} is not a thin waist address") + return (opts["host"], opts["port"]) diff --git a/newsfragments/115.feature.rst b/newsfragments/115.feature.rst new file mode 100644 index 0000000..30c0a68 --- /dev/null +++ b/newsfragments/115.feature.rst @@ -0,0 +1 @@ +Add ``from_net_addr()`` / ``to_net_addr()`` for socket address tuple conversion. diff --git a/tests/test_net_addr.py b/tests/test_net_addr.py new file mode 100644 index 0000000..4218b5c --- /dev/null +++ b/tests/test_net_addr.py @@ -0,0 +1,31 @@ +import pytest + +from multiaddr import Multiaddr, from_net_addr, to_net_addr + + +def test_from_net_addr_tcp4(): + assert str(from_net_addr(("1.2.3.4", 80))) == "/ip4/1.2.3.4/tcp/80" + + +def test_from_net_addr_udp6(): + assert str(from_net_addr(("::1", 53), transport="udp")) == "/ip6/::1/udp/53" + + +def test_from_net_addr_ipv6_tuple(): + assert str(from_net_addr(("2001:db8::1", 443, 0, 0))) == "/ip6/2001:db8::1/tcp/443" + + +def test_from_net_addr_rejects_bad_transport(): + with pytest.raises(ValueError, match="unsupported transport"): + from_net_addr(("1.2.3.4", 80), transport="sctp") + + +def test_to_net_addr_roundtrip(): + ma = Multiaddr("/ip4/1.2.3.4/tcp/80") + assert to_net_addr(ma) == ("1.2.3.4", 80) + assert str(from_net_addr(to_net_addr(ma))) == str(ma) + + +def test_to_net_addr_rejects_non_thin_waist(): + with pytest.raises(ValueError, match="thin waist"): + to_net_addr(Multiaddr("/unix/tmp/socket")) diff --git a/tests/test_package_exports.py b/tests/test_package_exports.py index 04a1928..8367557 100644 --- a/tests/test_package_exports.py +++ b/tests/test_package_exports.py @@ -32,6 +32,8 @@ "get_multiaddr_options", "get_network_addrs", "get_thin_waist_addresses", + "from_net_addr", + "to_net_addr", "is_ip6_link_local", "is_ip_loopback", "is_ip_unspecified",