diff --git a/README.md b/README.md index 9903c2d..700cedd 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,20 @@ If `enumerator_without_func` is set, the decorator skips calling the decorated m Relys on the enumerable's implementation of `__into__` and `__to_tuple__`. +#### [`any`](https://ruby-doc.org/core-3.1.2/Enumerable.html#method-i-any-3F) + +Returns whether any element meets a given criterion. +With no arguments, the criterion is truthiness. +With a callable argument, returns whether it returns a truthy value for any element. +With a non-callable argument, treats it as a [pattern](#user-content-patterns). + +#### [`all`](https://ruby-doc.org/core-3.1.2/Enumerable.html#method-i-all-3F) + +Returns whether every element meets a given criterion. +With no arguments, the criterion is truthiness. +With a callable argument, returns whether it returns a truthy value for all elements. +With a non-callable argument, treats it as a [pattern](#user-content-patterns). + #### [`collect`](https://ruby-doc.org/core-3.1.2/Enumerable.html#method-i-collect), [`map`](https://ruby-doc.org/core-3.1.2/Enumerable.html#method-i-map) Returns the result of mapping a function over the elements. @@ -139,7 +153,19 @@ Performs a reduction operation much like `functools.reduce`. If called with a single argument, treats it as the reduction function. If called with two arguments, the first is treated as the initial value for the reduction and the second argument acts as the reduction function. -Also available as the alias `reduce`. +#### [`none`](https://ruby-doc.org/core-3.1.2/Enumerable.html#method-i-none-3F) + +Returns whether no element meets a given criterion. +With no arguments, the criterion is truthiness. +With a callable argument, returns whether it returns a truthy value for no element. +With a non-callable argument, treats it as a [pattern](#user-content-patterns). + +#### [`one`](https://ruby-doc.org/core-3.1.2/Enumerable.html#method-i-one-3F) + +Returns whether exactly one element meets a given criterion. +With no arguments, the criterion is truthiness. +With a callable argument, returns whether it returns a truthy value for exactly one element. +With a non-callable argument, treats it as a [pattern](#user-content-patterns). #### [`reject`](https://ruby-doc.org/core-3.1.2/Enumerable.html#method-i-reject) @@ -195,3 +221,13 @@ If going beyond the enumeration, `StopIteration` is raised. Rewinds the enumeration sequence to the beginning. _Note that this may not be possible to do for underlying iterables that can be exhausted._ + +--- + +### Patterns + +Especially given Enumerable's [query methods](https://ruby-doc.org/core-3.1.2/Enumerable.html#module-Enumerable-label-Methods+for+Querying), the concept of patterns needs to be dealt with. + +In Ruby, [`===`](https://ruby-doc.org/core-3.1.2/Object.html#method-i-3D-3D-3D) is used, but this is not easily ported to Python. For example, in the case of a callabel type like `bool` a decision must be made whether to check for type equality or call `bool` with the element to determine truthiness. Thus Pyby gives precedence to type checks. + +In the case of regex patterns, they only attempt to match an element if that element is of a compatible type to the regex pattern, i.e. a string regex pattern is applied to string-like elements and a bytes regex pattern is applied to bytes-like elements. diff --git a/src/pyby/enumerable.py b/src/pyby/enumerable.py index 9f565b8..4680cb8 100644 --- a/src/pyby/enumerable.py +++ b/src/pyby/enumerable.py @@ -1,12 +1,15 @@ import functools from importlib import import_module -from itertools import islice +from itertools import dropwhile, islice +from operator import truth +import re from .object import RObject EMPTY_REDUCE_ERRORS = [ "reduce() of empty iterable with no initial value", "reduce() of empty sequence with no initial value", ] +NOT_FOUND = object() NOT_USED = object() @@ -50,6 +53,37 @@ def wrapper(self, *args, **kwargs): return decorator + def any(self, compare_to=truth): + is_a = lambda item: isinstance(item, compare_to) # noqa + same = lambda item: item == compare_to # noqa + match = lambda item: isinstance(item, type(compare_to.pattern)) and bool( # noqa + compare_to.search(item) + ) + comparison = compare_to + if isinstance(compare_to, type): + comparison = is_a + elif isinstance(compare_to, re.Pattern): + comparison = match + elif not callable(compare_to): + comparison = same + + return any(comparison(item) for item in self.__each__()) + + def all(self, compare_to=truth): + is_a = lambda item: isinstance(item, compare_to) # noqa + same = lambda item: item == compare_to # noqa + match = lambda item: isinstance(item, type(compare_to.pattern)) and bool( # noqa + compare_to.search(item) + ) + comparison = compare_to + if isinstance(compare_to, type): + comparison = is_a + elif isinstance(compare_to, re.Pattern): + comparison = match + elif not callable(compare_to): + comparison = same + return not self.any(inverse(comparison)) + @configure() def collect(self, into, to_tuple, func): """ @@ -147,6 +181,40 @@ def inject(self, func_or_initial, func=NOT_USED): else: raise + def none(self, compare_to=truth): + is_a = lambda item: isinstance(item, compare_to) # noqa + same = lambda item: item == compare_to # noqa + match = lambda item: isinstance(item, type(compare_to.pattern)) and bool( # noqa + compare_to.search(item) + ) + comparison = compare_to + if isinstance(compare_to, type): + comparison = is_a + elif isinstance(compare_to, re.Pattern): + comparison = match + elif not callable(compare_to): + comparison = same + return not self.any(comparison) + + def one(self, compare_to=truth): + is_a = lambda item: isinstance(item, compare_to) # noqa + same = lambda item: item == compare_to # noqa + match = lambda item: isinstance(item, type(compare_to.pattern)) and bool( # noqa + compare_to.search(item) + ) + comparison = compare_to + if isinstance(compare_to, type): + comparison = is_a + elif isinstance(compare_to, re.Pattern): + comparison = match + elif not callable(compare_to): + comparison = same + tail = dropwhile(inverse(comparison), self.__each__()) + if next(tail, NOT_FOUND) == NOT_FOUND: + return False + else: + return not any(comparison(item) for item in tail) + @configure(use_into=False, use_to_tuple=False) def reject(self, predicate): """ diff --git a/tests/enumerable/__init__.py b/tests/enumerable/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/enumerable/none_test.py b/tests/enumerable/none_test.py new file mode 100644 index 0000000..ab3a33a --- /dev/null +++ b/tests/enumerable/none_test.py @@ -0,0 +1,50 @@ +import pytest +import re +from pyby import EnumerableList + + +@pytest.fixture +def empty_list(): + return EnumerableList() + + +@pytest.fixture +def numbers(): + return EnumerableList([1, 2, 3]) + + +def test_none(empty_list, numbers): + assert not numbers.none() + assert empty_list.none() + assert EnumerableList([False, None]).none() + + +def test_none_with_an_object(numbers): + assert not numbers.none(3) + assert numbers.none(4) + + +def test_none_with_a_predicate(empty_list, numbers): + assert not EnumerableList([0]).none(is_zero) + assert empty_list.none(is_zero) + assert numbers.none(is_zero) + + +def test_none_with_a_regex_pattern(numbers): + string_pattern = re.compile(r"\d") + assert numbers.none(string_pattern) + numbers.append("the number 69") + assert not numbers.none(string_pattern) + bytes_pattern = re.compile(r"\d".encode()) + assert numbers.none(bytes_pattern) + numbers.append(b"binary 420") + assert not numbers.none(bytes_pattern) + + +def test_none_with_a_class(numbers): + assert not numbers.none(int) + assert numbers.none(str) + + +def is_zero(element): + return element == 0 diff --git a/tests/enumerable/one_test.py b/tests/enumerable/one_test.py new file mode 100644 index 0000000..e322c3c --- /dev/null +++ b/tests/enumerable/one_test.py @@ -0,0 +1,54 @@ +import pytest +import re +from pyby import EnumerableList + + +@pytest.fixture +def empty_list(): + return EnumerableList() + + +@pytest.fixture +def numbers(): + return EnumerableList([1, 2, 3]) + + +def test_one(empty_list, numbers): + assert not numbers.one() + assert not empty_list.one() + assert not EnumerableList([False]).one() + assert EnumerableList([True]).one() + + +def test_one_with_an_object(numbers): + assert numbers.one(3) + assert not numbers.one(4) + + +def test_one_with_a_predicate(empty_list, numbers): + assert EnumerableList([0]).one(is_zero) + assert not empty_list.one(is_zero) + assert not numbers.one(is_zero) + + +def test_one_with_a_regex_pattern(numbers): + string_pattern = re.compile(r"\d") + assert not numbers.one(string_pattern) + numbers.append("the number 69") + assert numbers.one(string_pattern) + numbers.append("another number 69") + assert not numbers.one(string_pattern) + bytes_pattern = re.compile(r"\d".encode()) + assert not numbers.one(bytes_pattern) + numbers.append(b"binary 420") + assert numbers.one(bytes_pattern) + + +def test_one_with_a_class(numbers): + assert not numbers.one(int) + numbers.append(1.23) + assert numbers.one(float) + + +def is_zero(element): + return element == 0 diff --git a/tests/enumerable_list_test.py b/tests/enumerable_list_test.py index 23b40ca..5372df2 100644 --- a/tests/enumerable_list_test.py +++ b/tests/enumerable_list_test.py @@ -1,4 +1,5 @@ import pytest +import re from operator import add from pyby import EnumerableList from .test_helpers import assert_enumerable_list, assert_enumerator, pass_through @@ -33,6 +34,74 @@ def test_repr(letters): assert repr(letters) == "EnumerableList(['a', 'b', 'c'])" +def test_any(empty_list, numbers): + assert numbers.any() + assert not empty_list.any() + assert not EnumerableList([False, None]).any() + + +def test_any_with_an_object(numbers): + assert numbers.any(3) + assert not numbers.any(4) + + +def test_any_with_a_predicate(empty_list, numbers): + assert EnumerableList([0]).any(is_zero) + assert not empty_list.any(is_zero) + assert not numbers.any(is_zero) + + +def test_any_with_a_regex_pattern(numbers): + string_pattern = re.compile(r"\d") + assert not numbers.any(string_pattern) + numbers.append("the number 69") + assert numbers.any(string_pattern) + bytes_pattern = re.compile(r"\d".encode()) + assert not numbers.any(bytes_pattern) + numbers.append(b"binary 420") + assert numbers.any(bytes_pattern) + + +def test_any_with_a_class(numbers): + assert numbers.any(int) + assert not numbers.any(str) + + +def test_all(empty_list, numbers): + assert numbers.all() + assert empty_list.all() + assert not EnumerableList([False, None]).all() + + +def test_all_with_an_object(numbers): + assert not numbers.all(3) + assert EnumerableList([4, 4, 4]).all(4) + + +def test_all_with_a_predicate(empty_list, numbers): + assert empty_list.all(is_zero) + assert EnumerableList([0]).all(is_zero) + assert not numbers.all(larger_than_one) + + +# def test_all_with_a_regex_pattern(empty_list, numbers): +# string_pattern = re.compile(r"\d") + +# assert not numbers.all(string_pattern) +# assert numbers.all(string_pattern) + +# bytes_pattern = re.compile(r"\d".encode()) +# assert not numbers.any(bytes_pattern) +# numbers.append(b"420") +# assert numbers.any(bytes_pattern) + + +def test_all_with_a_class(numbers, list_with_a_tuple): + assert numbers.all(int) + assert not numbers.all(str) + assert not list_with_a_tuple.all(int) + + def test_collect_with_a_function_maps_over_the_items_and_returns_an_enumerable_list(numbers): assert_enumerable_list(numbers.collect(increment), [2, 3, 4]) diff --git a/tests/enumerable_test.py b/tests/enumerable_test.py index 6afd242..a2565ee 100644 --- a/tests/enumerable_test.py +++ b/tests/enumerable_test.py @@ -11,6 +11,8 @@ def enumerable(): @pytest.mark.parametrize( "method_name", [ + "any", + "all", "each", "collect", "collect_concat", @@ -26,6 +28,8 @@ def enumerable(): "inject", "map", "member", + "none", + "one", "reduce", "reject", "select",