Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor exceptions and config to use pydantic and add unit tests #34

Merged
merged 5 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions grader/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os

from dotenv import find_dotenv, load_dotenv
from pydantic import BaseModel, Field


# for local development and unit testing
Expand All @@ -13,10 +14,34 @@


# pylint: disable=too-few-public-methods
class _AGRubric(BaseModel):
"""Private class to strongly type and bound package configuration params."""

INCORRECT_RESPONSE_TYPE_PENALTY_PCT: float = Field(0.10, gt=0.00, le=1.00)
INCORRECT_RESPONSE_VALUE_PENALTY_PCT: float = Field(0.15, gt=0.00, le=1.00)
RESPONSE_FAILED_PENALTY_PCT: float = Field(0.20, gt=0.00, le=1.00)
INVALID_RESPONSE_STRUCTURE_PENALTY_PCT: float = Field(0.30, gt=0.00, le=1.00)
INVALID_JSON_RESPONSE_PENALTY_PCT: float = Field(0.50, gt=0.00, le=1.00)

def __init__(self, **kwargs):
super().__init__(**kwargs)
self.INCORRECT_RESPONSE_TYPE_PENALTY_PCT = float(os.getenv("AG_INCORRECT_RESPONSE_TYPE_PENALTY_PCT", "0.10"))
self.INCORRECT_RESPONSE_VALUE_PENALTY_PCT = float(os.getenv("AG_INCORRECT_RESPONSE_VALUE_PENALTY_PCT", "0.15"))
self.RESPONSE_FAILED_PENALTY_PCT = float(os.getenv("AG_RESPONSE_FAILED_PENALTY_PCT", "0.20"))
self.INVALID_RESPONSE_STRUCTURE_PENALTY_PCT = float(
os.getenv("AG_INVALID_RESPONSE_STRUCTURE_PENALTY_PCT", "0.30")
)
self.INVALID_JSON_RESPONSE_PENALTY_PCT = float(os.getenv("AG_INVALID_JSON_RESPONSE_PENALTY_PCT", "0.50"))


ag_rubric = _AGRubric()


class AGRubric:
"""Constants for the AutomatedGrader class."""
"""Public class to access package configuration params."""

INCORRECT_RESPONSE_TYPE_PENALTY_PCT = float(os.getenv("AG_INCORRECT_RESPONSE_TYPE_PENALTY_PCT", "0.10"))
INCORRECT_RESPONSE_VALUE_PENALTY_PCT = float(os.getenv("AG_INCORRECT_RESPONSE_VALUE_PENALTY_PCT", "0.15"))
RESPONSE_FAILED_PENALTY_PCT = float(os.getenv("AG_RESPONSE_FAILED_PENALTY_PCT", "0.20"))
INVALID_RESPONSE_STRUCTURE_PENALTY_PCT = float(os.getenv("AG_INVALID_RESPONSE_STRUCTURE_PENALTY_PCT", "0.30"))
INCORRECT_RESPONSE_TYPE_PENALTY_PCT = ag_rubric.INCORRECT_RESPONSE_TYPE_PENALTY_PCT
INCORRECT_RESPONSE_VALUE_PENALTY_PCT = ag_rubric.INCORRECT_RESPONSE_VALUE_PENALTY_PCT
RESPONSE_FAILED_PENALTY_PCT = ag_rubric.RESPONSE_FAILED_PENALTY_PCT
INVALID_RESPONSE_STRUCTURE_PENALTY_PCT = ag_rubric.INVALID_RESPONSE_STRUCTURE_PENALTY_PCT
INVALID_JSON_RESPONSE_PENALTY_PCT = ag_rubric.INVALID_JSON_RESPONSE_PENALTY_PCT
56 changes: 36 additions & 20 deletions grader/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,63 +1,79 @@
# -*- coding: utf-8 -*-
"""Custom exceptions for the AutomatedGrader class."""

from pydantic import BaseModel, Field

from .config import AGRubric


