Files
Copies/copienator/copy_removal.py
T
2026-11-02 11:35:21 +01:00

196 lines
8.6 KiB
Python

"""Reversible removal of one stable copy ID and its working artifacts."""
from __future__ import annotations
import io
import json
import os
import re
import shutil
import uuid
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from PIL import Image, ImageDraw
from copienator import atomic_write_bytes, read_json
from copienator.evaluation_lock import EvaluationLock
@dataclass(frozen=True)
class RemovalPlan:
root: Path
copy: str
removed: tuple[Path, ...]
updated: dict[Path, bytes]
def available_copies(root: Path) -> list[str]:
names = set()
for folder in (root, root / "Copies", root / "Copies Originales"):
names.update(p.stem for p in folder.glob("Copie*.pdf")
if re.fullmatch(r"Copie[0-9]+", p.stem))
return sorted(names, key=lambda name: int(name[5:]))
def _json_bytes(data) -> bytes:
return (json.dumps(data, ensure_ascii=False, indent=2) + "\n").encode()
def removal_plan(root: Path, copy: str) -> RemovalPlan:
root = root.resolve()
if not re.fullmatch(r"Copie[0-9]+", copy) or copy not in available_copies(root):
raise ValueError("Choisissez une copie existante.")
ident = copy[5:]
def same_id(value):
return isinstance(value, (str, int)) and str(value).removeprefix("Copie").isdecimal() and int(str(value).removeprefix("Copie")) == int(ident)
def owned(name):
match = re.match(r"^Copie([0-9]+)(?:$|[_. -])", name)
return bool(match and same_id(match[1]))
removed = set()
files = []
# Never traverse symlinks or the backups themselves.
for current, dirs, names in os.walk(root, followlinks=False):
directory = Path(current)
dirs[:] = [name for name in dirs if name not in {"BackUp", ".git"}
and not (directory / name).is_symlink()]
for name in list(dirs):
returned = re.search(r" \(([0-9]+)\)$", name) if directory.name == "A Rendre" else None
if owned(name) or (returned and same_id(returned[1])):
removed.add(directory / name)
dirs.remove(name)
for name in names:
path = directory / name
if path.is_symlink():
continue
if owned(name):
removed.add(path)
else:
files.append(path)
updated = {}
dropped = object()
def prune(value, *, rows=False, numeric_keys=False):
if isinstance(value, dict):
if any(same_id(value.get(key)) for key in ("id", "copy_id", "copy", "copie")):
return dropped
result = {}
for key, item in value.items():
if owned(key) or (numeric_keys and same_id(key)):
continue
new = prune(item, rows=rows, numeric_keys=numeric_keys)
if new is not dropped:
result[key] = new
return result
if isinstance(value, list):
if value and ((rows and same_id(value[0])) or
(isinstance(value[0], str) and value[0] == copy)):
return dropped
return [new for item in value
if (new := prune(item, rows=rows, numeric_keys=numeric_keys)) is not dropped]
return value
for path in files:
relative = path.relative_to(root)
# Historical logs and GUI history are audit records, not active inputs.
if "logs" in relative.parts or path.name == ".copienator-gui.json":
continue
if path.name in {"manual_resolutions.txt", "annotation_modifications.txt"}:
text = path.read_text()
pattern = re.compile(r"\bCopie0*" + str(int(ident)) + r"\b")
new_text = "".join(line for line in text.splitlines(keepends=True) if not pattern.search(line))
if new_text != text:
updated[path] = new_text.encode()
if path.suffix == ".json":
data = read_json(path) # Fail before moving anything if an input is malformed.
is_group = path.name.startswith("Group_") and "Par label" in relative.parts
if is_group and isinstance(data, list):
targets = [entry for entry in data if isinstance(entry, list) and entry and same_id(entry[0])]
if targets:
remaining = [entry for entry in data if entry not in targets]
image_path = path.with_suffix(".jpg")
if not remaining:
removed.add(path)
if image_path.exists():
removed.add(image_path)
continue
# Blank the removed answer without shifting other students' coordinates.
from copienator.commands.grouping import LABEL_HEIGHT, SEPARATOR_HEIGHT
with Image.open(image_path) as source:
image = source.convert("RGB")
draw = ImageDraw.Draw(image)
for entry in targets:
top = int(entry[1]) - LABEL_HEIGHT
draw.rectangle((0, max(0, top - SEPARATOR_HEIGHT), image.width, int(entry[2]) - 1), fill="white")
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=95)
updated[image_path] = buffer.getvalue()
numeric_keys = path.name in {"custom_remarks.json", "general_remarks.json"}
new = prune(data, rows=is_group or path.name == "refaire.json", numeric_keys=numeric_keys)
if new is dropped:
removed.add(path)
elif new != data:
updated[path] = _json_bytes(new)
# Cached contact sheets and batch payloads contain several students. Discard
# them as a unit so stale recognition/results cannot reintroduce the copy.
for directory in (root / "Cutleft/Names/Grids",):
if directory.is_dir() and not directory.is_symlink():
removed.add(directory)
for path in files:
if (path.name.startswith(("batch_", "batched_")) or
path.name in {"correction_progress.json", "correction_pending_responses.json",
"histogramme.pdf", "merged_histogramme.pdf"}):
removed.add(path)
# Collapse children when a whole directory is being removed.
removed = {p for p in removed if not any(parent in removed for parent in p.parents)}
updated = {p: data for p, data in updated.items()
if p not in removed and not any(parent in removed for parent in p.parents)}
return RemovalPlan(root, copy, tuple(sorted(removed)), updated)
def remove_copy(root: Path, copy: str) -> Path:
"""Back up every changed file first; roll back all edits on failure."""
root = root.resolve()
if (root / "BackUp").is_symlink():
raise ValueError("BackUp doit être un dossier dans l’évaluation, pas un lien symbolique.")
with EvaluationLock(root):
plan = removal_plan(root, copy)
backup = plan.root / "BackUp" / (datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + copy + "-" + uuid.uuid4().hex[:8])
originals = backup / "files"
originals.mkdir(parents=True)
paths = [*plan.removed, *plan.updated]
for path in paths:
target = originals / path.relative_to(plan.root)
target.parent.mkdir(parents=True, exist_ok=True)
if path.is_dir():
shutil.copytree(path, target, symlinks=True)
else:
shutil.copy2(path, target)
manifest = {"copy": copy, "removed": [str(p.relative_to(plan.root)) for p in plan.removed],
"updated": [str(p.relative_to(plan.root)) for p in plan.updated], "status": "prepared"}
atomic_write_bytes(backup / "manifest.json", _json_bytes(manifest))
changed = []
try:
for path in plan.removed:
changed.append(path)
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
for path, data in plan.updated.items():
changed.append(path)
atomic_write_bytes(path, data)
manifest["status"] = "complete"
atomic_write_bytes(backup / "manifest.json", _json_bytes(manifest))
except Exception:
for path in reversed(changed):
source = originals / path.relative_to(plan.root)
if source.is_dir():
shutil.copytree(source, path, dirs_exist_ok=True, symlinks=True)
else:
shutil.copy2(source, path)
raise
return backup