in2lambda 0.3.2__tar.gz → 1.0.0__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-0.3.2 → in2lambda-1.0.0}/PKG-INFO +5 -3
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/api/question.py +1 -1
- in2lambda-1.0.0/in2lambda/api/set.py +185 -0
- in2lambda-1.0.0/in2lambda/api/visibility_status.py +76 -0
- in2lambda-1.0.0/in2lambda/figures/O2_potential.png +0 -0
- in2lambda-1.0.0/in2lambda/figures/activated_complex.png +0 -0
- in2lambda-1.0.0/in2lambda/figures/rocket-momentum.png +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartPartSolSol/filter.py +7 -7
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartSolPartSol/filter.py +8 -10
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartsOneSol/filter.py +6 -6
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartsSepSol/filter.py +8 -8
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/markdown.py +10 -10
- in2lambda-1.0.0/in2lambda/json_convert/__init__.py +1 -0
- in2lambda-1.0.0/in2lambda/json_convert/json_convert.py +144 -0
- in2lambda-1.0.0/in2lambda/json_convert/minimal_template_question.json +23 -0
- in2lambda-1.0.0/in2lambda/json_convert/minimal_template_set.json +9 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/main.py +50 -18
- {in2lambda-0.3.2 → in2lambda-1.0.0}/pyproject.toml +5 -1
- in2lambda-0.3.2/in2lambda/api/module.py +0 -122
- in2lambda-0.3.2/in2lambda/json_convert/__init__.py +0 -1
- in2lambda-0.3.2/in2lambda/json_convert/json_convert.py +0 -98
- in2lambda-0.3.2/in2lambda/json_convert/minimal_template.json +0 -26
- {in2lambda-0.3.2 → in2lambda-1.0.0}/LICENSE +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/README.md +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/__init__.py +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/api/__init__.py +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/api/part.py +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/example_questions.tex +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/example_solutions.tex +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartPartSolSol/__init__.py +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartPartSolSol/example.tex +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartSolPartSol/__init__.py +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartSolPartSol/example.tex +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartsOneSol/__init__.py +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartsOneSol/example.tex +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartsSepSol/__init__.py +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/PartsSepSol/example.tex +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/filters/__init__.py +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/katex_convert/__init__.py +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/katex_convert/delete_list.txt +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/katex_convert/katex_convert.py +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/katex_convert/replace_list.txt +0 -0
- {in2lambda-0.3.2 → in2lambda-1.0.0}/in2lambda/py.typed +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: in2lambda
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Converts content ready for import into Lambda Feedback
|
|
5
|
-
Home-page: https://lambda-feedback.github.io/in2lambda
|
|
6
5
|
License: MIT
|
|
7
6
|
Requires-Python: >=3.10,<4.0
|
|
8
7
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -13,13 +12,16 @@ Classifier: Programming Language :: Python :: 3
|
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.10
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
17
|
Classifier: Typing :: Typed
|
|
18
18
|
Requires-Dist: beartype (>=0.17.2,<0.18.0)
|
|
19
19
|
Requires-Dist: panflute (>=2.3.1,<3.0.0)
|
|
20
|
+
Requires-Dist: requests (<2.32.0)
|
|
20
21
|
Requires-Dist: rich-click (>=1.7.4,<2.0.0)
|
|
21
22
|
Project-URL: Bug Tracker, https://github.com/lambda-feedback/in2lambda/issues
|
|
22
23
|
Project-URL: Documentation, https://lambda-feedback.github.io/in2lambda
|
|
24
|
+
Project-URL: Homepage, https://lambda-feedback.github.io/in2lambda
|
|
23
25
|
Project-URL: Repository, https://github.com/lambda-feedback/in2lambda
|
|
24
26
|
Description-Content-Type: text/markdown
|
|
25
27
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Represents a list of questions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Union
|
|
5
|
+
|
|
6
|
+
import panflute as pf
|
|
7
|
+
|
|
8
|
+
from in2lambda.api.question import Question
|
|
9
|
+
from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Set:
|
|
14
|
+
"""Represents a list of questions."""
|
|
15
|
+
|
|
16
|
+
_name: str = field(default="set")
|
|
17
|
+
_description: str = field(default="")
|
|
18
|
+
_finalAnswerVisibility: VisibilityController = field(
|
|
19
|
+
default_factory=lambda: VisibilityController(
|
|
20
|
+
VisibilityStatus.OPEN_WITH_WARNINGS
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
_workedSolutionVisibility: VisibilityController = field(
|
|
24
|
+
default_factory=lambda: VisibilityController(
|
|
25
|
+
VisibilityStatus.OPEN_WITH_WARNINGS
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
_structuredTutorialVisibility: VisibilityController = field(
|
|
29
|
+
default_factory=lambda: VisibilityController(VisibilityStatus.OPEN)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
questions: list[Question] = field(default_factory=list)
|
|
33
|
+
_current_question_index = -1
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def current_question(self) -> Question:
|
|
37
|
+
"""The current question being modified, or Question("INVALID") if there are no questions.
|
|
38
|
+
|
|
39
|
+
The reasoning behind returning Question("INVALID") is in case filter logic is being applied
|
|
40
|
+
on text before the first question (e.g. intro paragraphs). In that case, there is no effect.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The current question or Question("INVALID") if there are no questions.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
>>> from in2lambda.api.set import Set
|
|
47
|
+
>>> Set().current_question
|
|
48
|
+
Question(title='INVALID', parts=[], images=[], main_text='')
|
|
49
|
+
>>> s = Set()
|
|
50
|
+
>>> s.add_question()
|
|
51
|
+
>>> s.current_question
|
|
52
|
+
Question(title='', parts=[], images=[], main_text='')
|
|
53
|
+
"""
|
|
54
|
+
return (
|
|
55
|
+
self.questions[self._current_question_index]
|
|
56
|
+
if self.questions
|
|
57
|
+
else Question("INVALID")
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def add_question(
|
|
61
|
+
self, title: str = "", main_text: Union[pf.Element, str] = pf.Str("")
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Inserts a new question into the set.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
title: An optional string for the title of the question. If no title
|
|
67
|
+
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.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
>>> from in2lambda.api.set import Set
|
|
72
|
+
>>> import panflute as pf
|
|
73
|
+
>>> s = Set()
|
|
74
|
+
>>> s.add_question("Some title", pf.Para(pf.Str("hello"), pf.Space, pf.Str("there")))
|
|
75
|
+
>>> 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
|
|
79
|
+
'Normal string text'
|
|
80
|
+
"""
|
|
81
|
+
question = Question(title=title)
|
|
82
|
+
question.main_text = main_text
|
|
83
|
+
self.questions.append(question)
|
|
84
|
+
|
|
85
|
+
def increment_current_question(self) -> None:
|
|
86
|
+
"""Manually overrides the current question being modified.
|
|
87
|
+
|
|
88
|
+
The default (-1) indicates the last question added. Incrementing for the
|
|
89
|
+
first time sets to 0 i.e. the first question.
|
|
90
|
+
|
|
91
|
+
The is useful if adding question text first and answers later.
|
|
92
|
+
|
|
93
|
+
Examples:
|
|
94
|
+
>>> from in2lambda.api.set import Set
|
|
95
|
+
>>> s = Set()
|
|
96
|
+
>>> # Imagine adding the questions from a question file first...
|
|
97
|
+
>>> s.add_question("Question 1")
|
|
98
|
+
>>> s.add_question("Question 2")
|
|
99
|
+
>>> # ...and then adding solutions from an answer file later
|
|
100
|
+
>>> s.increment_current_question() # Loop back to question 1
|
|
101
|
+
>>> s.current_question.add_solution("Question 1 answer")
|
|
102
|
+
>>> s.increment_current_question()
|
|
103
|
+
>>> s.current_question.add_solution("Question 2 answer")
|
|
104
|
+
>>> 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='')]
|
|
107
|
+
"""
|
|
108
|
+
self._current_question_index += 1
|
|
109
|
+
|
|
110
|
+
def to_json(self, output_dir: str) -> None:
|
|
111
|
+
"""Turns this set into Lambda Feedback JSON/ZIP files.
|
|
112
|
+
|
|
113
|
+
WARNING: This will overwrite any existing files in the directory.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
output_dir: Where to output the final Lambda Feedback JSON/ZIP files.
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
>>> import tempfile
|
|
120
|
+
>>> import os
|
|
121
|
+
>>> import json
|
|
122
|
+
>>> # Create a set with two questions
|
|
123
|
+
>>> s = Set()
|
|
124
|
+
>>> s.add_question("Question 1")
|
|
125
|
+
>>> s.add_question("Question 2")
|
|
126
|
+
>>> with tempfile.TemporaryDirectory() as temp_dir:
|
|
127
|
+
... # Write the JSON files to the temporary directory
|
|
128
|
+
... s.to_json(temp_dir)
|
|
129
|
+
... # Check the contents of the directory
|
|
130
|
+
... sorted(os.listdir(temp_dir))
|
|
131
|
+
... # Check the contents of the set directory
|
|
132
|
+
... sorted(os.listdir(f"{temp_dir}/set"))
|
|
133
|
+
... # Check the title of the first question
|
|
134
|
+
... with open(f"{temp_dir}/set/question_000_Question_1.json") as file:
|
|
135
|
+
... print(f"Question 1's title: {json.load(file)['title']}")
|
|
136
|
+
['set', 'set.zip']
|
|
137
|
+
['question_000_Question_1.json', 'question_001_Question_2.json', 'set_set.json']
|
|
138
|
+
Question 1's title: Question 1
|
|
139
|
+
"""
|
|
140
|
+
from in2lambda.json_convert import json_convert
|
|
141
|
+
|
|
142
|
+
json_convert.main(self, output_dir)
|
|
143
|
+
|
|
144
|
+
def set_name(self, name: str) -> None:
|
|
145
|
+
"""Sets the name of the set.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
name: The name to set for the set.
|
|
149
|
+
|
|
150
|
+
Examples:
|
|
151
|
+
>>> from in2lambda.api.set import Set
|
|
152
|
+
>>> s = Set()
|
|
153
|
+
>>> s.set_name("My Question Set")
|
|
154
|
+
>>> s._name
|
|
155
|
+
'My Question Set'
|
|
156
|
+
"""
|
|
157
|
+
self._name = name
|
|
158
|
+
|
|
159
|
+
def set_description(self, description: str) -> None:
|
|
160
|
+
"""Sets the description of the set.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
description: The description to set for the set.
|
|
164
|
+
|
|
165
|
+
Examples:
|
|
166
|
+
>>> from in2lambda.api.set import Set
|
|
167
|
+
>>> s = Set()
|
|
168
|
+
>>> s.set_description("This is my question set.")
|
|
169
|
+
>>> s._description
|
|
170
|
+
'This is my question set.'
|
|
171
|
+
"""
|
|
172
|
+
self._description = description
|
|
173
|
+
|
|
174
|
+
def __repr__(self):
|
|
175
|
+
"""Custom representation showing visibility status values instead of memory addresses."""
|
|
176
|
+
return (
|
|
177
|
+
f"Set("
|
|
178
|
+
f"_name={self._name!r}, "
|
|
179
|
+
f"_description={self._description!r}, "
|
|
180
|
+
f"_finalAnswerVisibility={str(self._finalAnswerVisibility)!r}, "
|
|
181
|
+
f"_workedSolutionVisibility={str(self._workedSolutionVisibility)!r}, "
|
|
182
|
+
f"_structuredTutorialVisibility={str(self._structuredTutorialVisibility)!r}, "
|
|
183
|
+
f"questions={self.questions!r}"
|
|
184
|
+
f")"
|
|
185
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Module for managing visibility status of questions and sets."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
class VisibilityStatus(Enum):
|
|
6
|
+
"""Enum representing the visibility status of a question or set."""
|
|
7
|
+
|
|
8
|
+
OPEN = "OPEN"
|
|
9
|
+
HIDE = "HIDE"
|
|
10
|
+
OPEN_WITH_WARNINGS = "OPEN_WITH_WARNINGS"
|
|
11
|
+
|
|
12
|
+
def __str__(self):
|
|
13
|
+
"""Return the string representation of the visibility status."""
|
|
14
|
+
return self.value
|
|
15
|
+
|
|
16
|
+
def __repr__(self) -> str:
|
|
17
|
+
"""Return a string representation for debugging."""
|
|
18
|
+
return str(self)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VisibilityController:
|
|
22
|
+
"""Controller for managing visibility status with easy-to-use methods."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, initial_status: VisibilityStatus = VisibilityStatus.OPEN):
|
|
25
|
+
"""Initialize the VisibilityController with a specific status."""
|
|
26
|
+
self._status = initial_status
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def status(self) -> VisibilityStatus:
|
|
30
|
+
"""Return the current visibility status."""
|
|
31
|
+
return self._status
|
|
32
|
+
|
|
33
|
+
def to_open(self):
|
|
34
|
+
"""Change status to OPEN.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
>>> vc = VisibilityController()
|
|
38
|
+
>>> vc.to_open()
|
|
39
|
+
>>> vc.status
|
|
40
|
+
OPEN
|
|
41
|
+
"""
|
|
42
|
+
self._status = VisibilityStatus.OPEN
|
|
43
|
+
|
|
44
|
+
def to_hide(self):
|
|
45
|
+
"""Change status to HIDE.
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
>>> vc = VisibilityController()
|
|
49
|
+
>>> vc.to_hide()
|
|
50
|
+
>>> vc.status
|
|
51
|
+
HIDE
|
|
52
|
+
"""
|
|
53
|
+
self._status = VisibilityStatus.HIDE
|
|
54
|
+
|
|
55
|
+
def to_open_with_warnings(self):
|
|
56
|
+
"""Change status to OPEN_WITH_WARNINGS.
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
>>> vc = VisibilityController()
|
|
60
|
+
>>> vc.to_open_with_warnings()
|
|
61
|
+
>>> vc.status
|
|
62
|
+
OPEN_WITH_WARNINGS
|
|
63
|
+
"""
|
|
64
|
+
self._status = VisibilityStatus.OPEN_WITH_WARNINGS
|
|
65
|
+
|
|
66
|
+
def __str__(self):
|
|
67
|
+
"""Return the string representation of the visibility status."""
|
|
68
|
+
return str(self._status)
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
"""Return a string representation for debugging."""
|
|
72
|
+
return str(self)
|
|
73
|
+
|
|
74
|
+
def to_dict(self):
|
|
75
|
+
"""Convert VisibilityController to dictionary for JSON serialization."""
|
|
76
|
+
return {"status": str(self._status)}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -6,7 +6,7 @@ from typing import Optional
|
|
|
6
6
|
|
|
7
7
|
import panflute as pf
|
|
8
8
|
|
|
9
|
-
from in2lambda.api.
|
|
9
|
+
from in2lambda.api.set import Set
|
|
10
10
|
from in2lambda.filters.markdown import filter
|
|
11
11
|
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ from in2lambda.filters.markdown import filter
|
|
|
14
14
|
def pandoc_filter(
|
|
15
15
|
elem: pf.Element,
|
|
16
16
|
doc: pf.elements.Doc,
|
|
17
|
-
|
|
17
|
+
set: Set,
|
|
18
18
|
parsing_answers: bool,
|
|
19
19
|
) -> Optional[pf.Str]:
|
|
20
20
|
"""A Pandoc filter that parses and translates various TeX elements.
|
|
@@ -23,7 +23,7 @@ def pandoc_filter(
|
|
|
23
23
|
elem: The current TeX element being processed. This could be a paragraph,
|
|
24
24
|
ordered list, etc.
|
|
25
25
|
doc: A Pandoc document container - essentially the Pandoc AST.
|
|
26
|
-
|
|
26
|
+
set: The Python API that is used to store the result after processing
|
|
27
27
|
the TeX file.
|
|
28
28
|
parsing_answers: Whether an answers-only document is currently being parsed.
|
|
29
29
|
|
|
@@ -46,10 +46,10 @@ def pandoc_filter(
|
|
|
46
46
|
|
|
47
47
|
# Solutions are in a Div
|
|
48
48
|
if isinstance(elem, pf.Div):
|
|
49
|
-
|
|
49
|
+
set.add_question(main_text="\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)
|
|
53
53
|
pandoc_filter.question = []
|
|
54
54
|
pandoc_filter.parts = []
|
|
55
55
|
|
|
@@ -58,8 +58,8 @@ def pandoc_filter(
|
|
|
58
58
|
match type(first_answer_part := elem.content[0]):
|
|
59
59
|
case pf.OrderedList:
|
|
60
60
|
for item in first_answer_part.content:
|
|
61
|
-
|
|
61
|
+
set.current_question.add_solution(pf.stringify(item))
|
|
62
62
|
case pf.Para:
|
|
63
|
-
|
|
63
|
+
set.current_question.add_solution(pf.stringify(elem))
|
|
64
64
|
|
|
65
65
|
return None
|
|
@@ -7,7 +7,7 @@ from typing import Optional
|
|
|
7
7
|
|
|
8
8
|
import panflute as pf
|
|
9
9
|
|
|
10
|
-
from in2lambda.api.
|
|
10
|
+
from in2lambda.api.set import Set
|
|
11
11
|
from in2lambda.filters.markdown import filter
|
|
12
12
|
|
|
13
13
|
|
|
@@ -15,7 +15,7 @@ from in2lambda.filters.markdown import filter
|
|
|
15
15
|
def pandoc_filter(
|
|
16
16
|
elem: pf.Element,
|
|
17
17
|
doc: pf.elements.Doc,
|
|
18
|
-
|
|
18
|
+
set: Set,
|
|
19
19
|
parsing_answers: bool,
|
|
20
20
|
) -> Optional[pf.Str]:
|
|
21
21
|
"""A Pandoc filter that parses and translates various TeX elements.
|
|
@@ -24,7 +24,7 @@ def pandoc_filter(
|
|
|
24
24
|
elem: The current TeX element being processed. This could be a paragraph,
|
|
25
25
|
ordered list, etc.
|
|
26
26
|
doc: A Pandoc document container - essentially the Pandoc AST.
|
|
27
|
-
|
|
27
|
+
set: The Python API that is used to store the result after processing
|
|
28
28
|
the TeX file.
|
|
29
29
|
parsing_answers: Whether an answers-only document is currently being parsed.
|
|
30
30
|
|
|
@@ -48,16 +48,14 @@ def pandoc_filter(
|
|
|
48
48
|
for item in listItem.content
|
|
49
49
|
if not isinstance(item, pf.Div)
|
|
50
50
|
]
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
pandoc_filter.solutions.popleft()
|
|
54
|
-
)
|
|
51
|
+
set.current_question.add_part_text("\n".join(part))
|
|
52
|
+
set.current_question.add_solution(pandoc_filter.solutions.popleft())
|
|
55
53
|
|
|
56
54
|
if isinstance(elem, pf.Div):
|
|
57
55
|
pandoc_filter.solutions.append(pf.stringify(elem))
|
|
58
56
|
if pandoc_filter.question:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
set.add_question(main_text="\n".join(pandoc_filter.question))
|
|
58
|
+
set.current_question.add_solution(pf.stringify(elem))
|
|
59
|
+
set.current_question._last_part["solution"] -= 1
|
|
62
60
|
pandoc_filter.question = []
|
|
63
61
|
return None
|
|
@@ -9,7 +9,7 @@ from typing import Optional
|
|
|
9
9
|
|
|
10
10
|
import panflute as pf
|
|
11
11
|
|
|
12
|
-
from in2lambda.api.
|
|
12
|
+
from in2lambda.api.set import Set
|
|
13
13
|
from in2lambda.filters.markdown import filter
|
|
14
14
|
|
|
15
15
|
|
|
@@ -17,7 +17,7 @@ from in2lambda.filters.markdown import filter
|
|
|
17
17
|
def pandoc_filter(
|
|
18
18
|
elem: pf.Element,
|
|
19
19
|
doc: pf.elements.Doc,
|
|
20
|
-
|
|
20
|
+
set: Set,
|
|
21
21
|
parsing_answers: bool,
|
|
22
22
|
) -> Optional[pf.Str]:
|
|
23
23
|
"""A Pandoc filter that parses and translates various TeX elements.
|
|
@@ -26,7 +26,7 @@ def pandoc_filter(
|
|
|
26
26
|
elem: The current TeX element being processed. This could be a paragraph,
|
|
27
27
|
ordered list, etc.
|
|
28
28
|
doc: A Pandoc document container - essentially the Pandoc AST.
|
|
29
|
-
|
|
29
|
+
set: The Python API that is used to store the result after processing
|
|
30
30
|
the TeX file.
|
|
31
31
|
parsing_answers: Whether an answers-only document is currently being parsed.
|
|
32
32
|
|
|
@@ -42,17 +42,17 @@ def pandoc_filter(
|
|
|
42
42
|
isinstance(elem.prev, pf.Header)
|
|
43
43
|
and pf.stringify(elem.prev) != "Solution"
|
|
44
44
|
):
|
|
45
|
-
|
|
45
|
+
set.add_question(main_text=elem)
|
|
46
46
|
|
|
47
47
|
# Parts are denoted via ordered lists
|
|
48
48
|
case pf.OrderedList:
|
|
49
49
|
for item in elem.content:
|
|
50
|
-
|
|
50
|
+
set.current_question.add_part_text(item)
|
|
51
51
|
|
|
52
52
|
# Solution is in a Div with nested content being "Solution"
|
|
53
53
|
case pf.Div:
|
|
54
54
|
if pf.stringify(elem.content[0].content) == "Solution":
|
|
55
|
-
|
|
55
|
+
set.current_question.add_solution(
|
|
56
56
|
pf.stringify(elem) # [len("Solution") :] - For Jon Rackham
|
|
57
57
|
)
|
|
58
58
|
|
|
@@ -9,7 +9,7 @@ from typing import Optional
|
|
|
9
9
|
|
|
10
10
|
import panflute as pf
|
|
11
11
|
|
|
12
|
-
from in2lambda.api.
|
|
12
|
+
from in2lambda.api.set import Set
|
|
13
13
|
from in2lambda.filters.markdown import filter
|
|
14
14
|
|
|
15
15
|
|
|
@@ -17,7 +17,7 @@ from in2lambda.filters.markdown import filter
|
|
|
17
17
|
def pandoc_filter(
|
|
18
18
|
elem: pf.Element,
|
|
19
19
|
doc: pf.elements.Doc,
|
|
20
|
-
|
|
20
|
+
set: Set,
|
|
21
21
|
parsing_answers: bool,
|
|
22
22
|
) -> Optional[pf.Str]:
|
|
23
23
|
"""A Pandoc filter that parses and translates various TeX elements.
|
|
@@ -26,7 +26,7 @@ def pandoc_filter(
|
|
|
26
26
|
elem: The current TeX element being processed. This could be a paragraph,
|
|
27
27
|
ordered list, etc.
|
|
28
28
|
doc: A Pandoc document container - essentially the Pandoc AST.
|
|
29
|
-
|
|
29
|
+
set: The Python API that is used to store the result after processing
|
|
30
30
|
the TeX file.
|
|
31
31
|
parsing_answers: Whether an answers-only document is currently being parsed.
|
|
32
32
|
|
|
@@ -39,7 +39,7 @@ def pandoc_filter(
|
|
|
39
39
|
for numbered_part in elem.content:
|
|
40
40
|
if parsing_answers:
|
|
41
41
|
# Denotes that we've reached the answer for a new question
|
|
42
|
-
|
|
42
|
+
set.increment_current_question()
|
|
43
43
|
# For each numbered question, extract blurb and parts
|
|
44
44
|
blurb: list[str] = []
|
|
45
45
|
lettered_parts: list[str] = []
|
|
@@ -64,17 +64,17 @@ def pandoc_filter(
|
|
|
64
64
|
|
|
65
65
|
# Answers for questions with no parts
|
|
66
66
|
if parsing_answers and not lettered_parts:
|
|
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
|
-
|
|
70
|
+
set.add_question(main_text=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
|
|
74
74
|
for part in lettered_parts:
|
|
75
75
|
(
|
|
76
|
-
|
|
76
|
+
set.current_question.add_solution(spaced_blurb + part)
|
|
77
77
|
if parsing_answers
|
|
78
|
-
else
|
|
78
|
+
else set.current_question.add_part_text(part)
|
|
79
79
|
)
|
|
80
80
|
return None
|
|
@@ -9,7 +9,7 @@ import panflute as pf
|
|
|
9
9
|
from beartype.typing import Callable, Optional
|
|
10
10
|
from rich_click import echo
|
|
11
11
|
|
|
12
|
-
from in2lambda.api.
|
|
12
|
+
from in2lambda.api.set import Set
|
|
13
13
|
from in2lambda.katex_convert.katex_convert import latex_to_katex
|
|
14
14
|
|
|
15
15
|
|
|
@@ -32,12 +32,12 @@ def image_directories(tex_file: str) -> list[str]:
|
|
|
32
32
|
>>> temp_dir = tempfile.mkdtemp()
|
|
33
33
|
>>> tex_file = os.path.join(temp_dir, 'test.tex')
|
|
34
34
|
>>> with open(tex_file, 'w') as f:
|
|
35
|
-
...
|
|
35
|
+
... f.write("\\graphicspath{{subdir1/}{subdir2/}{subdir3/}}")
|
|
36
36
|
45
|
|
37
37
|
>>> image_directories(tex_file)
|
|
38
38
|
['subdir1/', 'subdir2/', 'subdir3/']
|
|
39
39
|
>>> with open(tex_file, 'w') as f:
|
|
40
|
-
... f.write("
|
|
40
|
+
... f.write("\\graphicspath{ { subdir1/ }, { subdir2/ }, { subdir3/ } }")
|
|
41
41
|
57
|
|
42
42
|
>>> image_directories.cache_clear()
|
|
43
43
|
>>> image_directories(tex_file)
|
|
@@ -71,14 +71,14 @@ def image_path(image_name: str, tex_file: str) -> Optional[str]:
|
|
|
71
71
|
The absolute path to the image if it can be found. If not, it returns None.
|
|
72
72
|
|
|
73
73
|
Examples:
|
|
74
|
-
|
|
74
|
+
set.current_question.images.append(path)
|
|
75
75
|
>>> import tempfile
|
|
76
76
|
>>> import os
|
|
77
77
|
>>> # Example TeX file with a subdirectory
|
|
78
78
|
>>> temp_dir = tempfile.mkdtemp()
|
|
79
79
|
>>> tex_file = os.path.join(temp_dir, 'test.tex')
|
|
80
80
|
>>> with open(tex_file, 'w') as f:
|
|
81
|
-
... f.write("
|
|
81
|
+
... f.write("\\graphicspath{{./subdir1/}{./subdir2/}{./subdir3/}}")
|
|
82
82
|
51
|
|
83
83
|
>>> # Example image in a relative subdirectory
|
|
84
84
|
>>> sub_dir = os.path.join(temp_dir, 'subdir3')
|
|
@@ -117,11 +117,11 @@ def image_path(image_name: str, tex_file: str) -> Optional[str]:
|
|
|
117
117
|
|
|
118
118
|
def filter(
|
|
119
119
|
func: Callable[
|
|
120
|
-
[pf.Element, pf.elements.Doc,
|
|
120
|
+
[pf.Element, pf.elements.Doc, Set, bool],
|
|
121
121
|
Optional[pf.Str],
|
|
122
122
|
]
|
|
123
123
|
) -> Callable[
|
|
124
|
-
[pf.Element, pf.elements.Doc,
|
|
124
|
+
[pf.Element, pf.elements.Doc, Set, str, bool],
|
|
125
125
|
Optional[pf.Str],
|
|
126
126
|
]:
|
|
127
127
|
"""Python decorator to make generic LaTeX elements markdown readable.
|
|
@@ -136,7 +136,7 @@ def filter(
|
|
|
136
136
|
def markdown_converter(
|
|
137
137
|
elem: pf.Element,
|
|
138
138
|
doc: pf.elements.Doc,
|
|
139
|
-
|
|
139
|
+
set: Set,
|
|
140
140
|
tex_file: str,
|
|
141
141
|
parsing_answers: bool,
|
|
142
142
|
) -> Optional[pf.Str]:
|
|
@@ -175,7 +175,7 @@ def filter(
|
|
|
175
175
|
if path is None:
|
|
176
176
|
echo(f"Warning: Couldn't find {elem.url}")
|
|
177
177
|
else:
|
|
178
|
-
|
|
178
|
+
set.current_question.images.append(path)
|
|
179
179
|
return pf.Str(f"")
|
|
180
180
|
|
|
181
181
|
case pf.Strong:
|
|
@@ -189,6 +189,6 @@ def filter(
|
|
|
189
189
|
case pf.Str:
|
|
190
190
|
return pf.Str(elem.text.replace("\u00a0", "\u202f"))
|
|
191
191
|
|
|
192
|
-
return func(elem, doc,
|
|
192
|
+
return func(elem, doc, set, parsing_answers)
|
|
193
193
|
|
|
194
194
|
return markdown_converter
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Files relating to translating the questions of a Python set object into JSON."""
|
|
@@ -0,0 +1,144 @@
|
|
|
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)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"orderNumber": 0,
|
|
3
|
+
"title": "Question title here",
|
|
4
|
+
"masterContent": "Top level question here",
|
|
5
|
+
"publish": false,
|
|
6
|
+
"displayFinalAnswer": true,
|
|
7
|
+
"displayStructuredTutorial": true,
|
|
8
|
+
"displayWorkedSolution": true,
|
|
9
|
+
"displayChatbot": false,
|
|
10
|
+
"parts": [
|
|
11
|
+
{
|
|
12
|
+
"orderNumber": 0,
|
|
13
|
+
"content": "Part text here",
|
|
14
|
+
"answerContent": "",
|
|
15
|
+
"responseAreas": [],
|
|
16
|
+
"workedSolution": {
|
|
17
|
+
"title": "",
|
|
18
|
+
"content": "Part worked solution here",
|
|
19
|
+
"children": []
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "a simple example",
|
|
3
|
+
"description": "description here",
|
|
4
|
+
"manuallyHidden": true,
|
|
5
|
+
"finalAnswerVisibility": "OPEN_WITH_WARNINGS",
|
|
6
|
+
"workedSolutionVisibility": "OPEN_WITH_WARNINGS",
|
|
7
|
+
"structuredTutorialVisibility": "OPEN",
|
|
8
|
+
"chatbotVisibility": "HIDE"
|
|
9
|
+
}
|
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
"""The main input for in2lambda, defining both the CLT and main library function."""
|
|
2
2
|
|
|
3
|
+
# This commented block makes it run the local files rather than the pip library (I think, I don't understand it. Kevin wrote it.)
|
|
4
|
+
#
|
|
5
|
+
# import sys
|
|
6
|
+
# import os
|
|
7
|
+
# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
8
|
+
|
|
3
9
|
import importlib
|
|
4
10
|
import pkgutil
|
|
11
|
+
import subprocess
|
|
5
12
|
from typing import Optional
|
|
6
13
|
|
|
7
14
|
import panflute as pf
|
|
8
15
|
import rich_click as click
|
|
9
16
|
|
|
10
17
|
import in2lambda.filters
|
|
11
|
-
from in2lambda.api.
|
|
18
|
+
from in2lambda.api.set import Set
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def docx_to_md(docx_file: str) -> str:
|
|
22
|
+
"""Converts .docx files to markdown.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
docx_file: A file path with the file extension included.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
the contents of the .docx file in markdown formatting
|
|
29
|
+
"""
|
|
30
|
+
md_output = subprocess.check_output(["pandoc", docx_file, "-t", "markdown"])
|
|
31
|
+
return md_output.decode("utf-8")
|
|
12
32
|
|
|
13
33
|
|
|
14
34
|
def file_type(file: str) -> str:
|
|
@@ -52,7 +72,7 @@ def file_type(file: str) -> str:
|
|
|
52
72
|
):
|
|
53
73
|
return "markdown"
|
|
54
74
|
case "docx":
|
|
55
|
-
return "docx" # Pandoc doesn't seem to support doc
|
|
75
|
+
return "docx" # Pandoc doesn't seem to support .doc, and panflute doesn't like .docx.
|
|
56
76
|
raise RuntimeError(f"Unsupported file extension: .{extension}")
|
|
57
77
|
|
|
58
78
|
|
|
@@ -61,7 +81,7 @@ def runner(
|
|
|
61
81
|
chosen_filter: str,
|
|
62
82
|
output_dir: Optional[str] = None,
|
|
63
83
|
answer_file: Optional[str] = None,
|
|
64
|
-
) ->
|
|
84
|
+
) -> Set:
|
|
65
85
|
r"""Takes in a TeX file for a given subject and outputs how it's broken down within Lambda Feedback.
|
|
66
86
|
|
|
67
87
|
Args:
|
|
@@ -80,50 +100,62 @@ def runner(
|
|
|
80
100
|
>>> from in2lambda.main import runner
|
|
81
101
|
>>> # Retrieve an example TeX file and run the given filter.
|
|
82
102
|
>>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsSepSol/example.tex", "PartsSepSol") # doctest: +ELLIPSIS
|
|
83
|
-
|
|
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'), ...])
|
|
84
104
|
>>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsOneSol/example.tex", "PartsOneSol") # doctest: +ELLIPSIS
|
|
85
|
-
|
|
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.'), ...])
|
|
86
106
|
"""
|
|
87
107
|
# The list of questions for Lambda Feedback as a Python API.
|
|
88
|
-
|
|
108
|
+
set_obj = Set()
|
|
89
109
|
|
|
90
110
|
# Dynamically import the correct pandoc filter depending on the subject.
|
|
91
111
|
filter_module = importlib.import_module(f"in2lambda.filters.{chosen_filter}.filter")
|
|
92
112
|
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
if file_type(question_file) == "docx":
|
|
114
|
+
# Convert .docx to md using Pandoc and proceed
|
|
115
|
+
text = docx_to_md(question_file)
|
|
116
|
+
input_format = "markdown"
|
|
117
|
+
else:
|
|
118
|
+
with open(question_file, "r", encoding="utf-8") as file:
|
|
119
|
+
text = file.read()
|
|
120
|
+
|
|
121
|
+
input_format = file_type(question_file)
|
|
95
122
|
|
|
96
123
|
# Parse the Pandoc AST using the relevant panflute filter.
|
|
97
124
|
pf.run_filter(
|
|
98
125
|
filter_module.pandoc_filter,
|
|
99
|
-
doc=pf.convert_text(
|
|
100
|
-
|
|
101
|
-
),
|
|
102
|
-
module=module,
|
|
126
|
+
doc=pf.convert_text(text, input_format=input_format, standalone=True),
|
|
127
|
+
set=set_obj,
|
|
103
128
|
tex_file=question_file,
|
|
104
129
|
parsing_answers=False,
|
|
105
130
|
)
|
|
106
131
|
|
|
107
132
|
# If separate answer TeX file provided, parse that as well.
|
|
108
133
|
if answer_file:
|
|
109
|
-
|
|
110
|
-
|
|
134
|
+
|
|
135
|
+
if file_type(answer_file) == "docx":
|
|
136
|
+
|
|
137
|
+
answer_text = docx_to_md(answer_file)
|
|
138
|
+
answer_format = "markdown"
|
|
139
|
+
else:
|
|
140
|
+
with open(answer_file, "r", encoding="utf-8") as file:
|
|
141
|
+
answer_text = file.read()
|
|
142
|
+
answer_format = file_type(answer_file)
|
|
111
143
|
|
|
112
144
|
pf.run_filter(
|
|
113
145
|
filter_module.pandoc_filter,
|
|
114
146
|
doc=pf.convert_text(
|
|
115
|
-
answer_text, input_format=
|
|
147
|
+
answer_text, input_format=answer_format, standalone=True
|
|
116
148
|
),
|
|
117
|
-
|
|
149
|
+
set=set_obj,
|
|
118
150
|
tex_file=answer_file,
|
|
119
151
|
parsing_answers=True,
|
|
120
152
|
)
|
|
121
153
|
|
|
122
154
|
# Read the Python API format and convert to JSON.
|
|
123
155
|
if output_dir is not None:
|
|
124
|
-
|
|
156
|
+
set_obj.to_json(output_dir)
|
|
125
157
|
|
|
126
|
-
return
|
|
158
|
+
return set_obj
|
|
127
159
|
|
|
128
160
|
|
|
129
161
|
@click.command(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "in2lambda"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "1.0.0"
|
|
4
4
|
description = "Converts content ready for import into Lambda Feedback"
|
|
5
5
|
authors = []
|
|
6
6
|
license = "MIT"
|
|
@@ -26,6 +26,7 @@ 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
30
|
|
|
30
31
|
[tool.poetry.scripts]
|
|
31
32
|
in2lambda = "in2lambda.main:cli"
|
|
@@ -56,6 +57,9 @@ sphinx-togglebutton = "^0.3.2"
|
|
|
56
57
|
# panflute is missing type hints
|
|
57
58
|
ignore_missing_imports = true
|
|
58
59
|
|
|
60
|
+
[tool.isort]
|
|
61
|
+
profile = "black"
|
|
62
|
+
|
|
59
63
|
[build-system]
|
|
60
64
|
requires = ["poetry-core"]
|
|
61
65
|
build-backend = "poetry.core.masonry.api"
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
"""Represents a list of questions."""
|
|
2
|
-
|
|
3
|
-
from dataclasses import dataclass, field
|
|
4
|
-
from typing import Union
|
|
5
|
-
|
|
6
|
-
import panflute as pf
|
|
7
|
-
|
|
8
|
-
from in2lambda.api.question import Question
|
|
9
|
-
from in2lambda.json_convert import json_convert
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@dataclass
|
|
13
|
-
class Module:
|
|
14
|
-
"""Represents a list of questions."""
|
|
15
|
-
|
|
16
|
-
questions: list[Question] = field(default_factory=list)
|
|
17
|
-
_current_question_index = -1
|
|
18
|
-
|
|
19
|
-
@property
|
|
20
|
-
def current_question(self) -> Question:
|
|
21
|
-
"""The current question being modified, or Question("INVALID") if there are no questions.
|
|
22
|
-
|
|
23
|
-
The reasoning behind returning Question("INVALID") is in case filter logic is being applied
|
|
24
|
-
on text before the first question (e.g. intro paragraphs). In that case, there is no effect.
|
|
25
|
-
|
|
26
|
-
Returns:
|
|
27
|
-
The current question or Question("INVALID") if there are no questions.
|
|
28
|
-
|
|
29
|
-
Examples:
|
|
30
|
-
>>> from in2lambda.api.module import Module
|
|
31
|
-
>>> Module().current_question
|
|
32
|
-
Question(title='INVALID', parts=[], images=[], main_text='')
|
|
33
|
-
>>> module = Module()
|
|
34
|
-
>>> module.add_question()
|
|
35
|
-
>>> module.current_question
|
|
36
|
-
Question(title='', parts=[], images=[], main_text='')
|
|
37
|
-
"""
|
|
38
|
-
return (
|
|
39
|
-
self.questions[self._current_question_index]
|
|
40
|
-
if self.questions
|
|
41
|
-
else Question("INVALID")
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
def add_question(
|
|
45
|
-
self, title: str = "", main_text: Union[pf.Element, str] = pf.Str("")
|
|
46
|
-
) -> None:
|
|
47
|
-
"""Inserts a new question into the module.
|
|
48
|
-
|
|
49
|
-
Args:
|
|
50
|
-
title: An optional string for the title of the question. If no title
|
|
51
|
-
is provided, the question title auto-increments i.e. Question 1, 2, etc.
|
|
52
|
-
main_text: An optional string or panflute element for the main question text.
|
|
53
|
-
|
|
54
|
-
Examples:
|
|
55
|
-
>>> from in2lambda.api.module import Module
|
|
56
|
-
>>> import panflute as pf
|
|
57
|
-
>>> module = Module()
|
|
58
|
-
>>> module.add_question("Some title", pf.Para(pf.Str("hello"), pf.Space, pf.Str("there")))
|
|
59
|
-
>>> module
|
|
60
|
-
Module(questions=[Question(title='Some title', parts=[], images=[], main_text='hello there')])
|
|
61
|
-
>>> module.add_question(main_text="Normal string text")
|
|
62
|
-
>>> module.questions[1].main_text
|
|
63
|
-
'Normal string text'
|
|
64
|
-
"""
|
|
65
|
-
question = Question(title=title)
|
|
66
|
-
question.main_text = main_text
|
|
67
|
-
self.questions.append(question)
|
|
68
|
-
|
|
69
|
-
def increment_current_question(self) -> None:
|
|
70
|
-
"""Manually overrides the current question being modified.
|
|
71
|
-
|
|
72
|
-
The default (-1) indicates the last question added. Incrementing for the
|
|
73
|
-
first time sets to 0 i.e. the first question.
|
|
74
|
-
|
|
75
|
-
The is useful if adding question text first and answers later.
|
|
76
|
-
|
|
77
|
-
Examples:
|
|
78
|
-
>>> from in2lambda.api.module import Module
|
|
79
|
-
>>> module = Module()
|
|
80
|
-
>>> # Imagine adding the questions from a question file first...
|
|
81
|
-
>>> module.add_question("Question 1")
|
|
82
|
-
>>> module.add_question("Question 2")
|
|
83
|
-
>>> # ...and then adding solutions from an answer file later
|
|
84
|
-
>>> module.increment_current_question() # Loop back to question 1
|
|
85
|
-
>>> module.current_question.add_solution("Question 1 answer")
|
|
86
|
-
>>> module.increment_current_question()
|
|
87
|
-
>>> module.current_question.add_solution("Question 2 answer")
|
|
88
|
-
>>> module.questions
|
|
89
|
-
[Question(title='Question 1', parts=[Part(text='', worked_solution='Question 1 answer')], images=[], main_text=''),\
|
|
90
|
-
Question(title='Question 2', parts=[Part(text='', worked_solution='Question 2 answer')], images=[], main_text='')]
|
|
91
|
-
"""
|
|
92
|
-
self._current_question_index += 1
|
|
93
|
-
|
|
94
|
-
def to_json(self, output_dir: str) -> None:
|
|
95
|
-
"""Turns this module into Lambda Feedback JSON/ZIP files.
|
|
96
|
-
|
|
97
|
-
WARNING: This will overwrite any existing files in the directory.
|
|
98
|
-
|
|
99
|
-
Args:
|
|
100
|
-
output_dir: Where to output the final Lambda Feedback JSON/ZIP files.
|
|
101
|
-
|
|
102
|
-
Examples:
|
|
103
|
-
>>> import tempfile
|
|
104
|
-
>>> import os
|
|
105
|
-
>>> import json
|
|
106
|
-
>>> # Create a module with two questions
|
|
107
|
-
>>> module = Module()
|
|
108
|
-
>>> module.add_question("Question 1")
|
|
109
|
-
>>> module.add_question("Question 2")
|
|
110
|
-
>>> with tempfile.TemporaryDirectory() as temp_dir:
|
|
111
|
-
... # Write the JSON files to the temporary directory
|
|
112
|
-
... module.to_json(temp_dir)
|
|
113
|
-
... # Check the contents of the directory
|
|
114
|
-
... sorted(os.listdir(temp_dir))
|
|
115
|
-
... # Check the title of the first question
|
|
116
|
-
... with open(f"{temp_dir}/question_1/question_1.json") as file:
|
|
117
|
-
... print(f"Question 1's title: {json.load(file)['title']}")
|
|
118
|
-
['question_1', 'question_1.zip', 'question_2', 'question_2.zip']
|
|
119
|
-
Question 1's title: Question 1
|
|
120
|
-
|
|
121
|
-
"""
|
|
122
|
-
json_convert.main(self.questions, output_dir)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Files relating to translating the questions of a Python module object into JSON."""
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
"""Converts questions from a Python module object into Lambda Feedback JSON."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
import shutil
|
|
6
|
-
from copy import deepcopy
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
from in2lambda.api.question import Question
|
|
11
|
-
|
|
12
|
-
MINIMAL_TEMPLATE = "minimal_template.json"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def converter(
|
|
16
|
-
template: dict[str, Any], ListQuestions: list[Question], output_dir: str
|
|
17
|
-
) -> None:
|
|
18
|
-
"""Turns a list of question objects into Lambda Feedback JSON.
|
|
19
|
-
|
|
20
|
-
Args:
|
|
21
|
-
template: The loaded JSON from the minimal template (it needs to be in sync).
|
|
22
|
-
ListQuestions: A list of question objects.
|
|
23
|
-
output_dir: The absolute path for where to produced the final JSON/zip files.
|
|
24
|
-
"""
|
|
25
|
-
# Create output by copying template
|
|
26
|
-
|
|
27
|
-
for i in range(len(ListQuestions)):
|
|
28
|
-
output = deepcopy(template)
|
|
29
|
-
|
|
30
|
-
# add title to the question file
|
|
31
|
-
if ListQuestions[i].title != "":
|
|
32
|
-
output["title"] = ListQuestions[i].title
|
|
33
|
-
else:
|
|
34
|
-
output["title"] = "Question " + str(i + 1)
|
|
35
|
-
|
|
36
|
-
# add main text to the question file
|
|
37
|
-
output["masterContent"] = ListQuestions[i].main_text
|
|
38
|
-
|
|
39
|
-
# add parts to the question file
|
|
40
|
-
if ListQuestions[i].parts:
|
|
41
|
-
output["parts"][0]["content"] = ListQuestions[i].parts[0].text
|
|
42
|
-
output["parts"][0]["workedSolution"][0]["content"] = (
|
|
43
|
-
ListQuestions[i].parts[0].worked_solution
|
|
44
|
-
)
|
|
45
|
-
for j in range(1, len(ListQuestions[i].parts)):
|
|
46
|
-
output["parts"].append(deepcopy(template["parts"][0]))
|
|
47
|
-
output["parts"][j]["content"] = ListQuestions[i].parts[j].text
|
|
48
|
-
output["parts"][j]["workedSolution"][0]["content"] = (
|
|
49
|
-
ListQuestions[i].parts[j].worked_solution
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
# Output file
|
|
53
|
-
filename = "question_" + str(i + 1)
|
|
54
|
-
|
|
55
|
-
# create directory to put the questions
|
|
56
|
-
os.makedirs(output_dir, exist_ok=True)
|
|
57
|
-
output_question = os.path.join(output_dir, filename)
|
|
58
|
-
os.makedirs(output_question, exist_ok=True)
|
|
59
|
-
|
|
60
|
-
# create directory to put image
|
|
61
|
-
output_image = os.path.join(output_question, "media")
|
|
62
|
-
os.makedirs(output_image, exist_ok=True)
|
|
63
|
-
|
|
64
|
-
# write questions into directory
|
|
65
|
-
with open(f"{output_question}/{filename}.json", "w") as file:
|
|
66
|
-
json.dump(output, file)
|
|
67
|
-
|
|
68
|
-
# write image into directory
|
|
69
|
-
for k in range(len(ListQuestions[i].images)):
|
|
70
|
-
image_path = os.path.abspath(
|
|
71
|
-
ListQuestions[i].images[k]
|
|
72
|
-
) # converts computer path into python path
|
|
73
|
-
shutil.copy(image_path, output_image) # copies image into the directory
|
|
74
|
-
|
|
75
|
-
# output zip file in destination folder
|
|
76
|
-
shutil.make_archive(output_question, "zip", output_question)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def main(questions: list[Question], output_dir: str) -> None:
|
|
80
|
-
"""Preliminary defensive programming before calling the main converter function.
|
|
81
|
-
|
|
82
|
-
This ultimately then produces the Lambda Feedback JSON/ZIP files.
|
|
83
|
-
|
|
84
|
-
Args:
|
|
85
|
-
questions: A list of question objects.
|
|
86
|
-
output_dir: Where to output the final Lambda Feedback JSON/ZIP files.
|
|
87
|
-
"""
|
|
88
|
-
# Use path so minimal template can be found regardless of where the user is running python from.
|
|
89
|
-
with open(Path(__file__).with_name(MINIMAL_TEMPLATE), "r") as file:
|
|
90
|
-
template = json.load(file)
|
|
91
|
-
|
|
92
|
-
# check if directory exists in file
|
|
93
|
-
if os.path.isdir(output_dir):
|
|
94
|
-
try:
|
|
95
|
-
shutil.rmtree(output_dir)
|
|
96
|
-
except OSError as e:
|
|
97
|
-
print("Error: %s : %s" % (output_dir, e.strerror))
|
|
98
|
-
converter(template, questions, output_dir)
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"masterContent": "Top level question here",
|
|
3
|
-
"parts": [
|
|
4
|
-
{
|
|
5
|
-
"answer": "",
|
|
6
|
-
"content": "Part text here",
|
|
7
|
-
"responseArea": [],
|
|
8
|
-
"tutorial": [],
|
|
9
|
-
"universalPartId": "N/A",
|
|
10
|
-
"workedSolution": [
|
|
11
|
-
{
|
|
12
|
-
"content": "Part worked solution here",
|
|
13
|
-
"id": "N/A",
|
|
14
|
-
"title": ""
|
|
15
|
-
}
|
|
16
|
-
]
|
|
17
|
-
}
|
|
18
|
-
],
|
|
19
|
-
"publish": false,
|
|
20
|
-
"questionSettings": {
|
|
21
|
-
"displayFinalAnswer": true,
|
|
22
|
-
"displayStructuredTutorial": true,
|
|
23
|
-
"displayWorkedSolution": true
|
|
24
|
-
},
|
|
25
|
-
"title": "Question title here"
|
|
26
|
-
}
|
|
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
|