Skip to content

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 →