in2lambda 0.3.3__tar.gz → 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {in2lambda-0.3.3 → in2lambda-1.0.1}/PKG-INFO +8 -3
  2. in2lambda-1.0.1/in2lambda/api/part.py +30 -0
  3. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/api/question.py +48 -31
  4. in2lambda-1.0.1/in2lambda/api/response_area.py +317 -0
  5. in2lambda-1.0.1/in2lambda/api/set.py +201 -0
  6. in2lambda-1.0.1/in2lambda/api/visibility_status.py +77 -0
  7. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartPartSolSol/filter.py +7 -7
  8. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartSolPartSol/filter.py +8 -10
  9. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartsOneSol/filter.py +6 -6
  10. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartsSepSol/filter.py +8 -8
  11. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/markdown.py +9 -9
  12. in2lambda-1.0.1/in2lambda/json_convert/__init__.py +1 -0
  13. in2lambda-1.0.1/in2lambda/json_convert/json_convert.py +259 -0
  14. in2lambda-0.3.3/in2lambda/json_convert/minimal_template.json → in2lambda-1.0.1/in2lambda/json_convert/minimal_template_question.json +9 -10
  15. in2lambda-1.0.1/in2lambda/json_convert/minimal_template_set.json +9 -0
  16. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/main.py +9 -9
  17. {in2lambda-0.3.3 → in2lambda-1.0.1}/pyproject.toml +12 -3
  18. in2lambda-0.3.3/in2lambda/api/module.py +0 -125
  19. in2lambda-0.3.3/in2lambda/api/part.py +0 -11
  20. in2lambda-0.3.3/in2lambda/json_convert/__init__.py +0 -1
  21. in2lambda-0.3.3/in2lambda/json_convert/json_convert.py +0 -99
  22. {in2lambda-0.3.3 → in2lambda-1.0.1}/LICENSE +0 -0
  23. {in2lambda-0.3.3 → in2lambda-1.0.1}/README.md +0 -0
  24. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/__init__.py +0 -0
  25. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/api/__init__.py +0 -0
  26. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/example_questions.tex +0 -0
  27. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/example_solutions.tex +0 -0
  28. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/figures/O2_potential.png +0 -0
  29. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/figures/activated_complex.png +0 -0
  30. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/figures/rocket-momentum.png +0 -0
  31. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartPartSolSol/__init__.py +0 -0
  32. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartPartSolSol/example.tex +0 -0
  33. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartSolPartSol/__init__.py +0 -0
  34. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartSolPartSol/example.tex +0 -0
  35. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartsOneSol/__init__.py +0 -0
  36. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartsOneSol/example.tex +0 -0
  37. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartsSepSol/__init__.py +0 -0
  38. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/PartsSepSol/example.tex +0 -0
  39. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/filters/__init__.py +0 -0
  40. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/katex_convert/__init__.py +0 -0
  41. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/katex_convert/delete_list.txt +0 -0
  42. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/katex_convert/katex_convert.py +0 -0
  43. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/katex_convert/replace_list.txt +0 -0
  44. {in2lambda-0.3.3 → in2lambda-1.0.1}/in2lambda/py.typed +0 -0
@@ -1,9 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: in2lambda
3
- Version: 0.3.3
3
+ Version: 1.0.1
4
4
  Summary: Converts content ready for import into Lambda Feedback
5
- Home-page: https://lambda-feedback.github.io/in2lambda
6
5
  License: MIT
6
+ License-File: LICENSE
7
7
  Requires-Python: >=3.10,<4.0
8
8
  Classifier: Development Status :: 3 - Alpha
9
9
  Classifier: License :: OSI Approved :: MIT License
@@ -13,13 +13,18 @@ Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
16
19
  Classifier: Programming Language :: Python :: 3 :: Only
17
20
  Classifier: Typing :: Typed
18
21
  Requires-Dist: beartype (>=0.17.2,<0.18.0)
19
22
  Requires-Dist: panflute (>=2.3.1,<3.0.0)
23
+ Requires-Dist: requests (<2.34.0)
20
24
  Requires-Dist: rich-click (>=1.7.4,<2.0.0)
21
25
  Project-URL: Bug Tracker, https://github.com/lambda-feedback/in2lambda/issues
22
26
  Project-URL: Documentation, https://lambda-feedback.github.io/in2lambda
