diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index f04b5f2..3103ed4 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -7,7 +7,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [ 3.7, 3.8, 3.9, '3.10' ] + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11-dev'] os: [ ubuntu-latest, macOS-latest, windows-latest ] steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index af722f2..68a9253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,90 @@ # Changelog +## Version 0.0.3 (25 Dec 2021) + +See below for a break down of the development status of all methods provided by +the `Iter` class. Some methods also provide overloads via `typing`. Support for +working with dictionaries is rather scarce. Like `Enum` in Elixir, this is an eager +implementation meaning that generators are always converted into lists, thus consumed +all at once. Perhaps there will be a `Stream`-like structure in the future that's +compatible with `Iter`, though it remains to be seen whether such a thing will be +implemented in the future. + +- [x] `self.__init__` +- [x] `self.all` +- [x] `self.any` +- [x] `self.at` +- [x] `self.avg` +- [x] `self.chunk_by` +- [x] `self.chunk_every` +- [ ] `self.chunk_while` +- [x] `self.concat` +- [x] `self.count` +- [x] `self.count_until` +- [x] `self.dedup` +- [x] `self.dedup_by` +- [x] `self.drop` +- [x] `self.drop_every` +- [x] `self.drop_while` +- [x] `self.each` +- [x] `self.empty` +- [x] `self.fetch` +- [x] `self.filter` +- [x] `self.find` +- [x] `self.find_index` +- [x] `self.find_value` +- [x] `self.flat_map` +- [ ] `self.flat_map_reduce` +- [x] `self.frequencies` +- [x] `self.frequencies_by` +- [x] `self.group_by` +- [x] `self.intersperse` +- [x] `self.into` +- [x] `self.join` +- [x] `self.map` +- [x] `self.map_every` +- [x] `self.map_intersperse` +- [x] `self.map_join` +- [x] `self.map_reduce` +- [x] `self.max` +- [x] `self.member` +- [x] `self.min` +- [x] `self.min_max` +- [x] `self.product` +- [x] `self.random` +- [x] `Iter.range` +- [x] `self.reduce` +- [x] `self.reduce_while` +- [x] `self.reject` +- [x] `self.reverse` +- [x] `self.reverse_slice` +- [x] `self.scan` +- [x] `Iter.shorten` +- [x] `self.shuffle` +- [x] `self.slice` +- [x] `self.slide` +- [x] `self.sort` +- [x] `self.split` +- [x] `self.split_while` +- [x] `self.split_with` +- [x] `self.sum` +- [x] `self.take` +- [x] `self.take_every` +- [x] `self.take_random` +- [x] `self.take_while` +- [x] `self.uniq` +- [x] `self.unzip` +- [x] `self.with_index` +- [x] `self.zip` +- [ ] `self.zip_reduce` +- [ ] `self.zip_with` +- [x] `self.__repr__` +- [x] `self.__str__` + +The static non-standard functions `Iter.range` and `Iter.shorten` have been added +for convenience purposes only. The method signature `uniq_by` does not appear in +this list yet; it may be added in future versions of this library. + ## Version 0.0.2 (16 Dec 2021) Finalize package structure for development and setup release pipeline. diff --git a/README.md b/README.md index 292884e..a149606 100644 --- a/README.md +++ b/README.md @@ -18,4 +18,6 @@

