Skip to content

Commit 87be09d

Browse files
committed
implement: Make pandoc and the filters optional (t9)
2 parents cc16c1d + 5383e46 commit 87be09d

13 files changed

Lines changed: 529 additions & 36 deletions

File tree

docs/source/filters.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import importlib
22
import os
3-
import pkgutil
43
import shutil
54
import subprocess
65
from pathlib import Path
@@ -19,13 +18,7 @@ def generate_filters_docs():
1918
autosummary_directory.mkdir(exist_ok=True, parents=True)
2019
static_pdf_directory.mkdir(exist_ok=True)
2120

22-
filters = (
23-
i.name
24-
for i in pkgutil.iter_modules(in2lambda.filters.__path__)
25-
if i.name != "markdown"
26-
)
27-
28-
for filter_name in filters:
21+
for filter_name in in2lambda.filters.builtin_filters():
2922
filter_module = importlib.import_module(
3023
f"in2lambda.filters.{filter_name}.filter"
3124
)

in2lambda/api/set.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,42 @@ def to_json(self, output_dir: str) -> None:
136136

137137
json_convert.main(self, output_dir)
138138

139+
@classmethod
140+
def from_json(cls, path: str) -> "Set":
141+
"""Loads a Lambda Feedback export, as a folder or a zip, into a Set.
142+
143+
Only what the Set holds is read: the name, description, visibilities, and each
144+
question's title, main text, parts, worked solutions and images. A zip is
145+
extracted to a temporary directory that is not removed afterwards, because the
146+
loaded images point into it.
147+
148+
Args:
149+
path: The exported set's folder or zip.
150+
151+
Returns:
152+
The loaded set.
153+
154+
Raises:
155+
ValueError: If the export does not hold exactly one ``set_*.json``.
156+
157+
Examples:
158+
>>> import tempfile
159+
>>> s = Set()
160+
>>> s.add_question("Question 1")
161+
>>> s.add_question("Question 2")
162+
>>> with tempfile.TemporaryDirectory() as temp_dir:
163+
... s.to_json(temp_dir)
164+
... from_folder = Set.from_json(f"{temp_dir}/set")
165+
... from_zip = Set.from_json(f"{temp_dir}/set.zip")
166+
>>> [question.title for question in from_folder.questions]
167+
['Question 1', 'Question 2']
168+
>>> [question.title for question in from_zip.questions]
169+
['Question 1', 'Question 2']
170+
"""
171+
from in2lambda.json_convert import json_convert
172+
173+
return json_convert.load(path)
174+
139175
def set_name(self, name: str) -> None:
140176
"""Sets the name of the set.
141177

in2lambda/filters/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,19 @@
11
"""Subject specific panflute filters for parsing LaTeX documents."""
2+
3+
import pkgutil
4+
5+
6+
def builtin_filters() -> list[str]:
7+
"""Lists the filters shipped with in2lambda.
8+
9+
Each filter is a subpackage of this one; ``markdown`` is a helper module they share.
10+
11+
Returns:
12+
The filter names, as accepted by :func:`in2lambda.main.runner`.
13+
14+
Examples:
15+
>>> from in2lambda.filters import builtin_filters
16+
>>> "PartsSepSol" in builtin_filters()
17+
True
18+
"""
19+
return [i.name for i in pkgutil.iter_modules(__path__) if i.name != "markdown"]

in2lambda/json_convert/json_convert.py

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
"""Converts questions from a Python set object into Lambda Feedback JSON."""
1+
"""Converts questions between a Python set object and Lambda Feedback JSON."""
22

33
import json
44
import os
55
import re
66
import shutil
7+
import tempfile
78
import zipfile
89
from copy import deepcopy
910
from pathlib import Path
1011
from typing import Any
1112

13+
from in2lambda.api.part import Part
14+
from in2lambda.api.question import Question
1215
from in2lambda.api.set import Set
16+
from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus
1317

1418
MINIMAL_QUESTION_TEMPLATE = "minimal_template_question.json"
1519
MINIMAL_SET_TEMPLATE = "minimal_template_set.json"
@@ -96,12 +100,14 @@ def converter(
96100
ListQuestions[i].parts[j].worked_solution
97101
)
98102

99-
# Output file
103+
# Lambda Feedback names the file after the title with only spaces made
104+
# underscores. Path separators go too, so a title cannot leave the set folder,
105+
# and so do the characters Windows forbids in file names.
100106
filename = (
101107
"question_"
102108
+ str(i).zfill(3)
103109
+ "_"
104-
+ re.sub(r"[^\w\-_.]", "_", output["title"].strip())
110+
+ re.sub(r'[\s/\\<>:"|?*]', "_", output["title"].strip())
105111
)
106112

107113
# write questions into directory
@@ -145,3 +151,84 @@ def main(set_questions: Set, output_dir: str) -> None:
145151
except OSError as e:
146152
print("Error: %s : %s" % (output_dir, e.strerror))
147153
converter(question_template, set_template, set_questions, output_dir)
154+
155+
156+
def load(path: str) -> Set:
157+
"""Reads a Lambda Feedback export into a Set, keeping only what the model holds.
158+
159+
A zip is extracted to a new temporary directory, which is left for the operating
160+
system to clear: the loaded images point into it and must still exist when the
161+
set is written out.
162+
163+
Args:
164+
path: An exported set, as a folder or a zip, with or without a top-level folder.
165+
166+
Returns:
167+
The set, with each question's images as absolute paths into ``media/``.
168+
169+
Raises:
170+
ValueError: If the export does not hold exactly one ``set_*.json``.
171+
"""
172+
root = Path(path)
173+
if root.suffix == ".zip":
174+
extracted = tempfile.mkdtemp(prefix="in2lambda-")
175+
with zipfile.ZipFile(root) as zf:
176+
zf.extractall(extracted)
177+
root = Path(extracted)
178+
179+
set_files = list(root.rglob("set_*.json"))
180+
if len(set_files) != 1:
181+
raise ValueError(f"Expected one set_*.json in {path}, found {len(set_files)}")
182+
(set_file,) = set_files
183+
export_dir = set_file.parent
184+
185+
set_json = json.loads(set_file.read_text())
186+
question_set = Set(
187+
_name=set_json["name"],
188+
_description=set_json["description"],
189+
_finalAnswerVisibility=VisibilityController(
190+
VisibilityStatus(set_json["finalAnswerVisibility"])
191+
),
192+
_workedSolutionVisibility=VisibilityController(
193+
VisibilityStatus(set_json["workedSolutionVisibility"])
194+
),
195+
_structuredTutorialVisibility=VisibilityController(
196+
VisibilityStatus(set_json["structuredTutorialVisibility"])
197+
),
198+
)
199+
200+
question_files = sorted(
201+
export_dir.glob("question_*.json"),
202+
key=lambda file: json.loads(file.read_text())["orderNumber"],
203+
)
204+
media = sorted((export_dir / "media").glob("*"))
205+
for question_file in question_files:
206+
question_json = json.loads(question_file.read_text())
207+
parts = [
208+
Part(
209+
text=part["content"],
210+
worked_solution=(
211+
part["workedSolution"]["content"]
212+
if "workedSolution" in part
213+
else ""
214+
),
215+
)
216+
for part in question_json["parts"]
217+
]
218+
question_set.questions.append(
219+
Question(
220+
title=question_json["title"],
221+
main_text=question_json["masterContent"],
222+
parts=parts,
223+
images=[
224+
str(image)
225+
for image in media
226+
if image.name.startswith(f"{question_file.stem}_")
227+
],
228+
# Every loaded part already has its text and solution, so further
229+
# add_part_text/add_solution calls must add parts after them rather
230+
# than overwrite the first.
231+
_last_part={"solution": len(parts), "text": len(parts)},
232+
)
233+
)
234+
return question_set

in2lambda/json_convert/minimal_template_question.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
"answerContent": "",
1515
"responseAreas": [],
1616
"workedSolution": {
17-
"title": "",
1817
"content": "Part worked solution here",
1918
"children": []
2019
}

in2lambda/main.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import importlib
1010
import importlib.util
11-
import pkgutil
1211
import shutil
1312
import subprocess
1413
from typing import Optional
@@ -191,14 +190,7 @@ def runner(
191190
# Python files in the subjects directory
192191
@click.argument(
193192
"chosen_filter",
194-
type=click.Choice(
195-
[
196-
i.name
197-
for i in pkgutil.iter_modules(in2lambda.filters.__path__)
198-
if i.name != "markdown"
199-
],
200-
case_sensitive=False,
201-
),
193+
type=click.Choice(in2lambda.filters.builtin_filters(), case_sensitive=False),
202194
)
203195
@click.option(
204196
"--out",

in2lambda/validation/__init__.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Pre-flight checks for the ``#``/``##`` and ``$``/``$$`` markdown delimiters.
2+
3+
The markdown that in2lambda converts - however it was produced - is a shared
4+
contract with Lambda Feedback. These checks catch structural mistakes in that
5+
markdown, currently unbalanced or misplaced math delimiters, before it is
6+
converted.
7+
"""
8+
9+
from in2lambda.validation.delimiters import MathDelimiterError, math_delimiter_checker
10+
11+
__all__ = ["MathDelimiterError", "math_delimiter_checker", "check_markdown"]
12+
13+
14+
def check_markdown(md_content: str) -> list[MathDelimiterError]:
15+
"""Run every markdown check and return the problems found.
16+
17+
Args:
18+
md_content: The markdown text to validate.
19+
20+
Returns:
21+
A list of :class:`MathDelimiterError` members, one per problem found.
22+
An empty list means the markdown passed every check.
23+
24+
Examples:
25+
>>> from in2lambda.validation import check_markdown
26+
>>> check_markdown("Inline $x = y$ is fine.")
27+
[]
28+
>>> check_markdown("Unbalanced $x = y")
29+
[<MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR: 'unclosed inline $ ... $'>]
30+
"""
31+
problems: list[MathDelimiterError] = []
32+
33+
result = math_delimiter_checker(md_content)
34+
if result is not MathDelimiterError.PASSED:
35+
problems.append(result)
36+
37+
return problems

