初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.persistence import atomic_write_bytes
|
||||
|
||||
|
||||
LIBRARY_ORDER_SCHEMA_VERSION = 1
|
||||
LIBRARY_ITEM_TYPES = frozenset({"template", "animation"})
|
||||
LIBRARY_ORDER_FIELDS = frozenset({"schema_version", "items"})
|
||||
LIBRARY_ITEM_FIELDS = frozenset({"type", "id"})
|
||||
|
||||
|
||||
class LibraryOrderError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class LibraryOrderValidationError(LibraryOrderError):
|
||||
pass
|
||||
|
||||
|
||||
class LibraryOrderConflictError(LibraryOrderError):
|
||||
pass
|
||||
|
||||
|
||||
class LibraryOrderStorageFullError(LibraryOrderError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_library_items(value: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
raise LibraryOrderValidationError("library order items must be an array")
|
||||
normalized: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, dict) or set(item) != LIBRARY_ITEM_FIELDS:
|
||||
raise LibraryOrderValidationError("library order item fields are invalid")
|
||||
item_type = item.get("type")
|
||||
item_id = item.get("id")
|
||||
if item_type not in LIBRARY_ITEM_TYPES:
|
||||
raise LibraryOrderValidationError("library order item type is invalid")
|
||||
if not isinstance(item_id, str):
|
||||
raise LibraryOrderValidationError("library order item id must be a UUID")
|
||||
try:
|
||||
normalized_id = str(UUID(item_id))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise LibraryOrderValidationError("library order item id must be a UUID") from exc
|
||||
if normalized_id != item_id:
|
||||
raise LibraryOrderValidationError("library order item id must be a canonical UUID")
|
||||
key = (item_type, normalized_id)
|
||||
if key in seen:
|
||||
raise LibraryOrderValidationError("library order items must be unique")
|
||||
seen.add(key)
|
||||
normalized.append({"type": item_type, "id": normalized_id})
|
||||
return normalized
|
||||
|
||||
|
||||
def library_order_revision(items: list[dict[str, str]]) -> str:
|
||||
canonical = json.dumps(items, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
class LibraryOrderStore:
|
||||
def __init__(self, data_dir: Path, *, forbidden_ids: set[str] | None = None):
|
||||
self.path = Path(data_dir) / "library" / "order.json"
|
||||
self._forbidden_ids = set(forbidden_ids or ())
|
||||
self._lock = threading.RLock()
|
||||
if self.path.exists():
|
||||
self._read_items()
|
||||
|
||||
@staticmethod
|
||||
def _json_bytes(items: list[dict[str, str]]) -> bytes:
|
||||
document = {
|
||||
"schema_version": LIBRARY_ORDER_SCHEMA_VERSION,
|
||||
"items": items,
|
||||
}
|
||||
return (json.dumps(document, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
|
||||
def _read_items(self) -> list[dict[str, str]] | None:
|
||||
if not self.path.exists():
|
||||
return None
|
||||
try:
|
||||
document = json.loads(self.path.read_bytes().decode("utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise LibraryOrderValidationError("library order file is unreadable") from exc
|
||||
if not isinstance(document, dict) or set(document) != LIBRARY_ORDER_FIELDS:
|
||||
raise LibraryOrderValidationError("library order fields do not match the current schema")
|
||||
version = document.get("schema_version")
|
||||
if type(version) is not int or version != LIBRARY_ORDER_SCHEMA_VERSION:
|
||||
raise LibraryOrderValidationError("library order schema version is unsupported")
|
||||
items = normalize_library_items(document.get("items"))
|
||||
if any(item["id"] in self._forbidden_ids for item in items):
|
||||
raise LibraryOrderValidationError("demo items cannot be included in library order")
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _resolve_items(
|
||||
stored: list[dict[str, str]] | None,
|
||||
defaults: list[dict[str, str]],
|
||||
) -> list[dict[str, str]]:
|
||||
current = {(item["type"], item["id"]) for item in defaults}
|
||||
resolved = [item for item in (stored or []) if (item["type"], item["id"]) in current]
|
||||
included = {(item["type"], item["id"]) for item in resolved}
|
||||
resolved.extend(item for item in defaults if (item["type"], item["id"]) not in included)
|
||||
return resolved
|
||||
|
||||
def get(self, default_items: Any) -> dict[str, Any]:
|
||||
defaults = normalize_library_items(default_items)
|
||||
with self._lock:
|
||||
items = self._resolve_items(self._read_items(), defaults)
|
||||
return {"items": items, "revision": library_order_revision(items)}
|
||||
|
||||
def update(self, items: Any, default_items: Any, expected_revision: str) -> dict[str, Any]:
|
||||
requested = normalize_library_items(items)
|
||||
if any(item["id"] in self._forbidden_ids for item in requested):
|
||||
raise LibraryOrderValidationError("demo items cannot be included in library order")
|
||||
defaults = normalize_library_items(default_items)
|
||||
with self._lock:
|
||||
current = self._resolve_items(self._read_items(), defaults)
|
||||
if library_order_revision(current) != expected_revision:
|
||||
raise LibraryOrderConflictError("library order was changed by another client")
|
||||
requested_set = {(item["type"], item["id"]) for item in requested}
|
||||
current_set = {(item["type"], item["id"]) for item in defaults}
|
||||
if requested_set != current_set:
|
||||
raise LibraryOrderConflictError("library contents changed; refresh before sorting")
|
||||
try:
|
||||
atomic_write_bytes(self.path, self._json_bytes(requested))
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.ENOSPC, getattr(errno, "EDQUOT", errno.ENOSPC)}:
|
||||
raise LibraryOrderStorageFullError("not enough disk space to save library order") from exc
|
||||
raise
|
||||
return {"items": requested, "revision": library_order_revision(requested)}
|
||||
Reference in New Issue
Block a user