|
20 | 20 | MINIMAL_SET_TEMPLATE = "minimal_template_set.json" |
21 | 21 |
|
22 | 22 |
|
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. |
25 | 44 |
|
26 | 45 | 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. |
28 | 48 | zip_path: The path where the zip file will be created. |
29 | 49 | """ |
| 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()) |
30 | 54 | 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) |
37 | 57 |
|
38 | 58 |
|
39 | 59 | def _response_area_to_json(area: ResponseArea, order: int) -> dict[str, Any]: |
@@ -156,13 +176,29 @@ def _part_to_json( |
156 | 176 | return output |
157 | 177 |
|
158 | 178 |
|
| 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 | + |
159 | 195 | def _question_json( |
160 | 196 | question: Question, i: int, template: dict[str, Any] |
161 | 197 | ) -> dict[str, Any]: |
162 | 198 | output = deepcopy(template) |
163 | 199 |
|
164 | 200 | 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) |
166 | 202 | output["masterContent"] = question.main_text |
167 | 203 |
|
168 | 204 | output["publish"] = question.publish |
@@ -190,6 +226,59 @@ def _question_json( |
190 | 226 | return output |
191 | 227 |
|
192 | 228 |
|
| 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 | + |
193 | 282 | def converter( |
194 | 283 | question_template: dict[str, Any], |
195 | 284 | set_template: dict[str, Any], |
@@ -225,62 +314,29 @@ def converter( |
225 | 314 | SetQuestions._structuredTutorialVisibility.status |
226 | 315 | ) |
227 | 316 | # 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: |
229 | 320 | json.dump(set_template, file) |
230 | 321 |
|
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) |
257 | 325 |
|
258 | 326 | # output zip file in destination folder |
259 | | - _zip_sorted_folder(output_question, output_question + ".zip") |
| 327 | + _zip(written, folder, output_question + ".zip") |
260 | 328 |
|
261 | 329 |
|
262 | 330 | 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. |
264 | 332 |
|
265 | 333 | This ultimately then produces the Lambda Feedback JSON/ZIP files. |
266 | 334 |
|
267 | 335 | Args: |
268 | 336 | set_questions: A Set object containing questions. |
269 | 337 | output_dir: Where to output the final Lambda Feedback JSON/ZIP files. |
270 | 338 | """ |
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() |
284 | 340 | converter(question_template, set_template, set_questions, output_dir) |
285 | 341 |
|
286 | 342 |
|
|
0 commit comments