27
+ Project-URL: Homepage, https://lambda-feedback.github.io/in2lambda
23
28
  Project-URL: Repository, https://github.com/lambda-feedback/in2lambda
24
29
  Description-Content-Type: text/markdown
25
30
 
@@ -0,0 +1,30 @@
1
+ """A part of a question."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from in2lambda.api.response_area import ResponseArea
6
+
7
+
8
+ @dataclass
9
+ class Branch:
10
+ """A (titled) branch of a structured tutorial or worked solution, which may branch further."""
11
+
12
+ title: str
13
+ content: str
14
+ branches: list["Branch"] = field(default_factory=list)
15
+
16
+
17
+ @dataclass
18
+ class Part:
19
+ """A part of a question as represented on Lambda Feedback.
20
+
21
+ Markdown content for the question and supporting materials. Note that in ``tutorial`` and ``worked_solution`` fields, steps can by separated by a line with only ``---`` (or ``***``).
22
+ """
23
+
24
+ content: str = ""
25
+ response_areas: list[ResponseArea] = field(default_factory=list)
26
+ tutorial: str = ""
27
+ tutorial_branches: list[Branch] = field(default_factory=list)
28
+ answer: str = ""
29
+ worked_solution: str = ""
30
+ worked_solution_branches: list[Branch] = field(default_factory=list)
@@ -1,7 +1,7 @@
1
- """A full question with optional parts that's contained in a module."""
1
+ """A full question with optional parts that's contained in a set."""
2
2
 
3
3
  from dataclasses import dataclass, field
4
- from typing import Union
4
+ from typing import Optional, Union
5
5
 
6
6
  import panflute as pf
7
7
 
@@ -14,10 +14,12 @@ class Question:
14
14
 
15
15
  Each question has a title and is composed of a list of parts.
16
16
 
17
+ It also carries the settings Lambda Feedback keeps per question.
18
+
17
19
  Examples:
18
20
  >>> from in2lambda.api.question import Question
19
- >>> Question(title="Some title", main_text="Some text")
20
- Question(title='Some title', parts=[], images=[], main_text='Some text')
21
+ >>> Question(title="Some title", master_content="Some text")
22
+ Question(title='Some title', parts=[], images=[], master_content='Some text')
21
23
  """
22
24
 
23
25
  title: str = ""
@@ -25,10 +27,10 @@ class Question:
25
27
  parts: list[Part] = field(default_factory=list)
26
28
  images: list[str] = field(default_factory=list)
27
29
 
28
- # Use getter/setter methods for main_text without exposing private attribute.
30
+ # Use getter/setter methods for master_content without exposing private attribute.
29
31
  # See https://stackoverflow.com/a/61480946
30
- main_text: str = ""
31
- _main_text: str = field(init=False, repr=False, default="")
32
+ master_content: str = ""
33
+ _master_content: str = field(init=False, repr=False, default="")
32
34
 
33
35
  _last_part: dict[str, int] = field(
34
36
  default_factory=lambda: {"solution": 0, "text": 0}, repr=False
@@ -36,8 +38,23 @@ class Question:
36
38
  """Keeps track of the last question part that contains a solution /
37
39
  text."""
38
40
 
41
+ # The settings below are left out of the repr, so that printing a question shows its
42
+ # content rather than nine lines of configuration.
43
+
44
+ # The skill level may be an int as well as a float, because Lambda Feedback writes
45
+ # its exports in JavaScript, which writes the levels 0.0 and 1.0 as 0 and 1.
46
+ skill: Optional[Union[int, float]] = field(default=None, repr=False)
47
+ guidance: Optional[str] = field(default=None, repr=False)
48
+ duration_lower_bound: Optional[int] = field(default=None, repr=False)
49
+ duration_upper_bound: Optional[int] = field(default=None, repr=False)
50
+ publish: bool = field(default=False, repr=False)
51
+ display_final_answer: bool = field(default=True, repr=False)
52
+ display_worked_solution: bool = field(default=True, repr=False)
53
+ display_structured_tutorial: bool = field(default=True, repr=False)
54
+ display_chatbot: bool = field(default=False, repr=False)
55
+
39
56
  @property
40
- def main_text(self) -> str:
57
+ def master_content(self) -> str:
41
58
  r"""Main top-level question text.
42
59
 
43
60
  Setting the attribute multiple times appends to the current value with a newline.
