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
+64
View File
@@ -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")
+72
View File
@@ -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.",
)
+127
View File
@@ -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)
+68
View File
@@ -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",
]
+127
View File
@@ -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
+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()
+228
View 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
+218
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Python unit tests for snapshot."""
+17
View File
@@ -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!"
),
}
+152
View File
@@ -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
+98
View File
@@ -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"
+250
View File
@@ -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"
+187
View File
@@ -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")