Cache for reading annotation

This commit is contained in:
sebastien committed 2026-10-24 10:22:41 +02:00
1 parent 55d5cf16c9
commit ff7496b113
12 files changed
+466 -13

No files matched your search

+2 -1
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from PIL import Image, ImageDraw
from copienator import CliError, atomic_write_json, read_json, utils
from copienator.annotation_read_cache import STATE_FILE
from copienator.commands.resolve_manual import ManualInstruction, parse_instruction_text
DRAFT = "annotation_modifications.txt"
@@ -153,7 +154,7 @@ def edited_bodies(directory: Path, meta: dict, instructions: list[ManualInstruct
def output_removals(directory: Path) -> tuple[str, ...]:
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {}
return ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
return (STATE_FILE, "Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
*(f"{label}.jpg" for label, entry in info.items()
if Path(label).name == label))
+77
View File
@@ -0,0 +1,77 @@
"""Small content fingerprints for incremental --update-score reads."""
from hashlib import file_digest, sha256
import json
from pathlib import Path
from copienator import atomic_write_json, configuration, read_json
from copienator.annotation_documents import copy_documents
from copienator.return_answers import load_return_answer_options
STATE_FILE = ".annotation-read-state.json"
DOCUMENT_INPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
def _files_digest(paths) -> str:
digest = sha256()
for path in sorted(set(paths)):
digest.update(str(path).encode())
if path.is_file():
with path.open("rb") as stream:
digest.update(file_digest(stream, "sha256").digest())
else:
digest.update(b"missing")
return digest.hexdigest()
class AnnotationReadCache:
def __init__(self, workspace, mode: str, data: dict, group_dirs=()):
self.workspace = workspace
self.mode = mode
self.signatures = {}
root = workspace.root
shared = [root / "labels", root / mode / "annotation_modifications_applied.txt"]
for folder in ("Text2", "Sol2"):
shared.extend((root / folder).glob("*.pdf"))
for directory in group_dirs:
shared.extend(directory / name for name in DOCUMENT_INPUTS)
common = (_files_digest(shared), load_return_answer_options(root),
configuration.RETURN_ANSWERS_ENABLED,
configuration.LATEX_ANOT_BEFORE, configuration.LATEX_ANOT_AFTER)
for copy_id, labels in data.items():
directory = root / mode / f"Copie{copy_id}"
paths = [directory / "score.json", directory / "bnote.json"]
for document in copy_documents(directory):
paths.extend(document / name for name in DOCUMENT_INPUTS)
for content in labels.values():
pdf = Path(content["pdf_path"])
paths.extend((pdf, pdf.with_suffix(".layout.json")))
payload = json.dumps((1, common, labels, _files_digest(paths)),
sort_keys=True, default=str).encode()
self.signatures[copy_id] = sha256(payload).hexdigest()
def unchanged(self, copy_id: str) -> bool:
directory = self.workspace.root / self.mode / f"Copie{copy_id}"
try:
state = read_json(directory / STATE_FILE)
return (state["signature"] == self.signatures[copy_id]
and bool(state["outputs"])
and all(Path(path).is_file() for path in state["outputs"]))
except (OSError, ValueError, TypeError, KeyError):
return False
def remember(self, copy_ids) -> None:
for copy_id in copy_ids:
directory = self.workspace.root / self.mode / f"Copie{copy_id}"
outputs = [directory / "info.json"]
info = read_json(directory / "info.json")
outputs.extend(directory / f"{label}.jpg" for label, entry in info.items()
if entry["present"])
outputs.extend(directory.glob("Concat*.*"))
outputs.extend(directory.glob(".annotation-base-*.png"))
outputs.extend(directory.glob("annotation_baseline.json"))
for destination in self.workspace.return_dir.glob(f"* ({copy_id})"):
outputs.extend((destination / "answers").glob("*.jpg"))
atomic_write_json(directory / STATE_FILE, {
"signature": self.signatures[copy_id],
"outputs": [str(path) for path in outputs],
})
+36
View File
@@ -2,11 +2,13 @@ import argparse
import math
import shutil
import sys
from decimal import Decimal, InvalidOperation, ROUND_CEILING
from pathlib import Path
import pandas as pd
from PIL import Image, ImageDraw, ImageFont
from copienator import atomic_write_json, read_json
from copienator.configuration import (
FINAL_SCORE_FONT_PATH,
FINAL_SCORE_HISTOGRAM_PATH,
@@ -72,6 +74,39 @@ def copy_histogram(output_dir: Path, *, copy_merged_histogramme: bool = False) -
shutil.copy2(source, destination)
print(f"Copied histogram: {destination}")
def write_score_statistics(search_path: Path, output_dir: Path) -> None:
"""Average available per-question scores, rounding upward to one decimal."""
scores_by_label = {}
for path in sorted(search_path.glob("*/score.json")):
try:
scores = read_json(path)
if not isinstance(scores, dict):
raise ValueError("expected an object of per-label scores")
except (OSError, ValueError) as exc:
print(f"Warning: Could not read scores from '{path}': {exc}")
continue
for label, value in scores.items():
values = scores_by_label.setdefault(label, [])
if value is None or (isinstance(value, str) and not value.strip()):
continue
try:
number = Decimal(str(value))
if not number.is_finite():
raise InvalidOperation
except InvalidOperation:
print(f"Warning: Invalid score '{value}' for '{label}' in '{path}'.")
continue
values.append(number)
statistics = {
label: str((sum(values) / len(values)).quantize(Decimal("0.1"), rounding=ROUND_CEILING))
if values else ""
for label, values in scores_by_label.items()
}
atomic_write_json(output_dir / "stat.json", statistics)
print(f"Wrote per-label averages: {output_dir / 'stat.json'}")
def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
# 1. Load Data
try:
@@ -152,6 +187,7 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
for pdf_path in pdf_paths:
shutil.copy2(pdf_path, student_output / pdf_path.name)
write_score_statistics(search_path, output_dir)
copy_histogram(output_dir, copy_merged_histogramme=copy_merged_histogramme)
+9 -3
View File
@@ -4,7 +4,7 @@ import argparse
import re
import sys
from collections import defaultdict
from collections.abc import Sequence
from collections.abc import Collection, Sequence
from pathlib import Path
from copienator import (
@@ -77,8 +77,12 @@ def _annotation_source(
def update_named_return_answers(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
*,
copy_ids: Collection[str] | None = None,
) -> ExitCode:
"""Refresh only answers/ in existing returns, preserving manual names."""
"""Refresh answers/ in existing returns, optionally only successful rereads."""
if copy_ids is not None and (not copy_ids or not workspace.return_dir.is_dir()):
return ExitCode.SUCCESS
workspace.require_directories(annotation_dir_name, "A Rendre")
had_errors = False
found = False
@@ -86,6 +90,8 @@ def update_named_return_answers(
if not destination.is_dir():
continue
match = RETURN_COPY_ID.search(destination.name)
if copy_ids is not None and (match is None or match.group(1) not in copy_ids):
continue
if match is None:
print(
f"Warning: cannot identify a copy ID in {destination.name!r}; skipped",
@@ -116,7 +122,7 @@ def update_named_return_answers(
except (OSError, TypeError, ValueError) as exc:
print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True
if not found:
if not found and copy_ids is None:
print("Warning: no identifiable student folders found in A Rendre", file=sys.stderr)
had_errors = True
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
@@ -24,6 +24,7 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov
from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.annotation_documents import copy_documents
from copienator.annotation_layout import legacy_layout
from copienator.annotation_read_cache import AnnotationReadCache
from copienator.annotation_modifications import (
BASELINE,
output_removals,
@@ -31,6 +32,7 @@ from copienator.annotation_modifications import (
)
from copienator.answer_info import build_answer_info
from copienator.commands import annotating
from copienator.commands.giving_names import update_named_return_answers
from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options
@@ -324,7 +326,12 @@ def run(
return ExitCode.PARTIAL
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
cache = AnnotationReadCache(workspace, "Bnot", loaded.data) if update_score else None
regenerated: set[str] = set()
for student_id in sorted(loaded.data, key=utils.natural_key):
if cache is not None and cache.unchanged(student_id):
print(f"Skipping unchanged Copie{student_id}")
continue
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
only_empty = all(content["result"].get("error") == "empty-answer"
for content in loaded.data[student_id].values())
@@ -372,6 +379,12 @@ def run(
)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
else:
regenerated.add(student_id)
if update_named_return_answers(workspace, "Bnot", copy_ids=regenerated) != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
elif cache is not None:
cache.remember(regenerated)
return status
@@ -27,6 +27,7 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.annotation_documents import copy_documents, tablet_documents
from copienator.annotation_layout import legacy_layout
from copienator.annotation_read_cache import AnnotationReadCache
from copienator.annotation_modifications import (
BASELINE,
baseline,
@@ -36,6 +37,7 @@ from copienator.annotation_modifications import (
)
from copienator.answer_info import build_answer_info
from copienator.commands import annotating
from copienator.commands.giving_names import update_named_return_answers
from copienator.commands.reading_annotations import (
concatenate,
detect_checks_and_notes,
@@ -594,6 +596,18 @@ def run(
and path.is_dir()
and not path.name.startswith("Copie")
]
# Redo reads deliberately remain full reads: their selected labels and
# preserved baseline images have different dependencies.
cache = (AnnotationReadCache(workspace, annotation_dir, loaded.data, group_dirs)
if update_score and not refaire else None)
pending_ids = set(loaded.data)
if cache is not None:
pending_ids = {copy_id for copy_id in loaded.data if not cache.unchanged(copy_id)}
for copy_id in sorted(set(loaded.data) - pending_ids, key=utils.natural_key):
print(f"Skipping unchanged Copie{copy_id}")
if not pending_ids:
return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
only_ids = pending_ids
# Each worker decodes a full-height returned group and its reference image.
# Keep this stage deliberately narrow; answer regeneration below has its own
# parallel executor and a much smaller per-task memory footprint.
@@ -657,6 +671,7 @@ def run(
if refaire
else sorted(loaded.data, key=utils.natural_key)
)
regenerated: set[str] = set()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(
@@ -676,13 +691,19 @@ def run(
),
): student_id
for student_id in student_ids
if student_id in loaded.data and student_id not in skipped_students
if student_id in pending_ids and student_id not in skipped_students
}
for future in concurrent.futures.as_completed(futures):
result, output = future.result()
print(output)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
else:
regenerated.add(futures[future])
if update_named_return_answers(workspace, annotation_dir, copy_ids=regenerated) != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
elif cache is not None:
cache.remember(regenerated)
return status
+4 -2
View File
@@ -778,7 +778,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Étapes personnelles",
"Renseigner le barème",
"Exécute gestion_classe we.",
(CommandVariant("default", "gestion_classe we", "gestion_classe", "external", ("we",)),),
(CommandVariant("default", "gestion_classe we", "gestion_classe", "external", ("we",), fixed_args_before_positionals=True),),
arguments=(arg_evaluation_name(),),
personal=True,
),
StepDefinition(
@@ -823,7 +824,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Ajouter le score final",
"Prérequis immédiat : gestion_classe wse doit avoir été exécuté juste avant. "
"Génère ensuite les dossiers de diffusion avec le score final, puis copie "
"gestion_classe/Staging/histogramme.pdf dans le dossier Server/copies de l’évaluation.",
"gestion_classe/Staging/histogramme.pdf dans le dossier Server/copies de l’évaluation. "
"Écrit aussi stat.json avec les moyennes par label, arrondies par excès au dixième.",
(python("default", "Score final", "add-final-score", supports_verbose=False),),
arguments=(
arg_target("le dossier de l’évaluation"),
+22 -5
View File
@@ -261,8 +261,21 @@ Scores incorporate review checkbox changes and, when requested, existing score
overrides via `read-grouped --update-score` (or `read-annotations --update-score`
for `Bnot`). Editing the JSON alone does not update the rendered scores. Overrides
are read from the annotation source folder: a return symlink points there, but
an independent fallback copy does not. After regeneration, rerun `giving-names`
to refresh copied return files.
an independent fallback copy does not. Successful annotation rereads automatically
refresh individual `answers/` images in existing return folders, matching their
trailing copy ID and preserving manual names and other return files. This also
applies the selected supplementary-material options. Copies whose regeneration
fails retain their existing exports. Rerun `giving-names` to refresh other copied
return files.
With `--update-score`, both readers save a small content fingerprint per copy
after successful regeneration and answer publication. Subsequent runs skip
unchanged copies before scanning or rendering PDFs. A single edited `score.json`
therefore rebuilds only its copy. Changed shared documents or grouped annotation
inputs conservatively invalidate all copies; changed export options also trigger
regeneration. Missing generated files and failed runs are retried. The first run
establishes the fingerprints. Reads without `--update-score` always regenerate;
selective `--refaire` reads do not use this optimization.
`update-ods` reads these return JSON files; empty values become `NT` in the normal
per-question export. Its `--sum` option sums numeric values and skips nonnumeric
@@ -280,9 +293,13 @@ ones. Weighting and the final overall mark belong to the separate grading flow.
outputs whose source is now absent, including broken symlinks.
- `add-final-score` writes to `FINAL_SCORE_OUTPUT_DIR/<evaluation>/`. It stamps
the overall mark from the configured ODS in red at the JPEG's upper right,
rounded down to one decimal place. It copies PDFs unchanged and does not export
either JSON file or the `answers/` directory. It does not add that mark to the
files inside `A Rendre`.
rounded down to one decimal place. It copies PDFs unchanged, together with
`score.json`, `info.json` and the optional `answers/` directory. Alongside the
student folders it writes `stat.json`, averaging each label across the current
`A Rendre/*/score.json` files and rounding upward to one decimal place, stored
as strings like the original scores (for example `"2.7"`). Blank
and missing scores are excluded; labels without numeric scores retain `""`.
It does not add the overall mark to the files inside `A Rendre`.
- The `clean` command retains return images (including individual answers), PDFs,
`score.json` and `info.json`, materializing
retained symlinks before deleting their sources.
+11
View File
@@ -439,6 +439,17 @@ réponse.
Le mode **Régénérer avec les nouveaux énoncés/corrigés et les
score.json** sert à mettre à jour les scores sur les images finales à
partir d'une modification manuelle des fichiers `score.json`.
Après une lecture réussie, les images `answers` des dossiers déjà présents dans
**A Rendre** sont également régénérées, avec les compléments sélectionnés.
Les dossiers sont identifiés par leur numéro de copie final entre parenthèses ;
leurs noms personnalisés et les autres fichiers sont conservés.
Dans ce mode, les copies dont les fichiers et options sont inchangés sont
ignorées avant la lecture des PDF et la génération des images. Le premier
passage établit une empreinte par copie ; modifier un seul `score.json` ne
régénère ensuite que cette copie. Une modification des documents partagés
(énoncés, corrigés, annotations groupées) peut relancer plusieurs copies.
La lecture sans cette option force une régénération ; le mode refaire conserve
son fonctionnement habituel.
### Modifier les réponses annotées
+23
View File
@@ -9,9 +9,31 @@ import pandas as pd
from PIL import Image
from copienator.commands import add_final_score
from copienator import atomic_write_json, read_json
class AddFinalScoreTests(unittest.TestCase):
def test_statistics_average_all_score_files_and_round_up_without_float_drift(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
returns = root / 'A Rendre'
atomic_write_json(returns / 'Alice/score.json', {
'Ex 1': '2.0', 'Ex 2': '0.1', 'Ex 3': '', 'Empty': '', 'Zero': 0,
})
atomic_write_json(returns / 'Bob/score.json', {
'Ex 1': 2.01, 'Ex 2': '0.1', 'Ex 3': '3.5', 'Empty': None,
})
output = root / 'output'
output.mkdir()
add_final_score.write_score_statistics(returns, output)
self.assertEqual(read_json(output / 'stat.json'), {
'Ex 1': '2.1', 'Ex 2': '0.1', 'Ex 3': '3.5', 'Empty': '', 'Zero': '0.0',
})
# Rebuilding uses current returns, without accumulating old averages.
atomic_write_json(returns / 'Bob/score.json', {'Ex 1': 4})
add_final_score.write_score_statistics(returns, output)
self.assertEqual(read_json(output / 'stat.json')['Ex 1'], '3.0')
def test_merged_histogram_is_copied_only_when_selected(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -80,6 +102,7 @@ class AddFinalScoreTests(unittest.TestCase):
self.assertFalse((output / "Student.pdf").exists())
self.assertFalse((student / "answers" / "001 - Ex 1.jpg").exists())
self.assertEqual((output / "histogramme.pdf").read_bytes(), b"histogram")
self.assertEqual(read_json(output / "stat.json"), {"Ex 1": "4.0"})
def test_omits_answers_directory_when_it_was_not_generated(self):
with tempfile.TemporaryDirectory() as temporary:
+113 -1
View File
@@ -8,8 +8,10 @@ from unittest.mock import patch
from PIL import Image
from copienator import EvaluationWorkspace, atomic_write_json, configuration, read_json
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, configuration, read_json
from copienator.annotation_data import AnnotationLoadResult
from copienator.commands import annotating, giving_names
from copienator.commands import reading_annotations as standard
from copienator.commands import reading_grouped_annotations as reader
from copienator.return_answers import (
publish_answer_returns,
@@ -216,6 +218,116 @@ class AnswerReturnTests(unittest.TestCase):
)
class RereadAnswerReturnTests(unittest.TestCase):
def check_reread(self, module, mode, *, existing_returns=True, fail_publication=False,
failed_copy=False):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
root = Path(temporary.name)
workspace = EvaluationWorkspace(root)
for folder in ("Copies", "Par label", mode, "Text2"):
(root / folder).mkdir()
(root / "labels").write_text("Ex 1\n")
atomic_write_json(root / "correction.json", {})
question = root / "Text2" / "Ex 1.pdf"
question.touch()
data = {}
original_score = b'{\n "Ex 1": "3.5"\n}\n'
destinations = {}
for copy_id in ("01", "02"):
output = root / mode / f"Copie{copy_id}"
output.mkdir()
(output / "score.json").write_bytes(original_score)
atomic_write_json(output / "bnote.json", {"images": [{"label": "Ex 1"}]})
pdf = root / "Copies" / f"{copy_id}.pdf"
if copy_id == "01":
pdf.touch()
data[copy_id] = {"Ex 1": {
"result": {"score": 1, "feedback": []},
"pdf_path": pdf,
"coordinates": (0, 0),
}}
destination = workspace.return_dir / f"Renamed student ({copy_id})"
destinations[copy_id] = destination
if existing_returns:
(destination / "answers").mkdir(parents=True)
(destination / "answers" / "old.jpg").write_bytes(b"previous answers")
(destination / "custom.pdf").write_bytes(b"keep PDF")
(destination / "info.json").write_bytes(b"keep info")
if not failed_copy:
del data["02"]
def compose(base, label, result, *args, **kwargs):
# Make the rendered score observable in the published answer pixels.
color = "red" if str(result["score"]) == "3.5" else "black"
return Image.new("RGB", (100, 50), color), 10
def render(path):
if Path(path) == question and fail_publication:
raise ValueError("cannot render supplement")
return Image.new("RGB", (100, 20), "blue"), 0, 0
with (
patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True),
patch.object(module, "load_annotation_data", return_value=AnnotationLoadResult(data, [])),
patch.object(standard, "detect_checks_and_notes", return_value=([], None)),
patch.object(annotating, "make_base_image", side_effect=render),
patch.object(annotating, "compose_label_image", side_effect=compose),
patch.object(reader, "get_extra_pdfs_as_images", return_value=[]),
patch.object(reader, "save_paginated_pdf"),
patch.object(reader, "save_checked_pdf"),
contextlib.redirect_stdout(io.StringIO()),
contextlib.redirect_stderr(io.StringIO()),
):
status = module.run(
workspace, update_score=True,
return_answers_context=False, return_answers_question=True,
return_answers_solution=False,
)
expected = ExitCode.PARTIAL if failed_copy or fail_publication else ExitCode.SUCCESS
self.assertEqual(status, expected)
self.assertEqual((root / mode / "Copie01" / "score.json").read_bytes(), original_score)
if not existing_returns:
self.assertFalse(workspace.return_dir.exists())
return
for destination in destinations.values():
self.assertEqual((destination / "custom.pdf").read_bytes(), b"keep PDF")
self.assertEqual((destination / "info.json").read_bytes(), b"keep info")
self.assertEqual((destinations["02"] / "answers" / "old.jpg").read_bytes(), b"previous answers")
answers = destinations["01"] / "answers"
if fail_publication:
self.assertEqual((answers / "old.jpg").read_bytes(), b"previous answers")
return
self.assertEqual([p.name for p in answers.iterdir()], ["Ex 1.jpg"])
with Image.open(answers / "Ex 1.jpg") as image:
self.assertEqual(image.size, (100, 70))
self.assertGreater(image.getpixel((50, 10))[2], 240)
self.assertGreater(image.getpixel((50, 45))[0], 240)
self.assertLess(image.getpixel((50, 45))[2], 10)
def test_reread_refreshes_answers_from_new_scores_and_supplements(self):
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
with self.subTest(mode=mode):
self.check_reread(module, mode)
def test_reread_does_not_create_named_returns(self):
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
with self.subTest(mode=mode):
self.check_reread(module, mode, existing_returns=False)
def test_failed_copy_does_not_republish_stale_annotations(self):
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
with self.subTest(mode=mode):
self.check_reread(module, mode, failed_copy=True)
def test_failed_publication_preserves_previous_answers(self):
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
with self.subTest(mode=mode):
self.check_reread(module, mode, fail_publication=True)
class CompiledMembershipTests(unittest.TestCase):
def test_update_score_preserves_file_and_manual_value_wins(self):
with tempfile.TemporaryDirectory() as directory:
+134
View File
@@ -0,0 +1,134 @@
import contextlib
from copy import deepcopy
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from PIL import Image
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, configuration
from copienator.annotation_data import AnnotationLoadResult
from copienator.annotation_read_cache import STATE_FILE
from copienator.commands import annotating, reading_annotations as standard
from copienator.commands import reading_grouped_annotations as grouped
class IncrementalAnnotationReadsTests(unittest.TestCase):
def exercise(self, module, mode):
with tempfile.TemporaryDirectory() as temporary, contextlib.ExitStack() as stack:
root = Path(temporary)
workspace = EvaluationWorkspace(root)
for folder in ("Copies", "Par label", mode, "Text2"):
(root / folder).mkdir()
(root / "labels").write_text("Ex 1\n")
atomic_write_json(root / "correction.json", {})
question = root / "Text2" / "Ex 1.pdf"
question.write_bytes(b"original statement")
data = {}
for copy_id in ("01", "02"):
output = root / mode / f"Copie{copy_id}"
output.mkdir()
atomic_write_json(output / "score.json", {"Ex 1": "2"})
atomic_write_json(output / "bnote.json", {"images": [{"label": "Ex 1"}]})
pdf = root / "Copies" / f"{copy_id}.pdf"
pdf.write_bytes(b"student answer")
data[copy_id] = {"Ex 1": {"pdf_path": pdf,
"result": {"score": 1, "feedback": []}, "coordinates": (0, 0)}}
destination = workspace.return_dir / f"Renamed ({copy_id})" / "answers"
destination.mkdir(parents=True)
group = root / mode / "Ex 1 G1"
if mode == "BGnot":
group.mkdir()
atomic_write_json(group / "bnote.json", {"images": [
{"id": "01", "label": "Ex 1"}, {"id": "02", "label": "Ex 1"}]})
stack.enter_context(patch.object(configuration, "RETURN_ANSWERS_ENABLED", True))
stack.enter_context(patch.object(module, "load_annotation_data",
side_effect=lambda _: AnnotationLoadResult(deepcopy(data), [])))
detect = stack.enter_context(patch.object(standard, "detect_checks_and_notes",
return_value=([], None)))
scan = stack.enter_context(patch.object(grouped, "_scan_annotation_directory",
return_value=({}, {})))
stack.enter_context(patch.object(annotating, "make_base_image",
return_value=(Image.new("RGB", (100, 20), "white"), 0, 0)))
compose = stack.enter_context(patch.object(annotating, "compose_label_image",
return_value=(Image.new("RGB", (100, 50), "white"), 10)))
stack.enter_context(patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]))
stack.enter_context(patch.object(grouped, "save_paginated_pdf"))
stack.enter_context(patch.object(grouped, "save_checked_pdf"))
stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
stack.enter_context(contextlib.redirect_stderr(io.StringIO()))
def run(expected_count, **options):
compose.reset_mock()
detect.reset_mock()
scan.reset_mock()
status = module.run(workspace, update_score=True,
return_answers_context=False, return_answers_question=options.get("question", False),
return_answers_solution=False)
self.assertEqual(status, ExitCode.SUCCESS)
self.assertEqual(compose.call_count, expected_count)
if expected_count == 0:
detect.assert_not_called()
scan.assert_not_called()
run(2) # First run records fingerprints.
untouched = root / mode / "Copie02" / "Ex 1.jpg"
untouched_time = untouched.stat().st_mtime_ns
run(0)
score = root / mode / "Copie01" / "score.json"
score.touch() # Timestamp-only changes do not cause rendering.
run(0)
score.write_text('{"Ex 1": "3.5"}')
run(1)
self.assertEqual(compose.call_args.args[2]["score"], "3.5")
self.assertEqual(untouched.stat().st_mtime_ns, untouched_time)
if mode == "BGnot":
self.assertEqual(scan.call_args.args[1], {"01"})
run(0)
# Per-copy sources and correction data invalidate only that copy.
(root / "Copies" / "01.pdf").write_bytes(b"replaced answer PDF")
run(1)
data["02"]["Ex 1"]["result"]["feedback"] = [{"text": "new feedback"}]
run(1)
# Changed tablet input cannot be mistaken for a score-only update.
annotation = (group if mode == "BGnot" else score.parent) / "Concat_annotated.pdf"
annotation.write_bytes(b"new tablet annotations")
run(2 if mode == "BGnot" else 1)
run(0)
# New shared documents and changed export options must not be skipped.
question.write_bytes(b"updated statement")
run(2)
run(2, question=True)
run(0, question=True)
# Missing generated files are rebuilt even when inputs match.
(root / mode / "Copie01" / "Ex 1.jpg").unlink()
run(1, question=True)
(workspace.return_dir / "Renamed (02)" / "answers" / "Ex 1.jpg").unlink()
run(1, question=True)
# A failed publication must be retried on the next run.
score.write_text('{"Ex 1": "3"}')
with patch.object(module, "update_named_return_answers", return_value=ExitCode.PARTIAL):
self.assertEqual(module.run(workspace, update_score=True,
return_answers_context=False, return_answers_question=True,
return_answers_solution=False), ExitCode.PARTIAL)
self.assertFalse((score.parent / STATE_FILE).exists())
run(1, question=True)
run(0, question=True)
# Normal reads still force regeneration and invalidate the snapshot.
if mode == "Bnot":
detect.return_value = ([{"label": "Ex 1", "type": "score", "value": 2}], None)
self.assertEqual(module.run(workspace), ExitCode.SUCCESS)
self.assertFalse((score.parent / STATE_FILE).exists())
def test_standard(self):
self.exercise(standard, "Bnot")
def test_grouped(self):
self.exercise(grouped, "BGnot")