Source code for lettercase.tools

"""Utilities for making conversion easier."""

from contextlib import suppress
from functools import wraps
from typing import Any, Dict, FrozenSet, Iterable, Iterator, Mapping, MutableMapping, MutableSequence, \
    Optional, Set, Tuple, TypeVar, Union

from .converters import ConverterType, get_converter
from .letter_case import LetterCase, LetterCaseType, get_letter_case

__all__ = ["ConversionMemo",
           "MemoType",
           "memo_converter", "is_memo_converter",
           "convert_iter_items",
           "mut_convert_items", "mut_convert_keys"]

T = TypeVar("T")


class _CaseSensitiveInverseDict(MutableMapping[str, str]):
    _forward: MutableMapping[str, str]
    _backward: MutableMapping[str, str]

    _back_converter: ConverterType

    def __init__(self, forward: MutableMapping[str, str], backward: MutableMapping[str, str],
                 back_converter: ConverterType) -> None:
        self._forward = forward
        self._backward = backward
        self._back_converter = back_converter

    def _get_fkey(self, fkey: str) -> str:
        try:
            # if fkey is actually a bkey
            return self._backward[fkey]
        except KeyError:
            return self._back_converter(fkey)

    def __setitem__(self, fkey: str, bkey: str) -> None:
        fkey = self._get_fkey(fkey)
        self._forward[fkey] = bkey
        self._backward[bkey] = fkey

    def __delitem__(self, fkey: str) -> None:
        fkey = self._get_fkey(fkey)
        bkey = self[fkey]

        del self._forward[fkey]
        del self._backward[bkey]

    def __getitem__(self, fkey: str) -> str:
        return self._forward[fkey]

    def __len__(self) -> int:
        return len(self._forward)

    def __iter__(self) -> Iterator[str]:
        return iter(self._forward)


TwoWayMemoTuple = Tuple[Dict[str, str], Dict[str, str]]


class ConversionMemo:
    """Specialised memoization class which keeps memo maps.

    The advantage of using this over a normal `dict` is that it automatically
    "learns" the reverse operation of a conversion (ex: "a_b" -> "aB" also
    learns "aB" -> "a_b").

    These results are stored completely separate though, to avoid accidentally
    converting the wrong way (if a text is already in the right case but because
    of the map it's flipped).
    """

    _memos: Dict[FrozenSet[LetterCase], TwoWayMemoTuple]

    def __init__(self) -> None:
        self._memos = {}

    def _get_memo_tuple(self, from_case: Optional[LetterCase], to_case: LetterCase) -> TwoWayMemoTuple:
        key = frozenset((from_case, to_case))
        try:
            memo_tuple = self._memos[key]
        except KeyError:
            memo_tuple = self._memos[key] = ({}, {})

        first_case, second_case = key
        if first_case is from_case:
            return memo_tuple
        else:
            return memo_tuple[1], memo_tuple[0]

    def get_memo(self, from_case: Optional[LetterCaseType], to_case: LetterCaseType) -> MutableMapping[str, str]:
        """Get the memo map which maps texts from `from_case` to the converted text in `to_case`

        Args:
            from_case: Case to convert from. Can be `None`.
            to_case: Case to convert to.

        Returns:
            A mutable mapping which is used to store `from_case` -> `to_case`
            conversions.
        """
        if from_case is not None:
            from_case = get_letter_case(from_case)
        to_case = get_letter_case(to_case)

        forward, backward = self._get_memo_tuple(from_case, to_case)
        return _CaseSensitiveInverseDict(forward, backward, get_converter(to_case, from_case))


MemoType = Union[ConversionMemo, Mapping[str, str], MutableMapping[str, str]]

MEMO_CONVERTER_FLAG = "__memoized__"


def memo_converter(converter: ConverterType, memo: Union[Mapping[str, str], MutableMapping[str, str]]) -> ConverterType:
    """Decorator which adds memoization to a converter.

    Args:
        converter: Converter to patch
        memo: Memoization mapping to use. If the mapping is mutable it will automatically be updated with new keys.

    Examples:
        >>> memo_data = {}
        >>> converter = memo_converter(get_converter("snake", "dromedary"), memo_data)
        >>> print(converter("snake_test"))
        snakeTest
        >>> print(memo_data)
        {'snake_test': 'snakeTest'}
    """

    @wraps(converter)
    def wrapper(text: str) -> str:
        try:
            return memo[text]
        except KeyError:
            pass

        new_text = converter(text)

        with suppress(Exception):
            memo[text] = new_text

        return new_text

    setattr(wrapper, MEMO_CONVERTER_FLAG, True)

    return wrapper


def is_memo_converter(converter: ConverterType) -> bool:
    """Check if a converter is memoized using `memo_converter`.

    Args:
        converter: Converter to check

    Returns:
        `True` if the converter is memoized, `False` otherwise.
    """
    return getattr(converter, MEMO_CONVERTER_FLAG, False)


def _get_converter(from_case: Optional[LetterCaseType], to_case: LetterCaseType,
                   memo: Optional[MemoType]) -> ConverterType:
    """Internal utility function to get a patched converter.

    Raises:
        ValueError: If no converter was found from `from_case` to `to_case`
    """
    converter = get_converter(from_case, to_case)
    if not converter:
        if from_case:
            text = f"No converter for {from_case} -> {to_case}"
        else:
            text = f"No general converter to {to_case}"

        raise ValueError(text)

    if memo is not None:
        if isinstance(memo, ConversionMemo):
            memo = memo.get_memo(from_case, to_case)

        converter = memo_converter(converter, memo)

    return converter


def convert_iter_items(iterable: Iterable[str], from_case: Optional[LetterCaseType], to_case: LetterCaseType, *,
                       memo: MemoType = None) -> Iterator[str]:
    """Patch an iterable so that all items are converted to the case.

    Args:
        iterable: Iterable to convert
        from_case: `LetterCase` to convert from, passing `None` will use a general converter.
        to_case: `LetterCase` to convert to
        memo: Memoization mapping to make conversion faster.
    """
    converter = _get_converter(from_case, to_case, memo)
    return map(converter, iterable)


def mut_convert_items(seq: MutableSequence[str], from_case: Optional[LetterCaseType], to_case: LetterCaseType, *,
                      memo: MemoType = None) -> None:
    """Convert all items in a mutable sequence to the given case."""
    converter = _get_converter(from_case, to_case, memo)

    for i, item in enumerate(seq):
        new_item = converter(item)
        if new_item != item:
            seq[i] = new_item


def mut_convert_keys(mapping: MutableMapping[str, Any], from_case: Optional[LetterCaseType], to_case: LetterCaseType, *,
                     memo: MemoType = None) -> None:
    """Convert all keys in a mutable mapping to the given case.

    Args:
        mapping: Mapping whose keys are to be converted
        from_case: Specify the case to convert from. If not provided a general converter is used.
        to_case: `LetterCase` to convert to
        memo: Memoization map to use.
    """
    converter = _get_converter(from_case, to_case, memo)

    original_keys: Set[str] = set(mapping.keys())

    for key in original_keys:
        new_key = converter(key)
        if new_key != key:
            mapping[new_key] = mapping.pop(key)