class AGExceptionModel(BaseModel):
"""A Pydantic model for the custom exceptions for the AutomatedGrader class."""

message: str = Field(..., description="The message to display to the user.")
penalty_pct: float = Field(
..., description="The percentage of the total grade to deduct for this error.", ge=0, le=1
)


class AGException(Exception):
"""A custom base exception for the AutomatedGrader class."""

def __init__(self, message: str, penalty_pct: float):
self.message = message
message: str
penalty_pct: float

if not isinstance(penalty_pct, float):
raise TypeError(f"penalty_pct must be a float. penalty_pct: {penalty_pct}")
if not 0 <= penalty_pct <= 1:
raise ValueError(f"penalty_pct must be between 0 and 1. penalty_pct: {penalty_pct}")
def __init__(self, ag_exception_model: AGExceptionModel):
super().__init__(ag_exception_model.message)
self.message = ag_exception_model.message
self.penalty_pct = ag_exception_model.penalty_pct

self._penalty_pct = penalty_pct
super().__init__(self.message)

@property
def penalty_pct(self):
"""Return the penalty percentage for this error."""
return self._penalty_pct
class InvalidJSONResponseError(AGException):
"""A custom exception for the AutomatedGrader class."""

def __init__(self, message):
ag_exception_model = AGExceptionModel(message=message, penalty_pct=AGRubric.INVALID_JSON_RESPONSE_PENALTY_PCT)
super().__init__(ag_exception_model)


class InvalidResponseStructureError(AGException):
"""A custom exception for the AutomatedGrader class."""

def __init__(self, message):
self.message = message
super().__init__(self.message, penalty_pct=AGRubric.INVALID_RESPONSE_STRUCTURE_PENALTY_PCT)
ag_exception_model = AGExceptionModel(
message=message, penalty_pct=AGRubric.INVALID_RESPONSE_STRUCTURE_PENALTY_PCT
)
super().__init__(ag_exception_model)


class IncorrectResponseValueError(AGException):
"""A custom exception for the AutomatedGrader class."""

def __init__(self, message):
self.message = message
super().__init__(self.message, penalty_pct=AGRubric.INCORRECT_RESPONSE_VALUE_PENALTY_PCT)
ag_exception_model = AGExceptionModel(
message=message, penalty_pct=AGRubric.INCORRECT_RESPONSE_VALUE_PENALTY_PCT
)
super().__init__(ag_exception_model)


class IncorrectResponseTypeError(AGException):
"""A custom exception for the AutomatedGrader class."""

def __init__(self, message):
self.message = message
super().__init__(self.message, penalty_pct=AGRubric.INCORRECT_RESPONSE_TYPE_PENALTY_PCT)
ag_exception_model = AGExceptionModel(message=message, penalty_pct=AGRubric.INCORRECT_RESPONSE_TYPE_PENALTY_PCT)
super().__init__(ag_exception_model)


class ResponseFailedError(AGException):
"""A custom exception for the AutomatedGrader class."""

def __init__(self, message):
self.message = message
super().__init__(self.message, penalty_pct=AGRubric.RESPONSE_FAILED_PENALTY_PCT)
ag_exception_model = AGExceptionModel(message=message, penalty_pct=AGRubric.RESPONSE_FAILED_PENALTY_PCT)
super().__init__(ag_exception_model)


VALID_MESSAGE_TYPES = [
"Success",
InvalidJSONResponseError.__name__,
IncorrectResponseTypeError.__name__,
IncorrectResponseValueError.__name__,
InvalidResponseStructureError.__name__,
Expand Down
5 changes: 3 additions & 2 deletions grader/grader.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
AGException,
IncorrectResponseTypeError,
IncorrectResponseValueError,
InvalidJSONResponseError,
InvalidResponseStructureError,
ResponseFailedError,
)
Expand Down Expand Up @@ -72,8 +73,8 @@ def grade(self):
assignment_json = json.loads(self.assignment)
except json.JSONDecodeError as e:
try:
raise InvalidResponseStructureError("The assignment is not valid JSON") from e
except InvalidResponseStructureError as reraised_e:
raise InvalidJSONResponseError("The assignment is not valid JSON") from e
except InvalidJSONResponseError as reraised_e:
return self.grade_response(reraised_e)

