from __future__ import annotations from pathlib import Path from PIL import Image from copienator import atomic_write_json, configuration, read_json, utils from copienator.filesystem import staged_directory from copienator.platform import safe_filename RETURN_ANSWER_OPTIONS_FILE = Path(".copienator") / "return_answers.json" def configured_return_answer_options() -> dict[str, bool]: return { "context": bool(configuration.RETURN_ANSWERS_CONTEXT), "question": bool(configuration.RETURN_ANSWERS_QUESTION), "solution": bool(configuration.RETURN_ANSWERS_SOLUTION), } def save_return_answer_options( root: Path, *, context: bool, question: bool, solution: bool, ) -> None: path = Path(root) / RETURN_ANSWER_OPTIONS_FILE path.parent.mkdir(parents=True, exist_ok=True) atomic_write_json( path, {"context": context, "question": question, "solution": solution}, ) def load_return_answer_options(root: Path) -> dict[str, bool]: options = configured_return_answer_options() path = Path(root) / RETURN_ANSWER_OPTIONS_FILE if not path.is_file(): return options loaded = read_json(path) if not isinstance(loaded, dict): raise ValueError(f"Expected a return-answer options object in {path}") for name in options: if name in loaded: if type(loaded[name]) is not bool: raise ValueError(f"Expected a boolean for {name!r} in {path}") options[name] = loaded[name] return options def publish_answer_returns( root: Path, source: Path, destination: Path, *, answers_only: bool = False, ) -> None: """Publish reviewed answers, optionally without touching return metadata.""" scores = read_json(source / "score.json") if not isinstance(scores, dict): raise ValueError(f"Expected a score object in {source}") info_path = source / "info.json" if not info_path.is_file(): raise ValueError(f"Missing {info_path}; recompile annotations before giving-names") info = read_json(info_path) if not isinstance(info, dict) or set(info) != set(scores): raise ValueError(f"Invalid question information in {info_path}; recompile annotations") for label, entry in info.items(): if ( not isinstance(entry, dict) or set(entry) != {"present", "not_empty", "touched", "score"} or any(type(entry[key]) is not bool for key in ("present", "not_empty", "touched")) or (entry["not_empty"] and not entry["present"]) or (entry["touched"] and not entry["not_empty"]) ): raise ValueError(f"Invalid question information in {info_path}; recompile annotations") # Match score.json, including manual score edits awaiting recompilation. entry["score"] = scores[label] answers_dir = destination / "answers" if answers_dir.is_symlink(): raise ValueError(f"Expected a real answer directory: {answers_dir}") if configuration.RETURN_ANSWERS_ENABLED: options = load_return_answer_options(root) labels = [label for label, entry in info.items() if entry["present"] and entry["not_empty"]] # Import the rendering backend only when individual images are requested. from copienator.commands.annotating import make_base_image from copienator.commands.reading_annotations import concatenate all_labels = utils.read_all_labels(root) with staged_directory(answers_dir) as staging: for label in sorted(labels, key=utils.natural_key): paths = [] if options["context"]: paths.extend(utils.pdf_images_of_contexts(root, label, all_labels)) if options["question"]: paths.append(utils.pdf_image_of_enonce(root, label)) if options["solution"]: paths.append(utils.pdf_image_of_solution(root, label)) images = [] for path in paths: if path: supplement, _, _ = make_base_image(path) if supplement is None: raise ValueError(f"Could not render {path}") images.append(supplement) with Image.open(source / f"{label}.jpg") as answer: images.append(answer.convert("RGB")) image = concatenate(images) output = staging / f"{safe_filename(label)}.jpg" if output.exists(): raise ValueError( f"Answer labels produce the same filename in {answers_dir}: {label}" ) image.save(output) elif answers_dir.exists(): # Replace the managed directory with an empty one to remove stale exports. with staged_directory(answers_dir): pass if not answers_only: atomic_write_json(destination / "info.json", info) (destination / "touched.json").unlink(missing_ok=True)