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>
128 lines
3.1 KiB
Python
128 lines
3.1 KiB
Python
"""Abstract storage adapter for notebook snapshot backends.
|
|
|
|
This module defines the common interface for dumb bytes buckets. It has no
|
|
knowledge of notebooks, versions, manifests, compression, or hashing.
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
|
|
|
|
def validate_key(key: str) -> str:
|
|
"""Validate and return a POSIX-style relative storage key.
|
|
|
|
Rejects empty keys, absolute paths, parent-directory segments, and
|
|
backslashes. The returned value is the same string passed in.
|
|
|
|
Parameters
|
|
----------
|
|
key: str
|
|
The storage key to validate.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
The validated key.
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
If the key is empty, absolute, contains ``..``, or contains
|
|
backslashes.
|
|
"""
|
|
if not key:
|
|
raise ValueError("Storage key must not be empty")
|
|
if key.startswith("/"):
|
|
raise ValueError(f"Storage key must be relative, got absolute path: {key!r}")
|
|
if "\\" in key:
|
|
raise ValueError(f"Storage key must use POSIX separators, got backslash: {key!r}")
|
|
if ".." in key.split("/"):
|
|
raise ValueError(f"Storage key must not contain parent segments: {key!r}")
|
|
return key
|
|
|
|
|
|
class StorageAdapter(ABC):
|
|
"""Abstract base class for byte-oriented storage backends."""
|
|
|
|
@abstractmethod
|
|
def put(self, key: str, data: bytes) -> None:
|
|
"""Store ``data`` under ``key``.
|
|
|
|
Parameters
|
|
----------
|
|
key: str
|
|
The storage key.
|
|
data: bytes
|
|
The payload to store.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
def get(self, key: str) -> bytes:
|
|
"""Return the bytes stored under ``key``.
|
|
|
|
Parameters
|
|
----------
|
|
key: str
|
|
The storage key.
|
|
|
|
Returns
|
|
-------
|
|
bytes
|
|
The stored payload.
|
|
|
|
Raises
|
|
------
|
|
KeyError
|
|
If the key does not exist.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
def list_keys(self, prefix: str = "") -> list[str]:
|
|
"""Return a sorted list of keys starting with ``prefix``.
|
|
|
|
Parameters
|
|
----------
|
|
prefix: str, optional
|
|
Filter keys to those beginning with this value. The empty string
|
|
matches all keys.
|
|
|
|
Returns
|
|
-------
|
|
list[str]
|
|
Sorted matching keys.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
def delete(self, key: str) -> None:
|
|
"""Delete the object at ``key``.
|
|
|
|
This method is idempotent: deleting a missing key does not raise.
|
|
|
|
Parameters
|
|
----------
|
|
key: str
|
|
The storage key.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def exists(self, key: str) -> bool:
|
|
"""Return whether ``key`` exists in storage.
|
|
|
|
Parameters
|
|
----------
|
|
key: str
|
|
The storage key.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
``True`` if the key exists, ``False`` otherwise.
|
|
"""
|
|
try:
|
|
self.get(key)
|
|
return True
|
|
except KeyError:
|
|
return False
|