"""
Classes for test cases using code objects or functions.
Export classes for TestCases, listing ``*args`` and ``**kwargs`` for inputs and
(both actual and expected) outputs from a student submission:
- TestCase: Data class for storing inputs and expected outputs of tests.
- Test: Class for executing submissions on TestCases.
"""
from __future__ import annotations
__version__ = "0.3"
__all__ = ["TestCase", "TestSubtask", "Timer", "execute", "format_result"]
import functools
import importlib.machinery
import importlib.util
import logging
import types
from copy import deepcopy
from dataclasses import dataclass, field
from time import perf_counter
import pytest
logger = logging.getLogger(__name__)
[docs]
class Timer:
"""Context manager for measuring execution time."""
start: float
end: float | None
def __enter__(self) -> Timer:
"""
Enter the timer context and record start time.
Returns
-------
Timer
The timer instance.
"""
self.start = perf_counter()
self.end = None
return self
def __exit__(self, *_exc_args: object) -> None:
"""
Exit the timer context and record end time.
Parameters
----------
*_exc_args : object
Exception info passed by the context manager protocol.
"""
self.end = perf_counter()
@property
def elapsed(self) -> float:
"""
Return elapsed time in seconds.
Returns
-------
float
Elapsed time since entering the context.
"""
return (self.end or perf_counter()) - self.start
[docs]
@dataclass
class TestCase:
"""Inputs and expected outputs of a single test case."""
inputs: tuple[tuple, dict] = field(default_factory=lambda: (tuple(), {}))
expected: tuple[tuple, dict] = field(default_factory=lambda: (tuple(), {}))
raises: bool = False
timing: tuple[float | None, float | None] = (None, None)
[docs]
@dataclass
class TestSubtask:
"""Test cases, prerequisites, and assertions for a single subtask."""
cases: list[TestCase]
assertions: dict
prerequisites: dict = field(default_factory=dict)
[docs]
@functools.singledispatch
def execute(submission: object, case: TestCase) -> tuple[tuple, dict, float]:
"""
Execute a submission on a test case.
Parameters
----------
submission : object
The student submission to execute.
case : TestCase
Test case with inputs and expected outputs.
Returns
-------
tuple[tuple, dict, float]
A ``(positional_outputs, named_outputs, elapsed_time)`` triple.
"""
raise NotImplementedError(f"Cannot run {type(submission) = }.")
@execute.register
def _(submission: types.FunctionType, case: TestCase) -> tuple[tuple, dict, float]:
"""
Execute a function submission with test case inputs.
Parameters
----------
submission : types.FunctionType
The student function to call.
case : TestCase
Test case providing inputs and expected output count.
Returns
-------
tuple[tuple, dict, float]
A ``(positional_outputs, named_outputs, elapsed_time)`` triple.
"""
input_args, input_kwargs = deepcopy(case.inputs)
with Timer() as t:
return_value = submission(*input_args, **input_kwargs)
number_of_expected_args = len(case.expected[0])
if return_value is None:
output_args = tuple()
elif number_of_expected_args == 1:
output_args = (return_value,)
else:
output_args = return_value
if number_of_expected_args != len(output_args) and not case.raises:
logger.warning("Number of expected outputs (%s) does not match number of actual outputs (%s)!", number_of_expected_args, len(output_args))
return output_args, {}, t.elapsed
@execute.register
def _(submission: types.CodeType, case: TestCase) -> tuple[tuple, dict, float]:
"""
Execute bytecode submission with given input scope.
Parameters
----------
submission : types.CodeType
Compiled bytecode from student solution.
case : TestCase
Test case providing input scope as ``inputs[1]``.
Returns
-------
tuple[tuple, dict, float]
A ``(positional_outputs, named_outputs, elapsed_time)`` triple.
"""
outputs = deepcopy(case.inputs)
with Timer() as t:
exec(submission, outputs[1])
# subtract scope from empty code
pytest_scope = {}
exec(compile("", "", "exec"), pytest_scope)
outputs = (
outputs[0],
{key: value for key, value in outputs[1].items() if key not in pytest_scope},
t.elapsed,
)
return outputs
@execute.register
def _(submission: importlib.machinery.ModuleSpec, case: TestCase) -> tuple[tuple, dict, float]:
"""
Import a module from spec and store the return object.
Parameters
----------
submission : importlib.machinery.ModuleSpec
Module specification to import.
case : TestCase
Unused test case (module import ignores inputs).
Returns
-------
tuple[tuple, dict, float]
A ``(positional_outputs, named_outputs, elapsed_time)`` triple.
"""
with Timer() as t:
return_object = importlib.util.module_from_spec(submission)
submission.loader.exec_module(return_object)
return (return_object,), {}, t.elapsed
@execute.register
def _(submission: type, case: TestCase) -> tuple[tuple, dict, float]:
"""
Instantiate a class submission with test case inputs.
Parameters
----------
submission : type
The class to instantiate.
case : TestCase
Test case providing ``inputs`` as list of ``(args, kwargs)`` pairs.
Returns
-------
tuple[tuple, dict, float]
A ``(positional_outputs, named_outputs, elapsed_time)`` triple.
"""
with Timer() as t:
return_objects = tuple(submission(*args, **kwargs) for args, kwargs in case.inputs)
return return_objects, {}, t.elapsed