Skip to content

Sort Lines & Remove Duplicates — Python source

Alphabetize, reverse, shuffle, dedupe, or length-sort lines of text. Supports case-insensitive and natural sorting (file2 before file10).

This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.

"""sort-lines — Python polyglot showcase port.

Pure line-sorting logic — deterministic, stdlib-only. Splits on newlines and
applies the requested operation. Never raises.

CosmoDev polyglot showcase port of sort-lines, ported from
src/lib/sortLines.ts. Display source — part of CosmoDev's polyglot tool pages.
"""

from __future__ import annotations

import functools
import re
from dataclasses import dataclass
from enum import Enum
from typing import Callable, List, Optional


class SortMode(str, Enum):
    """The available line operations. String values mirror the TS union."""

    ASC = "asc"
    DESC = "desc"
    LENGTH_ASC = "length-asc"
    LENGTH_DESC = "length-desc"
    REVERSE = "reverse"
    SHUFFLE = "shuffle"
    UNIQUE = "unique"


@dataclass
class SortOptions:
    # `None` means "use the default", mirroring the TS optional fields
    # (omitted is not the same as False).
    case_sensitive: Optional[bool] = None  # default True
    trim: Optional[bool] = None            # default False
    natural: Optional[bool] = None         # default False
    seed: Optional[int] = None             # default 1


@dataclass
class SortResult:
    lines: List[str]
    text: str
    removed_duplicates: int


# Chunk splitter: alternating runs of ASCII digits and non-digits. We use the
# explicit [0-9] form rather than \d because Python's \d is Unicode-aware by
# default, whereas JS's \d (no /u flag) is ASCII-only — the explicit form keeps
# the two ports identical for any input.
_CHUNK_RE = re.compile(r"[0-9]+|[^0-9]+")


def mulberry32(seed: int) -> Callable[[], float]:
    """Return a deterministic PRNG closure producing floats in [0, 1).

    Deterministic by design (not cryptographic): a given seed reproduces the
    same shuffle. Python ints are arbitrary precision, so every wrapping step
    is masked with & 0xFFFFFFFF to match JS's 32-bit bitwise semantics.
    """
    a = seed & 0xFFFFFFFF

    def rng() -> float:
        nonlocal a
        a = (a + 0x6D2B79F5) & 0xFFFFFFFF
        t = ((a ^ (a >> 15)) * (1 | a)) & 0xFFFFFFFF
        t = ((t + ((t ^ (t >> 7)) * (61 | t))) ^ t) & 0xFFFFFFFF
        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0

    return rng


def _natural_compare(a: str, b: str, case_sensitive: bool) -> int:
    """Order strings chunk-by-chunk so numeric runs compare by value.

    Lets "file2" sort before "file10" rather than lexicographically.
    """
    ax = a if case_sensitive else a.lower()
    bx = b if case_sensitive else b.lower()
    aa = _CHUNK_RE.findall(ax) or [ax]
    bb = _CHUNK_RE.findall(bx) or [bx]
    for x, y in zip(aa, bb):
        an = x[:1].isdigit()   # "" -> "" -> isdigit() is False (empty-safe)
        bn = y[:1].isdigit()
        if an != bn:
            # A digit-run vs a text-run: compare as raw strings.
            return -1 if x < y else 1
        if an:
            diff = int(x) - int(y)
            if diff != 0:
                return diff
        elif x != y:
            return -1 if x < y else 1
    # All compared chunks equal: the shorter run-list wins.
    return len(aa) - len(bb)


def sort_lines(
    input: str,
    mode: "str | SortMode",
    opts: Optional[SortOptions] = None,
) -> SortResult:
    """Apply ``mode`` to the lines of ``input`` and return the result.

    All non-unique modes return ``removed_duplicates == 0``.
    """
    if opts is None:
        opts = SortOptions()
    case_sensitive = opts.case_sensitive if opts.case_sensitive is not None else True
    should_trim = opts.trim if opts.trim is not None else False
    natural = opts.natural if opts.natural is not None else False
    seed = opts.seed if opts.seed is not None else 1

    def norm(s: str) -> str:
        return s if case_sensitive else s.lower()

    # str.split("\n") on "" yields [""] — one empty line — matching JS. The
    # `(input or "")` guard mirrors the TS `(input ?? "")` nullish fallback.
    lines: List[str] = (input or "").split("\n")
    if should_trim:
        lines = [line.strip() for line in lines]

    removed = 0
    m = mode.value if isinstance(mode, SortMode) else mode

    if m == "unique":
        # Keep first occurrence of each normalised line; count the rest.
        seen: set = set()
        out: List[str] = []
        for line in lines:
            key = norm(line)
            if key in seen:
                removed += 1
            else:
                seen.add(key)
                out.append(line)
        lines = out

    elif m == "shuffle":
        # Fisher-Yates with the seeded PRNG -> reproducible ordering.
        rng = mulberry32(seed)
        arr = list(lines)
        for i in range(len(arr) - 1, 0, -1):
            j = int(rng() * (i + 1))
            arr[i], arr[j] = arr[j], arr[i]
        lines = arr

    elif m == "reverse":
        lines = list(reversed(lines))

    elif m in ("length-asc", "length-desc"):
        # Timsort is stable, so equal lengths keep input order — equivalent to
        # the TS decorate-by-index step. len(str) is a codepoint count.
        ordered = sorted(lines, key=len)
        if m == "length-desc":
            ordered = list(reversed(ordered))
        lines = ordered

    else:  # asc / desc
        # Python's sorted(reverse=...) is still stable, so equal keys retain
        # their original order in BOTH directions — matching the TS `c * dir`
        # stable sort.
        reverse = m == "desc"
        if natural:
            ordered = sorted(
                lines,
                key=functools.cmp_to_key(
                    lambda x, y: _natural_compare(x, y, case_sensitive)
                ),
                reverse=reverse,
            )
        else:
            # Codepoint ordering (Python's default str comparison) matches JS
            # localeCompare's default for ASCII input.
            ordered = sorted(lines, key=norm, reverse=reverse)
        lines = ordered

    return SortResult(lines=lines, text="\n".join(lines), removed_duplicates=removed)

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 →