Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions in2lambda/api/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,33 @@ def add_part_text(self, elem: Any) -> None:
self.parts[self._last_part["text"]].text = elem_text

self._last_part["text"] += 1

def to_json(self, output_dir: str, number: int = 0) -> None:
"""Turns this question alone into Lambda Feedback JSON/ZIP files.

This is what Lambda Feedback takes when importing a single question into a set
that already exists: the question's JSON and its images under ``media``, with
no set file. Images keep the names the JSON refers to them by.

Files of the same name are overwritten; nothing else in the directory is
touched.

Args:
output_dir: Where to output the final Lambda Feedback JSON/ZIP files.
number: The question's order number, which also prefixes its file names.

Examples:
>>> import os
>>> import tempfile
>>> from in2lambda.api.question import Question
>>> question = Question(title="Q", main_text="Some text")
>>> with tempfile.TemporaryDirectory() as temp_dir:
... question.to_json(temp_dir)
... sorted(os.listdir(temp_dir))
... sorted(os.listdir(f"{temp_dir}/question_000_Q"))
['question_000_Q', 'question_000_Q.zip']
['question_000_Q.json']
"""
from in2lambda.json_convert import json_convert

json_convert.write_question(self, output_dir, number)
3 changes: 2 additions & 1 deletion in2lambda/api/set.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ def increment_current_question(self) -> None:
def to_json(self, output_dir: str) -> None:
"""Turns this set into Lambda Feedback JSON/ZIP files.

WARNING: This will overwrite any existing files in the directory.
Files of the same name are overwritten; nothing else in the directory is
touched, and only what was written goes into the zip.

Args:
output_dir: Where to output the final Lambda Feedback JSON/ZIP files.
Expand Down
160 changes: 108 additions & 52 deletions in2lambda/json_convert/json_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,40 @@
MINIMAL_SET_TEMPLATE = "minimal_template_set.json"


def _zip_sorted_folder(folder_path, zip_path):
"""Zips the contents of a folder, preserving the directory structure.
def _templates() -> tuple[dict[str, Any], dict[str, Any]]:
"""Loads the minimal question and set templates that the writer fills in.

Returns:
The question template and the set template.
"""
# Use path so minimal template can be found regardless of where the user is running python from.
with open(Path(__file__).with_name(MINIMAL_QUESTION_TEMPLATE), "r") as file:
question_template = json.load(file)

with open(Path(__file__).with_name(MINIMAL_SET_TEMPLATE), "r") as file:
set_template = json.load(file)

return question_template, set_template


def _zip(files: list[Path], root: Path, zip_path: str) -> None:
"""Zips the given files, keeping where they sit relative to a folder.

Only what this run wrote is listed, so whatever else the folder holds is neither
uploaded nor removed.

Args:
folder_path: The path to the folder to zip.
files: The files to include, all inside root.
root: The folder the archive names are relative to.
zip_path: The path where the zip file will be created.
"""
# Sort by archive name for deterministic, alphabetical order. A file can be
# written more than once — an image used by both a question and its worked
# solution — and is still one file on disk, so name it once here too.
names = sorted({str(file.relative_to(root)): file for file in files}.items())
with zipfile.ZipFile(zip_path, "w") as zf:
for root, dirs, files in os.walk(folder_path):
# Sort files for deterministic, alphabetical order
for file in sorted(files):
abs_path = os.path.join(root, file)
rel_path = os.path.relpath(abs_path, folder_path)
zf.write(abs_path, arcname=rel_path)
for name, file in names:
zf.write(file, arcname=name)


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


def _question_title(question: Question, i: int) -> str:
return question.title if question.title != "" else f"Question {i + 1}"


def _question_stem(i: int, title: str) -> str:
# Lambda Feedback names the file after the title with only spaces made
# underscores. Path separators go too, so a title cannot leave the set folder,
# and so do the characters Windows forbids in file names.
return (
"question_"
+ str(i).zfill(3)
+ "_"
+ re.sub(r'[\s/\\<>:"|?*]', "_", title.strip())
)


def _question_json(
question: Question, i: int, template: dict[str, Any]
) -> dict[str, Any]:
output = deepcopy(template)

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

