Functional Weave
Code in Rust

collections.sort-by@1.0.0

impl/python.py

3,315 bytes · the Python implementation · view raw

from functools import cmp_to_key
from typing import Any, List, Mapping, Sequence

from .collections_sort_by_types import SortDirection


# The type ranks that give mixed values a total order. Absent sorts last in
# both directions, so it is ranked above every present value and then excluded
# from the direction flip below.
RANK_BOOL = 0
RANK_NUMBER = 1
RANK_STRING = 2
RANK_ABSENT = 3


def _rank_of(value: Any, key: str) -> int:
    # Absent and null are the same thing: a document that omits a field and one
    # that nulls it mean the same to every reader.
    if value is None:
        return RANK_ABSENT
    # bool before int: in Python True is an int, and ranking it as a number
    # would order it against 0 and 1 instead of with the other booleans.
    if isinstance(value, bool):
        return RANK_BOOL
    if isinstance(value, (int, float)):
        return RANK_NUMBER
    if isinstance(value, str):
        return RANK_STRING
    raise TypeError('cannot sort by the list or map at "%s"' % (key,))


def _compare_scalars(rank: int, a: Any, b: Any) -> int:
    if rank == RANK_STRING:
        # Python compares strings by code point, which is the order this
        # capability pins; TypeScript spells the same comparison out by hand
        # because JavaScript would otherwise compare UTF-16 code units.
        return -1 if a < b else (1 if a > b else 0)
    if rank == RANK_NUMBER:
        return -1 if a < b else (1 if a > b else 0)
    return 0 if a == b else (1 if a else -1)


def sort_by(
    records: Sequence[Mapping[str, Any]],
    key: str,
    direction: SortDirection = "asc",
) -> List[Mapping[str, Any]]:
    """Stably sort ``records`` by ``key``, ascending or descending.

    Ties keep their input order in both directions, which is what lets a user
    sort by one column and then another and get the multi-column sort they
    expect rather than a reshuffle.
    """
    if isinstance(records, (str, bytes)) or not isinstance(records, (list, tuple)):
        raise TypeError("sort_by needs a list of records")
    if not isinstance(key, str) or key == "":
        raise TypeError("sort_by needs a non-empty key name")
    if direction not in ("asc", "desc"):
        raise ValueError('direction must be "asc" or "desc", received "%s"' % (direction,))

    # Rank every record up front. Raising mid-comparison would make the error
    # depend on which comparisons this interpreter's sort happened to perform.
    values = [record.get(key) if isinstance(record, dict) else None for record in records]
    ranks = [_rank_of(value, key) for value in values]
    sign = -1 if direction == "desc" else 1

    indexed = list(range(len(records)))

    def compare(i: int, j: int) -> int:
        ri, rj = ranks[i], ranks[j]
        # Absent values sink to the bottom whichever way the sort runs: nobody
        # wants the rows they know nothing about at the top of a descending table.
        if ri == RANK_ABSENT or rj == RANK_ABSENT:
            if ri == rj:
                return 0
            return 1 if ri == RANK_ABSENT else -1
        if ri != rj:
            base = -1 if ri < rj else 1
        else:
            base = _compare_scalars(ri, values[i], values[j])
        return base * sign

    indexed.sort(key=cmp_to_key(compare))
    return [records[i] for i in indexed]