Skip to content

Commit

Permalink
feat!: implement server-package API changes
Browse files Browse the repository at this point in the history
  • Loading branch information
MHajoha committed Jun 7, 2024
1 parent a50b6b3 commit c0926ab
Show file tree
Hide file tree
Showing 14 changed files with 219 additions and 180 deletions.
188 changes: 94 additions & 94 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description = "QuestionPy application server"
authors = ["Technische Universität Berlin, innoCampus <[email protected]>"]
license = "MIT"
homepage = "https://questionpy.org"
version = "0.2.4"
version = "0.3.0"
packages = [
{ include = "questionpy_common" },
{ include = "questionpy_server" }
Expand Down
12 changes: 12 additions & 0 deletions questionpy_common/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# This file is part of QuestionPy. (https://questionpy.org)
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>
from collections.abc import Mapping, Sequence
from typing import TypeAlias

from pydantic import BaseModel


class Localized(BaseModel):
lang: str


PlainValue: TypeAlias = None | int | str | bool | Sequence["PlainValue"] | "PlainMapping"

Check failure on line 14 in questionpy_common/api/__init__.py

View workflow job for this annotation

GitHub Actions / ci / ruff-lint

Ruff (TCH010)

questionpy_common/api/__init__.py:14:76: TCH010 Invalid string member in `X | Y`-style union type
PlainMapping: TypeAlias = Mapping[str, "PlainValue"]
59 changes: 37 additions & 22 deletions questionpy_common/api/attempt.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
# (c) Technische Universität Berlin, innoCampus <[email protected]>

from abc import ABC, abstractmethod

Check failure on line 5 in questionpy_common/api/attempt.py

View workflow job for this annotation

GitHub Actions / ci / ruff-lint

Ruff (F401)

questionpy_common/api/attempt.py:5:17: F401 `abc.ABC` imported but unused

Check failure on line 5 in questionpy_common/api/attempt.py

View workflow job for this annotation

GitHub Actions / ci / ruff-lint

Ruff (F401)

questionpy_common/api/attempt.py:5:22: F401 `abc.abstractmethod` imported but unused
from collections.abc import Sequence
from enum import Enum
from typing import Annotated

from pydantic import BaseModel, Field

from . import Localized

__all__ = [
"AttemptFile",
"AttemptModel",
"AttemptScoredModel",
"AttemptStartedModel",
"AttemptUi",
"BaseAttempt",

Check failure on line 19 in questionpy_common/api/attempt.py

View workflow job for this annotation

GitHub Actions / ci / ruff-lint

Ruff (F822)

questionpy_common/api/attempt.py:19:5: F822 Undefined name `BaseAttempt` in `__all__`
"CacheControl",
Expand Down Expand Up @@ -51,11 +53,15 @@ class AttemptUi(BaseModel):
cache_control: CacheControl = CacheControl.PRIVATE_CACHE


class AttemptModel(BaseModel):
class AttemptModel(Localized):
variant: int
ui: AttemptUi


class AttemptStartedModel(AttemptModel):
attempt_state: str


class ScoringCode(Enum):
AUTOMATICALLY_SCORED = "AUTOMATICALLY_SCORED"
NEEDS_MANUAL_SCORING = "NEEDS_MANUAL_SCORING"
Expand All @@ -70,31 +76,40 @@ class ClassifiedResponse(BaseModel):
score: float


class ScoredInputState(Enum):
CORRECT = "CORRECT"
CONSEQUENTIAL_SCORE = "CONSEQUENTIAL_SCORE"
PARTIALLY_CORRECT = "PARTIALLY_CORRECT"
WRONG = "WRONG"


class ScoredInputModel(BaseModel):
state: ScoredInputState
score: float | None = None
score_max: float | None = None
specific_feedback: str | None = None
right_answer: str | None = None


class ScoredSubquestionModel(BaseModel):
score: float | None = None
score_final: float | None = None
scoring_code: ScoringCode | None = None
response_summary: str
response_class: str


class ScoreModel(BaseModel):
scoring_state: str = "{}"
scoring_state: str
scoring_code: ScoringCode
score: float | None
"""The total score for this question attempt, as a fraction of the default mark set by the LMS."""
classification: Sequence[ClassifiedResponse] | None = None
score_final: float | None
scored_inputs: dict[str, ScoredInputModel] = {}
"""Maps input names to their individual scores."""
scored_subquestions: dict[str, ScoredSubquestionModel] = {}
"""Maps subquestion IDs to their individual scores."""


class AttemptScoredModel(AttemptModel, ScoreModel):
pass


class BaseAttempt(ABC):
@abstractmethod
def export_attempt_state(self) -> str:
"""Serialize this attempt's relevant data.
A future call to :meth:`BaseQuestion.view_attempt` should result in an attempt object identical to the one which
exported the state.
"""

@abstractmethod
def export(self) -> AttemptModel:
"""Get metadata about this attempt."""

@abstractmethod
def export_scored_attempt(self) -> AttemptScoredModel:
"""Score this attempt."""
15 changes: 15 additions & 0 deletions questionpy_common/api/package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# This file is part of QuestionPy. (https://questionpy.org)
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>
from typing import Protocol

from questionpy_common.manifest import PackageFile


class QPyPackageInterface(Protocol):
def get_static_files(self) -> dict[str, PackageFile]:
pass


class QPyLibraryInterface(Protocol, QPyPackageInterface):
pass
15 changes: 8 additions & 7 deletions questionpy_common/api/qtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>

from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Protocol

from questionpy_common.elements import OptionsFormDefinition

from .question import BaseQuestion
from .question import QuestionInterface

__all__ = ["BaseQuestionType", "InvalidQuestionStateError", "OptionsFormValidationError"]
__all__ = ["InvalidQuestionStateError", "OptionsFormValidationError", "QuestionTypeInterface"]


class BaseQuestionType(ABC):
class QuestionTypeInterface(Protocol):
@abstractmethod
def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefinition, dict[str, object]]:
def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefinition, PlainMapping]:

Check failure on line 17 in questionpy_common/api/qtype.py

View workflow job for this annotation

GitHub Actions / ci / ruff-lint

Ruff (F821)

questionpy_common/api/qtype.py:17:92: F821 Undefined name `PlainMapping`
"""Get the form used to create a new or edit an existing question.
Args:
Expand All @@ -24,7 +25,7 @@ def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefin
"""

@abstractmethod
def create_question_from_options(self, old_state: str | None, form_data: dict[str, object]) -> BaseQuestion:
def create_question_from_options(self, old_state: str | None, form_data: PlainMapping) -> QuestionInterface:

Check failure on line 28 in questionpy_common/api/qtype.py

View workflow job for this annotation

GitHub Actions / ci / ruff-lint

Ruff (F821)

questionpy_common/api/qtype.py:28:78: F821 Undefined name `PlainMapping`
"""Create or update the question (state) with the form data from a submitted question edit form.
Args:
Expand All @@ -39,7 +40,7 @@ def create_question_from_options(self, old_state: str | None, form_data: dict[st
"""

@abstractmethod
def create_question_from_state(self, question_state: str) -> BaseQuestion:
def create_question_from_state(self, question_state: str) -> QuestionInterface:
"""Deserialize the given question state, returning a question object equivalent to the one which exported it.
Raises:
Expand Down
43 changes: 30 additions & 13 deletions questionpy_common/api/question.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# This file is part of QuestionPy. (https://questionpy.org)
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>
from abc import ABC, abstractmethod
from abc import abstractmethod
from enum import Enum
from typing import Annotated
from typing import Annotated, Protocol

from pydantic import BaseModel, Field

from .attempt import BaseAttempt
from . import Localized
from .attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel

__all__ = ["BaseQuestion", "PossibleResponse", "QuestionModel", "ScoringMethod", "SubquestionModel"]
__all__ = ["PossibleResponse", "QuestionInterface", "QuestionModel", "ScoringMethod", "SubquestionModel"]


class ScoringMethod(Enum):
Expand All @@ -29,7 +30,7 @@ class SubquestionModel(BaseModel):
response_classes: list[PossibleResponse] | None


class QuestionModel(BaseModel):
class QuestionModel(Localized):
num_variants: Annotated[int, Field(ge=1, strict=True)] = 1
score_min: float = 0
"""Lowest score used by this question, as a fraction of the default mark set by the LMS."""
Expand All @@ -43,9 +44,9 @@ class QuestionModel(BaseModel):
subquestions: list[SubquestionModel] | None = None


class BaseQuestion(ABC):
class QuestionInterface(Protocol):
@abstractmethod
def start_attempt(self, variant: int) -> BaseAttempt:
def start_attempt(self, variant: int) -> AttemptStartedModel:
"""Start an attempt at this question with the given variant.
Args:
Expand All @@ -57,23 +58,39 @@ def start_attempt(self, variant: int) -> BaseAttempt:

@abstractmethod
def get_attempt(
self, attempt_state: str, scoring_state: str | None = None, response: dict | None = None
) -> AttemptModel:
"""Create an attempt object for a previously started attempt.
Args:
attempt_state: The `attempt_state` attribute of an attempt which was previously returned by
:meth:`start_attempt`.
scoring_state: Not implemented.
response: The response currently entered by the student.
Returns:
A :class:`BaseAttempt` object which should be identical to the one which generated the given state(s).
"""

@abstractmethod
def score_attempt(
self,
attempt_state: str,
scoring_state: str | None = None,
response: dict | None = None,
*,
compute_score: bool = False,
generate_hint: bool = False,
) -> BaseAttempt:
try_scoring_with_countback: bool = False,
try_giving_hint: bool = False,
) -> AttemptScoredModel:
"""Create an attempt object for a previously started attempt.
Args:
attempt_state: The `attempt_state` attribute of an attempt which was previously returned by
:meth:`start_attempt`.
scoring_state: Not implemented.
response: The response currently entered by the student.
compute_score: Whether the attempt is retrieved to be scored.
generate_hint: Whether the package should generate a hint for the student.
try_scoring_with_countback: TBD
try_giving_hint: TBD
Returns:
A :class:`BaseAttempt` object which should be identical to the one which generated the given state(s).
Expand All @@ -83,7 +100,7 @@ def get_attempt(
def export_question_state(self) -> str:
"""Serialize this question's relevant data.
A future call to :meth:`BaseQuestionType.create_question_from_state` should result in a question object
A future call to :meth:`QuestionTypeInterface.create_question_from_state` should result in a question object
identical to the one which exported the state.
"""

Expand Down
4 changes: 2 additions & 2 deletions questionpy_common/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from importlib.resources.abc import Traversable
from typing import Protocol, TypeAlias

from questionpy_common.api.qtype import BaseQuestionType
from questionpy_common.api.qtype import QuestionTypeInterface
from questionpy_common.manifest import Manifest

__all__ = [
Expand Down Expand Up @@ -93,7 +93,7 @@ def register_on_request_callback(self, callback: OnRequestCallback) -> None:
"""


PackageInitFunction: TypeAlias = Callable[[Environment], BaseQuestionType] | Callable[[], BaseQuestionType]
PackageInitFunction: TypeAlias = Callable[[Environment], QuestionTypeInterface] | Callable[[], QuestionTypeInterface]
"""Signature of the "init"-function expected in the main package."""

_current_env: ContextVar[Environment | None] = ContextVar("_current_env")
Expand Down
4 changes: 0 additions & 4 deletions questionpy_server/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,6 @@ class AttemptStartArguments(RequestBaseData):
variant: Annotated[int, Field(ge=1, strict=True)]


class AttemptStarted(AttemptModel):
attempt_state: str


class AttemptViewArguments(RequestBaseData):
attempt_state: str
scoring_state: str | None = None
Expand Down
31 changes: 8 additions & 23 deletions questionpy_server/worker/runtime/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from dataclasses import dataclass
from typing import Any, NoReturn

from questionpy_common.api.qtype import BaseQuestionType
from questionpy_common.api.qtype import QuestionTypeInterface

Check failure on line 12 in questionpy_server/worker/runtime/manager.py

View workflow job for this annotation

GitHub Actions / ci / ruff-lint

Ruff (TCH001)

