autograder.testing.server

  1import os
  2import typing
  3
  4import edq.net.exchangeserver
  5import edq.testing.httpserver
  6import edq.util.serial
  7import lms.model.base
  8
  9import autograder.api.common
 10import autograder.error
 11import autograder.model.config
 12
 13THIS_DIR: str = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
 14ROOT_DIR: str = os.path.join(THIS_DIR, '..', '..')
 15EXCHANGES_DIR: str = os.path.join(ROOT_DIR, 'testdata', 'autograder-testdata')
 16
 17# TEST - Remove?
 18BASE_ARGUMENTS: typing.Dict[str, typing.Any] = {
 19    # Will be set with the correct port when the test is run.
 20    'server': None,
 21}
 22
 23# Exchange tests are unnecessary.
 24delattr(edq.testing.httpserver.HTTPServerTest, 'test_exchanges_base')
 25
 26@typing.runtime_checkable
 27class CleanFunction(typing.Protocol):
 28    """ A function for cleaning a test result (expected or actual) before assertion. """
 29
 30    def __call__(self, raw: typing.Any) -> typing.Any:
 31        """
 32        Clean and return data for assertion.
 33        """
 34
 35@typing.runtime_checkable
 36class AssertionFunction(typing.Protocol):
 37    """ A function for asserting during a test. """
 38
 39    def __call__(self, expected: typing.Any, actual: typing.Any) -> typing.Any:
 40        """
 41        Assert the relationship between expected and actual.
 42        """
 43
 44class ServerTest(edq.testing.httpserver.HTTPServerTest):
 45    """
 46    A special test suite that is common across all tests that require a test autograder server.
 47    """
 48
 49    # Use the same server for all test classes.
 50    tear_down_server = False
 51
 52    @classmethod
 53    def child_class_setup(cls) -> None:
 54        # Make the API request source information consistent.
 55        autograder.api.common.set_testing_source_info()
 56
 57        # Don't exit on error.
 58        autograder.error._exit_on_error_for_testing = False
 59
 60    @classmethod
 61    def setup_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None:
 62        context = edq.util.serial.SerializationContext(json_options = {
 63            'strict': True,
 64        })
 65
 66        edq.testing.httpserver.HTTPServerTest.setup_server(server)
 67        server.load_exchanges_dir(EXCHANGES_DIR, context = context)
 68
 69    def base_api_test(self,
 70            api_function: typing.Callable,
 71            test_cases: typing.List[typing.Tuple[autograder.model.config.Config, typing.Dict[str, typing.Any], typing.Any, typing.Union[str, None]]],
 72            actual_clean_func: typing.Union[CleanFunction, typing.Callable, None] = None,
 73            expected_clean_func: typing.Union[CleanFunction, typing.Callable, None] = None,
 74            assertion_func: typing.Union[AssertionFunction, None] = None,
 75            ) -> None:
 76        """
 77        A common test for the base API functionality.
 78        Test cases are passed in as: `[(configs (and overrides), kwargs, expected, error substring), ...]`.
 79        """
 80
 81        for (i, test_case) in enumerate(test_cases):
 82            (config, kwargs, expected, error_substring) = test_case
 83
 84            with self.subTest(msg = f"Case {i}:"):
 85                config.server = self.get_server_url()
 86
 87                try:
 88                    actual = api_function(config, **kwargs)
 89                except Exception as ex:
 90                    error_string = self.format_error_string(ex)
 91                    if (error_substring is None):
 92                        self.fail(f"Unexpected error: '{error_string}'.")
 93
 94                    self.assertIn(error_substring, error_string, 'Error is not as expected.')
 95
 96                    continue
 97
 98                if (error_substring is not None):
 99                    self.fail(f"Did not get expected error: '{error_substring}'.")