-Implements an iterator interface reminscent of languages such as Elixier of F#. +Implements an eager iterator class reminiscent of Elixir's `Enum` structure. See +also the changelog for more details. Note that the interface is not stable; signatures +may change at any time until further notice. diff --git a/setup.py b/setup.py index 5a13144..1d47c49 100644 --- a/setup.py +++ b/setup.py @@ -42,13 +42,13 @@ author_email="greve.stefan@outlook.jp", name=package_name, version=version, - description="Implements an iterator interface reminscent of languages such as Elixier of F#.", + description="Implements an eager iterator class reminiscent of Elixir's Enum structure.", long_description=long_description, long_description_content_type='text/markdown', license='MIT', url="https://github.com/StefanGreve/iterfun", project_urls={ - 'Documentation': "https://github.com/StefanGreve/iterfun/blob/master/README.md", #TODO: Add proper documention + 'Documentation': "https://github.com/StefanGreve/iterfun/blob/master/README.md", 'Source Code': "https://github.com/StefanGreve/iterfun", 'Bug Reports': "https://github.com/StefanGreve/iterfun/issues", 'Changelog': "https://github.com/StefanGreve/iterfun/blob/master/CHANGELOG.md" @@ -63,7 +63,7 @@ package_dir={'': 'src'}, packages=find_packages(where='src'), classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'License :: OSI Approved :: MIT License', 'Intended Audience :: Developers', 'Natural Language :: English', @@ -71,6 +71,8 @@ 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: Implementation :: CPython', 'Operating System :: OS Independent', 'Topic :: Utilities', ], diff --git a/src/iterfun/__init__.py b/src/iterfun/__init__.py index 9ad524a..0b3735b 100644 --- a/src/iterfun/__init__.py +++ b/src/iterfun/__init__.py @@ -2,7 +2,7 @@ from .iterfun import Iter -__version__ = "0.0.2" +__version__ = "0.0.3" package_name = "iterfun" python_major = "3" python_minor = "7" diff --git a/src/iterfun/iterfun.py b/src/iterfun/iterfun.py index 4bf6d01..25936d5 100644 --- a/src/iterfun/iterfun.py +++ b/src/iterfun/iterfun.py @@ -1,6 +1,1264 @@ #!/usr/bin/env python3 +from __future__ import annotations + +import functools +import itertools +import operator +import random +import statistics +import textwrap +from collections import ChainMap, Counter +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union, overload + + class Iter: + """ + Define a set of algorithms to work with iterable collections. In Python, + an iterable is any data type that implements the `__iter__` method which + returns an iterator, or alternatively, a `__getitem__` method suitable for + indexed lookup such as `list`, `tuple`, `dict`, or `set`. + + ```python + >>> Iter([1, 3]).map(lambda x: 2*x) + [2, 4, 6] + >>> Iter([1, 2, 3]).sum() + 6 + >>> Iter({'a': 1, 'b': 2}).map(lambda k, v: {k: 2 * v} + {'a': 2, 'b': 4} + ``` + """ + + @overload + def __init__(self, iter: Iterable, interval: bool=False) -> Iter: ... + + @overload + def __init__(self, iter: List[int, int], interval: bool=True) -> Iter: ... + + @overload + def __init__(self, iter: Tuple[int, int], interval: bool=True) -> Iter: ... + + def __init__(self, iter: Iterable | List[int, int] | Tuple[int, int], interval: bool=True) -> Iter: + self.image = Iter.__ctor(iter) if interval and len(iter) == 2 else iter + self.domain = self.image + @staticmethod - def hello(): - return "Hello, World!" + def __ctor(iter: Iterable | List[int, int] | Tuple[int, int]) -> List: + if (isinstance(iter, Tuple) or isinstance(iter, List)) and (isinstance(iter[0], int) and isinstance(iter[1], int)): + return Iter.range(iter) if iter[1] != 0 else [] + return iter + + def all(self, fun: Optional[Callable[[Any], bool]]=None) -> bool: + """ + Return `True` if all elements in `self.image` are truthy, or `True` if + `fun` is not None and its map truthy for all elements in `self.image`. + + ```python + >>> Iter([1, 2, 3]).all() + True + >>> Iter([1, None, 3]).all() + False + >>> Iter([]).all() + True + >>> Iter([1, 2, 3]).all(lambda x: x % 2 == 0) + False + ``` + """ + self.image = all(self.image) if fun is None else all(map(fun, self.image)) + return self.image + + def any(self, fun: Optional[Callable[[Any], bool]]=None) -> bool: + """ + Return `True` if any elements in `self.image` are truthy, or `True` if + `fun` is not None and its map truthy for at least on element in `self.image`. + + ```python + >>> Iter([False, False, False]).any() + False + >>> Iter([False, True, False]).any() + True + >>> Iter([2, 4, 6]).any(lambda x: x % 2 == 1) + False + ``` + """ + self.image = any(self.image) if fun is None else any(map(fun, self.image)) + return self.image + + def at(self, index: int) -> Any: + """ + Find the element at the given `index` (zero-based). Raise an `IndexError` + if `index` is out of bonds. A negative index can be passed, which means + `self.image` is enumerated once and the index is counted from the end. + + ```python + >>> Iter([2, 4, 6]).at(0) + 2 + >>> Iter([2, 4, 6]).at(-1) + 6 + >>> Iter([2, 4, 6]).at(4) + IndexError: list index out of range + ``` + """ + self.image = self.image[index] + return self.image + + def avg(self) -> Union[int, float]: + """ + Return the sample arithmetic mean of `self.image`. + + ```python + >>> Iter([0, 10]).avg() + 5 + ``` + """ + self.image = statistics.mean(self.image) + return self.image + + def chunk_by(self, fun: Callable[[Any], bool]) -> Iter: + """ + Split `self.image` on every element for which `fun` returns a new value. + + ```python + >>> Iter([1, 2, 2, 3, 4, 4, 6, 7, 7, 7]).chunk_by(lambda x: x % 2 == 1) + [[1], [2, 2], [3], [4, 4, 6], [7, 7, 7]] + ``` + """ + self.image = [list(group) for _, group in itertools.groupby(self.image, fun)] + return self + + def chunk_every(self, count: int, step: Optional[int]=None, leftover: Optional[List[Any]]=None) -> Iter: + """ + Return list of lists containing `count` elements each. `step` is optional + and, if not passed, defaults to `count`, i.e. chunks do not overlap. + + ```python + >>> Iter([1, 6]).chunk_every(2) + [[1, 2], [3, 4], [5, 6]] + >>> Iter([1, 6]).chunk_every(3, 2, [7]) + [[1, 2, 3], [3, 4, 5], [5, 6, 7]] + >>> Iter([1, 4]).chunk_every(3, 3) + [[1, 2, 3], [4]] + ``` + """ + step = step or count + self.image = [list(self.image)[i:i+count] for i in range(0, len(self.image), count-(count-step))] + if leftover: self.image[-1].extend(leftover[:len(self.image[-1])]) + return self + + def chunk_while(self, acc: List, chunk_fun: Callable, chunk_after: Callable) -> Iter: + raise NotImplementedError() + + def concat(self) -> Iter: + """ + Given a list of lists, concatenates the list into a single list. + + ```python + >>> Iter([[1, 2, 3], [4, 5, 6]]).concat() + [1, 2, 4, 5, 6] + >>> Iter([[1, [2], 3], [4], [5, 6]]).concat() + [1, [2], 3, 4, 5, 6] + ``` + + mode with ranges: + + ```python + >>> Iter([[1, 4], [5, 6], [7, 9]]).concat() + [1, 2, 3, 4, 5, 6, 7, 8, 9] + >>> Iter([(0, 4), (5, 6), (7, 9)]).concat() + [1, 2, 3, 8] + >>> Iter([(0, 4), (3, 7), (6, 10)]).concat() + [1, 2, 3, 4, 5, 6, 7, 8, 9] + ``` + """ + if all(map(lambda x: len(x) == 2, self.image)): + self.image = list(itertools.chain(*map(Iter.range, self.image))) + else: + self.image = list(itertools.chain(*self.image)) + return self + + def count(self, fun: Optional[Callable[[Any], bool]]=None) -> int: + """ + Return the size of the `self.image` if `fun` is `None`, else return the + count of elements in `self.image` for which `fun` returns a truthy value. + + ```python + >>> Iter([1, 3]).count() + 3 + >>> Iter([1, 5]).count(lambda x: x % 2 == 0) + 2 + ``` + """ + return len(list(self.image)) if fun is None else len(list(filter(fun, self.image))) + + def count_until(self, limit: int, fun: Optional[Callable[[Any], bool]]=None) -> int: + """ + Count the elements in `self.image` for which `fun` returns a truthy value, + stopping at `limit`. + + ```python + >>> Iter([1, 20]).count_until(5) + 5 + >>> Iter([1, 20]).count_until(50) + 20 + ``` + """ + return len(list(self.image)[:limit]) if fun is None else len(list(filter(fun, self.image))[:limit]) + + def dedup(self) -> Iter: + """ + Enumerate `self.image`, returning a list where all consecutive duplicated + elements are collapsed to a single element. + + ```python + >>> Iter([1, 2, 3, 3, 2, 1]).dedup() + [1, 2, 3, 2, 1] + ``` + """ + self.image = list(map(operator.itemgetter(0), itertools.groupby(self.image))) + return self + + def dedup_by(self, fun: Callable[[Any], bool]): + """ + Enumerates `self.image`, returning a list where all consecutive duplicated + elements are collapsed to a single element. + + ```python + >>> Iter([5, 1, 2, 3, 2, 1]).dedup_by(lambda x: x > 2) + [5, 1, 3, 2] + >>> Iter([0, 4, 9, 1, 2, 0, 3, 4, 9]).dedup_by(lambda x: x < 2) + [0, 4, 1, 2, 0, 3] + >>> Iter([3, 6, 7, 7, 2, 0, 1, 4, 1]).dedup_by(lambda x: x == 2) + [3, 2, 0] + ``` + """ + self.image = [self.image[0], *[self.image[i] for i in range(1, len(self.image)) if fun(self.image[i-1]) != fun(self.image[i])]] + return self + + def drop(self, amount: int) -> Iter: + """ + Drop the `amount` of elements from `self.image`. If a negative `amount` is + given, the `amount` of last values will be dropped. `self.image` will be + enumerated once to retrieve the proper index and the remaining calculation + is performed from the end. + + ```python + >>> Iter([1, 2, 3]).drop(2) + [3] + >>> Iter([1, 2, 3]).drop(10) + [] + >>> Iter([1, 2, 3]).drop(-1) + [1, 2] + ``` + """ + self.image = list(self.image) + self.image = self.image[amount:] if amount > 0 else self.image[:len(self.image)+amount] + return self + + def drop_every(self, nth: int) -> Iter: + """ + Return a list of every `nth` element in the `self.image` dropped, starting + with the first element. The first element is always dropped, unless `nth` + is `0`. The second argument specifying every nth element must be a non-negative + integer. + + ```python + >>> Iter([1, 10]).drop_every(2) + [2, 4, 6, 8, 10] + >>> Iter([1, 10]).drop_every(0) + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + >>> Iter([1, 3]).drop_every(1) + [] + ``` + """ + self.image = [] if nth == 1 else [self.image[i] for i in range(int(nth != 0), len(self.image), nth if nth > 1 else 1)] + return self + + def drop_while(self, fun: Callable[[Any], bool]) -> Iter: + """ + Drop elements at the beginning of `self.image` while `fun` returns a + truthy value. + + ```python + >>> Iter([1, 2, 3, 2, 1]).drop_while(lambda x: x < 3) + [3, 2, 1] + ``` + """ + self.image = list(itertools.dropwhile(fun, self.image)) + return self + + def each(self, fun: Callable[[Any], Any]) -> bool: + """ + Invoke the given `fun` for each element in `self.image`, then return + `True`. + >>> Iter([1, 3]).each(print) + 1 + 2 + 3 + """ + list(map(fun, self.image)) + return True + + def empty(self) -> bool: + """ + Return `True` if `self.image` is empty, otherwise `False`. + + ```python + >>> Iter([]).empty() + True + >>> Iter([0, 0]).empty() + True + >>> Iter([1, 10]).empty() + False + ``` + """ + return not bool(len(self.image)) + + def fetch(self, index: int) -> Dict[str, Any]: + """ + Find the element at the given index (zero-based). Return `{'ok': element}` + if found, otherwise `{'error': None}`. + + ```python + >>> Iter([2, 4, 6]).fetch(0) + {'ok': 2} + >>> Iter([2, 4, 6]).fetch(-3) + {'ok': 2} + >>> Iter([2, 4, 6]).fetch(2) + {'ok': 6} + >>> Iter([2, 4, 6]).fetch(4) + {'error': None} + ``` + """ + return {'ok': self.image[index]} if index < len(self.image) else {'error': None} + + def filter(self, fun: Callable[[Any], bool]) -> Iter: + """ + Filter `self.image`, i.e. return only those elements for which `fun` returns + a truthy value. + + ```python + >>> Iter([1, 3]).filter(lambda x: x % 2 == 0) + [2] + ``` + """ + self.image = list(filter(fun, self.image)) + return self + + def find(self, fun: Callable[[Any], bool], default: Optional[Any]=None) -> Optional[Any]: + """ + Return the first element for which `fun` returns a truthy value. If no + such element is found, return `default`. + + ```python + >>> Iter([2, 4]).find(lambda x: x % 2 == 1) + 3 + >>> Iter([2, 4, 6]).find(lambda x: x % 2 == 1) + None + >>> Iter([2, 4, 6]).find(lambda x: x % 2 == 1, default=0) + 0 + ``` + """ + return next(filter(fun, self.image), default) + + def find_index(self, fun: Callable[[Any], bool], default: Optional[Any]=None) -> Optional[Any]: + """ + Similar to `self.find`, but return the index (zero-based) of the element + instead of the element itself. + + ```python + >>> Iter([2, 4, 6]).find_index(lambda x: x % 2 == 1) + None + >>> Iter([2, 3, 4]).find_index(lambda x: x % 2 == 1) + 1 + ``` + """ + found = next(filter(fun, self.image), default) + return self.image.index(found) if found in self.image else default + + def find_value(self, fun: Callable[[Any], bool], default: Optional[Any]=None) -> Optional[Any]: + """ + Similar to `self.find`, but return the value of the function invocation instead + of the element itself. + + ```python + >>> Iter([2, 4, 6]).find_value(lambda x: x % 2 == 1) + None + >>> Iter([2, 3, 4]).find_value(lambda x: x % 2 == 1) + True + >>> Iter([1, 3]).find_value(lambda x: isinstance(x, bool), "no bools!") + 'no bools!' + ``` + """ + found = next(filter(fun, self.image), default) + return fun(found) if found is not default else default + + def flat_map(self, fun: Callable[[Any], Any]) -> Iter: + """ + Map the given `fun` over `self.image` and flattens the result. + + ```python + >>> Iter([(1, 3), (4, 6)]).flat_map(lambda x: list(range(x[0], x[1] + 1))) + [1, 2, 3, 4, 5, 6] + >>> Iter([1, 2, 3]).flat_map(lambda x: [[x]]) + [[1], [2], [3]] + ``` + """ + self.image = list(itertools.chain(*map(fun, self.image))) + return self + + def flat_map_reduce(self, fun: Callable[[Any, Any], Any], acc: int=1) -> Iter: + """ + Map and reduce an `self.image`, flattening the given results (only one + level deep). It expects an accumulator and a function that receives each + iterable element, and must return a... + + ```python + >>> #example + ``` + """ + raise NotImplementedError() + + def frequencies(self) -> Iter: + """ + Return a map with keys as unique elements of `self.image` and values as + the count of every element. + + ```python + >>> Iter([1, 2, 2, 3, 4, 5, 5, 6]).frequencies() + {1: 1, 2: 2, 3: 1, 4: 1, 5: 2, 6: 1} + ``` + """ + self.image = Counter(self.image) + return self + + def frequencies_by(self, key_fun: Callable[[Any], Any]) -> Iter: + """ + Return a map with keys as unique elements given by `key_fun` and values + as the count of every element. + + ```python + >>> Iter(["aa", "aA", "bb", "cc"]).frequencies_by(str.lower) + {"aa": 2, "bb": 1, "cc": 1} + >>> Iter(["aaa", "aA", "bbb", "cc", "c"]).frequencies_by(len) + {3: 2, 2: 2, 1: 1} + ``` + """ + self.image = Counter(map(key_fun, self.image)) + return self + + def group_by(self, key_fun: Callable[[Any], Any], value_fun: Optional[Callable[[Any], Any]]=None) -> Iter: + """ + Split `self.image` into groups based on `key_fun`. + + The result is a map where each key is given by `key_fun` and each value + is a list of elements given by `value_fun`. The order of elements within + each list is preserved from `self.image`. However, like all maps, the + resulting map is unordered. + + ```python + >>> Iter(["ant", "buffalo", "cat", "dingo"]).group_by(len) + {3: ["ant", "cat"], 5: ["dingo"], 7: ["buffalo"]} + >>> Iter(["ant", "buffalo", "cat", "dingo"]).group_by(len, operator.itemgetter(0)) + {3: ["a", "c"], 5: ["d"], 7: ["b"]} + ``` + """ + value = lambda g: list(g) if value_fun is None else list(map(value_fun, g)) + self.image = {k: value(g) for k, g in itertools.groupby(sorted(self.image, key=key_fun), key_fun)} + return self + + def intersperse(self, separator: Any) -> Iter: + """ + Intersperses separator between each element of `self.image`. + + ```python + >>> Iter([1, 3]).intersperse(0) + [1, 0, 2, 0, 3] + >>> Iter([1]).intersperse(0) + [1] + >>> Iter([]).intersperse(0) + [] + ``` + """ + self.image = list(itertools.islice(itertools.chain.from_iterable(zip(itertools.repeat(separator), self.image)), 1, None)) + return self + + def into(self, iter: Iterable) -> Iter: + """ + Insert the given `self.image` into `iter`. + + ```python + >>> Iter([1, 2]).into([]) + [1, 2] + >>> Iter({'a': 1, 'b': 2}).into({}) + {'a': 1, 'b': 2} + >>> Iter({'a': 1}).into({'b': 2}) + {'a': 1, 'b': 2} + ``` + """ + self.image = {**self.image, **iter} if isinstance(iter, Dict) else [*self.image, *iter] + return self + + def join(self, joiner: Optional[str]=None) -> str: + """ + Join `self.image` into a string using `joiner` as a separator. If `joiner` + is not passed at all, it defaults to an empty string. All elements in + `self.image` must be convertible to a string, otherwise an error is raised. + + ```python + >>> Iter([1,5]).join() + '12345' + >>> Iter[[1,5]].join(',') + '1,2,3,4,5' + ``` + """ + return f"{joiner or ''}".join(map(str, self.image)) + + @overload + def map(self, fun: Callable[[Any], Any]) -> Iter: + """ + Return a list where each element is the result of invoking `fun` on each + corresponding element of `self.image`. For dictionaries, the function expects + a key-value pair as arguments. + + ```python + >>> Iter([1,3]).map(lambda x: 2 * x) + [2, 4, 6] + >>> Iter({'a': 1, 'b': 2}).map(lambda k, v: {k: -v}) + {'a': -1, 'b': -2} + ``` + """ + ... + + @overload + def map(self, fun: Callable[[Any, Any], Dict]) -> Iter: ... + + def map(self, fun: Callable[[Any], Any]) -> Iter: + self.image = dict(ChainMap(*itertools.starmap(fun, self.image.items()))) if isinstance(self.image, Dict) else list(map(fun, self.image)) + return self + + def map_every(self, nth: int, fun: Callable[[Any], Any]) -> Iter: + """ + Return a list of results of invoking `fun` on every `nth` element of `self.image`, + starting with the first element. The first element is always passed to the given + function, unless `nth` is `0`. + + ```python + >>> Iter([1, 10]).map_every(2, lambda x: x + 1000) + [1001, 2, 1003, 4, 1005, 6, 1007, 8, 1009, 10] + >>> Iter([1, 5]).map_every(0, lambda x: x + 1000) + [1, 2, 3, 4, 5] + >>> Iter([1, 3]).map_every(1, lambda x: x + 1000) + [1001, 1002, 1003] + ``` + """ + if nth != 0: + for i in range(0, len(self.image), nth): + self.image[i] = fun(self.image[i]) + return self + + def map_intersperse(self, separator: Any, fun: Callable[[Any], Any]) -> Iter: + """ + Map and intersperses `self.image` in one pass. + + ```python + >>> Iter([1, 3]).map_intersperse(None, lambda x: 2 * x) + [2, None, 4, None, 6] + ``` + """ + self.image = list(itertools.islice(itertools.chain.from_iterable(zip(itertools.repeat(separator), map(fun, self.image))), 1, None)) + return self + + def map_join(self, fun: Callable[[Any], Any], joiner: Optional[str]=None) -> str: + """ + Map and join `self.image` in one pass. If joiner is not passed at all, it + defaults to an empty string. All elements returned from invoking `fun` must + be convertible to a string, otherwise an error is raised. + + ```python + >>> Iter([1, 3]).map_join(lambda x: 2 * x) + '246' + >>> Iter([1, 3]).map_join(lambda x: 2 * x, " = ") + '2 = 4 = 6' + ``` + """ + return f"{joiner or ''}".join(map(str, map(fun, self.image))) + + def map_reduce(self, acc: Union[int, float, complex], fun: Callable[[Any], Any], acc_fun: Optional[Callable[[Any, Any], Any]]) -> Iter: + """ + Invoke the given function to each element in `self.image` to reduce it to + a single element, while keeping an accumulator. Return a tuple where the + first element is the mapped `self.image` and the second one is the final + accumulator. + + ```python + >>> Iter([1, 3]).map_reduce(0, lambda x: 2 * x, lambda x, acc: x + acc) + ([2, 4, 6], 6) + >>> Iter([1, 3]).map_reduce(6, lambda x: x * x, operator.sub) + ([1, 4, 9], 0) + ``` + """ + self.image = (list(map(fun, self.image)), functools.reduce(acc_fun, self.image, acc)) + return self + + def max(self, fun: Optional[Callable[[Any], Any]]=None, empty_fallback: Optional[Any]=None) -> Any: + """ + Return the maximal element in `self.image` as calculated by the given `fun`. + + ```python + >>> Iter([1, 3]).max() + 3 + >>> Iter("you shall not pass".split()).max() + 'you' + >>> Iter("you shall not pass".split()).max(len) + 'shall' + >>> Iter([]).max(empty_fallback='n/a') + 'n/a' + ``` + """ + return (max(self.image, key=fun) if fun is not None else max(self.image)) if self.image else empty_fallback + + def member(self, element: Any) -> bool: + """ + Checks if element exists within `self.image`. + + ```python + >>> Iter([1, 10]).member(5) + True + >>> Iter([1, 10]).member(5.0) + False + >>> Iter([1.0, 2.0, 3.0]).member(2) + True + >>> Iter([1.0, 2.0, 3.0]).member(2.000) + True + >>> Iter(['a', 'b', 'c']).member('d') + False + ``` + """ + return element in self.image + + def min(self, fun: Optional[Callable[[Any], Any]]=None, empty_fallback: Optional[Any]=None) -> Any: + """ + Return the minimum element in `self.image` as calculated by the given `fun`. + + ```python + >>> Iter([1, 3]).max() + 1 + >>> Iter("you shall not pass".split()).max() + 'you' + >>> Iter("you shall not pass".split()).max(len) + 'not' + >>> Iter([]).max(empty_fallback='n/a') + 'n/a' + ``` + """ + return (min(self.image, key=fun) if fun is not None else min(self.image)) if self.image else empty_fallback + + def min_max(self, fun: Optional[Callable[[Any], Any]]=None, empty_fallback: Optional[Any]=None) -> Tuple: + """ + Return a tuple with the minimal and the maximal elements in `self.image`. + + ```python + >>> Iter([1, 3]).min_max() + (1, 3) + >>> Iter([]).min_max(empty_fallback=None) + (None, None) + >>> Iter(["aaa", "a", "bb", "c", "ccc"]).min_max(len) + ('a', 'aaa') + ``` + """ + return (self.min(fun, empty_fallback), self.max(fun, empty_fallback)) + + def product(self) -> Union[float, int, complex]: + """ + Return the product of all elements. + + ```python + >>> Iter([2, 3, 4]).product() + 24 + >>> Iter([2.0, 3.0, 4.0]).product() + 24.0 + ``` + """ + return functools.reduce(operator.mul, self.image, 1) + + def random(self) -> Any: + """ + Return a random element from `self.image`. + + ```python + >>> Iter([1, 100]).random() + 42 + >>> Iter([1, 100]).random() + 69 + ``` + """ + return random.choice(self.image) + + @overload + @staticmethod + def range(bounds: List[int, int]) -> List[int]: + """ + Return a sequence of integers from start to end. + + ```python + >>> Iter.range([1, 5]) + [1, 2, 3, 4, 5] + >>> Iter.range((1, 5)) + [2, 3, 4] + ``` + """ + ... + + @overload + @staticmethod + def range(bounds: Tuple[int, int]) -> List[int]: ... + + @staticmethod + def range(bounds: List[int, int] | Tuple[int, int]) -> List[int]: + return list(range(bounds[0], bounds[1]+1) if isinstance(bounds, List) else range(bounds[0]+1, bounds[1])) + + def reduce(self, fun: Callable[[Any, Any], Any], acc: int=0) -> Any: + """ + Invoke `fun` for each element in `self.image` with the accumulator. The + accumulator defaults to `0` if not otherwise specified. Reduce (sometimes + also called fold) is a basic building block in functional programming. + + ```python + >>> Iter([1, 4]).reduce(operator.add) + 10 + >>> Iter([1, 4]).reduce(lambda x, acc: x * acc, acc=1) + 24 + ``` + """ + return functools.reduce(fun, self.image, acc) + + def reduce_while(self, fun: Callable[[Any, Any], Tuple[bool, Any]], acc: int=0) -> Any: + """ + Reduce `self.image` until `fun` returns `(False, acc)`. + + ```python + >>> Iter([1, 100]).reduce_while(lambda x, acc: (True, acc + x) if x < 5 else (False, acc)) + 10 + >>> Iter([1, 100]).reduce_while(lambda x, acc: (True, acc - x) if x % 2 == 0 else (False, acc), acc=2550) + 0 + ``` + """ + return functools.reduce(lambda acc, x: fun(x, acc)[1], filter(lambda x: fun(x, acc)[0], self.image), acc) + + def reject(self, fun: Callable[[Any], bool]) -> Iter: + """ + Return a list of elements in `self.image` excluding those for which the + function `fun` returns a truthy value. + + ```python + >>> Iter([1, 3]).reject(lambda x: x % 2 == 0) + [1, 3] + ``` + """ + self.image = list(itertools.filterfalse(fun, self.image)) + return self + + @overload + def reverse(self) -> Iter: + """ + Return a list of elements in `self.image` in reverse order. + + ```python + >>> Iter([1, 5]).reverse() + [5, 4, 3, 2, 1] + ``` + + Reverse the elements in `self.image`, appends the `tail`, and returns it + as a list. + + ```python + >>> Iter([1, 3]).reverse([4, 5, 6]) + [3, 2, 1, 4, 5, 6] + ``` + """ + ... + + @overload + def reverse(self, tail: Optional[List]=None) -> Iter: ... + + def reverse(self, tail: Optional[List]=None) -> Iter: + self.image = list(reversed(self.image)) + if tail: self.image.extend(tail) + return self + + def reverse_slice(self, start_index: int, count: int) -> Iter: + """ + Reverse `self.image` in the range from initial `start_index` through `count` + elements. If `count` is greater than the size of the rest of `self.image`, + then this function will reverse the rest of `self.image`. + + ```python + >>> Iter([1, 6]).reverse_slice(2, 4) + [1, 2, 6, 5, 4, 3] + >>> Iter([1, 10]).reverse_slice(2, 4) + [1, 2, 6, 5, 4, 3, 7, 8, 9, 10] + >>> Iter([1, 10]).reverse_slice(2, 30) + [1, 2, 10, 9, 8, 7, 6, 5, 4, 3] + ``` + """ + self.image = [*self.image[:start_index], *reversed(self.image[start_index:count+start_index]), *self.image[count+start_index:]] + return self + + def scan(self, fun: Callable[[Any, Any], Any], acc: Optional[int]=None) -> Iter: + """ + Apply the given function to each element in `self.image`, storing the result + in a list and passing it as the accumulator for the next computation. Uses + the first element in `self.image` as the starting value if `acc` is `None`, + else uses the given `acc` as the starting value. + + ```python + >>> Iter([1, 5]).scan(operator.add) + [1, 3, 6, 10, 15] + >>> Iter([1, 5]).scan(lambda x, y: x + y, acc=0) + [1, 3, 6, 10, 15] + ``` + """ + acc = acc if acc is not None else 0 + self.image = list(map(lambda x: x + acc, itertools.accumulate(self.image, fun))) + return self + + @staticmethod + def shorten(sequence: List, width: int=20) -> str: + """ + Shorten an iterable sequence into an short, human-readable string. + + ```python + >>> Iter.shorten(range(1, 6)) + '[1, 2, 3, 4, 5]' + >>> Iter.shorten(range(1, 101), width=50) + '[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,...]' + ``` + """ + return textwrap.shorten(str(list(sequence)), width=width, placeholder=' ...]') + + def shuffle(self) -> Iter: + """ + Return a list with the elements of `self.image` shuffled. + + ```python + >>> Iter([1, 3]).shuffle() + [3, 2, 1] + >>> Iter([1, 3]).shuffle() + >>> [2, 1, 3] + ``` + """ + random.shuffle(self.image) + return self + + @overload + def slice(self, index: List[int]) -> Iter: + """ + Return a subset list of `self.image` by `index`. + + Given an `Iter`, it drops elements before `index[0]` (zero-base), + then it takes elements until element `index[1]` (inclusively). Indexes + are normalized, meaning that negative indexes will be counted from the end.\ + + If `index[1]` is out of bounds, then it is assigned as the index of + the last element. + + ```python + >>> Iter([1, 100]).slice([5, 10]) + [6, 7, 8, 9, 10, 11] + >>> Iter([1, 10]).slice([5, 20]) + [6, 7, 8, 9, 10] + >>> Iter([1, 30]).slice([-5, -1]) + [26, 27, 28, 29, 30] + ``` + + Alternatively, return a subset list of `self.image`, from `index` (zero-based) + with `amount` number of elements if available. Given `self.image`, it drops + elements right before element `index`; then, it takes `amount` of elements, + returning as many elements as possible if there are not enough elements. + + A negative `index` can be passed, which means `self.image` is enumerated + once and the index is counted from the end (for example, `-1` starts slicing + from the last element). It returns `[]` if `amount` is `0` or if `index` is + out of bounds. + + ```python + >>> Iter([1, 10]).slice(5, 100) + [6, 7, 8, 9, 10] + ``` + """ + ... + + @overload + def slice(self, index: int, amount: Optional[int]=None) -> Iter: ... + + def slice(self, index: int | List[int], amount: Optional[int]=None) -> Iter: + if isinstance(index, List): + self.image = self.image[index[0]:] if index[1] == -1 else self.image[index[0]:index[1]+1] + else: + self.image = self.image[index:amount] if abs(index) <= len(self.image) else [] + return self + + @overload + def slide(self, index: int, insertion_index: int) -> Iter: + """ + Slide a single or multiple elements given by `index` from `self.image` to + `insertion_index`. The semantics of the range to be moved match the semantics + of `self.slice()`. + + ```python + >>> Iter(list("abcdefg")).slide(5, 1) + ['a', 'f', 'b', 'c', 'd', 'e', 'g'] + >>> Iter(list("abcdefg")).slide([3, 5], 1) # slide backwards + ['a', 'd', 'e', 'f', 'b', 'c', 'g'] + >>> Iter(list("abcdefg")).slide([1, 3], 5) # slide forwards + ['a', 'e', 'f', 'b', 'c', 'd', 'g'] + >>> Iter(list("abcdefg")).slide(3, -1) + ['a', 'b', 'c', 'e', 'f', 'g', 'd'] + ``` + """ + ... + + @overload + def slide(self, index: List[int], insertion_index: int) -> Iter: ... + + def slide(self, index: int | List[int], insertion_index: int) -> Iter: + if isinstance(index, List): + if (max(index) + len(self.image) if max(index) < 0 else max(index)) > insertion_index: + # slide backwards + p1 = self.image[:insertion_index] + p3 = self.image[index[0]:index[1]+1] + p2 = self.image[insertion_index:index[0]] + p4 = self.image[index[1]+1:] + self.image = list(itertools.chain(p1, p3, p2, p4)) + else: + # slide forwards + p1 = self.image[:index[0]] + p2 = self.image[index[0]:index[1]+1] + p3 = self.image[index[1]+1:insertion_index+1] + p4 = self.image[insertion_index+1:] + self.image = list(itertools.chain(p1, p3, p2, p4)) + else: + element, ii = self.image.pop(index), insertion_index + self.image.insert((ii, ii+1)[ii<0], element) if ii!=-1 else self.image.append(element) + return self + + def sort(self, fun: Optional[Callable[[Any], bool]]=None, descending: bool=False) -> Iter: + """ + Return a new sorted `self.image`. `fun` specifies a function of one argument + that is used to extract a comparison key from each element in iterable (for + example, `key=str.lower`). The `descending` flag can be set to sort `self.image` + in descending order (ascending by default). + + Use `functools.cmp_to_key()` to convert an old-style cmp function to a key + function. + + ```python + >>> Iter([3, 1, 2]).sort() + [1, 2, 3] + ``` + """ + self.image = sorted(self.image, key=fun, reverse=descending) + return self + + def split(self, count: int) -> Iter: + """ + Split `self.image` into two lists, leaving `count` elements in the first + one. If `count` is a negative number, it starts counting from the back to + the beginning of `self.image`. + + ```python + >>> Iter([1, 3]).split(2) + [[1,2], [3]] + >>> Iter([1, 3]).split(10) + [[1, 2, 3], []] + >>> Iter([1, 3]).split(0) + [[], [1, 2, 3]] + >>> Iter([1, 3]).split(-1) + [[1, 2], [3]] + ``` + """ + self.image = [self.image[:count], self.image[count:]] + return self + + def split_while(self, fun: Callable[[Any], bool]) -> Iter: + """ + Split `self.image` in two at the position of the element for which `fun` + returns a falsy value for the first time. + + It returns a nested list of length two. The element that triggered the split + is part of the second list. + + ```python + >>> Iter([1, 4]).split_while(lambda x: x < 3) + [[1, 2], [3, 4]] + >>> Iter([1, 4]).split_while(lambda x: x < 0) + [[], [1, 2, 3, 4]] + >>> Iter([1, 4]).split_while(lambda x: x > 0) + [[1, 2, 3, 4], []] + ``` + """ + default = len(self.image) + 1 + element = next(itertools.filterfalse(fun, self.image), default) + count = self.image.index(element) if element in self.image else default + self.image = [self.image[:count], self.image[count:]] + return self + + @overload + def split_with(self, fun: Callable[[Any], bool]) -> Iter: + """ + Split `self.image` in two lists according to the given function `fun`. + + Split `self.image` in two lists by calling `fun` with each element in + `self.image` as its only argument. Returns a nested list with the first + list containing all the elements in `self.image` for which applying `fun` + returned a truthy value, and a second list with all the elements for which + applying fun returned a falsy value. The same logic is also applied when + a key-value paired lambda expression is passed as an argument, as a consequence + of which the dictionary will be also split into two parts following the + same pattern. + + The elements in both the returned lists (or dictionaries) are in the same + relative order as they were in the original `self.image` (if such iterable + was ordered, like a list). See the examples below. + + ```python + >>> Iter([1, 5]).reverse().split_with(lambda x: x % 2 == 0) + [[4, 2, 0], [5, 3, 1]] + >>> Iter({'a': 1, 'b': -2, 'c': 1, 'd': -3}).split_with(lambda k, v: v < 0) + [{'b': -2, 'd': -3}, {'a': 1, 'c':1}] + ``` + """ + ... + + @overload + def split_with(self, fun: Callable[[Any, Any], bool]) -> Iter: ... + + def split_with(self, fun: Callable[[Any], bool] | Callable[[Any, Any], bool]) -> Iter: + if isinstance(self.image, Dict): + f1 = ChainMap(*[{k: v} for k, v in self.image.items() if fun(k, v)]) + f2 = ChainMap(*[{k: v} for k, v in self.image.items() if not fun(k, v)]) + self.image = [dict(f1), dict(f2)] + else: + t1, t2 = itertools.tee(self.image) + self.image = [list(filter(fun, t1)), list(itertools.filterfalse(fun, t2))] + return self + + def sum(self) -> Union[int, float, complex, str]: + """ + Return the sum of all elements. + + ```python + >>> Iter([1, 100]).sum() + 5050 + ``` + """ + return sum(self.image) + + def take(self, amount: int) -> Iter: + """ + Takes an `amount` of elements from the beginning or the end of `self.image`. + If a positive `amount` is given, it takes the amount elements from the + beginning of `self.image`. If a negative `amount` is given, the amount of + elements will be taken from the end. `self.image` will be enumerated once + to retrieve the proper index and the remaining calculation is performed from + the end. If `amount` is `0`, it returns `[]`. + + ```python + >>> Iter([1, 3]).take(2) + [1, 2] + >>> Iter([1, 3]).take(10) + [1, 2, 3] + >>> Iter([1, 2, 3]).take(0) + [] + >>> Iter([1, 2, 3]).take(-1) + [3] + ``` + """ + self.image = list(itertools.islice(self.image, amount) if amount > 0 else itertools.islice(reversed(self.image), abs(amount))) + return self + + def take_every(self, nth: int) -> Iter: + """ + Return a list of every `nth` element in `self.image`, starting with the + first element. The first element is always included, unless `nth` is `0`. + The second argument specifying every `nth` element must be a non-negative + integer. + + ```python + >>> Iter([1, 10]).take_every(2) + [1, 3, 5, 7, 9] + >>> Iter([1, 10]).take_every(0) + [] + >>> Iter([1, 3]).take_every(1) + [1, 2, 3] + ``` + """ + self.image = list(itertools.islice(self.image, 0, len(self.image), nth)) if nth != 0 else [] + return self + + def take_random(self, count: int) -> Iter: + """ + Take `count` random elements from `self.image`. + + ```python + >>> Iter([1, 10]).take_random(2) + [3, 1] + >>> Iter([1, 10]).take_random(2) + [6, 9] + ``` + """ + self.image = random.choices(self.image, k=count) + return self + + def take_while(self, fun: Callable[[Any], bool]) -> Iter: + """ + Take the elements from the beginning of `self.image` while `fun` returns + a truthy value. + + ```python + >>> Iter([1, 3]).take_while(lambda x: x < 3) + [1, 2] + ``` + """ + self.image = list(itertools.takewhile(fun, self.image)) + return self + + def uniq(self) -> Iter: + """ + Enumerates `self.image`, removing all duplicated elements. + + ```python + >>> Iter([1, 2, 3, 3, 2, 1]).uniq() + [1, 2, 3] + ``` + """ + self.image = list(Counter(self.image).keys()) + return self + + def unzip(self) -> Iter: + """ + Opposite of `self.zip`. Extracts two-element tuples from `self.image` and + groups them together. + + ```python + >>> Iter({'a': 1, 'b': 2, 'c': 3}).unzip() + [['a', 'b', 'c'], [1, 2, 3]] + >>> Iter([('a', 1), ('b', 2), ('c', 3)]).unzip() + [['a', 'b', 'c'], [1, 2, 3]] + >>> Iter([['a', 1], ['b', 2], ['c', 3]]).unzip() + [['a', 'b', 'c'], [1, 2, 3]] + ``` + """ + if isinstance(self.image, Dict): + self.image = [list(self.image.keys()), list(self.image.values())] + else: + self.image = [list(map(operator.itemgetter(0), self.image)), list(map(operator.itemgetter(1), self.image))] + return self + + @overload + def with_index(self, fun_or_offset: Optional[int]=None) -> Iter: + """ + Return `self.image` with each element wrapped in a tuple alongside its index. + May receive a function or an integer offset. If an offset is given, it will + index from the given offset instead of from zero. If a function is given, + it will index by invoking the function for each element and index (zero-based) + of the `self.image`. + + ```python + >>> Iter(list("abc")).with_index() + [('a', 0), ('b', 1), ('c', 2)] + >>> Iter(list("abc")).with_index(2) + [('a', 2), ('b', 3), ('c', 4)] + >>> Iter(list("abc")).with_index(lambda k, v: (v, k)) + [(0, 'a'), (1, 'b'), (2, 'c')] + ``` + """ + ... + + @overload + def with_index(self, fun_or_offset: Callable[[Any, Any], Any]) -> Iter: ... + + def with_index(self, fun_or_offset: Optional[int] | Callable[[Any, Any], Any]=None) -> Iter: + if isinstance(fun_or_offset, int) or fun_or_offset is None: + offset = 0 if fun_or_offset is None else fun_or_offset + self.image = list(zip(self.image, range(offset, len(self.image)+offset))) + else: + self.image = list(itertools.starmap(fun_or_offset, zip(self.image, range(len(self.image))))) + return self + + def zip(self, *iterables: Iterable) -> Iter: + """ + Zip corresponding elements from a finite collection of iterables into a + list of tuples. The zipping finishes as soon as any iterable in the given + collection completes. + + ```python + >>> Iter(list("abc")).zip(range(3)) + [('a', 0), ('b', 1), ('c', 2)] + >>> Iter(list("abc")).zip(range(3), list("def")) + [('a', 0, 'd'), ('b', 1, 'e'), ('c', 2, 'f')] + >>> Iter([1, 3]).zip(list("abc"), ["foo", "bar", "baz"]) + [(1, 'a', "foo"), (2, 'b', "bar"), (3, 'c', "baz")] + ``` + """ + self.image = list(zip(self.image, *iterables)) + return self + + @overload + def zip_reduce(self, acc: List, reducer: Callable[[Any, Any], Any]) -> Iter: + """ + Reduce over all of `self.image`, halting as soon as any iterable is empty. + The reducer will receive 2 args: a list of elements (one from each enum) and + the accumulator. + + ```python + >>> Iter([[1, 1], [2, 2], [3, 3]]).zip_reduce([], lambda x, acc: tuple(x) + (acc,)) + [(1, 2, 3), (1, 2, 3)] + ``` + + Reduce over two iterables halting as soon as either iterable is empty. + + ```python + >>> Iter([]).zip_reduce([5, 6], lambda x, acc: tuple(x) + (acc,), [1, 2], {'a': 3, 'b': 4}) + [(1, {'a': 3}, 5), (2, {'b': 4}, 6)] + ``` + """ + ... + + @overload + def zip_reduce(self, acc: List, reducer: Callable[[Any, Any], Any], left: Iterable=None, right: Iterable=None) -> Iter: ... + + def zip_reduce(self, acc: List, reducer: Callable[[Any, Any], Any], left: Iterable=None, right: Iterable=None) -> Iter: + if left is not None and right is not None: + raise NotImplementedError() + else: + self.image = list(functools.reduce(reducer, zip(*self.image), acc)) + return self + + def zip_with(self, fun: Callable[..., Any], *iterable: Iterable) -> Iter: + """ + Zip corresponding elements from a finite collection of iterables into a list, + transforming them with the `fun` function as it goes. The first element from + each of the lists in iterables will be put into a list which is then passed + to the 1-arity `fun` function. Then, the second elements from each of the + iterables are put into a list and passed to `fun`, and so on until any one + of the lists in `iterables` runs out of elements. Returns a list with all + the results of calling `fun`. + + ```python + >>> Iter([]).zip_with(operator.add, [1, 2, 3], [4, 5, 6]) + [5, 7, 9] + >>> Iter([1, 1]).zip_with(operator.add, [1, 2], [3, 4, 6, 7]).image + [5, 7] + >>> Iter([]).zip_with(lambda x, y, z: x + y + z, [1, 3], [3, 5], [1, 3]) + [5, 11] + ``` + """ + self.image = [fun(*args) for args in zip(*iterable)] + return self + + def __repr__(self) -> str: + return f"Iter(domain={Iter.shorten(self.domain)},image={Iter.shorten(self.image)})" + + def __str__(self) -> str: + return Iter.shorten(self.image, width=80) diff --git a/tests/test_iterfun.py b/tests/test_iterfun.py index 0889402..b6f7fdd 100644 --- a/tests/test_iterfun.py +++ b/tests/test_iterfun.py @@ -1,9 +1,366 @@ +import operator import unittest +import pytest + from src.iterfun import Iter -class TestIterator(unittest.TestCase): - def test_hello(self): - self.assertEqual("Hello, World!", Iter.hello()) +class TestIter(unittest.TestCase): + def test_iter_domain(self): + iter = Iter([1, 4]) + self.assertEqual(iter.domain, iter.image) + self.assertNotEqual(iter.domain, iter.filter(lambda x: x % 2 == 0).image) + + def test_iter_image(self): + self.assertEqual([1, 2, 3], Iter([1, 2, 3]).image) + self.assertEqual([1, 2, 3, 4, 5], Iter([1, 5]).image) + self.assertEqual([1, 5], Iter([1, 5], interval=False).image) + self.assertEqual([2, 3, 4, 5, 6, 7, 8, 9], Iter((1, 10)).domain) + + def test_all(self): + self.assertTrue(Iter([1, 2, 3]).all()) + self.assertFalse(Iter([1, None, 3]).all()) + self.assertTrue(Iter([]).all()) + self.assertTrue(Iter([2, 4, 6]).all(lambda x: x % 2 == 0)) + self.assertFalse(Iter([1, 2, 3]).all(lambda x: x % 2 == 0)) + self.assertTrue(Iter([]).all(lambda: None)) + + def test_any(self): + self.assertFalse(Iter([False, False, False]).any()) + self.assertTrue(Iter([False, True, False]).any()) + self.assertFalse(Iter([]).any()) + self.assertFalse(Iter([2, 4, 6]).any(lambda x: x % 2 == 1)) + self.assertTrue(Iter([1, 2, 3]).any(lambda x: x % 2 == 1)) + self.assertFalse(Iter([]).any(lambda: None)) + + def test_at(self): + self.assertEqual(2, Iter([2, 4, 6]).at(0)) + self.assertEqual(6, Iter([2, 4, 6]).at(2)) + self.assertEqual(6, Iter([2, 4, 6]).at(-1)) + + def test_at_throws(self): + with pytest.raises(IndexError): + self.assertEqual(None, Iter([2, 4, 6]).at(4)) + + def test_avg(self): + self.assertEqual(5, Iter([0, 10]).avg()) + + def test_chunk_by(self): + expected = [[1], [2, 2], [3], [4, 4, 6], [7, 7]] + actual = Iter([1, 2, 2, 3, 4, 4, 6, 7, 7]).chunk_by(lambda x: x % 2 == 1).image + self.assertEqual(expected, actual) + + def test_chunk_every(self): + self.assertEqual([[1, 2], [3, 4], [5, 6]], Iter([1, 6]).chunk_every(2).image) + self.assertEqual([[1, 2, 3], [3, 4, 5], [5, 6]], Iter([1, 6]).chunk_every(3, 2).image) + self.assertEqual([[1, 2, 3], [3, 4, 5], [5, 6, 7]], Iter([1, 6]).chunk_every(3, 2, [7]).image) + self.assertEqual([[1, 2, 3], [4]], Iter([1, 4]).chunk_every(3, 3, []).image) + self.assertEqual([[1, 2, 3, 4]], Iter([1, 4]).chunk_every(10).image) + self.assertEqual([[1, 2], [4, 5]], Iter([1, 5]).chunk_every(2, 3, []).image) + + @pytest.mark.xfail(raises=NotImplementedError, reason="TODO") + def test_chunk_while(self): + self.assertEqual([[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]], Iter([1, 10]).chunk_while([], None, None)) + + def test_concat(self): + self.assertEqual([1, 2, 3, 4, 5, 6], Iter([[1, 2, 3], [4, 5, 6]]).concat().image) + self.assertEqual([1, [2], 3, 4, 5, 6], Iter([[1, [2], 3], [4], [5, 6]]).concat().image) + self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], Iter([[1, 4], [5, 6], [7, 9]]).concat().image) + self.assertEqual([1, 2, 3, 8], Iter([(0, 4), (5, 6), (7, 9)]).concat().image) + self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], Iter([(0, 4), (3, 7), (6, 10)]).concat().image) + + def test_count(self): + self.assertEqual(3, Iter([1, 3]).count()) + self.assertEqual(2, Iter([1, 5]).count(lambda x: x % 2 == 0)) + + def test_count_until(self): + self.assertEqual(5, Iter([1, 20]).count_until(5)) + self.assertEqual(20, Iter([1, 20]).count_until(50)) + self.assertTrue(Iter([1, 10]).count_until(10) == 10) + self.assertTrue(Iter([1, 12]).count_until(10 + 1) > 10) + self.assertTrue(Iter([1, 5]).count_until(10) < 10) + self.assertTrue(Iter([1, 10]).count_until(10 + 1) == 10) + self.assertEqual(7, Iter([1, 20]).count_until(7, lambda x: x % 2 == 0)) + self.assertTrue(10, Iter([1, 20]).count_until(11, lambda x: x % 2 == 0)) + + def test_dedup(self): + self.assertEqual([1, 2, 3, 2, 1], Iter([1, 2, 3, 3, 2, 1]).dedup().image) + + def test_dedup_by(self): + self.assertEqual([5, 1, 3, 2], Iter([5, 1, 2, 3, 2, 1]).dedup_by(lambda x: x > 2).image) + self.assertEqual([0, 3, 2], Iter([0, 1, 2, 3, 2, 1]).dedup_by(lambda x: x > 2).image) + self.assertEqual([0, 4, 1, 2, 0, 3], Iter([0, 4, 9, 1, 2, 0, 3, 4, 9]).dedup_by(lambda x: x < 2).image) + self.assertEqual([3, 2, 4, 1], Iter([3, 6, 7, 7, 2, 0, 1, 4, 1]).dedup_by(lambda x: x > 2).image) + self.assertEqual([3, 2, 0], Iter([3, 6, 7, 7, 2, 0, 1, 4, 1]).dedup_by(lambda x: x == 2).image) + + def test_drop(self): + self.assertEqual([3], Iter([1, 3]).drop(2).image) + self.assertEqual([], Iter([1, 3]).drop(10).image) + self.assertEqual([1, 2, 3], Iter([1, 3]).drop(0).image) + self.assertEqual([1, 2], Iter([1, 3]).drop(-1).image) + + def test_drop_every(self): + self.assertEqual([2, 4, 6, 8, 10], Iter([1, 10]).drop_every(2).image) + self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], Iter([1, 10]).drop_every(0).image) + self.assertEqual([], Iter([1, 3]).drop_every(1).image) + + def test_drop_while(self): + self.assertEqual([3, 2, 1], Iter([1, 2, 3, 2, 1]).drop_while(lambda x: x < 3).image) + + def test_each(self): + self.assertTrue(Iter(["some", "example"], interval=False).each(print)) + + def test_empty(self): + self.assertTrue(Iter([]).empty()) + self.assertTrue(Iter([0, 0]).empty()) + self.assertFalse(Iter([1, 10]).empty()) + + def test_fetch(self): + self.assertEqual({'ok': 2}, Iter([2, 4, 6]).fetch(0)) + self.assertEqual({'ok': 2}, Iter([2, 4, 6]).fetch(-3)) + self.assertEqual({'ok': 6}, Iter([2, 4, 6]).fetch(2)) + self.assertEqual({'error': None}, Iter([2, 4, 6]).fetch(4)) + + def test_filter(self): + self.assertEqual([2], Iter([1, 3]).filter(lambda x: x % 2 == 0).image) + + def test_find(self): + self.assertEqual(3, Iter([2, 4]).find(lambda x: x % 2 == 1)) + self.assertEqual(None, Iter([2, 4, 6]).find(lambda x: x % 2 == 1)) + self.assertEqual(0, Iter([2, 4, 6]).find(lambda x: x % 2 == 1, default=0)) + + def test_find_index(self): + self.assertEqual(None, Iter([2, 4, 6]).find_index(lambda x: x % 2 == 1)) + self.assertEqual(1, Iter([2, 3, 4]).find_index(lambda x: x % 2 == 1)) + + def test_find_value(self): + self.assertEqual(None, Iter([2, 4, 6]).find_value(lambda x: x % 2 == 1)) + self.assertTrue(Iter([2, 3, 4]).find_value(lambda x: x % 2 == 1)) + self.assertTrue(Iter([2, 3, 4]).find_value(lambda x: x % 2 == 1)) + self.assertEqual("no bools!", Iter([1, 3]).find_value(lambda x: isinstance(x, bool), default="no bools!")) + + def test_flat_map(self): + self.assertEqual([1, 2, 3, 4, 5, 6], Iter([(1, 3), (4, 6)], interval=False).flat_map(lambda x: Iter.range(list(x))).image) + self.assertEqual([[1], [2], [3]], Iter([1, 2, 3]).flat_map(lambda x: [[x]]).image) + + @pytest.mark.xfail(raises=NotImplementedError, reason="TODO") + def test_flat_map_reduce(self): + n = 3 + fun = lambda x, acc: [[x], acc+1] if acc < n else acc + self.assertEqual([[1, 2, 3], 3], Iter([1, 100]).flat_map_reduce(fun, acc=0).image) + + def test_frequencies(self): + self.assertEqual({1: 1, 2: 2, 3: 1, 4: 1, 5: 2, 6: 1}, Iter([1, 2, 2, 3, 4, 5, 5, 6]).frequencies().image) + + def test_frequencies_by(self): + self.assertEqual({"aa": 2, "bb": 1, "cc": 1}, Iter(["aa", "aA", "bb", "cc"]).frequencies_by(str.lower).image) + self.assertEqual({3: 2, 2: 2, 1: 1}, Iter(["aaa", "aA", "bbb", "cc", "c"]).frequencies_by(len).image) + + def test_group_by(self): + self.assertEqual({3: ["ant", "cat"], 5: ["dingo"], 7: ["buffalo"]}, Iter(["ant", "buffalo", "cat", "dingo"]).group_by(len).image) + self.assertEqual({3: ["a", "c"], 5: ["d"], 7: ["b"]}, Iter(["ant", "buffalo", "cat", "dingo"]).group_by(len, operator.itemgetter(0)).image) + + def test_intersperse(self): + self.assertEqual([1, 0, 2, 0, 3], Iter([1, 3]).intersperse(0).image) + self.assertEqual([1], Iter([1]).intersperse(0).image) + self.assertEqual([], Iter([]).intersperse(0).image) + + def test_into(self): + self.assertEqual([1, 2], Iter([1, 2]).into([]).image) + self.assertEqual({'a': 1, 'b': 2}, Iter({'a': 1, 'b': 2}).into({}).image) + self.assertEqual({'a': 1, 'b': 2}, Iter({'a': 1}).into({'b': 2}).image) + + def test_join(self): + self.assertEqual('12345', Iter([1, 5]).join()) + self.assertEqual('1,2,3,4,5', Iter([1, 5]).join(',')) + + def test_map(self): + self.assertEqual([2, 4, 6], Iter([1, 3]).map(lambda x: 2 * x).image) + self.assertEqual({'a': -1, 'b': -2}, Iter({'a': 1, 'b': 2}).map(lambda k, v: {k: -v}).image) + self.assertEqual({'a': 2, 'b': 4}, Iter({'a': 1, 'b': 2}).map(lambda k, v: {k: 2 * v}).image) + + def test_map_every(self): + self.assertEqual([1001, 2, 1003, 4, 1005, 6, 1007, 8, 1009, 10], Iter([1, 10]).map_every(2, lambda x: x+1000).image) + self.assertEqual([1001, 2, 3, 1004, 5, 6, 1007, 8, 9, 1010], Iter([1, 10]).map_every(3, lambda x: x + 1000).image) + self.assertEqual([1, 2, 3, 4, 5], Iter([1, 5]).map_every(0, lambda x: x + 1000).image) + self.assertEqual([1001, 1002, 1003], Iter([1, 3]).map_every(1, lambda x: x + 1000).image) + + def test_map_intersperse(self): + self.assertEqual([2, None, 4, None, 6], Iter([1, 3]).map_intersperse(None, lambda x: 2 * x).image) + + def test_map_join(self): + self.assertEqual('246', Iter([1, 3]).map_join(lambda x: 2 * x)) + self.assertEqual('2 = 4 = 6', Iter([1, 3]).map_join(lambda x: 2 * x, " = ")) + + def test_map_reduce(self): + self.assertEqual(([2, 4, 6], 6), Iter([1, 3]).map_reduce(0, lambda x: 2 * x, operator.add).image) + self.assertEqual(([1, 4, 9], 0), Iter([1, 3]).map_reduce(6, lambda x: x * x, operator.sub).image) + + def test_max(self): + self.assertEqual(3, Iter([1, 3]).max()) + self.assertEqual('you', Iter("you shall not pass".split()).max()) + self.assertEqual('shall', Iter("you shall not pass".split()).max(len)) + self.assertEqual('n/a', Iter([]).max(empty_fallback='n/a')) + + def test_member(self): + self.assertTrue(Iter([1, 10]).member(5)) + self.assertTrue(Iter([1, 10]).member(5.0)) + self.assertTrue(Iter([1.0, 2.0, 3.0]).member(2)) + self.assertTrue(Iter([1.0, 2.0, 3.0]).member(2.000)) + self.assertFalse(Iter(['a', 'b', 'c']).member('d')) + + def test_max(self): + self.assertEqual(1, Iter([1, 3]).min()) + self.assertEqual('not', Iter("you shall not pass".split()).min()) + self.assertEqual('you', Iter("you shall not pass".split()).min(len)) + self.assertEqual('n/a', Iter([]).min(empty_fallback='n/a')) + + def test_min_max(self): + self.assertEqual((1, 3), Iter([1, 3]).min_max()) + self.assertEqual((None, None), Iter([]).min_max(empty_fallback=None)) + self.assertEqual(('a', 'aaa'), Iter(["aaa", "a", "bb", "c", "ccc"]).min_max(len)) + + def test_product(self): + self.assertEqual(24, Iter([2, 3, 4]).product()) + self.assertEqual(24.0, Iter([2.0, 3.0, 4.0]).product()) + + def test_random(self): + numbers = Iter.range([1, 100]) + self.assertIn(Iter(numbers).random(), numbers) + + def test_range(self): + self.assertEqual([1, 2, 3, 4, 5], Iter.range([1, 5])) + self.assertEqual([2, 3, 4], Iter.range((1, 5))) + + def test_reduce(self): + self.assertEqual(10, Iter([1, 4]).reduce(operator.add)) + self.assertEqual(24, Iter([1, 4]).reduce(lambda x, acc: x * acc, acc=1)) + + def test_reduce_while(self): + self.assertEqual(10, Iter([1, 100]).reduce_while(lambda x, acc: (True, x + acc) if x < 5 else (False, acc))) + self.assertEqual(5050, Iter([1, 100]).reduce_while(lambda x, acc: (True, acc + x) if x > 0 else (False, acc))) + self.assertEqual(0, Iter([1, 100]).reduce_while(lambda x, acc: (True, acc - x) if x % 2 == 0 else (False, acc), acc=2550)) + + def test_reject(self): + self.assertEqual([1, 3], Iter([1, 3]).reject(lambda x: x % 2 == 0).image) + + def test_reverse(self): + self.assertEqual([5, 4, 3, 2, 1], Iter([1, 5]).reverse().image) + self.assertEqual([3, 2, 1, 4, 5, 6], Iter([1, 3]).reverse([4, 5, 6]).image) + + def test_reverse_slice(self): + self.assertEqual([1, 2, 6, 5, 4, 3], Iter([1, 6]).reverse_slice(2, 4).image) + self.assertEqual([1, 2, 6, 5, 4, 3, 7, 8, 9, 10], Iter([1, 10]).reverse_slice(2, 4).image) + self.assertEqual([1, 2, 10, 9, 8, 7, 6, 5, 4, 3], Iter([1, 10]).reverse_slice(2, 30).image) + + def test_scan(self): + self.assertEqual([1, 3, 6, 10, 15], Iter([1, 5]).scan(operator.add).image) + self.assertEqual([1, 3, 6, 10, 15], Iter([1, 5]).scan(lambda x, y: x + y, acc=0).image) + self.assertEqual([2, 4, 7, 11, 16], Iter([1, 5]).scan(operator.add, acc=1).image) + + def test_shorten(self): + self.assertEqual("[1, 2, 3, 4, 5]", Iter.shorten(range(1, 6))) + self.assertEqual("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, ...]", Iter.shorten(range(1, 101), width=50)) + + def test_shuffle(self): + iter = Iter([1, 10]).shuffle() + self.assertTrue(iter.all(lambda x: x in Iter.range([1, 10]))) + + def test_slice(self): + self.assertEqual([6, 7, 8, 9, 10, 11], Iter([1, 100]).slice([5, 10]).image) + self.assertEqual([6, 7, 8, 9, 10], Iter([1, 10]).slice([5, 20]).image) + self.assertEqual([26, 27, 28, 29, 30], Iter([1, 30]).slice([-5, -1]).image) + self.assertEqual([7, 8, 9], Iter([1, 10]).slice([-4, -2]).image) + self.assertEqual([7, 8, 9, 10], Iter([1, 10]).slice([-4, 100]).image) + self.assertEqual([6, 7, 8, 9, 10], Iter([1, 10]).slice(5, 100).image) + self.assertEqual([], Iter([1, 10]).slice(10, 5).image) + self.assertEqual([], Iter([1, 10]).slice(-11, 5).image) + + def test_slide(self): + self.assertEqual(['a', 'f', 'b', 'c', 'd', 'e', 'g'], Iter(list("abcdefg")).slide(5, 1).image, msg="Sliding a single element") + self.assertEqual(['a', 'd', 'e', 'f', 'b', 'c', 'g'], Iter(list("abcdefg")).slide([3, 5], 1).image, msg="Slide a range of elements backward") + self.assertEqual(['a', 'e', 'f', 'b', 'c', 'd', 'g'], Iter(list("abcdefg")).slide([1, 3], 5).image, msg="Slide a range of elements forward") + self.assertEqual(['a', 'd', 'e', 'f', 'b', 'c', 'g'], Iter(list("abcdefg")).slide([-4, -2], 1).image, msg="Slide with negative indices (counting from the end)") + self.assertEqual(['a', 'b', 'c', 'e', 'f', 'g', 'd'], Iter(list("abcdefg")).slide(3, -1).image, msg="Slide with negative indices (counting from the end)") + self.assertEqual(['a', 'b', 'c', 'e', 'f', 'd', 'g'], Iter(list("abcdefg")).slide(3, -2).image, msg="Slide with negative indices (counting from the end)") + + def test_sorted(self): + self.assertEqual([1, 2, 3], Iter([3, 1, 2]).sort().image) + self.assertEqual([3, 2, 1], Iter([3, 1, 2]).sort(descending=True).image) + + def test_split(self): + self.assertEqual([[1, 2], [3]], Iter([1, 3]).split(2).image) + self.assertEqual([[1, 2, 3], []], Iter([1, 3]).split(10).image) + self.assertEqual([[], [1, 2, 3]], Iter([1, 3]).split(0).image) + self.assertEqual([[1, 2], [3]], Iter([1, 3]).split(-1).image) + self.assertEqual([[1], [2, 3]], Iter([1, 3]).split(-2).image) + self.assertEqual([[], [1, 2, 3]], Iter([1, 3]).split(-5).image) + + def test_split_while(self): + self.assertEqual([[1, 2], [3, 4]], Iter([1, 4]).split_while(lambda x: x < 3).image) + self.assertEqual([[], [1, 2, 3, 4]], Iter([1, 4]).split_while(lambda x: x < 0).image) + self.assertEqual([[1, 2, 3, 4], []], Iter([1, 4]).split_while(lambda x: x > 0).image) + + def test_split_with(self): + self.assertEqual([[4, 2, 0], [5, 3, 1]], Iter([0, 5]).reverse().split_with(lambda x: x % 2 == 0).image) + self.assertEqual([{'b': -2, 'd': -3}, {'a': 1, 'c':1}], Iter({'a': 1, 'b': -2, 'c': 1, 'd': -3}).split_with(lambda k, v: v < 0).image) + self.assertEqual([{}, {'a': 1, 'b': -2, 'c': 1, 'd': -3}], Iter({'a': 1, 'b': -2, 'c': 1, 'd': -3}).split_with(lambda k, v: v > 50).image) + self.assertEqual([{}, {}], Iter({}).split_with(lambda k, v: v > 50).image) + + def test_sum(self): + self.assertEqual(5050, Iter([1, 100]).sum()) + + def test_take(self): + self.assertEqual([1, 2], Iter([1, 3]).take(2).image) + self.assertEqual([1, 2, 3], Iter([1, 3]).take(10).image) + self.assertEqual([], Iter([1, 3]).take(0).image) + self.assertEqual([3], Iter([1, 3]).take(-1).image) + + def test_take_every(self): + self.assertEqual([1, 3, 5, 7, 9], Iter([1, 10]).take_every(2).image) + self.assertEqual([], Iter([1, 10]).take_every(0).image) + self.assertEqual([1, 2, 3], Iter([1, 3]).take_every(1).image) + + def test_take_random(self): + numbers = set(Iter.range([1, 100])) + self.assertTrue(set(Iter([1, 100]).take_random(2).image).issubset(numbers)) + + def test_take_while(self): + self.assertEqual([1, 2], Iter([1, 3]).take_while(lambda x: x < 3).image) + + def test_uniq(self): + self.assertEqual([1, 2, 3], Iter([1, 2, 3, 3, 2, 1]).uniq().image) + + def test_unzip(self): + self.assertEqual([['a', 'b', 'c'], [1, 2, 3]], Iter({'a': 1, 'b': 2, 'c': 3}).unzip().image) + self.assertEqual([['a', 'b', 'c'], [1, 2, 3]], Iter([('a', 1), ('b', 2), ('c', 3)]).unzip().image) + self.assertEqual([['a', 'b', 'c'], [1, 2, 3]], Iter([['a', 1], ['b', 2], ['c', 3]]).unzip().image) + + def test_with_index(self): + self.assertEqual([('a', 0), ('b', 1), ('c', 2)], Iter(list("abc")).with_index().image) + self.assertEqual([('a', 2), ('b', 3), ('c', 4)], Iter(list("abc")).with_index(2).image) + self.assertEqual([(0, 'a'), (1, 'b'), (2, 'c')], Iter(list("abc")).with_index(lambda k, v: (v, k)).image) + + def test_zip(self): + self.assertEqual([(1, 'a', "foo"), (2, 'b', "bar"), (3, 'c', "baz")], Iter([1, 3]).zip(list("abc"), ["foo", "bar", "baz"]).image) + self.assertEqual([('a', 0), ('b', 1), ('c', 2)], Iter(list("abc")).zip(range(3)).image) + self.assertEqual([('a', 0, 'd'), ('b', 1, 'e'), ('c', 2, 'f')], Iter(list("abc")).zip(range(3), list("def")).image) + + @pytest.mark.xfail(raises=NotImplementedError,reason='TODO') + def test_zip_reduce(self): + self.assertEqual([(1, 2, 3), (1, 2, 3)], Iter([[1, 1], [2, 2], [3, 3]]).zip_reduce([], lambda x, acc: tuple(x) + (acc,)).image) + self.assertEqual([(1, {'a': 3}, 5), (2, {'b': 4}, 6)], Iter([]).zip_reduce([5, 6], lambda x, acc: tuple(x) + (acc,), [1, 2], {'a': 3, 'b': 4}).image) + + def test_zip_with(self): + self.assertEqual([5, 7, 9], Iter([]).zip_with(operator.add, [1, 2, 3], [4, 5, 6]).image) + self.assertEqual([5, 11], Iter([]).zip_with(lambda x, y, z: x + y + z, [1, 3], [3, 5], [1, 3]).image) + self.assertEqual([5, 7], Iter([]).zip_with(operator.add, [1, 2], [4, 5, 6, 7]).image) + + def test_str(self): + self.assertEqual("[1, 2, 3, 4, 5]", str(Iter([1, 5]))) + self.assertEqual("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, ...]", str(Iter([1, 100]))) + def test_repr(self): + self.assertEqual("Iter(domain=[1, 2, 3, 4, 5],image=[1, 2, 3, 4, 5])", repr(Iter([1, 5]))) + self.assertEqual("Iter(domain=[1, 2, 3, 4, 5, ...],image=[1, 2, 3, 4, 5, ...])", repr(Iter([1, 50])))