Created
November 18, 2025 07:42
-
-
Save jpgoldberg/5b33586e13cf00063a3fa9d59330b0c9 to your computer and use it in GitHub Desktop.
Egregiously over-engineered score to grade module (Python)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Egregiously over-engineered test score to letter grade utility | |
| Originally intended to illustrate how to do that grading program | |
| using bisect, expanding on the example from the bisect documentation | |
| https://docs.python.org/3/library/bisect.html#examples | |
| But then I started tinkering way to much. | |
| """ | |
| # bisect.bisect() does the magic, but it takes some practice, | |
| # and the documentation isn't really clear to those who haven't | |
| # tried to do this on their own first. | |
| from bisect import bisect | |
| # Mapping is just so I can give a proper type annotation for grade() | |
| # Don't worry about it at this point. It roughly means a | |
| # dict-like thing that is NOT mutable. | |
| # Likewise Sequence is for list-like things that are immutable. | |
| from collections.abc import Mapping, Sequence | |
| # Because we want math.inf for full range of possible scores | |
| import math | |
| # We will want to cache the __str__ result for a Grader instance | |
| # But note that type checkers and @cache don't play nicely together. | |
| # See https://stephantul.github.io/blog/cache/ | |
| import functools | |
| class Grader: | |
| """Score to grade calculator. | |
| Can be used for any bijective step function, Callable[[float], str]. | |
| """ | |
| DEFAULT_MAP: Mapping[str, float] = { | |
| "F": 59, | |
| "D": 69, | |
| "C": 79, | |
| "B": 89, | |
| "A": math.inf, | |
| } | |
| """Default grade map.""" | |
| def __init__( | |
| self, | |
| mapping: Mapping[str, float] = DEFAULT_MAP, | |
| min_score: float = -math.inf, | |
| ) -> None: | |
| """Defines the score to grade function. | |
| :param mapping: Grade : Maximum score for that grade | |
| :param min_score: The minimum possible score. | |
| The maximum possible score will simply be the maximum value | |
| in mapping. | |
| :raises ValueError: if mapping has duplicate values. | |
| :raises ValueError: if lowest mapping value is less than ``min_score``. | |
| """ | |
| self._min_score: float = min_score | |
| # create dictionary sorted by values | |
| self._mapping: Mapping[str, float] = dict( | |
| sorted(mapping.items(), key=lambda item: item[1]) | |
| ) | |
| self._cutoffs = [v + 1 for v in self._mapping.values()] | |
| if len(self._cutoffs) != len(set(self._cutoffs)): | |
| raise ValueError("Score cutoffs must be unique") | |
| if self._cutoffs[0] <= self._min_score: | |
| raise ValueError( | |
| f"lowest cutoff ({self._cutoffs[0]}) is less than " | |
| f"minimum allowed score ({self._min_score})" | |
| ) | |
| self._max_score = self._cutoffs[-1] | |
| self._grades: Sequence[str] = list(self._mapping.keys()) | |
| def grade(self, score: float) -> str: | |
| """Returns the grade for a particular score. | |
| :param score: The numeric score | |
| :raises ValueError: if score exceeds max_score. | |
| :raises ValueError: if score is less than min_score | |
| :raises ValueError: if score in not finite | |
| """ | |
| if abs(score) == math.inf: | |
| raise ValueError("Score must be finite") | |
| if score < self._min_score: | |
| raise ValueError(f"Score ({score}) < minimum ({self._min_score})") | |
| if score > self._max_score: | |
| raise ValueError(f"Score ({score}) > maximum ({self._max_score})") | |
| # Now the magic bit | |
| # idx will be the index to the the first member of the cutoffs | |
| # array that that is not less than score | |
| idx = bisect(self._cutoffs, score) | |
| return self._grades[idx] | |
| @property | |
| def mapping(self) -> Mapping[str, float]: | |
| return self._mapping | |
| @functools.cache | |
| def __str__(self) -> str: # pyright: ignore | |
| s = f"Grade Mapping: {self._mapping}\n" | |
| s += f"Minimum allowed score: {self._min_score}\n" | |
| s += f"Maximum allowed score: {self._max_score}" | |
| return s |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from collections.abc import Mapping | |
| import math | |
| import sys | |
| import unittest # for unittest.TestCase.subTest | |
| import pytest | |
| from dataclasses import dataclass | |
| import grades | |
| @dataclass(frozen=True) | |
| class ScoreVector: | |
| score: float | |
| grade: str | |
| exception: None | type[Exception] = None | |
| note: str | None = None | |
| class TestDefault(unittest.TestCase): | |
| grader = grades.Grader() | |
| vectors: list[ScoreVector] = [ | |
| ScoreVector(49, 'F', note="Normal"), | |
| ScoreVector(50, 'F', note="Normal"), | |
| ScoreVector(51, 'F', note="Normal"), | |
| ScoreVector(59, 'F', note="Normal"), | |
| ScoreVector(60, 'D', note="Normal"), | |
| ScoreVector(61, 'D', note="Normal"), | |
| ScoreVector(69, 'D', note="Normal"), | |
| ScoreVector(70, 'C', note="Normal"), | |
| ScoreVector(71, 'C', note="Normal"), | |
| ScoreVector(79, 'C', note="Normal"), | |
| ScoreVector(80, 'B', note="Normal"), | |
| ScoreVector(81, 'B', note="Normal"), | |
| ScoreVector(89, 'B', note="Normal"), | |
| ScoreVector(90, 'A', note="Normal"), | |
| ScoreVector(90, 'A', note="Normal"), | |
| ScoreVector(0, 'F'), | |
| ScoreVector(100, 'A'), | |
| ScoreVector(-1, 'F', note="Below 0"), | |
| ScoreVector(101, 'A', note="Above 100"), | |
| ScoreVector(math.inf, 'A', exception=ValueError, note="Not finite"), | |
| ScoreVector(-math.inf, 'F', exception=ValueError, note="Not finite"), | |
| ] # fmt: skip | |
| def test_normal(self) -> None: | |
| for v in self.vectors: | |
| if v.exception is not None: | |
| continue | |
| if not (v.note is None or v.note == "Normal"): | |
| continue | |
| with self.subTest(msg=f'score: {v.score}. Note: "{v.note}"'): | |
| grade = self.grader.grade(v.score) | |
| assert grade == v.grade | |
| def test_abnormal(self) -> None: | |
| for v in self.vectors: | |
| if v.exception is not None: | |
| continue | |
| if v.note is None or v.note == "Normal": | |
| continue | |
| with self.subTest(msg=f'score: {v.score}. Note: "{v.note}"'): | |
| grade = self.grader.grade(v.score) | |
| assert grade == v.grade | |
| def test_exceptions(self) -> None: | |
| for v in self.vectors: | |
| if v.exception is None: | |
| continue | |
| with self.subTest(msg=f'score: {v.score}. Note: "{v.note}"'): | |
| with pytest.raises(v.exception): | |
| _ = self.grader.grade(v.score) | |
| class TestGrader(unittest.TestCase): | |
| mapping: Mapping[str, float] = { | |
| "Highest": math.inf, | |
| "B": 89, | |
| "C": 79, | |
| "D": 69, | |
| "Lowest": 59, | |
| } | |
| def test_duplicate(self) -> None: | |
| bad_mapping: dict = {k: v for k, v in self.mapping.items()} | |
| bad_mapping["E"] = bad_mapping["D"] | |
| with pytest.raises(ValueError): | |
| _ = grades.Grader(bad_mapping) | |
| def test_min_mismatch(self) -> None: | |
| min_score = self.mapping["Lowest"] + 1 | |
| with pytest.raises(ValueError): | |
| _ = grades.Grader(mapping=self.mapping, min_score=min_score) | |
| # while this should not be an error | |
| _ = grades.Grader( | |
| mapping=self.mapping, min_score=self.mapping["Lowest"] | |
| ) | |
| if __name__ == "__main__": | |
| sys.exit(pytest.main(args=[__file__])) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment