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>
96 lines
2.9 KiB
Python
96 lines
2.9 KiB
Python
"""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()
|