vibe-sketcherDocumentation
Source code for vibesketcher.document.io
"""Read and write ``.vsk`` files.
A document is UTF-8 JSON, indented by two spaces, with a trailing newline,
so a ``.vsk`` diffs cleanly under version control. Writing is atomic: the
file is written beside its target and renamed over it, so an interrupted
save never leaves half a document.
"""
from __future__ import annotations
import json
import os
import stat
import tempfile
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pydantic import ValidationError
from vibesketcher.document.model import Document, Edit, Provenance
__all__ = [
"ABSENT",
"DocumentError",
"SaveRefused",
"check_output",
"digest_of",
"dumps",
"load",
"loads",
"new_document",
"save",
"validation_errors",
"with_edit",
"write_output",
]
#: The file extension of a vibe-sketcher document.
SUFFIX = ".vsk"
[docs]
class DocumentError(ValueError):
"""A document that is not valid JSON or not a valid vibe-sketcher document.
``errors`` lists every problem as ``"location: message"``, the form the
CLI prints and the REST API returns.
"""
def __init__(self, errors: list[str]) -> None:
super().__init__("invalid vibe-sketcher document:\n " + "\n ".join(errors))
self.errors = errors
[docs]
class SaveRefused(ValueError):
"""Writing would discard QVF result data or replace a changed target."""
[docs]
def validation_errors(error: ValidationError) -> list[str]:
"""Readable ``location: message`` lines for a pydantic validation error."""
lines = []
for item in error.errors(include_url=False):
location = ".".join(str(part) for part in item["loc"]) or "document"
lines.append(f"{location}: {item['msg']}")
return lines
def from_data(data: Any) -> Document:
"""Validate already-parsed JSON data as a document, within :mod:`vibesketcher.limits`."""
from vibesketcher.limits import check_depth
try:
check_depth(data, "the document")
except ValueError as error:
raise DocumentError([f"document: {error}"]) from None
try:
return Document.model_validate(data)
except ValidationError as error:
raise DocumentError(validation_errors(error)) from error
[docs]
def loads(text: str) -> Document:
"""Parse and validate a document from JSON text, within :mod:`vibesketcher.limits`."""
from vibesketcher.limits import parse_json
try:
data = parse_json(text, "the document")
except ValueError as error:
raise DocumentError([f"document: {error}"]) from error
return from_data(data)
[docs]
def load(path: str | os.PathLike[str]) -> Document:
"""Read and validate a document: a ``.qvf`` archive or a ``.vsk`` file."""
return _read_snapshot(path).document
@dataclass(frozen=True)
class _DocumentSnapshot:
"""One bounded read: parsed contents and their exact source, including absence.
The digest is derived from the bytes parsed below, never from a subsequent
filesystem lookup. A Store may bind this receipt without rereading the file.
"""
document: Document
path: Path
destination: Path
digest: str
result: dict[str, Any] | None = None
def _read_snapshot(
path: str | os.PathLike[str], *, allow_missing: bool = False
) -> _DocumentSnapshot:
import hashlib
from vibesketcher.limits import MAX_INPUT_BYTES, MAX_QVF_BYTES, LimitExceeded, read_file
source = Path(path).absolute()
destination = source.resolve()
qvf = source.suffix.lower() == ".qvf"
try:
content = read_file(
destination, source.name, limit=MAX_QVF_BYTES if qvf else MAX_INPUT_BYTES
)
except FileNotFoundError:
if not allow_missing:
raise
# Do not look again: a file created after this observation must not
# become the baseline of a new, empty document's first save.
_check_destination(source, destination)
return _DocumentSnapshot(new_document(), source, destination, ABSENT)
except LimitExceeded as error:
raise DocumentError([f"{source.name}: {error}"]) from None
result = None
if qvf:
from vibesketcher.document.qvf import QvfError, from_qvf
try:
derived = from_qvf(content)
except QvfError as error:
raise DocumentError([f"{source.name}: {error}"]) from None
document, result = derived.document, derived.result
else:
try:
document = loads(content.decode("utf-8"))
except UnicodeDecodeError as error:
raise DocumentError([f"{source.name}: not UTF-8 text ({error})"]) from None
_check_destination(source, destination)
return _DocumentSnapshot(
document, source, destination, hashlib.sha256(content).hexdigest(), result
)
[docs]
def dumps(document: Document) -> str:
"""The document as ``.vsk`` text."""
return json.dumps(document.to_json_data(), indent=2, ensure_ascii=False) + "\n"
_UMASK_LOCK = threading.Lock()
def _new_file_mode() -> int:
"""The mode a newly created file gets under the current umask."""
with _UMASK_LOCK: # reading the umask means setting it; keep threads out
umask = os.umask(0o022)
os.umask(umask)
return 0o666 & ~umask
#: What :func:`digest_of` says of a file that does not exist.
ABSENT = "absent"
_CHANGED = (
"The file changed outside vibe-sketcher since it was opened or last saved, so saving "
"would discard that change. Reopen the file to see it, or keep this version with "
"Save As or a download under another name."
)
[docs]
def digest_of(path: str | os.PathLike[str]) -> str:
"""The SHA-256 of a file's bytes, or :data:`ABSENT` when there is no file."""
import hashlib
try:
digest = hashlib.sha256()
with Path(path).open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
except FileNotFoundError:
return ABSENT
[docs]
def save(document: Document, path: str | os.PathLike[str], *, expect: str | None = None) -> Path:
"""Write ``document`` to ``path`` atomically; returns the path written.
A ``.qvf`` path gets a QVF archive (raising
:class:`~vibesketcher.document.qvf.QvfUnavailable` without qvf-writer), any
other a ``.vsk``. A file that exists keeps its permissions; a new one gets
the umask's. QVF results and archives with data outside the native sketch
raise :class:`SaveRefused`; save the sketch to another path instead.
With ``expect`` (a :func:`digest_of`), a file that no longer holds those
bytes is not replaced either.
"""
return _save_with_digest(document, path, expect=expect)[0]
def _save_with_digest(
document: Document,
path: str | os.PathLike[str],
*,
expect: str | None = None,
destination: Path | None = None,
) -> tuple[Path, str]:
"""Save once and bind the receipt to the serialized bytes, not a later file read."""
import hashlib
target = Path(path)
if destination is not None:
_check_destination(target, destination)
if target.suffix.lower() == ".qvf":
from vibesketcher.document.qvf import to_qvf
content = to_qvf(document)
else:
content = dumps(document).encode("utf-8")
written = _write_output(target, content, expect=expect, destination=destination)
digest = hashlib.sha256(content).hexdigest()
if digest_of(written) != digest:
raise SaveRefused(_CHANGED)
return written, digest
def _qvf_snapshot(target: Path) -> bytes | None:
from vibesketcher.limits import MAX_QVF_BYTES, LimitExceeded, read_file
try:
return read_file(target, target.name, limit=MAX_QVF_BYTES)
except FileNotFoundError:
return None
except LimitExceeded as error:
raise SaveRefused(f"Cannot inspect the QVF destination: {error}. Use Save As.") from None
[docs]
def check_output(path: str | os.PathLike[str]) -> None:
"""Refuse a protected QVF destination before an operation with side effects.
:func:`write_output` checks again at the actual write; this preflight is
useful before submitting jobs whose run records must be saved in place.
"""
target = Path(path)
if ".qvf" in (target.suffix.lower(), target.resolve().suffix.lower()):
from vibesketcher.document.qvf import overwrite_refusal
existing = _qvf_snapshot(target)
if existing is not None and (reason := overwrite_refusal(existing)):
raise SaveRefused(reason)
[docs]
def write_output(
path: str | os.PathLike[str], content: bytes, *, expect: str | None = None
) -> Path:
"""Atomically write exported bytes, protecting existing QVF result files.
This also guards explicit format overrides (such as SVG sent to a QVF
filename). Symlinks retain their link and write through to their target.
With ``expect``, the file must still hold the bytes that :func:`digest_of`
measured (or still be absent), before writing and again at the rename;
otherwise :class:`SaveRefused`, and the file is left as it is.
The new bytes reach the disk before they replace the old ones, and the
directory entry after, so a crash or a full disk leaves either the old file
or the new one, never a mixture. A crash at the wrong moment can leave a
hidden ``.NAME.*.tmp`` beside it, which is safe to delete.
"""
return _write_output(path, content, expect=expect)
def _write_output(
path: str | os.PathLike[str],
content: bytes,
*,
expect: str | None = None,
destination: Path | None = None,
) -> Path:
"""Use a caller's persistent destination when supplied, without resolving a new one."""
original = Path(path)
target = original.resolve() if destination is None else destination
_check_destination(original, target)
if expect is not None and digest_of(target) != expect:
raise SaveRefused(_CHANGED)
guarded = ".qvf" in (original.suffix.lower(), target.suffix.lower())
previous = _qvf_snapshot(target) if guarded else None
if guarded and previous is not None:
from vibesketcher.document.qvf import overwrite_refusal
if reason := overwrite_refusal(previous):
raise SaveRefused(reason)
mode = stat.S_IMODE(target.stat().st_mode) if target.exists() else _new_file_mode()
descriptor, temporary = tempfile.mkstemp(
prefix=f".{target.name}.", suffix=".tmp", dir=target.parent
)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, mode)
if guarded and _qvf_snapshot(target) != previous:
raise SaveRefused("The QVF destination changed during saving. Use Save As.")
if expect is not None and digest_of(target) != expect:
raise SaveRefused(_CHANGED)
_check_destination(original, target)
os.replace(temporary, target)
except BaseException:
Path(temporary).unlink(missing_ok=True)
raise
_sync_directory(target.parent)
_check_destination(original, target)
return original
def _check_destination(original: Path, target: Path) -> None:
"""A selected alias or symlinked parent must still identify the same destination."""
try:
unchanged = original.resolve() == target
except (OSError, RuntimeError):
unchanged = False
if not unchanged:
raise SaveRefused("The save destination changed during saving. Reopen it or use Save As.")
def _sync_directory(directory: Path) -> None:
"""Make a rename in ``directory`` durable; a no-op where directories cannot be synced."""
flags = getattr(os, "O_DIRECTORY", None)
if flags is None:
return
try:
descriptor = os.open(directory, os.O_RDONLY | flags)
except OSError:
return
try:
os.fsync(descriptor)
except OSError:
pass # some file systems refuse; the file itself is already synced
finally:
os.close(descriptor)
[docs]
def new_document(app: str | None = None) -> Document:
"""An empty document stamped with this application and the current time."""
if app is None:
from vibesketcher import __version__
app = f"vibe-sketcher {__version__}"
return Document(provenance=Provenance(app=app))
[docs]
def with_edit(document: Document, *, actor: str, client: str, summary: str) -> Document:
"""``document`` with an edit recorded for its next revision.
A run of alike edits (same actor, client and summary) folds into one
entry, so a drawing session does not leave hundreds. The revision itself
is the caller's to advance: the server's store does it on commit.
"""
from datetime import UTC, datetime
now = datetime.now(UTC).replace(microsecond=0)
edits = list(document.provenance.edits)
revision = document.revision + 1
if edits and (edits[-1].actor, edits[-1].client, edits[-1].summary) == (actor, client, summary):
edits[-1] = edits[-1].model_copy(update={"revision": revision, "at": now})
else:
fields = {"actor": actor, "client": client, "summary": summary}
edits.append(Edit.model_validate({"revision": revision, "at": now, **fields}))
provenance = document.provenance.model_copy(update={"edits": edits})
return document.model_copy(update={"provenance": provenance})