100
101                if (actual_clean_func is not None):
102                    actual = actual_clean_func(actual)
103
104                if (expected_clean_func is not None):
105                    expected = expected_clean_func(expected)
106
107                # If we expect a tuple, compare the tuple contents instead of the tuple itself.
108                if (isinstance(expected, tuple)):
109                    if (not isinstance(actual, tuple)):
110                        raise ValueError(f"Expected results to be a tuple, found '{type(actual)}'.")
111
112                    if (len(expected) != len(actual)):
113                        raise ValueError(f"Result size mismatch. Expected: {len(expected)}, Actual: {len(actual)}.")
114                else:
115                    # Wrap the results in a tuple.
116                    expected = (expected, )
117                    actual = (actual, )
118
119                for i in range(len(expected)):  # pylint: disable=consider-using-enumerate
120                    expected_value = expected[i]
121                    actual_value = actual[i]
122
123                    if (assertion_func is not None):
124                        assertion_func(expected_value, actual_value)
125                    elif (isinstance(expected_value, lms.model.base.BaseType)):
126                        self.assertJSONEqual(expected_value, actual_value)
127                    elif (isinstance(expected_value, dict)):
128                        self.assertJSONDictEqual(expected_value, actual_value)
129                    elif (isinstance(expected_value, list)):
130                        self.assertJSONListEqual(expected_value, actual_value)
131                    else:
132                        self.assertEqual(expected_value, actual_value)
THIS_DIR: str = '/home/runner/work/autograder-py/autograder-py/autograder/testing'
ROOT_DIR: str = '/home/runner/work/autograder-py/autograder-py/autograder/testing/../..'
EXCHANGES_DIR: str = '/home/runner/work/autograder-py/autograder-py/autograder/testing/../../testdata/autograder-testdata'
BASE_ARGUMENTS: Dict[str, Any] = {'server': None}
@typing.runtime_checkable
class CleanFunction(typing.Protocol):
27@typing.runtime_checkable
28class CleanFunction(typing.Protocol):
29    """ A function for cleaning a test result (expected or actual) before assertion. """
30
31    def __call__(self, raw: typing.Any) -> typing.Any:
32        """
33        Clean and return data for assertion.
34        """

A function for cleaning a test result (expected or actual) before assertion.

CleanFunction(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs):
1954    cls = type(self)
1955
1956    if cls._is_protocol:
1957        raise TypeError('Protocols cannot be instantiated')
1958
1959    # Already using a custom `__init__`. No need to calculate correct
1960    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1961    if cls.__init__ is not _no_init_or_replace_init:
1962        return
1963
1964    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1965    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1966    # searches for a proper new `__init__` in the MRO. The new `__init__`
1967    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1968    # instantiation of the protocol subclass will thus use the new
1969    # `__init__` and no longer call `_no_init_or_replace_init`.
1970    for base in cls.__mro__:
1971        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1972        if init is not _no_init_or_replace_init:
1973            cls.__init__ = init
1974            break
1975    else:
1976        # should not happen
1977        cls.__init__ = object.__init__
1978
1979    cls.__init__(self, *args, **kwargs)
@typing.runtime_checkable
class AssertionFunction(typing.Protocol):
36@typing.runtime_checkable
37class AssertionFunction(typing.Protocol):
38    """ A function for asserting during a test. """
39
40    def __call__(self, expected: typing.Any, actual: typing.Any) -> typing.Any:
41        """
42        Assert the relationship between expected and actual.
43        """

A function for asserting during a test.

