from __future__ import ( absolute_import, unicode_literals, ) import collections import importlib import logging import os import re import traceback from typing import ( AbstractSet, Any, AnyStr, Callable, Container, Dict, Iterable, List, NamedTuple, Optional, Pattern, Sequence, Tuple, Type, TypeVar, Union, cast, ) import unittest # noinspection PyProtectedMember from unittest.util import ( _count_diff_all_purpose, _count_diff_hashable, ) import warnings from _pytest._code.code import ExceptionInfo from _pytest.python_api import RaisesContext from _pytest.recwarn import WarningsChecker from conformity.settings import SettingsData import pytest import six from pysoa.client.client import Client from pysoa.common.errors import Error from pysoa.common.transport.local import LocalClientTransport from pysoa.common.types import ( ActionResponse, Body, ) from pysoa.server.server import Server from pysoa.test.assertions import ( raises_call_action_error, raises_error_codes, raises_field_errors, ) try: from typing import Literal # type: ignore except ImportError: from typing_extensions import Literal # type: ignore try: from typing import NoReturn # type: ignore except ImportError: from typing_extensions import NoReturn # type: ignore __all__ = ( 'BaseServerTestCase', 'PyTestServerTestCase', 'ServerTestCase', 'UnitTestServerTestCase', ) # noinspection PyPep8Naming,PyAttributeOutsideInit,PyMethodMayBeStatic class BaseServerTestCase(object): """ Base class for all test classes that need to call the server. It runs calls to actions through the server stack so configured middleware runs and requests and responses go through the normal validation cycles. Note that this uses the local transports, so requests and responses are not serialized. """ server_class = None # type: Optional[Type[Server]] """The reference to your `Server` class, which must be set in order to use the service helpers in this class.""" server_settings = None # type: Optional[SettingsData] """ A settings dict to use when instantiating your `Server` class. If not specified, the service helpers in this class will attempt to get settings from the configured Django or PySOA settings module. """ def setup_pysoa(self): # type: () -> None """ Sets up `self.client` for use in calling the local testing service. Requires you to configure `server_class` and `server_settings` class attributes. """ if self.server_class is None: raise TypeError('You must specify `server_class` in `ServerTestCase` subclasses') if not issubclass(self.server_class, Server): raise TypeError('`server_class` must be a subclass of `Server` in `ServerTestCase` subclasses') if not self.server_class.service_name: raise TypeError('`server_class.service_name` must be set in `ServerTestCase` subclasses') self.service_name = self.server_class.service_name # Get settings based on Django mode if self.server_settings is not None: settings = self.server_settings else: if self.server_class.use_django: # noinspection PyUnresolvedReferences from django.conf import settings as django_settings settings = cast(SettingsData, django_settings.SOA_SERVER_SETTINGS) # type: ignore else: settings_module = os.environ.get('PYSOA_SETTINGS_MODULE', None) if not settings_module: raise AssertionError('PYSOA_SETTINGS_MODULE environment variable must be set to run tests.') try: thing = importlib.import_module(settings_module) settings = cast(SettingsData, thing.SOA_SERVER_SETTINGS) # type: ignore except (ImportError, AttributeError) as e: raise AssertionError('Could not access {}.SOA_SERVER_SETTINGS: {}'.format(settings_module, e)) self.client = Client( { self.service_name: { 'transport': { 'path': 'pysoa.common.transport.local:LocalClientTransport', 'kwargs': { 'server_class': self.server_class, 'server_settings': settings, }, }, }, }, ) # noinspection PyProtectedMember cast( LocalClientTransport, self.client._get_handler(self.service_name).transport, ).server._skip_django_database_cleanup = True def call_action(self, action, body=None, service_name=None, **kwargs): # type: (six.text_type, Body, Optional[six.text_type], **Any) -> ActionResponse """ A convenience method alternative to calling `self.client.call_action` that allows you to omit the service name. :param action: The required action name to call :param body: The optional request body to send to the action :param service_name: The optional service name if you need to call a service other than the configured local testing service. :param kwargs: Additional keyword arguments to send to :meth:`pysoa.client.client.Client.call_action`. :return: The value returned from :meth:`pysoa.client.client.Client.call_action`. """ return self.client.call_action(service_name or self.service_name, action, body=body, **kwargs) def assertActionRunsWithAndReturnErrors(self, action, body, **kwargs): # type: (six.text_type, Body, **Any) -> List[Error] """ Calls `self.call_action` and asserts that it runs with errors, and returns those errors. """ with raises_call_action_error() as exc_info: self.call_action(action, body, **kwargs) return exc_info.soa_errors def assertActionRunsWithFieldErrors( self, action, # type: six.text_type body, # type: Body field_errors, # type: Dict[six.text_type, Union[Iterable[six.text_type], six.text_type]] only=False, # type: bool **kwargs # type: Any ): # type: (...) -> None """ Calls `self.call_action` and asserts that it runs with the specified field errors. :param action: The name of the action to call :param body: The request body to send to the action :param field_errors: A dictionary of field name keys to error codes or iterables of error codes for the fields (all of the specified errors must be present in the response). :param only: If `True` additional errors cause a failure (defaults to `False`, so additional errors are ignored). :param kwargs: Additional keyword arguments to send to :meth:`pysoa.client.client.Client.call_action`. """ with raises_field_errors(field_errors, only=only): self.call_action(action, body, **kwargs) def assertActionRunsWithOnlyFieldErrors( self, action, # type: six.text_type body, # type: Body field_errors, # type: Dict[six.text_type, Union[Iterable[six.text_type], six.text_type]] **kwargs # type: Any ): # type: (...) -> None """ Convenient alternative to calling :meth:`assertActionRunsWithFieldErrors` that sets the `only` argument to `True`. """ self.assertActionRunsWithFieldErrors(action, body, field_errors, only=True, **kwargs) def assertActionRunsWithErrorCodes( self, action, # type: six.text_type body, # type: Body error_codes, # type: Union[Iterable[six.text_type], six.text_type] only=False, # type: bool **kwargs # type: Any ): # type: (...) -> None """ Calls `self.call_action` and asserts that it runs with the specified error codes. :param action: The name of the action to call :param body: The request body to send to the action :param error_codes: A single error code or iterable of multiple error codes (all of the specified errors must be present in the response). :param only: If `True` additional errors cause a failure (defaults to `False`, so additional errors are ignored). :param kwargs: Additional keyword arguments to send to :meth:`pysoa.client.client.Client.call_action`. """ with raises_error_codes(error_codes, only=only): self.call_action(action, body, **kwargs) def assertActionRunsWithOnlyErrorCodes( self, action, # type: six.text_type body, # type: Body error_codes, # type: Union[Iterable[six.text_type], six.text_type] **kwargs # type: Any ): # type: (...) -> None """ Convenient alternative to calling :meth:`assertActionRunsWithErrorCodes` that sets the `only` argument to `True`. """ self.assertActionRunsWithErrorCodes(action, body, error_codes, only=True, **kwargs) class UnitTestServerTestCase(unittest.TestCase, BaseServerTestCase): """ An extension of :class:`BaseServerTestCase` that calls :meth:`BaseServerTestCase.setup_pysoa` in :meth:`setUp`. If you override `setUp` in your test class, you must call `super().setUp()`, or else it will not work properly. """ def setUp(self): # type: () -> None super(UnitTestServerTestCase, self).setUp() self.setup_pysoa() # noinspection PyShadowingBuiltins _S = TypeVar('_S', six.text_type, six.binary_type) _C = TypeVar('_C', bound=Callable) def _deprecate(original_func): # type: (_C) -> _C def deprecated_func(*args, **kwargs): warnings.warn( 'Please use {0} instead. This will be removed in PySOA 2.0.'.format(original_func.__name__), DeprecationWarning, 2, ) return original_func(*args, **kwargs) return cast(_C, deprecated_func) def _methods_are_not_same(m1, m2): if six.PY2: return m1.__func__ is not m2.__func__ return m1 is not m2 if six.PY2: # noinspection PyUnresolvedReferences _string_types = (str, unicode) # type: Tuple[Type, ...] # noqa else: # noinspection PyUnresolvedReferences _string_types = (str, bytes) # noqa # noinspection PyPep8Naming,PyAttributeOutsideInit,PyMethodMayBeStatic class PyTestServerTestCase(BaseServerTestCase): """ An extension of :class:`BaseServerTestCase` that calls :meth:`BaseServerTestCase.setup_pysoa` in :meth:`setup_method`. If you override `setup_method` in your test class, you must call `super().setup_method()`, or else it will not work properly. This class will detect in your test class and call, if present, implementations of `unittest`-like `setUpClass`, `tearDownClass`, `setUp`, and `tearDown` from `setup_class`, `teardown_class`, `setup_method`, and `teardown_method`, respectively, and issue a deprecation warning in doing so. You should migrate to the standard PyTest form of these methods if you wish to use this class. This class also provides :meth:`addCleanup`, which behaves the same as the same-named method in `unittest` and also issues a deprecation warning. All of these polyfills will be removed in PySOA 2.0. This class also provides polyfills for `unittest`-like `self.assert*` and `self.fail*` methods. There is currently no plan to deprecate and remove this, but that may happen by PySOA 2.0, and you should endeavor to adopt standard `assert`-style assertions, as they provide better failure output in PyTest results. """ @classmethod def setUpClass(cls): # type: () -> None """ Deprecated, to be removed in PySOA 2.0. Override :meth:`setup_class`, instead, and be sure to still call `super().setup_class()`. """ @classmethod def setup_class(cls): # type: () -> None # noinspection PyUnresolvedReferences if cls.setUpClass.__func__ is not PyTestServerTestCase.setUpClass.__func__: # type: ignore warnings.warn( '`ServerTestCase.setUpClass` is deprecated. `ServerTestCase` no longer inherits from ' '`unittest.TestCase`. Your test setup has been run, but you should change the `setUpClass` method name ' 'to `setup_class` and be sure to still call `super` within it. This will be removed in PySOA 2.0.', DeprecationWarning, ) cls.setUpClass() @classmethod def tearDownClass(cls): # type: () -> None """ Deprecated, to be removed in PySOA 2.0. Override :meth:`teardown_class`, instead, and be sure to still call `super().teardown_class()`. """ @classmethod def teardown_class(cls): # type: () -> None # noinspection PyUnresolvedReferences if cls.tearDownClass.__func__ is not PyTestServerTestCase.tearDownClass.__func__: # type: ignore warnings.warn( '`ServerTestCase.tearDownClass` is deprecated. `ServerTestCase` no longer inherits from ' '`unittest.TestCase`. Your test setup has been run, but you should change the `tearDownClass` method ' 'name to `teardown_class` and be sure to still call `super` within it. This will be removed in PySOA ' '2.0.', DeprecationWarning, ) cls.tearDownClass() def setUp(self): # type: () -> None """ Deprecated, to be removed in PySOA 2.0. Override :meth:`setup_method`, instead, and be sure to still call `super().setup_method()`. """ def setup_method(self): # type: () -> None self._cleanups = [] # type: List[Tuple[Callable, Tuple[Any, ...], Dict[str, Any]]] self.setup_pysoa() if _methods_are_not_same(self.__class__.setUp, PyTestServerTestCase.setUp): warnings.warn( '`ServerTestCase.setUp` is deprecated. `ServerTestCase` no longer inherits from `unittest.TestCase`. ' 'Your test setup has been run, but you should change the `setUp` method name to `setup_method` and ' 'be sure to still call `super` within it. This will be removed in PySOA 2.0.', DeprecationWarning, ) self.setUp() def tearDown(self): # type: () -> None """ Deprecated, to be removed in PySOA 2.0. Override :meth:`teardown_method`, instead, and be sure to still call `super().teardown_method()`. """ def teardown_method(self): # type: () -> None if _methods_are_not_same(self.__class__.tearDown, PyTestServerTestCase.tearDown): warnings.warn( '`ServerTestCase.tearDown` is deprecated. `ServerTestCase` no longer inherits from ' '`unittest.TestCase`. Your test setup has been run, but you should change the `tearDown` method name ' 'to `teardown_method` and be sure to still call `super` within it. This will be removed in PySOA 2.0.', DeprecationWarning, ) self.tearDown() self.doCleanups() def addCleanup(self, function, *args, **kwargs): # type: (Callable, *Any, **Any) -> None """ Deprecated, to be removed in PySOA 2.0. """ warnings.warn( '`ServerTestCase.addCleanup` is deprecated. `ServerTestCase` no longer inherits from `unittest.TestCase`. ' 'Your test cleanup will be run, but you should stop using `addCleanup` and clean up your tests in ' '`teardown_method`, instead. This will be removed in PySOA 2.0.', DeprecationWarning, ) self._cleanups.append((function, args, kwargs)) def doCleanups(self): # type: () -> bool ok = True while self._cleanups: function, args, kwargs = self._cleanups.pop(-1) # noinspection PyBroadException try: function(*args, **kwargs) except KeyboardInterrupt: raise except: # noqa: E722 if not self._cleanups: # If it's the last exception, raise it. raise ok = False traceback.print_exc() return ok # noinspection PyTypeChecker def fail(self, msg=None): # type: (Optional[object]) -> NoReturn if msg: raise AssertionError(msg) raise AssertionError() def assertEqual(self, first, second, msg=None): # type: (Any, Any, Optional[object]) -> None assert first == second, msg or '' assertEquals = _deprecate(assertEqual) failUnlessEqual = _deprecate(assertEqual) def assertNotEqual(self, first, second, msg=None): # type: (Any, Any, Optional[object]) -> None assert first != second, msg or '' assertNotEquals = _deprecate(assertNotEqual) failIfEqual = _deprecate(assertNotEqual) def assertMultiLineEqual(self, first, second, msg=None): # type: (six.text_type, six.text_type, Optional[object]) -> None assert isinstance(first, six.string_types), 'First argument is not a string' assert isinstance(second, six.string_types), 'Second argument is not a string' assert first == second, msg or '' def assertSequenceEqual(self, first, second, msg=None, seq_type=None): # type: (Sequence[Any], Sequence[Any], Optional[object], Optional[Type[Sequence[Any]]]) -> None if seq_type is not None: assert isinstance(first, seq_type) assert isinstance(second, seq_type) assert first == second, msg or '' def assertListEqual(self, first, second, msg=None): # type: (List[Any], List[Any], Optional[object]) -> None self.assertSequenceEqual(first, second, msg, list) def assertTupleEqual(self, first, second, msg=None): # type: (Tuple[Any, ...], Tuple[Any, ...], Optional[object]) -> None self.assertSequenceEqual(first, second, msg, tuple) def assertSetEqual(self, first, second, msg=None): # type: (AbstractSet[Any], AbstractSet[Any], Optional[object]) -> None assert isinstance(first, AbstractSet), 'First argument is not a set' assert isinstance(second, AbstractSet), 'Second argument is not a set' assert first == second, msg or '' def assertDictEqual(self, first, second, msg=None): # type: (Dict[Any, Any], Dict[Any, Any], Optional[object]) -> None assert isinstance(first, dict), 'First argument is not a dictionary' assert isinstance(second, dict), 'Second argument is not a dictionary' assert first == second, msg or '' def assertCountEqual(self, first, second, msg=None): # type: (Union[Iterable], Union[Iterable], Optional[object]) -> None warnings.warn( 'PyTestServerTestCase.assertCountEqual is deprecated, because it cannot be implemented practicably. ' 'There is no replacement. It will be removed in PySOA 2.0', DeprecationWarning, ) first_list, second_list = list(first), list(second) try: first_counter = collections.Counter(first_list) second_counter = collections.Counter(second_list) except TypeError: assert _count_diff_all_purpose(first_list, second_list) == [] else: assert first_counter == second_counter, msg or '' assert _count_diff_hashable(first_list, second_list) == [] def assertAlmostEqual(self, first, second, places=None, msg=None, delta=None): # type: (float, float, Optional[int], Optional[object], Optional[float]) -> None if first == second: return assert delta is None or places is None, 'Specify delta or places, but not both' diff = abs(first - second) if delta is not None: assert diff <= delta, msg or '{} != {} within {} delta ({} difference)'.format(first, second, delta, diff) else: if places is None: places = 7 assert round(diff, places) == 0, ( msg or '{} != {} within {} places ({} difference)'.format(first, second, places, diff) ) assertAlmostEquals = _deprecate(assertAlmostEqual) failUnlessAlmostEqual = _deprecate(assertAlmostEqual) def assertNotAlmostEqual(self, first, second, places=None, msg=None, delta=None): # type: (float, float, Optional[int], Optional[object], Optional[float]) -> None assert first != second, msg or '' assert delta is None or places is None, 'Specify delta or places, but not both' diff = abs(first - second) if delta is not None: assert diff > delta, msg or '{} == {} within {} delta ({} difference)'.format(first, second, delta, diff) else: if places is None: places = 7 assert round(diff, places) != 0, ( msg or '{} == {} within {} places ({} difference)'.format(first, second, places, diff) ) assertNotAlmostEquals = _deprecate(assertNotAlmostEqual) failIfAlmostEqual = _deprecate(assertNotAlmostEqual) def assertTrue(self, expr, msg=None): # type: (Any, Optional[object]) -> None assert expr, msg or '' failUnless = _deprecate(assertTrue) assert_ = _deprecate(assertTrue) def assertFalse(self, expr, msg=None): # type: (Any, Optional[object]) -> None assert not expr, msg or '' failIf = _deprecate(assertFalse) def assertIs(self, first, second, msg=None): # type: (Any, Any, Optional[object]) -> None assert first is second, msg or '' def assertIsNot(self, first, second, msg=None): # type: (Any, Any, Optional[object]) -> None assert first is not second, msg or '' def assertIsNone(self, expr, msg=None): # type: (Any, Optional[object]) -> None assert expr is None, msg or '' def assertIsNotNone(self, expr, msg=None): # type: (Any, Optional[object]) -> None assert expr is not None, msg or '' def assertIn(self, member, container, msg=None): # type: (Any, Union[Iterable[Any], Container[Any]], Optional[object]) -> None assert member in container, msg or '' # type: ignore def assertNotIn(self, member, container, msg=None): # type: (Any, Union[Iterable[Any], Container[Any]], Optional[object]) -> None assert member not in container, msg or '' # type: ignore def assertIsInstance(self, obj, cls, msg=None): # type: (Any, Union[Type, Tuple[Type, ...]], Optional[object]) -> None assert isinstance(obj, cls), msg or '' def assertNotIsInstance(self, obj, cls, msg=None): # type: (Any, Union[Type, Tuple[Type, ...]], Optional[object]) -> None assert not isinstance(obj, cls), msg or '' def assertGreater(self, first, second, msg=None): # type: (Any, Any, Optional[object]) -> None assert first > second, msg or '' def assertGreaterEqual(self, first, second, msg=None): # type: (Any, Any, Optional[object]) -> None assert first >= second, msg or '' def assertLess(self, first, second, msg=None): # type: (Any, Any, Optional[object]) -> None assert first < second, msg or '' def assertLessEqual(self, first, second, msg=None): # type: (Any, Any, Optional[object]) -> None assert first <= second, msg or '' def assertRegex(self, text, regex, msg=None): # type: (_S, Union[Pattern[_S], _S], Optional[object]) -> None assert regex is not None, 'Regex must not be None' # do this first to prevent a warning assert regex, 'Regex must not be empty' if isinstance(regex, _string_types): regex = re.compile(regex) assert regex.search(text) is not None, ( # type: ignore msg or 'Pattern: {}\nDoes not match text: {!r}'.format(regex.pattern, text) # type: ignore ) assertRegexpMatches = _deprecate(assertRegex) def assertNotRegex(self, text, regex, msg=None): # type: (_S, Union[Pattern[_S], _S], Optional[object]) -> None assert regex is not None, 'Regex must not be None' # do this first to prevent a warning assert regex, 'Regex must not be empty' if isinstance(regex, _string_types): regex = re.compile(regex) assert regex.search(text) is None, ( # type: ignore msg or 'Pattern: {}\nUnexpectedly matches text: {!r}'.format(regex.pattern, text) # type: ignore ) assertNotRegexpMatches = _deprecate(assertNotRegex) # noinspection PyShadowingBuiltins def assertRaises( self, exception, # type: Union[Type[BaseException], Tuple[Type[BaseException], ...]] callable=None, # type: Callable *args, # type: Any **kwargs # type: Any ): # type: (...) -> RaisesContext if callable: with pytest.raises(exception): callable(*args, **kwargs) # noinspection PyTypeChecker return None # type: ignore ExceptionInfo.exception = ExceptionInfo.value # alias the property for backwards compatibility return pytest.raises(exception, **kwargs) failUnlessRaises = _deprecate(assertRaises) # noinspection PyShadowingBuiltins def assertRaisesRegex( self, exception, # type: Union[Type[BaseException], Tuple[Type[BaseException], ...]] regex, # type: Union[Pattern[AnyStr], AnyStr] callable=None, # type: Callable *args, # type: Any **kwargs # type: Any ): # type: (...) -> RaisesContext if callable: with pytest.raises(exception, match=regex): callable(*args, **kwargs) # noinspection PyTypeChecker return None # type: ignore ExceptionInfo.exception = ExceptionInfo.value # alias the property for backwards compatibility kwargs['match'] = regex return pytest.raises(exception, **kwargs) assertRaisesRegexp = _deprecate(assertRaisesRegex) # noinspection PyShadowingBuiltins def assertWarns( self, exception, # type: Union[Type[Warning], Tuple[Type[Warning], ...]] callable=None, # type: Callable *args, # type: Any **kwargs # type: Any ): # type: (...) -> WarningsChecker if callable: with pytest.warns(exception): callable(*args, **kwargs) # noinspection PyTypeChecker return None # type: ignore return pytest.warns(exception, **kwargs) # noinspection PyShadowingBuiltins def assertWarnsRegex( self, exception, # type: Union[Type[Warning], Tuple[Type[Warning], ...]] regex, # type: Union[Pattern[AnyStr], AnyStr] callable=None, # type: Callable *args, # type: Any **kwargs # type: Any ): # type: (...) -> WarningsChecker if callable: with pytest.warns(exception, match=regex): callable(*args, **kwargs) # noinspection PyTypeChecker return None # type: ignore kwargs['match'] = regex return pytest.warns(exception, **kwargs) def assertLogs( self, logger=None, # type: Union[six.text_type, six.binary_type, logging.Logger, None] level=None, # type: Union[six.text_type, six.binary_type, int, None] ): # type: (...) -> _AssertLogsContext return _AssertLogsContext(logger, level) _LoggingWatcher = NamedTuple('_LoggingWatcher', ( ('records', List[logging.LogRecord]), ('output', List[six.text_type]), )) class _CapturingHandler(logging.Handler): def __init__(self): super(_CapturingHandler, self).__init__() self.watcher = _LoggingWatcher([], []) def flush(self): """Does nothing""" def emit(self, record): self.watcher.records.append(record) self.watcher.output.append(self.format(record)) class _AssertLogsContext(object): LOGGING_FORMAT = '%(levelname)s:%(name)s:%(message)s' def __init__( self, logger, # type: Union[six.text_type, six.binary_type, logging.Logger, None] level, # type: Union[six.text_type, six.binary_type, int, None] ): if isinstance(logger, logging.Logger): self.logger = logger self.logger_name = logger.name # type: Union[six.text_type, six.binary_type, None] else: # noinspection PyTypeChecker self.logger = logging.getLogger(logger) # type: ignore self.logger_name = logger if level: if isinstance(level, int): self.level = level else: if six.PY2: # noinspection PyProtectedMember,PyUnresolvedReferences self.level = logging._levelNames[level] # type: ignore else: # noinspection PyProtectedMember self.level = logging._nameToLevel[level] # type: ignore else: self.level = logging.INFO def __enter__(self): # type: () -> _LoggingWatcher formatter = logging.Formatter(self.LOGGING_FORMAT) handler = _CapturingHandler() handler.setFormatter(formatter) self.watcher = handler.watcher self._old_handlers = self.logger.handlers[:] self._old_level = self.logger.level self._old_propagate = self.logger.propagate self.logger.handlers = [handler] self.logger.setLevel(self.level) self.logger.propagate = False return handler.watcher def __exit__(self, exc_type, exc_value, tb): # type: (Any, Any, Any) -> Literal[False] self.logger.handlers = self._old_handlers self.logger.setLevel(self._old_level) self.logger.propagate = self._old_propagate if exc_type is not None or len(self.watcher.records) > 0: # let unexpected exceptions pass through # noinspection PyTypeChecker return False raise AssertionError('No logs of level {} or higher triggered on {}'.format( # type: ignore logging.getLevelName(self.level), self.logger_name, )) ServerTestCase = PyTestServerTestCase