net.ipv6
Parse IPv6 addresses and write them in RFC 5952 canonical form: lower-case, zeros compressed, IPv4 embedded.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 47 tests, run in TypeScript, Python and Rust.parseIpv6 22 · formatIpv6 15 · isIpv6 10
What it does
Parse IPv6 text to its eight 16-bit groups, write groups back as the one canonical text RFC 5952 defines, and check text without raising. The canonical form of any address is `formatIpv6(parseIpv6(text))`.
## Why canonical text matters
The functions
A group: 3 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.
- parse_ipv6 (text: string) -> int[]
- format_ipv6 (groups: int[]) -> string
- is_ipv6 (text: string) -> bool
Once installed, your code imports each one from the group's module.
parse_ipv6 throws on bad input 22 tests
def parse_ipv6(text: str) -> List[int]
| text | string | RFC 4291 text: hex groups, at most one ::, optionally a dotted IPv4 tail; no zone id or prefix |
| returns | int[] | the eight 16-bit groups, each 0 to 65535 |
For example
parse_ipv6(2001:db8::1)→ 8,193, 3,512, 0, 0, 0, 0, 0, 1 documentation address with ::parse_ipv6(::)→ 0, 0, 0, 0, 0, 0, 0, 0 the unspecified address is all zerosparse_ipv6(::1)→ 0, 0, 0, 0, 0, 0, 0, 1 loopback
from fune.net.ipv6 import parse_ipv6 # net.ipv6@^1
from typing import List, Optional, Sequence
from .net_ipv4_parse_ipv4 import ipv4_value
_ALLOWED = frozenset("0123456789abcdefABCDEF:.")
def parse_ipv6(text: str) -> List[int]:
"""The eight 16-bit groups of an IPv6 address written as RFC 4291 allows.
Hex groups of one to four digits in either case, at most one "::" standing
for one or more zero groups, and optionally a dotted IPv4 address as the
last 32 bits. A zone id ("%eth0") or a prefix ("/64") is not part of an
address and is refused, as is anything around it (spaces, newlines).
"""
groups = ipv6_groups(text)
if groups is None:
raise ValueError('"%s" is not an IPv6 address' % (text,))
return groups
# Exported for is_ipv6 and for capabilities that parse IPv6 text without
# wanting an error; not part of the group's contract.
def ipv6_groups(text: object) -> Optional[List[int]]:
"""The eight groups, or None when the text is not an IPv6 address."""
if not isinstance(text, str) or text == "":
return None
for ch in text:
if ch not in _ALLOWED:
return None
at = text.find("::")
if at < 0:
groups = _pieces(text.split(":"), True)
return groups if groups is not None and len(groups) == 8 else None
before = text[:at]
after = text[at + 2:]
if "::" in after:
return None
head = _pieces(before.split(":") if before else [], False)
tail = _pieces(after.split(":") if after else [], True)
if head is None or tail is None:
return None
# "::" stands for at least one group, so at most seven are written.
if len(head) + len(tail) > 7:
return None
return head + [0] * (8 - len(head) - len(tail)) + tail
def _pieces(parts: Sequence[str], may_end_with_ipv4: bool) -> Optional[List[int]]:
groups: List[int] = []
for i, part in enumerate(parts):
if "." in part:
if not may_end_with_ipv4 or i != len(parts) - 1:
return None
v4 = ipv4_value(part)
if v4 is None:
return None
groups.extend([v4 // 65536, v4 % 65536])
continue
if len(part) < 1 or len(part) > 4:
return None
groups.append(int(part, 16))
return groupsformat_ipv6 throws on bad input 15 tests
def format_ipv6(groups: Sequence[int]) -> str
| groups | int[] | exactly eight integers 0 to 65535 |
| returns | string | RFC 5952 canonical text; ::ffff:0:0/96 (IPv4-mapped) keeps a dotted-quad tail |
For example
format_ipv6(8,193, 3,512, 0, 0, 0, 0, 0, 1)→ 2001:db8::1 compresses the zero runformat_ipv6(0, 0, 0, 0, 0, 0, 0, 0)→ :: all zeros is ::format_ipv6(0, 0, 0, 0, 0, 0, 0, 1)→ ::1 loopback
from fune.net.ipv6 import format_ipv6 # net.ipv6@^1
from typing import Sequence
from .net_ipv4_format_ipv4 import format_ipv4
def format_ipv6(groups: Sequence[int]) -> str:
"""RFC 5952 canonical text for eight 16-bit groups.
Lower-case hex, no leading zeros, the longest run of two or more zero
groups written "::" (the first on a tie, never a single group). An
IPv4-mapped address (::ffff:0:0/96) keeps its dotted-quad tail.
"""
if isinstance(groups, (str, bytes)) or not isinstance(groups, (list, tuple)) or len(groups) != 8:
raise ValueError("IPv6 groups must be eight integers from 0 to 65535")
for g in groups:
if isinstance(g, bool) or not isinstance(g, int) or g < 0 or g > 65535:
raise ValueError("IPv6 groups must be eight integers from 0 to 65535")
if list(groups[:6]) == [0, 0, 0, 0, 0, 0xFFFF]:
return "::ffff:" + format_ipv4(groups[6] * 65536 + groups[7])
best_start, best_length = -1, 0
i = 0
while i < 8:
if groups[i] != 0:
i += 1
continue
j = i
while j < 8 and groups[j] == 0:
j += 1
# Strictly longer, so the first of two equal runs wins.
if j - i > best_length:
best_start, best_length = i, j - i
i = j
hexes = ["%x" % g for g in groups]
if best_length < 2:
return ":".join(hexes)
return ":".join(hexes[:best_start]) + "::" + ":".join(hexes[best_start + best_length:])is_ipv6 10 tests
def is_ipv6(text: str) -> bool
| text | string | any text; true exactly when parseIpv6 would accept it |
| returns | bool |
For example
is_ipv6(::1)→ true loopbackis_ipv6(2001:db8::1)→ true documentation addressis_ipv6(::ffff:192.0.2.1)→ true IPv4-mapped
from fune.net.ipv6 import is_ipv6 # net.ipv6@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from .net_ipv6_parse_ipv6 import ipv6_groups ← parseIpv6, another function of this group · built into the same file, even by a slim install
def is_ipv6(text: str) -> bool:
"""True exactly when parse_ipv6 would accept the text; never raises."""
return ipv6_groups(text) is not NoneInstall
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add net.ipv6
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add net.ipv6 --only parseIpv6
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./net.ipv6-1.0.0-python.fune, or fetch it from a terminal with fune pull net.ipv6@1.0.0:python.
The whole function, every language, is one file too: net.ipv6-1.0.0.fune, 25,384 bytes, sha256 f6cadea3f28991bad7dc0085b557c9d1e5f7db5ed39785b4040b78946ff72b56. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before net.ipv6.parseIpv6
# fune: before net.ipv6.formatIpv6
# fune: before net.ipv6.isIpv6
after — your function gets the result and the arguments, and returns the final result.
# fune: after net.ipv6.parseIpv6
# fune: after net.ipv6.formatIpv6
# fune: after net.ipv6.isIpv6
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace net.ipv4 in net.ipv6
step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show net.ipv6 --steps.
# fune: step net.ipv6.<fn> after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
parseIpv6 22 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| documentation address with :: | 2001:db8::1 | → | 8,193, 3,512, 0, 0, 0, 0, 0, 1 |
| the unspecified address is all zeros | :: | → | 0, 0, 0, 0, 0, 0, 0, 0 |
| loopback | ::1 | → | 0, 0, 0, 0, 0, 0, 0, 1 |
| :: at the end | fe80:: | → | 65,152, 0, 0, 0, 0, 0, 0, 0 |
| full form, upper case and leading zeros | 2001:0DB8:0000:0000:0000:ff00:0042:8329 | → | 8,193, 3,512, 0, 0, 0, 65,280, 66, 33,577 |
| IPv4-mapped with a dotted tail: 192.0 is c000, 2.128 is 0280 | ::ffff:192.0.2.128 | → | 0, 0, 0, 0, 0, 65,535, 49,152, 640 |
| NAT64 well-known prefix with a dotted tail | 64:ff9b::192.0.2.33 | → | 100, 65,435, 0, 0, 0, 0, 49,152, 545 |
| six groups and a dotted tail without :: | 1:2:3:4:5:6:1.2.3.4 | → | 1, 2, 3, 4, 5, 6, 258, 772 |
| seven groups and :: standing for one zero group | 1:2:3:4:5:6:7:: | → | 1, 2, 3, 4, 5, 6, 7, 0 |
| two :: are ambiguous | 2001:db8::1::1 | → | error: is not an IPv6 address |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| nine groups | 1:2:3:4:5:6:7:8:9 | → | error: is not an IPv6 address |
| seven groups without :: | 1:2:3:4:5:6:7 | → | error: is not an IPv6 address |
| eight groups and :: (:: must stand for at least one) | 1:2:3:4:5:6:7:8:: | → | error: is not an IPv6 address |
| a five-digit group | 12345:: | → | error: is not an IPv6 address |
| a zone id is not part of the address | fe80::1%eth0 | → | error: is not an IPv6 address |
| a prefix length is not part of the address | 2001:db8::/32 | → | error: is not an IPv6 address |
| a dotted part that is not last | ::192.0.2.1:1 | → | error: is not an IPv6 address |
| the dotted tail follows the IPv4 rules (no leading zeros) | ::ffff:01.2.3.4 | → | error: is not an IPv6 address |
| a single leading colon | :1:: | → | error: is not an IPv6 address |
| a trailing newline | ::1 | → | error: is not an IPv6 address |
| a non-ASCII digit | ١::1 | → | error: is not an IPv6 address |
| empty text | → | error: is not an IPv6 address |
formatIpv6 15 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| compresses the zero run | 8,193, 3,512, 0, 0, 0, 0, 0, 1 | → | 2001:db8::1 |
| all zeros is :: | 0, 0, 0, 0, 0, 0, 0, 0 | → | :: |
| loopback | 0, 0, 0, 0, 0, 0, 0, 1 | → | ::1 |
| a run at the end | 65,152, 0, 0, 0, 0, 0, 0, 0 | → | fe80:: |
| the longest run wins, not the first (RFC 5952 4.2.3) | 8,193, 3,512, 0, 1, 0, 0, 0, 1 | → | 2001:db8:0:1::1 |
| equal runs: the first is compressed | 8,193, 3,512, 0, 0, 1, 0, 0, 1 | → | 2001:db8::1:0:0:1 |
| equal runs at both ends: the first | 0, 0, 1, 2, 3, 4, 0, 0 | → | ::1:2:3:4:0:0 |
| a single zero group is never :: (RFC 5952 4.2.2) | 8,193, 3,512, 0, 1, 1, 1, 1, 1 | → | 2001:db8:0:1:1:1:1:1 |
| lower case, leading zeros dropped | 8,193, 3,512, 0, 0, 0, 65,280, 66, 33,577 | → | 2001:db8::ff00:42:8329 |
| IPv4-mapped keeps a dotted tail (RFC 5952 5) | 0, 0, 0, 0, 0, 65,535, 49,152, 640 | → | ::ffff:192.0.2.128 |
Show the other 5 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| not mapped (a non-zero fifth group): plain hex | 0, 0, 0, 0, 1, 65,535, 49,152, 640 | → | ::1:ffff:c000:280 |
| seven groups | 1, 2, 3, 4, 5, 6, 7 | → | error: IPv6 groups must be eight integers from 0 to 65535 |
| a group above 65535 | 65,536, 0, 0, 0, 0, 0, 0, 0 | → | error: IPv6 groups must be eight integers from 0 to 65535 |
| a negative group | 0, 0, 0, 0, 0, 0, 0, -1 | → | error: IPv6 groups must be eight integers from 0 to 65535 |
| a fractional group | 0, 0, 0, 0, 0, 0, 0, 1.5 | → | error: IPv6 groups must be eight integers from 0 to 65535 |
isIpv6 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| loopback | ::1 | → | true |
| documentation address | 2001:db8::1 | → | true |
| IPv4-mapped | ::ffff:192.0.2.1 | → | true |
| upper case | FE80::1 | → | true |
| a zone id | fe80::1%eth0 | → | false |
| an IPv4 address | 1.2.3.4 | → | false |
| a trailing newline | ::1 | → | false |
| three colons | 2001:db8:::1 | → | false |
| two :: | 1::2::3 | → | false |
| empty text | → | false |
More from the author
`2001:DB8:0:0:1:0:0:1`, `2001:db8::1:0:0:1` and `2001:0db8:0:0:1::1` are the same address. Compare them as text, log them, or use them as a map key, and they are three. RFC 5952 fixes one spelling:
- lower-case hex, no leading zeros in a group; - the longest run of two or more zero groups becomes `::`; on a tie the first run; a single zero group is written `0`, never `::`; - an IPv4-mapped address (`::ffff:0:0/96`) keeps its dotted-quad tail (`::ffff:192.0.2.128`), as section 5 recommends. Other prefixes that can carry IPv4 (the deprecated `::a.b.c.d` compatible form, `64:ff9b::/96`) are written in hex; the parser accepts a dotted tail on any of them.
## What parses
RFC 4291 section 2.2 text: one to four hex digits per group in either case, at most one `::` (standing for at least one zero group), and optionally a dotted IPv4 address, following `net.ipv4`'s strict rules, as the last 32 bits. Refused: a zone id (`fe80::1%eth0`, RFC 4007: it names an interface, it is not part of the address), a prefix (`/64`, see `net.cidr`), brackets, whitespace and non-ASCII digits.
`ipv6Groups` (`ipv6_groups`) is exported for sibling capabilities that want "groups or null"; it is not part of the contract.
Sources: RFC 4291 (IP Version 6 Addressing Architecture) section 2.2; RFC 5952 (A Recommendation for IPv6 Address Text Representation) sections 4 and 5.