IPv4 ↔ IPv6 Converter — Python source
Convert between IPv4 and IPv6 addresses both ways. Parse and validate addresses, expand and compress IPv6 to its canonical RFC 5952 form, map an IPv4 into IPv4-mapped and IPv4-compatible IPv6 (or any custom /96 prefix), and extract an embedded IPv4 back out.
This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.
# =============================================================================
# ip-converter — IPv4 ↔ IPv6 conversion (polyglot showcase: Python)
# CosmoDev polyglot port of ip-converter, ported from src/lib/ip-converter.ts.
# Display source — part of CosmoDev's polyglot tool pages.
#
# Pure, deterministic IPv4/IPv6 address conversion logic. Every parse function
# returns `None` (or '' for the string renderers) on invalid input rather than
# raising, so the UI can show a graceful error. IPv6 text follows RFC 5952:
# lowercase hex, no leading zeros, the single longest run of zero groups
# collapsed to `::`, and a dotted-decimal tail only for IPv4-mapped
# (`::ffff:`) addresses.
#
# Uses only the Python standard library (re, dataclasses, typing). Naming is
# snake_case per PEP 8.
# =============================================================================
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import List, Literal, Optional
# One IPv6 group: 1-4 hex digits.
_HEX = re.compile(r"^[0-9a-fA-F]{1,4}$")
# One IPv4 octet token: 1-3 decimal digits (range checked separately).
_DEC3 = re.compile(r"^\d{1,3}$")
@dataclass
class Ipv4ToIpv6Options:
"""Options for embedding an IPv4 octet quad into an IPv6 address."""
mode: Literal["mapped", "compatible"] = "mapped"
"""Embedding family. ``mapped`` (default) → ``::ffff:a.b.c.d``;
``compatible`` → ``::a.b.c.d``. Ignored when :attr:`prefix` is set."""
prefix: Optional[str] = None
"""Optional custom high-96-bit prefix (a valid IPv6 string; its first 6
groups are used and its low 32 bits are overwritten by the IPv4). e.g.
``"64:ff9b::"`` yields a NAT64-style ``64:ff9b::a.b.c.d``. Overrides
:attr:`mode`."""
def _is_valid_ipv6_groups(groups: object) -> bool:
"""True when *groups* is exactly eight integers in the 16-bit range."""
if not isinstance(groups, list) or len(groups) != 8:
return False
return all(isinstance(v, int) and 0 <= v <= 0xFFFF for v in groups)
def _hex_group(v: int) -> str:
"""Lowercase hex for one 16-bit group, with no leading zeros."""
return format(v, "x")
def parse_ipv4(s: str) -> Optional[List[int]]:
"""Parse a dotted-decimal IPv4 string into four octets, validating each is
0-255. Returns ``None`` for anything that is not exactly four numeric octets
in range."""
parts = s.strip().split(".")
if len(parts) != 4:
return None
octets: List[int] = []
for p in parts:
if not _DEC3.match(p):
return None
# _DEC3 guarantees digits only, so base-10 int() cannot raise here.
n = int(p)
if n < 0 or n > 255:
return None
octets.append(n)
return octets
def ipv4_to_string(octets: List[int]) -> str:
"""Render four octets as ``a.b.c.d``, or ``''`` if they are out of range."""
if len(octets) != 4 or not all(isinstance(o, int) and 0 <= o <= 255 for o in octets):
return ""
return ".".join(str(o) for o in octets)
def parse_ipv6(s: str) -> Optional[List[int]]:
"""Parse an IPv6 string (with ``::`` compression, hex groups, and an
optional dotted-decimal IPv4 tail for mapped/compatible forms) into eight
16-bit groups. Returns ``None`` on any malformed input — never raises."""
inp = s.strip()
if not inp:
return None
# At most one ``::`` run is legal; reject ambiguous double-compression.
if inp.count("::") > 1:
return None
dc = inp.find("::")
if dc >= 0:
before = inp[:dc]
after = inp[dc + 2:]
head_tokens = before.split(":") if before else []
tail_tokens = after.split(":") if after else []
head: List[int] = []
for g in head_tokens:
if not _HEX.match(g):
return None
head.append(int(g, 16))
tail: List[int] = []
for i, g in enumerate(tail_tokens):
# A dotted-quad IPv4 tail is permitted only in the final slot,
# where it contributes two groups (high octet pair, low octet pair).
if i == len(tail_tokens) - 1 and "." in g:
oct_ = parse_ipv4(g)
if oct_ is None:
return None
tail.append((oct_[0] << 8) | oct_[1])
tail.append((oct_[2] << 8) | oct_[3])
else:
if not _HEX.match(g):
return None
tail.append(int(g, 16))
total = len(head) + len(tail)
# ``::`` must elide at least one group.
if total >= 8:
return None
return head + [0] * (8 - total) + tail
# No compression: split on ':' and parse, allowing a dotted-quad only in
# the last slot. The result must be exactly eight groups.
tokens = inp.split(":")
groups: List[int] = []
for i, g in enumerate(tokens):
if i == len(tokens) - 1 and "." in g:
oct_ = parse_ipv4(g)
if oct_ is None:
return None
groups.append((oct_[0] << 8) | oct_[1])
groups.append((oct_[2] << 8) | oct_[3])
else:
if not _HEX.match(g):
return None
groups.append(int(g, 16))
return groups if len(groups) == 8 else None
def _is_mapped(g: List[int]) -> bool:
"""True when the eight groups form an IPv4-mapped (``::ffff:``) address."""
return (g[0] == 0 and g[1] == 0 and g[2] == 0
and g[3] == 0 and g[4] == 0 and g[5] == 0xFFFF)
def _is_compatible(g: List[int]) -> bool:
"""True when the eight groups form an IPv4-compatible (``::``) address."""
return (g[0] == 0 and g[1] == 0 and g[2] == 0
and g[3] == 0 and g[4] == 0 and g[5] == 0)
def _compress_groups(groups: List[int]) -> str:
"""Collapse the longest run (length >= 2) of zero groups into ``::`` (first
run wins on ties) and strip leading zeros — RFC 5952 canonical text for
pure-hex IPv6. Does not emit dotted-decimal; call :func:`_render_canonical`
for that."""
best_start = -1
best_len = 0
cur_start = -1
cur_len = 0
# Track the longest run of consecutive zero groups. best_start records the
# first run of the longest length encountered (strict > keeps earliest).
for i, v in enumerate(groups):
if v == 0:
if cur_start < 0:
cur_start = i
cur_len += 1
if cur_len > best_len:
best_len = cur_len
best_start = cur_start
else:
cur_start = -1
cur_len = 0
if best_len < 2:
return ":".join(_hex_group(v) for v in groups)
before = ":".join(_hex_group(v) for v in groups[:best_start])
after = ":".join(_hex_group(v) for v in groups[best_start + best_len:])
return f"{before}::{after}"
def _render_with_embedded_tail(high: List[int], octets: List[int]) -> str:
"""Render a compressed high part followed by a dotted-decimal IPv4 tail.
When the high part already ends in ``::`` (its zero run reaches the
boundary) the IPv4 attaches directly; otherwise a single ``:`` separates
them — so ``::ffff:`` → ``::ffff:a.b.c.d`` and ``::`` → ``::a.b.c.d``."""
high_str = _compress_groups(high)
ipv4 = ".".join(str(o) for o in octets)
if high_str.endswith("::"):
return f"{high_str}{ipv4}"
return f"{high_str}:{ipv4}"
def _render_canonical(groups: List[int]) -> str:
"""Canonical RFC 5952 text for eight groups: a dotted-decimal tail for
IPv4-mapped (``::ffff:``) addresses, otherwise pure compressed hex. The
deprecated IPv4-compatible range (``::/96``) is NOT rendered dotted here —
that would mis-render the unspecified (``::``) and loopback (``::1``)
addresses as ``::0.0.0.0`` / ``::0.0.0.1``. Compatible extraction is still
available via :func:`ipv6_to_ipv4`; on-demand compatible generation via
:func:`ipv4_to_ipv6` is untouched."""
if _is_mapped(groups):
octets = [
(groups[6] >> 8) & 0xFF,
groups[6] & 0xFF,
(groups[7] >> 8) & 0xFF,
groups[7] & 0xFF,
]
return _render_with_embedded_tail(groups[:6], octets)
return _compress_groups(groups)
def ipv6_to_string(groups: List[int]) -> str:
"""Render eight groups as canonical compressed IPv6, or ``''`` if invalid."""
if not _is_valid_ipv6_groups(groups):
return ""
return _render_canonical(groups)
def expand_ipv6(s: str) -> str:
"""Expand an IPv6 string to its full eight-group, four-hex-digit form;
``''`` if invalid."""
g = parse_ipv6(s)
if g is None:
return ""
# format(v, "04x") left-pads each group to a fixed 4-digit width: 0000..ffff.
return ":".join(format(v, "04x") for v in g)
def compress_ipv6(s: str) -> str:
"""Compress an IPv6 string to its RFC 5952 canonical form; ``''`` if
invalid."""
g = parse_ipv6(s)
if g is None:
return ""
return _render_canonical(g)
def ipv4_to_ipv6(octets: List[int], opts: Optional[Ipv4ToIpv6Options] = None) -> str:
"""Embed an IPv4 octet quad into an IPv6 address. By default produces the
IPv4-mapped form ``::ffff:a.b.c.d``; ``mode="compatible"`` yields
``::a.b.c.d``; a set ``prefix`` overrides both and places the IPv4 after any
custom /96 prefix (e.g. ``64:ff9b::a.b.c.d``). Returns ``''`` for invalid
octets or prefix."""
if len(octets) != 4 or not all(isinstance(o, int) and 0 <= o <= 255 for o in octets):
return ""
if opts is not None and opts.prefix is not None:
p = parse_ipv6(opts.prefix)
if p is None:
return ""
return _render_with_embedded_tail(p[:6], octets)
if opts is not None and opts.mode == "compatible":
return _render_with_embedded_tail([0, 0, 0, 0, 0, 0], octets)
return _render_with_embedded_tail([0, 0, 0, 0, 0, 0xFFFF], octets)
def ipv6_to_ipv4(s: str) -> Optional[str]:
"""Extract the embedded IPv4 from an IPv4-mapped (``::ffff:a.b.c.d``) or
IPv4-compatible (``::a.b.c.d``) address, returning dotted-decimal or
``None`` when the address carries no embedded IPv4 (or is unparseable)."""
g = parse_ipv6(s)
if g is None or not (_is_mapped(g) or _is_compatible(g)):
return None
octets = [
(g[6] >> 8) & 0xFF,
g[6] & 0xFF,
(g[7] >> 8) & 0xFF,
g[7] & 0xFF,
]
return ".".join(str(o) for o in octets)
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →