-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
8 changed files
with
385 additions
and
317 deletions.
There are no files selected for viewing
This file contains 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
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains 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
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
from abc import ABC | ||
from typing import ClassVar, Generic, TypeVar, Union, Literal, overload | ||
|
||
from pydantic import BaseModel | ||
from questionpy_common.api.attempt import BaseAttempt | ||
from questionpy_common.api.attempt_scored import BaseAttemptScored, ScoreModel, ScoringCode | ||
|
||
from questionpy import Question | ||
from questionpy._util import get_type_arg | ||
|
||
|
||
class BaseAttemptState(BaseModel): | ||
pass | ||
|
||
|
||
_AS = TypeVar("_AS", bound=BaseAttemptState) | ||
|
||
_Q = TypeVar("_Q", bound=Question) | ||
|
||
|
||
class SimpleScore(BaseAttemptScored): | ||
score: Union[None, int, float] | ||
code: ScoringCode | ||
|
||
@overload | ||
def __init__(self, code: Literal[ScoringCode.NEEDS_MANUAL_SCORING, | ||
ScoringCode.RESPONSE_NOT_SCORABLE, ScoringCode.INVALID_RESPONSE]) -> None: | ||
# These codes usually won't have a score associated with them. | ||
pass | ||
|
||
@overload | ||
def __init__(self, score: Union[int, float], | ||
code: Literal[ScoringCode.AUTOMATICALLY_SCORED] = ScoringCode.AUTOMATICALLY_SCORED) -> None: | ||
# AUTOMATICALLY_SCORED without a score makes little sense. | ||
pass | ||
|
||
def __init__(self, score: Union[None, int, float, ScoringCode] = None, | ||
code: Literal[ScoringCode.AUTOMATICALLY_SCORED] = ScoringCode.AUTOMATICALLY_SCORED) -> None: | ||
if isinstance(score, ScoringCode): | ||
self.score = 0 | ||
self.code = code | ||
else: | ||
self.score = score | ||
self.code = code | ||
|
||
def export_scoring_state(self) -> str: | ||
return "" | ||
|
||
def export(self) -> ScoreModel: | ||
return ScoreModel( | ||
scoring_code=self.code, | ||
score=self.score, | ||
classification=() | ||
) | ||
|
||
|
||
class Attempt(BaseAttempt, ABC, Generic[_Q, _AS]): | ||
state_class: ClassVar[type[BaseAttemptState]] = BaseAttemptState | ||
|
||
def __init__(self, question: _Q, attempt_state: _AS) -> None: | ||
self.question = question | ||
self.state = attempt_state | ||
|
||
def export_attempt_state(self) -> str: | ||
return self.state.model_dump_json() | ||
|
||
def __init_subclass__(cls, **kwargs: object) -> None: | ||
super().__init_subclass__(**kwargs) | ||
cls.state_class = get_type_arg(cls, Attempt, 1, bound=BaseAttemptState, default=BaseAttemptState) |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,93 +3,32 @@ | |
# (c) Technische Universität Berlin, innoCampus <[email protected]> | ||
|
||
from abc import ABC | ||
from typing import Optional, Type, Generic, TypeVar, get_args, get_origin, Literal, Union, cast | ||
from typing import Optional, Type, Generic, TypeVar, Union, cast, ClassVar | ||
|
||
from pydantic import BaseModel, ValidationError | ||
from questionpy_common.qtype import OptionsFormDefinition, BaseQuestionType, BaseQuestion, OptionsFormValidationError, \ | ||
BaseAttempt | ||
from questionpy_common.api.attempt import BaseAttempt, ScoreModel | ||
from questionpy_common.api.qtype import BaseQuestionType | ||
from questionpy_common.api.question import BaseQuestion | ||
|
||
from questionpy import get_qpy_environment | ||
from questionpy.form import FormModel | ||
from questionpy.form import FormModel, OptionsFormDefinition | ||
from questionpy._util import get_type_arg | ||
|
||
_T = TypeVar("_T") | ||
_F = TypeVar("_F", bound=FormModel) | ||
_QS = TypeVar("_QS", bound="BaseQuestionState") | ||
_AS = TypeVar("_AS", bound="BaseAttemptState") | ||
_SS = TypeVar("_SS", bound="BaseScoringState") | ||
_A = TypeVar("_A", bound="Attempt") | ||
_Q = TypeVar("_Q", bound="Question") | ||
|
||
|
||
def _get_type_arg(derived: type, generic_base: type, arg_index: int, *, | ||
bound: type = object, default: Union[_T, Literal["nodefault"]] = "nodefault") -> Union[type, _T]: | ||
"""Finds a type arg used by `derived` when inheriting from `generic_base`. | ||
Args: | ||
derived: The type which directly inherits from `generic_base`. | ||
generic_base: One of the direct bases of `derived`. | ||
arg_index: Among the type arguments accepted by `generic_base`, this is the index of the type argument to | ||
return. | ||
bound: Raises :class:`TypeError` if the type argument is not a subclass of this. | ||
default: Returns this when the type argument isn't given. If unset, an error is raised instead. | ||
Raises: | ||
TypeError: Upon any of the following: | ||
- `derived` is not a direct subclass of `generic_base` (transitive subclasses are not supported), | ||
- the type argument is not given and `default` is unset, or | ||
- the type argument is not a subclass of `bound` | ||
""" | ||
# __orig_bases__ is only present when at least one base is a parametrized generic. | ||
# See PEP 560 https://peps.python.org/pep-0560/ | ||
if "__orig_bases__" in derived.__dict__: | ||
bases = derived.__dict__["__orig_bases__"] | ||
else: | ||
bases = derived.__bases__ | ||
|
||
for base in bases: | ||
origin = get_origin(base) or base | ||
if origin is generic_base: | ||
args = get_args(base) | ||
if not args or arg_index >= len(args): | ||
# No type argument provided. | ||
if default == "nodefault": | ||
raise TypeError(f"Missing type argument on {generic_base.__name__} (type arg #{arg_index})") | ||
|
||
return default | ||
|
||
arg = args[arg_index] | ||
if not isinstance(arg, type) or not issubclass(arg, bound): | ||
raise TypeError(f"Type parameter '{arg!r}' of {generic_base.__name__} is not a subclass of " | ||
f"{bound.__name__}") | ||
return arg | ||
|
||
raise TypeError(f"{derived.__name__} is not a direct subclass of {generic_base.__name__}") | ||
|
||
|
||
class BaseAttemptState(BaseModel): | ||
pass | ||
|
||
|
||
class BaseQuestionState(BaseModel, Generic[_F]): | ||
package_name: str | ||
package_version: str | ||
options: _F | ||
|
||
|
||
class Attempt(BaseAttempt, ABC, Generic[_Q, _AS]): | ||
state_class: Type[BaseAttemptState] = BaseAttemptState | ||
|
||
def __init__(self, question: _Q, attempt_state: _AS): | ||
self.question = question | ||
self.state = attempt_state | ||
|
||
def export_attempt_state(self) -> str: | ||
return self.state.model_dump_json() | ||
|
||
def __init_subclass__(cls, **kwargs: object) -> None: | ||
super().__init_subclass__(**kwargs) | ||
cls.state_class = _get_type_arg(cls, Attempt, 1, bound=BaseAttemptState, default=BaseAttemptState) | ||
|
||
|
||
class Question(BaseQuestion, ABC, Generic[_QS, _A]): | ||
state_class: Type[BaseQuestionState] = BaseQuestionState | ||
attempt_class: Type["Attempt"] | ||
|
@@ -113,10 +52,10 @@ def export_question_state(self) -> str: | |
|
||
def __init_subclass__(cls, **kwargs: object) -> None: | ||
super().__init_subclass__(**kwargs) | ||
cls.state_class = _get_type_arg(cls, Question, 0, bound=BaseQuestionState, default=BaseQuestionState) | ||
if _get_type_arg(cls, Question, 0) == BaseQuestionState: | ||
cls.state_class = get_type_arg(cls, Question, 0, bound=BaseQuestionState, default=BaseQuestionState) | ||
if get_type_arg(cls, Question, 0) == BaseQuestionState: | ||
raise TypeError(f"{cls.state_class.__name__} must declare a specific FormModel.") | ||
cls.attempt_class = _get_type_arg(cls, Question, 1, bound=Attempt) | ||
cls.attempt_class = get_type_arg(cls, Question, 1, bound=Attempt) | ||
|
||
|
||
class QuestionType(BaseQuestionType, Generic[_F, _Q]): | ||
|
@@ -155,8 +94,8 @@ def __init__(self, options_class: Optional[Type[_F]] = None, question_class: Opt | |
def __init_subclass__(cls, **kwargs: object) -> None: | ||
super().__init_subclass__(**kwargs) | ||
|
||
cls.options_class = _get_type_arg(cls, QuestionType, 0, bound=FormModel, default=FormModel) | ||
cls.question_class = _get_type_arg(cls, QuestionType, 1, bound=Question) | ||
cls.options_class = get_type_arg(cls, QuestionType, 0, bound=FormModel, default=FormModel) | ||
cls.question_class = get_type_arg(cls, QuestionType, 1, bound=Question) | ||
if cls.options_class != cls.question_class.state_class.model_fields['options'].annotation: | ||
raise TypeError( | ||
f"{cls.__name__} must have the same FormModel as {cls.question_class.state_class.__name__}.") | ||
|
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
from typing import TypeVar, Union, Literal, get_origin, get_args | ||
|
||
_T = TypeVar("_T") | ||
|
||
|
||
def get_type_arg(derived: type, generic_base: type, arg_index: int, *, | ||
bound: type = object, default: Union[_T, Literal["nodefault"]] = "nodefault") -> Union[type, _T]: | ||
"""Finds a type arg used by `derived` when inheriting from `generic_base`. | ||
Args: | ||
derived: The type which directly inherits from `generic_base`. | ||
generic_base: One of the direct bases of `derived`. | ||
arg_index: Among the type arguments accepted by `generic_base`, this is the index of the type argument to | ||
return. | ||
bound: Raises :class:`TypeError` if the type argument is not a subclass of this. | ||
default: Returns this when the type argument isn't given. If unset, an error is raised instead. | ||
Raises: | ||
TypeError: Upon any of the following: | ||
- `derived` is not a direct subclass of `generic_base` (transitive subclasses are not supported), | ||
- the type argument is not given and `default` is unset, or | ||
- the type argument is not a subclass of `bound` | ||
""" | ||
# __orig_bases__ is only present when at least one base is a parametrized generic. | ||
# See PEP 560 https://peps.python.org/pep-0560/ | ||
if "__orig_bases__" in derived.__dict__: | ||
bases = derived.__dict__["__orig_bases__"] | ||
else: | ||
bases = derived.__bases__ | ||
|
||
for base in bases: | ||
origin = get_origin(base) or base | ||
if origin is generic_base: | ||
args = get_args(base) | ||
if not args or arg_index >= len(args): | ||
# No type argument provided. | ||
if default == "nodefault": | ||
raise TypeError(f"Missing type argument on {generic_base.__name__} (type arg #{arg_index})") | ||
|
||
return default | ||
|
||
arg = args[arg_index] | ||
if not isinstance(arg, type) or not issubclass(arg, bound): | ||
raise TypeError(f"Type parameter '{arg!r}' of {generic_base.__name__} is not a subclass of " | ||
f"{bound.__name__}") | ||
return arg | ||
|
||
raise TypeError(f"{derived.__name__} is not a direct subclass of {generic_base.__name__}") |
This file contains 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