AssertionFunction(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs):
1954    cls = type(self)
1955
1956    if cls._is_protocol:
1957        raise TypeError('Protocols cannot be instantiated')
1958
1959    # Already using a custom `__init__`. No need to calculate correct
1960    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1961    if cls.__init__ is not _no_init_or_replace_init:
1962        return
1963
1964    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1965    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1966    # searches for a proper new `__init__` in the MRO. The new `__init__`
1967    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1968    # instantiation of the protocol subclass will thus use the new
1969    # `__init__` and no longer call `_no_init_or_replace_init`.
1970    for base in cls.__mro__:
1971        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1972        if init is not _no_init_or_replace_init:
1973            cls.__init__ = init
1974            break
1975    else:
1976        # should not happen
1977        cls.__init__ = object.__init__
1978
1979    cls.__init__(self, *args, **kwargs)
class ServerTest(edq.testing.httpserver.HTTPServerTest):
 45class ServerTest(edq.testing.httpserver.HTTPServerTest):
 46    """
 47    A special test suite that is common across all tests that require a test autograder server.
 48    """
 49
 50    # Use the same server for all test classes.
 51    tear_down_server = False
 52
 53    @classmethod
 54    def child_class_setup(cls) -> None:
 55        # Make the API request source information consistent.
 56        autograder.api.common.set_testing_source_info()
 57
 58        # Don't exit on error.
 59        autograder.error._exit_on_error_for_testing = False
 60
 61    @classmethod
 62    def setup_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None:
 63        context = edq.util.serial.SerializationContext(json_options = {
 64            'strict': True,
 65        })
 66
 67        edq.testing.httpserver.HTTPServerTest.setup_server(server)
 68        server.load_exchanges_dir(EXCHANGES_DIR, context = context)
 69
 70    def base_api_test(self,
 71            api_function: typing.Callable,
 72            test_cases: typing.List[typing.Tuple[autograder.model.config.Config, typing.Dict[str, typing.Any], typing.Any, typing.Union[str, None]]],
 73            actual_clean_func: typing.Union[CleanFunction, typing.Callable, None] = None,
 74            expected_clean_func: typing.Union[CleanFunction, typing.Callable, None] = None,
 75            assertion_func: typing.Union[AssertionFunction, None] = None,
 76            ) -> None:
 77        """
 78        A common test for the base API functionality.
 79        Test cases are passed in as: `[(configs (and overrides), kwargs, expected, error substring), ...]`.
 80        """
 81
 82        for (i, test_case) in enumerate(test_cases):
 83            (config, kwargs, expected, error_substring) = test_case
 84
 85            with self.subTest(msg = f"Case {i}:"):
 86                config.server = self.get_server_url()
 87
 88                try:
 89                    actual = api_function(config, **kwargs)
 90                except Exception as ex:
 91                    error_string = self.format_error_string(ex)
 92                    if (error_substring is None):
 93                        self.fail(f"Unexpected error: '{error_string}'.")
 94
 95                    self.assertIn(error_substring, error_string, 'Error is not as expected.')
 96
 97                    continue
 98
 99                if (error_substring is not None):
100                    self.fail(f"Did not get expected error: '{error_substring}'.")
101
102                if (actual_clean_func is not None):
103                    actual = actual_clean_func(actual)
104
105                if (expected_clean_func is not None):
106                    expected = expected_clean_func(expected)
107
108                # If we expect a tuple, compare the tuple contents instead of the tuple itself.
109                if (isinstance(expected, tuple)):
110                    if (not isinstance(actual, tuple)):
111                        raise ValueError(f"Expected results to be a tuple, found '{type(actual)}'.")
112
113                    if (len(expected) != len(actual)):
114                        raise ValueError(f"Result size mismatch. Expected: {len(expected)}, Actual: {len(actual)}.")
115                else:
116                    # Wrap the results in a tuple.
117                    expected = (expected, )
118                    actual = (actual, )
119
120                for i in range(len(expected)):  # pylint: disable=consider-using-enumerate
121                    expected_value = expected[i]
122                    actual_value = actual[i]
123
124                    if (assertion_func is not None):
125                        assertion_func(expected_value, actual_value)
126                    elif (isinstance(expected_value, lms.model.base.BaseType)):
127                        self.assertJSONEqual(expected_value, actual_value)
128                    elif (isinstance(expected_value, dict)):
129                        self.assertJSONDictEqual(expected_value, actual_value)
130                    elif (isinstance(expected_value, list)):
131                        self.assertJSONListEqual(expected_value, actual_value)
132                    else:
133                        self.assertEqual(expected_value, actual_value)

A special test suite that is common across all tests that require a test autograder server.

tear_down_server = False

Tear down the relevant test server in tearDownClass(). If set to false then the server will never get torn down, but can be shared between child test classes.

