Skip to content

Commit

Permalink
- image.py
Browse files Browse the repository at this point in the history
	- 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
  • Loading branch information
manuvazquez committed Apr 25, 2020
1 parent 9e6d138 commit 65a8b33
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 35 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,14 @@ Only formulas inside `$`s are processed (no, e.g., `\textit` or `\textbf` inside
- `\left(` and `\right)`
- `\left[` and `\right]`
- `\begin{bmatrix}` and `\end{bmatrix}`
- symbols `\sim`
- symbols `\sim`, `\approx`

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

### Safety checks

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.

## Current limitations

- only *numerical* and *multiple-choice* questions are supported (notice that the GIFT format itself doesn't support every type of question available in Moodle)
Expand Down
25 changes: 23 additions & 2 deletions gift.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import re

import latex


class NotCompliantLatexFormula(Exception):

def __init__(self, formula: str) -> None:

self.formula = formula

def __str__(self) -> str:

return self.formula


html = '[html]'

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


def process_latex(text: str) -> str:
def process_latex(text: str, check_compliance: bool = True) -> str:
"""
Adapts every occurrence of $$ to GIFT.
Parameters
----------
text : str
Input text.
check_compliance: bool
Whether or not to check if the formula can be compiled.
Returns
-------
Expand All @@ -125,6 +140,12 @@ def replacement(m: re.Match) -> str:

latex_source = m.group(1)

if check_compliance:

if not latex.formula_can_be_compiled(latex_source):

raise NotCompliantLatexFormula(latex_source)

for to_be_escaped in ['\\', '{', '}', '=']:

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

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


def process_url_images(text: str, width: int, height: int) -> str:
Expand Down
23 changes: 9 additions & 14 deletions image.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import pathlib
import shutil
import subprocess
from typing import Union, Optional
from typing import Union

import colors

import latex

def compile_tex(source_file: Union[str, pathlib.Path], timeout: int = 10) -> pathlib.Path:

def tex_to_pdf(source_file: Union[str, pathlib.Path], timeout: int = 10) -> pathlib.Path:
"""
Compiles a TeX file.
Parameters
----------
source_file : str or pathlib.Path
Tex file.
TeX file.
timeout: int
Seconds that are given to compile the source.
Expand All @@ -26,27 +28,20 @@ def compile_tex(source_file: Union[str, pathlib.Path], timeout: int = 10) -> pat

source_file = pathlib.Path(source_file)

path_to_compiler = shutil.which('pdflatex')

assert path_to_compiler, 'cannot find pdflatex'

command = [path_to_compiler, '-halt-on-error', source_file.name]
# command = [path_to_compiler, r'-interaction=nonstopmode', source_file.name]

try:

run_summary = subprocess.run(command, capture_output=True, cwd=source_file.parent, timeout=timeout)
exit_status = latex.compile_tex(source_file, timeout=timeout)

except subprocess.TimeoutExpired:

print(
f'{colors.error}could not compile {colors.reset}{source_file}'
f' in {colors.reset}{timeout}{colors.error} seconds...probably some bug in the code'
f'{colors.error}could not compile {colors.reset}{source_file + ".tex"}'
f' {colors.error}in {colors.reset}{timeout}{colors.error} seconds'
)

raise SystemExit

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

return source_file.with_suffix('.pdf')

Expand Down
83 changes: 83 additions & 0 deletions latex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import pathlib
import shutil
import subprocess
import string
from typing import Union, List, Optional


def compile_tex(
source_file: Union[str, pathlib.Path], timeout: Optional[int], options: List[str] = ['halt-on-error']) -> int:
"""
Compiles a TeX file.
Parameters
----------
source_file : str or pathlib.Path
TeX file.
timeout: int
Seconds that are given to compile the source.
options: list of str
Options to be passed to `pdflatex`.
Returns
-------
out: int
The exit status of the call to `pdflatex`.
"""

source_file = pathlib.Path(source_file)

path_to_compiler = shutil.which('pdflatex')

assert path_to_compiler, 'cannot find pdflatex'

command = [path_to_compiler] + [f'-{o}' for o in options] + [source_file.name]

run_summary = subprocess.run(command, capture_output=True, cwd=source_file.parent, timeout=timeout)

return run_summary.returncode


latex_template = string.Template(r'''
\documentclass{standalone}
\usepackage{amsmath}
\begin{document}
$$
$formula
$$
\end{document}
''')


def formula_can_be_compiled(formula: str, auxiliary_file: str = '__latex_check.tex') -> bool:
"""
Checks whether a latex formula can be compiled with the above template, `latex_template`.
Parameters
----------
formula : str
Latex formula.
auxiliary_file : str
(Auxiliary) TeX file that is created to check the formula.
Returns
-------
out: bool
`True` if the compilation finished with no errors.
"""

tex_source_code = latex_template.substitute(formula=formula)

with open(auxiliary_file, 'w') as f:

f.write(tex_source_code)

exit_status = compile_tex(auxiliary_file, timeout=10, options=['halt-on-error', 'draftmode'])

return exit_status == 0
63 changes: 53 additions & 10 deletions question.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@
import gift
import image
import remote
import colors


class HtmlQuestion(metaclass=abc.ABCMeta):
"""
Abstract class implementing an html-based question.
"""

def __init__(self, name: str, statement: str, images_settings: dict, history: dict, feedback: Optional[str] = None):
def __init__(
self, name: str, statement: str, images_settings: dict, history: dict, check_latex_formulas: bool,
feedback: Optional[str] = None):
"""
Initializer.
Expand Down Expand Up @@ -42,7 +45,7 @@ def __init__(self, name: str, statement: str, images_settings: dict, history: di

self.processing_functions = [
functools.partial(gift.process_url_images, width=self.images_width, height=self.images_height),
gift.process_new_lines, gift.process_latex
gift.process_new_lines, functools.partial(gift.process_latex, check_compliance=check_latex_formulas)
]

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

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

text = function(text)
try:

text = function(text)

except gift.NotCompliantLatexFormula as e:

print(
f'\n{colors.error}cannot compile latex formula\n {colors.extra_info}{e.formula}{colors.reset} in '
f'{colors.info}{self.name}')

raise SystemExit

return text

Expand Down Expand Up @@ -116,10 +129,10 @@ class Numerical(HtmlQuestion):
"""

def __init__(
self, name: str, statement: str, images_settings: dict, history: dict, solution: dict,
feedback: Optional[str] = None):
self, name: str, statement: str, images_settings: dict, history: dict, check_latex_formulas: bool,
solution: dict, feedback: Optional[str] = None):

super().__init__(name, statement, images_settings, history, feedback)
super().__init__(name, statement, images_settings, history, check_latex_formulas, feedback)

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

Expand All @@ -144,10 +157,10 @@ class MultipleChoice(HtmlQuestion):
"""

def __init__(
self, name: str, statement: str, images_settings: dict, history: dict, answers: dict,
feedback: Optional[str] = None):
self, name: str, statement: str, images_settings: dict, history: dict, check_latex_formulas: bool,
answers: dict, feedback: Optional[str] = None):

super().__init__(name, statement, images_settings, history, feedback)
super().__init__(name, statement, images_settings, history, check_latex_formulas, feedback)

self.answers = answers

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

class QuestionDecorator:
"""
Abstract class to implement a question decorator.
"""

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

Expand All @@ -199,6 +215,27 @@ def __setattr__(self, key, value):
def transform_files(
text: str, pattern: str, process_match: Callable[[str], None],
replacement: Union[str, Callable[[re.Match], str]]):
"""
It searches in a text for strings corresponding to files (maybe including a path), replaces them by another
string according to some function and, additionally, processes each file according to another function.
Parameters
----------
text : str
Input text.
pattern : str
Regular expression including a capturing group that yields the file.
process_match : Callable[[str], None]
Function that will *process* each file.
replacement : str or Callable[[re.Match], str]
Regular expression making use of the capturing group or function processing the match delivered by `pattern`
Returns
-------
out: str
Output text with the replacements performed, after having processed all the files.
"""

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


class TexToSvg(QuestionDecorator):
"""
Decorator to converts TeX files into svg files.
"""

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

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

# ...it is...
image.pdf_to_svg(image.compile_tex(f))
image.pdf_to_svg(image.tex_to_pdf(f))

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


class SvgToHttp(QuestionDecorator):
"""
Decorator to transfer svg files to a remote location.
"""

def __init__(
self, decorated: Union[HtmlQuestion, QuestionDecorator], connection: remote.Connection,
Expand Down
11 changes: 7 additions & 4 deletions remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ def __init__(self, host: str, user: str, password: str, public_key: Union[str, p

except paramiko.ssh_exception.AuthenticationException:

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

raise SystemExit

except paramiko.ssh_exception.SSHException:

print(f'the provided public key ({public_key}) is not valid or has not been decrypted')
print(
f'{colors.error}the provided public key {colors.reset}({public_key}){colors.error}'
f' is not valid or has not been decrypted')

raise SystemExit

Expand Down Expand Up @@ -112,8 +114,9 @@ class FakeConnection:
For offline runs.
"""

def __init__(self) -> None:
def __init__(self, host: str) -> None:

self.host = host
self.already_copied = set()

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

print(
f'{colors.info}you *should* copy {colors.reset}{source}{colors.info} to'
f' {colors.reset}{remote_directory}')
f' {colors.reset}{remote_directory}{colors.info} in {colors.reset}{self.host}')

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

Expand Down
Loading

0 comments on commit 65a8b33

Please sign in to comment.