Repository: e-valuation/EvaP

Issue:
Check correctness of type annotations at test runtime
Plausible but wrong type annotations could give a false sense of security and are therefore (imho) more harmful than no type annotations. When we go through old code and annotate it (as we currently do in #2035), we might make mistakes that mypy does not catch, e.g. because calling code that uses other argument types simply isn't typed yet.

There is https://typeguard.readthedocs.io/en/latest/, which allows to inject run-time type checking into the program. I'm thinking of running the tests with this injected so that we can catch cases where we annotated stuff wrongly or too strictly.

One issue I can see is that this disallows passing mocked arguments in tests (especially if the validation uses `isinstance`). We might have to add manual exclusions for such cases. I wouldn't want this to be too invasive though, a best case scenario for me would be a handful of lines to inject the checking and maybe a handful more lines for manual exclusions where we use mocks.

@niklasmohrin @Kakadus thoughts?


Discussion:
Sounds like a cool Attempt :)

Resolving patch:
diff --git a/evap/evaluation/tools.py b/evap/evaluation/tools.py
--- a/evap/evaluation/tools.py
+++ b/evap/evaluation/tools.py
@@ -1,4 +1,5 @@
 import datetime
+import typing
 from abc import ABC, abstractmethod
 from collections import defaultdict
 from collections.abc import Iterable, Mapping
@@ -12,13 +13,17 @@
 from django.forms.formsets import BaseFormSet
 from django.http import HttpRequest, HttpResponse
 from django.shortcuts import get_object_or_404
+from django.utils.datastructures import MultiValueDict
 from django.utils.translation import get_language
 from django.views.generic import FormView
+from django_stubs_ext import StrOrPromise
 
 M = TypeVar("M", bound=Model)
 T = TypeVar("T")
 Key = TypeVar("Key")
 Value = TypeVar("Value")
+CellValue = str | int | float | None
+CV = TypeVar("CV", bound=CellValue)
 
 
 def unordered_groupby(key_value_pairs: Iterable[tuple[Key, Value]]) -> dict[Key, list[Value]]:
@@ -35,7 +40,9 @@ def unordered_groupby(key_value_pairs: Iterable[tuple[Key, Value]]) -> dict[Key,
     return dict(result)
 
 
-def get_object_from_dict_pk_entry_or_logged_40x(model_cls: type[M], dict_obj: Mapping[str, Any], key: str) -> M:
+def get_object_from_dict_pk_entry_or_logged_40x(
+    model_cls: type[M], dict_obj: MultiValueDict[str, Any] | Mapping[str, Any], key: str
+) -> M:
     try:
         return get_object_or_404(model_cls, pk=dict_obj[key])
     # ValidationError happens for UUID id fields when passing invalid arguments
@@ -130,7 +137,8 @@ def clean_email(email: EmailT) -> EmailT:
     return email
 
 
-def capitalize_first(string: str) -> str:
+def capitalize_first(string: StrOrPromise) -> str:
+    """Realize lazy promise objects and capitalize first letter."""
     return string[0].upper() + string[1:]
 
 
@@ -174,6 +182,7 @@ def formset_valid(self, formset) -> HttpResponse:
         return super().form_valid(formset)
 
 
+@typing.runtime_checkable
 class HasFormValid(Protocol):
     def form_valid(self, form):
         pass
@@ -262,7 +271,7 @@ def __init__(self) -> None:
         else:
             self.cur_sheet = None
 
-    def write_cell(self, label: str | None = "", style: str = "default") -> None:
+    def write_cell(self, label: CellValue = "", style: str = "default") -> None:
         """Write a single cell and move to the next column."""
         self.cur_sheet.write(
             self.cur_row,
@@ -276,7 +285,7 @@ def next_row(self) -> None:
         self.cur_col = 0
         self.cur_row += 1
 
-    def write_row(self, vals: Iterable[str], style: str = "default") -> None:
+    def write_row(self, vals: Iterable[CV], style: str | typing.Callable[[CV], str] = "default") -> None:
         """
         Write a cell for every value and go to the next row.
         Styling can be chosen
diff --git a/evap/settings.py b/evap/settings.py
--- a/evap/settings.py
+++ b/evap/settings.py
@@ -418,8 +418,12 @@ def CHARACTER_ALLOWED_IN_NAME(character):  # pylint: disable=invalid-name
 
 TESTING = "test" in sys.argv or "pytest" in sys.modules
 
-# speed up tests
+# speed up tests and activate typeguard introspection
 if TESTING:
+    from typeguard import install_import_hook
+
+    install_import_hook(("evap", "tools"))
+
     # do not use ManifestStaticFilesStorage as it requires running collectstatic beforehand
     STORAGES["staticfiles"]["BACKEND"] = "django.contrib.staticfiles.storage.StaticFilesStorage"
 
diff --git a/evap/staff/importers/enrollment.py b/evap/staff/importers/enrollment.py
--- a/evap/staff/importers/enrollment.py
+++ b/evap/staff/importers/enrollment.py
@@ -73,7 +73,14 @@ def differing_fields(self, other) -> set[str]:
         return {field.name for field in fields(self) if getattr(self, field.name) != getattr(other, field.name)}
 
 
-class ValidCourseData(CourseData):
+class ValidCourseDataMeta(type):
+    def __instancecheck__(cls, instance: object) -> TypeGuard["ValidCourseData"]:
+        if not isinstance(instance, CourseData):
+            return False
+        return all_fields_valid(instance)
+
+
+class ValidCourseData(CourseData, metaclass=ValidCourseDataMeta):
     """Typing: CourseData instance where no element is invalid_value"""
 
     degrees: set[Degree]


Test patch:
diff --git a/evap/evaluation/tests/test_models_logging.py b/evap/evaluation/tests/test_models_logging.py
--- a/evap/evaluation/tests/test_models_logging.py
+++ b/evap/evaluation/tests/test_models_logging.py
@@ -5,7 +5,7 @@
 from model_bakery import baker
 
 from evap.evaluation.models import Contribution, Course, Evaluation, Questionnaire, UserProfile
-from evap.evaluation.models_logging import FieldAction
+from evap.evaluation.models_logging import FieldAction, InstanceActionType
 
 
 class TestLoggedModel(TestCase):
@@ -52,7 +52,10 @@ def test_data_attribute_is_correctly_parsed_to_fieldactions(self):
         )
 
     def test_deletion_data(self):
-        self.assertEqual(self.evaluation._get_change_data(action_type="delete")["course"]["delete"][0], self.course.id)
+        self.assertEqual(
+            self.evaluation._get_change_data(action_type=InstanceActionType.DELETE)["course"]["delete"][0],
+            self.course.id,
+        )
         self.evaluation.delete()
         self.assertEqual(self.evaluation.related_logentries().count(), 0)