From 996ab435341f148cf2c246fe800f86b8cd416506 Mon Sep 17 00:00:00 2001 From: SoniEx2 Date: Sun, 3 Oct 2021 09:40:57 -0300 Subject: [Project] Antichecked Exceptions An alternative to checked exceptions that actually catches bugs and prevents unintended exceptions from being accidentally caught/silenced. Made for Python. --- .gitignore | 1 + LICENSE | 22 ++++++++++ antichecked.py | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 8 ++++ test.py | 126 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 antichecked.py create mode 100644 pyproject.toml create mode 100644 test.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a4179d7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2021 Soni L. + +Permission is hereby granted, free of charge, to any person ("You") obtaining +a copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +This license shall be void if You bring a copyright lawsuit, related or +unrelated to the Software, against any of the copyright holders. + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/antichecked.py b/antichecked.py new file mode 100644 index 0000000..7a7344a --- /dev/null +++ b/antichecked.py @@ -0,0 +1,119 @@ +# Antichecked Exceptions for Python +# Copyright (c) 2021 Soni L. +# +# Permission is hereby granted, free of charge, to any person ("You") obtaining +# a copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# This license shall be void if You bring a copyright lawsuit, related or +# unrelated to the Software, against any of the copyright holders. +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Antichecked exceptions for Python. + +This package implements antichecked exceptions. They're analogous to the +reverse of checked exceptions, that's why they're called antichecked. + +Just ``from antichecked import exceptions`` and you're ready to go! +""" + +__version__ = '1.0' + +class _hack: + def __init__(self, ctx): + self.ctx = ctx + + def __enter__(self): + pass + + def __exit__(self, exc_ty, exc_val, exc_tb): + exc_val.__context__ = self.ctx + +class exceptions: + """A context manager for antichecked exceptions. + + Antichecked exceptions are the reverse of checked exceptions: rather than + requiring the caller to catch the exceptions, they actively check that + your function/block actually intended to raise them. + + Examples: + + >>> from antichecked import exceptions + + Intercepting exceptions:: + + >>> with exceptions(StopIteration) as r: + ... raise StopIteration + RuntimeError + + Raising exceptions:: + + >>> with exceptions(StopIteration) as r: + ... raise r(StopIteration) + StopIteration + + Adding a cause:: + + >>> with exceptions(StopIteration) as r: + ... raise r(StopIteration) from ValueError() + StopIteration from ValueError + + Similarly, re-raising exceptions is done with ``raise r``. + """ + + def __init__(self, *args): + """Creates an antichecked exception context for the given exceptions. + + Args: + Accepts an arbitrary amount of exception types. + """ + # each context manager gets an unique exception type + class WrappedError(Exception): + def __init__(self, exc=None): + if isinstance(exc, BaseException) or exc is None: + self.value = exc + else: + self.value = exc() + self._exceptions = args + self._error = WrappedError + + def __enter__(self): + return self._error + + def __exit__(self, exc_ty, exc_val, exc_tb): + if exc_ty is None: + return + if exc_ty is self._error: + # handle `raise r(exc)` similar to `raise exc` + if exc_val.value is not None: + exc = exc_val.value + exc.__cause__ = exc_val.__cause__ + exc.__suppress_context__ = exc_val.__suppress_context__ + with _hack(exc_val.__context__): + raise exc.with_traceback(exc_tb) + # `raise r from foo` is unhandled, as `raise from foo` is not + # valid python. TODO make this case a RuntimeError. + pass + # handle `raise r` similar to `raise` + if exc_val.__context__ is None: + with _hack(None): + exc = RuntimeError("No active exception to re-raise") + raise exc.with_traceback(exc_tb) + with _hack(exc_val.__context__.__context__): + raise exc_val.__context__ + if any(issubclass(exc_ty, e) for e in self._exceptions): + # handle this similar to StopIteration in generators + raise RuntimeError("Antichecked exception raised") from exc_val diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..38d1136 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "antichecked" +authors = [{name = "SoniEx2", email = "endermoneymod@gmail.com"}] +dynamic = ["version", "description"] diff --git a/test.py b/test.py new file mode 100644 index 0000000..27ed7cf --- /dev/null +++ b/test.py @@ -0,0 +1,126 @@ +# Unit tests for Antichecked Exceptions for Python +# Copyright (c) 2021 Soni L. +# +# Permission is hereby granted, free of charge, to any person ("You") obtaining +# a copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# This license shall be void if You bring a copyright lawsuit, related or +# unrelated to the Software, against any of the copyright holders. +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from antichecked import exceptions + +class expect: + def __init__(self, exc, *, cause, context): + self.exc = exc + self.cause = cause or type(None) + self.context = context or type(None) + + def __enter__(self): + pass + + def __exit__(self, exc_ty, exc_val, exc_tb): + assert exc_ty is self.exc + assert type(exc_val.__cause__) is self.cause + assert type(exc_val.__context__) is self.context + return True + +def foo(i): + with exceptions(ValueError) as r: + try: + x = int(i) + except ValueError: + # re-raise ValueError from int() + raise r + else: + if x < 0: + # attempt to raise r without a context + # (should turn into RuntimeError, just like bare raise) + raise r + # this raises ValueError if x is not 1, and it becomes wrapped in + # RuntimeError + x, = [x]*x + +def test_foo(): + # ValueError from int() passes through (re-raised), context/cause is None + with expect(ValueError, cause=None, context=None): + foo("false") + # ValueError from unpacking gets wrapped, cause and context are ValueError + with expect(RuntimeError, cause=ValueError, context=ValueError): + foo("10") + # raise r without context becomes RuntimeError + with expect(RuntimeError, cause=None, context=None): + foo("-10") + # "1" gets accepted and doesn't raise anything + foo("1") + +def bar(): + with exceptions(ValueError) as r: + raise r + +def test_bar(): + with expect(RuntimeError, cause=None, context=None): + bar() + +def fake_gen(): + with exceptions(StopIteration) as r: + next(iter([])) + +def test_fake_gen(): + # actual generators don't use RuntimeError but we have no way of testing that + with expect(RuntimeError, cause=StopIteration, context=StopIteration): + fake_gen() + +def baz(i): + with exceptions(ValueError) as r: + try: + x = int(i) + except ValueError: + # raise new exception with context + raise r(TypeError) + else: + if x < 0: + # raise a new ValueError + raise r(ValueError) + try: + x, = [x]*x + except ValueError as e: + # raise with cause + raise r(TypeError) from e + +def test_baz(): + # ValueError from int() becomes TypeError with context + with expect(TypeError, cause=None, context=ValueError): + baz("false") + # raise TypeError with cause and context being ValueError + with expect(TypeError, cause=ValueError, context=ValueError): + baz("10") + # raise ValueError through antichecked exceptions + with expect(ValueError, cause=None, context=None): + baz("-10") + # "1" gets accepted and doesn't raise anything + baz("1") + + +def test_all(): + test_foo() + test_bar() + test_fake_gen() + test_baz() + +if __name__ == "__main__": + test_all() -- cgit v1.2.3