# 2.) attempt to validate the assignment using Pydantic
Expand Down
99 changes: 99 additions & 0 deletions grader/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# flake8: noqa: F401
# pylint: disable=duplicate-code
"""
Test integrity of configuration variables.
"""
import pytest # pylint: disable=unused-import

from ..config import AGRubric, _AGRubric


class TestConfig:
"""Test integrity of configuration variables."""

def test_configuration_data_types(self):
"""Test the data types of the configuration variables."""
assert isinstance(
AGRubric.INCORRECT_RESPONSE_TYPE_PENALTY_PCT, float
), "INCORRECT_RESPONSE_TYPE_PENALTY_PCT is not a float"
assert isinstance(
AGRubric.INCORRECT_RESPONSE_VALUE_PENALTY_PCT, float
), "INCORRECT_RESPONSE_VALUE_PENALTY_PCT is not a float"
assert isinstance(AGRubric.RESPONSE_FAILED_PENALTY_PCT, float), "RESPONSE_FAILED_PENALTY_PCT is not a float"
assert isinstance(
AGRubric.INVALID_RESPONSE_STRUCTURE_PENALTY_PCT, float
), "INVALID_RESPONSE_STRUCTURE_PENALTY_PCT is not a float"
assert isinstance(
AGRubric.INVALID_JSON_RESPONSE_PENALTY_PCT, float
), "INVALID_JSON_RESPONSE_PENALTY_PCT is not a float"

def test_configuration_values(self):
"""Test the values of the configuration variables."""
assert (
AGRubric.INCORRECT_RESPONSE_TYPE_PENALTY_PCT <= 1.00
), "INCORRECT_RESPONSE_TYPE_PENALTY_PCT is greater than 1.00"
assert (
AGRubric.INCORRECT_RESPONSE_TYPE_PENALTY_PCT >= 0.00
), "INCORRECT_RESPONSE_TYPE_PENALTY_PCT is less than 0.00"

assert (
AGRubric.INCORRECT_RESPONSE_VALUE_PENALTY_PCT <= 1.00
), "INCORRECT_RESPONSE_VALUE_PENALTY_PCT is greater than 1.00"
assert (
AGRubric.INCORRECT_RESPONSE_VALUE_PENALTY_PCT >= 0.00
), "INCORRECT_RESPONSE_VALUE_PENALTY_PCT is less than 0.00"

assert AGRubric.RESPONSE_FAILED_PENALTY_PCT <= 1.00, "RESPONSE_FAILED_PENALTY_PCT is greater than 1.00"
assert AGRubric.RESPONSE_FAILED_PENALTY_PCT >= 0.00, "RESPONSE_FAILED_PENALTY_PCT is less than 0.00"

assert (
AGRubric.INVALID_RESPONSE_STRUCTURE_PENALTY_PCT <= 1.00
), "INVALID_RESPONSE_STRUCTURE_PENALTY_PCT is greater than 1.00"
assert (
AGRubric.INVALID_RESPONSE_STRUCTURE_PENALTY_PCT >= 0.00
), "INVALID_RESPONSE_STRUCTURE_PENALTY_PCT is less than 0.00"

assert (
AGRubric.INVALID_JSON_RESPONSE_PENALTY_PCT <= 1.00
), "INVALID_JSON_RESPONSE_PENALTY_PCT is greater than 1.00"
assert AGRubric.INVALID_JSON_RESPONSE_PENALTY_PCT >= 0.00, "INVALID_JSON_RESPONSE_PENALTY_PCT is less than 0.00"

