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 5a5b281
Show file tree
Hide file tree
Showing 10 changed files with 198 additions and 127 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
5 changes: 5 additions & 0 deletions questionpy_common/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# 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 pydantic import BaseModel


class Localized(BaseModel):
lang: str
41 changes: 37 additions & 4 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
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",
"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,12 +76,39 @@ 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_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):
Expand All @@ -87,7 +120,7 @@ class BaseAttempt(ABC):
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
A future call to :meth:`QuestionInterface.view_attempt` should result in an attempt object identical to the one which

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

View workflow job for this annotation

GitHub Actions / ci / ruff-lint

Ruff (E501)

questionpy_common/api/attempt.py:123:121: E501 Line too long (125 > 120)
exported the state.
"""

Expand Down
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
13 changes: 7 additions & 6 deletions questionpy_common/api/qtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
# 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]]:
"""Get the form used to create a new or edit an existing question.
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: dict[str, object]) -> QuestionInterface:
"""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,
try_giving_hint: bool,
) -> 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
8 changes: 4 additions & 4 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
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,8 +112,8 @@ 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"
if not isinstance(qtype, QuestionTypeInterface):
msg = f"Package initialization returned '{qtype}', QuestionTypeInterface expected"
raise PackageInitFailedError(msg)

self.main_package = package
Expand Down
6 changes: 3 additions & 3 deletions questionpy_server/worker/runtime/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from typing import cast
from zipfile import ZipFile

from questionpy_common.api.qtype import BaseQuestionType
from questionpy_common.api.qtype import QuestionTypeInterface
from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME
from questionpy_common.environment import Environment, Package, set_qpy_environment
from questionpy_common.manifest import Manifest
Expand All @@ -38,7 +38,7 @@ class ImportablePackage(ABC, Package):
def setup_imports(self) -> None:
"""Modifies ``sys.path`` to include the package's python code."""

def init_as_main(self, env: Environment) -> BaseQuestionType:
def init_as_main(self, env: Environment) -> QuestionTypeInterface:
"""Imports the package's entrypoint and executes its ``init`` function.
:meth:`setup_imports` should be called beforehand to allow the import.
Expand Down Expand Up @@ -144,7 +144,7 @@ def setup_imports(self) -> None:
# Nothing to do.
pass

def init_as_main(self, env: Environment) -> BaseQuestionType:
def init_as_main(self, env: Environment) -> QuestionTypeInterface:
set_qpy_environment(env)

main_module = import_module(self.module_name)
Expand Down

0 comments on commit 5a5b281

Please sign in to comment.