in2lambda 1.0.0__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 (42) hide show
  1. {in2lambda-1.0.0 → in2lambda-1.0.1}/PKG-INFO +6 -3
  2. in2lambda-1.0.1/in2lambda/api/part.py +30 -0
  3. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/api/question.py +47 -30
  4. in2lambda-1.0.1/in2lambda/api/response_area.py +317 -0
  5. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/api/set.py +28 -12
  6. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/api/visibility_status.py +1 -0
  7. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartPartSolSol/filter.py +1 -1
  8. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartSolPartSol/filter.py +1 -1
  9. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsOneSol/filter.py +1 -1
  10. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsSepSol/filter.py +1 -1
  11. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/markdown.py +1 -1
  12. in2lambda-1.0.1/in2lambda/json_convert/json_convert.py +259 -0
  13. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/json_convert/minimal_template_question.json +0 -1
  14. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/main.py +2 -2
  15. {in2lambda-1.0.0 → in2lambda-1.0.1}/pyproject.toml +9 -4
  16. in2lambda-1.0.0/in2lambda/api/part.py +0 -11
  17. in2lambda-1.0.0/in2lambda/json_convert/json_convert.py +0 -144
  18. {in2lambda-1.0.0 → in2lambda-1.0.1}/LICENSE +0 -0
  19. {in2lambda-1.0.0 → in2lambda-1.0.1}/README.md +0 -0
  20. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/__init__.py +0 -0
  21. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/api/__init__.py +0 -0
  22. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/example_questions.tex +0 -0
  23. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/example_solutions.tex +0 -0
  24. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/figures/O2_potential.png +0 -0
  25. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/figures/activated_complex.png +0 -0
  26. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/figures/rocket-momentum.png +0 -0
  27. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartPartSolSol/__init__.py +0 -0
  28. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartPartSolSol/example.tex +0 -0
  29. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartSolPartSol/__init__.py +0 -0
  30. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartSolPartSol/example.tex +0 -0
  31. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsOneSol/__init__.py +0 -0
  32. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsOneSol/example.tex +0 -0
  33. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsSepSol/__init__.py +0 -0
  34. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsSepSol/example.tex +0 -0
  35. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/__init__.py +0 -0
  36. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/json_convert/__init__.py +0 -0
  37. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/json_convert/minimal_template_set.json +0 -0
  38. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/katex_convert/__init__.py +0 -0
  39. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/katex_convert/delete_list.txt +0 -0
  40. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/katex_convert/katex_convert.py +0 -0
  41. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/katex_convert/replace_list.txt +0 -0
  42. {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/py.typed +0 -0
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: in2lambda
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Converts content ready for import into Lambda Feedback
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Requires-Python: >=3.10,<4.0
7
8
  Classifier: Development Status :: 3 - Alpha
8
9
  Classifier: License :: OSI Approved :: MIT License
@@ -13,11 +14,13 @@ Classifier: Programming Language :: Python :: 3.10
13
14
  Classifier: Programming Language :: Python :: 3.11
14
15
  Classifier: Programming Language :: Python :: 3.12
15
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)
20
- Requires-Dist: requests (<2.32.0)
23
+ Requires-Dist: requests (<2.34.0)
21
24
  Requires-Dist: rich-click (>=1.7.4,<2.0.0)
22
25
  Project-URL: Bug Tracker, https://github.com/lambda-feedback/in2lambda/issues
23
26
  Project-URL: Documentation, https://lambda-feedback.github.io/in2lambda
@@ -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
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
+ }
@@ -1,7 +1,7 @@
1
1
  """Represents a list of questions."""
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
 
@@ -11,7 +11,11 @@ from in2lambda.api.visibility_status import VisibilityController, VisibilityStat
11
11
 
12
12
  @dataclass
13
13
  class Set:
