From 1edfb2008d094065294c0d8d28bcdee2a61025b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennart=20Frid=C3=A9n?= Date: Fri, 6 May 2022 18:10:36 +0200 Subject: [PATCH 01/10] Add stubs for any, all, none, and one --- src/pyby/enumerable.py | 12 ++++++++++++ tests/enumerable_test.py | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/src/pyby/enumerable.py b/src/pyby/enumerable.py index 9f565b8..016402a 100644 --- a/src/pyby/enumerable.py +++ b/src/pyby/enumerable.py @@ -50,6 +50,12 @@ def wrapper(self, *args, **kwargs): return decorator + def any(): + pass + + def all(): + pass + @configure() def collect(self, into, to_tuple, func): """ @@ -147,6 +153,12 @@ def inject(self, func_or_initial, func=NOT_USED): else: raise + def none(): + pass + + def one(): + pass + @configure(use_into=False, use_to_tuple=False) def reject(self, predicate): """ 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", From 1f12a59b1aa86cd7f7f63d4a5070e0a6d7de70f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennart=20Frid=C3=A9n?= Date: Mon, 9 May 2022 13:31:22 +0200 Subject: [PATCH 02/10] Quick'n'dirty implementation of any --- src/pyby/enumerable.py | 20 ++++++++++++++++---- tests/enumerable_list_test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/pyby/enumerable.py b/src/pyby/enumerable.py index 016402a..0dd02cc 100644 --- a/src/pyby/enumerable.py +++ b/src/pyby/enumerable.py @@ -1,6 +1,8 @@ import functools from importlib import import_module from itertools import islice +from operator import truth +import re from .object import RObject EMPTY_REDUCE_ERRORS = [ @@ -50,10 +52,20 @@ def wrapper(self, *args, **kwargs): return decorator - def any(): - pass - - def all(): + 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, str) and bool(compare_to.match(item)) # noqa + comparison = compare_to + if isinstance(compare_to, type): + comparison = is_a + if isinstance(compare_to, re.Pattern): + return any(match(item) for item in self.__each__()) + if not callable(compare_to): + comparison = same + return any(comparison(item) for item in self.__each__()) + + def all(self): pass @configure() diff --git a/tests/enumerable_list_test.py b/tests/enumerable_list_test.py index 23b40ca..50c10da 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,33 @@ def test_repr(letters): assert repr(letters) == "EnumerableList(['a', 'b', 'c'])" +def test_any(numbers): + assert numbers.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(numbers): + assert EnumerableList([0]).any(is_zero) + assert not numbers.any(is_zero) + + +def test_any_with_a_regex_pattern(numbers): + pattern = re.compile(r"\d") + assert not numbers.any(pattern) + numbers.append("69") + assert numbers.any(pattern) + + +def test_any_with_a_class(numbers): + assert numbers.any(int) + assert not numbers.any(str) + + 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]) From 21ee1ada7d8f5a0bb18c429e1535c2b0e3966d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennart=20Frid=C3=A9n?= Date: Sat, 14 May 2022 10:57:32 +0200 Subject: [PATCH 03/10] Add documentation stubs for any, all, none, and one. --- README.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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. From dc9a79c03e362aabcef89eac102b90b0eecf8f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennart=20Frid=C3=A9n?= Date: Sat, 14 May 2022 19:50:58 +0200 Subject: [PATCH 04/10] Add test case for any for an empty enumerable --- tests/enumerable_list_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/enumerable_list_test.py b/tests/enumerable_list_test.py index 50c10da..b5387d6 100644 --- a/tests/enumerable_list_test.py +++ b/tests/enumerable_list_test.py @@ -37,6 +37,7 @@ def test_repr(letters): def test_any(numbers): assert numbers.any() assert not EnumerableList([False, None]).any() + assert not EnumerableList().any() def test_any_with_an_object(numbers): From f9e935a01929ea88ecde52d6d6e5121756249a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennart=20Frid=C3=A9n?= Date: Sat, 14 May 2022 19:57:31 +0200 Subject: [PATCH 05/10] Handle bytes-like regex patterns in addition to string patterns in any --- src/pyby/enumerable.py | 4 +++- tests/enumerable_list_test.py | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pyby/enumerable.py b/src/pyby/enumerable.py index 0dd02cc..321f210 100644 --- a/src/pyby/enumerable.py +++ b/src/pyby/enumerable.py @@ -55,7 +55,9 @@ def wrapper(self, *args, **kwargs): 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, str) and bool(compare_to.match(item)) # noqa + match = lambda item: isinstance(item, type(compare_to.pattern)) and bool( # noqa + compare_to.match(item) + ) comparison = compare_to if isinstance(compare_to, type): comparison = is_a diff --git a/tests/enumerable_list_test.py b/tests/enumerable_list_test.py index b5387d6..caf840f 100644 --- a/tests/enumerable_list_test.py +++ b/tests/enumerable_list_test.py @@ -51,10 +51,14 @@ def test_any_with_a_predicate(numbers): def test_any_with_a_regex_pattern(numbers): - pattern = re.compile(r"\d") - assert not numbers.any(pattern) + string_pattern = re.compile(r"\d") + assert not numbers.any(string_pattern) numbers.append("69") - assert numbers.any(pattern) + assert numbers.any(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_any_with_a_class(numbers): From fb98c7c667b80228d3de8251f1957f0fb970f308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennart=20Frid=C3=A9n?= Date: Sat, 14 May 2022 20:15:41 +0200 Subject: [PATCH 06/10] quick'n'dirty implementation of all --- src/pyby/enumerable.py | 23 ++++++++++++++----- tests/enumerable_list_test.py | 42 ++++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/pyby/enumerable.py b/src/pyby/enumerable.py index 321f210..cddf935 100644 --- a/src/pyby/enumerable.py +++ b/src/pyby/enumerable.py @@ -61,14 +61,27 @@ def any(self, compare_to=truth): comparison = compare_to if isinstance(compare_to, type): comparison = is_a - if isinstance(compare_to, re.Pattern): - return any(match(item) for item in self.__each__()) - if not callable(compare_to): + 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): - pass + 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.match(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 all(comparison(item) for item in self.__each__()) + return not self.any(inverse(comparison)) @configure() def collect(self, into, to_tuple, func): diff --git a/tests/enumerable_list_test.py b/tests/enumerable_list_test.py index caf840f..0febdcb 100644 --- a/tests/enumerable_list_test.py +++ b/tests/enumerable_list_test.py @@ -34,10 +34,10 @@ def test_repr(letters): assert repr(letters) == "EnumerableList(['a', 'b', 'c'])" -def test_any(numbers): +def test_any(empty_list, numbers): assert numbers.any() + assert not empty_list.any() assert not EnumerableList([False, None]).any() - assert not EnumerableList().any() def test_any_with_an_object(numbers): @@ -45,8 +45,9 @@ def test_any_with_an_object(numbers): assert not numbers.any(4) -def test_any_with_a_predicate(numbers): +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) @@ -66,6 +67,41 @@ def test_any_with_a_class(numbers): 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]) From 1520976e10c93e97eb7811fcb146e3484a67fd2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennart=20Frid=C3=A9n?= Date: Sat, 14 May 2022 20:56:32 +0200 Subject: [PATCH 07/10] Use regex search, not regex match to behave like Ruby's regex matching --- src/pyby/enumerable.py | 4 ++-- tests/enumerable_list_test.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pyby/enumerable.py b/src/pyby/enumerable.py index cddf935..cc4cb3f 100644 --- a/src/pyby/enumerable.py +++ b/src/pyby/enumerable.py @@ -56,7 +56,7 @@ 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.match(item) + compare_to.search(item) ) comparison = compare_to if isinstance(compare_to, type): @@ -71,7 +71,7 @@ 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.match(item) + compare_to.search(item) ) comparison = compare_to if isinstance(compare_to, type): diff --git a/tests/enumerable_list_test.py b/tests/enumerable_list_test.py index 0febdcb..5372df2 100644 --- a/tests/enumerable_list_test.py +++ b/tests/enumerable_list_test.py @@ -54,11 +54,11 @@ def test_any_with_a_predicate(empty_list, numbers): def test_any_with_a_regex_pattern(numbers): string_pattern = re.compile(r"\d") assert not numbers.any(string_pattern) - numbers.append("69") + 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"420") + numbers.append(b"binary 420") assert numbers.any(bytes_pattern) From b29cb0adeec065e123b8b14186b7668fd2516c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennart=20Frid=C3=A9n?= Date: Sun, 15 May 2022 11:52:57 +0200 Subject: [PATCH 08/10] Quick'n'dirty implementation of none --- src/pyby/enumerable.py | 16 +++++++++-- tests/enumerable/__init__.py | 0 tests/enumerable/none_test.py | 50 +++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/enumerable/__init__.py create mode 100644 tests/enumerable/none_test.py diff --git a/src/pyby/enumerable.py b/src/pyby/enumerable.py index cc4cb3f..dc7bebe 100644 --- a/src/pyby/enumerable.py +++ b/src/pyby/enumerable.py @@ -180,8 +180,20 @@ def inject(self, func_or_initial, func=NOT_USED): else: raise - def none(): - pass + 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(): pass 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 From 7d74a2fef4479fdcc297025f0689dc91427cb1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennart=20Frid=C3=A9n?= Date: Tue, 17 May 2022 15:03:51 +0200 Subject: [PATCH 09/10] Use Enumerable.any from Enumerable.all --- src/pyby/enumerable.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pyby/enumerable.py b/src/pyby/enumerable.py index dc7bebe..a413d0b 100644 --- a/src/pyby/enumerable.py +++ b/src/pyby/enumerable.py @@ -80,7 +80,6 @@ def all(self, compare_to=truth): comparison = match elif not callable(compare_to): comparison = same - # return all(comparison(item) for item in self.__each__()) return not self.any(inverse(comparison)) @configure() From 7e5a8b0deaa2566fe61162757306ddc38f93811f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lennart=20Frid=C3=A9n?= Date: Tue, 17 May 2022 15:34:21 +0200 Subject: [PATCH 10/10] Quick'n'dirty implementation of none --- src/pyby/enumerable.py | 24 ++++++++++++++-- tests/enumerable/one_test.py | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 tests/enumerable/one_test.py diff --git a/src/pyby/enumerable.py b/src/pyby/enumerable.py index a413d0b..4680cb8 100644 --- a/src/pyby/enumerable.py +++ b/src/pyby/enumerable.py @@ -1,6 +1,6 @@ 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 @@ -9,6 +9,7 @@ "reduce() of empty iterable with no initial value", "reduce() of empty sequence with no initial value", ] +NOT_FOUND = object() NOT_USED = object() @@ -65,6 +66,7 @@ def any(self, compare_to=truth): comparison = match elif not callable(compare_to): comparison = same + return any(comparison(item) for item in self.__each__()) def all(self, compare_to=truth): @@ -194,8 +196,24 @@ def none(self, compare_to=truth): comparison = same return not self.any(comparison) - def one(): - pass + 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/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