keefereuther commited on
Commit
a79f199
·
verified ·
1 Parent(s): 116f5ea

Upload 32 files

Browse files
CHANGELOG.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Change Log
2
+
3
+
4
+ ## v0.7.0 (dev)
5
+
6
+ * Numbers in scientific notation with leading zeros in the exponent are now
7
+ permitted for siunitx notation within Markdown. The zeros are stripped
8
+ during processing.
9
+
10
+
11
+
12
+ ## v0.6.0 (2022-08-31)
13
+
14
+ * Leading plus signs `+` are now stripped for siunitx notation within
15
+ Markdown.
16
+
17
+ * Added `amsmath`, `amssymb`, and `siunitx` to template for LaTeX solutions
18
+ export.
19
+
20
+ * Fixed a bug with error handling when Pandoc fails to export solutions (#47).
21
+
22
+ * For question groups, the value of `pick` is now allowed to equal the total
23
+ number of questions (#44).
24
+
25
+ * Added support for Pandoc-style attributes on images:
26
+ `![alt_text](image_file){#id .class1 .class2 width=10em height=5em}` (#41).
27
+
28
+ * Executable code blocks now use PATH to locate executables under Windows, and
29
+ thus now work with Python environments. Previously PATH was ignored under
30
+ Windows due to the implementation details of Python's `subprocess.Popen()`
31
+ (#34, #42).
32
+
33
+ * Added command-line options `--solutions` and `--only-solutions` to
34
+ command-line application. These generate solutions in Pandoc Markdown,
35
+ PDF, and HTML formats. Pandoc Markdown solutions are only suitable for
36
+ use with LaTeX and HTML (#35).
37
+
38
+ * Added quiz-level options `feedback is solution`, `solutions sample groups`,
39
+ and `solutions randomize groups` for customizing solutions. Added
40
+ group-level option `solutions pick` for customizing solutions. Added
41
+ question-level syntax involving `!` for providing solutions.
42
+
43
+ * In quiz parsing, simplified question type handling and error checking.
44
+
45
+ * Fixed bug in calculation of total points possible per quiz.
46
+
47
+
48
+
49
+ ## v0.5.0 (2020-09-28)
50
+
51
+ * Added `text2qti_tk` executable, which provides a basic graphical user
52
+ interface (GUI) via `tkinter`. Added build scripts in `make_gui_exe/` for
53
+ creating a standalone GUI executable under Windows with PyInstaller (#27).
54
+ * In executable code blocks, `.python` now invokes `python3` on systems where
55
+ `python` is equivalent to `python2` as well as on systems that lack a
56
+ `python` executable. The README now suggests using `.python3` and
57
+ `.python2` to be more explicit (#22).
58
+ * Added a new keyword argument `executable` for executable code blocks, which
59
+ allows a custom executable (including path) to be specified for running
60
+ code. Added support for periods in code block language attributes, so that
61
+ things like `.python3.8` are now possible.
62
+ * Fixed bug caused by swapped identifiers in QTI XML (#18, #19). Now quiz
63
+ descriptions work with Canvas and question titles appear in Canvas in the
64
+ quiz editor (but not in the student view). The following options now work
65
+ with Canvas: `Shuffle answers`, `Show correct answers`,
66
+ `One question at a time`, and `Can't go back`.
67
+ * Installation (`setup.py`) now requires `markdown` >= 3.2 to ensure
68
+ compatibility and avoid Markdown parsing bugs fixed in 3.2 (#31).
69
+ * Fixed bug that produced incorrect QTI output paths when using quiz files
70
+ outside the current working directory (#28, #29).
71
+ * README now covers more options for installing the development version (#20).
72
+
73
+
74
+
75
+ ## v0.4.0 (2020-07-17)
76
+
77
+ * Improved preprocessing for siunitx notation, LaTeX math, and HTML comments.
78
+ Fixed catastrophic backtracking in LaTeX math regex (#11). Added support
79
+ for newlines in HTML comments. The preprocessor now skips backslash
80
+ escapes, inline code containing newlines, and fenced code blocks (as long as
81
+ they do not start on the same line as a list marker). The preprocessor now
82
+ handles the escape `\$` itself, since Python-Markdown ignores it (#14).
83
+ * Python-Markdown's Markdown-in-HTML extension is now enabled (#13).
84
+ * Added quiz options `Shuffle answers`, `Show correct answers`,
85
+ `One question at a time`, and `Can't go back` (#10). These options are
86
+ apparently ignored by Canvas, but may work with some other systems.
87
+ * Revised README to clarify that some features are apparently not supported
88
+ by Canvas (#16).
89
+
90
+
91
+
92
+ ## v0.3.0 (2020-05-26)
93
+
94
+ * Added support for multiple-answers questions.
95
+ * Added support for short-answer (fill-in-the-blank) questions.
96
+ * Added support for file-upload questions.
97
+ * Added support for setting question titles and point values (#9).
98
+ * Added support for text regions outside questions.
99
+ * Added `--pandoc-mathml` command-line option. This converts LaTeX to MathML
100
+ via Pandoc, rather than using a Canvas LaTeX rendering URL (#4).
101
+ * Added support for comments at the top level of quiz files (outside Markdown
102
+ content like questions, choices, or feedback). HTML comments within
103
+ Markdown are now stripped and no longer appear in the final QTI file (#2).
104
+ * For numerical questions, exact answers with allowed margin are now treated
105
+ as exact answers, rather than being converted into ranges of values. This
106
+ primarily affects how feedback for incorrect answers is worded (#7).
107
+ * Essay questions now support general feedback.
108
+ * Fixed a bug that prevented incorrect question feedback from working.
109
+ * Relaxed indentation requirements for quiz titles spanning multiple lines.
110
+ Indentation for wrapped lines must now be at least 2 spaces or 1 tab, rather
111
+ than being equivalent to that of the first character in the title.
112
+ * Fixed a bug that allowed trailing whitespace to cause incorrect indentation
113
+ calculations, resulting in indentation errors in valid quizzes.
114
+
115
+
116
+
117
+ ## v0.2.0 (2020-04-23)
118
+
119
+ * Added support for images, using standard Markdown syntax.
120
+ * Added support for multi-paragraph descriptions, questions, choices, and
121
+ feedback.
122
+ * Added support for essay questions.
123
+ * Added support for numerical questions.
124
+ * Added support for question groups. A subset of the questions in a group is
125
+ chosen at random when a quiz is taken.
126
+ * Added support for executable code blocks. These can be used to generate
127
+ questions automatically. This feature requires the command-line flag
128
+ `--run-code-blocks` or setting `run_code_blocks = true` in the config
129
+ file.
130
+ * Quiz titles are now processed as plain text, rather than as Markdown,
131
+ because QTI does not support HTML titles.
132
+ * Fixed a bug that prevented source file names from appearing in error
133
+ messages. Fixed misspelling of "imsmanifest.xml" in QTI output.
134
+
135
+
136
+
137
+ ## v0.1.0 (2020-04-01)
138
+
139
+ * Initial release.
LICENSE.txt ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2020, Geoffrey M. Poore
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ * Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
MANIFEST.in ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ include setup.cfg setup.py README.md LICENSE.txt CHANGELOG.md
2
+ recursive-include text2qti *.py
README.md CHANGED
@@ -1,21 +1,720 @@
1
- ---
2
- title: Text2qti
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: streamlit
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
- pinned: false
11
- short_description: Quiz converter from Markdown to LMS qti.zip
12
- license: gpl-3.0
13
- sdk_version: 1.45.1
14
- ---
15
-
16
- # Welcome to Streamlit!
17
-
18
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
19
-
20
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
21
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # text2qti – Create quizzes in QTI format from Markdown-based plain text
2
+
3
+ text2qti converts
4
+ [Markdown](https://daringfireball.net/projects/markdown/)-based plain text
5
+ files into quizzes in QTI format (version 1.2), which can be imported by
6
+ [Canvas](https://www.instructure.com/canvas/) and other educational software.
7
+ It supports multiple-choice, true/false, multiple-answers, numerical,
8
+ short-answer (fill-in-the-blank), essay, and file-upload questions. It
9
+ includes basic support for LaTeX math within Markdown, and allows a limited
10
+ subset of [siunitx](https://ctan.org/pkg/siunitx) notation for units and for
11
+ numbers in scientific notation.
12
+
13
+
14
+
15
+ ## Examples
16
+
17
+ text2qti allows quick and efficient quiz creation. Example
18
+ **multiple-choice** plain-text quiz question that can be converted to QTI and
19
+ then imported by Canvas:
20
+
21
+ ```
22
+ 1. What is 2+3?
23
+ a) 6
24
+ b) 1
25
+ *c) 5
26
+ ```
27
+
28
+ A **question** is created by a line that starts with a number followed by a
29
+ period and one or more spaces or tabs ("`1. `"). Possible **choices** are
30
+ created by lines that start with a letter followed by a closing parenthesis
31
+ and one or more spaces or tabs ("`a) `"). Numbers and letters do not have to
32
+ be ordered or unique. The **correct** choice is designated with an asterisk
33
+ ("`*c) `"). All question and choice text is processed as
34
+ [Markdown](https://daringfireball.net/projects/markdown/).
35
+
36
+ There is also support for a quiz title and description, as well as question
37
+ titles, point values, and feedback. Note that unlike most other text, titles
38
+ like quiz and question titles are treated as plain text, not Markdown, due to
39
+ the QTI format. **Also note that Canvas correctly shows question titles
40
+ within its quiz editor for instructors, but always replaces them with titles
41
+ like "Question 1" in the student view.** Question point values must be
42
+ positive integers or half-integers.
43
+
44
+ ```
45
+ Quiz title: Addition
46
+ Quiz description: Checking addition.
47
+
48
+ Title: An addition question
49
+ Points: 2
50
+ 1. What is 2+3?
51
+ ... General question feedback.
52
+ + Feedback for correct answer.
53
+ - Feedback for incorrect answer.
54
+ a) 6
55
+ ... Feedback for this particular answer.
56
+ b) 1
57
+ ... Feedback for this particular answer.
58
+ *c) 5
59
+ ... Feedback for this particular answer.
60
+ ```
61
+
62
+ **Multiple-answers questions** use `[]` or `[ ]` for incorrect answers and
63
+ `[*]` for correct answers.
64
+
65
+ ```
66
+ 1. Which of the following are dinosaurs?
67
+ [ ] Woolly mammoth
68
+ [*] Tyrannosaurus rex
69
+ [*] Triceratops
70
+ [ ] Smilodon fatalis
71
+ ```
72
+
73
+ **Numerical questions** use an equals sign followed by one or
74
+ more spaces or tabs followed by the numerical answer. Acceptable answers can
75
+ be designated as a range of the form `[<min>, <max>]` or as a correct answer
76
+ with a specified acceptable margin of error `<ans> +- <margin>`. When the
77
+ latter form is used, `<margin>` can be either a number or a percentage.
78
+ `<margin>` can be omitted when the answer is an integer and an exact answer is
79
+ required. In this case, scientific notation is not permitted, but the
80
+ underscore can be used as a digit separator; for example, `1000` and `1_000`
81
+ are both valid, but `1e3` is not. An exact answer can be required for
82
+ floating-point numbers, but this requires an explicit `+- 0`, since a range is
83
+ typically more appropriate for floating-point values. Numerical questions
84
+ have the limitation that the absolute value of the smallest acceptable answer
85
+ must be greater than or equal to 0.0001 (1e-4).
86
+
87
+ ```
88
+ 1. What is the square root of 2?
89
+ = 1.4142 +- 0.0001
90
+
91
+ 2. What is the cube root of 2?
92
+ = [1.2598, 1.2600]
93
+
94
+ 3. What is 2+3?
95
+ = 5
96
+ ```
97
+
98
+ **Short-answer (fill-in-the-blank) questions** use an asterisk followed by one
99
+ or more spaces or tabs followed by an answer. Multiple acceptable answers can
100
+ be given. Answers are restricted to a single line each and are treated as
101
+ plain text, not Markdown.
102
+ ```
103
+ 1. Who lives at the North Pole?
104
+ * Santa
105
+ * Santa Claus
106
+ * Father Christmas
107
+ * Saint Nicholas
108
+ * Saint Nick
109
+ ```
110
+
111
+ **Essay questions** are indicated by a sequence of three or more underscores.
112
+ They only support general question feedback.
113
+
114
+ ```
115
+ 1. Write an essay.
116
+ ... General question feedback.
117
+ ____
118
+ ```
119
+
120
+ **File-upload questions** are indicated by a sequence of three or more
121
+ circumflex accents. They only support general question feedback.
122
+ ```
123
+ 1. Upload a file.
124
+ ... General question feedback.
125
+ ^^^^
126
+ ```
127
+
128
+ **Text regions** outside of questions are supported. Note that unlike most
129
+ other text, titles like text region titles are treated as plain text, not
130
+ Markdown, due to the QTI format. Also note that Canvas apparently ignores the
131
+ text region title and only displays the text itself. Text regions are not
132
+ required to have both a title and text; either may be used alone, but the
133
+ title must come first when both are present.
134
+ ```
135
+ Text title: Instructions about the next questions
136
+ Text: General comments about the next questions.
137
+ ```
138
+
139
+
140
+ ## Installation
141
+
142
+ Install **Python 3.6+** if it is not already available on your machine. See
143
+ https://www.python.org/, or use the package manager or app store for your
144
+ operating system. Depending on your use case, you may want to consider a
145
+ Python distribution like [Anaconda](https://www.anaconda.com/distribution/)
146
+ instead.
147
+
148
+ Install
149
+ [setuptools](https://packaging.python.org/tutorials/installing-packages/)
150
+ for Python if it is not already installed. This can be accomplished by
151
+ running
152
+ ```
153
+ python -m pip install setuptools
154
+ ```
155
+ on the command line. Depending on your system, you may need to use `python3`
156
+ instead of `python`. This will often be the case for Linux and OS X.
157
+
158
+ Install text2qti by running this on the command line:
159
+ ```
160
+ python -m pip install text2qti
161
+ ```
162
+ Depending on your system, you may need to use `python3` instead of `python`.
163
+ This will often be the case for Linux and OS X.
164
+
165
+
166
+ ### Upgrading
167
+
168
+ ```
169
+ python -m pip install text2qti --upgrade
170
+ ```
171
+ Depending on your system, you may need to use `python3` instead of `python`.
172
+ This will often be the case for Linux and OS X.
173
+
174
+
175
+ ### Installing the development version
176
+
177
+ If you want to install the development version to use the latest features,
178
+ download `text2qti` from [GitHub](https://github.com/gpoore/text2qti) and
179
+ extract the files. A few different ways to install the development version
180
+ are listed below. Depending on your system, you may need to use `python3`
181
+ instead of `python` in the commands below. This will often be the case for
182
+ Linux and OS X.
183
+
184
+ * You can install using the included `setup.py` by running
185
+ ```
186
+ python setup.py install
187
+ ```
188
+ Depending on your system configuration, especially if you do not have root
189
+ or administrator privileges, you may want to
190
+ [customize the installation location](https://docs.python.org/3.8/install/#alternate-installation-the-user-scheme).
191
+ For example, you can add `--user` to install under `%APPDATA%\Python` (Windows), `~/.local` (UNIX, and Mac OS X non-framework builds), or
192
+ `~/Library/Python/<VERSION>` (Mac framework builds):
193
+ ```
194
+ python setup.py install --user
195
+ ```
196
+ * You can install using `pip`. For example, in the directory with `setup.py`,
197
+ run this:
198
+ ```
199
+ python -m pip install .
200
+ ```
201
+
202
+
203
+
204
+
205
+ ## Usage
206
+
207
+ text2qti has been designed to create QTI files for use with
208
+ [Canvas](https://www.instructure.com/canvas/). Some features may not be
209
+ supported by other educational software. You should **always preview**
210
+ quizzes or assessments after converting them to QTI and importing them.
211
+
212
+ Write your quiz or assessment in a plain text file. You can use a basic
213
+ editor like Notepad or gedit, or a code editor like
214
+ [VS Code](https://code.visualstudio.com/). You can even use Microsoft Word,
215
+ as long as you save your file as plain text (*.txt).
216
+
217
+ text2qti includes a graphical application and a command-line application.
218
+
219
+ * To use the graphical application, open a command line and run `text2qti_tk`.
220
+
221
+ * To use the command-line application, open a command line in the same folder
222
+ or directory as your quiz file. Under Windows, you can hold the SHIFT
223
+ button down on the keyboard, then right click next to your file, and select
224
+ "Open PowerShell window here" or "Open command window here". You can also
225
+ launch "Command Prompt" or "PowerShell" through the Start menu, and then
226
+ navigate to your file using `cd`.
227
+
228
+ Run the `text2qti` application using a command like this:
229
+ ```
230
+ text2qti quiz.txt
231
+ ```
232
+ Replace "quiz.txt" with the name of your file. This will create a file like
233
+ `quiz.zip` (with "quiz" replaced by the name of your file) which is the
234
+ converted quiz in QTI format.
235
+
236
+ Instructions for using the QTI file with Canvas:
237
+ * Go to the course in which you want to use the quiz.
238
+ * Go to Settings, click on "Import Course Content", select "QTI .zip file",
239
+ choose your file, and click "Import". Typically you should not need to
240
+ select a question bank; that should be managed automatically.
241
+ * While the quiz upload will often be very fast, there is an additional
242
+ processing step that can take up to several minutes. The status will
243
+ probably appear under "Current Jobs" after upload.
244
+ * Once the quiz import is marked as "Completed", the imported quiz should be
245
+ available under Quizzes. If the imported quiz does not appear after
246
+ several minutes, there may be an error in your quiz file or a bug in
247
+ `text2qti`. When Canvas encounters an invalid quiz file, it tends to fail
248
+ silently; instead of reporting an error in the quiz file, it just never
249
+ creates a quiz based on the invalid file.
250
+ * You should **always preview the quiz before use**. text2qti can detect a
251
+ number of potential issues, but not everything.
252
+
253
+ Typically, you should start your quizzes with a title, like this:
254
+ ```
255
+ Quiz title: Title here
256
+ ```
257
+ Otherwise, all quizzes will have the default title "Quiz", so it will be
258
+ difficult to tell them apart. Another option is to rename quizzes after
259
+ importing them. Note that unlike most other text, the title is treated as
260
+ plain text, not Markdown, due to the QTI format.
261
+
262
+ When you run `text2qti` for the first time, it will attempt to create a
263
+ configuration file called `.text2qti.bespon` in your home or user directory.
264
+ It will also ask for an institutional LaTeX rendering URL. This is only
265
+ needed if you plan to use LaTeX math; if not, simply press ENTER to continue.
266
+ * If you use Canvas, log into your account and look in the browser address
267
+ bar. You will typically see an address that starts with something like
268
+ `institution.instructure.com/` or `canvas.institution.edu/`, with
269
+ `institution` replaced by the name of your school or an abbreviation for
270
+ it. The LateX rendering URL that you want to use will then be something
271
+ like `https://institution.instructure.com/equation_images/` or
272
+ `https://canvas.institution.edu/equation_images/`, with `institution`
273
+ replaced by the appropriate value for your school. If the URL is like the
274
+ second form, you may need to replace the `.edu` domain with the appropriate
275
+ value for your institution.
276
+ * If you use other educational software that handles LaTeX in a manner
277
+ compatible with Canvas, consult the documentation for your software. Or
278
+ perhaps create a simple quiz within the software using its built-in tools,
279
+ then export the quiz to QTI and look through the resulting output to find
280
+ the URL.
281
+ * If you are using educational software that does not handle LaTeX in a
282
+ manner compatible with Canvas, try the `--pandoc-mathml` command-line
283
+ option when creating QTI files (note that this requires that
284
+ [Pandoc](https://pandoc.org/) be installed). If that does not work, please
285
+ open an issue requesting support for that software, and include as much
286
+ information as possible about how that software processes LaTeX.
287
+
288
+
289
+
290
+ ## Additional features
291
+
292
+ ### Question groups
293
+
294
+ A question group contains multiple questions, and only a specified number of
295
+ these are randomly selected and used each time a quiz is taken.
296
+
297
+ ```
298
+ GROUP
299
+ pick: 1
300
+ points per question: 1
301
+
302
+ 1. A question.
303
+ *a) true
304
+ b) false
305
+
306
+ 2. Another question.
307
+ *a) true
308
+ b) false
309
+
310
+ END_GROUP
311
+ ```
312
+
313
+ The number of questions from the group that are used is specified with
314
+ "`pick:`". If this is omitted, it defaults to `1`. The points assigned per
315
+ question is specified with "`points per question:`". If this is omitted, it
316
+ defaults to `1`. All questions within a group must be worth the same number
317
+ of points.
318
+
319
+
320
+ ### Executable code blocks
321
+
322
+ text2qti can execute the code in Markdown-style fenced code blocks. Code can
323
+ be used to generate questions within a quiz. Everything written to stdout by
324
+ the executed code is included in the quiz file; the code block is replaced by
325
+ stdout.
326
+
327
+ ``````
328
+ ```{.python .run}
329
+ import textwrap
330
+ for x in [2, 3]:
331
+ print(textwrap.dedent(rf"""
332
+ 1. What is ${x}\times 5$?
333
+ *a) ${x*5}$
334
+ b) ${x+1}$
335
+ """))
336
+ ```
337
+ ``````
338
+
339
+
340
+ For code to be executed, there are a few requirements:
341
+
342
+ * The code block fences (` ``` `) must not be indented; the code block must be
343
+ at the top level of the document, not part of a question, choice, or
344
+ feedback.
345
+
346
+ * As a security measure, code execution is disabled by default, so executable
347
+ code blocks will trigger an error. Run `text2qti` with the option
348
+ `--run-code-blocks` to enable code execution, or set `run_code_blocks =
349
+ true` in the text2qti config file in your user or home directory.
350
+
351
+ * The text immediately after the opening fence must have the form
352
+ `{.lang .run}` or `{.lang .run executable=<executable>}`. This is inspired
353
+ by the code-block attributes in
354
+ [Pandoc Markdown](https://pandoc.org/MANUAL.html).
355
+
356
+ If the keyword argument `executable` is not provided, then `lang` must
357
+ designate an executable that can run the code once the code has been saved
358
+ to a file. In the example above, `python` is extracted from the first line
359
+ (` ```{.python .run}`), code is saved in a temporary file, and then the
360
+ file is executed via `python <file>`.
361
+
362
+ If `executable` is used to specify an executable, then `lang` is ignored by
363
+ `text2qti`, but it is still useful since some editors will use it to provide
364
+ syntax highlighting.
365
+
366
+ When `executable` is specified, the executable path must be quoted with
367
+ double quotes `"` if it contains anything other than the tilde, Unicode word
368
+ characters, numbers, forward slashes, periods, hyphens, and underscores.
369
+ When the executable path is quoted, backslashes and quotation marks are
370
+ still prohibited; forward slashes should be used under all operating systems
371
+ including Windows. A leading `~` in the executable path is expanded to the
372
+ user's home directory under all operating systems including Windows.
373
+
374
+ * **Special Python note**: When `.python` is used with an executable code
375
+ block without specifying an `executable`, code will run with `python3` if
376
+ either of these conditions is met:
377
+
378
+ - `python3` exists on the system and `python` is equivalent to `python2`.
379
+
380
+ - `python` does not exist on the system, but `python3` does exist.
381
+
382
+ To avoid ambiguity, you may want to use `.python3` and `.python2` rather
383
+ than `.python` when working with operating systems other than Windows, or
384
+ when working with a Windows installation that includes a `python3`
385
+ executable or symlink. It is also possible to be even more specific by
386
+ using something like `.python3.8`.
387
+
388
+ Each code block is executed in its own process, so data and variables are not
389
+ shared between code blocks.
390
+
391
+ If an executable code block generates multiple questions that are identical,
392
+ or multiple choices for a single question that are identical, this will be
393
+ detected by `text2qti` and an error will be reported. Questions or choices
394
+ that may be equivalent, but are not represented by exactly the same text,
395
+ cannot be detected (for example, things like `100` versus `1e2`, or `answer`
396
+ versus `Answer`).
397
+
398
+
399
+ ### Additional quiz options
400
+
401
+ There are additional quiz options that can be set immediately after the quiz
402
+ title and quiz description. These all take values `true` or `false`. For
403
+ example, `shuffle answers: true` could be on the line right after the quiz
404
+ description.
405
+
406
+ * `shuffle answers` — Shuffle answer order for questions.
407
+ * `show correct answers` — Show correct answers after submission.
408
+ * `one question at a time` — Only show one question at a time.
409
+ * `can't go back` — Don't allow going back to the previous question when in
410
+ `one question at a time` mode.
411
+
412
+
413
+
414
+
415
+ ## Details for writing quiz text
416
+
417
+ text2qti processes almost all text as
418
+ [Markdown](https://daringfireball.net/projects/markdown/), using
419
+ [Python-Markdown](https://python-markdown.github.io/). (The only exceptions
420
+ are the quiz title, question titles, and text region titles, which are
421
+ processed as plain text due to the QTI format, plus the acceptable answers
422
+ for short-answer questions.) For example, `*emphasized*` produces *emphasized*
423
+ text, which typically appears as italics. Text can be styled using Markdown
424
+ notation, or with HTML. Remember to preview quizzes after conversion to QTI,
425
+ especially when using any significant amount of HTML.
426
+
427
+ Python-Markdown provides several
428
+ [extensions to basic Markdown](https://python-markdown.github.io/extensions/).
429
+ Currently, the following extensions are enabled:
430
+ * `smarty`: Automatic curly quotation marks and dashes.
431
+ * `sane_lists`: List behavior is closer to what might be expected.
432
+ * `def_list`: Definition lists of this form:
433
+ ```
434
+ term
435
+ : definition
436
+ * `fenced_code`: Fenced code blocks (` ``` ` or `~~~`).
437
+ * `footnotes`: Footnotes using this form:
438
+ ```
439
+ Normal text [^1].
440
+
441
+ [^1]: Footnote text.
442
+ * `tables`: Tables of this form:
443
+ ```
444
+ Header | Header
445
+ ------ | ------
446
+ Cell | Cell
447
+ Cell | Cell
448
+ ```
449
+ * `md_in_html`: Text inside HTML tags is treated as Markdown. This requires
450
+ setting the attribute `markdown="1"` in the opening tag for block-level
451
+ elements. See the
452
+ [documentation](https://python-markdown.github.io/extensions/md_in_html/)
453
+ for more details about proper usage and potential issues.
454
+
455
+ While indented Markdown code blocks are supported, fenced code blocks should
456
+ be preferred. Indented code can interfere with the preprocessor that strips
457
+ HTML comments and handles LaTeX math and siunitx notation.
458
+
459
+
460
+ ### Titles
461
+
462
+ Quiz, question, and text region titles are limited to a single paragraph. If
463
+ this paragraph is wrapped over multiple lines, all lines after the first must
464
+ be indented by at least two spaces or one tab, and share the same indentation.
465
+ All tabs are expanded to 4 spaces before indentation is compared, following
466
+ the typical Markdown approach.
467
+
468
+ All titles are treated as plain text, not Markdown, due to the QTI format.
469
+
470
+ Titles are always optional, but when they are used for a given element, they
471
+ are always required to be first, before any other attributes.
472
+
473
+
474
+ ### Descriptions, questions, choices, feedback, and text regions
475
+
476
+ Descriptions, questions, choices, feedback, and text regions may span multiple
477
+ paragraphs and include arbitrary Markdown content like code blocks or
478
+ quotations. Everything must be indented to at least the same level as the
479
+ start of the first paragraph on the initial line. All tabs are expanded to 4
480
+ spaces before indentation is compared, following the typical Markdown
481
+ approach. For example,
482
+ ```
483
+ 1. A question paragraph that is long enough to wrap onto a second line.
484
+ The second line must be indented to match up with the start of the
485
+ paragraph text on the first line.
486
+
487
+ Another paragraph.
488
+ ```
489
+ Note that the acceptable answers for short-answer questions are treated as
490
+ plain text and limited to a single line, and numerical answers are also
491
+ processed specially and limited to a single line.
492
+
493
+
494
+ ### Images
495
+
496
+ Images are included with the standard Markdown syntax:
497
+ ```
498
+ ![alt_text](image_file)
499
+ ```
500
+ It will typically be easiest to put your image files in the same folder or
501
+ directory as the quiz file, so you can use something like `![alt](image.jpg)`.
502
+ However, file paths are supported, including `~` user expansion under all
503
+ operating systems. All image paths not starting with `http://` or `https://`
504
+ are assumed to refer to local image files (files on your machine), and will
505
+ result in errors if these files are not found.
506
+
507
+ [Pandoc-style attributes](https://pandoc.org/MANUAL.html#images) can be used
508
+ with images:
509
+ ```
510
+ ![alt_text](image_file){#id .class1 .class2 width=10em height=5em}
511
+ ```
512
+ This allows image id, classes, and dimensions to be specified without
513
+ resorting to HTML.
514
+
515
+
516
+ ### LaTeX
517
+
518
+ By default, text2qti supports LaTeX using a Canvas LaTeX rendering URL. This
519
+ can be set during installation, or by editing the configuration file
520
+ `.text2qti.bespon` in your home or user directory. It is possible to convert
521
+ LaTeX to MathML instead with the `--pandoc-mathml` command-line option. This
522
+ requires that [Pandoc](https://pandoc.org/) be installed for converting LaTeX
523
+ to MathML. For example, to create a quiz you might run a command like this:
524
+ ```
525
+ text2qti --pandoc-mathml quiz.txt
526
+ ```
527
+ When `--pandoc-mathml` is used, a cache file `_text2qti_cache.zip` will be
528
+ created in the quiz file directory. This is used to store Pandoc MathML
529
+ output to increase performance for long quizzes with lots of math.
530
+
531
+ text2qti supports inline LaTeX math within dollar signs `$`. There must be a
532
+ non-space character immediately after the opening `$` and immediately before
533
+ the closing `$`. For example, `$F = ma$`. LaTeX math is limited to what is
534
+ supported by Canvas or whatever other educational software you are using. It
535
+ is usually a good idea to preview imported quizzes before assigning them,
536
+ because text2qti cannot detect LaTeX incompatibilities or limitations. There
537
+ is currently not support for block LaTeX math; only inline math is supported.
538
+
539
+ When using Canvas with LaTeX math, be aware that in some cases Canvas's
540
+ vertical alignment of math leaves much to be desired. Sometimes this can be
541
+ improved by including `\vphantom{fg}` or `\strut` at the beginning of an
542
+ equation. An alternative is simply to use LaTeX for all question or choice
543
+ text (via `\text`, etc.).
544
+
545
+ text2tqi supports a limited subset of LaTeX
546
+ [siunitx](https://ctan.org/pkg/siunitx) notation. You can use notation like
547
+ `\num{1.23e5}` to enter numbers in scientific notation. This would result in
548
+ `1.23×10⁵`. You can use notation like `\si{m/s}` or `\si{N.m}` to enter
549
+ units. These would result in `m/s` and `N·m`. Unit macros currently are not
550
+ supported, with these exceptions: `\degree`, `\celsius`, `\fahrenheit`,
551
+ `\ohm`, `\micro`. Finally, numbers and units can be combined with notation
552
+ like `\SI{1.23e5}{m/s}`. All of these can be used inside or outside LaTeX
553
+ math.
554
+
555
+ Technical note: LaTeX and siunitx support are currently implemented as
556
+ preprocessors that are used separately from Python-Markdown. In rare cases,
557
+ this may lead to conflicts with Markdown syntax. These features may be
558
+ reimplemented as Python-Markdown extensions in the future.
559
+
560
+
561
+ ### Comments
562
+
563
+ There are multiple ways to add comments within a quiz file. In all cases,
564
+ comments are completely removed during quiz creation and do not appear in the
565
+ final quiz in any form.
566
+
567
+ At the top level of a quiz document (outside of questions, choices, or
568
+ feedback) there are two types of comments. These comments cannot be indented.
569
+ * Line comments: Any line that starts with a percent sign `%` is discarded.
570
+ * Multiline comments: If a line starts with `COMMENT`, that line and all
571
+ subsequent lines are discarded through a line that starts with
572
+ `END_COMMENT`. The `COMMENT` and `END_COMMENT` delimiters must be on lines
573
+ by themselves; otherwise, an error is raised.
574
+
575
+ Within Markdown text, standard HTML comments of the form `<!--comment-->` may
576
+ be used. These are stripped out during processing and do not appear in the
577
+ final QTI file. HTML comments are not supported within LaTeX math.
578
+
579
+ Technical note: HTML comments are currently stripped in a preprocessing step
580
+ separate from Python-Markdown. In rare cases, this may conflict with raw HTML
581
+ embedded in Markdown. This feature may be reimplemented as a Python-Markdown
582
+ extension in the future.
583
+
584
+
585
+
586
+ ## Export solutions to PDF and HTML
587
+
588
+ There is basic support for exporting quiz solutions in Pandoc Markdown, PDF,
589
+ and HTML formats. This is currently only available in the command-line
590
+ application. Solutions exported as Pandoc Markdown are only suitable for use
591
+ with LaTeX and HTML. Exporting solutions as PDF requires
592
+ [Pandoc](https://pandoc.org/) and a LaTeX installation (such as [TeX
593
+ Live](https://www.tug.org/texlive/) or [MiKTeX](https://miktex.org/)). There
594
+ is currently no built-in support for customizing export, although you can edit
595
+ the Pandoc Markdown output before processing it via Pandoc.
596
+
597
+ To save solutions and also create a QTI file, use
598
+ `--solutions <solutions_file>`. To save solutions without creating a QTI
599
+ file, use `--only-solutions <solutions_file>`. `<solutions_file>` must have
600
+ an `.md` or `.markdown` extension for Pandoc Markdown export, `.pdf` for PDF
601
+ export, or `.html` for HTML export. `--solutions` and `--only-solutions` can
602
+ be used multiple times to generate solutions in multiple formats.
603
+
604
+ When using `--only-solutions`, be aware that solutions and QTI may differ if
605
+ executable code blocks generate problems using random numbers. Consider
606
+ creating solutions and QTI at the same time (`--solutions`), or setting a seed
607
+ for the random number generator so it is reproducible.
608
+
609
+
610
+ ### Customizing questions for solutions
611
+
612
+ Each question can provide a solution or other important information that is
613
+ *only* included in the solutions, *never* in the QTI. This is particularly
614
+ useful for essay and upload questions, since they are defined without
615
+ specifying an answer and require manual grading. For example,
616
+ ```
617
+ 1. Write an essay about text2qti.
618
+
619
+ ! This is important information about what the essay should cover.
620
+
621
+ This will only appear in the solutions, and can be as long or short as
622
+ you wish.
623
+
624
+ ____
625
+ ```
626
+ The syntax for a question solution is the same as that for question feedback,
627
+ except that an exclamation point `!` is used instead of `...` or `+` or `-`.
628
+
629
+
630
+ ### Quiz options
631
+
632
+ At the beginning of a quiz, there are some quiz-level options that can be set
633
+ to customize solutions. These all take `true`/`false` values, and are `false`
634
+ by default. For example, add `solutions sample groups: true` at the beginning
635
+ of a quiz.
636
+
637
+ * `feedback is solution` — This disables the special question solution syntax
638
+ involving `!`, and treats general question feedback (`...`) as both feedback
639
+ and solution. This is useful when you want to give students solution
640
+ information as part of the QTI feedback and also include this same
641
+ information in solutions.
642
+
643
+ * `solutions sample groups` — By default, *all* questions in a question group
644
+ are included in solutions, with a notice about the number that are randomly
645
+ selected when the quiz is taken, unless the question group has `solutions
646
+ pick` set to use a different value.
647
+
648
+ This option causes only a sample of the questions in a group to be included
649
+ in the solutions. This option displays the first N questions in a group
650
+ sequentially, where N is the group `solutions pick` value if it has been
651
+ set, and otherwise the `pick` value if it has been set, and otherwise 1. It
652
+ is possible for solutions to include N random questions from a group instead
653
+ of the first N questions; see `solutions randomize groups`.
654
+
655
+ * `solutions randomize groups` — For each question group, randomize the order
656
+ in which questions are displayed in solutions. If only some questions from
657
+ a group are included in solutions (`solutions sample groups` is `true` or
658
+ `solutions pick` is set), also randomize which questions are displayed
659
+ instead of taking all displayed questions sequentially from the beginning of
660
+ the group.
661
+
662
+ Randomization is not used by default for two reasons that relate to quizzes
663
+ using `solutions pick` or `solutions sample groups`. First, including group
664
+ questions sequentially allows specially chosen, representative questions to
665
+ be placed at the beginning of the group so that they will appear in
666
+ solutions. If a group contains many questions that are generated by an
667
+ executable code block, a random selection might not provide a sample that is
668
+ representative. Second, if a quiz is used several semesters or years in a
669
+ row with only minor modifications, and new randomized solutions are
670
+ distributed each time, this means that eventually all questions would be
671
+ distributed in solutions, rather than only a fixed subset.
672
+
673
+
674
+ ### Customizing groups for solutions
675
+
676
+ When a quiz is taken, the number of questions randomly selected from a
677
+ question group is the value of `pick` if `pick` is set for the group and
678
+ otherwise 1. However, by default solutions will include *all* questions from
679
+ a group. There are two ways to modify this.
680
+
681
+ It is possible to modify the number of questions displayed in solutions for a
682
+ specific group by setting `solutions pick` for the group. This causes only
683
+ the specified number of questions from the group to be displayed in solutions.
684
+ The questions that are displayed are taken sequentially from the beginning of
685
+ the group by default, with no randomization. For randomization, see the
686
+ quiz-level option `solutions randomize groups`.
687
+
688
+ It is also possible to modify the number of questions displayed in solutions
689
+ for *all* groups in a quiz by setting the quiz-level option `solutions sample
690
+ groups` to `true`. This option displays the first N questions in a group
691
+ sequentially, where N is the group `solutions pick` value if it has been set,
692
+ and otherwise the `pick` value if it has been set, and otherwise 1. It is
693
+ possible for solutions to include N random questions from a group instead of
694
+ the first N questions; see `solutions randomize groups`.
695
+
696
+ In the example below, the solutions would include 2 questions from the group,
697
+ even though only 1 is displayed when the quiz is taken. The first 2 questions
698
+ would be included in solutions, unless `solutions randomize groups: true` is
699
+ included at the beginning of the quiz.
700
+
701
+ ```
702
+ GROUP
703
+ pick: 1
704
+ solutions pick: 2
705
+
706
+ 1. A question.
707
+ *a) true
708
+ b) false
709
+
710
+ 2. Another question.
711
+ *a) true
712
+ b) false
713
+
714
+ 3. Yet another question.
715
+ *a) true
716
+ b) false
717
+
718
+ END_GROUP
719
+ ```
720
+
app.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import text2qti
3
+ import traceback
4
+ import subprocess
5
+ import os
6
+
7
+ def convert_txt_to_qti(file_path):
8
+ # Use subprocess to run the text2qti command
9
+ output_file = file_path.replace('.txt', '.zip')
10
+ try:
11
+ subprocess.run(["text2qti", file_path], check=True, capture_output=True, text=True)
12
+ return output_file, None
13
+ except subprocess.CalledProcessError as e:
14
+ # Capture the stderr output
15
+ error_message = e.stderr.strip() if e.stderr else "An error occurred without any specific error message."
16
+ return None, error_message
17
+
18
+ def main():
19
+ st.title("text2qti Converter")
20
+
21
+ st.markdown("""
22
+ For help or more information about how to generate/format .txt files for conversion and upload to Canvas, please visit the [following walk-through guide.](https://reutherlab.biosci.ucsd.edu/)
23
+ """)
24
+
25
+ uploaded_file = st.file_uploader("Upload a .txt file", type=["txt"])
26
+
27
+ if uploaded_file:
28
+ # Extract the filename without the extension
29
+ file_name = os.path.splitext(uploaded_file.name)[0]
30
+
31
+ # Save the uploaded file to a location with the same name
32
+ temp_file_path = f"{file_name}.txt"
33
+ with open(temp_file_path, "wb") as f:
34
+ f.write(uploaded_file.getbuffer())
35
+
36
+ st.write("File uploaded successfully!")
37
+
38
+ # Button to initiate the conversion
39
+ if st.button('Convert to QTI'):
40
+ try:
41
+ qti_zip_file, error = convert_txt_to_qti(temp_file_path)
42
+ if qti_zip_file and os.path.exists(qti_zip_file):
43
+ with open(qti_zip_file, "rb") as f:
44
+ zip_data = f.read()
45
+ st.download_button(f'Download {file_name}.zip', zip_data, file_name=f'{file_name}.zip', mime='application/zip')
46
+ else:
47
+ # If the conversion failed, display the error message
48
+ error_message = (f"""Failed to convert the file. You will see a long, confusing error. **Focus on the part that provides a line number and text from the quiz itself. it will often be near the end of the error message.**
49
+ ---
50
+ **Here is an made-up example of what you should look for:**
51
+ In test_quiz_error.txt on line 23: Syntax error; unexpected text, or incorrect indentation for a wrapped paragraph: #a) Polar heads face outward, nonpolar tails face inward
52
+ This would indicate that there is an error on line 23 of the quiz. You need to change #a) to \*a) to fix the error.
53
+ Please visit the walkthrough linked above for more information.
54
+ ---
55
+ **Your error:**
56
+ {error}")
57
+ """)
58
+ st.markdown(error_message)
59
+ except Exception as e:
60
+ # Catch any other exception that might occur and display it using traceback
61
+ error_traceback = traceback.format_exc()
62
+ st.error(f"An unexpected error occurred: {error_traceback}")
63
+
64
+
65
+ # Footer
66
+ st.markdown("---")
67
+ st.markdown("""
68
+ The text2qti python library is Copyright (c) 2020 by Geoffrey M. Poore.
69
+ It can be found at [https://github.com/gpoore/text2qti](https://github.com/gpoore/text2qti)
70
+ and is distributed under the BSD 3-Clause License.
71
+ """)
72
+ st.markdown("""
73
+ This app is managed by Keefe Reuther - [https://reutherlab.biosci.ucsd.edu/](https://reutherlab.biosci.ucsd.edu/)
74
+ """)
75
+
76
+ if __name__ == "__main__":
77
+ main()
make_gui_exe/README.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Create Standalone GUI Executable for Windows
2
+
3
+ This directory contains scripts for creating a standalone GUI executable under
4
+ Windows with PyInstaller.
5
+
6
+
7
+
8
+ ## Requirements
9
+
10
+ * Windows
11
+ * [conda](https://docs.conda.io/projects/conda/en/latest/user-guide/install/)
12
+
13
+
14
+
15
+ ## Directions
16
+
17
+ If you do not already have a local copy of the text2qti source, download
18
+ `make_tk_exe.bat` and `text2qti_tk.pyw`, and place them in the same directory.
19
+ Double-click on `make_tk_exe.bat` to run it. Or open a command prompt,
20
+ navigate to `make_gui_exe/` (or wherever the batch file is located), and run
21
+ the batch file. Under PowerShell, run something like
22
+ `cmd /c make_gui_exe.bat`.
23
+
24
+ The batch file performs these steps:
25
+ * Create a new conda environment for building the executable.
26
+ * Activate the conda environment.
27
+ * Install needed Python packages in the environment: bespon, markdown,
28
+ pyinstaller, and text2qti. If the batch file detects that it is part of a
29
+ local copy of the text2qti source, then this local version of text2qti will
30
+ be used. Otherwise, text2qti will be installed from PyPI via pip.
31
+ * Build executable `text2qti_tk_VERSION.exe` using PyInstaller.
32
+ * Deactivate the conda environment.
33
+ * Remove the conda environment.
34
+ * Move the executable to the working directory.
35
+ * Remove all temp files and build files.
make_gui_exe/make_tk_exe.bat ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ REM This is intended to be run with the .bat file directory as the working dir
2
+ if not exist make_tk_exe.bat (
3
+ echo Missing make_tk_exe.bat in working directory
4
+ pause
5
+ exit
6
+ )
7
+ if not exist text2qti_tk.pyw (
8
+ echo Missing text2qti_tk.pyw in working directory
9
+ pause
10
+ exit
11
+ )
12
+
13
+ REM Create and activate a conda env for packaging the .exe
14
+ call conda create -y --name make_text2qti_gui_exe python=3.9 --no-default-packages
15
+ call conda activate make_text2qti_gui_exe
16
+ REM List conda envs -- useful for debugging
17
+ call conda info --envs
18
+ REM Install dependencies
19
+ pip install bespon
20
+ pip install markdown
21
+ pip install pyinstaller
22
+ if exist ..\setup.py (
23
+ if exist ..\text2qti (
24
+ cd ..
25
+ pip install .
26
+ cd make_gui_exe
27
+ ) else (
28
+ pip install text2qti
29
+ )
30
+ ) else (
31
+ pip install text2qti
32
+ )
33
+ REM Build .exe
34
+ FOR /F "tokens=* USEBACKQ" %%g IN (`python -c "import text2qti; print(text2qti.__version__)"`) do (SET "TEXT2QTI_VERSION=%%g")
35
+ pyinstaller -F --name text2qti_tk_%TEXT2QTI_VERSION% text2qti_tk.pyw
36
+ REM Deactivate and delete conda env
37
+ call conda deactivate
38
+ call conda remove -y --name make_text2qti_gui_exe --all
39
+ REM List conda envs -- useful for debugging
40
+ call conda info --envs
41
+ REM Cleanup
42
+ move dist\text2qti_tk_%TEXT2QTI_VERSION%.exe text2qti_tk_%TEXT2QTI_VERSION%.exe
43
+ rd /s /q "__pycache__"
44
+ rd /s /q "build"
45
+ rd /s /q "dist"
46
+ del *.spec
47
+ pause
make_gui_exe/text2qti_tk.pyw ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ import text2qti.gui.tk
2
+ text2qti.gui.tk.main()
requirements.txt CHANGED
@@ -1,3 +1 @@
1
- altair
2
- pandas
3
- streamlit
 
1
+ text2qti
 
 
setup.cfg ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ [metadata]
2
+ license_file = LICENSE.txt
3
+
4
+
5
+ [tool:pytest]
6
+ norecursedirs = build
setup.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ import sys
12
+ if sys.version_info < (3, 6):
13
+ sys.exit('text2qti requires Python 3.6+')
14
+ import pathlib
15
+ from setuptools import setup, find_packages
16
+
17
+
18
+
19
+
20
+ # Extract the version from version.py, using functions in fmtversion.py
21
+ fmtversion_path = pathlib.Path(__file__).parent / 'text2qti' / 'fmtversion.py'
22
+ exec(compile(fmtversion_path.read_text(encoding='utf8'), 'text2qti/fmtversion.py', 'exec'))
23
+ version_path = pathlib.Path(__file__).parent / 'text2qti' / 'version.py'
24
+ version = get_version_from_version_py_str(version_path.read_text(encoding='utf8'))
25
+
26
+ readme_path = pathlib.Path(__file__).parent / 'README.md'
27
+ long_description = readme_path.read_text(encoding='utf8')
28
+
29
+
30
+ setup(name='text2qti',
31
+ version=version,
32
+ py_modules=[],
33
+ packages=find_packages(),
34
+ package_data = {},
35
+ description='Create quizzes in QTI format from Markdown-based plain text',
36
+ long_description=long_description,
37
+ long_description_content_type='text/markdown',
38
+ author='Geoffrey M. Poore',
39
+ author_email='gpoore@gmail.com',
40
+ url='http://github.com/gpoore/text2qti',
41
+ license='BSD',
42
+ keywords=['QTI', 'IMS Question & Test Interoperability', 'quiz', 'test',
43
+ 'exam', 'assessment', 'markdown', 'LaTeX', 'plain text'],
44
+ python_requires='>=3.6',
45
+ install_requires=[
46
+ 'bespon>=0.4',
47
+ 'markdown>=3.2',
48
+ ],
49
+ # https://pypi.python.org/pypi?:action=list_classifiers
50
+ classifiers=[
51
+ 'Development Status :: 4 - Beta',
52
+ 'Environment :: Console',
53
+ 'Intended Audience :: Education',
54
+ 'License :: OSI Approved :: BSD License',
55
+ 'Operating System :: OS Independent',
56
+ 'Programming Language :: Python :: 3.6',
57
+ 'Programming Language :: Python :: 3.7',
58
+ 'Programming Language :: Python :: 3.8',
59
+ 'Topic :: Education :: Testing',
60
+ ],
61
+ entry_points = {
62
+ 'console_scripts': ['text2qti = text2qti.cmdline:main'],
63
+ 'gui_scripts': ['text2qti_tk = text2qti.gui.tk:main'],
64
+ },
65
+ )
temp.txt ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Quiz title: Quiz 7 extra questions
2
+ Quiz description: Delve deeper into the world of genetics by understanding the role of statistics in data interpretation and exploring the intricacies of Mendelian inheritance.
3
+
4
+ Title: P-Values in Hypothesis Testing
5
+ Points: 1
6
+ 2. A p-value of 0.05 in a scientific study typically means:
7
+ ... A p-value of 0.05 means that there is a 5% chance of observing the given results, or more extreme results, if the null hypothesis is true. It is a common threshold for statistical significance.
8
+ a) There is definitely a difference between comparison groups.
9
+ *b) There is a 5% chance of getting a test statistic at least as big as observed in your data if the null hypothesis is true
10
+ c) There is a 95% chance that the alternative hypothesis is true
11
+ d) The results are never statistically significant
12
+
13
+ Title: Multiple Answer - False Positives and Negatives
14
+ Points: 1
15
+ 4. In the context of diagnostic tests (e.g., a COVID-19 or breast cancer test), what consequences can false positives and false negatives have?
16
+ ... False positives can lead to unnecessary treatment or distress, while false negatives can result in a lack of treatment for a disease that is actually present.
17
+ [*] False positives can lead to unnecessary treatment or emotional distress
18
+ [ ] False negatives can lead to unnecessary treatment or emotional distress
19
+ [ ] False positives are always better than false negatives.
20
+ [*] False negatives can result in a lack of treatment for a disease that is present
21
+ [ ] False positive can result in a lack of treatment for a disease that is present
22
+ [ ] False negatives are always better than false positives.
23
+ [ ] False positives and false negatives are not a concern in diagnostic tests since they are rare and we can never fully eliminate error.
24
+
25
+ Title: Complete Dominance Basis
26
+ Points: 1
27
+ 6. At the cellular level, what does complete dominance in Mendelian genetics typically reflect?
28
+ ... Complete dominance usually stems from the functionality of the gene product, where one allele produces a functional protein, while the other does not.
29
+ *a) The presence of one functional allele compensates for a non-functional allele.
30
+ b) Both alleles produce functional proteins in equal quantities.
31
+ c) The dominant allele suppresses the expression of other genes.
32
+ d) Both alleles are necessary for a complete phenotypic expression.
33
+
34
+ Title: ABO Blood Type System
35
+ Points: 1
36
+ 7. In the ABO blood group system, which phenomenon explains the simultaneous expression of both A and B antigens in individuals?
37
+ ... Codominance allows for both alleles to express their phenotypes fully when present.
38
+ *a) Codominance
39
+ b) Incomplete dominance
40
+ c) Epistasis
41
+ d) Pleiotropy
42
+
43
+ Title: Impacts of Lethal Alleles
44
+ Points: 1
45
+ 8. How do lethal alleles influence Mendelian ratios in genetic crosses?
46
+ ... Lethal alleles can cause death when homozygous, leading to deviations from the expected Mendelian phenotypic ratios.
47
+ *a) They can cause death in homozygous individuals, leading to a lack of that genotype in the offspring.
48
+ b) They prevent any form of genetic crossing.
49
+ c) They introduce mutations into the genetic code.
50
+ d) They always result in a dominant phenotype.
51
+
52
+ Title: Epistasis and Phenotypic Outcomes
53
+ Points: 1
54
+ 9. How does epistasis influence the phenotypic outcomes of genetic crosses?
55
+ ... Epistasis occurs when the alleles of one gene mask or modify the expression of another gene, thereby influencing the phenotypic outcomes of genetic crosses.
56
+ *a) It can mask or modify the expression of another gene, altering expected phenotypic results.
57
+ b) It increases the mutation rate in the offspring.
58
+ c) It leads to incomplete dominance of one gene over another.
59
+ d) It ensures that Mendelian ratios are always maintained.
temp.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:13fc67d7538c5aef01c94679e3077dfc707bd26cc703f536906a4acd5b7d1f3d
3
+ size 6896
test_quiz.txt ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Quiz title: Introduction to Cell Biology – Quiz 2
2
+ Quiz description: An examination of the key concepts in cell biology, including cell types, membrane structure, transport mechanisms, energy metabolism, and enzymatic reactions.
3
+
4
+ Title: Learn with integrity
5
+ Points: 1
6
+ 1. I agree to complete this quiz without the aid of others or AI. I agree not to discuss questions and answers of the quiz until the quiz key is posted. Anything otherwise is a breach of my Academic Integrity and subject to the policies of the University.
7
+ *a) I agree
8
+ b) I disagree
9
+
10
+ Title: Comparing Prokaryotic and Eukaryotic Cells
11
+ Points: 1
12
+ 2. Which of the following statements best distinguishes eukaryotic cells from prokaryotic cells?
13
+ ... Eukaryotic cells contain membrane-bound organelles, including a nucleus, while prokaryotic cells lack these structures.
14
+ a) Only eukaryotic cells have a plasma membrane
15
+ *b) Eukaryotic cells contain membrane-bound organelles, while prokaryotic cells do not
16
+ c) Prokaryotic cells contain more DNA than eukaryotic cells
17
+ d) Only prokaryotic cells contain ribosomes
18
+
19
+ Title: Phospholipids in Cell Membranes
20
+ Points: 1
21
+ 3. How do the polar and nonpolar ends of phospholipids interact in water to create the bilayer structure of cell membranes?
22
+ ... The polar (hydrophilic) heads face outward, interacting with water, while the nonpolar (hydrophobic) tails face inward, away from water, forming a bilayer.
23
+ *a) Polar heads face outward, nonpolar tails face inward
24
+ b) Polar heads face inward, nonpolar tails face outward
25
+ c) Polar and nonpolar ends mix randomly
26
+ d) Polar ends repel each other, forming a single layer
27
+
28
+ Title: Diffusion and Entropy
29
+ Points: 1
30
+ 4. Why does diffusion not require work and lead to passive transport?
31
+ ... Diffusion is the movement of molecules from an area of high concentration to low, driven by random motion, increasing entropy. It doesn't require energy input, aligning with the second law of thermodynamics.
32
+ *a) Diffusion increases entropy and is driven by random motion
33
+ b) Diffusion requires energy to move molecules against concentration gradients
34
+ c) Diffusion decreases entropy by organizing molecules
35
+ d) Diffusion is driven by active transport proteins
36
+
37
+ Title: Tonicity and Animal Cells
38
+ Points: 1
39
+ 5. What would happen to an animal cell if placed in a hypertonic solution?
40
+ ... In a hypertonic solution, the solute concentration is higher outside the cell, causing water to move out of the cell, leading to cell shrinkage or crenation.
41
+ *a) The cell would shrink due to water loss
42
+ b) The cell would swell due to water intake
43
+ c) The cell would remain unchanged in size
44
+ d) The cell would undergo lysis
45
+
46
+ Title: Functions of Smooth ER and Rough ER
47
+ Points: 1
48
+ 6. How do the functions of smooth endoplasmic reticulum (ER) and rough ER differ in a eukaryotic cell?
49
+ ... Smooth ER is involved in lipid synthesis and detoxification, while rough ER, studded with ribosomes, is involved in protein synthesis and processing.
50
+ *a) Smooth ER synthesizes lipids; Rough ER synthesizes proteins
51
+ b) Smooth ER synthesizes proteins; Rough ER synthesizes lipids
52
+ c) Both Smooth and Rough ER are primarily involved in protein synthesis
53
+ d) Both Smooth and Rough ER are primarily involved in lipid synthesis
54
+
55
+ Title: Origins of Chloroplasts and Mitochondria
56
+ Points: 1
57
+ 7. According to the endosymbiotic theory, how did chloroplasts and mitochondria originate?
58
+ ... The endosymbiotic theory proposes that chloroplasts and mitochondria originated from free-living bacteria that were engulfed by ancestral eukaryotic cells.
59
+ *a) They originated from free-living bacteria joining with ancestral eukaryotic cells
60
+ b) They originated from viral infections
61
+ c) They were synthesized within the host cell
62
+ d) They developed independently in eukaryotic cells just like the nucleus.
63
+
64
+ Title: Osmosis and Water Concentration
65
+ Points: 1
66
+ 8. How can you predict the movement of water based on solute concentration?
67
+ ... Water moves from areas of low solute concentration (high water concentration) to areas of high solute concentration (low water concentration) by osmosis.
68
+ *a) Water moves from low solute to high solute concentration
69
+ b) Water moves from high solute to low solute concentration
70
+ c) Water moves randomly without regard to solute concentration
71
+ d) Water movement is determined by temperature, not solute concentration
72
+
73
+ Title: Facilitated Diffusion vs. Active Transport
74
+ Points: 1
75
+ 9. What distinguishes facilitated diffusion from active transport in cell membrane transport?
76
+ ... Facilitated diffusion is passive and moves substances down their concentration gradient without energy input, while active transport requires energy to move substances against their gradient.
77
+ *a) Facilitated diffusion is passive; Active transport requires energy
78
+ b) Facilitated diffusion requires energy; Active transport is passive
79
+ c) Both facilitated diffusion and active transport require energy
80
+ d) Both facilitated diffusion and active transport are passive processes
81
+
82
+ Title: Bound vs. Free Ribosomes
83
+ Points: 1
84
+ 10. How do bound and free ribosomes differ in terms of the destination of the proteins they synthesize?
85
+ ... Bound ribosomes synthesize proteins that are incorporated into membranes or exported from the cell, while free ribosomes synthesize proteins that function within the cytosol.
86
+ *a) Bound ribosomes synthesize membrane or exported proteins; Free ribosomes synthesize proteins destined for the cytoplasm
87
+ b) Bound ribosomes synthesize proteins destined for the cytoplasm; Free ribosomes synthesize membrane proteins
88
+ c) Both bound and free ribosomes synthesize exported proteins
89
+ d) Both bound and free ribosomes synthesize proteins destined for the cytoplasm
90
+
91
+ Title: Lysosomal Storage Disease
92
+ Points: 1
93
+ 11. Lysosomal storage diseases are often the result of a specific enzyme not working, leading to an inability to break down and recycle a cellular waste product. Which of the following scenarios would likely be associated with a lysosomal storage disease?
94
+ ... Lysosomal storage diseases occur when there is a deficiency in an enzyme needed to break down a specific waste product, leading to its accumulation within lysosomes. This can result in cell damage and various symptoms.
95
+ a) A deficiency in an enzyme required in the gut for sucrose digestion.
96
+ b) An overproduction of a hormone regulating blood pressure
97
+ *c) A deficiency in an enzyme required to break down a specific cellular waste product, leading to its accumulation.
98
+ d) An overproduction of a gene responsible for cell growth
99
+
100
+ Title: Regulation of Exergonic Reactions
101
+ Points: 1
102
+ 12. Why don't exergonic reactions in our bodies happen quickly and spontaneously in a totally unregulated manner?
103
+ ... Exergonic reactions are thermodynamically favorable and release energy, but in biological systems, they are often controlled by enzymes and other regulatory mechanisms to ensure proper timing and coordination within metabolic pathways.
104
+ *a) They are prevented by transition states and high activation energies, that can only be lowered with enzymes.
105
+ b) They require energy input to proceed. Endergonic reactions are spontaneous.
106
+ c) They don't ever increase entropy, so it would require lots of work to get them going.
107
+ d) They are inhibited by high temperatures
108
+
109
+ Title: Reaction Rates and Temperature or Substrate Concentration
110
+ Points: 1
111
+ 13. What happens to reaction rates when you change the temperature or concentration of substrate/reactants?
112
+ ... Increasing the temperature generally accelerates reaction rates by providing more kinetic energy to the molecules. Increasing the concentration of substrate or reactants often enhances the reaction rate by increasing the frequency of collisions between molecules.
113
+ *a) Increasing temperature and substrate/reactant concentration generally accelerates reaction rates
114
+ b) Increasing temperature slows down reaction rates; increasing substrate/reactant concentration accelerates reaction rates
115
+ c) Increasing temperature accelerates reaction rates; increasing substrate/reactant concentration slows down reaction rates
116
+ d) Increasing temperature and substrate/reactant concentration generally slows down reaction rates
117
+
118
+ Title: Fatty Acid Tails of Phospholipids and Water Interaction
119
+ Points: 1
120
+ 14. Why do the fatty acid tails of phospholipids prefer not to interact with water?
121
+ ... The fatty acid tails of phospholipids are hydrophobic (nonpolar) and therefore tend to avoid interaction with water, which is a polar molecule. In a bilayer, they orient inward, away from the water, minimizing their contact with it.
122
+ *a) They are hydrophobic and avoid interaction with polar water molecules
123
+ b) They form hydrogen bonds with water
124
+ c) They are hydrophilic and attract water molecules
125
+ d) They dissolve in water, leading to membrane disintegration
126
+
127
+ Title: Essay - ATP's Role in Metabolic Reactions
128
+ Points: 2
129
+ 15. Explain how ATP functions in both anabolic and catabolic metabolic reactions.
130
+ ... ATP serves as the primary energy currency in cells, linking catabolic reactions that release energy with anabolic reactions that consume energy. Its hydrolysis provides the energy needed for various cellular functions, driving both the synthesis and breakdown of molecules.
131
+ _________
132
+
133
+ Title: Essay - Endomembrane System Pathway
134
+ Points: 2
135
+ 16. Describe the pathway a single amino acid might take through the endomembrane system if it was destined to travel the blood stream throughout the body. Begin your story at the ribosome and end it at the plasma membrane.
136
+ ... The endomembrane system is a complex network within eukaryotic cells, involving the synthesis, modification, and transport of proteins. The pathway includes the smooth ER for lipid synthesis, rough ER for protein synthesis, the Golgi apparatus for modification, and lysosomes for degradation.
137
+ _________
138
+
139
+ Title: Personal Reflection on Learning
140
+ Points: 2
141
+ 17. Share one thing you learned this week that wasn't directly mentioned in this quiz. Be specific in your explanation, and teach me about this concept or idea as if I were a fellow student.
142
+ ... Reflecting on what you've learned and teaching it to others can deepen your understanding and retention. Whether it's a novel concept, a fascinating fact, or a new perspective, I'm eager to learn from you!
143
+ _________
test_quiz.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2d5dbb85d30c59012c52c178ccc49928312b9a1e2c7c4fe6946a0da24c73a40f
3
+ size 11546
test_quiz_error.txt ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Quiz title: Introduction to Cell Biology – Quiz 2
2
+ Quiz description: An examination of the key concepts in cell biology, including cell types, membrane structure, transport mechanisms, energy metabolism, and enzymatic reactions.
3
+
4
+ Title: Learn with integrity
5
+ Points: 1
6
+ 1. I agree to complete this quiz without the aid of others or AI. I agree not to discuss questions and answers of the quiz until the quiz key is posted. Anything otherwise is a breach of my Academic Integrity and subject to the policies of the University.
7
+ *a) I agree
8
+ b) I disagree
9
+
10
+ Title: Comparing Prokaryotic and Eukaryotic Cells
11
+ Points: 1
12
+ 2. Which of the following statements best distinguishes eukaryotic cells from prokaryotic cells?
13
+ ... Eukaryotic cells contain membrane-bound organelles, including a nucleus, while prokaryotic cells lack these structures.
14
+ a) Only eukaryotic cells have a plasma membrane
15
+ *b) Eukaryotic cells contain membrane-bound organelles, while prokaryotic cells do not
16
+ c) Prokaryotic cells contain more DNA than eukaryotic cells
17
+ d) Only prokaryotic cells contain ribosomes
18
+
19
+ Title: Phospholipids in Cell Membranes
20
+ Points: 1
21
+ 3. How do the polar and nonpolar ends of phospholipids interact in water to create the bilayer structure of cell membranes?
22
+ ... The polar (hydrophilic) heads face outward, interacting with water, while the nonpolar (hydrophobic) tails face inward, away from water, forming a bilayer.
23
+ #a) Polar heads face outward, nonpolar tails face inward
24
+ b) Polar heads face inward, nonpolar tails face outward
25
+ c) Polar and nonpolar ends mix randomly
26
+ d) Polar ends repel each other, forming a single layer
27
+
28
+ Title: Diffusion and Entropy
29
+ Points: 1
30
+ 4. Why does diffusion not require work and lead to passive transport?
31
+ ... Diffusion is the movement of molecules from an area of high concentration to low, driven by random motion, increasing entropy. It doesn't require energy input, aligning with the second law of thermodynamics.
32
+ *a) Diffusion increases entropy and is driven by random motion
33
+ b) Diffusion requires energy to move molecules against concentration gradients
34
+ c) Diffusion decreases entropy by organizing molecules
35
+ d) Diffusion is driven by active transport proteins
36
+
37
+ Title: Tonicity and Animal Cells
38
+ Points: 1
39
+ 5. What would happen to an animal cell if placed in a hypertonic solution?
40
+ ... In a hypertonic solution, the solute concentration is higher outside the cell, causing water to move out of the cell, leading to cell shrinkage or crenation.
41
+ *a) The cell would shrink due to water loss
42
+ b) The cell would swell due to water intake
43
+ c) The cell would remain unchanged in size
44
+ d) The cell would undergo lysis
45
+
46
+ Title: Functions of Smooth ER and Rough ER
47
+ Points: 1
48
+ 6. How do the functions of smooth endoplasmic reticulum (ER) and rough ER differ in a eukaryotic cell?
49
+ ... Smooth ER is involved in lipid synthesis and detoxification, while rough ER, studded with ribosomes, is involved in protein synthesis and processing.
50
+ *a) Smooth ER synthesizes lipids; Rough ER synthesizes proteins
51
+ b) Smooth ER synthesizes proteins; Rough ER synthesizes lipids
52
+ c) Both Smooth and Rough ER are primarily involved in protein synthesis
53
+ d) Both Smooth and Rough ER are primarily involved in lipid synthesis
54
+
55
+ Title: Origins of Chloroplasts and Mitochondria
56
+ Points: 1
57
+ 7. According to the endosymbiotic theory, how did chloroplasts and mitochondria originate?
58
+ ... The endosymbiotic theory proposes that chloroplasts and mitochondria originated from free-living bacteria that were engulfed by ancestral eukaryotic cells.
59
+ *a) They originated from free-living bacteria joining with ancestral eukaryotic cells
60
+ b) They originated from viral infections
61
+ c) They were synthesized within the host cell
62
+ d) They developed independently in eukaryotic cells just like the nucleus.
63
+
64
+ Title: Osmosis and Water Concentration
65
+ Points: 1
66
+ 8. How can you predict the movement of water based on solute concentration?
67
+ ... Water moves from areas of low solute concentration (high water concentration) to areas of high solute concentration (low water concentration) by osmosis.
68
+ *a) Water moves from low solute to high solute concentration
69
+ b) Water moves from high solute to low solute concentration
70
+ c) Water moves randomly without regard to solute concentration
71
+ d) Water movement is determined by temperature, not solute concentration
72
+
73
+ Title: Facilitated Diffusion vs. Active Transport
74
+ Points: 1
75
+ 9. What distinguishes facilitated diffusion from active transport in cell membrane transport?
76
+ ... Facilitated diffusion is passive and moves substances down their concentration gradient without energy input, while active transport requires energy to move substances against their gradient.
77
+ *a) Facilitated diffusion is passive; Active transport requires energy
78
+ b) Facilitated diffusion requires energy; Active transport is passive
79
+ c) Both facilitated diffusion and active transport require energy
80
+ d) Both facilitated diffusion and active transport are passive processes
81
+
82
+ Title: Bound vs. Free Ribosomes
83
+ Points: 1
84
+ 10. How do bound and free ribosomes differ in terms of the destination of the proteins they synthesize?
85
+ ... Bound ribosomes synthesize proteins that are incorporated into membranes or exported from the cell, while free ribosomes synthesize proteins that function within the cytosol.
86
+ *a) Bound ribosomes synthesize membrane or exported proteins; Free ribosomes synthesize proteins destined for the cytoplasm
87
+ b) Bound ribosomes synthesize proteins destined for the cytoplasm; Free ribosomes synthesize membrane proteins
88
+ c) Both bound and free ribosomes synthesize exported proteins
89
+ d) Both bound and free ribosomes synthesize proteins destined for the cytoplasm
90
+
91
+ Title: Lysosomal Storage Disease
92
+ Points: 1
93
+ 11. Lysosomal storage diseases are often the result of a specific enzyme not working, leading to an inability to break down and recycle a cellular waste product. Which of the following scenarios would likely be associated with a lysosomal storage disease?
94
+ ... Lysosomal storage diseases occur when there is a deficiency in an enzyme needed to break down a specific waste product, leading to its accumulation within lysosomes. This can result in cell damage and various symptoms.
95
+ a) A deficiency in an enzyme required in the gut for sucrose digestion.
96
+ b) An overproduction of a hormone regulating blood pressure
97
+ *c) A deficiency in an enzyme required to break down a specific cellular waste product, leading to its accumulation.
98
+ d) An overproduction of a gene responsible for cell growth
99
+
100
+ Title: Regulation of Exergonic Reactions
101
+ Points: 1
102
+ 12. Why don't exergonic reactions in our bodies happen quickly and spontaneously in a totally unregulated manner?
103
+ ... Exergonic reactions are thermodynamically favorable and release energy, but in biological systems, they are often controlled by enzymes and other regulatory mechanisms to ensure proper timing and coordination within metabolic pathways.
104
+ *a) They are prevented by transition states and high activation energies, that can only be lowered with enzymes.
105
+ b) They require energy input to proceed. Endergonic reactions are spontaneous.
106
+ c) They don't ever increase entropy, so it would require lots of work to get them going.
107
+ d) They are inhibited by high temperatures
108
+
109
+ Title: Reaction Rates and Temperature or Substrate Concentration
110
+ Points: 1
111
+ 13. What happens to reaction rates when you change the temperature or concentration of substrate/reactants?
112
+ ... Increasing the temperature generally accelerates reaction rates by providing more kinetic energy to the molecules. Increasing the concentration of substrate or reactants often enhances the reaction rate by increasing the frequency of collisions between molecules.
113
+ *a) Increasing temperature and substrate/reactant concentration generally accelerates reaction rates
114
+ b) Increasing temperature slows down reaction rates; increasing substrate/reactant concentration accelerates reaction rates
115
+ c) Increasing temperature accelerates reaction rates; increasing substrate/reactant concentration slows down reaction rates
116
+ d) Increasing temperature and substrate/reactant concentration generally slows down reaction rates
117
+
118
+ Title: Fatty Acid Tails of Phospholipids and Water Interaction
119
+ Points: 1
120
+ 14. Why do the fatty acid tails of phospholipids prefer not to interact with water?
121
+ ... The fatty acid tails of phospholipids are hydrophobic (nonpolar) and therefore tend to avoid interaction with water, which is a polar molecule. In a bilayer, they orient inward, away from the water, minimizing their contact with it.
122
+ *a) They are hydrophobic and avoid interaction with polar water molecules
123
+ b) They form hydrogen bonds with water
124
+ c) They are hydrophilic and attract water molecules
125
+ d) They dissolve in water, leading to membrane disintegration
126
+
127
+ Title: Essay - ATP's Role in Metabolic Reactions
128
+ Points: 2
129
+ 15. Explain how ATP functions in both anabolic and catabolic metabolic reactions.
130
+ ... ATP serves as the primary energy currency in cells, linking catabolic reactions that release energy with anabolic reactions that consume energy. Its hydrolysis provides the energy needed for various cellular functions, driving both the synthesis and breakdown of molecules.
131
+ _________
132
+
133
+ Title: Essay - Endomembrane System Pathway
134
+ Points: 2
135
+ 16. Describe the pathway a single amino acid might take through the endomembrane system if it was destined to travel the blood stream throughout the body. Begin your story at the ribosome and end it at the plasma membrane.
136
+ ... The endomembrane system is a complex network within eukaryotic cells, involving the synthesis, modification, and transport of proteins. The pathway includes the smooth ER for lipid synthesis, rough ER for protein synthesis, the Golgi apparatus for modification, and lysosomes for degradation.
137
+ _________
138
+
139
+ Title: Personal Reflection on Learning
140
+ Points: 2
141
+ 17. Share one thing you learned this week that wasn't directly mentioned in this quiz. Be specific in your explanation, and teach me about this concept or idea as if I were a fellow student.
142
+ ... Reflecting on what you've learned and teaching it to others can deepen your understanding and retention. Whether it's a novel concept, a fascinating fact, or a new perspective, I'm eager to learn from you!
143
+ _________
text2qti/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ from .version import __version__, __version_info__
12
+ from .config import Config
13
+ from .quiz import Quiz
14
+ from .qti import QTI
text2qti/cmdline.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020-2021, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ import argparse
12
+ import os
13
+ import pathlib
14
+ import platform
15
+ from re import sub
16
+ import shutil
17
+ import subprocess
18
+ import textwrap
19
+ from .version import __version__ as version
20
+ from .err import Text2qtiError
21
+ from .config import Config
22
+ from .quiz import Quiz
23
+ from .qti import QTI
24
+ from .export import quiz_to_pandoc
25
+
26
+
27
+
28
+
29
+ def main():
30
+ '''
31
+ text2qti executable main function.
32
+ '''
33
+ parser = argparse.ArgumentParser(prog='text2qti')
34
+ parser.set_defaults(func=lambda x: parser.print_help())
35
+ parser.add_argument('--version', action='version', version=f'text2qti {version}')
36
+ parser.add_argument('--latex-render-url',
37
+ help='URL for rendering LaTeX equations')
38
+ parser.add_argument('--run-code-blocks', action='store_const', const=True,
39
+ help='Allow special code blocks to be executed and insert their output (off by default for security)')
40
+ parser.add_argument('--pandoc-mathml', action='store_const', const=True,
41
+ help='Convert LaTeX math to MathML using Pandoc (this will create a cache file "_text2qti_cache.zip" in the quiz file directory)')
42
+ soln_group = parser.add_mutually_exclusive_group()
43
+ soln_group.add_argument('--solutions', action='append', metavar='SOLUTIONS_FILE',
44
+ help='Save solutions in Pandoc Markdown (.md), PDF (.pdf), or HTML (.html) format, and also create a QTI file. '
45
+ 'Can be used multiple times to export multiple formats. '
46
+ 'Pandoc Markdown output is only suitable for use with LaTeX or HTML; PDF output requires Pandoc plus LaTeX.')
47
+ soln_group.add_argument('--only-solutions', action='append', metavar='SOLUTIONS_FILE',
48
+ help='Save solutions in Pandoc Markdown (.md), PDF (.pdf), or HTML (.html) format, but do not create a QTI file. '
49
+ 'Can be used multiple times to export multiple formats. '
50
+ 'Pandoc Markdown output is only suitable for use with LaTeX or HTML; PDF output requires Pandoc plus LaTeX. '
51
+ 'With this option, solutions and QTI may differ if executable code blocks generate problems using random numbers. '
52
+ 'Consider creating solutions and QTI together, or setting a seed for the random number generator so it is reproducible.')
53
+ parser.add_argument('file',
54
+ help='File to convert from text to QTI')
55
+ args = parser.parse_args()
56
+
57
+ config = Config()
58
+ config.load()
59
+ if not config.loaded_config_file:
60
+ latex_render_url = input(textwrap.dedent('''\
61
+ It looks like text2qti has not been installed on this machine
62
+ before. Would you like to set a default LaTeX rendering URL? If
63
+ no, press ENTER. If yes, provide the URL and press ENTER.
64
+
65
+ If you use Canvas, the URL will be something like
66
+ https://<institution>.instructure.com/equation_images/
67
+ or
68
+ https://canvas.<institution>.edu/equation_images/
69
+ with "<institution>" replaced by the name or abbreviation for
70
+ your institution. You can determine "<institution>" by logging
71
+ into Canvas and then looking in the browser address bar for
72
+ something like "<institution>.instructure.com/" or
73
+ "canvas.<institution>.edu/". If the address is similar to the
74
+ second form, you may need to change the domain from ".edu" to
75
+ the appropriate value for your institution.
76
+
77
+ If you do not use Canvas or software with a compatible LaTeX
78
+ rendering URL, you should not set a rendering URL. You may still
79
+ be able to use LaTeX via the command-line option
80
+ "--pandoc-mathml".
81
+
82
+ LaTeX rendering URL: '''))
83
+ latex_render_url = latex_render_url.strip()
84
+ if latex_render_url:
85
+ config['latex_render_url'] = latex_render_url
86
+ config.save()
87
+ if args.latex_render_url is not None:
88
+ config['latex_render_url'] = args.latex_render_url
89
+ if args.run_code_blocks is not None:
90
+ config['run_code_blocks'] = args.run_code_blocks
91
+ if args.pandoc_mathml is not None:
92
+ config['pandoc_mathml'] = args.pandoc_mathml
93
+
94
+ file_path = pathlib.Path(args.file).expanduser()
95
+ file_path_abs = file_path.absolute()
96
+ try:
97
+ text = file_path.read_text(encoding='utf-8-sig') # Handle BOM for Windows
98
+ except FileNotFoundError:
99
+ raise Text2qtiError(f'File "{file_path}" does not exist')
100
+ except PermissionError as e:
101
+ raise Text2qtiError(f'File "{file_path}" cannot be read due to permission error:\n{e}')
102
+ except UnicodeDecodeError as e:
103
+ raise Text2qtiError(f'File "{file_path}" is not encoded in valid UTF-8:\n{e}')
104
+
105
+ cwd = pathlib.Path.cwd()
106
+ if args.solutions:
107
+ qti_path = pathlib.Path(f'{file_path.stem}.zip')
108
+ solutions_paths = [pathlib.Path(x).expanduser().absolute() for x in args.solutions]
109
+ elif args.only_solutions:
110
+ qti_path = None
111
+ solutions_paths = [pathlib.Path(x).expanduser().absolute() for x in args.only_solutions]
112
+ else:
113
+ qti_path = pathlib.Path(f'{file_path.stem}.zip')
114
+ solutions_paths = None
115
+ if solutions_paths is not None:
116
+ if file_path_abs in solutions_paths:
117
+ raise Text2qtiError(f'Solutions cannot overwrite quiz file "{file_path}"')
118
+ if not all(x.suffix.lower() in ('.md', '.markdown', '.pdf', '.html') for x in solutions_paths):
119
+ invalid_extensions = ', '.join(x.suffix for x in solutions_paths if x.suffix not in ('.md', '.markdown', '.pdf', '.html'))
120
+ raise Text2qtiError(f'Unsupported export format(s) {invalid_extensions} for solutions; use .md, .markdown, .pdf, or .html')
121
+ os.chdir(file_path.parent)
122
+ try:
123
+ # Quiz and any solutions should only be generated once each so that
124
+ # any randomization is only invoked once.
125
+ quiz = Quiz(text, config=config, source_name=file_path.as_posix())
126
+ if solutions_paths is not None:
127
+ solutions_text = quiz_to_pandoc(quiz, solutions=True)
128
+ for solutions_path in solutions_paths:
129
+ if solutions_path.suffix.lower() == '.pdf':
130
+ if not shutil.which('pandoc'):
131
+ raise Text2qtiError('Exporting solutions in PDF format requires Pandoc (https://pandoc.org/)')
132
+ if not shutil.which('pdflatex'):
133
+ raise Text2qtiError('Exporting solutions in PDF format requires LaTeX (https://www.tug.org/texlive/ or https://miktex.org/)')
134
+ if platform.system() == 'Windows':
135
+ cmd = [shutil.which('pandoc'), '-f', 'markdown', '-o', str(solutions_path)]
136
+ else:
137
+ cmd = ['pandoc', '-f', 'markdown', '-o', str(solutions_path)]
138
+ try:
139
+ proc = subprocess.run(
140
+ cmd,
141
+ input=solutions_text,
142
+ capture_output=True,
143
+ check=True,
144
+ encoding='utf8'
145
+ )
146
+ except subprocess.CalledProcessError as e:
147
+ raise Text2qtiError(f'Pandoc failed:\n{"-"*78}\n{e}\n{"-"*78}')
148
+ elif solutions_path.suffix.lower() == '.html':
149
+ if not shutil.which('pandoc'):
150
+ raise Text2qtiError('Exporting solutions in HTML format requires Pandoc (https://pandoc.org/)')
151
+ if platform.system() == 'Windows':
152
+ cmd = [shutil.which('pandoc'), '-f', 'markdown', '-o', str(solutions_path), '--mathjax', '-s']
153
+ else:
154
+ cmd = ['pandoc', '-f', 'markdown', '-o', str(solutions_path), '--mathjax', '-s']
155
+ try:
156
+ proc = subprocess.run(
157
+ cmd,
158
+ input=solutions_text,
159
+ capture_output=True,
160
+ check=True,
161
+ encoding='utf8'
162
+ )
163
+ except subprocess.CalledProcessError as e:
164
+ raise Text2qtiError(f'Pandoc failed:\n{"-"*78}\n{e}\n{"-"*78}')
165
+ elif solutions_path.suffix.lower() in ('.md', '.markdown'):
166
+ solutions_path.write_text(solutions_text, encoding='utf8')
167
+ else:
168
+ raise ValueError
169
+ if qti_path is not None:
170
+ qti = QTI(quiz)
171
+ qti.save(qti_path)
172
+ finally:
173
+ os.chdir(cwd)
text2qti/config.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ import bespon
12
+ import pathlib
13
+ import textwrap
14
+ import warnings
15
+ from .err import Text2qtiError
16
+
17
+
18
+
19
+
20
+ class Config(dict):
21
+ '''
22
+ Dict-like configuration that raises an error when invalid keys are set.
23
+ If `.load()` is invoked, a config file in BespON format is loaded if it
24
+ exists, and otherwise is created if possible.
25
+ '''
26
+ def __init__(self, *args, **kwargs):
27
+ self.loaded_config_file = False
28
+ self.update(self._defaults)
29
+ self.update(dict(*args, **kwargs))
30
+
31
+ _defaults = {
32
+ 'pandoc_mathml': False,
33
+ 'run_code_blocks': False,
34
+ }
35
+ _key_check = {
36
+ 'latex_render_url': lambda x: isinstance(x, str),
37
+ 'pandoc_mathml': lambda x: isinstance(x, bool),
38
+ 'run_code_blocks': lambda x: isinstance(x, bool),
39
+ }
40
+ _config_path = pathlib.Path('~/.text2qti.bespon').expanduser()
41
+
42
+ def __setitem__(self, key, value):
43
+ if key not in self._key_check:
44
+ raise Text2qtiError(f'Invalid configuration option "{key}"')
45
+ if not self._key_check[key](value):
46
+ raise Text2qtiError(f'Configuration option "{key}" has invalid value "{value}"')
47
+ super().__setitem__(key, value)
48
+
49
+
50
+ def update(self, other: dict):
51
+ '''
52
+ Send all keys through __setitem__ so that they are checked for
53
+ validity.
54
+ '''
55
+ for k, v in other.items():
56
+ self[k] = v
57
+
58
+
59
+ def __missing__(self, key):
60
+ if self.loaded_config_file:
61
+ raise Text2qtiError(textwrap.dedent(f'''\
62
+ Configuration option "{key}" has not been set.
63
+ Open "{self._config_path}" to edit config manually.
64
+ '''))
65
+ raise Text2qtiError(f'Configuration option "{key}" has not been set.')
66
+
67
+
68
+ _default_config_template = textwrap.dedent('''\
69
+ # To set a default LaTeX rendering URL for Canvas, uncomment the
70
+ # appropriate config line below and replace <institution> with the name or
71
+ # abbreviation for your institution. You can find this by looking at
72
+ # your browser address bar when logged into Canvas. In some cases, more
73
+ # modifications may be necessary than simply replacing <institution>.
74
+
75
+ # For Canvas through Instructure:
76
+ # latex_render_url = "https://<institution>.instructure.com/equation_images/"
77
+
78
+ # For Canvas through your institution (may need to change ".edu" domain):
79
+ # latex_render_url = "https://canvas.<institution>.edu/equation_images/"
80
+ ''')
81
+
82
+ def load(self):
83
+ '''
84
+ Load config file.
85
+ '''
86
+ config_path = self._config_path
87
+ config_text = None
88
+ try:
89
+ config_text = config_path.read_text('utf8')
90
+ self.loaded_config_file = True
91
+ except FileNotFoundError:
92
+ try:
93
+ config_path.write_text(self._default_config_template, encoding='utf8')
94
+ except FileNotFoundError:
95
+ warnings.warn(f'Could not create default text2qti config file "{config_path}" due to FileNotFoundError (directory does not exist).')
96
+ except PermissionError:
97
+ warnings.warn(f'Could not create default text2qti config file "{config_path}" due to PermissionError.')
98
+ except PermissionError:
99
+ raise Text2qtiError(f'Could not open text2qti config file "{config_path}" due to PermissionError.')
100
+ except UnicodeDecodeError:
101
+ raise Text2qtiError(f'Could not open text2qti config file "{config_path}" due to UnicodeDecodeError. File may be corrupt.')
102
+
103
+ if config_text is not None:
104
+ try:
105
+ config_dict = bespon.loads(config_text, empty_default=dict)
106
+ except Exception as e:
107
+ raise Text2qtiError(f'Failed to load config file "{config_path}":\n{e}')
108
+ try:
109
+ self.update(config_dict)
110
+ except Text2qtiError as e:
111
+ raise Text2qtiError(f'Failed to load config file "{config_path}":\n{e}')
112
+
113
+ def save(self):
114
+ '''
115
+ Save config file.
116
+ '''
117
+ config_path = self._config_path
118
+ try:
119
+ bespon_text = bespon.dumps(dict(self))
120
+ except Exception as e:
121
+ raise Text2qtiError(f'Failed to convert config data to config file format (invalid data?):\n{e}')
122
+ try:
123
+ config_path.write_text(bespon_text, 'utf8')
124
+ except FileNotFoundError:
125
+ raise Text2qtiError(f'Could not create text2qti config file "{config_path}" due to FileNotFoundError (directory does not exist).')
126
+ except PermissionError:
127
+ raise Text2qtiError(f'Could not create text2qti config file "{config_path}" due to PermissionError.')
text2qti/err.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ class Text2qtiError(Exception):
12
+ pass
text2qti/export.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2021, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ import random
12
+ import re
13
+ import textwrap
14
+
15
+ from .quiz import Quiz, Question, GroupStart, GroupEnd, TextRegion
16
+ from .markdown import Markdown
17
+
18
+
19
+ markdown = Markdown()
20
+
21
+
22
+ # https://daringfireball.net/projects/markdown/syntax
23
+ _md_escape_chars_re = re.compile(r'[\\`*_{}\[\]()#+\-.!]')
24
+
25
+ def _md_escape_chars_repl_func(match: re.Match) -> str:
26
+ return '\\' + match.group()
27
+
28
+ def md_escape(raw_text: str) -> str:
29
+ '''
30
+ Escape raw text so that it is suitable for inclusion in Markdown.
31
+ '''
32
+ return _md_escape_chars_re.sub(_md_escape_chars_repl_func, raw_text)
33
+
34
+
35
+ def indent(text: str, n_spaces: int, first_line: bool=True):
36
+ '''
37
+ Indent a string by a specified number of spaces, optionally leaving the
38
+ first line unchanged.
39
+ '''
40
+ if n_spaces < 0:
41
+ raise ValueError
42
+ if n_spaces == 0 or not text:
43
+ return text
44
+ indent_spaces = ' '*n_spaces
45
+ indented_text = text.replace('\n', '\n' + indent_spaces)
46
+ indented_text = indented_text.replace('\n' + indent_spaces + '\n', '\n\n')
47
+ if text[-1] == '\n':
48
+ indented_text = indented_text.rstrip(' ')
49
+ if first_line and text[0] != '\n':
50
+ indented_text = indent_spaces + indented_text
51
+ return indented_text
52
+
53
+
54
+ _latex_templates = {
55
+ 'header': textwrap.dedent(
56
+ r'''
57
+ %%%% Begin text2qti custom preamble
58
+ % Page layout
59
+ \usepackage[margin=1in]{geometry}
60
+ % Graphics
61
+ \usepackage{graphicx}
62
+ % Math/science
63
+ \usepackage{amsmath, amssymb}
64
+ \usepackage{siunitx}
65
+ % Symbols for solutions
66
+ \usepackage{fontawesome}
67
+ % Answers and solutions use itemize with custom item symbols
68
+ \def\texttoqtimctfchoicesymb{%
69
+ \resizebox{2ex}{!}{\faCircleO}}
70
+ \def\texttoqtimctfcorrectchoicesymb{%
71
+ \resizebox{2ex}{!}{\faDotCircleO}}
72
+ \def\texttoqtimultanschoicesymb{%
73
+ \resizebox{2ex}{!}{\faSquareO}}
74
+ \def\texttoqtimultanscorrectchoicesymb{%
75
+ \resizebox{2ex}{!}{\faCheckSquare}}
76
+ \def\texttoqtigeneralcorrectanssymb{%
77
+ \resizebox{2ex}{!}{\faArrowRight}}
78
+ \def\texttoqtisolutionsymb{%
79
+ \resizebox{2ex}{!}{\faFileTextO}}
80
+ \def\texttoqtishortansbox{%
81
+ \framebox[0.25\linewidth]{\strut}}
82
+ \def\texttoqtiessayansbox{%
83
+ \framebox{\begin{minipage}\vspace{4\baselineskip}\end{minipage}}}
84
+ %%%% End text2qti custom preamble
85
+ '''[1:]
86
+ ),
87
+ 'mctf_choice_start': r'\item[\texttoqtimctfchoicesymb]',
88
+ 'mctf_choice_end': r'',
89
+ 'mctf_correct_choice_start': r'\item[\texttoqtimctfcorrectchoicesymb]',
90
+ 'mctf_correct_choice_end': r'',
91
+ 'multans_choice_start': r'\item[\texttoqtimultanschoicesymb]',
92
+ 'multans_choice_end': r'',
93
+ 'multans_correct_choice_start': r'\item[\texttoqtimultanscorrectchoicesymb]',
94
+ 'multans_correct_choice_end': r'',
95
+ 'generic_correct_choice_start': r'\item[\texttoqtigeneralcorrectanssymb]',
96
+ 'generic_correct_choice_end': r'',
97
+ 'choices_start': r'\begin{itemize}',
98
+ 'choices_end': r'\end{itemize}',
99
+ 'solution_start': r'\begin{itemize}' +'\n' + r'\item[\texttoqtisolutionsymb]',
100
+ 'solution_end': r'\end{itemize}',
101
+ 'shortans_placeholder': r'\texttoqtishortansbox',
102
+ 'essay_placeholder': r'\texttoqtiessayansbox',
103
+ 'file_upload_placeholder': r'\framebox{\texttt{<file upload>}}',
104
+ 'random_questions_start': r'\begingroup\renewcommand{\labelitemi}{[?]}',
105
+ 'random_questions_end': r'\endgroup',
106
+ }
107
+
108
+ _html_templates = {
109
+ 'header': textwrap.dedent(
110
+ '''
111
+ <style type="text/css">
112
+ html {
113
+ line-height: 1.2;
114
+ }
115
+ div.text2qti-randomized > ul {
116
+ list-style-position: outside;
117
+ margin-left: -0.5em;
118
+ }
119
+ div.text2qti-randomized > ul > li {
120
+ list-style-type: "[?]";
121
+ padding-left: 0.5em;
122
+ }
123
+ ul.text2qti {
124
+ list-style-position: outside;
125
+ /* margin-left: -0.5em; */
126
+ }
127
+ ul > li.text2qti-mctf-choice::marker, ul > li.text2qti-mctf-correct-choice::marker {
128
+ font-size: 1.5em;
129
+ }
130
+ ul > li.text2qti-mctf-choice, ul > li.text2qti-mctf-correct-choice {
131
+ margin-top: -0.5em;
132
+ }
133
+ li.text2qti-mctf-choice {
134
+ list-style-type: "○";
135
+ padding-left: 0.5em;
136
+ }
137
+ li.text2qti-mctf-correct-choice {
138
+ list-style-type: "●";
139
+ padding-left: 0.5em;
140
+ }
141
+ li.text2qti-multans-choice {
142
+ list-style-type: "☐";
143
+ padding-left: 0.5em;
144
+ }
145
+ li.text2qti-multans-correct-choice {
146
+ list-style-type: "☑";
147
+ padding-left: 0.5em;
148
+ }
149
+ li.text2qti-generic-correct {
150
+ list-style-type: "🡆";
151
+ padding-left: 0.5em;
152
+ }
153
+ ul > li.text2qti-solution::marker {
154
+ font-size: 1.75em;
155
+ }
156
+ ul > li.text2qti-solution {
157
+ margin-top: -0.25em;
158
+ }
159
+ li.text2qti-solution {
160
+ list-style-type: "🗈";
161
+ padding-left: 0.5em;
162
+ }
163
+ </style>
164
+ '''[1:]
165
+ ),
166
+ 'mctf_choice_start': '<li class="text2qti-mctf-choice">',
167
+ 'mctf_choice_end': '</li>',
168
+ 'mctf_correct_choice_start': '<li class="text2qti-mctf-correct-choice">',
169
+ 'mctf_correct_choice_end': '</li>',
170
+ 'multans_choice_start': '<li class="text2qti-multans-choice">',
171
+ 'multans_choice_end': '</li>',
172
+ 'multans_correct_choice_start': '<li class="text2qti-multans-correct-choice">',
173
+ 'multans_correct_choice_end': '</li>',
174
+ 'generic_correct_choice_start': '<li class="text2qti-generic-correct">',
175
+ 'generic_correct_choice_end': '</li>',
176
+ 'choices_start': '<ul class="text2qti">',
177
+ 'choices_end': '</ul>',
178
+ 'solution_start': '<ul class="text2qti"><li class="text2qti-solution">',
179
+ 'solution_end': '</ul></li>',
180
+ 'shortans_placeholder': '<div style="width:20em; height:1.5em; border:1px solid black;"></div>',
181
+ 'essay_placeholder': '<div style="width:100%; height:6em; border:1px solid black;"></div>',
182
+ 'file_upload_placeholder': '<pre style="border:1px solid black;"><file upload></pre>',
183
+ 'random_questions_start': '<div class="text2qti-randomized">',
184
+ 'random_questions_end': '</div>',
185
+ }
186
+
187
+ _templates = {}
188
+ template = textwrap.dedent(
189
+ r'''
190
+ ```{=latex}
191
+ !{latex}
192
+ ```
193
+
194
+ ```{=html}
195
+ !{html}
196
+ ```
197
+
198
+ '''[1:]
199
+ )
200
+ for key in _latex_templates:
201
+ _templates[key] = template.replace('!{latex}', _latex_templates[key]).replace('!{html}', _html_templates[key])
202
+ del template
203
+ _templates['divider'] = '{0}\n\n'.format('-'*78)
204
+
205
+
206
+ def question_to_markdown(question: Question, *,
207
+ solutions: bool, unordered: bool,
208
+ show_points: bool=False) -> str:
209
+ '''
210
+ Convert a question to Markdown
211
+ '''
212
+ if not solutions:
213
+ raise NotImplementedError
214
+
215
+ quiz_md = []
216
+
217
+ # List marker and point value
218
+ if solutions and unordered:
219
+ quiz_md.append('* ')
220
+ else:
221
+ quiz_md.append('@. ')
222
+ if show_points:
223
+ quiz_md.append('**[{0}]** '.format(question.points_possible))
224
+ quiz_md.append(indent(markdown.md_to_pandoc(question.question_raw), 4, first_line=False))
225
+ quiz_md.append('\n\n')
226
+
227
+ if question.type in ('true_false_question', 'multiple_choice_question'):
228
+ quiz_md.append(indent(_templates['choices_start'], 4))
229
+ for choice in question.choices:
230
+ if solutions and choice.correct:
231
+ quiz_md.append(indent(_templates['mctf_correct_choice_start'], 4))
232
+ else:
233
+ quiz_md.append(indent(_templates['mctf_choice_start'], 4))
234
+ quiz_md.append(indent(markdown.md_to_pandoc(choice.choice_raw), 4))
235
+ quiz_md.append('\n\n')
236
+ if solutions and choice.correct:
237
+ quiz_md.append(indent(_templates['mctf_correct_choice_end'], 4))
238
+ else:
239
+ quiz_md.append(indent(_templates['mctf_choice_end'], 4))
240
+ quiz_md.append(indent(_templates['choices_end'], 4))
241
+ elif question.type == 'multiple_answers_question':
242
+ quiz_md.append(indent(_templates['choices_start'], 4))
243
+ for choice in question.choices:
244
+ if solutions and choice.correct:
245
+ quiz_md.append(indent(_templates['multans_correct_choice_start'], 4))
246
+ else:
247
+ quiz_md.append(indent(_templates['multans_choice_start'], 4))
248
+ quiz_md.append(indent(markdown.md_to_pandoc(choice.choice_raw), 4))
249
+ quiz_md.append('\n\n')
250
+ if solutions and choice.correct:
251
+ quiz_md.append(indent(_templates['multans_correct_choice_end'], 4))
252
+ else:
253
+ quiz_md.append(indent(_templates['multans_choice_end'], 4))
254
+ quiz_md.append(indent(_templates['choices_end'], 4))
255
+ elif question.type == 'short_answer_question':
256
+ if solutions:
257
+ quiz_md.append(indent(_templates['choices_start'], 4))
258
+ quiz_md.append(indent(_templates['generic_correct_choice_start'], 4))
259
+ quiz_md.append(indent(' | '.join(markdown.md_to_pandoc(choice.choice_raw) for choice in question.choices), 4))
260
+ quiz_md.append('\n\n')
261
+ quiz_md.append(indent(_templates['generic_correct_choice_end'], 4))
262
+ quiz_md.append(indent(_templates['choices_end'], 4))
263
+ else:
264
+ quiz_md.append(indent(_templates['shortans_placeholder'], 4))
265
+ elif question.type == 'numerical_question':
266
+ if solutions:
267
+ quiz_md.append(indent(_templates['choices_start'], 4))
268
+ quiz_md.append(indent(_templates['generic_correct_choice_start'], 4))
269
+ ans = question.numerical_raw
270
+ if '+-' in ans:
271
+ while '+- ' in ans:
272
+ ans = ans.replace('+- ', '+-')
273
+ if ans.endswith('+-0'):
274
+ ans = ans[:-3]
275
+ else:
276
+ ans = ans.replace('+-', r'\pm ')
277
+ ans = ans.replace('%', r'\%')
278
+ quiz_md.append(indent('$', 4))
279
+ quiz_md.append(ans)
280
+ if question.numerical_min is not None and question.numerical_max is not None:
281
+ if '+-' in question.numerical_raw:
282
+ if isinstance(question.numerical_min, int) and isinstance(question.numerical_max, int):
283
+ quiz_md.append(rf' \quad \Rightarrow \quad [{question.numerical_min}, {question.numerical_max}]')
284
+ else:
285
+ quiz_md.append(rf' \quad \Rightarrow \quad [{question.numerical_min:.4f}, {question.numerical_max:.4f}]')
286
+ quiz_md.append('$')
287
+ quiz_md.append('\n\n')
288
+ quiz_md.append(indent(_templates['generic_correct_choice_end'], 4))
289
+ quiz_md.append(indent(_templates['choices_end'], 4))
290
+ elif question.type == 'essay_question':
291
+ if not solutions:
292
+ quiz_md.append(indent(_templates['essay_placeholder'], 4))
293
+ elif question.type == 'file_upload_question':
294
+ if not solutions:
295
+ quiz_md.append(indent(_templates['file_upload_placeholder'], 4))
296
+ else:
297
+ raise ValueError
298
+
299
+ if solutions and question.solution is not None:
300
+ quiz_md.append(indent(_templates['solution_start'], 4))
301
+ quiz_md.append(indent(markdown.md_to_pandoc(question.solution), 4))
302
+ quiz_md.append('\n\n')
303
+ quiz_md.append(indent(_templates['solution_end'], 4))
304
+
305
+ return ''.join(quiz_md)
306
+
307
+
308
+ def quiz_to_pandoc(quiz: Quiz, *, solutions=False) -> str:
309
+ '''
310
+ Generate a Pandoc Markdown version of assessment that optionally includes
311
+ solutions.
312
+ '''
313
+ if not solutions:
314
+ raise NotImplementedError
315
+
316
+ quiz_md = []
317
+
318
+ title = md_escape(quiz.title_raw or 'Quiz')
319
+ title += r'`\\ \textsc{solutions}`{=latex}'
320
+ title += r'`<br><span style="font-variant: small-caps;">solutions</span>`{=html}'
321
+ title = title.replace('\\', '\\\\').replace(r'"', r'\"')
322
+ meta = textwrap.dedent(
323
+ r'''
324
+ ---
325
+ title: "{title}"
326
+ header-includes: |
327
+ {header}
328
+ ...
329
+
330
+ '''[1:]
331
+ )
332
+ meta = meta.format(title=title, header=indent(_templates['header'], 4, first_line=False))
333
+ quiz_md.append(meta)
334
+
335
+ if quiz.description_raw:
336
+ quiz_md.append(markdown.md_to_pandoc(quiz.description_raw))
337
+ quiz_md.append('\n\n')
338
+ quiz_md.append(_templates['divider'])
339
+
340
+ len_quiz_md_before_questions = len(quiz_md)
341
+ in_group = False
342
+ group_needs_divider = False
343
+ for question_or_delim in quiz.questions_and_delims:
344
+ if isinstance(question_or_delim, TextRegion):
345
+ if question_or_delim.title_raw:
346
+ if len(quiz_md) > len_quiz_md_before_questions and quiz_md[-1] != _templates['divider']:
347
+ quiz_md.append(_templates['divider'])
348
+ quiz_md.append('## {0}\n\n'.format(md_escape(question_or_delim.title_raw.replace('\n', ' '))))
349
+ if question_or_delim.text_raw:
350
+ quiz_md.append(markdown.md_to_pandoc(question_or_delim.text_raw))
351
+ quiz_md.append('\n\n')
352
+ quiz_md.append(_templates['divider'])
353
+ continue
354
+ if isinstance(question_or_delim, GroupStart):
355
+ in_group = True
356
+ group = question_or_delim.group
357
+ if solutions:
358
+ if group.solutions_pick is not None:
359
+ num_questions_displayed = group.solutions_pick
360
+ elif quiz.solutions_sample_groups:
361
+ num_questions_displayed = group.pick
362
+ else:
363
+ num_questions_displayed = len(group.questions)
364
+ if num_questions_displayed > 1:
365
+ if len(quiz_md) > len_quiz_md_before_questions and quiz_md[-1] != _templates['divider']:
366
+ quiz_md.append(_templates['divider'])
367
+ group_needs_divider = True
368
+ if group.pick == 1:
369
+ if num_questions_displayed == 1:
370
+ quiz_md.append(f'### Randomized question: representative example is shown\n\n')
371
+ elif num_questions_displayed < len(group.questions):
372
+ quiz_md.append(f'### Randomized question: randomly select {group.pick} from representative examples shown\n\n')
373
+ else:
374
+ quiz_md.append(f'### Randomized question: randomly select {group.pick}\n\n')
375
+ else:
376
+ if num_questions_displayed == group.pick:
377
+ quiz_md.append(f'### Randomized questions: representative examples are shown\n\n')
378
+ elif num_questions_displayed < len(group.questions):
379
+ quiz_md.append(f'### Randomized questions: randomly select {group.pick} from representative examples shown\n\n')
380
+ else:
381
+ quiz_md.append(f'### Randomized questions: randomly select {group.pick}\n\n')
382
+ if num_questions_displayed != group.pick:
383
+ for _ in range(group.pick):
384
+ quiz_md.append('@. `<randomly selected>`\n\n')
385
+ unordered = num_questions_displayed != group.pick
386
+ if unordered:
387
+ quiz_md.append(_templates['random_questions_start'])
388
+ if quiz.solutions_randomize_groups:
389
+ for question in random.choices(question_or_delim.group.questions, num_questions_displayed):
390
+ quiz_md.append(question_to_markdown(question, solutions=solutions, unordered=unordered))
391
+ else:
392
+ for question in question_or_delim.group.questions[:num_questions_displayed]:
393
+ quiz_md.append(question_to_markdown(question, solutions=solutions, unordered=unordered))
394
+ if unordered:
395
+ quiz_md.append(_templates['random_questions_end'])
396
+ if group_needs_divider:
397
+ quiz_md.append(_templates['divider'])
398
+ group_needs_divider = False
399
+ continue
400
+ if isinstance(question_or_delim, GroupEnd):
401
+ in_group = False
402
+ continue
403
+ if isinstance(question_or_delim, Question):
404
+ if in_group:
405
+ continue
406
+ quiz_md.append(question_to_markdown(question_or_delim, solutions=solutions, unordered=in_group))
407
+ continue
408
+ raise TypeError
409
+
410
+ if quiz_md and quiz_md[-1] is _templates['divider']:
411
+ quiz_md.pop()
412
+ return ''.join(quiz_md)
text2qti/fmtversion.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2015-2018, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ '''
12
+ =============================================================
13
+ ``fmtversion``: Simple version variables for Python packages
14
+ =============================================================
15
+
16
+ :Author: Geoffrey M. Poore
17
+ :License: `BSD 3-Clause <http://opensource.org/licenses/BSD-3-Clause>`_
18
+
19
+ Converts version information into a string ``__version__`` and a namedtuple
20
+ ``__version_info__`` suitable for Python packages. The approach is inspired
21
+ by PEP 440 and ``sys.version_info``:
22
+
23
+ * https://www.python.org/dev/peps/pep-0440
24
+ * https://docs.python.org/3/library/sys.html
25
+
26
+ Versions of the form "major.minor.micro" are supported, with an optional,
27
+ numbered dev/alpha/beta/candidate/final/post status. The module does not
28
+ support more complicated version numbers like "1.0b2.post345.dev456", since
29
+ this does not fit into a namedtuple of the form used by ``sys.version_info``.
30
+ The code is written as a single module, so that it may be bundled into
31
+ packages, rather than needing to be installed as a separate dependency.
32
+
33
+ Typical usage in a package's ``version.py`` with a bundled ``fmtversion.py``::
34
+
35
+ from .fmtversion import get_version_plus_info
36
+ __version__, __version_info__ = get_version_plus_info(1, 1, 0, 'final', 0)
37
+
38
+ Following ``sys.version_info``, ``get_version_plus_info()`` takes arguments
39
+ for a five-component version number: major, minor, micro, releaselevel, and
40
+ serial. The releaselevel may be any of dev, alpha, beta, candidate, final, or
41
+ post, or common variations/abbreviations thereof. All arguments but
42
+ releaselevel must be convertable to integers.
43
+
44
+ If only ``__version__`` or ``__version_info__`` is desired, then the functions
45
+ ``get_version()`` or ``get_version_info()`` may be used instead. If a micro
46
+ version is not needed (``<major>.<minor>.<micro>``), then set the optional
47
+ keyword argument ``usemicro=False``. This will omit a micro version from the
48
+ string ``__version__``, while the namedtuple ``__version_info__`` will still
49
+ have a field ``micro`` that is set to zero to simplify comparisons. If each
50
+ releaselevel will only have one release, then set ``useserial=False``. This
51
+ will omit a serial number from the string ``__version__``, while the
52
+ namedtuple ``__version_info__`` will still have a field ``serial`` that is set
53
+ to zero.
54
+
55
+ A function ``get_version_from_version_py_str()`` is included for extracting
56
+ the version from a ``version.py`` file that has been read into a string. This
57
+ is intended for use in ``setup.py`` files.
58
+ '''
59
+
60
+
61
+
62
+
63
+ from __future__ import (division, print_function, absolute_import,
64
+ unicode_literals)
65
+ import sys
66
+ if sys.version_info.major == 2:
67
+ str = basestring
68
+ import collections
69
+
70
+
71
+ # Version of this module, which will later be converted into a `__version__`
72
+ # and `__version_info__` once the necessary functions are created
73
+ __version_tuple__ = (1, 1, 0, 'final', 0)
74
+
75
+ __docformat__ = 'restructuredtext en'
76
+
77
+
78
+
79
+
80
+ VersionInfo = collections.namedtuple('VersionInfo', ['major', 'minor', 'micro',
81
+ 'releaselevel', 'serial'])
82
+
83
+
84
+ def get_version_info(major, minor, micro, releaselevel, serial,
85
+ usemicro=True, useserial=True):
86
+ '''
87
+ Create a VersionInfo instance suitable for use as `__version_info__`.
88
+
89
+ Perform all type and value checking that is needed for arguments; assume
90
+ that no previous checks have been performed. This allows all checks to be
91
+ centralized in this single function.
92
+ '''
93
+ if not all(isinstance(x, int) or isinstance(x, str)
94
+ for x in (major, minor, micro, serial)):
95
+ raise TypeError('major, minor, micro, and serial must be integers or strings')
96
+ if not isinstance(releaselevel, str):
97
+ raise TypeError('releaselevel must be a string')
98
+ if not all(isinstance(x, bool) for x in (usemicro, useserial)):
99
+ raise TypeError('usemicro and useserial must be bools')
100
+
101
+ try:
102
+ major = int(major)
103
+ minor = int(minor)
104
+ micro = int(micro)
105
+ serial = int(serial)
106
+ except ValueError:
107
+ raise ValueError('major, minor, micro, and serial must be convertable to integers')
108
+ if any(x < 0 for x in (major, minor, micro, serial)):
109
+ raise ValueError('major, minor, micro, and serial must correspond to non-negative integers')
110
+ if not usemicro and micro != 0:
111
+ raise ValueError('usemicro=False, but a micro value "{0}" has been set'.format(micro))
112
+ if not useserial and serial != 0:
113
+ raise ValueError('useserial=False, but a serial value "{0}" has been set'.format(serial))
114
+
115
+ releaselevel_dict = {'dev': 'dev',
116
+ 'a': 'a', 'alpha': 'a',
117
+ 'b': 'b', 'beta': 'b',
118
+ 'c': 'c', 'rc': 'c',
119
+ 'candidate': 'c', 'releasecandidate': 'c',
120
+ 'pre': 'c', 'preview': 'c',
121
+ 'final': 'final',
122
+ 'post': 'post', 'r': 'post', 'rev': 'post'}
123
+ try:
124
+ releaselevel = releaselevel_dict[releaselevel.lower()]
125
+ except KeyError:
126
+ raise ValueError('Invalid releaselevel "{0}"'.format(releaselevel))
127
+ if releaselevel == 'final' and serial != 0:
128
+ raise ValueError('final release must not have non-zero serial')
129
+
130
+ return VersionInfo(major, minor, micro, releaselevel, serial)
131
+
132
+
133
+ def get_version(*args, **kwargs):
134
+ '''
135
+ Create a version string suitable for use as `__version__`.
136
+
137
+ Make sure arguments are appropriate, but leave all actual processing and
138
+ value and type checking to `get_version_info()`.
139
+ '''
140
+ usemicro = kwargs.pop('usemicro', True)
141
+ useserial = kwargs.pop('useserial', True)
142
+ if kwargs:
143
+ raise TypeError('Unexpected keyword(s): {0}'.format(', '.join('{0}'.format(k) for k in kwargs)))
144
+
145
+ if len(args) == 1:
146
+ version_info = args[0]
147
+ if not isinstance(version_info, VersionInfo):
148
+ raise TypeError('Positional argument must have 5 components, or be a VersionInfo instance')
149
+ elif len(args) == 5:
150
+ version_info = get_version_info(*args, usemicro=usemicro, useserial=useserial)
151
+ else:
152
+ raise TypeError('Positional argument must have 5 components, or be a VersionInfo instance')
153
+
154
+ version = '{0}.{1}'.format(version_info.major, version_info.minor)
155
+ if usemicro:
156
+ version += '.{0}'.format(version_info.micro)
157
+ if version_info.releaselevel != 'final':
158
+ if version_info.releaselevel in ('dev', 'post'):
159
+ version += '.{0}'.format(version_info.releaselevel)
160
+ else:
161
+ version += '{0}'.format(version_info.releaselevel)
162
+ if useserial:
163
+ version += '{0}'.format(version_info.serial)
164
+
165
+ return version
166
+
167
+
168
+ def get_version_plus_info(*args, **kwargs):
169
+ '''
170
+ Create a tuple consisting of a version string and a VersionInfo instance.
171
+ '''
172
+ usemicro = kwargs.pop('usemicro', True)
173
+ useserial = kwargs.pop('useserial', True)
174
+ if kwargs:
175
+ raise TypeError('Unexpected keyword(s): {0}'.format(', '.join('{0}'.format(k) for k in kwargs)))
176
+
177
+ version_info = get_version_info(*args, usemicro=usemicro, useserial=useserial)
178
+ version = get_version(version_info, usemicro=usemicro, useserial=useserial)
179
+ return (version, version_info)
180
+
181
+
182
+
183
+
184
+ # Now that the required functions exist, process `__version_tuple__` into
185
+ # `__version__` and `__version_info__` for the module
186
+ __version__, __version_info__ = get_version_plus_info(*__version_tuple__)
187
+
188
+
189
+
190
+
191
+
192
+ def get_version_from_version_py_str(string):
193
+ '''
194
+ Extract version from version.py that has been read into a string.
195
+
196
+ This assumes a simple, straightforward version.py. It is meant for use
197
+ in setup.py files.
198
+ '''
199
+ if not isinstance(string, str):
200
+ raise TypeError('Argument must be a string')
201
+ if string.count('__version__') != 1:
202
+ raise RuntimeError('Failed to extract version from string')
203
+ exec_locals = {}
204
+ for line in string.splitlines():
205
+ if line.startswith('__version__'):
206
+ if not any(x in line for x in ['get_version(', 'get_version_plus_info(']):
207
+ raise RuntimeError('Failed to extract version from string')
208
+ if 'fmtversion.get_version' in line:
209
+ line = line.replace('fmtversion.get_version', 'get_version')
210
+ try:
211
+ exec(compile(line, 'version.py', 'exec'), globals(), exec_locals)
212
+ except Exception:
213
+ raise RuntimeError('Failed to extract version from string')
214
+ break
215
+ if '__version__' not in exec_locals:
216
+ raise RuntimeError('Failed to extract version from string')
217
+ return exec_locals['__version__']
text2qti/gui/__init__.py ADDED
File without changes
text2qti/gui/tk.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ import os
12
+ import pathlib
13
+ import shutil
14
+ import time
15
+ import tkinter as tk
16
+ import tkinter.filedialog
17
+ import webbrowser
18
+ from ..config import Config
19
+ from ..err import Text2qtiError
20
+ from ..qti import QTI
21
+ from ..quiz import Quiz
22
+ from .. import version
23
+
24
+
25
+
26
+ def main():
27
+ config = Config()
28
+ config.load()
29
+ file_name = ''
30
+
31
+ window = tk.Tk()
32
+ window.title('text2qti')
33
+ # Bring window to front and put in focus
34
+ window.iconify()
35
+ window.update()
36
+ window.deiconify()
37
+
38
+ # Window grid setup
39
+ current_row = 0
40
+ column_count = 4
41
+
42
+
43
+ header_label = tk.Label(
44
+ window,
45
+ text='text2qti – Create quizzes in QTI format from Markdown-based plain text',
46
+ font=(None, 16),
47
+ )
48
+ header_label.grid(
49
+ row=current_row, column=0, columnspan=column_count, padx=(30, 30),
50
+ sticky='nsew',
51
+ )
52
+ current_row += 1
53
+ header_link_label = tk.Label(
54
+ window,
55
+ text='github.com/gpoore/text2qti',
56
+ font=(None, 14), fg='blue', cursor='hand2',
57
+ )
58
+ header_link_label.bind('<Button-1>', lambda x: webbrowser.open_new('https://github.com/gpoore/text2qti'))
59
+ header_link_label.grid(
60
+ row=current_row, column=0, columnspan=column_count, padx=(30, 30),
61
+ sticky='nsew',
62
+ )
63
+ current_row += 1
64
+ version_label = tk.Label(
65
+ window,
66
+ text=f'Version {version.__version__}',
67
+ )
68
+ version_label.grid(
69
+ row=current_row, column=0, columnspan=column_count, padx=(30, 30), pady=(0, 30),
70
+ sticky='nsew',
71
+ )
72
+ current_row += 1
73
+
74
+
75
+ file_browser_label = tk.Label(
76
+ window,
77
+ text='Quiz file:\n(plain text file)',
78
+ justify='right',
79
+ )
80
+ file_browser_label.grid(
81
+ row=current_row, column=0, padx=(30, 5), pady=(5, 25),
82
+ sticky='nse',
83
+ )
84
+ last_dir = None
85
+ def browse_files():
86
+ nonlocal file_name
87
+ nonlocal last_dir
88
+ if last_dir is None:
89
+ initialdir = pathlib.Path('~').expanduser()
90
+ else:
91
+ initialdir = last_dir
92
+ file_name = tkinter.filedialog.askopenfilename(
93
+ initialdir=initialdir,
94
+ title='Select a quiz file',
95
+ filetypes=[('Quiz files', '*.md;*.txt')],
96
+ )
97
+ if file_name:
98
+ if last_dir is None:
99
+ last_dir = pathlib.Path(file_name).parent
100
+ file_browser_button.config(text=f'"{file_name}"', fg='green')
101
+ else:
102
+ file_browser_button.config(text=f'<none selected>', fg='red')
103
+
104
+ file_browser_button = tk.Button(
105
+ window,
106
+ text='<none selected>',
107
+ fg='red',
108
+ command=browse_files,
109
+ )
110
+ file_browser_button.grid(
111
+ row=current_row, column=1, columnspan=column_count-1, padx=(0, 30), pady=(5, 25),
112
+ sticky='nsew',
113
+ )
114
+ current_row += 1
115
+
116
+
117
+ advanced_options_label = tk.Label(
118
+ window,
119
+ text='Advanced options – LaTeX math & executable code',
120
+ justify='right',
121
+ )
122
+ advanced_options_label.grid(
123
+ row=current_row, column=1, columnspan=2, padx=(0, 0), pady=(5, 5),
124
+ sticky='nsw',
125
+ )
126
+ current_row += 1
127
+
128
+
129
+ latex_url_label = tk.Label(
130
+ window,
131
+ text='LaTeX math rendering URL:\n(for Canvas and similar systems)',
132
+ justify='right',
133
+ )
134
+ latex_url_label.grid(
135
+ row=current_row, column=0, padx=(30, 5), pady=(5, 5),
136
+ sticky='nse',
137
+ )
138
+ latex_url_entry = tk.Entry(window, width=100)
139
+ latex_url_entry.grid(
140
+ row=current_row, column=1, columnspan=column_count-1, padx=(0, 30), pady=(5, 5),
141
+ sticky='nsew',
142
+ )
143
+ if 'latex_render_url' in config:
144
+ latex_url_entry.insert(1, f"{config['latex_render_url']}")
145
+ current_row += 1
146
+
147
+
148
+ pandoc_exists = bool(shutil.which('pandoc'))
149
+ pandoc_mathml_label = tk.Label(
150
+ window,
151
+ text='Convert LaTeX math to MathML:\n(requires Pandoc; ignores rendering URL)',
152
+ justify='right',
153
+ )
154
+ if not pandoc_exists:
155
+ pandoc_mathml_label['fg'] = 'gray'
156
+ pandoc_mathml_label.grid(
157
+ row=current_row, column=0, padx=(30, 5), pady=(5, 5),
158
+ sticky='nse',
159
+ )
160
+ pandoc_mathml_bool = tk.BooleanVar()
161
+ def pandoc_mathml_command():
162
+ if pandoc_mathml_bool.get():
163
+ latex_url_label['fg'] = 'gray'
164
+ latex_url_entry['fg'] = 'gray'
165
+ else:
166
+ latex_url_label['fg'] = 'black'
167
+ latex_url_entry['fg'] = 'black'
168
+ if pandoc_exists:
169
+ pandoc_mathml_button = tk.Checkbutton(
170
+ window,
171
+ variable=pandoc_mathml_bool,
172
+ command=pandoc_mathml_command,
173
+ )
174
+ pandoc_mathml_bool.set(config['pandoc_mathml'])
175
+ else:
176
+ pandoc_mathml_button = tk.Checkbutton(
177
+ window,
178
+ state=tk.DISABLED,
179
+ )
180
+ pandoc_mathml_button.grid(
181
+ row=current_row, column=1, sticky='w',
182
+ )
183
+ current_row += 1
184
+
185
+
186
+ run_code_blocks_label = tk.Label(
187
+ window,
188
+ text='Allow executable code blocks:\n(only use for trusted code)',
189
+ justify='right',
190
+ )
191
+ run_code_blocks_label.grid(
192
+ row=current_row, column=0, padx=(30, 5), pady=(5, 5),
193
+ sticky='nse',
194
+ )
195
+ run_code_blocks_bool = tk.BooleanVar()
196
+ run_code_blocks_bool.set(config['run_code_blocks'])
197
+ def run_code_blocks_command():
198
+ if run_code_blocks_bool.get():
199
+ run_code_blocks_label['fg'] = 'red'
200
+ else:
201
+ run_code_blocks_label['fg'] = 'black'
202
+ run_code_blocks_button = tk.Checkbutton(
203
+ window,
204
+ variable=run_code_blocks_bool,
205
+ command=run_code_blocks_command,
206
+ )
207
+ run_code_blocks_button.grid(
208
+ row=current_row, column=1, sticky='w',
209
+ )
210
+ current_row += 1
211
+
212
+
213
+ def run():
214
+ run_message_text.delete(1.0, tk.END)
215
+ run_message_text['fg'] = 'gray'
216
+ run_message_text.insert(tk.INSERT, 'Starting...')
217
+ run_message_text.update()
218
+ error_message = None
219
+ if not file_name:
220
+ error_message = 'Must select a quiz file'
221
+ run_message_text.delete(1.0, tk.END)
222
+ run_message_text.insert(tk.INSERT, error_message)
223
+ run_message_text['fg'] = 'red'
224
+ return
225
+ if latex_url_entry.get():
226
+ config['latex_render_url'] = latex_url_entry.get()
227
+ config['run_code_blocks'] = run_code_blocks_bool.get()
228
+ config['pandoc_mathml'] = pandoc_mathml_bool.get()
229
+
230
+ file_path = pathlib.Path(file_name)
231
+ try:
232
+ text = file_path.read_text(encoding='utf-8-sig') # Handle BOM for Windows
233
+ except FileNotFoundError:
234
+ error_message = f'File "{file_path}" does not exist.'
235
+ except PermissionError as e:
236
+ error_message = f'File "{file_path}" cannot be read due to permission error. Technical details:\n\n{e}'
237
+ except UnicodeDecodeError as e:
238
+ error_message = f'File "{file_path}" is not encoded in valid UTF-8. Technical details:\n\n{e}'
239
+ except Exception as e:
240
+ error_message = f'An error occurred in reading the quiz file. Technical details:\n\n{e}'
241
+ if error_message:
242
+ run_message_text.delete(1.0, tk.END)
243
+ run_message_text.insert(tk.INSERT, error_message)
244
+ run_message_text['fg'] = 'red'
245
+ return
246
+ cwd = pathlib.Path.cwd()
247
+ os.chdir(file_path.parent)
248
+ try:
249
+ quiz = Quiz(text, config=config, source_name=file_path.as_posix())
250
+ qti = QTI(quiz)
251
+ qti.save(f'{file_path.stem}.zip')
252
+ except Text2qtiError as e:
253
+ error_message = f'Quiz creation failed:\n\n{e}'
254
+ except Exception as e:
255
+ error_message = f'Quiz creation failed unexpectedly. Technical details:\n\n{e}'
256
+ finally:
257
+ os.chdir(cwd)
258
+ if error_message:
259
+ run_message_text.delete(1.0, tk.END)
260
+ run_message_text.insert(tk.INSERT, error_message)
261
+ run_message_text['fg'] = 'red'
262
+ else:
263
+ run_message_text.delete(1.0, tk.END)
264
+ run_message_text.insert(tk.INSERT, f'Created quiz "{file_path.parent.as_posix()}/{file_path.stem}.zip"')
265
+ run_message_text['fg'] = 'green'
266
+ run_button = tk.Button(
267
+ window,
268
+ text='RUN',
269
+ font=(None, 14),
270
+ command=run,
271
+ )
272
+ run_button.grid(
273
+ row=current_row, column=1, columnspan=2, padx=(0, 0), pady=(30, 30),
274
+ sticky='nsew',
275
+ )
276
+ current_row += 1
277
+
278
+
279
+ run_message_label = tk.Label(
280
+ window,
281
+ text='\nRun Summary:\n',
282
+ relief='ridge',
283
+ width=120,
284
+ )
285
+ run_message_label.grid(
286
+ row=current_row, column=0, columnspan=column_count, padx=(30, 30), pady=(0, 0),
287
+ sticky='nsew',
288
+ )
289
+ current_row += 1
290
+
291
+
292
+ run_message_frame = tk.Frame(
293
+ window,
294
+ width=120, height=40,
295
+ borderwidth=1, relief='sunken', bg='white',
296
+ )
297
+ run_message_frame.grid(
298
+ row=current_row, column=0, columnspan=column_count, padx=(30, 30), pady=(0, 30),
299
+ sticky='nsew',
300
+ )
301
+ run_message_scrollbar = tk.Scrollbar(run_message_frame)
302
+ run_message_scrollbar.pack(
303
+ side='right', fill='y',
304
+ )
305
+ run_message_text = tk.Text(
306
+ run_message_frame,
307
+ width=10, height=10, borderwidth=0, highlightthickness=0,
308
+ wrap='word',
309
+ yscrollcommand=run_message_scrollbar.set,
310
+ )
311
+ run_message_text.insert(tk.INSERT, 'Waiting...')
312
+ run_message_text['fg'] = 'gray'
313
+ run_message_scrollbar.config(command=run_message_text.yview)
314
+ run_message_text.pack(
315
+ side='left', fill='both', expand=True,
316
+ padx=(5, 5), pady=(5, 5),
317
+ )
318
+
319
+
320
+ window.mainloop()
text2qti/markdown.py ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ import atexit
12
+ import hashlib
13
+ import json
14
+ import pathlib
15
+ import platform
16
+ import re
17
+ import subprocess
18
+ import time
19
+ import typing
20
+ from typing import Dict, Optional, Set
21
+ import urllib.parse
22
+ import zipfile
23
+
24
+ import markdown
25
+ # Markdown extensions are imported and initialized explicitly to ensure that
26
+ # pyinstaller identifies them.
27
+ import markdown.extensions
28
+ import markdown.extensions.smarty
29
+ import markdown.extensions.sane_lists
30
+ import markdown.extensions.def_list
31
+ import markdown.extensions.fenced_code
32
+ import markdown.extensions.footnotes
33
+ import markdown.extensions.tables
34
+ import markdown.extensions.md_in_html
35
+ from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
36
+
37
+ from .config import Config
38
+ from .err import Text2qtiError
39
+ from .version import __version__ as version
40
+ from . import pymd_pandoc_attr
41
+
42
+
43
+ md_extensions = [
44
+ markdown.extensions.smarty.makeExtension(),
45
+ markdown.extensions.sane_lists.makeExtension(),
46
+ markdown.extensions.def_list.makeExtension(),
47
+ markdown.extensions.fenced_code.makeExtension(),
48
+ markdown.extensions.footnotes.makeExtension(),
49
+ markdown.extensions.tables.makeExtension(),
50
+ markdown.extensions.md_in_html.makeExtension(),
51
+ pymd_pandoc_attr.makeExtension(),
52
+ ]
53
+
54
+
55
+
56
+
57
+ class Image(object):
58
+ '''
59
+ Raw image data for quiz insertion.
60
+ '''
61
+ def __init__(self, name: str, data: bytes):
62
+ self.name = name
63
+ self.data = data
64
+ h = hashlib.blake2b()
65
+ h.update(data)
66
+ self.id = h.hexdigest()[:64]
67
+
68
+ @property
69
+ def src_path(self):
70
+ return f'%24IMS-CC-FILEBASE%24/images/{urllib.parse.quote(self.name)}'
71
+
72
+ @property
73
+ def qti_zip_path(self):
74
+ return f'images/{self.name}'
75
+
76
+ @property
77
+ def qti_xml_path(self):
78
+ return f'images/{urllib.parse.quote(self.name)}'
79
+
80
+
81
+
82
+
83
+ class Text2qtiImagePattern(ImageInlineProcessor):
84
+ '''
85
+ Custom image processor for Python-Markdown that modifies local image
86
+ paths to their final QTI form and also accumulates all image data for QTI
87
+ inclusion.
88
+ '''
89
+ def __init__(self, pattern_re, markdown_md, text2qti_md):
90
+ super().__init__(pattern_re, markdown_md)
91
+ self.text2qti_md = text2qti_md
92
+
93
+ def handleMatch(self, match, data):
94
+ node, start, end = super().handleMatch(match, data)
95
+ src = node.attrib.get('src')
96
+ if src and not any(src.startswith(x) for x in ('http://', 'https://')):
97
+ src_path = pathlib.Path(src).expanduser()
98
+ try:
99
+ data = src_path.read_bytes()
100
+ except FileNotFoundError:
101
+ raise Text2qtiError(f'File "{src_path}" does not exist')
102
+ except PermissionError as e:
103
+ raise Text2qtiError(f'File "{src_path}" cannot be read due to permission error:\n{e}')
104
+ image = Image(src_path.name, data)
105
+ if image.id in self.text2qti_md.images:
106
+ image = self.text2qti_md.images[image.id]
107
+ else:
108
+ if image.name in self.text2qti_md.image_name_set:
109
+ n = 8
110
+ while image.name in self.text2qti_md.image_name_set:
111
+ image.name = f'{src_path.stem}_{image.id[:n]}{src_path.suffix}'
112
+ n *= 2
113
+ if n >= len(image.id)*2:
114
+ raise Text2qtiError('Hash collision occurred during image deduplication')
115
+ self.text2qti_md.image_name_set.add(image.name)
116
+ self.text2qti_md.images[image.id] = image
117
+ node.attrib['src'] = image.src_path
118
+ return node, start, end
119
+
120
+
121
+
122
+
123
+ class Markdown(object):
124
+ r'''
125
+ Convert text from Markdown to HTML. Then escape the HTML for insertion
126
+ into XML templates.
127
+
128
+ During the Markdown to HTML conversion, LaTeX math is converted to Canvas
129
+ img tags. A subset of siunitx (https://ctan.org/pkg/siunitx) LaTeX macros
130
+ are also supported, with limited features: `\SI`, `\si`, and `\num`.
131
+ siunitx macros are extracted via regex and then converted into plain
132
+ LaTeX, since Canvas LaTeX support does not cover siunitx.
133
+ '''
134
+ def __init__(self, config: Optional[Config]=None):
135
+ self.config = config
136
+
137
+ markdown_processor = markdown.Markdown(extensions=md_extensions)
138
+ markdown_image_processor = Text2qtiImagePattern(IMAGE_LINK_RE, markdown_processor, self)
139
+ markdown_processor.inlinePatterns.register(markdown_image_processor, 'image_link', 150)
140
+ self.markdown_processor = markdown_processor
141
+
142
+ self.images: Dict[str, Image] = {}
143
+ self.image_name_set: Set[str] = set()
144
+
145
+ if config is None:
146
+ self.latex_to_qti = self._latex_to_qti_unconfigured
147
+ elif config['pandoc_mathml']:
148
+ self.latex_to_qti = self.latex_to_pandoc_mathml
149
+ self._prep_cache()
150
+ else:
151
+ self.latex_to_qti = self.latex_to_canvas_img
152
+
153
+
154
+ def finalize(self):
155
+ if self.config is not None and self.config['pandoc_mathml']:
156
+ self._save_cache()
157
+ self._cache_lock_path.unlink()
158
+
159
+
160
+ def _latex_to_qti_unconfigured(self, latex: str):
161
+ raise Text2qtiError('Cannot convert LaTeX to QTI unless Markdown configuration is provided')
162
+
163
+
164
+ def _prep_cache(self):
165
+ self._cache_path = pathlib.Path('_text2qti_cache.zip')
166
+ self._cache_lock_path = pathlib.Path('_text2qti_cache.lock')
167
+
168
+ max_lock_wait = 2
169
+ lock_check_interval = 0.1
170
+ lock_time = 0
171
+ while True:
172
+ try:
173
+ self._cache_lock_path.touch(exist_ok=False)
174
+ except FileExistsError:
175
+ if lock_time > max_lock_wait:
176
+ raise Text2qtiError('The text2qti cache is locked; this usually means that another instance of '
177
+ 'text2qti is already running and you should try again later')
178
+ time.sleep(lock_check_interval)
179
+ lock_time += lock_check_interval
180
+ else:
181
+ break
182
+ def final_cache_cleanup():
183
+ try:
184
+ self._cache_lock_path.unlink()
185
+ except FileNotFoundError:
186
+ pass
187
+ atexit.register(final_cache_cleanup)
188
+
189
+ default_cache = {
190
+ 'version': version,
191
+ 'pandoc_mathml': {}
192
+ }
193
+ try:
194
+ with zipfile.ZipFile(str(self._cache_path)) as zf:
195
+ with zf.open('cache.json') as f:
196
+ cache = json.load(f)
197
+ except (FileNotFoundError, KeyError, json.JSONDecodeError):
198
+ cache = default_cache
199
+ else:
200
+ if not isinstance(cache, dict) or cache.get('version') != version:
201
+ cache = default_cache
202
+ for v in cache['pandoc_mathml'].values():
203
+ v['unused_count'] += 1
204
+ self._cache = cache
205
+
206
+
207
+ def _save_cache(self):
208
+ self._cache['pandoc_mathml'] = {k: v for k, v in self._cache['pandoc_mathml'].items()
209
+ if v['unused_count'] <= 10}
210
+ with zipfile.ZipFile(str(self._cache_path), 'w', compression=zipfile.ZIP_DEFLATED) as zf:
211
+ zf.writestr('cache.json', json.dumps(self._cache))
212
+
213
+
214
+ XML_ESCAPES = (('&', '&amp;'),
215
+ ('<', '&lt;'),
216
+ ('>', '&gt;'),
217
+ ('"', '&quot;'),
218
+ ("'", '&apos;'))
219
+ XML_ESCAPES_LESS_QUOTES = tuple(x for x in XML_ESCAPES if x[0] not in ("'", '"'))
220
+ XML_ESCAPES_LESS_SQUOTE = tuple(x for x in XML_ESCAPES if x[0] != "'")
221
+ XML_ESCAPES_LESS_DQUOTE = tuple(x for x in XML_ESCAPES if x[0] != '"')
222
+
223
+ def xml_escape(self, string: str, *, squotes: bool=True, dquotes: bool=True) -> str:
224
+ '''
225
+ Escape a string for XML insertion, with options not to escape quotes.
226
+ '''
227
+ if squotes and dquotes:
228
+ escapes = self.XML_ESCAPES
229
+ elif squotes:
230
+ escapes = self.XML_ESCAPES_LESS_DQUOTE
231
+ elif dquotes:
232
+ escapes = self.XML_ESCAPES_LESS_SQUOTE
233
+ else:
234
+ escapes = self.XML_ESCAPES_LESS_QUOTES
235
+ for char, esc in escapes:
236
+ string = string.replace(char, esc)
237
+ return string
238
+
239
+
240
+ CANVAS_EQUATION_TEMPLATE = '<img class="equation_image" title="{latex_xml_escaped}" src="{latex_render_url}/{latex_url_escaped}" alt="LaTeX: {latex_xml_escaped}" data-equation-content="{latex_xml_escaped}">'
241
+
242
+ def latex_to_canvas_img(self, latex: str) -> str:
243
+ '''
244
+ Convert a LaTeX equation into an img tag suitable for Canvas.
245
+
246
+ Requires an institutional LaTeX equation rendering URL. The URL is stored
247
+ in the text2qti config file or can be passed with flag --latex-render-url.
248
+ It will typically be of the form
249
+
250
+ https://<institution>.instructure.com/equation_images/
251
+
252
+ or
253
+
254
+ https://canvas.<institution>.edu/equation_images/
255
+ '''
256
+ latex_render_url = self.config['latex_render_url'].rstrip('/')
257
+ latex_xml_escaped = self.xml_escape(latex)
258
+ # Double url escaping is required
259
+ latex_url_escaped = urllib.parse.quote(urllib.parse.quote(latex))
260
+ return self.CANVAS_EQUATION_TEMPLATE.format(latex_render_url=latex_render_url,
261
+ latex_xml_escaped=latex_xml_escaped,
262
+ latex_url_escaped=latex_url_escaped)
263
+
264
+
265
+ def latex_to_pandoc_mathml(self, latex: str) -> str:
266
+ '''
267
+ Convert a LaTeX equation into MathML using Pandoc.
268
+ '''
269
+ data = self._cache['pandoc_mathml'].get(latex)
270
+ if data is not None:
271
+ mathml = data['mathml']
272
+ data['unused_count'] = 0
273
+ else:
274
+ if platform.system() == 'Windows':
275
+ # Prevent console from appearing for an instant
276
+ startupinfo = subprocess.STARTUPINFO()
277
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
278
+ else:
279
+ startupinfo = None
280
+ try:
281
+ proc = subprocess.run(['pandoc', '-f', 'markdown', '-t', 'html', '--mathml'],
282
+ input='${0}$'.format(latex), encoding='utf8',
283
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
284
+ startupinfo=startupinfo,
285
+ check=True)
286
+ except FileNotFoundError as e:
287
+ raise Text2qtiError(f'Could not find Pandoc:\n{e}')
288
+ except subprocess.CalledProcessError as e:
289
+ raise Text2qtiError(f'Running Pandoc failed:\n{e}')
290
+ mathml = proc.stdout.strip()
291
+ if mathml.startswith('<p>'):
292
+ mathml = mathml[len('<p>'):]
293
+ if mathml.endswith('</p>'):
294
+ mathml = mathml[:-len('</p>')]
295
+ self._cache['pandoc_mathml'][latex] = {
296
+ 'mathml': mathml,
297
+ 'unused_count': 0,
298
+ }
299
+ return mathml
300
+
301
+
302
+ siunitx_num_number_re = re.compile(r'[+-]?(?:0|(?:[1-9][0-9]*(?:\.[0-9]+)?|0?\.[0-9]+)(?:[eE][+-]?(?:[1-9][0-9]*|0+[1-9][0-9]*))?)$')
303
+
304
+ def siunitx_num_to_plain_latex(self, number: str, in_math: bool=False) -> str:
305
+ r'''
306
+ Convert a basic subset of siunitx \num{<number>} syntax into plain LaTeX.
307
+ If `in_math` is true, covert the plain LaTeX to a Canvas img tag.
308
+ '''
309
+ number = number.strip()
310
+ if number.startswith('.'):
311
+ number = f'0{number}'
312
+ if not self.siunitx_num_number_re.match(number):
313
+ raise Text2qtiError(f'Invalid or unsupported LaTeX number "{number}"')
314
+ number = number.lower()
315
+ if 'e' in number:
316
+ significand, magnitude = number.split('e', 1)
317
+ magnitude = magnitude.lstrip('+').lstrip('0')
318
+ if magnitude.startswith('-0'):
319
+ magnitude = magnitude[0] + magnitude[1:].lstrip('0')
320
+ latex_number = f'{significand}\\times 10^{{{magnitude}}}'
321
+ else:
322
+ latex_number = number
323
+ if in_math:
324
+ return latex_number
325
+ return self.latex_to_qti(latex_number)
326
+
327
+
328
+ def siunitx_si_to_plain_latex(self, unit: str, in_math: bool=False) -> str:
329
+ r'''
330
+ Convert a basic subset of siunitx \si{<unit>} syntax into plain LaTeX.
331
+ If `in_math` is true, covert the plain LaTeX to a Canvas img tag.
332
+ '''
333
+ unit = unit.strip()
334
+ unit_list = []
335
+ unit_iter = iter(unit)
336
+ char = next(unit_iter, '')
337
+ while True:
338
+ if char == '' or char == ' ':
339
+ pass
340
+ elif char == '.':
341
+ unit_list.append(r'\!\cdot\!') # Alternative: r'\,'
342
+ elif char == '^':
343
+ char = next(unit_iter, '')
344
+ if char.isdigit():
345
+ unit_list.append(f'^{{{char}}}')
346
+ elif char == '\\':
347
+ unit_list.append('^')
348
+ continue
349
+ else:
350
+ raise Text2qtiError(f'Invalid or unsupported LaTeX unit "{unit}"')
351
+ elif char == '/':
352
+ unit_list.append(r'/') # Alternative: r'\big/'
353
+ elif char == '\\':
354
+ macro = char
355
+ char = next(unit_iter, '')
356
+ while char.isalpha():
357
+ macro += char
358
+ char = next(unit_iter, '')
359
+ if macro == r'\degree':
360
+ unit_list.append(r'^\circ')
361
+ elif macro == r'\celsius':
362
+ unit_list.append(r'^\circ\textrm{C}')
363
+ elif macro == r'\fahrenheit':
364
+ unit_list.append(r'^\circ\textrm{F}')
365
+ elif macro == r'\ohm':
366
+ unit_list.append(r'\Omega')
367
+ elif macro == r'\micro':
368
+ # Ideally, this would be an upright rather than slanted mu
369
+ unit_list.append(r'\mu')
370
+ else:
371
+ unit_list.append(macro)
372
+ continue
373
+ elif char.isalpha():
374
+ unit_list.append(r'\text{')
375
+ unit_list.append(char)
376
+ char = next(unit_iter, '')
377
+ while char.isalpha():
378
+ unit_list.append(char)
379
+ char = next(unit_iter, '')
380
+ unit_list.append('}')
381
+ continue
382
+ else:
383
+ raise Text2qtiError(f'Invalid or unsupported LaTeX unit "{unit}"')
384
+ try:
385
+ char = next(unit_iter)
386
+ except StopIteration:
387
+ break
388
+ latex_unit = '{' + ''.join(unit_list) + '}' # wrapping {} may prevent line breaks
389
+ if in_math:
390
+ return latex_unit
391
+ return self.latex_to_qti(latex_unit)
392
+
393
+
394
+ def siunitx_SI_to_plain_latex(self, number: str, unit: str, in_math: bool=False) -> str:
395
+ r'''
396
+ Convert a basic subset of siunitx \SI{<number>}{<unit>} syntax into plain
397
+ LaTeX. If `in_math` is true, covert the plain LaTeX to a Canvas img tag.
398
+ '''
399
+ latex_number = self.siunitx_num_to_plain_latex(number, in_math=True)
400
+ latex_unit = self.siunitx_si_to_plain_latex(unit, in_math=True)
401
+ if latex_unit.startswith(r'^\circ'):
402
+ unit_sep = ''
403
+ else:
404
+ unit_sep = r'\,' # Alternative: `\>`
405
+ latex = f'{latex_number}{unit_sep}{latex_unit}'
406
+ if in_math:
407
+ return latex
408
+ return self.latex_to_qti(latex)
409
+
410
+
411
+ siunitx_num_macro_pattern = r'\\num\{(?P<num_number>[^{}]+)\}'
412
+ siunitx_si_macro_pattern = r'\\si\{(?P<si_unit>[^{}]+)\}'
413
+ siunitx_SI_macro_pattern = r'\\SI\{(?P<SI_number>[^{}]+)\}\{(?P<SI_unit>[^{}]+)\}'
414
+ siunitx_latex_macros_pattern = '|'.join([siunitx_num_macro_pattern, siunitx_si_macro_pattern, siunitx_SI_macro_pattern])
415
+ siunitx_latex_macros_re = re.compile(siunitx_latex_macros_pattern)
416
+
417
+ def _siunitx_dispatch(self, match: typing.Match[str], in_math: bool) -> str:
418
+ '''
419
+ Convert an siunitx regex match to plain LaTeX. If `in_math` is true,
420
+ covert the plain LaTeX to a Canvas img tag.
421
+ '''
422
+ lastgroup = match.lastgroup
423
+ if lastgroup == 'SI_unit':
424
+ return self.siunitx_SI_to_plain_latex(match.group('SI_number'), match.group('SI_unit'), in_math)
425
+ if lastgroup == 'num_number':
426
+ return self.siunitx_num_to_plain_latex(match.group('num_number'), in_math)
427
+ if lastgroup == 'si_unit':
428
+ return self.siunitx_si_to_plain_latex(match.group('si_unit'), in_math)
429
+ raise ValueError
430
+
431
+ def sub_siunitx_to_plain_latex(self, string: str, in_math: bool=False) -> str:
432
+ '''
433
+ Convert all siunitx macros in a string to plain LaTeX. If `in_math` is
434
+ true, covert the plain LaTeX to a Canvas img tag.
435
+ '''
436
+ return self.siunitx_latex_macros_re.sub(lambda match: self._siunitx_dispatch(match, in_math), string)
437
+
438
+
439
+ escape = r'(?P<escape>\\\$)'
440
+ skip = r'(?P<skip>\\.|\\\n|\$\$+(?!\$))'
441
+ html_comment_pattern = r'(?P<html_comment><!--(?:.|\n)*?-->)'
442
+ block_code_pattern = (
443
+ r'^(?P<block_code>'
444
+ r'(?P<indent>[ \t]*)(?P<block_code_delim>```+(?!`)|~~~+(?!~)).*?\n'
445
+ r'(?:[ \t]*\n|(?P=indent).*\n)*?'
446
+ r'(?P=indent)(?P=block_code_delim)[ \t]*(?:\n|$)'
447
+ r')'
448
+ )
449
+ inline_code_pattern = (
450
+ r'(?P<inline_code>'
451
+ r'(?P<inline_code_delim>`+(?!`))'
452
+ r'(?:.|\n[ \t]*(?![ \t\n]))+?'
453
+ r'(?<!`)(?P=inline_code_delim)(?!`)'
454
+ r')'
455
+ )
456
+ inline_math_pattern = (
457
+ r'\$(?=[^ \t\n])'
458
+ r'(?P<math>(?:[^$\n\\]|\\.|\\?\n[ \t]*(?:[^ \t\n$]))+)'
459
+ r'(?<![ \t\n])\$(?!\$)'
460
+ )
461
+ patterns = '|'.join([
462
+ block_code_pattern,
463
+ siunitx_latex_macros_pattern,
464
+ escape,
465
+ skip,
466
+ html_comment_pattern,
467
+ inline_code_pattern,
468
+ inline_math_pattern,
469
+ ])
470
+ skip_or_html_comment_or_code_math_siunitx_re = re.compile(patterns, re.MULTILINE)
471
+
472
+ def _html_comment_or_inline_code_math_siunitx_dispatch(self, match: typing.Match[str]) -> str:
473
+ '''
474
+ Process LaTeX math and siunitx regex matches into Canvas image tags,
475
+ while stripping HTML comments and leaving things like backslash
476
+ escapes and code unchanged.
477
+ '''
478
+ lastgroup = match.lastgroup
479
+ if lastgroup == 'html_comment':
480
+ return ''
481
+ if lastgroup == 'escape':
482
+ return match.group('escape')[1:]
483
+ if lastgroup == 'skip':
484
+ return match.group('skip')
485
+ if lastgroup == 'block_code':
486
+ return match.group('block_code')
487
+ if lastgroup == 'inline_code':
488
+ return match.group('inline_code')
489
+ if lastgroup == 'math':
490
+ math = match.group('math')
491
+ math = math.replace('\n ', ' ').replace('\n', ' ')
492
+ math = self.sub_siunitx_to_plain_latex(math, in_math=True)
493
+ return self.latex_to_qti(math)
494
+ if lastgroup == 'SI_unit':
495
+ return self.siunitx_SI_to_plain_latex(match.group('SI_number'), match.group('SI_unit'), in_math=False)
496
+ if lastgroup == 'num_number':
497
+ return self.siunitx_num_to_plain_latex(match.group('num_number'), in_math=False)
498
+ if lastgroup == 'si_unit':
499
+ return self.siunitx_si_to_plain_latex(match.group('si_unit'), in_math=False)
500
+ raise ValueError
501
+
502
+ def sub_math_siunitx_to_canvas_img(self, string: str) -> str:
503
+ '''
504
+ Convert all siunitx macros in a string into plain LaTeX. Then convert
505
+ this LaTeX and all $-delimited LaTeX into Canvas img tags.
506
+ '''
507
+ return self.skip_or_html_comment_or_code_math_siunitx_re.sub(self._html_comment_or_inline_code_math_siunitx_dispatch, string)
508
+
509
+ def md_to_html_xml(self, markdown_string: str, strip_p_tags: bool=False) -> str:
510
+ '''
511
+ Convert the Markdown in a string to HTML, then escape the HTML for
512
+ embedding in XML.
513
+ '''
514
+ markdown_string_processed_latex = self.sub_math_siunitx_to_canvas_img(markdown_string)
515
+ try:
516
+ html = self.markdown_processor.reset().convert(markdown_string_processed_latex)
517
+ except Exception as e:
518
+ raise Text2qtiError(f'Conversion from Markdown to HTML failed:\n{e}')
519
+ if strip_p_tags:
520
+ if html.startswith('<p>'):
521
+ html = html[3:]
522
+ if html.endswith('</p>'):
523
+ html = html[:-4]
524
+ xml = self.xml_escape(html, squotes=False, dquotes=False)
525
+ return xml
526
+
527
+ def _md_to_pandoc_dispatch(self, match: typing.Match[str],
528
+ _passthrough=set(['escape', 'skip', 'block_code', 'inline_code'])) -> str:
529
+ '''
530
+ Process LaTeX math and siunitx regex matches into Pandoc Markdown,
531
+ while stripping HTML comments and leaving things like backslash
532
+ escapes and code unchanged.
533
+ '''
534
+ lastgroup = match.lastgroup
535
+ if lastgroup == 'html_comment':
536
+ return ''
537
+ if lastgroup in _passthrough:
538
+ return match.group(lastgroup)
539
+ if lastgroup == 'math':
540
+ math = match.group('math')
541
+ math = math.replace('\n ', ' ').replace('\n', ' ')
542
+ math = self.sub_siunitx_to_plain_latex(math, in_math=True)
543
+ return '${0}$'.format(math)
544
+ if lastgroup == 'SI_unit':
545
+ return '${0}$'.format(self.siunitx_SI_to_plain_latex(match.group('SI_number'), match.group('SI_unit'), in_math=True))
546
+ if lastgroup == 'num_number':
547
+ return '${0}$'.format(self.siunitx_num_to_plain_latex(match.group('num_number'), in_math=True))
548
+ if lastgroup == 'si_unit':
549
+ return '${0}$'.format(self.siunitx_si_to_plain_latex(match.group('si_unit'), in_math=True))
550
+ raise ValueError
551
+
552
+ def md_to_pandoc(self, string: str) -> str:
553
+ '''
554
+ Convert Markdown from a quiz into a form suitable for Pandoc Markdown.
555
+
556
+ Convert all siunitx macros in a string into plain LaTeX guaranteed to
557
+ be wrapped in `$`. This can be processed into multiple formats by
558
+ Pandoc.
559
+ '''
560
+ return self.skip_or_html_comment_or_code_math_siunitx_re.sub(self._md_to_pandoc_dispatch, string)
text2qti/pymd_pandoc_attr.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2021, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ '''
12
+ Pandoc-style attribute syntax for Python-Markdown.
13
+ Attribute support is currently limited to images.
14
+ See https://pandoc.org/MANUAL.html#images.
15
+
16
+ Inspired by https://github.com/Python-Markdown/markdown/blob/master/markdown/extensions/attr_list.py.
17
+ '''
18
+
19
+
20
+ from markdown.extensions import Extension
21
+ from markdown.treeprocessors import Treeprocessor
22
+ from typing import List, Tuple
23
+ import re
24
+
25
+
26
+ IDENTIFIER_PATTERN = r'[A-Za-z][0-9A-Za-z_\-]+'
27
+ ID_PATTERN = rf'#{IDENTIFIER_PATTERN}'
28
+ CLASS_PATTERN = rf'\.{IDENTIFIER_PATTERN}'
29
+ KV_PATTERN = rf'{IDENTIFIER_PATTERN}=[0-9A-Za-z_\-%]+'
30
+ ATTR_PATTERN = (
31
+ r'\{[ ]*(?!\})('
32
+ rf'(?:{ID_PATTERN}(?=[ \}}]))?'
33
+ rf'(?:{CLASS_PATTERN}(?=[ \}}])|{KV_PATTERN}(?=[ \}}])|[ ]+(?=[^ \}}]))*'
34
+ r')[ ]*\}'
35
+ )
36
+
37
+ def _handle_id(scanner: re.Scanner, token: str) -> Tuple[str, str]:
38
+ return '#', token[1:]
39
+
40
+ def _handle_class(scanner: re.Scanner, token: str) -> Tuple[str, str]:
41
+ return '.', token[1:]
42
+
43
+ def _handle_key_value(scanner: re.Scanner, token: str) -> Tuple[str, str]:
44
+ return token.split('=', 1)
45
+
46
+ _scanner = re.Scanner([
47
+ (ID_PATTERN, _handle_id),
48
+ (CLASS_PATTERN, _handle_class),
49
+ (KV_PATTERN, _handle_key_value),
50
+ (r'[ ]+', None),
51
+ ])
52
+
53
+ def get_attrs(string: str) -> List[Tuple[str, str]]:
54
+ '''
55
+ Parse a string of attributes `<attrs>` that has already been extracted
56
+ from a string of the form `{<attrs>}`. Return a list of attribute tuples
57
+ of the form `(<key>, <value>)`.
58
+ '''
59
+ results, remainder = _scanner.scan(string)
60
+ return results
61
+
62
+
63
+ class PandocAttrTreeprocessor(Treeprocessor):
64
+ ATTR_RE = re.compile(ATTR_PATTERN)
65
+ def run(self, doc):
66
+ for elem in doc.iter():
67
+ if self.md.is_block_level(elem.tag):
68
+ pass
69
+ else:
70
+ # inline, only for images currently
71
+ if elem.tag == 'img' and elem.tail and elem.tail.startswith('{'):
72
+ match = self.ATTR_RE.match(elem.tail)
73
+ if match:
74
+ self.assign_attrs(elem, match.group(1))
75
+ elem.tail = elem.tail[match.end():]
76
+
77
+ def assign_attrs(self, elem, attrs):
78
+ '''
79
+ Assign attrs to element.
80
+ '''
81
+ for k, v in get_attrs(attrs):
82
+ if k == '#':
83
+ elem.set('id', v)
84
+ elif k == '.':
85
+ elem_class = elem.get('class')
86
+ if elem_class:
87
+ elem.set('class', f'{elem_class} {v}')
88
+ else:
89
+ elem.set('class', v)
90
+ else:
91
+ elem_style = elem.get('style')
92
+ if elem_style:
93
+ elem.set('style', f'{elem_style} {k}:{v};')
94
+ else:
95
+ elem.set('style', f'{k}:{v};')
96
+
97
+
98
+ class PandocAttrExtension(Extension):
99
+ def extendMarkdown(self, md):
100
+ md.treeprocessors.register(PandocAttrTreeprocessor(md), 'pandoc_attr', 8)
101
+ md.registerExtension(self)
102
+
103
+
104
+ def makeExtension(**kwargs):
105
+ return PandocAttrExtension(**kwargs)
text2qti/qti.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ import io
12
+ import pathlib
13
+ from typing import Union, BinaryIO
14
+ import zipfile
15
+ from .quiz import Quiz
16
+ from .xml_imsmanifest import imsmanifest
17
+ from .xml_assessment_meta import assessment_meta
18
+ from .xml_assessment import assessment
19
+
20
+
21
+ class QTI(object):
22
+ '''
23
+ Create QTI from a Quiz object.
24
+ '''
25
+ def __init__(self, quiz: Quiz):
26
+ self.quiz = quiz
27
+ id_base = 'text2qti'
28
+ self.manifest_identifier = f'{id_base}_manifest_{quiz.id}'
29
+ self.assessment_identifier = f'{id_base}_assessment_{quiz.id}'
30
+ self.dependency_identifier = f'{id_base}_dependency_{quiz.id}'
31
+ self.assignment_identifier = f'{id_base}_assignment_{quiz.id}'
32
+ self.assignment_group_identifier = f'{id_base}_assignment-group_{quiz.id}'
33
+
34
+ self.imsmanifest_xml = imsmanifest(manifest_identifier=self.manifest_identifier,
35
+ assessment_identifier=self.assessment_identifier,
36
+ dependency_identifier=self.dependency_identifier,
37
+ images=self.quiz.images)
38
+ self.assessment_meta = assessment_meta(assessment_identifier=self.assessment_identifier,
39
+ assignment_identifier=self.assignment_identifier,
40
+ assignment_group_identifier=self.assignment_group_identifier,
41
+ title_xml=quiz.title_xml,
42
+ description_html_xml=quiz.description_html_xml,
43
+ points_possible=quiz.points_possible,
44
+ shuffle_answers=quiz.shuffle_answers_xml,
45
+ show_correct_answers=quiz.show_correct_answers_xml,
46
+ one_question_at_a_time=quiz.one_question_at_a_time_xml,
47
+ cant_go_back=quiz.cant_go_back_xml)
48
+ self.assessment = assessment(quiz=quiz,
49
+ assessment_identifier=self.assessment_identifier,
50
+ title_xml=quiz.title_xml)
51
+
52
+
53
+ def write(self, bytes_stream: BinaryIO):
54
+ with zipfile.ZipFile(bytes_stream, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
55
+ zf.writestr('imsmanifest.xml', self.imsmanifest_xml)
56
+ zf.writestr(zipfile.ZipInfo('non_cc_assessments/'), b'')
57
+ zf.writestr(f'{self.assessment_identifier}/assessment_meta.xml', self.assessment_meta)
58
+ zf.writestr(f'{self.assessment_identifier}/{self.assessment_identifier}.xml', self.assessment)
59
+ for image in self.quiz.images.values():
60
+ zf.writestr(image.qti_zip_path, image.data)
61
+
62
+
63
+ def zip_bytes(self) -> bytes:
64
+ stream = io.BytesIO()
65
+ self.write(stream)
66
+ return stream.getvalue()
67
+
68
+
69
+ def save(self, qti_path: Union[str, pathlib.Path]):
70
+ if isinstance(qti_path, str):
71
+ qti_path = pathlib.Path(qti_path)
72
+ elif not isinstance(qti_path, pathlib.Path):
73
+ raise TypeError
74
+ qti_path.write_bytes(self.zip_bytes())
text2qti/quiz.py ADDED
@@ -0,0 +1,1186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020-2021, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ '''
12
+ Parse text into a Quiz object that contains a list of Question objects, each
13
+ of which contains a list of Choice objects.
14
+ '''
15
+
16
+
17
+ import hashlib
18
+ import io
19
+ import itertools
20
+ import locale
21
+ import pathlib
22
+ import platform
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ import tempfile
27
+ from typing import Dict, List, Optional, Set, Union
28
+ from .config import Config
29
+ from .err import Text2qtiError
30
+ from .markdown import Image, Markdown
31
+
32
+
33
+
34
+
35
+ # regex patterns for parsing quiz content
36
+ start_patterns = {
37
+ 'question': r'\d+\.',
38
+ 'mctf_correct_choice': r'\*[a-zA-Z]\)',
39
+ 'mctf_incorrect_choice': r'[a-zA-Z]\)',
40
+ 'multans_correct_choice': r'\[\*\]',
41
+ 'multans_incorrect_choice': r'\[ ?\]',
42
+ 'shortans_correct_choice': r'\*',
43
+ 'feedback': r'\.\.\.',
44
+ 'correct_feedback': r'\+',
45
+ 'incorrect_feedback': r'\-',
46
+ 'solution': r'!',
47
+ 'essay': r'___+',
48
+ 'upload': r'\^\^\^+',
49
+ 'numerical': r'=',
50
+ 'question_title': r'[Tt]itle:',
51
+ 'question_points': r'[Pp]oints:',
52
+ 'text_title': r'[Tt]ext [Tt]itle:',
53
+ 'text': r'[Tt]ext:',
54
+ 'quiz_title': r'[Qq]uiz [Tt]itle:',
55
+ 'quiz_description': r'[Qq]uiz description:',
56
+ 'start_group': r'GROUP',
57
+ 'end_group': r'END_GROUP',
58
+ 'group_pick': r'[Pp]ick:',
59
+ 'group_solutions_pick': r'[Ss]olutions pick:',
60
+ 'group_points_per_question': r'[Pp]oints per question:',
61
+ 'start_code': r'```+\s*\S.*',
62
+ 'end_code': r'```+',
63
+ 'quiz_shuffle_answers': r'[Ss]huffle answers:',
64
+ 'quiz_show_correct_answers': r'[Ss]how correct answers:',
65
+ 'quiz_one_question_at_a_time': r'[Oo]ne question at a time:',
66
+ 'quiz_cant_go_back': r'''[Cc]an't go back:''',
67
+ 'quiz_feedback_is_solution': r'[Ff]eedback is solution:',
68
+ 'quiz_solutions_sample_groups': r'[Ss]olutions sample groups:',
69
+ 'quiz_solutions_randomize_groups': r'[Ss]olutions randomize groups:',
70
+ }
71
+ # comments are currently handled separately from content
72
+ comment_patterns = {
73
+ 'start_multiline_comment': r'COMMENT',
74
+ 'end_multiline_comment': r'END_COMMENT',
75
+ 'line_comment': r'%',
76
+ }
77
+ # whether regex needs to check after pattern for content on the same line
78
+ no_content = set(['essay', 'upload', 'start_group', 'end_group', 'start_code', 'end_code'])
79
+ # whether parser needs to check for multi-line content
80
+ single_line = set(['question_points', 'group_pick', 'group_solutions_pick', 'group_points_per_question',
81
+ 'numerical', 'shortans_correct_choice',
82
+ 'quiz_shuffle_answers', 'quiz_show_correct_answers',
83
+ 'quiz_one_question_at_a_time', 'quiz_cant_go_back',
84
+ 'quiz_feedback_is_solution', 'quiz_solutions_sample_groups', 'quiz_solutions_randomize_groups'])
85
+ multi_line = set([x for x in start_patterns
86
+ if x not in no_content and x not in single_line])
87
+ # whether parser needs to check for multi-paragraph content
88
+ multi_para = set([x for x in multi_line if 'title' not in x])
89
+ start_re = re.compile('|'.join(r'(?P<{0}>{1}[ \t]+(?=\S))'.format(name, pattern)
90
+ if name not in no_content else
91
+ r'(?P<{0}>{1}\s*)$'.format(name, pattern)
92
+ for name, pattern in start_patterns.items()))
93
+ start_missing_content_re = re.compile('|'.join(r'(?P<{0}>{1}[ \t]*$)'.format(name, pattern)
94
+ for name, pattern in start_patterns.items()
95
+ if name not in no_content))
96
+ start_missing_whitespace_re = re.compile('|'.join(r'(?P<{0}>{1}(?=\S))'.format(name, pattern)
97
+ for name, pattern in start_patterns.items()
98
+ if name not in no_content))
99
+ start_code_supported_info_re = re.compile(r'\{\s*'
100
+ r'\.(?P<lang>[a-zA-Z](?:[a-zA-Z0-9]+|[\._\-]+[a-zA-Z0-9]+)*)'
101
+ r'\s+'
102
+ r'\.run'
103
+ r'(?:\s+executable=(?P<executable>[~\w/\.\-]+|"[^\\\"\']+"))?'
104
+ r'\s*\}$')
105
+ int_re = re.compile('(?:0|[+-]?[1-9](?:[0-9]+|_[0-9]+)*)$')
106
+
107
+
108
+
109
+
110
+ class TextRegion(object):
111
+ '''
112
+ A text region between questions.
113
+ '''
114
+ def __init__(self, *, index: int, md: Markdown):
115
+ self.title_raw: Optional[str] = None
116
+ self.title_xml = ''
117
+ self.text_raw: Optional[str] = None
118
+ self.text_html_xml = ''
119
+ self.md = md
120
+ self._index = index
121
+
122
+ def _set_id(self):
123
+ h = hashlib.blake2b()
124
+ h.update(f'{self._index}'.encode('utf8'))
125
+ h.update(h.digest())
126
+ h.update(self.title_xml.encode('utf8'))
127
+ h.update(h.digest())
128
+ h.update(self.text_html_xml.encode('utf8'))
129
+ self.id = h.hexdigest()[:64]
130
+
131
+ def set_title(self, text: str):
132
+ if self.title_raw is not None:
133
+ raise Text2qtiError('Text title has already been set')
134
+ if self.text_raw is not None:
135
+ raise Text2qtiError('Must set text title before text itself')
136
+ self.title_raw = text
137
+ self.title_xml = self.md.xml_escape(text)
138
+ self._set_id()
139
+
140
+ def set_text(self, text: str):
141
+ if self.text_raw is not None:
142
+ raise Text2qtiError('Text has already been set')
143
+ self.text_raw = text
144
+ self.text_html_xml = self.md.md_to_html_xml(text)
145
+ self._set_id()
146
+
147
+
148
+
149
+
150
+ class Choice(object):
151
+ '''
152
+ A choice for a question plus optional feedback.
153
+
154
+ The id is based on a hash of both the question and the choice itself.
155
+ The presence of feedback does not affect the id.
156
+ '''
157
+ def __init__(self, text: str, *,
158
+ correct: bool, shortans: bool=False,
159
+ question_hash_digest: bytes, md: Markdown):
160
+ self.choice_raw = text
161
+ if shortans:
162
+ self.choice_xml = md.xml_escape(text)
163
+ else:
164
+ self.choice_html_xml = md.md_to_html_xml(text)
165
+ self.correct = correct
166
+ self.shortans = shortans
167
+ self.feedback_raw: Optional[str] = None
168
+ self.feedback_html_xml: Optional[str] = None
169
+ # ID is based on hash of choice XML as well as question XML. This
170
+ # gives different IDs for identical choices in different questions.
171
+ if shortans:
172
+ self.id = hashlib.blake2b(self.choice_xml.encode('utf8'), key=question_hash_digest).hexdigest()[:64]
173
+ else:
174
+ self.id = hashlib.blake2b(self.choice_html_xml.encode('utf8'), key=question_hash_digest).hexdigest()[:64]
175
+ self.md = md
176
+
177
+ def append_feedback(self, text: str):
178
+ if self.shortans:
179
+ raise Text2qtiError('Feedback cannot be specified for individual short answer responses, only for the question')
180
+ if self.feedback_raw is not None:
181
+ raise Text2qtiError('Feedback can only be specified once')
182
+ self.feedback_raw = text
183
+ self.feedback_html_xml = self.md.md_to_html_xml(text)
184
+
185
+
186
+ class Question(object):
187
+ '''
188
+ A question, along with a list of possible choices and optional feedback of
189
+ various types.
190
+ '''
191
+ def __init__(self, text: str, *, quiz: 'Quiz', title: Optional[str], points: Optional[str], md: Markdown):
192
+ # Question type is set once it is known. For true/false or multiple
193
+ # choice, this is done during .finalize(), once all choices are
194
+ # available. For essay, this is done as soon as essay response is
195
+ # specified.
196
+ self.type: Optional[str] = None
197
+ self.quiz: 'Quiz' = quiz
198
+ if title is None:
199
+ self.title_raw: Optional[str] = None
200
+ self.title_xml = 'Question'
201
+ else:
202
+ self.title_raw: Optional[str] = title
203
+ self.title_xml = md.xml_escape(title)
204
+ self.question_raw = text
205
+ self.question_html_xml = md.md_to_html_xml(text)
206
+ self.choices: List[Choice] = []
207
+ # The set for detecting duplicate choices uses the XML version of the
208
+ # choices, to avoid the issue of multiple Markdown representations of
209
+ # the same XML.
210
+ self._choice_set: Set[str] = set()
211
+ self.numerical_raw: Optional[str] = None
212
+ self.numerical_min: Optional[Union[int, float]] = None
213
+ self.numerical_min_html_xml: Optional[str] = None
214
+ self.numerical_exact: Optional[Union[int, float]] = None
215
+ self.numerical_exact_html_xml: Optional[str] = None
216
+ self.numerical_max: Optional[Union[int, float]] = None
217
+ self.numerical_max_html_xml: Optional[str] = None
218
+ self.correct_choices = 0
219
+ if points is None:
220
+ self.points_possible_raw: Optional[str] = None
221
+ self.points_possible: Union[int, float] = 1
222
+ else:
223
+ self.points_possible_raw: Optional[str] = points
224
+ try:
225
+ points_num = float(points)
226
+ except ValueError:
227
+ raise Text2qtiError(f'Invalid points value "{points}"; need positive integer or half-integer')
228
+ if points_num <= 0:
229
+ raise Text2qtiError(f'Invalid points value "{points}"; need positive integer or half-integer')
230
+ if points_num.is_integer():
231
+ points_num = int(points)
232
+ elif abs(points_num-round(points_num)) != 0.5:
233
+ raise Text2qtiError(f'Invalid points value "{points}"; need positive integer or half-integer')
234
+ self.points_possible: Union[int, float] = points_num
235
+ self.feedback_raw: Optional[str] = None
236
+ self.feedback_html_xml: Optional[str] = None
237
+ self.correct_feedback_raw: Optional[str] = None
238
+ self.correct_feedback_html_xml: Optional[str] = None
239
+ self.incorrect_feedback_raw: Optional[str] = None
240
+ self.incorrect_feedback_html_xml: Optional[str] = None
241
+ self.solution: Optional[str] = None
242
+ h = hashlib.blake2b(self.question_html_xml.encode('utf8'))
243
+ self.hash_digest = h.digest()
244
+ self.id = h.hexdigest()[:64]
245
+ self.md = md
246
+
247
+
248
+ def append_feedback(self, text: str):
249
+ if self.type is not None and not self.choices:
250
+ raise Text2qtiError('Question feedback must immediately follow the question')
251
+ if not self.choices:
252
+ if self.feedback_raw is not None:
253
+ raise Text2qtiError('Feedback can only be specified once')
254
+ self.feedback_raw = text
255
+ self.feedback_html_xml = self.md.md_to_html_xml(text)
256
+ if self.quiz.feedback_is_solution:
257
+ self.solution = text
258
+ else:
259
+ self.choices[-1].append_feedback(text)
260
+
261
+ def append_correct_feedback(self, text: str):
262
+ if self.type is not None:
263
+ raise Text2qtiError('Correct feedback can only be specified for questions')
264
+ if self.correct_feedback_raw is not None:
265
+ raise Text2qtiError('Feedback can only be specified once')
266
+ self.correct_feedback_raw = text
267
+ self.correct_feedback_html_xml = self.md.md_to_html_xml(text)
268
+
269
+ def append_incorrect_feedback(self, text: str):
270
+ if self.type is not None:
271
+ raise Text2qtiError('Incorrect feedback can only be specified for questions')
272
+ if self.incorrect_feedback_raw is not None:
273
+ raise Text2qtiError('Feedback can only be specified once')
274
+ self.incorrect_feedback_raw = text
275
+ self.incorrect_feedback_html_xml = self.md.md_to_html_xml(text)
276
+
277
+ def append_solution(self, text: str):
278
+ if self.type is not None:
279
+ raise Text2qtiError('Solutions can only be specified for questions')
280
+ if self.solution is not None:
281
+ raise Text2qtiError('Solutions can only be specified once')
282
+ if self.quiz.feedback_is_solution:
283
+ raise Text2qtiError('Solutions syntax with "!" is disabled for quizzes with setting "feedback is solution: true"')
284
+ self.solution = text
285
+
286
+ def append_mctf_correct_choice(self, text: str):
287
+ if self.type is None:
288
+ self.type = 'multiple_choice_question'
289
+ elif self.type != 'multiple_choice_question':
290
+ raise Text2qtiError(f'Question type "{self.type}" does not support multiple choice')
291
+ choice = Choice(text, correct=True, question_hash_digest=self.hash_digest, md=self.md)
292
+ if choice.choice_html_xml in self._choice_set:
293
+ raise Text2qtiError('Duplicate choice for question')
294
+ self._choice_set.add(choice.choice_html_xml)
295
+ self.choices.append(choice)
296
+ self.correct_choices += 1
297
+
298
+ def append_mctf_incorrect_choice(self, text: str):
299
+ if self.type is None:
300
+ self.type = 'multiple_choice_question'
301
+ elif self.type != 'multiple_choice_question':
302
+ raise Text2qtiError(f'Question type "{self.type}" does not support multiple choice')
303
+ choice = Choice(text, correct=False, question_hash_digest=self.hash_digest, md=self.md)
304
+ if choice.choice_html_xml in self._choice_set:
305
+ raise Text2qtiError('Duplicate choice for question')
306
+ self._choice_set.add(choice.choice_html_xml)
307
+ self.choices.append(choice)
308
+
309
+ def append_shortans_correct_choice(self, text: str):
310
+ if self.type is None:
311
+ self.type = 'short_answer_question'
312
+ elif self.type != 'short_answer_question':
313
+ raise Text2qtiError(f'Question type "{self.type}" does not support short answer')
314
+ choice = Choice(text, correct=True, shortans=True, question_hash_digest=self.hash_digest, md=self.md)
315
+ if choice.choice_xml in self._choice_set:
316
+ raise Text2qtiError('Duplicate choice for question')
317
+ self._choice_set.add(choice.choice_xml)
318
+ self.choices.append(choice)
319
+ self.correct_choices += 1
320
+
321
+ def append_multans_correct_choice(self, text: str):
322
+ if self.type is None:
323
+ self.type = 'multiple_answers_question'
324
+ elif self.type != 'multiple_answers_question':
325
+ raise Text2qtiError(f'Question type "{self.type}" does not support multiple answers')
326
+ choice = Choice(text, correct=True, question_hash_digest=self.hash_digest, md=self.md)
327
+ if choice.choice_html_xml in self._choice_set:
328
+ raise Text2qtiError('Duplicate choice for question')
329
+ self._choice_set.add(choice.choice_html_xml)
330
+ self.choices.append(choice)
331
+ self.correct_choices += 1
332
+
333
+ def append_multans_incorrect_choice(self, text: str):
334
+ if self.type is None:
335
+ self.type = 'multiple_answers_question'
336
+ elif self.type != 'multiple_answers_question':
337
+ raise Text2qtiError(f'Question type "{self.type}" does not support multiple answers')
338
+ choice = Choice(text, correct=False, question_hash_digest=self.hash_digest, md=self.md)
339
+ if choice.choice_html_xml in self._choice_set:
340
+ raise Text2qtiError('Duplicate choice for question')
341
+ self._choice_set.add(choice.choice_html_xml)
342
+ self.choices.append(choice)
343
+
344
+ def append_essay(self, text: str):
345
+ if text:
346
+ # The essay response syntax provides no text to process; the
347
+ # `text` argument just gives all append functions the same form.
348
+ raise ValueError
349
+ if self.type is not None:
350
+ if self.type == 'essay_question':
351
+ raise Text2qtiError(f'Cannot specify essay response multiple times')
352
+ raise Text2qtiError(f'Question type "{self.type}" does not support essay response')
353
+ self.type = 'essay_question'
354
+ if any(x is not None for x in (self.correct_feedback_raw, self.incorrect_feedback_raw)):
355
+ raise Text2qtiError(f'Question type "{self.type}" does not support correct/incorrect feedback')
356
+
357
+ def append_upload(self, text: str):
358
+ if text:
359
+ # The upload response syntax provides no text to process; the
360
+ # `text` argument just gives all append functions the same form.
361
+ raise ValueError
362
+ if self.type is not None:
363
+ if self.type == 'file_upload_question':
364
+ raise Text2qtiError(f'Cannot specify upload response multiple times')
365
+ raise Text2qtiError(f'Question type "{self.type}" does not support upload response')
366
+ self.type = 'file_upload_question'
367
+ if any(x is not None for x in (self.correct_feedback_raw, self.incorrect_feedback_raw)):
368
+ raise Text2qtiError(f'Question type "{self.type}" does not support correct/incorrect feedback')
369
+
370
+ def append_numerical(self, text: str):
371
+ if self.type is not None:
372
+ if self.type == 'numerical_question':
373
+ raise Text2qtiError(f'Cannot specify numerical response multiple times')
374
+ raise Text2qtiError(f'Question type "{self.type}" does not support numerical response')
375
+ self.type = 'numerical_question'
376
+ self.numerical_raw = text
377
+ if text.startswith('['):
378
+ if not text.endswith(']') or ',' not in text:
379
+ raise Text2qtiError('Invalid numerical response; need "[<min>, <max>]" or "<number> +- <margin>" or "<integer>"')
380
+ min, max = text[1:-1].split(',', 1)
381
+ try:
382
+ min = float(min)
383
+ max = float(max)
384
+ except Exception:
385
+ raise Text2qtiError('Invalid numerical response; need "[<min>, <max>]" or "<number> +- <margin>" or "<integer>"')
386
+ if min > max:
387
+ raise Text2qtiError('Invalid numerical response; need "[<min>, <max>]" with min < max')
388
+ self.numerical_min = min
389
+ self.numerical_max = max
390
+ if min.is_integer() and max.is_integer():
391
+ self.numerical_min_html_xml = f'{min}'
392
+ self.numerical_max_html_xml = f'{max}'
393
+ else:
394
+ self.numerical_min_html_xml = f'{min:.4f}'
395
+ self.numerical_max_html_xml = f'{max:.4f}'
396
+ elif '+-' in text:
397
+ num, margin = text.split('+-', 1)
398
+ if margin.endswith('%'):
399
+ margin_is_percentage = True
400
+ margin = margin[:-1]
401
+ else:
402
+ margin_is_percentage = False
403
+ try:
404
+ num = float(num)
405
+ margin = float(margin)
406
+ except Exception:
407
+ raise Text2qtiError('Invalid numerical response; need "[<min>, <max>]" or "<number> +- <margin>" or "<integer>"')
408
+ if margin < 0:
409
+ raise Text2qtiError('Invalid numerical response; need "<number> +- <margin>" with margin > 0')
410
+ if margin_is_percentage:
411
+ min = num - abs(num)*(margin/100)
412
+ max = num + abs(num)*(margin/100)
413
+ else:
414
+ min = num - margin
415
+ max = num + margin
416
+ self.numerical_min = min
417
+ self.numerical_exact = num
418
+ self.numerical_max = max
419
+ if min.is_integer() and num.is_integer() and max.is_integer():
420
+ self.numerical_min_html_xml = f'{min}'
421
+ self.numerical_exact_html_xml = f'{num}'
422
+ self.numerical_max_html_xml = f'{max}'
423
+ else:
424
+ self.numerical_min_html_xml = f'{min:.4f}'
425
+ self.numerical_exact_html_xml = f'{num:.4f}'
426
+ self.numerical_max_html_xml = f'{max:.4f}'
427
+ elif int_re.match(text):
428
+ num = int(text)
429
+ min = max = num
430
+ self.numerical_min = min
431
+ self.numerical_exact = num
432
+ self.numerical_max = max
433
+ self.numerical_min_html_xml = f'{min}'
434
+ self.numerical_exact_html_xml = f'{num}'
435
+ self.numerical_max_html_xml = f'{max}'
436
+ else:
437
+ raise Text2qtiError('Invalid numerical response; need "[<min>, <max>]" or "<number> +- <margin>" or "<integer>"')
438
+ if abs(min) < 1e-4 or abs(max) < 1e-4:
439
+ raise Text2qtiError('Invalid numerical response; all acceptable values must have a magnitude >= 0.0001')
440
+
441
+
442
+ def finalize(self):
443
+ if self.type is None:
444
+ raise Text2qtiError('Question must specify a response type')
445
+ elif self.type == 'multiple_choice_question':
446
+ if len(self.choices) == 2 and all(c.choice_raw in ('true', 'True', 'false', 'False') for c in self.choices):
447
+ self.type = 'true_false_question'
448
+ if not self.choices:
449
+ raise Text2qtiError('Question must provide choices')
450
+ if len(self.choices) < 2:
451
+ raise Text2qtiError('Question must provide more than one choice')
452
+ if self.correct_choices < 1:
453
+ raise Text2qtiError('Question must specify a correct choice')
454
+ if self.correct_choices > 1:
455
+ raise Text2qtiError('Question must specify only one correct choice')
456
+ elif self.type == 'short_answer_question':
457
+ if not self.choices:
458
+ raise Text2qtiError('Question must provide at least one answer')
459
+ elif self.type == 'multiple_answers_question':
460
+ if len(self.choices) < 2:
461
+ raise Text2qtiError('Question must provide more than one choice')
462
+ if self.correct_choices < 1:
463
+ raise Text2qtiError('Question must specify a correct choice')
464
+
465
+
466
+
467
+
468
+ class Group(object):
469
+ '''
470
+ A group of questions. A random subset of the questions in a group is
471
+ actually displayed.
472
+ '''
473
+ def __init__(self):
474
+ self.pick = 1
475
+ self._pick_is_set = False
476
+ self.solutions_pick: Optional[int] = None
477
+ self.points_per_question = 1
478
+ self._points_per_question_is_set = False
479
+ self.questions: List[Question] = []
480
+ self._question_points_possible: Optional[Union[int, float]] = None
481
+ self.title_raw: Optional[str] = None
482
+ self.title_xml = 'Group'
483
+
484
+ def append_group_pick(self, text: str):
485
+ if self.questions:
486
+ raise Text2qtiError('Question group options must be set at the very start of the group')
487
+ if self._pick_is_set:
488
+ Text2qtiError('"Pick" has already been set for this question group')
489
+ try:
490
+ self.pick = int(text)
491
+ except Exception as e:
492
+ raise Text2qtiError(f'"pick" value is invalid (must be positive number):\n{e}')
493
+ if self.pick <= 0:
494
+ raise Text2qtiError('"pick" value is invalid (must be positive number)')
495
+ if self.solutions_pick is not None and self.pick > self.solutions_pick:
496
+ raise Text2qtiError('"pick" value must be less than or equal to "solutions pick" value')
497
+ self._pick_is_set = True
498
+
499
+ def append_group_solutions_pick(self, text: str):
500
+ if self.questions:
501
+ raise Text2qtiError('Question group options must be set at the very start of the group')
502
+ if self.solutions_pick is not None:
503
+ Text2qtiError('"solutions pick" has already been set for this question group')
504
+ try:
505
+ self.solutions_pick = int(text)
506
+ except Exception as e:
507
+ raise Text2qtiError(f'"solutions pick" value is invalid (must be positive number):\n{e}')
508
+ if self.solutions_pick <= 0:
509
+ raise Text2qtiError('"solutions pick" value is invalid (must be positive number)')
510
+ if self.solutions_pick < self.pick:
511
+ raise Text2qtiError('"solutions pick" value must be greater than or equal to "pick" value')
512
+
513
+ def append_group_points_per_question(self, text: str):
514
+ if self.questions:
515
+ raise Text2qtiError('Question group options must be set at the very start of the group')
516
+ if self._points_per_question_is_set:
517
+ Text2qtiError('"Points per question" has already been set for this question group')
518
+ try:
519
+ self.points_per_question = int(text)
520
+ except Exception as e:
521
+ raise Text2qtiError(f'"Points per question" value is invalid (must be positive number):\n{e}')
522
+ if self.points_per_question <= 0:
523
+ raise Text2qtiError(f'"Points per question" value is invalid (must be positive number):')
524
+ self._points_per_question_is_set = True
525
+
526
+ def append_question(self, question: Question):
527
+ if self._question_points_possible is None:
528
+ self._question_points_possible = question.points_possible
529
+ elif question.points_possible != self._question_points_possible:
530
+ raise Text2qtiError('Question groups must only contain questions with the same point value')
531
+ self.questions.append(question)
532
+
533
+ def finalize(self):
534
+ if len(self.questions) < self.pick:
535
+ raise Text2qtiError(f'Question group only contains {len(self.questions)} questions, needs at least {self.pick+1}')
536
+ if self.solutions_pick is not None and len(self.questions) < self.solutions_pick:
537
+ raise Text2qtiError(f'Question group only contains {len(self.questions)} questions, needs at least {self.solutions_pick}')
538
+ h = hashlib.blake2b()
539
+ for digest in sorted(q.hash_digest for q in self.questions):
540
+ h.update(digest)
541
+ self.hash_digest = h.digest()
542
+ self.id = h.hexdigest()[:64]
543
+
544
+ class GroupStart(object):
545
+ '''
546
+ Start delim for a group of questions.
547
+ '''
548
+ def __init__(self, group: Group):
549
+ self.group = group
550
+
551
+ class GroupEnd(object):
552
+ '''
553
+ End delim for a group of questions.
554
+ '''
555
+ def __init__(self, group: Group):
556
+ self.group = group
557
+
558
+
559
+
560
+
561
+ class Quiz(object):
562
+ '''
563
+ A quiz or assessment. Contains a list of questions along with possible
564
+ choices and feedback.
565
+ '''
566
+ def __init__(self, string: str, *, config: Config,
567
+ source_name: Optional[str]=None,
568
+ resource_path: Optional[Union[str, pathlib.Path]]=None):
569
+ self.string = string
570
+ self.config = config
571
+ self.source_name = '<string>' if source_name is None else f'"{source_name}"'
572
+ if resource_path is not None:
573
+ if isinstance(resource_path, str):
574
+ resource_path = pathlib.Path(resource_path)
575
+ else:
576
+ raise TypeError
577
+ if not resource_path.is_dir():
578
+ raise Text2qtiError(f'Resource path "{resource_path.as_posix()}" does not exist')
579
+ self.resource_path = resource_path
580
+ self.title_raw = None
581
+ self.title_xml = 'Quiz'
582
+ self.description_raw = None
583
+ self.description_html_xml = ''
584
+ self.shuffle_answers_raw = None
585
+ self.shuffle_answers_xml = 'false'
586
+ self.show_correct_answers_raw = None
587
+ self.show_correct_answers_xml = 'true'
588
+ self.one_question_at_a_time_raw = None
589
+ self.one_question_at_a_time_xml = 'false'
590
+ self.cant_go_back_raw = None
591
+ self.cant_go_back_xml = 'false'
592
+ self.feedback_is_solution: Optional[bool] = None
593
+ self.solutions_sample_groups: Optional[bool] = None
594
+ self.solutions_randomize_groups: Optional[bool] = None
595
+ self.questions_and_delims: List[Union[Question, GroupStart, GroupEnd, TextRegion]] = []
596
+ self._current_group: Optional[Group] = None
597
+ # The set for detecting duplicate questions uses the XML version of
598
+ # the question, to avoid the issue of multiple Markdown
599
+ # representations of the same XML.
600
+ self.question_set: Set[str] = set()
601
+ self.md = Markdown(config)
602
+ self.images: Dict[str, Image] = self.md.images
603
+ self._next_question_attr = {}
604
+
605
+ # Determine how to interpret `.python` for executable code blocks.
606
+ # If `python3` exists, use it instead of `python` if `python` does not
607
+ # exist or if `python` is equivalent to `python2`.
608
+ if not shutil.which('python2') or not shutil.which('python3'):
609
+ python_executable = 'python'
610
+ elif not shutil.which('python'):
611
+ python_executable = 'python3'
612
+ elif pathlib.Path(shutil.which('python')).resolve() == pathlib.Path(shutil.which('python2')).resolve():
613
+ python_executable = 'python3'
614
+ else:
615
+ python_executable = 'python'
616
+
617
+ try:
618
+ parse_actions = {}
619
+ for k in start_patterns:
620
+ parse_actions[k] = getattr(self, f'append_{k}')
621
+ parse_actions[None] = self.append_unknown
622
+ start_multiline_comment_pattern = comment_patterns['start_multiline_comment']
623
+ end_multiline_comment_pattern = comment_patterns['end_multiline_comment']
624
+ line_comment_pattern = comment_patterns['line_comment']
625
+ n_line_iter = iter(x for x in enumerate(string.splitlines()))
626
+ n, line = next(n_line_iter, (0, None))
627
+ lookahead = False
628
+ n_code_start = 0
629
+ while line is not None:
630
+ match = start_re.match(line)
631
+ if match:
632
+ action = match.lastgroup
633
+ text = line[match.end():].strip()
634
+ if action == 'start_code':
635
+ info = line.lstrip('`').strip()
636
+ info_match = start_code_supported_info_re.match(info)
637
+ if info_match is None:
638
+ pass
639
+ else:
640
+ executable = info_match.group('executable')
641
+ if executable is not None:
642
+ if executable.startswith('"'):
643
+ executable = executable[1:-1]
644
+ executable = pathlib.Path(executable).expanduser().as_posix()
645
+ else:
646
+ executable = info_match.group('lang')
647
+ if executable == 'python':
648
+ executable = python_executable
649
+ delim = '`'*(len(line) - len(line.lstrip('`')))
650
+ n_code_start = n
651
+ code_lines = []
652
+ n, line = next(n_line_iter, (0, None))
653
+ # No lookahead here; all lines are consumed
654
+ while line is not None and not (line.startswith(delim) and line[len(delim):] == line.lstrip('`')):
655
+ code_lines.append(line)
656
+ n, line = next(n_line_iter, (0, None))
657
+ if line is None:
658
+ raise Text2qtiError(f'In {self.source_name} on line {n}:\nCode closing fence is missing')
659
+ if line.lstrip('`').strip():
660
+ raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nCode closing fence is missing')
661
+ code_lines.append('\n')
662
+ code = '\n'.join(code_lines)
663
+ try:
664
+ stdout = self._run_code(executable, code)
665
+ except Exception as e:
666
+ raise Text2qtiError(f'In {self.source_name} on line {n_code_start+1}:\n{e}')
667
+ code_n_line_iter = ((n_code_start, stdout_line) for stdout_line in stdout.splitlines())
668
+ n_line_iter = itertools.chain(code_n_line_iter, n_line_iter)
669
+ n, line = next(n_line_iter, (0, None))
670
+ continue
671
+ elif action in multi_line:
672
+ if start_patterns[action].endswith(':'):
673
+ indent_expandtabs = None
674
+ else:
675
+ indent_expandtabs = ' '*len(line[:match.end()].expandtabs(4))
676
+ text_lines = [text]
677
+ n, line = next(n_line_iter, (0, None))
678
+ line_expandtabs = line.expandtabs(4) if line is not None else None
679
+ lookahead = True
680
+ while (line is not None and
681
+ (not line or line.isspace() or
682
+ indent_expandtabs is None or line_expandtabs.startswith(indent_expandtabs))):
683
+ if not line or line.isspace():
684
+ if action in multi_para:
685
+ text_lines.append('')
686
+ else:
687
+ break
688
+ else:
689
+ if indent_expandtabs is None:
690
+ if not line.startswith(' '):
691
+ break
692
+ indent_expandtabs = ' '*(len(line_expandtabs)-len(line_expandtabs.lstrip(' ')))
693
+ if len(indent_expandtabs) < 2:
694
+ raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nIndentation must be at least 2 spaces or 1 tab here')
695
+ # The `rstrip()` prevents trailing double
696
+ # spaces from becoming `<br />`.
697
+ text_lines.append(line_expandtabs[len(indent_expandtabs):].rstrip())
698
+ n, line = next(n_line_iter, (0, None))
699
+ line_expandtabs = line.expandtabs(4) if line is not None else None
700
+ if text_lines and not text_lines[-1]:
701
+ while text_lines and not text_lines[-1]:
702
+ text_lines.pop()
703
+ text = '\n'.join(text_lines)
704
+ elif line.startswith(line_comment_pattern):
705
+ n, line = next(n_line_iter, (0, None))
706
+ continue
707
+ elif line.startswith(start_multiline_comment_pattern):
708
+ if line.strip() != start_multiline_comment_pattern:
709
+ raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nUnexpected content after "{start_multiline_comment_pattern}"')
710
+ n, line = next(n_line_iter, (0, None))
711
+ while line is not None and not line.startswith(end_multiline_comment_pattern):
712
+ n, line = next(n_line_iter, (0, None))
713
+ if line is None:
714
+ raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nf"{start_multiline_comment_pattern}" without following "{end_multiline_comment_pattern}"')
715
+ if line.strip() != end_multiline_comment_pattern:
716
+ raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nUnexpected content after "{end_multiline_comment_pattern}"')
717
+ n, line = next(n_line_iter, (0, None))
718
+ continue
719
+ elif line.startswith(end_multiline_comment_pattern):
720
+ raise Text2qtiError(f'In {self.source_name} on line {n+1}:\n"{end_multiline_comment_pattern}" without preceding "{start_multiline_comment_pattern}"')
721
+ else:
722
+ action = None
723
+ text = line
724
+ try:
725
+ parse_actions[action](text)
726
+ except Text2qtiError as e:
727
+ if lookahead and n != n_code_start:
728
+ raise Text2qtiError(f'In {self.source_name} on line {n}:\n{e}')
729
+ raise Text2qtiError(f'In {self.source_name} on line {n+1}:\n{e}')
730
+ if not lookahead:
731
+ n, line = next(n_line_iter, (0, None))
732
+ lookahead = False
733
+ if not self.questions_and_delims:
734
+ raise Text2qtiError('No questions were found')
735
+ if self._current_group is not None:
736
+ raise Text2qtiError(f'In {self.source_name} on line {len(string.splitlines())}:\nQuestion group never ended')
737
+ last_question_or_delim = self.questions_and_delims[-1]
738
+ if isinstance(last_question_or_delim, Question):
739
+ try:
740
+ last_question_or_delim.finalize()
741
+ except Text2qtiError as e:
742
+ raise Text2qtiError(f'In {self.source_name} on line {len(string.splitlines())}:\n{e}')
743
+
744
+ points_possible = 0
745
+ digests = []
746
+ for x in self.questions_and_delims:
747
+ if isinstance(x, Question):
748
+ points_possible += x.points_possible
749
+ digests.append(x.hash_digest)
750
+ elif isinstance(x, GroupStart):
751
+ points_possible += x.group.points_per_question*x.group.pick
752
+ digests.append(x.group.hash_digest)
753
+ elif isinstance(x, GroupEnd):
754
+ pass
755
+ elif isinstance(x, TextRegion):
756
+ pass
757
+ else:
758
+ raise TypeError
759
+ self.points_possible = points_possible
760
+ h = hashlib.blake2b()
761
+ for digest in sorted(digests):
762
+ h.update(digest)
763
+ self.hash_digest = h.digest()
764
+ self.id = h.hexdigest()[:64]
765
+ finally:
766
+ self.md.finalize()
767
+
768
+ def _run_code(self, executable: str, code: str) -> str:
769
+ if not self.config['run_code_blocks']:
770
+ raise Text2qtiError('Code execution for code blocks is not enabled; use --run-code-blocks, or set run_code_blocks = true in config')
771
+ h = hashlib.blake2b()
772
+ h.update(code.encode('utf8'))
773
+ if platform.system() == 'Windows':
774
+ # Prevent console from appearing for an instant
775
+ startupinfo = subprocess.STARTUPINFO()
776
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
777
+ else:
778
+ startupinfo = None
779
+ with tempfile.TemporaryDirectory() as tempdir:
780
+ tempdir_path = pathlib.Path(tempdir)
781
+ code_path = tempdir_path / f'{h.hexdigest()[:16]}.code'
782
+ code_path.write_text(code, encoding='utf8')
783
+ if platform.system() == 'Windows':
784
+ # Modify executable since subprocess.Popen() ignores PATH
785
+ # * https://bugs.python.org/issue15451
786
+ # * https://bugs.python.org/issue8557
787
+ which_executable = shutil.which(executable)
788
+ if which_executable is None:
789
+ raise Text2qtiError(f'Failed to execute code (missing executable "{executable}")')
790
+ cmd = [which_executable, code_path.as_posix()]
791
+ else:
792
+ cmd = [executable, code_path.as_posix()]
793
+ try:
794
+ # stdin is needed for GUI because standard file handles can't
795
+ # be inherited
796
+ proc = subprocess.run(cmd,
797
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE,
798
+ startupinfo=startupinfo)
799
+ except FileNotFoundError as e:
800
+ raise Text2qtiError(f'Failed to execute code (missing executable "{executable}"?):\n{e}')
801
+ except Exception as e:
802
+ raise Text2qtiError(f'Failed to execute code with command "{cmd}":\n{e}')
803
+ # Use io to handle output as if read from a file in terms of newline
804
+ # treatment
805
+ if proc.returncode != 0:
806
+ stderr_str = io.TextIOWrapper(io.BytesIO(proc.stderr),
807
+ encoding=locale.getpreferredencoding(False),
808
+ errors='backslashreplace').read()
809
+ raise Text2qtiError(f'Code execution resulted in errors:\n{"-"*50}\n{stderr_str}\n{"-"*50}')
810
+ try:
811
+ stdout_str = io.TextIOWrapper(io.BytesIO(proc.stdout),
812
+ encoding=locale.getpreferredencoding(False)).read()
813
+ except Exception as e:
814
+ raise Text2qtiError(f'Failed to decode output of executed code:\n{e}')
815
+ return stdout_str
816
+
817
+ def append_quiz_title(self, text: str):
818
+ if any(x is not None for x in (self.shuffle_answers_raw, self.show_correct_answers_raw,
819
+ self.one_question_at_a_time_raw, self.cant_go_back_raw)):
820
+ raise Text2qtiError('Must give quiz title before quiz options')
821
+ if self._next_question_attr:
822
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
823
+ if self.title_raw is not None:
824
+ raise Text2qtiError('Quiz title has already been given')
825
+ if self.questions_and_delims:
826
+ raise Text2qtiError('Must give quiz title before questions')
827
+ if self.description_raw is not None:
828
+ raise Text2qtiError('Must give quiz title before quiz description')
829
+ self.title_raw = text
830
+ self.title_xml = self.md.xml_escape(text)
831
+
832
+ def append_quiz_description(self, text: str):
833
+ if any(x is not None for x in (self.shuffle_answers_raw, self.show_correct_answers_raw,
834
+ self.one_question_at_a_time_raw, self.cant_go_back_raw)):
835
+ raise Text2qtiError('Must give quiz description before quiz options')
836
+ if self._next_question_attr:
837
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
838
+ if self.description_raw is not None:
839
+ raise Text2qtiError('Quiz description has already been given')
840
+ if self.questions_and_delims:
841
+ raise Text2qtiError('Must give quiz description before questions')
842
+ self.description_raw = text
843
+ self.description_html_xml = self.md.md_to_html_xml(text)
844
+
845
+ def append_quiz_shuffle_answers(self, text: str):
846
+ if self._next_question_attr:
847
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
848
+ if self.questions_and_delims:
849
+ raise Text2qtiError('Must give quiz options before questions')
850
+ if self.shuffle_answers_raw is not None:
851
+ raise Text2qtiError('Quiz option "Shuffle answers" has already been set')
852
+ if text not in ('true', 'True', 'false', 'False'):
853
+ raise Text2qtiError('Expected option value "true" or "false"')
854
+ self.shuffle_answers_raw = text
855
+ self.shuffle_answers_xml = text.lower()
856
+
857
+ def append_quiz_show_correct_answers(self, text: str):
858
+ if self._next_question_attr:
859
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
860
+ if self.questions_and_delims:
861
+ raise Text2qtiError('Must give quiz options before questions')
862
+ if self.show_correct_answers_raw is not None:
863
+ raise Text2qtiError('Quiz option "Show correct answers" has already been set')
864
+ if text not in ('true', 'True', 'false', 'False'):
865
+ raise Text2qtiError('Expected option value "true" or "false"')
866
+ self.show_correct_answers_raw = text
867
+ self.show_correct_answers_xml = text.lower()
868
+
869
+ def append_quiz_one_question_at_a_time(self, text: str):
870
+ if self._next_question_attr:
871
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
872
+ if self.questions_and_delims:
873
+ raise Text2qtiError('Must give quiz options before questions')
874
+ if self.one_question_at_a_time_raw is not None:
875
+ raise Text2qtiError('Quiz option "One question at a time" has already been set')
876
+ if text not in ('true', 'True', 'false', 'False'):
877
+ raise Text2qtiError('Expected option value "true" or "false"')
878
+ self.one_question_at_a_time_raw = text
879
+ self.one_question_at_a_time_xml = text.lower()
880
+
881
+ def append_quiz_cant_go_back(self, text: str):
882
+ if self._next_question_attr:
883
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
884
+ if self.questions_and_delims:
885
+ raise Text2qtiError('Must give quiz options before questions')
886
+ if self.cant_go_back_raw is not None:
887
+ raise Text2qtiError('''Quiz option "Can't go back" has already been set''')
888
+ if text not in ('true', 'True', 'false', 'False'):
889
+ raise Text2qtiError('Expected option value "true" or "false"')
890
+ if self.one_question_at_a_time_xml != 'true':
891
+ raise Text2qtiError('''Must set "One question at a time" to "true" before setting "Can't go back"''')
892
+ self.cant_go_back_raw = text
893
+ self.cant_go_back_xml = text.lower()
894
+
895
+ def append_quiz_feedback_is_solution(self, text: str):
896
+ if self._next_question_attr:
897
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
898
+ if self.questions_and_delims:
899
+ raise Text2qtiError('Must give quiz options before questions')
900
+ if self.feedback_is_solution is not None:
901
+ raise Text2qtiError('Quiz option "feedback is solution" has already been set')
902
+ if text in ('true', 'True'):
903
+ self.feedback_is_solution = True
904
+ elif text in ('false', 'False'):
905
+ self.feedback_is_solution = False
906
+ else:
907
+ raise Text2qtiError('Expected option value "true" or "false"')
908
+
909
+ def append_quiz_solutions_sample_groups(self, text: str):
910
+ if self._next_question_attr:
911
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
912
+ if self.questions_and_delims:
913
+ raise Text2qtiError('Must give quiz options before questions')
914
+ if self.solutions_sample_groups is not None:
915
+ raise Text2qtiError('Quiz option "solutions sample groups" has already been set')
916
+ if text in ('true', 'True'):
917
+ self.solutions_sample_groups = True
918
+ elif text in ('false', 'False'):
919
+ self.solutions_sample_groups = False
920
+ else:
921
+ raise Text2qtiError('Expected option value "true" or "false"')
922
+
923
+ def append_quiz_solutions_randomize_groups(self, text: str):
924
+ if self._next_question_attr:
925
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
926
+ if self.questions_and_delims:
927
+ raise Text2qtiError('Must give quiz options before questions')
928
+ if self.solutions_randomize_groups is not None:
929
+ raise Text2qtiError('Quiz option "solutions randomize groups" has already been set')
930
+ if text in ('true', 'True'):
931
+ self.solutions_randomize_groups = True
932
+ elif text in ('false', 'False'):
933
+ self.solutions_randomize_groups = False
934
+ else:
935
+ raise Text2qtiError('Expected option value "true" or "false"')
936
+
937
+ def append_text_title(self, text: str):
938
+ if self._next_question_attr:
939
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
940
+ if self.questions_and_delims:
941
+ last_question_or_delim = self.questions_and_delims[-1]
942
+ if isinstance(last_question_or_delim, Question):
943
+ last_question_or_delim.finalize()
944
+ text_region = TextRegion(index=len(self.questions_and_delims), md=self.md)
945
+ text_region.set_title(text)
946
+ self.questions_and_delims.append(text_region)
947
+
948
+ def append_text(self, text: str):
949
+ if self._next_question_attr:
950
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
951
+ if self.questions_and_delims:
952
+ last_question_or_delim = self.questions_and_delims[-1]
953
+ if isinstance(last_question_or_delim, Question):
954
+ last_question_or_delim.finalize()
955
+ if isinstance(last_question_or_delim, TextRegion) and last_question_or_delim.text_raw is None:
956
+ last_question_or_delim.set_text(text)
957
+ else:
958
+ text_region = TextRegion(index=len(self.questions_and_delims), md=self.md)
959
+ text_region.set_text(text)
960
+ self.questions_and_delims.append(text_region)
961
+ else:
962
+ text_region = TextRegion(index=len(self.questions_and_delims), md=self.md)
963
+ text_region.set_text(text)
964
+ self.questions_and_delims.append(text_region)
965
+
966
+ def append_question(self, text: str):
967
+ if self.questions_and_delims:
968
+ last_question_or_delim = self.questions_and_delims[-1]
969
+ if isinstance(last_question_or_delim, Question):
970
+ last_question_or_delim.finalize()
971
+ question = Question(text,
972
+ quiz=self,
973
+ title=self._next_question_attr.get('title'),
974
+ points=self._next_question_attr.get('points'),
975
+ md=self.md)
976
+ self._next_question_attr = {}
977
+ if question.question_html_xml in self.question_set:
978
+ raise Text2qtiError('Duplicate question')
979
+ self.question_set.add(question.question_html_xml)
980
+ self.questions_and_delims.append(question)
981
+ if self._current_group is not None:
982
+ self._current_group.append_question(question)
983
+
984
+ def append_question_title(self, text: str):
985
+ if 'title' in self._next_question_attr:
986
+ raise Text2qtiError('Title for next question has already been set')
987
+ if 'points' in self._next_question_attr:
988
+ raise Text2qtiError('Title for next question must be set before point value')
989
+ self._next_question_attr['title'] = text
990
+
991
+ def append_question_points(self, text: str):
992
+ if 'points' in self._next_question_attr:
993
+ raise Text2qtiError('Points for next question has already been set')
994
+ self._next_question_attr['points'] = text
995
+
996
+ def append_feedback(self, text: str):
997
+ if self._next_question_attr:
998
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
999
+ if not self.questions_and_delims:
1000
+ raise Text2qtiError('Cannot have feedback without a question')
1001
+ last_question_or_delim = self.questions_and_delims[-1]
1002
+ if not isinstance(last_question_or_delim, Question):
1003
+ raise Text2qtiError('Cannot have feedback without a question')
1004
+ last_question_or_delim.append_feedback(text)
1005
+
1006
+ def append_correct_feedback(self, text: str):
1007
+ if self._next_question_attr:
1008
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1009
+ if not self.questions_and_delims:
1010
+ raise Text2qtiError('Cannot have feedback without a question')
1011
+ last_question_or_delim = self.questions_and_delims[-1]
1012
+ if not isinstance(last_question_or_delim, Question):
1013
+ raise Text2qtiError('Cannot have feedback without a question')
1014
+ last_question_or_delim.append_correct_feedback(text)
1015
+
1016
+ def append_incorrect_feedback(self, text: str):
1017
+ if self._next_question_attr:
1018
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1019
+ if not self.questions_and_delims:
1020
+ raise Text2qtiError('Cannot have feedback without a question')
1021
+ last_question_or_delim = self.questions_and_delims[-1]
1022
+ if not isinstance(last_question_or_delim, Question):
1023
+ raise Text2qtiError('Cannot have feedback without a question')
1024
+ last_question_or_delim.append_incorrect_feedback(text)
1025
+
1026
+ def append_solution(self, text: str):
1027
+ if self.feedback_is_solution:
1028
+ raise Text2qtiError('Quiz option "feedback is solution" is "true"; '
1029
+ '"!" solution syntax is disabled and solutions must be provided as feedback ("...") rather than separately')
1030
+ if self._next_question_attr:
1031
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1032
+ if not self.questions_and_delims:
1033
+ raise Text2qtiError('Cannot have a solution without a question')
1034
+ last_question_or_delim = self.questions_and_delims[-1]
1035
+ if not isinstance(last_question_or_delim, Question):
1036
+ raise Text2qtiError('Cannot have a solution without a question')
1037
+ last_question_or_delim.append_solution(text)
1038
+
1039
+ def append_mctf_correct_choice(self, text: str):
1040
+ if self._next_question_attr:
1041
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1042
+ if not self.questions_and_delims:
1043
+ raise Text2qtiError('Cannot have a choice without a question')
1044
+ last_question_or_delim = self.questions_and_delims[-1]
1045
+ if not isinstance(last_question_or_delim, Question):
1046
+ raise Text2qtiError('Cannot have a choice without a question')
1047
+ last_question_or_delim.append_mctf_correct_choice(text)
1048
+
1049
+ def append_mctf_incorrect_choice(self, text: str):
1050
+ if self._next_question_attr:
1051
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1052
+ if not self.questions_and_delims:
1053
+ raise Text2qtiError('Cannot have a choice without a question')
1054
+ last_question_or_delim = self.questions_and_delims[-1]
1055
+ if not isinstance(last_question_or_delim, Question):
1056
+ raise Text2qtiError('Cannot have a choice without a question')
1057
+ last_question_or_delim.append_mctf_incorrect_choice(text)
1058
+
1059
+ def append_shortans_correct_choice(self, text: str):
1060
+ if self._next_question_attr:
1061
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1062
+ if not self.questions_and_delims:
1063
+ raise Text2qtiError('Cannot have an answer without a question')
1064
+ last_question_or_delim = self.questions_and_delims[-1]
1065
+ if not isinstance(last_question_or_delim, Question):
1066
+ raise Text2qtiError('Cannot have an answer without a question')
1067
+ last_question_or_delim.append_shortans_correct_choice(text)
1068
+
1069
+ def append_multans_correct_choice(self, text: str):
1070
+ if self._next_question_attr:
1071
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1072
+ if not self.questions_and_delims:
1073
+ raise Text2qtiError('Cannot have a choice without a question')
1074
+ last_question_or_delim = self.questions_and_delims[-1]
1075
+ if not isinstance(last_question_or_delim, Question):
1076
+ raise Text2qtiError('Cannot have a choice without a question')
1077
+ last_question_or_delim.append_multans_correct_choice(text)
1078
+
1079
+ def append_multans_incorrect_choice(self, text: str):
1080
+ if self._next_question_attr:
1081
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1082
+ if not self.questions_and_delims:
1083
+ raise Text2qtiError('Cannot have a choice without a question')
1084
+ last_question_or_delim = self.questions_and_delims[-1]
1085
+ if not isinstance(last_question_or_delim, Question):
1086
+ raise Text2qtiError('Cannot have a choice without a question')
1087
+ last_question_or_delim.append_multans_incorrect_choice(text)
1088
+
1089
+ def append_essay(self, text: str):
1090
+ if self._next_question_attr:
1091
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1092
+ if not self.questions_and_delims:
1093
+ raise Text2qtiError('Cannot have an essay response without a question')
1094
+ last_question_or_delim = self.questions_and_delims[-1]
1095
+ if not isinstance(last_question_or_delim, Question):
1096
+ raise Text2qtiError('Cannot have an essay response without a question')
1097
+ last_question_or_delim.append_essay(text)
1098
+
1099
+ def append_upload(self, text: str):
1100
+ if self._next_question_attr:
1101
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1102
+ if not self.questions_and_delims:
1103
+ raise Text2qtiError('Cannot have an upload response without a question')
1104
+ last_question_or_delim = self.questions_and_delims[-1]
1105
+ if not isinstance(last_question_or_delim, Question):
1106
+ raise Text2qtiError('Cannot have an upload response without a question')
1107
+ last_question_or_delim.append_upload(text)
1108
+
1109
+ def append_numerical(self, text: str):
1110
+ if self._next_question_attr:
1111
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1112
+ if not self.questions_and_delims:
1113
+ raise Text2qtiError('Cannot have a numerical response without a question')
1114
+ last_question_or_delim = self.questions_and_delims[-1]
1115
+ if not isinstance(last_question_or_delim, Question):
1116
+ raise Text2qtiError('Cannot have a numerical response without a question')
1117
+ last_question_or_delim.append_numerical(text)
1118
+
1119
+ def append_start_group(self, text: str):
1120
+ if self._next_question_attr:
1121
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1122
+ if text:
1123
+ raise ValueError
1124
+ if self._current_group is not None:
1125
+ raise Text2qtiError('Question groups cannot be nested')
1126
+ if self.questions_and_delims:
1127
+ last_question_or_delim = self.questions_and_delims[-1]
1128
+ if isinstance(last_question_or_delim, Question):
1129
+ last_question_or_delim.finalize()
1130
+ group = Group()
1131
+ self._current_group = group
1132
+ self.questions_and_delims.append(GroupStart(group))
1133
+
1134
+ def append_end_group(self, text: str):
1135
+ if self._next_question_attr:
1136
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1137
+ if text:
1138
+ raise ValueError
1139
+ if self._current_group is None:
1140
+ raise Text2qtiError('No question group to end')
1141
+ if self.questions_and_delims:
1142
+ last_question_or_delim = self.questions_and_delims[-1]
1143
+ if isinstance(last_question_or_delim, Question):
1144
+ last_question_or_delim.finalize()
1145
+ self._current_group.finalize()
1146
+ self.questions_and_delims.append(GroupEnd(self._current_group))
1147
+ self._current_group = None
1148
+
1149
+ def append_group_pick(self, text: str):
1150
+ if self._next_question_attr:
1151
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1152
+ if self._current_group is None:
1153
+ raise Text2qtiError('No question group for setting properties')
1154
+ self._current_group.append_group_pick(text)
1155
+
1156
+ def append_group_solutions_pick(self, text: str):
1157
+ if self._next_question_attr:
1158
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1159
+ if self._current_group is None:
1160
+ raise Text2qtiError('No question group for setting properties')
1161
+ self._current_group.append_group_solutions_pick(text)
1162
+
1163
+ def append_group_points_per_question(self, text: str):
1164
+ if self._next_question_attr:
1165
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1166
+ if self._current_group is None:
1167
+ raise Text2qtiError('No question group for setting properties')
1168
+ self._current_group.append_group_points_per_question(text)
1169
+
1170
+ def append_start_code(self, text: str):
1171
+ raise Text2qtiError('Invalid code block start')
1172
+
1173
+ def append_end_code(self, text: str):
1174
+ raise Text2qtiError('Code block end missing code block start')
1175
+
1176
+ def append_unknown(self, text: str):
1177
+ if self._next_question_attr:
1178
+ raise Text2qtiError('Expected question; question title and/or points were set but not used')
1179
+ if text and not text.isspace():
1180
+ match = start_missing_whitespace_re.match(text)
1181
+ if match:
1182
+ raise Text2qtiError(f'Missing whitespace after "{match.group().strip()}"')
1183
+ match = start_missing_content_re.match(text)
1184
+ if match:
1185
+ raise Text2qtiError(f'Missing content after "{match.group().strip()}"')
1186
+ raise Text2qtiError(f'Syntax error; unexpected text, or incorrect indentation for a wrapped paragraph:\n"{text}"')
text2qti/version.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from .fmtversion import get_version_plus_info
4
+ __version__, __version_info__ = get_version_plus_info(0, 7, 0, 'dev', 1)
text2qti/xml_assessment.py ADDED
@@ -0,0 +1,621 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ from .quiz import Quiz, Question, GroupStart, GroupEnd, TextRegion
12
+
13
+
14
+ BEFORE_ITEMS = '''\
15
+ <?xml version="1.0" encoding="UTF-8"?>
16
+ <questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/ims_qtiasiv1p2 http://www.imsglobal.org/xsd/ims_qtiasiv1p2p1.xsd">
17
+ <assessment ident="{assessment_identifier}" title="{title}">
18
+ <qtimetadata>
19
+ <qtimetadatafield>
20
+ <fieldlabel>cc_maxattempts</fieldlabel>
21
+ <fieldentry>1</fieldentry>
22
+ </qtimetadatafield>
23
+ </qtimetadata>
24
+ <section ident="root_section">
25
+ '''
26
+
27
+ AFTER_ITEMS = '''\
28
+ </section>
29
+ </assessment>
30
+ </questestinterop>
31
+ '''
32
+
33
+ GROUP_START = '''\
34
+ <section ident="{ident}" title="{group_title}">
35
+ <selection_ordering>
36
+ <selection>
37
+ <selection_number>{pick}</selection_number>
38
+ <selection_extension>
39
+ <points_per_item>{points_per_item}</points_per_item>
40
+ </selection_extension>
41
+ </selection>
42
+ </selection_ordering>
43
+ '''
44
+
45
+ GROUP_END = '''\
46
+ </section>
47
+ '''
48
+
49
+ TEXT = '''\
50
+ <item ident="{ident}" title="{text_title_xml}">
51
+ <itemmetadata>
52
+ <qtimetadata>
53
+ <qtimetadatafield>
54
+ <fieldlabel>question_type</fieldlabel>
55
+ <fieldentry>text_only_question</fieldentry>
56
+ </qtimetadatafield>
57
+ <qtimetadatafield>
58
+ <fieldlabel>points_possible</fieldlabel>
59
+ <fieldentry>0</fieldentry>
60
+ </qtimetadatafield>
61
+ <qtimetadatafield>
62
+ <fieldlabel>original_answer_ids</fieldlabel>
63
+ <fieldentry></fieldentry>
64
+ </qtimetadatafield>
65
+ <qtimetadatafield>
66
+ <fieldlabel>assessment_question_identifierref</fieldlabel>
67
+ <fieldentry>{assessment_question_identifierref}</fieldentry>
68
+ </qtimetadatafield>
69
+ </qtimetadata>
70
+ </itemmetadata>
71
+ <presentation>
72
+ <material>
73
+ <mattext texttype="text/html">{text_html_xml}</mattext>
74
+ </material>
75
+ </presentation>
76
+ </item>
77
+ '''
78
+
79
+ START_ITEM = '''\
80
+ <item ident="{question_identifier}" title="{question_title}">
81
+ '''
82
+
83
+ END_ITEM = '''\
84
+ </item>
85
+ '''
86
+
87
+
88
+ ITEM_METADATA_MCTF_SHORTANS_MULTANS_NUM = '''\
89
+ <itemmetadata>
90
+ <qtimetadata>
91
+ <qtimetadatafield>
92
+ <fieldlabel>question_type</fieldlabel>
93
+ <fieldentry>{question_type}</fieldentry>
94
+ </qtimetadatafield>
95
+ <qtimetadatafield>
96
+ <fieldlabel>points_possible</fieldlabel>
97
+ <fieldentry>{points_possible}</fieldentry>
98
+ </qtimetadatafield>
99
+ <qtimetadatafield>
100
+ <fieldlabel>original_answer_ids</fieldlabel>
101
+ <fieldentry>{original_answer_ids}</fieldentry>
102
+ </qtimetadatafield>
103
+ <qtimetadatafield>
104
+ <fieldlabel>assessment_question_identifierref</fieldlabel>
105
+ <fieldentry>{assessment_question_identifierref}</fieldentry>
106
+ </qtimetadatafield>
107
+ </qtimetadata>
108
+ </itemmetadata>
109
+ '''
110
+
111
+ ITEM_METADATA_ESSAY = ITEM_METADATA_MCTF_SHORTANS_MULTANS_NUM.replace('{original_answer_ids}', '')
112
+
113
+ ITEM_METADATA_UPLOAD = ITEM_METADATA_ESSAY
114
+
115
+ ITEM_PRESENTATION_MCTF = '''\
116
+ <presentation>
117
+ <material>
118
+ <mattext texttype="text/html">{question_html_xml}</mattext>
119
+ </material>
120
+ <response_lid ident="response1" rcardinality="Single">
121
+ <render_choice>
122
+ {choices}
123
+ </render_choice>
124
+ </response_lid>
125
+ </presentation>
126
+ '''
127
+
128
+ ITEM_PRESENTATION_MCTF_CHOICE = '''\
129
+ <response_label ident="{ident}">
130
+ <material>
131
+ <mattext texttype="text/html">{choice_html_xml}</mattext>
132
+ </material>
133
+ </response_label>'''
134
+
135
+ ITEM_PRESENTATION_MULTANS = ITEM_PRESENTATION_MCTF.replace('Single', 'Multiple')
136
+
137
+ ITEM_PRESENTATION_MULTANS_CHOICE = ITEM_PRESENTATION_MCTF_CHOICE
138
+
139
+ ITEM_PRESENTATION_SHORTANS = '''\
140
+ <presentation>
141
+ <material>
142
+ <mattext texttype="text/html">{question_html_xml}</mattext>
143
+ </material>
144
+ <response_str ident="response1" rcardinality="Single">
145
+ <render_fib>
146
+ <response_label ident="answer1" rshuffle="No"/>
147
+ </render_fib>
148
+ </response_str>
149
+ </presentation>
150
+ '''
151
+
152
+ ITEM_PRESENTATION_ESSAY = '''\
153
+ <presentation>
154
+ <material>
155
+ <mattext texttype="text/html">{question_html_xml}</mattext>
156
+ </material>
157
+ <response_str ident="response1" rcardinality="Single">
158
+ <render_fib>
159
+ <response_label ident="answer1" rshuffle="No"/>
160
+ </render_fib>
161
+ </response_str>
162
+ </presentation>
163
+ '''
164
+
165
+ ITEM_PRESENTATION_UPLOAD = '''\
166
+ <presentation>
167
+ <material>
168
+ <mattext texttype="text/html">{question_html_xml}</mattext>
169
+ </material>
170
+ </presentation>
171
+ '''
172
+
173
+ ITEM_PRESENTATION_NUM = '''\
174
+ <presentation>
175
+ <material>
176
+ <mattext texttype="text/html">{question_html_xml}</mattext>
177
+ </material>
178
+ <response_str ident="response1" rcardinality="Single">
179
+ <render_fib fibtype="Decimal">
180
+ <response_label ident="answer1"/>
181
+ </render_fib>
182
+ </response_str>
183
+ </presentation>
184
+ '''
185
+
186
+
187
+ ITEM_RESPROCESSING_START = '''\
188
+ <resprocessing>
189
+ <outcomes>
190
+ <decvar maxvalue="100" minvalue="0" varname="SCORE" vartype="Decimal"/>
191
+ </outcomes>
192
+ '''
193
+
194
+ ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK = '''\
195
+ <respcondition continue="Yes">
196
+ <conditionvar>
197
+ <other/>
198
+ </conditionvar>
199
+ <displayfeedback feedbacktype="Response" linkrefid="general_fb"/>
200
+ </respcondition>
201
+ '''
202
+
203
+ ITEM_RESPROCESSING_MCTF_CHOICE_FEEDBACK = '''\
204
+ <respcondition continue="Yes">
205
+ <conditionvar>
206
+ <varequal respident="response1">{ident}</varequal>
207
+ </conditionvar>
208
+ <displayfeedback feedbacktype="Response" linkrefid="{ident}_fb"/>
209
+ </respcondition>
210
+ '''
211
+
212
+ ITEM_RESPROCESSING_MCTF_SET_CORRECT_WITH_FEEDBACK = '''\
213
+ <respcondition continue="No">
214
+ <conditionvar>
215
+ <varequal respident="response1">{ident}</varequal>
216
+ </conditionvar>
217
+ <setvar action="Set" varname="SCORE">100</setvar>
218
+ <displayfeedback feedbacktype="Response" linkrefid="correct_fb"/>
219
+ </respcondition>
220
+ '''
221
+
222
+ ITEM_RESPROCESSING_MCTF_SET_CORRECT_NO_FEEDBACK = '''\
223
+ <respcondition continue="No">
224
+ <conditionvar>
225
+ <varequal respident="response1">{ident}</varequal>
226
+ </conditionvar>
227
+ <setvar action="Set" varname="SCORE">100</setvar>
228
+ </respcondition>
229
+ '''
230
+
231
+ ITEM_RESPROCESSING_MCTF_INCORRECT_FEEDBACK = '''\
232
+ <respcondition continue="Yes">
233
+ <conditionvar>
234
+ <other/>
235
+ </conditionvar>
236
+ <displayfeedback feedbacktype="Response" linkrefid="general_incorrect_fb"/>
237
+ </respcondition>
238
+ '''
239
+
240
+ ITEM_RESPROCESSING_SHORTANS_GENERAL_FEEDBACK = ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK
241
+
242
+ ITEM_RESPROCESSING_SHORTANS_CHOICE_FEEDBACK = '''\
243
+ <respcondition continue="Yes">
244
+ <conditionvar>
245
+ <varequal respident="response1">{answer_xml}</varequal>
246
+ </conditionvar>
247
+ <displayfeedback feedbacktype="Response" linkrefid="{ident}_fb"/>
248
+ </respcondition>
249
+ '''
250
+
251
+ ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_WITH_FEEDBACK = '''\
252
+ <respcondition continue="No">
253
+ <conditionvar>
254
+ {varequal}
255
+ </conditionvar>
256
+ <setvar action="Set" varname="SCORE">100</setvar>
257
+ <displayfeedback feedbacktype="Response" linkrefid="correct_fb"/>
258
+ </respcondition>
259
+ '''
260
+
261
+ ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_NO_FEEDBACK = '''\
262
+ <respcondition continue="No">
263
+ <conditionvar>
264
+ {varequal}
265
+ </conditionvar>
266
+ <setvar action="Set" varname="SCORE">100</setvar>
267
+ </respcondition>
268
+ '''
269
+
270
+ ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_VAREQUAL = '''\
271
+ <varequal respident="response1">{answer_xml}</varequal>'''
272
+
273
+ ITEM_RESPROCESSING_SHORTANS_INCORRECT_FEEDBACK = ITEM_RESPROCESSING_MCTF_INCORRECT_FEEDBACK
274
+
275
+ ITEM_RESPROCESSING_MULTANS_GENERAL_FEEDBACK = ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK
276
+
277
+ ITEM_RESPROCESSING_MULTANS_CHOICE_FEEDBACK = ITEM_RESPROCESSING_MCTF_CHOICE_FEEDBACK
278
+
279
+ ITEM_RESPROCESSING_MULTANS_SET_CORRECT_WITH_FEEDBACK = '''\
280
+ <respcondition continue="No">
281
+ <conditionvar>
282
+ <and>
283
+ {varequal}
284
+ </and>
285
+ </conditionvar>
286
+ <setvar action="Set" varname="SCORE">100</setvar>
287
+ <displayfeedback feedbacktype="Response" linkrefid="correct_fb"/>
288
+ </respcondition>
289
+ '''
290
+
291
+ ITEM_RESPROCESSING_MULTANS_SET_CORRECT_NO_FEEDBACK = '''\
292
+ <respcondition continue="No">
293
+ <conditionvar>
294
+ <and>
295
+ {varequal}
296
+ </and>
297
+ </conditionvar>
298
+ <setvar action="Set" varname="SCORE">100</setvar>
299
+ </respcondition>
300
+ '''
301
+
302
+ ITEM_RESPROCESSING_MULTANS_SET_CORRECT_VAREQUAL_CORRECT = '''\
303
+ <varequal respident="response1">{ident}</varequal>'''
304
+
305
+ ITEM_RESPROCESSING_MULTANS_SET_CORRECT_VAREQUAL_INCORRECT = '''\
306
+ <not>
307
+ <varequal respident="response1">{ident}</varequal>
308
+ </not>'''
309
+
310
+ ITEM_RESPROCESSING_MULTANS_INCORRECT_FEEDBACK = ITEM_RESPROCESSING_MCTF_INCORRECT_FEEDBACK
311
+
312
+ ITEM_RESPROCESSING_ESSAY_GENERAL_FEEDBACK = ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK
313
+
314
+ ITEM_RESPROCESSING_UPLOAD_GENERAL_FEEDBACK = ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK
315
+
316
+ ITEM_RESPROCESSING_NUM_GENERAL_FEEDBACK = ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK
317
+
318
+ ITEM_RESPROCESSING_NUM_RANGE_SET_CORRECT_WITH_FEEDBACK = '''\
319
+ <respcondition continue="No">
320
+ <conditionvar>
321
+ <vargte respident="response1">{num_min}</vargte>
322
+ <varlte respident="response1">{num_max}</varlte>
323
+ </conditionvar>
324
+ <setvar action="Set" varname="SCORE">100</setvar>
325
+ <displayfeedback feedbacktype="Response" linkrefid="correct_fb"/>
326
+ </respcondition>
327
+ '''
328
+
329
+ ITEM_RESPROCESSING_NUM_RANGE_SET_CORRECT_NO_FEEDBACK = '''\
330
+ <respcondition continue="No">
331
+ <conditionvar>
332
+ <vargte respident="response1">{num_min}</vargte>
333
+ <varlte respident="response1">{num_max}</varlte>
334
+ </conditionvar>
335
+ <setvar action="Set" varname="SCORE">100</setvar>
336
+ </respcondition>
337
+ '''
338
+
339
+ ITEM_RESPROCESSING_NUM_EXACT_SET_CORRECT_WITH_FEEDBACK = '''\
340
+ <respcondition continue="No">
341
+ <conditionvar>
342
+ <or>
343
+ <varequal respident="response1">{num_exact}</varequal>
344
+ <and>
345
+ <vargte respident="response1">{num_min}</vargte>
346
+ <varlte respident="response1">{num_max}</varlte>
347
+ </and>
348
+ </or>
349
+ </conditionvar>
350
+ <setvar action="Set" varname="SCORE">100</setvar>
351
+ <displayfeedback feedbacktype="Response" linkrefid="correct_fb"/>
352
+ </respcondition>
353
+ '''
354
+
355
+ ITEM_RESPROCESSING_NUM_EXACT_SET_CORRECT_NO_FEEDBACK = '''\
356
+ <respcondition continue="No">
357
+ <conditionvar>
358
+ <or>
359
+ <varequal respident="response1">{num_exact}</varequal>
360
+ <and>
361
+ <vargte respident="response1">{num_min}</vargte>
362
+ <varlte respident="response1">{num_max}</varlte>
363
+ </and>
364
+ </or>
365
+ </conditionvar>
366
+ <setvar action="Set" varname="SCORE">100</setvar>
367
+ </respcondition>
368
+ '''
369
+
370
+ ITEM_RESPROCESSING_NUM_INCORRECT_FEEDBACK = ITEM_RESPROCESSING_MCTF_INCORRECT_FEEDBACK
371
+
372
+ ITEM_RESPROCESSING_ESSAY = '''\
373
+ <respcondition continue="No">
374
+ <conditionvar>
375
+ <other/>
376
+ </conditionvar>
377
+ </respcondition>
378
+ '''
379
+
380
+
381
+
382
+ ITEM_RESPROCESSING_END = '''\
383
+ </resprocessing>
384
+ '''
385
+
386
+
387
+ ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_GENERAL = '''\
388
+ <itemfeedback ident="general_fb">
389
+ <flow_mat>
390
+ <material>
391
+ <mattext texttype="text/html">{feedback}</mattext>
392
+ </material>
393
+ </flow_mat>
394
+ </itemfeedback>
395
+ '''
396
+
397
+ ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_CORRECT = '''\
398
+ <itemfeedback ident="correct_fb">
399
+ <flow_mat>
400
+ <material>
401
+ <mattext texttype="text/html">{feedback}</mattext>
402
+ </material>
403
+ </flow_mat>
404
+ </itemfeedback>
405
+ '''
406
+
407
+ ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_INCORRECT = '''\
408
+ <itemfeedback ident="general_incorrect_fb">
409
+ <flow_mat>
410
+ <material>
411
+ <mattext texttype="text/html">{feedback}</mattext>
412
+ </material>
413
+ </flow_mat>
414
+ </itemfeedback>
415
+ '''
416
+
417
+ ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_INDIVIDUAL = '''\
418
+ <itemfeedback ident="{ident}_fb">
419
+ <flow_mat>
420
+ <material>
421
+ <mattext texttype="text/html">{feedback}</mattext>
422
+ </material>
423
+ </flow_mat>
424
+ </itemfeedback>
425
+ '''
426
+
427
+
428
+
429
+
430
+ def assessment(*, quiz: Quiz, assessment_identifier: str, title_xml: str) -> str:
431
+ '''
432
+ Generate assessment XML from Quiz.
433
+ '''
434
+ xml = []
435
+ xml.append(BEFORE_ITEMS.format(assessment_identifier=assessment_identifier,
436
+ title=title_xml))
437
+ for question_or_delim in quiz.questions_and_delims:
438
+ if isinstance(question_or_delim, TextRegion):
439
+ xml.append(TEXT.format(ident=f'text2qti_text_{question_or_delim.id}',
440
+ text_title_xml=question_or_delim.title_xml,
441
+ assessment_question_identifierref=f'text2qti_question_ref_{question_or_delim.id}',
442
+ text_html_xml=question_or_delim.text_html_xml))
443
+ continue
444
+ if isinstance(question_or_delim, GroupStart):
445
+ xml.append(GROUP_START.format(ident=f'text2qti_group_{question_or_delim.group.id}',
446
+ group_title=question_or_delim.group.title_xml,
447
+ pick=question_or_delim.group.pick,
448
+ points_per_item=question_or_delim.group.points_per_question))
449
+ continue
450
+ if isinstance(question_or_delim, GroupEnd):
451
+ xml.append(GROUP_END)
452
+ continue
453
+ if not isinstance(question_or_delim, Question):
454
+ raise TypeError
455
+ question = question_or_delim
456
+
457
+ xml.append(START_ITEM.format(question_identifier=f'text2qti_question_{question.id}',
458
+ question_title=question.title_xml))
459
+
460
+ if question.type in ('true_false_question', 'multiple_choice_question',
461
+ 'short_answer_question', 'multiple_answers_question'):
462
+ item_metadata = ITEM_METADATA_MCTF_SHORTANS_MULTANS_NUM
463
+ original_answer_ids = ','.join(f'text2qti_choice_{c.id}' for c in question.choices)
464
+ elif question.type == 'numerical_question':
465
+ item_metadata = ITEM_METADATA_MCTF_SHORTANS_MULTANS_NUM
466
+ original_answer_ids = f'text2qti_numerical_{question.id}'
467
+ elif question.type == 'essay_question':
468
+ item_metadata = ITEM_METADATA_ESSAY
469
+ original_answer_ids = f'text2qti_essay_{question.id}'
470
+ elif question.type == 'file_upload_question':
471
+ item_metadata = ITEM_METADATA_UPLOAD
472
+ original_answer_ids = f'text2qti_upload_{question.id}'
473
+ else:
474
+ raise ValueError
475
+ xml.append(item_metadata.format(question_type=question.type,
476
+ points_possible=question.points_possible,
477
+ original_answer_ids=original_answer_ids,
478
+ assessment_question_identifierref=f'text2qti_question_ref_{question.id}'))
479
+
480
+ if question.type in ('true_false_question', 'multiple_choice_question', 'multiple_answers_question'):
481
+ if question.type in ('true_false_question', 'multiple_choice_question'):
482
+ item_presentation_choice = ITEM_PRESENTATION_MCTF_CHOICE
483
+ item_presentation = ITEM_PRESENTATION_MCTF
484
+ elif question.type == 'multiple_answers_question':
485
+ item_presentation_choice = ITEM_PRESENTATION_MULTANS_CHOICE
486
+ item_presentation = ITEM_PRESENTATION_MULTANS
487
+ else:
488
+ raise ValueError
489
+ choices = '\n'.join(item_presentation_choice.format(ident=f'text2qti_choice_{c.id}', choice_html_xml=c.choice_html_xml)
490
+ for c in question.choices)
491
+ xml.append(item_presentation.format(question_html_xml=question.question_html_xml, choices=choices))
492
+ elif question.type == 'short_answer_question':
493
+ xml.append(ITEM_PRESENTATION_SHORTANS.format(question_html_xml=question.question_html_xml))
494
+ elif question.type == 'numerical_question':
495
+ xml.append(ITEM_PRESENTATION_NUM.format(question_html_xml=question.question_html_xml))
496
+ elif question.type == 'essay_question':
497
+ xml.append(ITEM_PRESENTATION_ESSAY.format(question_html_xml=question.question_html_xml))
498
+ elif question.type == 'file_upload_question':
499
+ xml.append(ITEM_PRESENTATION_UPLOAD.format(question_html_xml=question.question_html_xml))
500
+ else:
501
+ raise ValueError
502
+
503
+ if question.type in ('true_false_question', 'multiple_choice_question'):
504
+ correct_choice = None
505
+ for choice in question.choices:
506
+ if choice.correct:
507
+ correct_choice = choice
508
+ break
509
+ if correct_choice is None:
510
+ raise TypeError
511
+ resprocessing = []
512
+ resprocessing.append(ITEM_RESPROCESSING_START)
513
+ if question.feedback_raw is not None:
514
+ resprocessing.append(ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK)
515
+ for choice in question.choices:
516
+ if choice.feedback_raw is not None:
517
+ resprocessing.append(ITEM_RESPROCESSING_MCTF_CHOICE_FEEDBACK.format(ident=f'text2qti_choice_{choice.id}'))
518
+ if question.correct_feedback_raw is not None:
519
+ resprocessing.append(ITEM_RESPROCESSING_MCTF_SET_CORRECT_WITH_FEEDBACK.format(ident=f'text2qti_choice_{correct_choice.id}'))
520
+ else:
521
+ resprocessing.append(ITEM_RESPROCESSING_MCTF_SET_CORRECT_NO_FEEDBACK.format(ident=f'text2qti_choice_{correct_choice.id}'))
522
+ if question.incorrect_feedback_raw is not None:
523
+ resprocessing.append(ITEM_RESPROCESSING_MCTF_INCORRECT_FEEDBACK)
524
+ resprocessing.append(ITEM_RESPROCESSING_END)
525
+ xml.extend(resprocessing)
526
+ elif question.type == 'short_answer_question':
527
+ resprocessing = []
528
+ resprocessing.append(ITEM_RESPROCESSING_START)
529
+ if question.feedback_raw is not None:
530
+ resprocessing.append(ITEM_RESPROCESSING_SHORTANS_GENERAL_FEEDBACK)
531
+ for choice in question.choices:
532
+ if choice.feedback_raw is not None:
533
+ resprocessing.append(ITEM_RESPROCESSING_SHORTANS_CHOICE_FEEDBACK.format(ident=f'text2qti_choice_{choice.id}', answer_xml=choice.choice_xml))
534
+ varequal = []
535
+ for choice in question.choices:
536
+ varequal.append(ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_VAREQUAL.format(answer_xml=choice.choice_xml))
537
+ if question.correct_feedback_raw is not None:
538
+ resprocessing.append(ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_WITH_FEEDBACK.format(varequal='\n'.join(varequal)))
539
+ else:
540
+ resprocessing.append(ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_NO_FEEDBACK.format(varequal='\n'.join(varequal)))
541
+ if question.incorrect_feedback_raw is not None:
542
+ resprocessing.append(ITEM_RESPROCESSING_SHORTANS_INCORRECT_FEEDBACK)
543
+ resprocessing.append(ITEM_RESPROCESSING_END)
544
+ xml.extend(resprocessing)
545
+ elif question.type == 'multiple_answers_question':
546
+ resprocessing = []
547
+ resprocessing.append(ITEM_RESPROCESSING_START)
548
+ if question.feedback_raw is not None:
549
+ resprocessing.append(ITEM_RESPROCESSING_MULTANS_GENERAL_FEEDBACK)
550
+ for choice in question.choices:
551
+ if choice.feedback_raw is not None:
552
+ resprocessing.append(ITEM_RESPROCESSING_MULTANS_CHOICE_FEEDBACK.format(ident=f'text2qti_choice_{choice.id}'))
553
+ varequal = []
554
+ for choice in question.choices:
555
+ if choice.correct:
556
+ varequal.append(ITEM_RESPROCESSING_MULTANS_SET_CORRECT_VAREQUAL_CORRECT.format(ident=f'text2qti_choice_{choice.id}'))
557
+ else:
558
+ varequal.append(ITEM_RESPROCESSING_MULTANS_SET_CORRECT_VAREQUAL_INCORRECT.format(ident=f'text2qti_choice_{choice.id}'))
559
+ if question.correct_feedback_raw is not None:
560
+ resprocessing.append(ITEM_RESPROCESSING_MULTANS_SET_CORRECT_WITH_FEEDBACK.format(varequal='\n'.join(varequal)))
561
+ else:
562
+ resprocessing.append(ITEM_RESPROCESSING_MULTANS_SET_CORRECT_NO_FEEDBACK.format(varequal='\n'.join(varequal)))
563
+ if question.incorrect_feedback_raw is not None:
564
+ resprocessing.append(ITEM_RESPROCESSING_MULTANS_INCORRECT_FEEDBACK)
565
+ resprocessing.append(ITEM_RESPROCESSING_END)
566
+ xml.extend(resprocessing)
567
+ elif question.type == 'numerical_question':
568
+ xml.append(ITEM_RESPROCESSING_START)
569
+ if question.feedback_raw is not None:
570
+ xml.append(ITEM_RESPROCESSING_NUM_GENERAL_FEEDBACK)
571
+ if question.correct_feedback_raw is None:
572
+ if question.numerical_exact is None:
573
+ item_resprocessing_num_set_correct = ITEM_RESPROCESSING_NUM_RANGE_SET_CORRECT_NO_FEEDBACK
574
+ else:
575
+ item_resprocessing_num_set_correct = ITEM_RESPROCESSING_NUM_EXACT_SET_CORRECT_NO_FEEDBACK
576
+ else:
577
+ if question.numerical_exact is None:
578
+ item_resprocessing_num_set_correct = ITEM_RESPROCESSING_NUM_RANGE_SET_CORRECT_WITH_FEEDBACK
579
+ else:
580
+ item_resprocessing_num_set_correct = ITEM_RESPROCESSING_NUM_EXACT_SET_CORRECT_WITH_FEEDBACK
581
+ xml.append(item_resprocessing_num_set_correct.format(num_min=question.numerical_min_html_xml,
582
+ num_exact=question.numerical_exact_html_xml,
583
+ num_max=question.numerical_max_html_xml))
584
+ if question.incorrect_feedback_raw is not None:
585
+ xml.append(ITEM_RESPROCESSING_NUM_INCORRECT_FEEDBACK)
586
+ xml.append(ITEM_RESPROCESSING_END)
587
+ elif question.type == 'essay_question':
588
+ xml.append(ITEM_RESPROCESSING_START)
589
+ xml.append(ITEM_RESPROCESSING_ESSAY)
590
+ if question.feedback_raw is not None:
591
+ xml.append(ITEM_RESPROCESSING_ESSAY_GENERAL_FEEDBACK)
592
+ xml.append(ITEM_RESPROCESSING_END)
593
+ elif question.type == 'file_upload_question':
594
+ xml.append(ITEM_RESPROCESSING_START)
595
+ if question.feedback_raw is not None:
596
+ xml.append(ITEM_RESPROCESSING_UPLOAD_GENERAL_FEEDBACK)
597
+ xml.append(ITEM_RESPROCESSING_END)
598
+ else:
599
+ raise ValueError
600
+
601
+ if question.type in ('true_false_question', 'multiple_choice_question',
602
+ 'short_answer_question', 'multiple_answers_question',
603
+ 'numerical_question', 'essay_question', 'file_upload_question'):
604
+ if question.feedback_raw is not None:
605
+ xml.append(ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_GENERAL.format(feedback=question.feedback_html_xml))
606
+ if question.correct_feedback_raw is not None:
607
+ xml.append(ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_CORRECT.format(feedback=question.correct_feedback_html_xml))
608
+ if question.incorrect_feedback_raw is not None:
609
+ xml.append(ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_INCORRECT.format(feedback=question.incorrect_feedback_html_xml))
610
+ if question.type in ('true_false_question', 'multiple_choice_question',
611
+ 'short_answer_question', 'multiple_answers_question'):
612
+ for choice in question.choices:
613
+ if choice.feedback_raw is not None:
614
+ xml.append(ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_INDIVIDUAL.format(ident=f'text2qti_choice_{choice.id}',
615
+ feedback=choice.feedback_html_xml))
616
+
617
+ xml.append(END_ITEM)
618
+
619
+ xml.append(AFTER_ITEMS)
620
+
621
+ return ''.join(xml)
text2qti/xml_assessment_meta.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ from typing import Union
12
+
13
+
14
+ TEMPLATE = '''\
15
+ <?xml version="1.0" encoding="UTF-8"?>
16
+ <quiz identifier="{assessment_identifier}" xmlns="http://canvas.instructure.com/xsd/cccv1p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd">
17
+ <title>{title}</title>
18
+ <description>{description}</description>
19
+ <shuffle_answers>{shuffle_answers}</shuffle_answers>
20
+ <scoring_policy>keep_highest</scoring_policy>
21
+ <hide_results>{hide_results}</hide_results>
22
+ <quiz_type>assignment</quiz_type>
23
+ <points_possible>{points_possible:.1f}</points_possible>
24
+ <require_lockdown_browser>false</require_lockdown_browser>
25
+ <require_lockdown_browser_for_results>false</require_lockdown_browser_for_results>
26
+ <require_lockdown_browser_monitor>false</require_lockdown_browser_monitor>
27
+ <lockdown_browser_monitor_data/>
28
+ <show_correct_answers>{show_correct_answers}</show_correct_answers>
29
+ <anonymous_submissions>false</anonymous_submissions>
30
+ <could_be_locked>false</could_be_locked>
31
+ <allowed_attempts>1</allowed_attempts>
32
+ <one_question_at_a_time>{one_question_at_a_time}</one_question_at_a_time>
33
+ <cant_go_back>{cant_go_back}</cant_go_back>
34
+ <available>false</available>
35
+ <one_time_results>false</one_time_results>
36
+ <show_correct_answers_last_attempt>false</show_correct_answers_last_attempt>
37
+ <only_visible_to_overrides>false</only_visible_to_overrides>
38
+ <module_locked>false</module_locked>
39
+ <assignment identifier="{assignment_identifier}">
40
+ <title>{title}</title>
41
+ <due_at/>
42
+ <lock_at/>
43
+ <unlock_at/>
44
+ <module_locked>false</module_locked>
45
+ <workflow_state>unpublished</workflow_state>
46
+ <assignment_overrides>
47
+ </assignment_overrides>
48
+ <quiz_identifierref>{assessment_identifier}</quiz_identifierref>
49
+ <allowed_extensions></allowed_extensions>
50
+ <has_group_category>false</has_group_category>
51
+ <points_possible>{points_possible:.1f}</points_possible>
52
+ <grading_type>points</grading_type>
53
+ <all_day>false</all_day>
54
+ <submission_types>online_quiz</submission_types>
55
+ <position>1</position>
56
+ <turnitin_enabled>false</turnitin_enabled>
57
+ <vericite_enabled>false</vericite_enabled>
58
+ <peer_review_count>0</peer_review_count>
59
+ <peer_reviews>false</peer_reviews>
60
+ <automatic_peer_reviews>false</automatic_peer_reviews>
61
+ <anonymous_peer_reviews>false</anonymous_peer_reviews>
62
+ <grade_group_students_individually>false</grade_group_students_individually>
63
+ <freeze_on_copy>false</freeze_on_copy>
64
+ <omit_from_final_grade>false</omit_from_final_grade>
65
+ <intra_group_peer_reviews>false</intra_group_peer_reviews>
66
+ <only_visible_to_overrides>false</only_visible_to_overrides>
67
+ <post_to_sis>false</post_to_sis>
68
+ <moderated_grading>false</moderated_grading>
69
+ <grader_count>0</grader_count>
70
+ <grader_comments_visible_to_graders>true</grader_comments_visible_to_graders>
71
+ <anonymous_grading>false</anonymous_grading>
72
+ <graders_anonymous_to_graders>false</graders_anonymous_to_graders>
73
+ <grader_names_visible_to_final_grader>true</grader_names_visible_to_final_grader>
74
+ <anonymous_instructor_annotations>false</anonymous_instructor_annotations>
75
+ <post_policy>
76
+ <post_manually>false</post_manually>
77
+ </post_policy>
78
+ </assignment>
79
+ <assignment_group_identifierref>{assignment_group_identifier}</assignment_group_identifierref>
80
+ <assignment_overrides>
81
+ </assignment_overrides>
82
+ </quiz>
83
+ '''
84
+
85
+
86
+ def assessment_meta(*,
87
+ assessment_identifier: str,
88
+ assignment_group_identifier: str,
89
+ assignment_identifier: str,
90
+ title_xml: str,
91
+ description_html_xml: str,
92
+ points_possible: Union[int, float],
93
+ shuffle_answers: str,
94
+ show_correct_answers: str,
95
+ one_question_at_a_time: str,
96
+ cant_go_back: str) -> str:
97
+ '''
98
+ Generate `assessment_meta.xml`.
99
+ '''
100
+ return TEMPLATE.format(assessment_identifier=assessment_identifier,
101
+ assignment_identifier=assignment_identifier,
102
+ assignment_group_identifier=assignment_group_identifier,
103
+ title=title_xml,
104
+ description=description_html_xml,
105
+ points_possible=points_possible,
106
+ shuffle_answers=shuffle_answers,
107
+ show_correct_answers=show_correct_answers,
108
+ hide_results='always' if show_correct_answers == 'false' else '',
109
+ one_question_at_a_time=one_question_at_a_time,
110
+ cant_go_back=cant_go_back)
text2qti/xml_imsmanifest.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2020, Geoffrey M. Poore
4
+ # All rights reserved.
5
+ #
6
+ # Licensed under the BSD 3-Clause License:
7
+ # http://opensource.org/licenses/BSD-3-Clause
8
+ #
9
+
10
+
11
+ import datetime
12
+ from typing import Dict, Optional
13
+ from .quiz import Image
14
+
15
+
16
+ MANIFEST_START = '''\
17
+ <?xml version="1.0" encoding="UTF-8"?>
18
+ <manifest identifier="{manifest_identifier}" xmlns="http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1" xmlns:lom="http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource" xmlns:imsmd="http://www.imsglobal.org/xsd/imsmd_v1p2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 http://www.imsglobal.org/xsd/imscp_v1p1.xsd http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd http://www.imsglobal.org/xsd/imsmd_v1p2 http://www.imsglobal.org/xsd/imsmd_v1p2p2.xsd">
19
+ <metadata>
20
+ <schema>IMS Content</schema>
21
+ <schemaversion>1.1.3</schemaversion>
22
+ <imsmd:lom>
23
+ <imsmd:general>
24
+ <imsmd:title>
25
+ <imsmd:string>QTI assessment generated by text2qti</imsmd:string>
26
+ </imsmd:title>
27
+ </imsmd:general>
28
+ <imsmd:lifeCycle>
29
+ <imsmd:contribute>
30
+ <imsmd:date>
31
+ <imsmd:dateTime>{date}</imsmd:dateTime>
32
+ </imsmd:date>
33
+ </imsmd:contribute>
34
+ </imsmd:lifeCycle>
35
+ <imsmd:rights>
36
+ <imsmd:copyrightAndOtherRestrictions>
37
+ <imsmd:value>yes</imsmd:value>
38
+ </imsmd:copyrightAndOtherRestrictions>
39
+ <imsmd:description>
40
+ <imsmd:string>Private (Copyrighted) - http://en.wikipedia.org/wiki/Copyright</imsmd:string>
41
+ </imsmd:description>
42
+ </imsmd:rights>
43
+ </imsmd:lom>
44
+ </metadata>
45
+ <organizations/>
46
+ <resources>
47
+ <resource identifier="{assessment_identifier}" type="imsqti_xmlv1p2">
48
+ <file href="{assessment_identifier}/{assessment_identifier}.xml"/>
49
+ <dependency identifierref="{dependency_identifier}"/>
50
+ </resource>
51
+ <resource identifier="{dependency_identifier}" type="associatedcontent/imscc_xmlv1p1/learning-application-resource" href="{assessment_identifier}/assessment_meta.xml">
52
+ <file href="{assessment_identifier}/assessment_meta.xml"/>
53
+ </resource>
54
+ '''
55
+
56
+ IMAGE = '''\
57
+ <resource identifier="text2qti_image_{ident}" type="webcontent" href="{path}">
58
+ <file href="{path}"/>
59
+ </resource>
60
+ '''
61
+
62
+ MANIFEST_END = '''\
63
+ </resources>
64
+ </manifest>
65
+ '''
66
+
67
+
68
+ def imsmanifest(*,
69
+ manifest_identifier: str,
70
+ assessment_identifier: str,
71
+ dependency_identifier: str,
72
+ images: Dict[str, Image],
73
+ date: Optional[str]=None) -> str:
74
+ '''
75
+ Generate `imsmanifest.xml`.
76
+ '''
77
+ if date is None:
78
+ date = str(datetime.date.today())
79
+ xml = []
80
+ xml.append(MANIFEST_START.format(manifest_identifier=manifest_identifier,
81
+ assessment_identifier=assessment_identifier,
82
+ dependency_identifier=dependency_identifier,
83
+ date=date))
84
+ for image in images.values():
85
+ xml.append(IMAGE.format(ident=image.id, path=image.qti_xml_path))
86
+ xml.append(MANIFEST_END)
87
+ return ''.join(xml)