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.
- {in2lambda-1.0.0 → in2lambda-1.0.1}/PKG-INFO +6 -3
- in2lambda-1.0.1/in2lambda/api/part.py +30 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/api/question.py +47 -30
- in2lambda-1.0.1/in2lambda/api/response_area.py +317 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/api/set.py +28 -12
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/api/visibility_status.py +1 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartPartSolSol/filter.py +1 -1
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartSolPartSol/filter.py +1 -1
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsOneSol/filter.py +1 -1
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsSepSol/filter.py +1 -1
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/markdown.py +1 -1
- in2lambda-1.0.1/in2lambda/json_convert/json_convert.py +259 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/json_convert/minimal_template_question.json +0 -1
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/main.py +2 -2
- {in2lambda-1.0.0 → in2lambda-1.0.1}/pyproject.toml +9 -4
- in2lambda-1.0.0/in2lambda/api/part.py +0 -11
- in2lambda-1.0.0/in2lambda/json_convert/json_convert.py +0 -144
- {in2lambda-1.0.0 → in2lambda-1.0.1}/LICENSE +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/README.md +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/__init__.py +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/api/__init__.py +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/example_questions.tex +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/example_solutions.tex +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/figures/O2_potential.png +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/figures/activated_complex.png +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/figures/rocket-momentum.png +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartPartSolSol/__init__.py +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartPartSolSol/example.tex +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartSolPartSol/__init__.py +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartSolPartSol/example.tex +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsOneSol/__init__.py +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsOneSol/example.tex +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsSepSol/__init__.py +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/PartsSepSol/example.tex +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/filters/__init__.py +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/json_convert/__init__.py +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/json_convert/minimal_template_set.json +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/katex_convert/__init__.py +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/katex_convert/delete_list.txt +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/katex_convert/katex_convert.py +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/katex_convert/replace_list.txt +0 -0
- {in2lambda-1.0.0 → in2lambda-1.0.1}/in2lambda/py.typed +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: in2lambda
|
|
3
|
-
Version: 1.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.
|
|
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",
|
|
20
|
-
Question(title='Some title', parts=[], images=[],
|
|
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
|
|
30
|
+
# Use getter/setter methods for master_content without exposing private attribute.
|
|
29
31
|
# See https://stackoverflow.com/a/61480946
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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.
|
|
50
|
-
>>> question.
|
|
51
|
-
>>> question.
|
|
66
|
+
>>> question.master_content = "hello"
|
|
67
|
+
>>> question.master_content = "there"
|
|
68
|
+
>>> question.master_content
|
|
52
69
|
'hello\nthere'
|
|
53
70
|
"""
|
|
54
|
-
return self.
|
|
71
|
+
return self._master_content
|
|
55
72
|
|
|
56
|
-
@
|
|
57
|
-
def
|
|
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
|
|
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.
|
|
89
|
+
text_value = self.master_content
|
|
73
90
|
case pf.Element():
|
|
74
91
|
text_value = pf.stringify(value, False)
|
|
75
92
|
|
|
76
|
-
if self.
|
|
77
|
-
self.
|
|
78
|
-
self.
|
|
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(
|
|
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(
|
|
99
|
-
Part(
|
|
100
|
-
Part(
|
|
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(
|
|
104
|
-
Part(
|
|
105
|
-
Part(
|
|
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(
|
|
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(
|
|
140
|
-
Part(
|
|
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(
|
|
162
|
+
self.parts.append(Part(content=elem_text))
|
|
146
163
|
else:
|
|
147
|
-
self.parts[self._last_part["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=[],
|
|
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=[],
|
|
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 = "",
|
|
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
|
-
|
|
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=[],
|
|
77
|
-
>>> s.add_question(
|
|
78
|
-
>>> s.questions[1].
|
|
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.
|
|
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(
|
|
106
|
-
Question(title='Question 2', parts=[Part(
|
|
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
|
)
|
|
@@ -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(
|
|
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(
|
|
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 = []
|
|
@@ -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(
|
|
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
|
|
@@ -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)
|
|
@@ -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(
|
|
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(
|
|
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.
|
|
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.
|
|
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 = "
|
|
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 = "
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|