14
- """Represents a list of questions."""
14
+ """Represents a list of questions.
15
+
16
+ A collection of questions. Contains set-level parameters including visibility, with
17
+ release dates (ISO 8601 time, omitted if unset), and survey status.
18
+ """
15
19
 
16
20
  _name: str = field(default="set")
17
21
  _description: str = field(default="")
@@ -28,6 +32,13 @@ class Set:
28
32
  _structuredTutorialVisibility: VisibilityController = field(
29
33
  default_factory=lambda: VisibilityController(VisibilityStatus.OPEN)
30
34
  )
35
+ _chatbotVisibility: VisibilityController = field(
36
+ default_factory=lambda: VisibilityController(VisibilityStatus.HIDE)
37
+ )
38
+ is_survey: bool = False
39
+ released_at: Optional[str] = None
40
+ hidden_at: Optional[str] = None
41
+ manually_hidden: bool = True
31
42
 
32
43
  questions: list[Question] = field(default_factory=list)
33
44
  _current_question_index = -1
@@ -45,11 +56,11 @@ class Set:
45
56
  Examples:
46
57
  >>> from in2lambda.api.set import Set
47
58
  >>> Set().current_question
48
- Question(title='INVALID', parts=[], images=[], main_text='')
59
+ Question(title='INVALID', parts=[], images=[], master_content='')
49
60
  >>> s = Set()
50
61
  >>> s.add_question()
51
62
  >>> s.current_question
52
- Question(title='', parts=[], images=[], main_text='')
63
+ Question(title='', parts=[], images=[], master_content='')
53
64
  """
54
65
  return (
55
66
  self.questions[self._current_question_index]
@@ -58,14 +69,14 @@ class Set:
58
69
  )
59
70
 
60
71
  def add_question(
61
- self, title: str = "", main_text: Union[pf.Element, str] = pf.Str("")
72
+ self, title: str = "", master_content: Union[pf.Element, str] = pf.Str("")
62
73
  ) -> None:
63
74
  """Inserts a new question into the set.
64
75
 
65
76
  Args:
66
77
  title: An optional string for the title of the question. If no title
67
78
  is provided, the question title auto-increments i.e. Question 1, 2, etc.
68
- main_text: An optional string or panflute element for the main question text.
79
+ master_content: An optional string or panflute element for the main question text.
69
80
 
70
81
  Examples:
71
82
  >>> from in2lambda.api.set import Set
@@ -73,13 +84,13 @@ class Set:
73
84
  >>> s = Set()
74
85
  >>> s.add_question("Some title", pf.Para(pf.Str("hello"), pf.Space, pf.Str("there")))
75
86
  >>> s.questions
76
- [Question(title='Some title', parts=[], images=[], main_text='hello there')]
77
- >>> s.add_question(main_text="Normal string text")
78
- >>> s.questions[1].main_text
87
+ [Question(title='Some title', parts=[], images=[], master_content='hello there')]
88
+ >>> s.add_question(master_content="Normal string text")
89
+ >>> s.questions[1].master_content
79
90
  'Normal string text'
80
91
  """
81
92
  question = Question(title=title)
82
- question.main_text = main_text
93
+ question.master_content = master_content
83
94
  self.questions.append(question)
84
95
 
85
96
  def increment_current_question(self) -> None:
@@ -102,8 +113,8 @@ class Set:
102
113
  >>> s.increment_current_question()
103
114
  >>> s.current_question.add_solution("Question 2 answer")
104
115
  >>> s.questions
105
- [Question(title='Question 1', parts=[Part(text='', worked_solution='Question 1 answer')], images=[], main_text=''),\
106
- Question(title='Question 2', parts=[Part(text='', worked_solution='Question 2 answer')], images=[], main_text='')]
116
+ [Question(title='Question 1', parts=[Part(content='', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='Question 1 answer', worked_solution_branches=[])], images=[], master_content=''),\
117
+ Question(title='Question 2', parts=[Part(content='', response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='Question 2 answer', worked_solution_branches=[])], images=[], master_content='')]
107
118
  """