in2lambda/validation/delimiters.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Checks that ``$ ... $`` and ``$$ ... $$`` math delimiters are balanced and placed correctly.
2+
3+
KaTeX (and Lambda Feedback) expect inline math wrapped in single dollar signs on
4+
one line, and display math wrapped in ``$$`` that each sit alone on their own
5+
line. This module scans markdown character by character and reports the first
6+
delimiter mistake it finds.
7+
"""
8+
9+
from enum import Enum
10+
11+
12+
class MathDelimiterError(Enum):
13+
"""Outcome of :func:`math_delimiter_checker`.
14+
15+
``PASSED`` means no problem was found; every other member describes a
16+
specific delimiter mistake. The value is a short human-readable message
17+
suitable for showing on the command line.
18+
"""
19+
20+
PASSED = "ok"
21+
MISSING_NEWLINE_BEFORE_OPENING_DISPLAY = "opening $$ must start its own line"
22+
MISSING_NEWLINE_AFTER_OPENING_DISPLAY = "opening $$ must be followed by a newline"
23+
DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE = "inline $ ... $ closed with $$"
24+
MISSING_CLOSING_DOUBLE_INSTEAD_OF_SINGLE = (
25+
"display $$ ... $$ closed with a single $"
26+
)
27+
MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY = "closing $$ must start its own line"
28+
MISSING_NEWLINE_AFTER_CLOSING_DISPLAY = "closing $$ must be followed by a newline"
29+
INVALID_NEWLINE_INSIDE_INLINE = "newline inside an inline $ ... $ expression"
30+
MISSING_CLOSING_SINGLE_DOLLAR = "unclosed inline $ ... $"
31+
MISSING_CLOSING_DOUBLE_DOLLAR = "unclosed display $$ ... $$"
32+
33+
34+
def math_delimiter_checker(md_content: str) -> MathDelimiterError:
35+
r"""Scan markdown for the first math-delimiter mistake.
36+
37+
``\$`` is treated as a literal dollar sign, not a delimiter.
38+
39+
Args:
40+
md_content: The markdown text to check.
41+
42+
Returns:
43+
``MathDelimiterError.PASSED`` if the delimiters are well formed,
44+
otherwise the member describing the first problem found.
45+
46+
Examples:
47+
>>> from in2lambda.validation.delimiters import math_delimiter_checker
48+
>>> math_delimiter_checker("An inline $x = y$ expression.")
49+
<MathDelimiterError.PASSED: 'ok'>
50+
>>> math_delimiter_checker("Display:\n$$\nx = y\n$$")
51+
<MathDelimiterError.PASSED: 'ok'>
52+
>>> math_delimiter_checker("This costs \\$5, no math here.")
53+
<MathDelimiterError.PASSED: 'ok'>
54+
>>> math_delimiter_checker("Broken $x = y")
55+
<MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR: 'unclosed inline $ ... $'>
56+
"""
57+
# False once we are inside a math expression and awaiting its closing delimiter.
58+
expect_open_delimiter = True
59+
# While inside an expression, whether it opened with a single "$" (inline) or "$$" (display).
60+
expect_single_dollar = True
61+
62+
idx = 0
63+
while idx < len(md_content):
64+
prev_character = md_content[idx - 1] if idx > 0 else None
65+
character = md_content[idx]
66+
next_character = md_content[idx + 1] if idx + 1 < len(md_content) else None
67+
68+
if character == "$" and prev_character != "\\":
69+
if expect_open_delimiter:
70+
expect_open_delimiter = False
71+
72+
if next_character == "$":
73+
next_next_character = (
74+
md_content[idx + 2] if idx + 2 < len(md_content) else None
75+
)
76+
# "$$" must sit alone on its own line.
77+
if prev_character != "\n" and prev_character is not None:
78+
return MathDelimiterError.MISSING_NEWLINE_BEFORE_OPENING_DISPLAY
79+
if next_next_character != "\n":
80+
return MathDelimiterError.MISSING_NEWLINE_AFTER_OPENING_DISPLAY
81+
82+
expect_single_dollar = False
83+
idx += 1 # Skip the second "$"; the loop increments idx again.
84+
else:
85+
expect_single_dollar = True
86+
else:
87+
expect_open_delimiter = True
88+
89+
if expect_single_dollar and next_character == "$":
90+
return MathDelimiterError.DOUBLE_DOLLAR_INSTEAD_OF_CLOSING_SINGLE
91+
92+
elif not expect_single_dollar:
93+
if next_character != "$":
94+
return (
95+
MathDelimiterError.MISSING_CLOSING_DOUBLE_INSTEAD_OF_SINGLE
96+
)
97+
98+
next_next_character = (
99+
md_content[idx + 2] if idx + 2 < len(md_content) else None
100+
)
101+
if prev_character != "\n" and prev_character is not None:
102+
return MathDelimiterError.MISSING_NEWLINE_BEFORE_CLOSING_DISPLAY
103+
if next_next_character != "\n" and next_next_character is not None:
104+
return MathDelimiterError.MISSING_NEWLINE_AFTER_CLOSING_DISPLAY
105+
106+
idx += 1 # Skip the second "$"; the loop increments idx again.
107+
108+
# A newline may not appear inside an inline "$ ... $" expression.
109+
elif character == "\n" and not expect_open_delimiter and expect_single_dollar:
110+
return MathDelimiterError.INVALID_NEWLINE_INSIDE_INLINE
111+
112+
idx += 1
113+
114+
if expect_open_delimiter:
115+
return MathDelimiterError.PASSED
116+
elif expect_single_dollar:
117+
return MathDelimiterError.MISSING_CLOSING_SINGLE_DOLLAR
118+
else:
119+
return MathDelimiterError.MISSING_CLOSING_DOUBLE_DOLLAR

0 commit comments

Comments
 (0)