Copy removal
This commit is contained in:
1 parent
c36642e8de
commit
213576f83d
8 files changed
+464
-26
No files matched your search
@@ -153,7 +153,8 @@ def build_cleanup_plan(workspace: EvaluationWorkspace) -> CleanupPlan:
|
||||
|
||||
files, directories = _workspace_entries(workspace.root)
|
||||
|
||||
kept_files = set(processed_copies)
|
||||
kept_files = {path for path in files if path.is_relative_to(workspace.root / "BackUp")}
|
||||
kept_files.update(processed_copies)
|
||||
kept_files.add(workspace.correction_file)
|
||||
lock_file = workspace.root / ".copienator-finalisation.lock"
|
||||
if lock_file in files:
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user