output["publish"] = question.publish
Expand Down Expand Up @@ -190,6 +226,59 @@ def _question_json(
return output


def _write_question(
question: Question, i: int, template: dict[str, Any], folder: Path
) -> list[Path]:
"""Writes one question's JSON, and any images it uses, into an existing folder.

Args:
question: The question to write.
i: Its order number, which also prefixes the file name.
template: The loaded JSON from the minimal question template.
folder: The folder to write into.

Returns:
The files written.
"""
output = _question_json(question, i, template)

json_file = folder / f"{_question_stem(i, output['title'])}.json"
with open(json_file, "w") as file:
json.dump(output, file)
written = [json_file]

for image in question.images:
# If images exist, create a media directory
media = folder / "media"
media.mkdir(exist_ok=True)
# The JSON refers to an image by its file name, so copying keeps that name.
written.append(Path(shutil.copy(os.path.abspath(image), media)))

return written


def write_question(question: Question, output_dir: str, number: int = 0) -> None:
"""Writes a single question as its own Lambda Feedback import.

The question gets a folder named after it, holding its JSON and its images under
``media``, and a zip of that folder. There is no set file: this is what Lambda
Feedback takes when importing one question into a set that already exists.

Args:
question: The question to write.
output_dir: Where to put the question's folder and zip.
number: The question's order number, which also prefixes its file names.
"""
question_template, _ = _templates()

folder = Path(output_dir) / _question_stem(
number, _question_title(question, number)
)
folder.mkdir(parents=True, exist_ok=True)
written = _write_question(question, number, question_template, folder)
_zip(written, folder, f"{folder}.zip")


def converter(
question_template: dict[str, Any],
set_template: dict[str, Any],
Expand Down Expand Up @@ -225,62 +314,29 @@ def converter(
SetQuestions._structuredTutorialVisibility.status
)
# create the set file
with open(f"{output_question}/set_{set_name}.json", "w") as file:
folder = Path(output_question)
set_file = folder / f"set_{set_name}.json"
with open(set_file, "w") as file:
json.dump(set_template, file)

for i in range(len(ListQuestions)):
output = _question_json(ListQuestions[i], i, question_template)

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

# write questions into directory
with open(f"{output_question}/{filename}.json", "w") as file:
json.dump(output, file)

# write image into directory
for k in range(len(ListQuestions[i].images)):
image_path = os.path.abspath(
ListQuestions[i].images[k]
) # converts computer path into python path
# If images exist, create a media directory
output_image = os.path.join(output_question, "media")
os.makedirs(output_image, exist_ok=True)
shutil.copy(image_path, output_image) # copies image into the directory
written = [set_file]
for i, question in enumerate(ListQuestions):
written += _write_question(question, i, question_template, folder)

# output zip file in destination folder
_zip_sorted_folder(output_question, output_question + ".zip")
_zip(written, folder, output_question + ".zip")


def main(set_questions: Set, output_dir: str) -> None:
"""Preliminary defensive programming before calling the main converter function.
"""Loads the templates and calls the main converter function.

This ultimately then produces the Lambda Feedback JSON/ZIP files.

Args:
set_questions: A Set object containing questions.
output_dir: Where to output the final Lambda Feedback JSON/ZIP files.
"""
# Use path so minimal template can be found regardless of where the user is running python from.
with open(Path(__file__).with_name(MINIMAL_QUESTION_TEMPLATE), "r") as file:
question_template = json.load(file)

with open(Path(__file__).with_name(MINIMAL_SET_TEMPLATE), "r") as file:
set_template = json.load(file)

# check if directory exists in file
if os.path.isdir(output_dir):
try:
shutil.rmtree(output_dir)
except OSError as e:
print("Error: %s : %s" % (output_dir, e.strerror))
question_template, set_template = _templates()
converter(question_template, set_template, set_questions, output_dir)


Expand Down
80 changes: 80 additions & 0 deletions tests/test_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import json
import re
import uuid
import zipfile
from dataclasses import replace
from pathlib import Path

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


@each_export
def test_question_exports_alone(export_dir: Path, tmp_path: Path) -> None:
"""Each question writes on its own as the set writes it, with the images it uses."""
loaded = Set.from_json(str(export_dir))
from_set = _write_back(loaded, tmp_path)
exported_media = _relative_files(export_dir / "media")

for i, question in enumerate(loaded.questions):
question.to_json(str(tmp_path / "single"), number=i)

# The question writes under the name the set gives it, so the files the set
# wrote for the same number say what to expect.
(set_file,) = from_set.glob(f"question_{i:03}_*.json")
folder = tmp_path / "single" / set_file.stem
expected = sorted(
[set_file.name]
+ [
f"media/{name}"
for name in exported_media
if name.startswith(f"{set_file.stem}_")
]
)

assert _relative_files(folder) == expected
with zipfile.ZipFile(f"{folder}.zip") as zf:
assert sorted(zf.namelist()) == expected

written = (folder / set_file.name).read_text()
assert json.loads(written) == json.loads(set_file.read_text())

# Every image the JSON points at must be beside it, or it will not resolve
# once the question is imported.
references = re.findall(r"!\[[^\]]*\]\(([^)]+)\)", written)
assert all(
(folder / "media" / reference).is_file() for reference in references
), references


def test_writing_leaves_other_files(tmp_path: Path) -> None:
"""Writing over an export keeps files it did not write and leaves them out of the zip."""
image = tmp_path / "diagram.png"
image.write_bytes(b"not really a png")
question_set = Set(questions=[Question(title="Q", images=[str(image)])])

written = _write_back(question_set, tmp_path)
strays = [
tmp_path / "out" / "notes.txt",
written / "notes.txt",
written / "media" / "notes.txt",
]
for stray in strays:
stray.write_text("someone else's work")

_write_back(question_set, tmp_path)

assert [stray.read_text() for stray in strays] == ["someone else's work"] * 3
with zipfile.ZipFile(f"{written}.zip") as zf:
assert sorted(zf.namelist()) == [
"media/diagram.png",
"question_000_Q.json",
"set_set.json",
]


def test_repeated_image_zipped_once(tmp_path: Path) -> None:
"""An image listed twice, as one used in both a question and its solution, is one file."""
image = tmp_path / "diagram.png"
image.write_bytes(b"not really a png")
question_set = Set(questions=[Question(title="Q", images=[str(image), str(image)])])

written = _write_back(question_set, tmp_path)

assert _relative_files(written / "media") == ["diagram.png"]
with zipfile.ZipFile(f"{written}.zip") as zf:
assert [name for name in zf.namelist() if name.startswith("media/")] == [
"media/diagram.png"
]


def _area_shape(area: dict) -> frozenset[str]:
# Without indices, an area's shape is the keys it has, not how many tests, cases
# or symbols it lists.
Expand Down
Loading