Source code for nocaselist._nocaselist

"""
This module provides class NocaseList.
"""

import sys
import os
from typing import Callable, AnyStr, Optional, Union
try:
    from typing import SupportsIndex  # type: ignore
except ImportError:
    from typing_extensions import SupportsIndex  # Python <=3.7
try:
    from typing import TypeAlias  # type: ignore
except ImportError:
    from typing_extensions import TypeAlias  # Python <=3.9
if sys.version_info[0:2] >= (3, 9):
    from collections.abc import Iterable  # type: ignore
else:
    # Before py39, collections.abc.Iterable did not support generic type
    from typing import Iterable

__all__ = ['NocaseList']

# This env var is set when building the docs. It causes the methods
# that are supposed to exist only in a particular Python version, not to be
# removed, so they appear in the docs.
BUILDING_DOCS = os.environ.get('BUILDING_DOCS', False)

# Type for values in NocaseList
Value: TypeAlias = Optional[AnyStr]

# Type for returning single values or slices
ValueOrNocaseList: TypeAlias = Union[Value, 'NocaseList']

# Type for other argument in rich comparison methods
OtherList: TypeAlias = Iterable[Value]  # pylint: disable=unsubscriptable-object

# Type for index argument that can also specify a slice
IndexOrSlice: TypeAlias = Union[SupportsIndex, slice]


[docs]class NocaseList(list): """ A case-insensitive and case-preserving list. The list is case-insensitive: Whenever items of the list are looked up by value or item values are compared, that is done case-insensitively. The case-insensitivity is defined by performing the lookup or comparison on the result of the :meth:`__casefold__` method on the on list items. `None` is allowed as a list value and will not be case folded. The list is case-preserving: Whenever the value of list items is returned, they have the lexical case that was originally specified when adding or updating the item. Except for the case-insensitivity of its items, it behaves like, and is in fact derived from, the built-in :class:`py:list` class in order to facilitate type checks. The implementation maintains a second list with the casefolded items of the inherited list, and ensures that both lists are in sync. The list supports serialization via the Python :mod:`py:pickle` module. To save space and time, only the originally cased list is serialized. """ # Methods not implemented: # # * __getattribute__(self, name): The method inherited from object is used; # no reason to have a different implementation. # # * __sizeof__(self): The method inherited from list is used; no reason # to have a different implementation. # # * __len__(self): The method inherited from list is used; no reason # to have a different implementation. # # __repr__(): The method inherited from list is used; no reason # to have a different implementation. # # __getitem__(): The method inherited from list is used; no reason # to have a different implementation. # # __iter__(): The method inherited from list is used; no reason # to have a different implementation. def __init__(self, iterable=()) -> None: """ Initialize the list with the items in the specified iterable. """ super(NocaseList, self).__init__(iterable) # The _casefolded_list attribute is a list with the same items as the # original (inherited) list, except they are casefolded using the # __casefold__() method. # The following is an optimization based on the assumption that in # many cases, casefolding the input list is more expensive than # copying it (plus the overhead to check that). if isinstance(iterable, NocaseList): # pylint: disable=protected-access casefolded_list = iterable._casefolded_list.copy() else: casefolded_list = self._new_casefolded_list(self) self._casefolded_list: list = casefolded_list def _new_casefolded_list(self, lst: OtherList) -> list: """ Return a casefolded list from the input list. """ result = [] for value in lst: result.append(self._casefolded_value(value)) return result def _casefolded_value(self, value: Value) -> Value: """ This method returns the casefolded value and handles the case of value being `None`. The value may be a string or an list/tuple of strings. """ if value is None: return None if isinstance(value, (list, tuple)): return [self._casefolded_value(v) for v in value] return self.__casefold__(value)
[docs] @staticmethod def __casefold__(value: AnyStr) -> AnyStr: """ This method implements the case-insensitive behavior of the class. It returns a case-insensitive form of the input value by calling a "casefold method" on the value. The input value will not be `None`. The casefold method called by this method is :meth:`py:str.casefold`. If that method does not exist on the key value (e.g. because it is a byte string), :meth:`py:bytes.lower` is called, for compatibility with earlier versions of the package. This method can be overridden by users in order to change the case-insensitive behavior of the class. See :ref:`Overriding the default casefold method` for details. Parameters: value (str or bytes): Input value. Will not be `None`. Returns: str or bytes: Case-insensitive form of the input value. Raises: AttributeError: The value does not have the casefold method. """ try: return value.casefold() # type: ignore except AttributeError: return value.lower()
[docs] def __getstate__(self): """ Called when pickling the object, see :meth:`py:object.__getstate__`. In order to save space and time, only the list with the originally cased items is saved, but not the second list with the casefolded items. """ # This copies the state of the inherited list even though it is # not visible in self.__dict__. state = self.__dict__.copy() del state['_casefolded_list'] return state
[docs] def __setstate__(self, state): """ Called when unpickling the object, see :meth:`py:object.__setstate__`. """ self.__dict__.update(state) self._casefolded_list = self._new_casefolded_list(self)
[docs] def __setitem__(self, index: IndexOrSlice, value: Value) -> None: """ Update the value of the item at an existing index or slice in the list. Invoked using ``ncl[index] = value``. Raises: AttributeError: The value does not have the casefold method. """ super(NocaseList, self).__setitem__(index, value) # type: ignore self._casefolded_list[index] = self._casefolded_value(value) # type: ignore # noqa: E501 pylint: disable=line-too-long
[docs] def __delitem__(self, index: IndexOrSlice) -> None: """ Delete an item at an existing index or slice from the list. Invoked using ``del ncl[index]``. """ super(NocaseList, self).__delitem__(index) del self._casefolded_list[index]
[docs] def __contains__(self, value: Value) -> bool: """ Return a boolean indicating whether the list contains at least one item with the value, by looking it up case-insensitively. Invoked using ``value in ncl``. Raises: AttributeError: The value does not have the casefold method. """ return self._casefolded_value(value) in self._casefolded_list
[docs] def __add__(self, other: OtherList) -> 'NocaseList': """ Return a new :class:`NocaseList` object that contains the items from the left hand operand (``self``) and the items from the right hand operand (``other``). The right hand operand (``other``) must be an instance of :class:`py:list` (including :class:`NocaseList`) or :class:`py:tuple`. The operands are not changed. Invoked using e.g. ``ncl + other`` Raises: TypeError: The other iterable is not a list or tuple """ if not isinstance(other, (list, tuple)): raise TypeError( "Can only concatenate list or tuple (not {t}) to NocaseList". format(t=type(other))) lst = self.copy() lst.extend(other) return lst
[docs] def __iadd__(self, other: OtherList) -> 'NocaseList': """ Extend the left hand operand (``self``) by the items from the right hand operand (``other``). The ``other`` parameter must be an iterable but is otherwise not restricted in type. Thus, if it is a string, the characters of the string are added as distinct items to the list. Invoked using ``ncl += other``. """ # Note: It is unusual that the method has to return self, but it was # verified that this is necessary. self.extend(other) return self
[docs] def __mul__(self, number: int) -> 'NocaseList': # type: ignore """ Return a new :class:`NocaseList` object that contains the items from the left hand operand (``self``) as many times as specified by the right hand operand (``number``). A number <= 0 causes the returned list to be empty. The left hand operand (``self``) is not changed. Invoked using ``ncl * number``. """ # Despite using type hints, passing a non-int object does not raise # any exception, so we still need to check the type: if not isinstance(number, int): raise TypeError( "Cannot multiply NocaseList by non-integer of type {t}". format(t=type(number))) lst = NocaseList() for _ in range(0, number): lst.extend(self) return lst
[docs] def __rmul__(self, number: int) -> 'NocaseList': # type: ignore """ Return a new :class:`NocaseList` object that contains the items from the right hand operand (``self``) as many times as specified by the left hand operand (``number``). A number <= 0 causes the returned list to be empty. The right hand operand (``self``) is not changed. Invoked using ``number * ncl``. """ lst = self * number # Delegates to __mul__() return lst
[docs] def __imul__(self, number: int) -> 'NocaseList': # type: ignore """ Change the left hand operand (``self``) so that it contains the items from the original left hand operand (``self``) as many times as specified by the right hand operand (``number``). A number <= 0 will empty the left hand operand. Invoked using ``ncl *= number``. """ # Despite using type hints, passing a non-int object does not raise # any exception, so we still need to check the type: if not isinstance(number, int): raise TypeError( "Cannot multiply NocaseList by non-integer of type {t}". format(t=type(number))) if number <= 0: del self[:] del self._casefolded_list[:] else: self_items = list(self) for _ in range(0, number - 1): self.extend(self_items) # Note: It is unusual that the method has to return self, but it was # verified that this is necessary. return self
[docs] def __reversed__(self) -> 'NocaseList': # type: ignore """ Return a shallow copy of the list that has its items reversed in order. Invoked using ``reversed(ncl)``. """ lst = self.copy() lst.reverse() return lst
[docs] def __eq__(self, other: object) -> bool: """ Return a boolean indicating whether the list and the other list are equal, by comparing corresponding list items case-insensitively. The other list may be a :class:`NocaseList` object or any other iterable. In all cases, the comparison takes place case-insensitively. Invoked using e.g. ``ncl == other``. Raises: AttributeError: A value in the other list does not have the casefold method. """ if isinstance(other, NocaseList): # pylint: disable=protected-access return self._casefolded_list == other._casefolded_list if isinstance(other, Iterable): return self._casefolded_list == self._new_casefolded_list(other) return NotImplemented
[docs] def __ne__(self, other: object) -> bool: """ Return a boolean indicating whether the list and the other list are not equal, by comparing corresponding list items case-insensitively. The other list may be a :class:`NocaseList` object or any other iterable. In all cases, the comparison takes place case-insensitively. Invoked using e.g. ``ncl != other``. Raises: AttributeError: A value in the other list does not have the casefold method. """ eq = self.__eq__(other) if eq is NotImplemented: return NotImplemented return not eq
[docs] def __gt__(self, other: object) -> bool: """ Return a boolean indicating whether the list is greater than the other list, by comparing corresponding list items case-insensitively. The other list may be a :class:`NocaseList` object or any other iterable. In all cases, the comparison takes place case-insensitively. Invoked using e.g. ``ncl > other``. Raises: AttributeError: A value in the other list does not have the casefold method. """ if isinstance(other, NocaseList): # pylint: disable=protected-access return self._casefolded_list > other._casefolded_list if isinstance(other, Iterable): return self._casefolded_list > self._new_casefolded_list(other) return NotImplemented
[docs] def __lt__(self, other: object) -> bool: """ Return a boolean indicating whether the list is less than the other list, by comparing corresponding list items case-insensitively. The other list may be a :class:`NocaseList` object or any other iterable. In all cases, the comparison takes place case-insensitively. Invoked using e.g. ``ncl < other``. Raises: AttributeError: A value in the other list does not have the casefold method. """ if isinstance(other, NocaseList): # pylint: disable=protected-access return self._casefolded_list < other._casefolded_list if isinstance(other, Iterable): return self._casefolded_list < self._new_casefolded_list(other) return NotImplemented
[docs] def __ge__(self, other: object) -> bool: """ Return a boolean indicating whether the list is greater than or equal to the other list, by comparing corresponding list items case-insensitively. The other list may be a :class:`NocaseList` object or any other iterable. In all cases, the comparison takes place case-insensitively. Invoked using e.g. ``ncl >= other``. Raises: AttributeError: A value in the other list does not have the casefold method. """ lt = self.__lt__(other) if lt is NotImplemented: return NotImplemented return not lt
[docs] def __le__(self, other: object) -> bool: """ Return a boolean indicating whether the list is less than or equal to the other list, by comparing corresponding list items case-insensitively. The other list may be a :class:`NocaseList` object or any other iterable. In all cases, the comparison takes place case-insensitively. Invoked using e.g. ``ncl <= other``. Raises: AttributeError: A value in the other list does not have the casefold method. """ gt = self.__gt__(other) if gt is NotImplemented: return NotImplemented return not gt
[docs] def count(self, value: Value) -> int: """ Return the number of times the specified value occurs in the list, comparing the value and the list items case-insensitively. Raises: AttributeError: The value does not have the casefold method. """ return self._casefolded_list.count(self._casefolded_value(value))
[docs] def copy(self) -> 'NocaseList': """ Return a shallow copy of the list. """ return NocaseList(self)
[docs] def clear(self) -> None: """ Remove all items from the list (and return None). """ super(NocaseList, self).clear() self._casefolded_list.clear()
[docs] def index(self, value: Value, start: SupportsIndex = 0, stop: SupportsIndex = 9223372036854775807) -> int: """ Return the index of the first item that is equal to the specified value, comparing the value and the list items case-insensitively. The search is limited to the index range defined by the specified ``start`` and ``stop`` parameters, whereby ``stop`` is the index of the first item after the search range. Raises: AttributeError: The value does not have the casefold method. ValueError: No such item is found. """ return self._casefolded_list.index( self._casefolded_value(value), start, stop)
[docs] def append(self, value: Value) -> None: """ Append the specified value as a new item to the end of the list (and return None). Raises: AttributeError: The value does not have the casefold method. """ super(NocaseList, self).append(value) self._casefolded_list.append(self._casefolded_value(value))
[docs] def extend(self, values: Iterable) -> None: """ Extend the list by the items in the specified iterable (and return None). Raises: AttributeError: A value in the iterable does not have the casefold method. """ super(NocaseList, self).extend(values) # The following is a circumvention for a behavior of the 'pickle' module # that during unpickling may call this method on an object that has # been created with __new__() without calling __init__(). try: for value in values: self._casefolded_list.append(self._casefolded_value(value)) except AttributeError: self._casefolded_list = self._new_casefolded_list(self)
[docs] def insert(self, index: SupportsIndex, value: Value) -> None: """ Insert a new item with specified value before the item at the specified index (and return None). Raises: AttributeError: The value does not have the casefold method. """ super(NocaseList, self).insert(index, value) self._casefolded_list.insert(index, self._casefolded_value(value))
[docs] def pop(self, index: SupportsIndex = -1) -> Value: """ Return the value of the item at the specified index and also remove it from the list. """ self._casefolded_list.pop(index) return super(NocaseList, self).pop(index)
[docs] def remove(self, value: Value) -> None: """ Remove the first item from the list whose value is equal to the specified value (and return None), comparing the value and the list items case-insensitively. Raises: AttributeError: The value does not have the casefold method. """ self._casefolded_list.remove(self._casefolded_value(value)) super(NocaseList, self).remove(value)
[docs] def reverse(self) -> None: """ Reverse the items in the list in place (and return None). """ super(NocaseList, self).reverse() self._casefolded_list = self._new_casefolded_list(self)
[docs] def sort(self, *, key: Optional[Callable] = None, reverse: bool = False) -> None: """ Sort the items in the list in place (and return None). The sort is stable, in that the order of two (case-insensitively) equal elements is maintained. By default, the list is sorted in ascending order of its casefolded item values. If a key function is given, it is applied once to each casefolded list item and the list is sorted in ascending or descending order of their key function values. The ``reverse`` flag can be set to sort in descending order. """ def casefolded_key(value): """Key function used for sorting""" # The function cannot raise AttributeError due to missing casefold # method, because the list items have been verified for that when # adding them to the list. if key: return key(self._casefolded_value(value)) return self._casefolded_value(value) super(NocaseList, self).sort(key=casefolded_key, reverse=reverse) self._casefolded_list = self._new_casefolded_list(self)