def test_illegal_config_values_low(self):
"""Test illegal configuration values (low)."""
with pytest.raises(ValueError):
_AGRubric(INCORRECT_RESPONSE_TYPE_PENALTY_PCT=-0.01)
with pytest.raises(ValueError):
_AGRubric(INCORRECT_RESPONSE_VALUE_PENALTY_PCT=-0.01)
with pytest.raises(ValueError):
_AGRubric(RESPONSE_FAILED_PENALTY_PCT=-0.01)
with pytest.raises(ValueError):
_AGRubric(INVALID_RESPONSE_STRUCTURE_PENALTY_PCT=-0.01)
with pytest.raises(ValueError):
_AGRubric(INVALID_JSON_RESPONSE_PENALTY_PCT=-0.01)

def test_illegal_config_values_high(self):
"""Test illegal configuration values (high)."""
with pytest.raises(ValueError):
_AGRubric(INCORRECT_RESPONSE_TYPE_PENALTY_PCT=1.01)
with pytest.raises(ValueError):
_AGRubric(INCORRECT_RESPONSE_VALUE_PENALTY_PCT=1.01)
with pytest.raises(ValueError):
_AGRubric(RESPONSE_FAILED_PENALTY_PCT=1.01)
with pytest.raises(ValueError):
_AGRubric(INVALID_RESPONSE_STRUCTURE_PENALTY_PCT=1.01)
with pytest.raises(ValueError):
_AGRubric(INVALID_JSON_RESPONSE_PENALTY_PCT=1.01)

def test_illegal_config_values_bad_data_type(self):
"""Test illegal configuration values (bad data type)."""
with pytest.raises(ValueError):
_AGRubric(INCORRECT_RESPONSE_TYPE_PENALTY_PCT="X0.01")
with pytest.raises(ValueError):
_AGRubric(INCORRECT_RESPONSE_VALUE_PENALTY_PCT="BAD DATA")
with pytest.raises(ValueError):
_AGRubric(RESPONSE_FAILED_PENALTY_PCT="TRUE")
with pytest.raises(ValueError):
_AGRubric(INVALID_RESPONSE_STRUCTURE_PENALTY_PCT="_0.01_")
with pytest.raises(ValueError):
_AGRubric(INVALID_JSON_RESPONSE_PENALTY_PCT="#0.01")
54 changes: 54 additions & 0 deletions grader/tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# flake8: noqa: F401
# pylint: disable=duplicate-code
"""
Test integrity of exception classes.
"""
import pytest # pylint: disable=unused-import

from ..exceptions import (
VALID_MESSAGE_TYPES,
AGException,
IncorrectResponseTypeError,
IncorrectResponseValueError,
InvalidJSONResponseError,
InvalidResponseStructureError,
ResponseFailedError,
)


class TestExceptions:
"""Test integrity of exception classes."""

def eval_exception_class(self, exception_class):
"""Evaluate an exception class."""
try:
raise exception_class("This is an exception")
except exception_class as err:
assert issubclass(
exception_class, AGException
), "IncorrectResponseTypeError is not a subclass of AGException"
assert err.penalty_pct <= 1.00, "Penalty percentage is greater than 1.00"
assert err.penalty_pct >= 0.00, "Penalty percentage is less than 0.00"
assert err.message == "This is an exception", "Incorrect message"
assert exception_class.__name__ in VALID_MESSAGE_TYPES, "Invalid message type"

def test_incorrect_response_type(self):
"""Test an incorrect response type."""
self.eval_exception_class(IncorrectResponseTypeError)

def test_incorrect_response_value(self):
"""Test an incorrect response value."""
self.eval_exception_class(IncorrectResponseValueError)

def test_invalid_json_response(self):
"""Test an invalid JSON response."""
self.eval_exception_class(InvalidJSONResponseError)

def test_invalid_response_structure(self):
"""Test an invalid response structure."""
self.eval_exception_class(InvalidResponseStructureError)

def test_response_failed(self):
"""Test a failed response."""
self.eval_exception_class(ResponseFailedError)
Loading
Loading