questionpy_server/worker/runtime/manager.py:12:41: TCH001 Move application import `questionpy_common.api.qtype.QuestionTypeInterface` into a type-checking block
from questionpy_common.environment import (
Environment,
OnRequestCallback,
Expand Down Expand Up @@ -56,7 +56,7 @@ def __init__(self, server_connection: WorkerToServerConnection):
self.limits: WorkerResourceLimits | None = None
self.loaded_packages: dict[str, ImportablePackage] = {}
self.main_package: ImportablePackage | None = None
self.question_type: BaseQuestionType | None = None
self.question_type: QuestionTypeInterface | None = None
self.message_dispatch: dict[MessageIds, Callable[[Any], MessageToServer]] = {
LoadQPyPackage.message_id: self.on_msg_load_qpy_package,
GetQPyPackageManifest.message_id: self.on_msg_get_qpy_package_manifest,
Expand Down Expand Up @@ -112,9 +112,6 @@ def on_msg_load_qpy_package(self, msg: LoadQPyPackage) -> MessageToServer:
_on_request_callbacks=self._on_request_callbacks,
)
)
if not isinstance(qtype, BaseQuestionType):
msg = f"Package initialization returned '{qtype}', BaseQuestionType expected"
raise PackageInitFailedError(msg)

self.main_package = package
self.question_type = qtype
Expand Down Expand Up @@ -160,8 +157,8 @@ def on_msg_start_attempt(self, msg: StartAttempt) -> StartAttempt.Response:

with self._with_request_user(msg.request_user):
question = self.question_type.create_question_from_state(msg.question_state)
attempt = question.start_attempt(msg.variant)
return StartAttempt.Response(attempt_state=attempt.export_attempt_state(), attempt_model=attempt.export())
attempt_started_model = question.start_attempt(msg.variant)
return StartAttempt.Response(attempt_started_model=attempt_started_model)

def on_msg_view_attempt(self, msg: ViewAttempt) -> ViewAttempt.Response:
if not self.worker_type:
Expand All @@ -171,17 +168,8 @@ def on_msg_view_attempt(self, msg: ViewAttempt) -> ViewAttempt.Response:

with self._with_request_user(msg.request_user):
question = self.question_type.create_question_from_state(msg.question_state)
attempt = question.get_attempt(
msg.attempt_state, msg.scoring_state, msg.response, compute_score=False, generate_hint=False
)
if __debug__ and msg.attempt_state != attempt.export_attempt_state():
warnings.warn(
"The attempt state has been changed by viewing the attempt, which has no effect and "
"should not happen.",
stacklevel=2,
)

return ViewAttempt.Response(attempt_model=attempt.export())
attempt_model = question.get_attempt(msg.attempt_state, msg.scoring_state, msg.response)
return ViewAttempt.Response(attempt_model=attempt_model)

def on_msg_score_attempt(self, msg: ScoreAttempt) -> ScoreAttempt.Response:
if not self.worker_type:
Expand All @@ -191,11 +179,8 @@ def on_msg_score_attempt(self, msg: ScoreAttempt) -> ScoreAttempt.Response:

with self._with_request_user(msg.request_user):
question = self.question_type.create_question_from_state(msg.question_state)
attempt = question.get_attempt(
msg.attempt_state, msg.scoring_state, msg.response, compute_score=True, generate_hint=False
)
scored_model = attempt.export_scored_attempt()
return ScoreAttempt.Response(attempt_scored_model=scored_model)
attempt_scored_model = question.score_attempt(msg.attempt_state, msg.scoring_state, msg.response)
return ScoreAttempt.Response(attempt_scored_model=attempt_scored_model)

@staticmethod
def _raise_not_initialized(msg: MessageToWorker) -> NoReturn:
Expand Down
5 changes: 2 additions & 3 deletions questionpy_server/worker/runtime/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from pydantic import BaseModel

from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel
from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel
from questionpy_common.api.qtype import InvalidQuestionStateError, OptionsFormDefinition
from questionpy_common.api.question import QuestionModel
from questionpy_common.environment import RequestUser, WorkerResourceLimits
Expand Down Expand Up @@ -164,8 +164,7 @@ class StartAttempt(MessageToWorker):

class Response(MessageToServer):
message_id: ClassVar[MessageIds] = MessageIds.RETURN_START_ATTEMPT
attempt_state: str
attempt_model: AttemptModel
attempt_started_model: AttemptStartedModel


class ViewAttempt(MessageToWorker):
Expand Down
Loading

0 comments on commit c0926ab

Please sign in to comment.