108
119
  self._current_question_index += 1
109
120
 
@@ -180,6 +191,11 @@ class Set:
180
191
  f"_finalAnswerVisibility={str(self._finalAnswerVisibility)!r}, "
181
192
  f"_workedSolutionVisibility={str(self._workedSolutionVisibility)!r}, "
182
193
  f"_structuredTutorialVisibility={str(self._structuredTutorialVisibility)!r}, "
194
+ f"_chatbotVisibility={str(self._chatbotVisibility)!r}, "
195
+ f"is_survey={self.is_survey!r}, "
196
+ f"released_at={self.released_at!r}, "
197
+ f"hidden_at={self.hidden_at!r}, "
198
+ f"manually_hidden={self.manually_hidden!r}, "
183
199
  f"questions={self.questions!r}"
184
200
  f")"
185
201
  )
@@ -2,6 +2,7 @@
2
2
 
3
3
  from enum import Enum
4
4
 
5
+
5
6
  class VisibilityStatus(Enum):
6
7
  """Enum representing the visibility status of a question or set."""
7
8
 
@@ -46,7 +46,7 @@ def pandoc_filter(
46
46
 
47
47
  # Solutions are in a Div
48
48
  if isinstance(elem, pf.Div):
49
- set.add_question(main_text="\n".join(pandoc_filter.question))
49
+ set.add_question(master_content="\n".join(pandoc_filter.question))
50
50
  if hasattr(pandoc_filter, "parts"):
51
51
  for part in pandoc_filter.parts:
52
52
  set.current_question.add_part_text(part)
@@ -54,7 +54,7 @@ def pandoc_filter(
54
54
  if isinstance(elem, pf.Div):
55
55
  pandoc_filter.solutions.append(pf.stringify(elem))
56
56
  if pandoc_filter.question:
57
- set.add_question(main_text="\n".join(pandoc_filter.question))
57
+ set.add_question(master_content="\n".join(pandoc_filter.question))
58
58
  set.current_question.add_solution(pf.stringify(elem))
59
59
  set.current_question._last_part["solution"] -= 1
60
60
  pandoc_filter.question = []
@@ -42,7 +42,7 @@ def pandoc_filter(
42
42
  isinstance(elem.prev, pf.Header)
43
43
  and pf.stringify(elem.prev) != "Solution"
44
44
  ):
45
- set.add_question(main_text=elem)
45
+ set.add_question(master_content=elem)
46
46
 
47
47
  # Parts are denoted via ordered lists
48
48
  case pf.OrderedList:
@@ -67,7 +67,7 @@ def pandoc_filter(
67
67
  set.current_question.add_solution(spaced_blurb)
68
68
  # Add the main question text as the Lambda Feedback blurb
69
69
  elif not parsing_answers:
70
- set.add_question(main_text=spaced_blurb)
70
+ set.add_question(master_content=spaced_blurb)
71
71
 
72
72
  # Add each part solution/text
73
73
  # For the solution, prepend any top level answer text to each part answer
@@ -119,7 +119,7 @@ def filter(
119
119
  func: Callable[
120
120
  [pf.Element, pf.elements.Doc, Set, bool],
121
121
  Optional[pf.Str],
122
- ]
122
+ ],
123
123
  ) -> Callable[
124
124
  [pf.Element, pf.elements.Doc, Set, str, bool],
125
125
  Optional[pf.Str],
@@ -0,0 +1,259 @@
1
+ """Converts questions from a Python set object into Lambda Feedback JSON."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import zipfile
8
+ from copy import deepcopy
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from in2lambda.api.part import Branch, Part
13
+ from in2lambda.api.response_area import Case, ResponseArea
14
+ from in2lambda.api.set import Set
15
+
16
+ MINIMAL_QUESTION_TEMPLATE = "minimal_template_question.json"
17
+ MINIMAL_SET_TEMPLATE = "minimal_template_set.json"
18
+
19
+
20
+ def _zip_sorted_folder(folder_path, zip_path):
21
+ """Zips the contents of a folder, preserving the directory structure.
22
+
23
+ Args:
24
+ folder_path: The path to the folder to zip.
25
+ zip_path: The path where the zip file will be created.
26
+ """
27
+ with zipfile.ZipFile(zip_path, "w") as zf:
28
+ for root, dirs, files in os.walk(folder_path):
29
+ # Sort files for deterministic, alphabetical order
30
+ for file in sorted(files):
31
+ abs_path = os.path.join(root, file)
32
+ rel_path = os.path.relpath(abs_path, folder_path)
33
+ zf.write(abs_path, arcname=rel_path)
34
+
35
+
36
+ def _case_to_json(case: Case) -> dict[str, Any]:
37
+ output = {
38
+ "id": case.id,
39
+ "answer": case.answer,
40
+ "feedback": case.feedback,
41
+ "isCorrect": case.is_correct,
42
+ "params": case.params,
43
+ }
44
+ if case.color is not None:
45
+ output["color"] = case.color.value
46
+ return output
47
+
48
+
49
+ def _response_area_to_json(area: ResponseArea, order: int) -> dict[str, Any]:
50
+ output = {
51
+ "orderNumber": order,
52
+ "contentAfter": area.content_after,
53
+ "preResponseText": area.pre_text,
54
+ "postResponseText": area.post_text,
55
+ "inputSymbols": [
56
+ {
57
+ "symbol": symbol.symbol,
58
+ "code": symbol.code,
59
+ "aliases": symbol.aliases,
60
+ "isVisible": symbol.is_visible,
61
+ }
62
+ for symbol in area.input_symbols
63
+ ],
64
+ "displayInputSymbols": area.display_input_symbols,
65
+ "includeInPdf": area.include_in_pdf,
66
+ "saveAllowed": area.save_allowed,
67
+ "evaluationFunctionName": area.evaluation_function,
68
+ "livePreview": area.live_preview,
69
+ "gradeParams": area._grade_params(),
70
+ "separateFeedback": area.separate_feedback,
71
+ "commonFeedbackColor": area.common_feedback_color,
72
+ "correctFeedbackColor": area.correct_feedback_color,
73
+ "correctFeedbackPrefix": area.correct_feedback_prefix,
74
+ "incorrectFeedbackColor": area.incorrect_feedback_color,
75
+ "incorrectFeedbackPrefix": area.incorrect_feedback_prefix,
76
+ "tests": [
77
+ {
78
+ "id": test.id,
79
+ "payload": test.payload,
80
+ "expectedResponse": {"isCorrect": test.is_correct},
81
+ }
82
+ for test in area.tests
83
+ ],
84
+ "cases": [_case_to_json(case) for case in area.cases],
85
+ "response": {
86
+ "responseInput": {
87
+ "responseType": area.response_type,
88
+ "answer": area.answer,
89
+ "config": area._config(),
90
+ }
91
+ },
92
+ }
93
+ if area.check_button_text:
94
+ output["checkButtonText"] = area.check_button_text
95
+ return output
96
+
97
+
98
+ def _branch_to_json(branch: Branch) -> dict[str, Any]:
99
+ return {
100
+ "title": branch.title,
101
+ "content": branch.content,
102
+ "children": [_branch_to_json(child) for child in branch.branches],
103
+ }
104
+
105
+
106
+ def _part_to_json(part: Part, order: int) -> dict[str, Any]:
107
+ output = {
108
+ "orderNumber": order,
109
+ "content": part.content,
110
+ "answerContent": part.answer,
111
+ "responseAreas": [
112
+ _response_area_to_json(area, j)
113
+ for j, area in enumerate(part.response_areas)
114
+ ],
115
+ }
116
+ # Lambda Feedback leaves each key out of a part that has none.
117
+ for key, content, branches in (
118
+ ("tutorial", part.tutorial, part.tutorial_branches),
119
+ ("workedSolution", part.worked_solution, part.worked_solution_branches),
120
+ ):
121
+ if content or branches:
122
+ output[key] = {
123
+ "content": content,
124
+ "children": [_branch_to_json(branch) for branch in branches],
125
+ }
126
+ return output
127
+
128
+
129
+ def converter(
130
+ question_template: dict[str, Any],
131
+ set_template: dict[str, Any],
132
+ SetQuestions: Set,
133
+ output_dir: str,
134
+ ) -> None:
135
+ """Turns a set of question objects into Lambda Feedback JSON.
136
+
137
+ Args:
138
+ question_template: The loaded JSON from the minimal question template (it needs to be in sync).
139
+ set_template: The loaded JSON from the minimal set template (it needs to be in sync).
140
+ SetQuestions: A Set object containing questions.
141
+ output_dir: The absolute path for where to produced the final JSON/zip files.
142
+ """
143
+ ListQuestions = SetQuestions.questions
144
+ set_name = SetQuestions._name
145
+ set_description = SetQuestions._description
146
+
147
+ # create directory to put the questions
148
+ os.makedirs(output_dir, exist_ok=True)
149
+ output_question = os.path.join(output_dir, set_name)
150
+ os.makedirs(output_question, exist_ok=True)
151
+
152
+ set_template["name"] = set_name
153
+ set_template["description"] = set_description
154
+ set_template["finalAnswerVisibility"] = str(
155
+ SetQuestions._finalAnswerVisibility.status
156
+ )
157
+ set_template["workedSolutionVisibility"] = str(
158
+ SetQuestions._workedSolutionVisibility.status
159
+ )
160
+ set_template["structuredTutorialVisibility"] = str(
161
+ SetQuestions._structuredTutorialVisibility.status
162
+ )
163
+ set_template["chatbotVisibility"] = str(SetQuestions._chatbotVisibility.status)
164
+ set_template["isSurvey"] = SetQuestions.is_survey
165
+ set_template["manuallyHidden"] = SetQuestions.manually_hidden
166
+ for key, value in {
167
+ "releasedAt": SetQuestions.released_at,
168
+ "hiddenAt": SetQuestions.hidden_at,
169
+ }.items():
170
+ if value is not None:
171
+ set_template[key] = value
172
+ # create the set file
173
+ with open(f"{output_question}/set_{set_name}.json", "w") as file:
174
+ json.dump(set_template, file)
175
+
176
+ for i in range(len(ListQuestions)):
177
+ output = deepcopy(question_template)
178
+
179
+ output["orderNumber"] = i # order number starts at 0
180
+ # add title to the question file
181
+ if ListQuestions[i].title != "":
182
+ output["title"] = ListQuestions[i].title
183
+ else:
184
+ output["title"] = "Question " + str(i + 1)
185
+
186
+ # add main text to the question file
187
+ output["masterContent"] = ListQuestions[i].master_content
188
+
189
+ question = ListQuestions[i]
190
+ output["publish"] = question.publish
191
+ output["displayFinalAnswer"] = question.display_final_answer
192
+ output["displayWorkedSolution"] = question.display_worked_solution
193
+ output["displayStructuredTutorial"] = question.display_structured_tutorial
194
+ output["displayChatbot"] = question.display_chatbot
195
+ # An unset optional setting is left out of the JSON, so that in2lambda writes no
196
+ # value the author did not choose.
197
+ for key, value in {
198
+ "skill": question.skill,
199
+ "guidance": question.guidance,
200
+ "durationLowerBound": question.duration_lower_bound,
201
+ "durationUpperBound": question.duration_upper_bound,
202
+ }.items():
203
+ if value is not None:
204
+ output[key] = value
205
+
206
+ if question.parts:
207
+ output["parts"] = [
208
+ _part_to_json(part, j) for j, part in enumerate(question.parts)
209
+ ]
210
+
211
+ # Output file
212
+ filename = (
213
+ "question_"
214
+ + str(i).zfill(3)
215
+ + "_"
216
+ + re.sub(r"[^\w\-_.]", "_", output["title"].strip())
217
+ )
218
+
219
+ # write questions into directory
220
+ with open(f"{output_question}/{filename}.json", "w") as file:
221
+ json.dump(output, file)
222
+
223
+ # write image into directory
224
+ for k in range(len(ListQuestions[i].images)):
225
+ image_path = os.path.abspath(
226
+ ListQuestions[i].images[k]
227
+ ) # converts computer path into python path
228
+ # If images exist, create a media directory
229
+ output_image = os.path.join(output_question, "media")
230
+ os.makedirs(output_image, exist_ok=True)
231
+ shutil.copy(image_path, output_image) # copies image into the directory
232
+
233
+ # output zip file in destination folder
234
+ _zip_sorted_folder(output_question, output_question + ".zip")
235
+
236
+
237
+ def main(set_questions: Set, output_dir: str) -> None:
238
+ """Preliminary defensive programming before calling the main converter function.
239
+
240
+ This ultimately then produces the Lambda Feedback JSON/ZIP files.
241
+
242
+ Args:
243
+ set_questions: A Set object containing questions.
244
+ output_dir: Where to output the final Lambda Feedback JSON/ZIP files.
245
+ """
246
+ # Use path so minimal template can be found regardless of where the user is running python from.
247
+ with open(Path(__file__).with_name(MINIMAL_QUESTION_TEMPLATE), "r") as file:
248
+ question_template = json.load(file)
249
+
250
+ with open(Path(__file__).with_name(MINIMAL_SET_TEMPLATE), "r") as file:
251
+ set_template = json.load(file)
252
+
253
+ # check if directory exists in file
254
+ if os.path.isdir(output_dir):
255
+ try:
256
+ shutil.rmtree(output_dir)
257
+ except OSError as e:
258
+ print("Error: %s : %s" % (output_dir, e.strerror))
259
+ converter(question_template, set_template, set_questions, output_dir)
@@ -14,7 +14,6 @@
14
14
  "answerContent": "",
15
15
  "responseAreas": [],
16
16
  "workedSolution": {
17
- "title": "",
18
17
  "content": "Part worked solution here",
19
18
  "children": []
20
19
  }
@@ -100,9 +100,9 @@ def runner(
100
100
  >>> from in2lambda.main import runner
101
101
  >>> # Retrieve an example TeX file and run the given filter.
102
102
  >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsSepSol/example.tex", "PartsSepSol") # doctest: +ELLIPSIS
103
- Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution=''), ...], images=[], main_text='This is a sample question\n\n'), ...])
103
+ Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', _chatbotVisibility='HIDE', is_survey=False, released_at=None, hidden_at=None, manually_hidden=True, questions=[Question(title='', parts=[Part(content=..., response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='', worked_solution_branches=[]), ...], images=[], master_content='This is a sample question\n\n'), ...])
104
104
  >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsOneSol/example.tex", "PartsOneSol") # doctest: +ELLIPSIS
105
- Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution=''), ...], images=[], main_text='Here is some preliminary question information that might be useful.'), ...])
105
+ Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', _chatbotVisibility='HIDE', is_survey=False, released_at=None, hidden_at=None, manually_hidden=True, questions=[Question(title='', parts=[Part(content=..., response_areas=[], tutorial='', tutorial_branches=[], answer='', worked_solution='', worked_solution_branches=[]), ...], images=[], master_content='Here is some preliminary question information that might be useful.'), ...])
106
106
  """
107
107
  # The list of questions for Lambda Feedback as a Python API.
108
108
  set_obj = Set()
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "in2lambda"
3
- version = "1.0.0"
3
+ version = "1.0.1"
4
4
  description = "Converts content ready for import into Lambda Feedback"
5
5
  authors = []
6
6
  license = "MIT"
@@ -26,17 +26,17 @@ python = "^3.10"
26
26
  panflute = "^2.3.1"
27
27
  rich-click = "^1.7.4"
28
28
  beartype = "^0.17.2"
29
- requests = "<2.32.0"
29
+ requests = "<2.34.0"
30
30
 
31
31
  [tool.poetry.scripts]
32
32
  in2lambda = "in2lambda.main:cli"
33
33
 
34
34
  [tool.poetry.group.dev.dependencies]
35
- black = "^24.3.0"
35
+ black = ">=24.3,<27.0"
36
36
  isort = "^5.13.2"
37
37
  pydocstyle = "^6.3.0"
38
38
  mypy = "^1.9.0"
39
- pytest = "^8.1.1"
39
+ pytest = ">=8.1.1,<10.0.0"
40
40
  pytest-cov = "^4.1.0"
41
41
  pytest-sugar = "^1.0.0"
42
42
  pre-commit = "^3.6.2"
@@ -60,6 +60,11 @@ ignore_missing_imports = true
60
60
  [tool.isort]
61
61
  profile = "black"
62
62
 
63
+ [tool.pytest.ini_options]
64
+ # Collect both the unit tests in tests/ and the doctests embedded in the package.
65
+ testpaths = ["tests", "in2lambda"]
66
+ addopts = "--doctest-modules"
67
+
63
68
  [build-system]
64
69
  requires = ["poetry-core"]
65
70
  build-backend = "poetry.core.masonry.api"
@@ -1,11 +0,0 @@
1
- """A part of a question."""
2
-
3
- from dataclasses import dataclass
4
-
5
-
6
- @dataclass
7
- class Part:
8
- """A part of a question as represented on Lambda Feedback."""
9
-
10
- text: str = ""
11
- worked_solution: str = ""
@@ -1,144 +0,0 @@
1
- """Converts questions from a Python set object into Lambda Feedback JSON."""
2
-
3
- import json
4
- import os
5
- import re
6
- import shutil
7
- import zipfile
8
- from copy import deepcopy
9
- from pathlib import Path
10
- from typing import Any
11
-
12
- from in2lambda.api.set import Set
13
-
14
- MINIMAL_QUESTION_TEMPLATE = "minimal_template_question.json"
15
- MINIMAL_SET_TEMPLATE = "minimal_template_set.json"
16
-
17
-
18
- def _zip_sorted_folder(folder_path, zip_path):
19
- """Zips the contents of a folder, preserving the directory structure.
20
-
21
- Args:
22
- folder_path: The path to the folder to zip.
23
- zip_path: The path where the zip file will be created.
24
- """
25
- with zipfile.ZipFile(zip_path, "w") as zf:
26
- for root, dirs, files in os.walk(folder_path):
27
- # Sort files for deterministic, alphabetical order
28
- for file in sorted(files):
29
- abs_path = os.path.join(root, file)
30
- rel_path = os.path.relpath(abs_path, folder_path)
31
- zf.write(abs_path, arcname=rel_path)
32
-
33
-
34
- def converter(
35
- question_template: dict[str, Any],
36
- set_template: dict[str, Any],
37
- SetQuestions: Set,
38
- output_dir: str,
39
- ) -> None:
40
- """Turns a set of question objects into Lambda Feedback JSON.
41
-
42
- Args:
43
- question_template: The loaded JSON from the minimal question template (it needs to be in sync).
44
- set_template: The loaded JSON from the minimal set template (it needs to be in sync).
45
- SetQuestions: A Set object containing questions.
46
- output_dir: The absolute path for where to produced the final JSON/zip files.
47
- """
48
- ListQuestions = SetQuestions.questions
49
- set_name = SetQuestions._name
50
- set_description = SetQuestions._description
51
-
52
- # create directory to put the questions
53
- os.makedirs(output_dir, exist_ok=True)
54
- output_question = os.path.join(output_dir, set_name)
55
- os.makedirs(output_question, exist_ok=True)
56
-
57
- set_template["name"] = set_name
58
- set_template["description"] = set_description
59
- set_template["finalAnswerVisibility"] = str(
60
- SetQuestions._finalAnswerVisibility.status
61
- )
62
- set_template["workedSolutionVisibility"] = str(
63
- SetQuestions._workedSolutionVisibility.status
64
- )
65
- set_template["structuredTutorialVisibility"] = str(
66
- SetQuestions._structuredTutorialVisibility.status
67
- )
68
- # create the set file
69
- with open(f"{output_question}/set_{set_name}.json", "w") as file:
70
- json.dump(set_template, file)
71
-
72
- for i in range(len(ListQuestions)):
73
- output = deepcopy(question_template)
74
-
75
- output["orderNumber"] = i # order number starts at 0
76
- # add title to the question file
77
- if ListQuestions[i].title != "":
78
- output["title"] = ListQuestions[i].title
79
- else:
80
- output["title"] = "Question " + str(i + 1)
81
-
82
- # add main text to the question file
83
- output["masterContent"] = ListQuestions[i].main_text
84
-
85
- # add parts to the question file
86
- if ListQuestions[i].parts:
87
- output["parts"][0]["content"] = ListQuestions[i].parts[0].text
88
- output["parts"][0]["workedSolution"]["content"] = (
89
- ListQuestions[i].parts[0].worked_solution
90
- )
91
- for j in range(1, len(ListQuestions[i].parts)):
92
- output["parts"].append(deepcopy(question_template["parts"][0]))
93
- output["parts"][j]["content"] = ListQuestions[i].parts[j].text
94
- output["parts"][j]["orderNumber"] = j
95
- output["parts"][j]["workedSolution"]["content"] = (
96
- ListQuestions[i].parts[j].worked_solution
97
- )
98
-
99
- # Output file
100
- filename = (
101
- "question_" + str(i).zfill(3) + "_" + re.sub(r'[^\w\-_.]', '_', output['title'].strip())
102
- )
103
-
104
- # write questions into directory
105
- with open(f"{output_question}/{filename}.json", "w") as file:
106
- json.dump(output, file)
107
-
108
- # write image into directory
109
- for k in range(len(ListQuestions[i].images)):
110
- image_path = os.path.abspath(
111
- ListQuestions[i].images[k]
112
- ) # converts computer path into python path
113
- # If images exist, create a media directory
114
- output_image = os.path.join(output_question, "media")
115
- os.makedirs(output_image, exist_ok=True)
116
- shutil.copy(image_path, output_image) # copies image into the directory
117
-
118
- # output zip file in destination folder
119
- _zip_sorted_folder(output_question, output_question + ".zip")
120
-
121
-
122
- def main(set_questions: Set, output_dir: str) -> None:
123
- """Preliminary defensive programming before calling the main converter function.
124
-
125
- This ultimately then produces the Lambda Feedback JSON/ZIP files.
126
-
127
- Args:
128
- set_questions: A Set object containing questions.
129
- output_dir: Where to output the final Lambda Feedback JSON/ZIP files.
130
- """
131
- # Use path so minimal template can be found regardless of where the user is running python from.
132
- with open(Path(__file__).with_name(MINIMAL_QUESTION_TEMPLATE), "r") as file:
133
- question_template = json.load(file)
134
-
135
- with open(Path(__file__).with_name(MINIMAL_SET_TEMPLATE), "r") as file:
136
- set_template = json.load(file)
137
-
138
- # check if directory exists in file
139
- if os.path.isdir(output_dir):
140
- try:
141
- shutil.rmtree(output_dir)
142
- except OSError as e:
143
- print("Error: %s : %s" % (output_dir, e.strerror))
144
- converter(question_template, set_template, set_questions, output_dir)
File without changes
File without changes
File without changes