SylvainDe/DidYouMean-Python

View on GitHub
didyoumean/didyoumean_api_tests.py

Summary

Maintainability
A
1 hr
Test Coverage
# -*- coding: utf-8
"""Unit tests for didyoumean APIs."""
from didyoumean_api import didyoumean_decorator, didyoumean_contextmanager,\
    didyoumean_postmortem, didyoumean_enablehook, didyoumean_disablehook
from didyoumean_common_tests import TestWithStringFunction,\
    get_exception, no_exception, NoFileIoError, unittest_module
import contextlib
import sys
import os


class ApiTest(TestWithStringFunction):
    """Tests about the didyoumean APIs.

    The aim is to test the different APIs in various situations:
        - when no exception is raised
        - when a NameError leading to no suggestion is raised
        - when a NameError leading to a suggestion is raised
        - when a SyntaxError leading to a suggestion is raised
        - when a NoFileIoError leading to a suggestion is raised
    In all these situations, the exception with and without using
    the API are retrieved and the test checks that the suggestion
    is added (when relevant) to the exceptions in both the __str__
    form and the __repr__ form.

    In order to do so, one just needs to inherit from ApiTest and
    override `run_with_api`.
    """

    def run_with_api(self, code):
        """Abstract method to run code with tested API."""
        raise NotImplementedError("'run_with_api' needs to be implemented")

    def get_exc_with_api(self, code):
        """Get exception raised with running code with tested API."""
        try:
            self.run_with_api(code)
        except Exception:
            return sys.exc_info()
        assert False, "No exception thrown"

    def get_exc_as_str(self, code, type_arg):
        """Retrieve string representations of exceptions raised by code.

        String representations are provided for the same code run
        with and without the API.
        """
        type1, value1, _ = get_exception(code)
        details1 = "%s %s" % (str(type1), str(value1))
        self.assertTrue(isinstance(value1, type1), details1)
        self.assertEqual(type_arg, type1, details1)
        str1, repr1 = str(value1), repr(value1)
        type2, value2, _ = self.get_exc_with_api(code)
        details2 = "%s %s" % (str(type2), str(value2))
        self.assertTrue(isinstance(value2, type2), details2)
        self.assertEqual(type_arg, type2, details2)
        str2, repr2 = str(value2), repr(value2)
        return (str1, repr1, str2, repr2)

    def check_sugg_added(self, code, type_, sugg, normalise_quotes=False):
        """Check that the suggestion gets added to the exception.

        Get the string representations for the exception before and after
        and check that the suggestion `sugg` is added to `before` to get
        `after`. `normalise_quotes` can be provided to replace all quotes
        by double quotes before checking the `repr()` representations as
        they may get changed sometimes.
        """
        str1, repr1, str2, repr2 = self.get_exc_as_str(
            code, type_)
        self.assertStringAdded(sugg, str1, str2, True)
        if normalise_quotes:
            sugg = sugg.replace("'", '"')
            repr1 = repr1.replace("'", '"')
            repr2 = repr2.replace("'", '"')
        self.assertStringAdded(sugg, repr1, repr2, True)

    def test_api_no_exception(self):
        """Check the case with no exception."""
        code = 'babar = 0\nbabar'
        no_exception(code)
        self.run_with_api(code)

    def test_api_suggestion(self):
        """Check the case with a suggestion."""
        type_ = NameError
        sugg = ". Did you mean 'babar' (local)?"
        code = 'babar = 0\nbaba'
        self.check_sugg_added(code, type_, sugg)

    def test_api_no_suggestion(self):
        """Check the case with no suggestion."""
        type_ = NameError
        sugg = ""
        code = 'babar = 0\nfdjhflsdsqfjlkqs'
        self.check_sugg_added(code, type_, sugg)

    def test_api_syntax(self):
        """Check the case with syntax error suggestion."""
        type_ = SyntaxError
        sugg = ". Did you mean to indent it, 'sys.exit([arg])'?"
        code = 'return'
        self.check_sugg_added(code, type_, sugg, True)

    def test_api_ioerror(self):
        """Check the case with IO error suggestion."""
        type_ = NoFileIoError
        home = os.path.expanduser("~")
        sugg = ". Did you mean '" + home + "' (calling os.path.expanduser)?"
        code = 'with open("~") as f:\n\tpass'
        self.check_sugg_added(code, type_, sugg, True)


class DecoratorTest(unittest_module.TestCase, ApiTest):
    """Tests about the didyoumean decorator."""

    def run_with_api(self, code):
        """Run code with didyoumean decorator."""
        @didyoumean_decorator
        def my_func():
            no_exception(code)
        my_func()


class ContextManagerTest(unittest_module.TestCase, ApiTest):
    """Tests about the didyoumean context manager."""

    def run_with_api(self, code):
        """Run code with didyoumean context manager."""
        with didyoumean_contextmanager():
            no_exception(code)


