Skip to content

Commit 29fe9e6

Browse files
committed
Merge branch 'wb/t32' into wb/t10
2 parents bc94161 + b613ca5 commit 29fe9e6

4 files changed

Lines changed: 220 additions & 53 deletions

File tree

in2lambda/api/question.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,33 @@ def add_part_text(self, elem: Any) -> None:
178178
self.parts[self._last_part["text"]].text = elem_text
179179

180180
self._last_part["text"] += 1
181+
182+
def to_json(self, output_dir: str, number: int = 0) -> None:
183+
"""Turns this question alone into Lambda Feedback JSON/ZIP files.
184+
185+
This is what Lambda Feedback takes when importing a single question into a set
186+
that already exists: the question's JSON and its images under ``media``, with
187+
no set file. Images keep the names the JSON refers to them by.
188+
189+
Files of the same name are overwritten; nothing else in the directory is
190+
touched.
191+
192+
Args:
193+
output_dir: Where to output the final Lambda Feedback JSON/ZIP files.
194+
number: The question's order number, which also prefixes its file names.
195+
196+
Examples:
197+
>>> import os
198+
>>> import tempfile
199+
>>> from in2lambda.api.question import Question
200+
>>> question = Question(title="Q", main_text="Some text")
201+
>>> with tempfile.TemporaryDirectory() as temp_dir:
202+
... question.to_json(temp_dir)
203+
... sorted(os.listdir(temp_dir))
204+
... sorted(os.listdir(f"{temp_dir}/question_000_Q"))
205+
['question_000_Q', 'question_000_Q.zip']
206+
['question_000_Q.json']
207+
"""
208+
from in2lambda.json_convert import json_convert
209+
210+
json_convert.write_question(self, output_dir, number)

in2lambda/api/set.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ def increment_current_question(self) -> None:
105105
def to_json(self, output_dir: str) -> None:
106106
"""Turns this set into Lambda Feedback JSON/ZIP files.
107107
108-
WARNING: This will overwrite any existing files in the directory.
108+
Files of the same name are overwritten; nothing else in the directory is
109+
touched, and only what was written goes into the zip.
109110
110111
Args:
111112
output_dir: Where to output the final Lambda Feedback JSON/ZIP files.

in2lambda/json_convert/json_convert.py

Lines changed: 108 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,40 @@
2020
MINIMAL_SET_TEMPLATE = "minimal_template_set.json"
2121

2222

23-
def _zip_sorted_folder(folder_path, zip_path):
24-
"""Zips the contents of a folder, preserving the directory structure.
23+
def _templates() -> tuple[dict[str, Any], dict[str, Any]]:
24+
"""Loads the minimal question and set templates that the writer fills in.
25+
26+
Returns:
27+
The question template and the set template.
28+
"""
29+
# Use path so minimal template can be found regardless of where the user is running python from.
30+
with open(Path(__file__).with_name(MINIMAL_QUESTION_TEMPLATE), "r") as file:
31+
question_template = json.load(file)
32+
33+
with open(Path(__file__).with_name(MINIMAL_SET_TEMPLATE), "r") as file:
34+
set_template = json.load(file)
35+
36+
return question_template, set_template
37+
38+
39+
def _zip(files: list[Path], root: Path, zip_path: str) -> None:
40+
"""Zips the given files, keeping where they sit relative to a folder.
41+
42+
Only what this run wrote is listed, so whatever else the folder holds is neither
43+
uploaded nor removed.
2544
2645
Args:
27-
folder_path: The path to the folder to zip.
46+
files: The files to include, all inside root.
47+
root: The folder the archive names are relative to.
2848
zip_path: The path where the zip file will be created.
2949
"""
50+
# Sort by archive name for deterministic, alphabetical order. A file can be
51+
# written more than once — an image used by both a question and its worked
52+
# solution — and is still one file on disk, so name it once here too.
53+
names = sorted({str(file.relative_to(root)): file for file in files}.items())
3054
with zipfile.ZipFile(zip_path, "w") as zf:
31-
for root, dirs, files in os.walk(folder_path):
32-
# Sort files for deterministic, alphabetical order
33-
for file in sorted(files):
34-
abs_path = os.path.join(root, file)
35-
rel_path = os.path.relpath(abs_path, folder_path)
36-
zf.write(abs_path, arcname=rel_path)
55+
for name, file in names:
56+
zf.write(file, arcname=name)
3757

3858

