generated from pyiron/pyiron_module_template
-
Notifications
You must be signed in to change notification settings - Fork 0
Exception context #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
1e09938
Add ExceptionExitStack
liamhuber d767204
Add ExceptionExitStack
liamhuber a0048e2
Extract and re-name function
liamhuber 5c4e3fa
Add a context manager for exception callbacks
liamhuber 64e6dde
Demonstrate and test combinations
liamhuber e643c18
Merge branch 'main' into exception_context
liamhuber a836c34
Clarify variable type
liamhuber 489e180
Add example to README
liamhuber 9325574
Only use Exception
liamhuber 57eea7e
Mention the wrapper
liamhuber b4b37d2
Explicitly hint and type match
liamhuber e50de15
Explicitly catch the non-exception case
liamhuber ed13c27
Fail more cleanly
liamhuber d51b0c6
black
liamhuber a84ccb1
Ruff
liamhuber 3e82ba4
Merge branch 'main' into exception_context
liamhuber a4b7218
Add on_error example
liamhuber 85e83a8
Update test syntax
liamhuber 88ea01e
Scope import
liamhuber File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| import contextlib | ||
| from collections.abc import Callable, Collection | ||
| from typing import Any | ||
|
|
||
|
|
||
| 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. But, the stack can be combined with | ||
| other stacks. | ||
|
|
||
| >>> import contextlib | ||
| >>> | ||
| >>> history = [] | ||
| >>> 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]): | ||
| 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 | ||
| ) | ||
|
|
||
| def __exit__(self, exc_type, exc_val, exc_tb): | ||
| 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() | ||
|
|
||
|
|
||
| @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. 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'] | ||
| """ | ||
|
|
||
| exception_types: tuple[type[Exception], ...] | ||
| if exceptions is None: | ||
| exception_types = (Exception,) | ||
| 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: | ||
| yield | ||
| except Exception as e: | ||
| if any(isinstance(e, exc_type) for exc_type in exception_types): | ||
| func(*args, **kwargs) | ||
| raise | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a simple example that shows the use as a plain decorator would be nice, too.
At least I expect to get the most mileage out of this incarnation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, absolutely. I had misunderstood how much power
@contextlib.contextmanagerwas giving us -- I thought we additionally had to use it inside a regular exit stack.I agree, many applications will need only a single callback, for which this is a much more condensed syntax 👍