diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..39b3736 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "eip_testing" +authors = [{name = "Nils Pukropp", email = "nils@narl.io"}] +readme = "README.md" +license = {file = "LICENSE"} +classifiers = ["License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)"] +dynamic = ["version", "description"] + +[project.urls] +Home = "https://github.com/narrrl/eip_testing" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/testing.py b/src/testing.py new file mode 100644 index 0000000..bd89a94 --- /dev/null +++ b/src/testing.py @@ -0,0 +1,119 @@ +import functools +import multiprocessing +from typing import Callable, Iterator + + +class TimeoutException(Exception): + pass + +def get_subtasks_for(cls: type) -> Iterator[type]: + """get all tasks in a tasks aka subtasks""" + return [e for e in cls.__dict__.values() if hasattr(e, 'is_task')] + + +def get_tests_for(cls: type) -> Iterator[Callable[[], None]]: + """get all functions that are declared as tests""" + return [e for e in cls.__dict__.values() if hasattr(e, 'is_test')] + +def test_wrapper(test: Callable[[], None]): + try: + test() + except Exception as e: + test.has_failed = True + test.cause = e + + +def run_test(test: Callable[[], None]): + """run a test and catch any unexpected behavior and mark it as failed with the exception as cause""" + if not hasattr(test, 'is_test'): + return + timeout = test.timeout + p = multiprocessing.Process(target=test_wrapper, args=[test]) + p.start() + p.join(timeout) + if p.is_alive(): + test.has_failed = True + test.cause = TimeoutException(f"test failed after {timeout} seconds") + + +def run_tests_for_task(task: type) -> None: + for test in task.tests: + run_test(test) + for task in task.tasks: + run_tests_for_task(task) + + +def points_to_detuct(e: type | Callable) -> int: + match e: + case type() if hasattr(e, 'is_task'): + to_detuct = 0 + for test in e.tests: + if hasattr(test, 'is_test'): + to_detuct += test.to_deduct() + for task in e.tasks: + to_detuct += points_to_detuct(task) + return e.max_points if to_detuct > e.max_points else to_detuct + case Callable(test): + return test.to_detuct() + case _: + return 0 + + +class Exercise(object): + def __init__(self, id: str) -> None: + self.__tasks: list[type] = [] + self.__id = id + self.__max_points = 0 + self.__points = 0 + + def register(self, cls: type) -> None: + if hasattr(cls, 'is_task'): + self.__tasks.append(cls) + self.__max_points += cls.max_points + return cls + + def run(self) -> str: + self.run_tests() + self.deduct_points() + + + def run_tests(self): + for task in self.__tasks: + run_tests_for_task(task) + + def deduct_points(self): + for task in self.__tasks: + to_detuct = points_to_detuct(task) + task.points = 0 if to_detuct > task.max_points else task.max_points - to_detuct + self.__points = functools.reduce( + lambda a, b: a + b, map(lambda t: t.points, self.__tasks), 0.0) + + def get_points(self) -> float: + return self.__points + + def get_max_points(self) -> float: + return self.__max_points + + +def eip_task(header: str, max_points: float, ex: Exercise) -> Callable[[type], type]: + def wrapper(cls: type) -> type: + cls.is_task = True + cls.header = header + cls.max_points = max_points + cls.tasks = get_subtasks_for(cls) + cls.tests = get_tests_for(cls) + ex.register(cls) + return cls + return wrapper + + +def eip_test(msg: str, to_deduct: float, timeout = 10) -> Callable[[], None]: + def wrapper(test: Callable[[], None]) -> Callable[[], None]: + test.is_test = True + test.msg = msg + test.has_failed = False + test.to_deduct = lambda: to_deduct if test.has_failed else 0 + test.timeout = timeout + test.cause = None + return test + return wrapper diff --git a/src/tests/primes.py b/src/tests/primes.py new file mode 100644 index 0000000..1d1e63c --- /dev/null +++ b/src/tests/primes.py @@ -0,0 +1,29 @@ +def is_prime(n: int) -> bool: + if n < 2: + return False + + for i in range(2, n // 2 + 1): + if n % i == 0: + return False + + return True + + +def next_prime(n: int) -> int: + num = n + 1 + while not is_prime(num): + num += 1 + return num + +def prime_factorize(n: int) -> list[int]: + prime_factors: list[int] = [] + num = n + prime = 2 + while num > 1: + if num % prime == 0: + prime_factors.append(prime) + num //= prime + prime = 2 + else: + prime = next_prime(prime) + return prime_factors diff --git a/src/tests/test_testing.py b/src/tests/test_testing.py new file mode 100644 index 0000000..b175e9c --- /dev/null +++ b/src/tests/test_testing.py @@ -0,0 +1,103 @@ +from utils import has_annotation_callable +from testing import eip_task, eip_test, Exercise + +PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, + 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97] +PRIME_FACTORS = {2: [2], 3: [3], 4: [2, 2], 5: [5], 6: [2, 3], 7: [7], 8: [2, 2, 2], 9: [3, 3], 10: [2, 5], 11: [11], 12: [2, 2, 3], 13: [13], 14: [2, 7], 15: [3, 5], 16: [2, 2, 2, 2], 17: [17], 18: [2, 3, 3], 19: [19], 20: [2, 2, 5], 21: [3, 7], 22: [2, 11], 23: [23], 24: [2, 2, 2, 3], 25: [5, 5], 26: [2, 13], 27: [3, 3, 3], 28: [2, 2, 7], 29: [29], 30: [2, 3, 5], 31: [31], 32: [2, 2, 2, 2, 2], 33: [3, 11], 34: [2, 17], 35: [5, 7], 36: [2, 2, 3, 3], 37: [37], 38: [2, 19], 39: [3, 13], 40: [2, 2, 2, 5], 41: [41], 42: [2, 3, 7], 43: [43], 44: [2, 2, 11], 45: [3, 3, 5], 46: [2, 23], 47: [47], 48: [2, 2, 2, 2, 3], 49: [7, 7], 50: [2, 5, 5], 51: [3, 17], 52: [2, 2, 13], 53: [53], 54: [ + 2, 3, 3, 3], 55: [5, 11], 56: [2, 2, 2, 7], 57: [3, 19], 58: [2, 29], 59: [59], 60: [2, 2, 3, 5], 61: [61], 62: [2, 31], 63: [3, 3, 7], 64: [2, 2, 2, 2, 2, 2], 65: [5, 13], 66: [2, 3, 11], 67: [67], 68: [2, 2, 17], 69: [3, 23], 70: [2, 5, 7], 71: [71], 72: [2, 2, 2, 3, 3], 73: [73], 74: [2, 37], 75: [3, 5, 5], 76: [2, 2, 19], 77: [7, 11], 78: [2, 3, 13], 79: [79], 80: [2, 2, 2, 2, 5], 81: [3, 3, 3, 3], 82: [2, 41], 83: [83], 84: [2, 2, 3, 7], 85: [5, 17], 86: [2, 43], 87: [3, 29], 88: [2, 2, 2, 11], 89: [89], 90: [2, 3, 3, 5], 91: [7, 13], 92: [2, 2, 23], 93: [3, 31], 94: [2, 47], 95: [5, 19], 96: [2, 2, 2, 2, 2, 3], 97: [97], 98: [2, 7, 7], 99: [3, 3, 11], 100: [2, 2, 5, 5]} +MAX = 100 + + +primes_exercise = Exercise("primes") + + +@eip_task("`is_prime`", 4, primes_exercise) +class IsPrime: + + @eip_test("`is_prime` nicht implementiert", 4) + def is_implemented(): + from primes import is_prime as _ + + @eip_test("Manche Primzahlen evaluieren zu `False`", 2) + def test_primes(): + from primes import is_prime + for prime in PRIMES: + assert is_prime(prime), f"`is_prime({prime}) = False`" + + @eip_test("Manche Zahlen evaluieren zu `True` obwohl keine Primzahl", 2) + def test_non_primes(): + from primes import is_prime + for not_prime in [num for num in range(4, MAX + 1) if num not in PRIMES]: + assert not is_prime(not_prime), f"`is_prime({not_prime}) = True`" + + @eip_test("Typeannotation ist nicht korrekt/unvollständig", 0.5) + def test_annotation(): + from primes import is_prime + has_annotation_callable(is_prime, [int], bool) + + +@eip_task("`next_prime`", 6, primes_exercise) +class NextPrime: + + @eip_test("`next_prime` nicht implementiert", 6) + def is_implemented(): + from primes import next_prime as _ + + @eip_test("Stimmt nicht für alle überprüften Werte", 4) + def test_next_prime(): + from primes import next_prime + for num in range(2, PRIMES[len(PRIMES) - 1]): + prime = next_prime(num) + i = 0 + while i < len(PRIMES) - 1 and num >= PRIMES[i + 1]: + i += 1 + p1 = PRIMES[i] + p2 = PRIMES[i + 1] + assert p1 <= num < p2 and p2 == prime, f"`next_prime({num}) = {prime} != {p2}`" + + @eip_test("`next_prime` evaluiert nicht `2` für Werte kleiner als `2`", 1) + def test_lower_values_next_prime(): + import primes + for num in range(-2, 2): + assert primes.next_prime(num) == 2, f"`next_prime({num}) != 2`" + + @eip_test("`Typannotation inkorrekt/unvollständig", 0.5) + def test_next_prime_annotation(): + import primes + has_annotation_callable(primes.next_prime, [int], int) + + +@eip_task("c) `prime_factorize`", 10, primes_exercise) +class PrimeFactorize: + + @eip_test("`prime_factorize` nicht implementiert", 10) + def prime_factorize_implemented(): + from primes import prime_factorize as _ + + @eip_test("`prime_factorize` gibt nicht immer die korrekten Faktoren", 8) + def test_prime_factorize(): + import primes + for prime in PRIMES: + assert primes.prime_factorize(prime) == [prime], f"prime_factorize({prime}) = {primes.prime_factorize(prime)} != [{prime}]" + prime_factors = [(n, primes.prime_factorize(n)) + for n in range(2, MAX + 1)] + for n, factors in prime_factors: + for factor in factors: + assert factor in PRIMES, f"{factor} in {factors} is not prime" + assert PRIME_FACTORS[n] == factors, f"prime_factorize({n}) = {factors} != {PRIME_FACTORS[n]}" + + @eip_test("`prime_factorize` gibt keine leere Liste für Werte kleiner 2", 2) + def test_lower_values_next_prime(): + import primes + for num in range(-2, 2): + assert primes.next_prime(num) == 2 + + @eip_test("`Typannotation inkorrekt/unvollständig", 0.5) + def test_type_annotation(): + import primes + has_annotation_callable(primes.prime_factorize, [int], list[int]) + + +if __name__ == "__main__": + primes_exercise.run() + print(f"# exercise ({primes_exercise.get_points():.2g} / {primes_exercise.get_max_points()})") diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..705dc56 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,14 @@ +from typing import Callable, TypeAliasType + +def has_annotation_callable(f: Callable, param_types: list[type], return_type: object = None): + a = f.__annotations__ + ret = a['return'] if 'return' in a else None + assert ret == return_type + + params = list(a.values())[:-1] if 'return' in a else list(a.values()) + for param, param_type in zip(params, param_types): + while isinstance(param, TypeAliasType): + param = param.__value__ + while isinstance(param_type, TypeAliasType): + param_type = param_type.__value__ + assert param == param_type \ No newline at end of file