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,64 @@
|
||||
try:
|
||||
from ._version import __version__
|
||||
except ImportError:
|
||||
# Fallback when using the package in dev mode without installing
|
||||
# in editable mode with pip. It is highly recommended to install
|
||||
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
|
||||
import warnings
|
||||
warnings.warn("Importing 'snapshot' outside a proper installation.")
|
||||
__version__ = "dev"
|
||||
from .config import SnapshotConfig
|
||||
from .routes import setup_route_handlers
|
||||
from .storage import create_storage
|
||||
from .store import SnapshotStore
|
||||
|
||||
|
||||
def _jupyter_labextension_paths():
|
||||
return [{
|
||||
"src": "labextension",
|
||||
"dest": "snapshot"
|
||||
}]
|
||||
|
||||
|
||||
def _jupyter_server_extension_points():
|
||||
return [{
|
||||
"module": "snapshot"
|
||||
}]
|
||||
|
||||
|
||||
def _load_jupyter_server_extension(server_app):
|
||||
"""Registers the API handler to receive HTTP requests from the frontend extension.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
server_app: jupyterlab.labapp.LabApp
|
||||
JupyterLab application instance
|
||||
"""
|
||||
config = SnapshotConfig(config=server_app.config)
|
||||
storage = create_storage(
|
||||
storage_type=config.storage_type,
|
||||
root_dir=server_app.root_dir,
|
||||
local_root=config.local_root,
|
||||
endpoint=config.s3_endpoint,
|
||||
access_key=config.s3_access_key,
|
||||
secret_key=config.s3_secret_key,
|
||||
bucket=config.s3_bucket,
|
||||
secure=config.s3_secure,
|
||||
prefix=config.s3_prefix,
|
||||
)
|
||||
server_app.web_app.settings["snapshot_config"] = config
|
||||
server_app.web_app.settings["snapshot_storage"] = storage
|
||||
server_app.web_app.settings["snapshot_store"] = SnapshotStore(storage)
|
||||
if config.storage_type == "s3":
|
||||
server_app.log.info(
|
||||
f"Snapshot S3 storage ready: endpoint={config.s3_endpoint}, "
|
||||
f"bucket={config.s3_bucket}, secure={config.s3_secure}"
|
||||
)
|
||||
else:
|
||||
server_app.log.info(
|
||||
f"Activated snapshot storage backend: {config.storage_type}"
|
||||
)
|
||||
|
||||
setup_route_handlers(server_app.web_app)
|
||||
name = "snapshot"
|
||||
server_app.log.info(f"Registered {name} server extension")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Traitlets-based configuration for the snapshot server extension."""
|
||||
|
||||
import os
|
||||
|
||||
from traitlets.config.configurable import Configurable
|
||||
from traitlets.traitlets import Bool, Unicode, default
|
||||
|
||||
|
||||
class SnapshotConfig(Configurable):
|
||||
"""Configuration options for the notebook snapshot extension."""
|
||||
|
||||
storage_type = Unicode(
|
||||
"local",
|
||||
config=True,
|
||||
help='Storage backend type. Either "local" or "s3" (covers AWS S3, MinIO, etc.).',
|
||||
)
|
||||
local_root = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="Directory for local snapshot storage. Empty means <server root_dir>/.notebook_versions.",
|
||||
)
|
||||
|
||||
s3_endpoint = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="S3 endpoint host (without scheme), e.g. localhost:9000 for MinIO or s3.us-east-1.amazonaws.com for AWS S3.",
|
||||
)
|
||||
|
||||
@default("s3_endpoint")
|
||||
def _default_s3_endpoint(self):
|
||||
"""Default to the ``S3_ENDPOINT`` environment variable."""
|
||||
return os.environ.get("S3_ENDPOINT", "")
|
||||
|
||||
s3_access_key = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="S3 access key ID.",
|
||||
)
|
||||
|
||||
@default("s3_access_key")
|
||||
def _default_s3_access_key(self):
|
||||
"""Default to the ``S3_ACCESS_KEY`` environment variable."""
|
||||
return os.environ.get("S3_ACCESS_KEY", "")
|
||||
|
||||
s3_secret_key = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="S3 secret access key.",
|
||||
)
|
||||
|
||||
@default("s3_secret_key")
|
||||
def _default_s3_secret_key(self):
|
||||
"""Default to the ``S3_SECRET_KEY`` environment variable."""
|
||||
return os.environ.get("S3_SECRET_KEY", "")
|
||||
|
||||
s3_bucket = Unicode(
|
||||
"notebook-snapshot",
|
||||
config=True,
|
||||
help="S3 bucket name. Created automatically if it does not exist.",
|
||||
)
|
||||
|
||||
s3_secure = Bool(
|
||||
False,
|
||||
config=True,
|
||||
help="Use HTTPS for the S3 connection.",
|
||||
)
|
||||
|
||||
s3_prefix = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="Optional prefix prepended to all object keys in the S3 bucket.",
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
import json
|
||||
|
||||
from jupyter_server.base.handlers import APIHandler
|
||||
from jupyter_server.utils import url_path_join
|
||||
import tornado
|
||||
|
||||
from .store import SnapshotUnchangedError
|
||||
|
||||
|
||||
class HelloRouteHandler(APIHandler):
|
||||
# The following decorator should be present on all verb methods (head, get, post,
|
||||
# patch, put, delete, options) to ensure only authorized user can request the
|
||||
# Jupyter server
|
||||
@tornado.web.authenticated
|
||||
def get(self):
|
||||
self.finish(json.dumps({
|
||||
"data": (
|
||||
"Hello, world!"
|
||||
" This is the '/snapshot/hello' endpoint."
|
||||
" Try visiting me in your browser!"
|
||||
),
|
||||
}))
|
||||
|
||||
|
||||
class CommitHandler(APIHandler):
|
||||
"""POST /snapshot/notebook-version/commit
|
||||
|
||||
Persist a new notebook snapshot. Body: ``{path, content, name, description}``.
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
def post(self):
|
||||
body = self.get_json_body() or {}
|
||||
path = body.get("path")
|
||||
content = body.get("content")
|
||||
name = body.get("name")
|
||||
description = body.get("description", "")
|
||||
if not path or content is None or not name:
|
||||
self.set_status(400)
|
||||
self.finish({"message": "path, content, and name are required"})
|
||||
return
|
||||
store = self.settings["snapshot_store"]
|
||||
try:
|
||||
entry = store.commit(path, content, name, description)
|
||||
except SnapshotUnchangedError as exc:
|
||||
# Not an error — same content as the most recent version.
|
||||
# The frontend surfaces this as a warning to the user.
|
||||
self.finish({"skipped": True, "reason": "unchanged", "message": str(exc)})
|
||||
return
|
||||
except ValueError as exc:
|
||||
self.set_status(400)
|
||||
self.finish({"message": str(exc)})
|
||||
return
|
||||
except KeyError as exc:
|
||||
self.set_status(404)
|
||||
self.finish({"message": f"not found: {exc}"})
|
||||
return
|
||||
self.finish({"entry": entry})
|
||||
|
||||
|
||||
class ListHandler(APIHandler):
|
||||
"""GET /snapshot/notebook-version/list?path=...
|
||||
|
||||
Return the manifest versions for ``path``, newest first.
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
def get(self):
|
||||
path = self.get_query_argument("path", default=None)
|
||||
if not path:
|
||||
self.set_status(400)
|
||||
self.finish({"message": "path is required"})
|
||||
return
|
||||
versions = self.settings["snapshot_store"].list_versions(path)
|
||||
self.finish({"versions": versions})
|
||||
|
||||
|
||||
class ContentHandler(APIHandler):
|
||||
"""GET /snapshot/notebook-version/content?path=...&id=...
|
||||
|
||||
Return the cleaned notebook body for a given version.
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
def get(self):
|
||||
path = self.get_query_argument("path", default=None)
|
||||
version_id = self.get_query_argument("id", default=None)
|
||||
if not path or not version_id:
|
||||
self.set_status(400)
|
||||
self.finish({"message": "path and id are required"})
|
||||
return
|
||||
try:
|
||||
notebook = self.settings["snapshot_store"].get_notebook(path, version_id)
|
||||
except KeyError:
|
||||
self.set_status(404)
|
||||
self.finish({"message": "version not found"})
|
||||
return
|
||||
except ValueError as exc:
|
||||
self.set_status(400)
|
||||
self.finish({"message": str(exc)})
|
||||
return
|
||||
self.set_header("Content-Type", "application/json")
|
||||
self.finish(json.dumps(notebook))
|
||||
|
||||
|
||||
def setup_route_handlers(web_app):
|
||||
host_pattern = ".*$"
|
||||
base_url = web_app.settings["base_url"]
|
||||
|
||||
hello_route_pattern = url_path_join(base_url, "snapshot", "hello")
|
||||
commit_route_pattern = url_path_join(
|
||||
base_url, "snapshot", "notebook-version", "commit"
|
||||
)
|
||||
list_route_pattern = url_path_join(
|
||||
base_url, "snapshot", "notebook-version", "list"
|
||||
)
|
||||
content_route_pattern = url_path_join(
|
||||
base_url, "snapshot", "notebook-version", "content"
|
||||
)
|
||||
handlers = [
|
||||
(hello_route_pattern, HelloRouteHandler),
|
||||
(commit_route_pattern, CommitHandler),
|
||||
(list_route_pattern, ListHandler),
|
||||
(content_route_pattern, ContentHandler),
|
||||
]
|
||||
|
||||
web_app.add_handlers(host_pattern, handlers)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Storage backends and factory for notebook snapshots."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .base import StorageAdapter, validate_key
|
||||
from .local import LocalFileStorage
|
||||
from .s3 import S3ConnectionError, S3Storage
|
||||
|
||||
|
||||
def create_storage(
|
||||
storage_type: str,
|
||||
root_dir: str | Path,
|
||||
**kwargs,
|
||||
) -> StorageAdapter:
|
||||
"""Create a storage adapter from a configuration string.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
storage_type: str
|
||||
One of ``"local"`` or ``"s3"`` (covers AWS S3, MinIO, and any
|
||||
S3-API-compatible service via the ``endpoint`` parameter).
|
||||
root_dir: str | Path
|
||||
Root directory used by local storage. For S3 this is ignored.
|
||||
**kwargs
|
||||
Extra arguments forwarded to the backend constructor. For S3,
|
||||
expected keys include ``endpoint``, ``access_key``, ``secret_key``,
|
||||
``bucket``, ``secure``, and ``prefix``. For local, ``local_root`` may
|
||||
override the default ``root_dir / ".notebook_versions"`` path.
|
||||
|
||||
Returns
|
||||
-------
|
||||
StorageAdapter
|
||||
Configured storage adapter instance.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If ``storage_type`` is unknown.
|
||||
S3ConnectionError
|
||||
If ``storage_type="s3"`` and the endpoint is unreachable or
|
||||
credentials are missing/invalid.
|
||||
"""
|
||||
storage_type = storage_type.lower()
|
||||
if storage_type == "local":
|
||||
local_root = kwargs.get("local_root")
|
||||
if local_root:
|
||||
return LocalFileStorage(local_root)
|
||||
return LocalFileStorage(Path(root_dir) / ".notebook_versions")
|
||||
if storage_type == "s3":
|
||||
return S3Storage(
|
||||
endpoint=kwargs["endpoint"],
|
||||
access_key=kwargs["access_key"],
|
||||
secret_key=kwargs["secret_key"],
|
||||
bucket=kwargs.get("bucket", "notebook-snapshot"),
|
||||
secure=kwargs.get("secure", False),
|
||||
prefix=kwargs.get("prefix", ""),
|
||||
)
|
||||
raise ValueError(f"Unknown storage type: {storage_type!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_storage",
|
||||
"LocalFileStorage",
|
||||
"S3ConnectionError",
|
||||
"S3Storage",
|
||||
"StorageAdapter",
|
||||
"validate_key",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,228 @@
|
||||
"""S3-compatible storage adapter backed by boto3.
|
||||
|
||||
Works with AWS S3, MinIO, and any S3-API-compatible service.
|
||||
"""
|
||||
|
||||
from .base import StorageAdapter, validate_key
|
||||
|
||||
|
||||
class S3ConnectionError(Exception):
|
||||
"""Raised when the S3 connectivity / credentials check fails at startup.
|
||||
|
||||
Wraps the underlying boto3/botocore exception with a friendly, actionable
|
||||
message naming the endpoint, bucket, and the most likely fix.
|
||||
"""
|
||||
|
||||
|
||||
class S3Storage(StorageAdapter):
|
||||
"""Store objects in an S3-compatible bucket via boto3.
|
||||
|
||||
Works with AWS S3 and MinIO (just point ``endpoint`` at the MinIO host).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
bucket: str,
|
||||
secure: bool = False,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
"""Initialize the adapter and verify connectivity.
|
||||
|
||||
boto3 is imported lazily inside this constructor so that importing
|
||||
``snapshot.storage.s3`` does not require boto3 to be installed.
|
||||
|
||||
On construction, the adapter performs a HEAD on the configured bucket
|
||||
to validate network reachability and credentials. If the bucket does
|
||||
not exist, it is created automatically. Any connectivity, credential,
|
||||
or permission problem is surfaced as :class:`S3ConnectionError` with
|
||||
a message that names the endpoint, bucket, and likely fix.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
endpoint: str
|
||||
S3 endpoint host (without scheme), e.g. ``localhost:9000`` for
|
||||
MinIO or ``s3.us-east-1.amazonaws.com`` for AWS S3.
|
||||
access_key: str
|
||||
S3 access key ID.
|
||||
secret_key: str
|
||||
S3 secret access key.
|
||||
bucket: str
|
||||
Target bucket name. Created if it does not exist.
|
||||
secure: bool, optional
|
||||
Use HTTPS instead of HTTP. Defaults to ``False``.
|
||||
prefix: str, optional
|
||||
Prefix prepended to every stored key. Defaults to ``""``.
|
||||
|
||||
Raises
|
||||
------
|
||||
S3ConnectionError
|
||||
If the endpoint is unreachable, credentials are missing/invalid,
|
||||
or the bucket cannot be created due to permissions.
|
||||
"""
|
||||
try:
|
||||
import boto3
|
||||
from botocore.client import Config as BotoConfig
|
||||
from botocore.exceptions import ClientError
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"boto3 is required for S3 storage. "
|
||||
"Install it with: pip install snapshot[s3]"
|
||||
) from exc
|
||||
|
||||
self._bucket = bucket
|
||||
self._prefix = prefix
|
||||
self._endpoint = endpoint
|
||||
scheme = "https" if secure else "http"
|
||||
self._client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{scheme}://{endpoint}",
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name="us-east-1",
|
||||
config=BotoConfig(signature_version="s3v4"),
|
||||
)
|
||||
self._ClientError = ClientError
|
||||
self._connect_and_ensure_bucket()
|
||||
|
||||
def _connect_and_ensure_bucket(self) -> None:
|
||||
"""Verify connectivity/credentials, then ensure the bucket exists.
|
||||
|
||||
Raises :class:`S3ConnectionError` with a friendly message on failure.
|
||||
"""
|
||||
try:
|
||||
self._client.head_bucket(Bucket=self._bucket)
|
||||
return # bucket exists, connectivity and credentials confirmed
|
||||
except self._ClientError as exc:
|
||||
code = exc.response.get("Error", {}).get("Code", "")
|
||||
if code in ("404", "NoSuchBucket"):
|
||||
# Bucket missing — try to create it. Permission failure here
|
||||
# surfaces as S3ConnectionError rather than the raw boto3 error.
|
||||
try:
|
||||
self._client.create_bucket(Bucket=self._bucket)
|
||||
except self._ClientError as create_exc:
|
||||
ccode = create_exc.response.get("Error", {}).get("Code", "")
|
||||
raise S3ConnectionError(
|
||||
f"S3 bucket '{self._bucket}' does not exist and could "
|
||||
f"not be created (code={ccode}). Check that the "
|
||||
f"credentials have s3:CreateBucket permission on "
|
||||
f"endpoint {self._endpoint}."
|
||||
) from create_exc
|
||||
return
|
||||
# Other ClientError: permission denied, account disabled, etc.
|
||||
raise S3ConnectionError(
|
||||
f"S3 access denied (endpoint={self._endpoint}, "
|
||||
f"bucket={self._bucket}, code={code}). Check that the "
|
||||
f"credentials have s3:ListBucket and s3:GetBucketLocation "
|
||||
f"permissions."
|
||||
) from exc
|
||||
except Exception as exc: # noqa: BLE001 - catch any boto3/botocore error
|
||||
# EndpointConnectionError, NoCredentialsError, InvalidAccessKeyId,
|
||||
# SSLError, ConnectTimeoutError, etc. all land here.
|
||||
cls = type(exc).__name__
|
||||
hint = ""
|
||||
if "Credentials" in cls or "AccessKey" in cls or "Auth" in cls:
|
||||
hint = (
|
||||
" Check that S3_ACCESS_KEY / S3_SECRET_KEY env vars (or "
|
||||
"c.SnapshotConfig.s3_access_key / s3_secret_key) are set "
|
||||
"and correct."
|
||||
)
|
||||
elif "Endpoint" in cls or "Connection" in cls or "Connect" in cls or "SSLError" in cls or "timeout" in cls.lower():
|
||||
hint = (
|
||||
f" Check that the endpoint '{self._endpoint}' is reachable, "
|
||||
f"the URL is correct, and s3_secure matches the protocol."
|
||||
)
|
||||
raise S3ConnectionError(
|
||||
f"Cannot connect to S3 endpoint {self._endpoint} "
|
||||
f"(bucket={self._bucket}): {cls}: {exc}.{hint}"
|
||||
) from exc
|
||||
|
||||
def _object_key(self, key: str) -> str:
|
||||
"""Return the full S3 object key, including the configured prefix."""
|
||||
return f"{self._prefix}{key}"
|
||||
|
||||
def put(self, key: str, data: bytes) -> None:
|
||||
"""Store ``data`` under ``key`` in the configured bucket."""
|
||||
validate_key(key)
|
||||
self._client.put_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._object_key(key),
|
||||
Body=data,
|
||||
)
|
||||
|
||||
def get(self, key: str) -> bytes:
|
||||
"""Return the bytes stored under ``key``.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If the object does not exist.
|
||||
"""
|
||||
validate_key(key)
|
||||
try:
|
||||
response = self._client.get_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._object_key(key),
|
||||
)
|
||||
except self._ClientError as exc:
|
||||
error_code = exc.response.get("Error", {}).get("Code", "")
|
||||
if error_code in ("NoSuchKey", "404"):
|
||||
raise KeyError(key) from exc
|
||||
raise
|
||||
return response["Body"].read()
|
||||
|
||||
def list_keys(self, prefix: str = "") -> list[str]:
|
||||
"""Return a sorted list of keys starting with ``prefix``.
|
||||
|
||||
The configured bucket prefix is stripped from returned keys.
|
||||
"""
|
||||
if prefix:
|
||||
validate_key(prefix)
|
||||
full_prefix = self._object_key(prefix)
|
||||
keys = []
|
||||
continuation_token = None
|
||||
while True:
|
||||
kwargs: dict = {
|
||||
"Bucket": self._bucket,
|
||||
"Prefix": full_prefix,
|
||||
}
|
||||
if continuation_token:
|
||||
kwargs["ContinuationToken"] = continuation_token
|
||||
response = self._client.list_objects_v2(**kwargs)
|
||||
for obj in response.get("Contents", []):
|
||||
full_key = obj["Key"]
|
||||
if full_key.startswith(self._prefix):
|
||||
stripped = full_key[len(self._prefix):]
|
||||
keys.append(stripped)
|
||||
if not response.get("IsTruncated"):
|
||||
break
|
||||
continuation_token = response.get("NextContinuationToken")
|
||||
return sorted(keys)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Delete the object at ``key``.
|
||||
|
||||
Idempotent: S3 ``delete_object`` succeeds even when the key is absent.
|
||||
"""
|
||||
validate_key(key)
|
||||
self._client.delete_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._object_key(key),
|
||||
)
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Return whether ``key`` exists in the bucket."""
|
||||
validate_key(key)
|
||||
try:
|
||||
self._client.head_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._object_key(key),
|
||||
)
|
||||
return True
|
||||
except self._ClientError as exc:
|
||||
error_code = exc.response.get("Error", {}).get("Code", "")
|
||||
if error_code in ("404", "NoSuchKey"):
|
||||
return False
|
||||
raise
|
||||
@@ -0,0 +1,218 @@
|
||||
"""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
|
||||
@@ -0,0 +1 @@
|
||||
"""Python unit tests for snapshot."""
|
||||
@@ -0,0 +1,17 @@
|
||||
import json
|
||||
|
||||
|
||||
async def test_hello(jp_fetch):
|
||||
# When
|
||||
response = await jp_fetch("snapshot", "hello")
|
||||
|
||||
# Then
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {
|
||||
"data": (
|
||||
"Hello, world!"
|
||||
" This is the '/snapshot/hello' endpoint."
|
||||
" Try visiting me in your browser!"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Integration tests for the snapshot REST API (commit / list / content)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
SAMPLE_NB = {
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "c1",
|
||||
"source": "print('hi')",
|
||||
"outputs": [{"output_type": "stream", "text": "hi"}],
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c2",
|
||||
"source": "# title",
|
||||
"metadata": {},
|
||||
},
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
}
|
||||
|
||||
|
||||
def _post_commit(jp_fetch, path: str, name: str, description: str = ""):
|
||||
body = json.dumps(
|
||||
{
|
||||
"path": path,
|
||||
"content": SAMPLE_NB,
|
||||
"name": name,
|
||||
"description": description,
|
||||
}
|
||||
).encode()
|
||||
return jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"commit",
|
||||
method="POST",
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
async def test_commit_creates_entry(jp_fetch):
|
||||
response = await _post_commit(jp_fetch, "api_a.ipynb", "v1", "first")
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload["entry"]["name"] == "v1"
|
||||
assert payload["entry"]["description"] == "first"
|
||||
assert payload["entry"]["id"].startswith("v_")
|
||||
assert payload["entry"]["size"] > 0
|
||||
assert payload["entry"]["hash"]
|
||||
|
||||
|
||||
async def test_list_contains_committed_entry(jp_fetch):
|
||||
await _post_commit(jp_fetch, "api_b.ipynb", "v1", "desc")
|
||||
response = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"list",
|
||||
params={"path": "api_b.ipynb"},
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert len(payload["versions"]) == 1
|
||||
assert payload["versions"][0]["name"] == "v1"
|
||||
assert payload["versions"][0]["description"] == "desc"
|
||||
|
||||
|
||||
async def test_content_returns_stripped_notebook(jp_fetch):
|
||||
commit_resp = await _post_commit(jp_fetch, "api_c.ipynb", "v1")
|
||||
entry = json.loads(commit_resp.body)["entry"]
|
||||
response = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"content",
|
||||
params={"path": "api_c.ipynb", "id": entry["id"]},
|
||||
)
|
||||
assert response.code == 200
|
||||
notebook = json.loads(response.body)
|
||||
code = next(c for c in notebook["cells"] if c["cell_type"] == "code")
|
||||
assert code["outputs"] == []
|
||||
assert code["execution_count"] is None
|
||||
assert notebook["nbformat"] == 4
|
||||
|
||||
|
||||
async def test_commit_missing_name_returns_400(jp_fetch):
|
||||
body = json.dumps(
|
||||
{"path": "api_d.ipynb", "content": SAMPLE_NB}
|
||||
).encode()
|
||||
response = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"commit",
|
||||
method="POST",
|
||||
body=body,
|
||||
raise_error=False,
|
||||
)
|
||||
assert response.code == 400
|
||||
|
||||
|
||||
async def test_list_unknown_path_returns_empty(jp_fetch):
|
||||
response = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"list",
|
||||
params={"path": "never_committed_unique.ipynb"},
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload["versions"] == []
|
||||
|
||||
|
||||
async def test_commit_duplicate_returns_skipped(jp_fetch):
|
||||
"""Second commit with identical content must NOT save; response is {skipped: True}."""
|
||||
r1 = await _post_commit(jp_fetch, "api_dup.ipynb", "v1")
|
||||
assert r1.code == 200
|
||||
first = json.loads(r1.body)
|
||||
assert first.get("entry") is not None
|
||||
assert first.get("skipped") in (None, False)
|
||||
|
||||
r2 = await _post_commit(jp_fetch, "api_dup.ipynb", "v2")
|
||||
assert r2.code == 200 # not an error — just a warning
|
||||
second = json.loads(r2.body)
|
||||
assert second.get("skipped") is True
|
||||
assert second.get("reason") == "unchanged"
|
||||
assert "未变更" in second.get("message", "") or "unchanged" in second.get("message", "").lower()
|
||||
assert second.get("entry") is None
|
||||
|
||||
# And the manifest still has exactly one entry.
|
||||
list_resp = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"list",
|
||||
params={"path": "api_dup.ipynb"},
|
||||
)
|
||||
assert json.loads(list_resp.body)["versions"].__len__() == 1
|
||||
|
||||
|
||||
async def test_content_unknown_id_returns_404(jp_fetch):
|
||||
response = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"content",
|
||||
params={"path": "api_e.ipynb", "id": "v_0_doesnotexist"},
|
||||
raise_error=False,
|
||||
)
|
||||
assert response.code == 404
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for the local filesystem storage adapter."""
|
||||
|
||||
import pytest
|
||||
|
||||
from snapshot.storage import LocalFileStorage, create_storage, validate_key
|
||||
from snapshot.storage.base import StorageAdapter
|
||||
|
||||
|
||||
def _contract(adapter: StorageAdapter) -> None:
|
||||
"""Run the shared storage contract against an adapter instance."""
|
||||
adapter.put("a/b/file1.txt", b"hello")
|
||||
adapter.put("a/b/file2.txt", b"world")
|
||||
adapter.put("c/file3.txt", b"!")
|
||||
|
||||
assert adapter.get("a/b/file1.txt") == b"hello"
|
||||
assert adapter.exists("a/b/file1.txt")
|
||||
assert not adapter.exists("a/b/missing.txt")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
adapter.get("a/b/missing.txt")
|
||||
|
||||
assert adapter.list_keys() == [
|
||||
"a/b/file1.txt",
|
||||
"a/b/file2.txt",
|
||||
"c/file3.txt",
|
||||
]
|
||||
assert adapter.list_keys("a/b") == [
|
||||
"a/b/file1.txt",
|
||||
"a/b/file2.txt",
|
||||
]
|
||||
|
||||
adapter.delete("a/b/file1.txt")
|
||||
adapter.delete("a/b/file1.txt") # idempotent
|
||||
assert not adapter.exists("a/b/file1.txt")
|
||||
assert adapter.list_keys() == [
|
||||
"a/b/file2.txt",
|
||||
"c/file3.txt",
|
||||
]
|
||||
|
||||
|
||||
def test_contract(tmp_path):
|
||||
"""Validate the adapter contract on a temporary directory."""
|
||||
adapter = LocalFileStorage(tmp_path)
|
||||
_contract(adapter)
|
||||
|
||||
|
||||
def test_put_creates_parent_directories(tmp_path):
|
||||
"""put() must create intermediate directories."""
|
||||
adapter = LocalFileStorage(tmp_path)
|
||||
adapter.put("deep/nested/key.bin", b"data")
|
||||
assert (tmp_path / "deep" / "nested" / "key.bin").read_bytes() == b"data"
|
||||
|
||||
|
||||
def test_atomic_put_leaves_no_tmp_files(tmp_path):
|
||||
"""Atomic writes must not leak temporary files."""
|
||||
adapter = LocalFileStorage(tmp_path)
|
||||
adapter.put("x/y/z.txt", b"payload")
|
||||
tmp_files = list(tmp_path.rglob("*.tmp-*"))
|
||||
assert tmp_files == []
|
||||
|
||||
|
||||
def test_validate_key_rejects_bad_keys():
|
||||
"""validate_key must reject unsafe key strings."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_key("")
|
||||
with pytest.raises(ValueError):
|
||||
validate_key("/absolute/path")
|
||||
with pytest.raises(ValueError):
|
||||
validate_key("../escape")
|
||||
with pytest.raises(ValueError):
|
||||
validate_key("a\\b")
|
||||
|
||||
|
||||
def test_get_missing_raises_key_error(tmp_path):
|
||||
"""Reading a missing key must raise KeyError."""
|
||||
adapter = LocalFileStorage(tmp_path)
|
||||
with pytest.raises(KeyError):
|
||||
adapter.get("missing.bin")
|
||||
|
||||
|
||||
def test_create_storage_custom_local_root(tmp_path):
|
||||
"""create_storage must honor an explicit local_root directory."""
|
||||
custom_dir = tmp_path / "custom_versions"
|
||||
adapter = create_storage(
|
||||
"local",
|
||||
root_dir=tmp_path,
|
||||
local_root=str(custom_dir),
|
||||
)
|
||||
adapter.put("notebook.ipynb", b"data")
|
||||
assert (custom_dir / "notebook.ipynb").read_bytes() == b"data"
|
||||
|
||||
|
||||
def test_create_storage_default_local_root(tmp_path):
|
||||
"""create_storage must fall back to root_dir/.notebook_versions."""
|
||||
adapter = create_storage("local", root_dir=tmp_path)
|
||||
adapter.put("notebook.ipynb", b"data")
|
||||
expected = tmp_path / ".notebook_versions" / "notebook.ipynb"
|
||||
assert expected.read_bytes() == b"data"
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Tests for the S3 storage adapter using a monkey-patched boto3."""
|
||||
|
||||
import pytest
|
||||
|
||||
boto3 = pytest.importorskip("boto3")
|
||||
from botocore.exceptions import ClientError # noqa: E402
|
||||
|
||||
from snapshot.storage import S3ConnectionError, S3Storage
|
||||
from snapshot.storage.base import StorageAdapter
|
||||
|
||||
|
||||
class _FakeS3Client:
|
||||
"""In-memory S3 client sufficient for exercising S3Storage."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._objects = {}
|
||||
self._buckets = set()
|
||||
# Failure injection: if set, these are raised instead of the normal
|
||||
# behavior. Used to exercise the S3ConnectionError classification.
|
||||
self.head_bucket_error: Exception | None = None
|
||||
self.create_bucket_error: Exception | None = None
|
||||
|
||||
def head_bucket(self, Bucket):
|
||||
if self.head_bucket_error is not None:
|
||||
raise self.head_bucket_error
|
||||
if Bucket not in self._buckets:
|
||||
raise ClientError({"Error": {"Code": "404"}}, "HeadBucket")
|
||||
|
||||
def create_bucket(self, Bucket):
|
||||
if self.create_bucket_error is not None:
|
||||
raise self.create_bucket_error
|
||||
self._buckets.add(Bucket)
|
||||
|
||||
def put_object(self, Bucket, Key, Body):
|
||||
self._buckets.add(Bucket)
|
||||
if isinstance(Body, bytes):
|
||||
data = Body
|
||||
else:
|
||||
data = Body.read()
|
||||
self._objects[(Bucket, Key)] = data
|
||||
|
||||
def get_object(self, Bucket, Key):
|
||||
self.head_bucket(Bucket)
|
||||
if (Bucket, Key) not in self._objects:
|
||||
raise ClientError({"Error": {"Code": "NoSuchKey"}}, "GetObject")
|
||||
return {"Body": _Body(self._objects[(Bucket, Key)])}
|
||||
|
||||
def delete_object(self, Bucket, Key):
|
||||
self._objects.pop((Bucket, Key), None)
|
||||
|
||||
def head_object(self, Bucket, Key):
|
||||
self.head_bucket(Bucket)
|
||||
if (Bucket, Key) not in self._objects:
|
||||
raise ClientError({"Error": {"Code": "404"}}, "HeadObject")
|
||||
|
||||
def list_objects_v2(self, Bucket, Prefix="", ContinuationToken=None):
|
||||
self.head_bucket(Bucket)
|
||||
keys = sorted(
|
||||
key for (b, key) in self._objects if b == Bucket and key.startswith(Prefix)
|
||||
)
|
||||
return {"Contents": [{"Key": key} for key in keys], "IsTruncated": False}
|
||||
|
||||
|
||||
class _Body:
|
||||
def __init__(self, data: bytes):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch, prefix=""):
|
||||
monkeypatch.setattr(boto3, "client", lambda *args, **kwargs: _FakeS3Client(**kwargs))
|
||||
return S3Storage(
|
||||
endpoint="localhost:9000",
|
||||
access_key="access",
|
||||
secret_key="secret",
|
||||
bucket="test-bucket",
|
||||
prefix=prefix,
|
||||
)
|
||||
|
||||
|
||||
def _contract(adapter: StorageAdapter) -> None:
|
||||
adapter.put("a/b/file1.txt", b"hello")
|
||||
adapter.put("a/b/file2.txt", b"world")
|
||||
adapter.put("c/file3.txt", b"!")
|
||||
|
||||
assert adapter.get("a/b/file1.txt") == b"hello"
|
||||
assert adapter.exists("a/b/file1.txt")
|
||||
assert not adapter.exists("a/b/missing.txt")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
adapter.get("a/b/missing.txt")
|
||||
|
||||
assert adapter.list_keys() == [
|
||||
"a/b/file1.txt",
|
||||
"a/b/file2.txt",
|
||||
"c/file3.txt",
|
||||
]
|
||||
assert adapter.list_keys("a/b") == [
|
||||
"a/b/file1.txt",
|
||||
"a/b/file2.txt",
|
||||
]
|
||||
|
||||
adapter.delete("a/b/file1.txt")
|
||||
adapter.delete("a/b/file1.txt") # idempotent
|
||||
assert not adapter.exists("a/b/file1.txt")
|
||||
assert adapter.list_keys() == [
|
||||
"a/b/file2.txt",
|
||||
"c/file3.txt",
|
||||
]
|
||||
|
||||
|
||||
def test_contract(monkeypatch):
|
||||
"""Validate the adapter contract against the fake S3 client."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
_contract(adapter)
|
||||
|
||||
|
||||
def test_prefix_stripping(monkeypatch):
|
||||
"""list_keys must strip the configured prefix from returned keys."""
|
||||
adapter = _make_adapter(monkeypatch, prefix="notebooks/")
|
||||
adapter.put("version1.ipynb", b"v1")
|
||||
adapter.put("version2.ipynb", b"v2")
|
||||
assert adapter.list_keys() == ["version1.ipynb", "version2.ipynb"]
|
||||
assert adapter.get("version1.ipynb") == b"v1"
|
||||
|
||||
|
||||
def test_bucket_auto_creation(monkeypatch):
|
||||
"""The bucket must be created automatically if it does not exist."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.put("first.bin", b"x")
|
||||
assert adapter.exists("first.bin")
|
||||
|
||||
|
||||
def test_imports_work_without_boto3():
|
||||
"""Module-level imports must succeed even when boto3 is unavailable."""
|
||||
import snapshot
|
||||
import snapshot.storage
|
||||
import snapshot.storage.s3
|
||||
|
||||
assert snapshot.storage.s3.S3Storage is not None
|
||||
assert snapshot.storage.StorageAdapter is not None
|
||||
|
||||
|
||||
def test_lazy_import_error(monkeypatch):
|
||||
"""Instantiation must raise ImportError when boto3 is absent."""
|
||||
real_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __builtins__["__import__"]
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "boto3" or name.startswith("boto3."):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.__import__", fake_import)
|
||||
with pytest.raises(ImportError):
|
||||
S3Storage(
|
||||
endpoint="localhost:9000",
|
||||
access_key="a",
|
||||
secret_key="s",
|
||||
bucket="b",
|
||||
)
|
||||
|
||||
|
||||
# ---- connectivity / credentials checks at startup ------------------------
|
||||
|
||||
|
||||
def _make_with_fake(monkeypatch, fake: _FakeS3Client) -> S3Storage:
|
||||
monkeypatch.setattr(boto3, "client", lambda *a, **kw: fake)
|
||||
return S3Storage(
|
||||
endpoint="localhost:9000",
|
||||
access_key="access",
|
||||
secret_key="secret",
|
||||
bucket="test-bucket",
|
||||
)
|
||||
|
||||
|
||||
def test_permission_denied_raises_s3_connection_error(monkeypatch):
|
||||
"""head_bucket returning 403/AccessDenied must surface as S3ConnectionError."""
|
||||
fake = _FakeS3Client()
|
||||
fake.head_bucket_error = ClientError(
|
||||
{"Error": {"Code": "AccessDenied"}}, "HeadBucket"
|
||||
)
|
||||
with pytest.raises(S3ConnectionError) as excinfo:
|
||||
_make_with_fake(monkeypatch, fake)
|
||||
msg = str(excinfo.value)
|
||||
assert "AccessDenied" in msg
|
||||
assert "localhost:9000" in msg
|
||||
assert "test-bucket" in msg
|
||||
assert "s3:ListBucket" in msg or "permission" in msg.lower()
|
||||
|
||||
|
||||
def test_create_bucket_failure_raises_s3_connection_error(monkeypatch):
|
||||
"""Bucket missing AND create_bucket failing must surface as S3ConnectionError."""
|
||||
fake = _FakeS3Client()
|
||||
fake.create_bucket_error = ClientError(
|
||||
{"Error": {"Code": "AccessDenied"}}, "CreateBucket"
|
||||
)
|
||||
with pytest.raises(S3ConnectionError) as excinfo:
|
||||
_make_with_fake(monkeypatch, fake)
|
||||
msg = str(excinfo.value)
|
||||
assert "does not exist" in msg
|
||||
assert "could not be created" in msg
|
||||
assert "CreateBucket" in msg
|
||||
|
||||
|
||||
def test_endpoint_unreachable_raises_s3_connection_error(monkeypatch):
|
||||
"""Network failures (e.g. EndpointConnectionError) surface as S3ConnectionError."""
|
||||
from botocore.exceptions import EndpointConnectionError
|
||||
|
||||
fake = _FakeS3Client()
|
||||
fake.head_bucket_error = EndpointConnectionError(
|
||||
endpoint_url="http://localhost:9000"
|
||||
)
|
||||
with pytest.raises(S3ConnectionError) as excinfo:
|
||||
_make_with_fake(monkeypatch, fake)
|
||||
msg = str(excinfo.value)
|
||||
assert "Cannot connect" in msg
|
||||
assert "localhost:9000" in msg
|
||||
assert "EndpointConnectionError" in msg
|
||||
|
||||
|
||||
def test_credentials_error_surfaces_credentials_hint(monkeypatch):
|
||||
"""Credential-related boto3 errors mention the S3_* env vars in the hint."""
|
||||
from botocore.exceptions import NoCredentialsError
|
||||
|
||||
fake = _FakeS3Client()
|
||||
fake.head_bucket_error = NoCredentialsError()
|
||||
with pytest.raises(S3ConnectionError) as excinfo:
|
||||
_make_with_fake(monkeypatch, fake)
|
||||
msg = str(excinfo.value)
|
||||
assert "S3_ACCESS_KEY" in msg
|
||||
assert "S3_SECRET_KEY" in msg
|
||||
|
||||
|
||||
def test_connectivity_succeeds_silently_when_bucket_exists(monkeypatch):
|
||||
"""If head_bucket succeeds (bucket exists), no create is called."""
|
||||
fake = _FakeS3Client()
|
||||
fake._buckets.add("test-bucket") # simulate pre-existing bucket
|
||||
create_called = {"flag": False}
|
||||
original_create = fake.create_bucket
|
||||
|
||||
def tracking_create(Bucket):
|
||||
create_called["flag"] = True
|
||||
return original_create(Bucket)
|
||||
|
||||
fake.create_bucket = tracking_create
|
||||
_make_with_fake(monkeypatch, fake)
|
||||
assert create_called["flag"] is False, "create_bucket must not be called when bucket already exists"
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Unit tests for the SnapshotStore (manual-commit model)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from snapshot.storage import create_storage
|
||||
from snapshot.store import SnapshotStore
|
||||
|
||||
|
||||
def make_store(tmp_path):
|
||||
"""Create a SnapshotStore backed by a fresh local adapter under tmp_path."""
|
||||
storage = create_storage(
|
||||
"local",
|
||||
root_dir=tmp_path,
|
||||
local_root=str(tmp_path / "snaps"),
|
||||
)
|
||||
return SnapshotStore(storage)
|
||||
|
||||
|
||||
SAMPLE_NB = {
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "c1",
|
||||
"source": "print('hi')",
|
||||
"outputs": [{"output_type": "stream", "text": "hi"}],
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c2",
|
||||
"source": "# title",
|
||||
"metadata": {},
|
||||
},
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
}
|
||||
|
||||
|
||||
def test_sanitize_strips_code_outputs_and_exec(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
entry = store.commit("nb.ipynb", SAMPLE_NB, name="v1")
|
||||
nb = store.get_notebook("nb.ipynb", entry["id"])
|
||||
code = next(c for c in nb["cells"] if c["cell_type"] == "code")
|
||||
assert code["outputs"] == []
|
||||
assert code["execution_count"] is None
|
||||
md = next(c for c in nb["cells"] if c["cell_type"] == "markdown")
|
||||
assert md["source"] == "# title"
|
||||
assert md["id"] == "c2"
|
||||
|
||||
|
||||
def test_hash_stable_regardless_of_outputs(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
nb_a = {
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "c1",
|
||||
"source": "x = 1",
|
||||
"outputs": [{"output_type": "stream", "text": "1"}],
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
}
|
||||
nb_b = {
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "c1",
|
||||
"source": "x = 1",
|
||||
"outputs": [
|
||||
{"output_type": "display_data", "data": {"text/plain": "9999"}}
|
||||
],
|
||||
"execution_count": 42,
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
}
|
||||
e1 = store.commit("hash_a.ipynb", nb_a, name="a")
|
||||
e2 = store.commit("hash_b.ipynb", nb_b, name="b")
|
||||
assert e1["hash"] == e2["hash"]
|
||||
|
||||
|
||||
def test_commit_list_content_roundtrip(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
entry = store.commit("rt.ipynb", SAMPLE_NB, name="first", description="hello")
|
||||
assert entry["name"] == "first"
|
||||
assert entry["description"] == "hello"
|
||||
assert entry["id"].startswith("v_")
|
||||
|
||||
versions = store.list_versions("rt.ipynb")
|
||||
assert len(versions) == 1
|
||||
assert versions[0]["id"] == entry["id"]
|
||||
assert versions[0]["name"] == "first"
|
||||
assert versions[0]["description"] == "hello"
|
||||
|
||||
nb = store.get_notebook("rt.ipynb", entry["id"])
|
||||
assert nb["nbformat"] == 4
|
||||
code = next(c for c in nb["cells"] if c["cell_type"] == "code")
|
||||
assert code["outputs"] == []
|
||||
|
||||
|
||||
def test_duplicate_content_skipped(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
e1 = store.commit("dup.ipynb", SAMPLE_NB, name="first")
|
||||
# Second commit with the same content should be skipped, not saved.
|
||||
import pytest as _pytest
|
||||
from snapshot.store import SnapshotUnchangedError
|
||||
with _pytest.raises(SnapshotUnchangedError):
|
||||
store.commit("dup.ipynb", SAMPLE_NB, name="second")
|
||||
versions = store.list_versions("dup.ipynb")
|
||||
assert len(versions) == 1
|
||||
assert versions[0]["id"] == e1["id"]
|
||||
assert versions[0]["name"] == "first"
|
||||
|
||||
|
||||
def test_edited_content_still_saves(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
e1 = store.commit("edit.ipynb", SAMPLE_NB, name="v1")
|
||||
edited = {
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "c1",
|
||||
"source": "x = 2", # different source → different hash
|
||||
"outputs": [],
|
||||
"execution_count": None,
|
||||
"metadata": {},
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c2",
|
||||
"source": "# title",
|
||||
"metadata": {},
|
||||
},
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
}
|
||||
e2 = store.commit("edit.ipynb", edited, name="v2")
|
||||
assert e1["id"] != e2["id"]
|
||||
assert e1["hash"] != e2["hash"]
|
||||
assert len(store.list_versions("edit.ipynb")) == 2
|
||||
|
||||
|
||||
def test_first_commit_never_skipped(tmp_path):
|
||||
"""No prior version exists — must always save regardless of content."""
|
||||
store = make_store(tmp_path)
|
||||
entry = store.commit("first.ipynb", SAMPLE_NB, name="only")
|
||||
assert entry["id"].startswith("v_")
|
||||
assert len(store.list_versions("first.ipynb")) == 1
|
||||
|
||||
|
||||
def test_manifest_rebuild_from_files(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
entry = store.commit("rebuild.ipynb", SAMPLE_NB, name="only")
|
||||
# Tamper: delete manifest directly via the storage adapter
|
||||
from snapshot.storage import validate_key
|
||||
|
||||
manifest_key = "rebuild.ipynb/manifest.json"
|
||||
validate_key(manifest_key)
|
||||
store._storage.delete(manifest_key)
|
||||
|
||||
versions = store.list_versions("rebuild.ipynb")
|
||||
assert len(versions) == 1
|
||||
assert versions[0]["id"] == entry["id"]
|
||||
assert versions[0]["name"] == "only"
|
||||
|
||||
# Manifest should have been rewritten by the rebuild
|
||||
rebuilt = store.list_versions("rebuild.ipynb")
|
||||
assert len(rebuilt) == 1
|
||||
|
||||
|
||||
def test_get_notebook_missing_raises_key_error(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
with pytest.raises(KeyError):
|
||||
store.get_notebook("missing.ipynb", "v_0_doesnotexist")
|
||||
Reference in New Issue
Block a user