Manual-commit notebook snapshot extension (JupyterLab 4):
Backend
- Storage adapter layer (Local + S3 via boto3, pip extra)
- Dumb put/get/list/delete contract; key validation
- S3Storage: lazy boto3 import, S3ConnectionError with friendly
messages + connectivity check at startup
- SnapshotStore
- Strip code outputs/execution_count, MD5 on cleaned JSON
- Gzipped envelope (id/timestamp/name/description/hash/size/notebook)
- Append-only manifest.json (rebuildable from version files)
- SnapshotUnchangedError when new hash matches most recent version
- REST API (commit / list / content) under /snapshot/ namespace
- traitlets config (storage_type, local_root, s3_* + S3_* env fallback)
Frontend
- snapshot:commit command (toolbar button + command palette)
- Dialog for name + description
- SnapshotPanel (left sidebar timeline) + DiffWidget (main area)
- Cell diff: id-match first, LCS fallback; jsdiff for line diff
- Restore via model.fromJSON() + context.save() (no refresh)
- All AGENTS.md hard conventions enforced
Tests: 33 backend pytest passing (storage + store + routes)
Docs: AGENTS.md, design.md, README.md synced with implementation.
Co-Authored-By: Claude <noreply@anthropic.com>
219 lines
7.5 KiB
Python
219 lines
7.5 KiB
Python
"""Snapshot store: sanitize, hash, persist, and index notebook versions.
|
|
|
|
Wraps a StorageAdapter and provides:
|
|
- Manual commits with name + description
|
|
- Gzipped JSON envelopes that embed the metadata, so the manifest is
|
|
fully rebuildable from the version files alone.
|
|
- Atomic manifest writes (delegated to the StorageAdapter).
|
|
- Skip-on-duplicate: if the new content hash matches the most recent
|
|
version, ``commit`` raises ``SnapshotUnchangedError`` instead of saving.
|
|
"""
|
|
|
|
import copy
|
|
import gzip
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import time
|
|
|
|
from .storage import StorageAdapter
|
|
|
|
|
|
_VERSION_FILE_RE = re.compile(r"^v_.*\.ipynb\.gz$")
|
|
|
|
|
|
class SnapshotUnchangedError(Exception):
|
|
"""Raised when the new content hash matches the most recent version."""
|
|
|
|
|
|
class SnapshotStore:
|
|
"""High-level notebook version operations on a StorageAdapter."""
|
|
|
|
def __init__(self, storage: StorageAdapter) -> None:
|
|
self._storage = storage
|
|
|
|
# ---- public API --------------------------------------------------------
|
|
|
|
def commit(
|
|
self,
|
|
path: str,
|
|
content: dict,
|
|
name: str,
|
|
description: str = "",
|
|
) -> dict:
|
|
"""Persist a new snapshot and append it to the manifest.
|
|
|
|
If the new content hash matches the most recent version's hash,
|
|
raises :class:`SnapshotUnchangedError` without writing anything.
|
|
This avoids accidental duplicate snapshots when the user re-fires
|
|
the commit command without making changes.
|
|
|
|
Parameters
|
|
----------
|
|
path: str
|
|
The notebook's relative path (acts as a key prefix).
|
|
content: dict
|
|
The notebook JSON as sent by the frontend.
|
|
name: str
|
|
User-provided snapshot name.
|
|
description: str, optional
|
|
User-provided snapshot description.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
The manifest entry (metadata only — no notebook body).
|
|
|
|
Raises
|
|
------
|
|
SnapshotUnchangedError
|
|
If the cleaned content hash equals the most recent version's
|
|
hash. The route layer translates this into a 200 response
|
|
with ``{"skipped": True, "reason": "unchanged"}``.
|
|
"""
|
|
cleaned = self._sanitize(content)
|
|
hash_ = self._hash(cleaned)
|
|
|
|
# Skip-on-duplicate check (compare to the most recent version only).
|
|
manifest = self._read_manifest(path) or {"versions": []}
|
|
existing = manifest.get("versions", [])
|
|
if existing and existing[-1].get("hash") == hash_:
|
|
raise SnapshotUnchangedError(
|
|
f"内容未变更(与最近版本 {existing[-1]['id']} hash 相同),已跳过保存"
|
|
)
|
|
|
|
timestamp = int(time.time() * 1_000_000)
|
|
version_id = f"v_{timestamp}_{hash_[:8]}"
|
|
envelope = {
|
|
"id": version_id,
|
|
"timestamp": timestamp,
|
|
"name": name,
|
|
"description": description,
|
|
"hash": hash_,
|
|
"size": 0, # filled in after first encoding pass
|
|
"notebook": cleaned,
|
|
}
|
|
file_bytes = self._encode_envelope(envelope)
|
|
envelope["size"] = len(file_bytes)
|
|
file_bytes = self._encode_envelope(envelope)
|
|
self._storage.put(self._version_key(path, version_id), file_bytes)
|
|
entry = {
|
|
k: envelope[k]
|
|
for k in ("id", "timestamp", "name", "description", "hash", "size")
|
|
}
|
|
self._append_manifest(path, entry)
|
|
return entry
|
|
|
|
def list_versions(self, path: str) -> list[dict]:
|
|
"""Return the versions for ``path``, newest first.
|
|
|
|
Rebuilds the manifest from the version files if the manifest is
|
|
missing or unparseable.
|
|
"""
|
|
manifest = self._read_manifest(path)
|
|
if manifest is None:
|
|
manifest = self._rebuild_manifest(path)
|
|
return sorted(
|
|
manifest.get("versions", []),
|
|
key=lambda e: e.get("timestamp", 0),
|
|
reverse=True,
|
|
)
|
|
|
|
def get_notebook(self, path: str, version_id: str) -> dict:
|
|
"""Return the cleaned notebook body for ``version_id``.
|
|
|
|
Raises
|
|
------
|
|
KeyError
|
|
If no such version exists.
|
|
"""
|
|
key = self._version_key(path, version_id)
|
|
try:
|
|
raw = self._storage.get(key)
|
|
except KeyError:
|
|
raise KeyError(version_id) from None
|
|
envelope = self._decode_envelope(raw)
|
|
return envelope["notebook"]
|
|
|
|
# ---- internals ---------------------------------------------------------
|
|
|
|
def _prefix(self, path: str) -> str:
|
|
return f"{path}/"
|
|
|
|
def _manifest_key(self, path: str) -> str:
|
|
return f"{self._prefix(path)}manifest.json"
|
|
|
|
def _version_key(self, path: str, version_id: str) -> str:
|
|
return f"{self._prefix(path)}{version_id}.ipynb.gz"
|
|
|
|
def _sanitize(self, content: dict) -> dict:
|
|
"""Strip all code-cell outputs and execution counts."""
|
|
cleaned = copy.deepcopy(content)
|
|
for cell in cleaned.get("cells", []):
|
|
if cell.get("cell_type") == "code":
|
|
cell["outputs"] = []
|
|
cell["execution_count"] = None
|
|
return cleaned
|
|
|
|
def _hash(self, cleaned: dict) -> str:
|
|
encoded = json.dumps(
|
|
cleaned, sort_keys=True, separators=(",", ":")
|
|
).encode()
|
|
return hashlib.md5(encoded).hexdigest()
|
|
|
|
def _encode_envelope(self, envelope: dict) -> bytes:
|
|
return gzip.compress(json.dumps(envelope, ensure_ascii=False).encode())
|
|
|
|
def _decode_envelope(self, raw: bytes) -> dict:
|
|
return json.loads(gzip.decompress(raw).decode())
|
|
|
|
def _read_manifest(self, path: str):
|
|
"""Return the parsed manifest, ``None`` if missing or corrupt.
|
|
|
|
Returning ``None`` (instead of an empty stub) signals the caller to
|
|
rebuild from version files, which also writes a fresh manifest.
|
|
"""
|
|
try:
|
|
raw = self._storage.get(self._manifest_key(path))
|
|
except KeyError:
|
|
return None
|
|
try:
|
|
return json.loads(raw.decode())
|
|
except (ValueError, UnicodeDecodeError):
|
|
return None
|
|
|
|
def _write_manifest(self, path: str, manifest: dict) -> None:
|
|
payload = json.dumps(manifest, ensure_ascii=False).encode()
|
|
self._storage.put(self._manifest_key(path), payload)
|
|
|
|
def _append_manifest(self, path: str, entry: dict) -> None:
|
|
manifest = self._read_manifest(path) or {"versions": []}
|
|
manifest.setdefault("versions", []).append(entry)
|
|
self._write_manifest(path, manifest)
|
|
|
|
def _rebuild_manifest(self, path: str) -> dict:
|
|
prefix = self._prefix(path)
|
|
versions: list[dict] = []
|
|
for key in self._storage.list_keys(prefix=prefix):
|
|
tail = key[len(prefix):]
|
|
if not _VERSION_FILE_RE.match(tail):
|
|
continue
|
|
try:
|
|
envelope = self._decode_envelope(self._storage.get(key))
|
|
except (KeyError, ValueError, OSError):
|
|
continue
|
|
versions.append(
|
|
{
|
|
"id": envelope.get("id"),
|
|
"timestamp": envelope.get("timestamp", 0),
|
|
"name": envelope.get("name", ""),
|
|
"description": envelope.get("description", ""),
|
|
"hash": envelope.get("hash", ""),
|
|
"size": envelope.get("size", 0),
|
|
}
|
|
)
|
|
versions.sort(key=lambda e: e.get("timestamp", 0), reverse=True)
|
|
manifest = {"versions": versions}
|
|
self._write_manifest(path, manifest)
|
|
return manifest
|