@@ -46,21 +63,21 @@ class Question:
46
63
  Examples:
47
64
  >>> from in2lambda.api.question import Question
48
65
  >>> question = Question()
49
- >>> question.main_text = "hello"
50
- >>> question.main_text = "there"
51
- >>> question.main_text
66
+ >>> question.master_content = "hello"
67
+ >>> question.master_content = "there"
68
+ >>> question.master_content
52
69
  'hello\nthere'
53
70
  """
54
- return self._main_text
71
+ return self._master_content
55
72
 
56
- @main_text.setter
57
- def main_text(self, value: Union[pf.Element, str, property]) -> None:
73
+ @master_content.setter
74
+ def master_content(self, value: Union[pf.Element, str, property]) -> None:
58
75
  r"""Appends to the top-level main text, which starts off as an empty string.
59
76
 
60
77
  Args:
61
78
  value: A panflute element or string denoting what to append to the main text.
62
79
 
63
- See example in main_text property.
80
+ See example in master_content property.
64
81
  """
65
82
  # Converts the inputted value into a string, stored in text_value
66
83
  match value:
@@ -69,13 +86,13 @@ class Question:
69
86
  case property():
70
87
  # Use default value when no value set at initialisation.
71
88
  # See https://stackoverflow.com/a/61480946
72
- text_value = self.main_text
89
+ text_value = self.master_content
73
90
  case pf.Element():
74
91
  text_value = pf.stringify(value, False)
75
92
 
76
- if self._main_text:
77
- self._main_text += "\n"
78
- self._main_text += text_value
93
+ if self._master_content:
94
+ self._master_content += "\n"
95
+ self._master_content += text_value
79
96
 
80
97
  def add_solution(self, elem: Union[pf.Element, str]) -> None:
81
98
  """Adds a worked solution to all question parts without one, or inserts a new empty part with the solution if all parts already have a solution.
@@ -89,20 +106,20 @@ class Question:
89
106
  >>> question.add_part_text("part a")
90
107
  >>> question.add_solution("part a solution")
91
108
  >>> question
