Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
MHajoha committed Dec 5, 2023
1 parent f02a6f1 commit 02375e7
Show file tree
Hide file tree
Showing 8 changed files with 385 additions and 317 deletions.
20 changes: 17 additions & 3 deletions example/python/local/example/question_type.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
from importlib.resources import files

from questionpy_common.models import AttemptModel, AttemptUi, QuestionModel, ScoringMethod
from questionpy_common.api.attempt import AttemptUi, AttemptModel
from questionpy_common.api.attempt_scored import BaseAttemptScored, ScoringCode
from questionpy_common.api.question import QuestionModel, ScoringMethod

from questionpy import Attempt, BaseAttemptState, Question, BaseQuestionState, QuestionType
from questionpy import Attempt, BaseAttemptState, Question, BaseQuestionState, QuestionType, SimpleScore
from .form import MyModel


class ExampleAttempt(Attempt["ExampleQuestion", BaseAttemptState]):
def score_attempt(self, response: dict) -> BaseAttemptScored:
if "choice" not in response:
return SimpleScore(ScoringCode.RESPONSE_NOT_SCORABLE)

if response["choice"] == "B":
return SimpleScore(1)

return SimpleScore(0)

def export(self) -> AttemptModel:
return AttemptModel(variant=1, ui=AttemptUi(
content=(files(__package__) / "multiple-choice.xhtml").read_text()
content=(files(__package__) / "multiple-choice.xhtml").read_text(),
placeholders={
"description": "Welcher ist der zweite Buchstabe im deutschen Alphabet?"
}
))


Expand Down
470 changes: 233 additions & 237 deletions poetry.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ python = "^3.9"
aiohttp = "^3.8.1"
pydantic = "^2.4"
PyYAML = "^6.0"
questionpy-common = { git = "https://github.com/questionpy-org/questionpy-common.git", rev = "885f7a2b3333eb3c7143c270873b79810eb410d6" }
questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "35fa36b04a6a2a81fafb33ceacd673e15784561e" }
questionpy-common = { path = "../questionpy-common", develop = true }
questionpy-server = { path = "../questionpy-server", develop = true }
jinja2 = "^3.1.2"
aiohttp-jinja2 = "^1.5"

Expand Down
3 changes: 2 additions & 1 deletion questionpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
from questionpy_common.manifest import Manifest, PackageType # noqa
from questionpy_common.environment import RequestUser, WorkerResourceLimits, Package, OnRequestCallback, Environment, \
PackageInitFunction, get_qpy_environment, NoEnvironmentError # noqa
from questionpy._qtype import QuestionType, Question, Attempt, BaseQuestionState, BaseAttemptState # noqa
from questionpy._qtype import QuestionType, Question, Attempt, AttemptScored, BaseQuestionState, BaseAttemptState, \
BaseScoringState, SimpleScore # noqa
69 changes: 69 additions & 0 deletions questionpy/_attempt.py
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)
85 changes: 12 additions & 73 deletions questionpy/_qtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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]):
Expand Down Expand Up @@ -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__}.")
Expand Down
48 changes: 48 additions & 0 deletions questionpy/_util.py
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__}")
3 changes: 2 additions & 1 deletion tests/test_qtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
from typing import Optional, cast

import pytest
from questionpy_common.api.attempt import AttemptModel, AttemptUi
from questionpy_common.api.question import QuestionModel, ScoringMethod
from questionpy_common.environment import set_qpy_environment
from questionpy_common.models import QuestionModel, ScoringMethod, AttemptModel, AttemptUi
from questionpy_server.worker.runtime.manager import EnvironmentImpl
from questionpy_server.worker.runtime.package import QPyMainPackage

Expand Down

0 comments on commit 02375e7

Please sign in to comment.