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
+19
View File
@@ -0,0 +1,19 @@
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
_commit: v4.6.2
_src_path: https://github.com/jupyterlab/extension-template
advanced: true
author_email: ''
author_name: taochen
create_claude_symlink: true
create_gemini_symlink: false
has_ai_rules: true
has_binder: false
has_settings: true
kind: frontend-and-server
labextension_name: snapshot
project_short_description: A JupyterLab extension.
python_name: snapshot
repository: http://101.43.40.124:3000/tao.chen/notebook-snapshot-extension.git
test: true
yarn_linker: pnpm
+162
View File
@@ -0,0 +1,162 @@
name: Build
on:
push:
branches: main
pull_request:
branches: '*'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Install dependencies
run: python -m pip install -U "jupyterlab>=4.0.0,<5"
- name: Lint the extension
run: |
set -eux
jlpm
jlpm run lint:check
- name: Test the extension
run: |
set -eux
jlpm run test
- name: Build the extension
run: |
set -eux
python -m pip install .[test]
pytest -vv -r ap --cov snapshot
jupyter server extension list
jupyter server extension list 2>&1 | grep -ie "snapshot.*OK"
jupyter labextension list
jupyter labextension list 2>&1 | grep -ie "snapshot.*OK"
python -m jupyterlab.browser_check
- name: Package the extension
run: |
set -eux
pip install build
python -m build
pip uninstall -y "snapshot" jupyterlab
- name: Upload extension packages
uses: actions/upload-artifact@v4
with:
name: extension-artifacts
path: dist/snapshot*
if-no-files-found: error
test_isolated:
needs: build
runs-on: ubuntu-latest
steps:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
architecture: 'x64'
- uses: actions/download-artifact@v4
with:
name: extension-artifacts
- name: Install and Test
run: |
set -eux
# Remove NodeJS, twice to take care of system and locally installed node versions.
sudo rm -rf $(which node)
sudo rm -rf $(which node)
pip install "jupyterlab>=4.0.0,<5" snapshot*.whl
jupyter server extension list
jupyter server extension list 2>&1 | grep -ie "snapshot.*OK"
jupyter labextension list
jupyter labextension list 2>&1 | grep -ie "snapshot.*OK"
python -m jupyterlab.browser_check --no-browser-test
integration-tests:
name: Integration tests
needs: build
runs-on: ubuntu-latest
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/pw-browsers
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Download extension package
uses: actions/download-artifact@v4
with:
name: extension-artifacts
- name: Install the extension
run: |
set -eux
python -m pip install "jupyterlab>=4.0.0,<5" snapshot*.whl
- name: Install dependencies
working-directory: ui-tests
env:
YARN_ENABLE_IMMUTABLE_INSTALLS: 0
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: jlpm install
- name: Set up browser cache
uses: actions/cache@v4
with:
path: |
${{ github.workspace }}/pw-browsers
key: ${{ runner.os }}-${{ hashFiles('ui-tests/yarn.lock') }}
- name: Install browser
run: |
set -eux
jlpm playwright install chromium --only-shell
working-directory: ui-tests
- name: Execute integration tests
working-directory: ui-tests
run: |
jlpm playwright test
- name: Upload Playwright Test report
if: always()
uses: actions/upload-artifact@v4
with:
name: snapshot-playwright-tests
path: |
ui-tests/test-results
ui-tests/playwright-report
check_links:
name: Check Links
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1
+30
View File
@@ -0,0 +1,30 @@
name: Check Release
on:
push:
branches: ["main"]
pull_request:
branches: ["*"]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Check Release
uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Distributions
uses: actions/upload-artifact@v4
with:
name: snapshot-releaser-dist-${{ github.run_number }}
path: .jupyter_releaser_checkout/dist
+13
View File
@@ -0,0 +1,13 @@
name: Enforce PR label
on:
pull_request:
types: [labeled, unlabeled, opened, edited, synchronize]
jobs:
enforce-label:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: enforce-triage-label
uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1
+48
View File
@@ -0,0 +1,48 @@
name: "Step 1: Prep Release"
on:
workflow_dispatch:
inputs:
version_spec:
description: "New Version Specifier"
default: "next"
required: false
branch:
description: "The branch to target"
required: false
post_version_spec:
description: "Post Version Specifier"
required: false
# silent:
# description: "Set a placeholder in the changelog and don't publish the release."
# required: false
# type: boolean
since:
description: "Use PRs with activity since this date or git reference"
required: false
since_last_stable:
description: "Use PRs with activity since the last stable git tag"
required: false
type: boolean
jobs:
prep_release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Prep Release
id: prep-release
uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
version_spec: ${{ github.event.inputs.version_spec }}
# silent: ${{ github.event.inputs.silent }}
post_version_spec: ${{ github.event.inputs.post_version_spec }}
branch: ${{ github.event.inputs.branch }}
since: ${{ github.event.inputs.since }}
since_last_stable: ${{ github.event.inputs.since_last_stable }}
- name: "** Next Step **"
run: |
echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}"
+58
View File
@@ -0,0 +1,58 @@
name: "Step 2: Publish Release"
on:
workflow_dispatch:
inputs:
branch:
description: "The target branch"
required: false
release_url:
description: "The URL of the draft GitHub release"
required: false
steps_to_skip:
description: "Comma separated list of steps to skip"
required: false
jobs:
publish_release:
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
steps:
- uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Populate Release
id: populate-release
uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2
with:
token: ${{ steps.app-token.outputs.token }}
branch: ${{ github.event.inputs.branch }}
release_url: ${{ github.event.inputs.release_url }}
steps_to_skip: ${{ github.event.inputs.steps_to_skip }}
- name: Finalize Release
id: finalize-release
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2
with:
token: ${{ steps.app-token.outputs.token }}
release_url: ${{ steps.populate-release.outputs.release_url }}
- name: "** Next Step **"
if: ${{ success() }}
run: |
echo "Verify the final release"
echo ${{ steps.finalize-release.outputs.release_url }}
- name: "** Failure Message **"
if: ${{ failure() }}
run: |
echo "Failed to Publish the Draft Release Url:"
echo ${{ steps.populate-release.outputs.release_url }}
@@ -0,0 +1,39 @@
name: Update Playwright Snapshots
on:
issue_comment:
types: [created, edited]
permissions:
contents: write
pull-requests: write
jobs:
update-snapshots:
runs-on: ubuntu-latest
if: github.event.issue.pull_request && contains(github.event.comment.body, 'please update snapshots')
steps:
- uses: jupyterlab/maintainer-tools/.github/actions/update-snapshots-checkout@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Install dependencies
run: python -m pip install -U "jupyterlab>=4.0.0,<5"
- name: Install extension
run: |
set -eux
jlpm
python -m pip install .
- uses: jupyterlab/maintainer-tools/.github/actions/update-snapshots@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# Playwright knows how to start JupyterLab server
start_server_script: 'null'
test_folder: ui-tests
npm_client: jlpm
+136
View File
@@ -0,0 +1,136 @@
*.bundle.*
lib/
node_modules/
*.log
.eslintcache
.stylelintcache
*.egg-info/
.ipynb_checkpoints
*.tsbuildinfo
snapshot/labextension
# Version file is handled by hatchling
snapshot/_version.py
# IDE
.idea/
.vscode/
# Local dev / test data
data/
test.ipynb
# Integration tests
ui-tests/test-results/
ui-tests/playwright-report/
# Created by https://www.gitignore.io/api/python
# Edit at https://www.gitignore.io/?templates=python
### Python ###
# Virtual environments
.venv
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage/
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# End of https://www.gitignore.io/api/python
# OSX files
.DS_Store
# Yarn cache
.yarn/
+8
View File
@@ -0,0 +1,8 @@
node_modules
**/node_modules
**/lib
**/package.json
!/package.json
snapshot
eslint.config.mjs
.venv
+7
View File
@@ -0,0 +1,7 @@
nodeLinker: pnpm
enableScripts: false
packageExtensions:
"@module-federation/sdk@*":
dependencies:
process: "^0.11.10"
+815
View File
@@ -0,0 +1,815 @@
# JupyterLab Extension Development
This guide provides coding standards and best practices for developing JupyterLab extensions. Follow these rules to align with community standards and keep your extension maintainable.
**Extension type**: frontend-and-server
---
# Project: notebook-snapshot
## What This Project Is
Notebook 版本快照扩展:自动保存 `.ipynb` 历史版本(**无 Git 依赖**),支持侧边栏查看版本时间轴、Cell 级 diff、无刷新恢复。
**`design.md` 是目标架构文档**;当前代码仍处于模板初始状态(`src/index.ts` 调用 `/snapshot/hello``snapshot/routes.py` 只有 `HelloRouteHandler`)。实现新功能一律以 `design.md` 为准,并把模板代码替换为真实实现。
## Architecture (design.md 摘要)
- **前端**:手动提交(notebook 工具栏按钮 / 命令面板 `snapshot:commit`)→ 对话框填 name + description → POST commit**无防抖自动提交,不绑定 Ctrl+S**);左侧栏版本时间轴(list API);Cell 级 diffrestore 用 `model.fromJSON()` + `context.save()` 无刷新覆盖
- **后端**:路由挂在 `/snapshot/` 命名空间;存储 adapterLocal 默认;S3 / MinIO 走 boto3 做成 pip extra**基础安装保持零依赖**);MD5 去重(hash 命中最近版本则弹 warning 跳过);gzip 压缩持久化;`manifest.json` 记录版本索引
- **API 契约**
- `POST /snapshot/notebook-version/commit` — body `{path, content, name, description}`,content 为前端显式提交的 notebook JSON 本体(后端**不**从磁盘读);若 hash 与最近版本相同,响应 `{skipped: true, reason: "unchanged", message}`(不视为错误),前端弹 warning
- `GET /snapshot/notebook-version/list?path=...` — 返回 manifest 版本列表(name / description / 时间戳 / hash / 大小)
- `GET /snapshot/notebook-version/content?path=...&id=...` — 解压并返回旧版本 notebook JSON(已剥离 outputs
- `POST /snapshot/notebook-version/restore`**暂缓**,前端 restore 走 `fromJSON()` + `save()` 闭环
## Hard Conventions (从 design.md 评审钉死,实现时不得违背)
1. **Hash 在清洗之后计算 + 去重**:快照存的是剥离全部 `outputs` / `execution_count` 后的内容,MD5 基于清洗后 JSON 计算;若新内容 hash 与**最近版本**相同,**跳过保存**(不写文件、不更新 manifest),通过 `SnapshotUnchangedError` 通知前端弹 warning
2. **禁用 Monaco**JupyterLab 4 使用 CodeMirror 6,行级 diff 用 `diff`jsdiff)库或 CodeMirror merge 视图,不得引入第二个编辑器
3. **历史 append-only**:永不改写 / 删除历史版本;restore 走确认对话框(警告未保存修改将丢失,提示先建快照)→ `fromJSON()``context.save()`——注意手动模式下 restore **不再**自动生成备份快照
4. **快照走命令系统**:注册 `snapshot:commit` 命令(工具栏 + 命令面板);**不绑定 Ctrl+S**(与 `docmanager:save` 冲突,Lumino 后注册者会吃掉保存),不拦截 DOM 键盘事件
5. **manifest 只是索引**:真相在快照文件的 envelope`{id, timestamp, name, description, hash, size, notebook}`)内;manifest 写入原子化(tmp + rename),损坏 / 丢失时扫描版本文件重建
6. **Cell diff 匹配顺序**:先按 nbformat 4.5 的 cell id 匹配,LCS 仅作对齐兜底——纯 LCS 在 cell 重排时会产生噪声 diff
## Environment
本仓库使用 **uv/venv**(非 conda):
```bash
source .venv/bin/activate
```
## Commands
- **构建**`jlpm build`(改 TS 后必跑)/ `jlpm watch`(自动重建)
- **前端测试**`jlpm test`jest);单文件:`jlpm jest src/__tests__/snapshot.spec.ts`
- **后端测试**`pytest snapshot/tests/ -vv`;单用例:`pytest snapshot/tests/test_routes.py::test_hello`
- **Lint**`jlpm lint:check`stylelint + prettier + eslint
- **安装注册**`pip install -e ".[dev,test]" && jupyter-builder develop . --overwrite && jupyter server extension enable snapshot`
- **集成测试**`ui-tests/`Playwright + Galata,见 `ui-tests/README.md`
---
## External Documentation and Resources
### PRIORITY RESOURCE USAGE
**When you encounter uncertainty, incomplete information, or need implementation examples, you MUST consult these external resources FIRST before attempting to implement features.**
Use your available tools (web search, documentation search) to access and retrieve content from these resources when:
- You're unsure about API usage, method signatures, or interface requirements
- You need to verify the correct approach for a feature or pattern
- You're looking for existing implementation examples or best practices
- You're debugging unexpected behavior and need official documentation
- You're implementing a feature that likely exists in core JupyterLab or other extensions
### Required External Resources
**These resources are PRIORITY references. Always check them when you need external information:**
1. **JupyterLab Extension Developer Guide**
- URL: https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html
- Use for: Extension patterns, architecture overview, development workflow, and best practices
- **Action**: Use web search or documentation tools to retrieve specific sections when needed
2. **JupyterLab API Reference (Frontend)**
- URL: https://jupyterlab.readthedocs.io/en/latest/api/index.html
- Use for: Complete API reference for all JupyterLab frontend packages, interfaces, classes, and methods
- **Action**: Search for specific APIs when you need method signatures, interface definitions, or class documentation. For example, search "JupyterLab IRenderMime.IRenderer" or "JupyterLab ICommandPalette"
3. **JupyterLab Extension Examples Repository**
- URL: https://github.com/jupyterlab/extension-examples
- Use for: Working code examples, implementation patterns, complete working extensions
- **Action**: Search this repository for similar features before implementing from scratch
4. **JupyterLab Core Repository**
- URL: https://github.com/jupyterlab/jupyterlab
- Use for: Reference implementations in `packages/` directory - all core packages are extensions themselves
- **Action**: When implementing complex features, search this repo for how core extensions solve similar problems
5. **Jupyter Server API Documentation**
- URL: https://jupyter-server.readthedocs.io/
- Use for: Server-side API handlers, route setup, backend integration patterns
- **Action**: Consult when working on backend routes or server extension configuration
6. **Project-Specific Documentation**
- Locations: `README.md`, `RELEASE.md` in project root; check for `docs/` directory
- Use for: Project requirements, specific configuration, custom conventions
- **Action**: Read these files at the start of work and reference when making architectural decisions
### When to Use These Resources
**ALWAYS consult external documentation when:**
- ❗ You're about to implement a feature without knowing if there's an established pattern
- ❗ An API call or method isn't working as expected
- ❗ You need to understand the correct lifecycle methods or hooks
- ❗ You're uncertain about type definitions or interfaces
- ❗ You're implementing something that seems like it should be a common pattern
**HOW to access these resources:**
- 🔍 Use web search tools with specific queries like: "JupyterLab IRenderMime.IRenderer interface documentation"
- 🔍 Search GitHub repositories for code examples: "JupyterLab extension examples widget"
- 🔍 Retrieve documentation pages to read API specifications and usage guidelines
- 🔍 Look for working code in the extension-examples repository before writing custom implementations
**Remember:** These resources contain the authoritative information. Don't guess at API usage - look it up!
## Code Quality Rules
### Logging and Debugging
**❌ Don't**: Use `console.log()`
**✅ Do**: Use structured logging or user-facing notifications
```typescript
// In TypeScript files like src/index.ts
import { INotification } from '@jupyterlab/apputils';
app.commands.notifyCommandChanged();
```
**✅ Do**: Use `console.error()` to log low-level error details that should not be presented to users in the UI
**✅ Do**: Use `console.warn()` to log non-optimal conditions, e.g. an unexpected response from an external API that's been successfully handled.
### Type Safety
**✅ Do**: Define explicit interfaces (see example patterns in `src/index.ts`)
```typescript
interface PluginConfig {
enabled: boolean;
apiEndpoint: string;
}
```
**❌ Don't**: Use the `any` type in TypeScript files
**✅ Do**: Prefer typeguards over type casts
### File-Scoped Validation
After editing TypeScript files, run:
```bash
npx tsc --noEmit src/index.ts # Check single file
npx tsc --noEmit # Check all files
```
After editing Python files (like `snapshot/routes.py`):
```bash
python -m py_compile snapshot/__init__.py # Check single file for syntax errors
```
## Coding Standards
### Naming Conventions
**Python** (in `snapshot/*.py` files):
- **✅ Do**: Use PEP 8 style with 4-space indentation
- Classes: `DataProcessor`, `UserDataRouteHandler`
- Functions/methods: `setup_route_handlers()`, `process_request()`
- Private: `_internal_method()`
- **❌ Don't**: Use camelCase for Python or mix styles
**TypeScript/JavaScript** (in `src/*.ts` files):
- **✅ Do**: Use consistent casing
- Classes/interfaces: `MyPanelWidget`, `PluginConfig`
- Functions/variables: `activatePlugin()`, `buttonCount`
- Constants: `PLUGIN_ID`, `COMMAND_ID`
- **✅ Do**: Use 2-space indentation (Prettier default)
- **❌ Don't**: Use lowercase_snake_case or inconsistent formatting
### Documentation
**✅ Do**: Add JSDoc for TypeScript and docstrings for Python
```typescript
/**
* Activates the extension plugin.
* @param app - JupyterLab application instance
*/
function activate(app: JupyterFrontEnd): void {}
```
**❌ Don't**: Leave complex logic undocumented or use vague names like `MyRouteHandler` — prefer `DataUploadRouteHandler`
### Code Organization
**✅ Do**: Keep backend and frontend logic separate
- Backend processing in `snapshot/routes.py`
- Frontend calls in `src/request.ts` using `requestAPI()`
**❌ Don't**: Duplicate business logic across TypeScript and Python
**✅ Do**: Implement features completely or not at all. Notify the prompter if you're unable to completely implement a feature.
**❌ Don't**: Leave TODO comments or dead code in committed files
## Project Structure and Naming
### Package Naming
**Python package** (directory name and imports):
- **✅ Do**: `snapshot/` with underscores, all lowercase
- **❌ Don't**: Use dashes in any Python file or directory names
**PyPI distribution name** (in `pyproject.toml`):
- **✅ Do**: Use dashes instead of underscores, like `jupyterlab-myext`
- **✅ Do**: Match it to the npm package name for consistency
**NPM package** (in `package.json`):
- **✅ Do**: Use lowercase with dashes: `"jupyterlab-myext"` or scoped `"@org/myext"`
- **❌ Don't**: Mix naming styles between package.json and pyproject.toml
### Plugin and Command IDs
**✅ Do**: Define plugin ID in `src/index.ts`:
```typescript
const PLUGIN_ID = 'snapshot:plugin';
```
**✅ Do**: For extensions with multiple commands, create a `src/commands.ts` module to centralize command definitions:
```typescript
// src/commands.ts
import { JupyterFrontEnd } from '@jupyterlab/application';
import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
// Command IDs
export namespace CommandIDs {
export const openPanel = 'snapshot:open-panel';
export const refreshData = 'snapshot:refresh-data';
}
// Command argument types
export namespace CommandArguments {
export interface IOpenPanel {
filePath?: string;
}
export interface IRefreshData {
force?: boolean;
}
}
/**
* Register all commands with the application command registry.
* Call this function in your plugin's activate function.
*/
export function registerCommands(app: JupyterFrontEnd): void {
// Register the openPanel command
app.commands.addCommand(CommandIDs.openPanel, {
label: 'Open Panel',
caption: 'Open the extension panel',
execute: (args: ReadonlyPartialJSONObject) => {
const typedArgs = args as CommandArguments.IOpenPanel;
// Implementation using typedArgs.filePath
}
});
// Register the refreshData command
app.commands.addCommand(CommandIDs.refreshData, {
label: 'Refresh Data',
execute: (args: ReadonlyPartialJSONObject) => {
const typedArgs = args as CommandArguments.IRefreshData;
// Implementation using typedArgs.force
}
});
}
```
Then in `src/index.ts`:
```typescript
import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { registerCommands, CommandIDs, CommandArguments } from './commands';
const plugin: JupyterFrontEndPlugin<void> = {
id: 'snapshot:plugin',
autoStart: true,
activate: (app: JupyterFrontEnd) => {
// Register all commands with JupyterLab's command registry
registerCommands(app);
// Commands are now registered and can be executed anywhere:
// - From the command palette
// - From menus
// - Programmatically via app.commands.execute()
// ... rest of activation (e.g., add to palette, create widgets, etc.)
}
};
export default plugin;
```
**Executing commands with typed arguments:**
```typescript
import { CommandIDs, CommandArguments } from './commands';
// Execute with typed arguments
await app.commands.execute(CommandIDs.openPanel, {
filePath: '/path/to/file'
} as CommandArguments.IOpenPanel);
// Execute without arguments
await app.commands.execute(CommandIDs.refreshData);
```
**Notes:**
- Accept `ReadonlyPartialJSONObject` in the execute function signature (required by Lumino)
- Cast to your typed interface inside the function for type safety
- Use namespaces (`CommandIDs`, `CommandArguments`) to organize related constants and types
- This pattern matches how popular extensions like `jupyterlab-git` handle commands
**✅ Do**: For simple extensions with 1-2 commands, you can define them directly in `src/index.ts`
**❌ Don't**: Use generic IDs like `'mycommand'` or mix casing styles
### File Organization
**✅ Do**: Organize related files into directories and name by their purpose
- Widget components: `src/widgets/DataPanel.tsx` (class `DataPanel`)
- Command definitions (for multiple commands): `src/commands.ts` with `COMMANDS` mapping
- API utilities: `src/api.ts` (not `src/utils.ts`)
- Backend routes: `snapshot/routes.py` (class `DataRouteHandler`)
- Frontend logic: `src/` directory
- Python package: `snapshot/` directory
**❌ Don't**: Create catch-all files or directories like `utils.ts` or `helpers.py` or `handlers.py` — partition by feature instead
## BackendFrontend Integration
### Integration Workflow (Critical!)
When connecting frontend and backend, **ALWAYS follow this order**:
1. **Read the backend first** — Check `snapshot/routes.py` to understand the existing API contract
2. **Write frontend to match** — Create TypeScript interfaces in `src/api.ts` that match backend responses exactly
3. **Or modify backend intentionally** — If changing the backend, update it first, then write matching frontend code
**Why this matters**: Writing frontend code based on assumptions leads to field name mismatches (e.g., expecting `message` when backend returns `data`), causing empty widgets and debugging cycles. Always verify the actual backend response format first.
### Backend Routes
Create RESTful endpoints in `snapshot/routes.py`:
**✅ Do**: Extend `APIHandler` from `jupyter_server.base.handlers`
```python
from jupyter_server.base.handlers import APIHandler
from jupyter_server.utils import url_path_join
class DataRouteHandler(APIHandler):
def get(self):
"""Handle GET requests."""
result = {"status": "success", "data": "Hello"}
self.finish(result)
def post(self):
"""Handle POST requests."""
body = self.get_json_body()
# Process body...
self.finish({"status": "success"})
def setup_route_handlers(web_app):
base_url = web_app.settings.get("base_url", "/")
data_route = url_path_join(base_url, "snapshot", "data")
web_app.add_handlers(r".*$", [(data_route, DataRouteHandler)])
```
**✅ Do**: Include error handling in route handlers
**❌ Don't**:
- Hardcode URL paths — always use `url_path_join()`
- Use plain `tornado.web.RequestHandler` — instead, use `APIHandler` from `jupyter_server.base.handlers`
### Frontend API Calls
**✅ Do**: Call backend endpoints from typed API functions in `src/api.ts` (not directly in widgets):
```ts
import { ServerConnection } from '@jupyterlab/services';
import { requestAPI } from './request';
interface DataResponse {
status: 'success' | 'error';
data: string;
}
export async function fetchData(
serverSettings: ServerConnection.ISettings
): Promise<string> {
try {
const response = await requestAPI<DataResponse>('data', serverSettings, {
method: 'GET'
});
if (response.status === 'error') {
throw new Error('Server returned error');
}
return response.data;
} catch (err) {
// Extract detailed error information from ResponseError
if (err instanceof ServerConnection.ResponseError) {
const status = err.response.status;
let detail = err.message;
// Truncate HTML responses for cleaner error messages
if (
typeof detail === 'string' &&
(detail.includes('<!DOCTYPE') || detail.includes('<html'))
) {
detail = `HTML error page (${detail.substring(0, 100)}...)`;
}
throw new Error(`API request failed (${status}): ${detail}`);
}
const msg = err instanceof Error ? err.message : 'Unknown error';
throw new Error(`API request failed: ${msg}`);
}
}
```
**✅ Do**:
- Always wrap API calls in try-catch blocks with proper error handling
- Check for `ServerConnection.ResponseError` to extract HTTP status codes and response details
- Handle HTML error responses gracefully by truncating them (they're often unhelpful error pages)
- Include response status codes in error messages for better debugging
- Use matching response types between Python and TypeScript
- Create typed API wrapper functions in `src/api.ts` instead of calling `requestAPI()` directly from widgets
### API Sync and Naming
**✅ Do**: Keep backend and frontend in sync
- Match JSON keys: `{"result": ...}` in Python → `response.result` in TypeScript
- Update TypeScript interfaces when changing Python responses
- Define matching endpoint path strings (e.g., `"hello"`, `"get-data"`) in both `snapshot/routes.py` and `src/api.ts` to ensure routes sync between backend and frontend
**❌ Don't**:
- Create unused routes or orphaned API calls
- Use inconsistent field naming across languages
## Development Workflow
### Environment Activation (CRITICAL)
**Before ANY command**, ensure you're in the correct environment. This repository uses a uv-managed venv at `.venv/` (see [Environment](#environment) above):
```bash
source .venv/bin/activate # macOS/Linux (this repo)
```
**All `jlpm`, `pip`, and `jupyter` commands MUST run within the activated environment.**
**Symptoms of running outside the environment:**
- `jlpm: command not found`
- Extension not appearing after build
- `jupyter: command not found`
**✅ Do**: Always activate your environment first
**❌ Don't**: Run commands in your base/system environment
---
### Complete Development Workflow Checklist
**When implementing a new feature from scratch, follow this complete sequence:**
1. **Activate environment** (see above — required first!)
2. **Write the code** (TypeScript in `src/`, styles in `style/`, Python in `snapshot/`)
3. **Install dependencies** (if you added any to `package.json`):
```bash
jlpm install
```
4. **Build the extension**:
```bash
jlpm build
```
5. **Install the extension** (REQUIRED for JupyterLab to recognize it):
```bash
pip install -e .
jupyter-builder develop . --overwrite
jupyter server extension enable snapshot
```
6. **Verify installation**:
```bash
jupyter labextension list # Should show your extension as "enabled" and "OK"
jupyter server extension list # Should show backend extension
```
7. **Start JupyterLab**:
```bash
jupyter lab
```
8. **Test the feature** in your browser
**Critical: Steps 5-7 are REQUIRED after building. Building alone is not enough!**
---
### Understanding Build vs Install
Many issues arise from confusing these two steps:
#### `jlpm build` — Compiles the Extension. Do this every time you change TypeScript code.
- **What it does**: Compiles TypeScript → JavaScript, bundles the extension
- **Output**: Creates files in `lib/` and `snapshot/labextension/`
- **What it does NOT do**: Register the extension with JupyterLab
#### `pip install -e .` + `jupyter-builder develop .` — Registers the Extension. Do this once as a setup step.
- **What it does**: Tells JupyterLab where to find your extension
- **Output**: Creates symlinks so changes are reflected
- **Note**: Also installs the Python package in editable mode
- **Result**: Extension appears in JupyterLab
**You need BOTH steps!** Building prepares the code; installing registers it with JupyterLab.
**Common mistake**: Running only `jlpm build` and expecting the extension to appear. It won't show up until you also run the installation commands.
---
### Initial Setup (run once)
```bash
pip install -e ".[dev,test]"
jupyter-builder develop . --overwrite
jupyter server extension enable snapshot
```
### Iterative Development
**Development with auto-rebuild** (recommended):
```bash
jlpm run watch # Auto-rebuild on file changes (keep running)
# In another terminal:
jupyter lab
```
**After editing TypeScript** (files in `src/`):
- If using `jlpm run watch`: Just **refresh your browser** (Cmd+R / Ctrl+R)
- If not using watch: Run `jlpm build`, then **refresh your browser**
**Quick TypeScript validation** (optional, for fast feedback):
```bash
npx tsc --noEmit src/index.ts # Check single file
```
**After editing Python** (files in `snapshot/`):
- **Restart the JupyterLab server** (Ctrl+C in terminal, then `jupyter lab` again)
- No rebuild needed!
- Only run `pip install -e .` if you changed package structure (renamed package directory, or modified entry points in `pyproject.toml`)
**Memory aid**: "What did you change? Restart that!"
- Changed **JavaScript** → Build (or auto-builds with watch) → **Refresh browser**
- Changed **Python** → **Restart JupyterLab server** (no build needed)
### Debugging and Diagnostics
```bash
jupyter labextension list # Check if extension is installed
jupyter server extension list # Check backend extension
jlpm run lint # Lint frontend code
```
**Browser console** (ask user to check):
- Request user to open browser console (F12 or Cmd+Option+I)
- Ask user to report any JavaScript errors
- Ask user to check for failed network requests to backend endpoints
- Ask user if the extension appears to be loaded
**Server logs** (terminal running `jupyter lab`):
- Check for Python errors or exceptions
- Verify backend routes are registered
- Look for HTTP request logs
---
### Troubleshooting: Extension Not Appearing
If your extension doesn't appear in JupyterLab after building:
**1. Check if the extension is installed:**
```bash
jupyter labextension list
```
Your extension should appear as **"enabled"** and **"OK"**.
**2. If NOT in the list**, run the installation commands:
```bash
pip install -e .
jupyter-builder develop . --overwrite
jupyter server extension enable snapshot
```
**3. Did you restart JupyterLab?**
- Changes require a full restart (Ctrl+C in terminal, then `jupyter lab` again)
- Simply refreshing the browser is NOT enough for new extensions
**4. Ask user to check the browser console** (F12 or Cmd+Option+I):
- Request user to look for JavaScript errors that might prevent extension activation
- Ask user to search for the extension ID (`snapshot`) to see if it loaded
- Ask user to report any error messages or warnings
**5. Verify the build output:**
```bash
ls -la lib/ # Should contain compiled .js files
ls -la snapshot/labextension/ # Should contain bundled extension
```
**6. If still not working**, try a clean rebuild following the reset instructions below
**Common causes:**
- ❌ Only ran `jlpm build` without installation commands
- ❌ Forgot to restart JupyterLab after installation
- ❌ Running commands outside the activated environment
- ❌ Build errors that were missed (check terminal output)
### Reset (if build state is broken)
```bash
jlpm clean:all # Clean build artifacts
# git clean -fdX # (Optional) Remove all ignored files including node_modules
jlpm install # Only needed if you used 'git clean -fdX'
jlpm build
pip install -e ".[dev,test]"
jupyter-builder develop . --overwrite
jupyter server extension enable snapshot
```
### Environment Notes
**✅ Do**: Use a virtual environment (conda/mamba/micromamba/venv)
**✅ Do**: Use `jlpm` exclusively
**❌ Don't**: Mix package managers (`npm`, `yarn`) with `jlpm`
**❌ Don't**: Mix lockfiles — keep only `yarn.lock`, not `package-lock.json`
## Best Practices
### Project Structure Alignment
**✅ Do**: Follow the template structure
- Keep configuration files in project root: `package.json`, `pyproject.toml`, `tsconfig.json`
- Backend routes: `snapshot/routes.py`
- Server extension config: `jupyter-config/server-config/snapshot.json`
- Frontend code: `src/index.ts` and other `src/` files
- Styles: `style/index.css`
- Settings schema: `schema/plugin.json`
**❌ Don't**: Rename or move core files without updating all references in configuration
### Version Management
**✅ Do**: Update version in `package.json` only
- The `package.json` version is the source of truth
- `pyproject.toml` automatically syncs from `package.json` via `hatch-nodejs-version`
- Follow semantic versioning: MAJOR.MINOR.PATCH
**❌ Don't**: Manually edit version in `pyproject.toml` — it's dynamically sourced from `package.json`
**Note**: Releases are handled by GitHub Actions, not manually. AI agents should only update versions when explicitly requested by the user.
### Development Approach
**✅ Do**: Start simple and iterate
- Begin with minimal functionality (e.g., a single command or widget)
- **When integrating backend/frontend**: See [Integration Workflow](#integration-workflow-critical) for the correct order
- Add backend routes or verbs only when frontend needs them
- Test in running JupyterLab frequently
- Ask user to check browser console and review terminal logs for errors
**❌ Don't**: Build complex features without incremental testing
**❌ Don't**: Write frontend interfaces without first checking the backend API contract in `snapshot/routes.py`
## Common Pitfalls
### Package Management
**✅ Do**: Use `jlpm` consistently
```bash
jlpm install
jlpm build
```
**❌ Don't**: Mix package managers or lockfiles
- Don't use `package-lock.json` (this project uses `yarn.lock`)
- Don't run `npm install`
### Path Handling
**✅ Do**: Use relative imports in TypeScript (`src/` files)
```typescript
import { MyWidget } from './widgets/MyWidget';
```
**❌ Don't**: Use absolute paths or assume specific directory structures
### Error Handling
**✅ Do**: Wrap async operations in try-catch (in `src/api.ts`, widget code)
```typescript
try {
const data = await fetchData();
} catch (err) {
showErrorMessage('Failed to fetch data');
}
```
**❌ Don't**: Let errors propagate silently or crash the extension
### CSS and Styling
**✅ Do**: Namespace all CSS in `style/index.css`
```css
.jp-snapshot-widget {
padding: 8px;
}
```
**❌ Don't**: Use generic class names like `.widget` or `.button`
### Resource Cleanup
**✅ Do**: Dispose resources in widget `dispose()` methods
```typescript
dispose(): void {
this._signal.disconnect();
super.dispose();
}
```
**❌ Don't**: Leave event listeners or signal connections active after disposal
### Backend Integration
**✅ Do**: Use relative imports within your package
```python
from .routes import setup_route_handlers
```
**❌ Don't**: Use absolute imports like `from snapshot.routes import ...`
## Quick Reference
### Key Identifiers
Use these patterns consistently throughout your code:
- **Plugin ID** (in `src/index.ts`): `'snapshot:plugin'`
- **Command IDs** (in `src/commands.ts` or `src/index.ts`): `'snapshot:command-name'`
- For multiple commands, create `src/commands.ts` with a centralized `COMMANDS` mapping
- For 1-2 commands, define directly in `src/index.ts`
- **CSS classes** (in `style/index.css`): `.jp-snapshot-ClassName`
- **API routes** (in `snapshot/routes.py`): `url_path_join(base_url, "snapshot", "endpoint")`
### Essential Commands
See [Development Workflow](#development-workflow) section for full command reference.
+5
View File
@@ -0,0 +1,5 @@
# Changelog
<!-- <START NEW CHANGELOG ENTRY> -->
<!-- <END NEW CHANGELOG ENTRY> -->
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+102
View File
@@ -0,0 +1,102 @@
# Contributing
## Development install
Note: You will need Node.js to build the extension package.
You may install it from [nodejs.org](https://nodejs.org/en/download). We
recommend using the latest LTS version of Node.js.
The `jlpm` command is JupyterLab's pinned version of
[yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use
`yarn` or `npm` in lieu of `jlpm` below.
```bash
# Clone the repo to your local environment
# Change directory to the snapshot directory
# Set up a virtual environment and install package in development mode
python -m venv .venv
source .venv/bin/activate
pip install --editable ".[dev,test]"
# Link your development version of the extension with JupyterLab
jupyter-builder develop . --overwrite
# Server extension must be manually installed in develop mode
jupyter server extension enable snapshot
# Rebuild extension Typescript source after making changes
# IMPORTANT: Unlike the steps above which are performed only once, do this step
# every time you make a change.
jlpm build
```
You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension.
```bash
# Watch the source directory in one terminal, automatically rebuilding when needed
jlpm watch
# Run JupyterLab in another terminal
jupyter lab
```
With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt).
By default, the `jlpm build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command:
```bash
jupyter lab build --minimize=False
```
## Development uninstall
```bash
# Server extension must be manually disabled in develop mode
jupyter server extension disable snapshot
pip uninstall snapshot
```
In development mode, you will also need to remove the symlink created by `jupyter-builder develop`
command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions`
folder is located. Then you can remove the symlink named `snapshot` within that folder.
## Testing the extension
### Server tests
This extension is using [Pytest](https://docs.pytest.org/) for Python code testing.
Install test dependencies (needed only once):
```sh
pip install -e ".[test]"
# Each time you install the Python package, you need to restore the front-end extension link
jupyter-builder develop . --overwrite
```
To execute them, run:
```sh
pytest -vv -r ap --cov snapshot
```
#### Frontend tests
This extension is using [Jest](https://jestjs.io/) for JavaScript code testing.
To execute them, execute:
```sh
jlpm
jlpm test
```
### Integration tests
This extension uses [Playwright](https://playwright.dev/docs/intro) for the integration tests (aka user level tests).
More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab.
More information is provided within the [ui-tests](./ui-tests/README.md) README.
## Packaging the extension
See [RELEASE](RELEASE.md)
+29
View File
@@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2026, taochen
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+147
View File
@@ -0,0 +1,147 @@
# notebook-snapshot
JupyterLab 扩展:**手动保存 .ipynb 快照**,无 Git 依赖。
左侧栏查看版本时间轴、Cell 级 diff、一键无刷新恢复。
## 功能
- 工具栏「保存快照」按钮 / 命令面板 `snapshot:commit` 触发
- 对话框填写 `name`(必填)+ `description`(可选)后保存
- 内容与最近版本相同时自动跳过(弹 warning),避免重复快照
- 左侧栏时间轴,支持对比(Cell 级 diff,行级红绿高亮)和恢复
- 恢复走 `model.fromJSON()` + `context.save()`,无页面刷新
- 历史 append-only,永不删除 / 改写
## 存储后端
- **Local**(默认):`<root_dir>/.notebook_versions` 或自定义路径
- **S3**pip extra):通过 boto3 对接 AWS S3 / MinIO / 任何 S3-API 兼容服务
## 环境要求
- JupyterLab >= 4.0.0
- Python >= 3.10
- Node.js(构建前端用)
## 安装
```bash
# 1. 后端(基础包零依赖)
pip install snapshot
# 2. 前端构建
jlpm install
jlpm build
# 3. 启用 server extension
jupyter server extension enable snapshot
jupyter labextension develop . --overwrite # dev 安装;正式版 wheel 跳过此步
```
可选 S3 后端:
```bash
pip install "snapshot[s3]" # 装 boto3
```
## 配置
写在 `~/.jupyter/jupyter_server_config.py`(用户级)或 `<项目>/jupyter_server_config.py`(项目级):
| 字段 | 默认 | 说明 |
|------|------|------|
| `storage_type` | `"local"` | `"local"``"s3"` |
| `local_root` | `""``<root_dir>/.notebook_versions` | Local 存储根目录;**建议显式设置**避免污染 notebook 目录 |
| `s3_endpoint` | `""`env: `S3_ENDPOINT` | `localhost:9000`MinIO/ `s3.us-east-1.amazonaws.com`AWS |
| `s3_access_key` | `""`env: `S3_ACCESS_KEY` | |
| `s3_secret_key` | `""`env: `S3_SECRET_KEY` | |
| `s3_bucket` | `"notebook-snapshot"` | 不存在自动建 |
| `s3_secure` | `False` | HTTPS |
| `s3_prefix` | `""` | 桶内 key 前缀 |
### 示例
```python
# 切到自定义 local 目录
c.SnapshotConfig.local_root = "/var/lib/jupyter/snapshots"
# 切到 MinIO
c.SnapshotConfig.storage_type = "s3"
c.SnapshotConfig.s3_endpoint = "minio.example.com:9000"
c.SnapshotConfig.s3_bucket = "team-snapshots"
# 凭证走 envexport S3_ACCESS_KEY=... ; export S3_SECRET_KEY=...
# 切到 AWS S3
c.SnapshotConfig.storage_type = "s3"
c.SnapshotConfig.s3_endpoint = "s3.us-east-1.amazonaws.com"
c.SnapshotConfig.s3_secure = True
# 用 AWS 标准凭证:export AWS_ACCESS_KEY_ID=... ; export AWS_SECRET_ACCESS_KEY=...
# (注:当前版本仅读取 S3_* 环境变量;如需 AWS_* 自动识别,参考 boto3 文档配置)
```
环境变量 / 命令行 / 配置文件三种方式优先级遵循 traitlets 规则;改完配置**必须重启 `jupyter lab`**。
## 开发
```bash
source .venv/bin/activate # 本仓库用 uv/venv
pip install -e ".[dev,test,s3]" # 后端装 dev/test/s3 依赖
.venv/bin/jlpm install # 前端依赖
.venv/bin/jlpm build # 一次性构建(tsc + rspack + labextension
.venv/bin/jlpm watch # 终端 1:监听 src/ 自动重编
jupyter lab # 终端 2:启动 JupyterLab
```
**改了 Python 后端**:重启 `jupyter lab`(不需要 rebuild 前端)。
**改了前端 TypeScript**`jlpm watch` 自动重编,浏览器刷新即可。
## 验证
```bash
# 后端
.venv/bin/pytest snapshot/tests/ -vv
# 前端类型检查 + 打包
.venv/bin/jlpm build
# Lint
.venv/bin/jlpm lint:check
```
## 架构
参见 [design.md](design.md)。
简要:
- **后端**`/snapshot/` 命名空间下三个端点(commit / list / content);`SnapshotStore` 包装 `StorageAdapter`Local + S3),gzip + envelope 存盘,manifest 是可重建的索引
- **前端**`SnapshotModel` + `SnapshotPanel`(左侧栏) + `DiffWidget`(主区) + `snapshot:commit` 命令(工具栏 + 命令面板)
硬约定(实现时不得违背)见 [AGENTS.md](AGENTS.md)。
## 排错
```bash
jupyter server extension list # 后端 extension 状态
jupyter labextension list # 前端 extension 状态
```
- **工具栏没有「保存快照」按钮**:刷新浏览器;确认 `jupyter labextension list` 显示 enabled + OK
- **保存时 400**:检查 `path` / `content` / `name` 是否齐全
- **保存后弹"内容未变更"**:正常行为——内容与最近版本 hash 相同会自动跳过,改动 cell 后再保存即可
- **S3 连不上**:确认 `endpoint`(不带 scheme)、`secure`http/https)、bucket 存在性;凭证走 env 别写进配置
## 卸载
```bash
jupyter server extension disable snapshot
pip uninstall snapshot
```
## AI 工具支持
本项目遵循 [AGENTS.md](https://agents.md) 标准,配套符号链接:
- `CLAUDE.md``AGENTS.md`Claude Code
- 其他工具:Cursor / Copilot / Windsurf / Aider 等直接读取 `AGENTS.md`
详细开发规范见 [AGENTS.md](AGENTS.md),设计文档见 [design.md](design.md)。
+88
View File
@@ -0,0 +1,88 @@
# Making a new release of snapshot
The extension can be published to `PyPI` and `npm` manually or using the [Jupyter Releaser](https://github.com/jupyter-server/jupyter_releaser).
## Manual release
### Python package
This extension can be distributed as Python packages. All of the Python
packaging instructions are in the `pyproject.toml` file to wrap your extension in a
Python package. Before generating a package, you first need to install some tools:
```bash
pip install build twine hatch
```
Bump the version using `hatch`. By default this will create a tag.
See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details.
```bash
hatch version <new-version>
```
Make sure to clean up all the development files before building the package:
```bash
jlpm clean:all
```
You could also clean up the local git repository:
```bash
git clean -dfX
```
To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do:
```bash
python -m build
```
> `python setup.py sdist bdist_wheel` is deprecated and will not work for this package.
Then to upload the package to PyPI, do:
```bash
twine upload dist/*
```
### NPM package
To publish the frontend part of the extension as a NPM package, do:
```bash
npm login
npm publish --access public
```
## Automated releases with the Jupyter Releaser
The extension repository should already be compatible with the Jupyter Releaser. But
the GitHub repository and the package managers need to be properly set up. Please
follow the instructions of the Jupyter Releaser [checklist](https://jupyter-releaser.readthedocs.io/en/latest/how_to_guides/convert_repo_from_repo.html).
For the release workflows in this repository, make sure GitHub is configured with:
- a `release` environment
- an `APP_PRIVATE_KEY` secret
- an `APP_ID` repository variable
When using [npm trusted publishing](https://docs.npmjs.com/trusted-publishers), `NPM_TOKEN` is not required (and trusted publishing is recommended). Configure `NPM_TOKEN` only if you are publishing without trusted publishers.
Here is a summary of the steps to cut a new release:
- Go to the Actions panel
- Run the "Step 1: Prep Release" workflow
- Check the draft changelog
- Run the "Step 2: Publish Release" workflow
> [!NOTE]
> Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html)
> for more information.
## Publishing to `conda-forge`
If the package is not on conda forge yet, check the documentation to learn how to add it: https://conda-forge.org/docs/maintainer/adding_pkgs.html
Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically.
+1
View File
@@ -0,0 +1 @@
module.exports = require('@jupyterlab/testutils/lib/babel.config');
+8
View File
@@ -0,0 +1,8 @@
import pytest
pytest_plugins = ("pytest_jupyter.jupyter_server", )
@pytest.fixture
def jp_server_config(jp_server_config):
return {"ServerApp": {"jpserver_extensions": {"snapshot": True}}}
+84
View File
@@ -0,0 +1,84 @@
### `notebook-snapshot` 架构总结
本方案摒弃了对宿主机 Git CLI 的依赖,采用**轻量级本地文件 / 对象存储快照模式**。通过后端存储网关实现版本的压缩持久化,前端专注监听与 Cell 级的 UI 比对,整体具有**零环境依赖、轻量极速、天然兼容云存储**的特点。
#### 1. 前端职责 (TypeScript + React)
* **手动快照提交**:注册命令 `snapshot:commit`notebook 工具栏按钮 + 命令面板入口),弹出对话框填写 **name(必填)+ description(可选)** 后 POST 提交。**不做** contentChanged 防抖自动提交,**不绑定** Ctrl+S(避免与 `docmanager:save` 冲突)。
* **可视化侧边栏**:左侧栏渲染历史版本时间轴(名称 / 描述 / 时间戳 / 大小,来源于 manifest 查询 API),提供「对比 (Diff)」与「恢复 (Restore)」入口。
* **Cell 级 Diff 引擎**:先按 nbformat 4.5 cell id 匹配(modified / unchanged / added / deleted),LCS 仅作无 id 时的对齐兜底;行级 diff 用 jsdiff 渲染红绿行。**禁用 Monaco**。
* **无刷新还原**:确认对话框(含「未保存修改将丢失」警告)→ GET 旧版本 JSON → `model.fromJSON()` 内存覆盖 → `context.save()` 落盘。
#### 2. 后端职责 (Jupyter Server Extension / Python)
* **统一存储适配器 (Storage Adapter)**:抹平本地隐藏目录(如 `.notebook_versions/`)与云端对象存储(S3 / MinIO / 阿里云 OSS 等 S3-API 兼容服务)的接口差异。**已实现** Local File + S3boto3),哑接口 `put/get/list_keys/delete`
* **内容清洗 (Sanitizer)**:剥离全部 `outputs``execution_count`,基于清洗后 JSON 计算 MD5 并记录;**新内容 hash 与最近版本相同则跳过保存**(弹 warningappend-only 历史不变)。
* **压缩持久化**:快照文件为 gzip 压缩的 JSON **envelope**`{id, timestamp, name, description, hash, size, notebook}`),文件名 `v_<ts>_<hash8>.ipynb.gz`KB 级。
* **索引维护 (Manifest)**`manifest.json` 是纯索引(原子写)。真相在快照文件的 envelope 内,manifest 损坏 / 丢失时可扫描版本文件重建。
#### 3. 可配置项
* **存储类型**`local`(默认;路径 `c.SnapshotConfig.local_root`,缺省 `<root_dir>/.notebook_versions` / `s3`boto3;六件套 traitlet + `S3_ENDPOINT` / `S3_ACCESS_KEY` / `S3_SECRET_KEY` 环境变量兜底;同一配置可指向 AWS S3 或自建 MinIO
#### 4. API 契约
* `POST /snapshot/notebook-version/commit` — body `{path, content, name, description}``content` 为前端显式提交的 notebook JSON 本体(后端**不**从磁盘读)。若新内容 hash 与最近版本相同,响应 `{"skipped": true, "reason": "unchanged", "message": "..."}`HTTP 200,前端弹 warning)。
* `GET /snapshot/notebook-version/list?path=...` — 返回 manifest 版本列表(name / description / 时间戳 / hash / 大小)。
* `GET /snapshot/notebook-version/content?path=...&id=...` — 解压并返回旧版本 notebook JSON(已剥离 outputs)。
* `POST /snapshot/notebook-version/restore`**暂缓**。前端 restore 走 `fromJSON()` + `context.save()` 闭环,不依赖此端点。
---
### 系统交互流程图
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ JupyterLab FrontEnd │
└──────────────────────────────────────────────────────────────────────────────────┘
├─► [ 1. 用户点击工具栏「保存快照」按钮 / 命令面板执行 snapshot:commit ]
│ │
│ ▼
├─► [ 2. 弹出对话框:填 name (必填) + description (可选) ]
│ │
│ ▼
├─► [ 3. (POST /snapshot/notebook-version/commit) body={path, content, name, description} ]
│ │
┌──┴─────────────────────────────────────────────────────────────────────────────────┴──┐
│ Jupyter Server Extension │
└──┴────────────────────────────────────────────────────────────────────────────────────┘
├─► [ 4. Outputs / execution_count 清洗 & MD5 哈希计算 (基于清洗后 JSON) ]
│ │
│ ▼
├─► [ 5. 构建 envelope {id, timestamp, name, description, hash, size, notebook} ]
│ │
│ ▼
├─► [ 6. Gzip 压缩 envelope → 写入 v_<ts>_<hash8>.ipynb.gz (Storage Adapter) ]
└─► [ 7. append manifest.json (Storage.put 原子写) ]
│ (200 entry 响应)
┌──────────────────────────────────────────────────────────────────────────────────┐
│ JupyterLab FrontEnd │
└──────────────────────────────────────────────────────────────────────────────────┘
├─► [ 8. 侧边栏 refresh (GET /notebook-version/list?path=...) → 渲染带 name 的条目 ]
├──► [ 9. 用户点击某条 "Diff" ]
│ │
│ ├──► (GET /notebook-version/content?path=&id=) ──► 后端返回清洗后旧 JSON
│ │
│ └──► 前端按 cell id 匹配 + LCS 兜底 + jsdiff 行级 ──► 主区 DiffWidget 红绿高亮
└───► [ 10. 用户点击 "Restore" ]
├──► 确认对话框(警告未保存修改将丢失,提示先建快照)
├──► (GET /notebook-version/content?path=&id=) ──► 旧 JSON
└──► model.fromJSON() 内存覆盖 + context.save() 落盘
```
+68
View File
@@ -0,0 +1,68 @@
import js from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import prettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import jupyterPlugin from '@jupyter/eslint-plugin';
export default defineConfig([
{
ignores: [
'node_modules',
'dist',
'coverage',
'**/*.js',
'**/*.d.ts',
'.venv',
'tests',
'**/__tests__',
'ui-tests'
]
},
js.configs.recommended,
tseslint.configs.recommended,
jupyterPlugin.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
jupyter: jupyterPlugin
},
languageOptions: {
globals: {
...globals.browser,
...globals.es2015,
...globals.node
},
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module'
}
},
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'interface',
format: ['PascalCase'],
custom: {
regex: '^I[A-Z]',
match: true
}
}
],
'@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/quotes': [
'error',
'single',
{ avoidEscape: true, allowTemplateLiterals: false }
],
curly: ['error', 'all'],
eqeqeq: 'error',
'prefer-arrow-callback': 'error'
}
},
prettierRecommended
]);
+5
View File
@@ -0,0 +1,5 @@
{
"packageManager": "python",
"packageName": "snapshot",
"uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package snapshot"
}
+29
View File
@@ -0,0 +1,29 @@
const jestJupyterLab = require('@jupyterlab/testutils/lib/jest-config');
const esModules = [
'@codemirror',
'@jupyter/ydoc',
'@jupyterlab/',
'lib0',
'nanoid',
'vscode-ws-jsonrpc',
'y-protocols',
'y-websocket',
'yjs'
].join('|');
const baseConfig = jestJupyterLab(__dirname);
module.exports = {
...baseConfig,
automock: false,
reporters: ['default'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/.ipynb_checkpoints/*'
],
coverageReporters: ['lcov', 'text'],
testRegex: 'src/.*/.*.spec.ts[x]?$',
transformIgnorePatterns: [`/node_modules/(?!${esModules}).+`]
};
@@ -0,0 +1,7 @@
{
"ServerApp": {
"jpserver_extensions": {
"snapshot": true
}
}
}
+163
View File
@@ -0,0 +1,163 @@
{
"name": "snapshot",
"version": "0.1.0",
"description": "A JupyterLab extension.",
"keywords": [
"jupyter",
"jupyterlab",
"jupyterlab-extension"
],
"homepage": "http://101.43.40.124:3000/tao.chen/notebook-snapshot-extension.git",
"bugs": {
"url": "http://101.43.40.124:3000/tao.chen/notebook-snapshot-extension.git/issues"
},
"license": "BSD-3-Clause",
"author": "taochen",
"files": [
"lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
"style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
"src/**/*.{ts,tsx}",
"schema/*.json"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"style": "style/index.css",
"repository": {
"type": "git",
"url": "http://101.43.40.124:3000/tao.chen/notebook-snapshot-extension.git.git"
},
"scripts": {
"build": "jlpm build:lib && jlpm build:labextension:dev",
"build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension",
"build:labextension": "jupyter-builder build .",
"build:labextension:dev": "jupyter-builder build --development True .",
"build:lib": "tsc --sourceMap",
"build:lib:prod": "tsc",
"clean": "jlpm clean:lib",
"clean:lib": "rimraf lib tsconfig.tsbuildinfo",
"clean:lintcache": "rimraf .eslintcache .stylelintcache",
"clean:labextension": "rimraf snapshot/labextension snapshot/_version.py",
"clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache",
"eslint": "jlpm eslint:check --fix",
"eslint:check": "eslint . --cache",
"install:extension": "jlpm build",
"lint": "jlpm stylelint && jlpm prettier && jlpm eslint",
"lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check",
"prettier": "jlpm prettier:base --write --list-different",
"prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"",
"prettier:check": "jlpm prettier:base --check",
"stylelint": "jlpm stylelint:check --fix",
"stylelint:check": "stylelint --cache \"style/**/*.css\"",
"test": "jest --coverage",
"watch": "run-p watch:src watch:labextension",
"watch:src": "tsc -w --sourceMap",
"watch:labextension": "jupyter-builder watch ."
},
"dependencies": {
"@jupyterlab/application": "^4.0.0",
"@jupyterlab/apputils": "^4.0.0",
"@jupyterlab/coreutils": "^6.0.0",
"@jupyterlab/docregistry": "^4.0.0",
"@jupyterlab/notebook": "^4.0.0",
"@jupyterlab/services": "^7.0.0",
"@jupyterlab/settingregistry": "^4.0.0",
"@lumino/signaling": "^2.0.0",
"@lumino/widgets": "^2.0.0",
"diff": "^5.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@jupyter/builder": "^1.0.0",
"@jupyter/eslint-plugin": "^0.0.5",
"@jupyterlab/core-meta": "^4.6.0-beta.0",
"@jupyterlab/testing": "^4.6.1",
"@jupyterlab/testutils": "^4.0.0",
"@module-federation/runtime-tools": "^2.0.0",
"@types/diff": "^5.2.0",
"@types/jest": "^29.2.0",
"@types/json-schema": "^7.0.11",
"@types/react": "^18.0.26",
"@types/react-addons-linked-state-mixin": "^0.14.22",
"css-loader": "^6.7.1",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"globals": "^15.0.0",
"jest": "^29.2.0",
"mkdirp": "^1.0.3",
"npm-run-all2": "^7.0.1",
"prettier": "^3.0.0",
"rimraf": "^5.0.1",
"source-map-loader": "^1.0.2",
"style-loader": "^3.3.1",
"stylelint": "^15.10.1",
"stylelint-config-recommended": "^13.0.0",
"stylelint-config-standard": "^34.0.0",
"stylelint-csstree-validator": "^3.0.0",
"stylelint-prettier": "^4.0.0",
"ts-jest": "^29.4.11",
"typescript": "~5.5.4",
"typescript-eslint": "^8.0.0",
"yjs": "^13.5.0"
},
"resolutions": {
"lib0": "0.2.111",
"webpack": "5.106.0"
},
"sideEffects": [
"style/*.css",
"style/index.js"
],
"styleModule": "style/index.js",
"publishConfig": {
"access": "public"
},
"jupyterlab": {
"discovery": {
"server": {
"managers": [
"pip"
],
"base": {
"name": "snapshot"
}
}
},
"extension": true,
"outputDir": "snapshot/labextension",
"schemaDir": "schema"
},
"prettier": {
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid",
"endOfLine": "auto",
"overrides": [
{
"files": "package.json",
"options": {
"tabWidth": 4
}
}
]
},
"stylelint": {
"extends": [
"stylelint-config-recommended",
"stylelint-config-standard",
"stylelint-prettier/recommended"
],
"plugins": [
"stylelint-csstree-validator"
],
"rules": {
"csstree/validator": true,
"property-no-vendor-prefix": null,
"selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$",
"selector-no-vendor-prefix": null,
"value-no-vendor-prefix": null
}
}
}
+99
View File
@@ -0,0 +1,99 @@
[build-system]
requires = ["hatchling>=1.5.0", "hatch-nodejs-version>=0.3.2", "jupyter-builder>=1.0.0,<2"]
build-backend = "hatchling.build"
[project]
name = "snapshot"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
classifiers = [
"Framework :: Jupyter",
"Framework :: Jupyter :: JupyterLab",
"Framework :: Jupyter :: JupyterLab :: 4",
"Framework :: Jupyter :: JupyterLab :: Extensions",
"Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt",
"License :: OSI Approved :: BSD License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"jupyter_server>=2.4.0,<3",
]
dynamic = ["version", "description", "authors", "urls", "keywords"]
[project.optional-dependencies]
dev = [
"jupyterlab>=4",
"jupyter-builder>=1.0.0",
]
test = [
"coverage",
"pytest",
"pytest-asyncio",
"pytest-cov",
"pytest-jupyter[server]>=0.6.0"
]
s3 = [
"boto3>=1.34.0",
]
[[tool.uv.index]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
default = true
[tool.hatch.version]
source = "nodejs"
[tool.hatch.metadata.hooks.nodejs]
fields = ["description", "authors", "urls", "keywords"]
[tool.hatch.build.targets.sdist]
artifacts = ["snapshot/labextension"]
exclude = [".github", "binder"]
[tool.hatch.build.targets.wheel.shared-data]
"snapshot/labextension" = "share/jupyter/labextensions/snapshot"
"install.json" = "share/jupyter/labextensions/snapshot/install.json"
"jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d"
[tool.hatch.build.hooks.version]
path = "snapshot/_version.py"
[tool.hatch.build.hooks.jupyter-builder]
dependencies = ["hatch-jupyter-builder>=0.5"]
build-function = "hatch_jupyter_builder.npm_builder"
ensured-targets = [
"snapshot/labextension/static/style.js",
"snapshot/labextension/package.json",
]
skip-if-exists = ["snapshot/labextension/static/style.js"]
[tool.hatch.build.hooks.jupyter-builder.build-kwargs]
build_cmd = "build:prod"
npm = ["jlpm"]
[tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs]
build_cmd = "install:extension"
npm = ["jlpm"]
source_dir = "src"
build_dir = "snapshot/labextension"
[tool.jupyter-releaser.options]
version_cmd = "hatch version"
[tool.jupyter-releaser.hooks]
before-build-npm = [
"python -m pip install 'jupyter-builder>=1.0.0,<2'",
"jlpm",
"jlpm build:prod"
]
before-build-python = ["jlpm clean:all"]
[tool.check-wheel-contents]
ignore = ["W002"]
+8
View File
@@ -0,0 +1,8 @@
{
"jupyter.lab.shortcuts": [],
"title": "snapshot",
"description": "snapshot settings.",
"type": "object",
"properties": {},
"additionalProperties": false
}
+1
View File
@@ -0,0 +1 @@
__import__("setuptools").setup()
+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")
+62
View File
@@ -0,0 +1,62 @@
/**
* Smoke tests for the typed API wrappers. We mock requestAPI so the
* tests stay independent of the JupyterLab runtime.
*/
jest.mock('../request', () => ({
requestAPI: jest.fn()
}));
import { requestAPI } from '../request';
import { commitSnapshot, listSnapshots, getSnapshotContent } from '../api';
const mockedRequestAPI = requestAPI as jest.MockedFunction<typeof requestAPI>;
const fakeSettings = {} as any;
beforeEach(() => {
mockedRequestAPI.mockReset();
});
describe('api wrappers', () => {
it('commitSnapshot sends POST with JSON body and returns entry', async () => {
mockedRequestAPI.mockResolvedValueOnce({ entry: { id: 'v1' } } as any);
const entry = await commitSnapshot(fakeSettings, 'p.ipynb', { cells: [] }, 'n', 'd');
expect(entry).toEqual({ id: 'v1' });
expect(mockedRequestAPI).toHaveBeenCalledWith(
'notebook-version/commit',
fakeSettings,
expect.objectContaining({ method: 'POST' })
);
const init = mockedRequestAPI.mock.calls[0][2];
expect(init!.headers).toEqual({ 'Content-Type': 'application/json' });
expect(JSON.parse(init!.body as string)).toEqual({
path: 'p.ipynb',
content: { cells: [] },
name: 'n',
description: 'd'
});
});
it('listSnapshots encodes the path query and unwraps versions', async () => {
mockedRequestAPI.mockResolvedValueOnce({ versions: [{ id: 'a' }] } as any);
const versions = await listSnapshots(fakeSettings, 'a b.ipynb');
expect(versions).toEqual([{ id: 'a' }]);
expect(mockedRequestAPI).toHaveBeenCalledWith(
'notebook-version/list?path=a%20b.ipynb',
fakeSettings,
undefined
);
});
it('getSnapshotContent encodes path and id', async () => {
mockedRequestAPI.mockResolvedValueOnce({ cells: [] } as any);
const nb = await getSnapshotContent(fakeSettings, 'p.ipynb', 'v 1');
expect(nb).toEqual({ cells: [] });
expect(mockedRequestAPI).toHaveBeenCalledWith(
'notebook-version/content?path=p.ipynb&id=v%201',
fakeSettings,
undefined
);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { diffNotebook, INotebook } from '../diff';
const mk = (cells: INotebook['cells']): INotebook => ({
cells,
metadata: {},
nbformat: 4,
nbformat_minor: 5
});
describe('diffNotebook', () => {
it('marks all cells unchanged when both notebooks are identical', () => {
const nb = mk([
{ cell_type: 'code', id: 'a', source: 'x = 1' },
{ cell_type: 'markdown', id: 'b', source: '# hi' }
]);
const out = diffNotebook(nb, nb);
expect(out).toHaveLength(2);
expect(out.every(c => c.type === 'unchanged')).toBe(true);
});
it('marks modified cells with a non-empty line diff (by id)', () => {
const oldNb = mk([{ cell_type: 'code', id: 'a', source: 'x = 1' }]);
const newNb = mk([{ cell_type: 'code', id: 'a', source: 'x = 2' }]);
const out = diffNotebook(oldNb, newNb);
expect(out).toHaveLength(1);
expect(out[0].type).toBe('modified');
expect(out[0].lineDiff).toBeDefined();
expect(out[0].lineDiff!.length).toBeGreaterThan(0);
});
it('marks added cells (new id)', () => {
const oldNb = mk([{ cell_type: 'code', id: 'a', source: 'x' }]);
const newNb = mk([
{ cell_type: 'code', id: 'a', source: 'x' },
{ cell_type: 'code', id: 'b', source: 'y' }
]);
const out = diffNotebook(oldNb, newNb);
const added = out.find(c => c.type === 'added');
expect(added).toBeDefined();
expect(added!.newCell!.id).toBe('b');
});
it('marks deleted cells (id not in new)', () => {
const oldNb = mk([
{ cell_type: 'code', id: 'a', source: 'x' },
{ cell_type: 'code', id: 'b', source: 'y' }
]);
const newNb = mk([{ cell_type: 'code', id: 'a', source: 'x' }]);
const out = diffNotebook(oldNb, newNb);
const deleted = out.find(c => c.type === 'deleted');
expect(deleted).toBeDefined();
expect(deleted!.oldCell!.id).toBe('b');
});
it('falls back to LCS alignment when cells have no ids', () => {
const oldNb = mk([{ cell_type: 'code', source: 'a' }]);
const newNb = mk([
{ cell_type: 'code', source: 'a' },
{ cell_type: 'code', source: 'b' }
]);
const out = diffNotebook(oldNb, newNb);
const types = out.map(c => c.type);
expect(types).toContain('unchanged');
expect(types).toContain('added');
});
});
+9
View File
@@ -0,0 +1,9 @@
/**
* Example of [Jest](https://jestjs.io/docs/getting-started) unit tests
*/
describe('snapshot', () => {
it('should be tested', () => {
expect(1 + 1).toEqual(2);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { ServerConnection } from '@jupyterlab/services';
import { requestAPI } from './request';
export interface VersionEntry {
id: string;
timestamp: number;
name: string;
description: string;
hash: string;
size: number;
}
export interface CommitResponse {
entry: VersionEntry | null;
skipped: boolean;
reason: string | null;
message: string | null;
}
export async function commitSnapshot(
serverSettings: ServerConnection.ISettings,
path: string,
content: unknown,
name: string,
description: string
): Promise<CommitResponse> {
return requestAPI<CommitResponse>('notebook-version/commit', serverSettings, {
method: 'POST',
body: JSON.stringify({ path, content, name, description }),
headers: { 'Content-Type': 'application/json' }
});
}
export async function listSnapshots(
serverSettings: ServerConnection.ISettings,
path: string
): Promise<VersionEntry[]> {
const endpoint = `notebook-version/list?path=${encodeURIComponent(path)}`;
const response = await requestAPI<{ versions: VersionEntry[] }>(
endpoint,
serverSettings
);
return response.versions;
}
export async function getSnapshotContent(
serverSettings: ServerConnection.ISettings,
path: string,
id: string
): Promise<unknown> {
const endpoint = `notebook-version/content?path=${encodeURIComponent(path)}&id=${encodeURIComponent(id)}`;
return requestAPI<unknown>(endpoint, serverSettings);
}
+125
View File
@@ -0,0 +1,125 @@
import { JupyterFrontEnd } from '@jupyterlab/application';
import { INotebookTracker, NotebookModel } from '@jupyterlab/notebook';
import { Dialog, Notification, showDialog } from '@jupyterlab/apputils';
import { commitSnapshot, getSnapshotContent } from './api';
import { SnapshotModel } from './model';
import { CommitDialogBody } from './widgets/CommitDialog';
export namespace CommandIDs {
export const commit = 'snapshot:commit';
export const restore = 'snapshot:restore';
}
type RestoreArgs = { id?: string };
export function registerCommands(
app: JupyterFrontEnd,
tracker: INotebookTracker,
model: SnapshotModel
): void {
const { commands } = app;
commands.addCommand(CommandIDs.commit, {
label: '保存快照',
isEnabled: () => {
const panel = tracker.currentWidget;
return !!panel && !panel.context.isDisposed;
},
execute: async () => {
const panel = tracker.currentWidget;
if (!panel) {
return;
}
const path = panel.context.path;
if (!path) {
Notification.warning('请先保存当前 notebook(确定路径)后再创建快照');
return;
}
const notebookModel = panel.content.model;
if (!notebookModel) {
return;
}
const notebook = notebookModel.toJSON();
const body = new CommitDialogBody();
const result = await showDialog({
title: '保存快照',
body,
buttons: [Dialog.cancelButton(), Dialog.okButton({ label: '保存' })]
});
if (!result.button.accept) {
return;
}
const name = body.getName().trim();
if (!name) {
Notification.warning('快照名称不能为空');
return;
}
const description = body.getDescription();
try {
const result = await commitSnapshot(
app.serviceManager.serverSettings,
path,
notebook,
name,
description
);
if (result.skipped) {
// Content hash matches the most recent version — nothing to save.
Notification.warning(result.message ?? '内容未变更,已跳过保存');
} else if (result.entry) {
await model.refresh();
Notification.success(`已保存快照:${name}`);
}
} catch (e) {
Notification.error(`保存快照失败:${(e as Error).message}`);
}
}
});
commands.addCommand(CommandIDs.restore, {
label: '恢复快照',
isEnabled: () => !!tracker.currentWidget,
execute: async (args: Partial<RestoreArgs>) => {
const versionId = args.id;
if (!versionId) {
return;
}
const panel = tracker.currentWidget;
if (!panel) {
return;
}
const path = panel.context.path;
if (!path) {
return;
}
const confirm = await showDialog({
title: '恢复快照',
body:
'恢复旧版本将覆盖当前画布,未保存的修改将丢失。\n' +
'建议先为当前状态创建快照再继续。',
buttons: [Dialog.cancelButton(), Dialog.okButton({ label: '恢复' })]
});
if (!confirm.button.accept) {
return;
}
try {
const notebook = await getSnapshotContent(
app.serviceManager.serverSettings,
path,
versionId
);
const notebookModel = panel.content.model;
if (!notebookModel) {
return;
}
type NotebookContent = Parameters<NotebookModel['fromJSON']>[0];
notebookModel.fromJSON(notebook as NotebookContent);
await panel.context.save();
Notification.success('已恢复快照');
} catch (e) {
Notification.error(`恢复失败:${(e as Error).message}`);
}
}
});
}
+130
View File
@@ -0,0 +1,130 @@
import { diffLines, Change } from 'diff';
export type ChangeType = 'unchanged' | 'added' | 'deleted' | 'modified';
export interface CellChange {
type: ChangeType;
newIndex?: number;
oldIndex?: number;
newCell?: ICell;
oldCell?: ICell;
lineDiff?: Change[];
}
export interface ICell {
cell_type: string;
id?: string;
source: string;
[key: string]: unknown;
}
export interface INotebook {
cells: ICell[];
[key: string]: unknown;
}
const cellId = (c: ICell | undefined): string | undefined => c?.id as string | undefined;
const cellSource = (c: ICell | undefined): string =>
Array.isArray(c?.source) ? (c!.source as unknown as string[]).join('') : (c?.source as string) ?? '';
/**
* Diff two notebooks at the cell level.
*
* Matching order (per hard convention #6): cell id first; LCS is a fallback
* for notebooks without stable cell ids.
*/
export function diffNotebook(oldNb: INotebook, newNb: INotebook): CellChange[] {
const oldCells: ICell[] = oldNb.cells ?? [];
const newCells: ICell[] = newNb.cells ?? [];
const oldById = new Map<string, { cell: ICell; idx: number }>();
oldCells.forEach((c, i) => {
const id = cellId(c);
if (id) oldById.set(id, { cell: c, idx: i });
});
const allHaveIds = newCells.every(c => !!cellId(c)) && oldCells.every(c => !!cellId(c));
if (allHaveIds) {
return diffById(oldById, newCells);
}
return diffByLcs(oldCells, newCells);
}
function diffById(
oldById: Map<string, { cell: ICell; idx: number }>,
newCells: ICell[]
): CellChange[] {
const seenOld = new Set<string>();
const out: CellChange[] = [];
newCells.forEach((nc, ni) => {
const id = cellId(nc)!;
const hit = oldById.get(id);
if (hit) {
seenOld.add(id);
const oldSrc = cellSource(hit.cell);
const newSrc = cellSource(nc);
if (oldSrc === newSrc) {
out.push({ type: 'unchanged', newIndex: ni, oldIndex: hit.idx, newCell: nc, oldCell: hit.cell });
} else {
out.push({
type: 'modified',
newIndex: ni,
oldIndex: hit.idx,
newCell: nc,
oldCell: hit.cell,
lineDiff: diffLines(oldSrc, newSrc)
});
}
} else {
out.push({ type: 'added', newIndex: ni, newCell: nc });
}
});
// Append unmatched old cells as deleted, in their original order.
const deleted: CellChange[] = [];
oldById.forEach(({ cell, idx }, id) => {
if (!seenOld.has(id)) {
deleted.push({ type: 'deleted', oldIndex: idx, oldCell: cell });
}
});
deleted.sort((a, b) => (a.oldIndex ?? 0) - (b.oldIndex ?? 0));
return out.concat(deleted);
}
function diffByLcs(oldCells: ICell[], newCells: ICell[]): CellChange[] {
// Build an LCS on source hashes (content-equivalence), then walk.
const oldKeys = oldCells.map(c => cellSource(c));
const newKeys = newCells.map(c => cellSource(c));
const m = oldKeys.length;
const n = newKeys.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--) {
for (let j = n - 1; j >= 0; j--) {
dp[i][j] = oldKeys[i] === newKeys[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const out: CellChange[] = [];
let i = 0;
let j = 0;
while (i < m && j < n) {
if (oldKeys[i] === newKeys[j]) {
out.push({ type: 'unchanged', newIndex: j, oldIndex: i, newCell: newCells[j], oldCell: oldCells[i] });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ type: 'deleted', oldIndex: i, oldCell: oldCells[i] });
i++;
} else {
out.push({ type: 'added', newIndex: j, newCell: newCells[j] });
j++;
}
}
while (i < m) {
out.push({ type: 'deleted', oldIndex: i, oldCell: oldCells[i] });
i++;
}
while (j < n) {
out.push({ type: 'added', newIndex: j, newCell: newCells[j] });
j++;
}
return out;
}
+102
View File
@@ -0,0 +1,102 @@
import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
import { ToolbarButton, MainAreaWidget } from '@jupyterlab/apputils';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import { requestAPI } from './request';
import { registerCommands, CommandIDs } from './commands';
import { SnapshotModel } from './model';
import { SnapshotPanel } from './widgets/SnapshotPanel';
import { DiffWidget } from './widgets/DiffWidget';
import { INotebook } from './diff';
/**
* Initialization data for the snapshot extension.
*/
const plugin: JupyterFrontEndPlugin<void> = {
id: 'snapshot:plugin',
description: 'Notebook version snapshot extension (manual commit, name + description).',
autoStart: true,
requires: [INotebookTracker],
optional: [ISettingRegistry],
activate: (
app: JupyterFrontEnd,
tracker: INotebookTracker,
settingRegistry: ISettingRegistry | null
) => {
console.log('JupyterLab extension snapshot is activated!');
if (settingRegistry) {
settingRegistry
.load(plugin.id)
.then(settings => {
console.log('snapshot settings loaded:', settings.composite);
})
.catch(reason => {
console.error('Failed to load settings for snapshot.', reason);
});
}
const serverSettings = app.serviceManager.serverSettings;
const model = new SnapshotModel(serverSettings);
const openDiff = (newNb: INotebook, oldNb: INotebook, name: string) => {
const widget = new DiffWidget({ newNb, oldNb });
widget.title.label = name && name.trim() ? name : '快照对比';
widget.title.closable = true;
const host = new MainAreaWidget({ content: widget });
app.shell.add(host, 'main', { mode: 'split-right' });
};
const panel = new SnapshotPanel({
model,
app,
tracker,
serverSettings,
onOpenDiff: openDiff
});
panel.id = 'snapshot:panel';
panel.title.label = '快照历史';
app.shell.add(panel, 'left', { rank: 200 });
// Keep the model in sync with the current notebook.
const syncPath = () => {
const current = tracker.currentWidget;
model.setPath(current?.context.path ?? '');
};
tracker.widgetAdded.connect(() => syncPath());
tracker.currentChanged.connect(() => syncPath());
syncPath();
registerCommands(app, tracker, model);
// Add a "保存快照" button to every notebook panel's toolbar.
// NOTE: docRegistry.addWidgetExtension('Notebook', ...) places its
// returned widget in the *main area* (a new tab), not on the toolbar.
// The toolbar wants panel.toolbar.addItem directly.
const addCommitButton = (panelHost: NotebookPanel) => {
const button = new ToolbarButton({
label: '保存快照',
tooltip: '保存当前 notebook 为快照',
onClick: () => {
void app.commands.execute(CommandIDs.commit);
}
});
button.addClass('jp-snapshot-toolbar-button');
panelHost.toolbar.addItem('snapshot:commit', button);
};
// Add to panels that are already open at activation time.
tracker.forEach(addCommitButton);
// Add to any panel that opens later.
tracker.widgetAdded.connect((_, panelHost) => addCommitButton(panelHost));
// Keep the unused import alive for the build (requestAPI is part of
// the public surface and re-used by the api module).
void requestAPI;
}
};
export default plugin;
+66
View File
@@ -0,0 +1,66 @@
import { ISignal, Signal } from '@lumino/signaling';
import { ServerConnection } from '@jupyterlab/services';
import { listSnapshots, VersionEntry } from './api';
/**
* Front-end state for the snapshot panel of a single notebook path.
*
* Holds the current path + cached version list, and emits
* `versionsChanged` whenever either is updated. The panel renders
* from this state.
*/
export class SnapshotModel {
private _path = '';
private _versions: VersionEntry[] = [];
private _loading = false;
private _versionsChanged = new Signal<this, void>(this);
constructor(private _serverSettings: ServerConnection.ISettings) {}
get path(): string {
return this._path;
}
get versions(): VersionEntry[] {
return this._versions;
}
get loading(): boolean {
return this._loading;
}
get versionsChanged(): ISignal<this, void> {
return this._versionsChanged;
}
setPath(path: string): void {
if (this._path === path) {
return;
}
this._path = path;
if (path) {
void this.refresh();
} else {
this._versions = [];
this._versionsChanged.emit();
}
}
async refresh(): Promise<void> {
if (!this._path) {
return;
}
this._loading = true;
this._versionsChanged.emit();
try {
this._versions = await listSnapshots(this._serverSettings, this._path);
} catch (e) {
console.error('Failed to list snapshots', e);
this._versions = [];
} finally {
this._loading = false;
this._versionsChanged.emit();
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
/**
* Call the server extension
*
* @param endPoint API REST end point for the extension
* @param serverSettings The server settings to use for the request
* @param init Initial values for the request
* @returns The response body interpreted as JSON
*/
export async function requestAPI<T>(
endPoint: string,
serverSettings: ServerConnection.ISettings,
init: RequestInit = {}
): Promise<T> {
// Make request to Jupyter API
const requestUrl = URLExt.join(
serverSettings.baseUrl,
'snapshot', // our server extension's API namespace
endPoint
);
let response: Response;
try {
response = await ServerConnection.makeRequest(
requestUrl,
init,
serverSettings
);
} catch (error) {
throw new ServerConnection.NetworkError(error as any);
}
let data: any = await response.text();
if (data.length > 0) {
try {
data = JSON.parse(data);
} catch (error) {
console.log('Not a JSON response body.', response);
}
}
if (!response.ok) {
throw new ServerConnection.ResponseError(response, data.message || data);
}
return data;
}
+52
View File
@@ -0,0 +1,52 @@
import { Widget } from '@lumino/widgets';
/**
* Body widget for the manual-commit dialog. Two fields:
* - name (required)
* - description (optional)
*
* Values are read out via `getName()` / `getDescription()` after the
* dialog is dismissed with the OK button.
*/
export class CommitDialogBody extends Widget {
private _name: HTMLInputElement;
private _description: HTMLTextAreaElement;
constructor() {
super();
this.addClass('jp-snapshot-commit-dialog');
this._name = document.createElement('input');
this._name.className = 'jp-snapshot-commit-dialog-name';
this._name.type = 'text';
this._name.placeholder = '快照名称(必填)';
this._name.spellcheck = false;
this._description = document.createElement('textarea');
this._description.className = 'jp-snapshot-commit-dialog-desc';
this._description.placeholder = '快照描述(可选)';
this._description.rows = 4;
const nameLabel = document.createElement('label');
nameLabel.textContent = '名称';
const descLabel = document.createElement('label');
descLabel.textContent = '描述';
this.node.appendChild(nameLabel);
this.node.appendChild(this._name);
this.node.appendChild(descLabel);
this.node.appendChild(this._description);
}
getName(): string {
return this._name.value;
}
getDescription(): string {
return this._description.value;
}
/** Focus the name input on activation for keyboard convenience. */
protected onAfterAttach(): void {
this._name.focus();
}
}
+97
View File
@@ -0,0 +1,97 @@
import * as React from 'react';
import { ReactWidget } from '@jupyterlab/apputils';
import { diffNotebook, CellChange, INotebook } from '../diff';
const TYPE_LABEL: Record<CellChange['type'], string> = {
unchanged: '未变',
added: '新增',
deleted: '删除',
modified: '修改'
};
function renderSource(source: string): React.ReactNode {
// Preserve line structure.
return source.split('\n').map((line, i, arr) => (
<React.Fragment key={i}>
{line}
{i < arr.length - 1 ? '\n' : ''}
</React.Fragment>
));
}
function renderLineDiff(changes: import('diff').Change[]): React.ReactNode {
return (
<pre className="jp-snapshot-diff-pre">
{changes.map((c, i) => {
const cls = c.added
? 'jp-snapshot-diff-line-added'
: c.removed
? 'jp-snapshot-diff-line-removed'
: 'jp-snapshot-diff-line-context';
return (
<span key={i} className={cls}>
{c.added ? '+ ' : c.removed ? '- ' : ' '}
{renderSource(c.value)}
</span>
);
})}
</pre>
);
}
const ChangeRow: React.FC<{ change: CellChange }> = ({ change }) => {
const cell =
change.newCell ?? change.oldCell;
const source =
change.newCell
? Array.isArray(change.newCell.source)
? (change.newCell.source as unknown as string[]).join('')
: (change.newCell.source as string)
: change.oldCell
? Array.isArray(change.oldCell.source)
? (change.oldCell.source as unknown as string[]).join('')
: (change.oldCell.source as string)
: '';
return (
<div className={`jp-snapshot-diff-row jp-snapshot-diff-${change.type}`}>
<div className="jp-snapshot-diff-badge">{TYPE_LABEL[change.type]}</div>
<div className="jp-snapshot-diff-cell-type">{cell?.cell_type ?? ''}</div>
{change.type === 'modified' && change.lineDiff ? (
renderLineDiff(change.lineDiff)
) : (
<pre className="jp-snapshot-diff-pre">{renderSource(source)}</pre>
)}
</div>
);
};
interface DiffWidgetProps {
newNb: INotebook;
oldNb: INotebook;
}
const DiffView: React.FC<DiffWidgetProps> = ({ newNb, oldNb }) => {
const changes = React.useMemo(() => diffNotebook(oldNb, newNb), [oldNb, newNb]);
if (changes.length === 0) {
return <div className="jp-snapshot-diff-empty"></div>;
}
return (
<div className="jp-snapshot-diff-container">
{changes.map((c, i) => (
<ChangeRow key={i} change={c} />
))}
</div>
);
};
export class DiffWidget extends ReactWidget {
constructor(private readonly _props: DiffWidgetProps) {
super();
this.addClass('jp-snapshot-diff-widget');
}
render(): React.ReactElement {
return <DiffView {...this._props} />;
}
}
+127
View File
@@ -0,0 +1,127 @@
import * as React from 'react';
import { ReactWidget } from '@jupyterlab/apputils';
import { ServerConnection } from '@jupyterlab/services';
import { getSnapshotContent } from '../api';
import { INotebook } from '../diff';
import { SnapshotModel } from '../model';
import { CommandIDs } from '../commands';
import { JupyterFrontEnd } from '@jupyterlab/application';
import { INotebookTracker } from '@jupyterlab/notebook';
function formatTimestamp(ts: number): string {
// timestamp is microseconds (see backend store.py)
const ms = ts / 1000;
return new Date(ms).toLocaleString();
}
function formatSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
interface PanelProps {
model: SnapshotModel;
app: JupyterFrontEnd;
tracker: INotebookTracker;
serverSettings: ServerConnection.ISettings;
onOpenDiff: (newNb: INotebook, oldNb: INotebook, name: string) => void;
}
const Panel: React.FC<PanelProps> = ({ model, app, tracker, serverSettings, onOpenDiff }) => {
const [, force] = React.useReducer(x => x + 1, 0);
React.useEffect(() => {
const onChange = () => force();
model.versionsChanged.connect(onChange);
return () => {
model.versionsChanged.disconnect(onChange);
};
}, [model]);
if (!model.path) {
return <div className="jp-snapshot-panel-empty"> notebook</div>;
}
if (model.loading && model.versions.length === 0) {
return <div className="jp-snapshot-panel-empty"></div>;
}
if (model.versions.length === 0) {
return (
<div className="jp-snapshot-panel-empty">
<div></div>
<div className="jp-snapshot-panel-hint"></div>
</div>
);
}
return (
<ul className="jp-snapshot-panel-list">
{model.versions.map(v => (
<li key={v.id} className="jp-snapshot-panel-item">
<div className="jp-snapshot-panel-item-header">
<span className="jp-snapshot-panel-name" title={v.name}>
{v.name}
</span>
<span className="jp-snapshot-panel-size">{formatSize(v.size)}</span>
</div>
{v.description ? (
<div className="jp-snapshot-panel-desc">{v.description}</div>
) : null}
<div className="jp-snapshot-panel-time">{formatTimestamp(v.timestamp)}</div>
<div className="jp-snapshot-panel-actions">
<button
className="jp-snapshot-panel-button"
onClick={async () => {
const panel = tracker.currentWidget;
if (!panel) {
return;
}
const notebookModel = panel.content.model;
if (!notebookModel) {
return;
}
try {
const oldNb = (await getSnapshotContent(
serverSettings,
model.path,
v.id
)) as INotebook;
const newNb = notebookModel.toJSON() as unknown as INotebook;
onOpenDiff(newNb, oldNb, v.name);
} catch (e) {
console.error('Diff failed', e);
}
}}
>
</button>
<button
className="jp-snapshot-panel-button"
onClick={() => {
void app.commands.execute(CommandIDs.restore, { id: v.id });
}}
>
</button>
</div>
</li>
))}
</ul>
);
};
export class SnapshotPanel extends ReactWidget {
constructor(
private readonly _props: PanelProps
) {
super();
this.addClass('jp-snapshot-panel');
}
render(): React.ReactElement {
return <Panel {...this._props} />;
}
}
+206
View File
@@ -0,0 +1,206 @@
/*
See the JupyterLab Developer Guide for useful CSS Patterns:
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
*/
/* snapshot extension */
.jp-snapshot-panel {
padding: 8px;
overflow-y: auto;
height: 100%;
box-sizing: border-box;
}
.jp-snapshot-panel-empty {
color: var(--jp-ui-font-color2, #555);
padding: 12px;
text-align: center;
}
.jp-snapshot-panel-hint {
font-size: 0.85em;
margin-top: 8px;
color: var(--jp-ui-font-color2, #888);
}
.jp-snapshot-panel-list {
list-style: none;
margin: 0;
padding: 0;
}
.jp-snapshot-panel-item {
border: 1px solid var(--jp-border-color2, #ddd);
border-radius: 4px;
padding: 8px;
margin-bottom: 8px;
background: var(--jp-layout-color1, #fff);
}
.jp-snapshot-panel-item-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.jp-snapshot-panel-name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jp-snapshot-panel-size {
font-size: 0.8em;
color: var(--jp-ui-font-color2, #888);
white-space: nowrap;
}
.jp-snapshot-panel-desc {
font-size: 0.9em;
color: var(--jp-ui-font-color2, #555);
margin: 4px 0;
white-space: pre-wrap;
}
.jp-snapshot-panel-time {
font-size: 0.8em;
color: var(--jp-ui-font-color2, #888);
margin-bottom: 6px;
}
.jp-snapshot-panel-actions {
display: flex;
gap: 4px;
}
.jp-snapshot-panel-button {
flex: 1;
padding: 4px 8px;
font-size: 0.85em;
border: 1px solid var(--jp-border-color2, #ccc);
border-radius: 3px;
background: var(--jp-layout-color2, #f5f5f5);
cursor: pointer;
}
.jp-snapshot-panel-button:hover {
background: var(--jp-layout-color3, #e8e8e8);
}
/* commit dialog */
.jp-snapshot-commit-dialog {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
min-width: 320px;
}
.jp-snapshot-commit-dialog label {
font-weight: 600;
font-size: 0.9em;
}
.jp-snapshot-commit-dialog-name,
.jp-snapshot-commit-dialog-desc {
font-family: inherit;
font-size: 1em;
padding: 4px 6px;
border: 1px solid var(--jp-border-color2, #ccc);
border-radius: 3px;
box-sizing: border-box;
width: 100%;
}
.jp-snapshot-commit-dialog-desc {
resize: vertical;
}
/* diff widget */
.jp-snapshot-diff-widget {
padding: 8px;
overflow-y: auto;
height: 100%;
box-sizing: border-box;
}
.jp-snapshot-diff-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.jp-snapshot-diff-row {
border: 1px solid var(--jp-border-color2, #ddd);
border-radius: 4px;
padding: 6px;
background: var(--jp-layout-color1, #fff);
}
.jp-snapshot-diff-added {
background: var(--jp-diff-added-color1, #e6ffed);
border-left: 3px solid #2cbe4e;
}
.jp-snapshot-diff-deleted {
background: var(--jp-diff-deleted-color1, #ffeef0);
border-left: 3px solid #cb2431;
}
.jp-snapshot-diff-modified {
background: var(--jp-diff-modified-color1, #fff5b1);
border-left: 3px solid #dfb700;
}
.jp-snapshot-diff-unchanged {
background: var(--jp-layout-color1, #fff);
border-left: 3px solid var(--jp-border-color2, #ddd);
}
.jp-snapshot-diff-badge {
display: inline-block;
font-size: 0.8em;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
margin-right: 6px;
background: var(--jp-layout-color2, #eee);
}
.jp-snapshot-diff-cell-type {
display: inline-block;
font-size: 0.8em;
color: var(--jp-ui-font-color2, #888);
}
.jp-snapshot-diff-pre {
font-family: var(--jp-code-font-family, monospace);
font-size: 0.9em;
margin: 6px 0 0 0;
white-space: pre-wrap;
word-break: break-all;
}
.jp-snapshot-diff-line-added {
background: #ccffd8;
}
.jp-snapshot-diff-line-removed {
background: #ffd7d5;
}
.jp-snapshot-diff-line-context {
color: var(--jp-ui-font-color2, #555);
}
.jp-snapshot-diff-empty {
color: var(--jp-ui-font-color2, #888);
text-align: center;
padding: 16px;
}
+1
View File
@@ -0,0 +1 @@
@import url('base.css');
+1
View File
@@ -0,0 +1 @@
import './base.css';
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"composite": true,
"declaration": true,
"esModuleInterop": true,
"incremental": true,
"jsx": "react",
"lib": ["DOM", "ES2018", "ES2020.Intl"],
"module": "esnext",
"moduleResolution": "node",
"noEmitOnError": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"preserveWatchOutput": true,
"resolveJsonModule": true,
"outDir": "lib",
"rootDir": "src",
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"target": "ES2018",
"types": ["jest"]
},
"include": ["src/**/*"]
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./tsconfig"
}
+167
View File
@@ -0,0 +1,167 @@
# Integration Testing
This folder contains the integration tests of the extension.
They are defined using [Playwright](https://playwright.dev/docs/intro) test runner
and [Galata](https://github.com/jupyterlab/jupyterlab/tree/main/galata) helper.
The Playwright configuration is defined in [playwright.config.js](./playwright.config.js).
The JupyterLab server configuration to use for the integration test is defined
in [jupyter_server_test_config.py](./jupyter_server_test_config.py).
The default configuration will produce video for failing tests and an HTML report.
> There is a UI mode that you may like; see [that video](https://www.youtube.com/watch?v=jF0yA-JLQW0).
## Run the tests
> All commands are assumed to be executed from the root directory
To run the tests, you need to:
1. Compile the extension:
```sh
jlpm install
jlpm build:prod
```
> Check the extension is installed in JupyterLab.
2. Install test dependencies (needed only once):
```sh
cd ./ui-tests
jlpm install
jlpm playwright install
cd ..
```
3. Execute the [Playwright](https://playwright.dev/docs/intro) tests:
```sh
cd ./ui-tests
jlpm playwright test
```
Test results will be shown in the terminal. In case of any test failures, the test report
will be opened in your browser at the end of the tests execution; see
[Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter)
for configuring that behavior.
## Update the tests snapshots
> All commands are assumed to be executed from the root directory
If you are comparing snapshots to validate your tests, you may need to update
the reference snapshots stored in the repository. To do that, you need to:
1. Compile the extension:
```sh
jlpm install
jlpm build:prod
```
> Check the extension is installed in JupyterLab.
2. Install test dependencies (needed only once):
```sh
cd ./ui-tests
jlpm install
jlpm playwright install
cd ..
```
3. Execute the [Playwright](https://playwright.dev/docs/intro) command:
```sh
cd ./ui-tests
jlpm playwright test -u
```
> Some discrepancy may occurs between the snapshots generated on your computer and
> the one generated on the CI. To ease updating the snapshots on a PR, you can
> type `please update playwright snapshots` to trigger the update by a bot on the CI.
> Once the bot has computed new snapshots, it will commit them to the PR branch.
## Create tests
> All commands are assumed to be executed from the root directory
To create tests, the easiest way is to use the code generator tool of playwright:
1. Compile the extension:
```sh
jlpm install
jlpm build:prod
```
> Check the extension is installed in JupyterLab.
2. Install test dependencies (needed only once):
```sh
cd ./ui-tests
jlpm install
jlpm playwright install
cd ..
```
3. Start the server:
```sh
cd ./ui-tests
jlpm start
```
4. Execute the [Playwright code generator](https://playwright.dev/docs/codegen) in **another terminal**:
```sh
cd ./ui-tests
jlpm playwright codegen localhost:8888
```
## Debug tests
> All commands are assumed to be executed from the root directory
To debug tests, a good way is to use the inspector tool of playwright:
1. Compile the extension:
```sh
jlpm install
jlpm build:prod
```
> Check the extension is installed in JupyterLab.
2. Install test dependencies (needed only once):
```sh
cd ./ui-tests
jlpm install
jlpm playwright install
cd ..
```
3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug):
```sh
cd ./ui-tests
jlpm playwright test --debug
```
## Upgrade Playwright and the browsers
To update the web browser versions, you must update the package `@playwright/test`:
```sh
cd ./ui-tests
jlpm up "@playwright/test"
jlpm playwright install
```
+12
View File
@@ -0,0 +1,12 @@
"""Server configuration for integration tests.
!! Never use this configuration in production because it
opens the server to the world and provide access to JupyterLab
JavaScript objects through the global window variable.
"""
from jupyterlab.galata import configure_jupyter_server
configure_jupyter_server(c)
# Uncomment to set server log level to debug level
# c.ServerApp.log_level = "DEBUG"
+15
View File
@@ -0,0 +1,15 @@
{
"name": "snapshot-ui-tests",
"version": "1.0.0",
"description": "JupyterLab snapshot Integration Tests",
"private": true,
"scripts": {
"start": "jupyter lab --config jupyter_server_test_config.py",
"test": "jlpm playwright test",
"test:update": "jlpm playwright test --update-snapshots"
},
"devDependencies": {
"@jupyterlab/galata": "^5.0.5",
"@playwright/test": "^1.60.0"
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Configuration for Playwright using default from @jupyterlab/galata
*/
const baseConfig = require('@jupyterlab/galata/lib/playwright-config');
module.exports = {
...baseConfig,
webServer: {
command: 'jlpm start',
url: 'http://localhost:8888/lab',
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI
}
};
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from '@jupyterlab/galata';
/**
* Don't load JupyterLab webpage before running the tests.
* This is required to ensure we capture all log messages.
*/
test.use({ autoGoto: false });
test('should emit an activation console message', async ({ page }) => {
const logs: string[] = [];
page.on('console', message => {
logs.push(message.text());
});
await page.goto();
expect(
logs.filter(s => s === 'JupyterLab extension snapshot is activated!')
).toHaveLength(1);
});
View File
Generated
+2202
View File
File diff suppressed because it is too large Load Diff
+10433
View File
File diff suppressed because it is too large Load Diff