92
- Question(title='', parts=[Part(text='part a', worked_solution='part a solution')], images=[], main_text='')
109
+ Question(title='', parts=[Part(content='part a', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part a solution', worked_solution_branches=[])], images=[], master_content='')
93
110
  >>> question.add_part_text("part b")
94
111
  >>> question.add_part_text("part c")
95
112
  >>> question.add_solution("Solution for b")
96
113
  >>> # Note that since c doesn't have a solution, it's set to b's solution
97
114
  >>> question
98
- Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \
99
- Part(text='part b', worked_solution='Solution for b'), \
100
- Part(text='part c', worked_solution='Solution for b')], images=[], main_text='')
115
+ Question(title='', parts=[Part(content='part a', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part a solution', worked_solution_branches=[]), \
116
+ Part(content='part b', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='Solution for b', worked_solution_branches=[]), \
117
+ Part(content='part c', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='Solution for b', worked_solution_branches=[])], images=[], master_content='')
101
118
  >>> question.add_solution("We now have a solution for c!")
102
119
  >>> question
103
- Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \
104
- Part(text='part b', worked_solution='Solution for b'), \
105
- Part(text='part c', worked_solution='We now have a solution for c!')], images=[], main_text='')
120
+ Question(title='', parts=[Part(content='part a', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part a solution', worked_solution_branches=[]), \
121
+ Part(content='part b', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='Solution for b', worked_solution_branches=[]), \
122
+ Part(content='part c', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='We now have a solution for c!', worked_solution_branches=[])], images=[], master_content='')
106
123
  """
107
124
  elem_text = elem if isinstance(elem, str) else pf.stringify(elem)
108
125
 
@@ -131,19 +148,19 @@ Part(text='part c', worked_solution='We now have a solution for c!')], images=[]
131
148
  >>> question.add_part_text("part a")
132
149
  >>> question.add_solution("part a solution")
133
150
  >>> question
134
- Question(title='', parts=[Part(text='part a', worked_solution='part a solution')], images=[], main_text='')
151
+ Question(title='', parts=[Part(content='part a', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part a solution', worked_solution_branches=[])], images=[], master_content='')
135
152
  >>> # Supports adding the answer first.
136
153
  >>> question.add_solution("part b solution")
137
154
  >>> question.add_part_text("part b")
138
155
  >>> question
139
- Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \
140
- Part(text='part b', worked_solution='part b solution')], images=[], main_text='')
156
+ Question(title='', parts=[Part(content='part a', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part a solution', worked_solution_branches=[]), \
157
+ Part(content='part b', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='part b solution', worked_solution_branches=[])], images=[], master_content='')
141
158
  """
142
159
  elem_text = elem if isinstance(elem, str) else pf.stringify(elem)
143
160
 
144
161
  if len(self.parts) == self._last_part["text"]:
145
- self.parts.append(Part(text=elem_text))
162
+ self.parts.append(Part(content=elem_text))
146
163
  else:
147
- self.parts[self._last_part["text"]].text = elem_text
164
+ self.parts[self._last_part["text"]].content = elem_text
148
165
 
149
166
  self._last_part["text"] += 1
@@ -0,0 +1,317 @@
1
+ """An area for student responses that will receive automated feedback."""
2
+
3
+ import uuid
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Any, ClassVar, Optional, Union
7
+
8
+
9
+ def _new_id() -> str:
10
+ return str(uuid.uuid4())
11
+
12
+
13
+ @dataclass
14
+ class InputSymbol:
15
+ """Defines symbols and how to enter them into a response area.
16
+
17
+ Fields per symbol:
18
+ - ``symbol`` for display to users (markdown, typically dollar-delimited math latex)
19
+ - ``code`` for users to input (plan text, fed to the algorithm and also be rendered to users within backticks as `code`)
20
+ - ``aliases`` list of alternative code inputs, to be substituted by the `code` field when evaluating
21
+ - ``is_visible`` Boolean, whether to display input symbol info to users (in addition to always sending it to the evaluator to help parsing)
22
+ """
23
+
24
+ symbol: str
25
+ code: str
26
+ aliases: list[str] = field(default_factory=list)
27
+ is_visible: bool = True
28
+
29
+
30
+ @dataclass
31
+ class Test:
32
+ """Teacher author's assertions of how responses should be evaluated."""
33
+
34
+ # Its name would otherwise make pytest try to collect it wherever it is imported.
35
+ __test__ = False
36
+
37
+ payload: str
38
+ is_correct: bool
39
+ id: str = field(default_factory=_new_id)
40
+
41
+
42
+ class Color(Enum):
43
+ """Acceptable colours for a feedback case."""
44
+
45
+ RED = "#ff5630"
46
+ AMBER = "#FFAB00"
47
+ GREEN = "#22C55E"
48
+ BLUE = "#0099c4"
49
+ GREY = "#C4CDD5"
50
+
51
+
52
+ @dataclass
53
+ class Case:
54
+ """A declared case for which bespoke feedback is prescribed."""
55
+
56
+ answer: str
57
+ feedback: str
58
+ is_correct: bool
59
+ params: Any = None
60
+ color: Optional[Color] = None
61
+ id: str = field(default_factory=_new_id)
62
+
63
+
64
+ @dataclass(kw_only=True)
65
+ class ResponseArea:
66
+ """Specifies the interaction when a student provides a response.
67
+
68
+ Multiple response areas per part are allowed; if so, order is implicit.
69
+ Use a subclass for each response type. ``grade_params`` are passed to the
70
+ evaluation function unedited.
71
+
72
+ Examples:
73
+ >>> from in2lambda.api.response_area import NumericUnits, Test
74
+ >>> area = NumericUnits(
75
+ ... answer="30 N",
76
+ ... grade_params={"rtol": 0.05},
77
+ ... pre_text="$F=$",
78
+ ... tests=[Test("30 N", True)],
79
+ ... )
80
+ >>> area.response_type, area.evaluation_function
81
+ ('NUMERIC_UNITS', 'compareExpressions')
82
+ """
83
+
84
+ response_type: ClassVar[str]
85
+ answer: Any = None
86
+ evaluation_function: str
87
+ grade_params: Optional[dict[str, Any]] = None
88
+ pre_text: str = ""
89
+ post_text: str = ""
90
+ content_after: str = ""
91
+ """Markdown shown after the box, before the next one."""
92
+ check_button_text: str = ""
93
+ """Replaces the label of the check button; left out of the JSON when empty."""
94
+ input_symbols: list[InputSymbol] = field(default_factory=list)
95
+ display_input_symbols: bool = False
96
+ live_preview: bool = False
97
+ include_in_pdf: bool = False
98
+ save_allowed: bool = False
99
+ separate_feedback: bool = True
100
+ common_feedback_color: str = "#C4CDD5"
101
+ correct_feedback_color: str = "#22C55E"
102
+ correct_feedback_prefix: str = "Correct"
103
+ incorrect_feedback_color: str = "#ff5630"
104
+ incorrect_feedback_prefix: str = "Incorrect"
105
+ tests: list[Test] = field(default_factory=list)
106
+ cases: list[Case] = field(default_factory=list)
107
+
108
+ def _config(self) -> Optional[dict[str, Any]]:
109
+ """The response type's settings, as Lambda Feedback writes them."""
110
+ return None
111
+
112
+ def _grade_params(self) -> Optional[dict[str, Any]]:
113
+ """The parameters sent to the evaluation function."""
114
+ return self.grade_params
115
+
116
+
117
+ @dataclass(kw_only=True)
118
+ class _Maths(ResponseArea):
119
+ answer: str = ""
120
+ allow_handwrite: bool = True
121
+ allow_photo: bool = True
122
+ enable_refinement: bool = True
123
+
124
+ def _config(self) -> Optional[dict[str, Any]]:
125
+ return {
126
+ "allowHandwrite": self.allow_handwrite,
127
+ "allowPhoto": self.allow_photo,
128
+ "enableRefinement": self.enable_refinement,
129
+ }
130
+
131
+
132
+ @dataclass(kw_only=True)
133
+ class MathSingleLine(_Maths):
134
+ """A single symbolic expression."""
135
+
136
+ response_type: ClassVar[str] = "MATH_SINGLE_LINE"
137
+ evaluation_function: str = "compareExpressions"
138
+
139
+
140
+ @dataclass(kw_only=True)
141
+ class MathMultiLines(_Maths):
142
+ """Multiple lines of mathematical work."""
143
+
144
+ response_type: ClassVar[str] = "MATH_MULTI_LINES"
145
+
146
+
147
+ @dataclass(kw_only=True)
148
+ class Number(ResponseArea):
149
+ """A number."""
150
+
151
+ response_type: ClassVar[str] = "NUMBER"
152
+ answer: Optional[Union[int, float]] = None
153
+ evaluation_function: str = "isSimilar"
154
+
155
+
156
+ @dataclass(kw_only=True)
157
+ class NumericUnits(ResponseArea):
158
+ """A number with units, e.g. ``30 N``."""
159
+
160
+ response_type: ClassVar[str] = "NUMERIC_UNITS"
161
+ answer: str = ""
162
+ evaluation_function: str = "compareExpressions"
163
+
164
+ def _grade_params(self) -> Optional[dict[str, Any]]:
165
+ # compareExpressions compares units only when told to.
166
+ return {"physical_quantity": True, **(self.grade_params or {})}
167
+
168
+
169
+ @dataclass(kw_only=True)
170
+ class Text(ResponseArea):
171
+ """A short piece of text."""
172
+
173
+ response_type: ClassVar[str] = "TEXT"
174
+ answer: str = ""
175
+
176
+
177
+ @dataclass(kw_only=True)
178
+ class Boolean(ResponseArea):
179
+ """True or false."""
180
+
181
+ response_type: ClassVar[str] = "BOOLEAN"
182
+ answer: bool = False
183
+ evaluation_function: str = "isExactEqual"
184
+
185
+
186
+ @dataclass(kw_only=True)
187
+ class MultipleChoice(ResponseArea):
188
+ """A choice among ``options``; ``answer`` marks each option correct or not."""
189
+
190
+ response_type: ClassVar[str] = "MULTIPLE_CHOICE"
191
+ answer: list[bool] = field(default_factory=list)
192
+ evaluation_function: str = "arrayEqual"
193
+ options: list[str] = field(default_factory=list)
194
+ single: bool = True
195
+ randomise: bool = False
196
+
197
+ def _config(self) -> Optional[dict[str, Any]]:
198
+ return {
199
+ "options": self.options,
200
+ "randomise": self.randomise,
201
+ "single": self.single,
202
+ }
203
+
204
+
205
+ @dataclass(kw_only=True)
206
+ class Table(ResponseArea):
207
+ """A grid of cells, one row per row name and one column per column name."""
208
+
209
+ response_type: ClassVar[str] = "TABLE"
210
+ answer: list[list[str]] = field(default_factory=list)
211
+ evaluation_function: str = "arraySymbolicEqual"
212
+ row_names: list[str] = field(default_factory=list)
213
+ col_names: list[str] = field(default_factory=list)
214
+
215
+ def _config(self) -> Optional[dict[str, Any]]:
216
+ return {
217
+ "rows": len(self.row_names),
218
+ "cols": len(self.col_names),
219
+ "rowNames": self.row_names,
220
+ "colNames": self.col_names,
221
+ }
222
+
223
+
224
+ @dataclass(kw_only=True)
225
+ class Matrix(ResponseArea):
226
+ """A table, with parentheses displayed around it, with the shape of ``answer``."""
227
+
228
+ response_type: ClassVar[str] = "MATRIX"
229
+ answer: list[list[str]] = field(default_factory=list)
230
+ evaluation_function: str = "arraySymbolicEqual"
231
+
232
+ def _config(self) -> Optional[dict[str, Any]]:
233
+ return {
234
+ "rows": len(self.answer),
235
+ "cols": len(self.answer[0]) if self.answer else 0,
236
+ }
237
+
238
+
239
+ @dataclass(kw_only=True)
240
+ class Code(ResponseArea):
241
+ """A monaco code editor."""
242
+
243
+ response_type: ClassVar[str] = "CODE"
244
+ answer: str = ""
245
+ evaluation_function: str = "evaluatePython"
246
+ language: str = "python"
247
+
248
+ def _config(self) -> Optional[dict[str, Any]]:
249
+ return {"language": self.language}
250
+
251
+
252
+ @dataclass(kw_only=True)
253
+ class Essay(ResponseArea):
254
+ """Large text box."""
255
+
256
+ response_type: ClassVar[str] = "ESSAY"
257
+ answer: Union[str, dict[str, str]] = ""
258
+ allow_draw: bool = False
259
+ allow_scan: bool = False
260
+ repeat_for_subjects: bool = False
261
+ allow_empty_submission: bool = False
262
+
263
+ def _config(self) -> Optional[dict[str, Any]]:
264
+ return {
265
+ "allowDraw": self.allow_draw,
266
+ "allowScan": self.allow_scan,
267
+ "repeatForSubjects": self.repeat_for_subjects,
268
+ "allowEmptySubmission": self.allow_empty_submission,
269
+ }
270
+
271
+
272
+ @dataclass(kw_only=True)
273
+ class Likert(ResponseArea):
274
+ """A multiple choice with Likert scales."""
275
+
276
+ response_type: ClassVar[str] = "LIKERT"
277
+ answer: Union[
278
+ list[Optional[Union[int, float]]],
279
+ dict[str, list[Optional[Union[int, float]]]],
280
+ None,
281
+ ] = None
282
+ statements: list[str] = field(default_factory=list)
283
+ options: Optional[list[str]] = None
284
+ repeat_for_subjects: bool = False
285
+ allow_empty_submission: bool = False
286
+
287
+ def _config(self) -> Optional[dict[str, Any]]:
288
+ config: dict[str, Any] = {
289
+ "statements": self.statements,
290
+ "repeatForSubjects": self.repeat_for_subjects,
291
+ "allowEmptySubmission": self.allow_empty_submission,
292
+ }
293
+ if self.options is not None:
294
+ config["options"] = self.options
295
+ return config
296
+
297
+
298
+ @dataclass(kw_only=True)
299
+ class Images(ResponseArea):
300
+ """Upload images."""
301
+
302
+ response_type: ClassVar[str] = "IMAGES"
303
+ max_images: int = 3
304
+ allowed_types: list[str] = field(
305
+ default_factory=lambda: ["image/jpg", "image/jpeg", "image/png"]
306
+ )
307
+ max_size_mb: int = 10
308
+ resize_max_side: int = 0
309
+ """The longest side images are resized to; 0 leaves them as they are."""
310
+
311
+ def _config(self) -> Optional[dict[str, Any]]:
312
+ return {
313
+ "maxImages": self.max_images,
314
+ "allowedTypes": self.allowed_types,
315
+ "maxSizeMb": self.max_size_mb,
316
+ "resizeMaxSide": self.resize_max_side,
317
+ }