Skip to content

Commit 65a8b33

Browse files
committed
- image.py
- compile_tex -> tex_to_pdf - most code moved to... - ...new module "latex.py" - gift.py - process_latex - an exception is raised if a formula cannot be compiled... - question.py - HtmlQuestion - process_text - ...the exception is caught - wrap.py - new command-line option "no-checks" - minor improvements - more documentation
1 parent 9e6d138 commit 65a8b33

File tree

7 files changed

+191
-35
lines changed

7 files changed

+191
-35
lines changed

README.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,14 @@ Only formulas inside `$`s are processed (no, e.g., `\textit` or `\textbf` inside
105105
- `\left(` and `\right)`
106106
- `\left[` and `\right]`
107107
- `\begin{bmatrix}` and `\end{bmatrix}`
108-
- symbols `\sim`
108+
- symbols `\sim`, `\approx`
109109

110110
More things are probably ok, but I have not tried them yet.
111111

112+
### Safety checks
113+
114+
By default, `wrap` checks whether or not the formulas you wrote between `$`'s can actually be compiled. Right now this involves a call to `pdflatex` *for every formula*, meaning that it can significantly slow down the process. It can be disabled by passing ` --no-checks` (or simply `-n`). It is probably a good idea to actually check the formulas every once in a while (e.g., every time you add a new one), though, since *bad* latex formulas will be (silently) imported by Moodle anyway, and not only will they be incorrectly rendered but they may also mess up subsequent content.
115+
112116
## Current limitations
113117

114118
- only *numerical* and *multiple-choice* questions are supported (notice that the GIFT format itself doesn't support every type of question available in Moodle)

gift.py

+23-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
import re
22

3+
import latex
4+
5+
6+
class NotCompliantLatexFormula(Exception):
7+
8+
def __init__(self, formula: str) -> None:
9+
10+
self.formula = formula
11+
12+
def __str__(self) -> str:
13+
14+
return self.formula
15+
316

417
html = '[html]'
518

@@ -105,14 +118,16 @@ def from_feedback(text: str) -> str:
105118
return '#'*4 + text
106119

107120

108-
def process_latex(text: str) -> str:
121+
def process_latex(text: str, check_compliance: bool = True) -> str:
109122
"""
110123
Adapts every occurrence of $$ to GIFT.
111124
112125
Parameters
113126
----------
114127
text : str
115128
Input text.
129+
check_compliance: bool
130+
Whether or not to check if the formula can be compiled.
116131
117132
Returns
118133
-------
@@ -125,6 +140,12 @@ def replacement(m: re.Match) -> str:
125140

126141
latex_source = m.group(1)
127142

143+
if check_compliance:
144+
145+
if not latex.formula_can_be_compiled(latex_source):
146+
147+
raise NotCompliantLatexFormula(latex_source)
148+
128149
for to_be_escaped in ['\\', '{', '}', '=']:
129150

130151
latex_source = latex_source.replace(to_be_escaped, '\\' + to_be_escaped)
@@ -134,7 +155,7 @@ def replacement(m: re.Match) -> str:
134155
return r'\\(' + latex_source + r'\\)'
135156

136157
# it looks for strings between $'s (that do not include $ itself) and wraps them in \( and \)
137-
return re.sub('\$([^\$]*)\$', replacement, text)
158+
return re.sub(r'\$([^\$]*)\$', replacement, text)
138159

139160

140161
def process_url_images(text: str, width: int, height: int) -> str:

image.py

+9-14
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import pathlib
22
import shutil
33
import subprocess
4-
from typing import Union, Optional
4+
from typing import Union
55

66
import colors
77

8+
import latex
89

9-
def compile_tex(source_file: Union[str, pathlib.Path], timeout: int = 10) -> pathlib.Path:
10+
11+
def tex_to_pdf(source_file: Union[str, pathlib.Path], timeout: int = 10) -> pathlib.Path:
1012
"""
1113
Compiles a TeX file.
1214
1315
Parameters
1416
----------
1517
source_file : str or pathlib.Path
16-
Tex file.
18+
TeX file.
1719
timeout: int
1820
Seconds that are given to compile the source.
1921
@@ -26,27 +28,20 @@ def compile_tex(source_file: Union[str, pathlib.Path], timeout: int = 10) -> pat
2628

2729
source_file = pathlib.Path(source_file)
2830

29-
path_to_compiler = shutil.which('pdflatex')
30-
31-
assert path_to_compiler, 'cannot find pdflatex'
32-
33-
command = [path_to_compiler, '-halt-on-error', source_file.name]
34-
# command = [path_to_compiler, r'-interaction=nonstopmode', source_file.name]
35-
3631
try:
3732

38-
run_summary = subprocess.run(command, capture_output=True, cwd=source_file.parent, timeout=timeout)
33+
exit_status = latex.compile_tex(source_file, timeout=timeout)
3934

4035
except subprocess.TimeoutExpired:
4136

4237
print(
43-
f'{colors.error}could not compile {colors.reset}{source_file}'
44-
f' in {colors.reset}{timeout}{colors.error} seconds...probably some bug in the code'
38+
f'{colors.error}could not compile {colors.reset}{source_file + ".tex"}'
39+
f' {colors.error}in {colors.reset}{timeout}{colors.error} seconds'
4540
)
4641

4742
raise SystemExit
4843

49-
assert run_summary.returncode == 0, f'{colors.error}errors were found while compiling {colors.reset}{source_file}'
44+
assert exit_status == 0, f'{colors.error}errors were found while compiling {colors.reset}{source_file}'
5045

5146
return source_file.with_suffix('.pdf')
5247

latex.py

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import pathlib
2+
import shutil
3+
import subprocess
4+
import string
5+
from typing import Union, List, Optional
6+
7+
8+
def compile_tex(
9+
source_file: Union[str, pathlib.Path], timeout: Optional[int], options: List[str] = ['halt-on-error']) -> int:
10+
"""
11+
Compiles a TeX file.
12+
13+
Parameters
14+
----------
15+
source_file : str or pathlib.Path
16+
TeX file.
17+
timeout: int
18+
Seconds that are given to compile the source.
19+
options: list of str
20+
Options to be passed to `pdflatex`.
21+
22+
Returns
23+
-------
24+
out: int
25+
The exit status of the call to `pdflatex`.
26+
27+
"""
28+
29+
source_file = pathlib.Path(source_file)
30+
31+
path_to_compiler = shutil.which('pdflatex')
32+
33+
assert path_to_compiler, 'cannot find pdflatex'
34+
35+
command = [path_to_compiler] + [f'-{o}' for o in options] + [source_file.name]
36+
37+
run_summary = subprocess.run(command, capture_output=True, cwd=source_file.parent, timeout=timeout)
38+
39+
return run_summary.returncode
40+
41+
42+
latex_template = string.Template(r'''
43+
\documentclass{standalone}
44+
45+
\usepackage{amsmath}
46+
47+
\begin{document}
48+
49+
$$
50+
$formula
51+
$$
52+
53+
\end{document}
54+
''')
55+
56+
57+
def formula_can_be_compiled(formula: str, auxiliary_file: str = '__latex_check.tex') -> bool:
58+
"""
59+
Checks whether a latex formula can be compiled with the above template, `latex_template`.
60+
61+
Parameters
62+
----------
63+
formula : str
64+
Latex formula.
65+
auxiliary_file : str
66+
(Auxiliary) TeX file that is created to check the formula.
67+
68+
Returns
69+
-------
70+
out: bool
71+
`True` if the compilation finished with no errors.
72+
73+
"""
74+
75+
tex_source_code = latex_template.substitute(formula=formula)
76+
77+
with open(auxiliary_file, 'w') as f:
78+
79+
f.write(tex_source_code)
80+
81+
exit_status = compile_tex(auxiliary_file, timeout=10, options=['halt-on-error', 'draftmode'])
82+
83+
return exit_status == 0

question.py

+53-10
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77
import gift
88
import image
99
import remote
10+
import colors
1011

1112

1213
class HtmlQuestion(metaclass=abc.ABCMeta):
1314
"""
1415
Abstract class implementing an html-based question.
1516
"""
1617

17-
def __init__(self, name: str, statement: str, images_settings: dict, history: dict, feedback: Optional[str] = None):
18+
def __init__(
19+
self, name: str, statement: str, images_settings: dict, history: dict, check_latex_formulas: bool,
20+
feedback: Optional[str] = None):
1821
"""
1922
Initializer.
2023
@@ -42,7 +45,7 @@ def __init__(self, name: str, statement: str, images_settings: dict, history: di
4245

4346
self.processing_functions = [
4447
functools.partial(gift.process_url_images, width=self.images_width, height=self.images_height),
45-
gift.process_new_lines, gift.process_latex
48+
gift.process_new_lines, functools.partial(gift.process_latex, check_compliance=check_latex_formulas)
4649
]
4750

4851
# this might be tampered with by subclasses/decorator
@@ -66,7 +69,17 @@ def process_text(self, text: str) -> str:
6669

6770
for function in (self.pre_processing_functions + self.processing_functions):
6871

69-
text = function(text)
72+
try:
73+
74+
text = function(text)
75+
76+
except gift.NotCompliantLatexFormula as e:
77+
78+
print(
79+
f'\n{colors.error}cannot compile latex formula\n {colors.extra_info}{e.formula}{colors.reset} in '
80+
f'{colors.info}{self.name}')
81+
82+
raise SystemExit
7083

7184
return text
7285

@@ -116,10 +129,10 @@ class Numerical(HtmlQuestion):
116129
"""
117130

118131
def __init__(
119-
self, name: str, statement: str, images_settings: dict, history: dict, solution: dict,
120-
feedback: Optional[str] = None):
132+
self, name: str, statement: str, images_settings: dict, history: dict, check_latex_formulas: bool,
133+
solution: dict, feedback: Optional[str] = None):
121134

122-
super().__init__(name, statement, images_settings, history, feedback)
135+
super().__init__(name, statement, images_settings, history, check_latex_formulas, feedback)
123136

124137
assert ('value' in solution), '"value" missing in "solution"'
125138

@@ -144,10 +157,10 @@ class MultipleChoice(HtmlQuestion):
144157
"""
145158

146159
def __init__(
147-
self, name: str, statement: str, images_settings: dict, history: dict, answers: dict,
148-
feedback: Optional[str] = None):
160+
self, name: str, statement: str, images_settings: dict, history: dict, check_latex_formulas: bool,
161+
answers: dict, feedback: Optional[str] = None):
149162

150-
super().__init__(name, statement, images_settings, history, feedback)
163+
super().__init__(name, statement, images_settings, history, check_latex_formulas, feedback)
151164

152165
self.answers = answers
153166

@@ -174,6 +187,9 @@ def answer(self):
174187
# ========================================== Decorators
175188

176189
class QuestionDecorator:
190+
"""
191+
Abstract class to implement a question decorator.
192+
"""
177193

178194
def __init__(self, decorated: Union[HtmlQuestion, 'QuestionDecorator']):
179195

@@ -199,6 +215,27 @@ def __setattr__(self, key, value):
199215
def transform_files(
200216
text: str, pattern: str, process_match: Callable[[str], None],
201217
replacement: Union[str, Callable[[re.Match], str]]):
218+
"""
219+
It searches in a text for strings corresponding to files (maybe including a path), replaces them by another
220+
string according to some function and, additionally, processes each file according to another function.
221+
222+
Parameters
223+
----------
224+
text : str
225+
Input text.
226+
pattern : str
227+
Regular expression including a capturing group that yields the file.
228+
process_match : Callable[[str], None]
229+
Function that will *process* each file.
230+
replacement : str or Callable[[re.Match], str]
231+
Regular expression making use of the capturing group or function processing the match delivered by `pattern`
232+
233+
Returns
234+
-------
235+
out: str
236+
Output text with the replacements performed, after having processed all the files.
237+
238+
"""
202239

203240
# all the matching files in the given text
204241
files = re.findall(pattern, text)
@@ -214,6 +251,9 @@ def transform_files(
214251

215252

216253
class TexToSvg(QuestionDecorator):
254+
"""
255+
Decorator to converts TeX files into svg files.
256+
"""
217257

218258
def __init__(self, decorated: Union[HtmlQuestion, QuestionDecorator]):
219259

@@ -225,7 +265,7 @@ def process_match(f):
225265
if f not in self.history['already compiled']:
226266

227267
# ...it is...
228-
image.pdf_to_svg(image.compile_tex(f))
268+
image.pdf_to_svg(image.tex_to_pdf(f))
229269

230270
# ...and a note is made of it
231271
self.history['already compiled'].add(f)
@@ -241,6 +281,9 @@ def process_match(f):
241281

242282

243283
class SvgToHttp(QuestionDecorator):
284+
"""
285+
Decorator to transfer svg files to a remote location.
286+
"""
244287

245288
def __init__(
246289
self, decorated: Union[HtmlQuestion, QuestionDecorator], connection: remote.Connection,

remote.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,15 @@ def __init__(self, host: str, user: str, password: str, public_key: Union[str, p
5151

5252
except paramiko.ssh_exception.AuthenticationException:
5353

54-
print(f'provided username ({user}) and/or password are not valid')
54+
print(f'{colors.error}provided username {colors.reset}({user}){colors.error} and/or password are not valid')
5555

5656
raise SystemExit
5757

5858
except paramiko.ssh_exception.SSHException:
5959

60-
print(f'the provided public key ({public_key}) is not valid or has not been decrypted')
60+
print(
61+
f'{colors.error}the provided public key {colors.reset}({public_key}){colors.error}'
62+
f' is not valid or has not been decrypted')
6163

6264
raise SystemExit
6365

@@ -112,8 +114,9 @@ class FakeConnection:
112114
For offline runs.
113115
"""
114116

115-
def __init__(self) -> None:
117+
def __init__(self, host: str) -> None:
116118

119+
self.host = host
117120
self.already_copied = set()
118121

119122
@staticmethod
@@ -129,7 +132,7 @@ def copy(self, source: Union[str, pathlib.Path], remote_directory: str):
129132

130133
print(
131134
f'{colors.info}you *should* copy {colors.reset}{source}{colors.info} to'
132-
f' {colors.reset}{remote_directory}')
135+
f' {colors.reset}{remote_directory}{colors.info} in {colors.reset}{self.host}')
133136

134137
self.already_copied.add(source.as_posix())
135138

0 commit comments

Comments
 (0)