Files
Copies/copienator/return_answers.py
T

126 lines
5.0 KiB
Python

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)