@classmethod
def child_class_setup(cls) -> None:
53    @classmethod
54    def child_class_setup(cls) -> None:
55        # Make the API request source information consistent.
56        autograder.api.common.set_testing_source_info()
57
58        # Don't exit on error.
59        autograder.error._exit_on_error_for_testing = False

This function is the recommended time for child classes to set any configuration.

@classmethod
def setup_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None:
61    @classmethod
62    def setup_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None:
63        context = edq.util.serial.SerializationContext(json_options = {
64            'strict': True,
65        })
66
67        edq.testing.httpserver.HTTPServerTest.setup_server(server)
68        server.load_exchanges_dir(EXCHANGES_DIR, context = context)

An opportunity for child classes to configure the test server before starting it.

def base_api_test( self, api_function: Callable, test_cases: List[Tuple[autograder.model.config.Config, Dict[str, Any], Any, Optional[str]]], actual_clean_func: Union[CleanFunction, Callable, NoneType] = None, expected_clean_func: Union[CleanFunction, Callable, NoneType] = None, assertion_func: Optional[AssertionFunction] = None) -> None:
 70    def base_api_test(self,
 71            api_function: typing.Callable,
 72            test_cases: typing.List[typing.Tuple[autograder.model.config.Config, typing.Dict[str, typing.Any], typing.Any, typing.Union[str, None]]],
 73            actual_clean_func: typing.Union[CleanFunction, typing.Callable, None] = None,
 74            expected_clean_func: typing.Union[CleanFunction, typing.Callable, None] = None,
 75            assertion_func: typing.Union[AssertionFunction, None] = None,
 76            ) -> None:
 77        """
 78        A common test for the base API functionality.
 79        Test cases are passed in as: `[(configs (and overrides), kwargs, expected, error substring), ...]`.
 80        """
 81
 82        for (i, test_case) in enumerate(test_cases):
 83            (config, kwargs, expected, error_substring) = test_case
 84
 85            with self.subTest(msg = f"Case {i}:"):
 86                config.server = self.get_server_url()
 87
 88                try:
 89                    actual = api_function(config, **kwargs)
 90                except Exception as ex:
 91                    error_string = self.format_error_string(ex)
 92                    if (error_substring is None):
 93                        self.fail(f"Unexpected error: '{error_string}'.")
 94
 95                    self.assertIn(error_substring, error_string, 'Error is not as expected.')
 96
 97                    continue
 98
 99                if (error_substring is not None):
100                    self.fail(f"Did not get expected error: '{error_substring}'.")
101
102                if (actual_clean_func is not None):
103                    actual = actual_clean_func(actual)
104
105                if (expected_clean_func is not None):
106                    expected = expected_clean_func(expected)
107
108                # If we expect a tuple, compare the tuple contents instead of the tuple itself.
109                if (isinstance(expected, tuple)):
110                    if (not isinstance(actual, tuple)):
111                        raise ValueError(f"Expected results to be a tuple, found '{type(actual)}'.")
112
113                    if (len(expected) != len(actual)):
114                        raise ValueError(f"Result size mismatch. Expected: {len(expected)}, Actual: {len(actual)}.")
115                else:
116                    # Wrap the results in a tuple.
117                    expected = (expected, )
118                    actual = (actual, )
119
120                for i in range(len(expected)):  # pylint: disable=consider-using-enumerate
121                    expected_value = expected[i]
122                    actual_value = actual[i]
123
124                    if (assertion_func is not None):
125                        assertion_func(expected_value, actual_value)
126                    elif (isinstance(expected_value, lms.model.base.BaseType)):
127                        self.assertJSONEqual(expected_value, actual_value)
128                    elif (isinstance(expected_value, dict)):
129                        self.assertJSONDictEqual(expected_value, actual_value)
130                    elif (isinstance(expected_value, list)):
131                        self.assertJSONListEqual(expected_value, actual_value)
132                    else:
133                        self.assertEqual(expected_value, actual_value)

A common test for the base API functionality. Test cases are passed in as: [(configs (and overrides), kwargs, expected, error substring), ...].