from typing import Dict, Optional, Sequence from .education_gpa_data import DEFAULT_GRADE_POINTS from .education_gpa_types import GpaCourse, GpaResult, GradePoint from .math_round_div import RoundingMode, round_div def _is_int(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) def gpa(courses: Sequence[GpaCourse], grade_points: Optional[Sequence[GradePoint]], mode: RoundingMode) -> GpaResult: """Credit-weighted grade point average in hundredths, rounded once. Quality points are summed exactly in integers (hundredths x credits), so the rounding mode the caller names is the only rounding that happens. """ table = DEFAULT_GRADE_POINTS if grade_points is None else grade_points if len(table) == 0: raise ValueError("gradePoints must not be empty; pass null for the default table") points: Dict[str, Optional[int]] = {} for row in table: if row.grade in points: raise ValueError('grade "%s" appears twice in gradePoints' % (row.grade,)) if row.points is not None and (not _is_int(row.points) or row.points < 0): raise ValueError( 'points must be whole hundredths of 0 or more, received %s for grade "%s"' % (row.points, row.grade) ) points[row.grade] = row.points quality_points = 0 gpa_credits = 0 excluded_credits = 0 for course in courses: if not _is_int(course.credits) or course.credits < 0: raise ValueError("credits must be a whole number of 0 or more, received %s" % (course.credits,)) if course.grade not in points: raise ValueError('grade "%s" is not in the grade-point table' % (course.grade,)) p = points[course.grade] if p is None: excluded_credits += course.credits else: quality_points += p * course.credits gpa_credits += course.credits # Validate the mode even when there is nothing to divide. round_div(0, 1, mode) return GpaResult( gpa=None if gpa_credits == 0 else round_div(quality_points, gpa_credits, mode), quality_points=quality_points, gpa_credits=gpa_credits, excluded_credits=excluded_credits, )