From 30d5c1e66bc340398685262545fcd1105223fe4a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 5 Jan 2023 21:11:14 +0000 Subject: [PATCH] Add a generic immutable sequence wrapper class In anticipation of satisfying #1398, this adds a generic immutable sequence wrapper class. The idea being that it can be used to wrap up a list or similar, that you don't want the caller to modify. This commit aims to get the basics down for this, and also adds a minimal set of unit tests. --- src/textual/_collections.py | 65 +++++++++++++++++++++++++++++ tests/test_collections.py | 81 +++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/textual/_collections.py create mode 100644 tests/test_collections.py diff --git a/src/textual/_collections.py b/src/textual/_collections.py new file mode 100644 index 0000000000..09b8af206d --- /dev/null +++ b/src/textual/_collections.py @@ -0,0 +1,65 @@ +"""Provides collection-based utility code.""" + +from __future__ import annotations +from typing import Generic, TypeVar, Iterator, overload, Iterable + +T = TypeVar("T") + + +class ImmutableSequence(Generic[T]): + """Class to wrap a sequence of some sort, but not allow modification.""" + + def __init__(self, wrap: Iterable[T]) -> None: + """Initialise the immutable sequence. + + Args: + wrap (Iterable[T]): The iterable value being wrapped. + """ + self._list = list(wrap) + + @overload + def __getitem__(self, index: int) -> T: + ... + + @overload + def __getitem__(self, index: slice) -> ImmutableSequence[T]: + ... + + def __getitem__(self, index: int | slice) -> T | ImmutableSequence[T]: + return ( + self._list[index] + if isinstance(index, int) + else ImmutableSequence[T](self._list[index]) + ) + + def __iter__(self) -> Iterator[T]: + return iter(self._list) + + def __len__(self) -> int: + return len(self._list) + + def __length_hint__(self) -> int: + return len(self) + + def __bool__(self) -> bool: + return bool(len(self)) + + def __contains__(self, item: T) -> bool: + return item in self._list + + def index(self, item: T) -> int: + """Return the index of the given item. + + Args: + item (T): The item to find in the sequence. + + Returns: + int: The index of the item in the sequence. + + Raises: + ValueError: If the item is not in the sequence. + """ + return self._list.index(item) + + def __reversed__(self) -> Iterator[T]: + return reversed(self._list) diff --git a/tests/test_collections.py b/tests/test_collections.py new file mode 100644 index 0000000000..cd4d0f71e6 --- /dev/null +++ b/tests/test_collections.py @@ -0,0 +1,81 @@ +import pytest + +from typing import Iterable +from textual._collections import ImmutableSequence + +def wrap(source: Iterable[int]) -> ImmutableSequence[int]: + """Wrap an itertable of integers inside an immutable sequence.""" + return ImmutableSequence[int](source) + + +def test_empty_immutable_sequence() -> None: + """An empty immutable sequence should act as anticipated.""" + assert len(wrap([])) == 0 + assert bool(wrap([])) is False + assert list(wrap([])) == [] + + +def test_non_empty_immutable_sequence() -> None: + """A non-empty immutable sequence should act as anticipated.""" + assert len(wrap([0])) == 1 + assert bool(wrap([0])) is True + assert list(wrap([0])) == [0] + + +def test_immutable_sequence_from_empty_iter() -> None: + """An immutable sequence around an empty iterator should act as anticipated.""" + assert len(wrap([])) == 0 + assert bool(wrap([])) is False + assert list(wrap(iter([]))) == [] + + +def test_immutable_sequence_from_non_empty_iter() -> None: + """An immutable sequence around a non-empty iterator should act as anticipated.""" + assert len(wrap(range(23))) == 23 + assert bool(wrap(range(23))) is True + assert list(wrap(range(23))) == list(range(23)) + + +def test_no_assign_to_immutable_sequence() -> None: + """It should not be possible to assign into an immutable sequence.""" + tester = wrap([1,2,3,4,5]) + with pytest.raises(TypeError): + tester[0] = 23 + with pytest.raises(TypeError): + tester[0:3] = 23 + + +def test_no_del_from_iummutable_sequence() -> None: + """It should not be possible delete an item from an immutable sequence.""" + tester = wrap([1,2,3,4,5]) + with pytest.raises(TypeError): + del tester[0] + + +def test_get_item_from_immutable_sequence() -> None: + """It should be possible to get an item from an immutable sequence.""" + assert wrap(range(10))[0] == 0 + assert wrap(range(10))[-1] == 9 + +def test_get_slice_from_immutable_sequence() -> None: + """It should be possible to get a slice from an immutable sequence.""" + assert list(wrap(range(10))[0:2]) == [0,1] + assert list(wrap(range(10))[0:-1]) == [0,1,2,3,4,5,6,7,8] + + +def test_immutable_sequence_contains() -> None: + """It should be possible to see if an immutable sequence contains a value.""" + tester = wrap([1,2,3,4,5]) + assert 1 in tester + assert 11 not in tester + + +def test_immutable_sequence_index() -> None: + tester = wrap([1,2,3,4,5]) + assert tester.index(1) == 0 + with pytest.raises(ValueError): + _ = tester.index(11) + + +def test_reverse_immutable_sequence() -> None: + assert list(reversed(wrap([1,2]))) == [2,1]