3959
def _response_area_to_json(area: ResponseArea, order: int) -> dict[str, Any]:
@@ -156,13 +176,29 @@ def _part_to_json(
156176
return output
157177

158178

179+
def _question_title(question: Question, i: int) -> str:
180+
return question.title if question.title != "" else f"Question {i + 1}"
181+
182+
183+
def _question_stem(i: int, title: str) -> str:
184+
# Lambda Feedback names the file after the title with only spaces made
185+
# underscores. Path separators go too, so a title cannot leave the set folder,
186+
# and so do the characters Windows forbids in file names.
187+
return (
188+
"question_"
189+
+ str(i).zfill(3)
190+
+ "_"
191+
+ re.sub(r'[\s/\\<>:"|?*]', "_", title.strip())
192+
)
193+
194+
159195
def _question_json(
160196
question: Question, i: int, template: dict[str, Any]
161197
) -> dict[str, Any]:
162198
output = deepcopy(template)
163199

164200
output["orderNumber"] = i # order number starts at 0
165-
output["title"] = question.title if question.title != "" else f"Question {i + 1}"
201+
output["title"] = _question_title(question, i)
166202
output["masterContent"] = question.main_text
167203

168204
output["publish"] = question.publish
@@ -190,6 +226,59 @@ def _question_json(
190226
return output
191227

192228

229+
def _write_question(
230+
question: Question, i: int, template: dict[str, Any], folder: Path
231+
) -> list[Path]:
232+
"""Writes one question's JSON, and any images it uses, into an existing folder.
233+
234+
Args:
235+
question: The question to write.
236+
i: Its order number, which also prefixes the file name.
237+
template: The loaded JSON from the minimal question template.
238+
folder: The folder to write into.
239+
240+
Returns:
241+
The files written.
242+
"""
243+
output = _question_json(question, i, template)
244+
245+
json_file = folder / f"{_question_stem(i, output['title'])}.json"
246+
with open(json_file, "w") as file:
247+
json.dump(output, file)
248+
written = [json_file]
249+
250+
for image in question.images:
251+
# If images exist, create a media directory
252+
media = folder / "media"
253+
media.mkdir(exist_ok=True)
254+
# The JSON refers to an image by its file name, so copying keeps that name.
255+
written.append(Path(shutil.copy(os.path.abspath(image), media)))
256+
257+
return written
258+
259+
260+
def write_question(question: Question, output_dir: str, number: int = 0) -> None:
261+
"""Writes a single question as its own Lambda Feedback import.
262+
263+
The question gets a folder named after it, holding its JSON and its images under
264+
``media``, and a zip of that folder. There is no set file: this is what Lambda
265+
Feedback takes when importing one question into a set that already exists.
266+
267+
Args:
268+
question: The question to write.
269+
output_dir: Where to put the question's folder and zip.
270+
number: The question's order number, which also prefixes its file names.
271+
"""
272+
question_template, _ = _templates()
273+
274+
folder = Path(output_dir) / _question_stem(
275+
number, _question_title(question, number)
276+
)
277+
folder.mkdir(parents=True, exist_ok=True)
278+
written = _write_question(question, number, question_template, folder)
279+
_zip(written, folder, f"{folder}.zip")
280+
281+
193282
def converter(
194283
question_template: dict[str, Any],
195284
set_template: dict[str, Any],
@@ -225,62 +314,29 @@ def converter(
225314
SetQuestions._structuredTutorialVisibility.status
226315
)
227316
# create the set file
228-
with open(f"{output_question}/set_{set_name}.json", "w") as file:
317+
folder = Path(output_question)
318+
set_file = folder / f"set_{set_name}.json"
319+
with open(set_file, "w") as file:
229320
json.dump(set_template, file)
230321

231-
for i in range(len(ListQuestions)):
232-
output = _question_json(ListQuestions[i], i, question_template)
233-
234-
# Lambda Feedback names the file after the title with only spaces made
235-
# underscores. Path separators go too, so a title cannot leave the set folder,
236-
# and so do the characters Windows forbids in file names.
237-
filename = (
238-
"question_"
239-
+ str(i).zfill(3)
240-
+ "_"
241-
+ re.sub(r'[\s/\\<>:"|?*]', "_", output["title"].strip())
242-
)
243-
244-
# write questions into directory
245-
with open(f"{output_question}/{filename}.json", "w") as file:
246-
json.dump(output, file)
247-
248-
# write image into directory
249-
for k in range(len(ListQuestions[i].images)):
250-
image_path = os.path.abspath(
251-
ListQuestions[i].images[k]
252-
) # converts computer path into python path
253-
# If images exist, create a media directory
254-
output_image = os.path.join(output_question, "media")
255-
os.makedirs(output_image, exist_ok=True)
256-
shutil.copy(image_path, output_image) # copies image into the directory
322+
written = [set_file]
323+
for i, question in enumerate(ListQuestions):
324+
written += _write_question(question, i, question_template, folder)
257325

258326
# output zip file in destination folder
259-
_zip_sorted_folder(output_question, output_question + ".zip")
327+
_zip(written, folder, output_question + ".zip")
260328

261329

262330
def main(set_questions: Set, output_dir: str) -> None:
263-
"""Preliminary defensive programming before calling the main converter function.
331+
"""Loads the templates and calls the main converter function.
264332
265333
This ultimately then produces the Lambda Feedback JSON/ZIP files.
266334
267335
Args:
268336
set_questions: A Set object containing questions.
269337
output_dir: Where to output the final Lambda Feedback JSON/ZIP files.
270338
"""
271-
# Use path so minimal template can be found regardless of where the user is running python from.
272-
with open(Path(__file__).with_name(MINIMAL_QUESTION_TEMPLATE), "r") as file:
273-
question_template = json.load(file)
274-
275-
with open(Path(__file__).with_name(MINIMAL_SET_TEMPLATE), "r") as file:
276-
set_template = json.load(file)
277-
278-
# check if directory exists in file
279-
if os.path.isdir(output_dir):
280-
try:
281-
shutil.rmtree(output_dir)
282-
except OSError as e:
283-
print("Error: %s : %s" % (output_dir, e.strerror))
339+
question_template, set_template = _templates()
284340
converter(question_template, set_template, set_questions, output_dir)
285341

286342

tests/test_exports.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import json
1111
import re
1212
import uuid
13+
import zipfile
1314
from dataclasses import replace
1415
from pathlib import Path
1516

@@ -134,6 +135,85 @@ def test_written_keys_exist_in_export(export_dir: Path, tmp_path: Path) -> None:
134135
assert not missing, missing
135136

136137

138+
@each_export
139+
def test_question_exports_alone(export_dir: Path, tmp_path: Path) -> None:
140+
"""Each question writes on its own as the set writes it, with the images it uses."""
141+
loaded = Set.from_json(str(export_dir))
142+
from_set = _write_back(loaded, tmp_path)
143+
exported_media = _relative_files(export_dir / "media")
144+
145+
for i, question in enumerate(loaded.questions):
146+
question.to_json(str(tmp_path / "single"), number=i)
147+
148+
# The question writes under the name the set gives it, so the files the set
149+
# wrote for the same number say what to expect.
150+
(set_file,) = from_set.glob(f"question_{i:03}_*.json")
151+
folder = tmp_path / "single" / set_file.stem
152+
expected = sorted(
153+
[set_file.name]
154+
+ [
155+
f"media/{name}"
156+
for name in exported_media
157+
if name.startswith(f"{set_file.stem}_")
158+
]
159+
)
160+
161+
assert _relative_files(folder) == expected
162+
with zipfile.ZipFile(f"{folder}.zip") as zf:
163+
assert sorted(zf.namelist()) == expected
164+
165+
written = (folder / set_file.name).read_text()
166+
assert json.loads(written) == json.loads(set_file.read_text())
167+
168+
# Every image the JSON points at must be beside it, or it will not resolve
169+
# once the question is imported.
170+
references = re.findall(r"!\[[^\]]*\]\(([^)]+)\)", written)
171+
assert all(
172+
(folder / "media" / reference).is_file() for reference in references
173+
), references
174+
175+
176+
def test_writing_leaves_other_files(tmp_path: Path) -> None:
177+
"""Writing over an export keeps files it did not write and leaves them out of the zip."""
178+
image = tmp_path / "diagram.png"
179+
image.write_bytes(b"not really a png")
180+
question_set = Set(questions=[Question(title="Q", images=[str(image)])])
181+
182+
written = _write_back(question_set, tmp_path)
183+
strays = [
184+
tmp_path / "out" / "notes.txt",
185+
written / "notes.txt",
186+
written / "media" / "notes.txt",
187+
]
188+
for stray in strays:
189+
stray.write_text("someone else's work")
190+
191+
_write_back(question_set, tmp_path)
192+
193+
assert [stray.read_text() for stray in strays] == ["someone else's work"] * 3
194+
with zipfile.ZipFile(f"{written}.zip") as zf:
195+
assert sorted(zf.namelist()) == [
196+
"media/diagram.png",
197+
"question_000_Q.json",
198+
"set_set.json",
199+
]
200+
201+
202+
def test_repeated_image_zipped_once(tmp_path: Path) -> None:
203+
"""An image listed twice, as one used in both a question and its solution, is one file."""
204+
image = tmp_path / "diagram.png"
205+
image.write_bytes(b"not really a png")
206+
question_set = Set(questions=[Question(title="Q", images=[str(image), str(image)])])
207+
208+
written = _write_back(question_set, tmp_path)
209+
210+
assert _relative_files(written / "media") == ["diagram.png"]
211+
with zipfile.ZipFile(f"{written}.zip") as zf:
212+
assert [name for name in zf.namelist() if name.startswith("media/")] == [
213+
"media/diagram.png"
214+
]
215+
216+
137217
def _area_shape(area: dict) -> frozenset[str]:
138218
# Without indices, an area's shape is the keys it has, not how many tests, cases
139219
# or symbols it lists.

0 commit comments

Comments
 (0)