dev: initial implementation of notebook-snapshot extension

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>
This commit is contained in:
tao.chen
2026-07-21 17:33:55 +08:00
co-authored by Claude
commit cd1aba6698
67 changed files with 17926 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
"""Local filesystem storage adapter."""
import os
from pathlib import Path
from .base import StorageAdapter, validate_key
class LocalFileStorage(StorageAdapter):
"""Store objects as files under a root directory on the local filesystem."""
def __init__(self, root: str | Path) -> None:
"""Initialize the adapter with a root directory.
Parameters
----------
root: str | Path
The root directory under which all objects are stored.
"""
self._root = Path(root).expanduser().resolve()
self._root.mkdir(parents=True, exist_ok=True)
def _key_to_path(self, key: str) -> Path:
"""Resolve a storage key to a path inside the root directory.
Raises
------
ValueError
If the resolved path would escape the root directory.
"""
path = (self._root / key).resolve()
if self._root not in path.parents and path != self._root:
raise ValueError(f"Resolved path {path} escapes root {self._root}")
return path
def put(self, key: str, data: bytes) -> None:
"""Store ``data`` under ``key`` atomically.
Writes to a temporary file in the target directory and renames it into
place. Parent directories are created as needed.
"""
validate_key(key)
path = self._key_to_path(key)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.parent / f".{path.name}.tmp-{os.getpid()}"
try:
tmp_path.write_bytes(data)
os.replace(str(tmp_path), str(path))
finally:
if tmp_path.exists():
tmp_path.unlink()
def get(self, key: str) -> bytes:
"""Return the bytes stored under ``key``.
Raises
------
KeyError
If the file does not exist.
"""
validate_key(key)
path = self._key_to_path(key)
if not path.is_file():
raise KeyError(key)
return path.read_bytes()
def list_keys(self, prefix: str = "") -> list[str]:
"""Return a sorted list of file keys starting with ``prefix``."""
if prefix:
validate_key(prefix)
keys = []
for item in self._root.rglob("*"):
if item.is_file():
rel_key = item.relative_to(self._root).as_posix()
if rel_key.startswith(prefix):
keys.append(rel_key)
return sorted(keys)
def delete(self, key: str) -> None:
"""Delete the file at ``key``.
Idempotent: no error if the file is already missing.
"""
validate_key(key)
path = self._key_to_path(key)
try:
path.unlink()
except FileNotFoundError:
return
def exists(self, key: str) -> bool:
"""Return whether ``key`` exists as a regular file."""
validate_key(key)
path = self._key_to_path(key)
return path.is_file()