From 9caec1e718513ea6c420b2daca73c2ef91e743bf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ma=C5=9Blanka?= <piotr.maslanka@henrietta.com.pl>
Date: Sun, 17 Nov 2024 20:17:57 +0100
Subject: [PATCH] added `run_when_iterator_completes`

---
 CHANGELOG.md                        |  4 ++++
 docs/coding/sequences.rst           |  4 +++-
 satella/__init__.py                 |  2 +-
 satella/coding/__init__.py          |  4 ++--
 satella/coding/generators.py        | 26 +++++++++++++++++++-------
 satella/coding/iterators.py         | 13 +++++++++++++
 tests/test_coding/test_iterators.py | 23 ++++++++++++++++++++++-
 7 files changed, 64 insertions(+), 12 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b4a0d716..647295ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+# v2.26.3
+
+* added `run_when_iterator_completes`
+
 # v2.26.2
 
 * RunActionAfterGeneratorCompletes won't call it's on_done action if closed prematurely
diff --git a/docs/coding/sequences.rst b/docs/coding/sequences.rst
index ab964020..857c5fbb 100644
--- a/docs/coding/sequences.rst
+++ b/docs/coding/sequences.rst
@@ -7,7 +7,9 @@ Generators
 .. autoclass:: satella.coding.RunActionAfterGeneratorCompletes
     :members:
 
-.. autoclass:: satella.coding.
+.. autofunction:: satella.coding.run_when_generator_completes
+
+.. autofunction:: satella.coding.run_when_iterator_completes
 
 Rolling averages
 ================
diff --git a/satella/__init__.py b/satella/__init__.py
index b9d4f6d2..dc3b7a8e 100644
--- a/satella/__init__.py
+++ b/satella/__init__.py
@@ -1 +1 @@
-__version__ = '2.26.2'
+__version__ = '2.26.3a1'
diff --git a/satella/coding/__init__.py b/satella/coding/__init__.py
index 3c8acc60..653a3bb7 100644
--- a/satella/coding/__init__.py
+++ b/satella/coding/__init__.py
@@ -13,7 +13,7 @@ from .deleters import ListDeleter, DictDeleter
 from .environment import Context
 from .expect_exception import expect_exception
 from .fun_static import static_var
-from .iterators import hint_with_length, SelfClosingGenerator, exhaust, chain
+from .iterators import hint_with_length, SelfClosingGenerator, exhaust, chain, run_when_iterator_completes
 from .metaclasses import metaclass_maker, wrap_with, dont_wrap, wrap_property, DocsFromParent, \
     CopyDocsFrom
 from .misc import update_if_not_none, update_key_if_none, update_attr_if_none, queue_iterator, \
