from __future__ import annotations import json import shutil from pathlib import Path from threading import RLock from typing import Iterable from uuid import UUID from app.storage.models import DATA_FORMAT_VERSION, StoragePayloadV1, StoredTask, upgrade_payload class StorageError(Exception): """Base storage error.""" class StorageTaskNotFoundError(StorageError): """Task was not found in storage.""" class JsonFileTaskRepository: """ File-based repository with: - versioned payload format - atomic writes via temporary file + replace - corruption handling with backup + reset """ def __init__(self, file_path: str | Path) -> None: self._file_path = Path(file_path) self._lock = RLock() self._ensure_parent_dir() self._ensure_storage_exists() @property def data_format_version(self) -> int: return DATA_FORMAT_VERSION def list_tasks(self) -> list[StoredTask]: with self._lock: payload = self._load_payload() return list(payload.tasks) def get_task(self, task_id: UUID) -> StoredTask | None: with self._lock: payload = self._load_payload() for task in payload.tasks: if task.id == task_id: return task return None def create_task(self, task: StoredTask) -> StoredTask: with self._lock: payload = self._load_payload() payload.tasks.append(task) self._save_payload(payload) return task def update_task(self, task: StoredTask) -> StoredTask: with self._lock: payload = self._load_payload() updated = False new_tasks: list[StoredTask] = [] for existing_task in payload.tasks: if existing_task.id == task.id: new_tasks.append(task) updated = True else: new_tasks.append(existing_task) if not updated: raise StorageTaskNotFoundError(f"Task {task.id} not found") payload.tasks = new_tasks self._save_payload(payload) return task def delete_task(self, task_id: UUID) -> bool: with self._lock: payload = self._load_payload() initial_count = len(payload.tasks) payload.tasks = [task for task in payload.tasks if task.id != task_id] if len(payload.tasks) == initial_count: return False self._save_payload(payload) return True def replace_all(self, tasks: Iterable[StoredTask]) -> None: with self._lock: payload = StoragePayloadV1( version=DATA_FORMAT_VERSION, tasks=list(tasks), ) self._save_payload(payload) def _ensure_parent_dir(self) -> None: self._file_path.parent.mkdir(parents=True, exist_ok=True) def _ensure_storage_exists(self) -> None: if self._file_path.exists(): return self._save_payload( StoragePayloadV1( version=DATA_FORMAT_VERSION, tasks=[], ), ) def _load_payload(self) -> StoragePayloadV1: if not self._file_path.exists(): payload = StoragePayloadV1( version=DATA_FORMAT_VERSION, tasks=[], ) self._save_payload(payload) return payload try: raw_text = self._file_path.read_text(encoding="utf-8") raw_data = json.loads(raw_text) return upgrade_payload(raw_data) except Exception: self._backup_corrupted_file() reset_payload = StoragePayloadV1( version=DATA_FORMAT_VERSION, tasks=[], ) self._save_payload(reset_payload) return reset_payload def _save_payload(self, payload: StoragePayloadV1) -> None: tmp_path = self._file_path.with_name(f"{self._file_path.name}.tmp") serialized = json.dumps( payload.model_dump(mode="json"), ensure_ascii=False, indent=2, ) tmp_path.write_text(serialized + "\n", encoding="utf-8") tmp_path.replace(self._file_path) def _backup_corrupted_file(self) -> None: if not self._file_path.exists(): return backup_path = self._next_backup_path() shutil.copy2(self._file_path, backup_path) def _next_backup_path(self) -> Path: base_name = f"{self._file_path.name}.corrupted" candidate = self._file_path.with_name(base_name) if not candidate.exists(): return candidate index = 1 while True: candidate = self._file_path.with_name(f"{base_name}.{index}") if not candidate.exists(): return candidate index += 1