Skip to content

Commit fd510dd

Browse files
committed
refactor: define protocols for started and scored attempts
1 parent 812a8e3 commit fd510dd

File tree

3 files changed

+117
-26
lines changed

3 files changed

+117
-26
lines changed

questionpy/_attempt.py

+76-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from abc import ABC, abstractmethod
2-
from collections.abc import Sequence
2+
from collections.abc import Mapping, Sequence
33
from functools import cached_property
4-
from typing import TYPE_CHECKING, ClassVar
4+
from typing import TYPE_CHECKING, ClassVar, Protocol
55

66
import jinja2
77
from pydantic import BaseModel, JsonValue
@@ -60,6 +60,80 @@ def _merge_uis(
6060
)
6161

6262

63+
class AttemptProtocol(Protocol):
64+
"""Defines the properties and methods an attempt must always contain."""
65+
66+
@property
67+
def cache_control(self) -> CacheControl:
68+
pass
69+
70+
@property
71+
def placeholders(self) -> dict[str, str]:
72+
pass
73+
74+
@property
75+
def css_files(self) -> list[str]:
76+
pass
77+
78+
@property
79+
def files(self) -> dict[str, AttemptFile]:
80+
pass
81+
82+
@property
83+
def variant(self) -> int:
84+
pass
85+
86+
@property
87+
def formulation(self) -> str:
88+
pass
89+
90+
@property
91+
def general_feedback(self) -> str | None:
92+
pass
93+
94+
@property
95+
def specific_feedback(self) -> str | None:
96+
pass
97+
98+
@property
99+
def right_answer_description(self) -> str | None:
100+
pass
101+
102+
103+
class AttemptStartedProtocol(AttemptProtocol, Protocol):
104+
"""In addition to [AttemptProtocol][], defines that a newly started attempt must provide its attempt state.
105+
106+
The attempt state is only generated at attempt start and immutable afterwards, so it must only be defined on the
107+
object returned by [Question.start_attempt][].
108+
"""
109+
110+
def to_plain_attempt_state(self) -> dict[str, JsonValue]:
111+
"""Return a jsonable representation of this attempt's state."""
112+
113+
114+
class AttemptScoredProtocol(AttemptProtocol, Protocol):
115+
"""In addition to [AttemptProtocol][], defines properties and methods which must be set after scoring."""
116+
117+
@property
118+
def scoring_code(self) -> ScoringCode:
119+
pass
120+
121+
@property
122+
def scored_inputs(self) -> Mapping[str, ScoredInputModel]:
123+
pass
124+
125+
@property
126+
def score(self) -> float | None:
127+
pass
128+
129+
@property
130+
def score_final(self) -> float | None:
131+
pass
132+
133+
def to_plain_scoring_state(self) -> Mapping[str, JsonValue] | None:
134+
"""Return a jsonable representation of this attempt's scoring state, if any."""
135+
136+
63137
class Attempt(ABC):
64138
attempt_state: BaseAttemptState
65139
scoring_state: BaseScoringState | None

questionpy/_qtype.py

+17-4
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
33
# (c) Technische Universität Berlin, innoCampus <[email protected]>
44
from abc import ABC
5-
from typing import ClassVar, Generic, Self, TypeVar
5+
from typing import ClassVar, Generic, Self, TypeVar, cast
66

77
from pydantic import BaseModel, JsonValue, ValidationError
88

99
from questionpy_common.api.qtype import InvalidQuestionStateError, OptionsFormValidationError
1010
from questionpy_common.api.question import ScoringMethod, SubquestionModel
1111
from questionpy_common.environment import get_qpy_environment
1212

13-
from ._attempt import Attempt
13+
from ._attempt import Attempt, AttemptProtocol, AttemptScoredProtocol, AttemptStartedProtocol
1414
from ._util import get_mro_type_hint
1515
from .form import FormModel, OptionsFormDefinition
1616

@@ -126,7 +126,7 @@ def get_options_form(self) -> tuple[OptionsFormDefinition, dict[str, JsonValue]]
126126
"""Return the options form and field values for viewing or editing this question."""
127127
return self.options_class.qpy_form, self.options.model_dump(mode="json")
128128

129-
def start_attempt(self, variant: int) -> Attempt:
129+
def start_attempt(self, variant: int) -> AttemptStartedProtocol:
130130
attempt_state = self.attempt_class.make_attempt_state(self, variant)
131131
return self.attempt_class(self, attempt_state)
132132

@@ -135,14 +135,27 @@ def get_attempt(
135135
attempt_state: dict[str, JsonValue],
136136
scoring_state: dict[str, JsonValue] | None = None,
137137
response: dict[str, JsonValue] | None = None,
138-
) -> Attempt:
138+
) -> AttemptProtocol:
139139
parsed_attempt_state = self.attempt_class.attempt_state_class.model_validate(attempt_state)
140140
parsed_scoring_state = None
141141
if scoring_state is not None:
142142
parsed_scoring_state = self.attempt_class.scoring_state_class.model_validate(scoring_state)
143143

144144
return self.attempt_class(self, parsed_attempt_state, parsed_scoring_state, response)
145145

146+
def score_attempt(
147+
self,
148+
attempt_state: dict[str, JsonValue],
149+
scoring_state: dict[str, JsonValue] | None,
150+
response: dict[str, JsonValue] | None,
151+
*,
152+
try_scoring_with_countback: bool,
153+
try_giving_hint: bool,
154+
) -> AttemptScoredProtocol:
155+
attempt = cast(Attempt, self.get_attempt(attempt_state, scoring_state, response))
156+
attempt.score_response(try_scoring_with_countback=try_scoring_with_countback, try_giving_hint=try_giving_hint)
157+
return cast(AttemptScoredProtocol, attempt)
158+
146159
def __init_subclass__(cls, *args: object, **kwargs: object) -> None:
147160
super().__init_subclass__(*args, **kwargs)
148161

questionpy/_wrappers/_question.py

+24-20
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
from pydantic import JsonValue
77

8-
from questionpy import Attempt, Question
8+
from questionpy import Question
9+
from questionpy._attempt import AttemptProtocol, AttemptScoredProtocol
910
from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel, AttemptUi
1011
from questionpy_common.api.question import QuestionInterface, QuestionModel
1112
from questionpy_common.environment import get_qpy_environment
@@ -43,10 +44,10 @@ def _export_question(question: Question) -> QuestionModel:
4344
)
4445

4546

46-
def _export_attempt(attempt: Attempt) -> dict:
47+
def _export_attempt(attempt: AttemptProtocol) -> dict:
4748
return {
4849
"lang": _get_output_lang(),
49-
"variant": attempt.attempt_state.variant,
50+
"variant": attempt.variant,
5051
"ui": AttemptUi(
5152
formulation=attempt.formulation,
5253
general_feedback=attempt.general_feedback,
@@ -60,7 +61,7 @@ def _export_attempt(attempt: Attempt) -> dict:
6061
}
6162

6263

63-
def _export_score(attempt: Attempt) -> dict:
64+
def _export_score(attempt: AttemptScoredProtocol) -> dict:
6465
plain_scoring_state = attempt.to_plain_scoring_state()
6566
return {
6667
"scoring_state": None if plain_scoring_state is None else json.dumps(plain_scoring_state),
@@ -81,23 +82,16 @@ def start_attempt(self, variant: int) -> AttemptStartedModel:
8182
plain_attempt_state = attempt.to_plain_attempt_state()
8283
return AttemptStartedModel(**_export_attempt(attempt), attempt_state=json.dumps(plain_attempt_state))
8384

84-
def _get_attempt_internal(
85-
self,
86-
attempt_state: str,
87-
scoring_state: str | None = None,
88-
response: dict[str, JsonValue] | None = None,
89-
) -> Attempt:
90-
plain_attempt_state = json.loads(attempt_state)
91-
plain_scoring_state = None
92-
if scoring_state:
93-
plain_scoring_state = json.loads(scoring_state)
94-
95-
return self._question.get_attempt(plain_attempt_state, plain_scoring_state, response)
96-
9785
def get_attempt(
9886
self, attempt_state: str, scoring_state: str | None = None, response: dict[str, JsonValue] | None = None
9987
) -> AttemptModel:
100-
return AttemptModel(**_export_attempt(self._get_attempt_internal(attempt_state, scoring_state, response)))
88+
parsed_attempt_state = json.loads(attempt_state)
89+
parsed_scoring_state = None
90+
if scoring_state:
91+
parsed_scoring_state = json.loads(scoring_state)
92+
93+
attempt = self._question.get_attempt(parsed_attempt_state, parsed_scoring_state, response)
94+
return AttemptModel(**_export_attempt(attempt))
10195

10296
def score_attempt(
10397
self,
@@ -108,8 +102,18 @@ def score_attempt(
108102
try_scoring_with_countback: bool = False,
109103
try_giving_hint: bool = False,
110104
) -> AttemptScoredModel:
111-
attempt = self._get_attempt_internal(attempt_state, scoring_state, response)
112-
attempt.score_response(try_scoring_with_countback=try_scoring_with_countback, try_giving_hint=try_giving_hint)
105+
parsed_attempt_state = json.loads(attempt_state)
106+
parsed_scoring_state = None
107+
if scoring_state:
108+
parsed_scoring_state = json.loads(scoring_state)
109+
110+
attempt = self._question.score_attempt(
111+
parsed_attempt_state,
112+
parsed_scoring_state,
113+
response,
114+
try_scoring_with_countback=try_scoring_with_countback,
115+
try_giving_hint=try_giving_hint,
116+
)
113117
return AttemptScoredModel(**_export_attempt(attempt), **_export_score(attempt))
114118

115119
def export_question_state(self) -> str:

0 commit comments

Comments
 (0)