class PostMortemTest(unittest_module.TestCase, ApiTest):
    """Tests about the didyoumean post mortem."""

    # A bit of an ugly way to proceed with "exc.last_<xxx>" attributes:
    # in real life scenario, these would not be set except in case of
    # uncaught exception in interactive interpreter where they would be
    # set automatically.

    def check_sys_last_attr_not_set(self):
        """Check that attributes 'last_<xxx>' do not exist."""
        for a in ('last_type', 'last_value', 'last_traceback'):
            self.assertFalse(hasattr(sys, a))

    def set_sys_last_attr_from_exc(self, code):
        """Set attributes 'last_<xxx>' from exception thrown by code if any."""
        try:
            no_exception(code)
        except Exception:
            sys.last_type, sys.last_value, sys.last_traceback = sys.exc_info()

    def cleanup_sys_last_attr(self):
        """Delete attributes 'last_<xxx>' artificially added."""
        for a in ('last_type', 'last_value', 'last_traceback'):
            if hasattr(sys, a):
                delattr(sys, a)

    def run_with_api(self, code):
        """Run code with didyoumean post mortem."""
        self.check_sys_last_attr_not_set()
        self.set_sys_last_attr_from_exc(code)
        ret = didyoumean_postmortem()
        self.cleanup_sys_last_attr()
        self.check_sys_last_attr_not_set()
        if ret is not None:
            raise ret


class HookTest(ApiTest):
    """Tests about the didyoumean hooks.

    These tests are somewhat artificial as one needs to explicitely catch
    the exception, simulate a call to the function that would have been
    called for an uncatched exception and reraise it (so that then it gets
    caught by yet another try-except).
    Realistically it might not catch any real-life problems (because these
    would happen when the shell does not behave as expected) but it might be
    useful to prevent regressions.
    """

    pass  # Can't write tests as the hook seems to be ignored.


@contextlib.contextmanager
def suppress_stderr():
    """Decorator to ignore content sent to stderr."""
    with open(os.devnull, "w") as devnull:
        old_stderr = sys.stderr
        sys.stderr = devnull
        try:
            yield
        finally:
            sys.stderr = old_stderr


class ExceptHookTest(unittest_module.TestCase, HookTest):
    """Tests about the didyoumean excepthook."""

    def run_with_api(self, code):
        """Run code with didyoumean after enabling didyoumean hook."""
        prev_hook = sys.excepthook
        self.assertEqual(prev_hook, sys.excepthook)
        didyoumean_enablehook()
        self.assertNotEqual(prev_hook, sys.excepthook)
        try:
            no_exception(code)
        except Exception:
            last_type, last_value, last_traceback = sys.exc_info()
            with suppress_stderr():
                sys.excepthook(last_type, last_value, last_traceback)
            raise
        finally:
            self.assertNotEqual(prev_hook, sys.excepthook)
            didyoumean_disablehook()
            self.assertEqual(prev_hook, sys.excepthook)


class DummyShell:
    """Dummy class to emulate the iPython interactive shell.

    https://ipython.org/ipython-doc/dev/api/generated/IPython.core.interactiveshell.html
    """

    def __init__(self):
        """Init."""
        self.handler = None
        self.exc_tuple = None

    def set_custom_exc(self, exc_tuple, handler):
        """Emulate the interactiveshell.set_custom_exc method."""
        self.handler = handler
        self.exc_tuple = exc_tuple

    def showtraceback(self, exc_tuple=None,
                      filename=None, tb_offset=None, exception_only=False):
        """Emulate the interactiveshell.showtraceback method.

        Calls the custom exception handler if is it set.
        """
        if self.handler is not None and self.exc_tuple is not None:
            etype, evalue, tb = exc_tuple
            func, self.handler = self.handler, None  # prevent recursive calls
            func(self, etype, evalue, tb, tb_offset)
            self.handler = func

    def set(self, module):
        """Make shell accessible in module via 'get_ipython'."""
        assert 'get_ipython' not in dir(module)
        module.get_ipython = lambda: self

    def remove(self, module):
        """Make shell un-accessible in module via 'get_ipython'."""
        del module.get_ipython


class IPythonHookTest(unittest_module.TestCase, HookTest):
    """Tests about the didyoumean custom exception handler for iPython.

    These tests need a dummy shell to be create to be able to use/define
    its functions related to the custom exception handlers.
    """

    def run_with_api(self, code):
        """Run code with didyoumean after enabling didyoumean hook."""
        prev_handler = None
        shell = DummyShell()
        module = sys.modules['didyoumean_api']
        shell.set(module)
        self.assertEqual(shell.handler, prev_handler)
        didyoumean_enablehook()
        self.assertNotEqual(shell.handler, prev_handler)
        try:
            no_exception(code)
        except Exception:
            shell.showtraceback(sys.exc_info())
            raise
        finally:
            self.assertNotEqual(shell.handler, prev_handler)
            didyoumean_disablehook()
            self.assertEqual(shell.handler, prev_handler)
            shell.remove(module)
            shell = None


if __name__ == '__main__':
    print(sys.version_info)
    unittest_module.main()