EvaluationWorkspace

This commit is contained in:
2026-08-20 12:44:58 +02:00
parent ac4ab782b2
commit 2f1cd00e32
11 changed files with 746 additions and 33 deletions
+25
View File
@@ -0,0 +1,25 @@
"""Core building blocks shared by Copienator scripts and interfaces."""
from .json_io import (
JsonLockTimeout,
atomic_update_json,
atomic_write_json,
atomic_write_text,
read_json,
)
from .workspace import (
EvaluationWorkspace,
WorkspaceNotFoundError,
WorkspaceValidationError,
)
__all__ = [
"EvaluationWorkspace",
"JsonLockTimeout",
"WorkspaceNotFoundError",
"WorkspaceValidationError",
"atomic_update_json",
"atomic_write_json",
"atomic_write_text",
"read_json",
]
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
import json
import os
import stat
import tempfile
import time
from collections.abc import Callable
from contextlib import contextmanager
from pathlib import Path
from typing import Any, BinaryIO, TypeVar, cast
JsonValue = dict[str, Any] | list[Any] | str | int | float | bool | None
T = TypeVar("T", bound=JsonValue)
_MISSING = object()
class JsonLockTimeout(TimeoutError):
pass
def read_json(path: str | Path, *, default: T | object = _MISSING) -> T:
target = Path(path)
try:
with target.open("r", encoding="utf-8") as stream:
return cast(T, json.load(stream))
except FileNotFoundError:
if default is _MISSING:
raise
return cast(T, default)
def _sync_directory(directory: Path) -> None:
if os.name == "nt" or not hasattr(os, "O_DIRECTORY"):
return
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _atomic_write(path: Path, payload: bytes) -> None:
path = path.expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
previous_mode = None
try:
previous_mode = stat.S_IMODE(path.stat().st_mode)
except FileNotFoundError:
pass
descriptor, temporary_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
if previous_mode is not None:
os.chmod(temporary, previous_mode)
os.replace(temporary, path)
_sync_directory(path.parent)
finally:
if temporary.exists():
temporary.unlink()
def atomic_write_text(
path: str | Path,
text: str,
*,
encoding: str = "utf-8",
) -> None:
_atomic_write(Path(path), text.encode(encoding))
def atomic_write_json(
path: str | Path,
value: JsonValue,
*,
indent: int | None = 2,
ensure_ascii: bool = False,
sort_keys: bool = False,
) -> None:
serialized = json.dumps(
value,
indent=indent,
ensure_ascii=ensure_ascii,
sort_keys=sort_keys,
)
atomic_write_text(path, serialized + "\n")
def _prepare_lock_file(stream: BinaryIO) -> None:
stream.seek(0, os.SEEK_END)
if stream.tell() == 0:
stream.write(b"\0")
stream.flush()
def _try_lock(stream: BinaryIO) -> None:
stream.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
def _unlock(stream: BinaryIO) -> None:
stream.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
@contextmanager
def json_file_lock(path: str | Path, *, timeout: float = 10.0):
target = Path(path).expanduser()
target.parent.mkdir(parents=True, exist_ok=True)
lock_path = target.with_name(f".{target.name}.lock")
deadline = time.monotonic() + timeout
with lock_path.open("a+b") as stream:
_prepare_lock_file(stream)
while True:
try:
_try_lock(stream)
break
except (BlockingIOError, PermissionError, OSError) as exc:
if time.monotonic() >= deadline:
raise JsonLockTimeout(
f"Could not acquire JSON lock within {timeout:.1f}s: {lock_path}"
) from exc
time.sleep(0.05)
try:
yield
finally:
_unlock(stream)
def atomic_update_json(
path: str | Path,
update: Callable[[T], T | None],
*,
default_factory: Callable[[], T],
timeout: float = 10.0,
indent: int | None = 2,
) -> T:
target = Path(path)
with json_file_lock(target, timeout=timeout):
current = read_json(target, default=default_factory())
updated = update(current)
result = current if updated is None else updated
atomic_write_json(target, result, indent=indent)
return result
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
class WorkspaceNotFoundError(FileNotFoundError):
pass
class WorkspaceValidationError(ValueError):
def __init__(self, root: Path, missing: list[str]) -> None:
self.root = root
self.missing = missing
super().__init__(
f"Invalid evaluation workspace {root}: missing {', '.join(missing)}"
)
@dataclass(frozen=True, slots=True)
class EvaluationWorkspace:
root: Path
repository: Path | None = None
def __post_init__(self) -> None:
object.__setattr__(self, "root", Path(self.root).expanduser().resolve())
if self.repository is not None:
object.__setattr__(
self, "repository", Path(self.repository).expanduser().resolve()
)
@classmethod
def discover(
cls,
target: str | Path,
*,
repository: str | Path | None = None,
) -> EvaluationWorkspace:
candidate = Path(target).expanduser().resolve()
if candidate.is_file():
candidate = candidate.parent
repository_path = Path(repository).expanduser().resolve() if repository else None
for directory in (candidate, *candidate.parents):
if cls.looks_like_evaluation(directory):
return cls(directory, repository_path)
if repository_path is not None and directory == repository_path:
break
raise WorkspaceNotFoundError(
f"Could not find an evaluation workspace from: {target}"
)
@staticmethod
def looks_like_evaluation(path: Path) -> bool:
markers = (
"enonce.pdf",
"enonce.tex",
"labels",
"Copies",
"correction.json",
".copienator",
".copienator-gui.json",
)
return path.is_dir() and any((path / marker).exists() for marker in markers)
@property
def name(self) -> str:
return self.root.name
@property
def metadata_dir(self) -> Path:
return self.root / ".copienator"
@property
def logs_dir(self) -> Path:
return self.metadata_dir / "logs"
@property
def runs_dir(self) -> Path:
return self.metadata_dir / "runs"
@property
def state_database(self) -> Path:
return self.metadata_dir / "state.sqlite3"
@property
def gui_state_file(self) -> Path:
return self.root / ".copienator-gui.json"
@property
def labels_file(self) -> Path:
return self.root / "labels"
@property
def correction_file(self) -> Path:
return self.root / "correction.json"
@property
def correction_progress_file(self) -> Path:
return self.root / "correction_progress.json"
@property
def manual_resolutions_file(self) -> Path:
return self.root / "manual_resolutions.txt"
@property
def refaire_file(self) -> Path:
return self.root / "refaire.json"
@property
def copies_dir(self) -> Path:
return self.root / "Copies"
@property
def original_copies_dir(self) -> Path:
return self.root / "Copies Originales"
@property
def cutleft_dir(self) -> Path:
return self.root / "Cutleft"
@property
def groups_dir(self) -> Path:
return self.root / "Par label"
@property
def text_dir(self) -> Path:
return self.root / "Text"
@property
def solution_dir(self) -> Path:
return self.root / "Sol"
@property
def rendered_text_dir(self) -> Path:
return self.root / "Text2"
@property
def rendered_solution_dir(self) -> Path:
return self.root / "Sol2"
@property
def rubric_dir(self) -> Path:
return self.root / "Persp"
@property
def return_dir(self) -> Path:
return self.root / "A Rendre"
def annotation_dir(self, mode: str) -> Path:
directories = {
"simple": "Anot",
"checks": "Bnot",
"grouped": "BGnot",
"refaire": "BRnot",
}
try:
return self.root / directories[mode]
except KeyError as exc:
choices = ", ".join(directories)
raise ValueError(
f"Unknown annotation mode {mode!r}; expected one of: {choices}"
) from exc
def names_file(self) -> Path:
local = self.root / "names"
if local.exists() or self.repository is None:
return local
return self.repository / "names"
def read_labels(self) -> list[str]:
return [
line.strip()
for line in self.labels_file.read_text(encoding="utf-8").splitlines()
if line.strip()
]
def require(self, *relative_paths: str) -> None:
missing = [
relative_path
for relative_path in relative_paths
if not (self.root / relative_path).exists()
]
if missing:
raise WorkspaceValidationError(self.root, missing)
def ensure_control_directories(self) -> None:
self.logs_dir.mkdir(parents=True, exist_ok=True)
self.runs_dir.mkdir(parents=True, exist_ok=True)
def command_argument(self) -> str:
if self.repository is not None:
try:
return str(self.root.relative_to(self.repository))
except ValueError:
pass
return str(self.root)
def log_path(self, step_id: str, when: datetime | None = None) -> Path:
timestamp = (when or datetime.now().astimezone()).strftime("%Y%m%d-%H%M%S")
safe_step = re.sub(r"[^A-Za-z0-9_.-]+", "_", step_id)
return self.logs_dir / f"{timestamp}-{safe_step}.log"