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 PIL import Image, ImageDraw
from copienator import CliError, atomic_write_json, read_json, utils 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 from copienator.commands.resolve_manual import ManualInstruction, parse_instruction_text
DRAFT = "annotation_modifications.txt" 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, ...]: def output_removals(directory: Path) -> tuple[str, ...]:
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {} 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() *(f"{label}.jpg" for label, entry in info.items()
if Path(label).name == label)) 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 math
import shutil import shutil
import sys import sys
from decimal import Decimal, InvalidOperation, ROUND_CEILING
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from copienator import atomic_write_json, read_json
from copienator.configuration import ( from copienator.configuration import (
FINAL_SCORE_FONT_PATH, FINAL_SCORE_FONT_PATH,
FINAL_SCORE_HISTOGRAM_PATH, FINAL_SCORE_HISTOGRAM_PATH,
@@ -72,6 +74,39 @@ def copy_histogram(output_dir: Path, *, copy_merged_histogramme: bool = False) -
shutil.copy2(source, destination) shutil.copy2(source, destination)
print(f"Copied histogram: {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): def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
# 1. Load Data # 1. Load Data
try: try:
@@ -152,6 +187,7 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
for pdf_path in pdf_paths: for pdf_path in pdf_paths:
shutil.copy2(pdf_path, student_output / pdf_path.name) 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) copy_histogram(output_dir, copy_merged_histogramme=copy_merged_histogramme)
+9 -3
View File
@@ -4,7 +4,7 @@ import argparse
import re import re
import sys import sys
from collections import defaultdict from collections import defaultdict
from collections.abc import Sequence from collections.abc import Collection, Sequence
from pathlib import Path from pathlib import Path
from copienator import ( from copienator import (
@@ -77,8 +77,12 @@ def _annotation_source(
def update_named_return_answers( def update_named_return_answers(
workspace: EvaluationWorkspace, workspace: EvaluationWorkspace,
annotation_dir_name: str, annotation_dir_name: str,
*,
copy_ids: Collection[str] | None = None,
) -> ExitCode: ) -> 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") workspace.require_directories(annotation_dir_name, "A Rendre")
had_errors = False had_errors = False
found = False found = False
@@ -86,6 +90,8 @@ def update_named_return_answers(
if not destination.is_dir(): if not destination.is_dir():
continue continue
match = RETURN_COPY_ID.search(destination.name) 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: if match is None:
print( print(
f"Warning: cannot identify a copy ID in {destination.name!r}; skipped", 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: except (OSError, TypeError, ValueError) as exc:
print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr) print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True 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) print("Warning: no identifiable student folders found in A Rendre", file=sys.stderr)
had_errors = True had_errors = True
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS 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_data import AnnotationData, load_annotation_data
from copienator.annotation_documents import copy_documents from copienator.annotation_documents import copy_documents
from copienator.annotation_layout import legacy_layout from copienator.annotation_layout import legacy_layout
from copienator.annotation_read_cache import AnnotationReadCache
from copienator.annotation_modifications import ( from copienator.annotation_modifications import (
BASELINE, BASELINE,
output_removals, output_removals,
@@ -31,6 +32,7 @@ from copienator.annotation_modifications import (
) )
from copienator.answer_info import build_answer_info from copienator.answer_info import build_answer_info
from copienator.commands import annotating from copienator.commands import annotating
from copienator.commands.giving_names import update_named_return_answers
from copienator.filesystem import staged_files from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options from copienator.return_answers import save_return_answer_options
@@ -324,7 +326,12 @@ def run(
return ExitCode.PARTIAL return ExitCode.PARTIAL
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS 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): 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}" output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
only_empty = all(content["result"].get("error") == "empty-answer" only_empty = all(content["result"].get("error") == "empty-answer"
for content in loaded.data[student_id].values()) for content in loaded.data[student_id].values())
@@ -372,6 +379,12 @@ def run(
) )
if result != ExitCode.SUCCESS: if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL 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 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_data import AnnotationData, RefaireList, load_annotation_data
from copienator.annotation_documents import copy_documents, tablet_documents from copienator.annotation_documents import copy_documents, tablet_documents
from copienator.annotation_layout import legacy_layout from copienator.annotation_layout import legacy_layout
from copienator.annotation_read_cache import AnnotationReadCache
from copienator.annotation_modifications import ( from copienator.annotation_modifications import (
BASELINE, BASELINE,
baseline, baseline,
@@ -36,6 +37,7 @@ from copienator.annotation_modifications import (
) )
from copienator.answer_info import build_answer_info from copienator.answer_info import build_answer_info
from copienator.commands import annotating from copienator.commands import annotating
from copienator.commands.giving_names import update_named_return_answers
from copienator.commands.reading_annotations import ( from copienator.commands.reading_annotations import (
concatenate, concatenate,
detect_checks_and_notes, detect_checks_and_notes,
@@ -594,6 +596,18 @@ def run(
and path.is_dir() and path.is_dir()
and not path.name.startswith("Copie") 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. # Each worker decodes a full-height returned group and its reference image.
# Keep this stage deliberately narrow; answer regeneration below has its own # Keep this stage deliberately narrow; answer regeneration below has its own
# parallel executor and a much smaller per-task memory footprint. # parallel executor and a much smaller per-task memory footprint.
@@ -657,6 +671,7 @@ def run(
if refaire if refaire
else sorted(loaded.data, key=utils.natural_key) else sorted(loaded.data, key=utils.natural_key)
) )
regenerated: set[str] = set()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
futures = { futures = {
executor.submit( executor.submit(
@@ -676,13 +691,19 @@ def run(
), ),
): student_id ): student_id
for student_id in student_ids 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): for future in concurrent.futures.as_completed(futures):
result, output = future.result() result, output = future.result()
print(output) print(output)
if result != ExitCode.SUCCESS: if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL 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 return status
+4 -2
View File
@@ -778,7 +778,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Étapes personnelles", "Étapes personnelles",
"Renseigner le barème", "Renseigner le barème",
"Exécute gestion_classe we.", "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, personal=True,
), ),
StepDefinition( StepDefinition(
@@ -823,7 +824,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Ajouter le score final", "Ajouter le score final",
"Prérequis immédiat : gestion_classe wse doit avoir été exécuté juste avant. " "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 " "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),), (python("default", "Score final", "add-final-score", supports_verbose=False),),
arguments=( arguments=(
arg_target("le dossier de l’évaluation"), 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` overrides via `read-grouped --update-score` (or `read-annotations --update-score`
for `Bnot`). Editing the JSON alone does not update the rendered scores. Overrides 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 are read from the annotation source folder: a return symlink points there, but
an independent fallback copy does not. After regeneration, rerun `giving-names` an independent fallback copy does not. Successful annotation rereads automatically
to refresh copied return files. 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 `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 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. outputs whose source is now absent, including broken symlinks.
- `add-final-score` writes to `FINAL_SCORE_OUTPUT_DIR/<evaluation>/`. It stamps - `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, 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 rounded down to one decimal place. It copies PDFs unchanged, together with
either JSON file or the `answers/` directory. It does not add that mark to the `score.json`, `info.json` and the optional `answers/` directory. Alongside the
files inside `A Rendre`. 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, - The `clean` command retains return images (including individual answers), PDFs,
`score.json` and `info.json`, materializing `score.json` and `info.json`, materializing
retained symlinks before deleting their sources. 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 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 à score.json** sert à mettre à jour les scores sur les images finales à
partir d'une modification manuelle des fichiers `score.json`. 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 ### Modifier les réponses annotées
+23
View File
@@ -9,9 +9,31 @@ import pandas as pd
from PIL import Image from PIL import Image
from copienator.commands import add_final_score from copienator.commands import add_final_score
from copienator import atomic_write_json, read_json
class AddFinalScoreTests(unittest.TestCase): 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): def test_merged_histogram_is_copied_only_when_selected(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) root = Path(temporary)
@@ -80,6 +102,7 @@ class AddFinalScoreTests(unittest.TestCase):
self.assertFalse((output / "Student.pdf").exists()) self.assertFalse((output / "Student.pdf").exists())
self.assertFalse((student / "answers" / "001 - Ex 1.jpg").exists()) self.assertFalse((student / "answers" / "001 - Ex 1.jpg").exists())
self.assertEqual((output / "histogramme.pdf").read_bytes(), b"histogram") 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): def test_omits_answers_directory_when_it_was_not_generated(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
+113 -1
View File
@@ -8,8 +8,10 @@ from unittest.mock import patch
from PIL import Image 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 annotating, giving_names
from copienator.commands import reading_annotations as standard
from copienator.commands import reading_grouped_annotations as reader from copienator.commands import reading_grouped_annotations as reader
from copienator.return_answers import ( from copienator.return_answers import (
publish_answer_returns, 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): class CompiledMembershipTests(unittest.TestCase):
def test_update_score_preserves_file_and_manual_value_wins(self): def test_update_score_preserves_file_and_manual_value_wins(self):
with tempfile.TemporaryDirectory() as directory: 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")