@@ -26,7 +26,7 @@ from .recast_exceptions import rethrow_as, silence_excs, catch_exception, log_ex
 from .generators import RunActionAfterGeneratorCompletes, run_when_generator_completes
 
 __all__ = [
-    'RunActionAfterGeneratorCompletes', 'run_when_generator_completes',
+    'RunActionAfterGeneratorCompletes', 'run_when_generator_completes', 'run_when_iterator_completes',
     'EmptyContextManager', 'Context', 'length',
     'assert_equal', 'InequalityReason', 'Inequal', 'wrap_callable_in_context_manager',
     'Closeable', 'contains', 'enum_value',
diff --git a/satella/coding/generators.py b/satella/coding/generators.py
index fd29de6a..ac6673c6 100644
--- a/satella/coding/generators.py
+++ b/satella/coding/generators.py
@@ -12,26 +12,35 @@ class RunActionAfterGeneratorCompletes(tp.Generator, metaclass=ABCMeta):
     via close()
     """
 
-    __slots__ = 'generator', 'args', 'kwargs', 'closed'
+    __slots__ = 'generator', 'args', 'kwargs', 'closed', 'call_despite_closed'
 
-    def __init__(self, generator: tp.Generator, *args, **kwargs):
+    def __init__(self, generator: tp.Generator, *args, call_despite_closed: bool = False, **kwargs):
         """
         :param generator: generator to watch for
         :param args: arguments to invoke action_to_run with
+        :param call_despite_closed: :meth:`action_to_run` will be called even if the generator is closed
         :param kwargs: keyword arguments to invoke action_to_run with
         """
         self.closed = False
         self.generator = generator
         self.args = args
+        self.call_despite_closed = call_despite_closed
         self.kwargs = kwargs
 
     def close(self):
+        """
+        Close this generator. Note that this will cause :meth:`action_to_run` not to run
+        """
         self.closed = True
         self.generator.close()
 
     def send(self, value):
         """Send a value to the generator"""
-        return self.generator.send(value)
+        try:
+            return self.generator.send(value)
+        except StopIteration:
+            self._try_action_run()
+            raise
 
     def next(self):
         return self.generator.__next__()
@@ -46,16 +55,19 @@ class RunActionAfterGeneratorCompletes(tp.Generator, metaclass=ABCMeta):
         try:
             return self.generator.__next__()
         except StopIteration:
-            if not self.closed:
-                self.action_to_run(*self.args, **self.kwargs)
+            self._try_action_run()
             raise
 
+    def _try_action_run(self):
+        if not self.closed and not self.call_despite_closed:
+            self.action_to_run(*self.args, **self.kwargs)
+
     @abstractmethod
-    def action_to_run(self):
+    def action_to_run(self, *args, **kwargs):
         """This will run when this generator completes. Override it."""
 
 
-def run_when_generator_completes(gen: tp.Generator, call_on_done: tp.Callable[[], None],
+def run_when_generator_completes(gen: tp.Generator, call_on_done: tp.Callable[[...], None],
                                  *args, **kwargs) -> RunActionAfterGeneratorCompletes:
     """
     Return the generator with call_on_done to be called on when it finishes
diff --git a/satella/coding/iterators.py b/satella/coding/iterators.py
index 2fa23658..99b1b242 100644
--- a/satella/coding/iterators.py
+++ b/satella/coding/iterators.py
@@ -136,3 +136,16 @@ class hint_with_length:
             return self.length_factory()
         else:
             return self.length
+
+
+def run_when_iterator_completes(iterator, func_to_run, *args, **kwargs):
+    """
+    Schedule a function to be called when an iterator completes.
+
+    :param iterator: iterator to use
+    :param func_to_run: function to run afterwards
+    :param args: arguments to pass to the function
+    :param kwargs: keyword arguments to pass to the function
+    """
+    yield from iterator
+    func_to_run(*args, **kwargs)
diff --git a/tests/test_coding/test_iterators.py b/tests/test_coding/test_iterators.py
index b1afe729..539b5e29 100644
--- a/tests/test_coding/test_iterators.py
+++ b/tests/test_coding/test_iterators.py
@@ -3,7 +3,8 @@ import sys
 import logging
 import unittest
 
-from satella.coding import SelfClosingGenerator, hint_with_length, chain, run_when_generator_completes, typing
+from satella.coding import SelfClosingGenerator, hint_with_length, chain, run_when_generator_completes, typing, \
+    run_when_iterator_completes
 from satella.coding.sequences import smart_enumerate, ConstruableIterator, walk, \
     IteratorListAdapter, is_empty, ListWrapperIterator
 
@@ -73,6 +74,26 @@ class TestIterators(unittest.TestCase):
             pass
         self.assertTrue(called)
 
+    def test_run_when_iterator_completes(self):
+        called = False
+
+        def generator():
+            yield 1
+            yield 2
+            yield 3
+
+        def mark_done():
+            nonlocal called
+            called = True
+
+        a = run_when_iterator_completes(generator(), mark_done)
+        self.assertFalse(called)
+        next(a)
+        self.assertFalse(called)
+        for i in a:
+            pass
+        self.assertTrue(called)
+
     def test_run_when_generator_closed(self):
         called = False
 
-- 
GitLab