From 1e099384ab664d32b726b667fb83164003568437 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 22 Aug 2025 20:42:33 -0700 Subject: [PATCH 01/17] Add ExceptionExitStack Signed-off-by: liamhuber --- pyiron_snippets/exception_context.py | 79 ++++++++++++++++++++++++++++ tests/unit/test_exception_context.py | 43 +++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 pyiron_snippets/exception_context.py create mode 100644 tests/unit/test_exception_context.py diff --git a/pyiron_snippets/exception_context.py b/pyiron_snippets/exception_context.py new file mode 100644 index 0000000..0084f3d --- /dev/null +++ b/pyiron_snippets/exception_context.py @@ -0,0 +1,79 @@ +import contextlib + + +class ExceptionExitStack(contextlib.ExitStack): + """ + A variant of contextlib.ExitStack that only executes registered callbacks + when an exception is raised, and only if that exception matches one of the + specified exception types. + + Behavior: + - If no exception types are given, callbacks run for any raised exception. + - If one or more exception types are given, callbacks run only when the + raised exception is an instance of at least one of those types. + - On normal (non-exceptional) exit, callbacks are discarded and not run. + - Exceptions are not suppressed by this context manager. + + Parameters: + *exceptions: type[Exception] + Zero or more exception types. If empty, callbacks run for any + exception; otherwise, only for matching exception types. + + Examples: + Let's take a toy callback and see how we do (or don't) trigger it. + + >>> def its_historical(history: list[str], message: str) -> None: + ... history.append(message) + + No types specified: callbacks run for any raised exception. + + >>> from pyiron_snippets.exception_context import ExceptionExitStack + >>> history = [] + >>> try: + ... with ExceptionExitStack() as stack: + ... _ = stack.callback(its_historical, history, "with no types") + ... raise RuntimeError("Application error") + ... except RuntimeError: + ... history + ['with no types'] + + Specified type(s) match(es) the raised exception: callbacks run. + + >>> history = [] + >>> try: + ... with ExceptionExitStack(RuntimeError) as stack: + ... _ = stack.callback(its_historical, history, "with matching type") + ... raise RuntimeError("Application error") + ... except RuntimeError: + ... history + ['with matching type'] + + Specified type(s) do(es) not match the raised exception: callbacks do not run. + + >>> history = [] + >>> try: + ... with ExceptionExitStack(TypeError, ValueError) as stack: + ... _ = stack.callback(its_historical, history, "with mis-matching types") + ... raise RuntimeError("Application error") + ... except RuntimeError: + ... history + [] + + No exception raised: callbacks do not run. + + >>> history = [] + >>> with ExceptionExitStack() as stack: + ... _ = stack.callback(its_historical, history, "we shouldn't see this") + ... # because there's no exception here + >>> history + [] + """ + + def __init__(self, *exceptions: type[Exception]): + super().__init__() + self._exception_types = [Exception] if len(exceptions) == 0 else exceptions + + def __exit__(self, exc_type, exc_val, exc_tb): + if any(isinstance(exc_val, e) for e in self._exception_types): + return super().__exit__(exc_type, exc_val, exc_tb) + self.pop_all() diff --git a/tests/unit/test_exception_context.py b/tests/unit/test_exception_context.py new file mode 100644 index 0000000..54ac8ae --- /dev/null +++ b/tests/unit/test_exception_context.py @@ -0,0 +1,43 @@ +import unittest + +from pyiron_snippets.exception_context import ExceptionExitStack + + +class TestExceptionContext(unittest.TestCase): + def test_exception_exit_stack(self): + def rollback(history: list[str], message: str) -> None: + history.append(message) + + with self.subTest("Callback on all exceptions when no types are specified"): + history = [] + try: + with ExceptionExitStack() as stack: + stack.callback(rollback, history, "with no types") + raise RuntimeError("Application error") + except RuntimeError: + self.assertEqual(history, ["with no types"]) + + with self.subTest("Callback on matching exception with specifier"): + history = [] + try: + with ExceptionExitStack(RuntimeError) as stack: + stack.callback(rollback, history, "with matching type") + raise RuntimeError("Application error") + except RuntimeError: + self.assertEqual(history, ["with matching type"]) + + with self.subTest("No callback on mis-matching exception with specifier(s)"): + history = [] + try: + with ExceptionExitStack(TypeError, ValueError) as stack: + stack.callback(rollback, history, "with mis-matching types") + raise RuntimeError("Application error") + except RuntimeError: + self.assertEqual(history, []) + + with self.subTest("No callback without exceptions"): + history = [] + with ExceptionExitStack() as stack: + stack.callback(rollback, history, "we shouldn't see this") + # because there's no exception here + self.assertEqual(history, []) From d76720469035ec5195f486c7464718303078e110 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 22 Aug 2025 20:45:54 -0700 Subject: [PATCH 02/17] Add ExceptionExitStack Signed-off-by: liamhuber --- tests/unit/test_exception_context.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/test_exception_context.py b/tests/unit/test_exception_context.py index 54ac8ae..84c7c7c 100644 --- a/tests/unit/test_exception_context.py +++ b/tests/unit/test_exception_context.py @@ -41,3 +41,7 @@ def rollback(history: list[str], message: str) -> None: stack.callback(rollback, history, "we shouldn't see this") # because there's no exception here self.assertEqual(history, []) + + +if __name__ == "__main__": + unittest.main() From a0048e26ca6574c34baba7eb6818289e9d4c4c06 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 22 Aug 2025 20:57:23 -0700 Subject: [PATCH 03/17] Extract and re-name function Signed-off-by: liamhuber --- tests/unit/test_exception_context.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_exception_context.py b/tests/unit/test_exception_context.py index 84c7c7c..4da19f5 100644 --- a/tests/unit/test_exception_context.py +++ b/tests/unit/test_exception_context.py @@ -3,16 +3,17 @@ from pyiron_snippets.exception_context import ExceptionExitStack +def its_historical(history: list[str], message: str) -> None: + history.append(message) + + class TestExceptionContext(unittest.TestCase): def test_exception_exit_stack(self): - def rollback(history: list[str], message: str) -> None: - history.append(message) - with self.subTest("Callback on all exceptions when no types are specified"): history = [] try: with ExceptionExitStack() as stack: - stack.callback(rollback, history, "with no types") + stack.callback(its_historical, history, "with no types") raise RuntimeError("Application error") except RuntimeError: self.assertEqual(history, ["with no types"]) @@ -21,7 +22,7 @@ def rollback(history: list[str], message: str) -> None: history = [] try: with ExceptionExitStack(RuntimeError) as stack: - stack.callback(rollback, history, "with matching type") + stack.callback(its_historical, history, "with matching type") raise RuntimeError("Application error") except RuntimeError: self.assertEqual(history, ["with matching type"]) @@ -30,7 +31,7 @@ def rollback(history: list[str], message: str) -> None: history = [] try: with ExceptionExitStack(TypeError, ValueError) as stack: - stack.callback(rollback, history, "with mis-matching types") + stack.callback(its_historical, history, "with mis-matching types") raise RuntimeError("Application error") except RuntimeError: self.assertEqual(history, []) @@ -38,7 +39,7 @@ def rollback(history: list[str], message: str) -> None: with self.subTest("No callback without exceptions"): history = [] with ExceptionExitStack() as stack: - stack.callback(rollback, history, "we shouldn't see this") + stack.callback(its_historical, history, "we shouldn't see this") # because there's no exception here self.assertEqual(history, []) From 5c4e3fa0690b2e94edb9888ac9a9dc47890c93f0 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 22 Aug 2025 21:13:22 -0700 Subject: [PATCH 04/17] Add a context manager for exception callbacks Co-authored-by: Marvin Poul Signed-off-by: liamhuber --- pyiron_snippets/exception_context.py | 106 +++++++++++++++++++++++++++ tests/unit/test_exception_context.py | 68 ++++++++++++++++- 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/pyiron_snippets/exception_context.py b/pyiron_snippets/exception_context.py index 0084f3d..780a938 100644 --- a/pyiron_snippets/exception_context.py +++ b/pyiron_snippets/exception_context.py @@ -1,4 +1,6 @@ import contextlib +from collections.abc import Callable, Collection +from typing import Any class ExceptionExitStack(contextlib.ExitStack): @@ -77,3 +79,107 @@ def __exit__(self, exc_type, exc_val, exc_tb): if any(isinstance(exc_val, e) for e in self._exception_types): return super().__exit__(exc_type, exc_val, exc_tb) self.pop_all() + + +@contextlib.contextmanager +def on_error( + func: Callable[..., Any], + exceptions: type[Exception] | Collection[type[Exception]] | None, + *args: Any, + **kwargs: Any, +): + """ + A context manager that invokes a callback only when an exception is raised, + and only if that exception matches the specified type(s). + + This is analogous to ExceptionExitStack, but designed for use with an + existing context manager stack (e.g., contextlib.ExitStack). It registers + a single callback and defers calling it until an exception occurs and + matches the provided exception type(s). + + Behavior: + - If exceptions is None, the callback runs for any raised Exception. + - If a single exception type is provided, the callback runs only when the + raised exception is an instance of that type. + - If a collection of exception types is provided, the callback runs when + the raised exception matches any type in the collection. + - On normal (non-exceptional) exit, the callback does not run. + - Exceptions are never suppressed; they are always re-raised after the + callback (if any) has been executed. + + Parameters: + func: Callable[..., Any] + The callback to execute on a matching exception. + exceptions: type[Exception] | Collection[type[Exception]] | None + The exception type(s) that should trigger the callback. Use None + to match all Exceptions. + *args: Any + Positional arguments passed to the callback. + **kwargs: Any + Keyword arguments passed to the callback. + + Examples: + A simple callback that records a message: + + >>> def its_historical(history: list[str], message: str) -> None: + ... history.append(message) + + Callback on all exceptions when no types are specified: + + >>> import contextlib + >>> from pyiron_snippets.exception_context import on_error + >>> history = [] + >>> msg = "with no types" + >>> try: + ... with contextlib.ExitStack() as stack: + ... _ = stack.enter_context(on_error(its_historical, None, history, message=msg)) + ... raise RuntimeError("Application error") + ... except RuntimeError: + ... history + ['with no types'] + + Callback on matching exception with a specifier: + + >>> history = [] + >>> msg = "with matching type" + >>> try: + ... with contextlib.ExitStack() as stack: + ... _ = stack.enter_context(on_error(its_historical, RuntimeError, history, message=msg)) + ... raise RuntimeError("Application error") + ... except RuntimeError: + ... history + ['with matching type'] + + No callback on mis-matching exception types: + + >>> history = [] + >>> try: + ... with contextlib.ExitStack() as stack: + ... _ = stack.enter_context(on_error(its_historical, (TypeError, ValueError), history, message="nope")) + ... raise RuntimeError("Application error") + ... except RuntimeError: + ... history + [] + + No exception raised: callback does not run: + + >>> history = [] + >>> with contextlib.ExitStack() as stack: + ... _ = stack.enter_context(on_error(its_historical, None, history, message="we shouldn't see this")) + >>> history + [] + """ + + if exceptions is None: + exception_types = (Exception,) + elif isinstance(exceptions, type) and issubclass(exceptions, BaseException): + exception_types = (exceptions,) + else: + exception_types = tuple(exceptions) + + try: + yield + except Exception as e: + if any(isinstance(e, exc_type) for exc_type in exception_types): + func(*args, **kwargs) + raise diff --git a/tests/unit/test_exception_context.py b/tests/unit/test_exception_context.py index 4da19f5..bd26e50 100644 --- a/tests/unit/test_exception_context.py +++ b/tests/unit/test_exception_context.py @@ -1,6 +1,7 @@ +import contextlib import unittest -from pyiron_snippets.exception_context import ExceptionExitStack +from pyiron_snippets.exception_context import ExceptionExitStack, on_error def its_historical(history: list[str], message: str) -> None: @@ -43,6 +44,71 @@ def test_exception_exit_stack(self): # because there's no exception here self.assertEqual(history, []) + def test_on_error(self): + with self.subTest("Callback on all exceptions when no types are specified"): + history = [] + msg = "with no types" + try: + with contextlib.ExitStack() as stack: + stack.enter_context( + on_error( + its_historical, + None, + history, + message=msg, + ) + ) + raise RuntimeError("Application error") + except RuntimeError: + self.assertEqual(history, [msg]) + + with self.subTest("Callback on matching exception with specifier"): + history = [] + msg = "with matching type" + try: + with contextlib.ExitStack() as stack: + stack.enter_context( + on_error( + its_historical, + RuntimeError, + history, + message=msg, + ) + ) + raise RuntimeError("Application error") + except RuntimeError: + self.assertEqual(history, [msg]) + + with self.subTest("No callback on mis-matching exception with specifier(s)"): + history = [] + try: + with contextlib.ExitStack() as stack: + stack.enter_context( + on_error( + its_historical, + (TypeError, ValueError), + history, + message="with mis-matching types", + ) + ) + raise RuntimeError("Application error") + except RuntimeError: + self.assertEqual(history, []) + + with self.subTest("No callback without exceptions"): + history = [] + with contextlib.ExitStack() as stack: + stack.enter_context( + on_error( + its_historical, + (TypeError, ValueError), + history, + message="we shouldn't see this", + ) + ) + # because there's no exception here + self.assertEqual(history, []) + if __name__ == "__main__": unittest.main() From 64e6dde91714973cbdf2d0df850d0a826443c924 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Fri, 22 Aug 2025 21:18:38 -0700 Subject: [PATCH 05/17] Demonstrate and test combinations Signed-off-by: liamhuber --- pyiron_snippets/exception_context.py | 19 ++++++++++++------- tests/unit/test_exception_context.py | 14 +++++++++----- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/pyiron_snippets/exception_context.py b/pyiron_snippets/exception_context.py index 780a938..8fc7fe4 100644 --- a/pyiron_snippets/exception_context.py +++ b/pyiron_snippets/exception_context.py @@ -61,14 +61,17 @@ class ExceptionExitStack(contextlib.ExitStack): ... history [] - No exception raised: callbacks do not run. + No exception raised: callbacks do not run. But, the stack can be combined with + other stacks. + >>> import contextlib + >>> >>> history = [] - >>> with ExceptionExitStack() as stack: - ... _ = stack.callback(its_historical, history, "we shouldn't see this") - ... # because there's no exception here + >>> with ExceptionExitStack() as exc_stack, contextlib.ExitStack() as reg_stack: + ... _ = exc_stack.callback(its_historical, history, "we shouldn't see this") + ... _ = reg_stack.callback(its_historical, history, "but we should see this") >>> history - [] + ['but we should see this'] """ def __init__(self, *exceptions: type[Exception]): @@ -161,13 +164,15 @@ def on_error( ... history [] - No exception raised: callback does not run: + No exception raised: callback does not run. But, we can add regular callbacks + to the stack to combine effects. >>> history = [] >>> with contextlib.ExitStack() as stack: ... _ = stack.enter_context(on_error(its_historical, None, history, message="we shouldn't see this")) + ... _ = stack.callback(its_historical, history, message="but we should see this") >>> history - [] + ['but we should see this'] """ if exceptions is None: diff --git a/tests/unit/test_exception_context.py b/tests/unit/test_exception_context.py index bd26e50..9de102f 100644 --- a/tests/unit/test_exception_context.py +++ b/tests/unit/test_exception_context.py @@ -37,12 +37,14 @@ def test_exception_exit_stack(self): except RuntimeError: self.assertEqual(history, []) - with self.subTest("No callback without exceptions"): + with self.subTest("No callback without exceptions; combining is ok"): history = [] - with ExceptionExitStack() as stack: - stack.callback(its_historical, history, "we shouldn't see this") + msg = "but we should see this" + with ExceptionExitStack() as exc_stack, contextlib.ExitStack() as reg_stack: + exc_stack.callback(its_historical, history, "we shouldn't see this") # because there's no exception here - self.assertEqual(history, []) + reg_stack.callback(its_historical, history, msg) + self.assertEqual(history, [msg]) def test_on_error(self): with self.subTest("Callback on all exceptions when no types are specified"): @@ -97,6 +99,7 @@ def test_on_error(self): with self.subTest("No callback without exceptions"): history = [] + msg = "but we should see this" with contextlib.ExitStack() as stack: stack.enter_context( on_error( @@ -107,7 +110,8 @@ def test_on_error(self): ) ) # because there's no exception here - self.assertEqual(history, []) + stack.callback(its_historical, history, msg) + self.assertEqual(history, [msg]) if __name__ == "__main__": From a836c343f9afa63f3786bbd470b1603d8bda73c8 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 2 Oct 2025 10:05:36 -0700 Subject: [PATCH 06/17] Clarify variable type Signed-off-by: liamhuber --- pyiron_snippets/exception_context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyiron_snippets/exception_context.py b/pyiron_snippets/exception_context.py index 8fc7fe4..a6b55e6 100644 --- a/pyiron_snippets/exception_context.py +++ b/pyiron_snippets/exception_context.py @@ -175,6 +175,7 @@ def on_error( ['but we should see this'] """ + exception_types: tuple[type[Exception], ...] if exceptions is None: exception_types = (Exception,) elif isinstance(exceptions, type) and issubclass(exceptions, BaseException): From 489e180e52ceab8bd7d8e2e65fe20d2e02cc9567 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 2 Oct 2025 10:13:00 -0700 Subject: [PATCH 07/17] Add example to README Signed-off-by: liamhuber --- docs/README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/README.md b/docs/README.md index 0780ee4..ad0ea49 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,6 +65,36 @@ A dictionary that allows dot-access. Has `.items()` etc. ``` +## Exception context + +A variant of [`contextlib.ExitStack`](https://docs.python.org/3/library/contextlib.html#contextlib.ExitStack) that only executes registered callbacks when an exception is raised, and only if that exception matches one of the specified exception types (or any exception, if types are not specified). + +```python +>>> from pyiron_snippets.exception_context import ExceptionExitStack +>>> +>>> def its_historical(history: list[str], message: str) -> None: +... history.append(message) +>>> +>>> history = [] +>>> try: +... with ExceptionExitStack(RuntimeError) as stack: +... _ = stack.callback(its_historical, history, "with matching type") +... raise RuntimeError("Application error") +... except RuntimeError: +... history +['with matching type'] + +>>> history = [] +>>> try: +... with ExceptionExitStack(TypeError, ValueError) as stack: +... _ = stack.callback(its_historical, history, "with mis-matching types") +... raise RuntimeError("Application error") +... except RuntimeError: +... history +[] + +``` + ## Factory Make dynamic classes that are still pickle-able From 9325574029d97a28f7d392268abdaa370fdf8536 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 2 Oct 2025 10:19:25 -0700 Subject: [PATCH 08/17] Only use Exception not BaseException; for consistency with type hints and between tools Signed-off-by: liamhuber --- pyiron_snippets/exception_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyiron_snippets/exception_context.py b/pyiron_snippets/exception_context.py index a6b55e6..f5d6cde 100644 --- a/pyiron_snippets/exception_context.py +++ b/pyiron_snippets/exception_context.py @@ -178,7 +178,7 @@ def on_error( exception_types: tuple[type[Exception], ...] if exceptions is None: exception_types = (Exception,) - elif isinstance(exceptions, type) and issubclass(exceptions, BaseException): + elif isinstance(exceptions, type) and issubclass(exceptions, Exception): exception_types = (exceptions,) else: exception_types = tuple(exceptions) From 57eea7eb377e842490a2b0674a0093da8c8b8292 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 2 Oct 2025 10:20:20 -0700 Subject: [PATCH 09/17] Mention the wrapper Signed-off-by: liamhuber --- docs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/README.md b/docs/README.md index ad0ea49..fd00f25 100644 --- a/docs/README.md +++ b/docs/README.md @@ -95,6 +95,8 @@ A variant of [`contextlib.ExitStack`](https://docs.python.org/3/library/contextl ``` +The module also provides a wrapper, `on_error`, for use directly with `contextlib.ExitStack`. + ## Factory Make dynamic classes that are still pickle-able From b4b37d2a9c05513707d0d9466455e221ce181f62 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 2 Oct 2025 10:22:19 -0700 Subject: [PATCH 10/17] Explicitly hint and type match Signed-off-by: liamhuber --- pyiron_snippets/exception_context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyiron_snippets/exception_context.py b/pyiron_snippets/exception_context.py index f5d6cde..e2afcaf 100644 --- a/pyiron_snippets/exception_context.py +++ b/pyiron_snippets/exception_context.py @@ -76,7 +76,9 @@ class ExceptionExitStack(contextlib.ExitStack): def __init__(self, *exceptions: type[Exception]): super().__init__() - self._exception_types = [Exception] if len(exceptions) == 0 else exceptions + self._exception_types: tuple[type[Exception], ...] = ( + (Exception,) if len(exceptions) == 0 else exceptions + ) def __exit__(self, exc_type, exc_val, exc_tb): if any(isinstance(exc_val, e) for e in self._exception_types): From e50de1537ba2cc2ba279d9d719a781b1fddd8a96 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 2 Oct 2025 10:23:12 -0700 Subject: [PATCH 11/17] Explicitly catch the non-exception case Logic is unchanged, but meaning is clearer Signed-off-by: liamhuber --- pyiron_snippets/exception_context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyiron_snippets/exception_context.py b/pyiron_snippets/exception_context.py index e2afcaf..fed95c6 100644 --- a/pyiron_snippets/exception_context.py +++ b/pyiron_snippets/exception_context.py @@ -81,7 +81,9 @@ def __init__(self, *exceptions: type[Exception]): ) def __exit__(self, exc_type, exc_val, exc_tb): - if any(isinstance(exc_val, e) for e in self._exception_types): + if exc_val is not None and any( + isinstance(exc_val, e) for e in self._exception_types + ): return super().__exit__(exc_type, exc_val, exc_tb) self.pop_all() From ed13c278f338bea095199b6cde59bdf76c37533a Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 2 Oct 2025 10:34:52 -0700 Subject: [PATCH 12/17] Fail more cleanly on garbage input Signed-off-by: liamhuber --- pyiron_snippets/exception_context.py | 14 ++++++++++++++ tests/unit/test_exception_context.py | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/pyiron_snippets/exception_context.py b/pyiron_snippets/exception_context.py index fed95c6..c28e1b5 100644 --- a/pyiron_snippets/exception_context.py +++ b/pyiron_snippets/exception_context.py @@ -75,6 +75,13 @@ class ExceptionExitStack(contextlib.ExitStack): """ def __init__(self, *exceptions: type[Exception]): + if not all( + isinstance(e, type) and issubclass(e, Exception) for e in exceptions + ): + raise ValueError( + f"Invalid exception type(s) provided. Expected only subclasses of " + f"`Exception`, but got {exceptions}" + ) super().__init__() self._exception_types: tuple[type[Exception], ...] = ( (Exception,) if len(exceptions) == 0 else exceptions @@ -185,6 +192,13 @@ def on_error( elif isinstance(exceptions, type) and issubclass(exceptions, Exception): exception_types = (exceptions,) else: + if not all( + isinstance(e, type) and issubclass(e, Exception) for e in exceptions + ): + raise ValueError( + f"Invalid exception type(s) provided. Expected only subclasses of " + f"`Exception`, but got {exceptions}" + ) exception_types = tuple(exceptions) try: diff --git a/tests/unit/test_exception_context.py b/tests/unit/test_exception_context.py index 9de102f..e526a74 100644 --- a/tests/unit/test_exception_context.py +++ b/tests/unit/test_exception_context.py @@ -46,6 +46,10 @@ def test_exception_exit_stack(self): reg_stack.callback(its_historical, history, msg) self.assertEqual(history, [msg]) + with self.subTest("Clean error message on garbage input"): + with self.assertRaises(ValueError): + ExceptionExitStack("these", "aren't", "exceptions") + def test_on_error(self): with self.subTest("Callback on all exceptions when no types are specified"): history = [] @@ -113,6 +117,18 @@ def test_on_error(self): stack.callback(its_historical, history, msg) self.assertEqual(history, [msg]) + with self.subTest("Clean error message on garbage input"): + history = [] + with self.assertRaises(ValueError), contextlib.ExitStack() as stack: + stack.enter_context( + on_error( + its_historical, + "this is not an exception type", + history, + message="we shouldn't see this", + ) + ) + if __name__ == "__main__": unittest.main() From d51b0c63abd05563c448573cf4ec48fcaff85972 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 2 Oct 2025 10:36:09 -0700 Subject: [PATCH 13/17] black Signed-off-by: liamhuber --- pyiron_snippets/exception_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyiron_snippets/exception_context.py b/pyiron_snippets/exception_context.py index c28e1b5..7ce8d77 100644 --- a/pyiron_snippets/exception_context.py +++ b/pyiron_snippets/exception_context.py @@ -193,7 +193,7 @@ def on_error( exception_types = (exceptions,) else: if not all( - isinstance(e, type) and issubclass(e, Exception) for e in exceptions + isinstance(e, type) and issubclass(e, Exception) for e in exceptions ): raise ValueError( f"Invalid exception type(s) provided. Expected only subclasses of " From a84ccb1afac184614665d84f16f2dd94842e2706 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Thu, 2 Oct 2025 10:36:39 -0700 Subject: [PATCH 14/17] Ruff Signed-off-by: liamhuber --- tests/unit/test_exception_context.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_exception_context.py b/tests/unit/test_exception_context.py index e526a74..f4ece85 100644 --- a/tests/unit/test_exception_context.py +++ b/tests/unit/test_exception_context.py @@ -46,9 +46,11 @@ def test_exception_exit_stack(self): reg_stack.callback(its_historical, history, msg) self.assertEqual(history, [msg]) - with self.subTest("Clean error message on garbage input"): - with self.assertRaises(ValueError): - ExceptionExitStack("these", "aren't", "exceptions") + with ( + self.subTest("Clean error message on garbage input"), + self.assertRaises(ValueError), + ): + ExceptionExitStack("these", "aren't", "exceptions") def test_on_error(self): with self.subTest("Callback on all exceptions when no types are specified"): From a4b7218c7ef51fb711e9dc23181502ae13610b13 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 4 Oct 2025 09:23:17 -0700 Subject: [PATCH 15/17] Add on_error example Signed-off-by: liamhuber --- docs/README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 55fde8a..209b545 100644 --- a/docs/README.md +++ b/docs/README.md @@ -95,7 +95,29 @@ A variant of [`contextlib.ExitStack`](https://docs.python.org/3/library/contextl ``` -The module also provides a wrapper, `on_error`, for use directly with `contextlib.ExitStack`. +The module also provides a wrapper, `on_error`, which provides a more compact interface if you only have a single callback function (as in the examples above): + +```python +>>> from pyiron_snippets import exception_context +>>> +>>> def its_historical(history: list[str], message: str) -> None: +... history.append(message) +>>> +>>> history = [] +>>> +>>> try: +... with exception_context.on_error( +... its_historical, +... RuntimeError, +... history, +... "a more compact single-callback interface", +... ): +... raise RuntimeError("Application") +... except RuntimeError: +... history +['a more compact single-callback interface'] + +``` ## Factory From 85e83a8718672aef330abcfb806b6872b3b1ac41 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 4 Oct 2025 09:23:25 -0700 Subject: [PATCH 16/17] Update test syntax Signed-off-by: liamhuber --- tests/unit/test_exception_context.py | 30 +++++++++++----------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/tests/unit/test_exception_context.py b/tests/unit/test_exception_context.py index f4ece85..84098d7 100644 --- a/tests/unit/test_exception_context.py +++ b/tests/unit/test_exception_context.py @@ -57,15 +57,12 @@ def test_on_error(self): history = [] msg = "with no types" try: - with contextlib.ExitStack() as stack: - stack.enter_context( - on_error( - its_historical, - None, - history, - message=msg, - ) - ) + with on_error( + its_historical, + None, + history, + message=msg, + ): raise RuntimeError("Application error") except RuntimeError: self.assertEqual(history, [msg]) @@ -90,15 +87,12 @@ def test_on_error(self): with self.subTest("No callback on mis-matching exception with specifier(s)"): history = [] try: - with contextlib.ExitStack() as stack: - stack.enter_context( - on_error( - its_historical, - (TypeError, ValueError), - history, - message="with mis-matching types", - ) - ) + with on_error( + its_historical, + (TypeError, ValueError), + history, + message="with mis-matching types", + ): raise RuntimeError("Application error") except RuntimeError: self.assertEqual(history, []) From 88ea01eac7e5a757867314a27eaeef4ebe7399d0 Mon Sep 17 00:00:00 2001 From: liamhuber Date: Sat, 4 Oct 2025 09:24:21 -0700 Subject: [PATCH 17/17] Scope import Signed-off-by: liamhuber --- docs/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index 209b545..13a21a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -70,14 +70,14 @@ A dictionary that allows dot-access. Has `.items()` etc. A variant of [`contextlib.ExitStack`](https://docs.python.org/3/library/contextlib.html#contextlib.ExitStack) that only executes registered callbacks when an exception is raised, and only if that exception matches one of the specified exception types (or any exception, if types are not specified). ```python ->>> from pyiron_snippets.exception_context import ExceptionExitStack +>>> from pyiron_snippets import exception_context >>> >>> def its_historical(history: list[str], message: str) -> None: ... history.append(message) >>> >>> history = [] >>> try: -... with ExceptionExitStack(RuntimeError) as stack: +... with exception_context.ExceptionExitStack(RuntimeError) as stack: ... _ = stack.callback(its_historical, history, "with matching type") ... raise RuntimeError("Application error") ... except RuntimeError: @@ -86,7 +86,7 @@ A variant of [`contextlib.ExitStack`](https://docs.python.org/3/library/contextl >>> history = [] >>> try: -... with ExceptionExitStack(TypeError, ValueError) as stack: +... with exception_context.ExceptionExitStack(TypeError, ValueError) as stack: ... _ = stack.callback(its_historical, history, "with mis-matching types") ... raise RuntimeError("Application error") ... except RuntimeError: