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:
@@ -0,0 +1,127 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user