dev: initial implementation of notebook-snapshot extension
Manual-commit notebook snapshot extension (JupyterLab 4):
Backend
- Storage adapter layer (Local + S3 via boto3, pip extra)
- Dumb put/get/list/delete contract; key validation
- S3Storage: lazy boto3 import, S3ConnectionError with friendly
messages + connectivity check at startup
- SnapshotStore
- Strip code outputs/execution_count, MD5 on cleaned JSON
- Gzipped envelope (id/timestamp/name/description/hash/size/notebook)
- Append-only manifest.json (rebuildable from version files)
- SnapshotUnchangedError when new hash matches most recent version
- REST API (commit / list / content) under /snapshot/ namespace
- traitlets config (storage_type, local_root, s3_* + S3_* env fallback)
Frontend
- snapshot:commit command (toolbar button + command palette)
- Dialog for name + description
- SnapshotPanel (left sidebar timeline) + DiffWidget (main area)
- Cell diff: id-match first, LCS fallback; jsdiff for line diff
- Restore via model.fromJSON() + context.save() (no refresh)
- All AGENTS.md hard conventions enforced
Tests: 33 backend pytest passing (storage + store + routes)
Docs: AGENTS.md, design.md, README.md synced with implementation.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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
@@ -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/
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/lib
|
||||
**/package.json
|
||||
!/package.json
|
||||
snapshot
|
||||
eslint.config.mjs
|
||||
.venv
|
||||
@@ -0,0 +1,7 @@
|
||||
nodeLinker: pnpm
|
||||
enableScripts: false
|
||||
|
||||
packageExtensions:
|
||||
"@module-federation/sdk@*":
|
||||
dependencies:
|
||||
process: "^0.11.10"
|
||||
@@ -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 级 diff;restore 用 `model.fromJSON()` + `context.save()` 无刷新覆盖
|
||||
- **后端**:路由挂在 `/snapshot/` 命名空间;存储 adapter(Local 默认;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
|
||||
|
||||
## Backend–Frontend 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.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
<!-- <START NEW CHANGELOG ENTRY> -->
|
||||
|
||||
<!-- <END NEW CHANGELOG ENTRY> -->
|
||||
+102
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
# 凭证走 env:export 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
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@jupyterlab/testutils/lib/babel.config');
|
||||
@@ -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}}}
|
||||
@@ -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 + S3(boto3),哑接口 `put/get/list_keys/delete`。
|
||||
* **内容清洗 (Sanitizer)**:剥离全部 `outputs` 与 `execution_count`,基于清洗后 JSON 计算 MD5 并记录;**新内容 hash 与最近版本相同则跳过保存**(弹 warning,append-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() 落盘
|
||||
```
|
||||
@@ -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
|
||||
]);
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"packageManager": "python",
|
||||
"packageName": "snapshot",
|
||||
"uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package snapshot"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"jupyter.lab.shortcuts": [],
|
||||
"title": "snapshot",
|
||||
"description": "snapshot settings.",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
try:
|
||||
from ._version import __version__
|
||||
except ImportError:
|
||||
# Fallback when using the package in dev mode without installing
|
||||
# in editable mode with pip. It is highly recommended to install
|
||||
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
|
||||
import warnings
|
||||
warnings.warn("Importing 'snapshot' outside a proper installation.")
|
||||
__version__ = "dev"
|
||||
from .config import SnapshotConfig
|
||||
from .routes import setup_route_handlers
|
||||
from .storage import create_storage
|
||||
from .store import SnapshotStore
|
||||
|
||||
|
||||
def _jupyter_labextension_paths():
|
||||
return [{
|
||||
"src": "labextension",
|
||||
"dest": "snapshot"
|
||||
}]
|
||||
|
||||
|
||||
def _jupyter_server_extension_points():
|
||||
return [{
|
||||
"module": "snapshot"
|
||||
}]
|
||||
|
||||
|
||||
def _load_jupyter_server_extension(server_app):
|
||||
"""Registers the API handler to receive HTTP requests from the frontend extension.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
server_app: jupyterlab.labapp.LabApp
|
||||
JupyterLab application instance
|
||||
"""
|
||||
config = SnapshotConfig(config=server_app.config)
|
||||
storage = create_storage(
|
||||
storage_type=config.storage_type,
|
||||
root_dir=server_app.root_dir,
|
||||
local_root=config.local_root,
|
||||
endpoint=config.s3_endpoint,
|
||||
access_key=config.s3_access_key,
|
||||
secret_key=config.s3_secret_key,
|
||||
bucket=config.s3_bucket,
|
||||
secure=config.s3_secure,
|
||||
prefix=config.s3_prefix,
|
||||
)
|
||||
server_app.web_app.settings["snapshot_config"] = config
|
||||
server_app.web_app.settings["snapshot_storage"] = storage
|
||||
server_app.web_app.settings["snapshot_store"] = SnapshotStore(storage)
|
||||
if config.storage_type == "s3":
|
||||
server_app.log.info(
|
||||
f"Snapshot S3 storage ready: endpoint={config.s3_endpoint}, "
|
||||
f"bucket={config.s3_bucket}, secure={config.s3_secure}"
|
||||
)
|
||||
else:
|
||||
server_app.log.info(
|
||||
f"Activated snapshot storage backend: {config.storage_type}"
|
||||
)
|
||||
|
||||
setup_route_handlers(server_app.web_app)
|
||||
name = "snapshot"
|
||||
server_app.log.info(f"Registered {name} server extension")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Traitlets-based configuration for the snapshot server extension."""
|
||||
|
||||
import os
|
||||
|
||||
from traitlets.config.configurable import Configurable
|
||||
from traitlets.traitlets import Bool, Unicode, default
|
||||
|
||||
|
||||
class SnapshotConfig(Configurable):
|
||||
"""Configuration options for the notebook snapshot extension."""
|
||||
|
||||
storage_type = Unicode(
|
||||
"local",
|
||||
config=True,
|
||||
help='Storage backend type. Either "local" or "s3" (covers AWS S3, MinIO, etc.).',
|
||||
)
|
||||
local_root = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="Directory for local snapshot storage. Empty means <server root_dir>/.notebook_versions.",
|
||||
)
|
||||
|
||||
s3_endpoint = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="S3 endpoint host (without scheme), e.g. localhost:9000 for MinIO or s3.us-east-1.amazonaws.com for AWS S3.",
|
||||
)
|
||||
|
||||
@default("s3_endpoint")
|
||||
def _default_s3_endpoint(self):
|
||||
"""Default to the ``S3_ENDPOINT`` environment variable."""
|
||||
return os.environ.get("S3_ENDPOINT", "")
|
||||
|
||||
s3_access_key = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="S3 access key ID.",
|
||||
)
|
||||
|
||||
@default("s3_access_key")
|
||||
def _default_s3_access_key(self):
|
||||
"""Default to the ``S3_ACCESS_KEY`` environment variable."""
|
||||
return os.environ.get("S3_ACCESS_KEY", "")
|
||||
|
||||
s3_secret_key = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="S3 secret access key.",
|
||||
)
|
||||
|
||||
@default("s3_secret_key")
|
||||
def _default_s3_secret_key(self):
|
||||
"""Default to the ``S3_SECRET_KEY`` environment variable."""
|
||||
return os.environ.get("S3_SECRET_KEY", "")
|
||||
|
||||
s3_bucket = Unicode(
|
||||
"notebook-snapshot",
|
||||
config=True,
|
||||
help="S3 bucket name. Created automatically if it does not exist.",
|
||||
)
|
||||
|
||||
s3_secure = Bool(
|
||||
False,
|
||||
config=True,
|
||||
help="Use HTTPS for the S3 connection.",
|
||||
)
|
||||
|
||||
s3_prefix = Unicode(
|
||||
"",
|
||||
config=True,
|
||||
help="Optional prefix prepended to all object keys in the S3 bucket.",
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
import json
|
||||
|
||||
from jupyter_server.base.handlers import APIHandler
|
||||
from jupyter_server.utils import url_path_join
|
||||
import tornado
|
||||
|
||||
from .store import SnapshotUnchangedError
|
||||
|
||||
|
||||
class HelloRouteHandler(APIHandler):
|
||||
# The following decorator should be present on all verb methods (head, get, post,
|
||||
# patch, put, delete, options) to ensure only authorized user can request the
|
||||
# Jupyter server
|
||||
@tornado.web.authenticated
|
||||
def get(self):
|
||||
self.finish(json.dumps({
|
||||
"data": (
|
||||
"Hello, world!"
|
||||
" This is the '/snapshot/hello' endpoint."
|
||||
" Try visiting me in your browser!"
|
||||
),
|
||||
}))
|
||||
|
||||
|
||||
class CommitHandler(APIHandler):
|
||||
"""POST /snapshot/notebook-version/commit
|
||||
|
||||
Persist a new notebook snapshot. Body: ``{path, content, name, description}``.
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
def post(self):
|
||||
body = self.get_json_body() or {}
|
||||
path = body.get("path")
|
||||
content = body.get("content")
|
||||
name = body.get("name")
|
||||
description = body.get("description", "")
|
||||
if not path or content is None or not name:
|
||||
self.set_status(400)
|
||||
self.finish({"message": "path, content, and name are required"})
|
||||
return
|
||||
store = self.settings["snapshot_store"]
|
||||
try:
|
||||
entry = store.commit(path, content, name, description)
|
||||
except SnapshotUnchangedError as exc:
|
||||
# Not an error — same content as the most recent version.
|
||||
# The frontend surfaces this as a warning to the user.
|
||||
self.finish({"skipped": True, "reason": "unchanged", "message": str(exc)})
|
||||
return
|
||||
except ValueError as exc:
|
||||
self.set_status(400)
|
||||
self.finish({"message": str(exc)})
|
||||
return
|
||||
except KeyError as exc:
|
||||
self.set_status(404)
|
||||
self.finish({"message": f"not found: {exc}"})
|
||||
return
|
||||
self.finish({"entry": entry})
|
||||
|
||||
|
||||
class ListHandler(APIHandler):
|
||||
"""GET /snapshot/notebook-version/list?path=...
|
||||
|
||||
Return the manifest versions for ``path``, newest first.
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
def get(self):
|
||||
path = self.get_query_argument("path", default=None)
|
||||
if not path:
|
||||
self.set_status(400)
|
||||
self.finish({"message": "path is required"})
|
||||
return
|
||||
versions = self.settings["snapshot_store"].list_versions(path)
|
||||
self.finish({"versions": versions})
|
||||
|
||||
|
||||
class ContentHandler(APIHandler):
|
||||
"""GET /snapshot/notebook-version/content?path=...&id=...
|
||||
|
||||
Return the cleaned notebook body for a given version.
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
def get(self):
|
||||
path = self.get_query_argument("path", default=None)
|
||||
version_id = self.get_query_argument("id", default=None)
|
||||
if not path or not version_id:
|
||||
self.set_status(400)
|
||||
self.finish({"message": "path and id are required"})
|
||||
return
|
||||
try:
|
||||
notebook = self.settings["snapshot_store"].get_notebook(path, version_id)
|
||||
except KeyError:
|
||||
self.set_status(404)
|
||||
self.finish({"message": "version not found"})
|
||||
return
|
||||
except ValueError as exc:
|
||||
self.set_status(400)
|
||||
self.finish({"message": str(exc)})
|
||||
return
|
||||
self.set_header("Content-Type", "application/json")
|
||||
self.finish(json.dumps(notebook))
|
||||
|
||||
|
||||
def setup_route_handlers(web_app):
|
||||
host_pattern = ".*$"
|
||||
base_url = web_app.settings["base_url"]
|
||||
|
||||
hello_route_pattern = url_path_join(base_url, "snapshot", "hello")
|
||||
commit_route_pattern = url_path_join(
|
||||
base_url, "snapshot", "notebook-version", "commit"
|
||||
)
|
||||
list_route_pattern = url_path_join(
|
||||
base_url, "snapshot", "notebook-version", "list"
|
||||
)
|
||||
content_route_pattern = url_path_join(
|
||||
base_url, "snapshot", "notebook-version", "content"
|
||||
)
|
||||
handlers = [
|
||||
(hello_route_pattern, HelloRouteHandler),
|
||||
(commit_route_pattern, CommitHandler),
|
||||
(list_route_pattern, ListHandler),
|
||||
(content_route_pattern, ContentHandler),
|
||||
]
|
||||
|
||||
web_app.add_handlers(host_pattern, handlers)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Storage backends and factory for notebook snapshots."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .base import StorageAdapter, validate_key
|
||||
from .local import LocalFileStorage
|
||||
from .s3 import S3ConnectionError, S3Storage
|
||||
|
||||
|
||||
def create_storage(
|
||||
storage_type: str,
|
||||
root_dir: str | Path,
|
||||
**kwargs,
|
||||
) -> StorageAdapter:
|
||||
"""Create a storage adapter from a configuration string.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
storage_type: str
|
||||
One of ``"local"`` or ``"s3"`` (covers AWS S3, MinIO, and any
|
||||
S3-API-compatible service via the ``endpoint`` parameter).
|
||||
root_dir: str | Path
|
||||
Root directory used by local storage. For S3 this is ignored.
|
||||
**kwargs
|
||||
Extra arguments forwarded to the backend constructor. For S3,
|
||||
expected keys include ``endpoint``, ``access_key``, ``secret_key``,
|
||||
``bucket``, ``secure``, and ``prefix``. For local, ``local_root`` may
|
||||
override the default ``root_dir / ".notebook_versions"`` path.
|
||||
|
||||
Returns
|
||||
-------
|
||||
StorageAdapter
|
||||
Configured storage adapter instance.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If ``storage_type`` is unknown.
|
||||
S3ConnectionError
|
||||
If ``storage_type="s3"`` and the endpoint is unreachable or
|
||||
credentials are missing/invalid.
|
||||
"""
|
||||
storage_type = storage_type.lower()
|
||||
if storage_type == "local":
|
||||
local_root = kwargs.get("local_root")
|
||||
if local_root:
|
||||
return LocalFileStorage(local_root)
|
||||
return LocalFileStorage(Path(root_dir) / ".notebook_versions")
|
||||
if storage_type == "s3":
|
||||
return S3Storage(
|
||||
endpoint=kwargs["endpoint"],
|
||||
access_key=kwargs["access_key"],
|
||||
secret_key=kwargs["secret_key"],
|
||||
bucket=kwargs.get("bucket", "notebook-snapshot"),
|
||||
secure=kwargs.get("secure", False),
|
||||
prefix=kwargs.get("prefix", ""),
|
||||
)
|
||||
raise ValueError(f"Unknown storage type: {storage_type!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_storage",
|
||||
"LocalFileStorage",
|
||||
"S3ConnectionError",
|
||||
"S3Storage",
|
||||
"StorageAdapter",
|
||||
"validate_key",
|
||||
]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Abstract storage adapter for notebook snapshot backends.
|
||||
|
||||
This module defines the common interface for dumb bytes buckets. It has no
|
||||
knowledge of notebooks, versions, manifests, compression, or hashing.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
def validate_key(key: str) -> str:
|
||||
"""Validate and return a POSIX-style relative storage key.
|
||||
|
||||
Rejects empty keys, absolute paths, parent-directory segments, and
|
||||
backslashes. The returned value is the same string passed in.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key: str
|
||||
The storage key to validate.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The validated key.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the key is empty, absolute, contains ``..``, or contains
|
||||
backslashes.
|
||||
"""
|
||||
if not key:
|
||||
raise ValueError("Storage key must not be empty")
|
||||
if key.startswith("/"):
|
||||
raise ValueError(f"Storage key must be relative, got absolute path: {key!r}")
|
||||
if "\\" in key:
|
||||
raise ValueError(f"Storage key must use POSIX separators, got backslash: {key!r}")
|
||||
if ".." in key.split("/"):
|
||||
raise ValueError(f"Storage key must not contain parent segments: {key!r}")
|
||||
return key
|
||||
|
||||
|
||||
class StorageAdapter(ABC):
|
||||
"""Abstract base class for byte-oriented storage backends."""
|
||||
|
||||
@abstractmethod
|
||||
def put(self, key: str, data: bytes) -> None:
|
||||
"""Store ``data`` under ``key``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key: str
|
||||
The storage key.
|
||||
data: bytes
|
||||
The payload to store.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get(self, key: str) -> bytes:
|
||||
"""Return the bytes stored under ``key``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key: str
|
||||
The storage key.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The stored payload.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If the key does not exist.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def list_keys(self, prefix: str = "") -> list[str]:
|
||||
"""Return a sorted list of keys starting with ``prefix``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
prefix: str, optional
|
||||
Filter keys to those beginning with this value. The empty string
|
||||
matches all keys.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
Sorted matching keys.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, key: str) -> None:
|
||||
"""Delete the object at ``key``.
|
||||
|
||||
This method is idempotent: deleting a missing key does not raise.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key: str
|
||||
The storage key.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Return whether ``key`` exists in storage.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key: str
|
||||
The storage key.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` if the key exists, ``False`` otherwise.
|
||||
"""
|
||||
try:
|
||||
self.get(key)
|
||||
return True
|
||||
except KeyError:
|
||||
return False
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Local filesystem storage adapter."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .base import StorageAdapter, validate_key
|
||||
|
||||
|
||||
class LocalFileStorage(StorageAdapter):
|
||||
"""Store objects as files under a root directory on the local filesystem."""
|
||||
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
"""Initialize the adapter with a root directory.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
root: str | Path
|
||||
The root directory under which all objects are stored.
|
||||
"""
|
||||
self._root = Path(root).expanduser().resolve()
|
||||
self._root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _key_to_path(self, key: str) -> Path:
|
||||
"""Resolve a storage key to a path inside the root directory.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the resolved path would escape the root directory.
|
||||
"""
|
||||
path = (self._root / key).resolve()
|
||||
if self._root not in path.parents and path != self._root:
|
||||
raise ValueError(f"Resolved path {path} escapes root {self._root}")
|
||||
return path
|
||||
|
||||
def put(self, key: str, data: bytes) -> None:
|
||||
"""Store ``data`` under ``key`` atomically.
|
||||
|
||||
Writes to a temporary file in the target directory and renames it into
|
||||
place. Parent directories are created as needed.
|
||||
"""
|
||||
validate_key(key)
|
||||
path = self._key_to_path(key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.parent / f".{path.name}.tmp-{os.getpid()}"
|
||||
try:
|
||||
tmp_path.write_bytes(data)
|
||||
os.replace(str(tmp_path), str(path))
|
||||
finally:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
|
||||
def get(self, key: str) -> bytes:
|
||||
"""Return the bytes stored under ``key``.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If the file does not exist.
|
||||
"""
|
||||
validate_key(key)
|
||||
path = self._key_to_path(key)
|
||||
if not path.is_file():
|
||||
raise KeyError(key)
|
||||
return path.read_bytes()
|
||||
|
||||
def list_keys(self, prefix: str = "") -> list[str]:
|
||||
"""Return a sorted list of file keys starting with ``prefix``."""
|
||||
if prefix:
|
||||
validate_key(prefix)
|
||||
keys = []
|
||||
for item in self._root.rglob("*"):
|
||||
if item.is_file():
|
||||
rel_key = item.relative_to(self._root).as_posix()
|
||||
if rel_key.startswith(prefix):
|
||||
keys.append(rel_key)
|
||||
return sorted(keys)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Delete the file at ``key``.
|
||||
|
||||
Idempotent: no error if the file is already missing.
|
||||
"""
|
||||
validate_key(key)
|
||||
path = self._key_to_path(key)
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Return whether ``key`` exists as a regular file."""
|
||||
validate_key(key)
|
||||
path = self._key_to_path(key)
|
||||
return path.is_file()
|
||||
@@ -0,0 +1,228 @@
|
||||
"""S3-compatible storage adapter backed by boto3.
|
||||
|
||||
Works with AWS S3, MinIO, and any S3-API-compatible service.
|
||||
"""
|
||||
|
||||
from .base import StorageAdapter, validate_key
|
||||
|
||||
|
||||
class S3ConnectionError(Exception):
|
||||
"""Raised when the S3 connectivity / credentials check fails at startup.
|
||||
|
||||
Wraps the underlying boto3/botocore exception with a friendly, actionable
|
||||
message naming the endpoint, bucket, and the most likely fix.
|
||||
"""
|
||||
|
||||
|
||||
class S3Storage(StorageAdapter):
|
||||
"""Store objects in an S3-compatible bucket via boto3.
|
||||
|
||||
Works with AWS S3 and MinIO (just point ``endpoint`` at the MinIO host).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
bucket: str,
|
||||
secure: bool = False,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
"""Initialize the adapter and verify connectivity.
|
||||
|
||||
boto3 is imported lazily inside this constructor so that importing
|
||||
``snapshot.storage.s3`` does not require boto3 to be installed.
|
||||
|
||||
On construction, the adapter performs a HEAD on the configured bucket
|
||||
to validate network reachability and credentials. If the bucket does
|
||||
not exist, it is created automatically. Any connectivity, credential,
|
||||
or permission problem is surfaced as :class:`S3ConnectionError` with
|
||||
a message that names the endpoint, bucket, and likely fix.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
endpoint: str
|
||||
S3 endpoint host (without scheme), e.g. ``localhost:9000`` for
|
||||
MinIO or ``s3.us-east-1.amazonaws.com`` for AWS S3.
|
||||
access_key: str
|
||||
S3 access key ID.
|
||||
secret_key: str
|
||||
S3 secret access key.
|
||||
bucket: str
|
||||
Target bucket name. Created if it does not exist.
|
||||
secure: bool, optional
|
||||
Use HTTPS instead of HTTP. Defaults to ``False``.
|
||||
prefix: str, optional
|
||||
Prefix prepended to every stored key. Defaults to ``""``.
|
||||
|
||||
Raises
|
||||
------
|
||||
S3ConnectionError
|
||||
If the endpoint is unreachable, credentials are missing/invalid,
|
||||
or the bucket cannot be created due to permissions.
|
||||
"""
|
||||
try:
|
||||
import boto3
|
||||
from botocore.client import Config as BotoConfig
|
||||
from botocore.exceptions import ClientError
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"boto3 is required for S3 storage. "
|
||||
"Install it with: pip install snapshot[s3]"
|
||||
) from exc
|
||||
|
||||
self._bucket = bucket
|
||||
self._prefix = prefix
|
||||
self._endpoint = endpoint
|
||||
scheme = "https" if secure else "http"
|
||||
self._client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{scheme}://{endpoint}",
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name="us-east-1",
|
||||
config=BotoConfig(signature_version="s3v4"),
|
||||
)
|
||||
self._ClientError = ClientError
|
||||
self._connect_and_ensure_bucket()
|
||||
|
||||
def _connect_and_ensure_bucket(self) -> None:
|
||||
"""Verify connectivity/credentials, then ensure the bucket exists.
|
||||
|
||||
Raises :class:`S3ConnectionError` with a friendly message on failure.
|
||||
"""
|
||||
try:
|
||||
self._client.head_bucket(Bucket=self._bucket)
|
||||
return # bucket exists, connectivity and credentials confirmed
|
||||
except self._ClientError as exc:
|
||||
code = exc.response.get("Error", {}).get("Code", "")
|
||||
if code in ("404", "NoSuchBucket"):
|
||||
# Bucket missing — try to create it. Permission failure here
|
||||
# surfaces as S3ConnectionError rather than the raw boto3 error.
|
||||
try:
|
||||
self._client.create_bucket(Bucket=self._bucket)
|
||||
except self._ClientError as create_exc:
|
||||
ccode = create_exc.response.get("Error", {}).get("Code", "")
|
||||
raise S3ConnectionError(
|
||||
f"S3 bucket '{self._bucket}' does not exist and could "
|
||||
f"not be created (code={ccode}). Check that the "
|
||||
f"credentials have s3:CreateBucket permission on "
|
||||
f"endpoint {self._endpoint}."
|
||||
) from create_exc
|
||||
return
|
||||
# Other ClientError: permission denied, account disabled, etc.
|
||||
raise S3ConnectionError(
|
||||
f"S3 access denied (endpoint={self._endpoint}, "
|
||||
f"bucket={self._bucket}, code={code}). Check that the "
|
||||
f"credentials have s3:ListBucket and s3:GetBucketLocation "
|
||||
f"permissions."
|
||||
) from exc
|
||||
except Exception as exc: # noqa: BLE001 - catch any boto3/botocore error
|
||||
# EndpointConnectionError, NoCredentialsError, InvalidAccessKeyId,
|
||||
# SSLError, ConnectTimeoutError, etc. all land here.
|
||||
cls = type(exc).__name__
|
||||
hint = ""
|
||||
if "Credentials" in cls or "AccessKey" in cls or "Auth" in cls:
|
||||
hint = (
|
||||
" Check that S3_ACCESS_KEY / S3_SECRET_KEY env vars (or "
|
||||
"c.SnapshotConfig.s3_access_key / s3_secret_key) are set "
|
||||
"and correct."
|
||||
)
|
||||
elif "Endpoint" in cls or "Connection" in cls or "Connect" in cls or "SSLError" in cls or "timeout" in cls.lower():
|
||||
hint = (
|
||||
f" Check that the endpoint '{self._endpoint}' is reachable, "
|
||||
f"the URL is correct, and s3_secure matches the protocol."
|
||||
)
|
||||
raise S3ConnectionError(
|
||||
f"Cannot connect to S3 endpoint {self._endpoint} "
|
||||
f"(bucket={self._bucket}): {cls}: {exc}.{hint}"
|
||||
) from exc
|
||||
|
||||
def _object_key(self, key: str) -> str:
|
||||
"""Return the full S3 object key, including the configured prefix."""
|
||||
return f"{self._prefix}{key}"
|
||||
|
||||
def put(self, key: str, data: bytes) -> None:
|
||||
"""Store ``data`` under ``key`` in the configured bucket."""
|
||||
validate_key(key)
|
||||
self._client.put_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._object_key(key),
|
||||
Body=data,
|
||||
)
|
||||
|
||||
def get(self, key: str) -> bytes:
|
||||
"""Return the bytes stored under ``key``.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If the object does not exist.
|
||||
"""
|
||||
validate_key(key)
|
||||
try:
|
||||
response = self._client.get_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._object_key(key),
|
||||
)
|
||||
except self._ClientError as exc:
|
||||
error_code = exc.response.get("Error", {}).get("Code", "")
|
||||
if error_code in ("NoSuchKey", "404"):
|
||||
raise KeyError(key) from exc
|
||||
raise
|
||||
return response["Body"].read()
|
||||
|
||||
def list_keys(self, prefix: str = "") -> list[str]:
|
||||
"""Return a sorted list of keys starting with ``prefix``.
|
||||
|
||||
The configured bucket prefix is stripped from returned keys.
|
||||
"""
|
||||
if prefix:
|
||||
validate_key(prefix)
|
||||
full_prefix = self._object_key(prefix)
|
||||
keys = []
|
||||
continuation_token = None
|
||||
while True:
|
||||
kwargs: dict = {
|
||||
"Bucket": self._bucket,
|
||||
"Prefix": full_prefix,
|
||||
}
|
||||
if continuation_token:
|
||||
kwargs["ContinuationToken"] = continuation_token
|
||||
response = self._client.list_objects_v2(**kwargs)
|
||||
for obj in response.get("Contents", []):
|
||||
full_key = obj["Key"]
|
||||
if full_key.startswith(self._prefix):
|
||||
stripped = full_key[len(self._prefix):]
|
||||
keys.append(stripped)
|
||||
if not response.get("IsTruncated"):
|
||||
break
|
||||
continuation_token = response.get("NextContinuationToken")
|
||||
return sorted(keys)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Delete the object at ``key``.
|
||||
|
||||
Idempotent: S3 ``delete_object`` succeeds even when the key is absent.
|
||||
"""
|
||||
validate_key(key)
|
||||
self._client.delete_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._object_key(key),
|
||||
)
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Return whether ``key`` exists in the bucket."""
|
||||
validate_key(key)
|
||||
try:
|
||||
self._client.head_object(
|
||||
Bucket=self._bucket,
|
||||
Key=self._object_key(key),
|
||||
)
|
||||
return True
|
||||
except self._ClientError as exc:
|
||||
error_code = exc.response.get("Error", {}).get("Code", "")
|
||||
if error_code in ("404", "NoSuchKey"):
|
||||
return False
|
||||
raise
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Snapshot store: sanitize, hash, persist, and index notebook versions.
|
||||
|
||||
Wraps a StorageAdapter and provides:
|
||||
- Manual commits with name + description
|
||||
- Gzipped JSON envelopes that embed the metadata, so the manifest is
|
||||
fully rebuildable from the version files alone.
|
||||
- Atomic manifest writes (delegated to the StorageAdapter).
|
||||
- Skip-on-duplicate: if the new content hash matches the most recent
|
||||
version, ``commit`` raises ``SnapshotUnchangedError`` instead of saving.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from .storage import StorageAdapter
|
||||
|
||||
|
||||
_VERSION_FILE_RE = re.compile(r"^v_.*\.ipynb\.gz$")
|
||||
|
||||
|
||||
class SnapshotUnchangedError(Exception):
|
||||
"""Raised when the new content hash matches the most recent version."""
|
||||
|
||||
|
||||
class SnapshotStore:
|
||||
"""High-level notebook version operations on a StorageAdapter."""
|
||||
|
||||
def __init__(self, storage: StorageAdapter) -> None:
|
||||
self._storage = storage
|
||||
|
||||
# ---- public API --------------------------------------------------------
|
||||
|
||||
def commit(
|
||||
self,
|
||||
path: str,
|
||||
content: dict,
|
||||
name: str,
|
||||
description: str = "",
|
||||
) -> dict:
|
||||
"""Persist a new snapshot and append it to the manifest.
|
||||
|
||||
If the new content hash matches the most recent version's hash,
|
||||
raises :class:`SnapshotUnchangedError` without writing anything.
|
||||
This avoids accidental duplicate snapshots when the user re-fires
|
||||
the commit command without making changes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path: str
|
||||
The notebook's relative path (acts as a key prefix).
|
||||
content: dict
|
||||
The notebook JSON as sent by the frontend.
|
||||
name: str
|
||||
User-provided snapshot name.
|
||||
description: str, optional
|
||||
User-provided snapshot description.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The manifest entry (metadata only — no notebook body).
|
||||
|
||||
Raises
|
||||
------
|
||||
SnapshotUnchangedError
|
||||
If the cleaned content hash equals the most recent version's
|
||||
hash. The route layer translates this into a 200 response
|
||||
with ``{"skipped": True, "reason": "unchanged"}``.
|
||||
"""
|
||||
cleaned = self._sanitize(content)
|
||||
hash_ = self._hash(cleaned)
|
||||
|
||||
# Skip-on-duplicate check (compare to the most recent version only).
|
||||
manifest = self._read_manifest(path) or {"versions": []}
|
||||
existing = manifest.get("versions", [])
|
||||
if existing and existing[-1].get("hash") == hash_:
|
||||
raise SnapshotUnchangedError(
|
||||
f"内容未变更(与最近版本 {existing[-1]['id']} hash 相同),已跳过保存"
|
||||
)
|
||||
|
||||
timestamp = int(time.time() * 1_000_000)
|
||||
version_id = f"v_{timestamp}_{hash_[:8]}"
|
||||
envelope = {
|
||||
"id": version_id,
|
||||
"timestamp": timestamp,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"hash": hash_,
|
||||
"size": 0, # filled in after first encoding pass
|
||||
"notebook": cleaned,
|
||||
}
|
||||
file_bytes = self._encode_envelope(envelope)
|
||||
envelope["size"] = len(file_bytes)
|
||||
file_bytes = self._encode_envelope(envelope)
|
||||
self._storage.put(self._version_key(path, version_id), file_bytes)
|
||||
entry = {
|
||||
k: envelope[k]
|
||||
for k in ("id", "timestamp", "name", "description", "hash", "size")
|
||||
}
|
||||
self._append_manifest(path, entry)
|
||||
return entry
|
||||
|
||||
def list_versions(self, path: str) -> list[dict]:
|
||||
"""Return the versions for ``path``, newest first.
|
||||
|
||||
Rebuilds the manifest from the version files if the manifest is
|
||||
missing or unparseable.
|
||||
"""
|
||||
manifest = self._read_manifest(path)
|
||||
if manifest is None:
|
||||
manifest = self._rebuild_manifest(path)
|
||||
return sorted(
|
||||
manifest.get("versions", []),
|
||||
key=lambda e: e.get("timestamp", 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def get_notebook(self, path: str, version_id: str) -> dict:
|
||||
"""Return the cleaned notebook body for ``version_id``.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If no such version exists.
|
||||
"""
|
||||
key = self._version_key(path, version_id)
|
||||
try:
|
||||
raw = self._storage.get(key)
|
||||
except KeyError:
|
||||
raise KeyError(version_id) from None
|
||||
envelope = self._decode_envelope(raw)
|
||||
return envelope["notebook"]
|
||||
|
||||
# ---- internals ---------------------------------------------------------
|
||||
|
||||
def _prefix(self, path: str) -> str:
|
||||
return f"{path}/"
|
||||
|
||||
def _manifest_key(self, path: str) -> str:
|
||||
return f"{self._prefix(path)}manifest.json"
|
||||
|
||||
def _version_key(self, path: str, version_id: str) -> str:
|
||||
return f"{self._prefix(path)}{version_id}.ipynb.gz"
|
||||
|
||||
def _sanitize(self, content: dict) -> dict:
|
||||
"""Strip all code-cell outputs and execution counts."""
|
||||
cleaned = copy.deepcopy(content)
|
||||
for cell in cleaned.get("cells", []):
|
||||
if cell.get("cell_type") == "code":
|
||||
cell["outputs"] = []
|
||||
cell["execution_count"] = None
|
||||
return cleaned
|
||||
|
||||
def _hash(self, cleaned: dict) -> str:
|
||||
encoded = json.dumps(
|
||||
cleaned, sort_keys=True, separators=(",", ":")
|
||||
).encode()
|
||||
return hashlib.md5(encoded).hexdigest()
|
||||
|
||||
def _encode_envelope(self, envelope: dict) -> bytes:
|
||||
return gzip.compress(json.dumps(envelope, ensure_ascii=False).encode())
|
||||
|
||||
def _decode_envelope(self, raw: bytes) -> dict:
|
||||
return json.loads(gzip.decompress(raw).decode())
|
||||
|
||||
def _read_manifest(self, path: str):
|
||||
"""Return the parsed manifest, ``None`` if missing or corrupt.
|
||||
|
||||
Returning ``None`` (instead of an empty stub) signals the caller to
|
||||
rebuild from version files, which also writes a fresh manifest.
|
||||
"""
|
||||
try:
|
||||
raw = self._storage.get(self._manifest_key(path))
|
||||
except KeyError:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw.decode())
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
def _write_manifest(self, path: str, manifest: dict) -> None:
|
||||
payload = json.dumps(manifest, ensure_ascii=False).encode()
|
||||
self._storage.put(self._manifest_key(path), payload)
|
||||
|
||||
def _append_manifest(self, path: str, entry: dict) -> None:
|
||||
manifest = self._read_manifest(path) or {"versions": []}
|
||||
manifest.setdefault("versions", []).append(entry)
|
||||
self._write_manifest(path, manifest)
|
||||
|
||||
def _rebuild_manifest(self, path: str) -> dict:
|
||||
prefix = self._prefix(path)
|
||||
versions: list[dict] = []
|
||||
for key in self._storage.list_keys(prefix=prefix):
|
||||
tail = key[len(prefix):]
|
||||
if not _VERSION_FILE_RE.match(tail):
|
||||
continue
|
||||
try:
|
||||
envelope = self._decode_envelope(self._storage.get(key))
|
||||
except (KeyError, ValueError, OSError):
|
||||
continue
|
||||
versions.append(
|
||||
{
|
||||
"id": envelope.get("id"),
|
||||
"timestamp": envelope.get("timestamp", 0),
|
||||
"name": envelope.get("name", ""),
|
||||
"description": envelope.get("description", ""),
|
||||
"hash": envelope.get("hash", ""),
|
||||
"size": envelope.get("size", 0),
|
||||
}
|
||||
)
|
||||
versions.sort(key=lambda e: e.get("timestamp", 0), reverse=True)
|
||||
manifest = {"versions": versions}
|
||||
self._write_manifest(path, manifest)
|
||||
return manifest
|
||||
@@ -0,0 +1 @@
|
||||
"""Python unit tests for snapshot."""
|
||||
@@ -0,0 +1,17 @@
|
||||
import json
|
||||
|
||||
|
||||
async def test_hello(jp_fetch):
|
||||
# When
|
||||
response = await jp_fetch("snapshot", "hello")
|
||||
|
||||
# Then
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {
|
||||
"data": (
|
||||
"Hello, world!"
|
||||
" This is the '/snapshot/hello' endpoint."
|
||||
" Try visiting me in your browser!"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Integration tests for the snapshot REST API (commit / list / content)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
SAMPLE_NB = {
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "c1",
|
||||
"source": "print('hi')",
|
||||
"outputs": [{"output_type": "stream", "text": "hi"}],
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c2",
|
||||
"source": "# title",
|
||||
"metadata": {},
|
||||
},
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
}
|
||||
|
||||
|
||||
def _post_commit(jp_fetch, path: str, name: str, description: str = ""):
|
||||
body = json.dumps(
|
||||
{
|
||||
"path": path,
|
||||
"content": SAMPLE_NB,
|
||||
"name": name,
|
||||
"description": description,
|
||||
}
|
||||
).encode()
|
||||
return jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"commit",
|
||||
method="POST",
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
async def test_commit_creates_entry(jp_fetch):
|
||||
response = await _post_commit(jp_fetch, "api_a.ipynb", "v1", "first")
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload["entry"]["name"] == "v1"
|
||||
assert payload["entry"]["description"] == "first"
|
||||
assert payload["entry"]["id"].startswith("v_")
|
||||
assert payload["entry"]["size"] > 0
|
||||
assert payload["entry"]["hash"]
|
||||
|
||||
|
||||
async def test_list_contains_committed_entry(jp_fetch):
|
||||
await _post_commit(jp_fetch, "api_b.ipynb", "v1", "desc")
|
||||
response = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"list",
|
||||
params={"path": "api_b.ipynb"},
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert len(payload["versions"]) == 1
|
||||
assert payload["versions"][0]["name"] == "v1"
|
||||
assert payload["versions"][0]["description"] == "desc"
|
||||
|
||||
|
||||
async def test_content_returns_stripped_notebook(jp_fetch):
|
||||
commit_resp = await _post_commit(jp_fetch, "api_c.ipynb", "v1")
|
||||
entry = json.loads(commit_resp.body)["entry"]
|
||||
response = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"content",
|
||||
params={"path": "api_c.ipynb", "id": entry["id"]},
|
||||
)
|
||||
assert response.code == 200
|
||||
notebook = json.loads(response.body)
|
||||
code = next(c for c in notebook["cells"] if c["cell_type"] == "code")
|
||||
assert code["outputs"] == []
|
||||
assert code["execution_count"] is None
|
||||
assert notebook["nbformat"] == 4
|
||||
|
||||
|
||||
async def test_commit_missing_name_returns_400(jp_fetch):
|
||||
body = json.dumps(
|
||||
{"path": "api_d.ipynb", "content": SAMPLE_NB}
|
||||
).encode()
|
||||
response = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"commit",
|
||||
method="POST",
|
||||
body=body,
|
||||
raise_error=False,
|
||||
)
|
||||
assert response.code == 400
|
||||
|
||||
|
||||
async def test_list_unknown_path_returns_empty(jp_fetch):
|
||||
response = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"list",
|
||||
params={"path": "never_committed_unique.ipynb"},
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload["versions"] == []
|
||||
|
||||
|
||||
async def test_commit_duplicate_returns_skipped(jp_fetch):
|
||||
"""Second commit with identical content must NOT save; response is {skipped: True}."""
|
||||
r1 = await _post_commit(jp_fetch, "api_dup.ipynb", "v1")
|
||||
assert r1.code == 200
|
||||
first = json.loads(r1.body)
|
||||
assert first.get("entry") is not None
|
||||
assert first.get("skipped") in (None, False)
|
||||
|
||||
r2 = await _post_commit(jp_fetch, "api_dup.ipynb", "v2")
|
||||
assert r2.code == 200 # not an error — just a warning
|
||||
second = json.loads(r2.body)
|
||||
assert second.get("skipped") is True
|
||||
assert second.get("reason") == "unchanged"
|
||||
assert "未变更" in second.get("message", "") or "unchanged" in second.get("message", "").lower()
|
||||
assert second.get("entry") is None
|
||||
|
||||
# And the manifest still has exactly one entry.
|
||||
list_resp = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"list",
|
||||
params={"path": "api_dup.ipynb"},
|
||||
)
|
||||
assert json.loads(list_resp.body)["versions"].__len__() == 1
|
||||
|
||||
|
||||
async def test_content_unknown_id_returns_404(jp_fetch):
|
||||
response = await jp_fetch(
|
||||
"snapshot",
|
||||
"notebook-version",
|
||||
"content",
|
||||
params={"path": "api_e.ipynb", "id": "v_0_doesnotexist"},
|
||||
raise_error=False,
|
||||
)
|
||||
assert response.code == 404
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for the local filesystem storage adapter."""
|
||||
|
||||
import pytest
|
||||
|
||||
from snapshot.storage import LocalFileStorage, create_storage, validate_key
|
||||
from snapshot.storage.base import StorageAdapter
|
||||
|
||||
|
||||
def _contract(adapter: StorageAdapter) -> None:
|
||||
"""Run the shared storage contract against an adapter instance."""
|
||||
adapter.put("a/b/file1.txt", b"hello")
|
||||
adapter.put("a/b/file2.txt", b"world")
|
||||
adapter.put("c/file3.txt", b"!")
|
||||
|
||||
assert adapter.get("a/b/file1.txt") == b"hello"
|
||||
assert adapter.exists("a/b/file1.txt")
|
||||
assert not adapter.exists("a/b/missing.txt")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
adapter.get("a/b/missing.txt")
|
||||
|
||||
assert adapter.list_keys() == [
|
||||
"a/b/file1.txt",
|
||||
"a/b/file2.txt",
|
||||
"c/file3.txt",
|
||||
]
|
||||
assert adapter.list_keys("a/b") == [
|
||||
"a/b/file1.txt",
|
||||
"a/b/file2.txt",
|
||||
]
|
||||
|
||||
adapter.delete("a/b/file1.txt")
|
||||
adapter.delete("a/b/file1.txt") # idempotent
|
||||
assert not adapter.exists("a/b/file1.txt")
|
||||
assert adapter.list_keys() == [
|
||||
"a/b/file2.txt",
|
||||
"c/file3.txt",
|
||||
]
|
||||
|
||||
|
||||
def test_contract(tmp_path):
|
||||
"""Validate the adapter contract on a temporary directory."""
|
||||
adapter = LocalFileStorage(tmp_path)
|
||||
_contract(adapter)
|
||||
|
||||
|
||||
def test_put_creates_parent_directories(tmp_path):
|
||||
"""put() must create intermediate directories."""
|
||||
adapter = LocalFileStorage(tmp_path)
|
||||
adapter.put("deep/nested/key.bin", b"data")
|
||||
assert (tmp_path / "deep" / "nested" / "key.bin").read_bytes() == b"data"
|
||||
|
||||
|
||||
def test_atomic_put_leaves_no_tmp_files(tmp_path):
|
||||
"""Atomic writes must not leak temporary files."""
|
||||
adapter = LocalFileStorage(tmp_path)
|
||||
adapter.put("x/y/z.txt", b"payload")
|
||||
tmp_files = list(tmp_path.rglob("*.tmp-*"))
|
||||
assert tmp_files == []
|
||||
|
||||
|
||||
def test_validate_key_rejects_bad_keys():
|
||||
"""validate_key must reject unsafe key strings."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_key("")
|
||||
with pytest.raises(ValueError):
|
||||
validate_key("/absolute/path")
|
||||
with pytest.raises(ValueError):
|
||||
validate_key("../escape")
|
||||
with pytest.raises(ValueError):
|
||||
validate_key("a\\b")
|
||||
|
||||
|
||||
def test_get_missing_raises_key_error(tmp_path):
|
||||
"""Reading a missing key must raise KeyError."""
|
||||
adapter = LocalFileStorage(tmp_path)
|
||||
with pytest.raises(KeyError):
|
||||
adapter.get("missing.bin")
|
||||
|
||||
|
||||
def test_create_storage_custom_local_root(tmp_path):
|
||||
"""create_storage must honor an explicit local_root directory."""
|
||||
custom_dir = tmp_path / "custom_versions"
|
||||
adapter = create_storage(
|
||||
"local",
|
||||
root_dir=tmp_path,
|
||||
local_root=str(custom_dir),
|
||||
)
|
||||
adapter.put("notebook.ipynb", b"data")
|
||||
assert (custom_dir / "notebook.ipynb").read_bytes() == b"data"
|
||||
|
||||
|
||||
def test_create_storage_default_local_root(tmp_path):
|
||||
"""create_storage must fall back to root_dir/.notebook_versions."""
|
||||
adapter = create_storage("local", root_dir=tmp_path)
|
||||
adapter.put("notebook.ipynb", b"data")
|
||||
expected = tmp_path / ".notebook_versions" / "notebook.ipynb"
|
||||
assert expected.read_bytes() == b"data"
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Tests for the S3 storage adapter using a monkey-patched boto3."""
|
||||
|
||||
import pytest
|
||||
|
||||
boto3 = pytest.importorskip("boto3")
|
||||
from botocore.exceptions import ClientError # noqa: E402
|
||||
|
||||
from snapshot.storage import S3ConnectionError, S3Storage
|
||||
from snapshot.storage.base import StorageAdapter
|
||||
|
||||
|
||||
class _FakeS3Client:
|
||||
"""In-memory S3 client sufficient for exercising S3Storage."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._objects = {}
|
||||
self._buckets = set()
|
||||
# Failure injection: if set, these are raised instead of the normal
|
||||
# behavior. Used to exercise the S3ConnectionError classification.
|
||||
self.head_bucket_error: Exception | None = None
|
||||
self.create_bucket_error: Exception | None = None
|
||||
|
||||
def head_bucket(self, Bucket):
|
||||
if self.head_bucket_error is not None:
|
||||
raise self.head_bucket_error
|
||||
if Bucket not in self._buckets:
|
||||
raise ClientError({"Error": {"Code": "404"}}, "HeadBucket")
|
||||
|
||||
def create_bucket(self, Bucket):
|
||||
if self.create_bucket_error is not None:
|
||||
raise self.create_bucket_error
|
||||
self._buckets.add(Bucket)
|
||||
|
||||
def put_object(self, Bucket, Key, Body):
|
||||
self._buckets.add(Bucket)
|
||||
if isinstance(Body, bytes):
|
||||
data = Body
|
||||
else:
|
||||
data = Body.read()
|
||||
self._objects[(Bucket, Key)] = data
|
||||
|
||||
def get_object(self, Bucket, Key):
|
||||
self.head_bucket(Bucket)
|
||||
if (Bucket, Key) not in self._objects:
|
||||
raise ClientError({"Error": {"Code": "NoSuchKey"}}, "GetObject")
|
||||
return {"Body": _Body(self._objects[(Bucket, Key)])}
|
||||
|
||||
def delete_object(self, Bucket, Key):
|
||||
self._objects.pop((Bucket, Key), None)
|
||||
|
||||
def head_object(self, Bucket, Key):
|
||||
self.head_bucket(Bucket)
|
||||
if (Bucket, Key) not in self._objects:
|
||||
raise ClientError({"Error": {"Code": "404"}}, "HeadObject")
|
||||
|
||||
def list_objects_v2(self, Bucket, Prefix="", ContinuationToken=None):
|
||||
self.head_bucket(Bucket)
|
||||
keys = sorted(
|
||||
key for (b, key) in self._objects if b == Bucket and key.startswith(Prefix)
|
||||
)
|
||||
return {"Contents": [{"Key": key} for key in keys], "IsTruncated": False}
|
||||
|
||||
|
||||
class _Body:
|
||||
def __init__(self, data: bytes):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch, prefix=""):
|
||||
monkeypatch.setattr(boto3, "client", lambda *args, **kwargs: _FakeS3Client(**kwargs))
|
||||
return S3Storage(
|
||||
endpoint="localhost:9000",
|
||||
access_key="access",
|
||||
secret_key="secret",
|
||||
bucket="test-bucket",
|
||||
prefix=prefix,
|
||||
)
|
||||
|
||||
|
||||
def _contract(adapter: StorageAdapter) -> None:
|
||||
adapter.put("a/b/file1.txt", b"hello")
|
||||
adapter.put("a/b/file2.txt", b"world")
|
||||
adapter.put("c/file3.txt", b"!")
|
||||
|
||||
assert adapter.get("a/b/file1.txt") == b"hello"
|
||||
assert adapter.exists("a/b/file1.txt")
|
||||
assert not adapter.exists("a/b/missing.txt")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
adapter.get("a/b/missing.txt")
|
||||
|
||||
assert adapter.list_keys() == [
|
||||
"a/b/file1.txt",
|
||||
"a/b/file2.txt",
|
||||
"c/file3.txt",
|
||||
]
|
||||
assert adapter.list_keys("a/b") == [
|
||||
"a/b/file1.txt",
|
||||
"a/b/file2.txt",
|
||||
]
|
||||
|
||||
adapter.delete("a/b/file1.txt")
|
||||
adapter.delete("a/b/file1.txt") # idempotent
|
||||
assert not adapter.exists("a/b/file1.txt")
|
||||
assert adapter.list_keys() == [
|
||||
"a/b/file2.txt",
|
||||
"c/file3.txt",
|
||||
]
|
||||
|
||||
|
||||
def test_contract(monkeypatch):
|
||||
"""Validate the adapter contract against the fake S3 client."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
_contract(adapter)
|
||||
|
||||
|
||||
def test_prefix_stripping(monkeypatch):
|
||||
"""list_keys must strip the configured prefix from returned keys."""
|
||||
adapter = _make_adapter(monkeypatch, prefix="notebooks/")
|
||||
adapter.put("version1.ipynb", b"v1")
|
||||
adapter.put("version2.ipynb", b"v2")
|
||||
assert adapter.list_keys() == ["version1.ipynb", "version2.ipynb"]
|
||||
assert adapter.get("version1.ipynb") == b"v1"
|
||||
|
||||
|
||||
def test_bucket_auto_creation(monkeypatch):
|
||||
"""The bucket must be created automatically if it does not exist."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.put("first.bin", b"x")
|
||||
assert adapter.exists("first.bin")
|
||||
|
||||
|
||||
def test_imports_work_without_boto3():
|
||||
"""Module-level imports must succeed even when boto3 is unavailable."""
|
||||
import snapshot
|
||||
import snapshot.storage
|
||||
import snapshot.storage.s3
|
||||
|
||||
assert snapshot.storage.s3.S3Storage is not None
|
||||
assert snapshot.storage.StorageAdapter is not None
|
||||
|
||||
|
||||
def test_lazy_import_error(monkeypatch):
|
||||
"""Instantiation must raise ImportError when boto3 is absent."""
|
||||
real_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __builtins__["__import__"]
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "boto3" or name.startswith("boto3."):
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.__import__", fake_import)
|
||||
with pytest.raises(ImportError):
|
||||
S3Storage(
|
||||
endpoint="localhost:9000",
|
||||
access_key="a",
|
||||
secret_key="s",
|
||||
bucket="b",
|
||||
)
|
||||
|
||||
|
||||
# ---- connectivity / credentials checks at startup ------------------------
|
||||
|
||||
|
||||
def _make_with_fake(monkeypatch, fake: _FakeS3Client) -> S3Storage:
|
||||
monkeypatch.setattr(boto3, "client", lambda *a, **kw: fake)
|
||||
return S3Storage(
|
||||
endpoint="localhost:9000",
|
||||
access_key="access",
|
||||
secret_key="secret",
|
||||
bucket="test-bucket",
|
||||
)
|
||||
|
||||
|
||||
def test_permission_denied_raises_s3_connection_error(monkeypatch):
|
||||
"""head_bucket returning 403/AccessDenied must surface as S3ConnectionError."""
|
||||
fake = _FakeS3Client()
|
||||
fake.head_bucket_error = ClientError(
|
||||
{"Error": {"Code": "AccessDenied"}}, "HeadBucket"
|
||||
)
|
||||
with pytest.raises(S3ConnectionError) as excinfo:
|
||||
_make_with_fake(monkeypatch, fake)
|
||||
msg = str(excinfo.value)
|
||||
assert "AccessDenied" in msg
|
||||
assert "localhost:9000" in msg
|
||||
assert "test-bucket" in msg
|
||||
assert "s3:ListBucket" in msg or "permission" in msg.lower()
|
||||
|
||||
|
||||
def test_create_bucket_failure_raises_s3_connection_error(monkeypatch):
|
||||
"""Bucket missing AND create_bucket failing must surface as S3ConnectionError."""
|
||||
fake = _FakeS3Client()
|
||||
fake.create_bucket_error = ClientError(
|
||||
{"Error": {"Code": "AccessDenied"}}, "CreateBucket"
|
||||
)
|
||||
with pytest.raises(S3ConnectionError) as excinfo:
|
||||
_make_with_fake(monkeypatch, fake)
|
||||
msg = str(excinfo.value)
|
||||
assert "does not exist" in msg
|
||||
assert "could not be created" in msg
|
||||
assert "CreateBucket" in msg
|
||||
|
||||
|
||||
def test_endpoint_unreachable_raises_s3_connection_error(monkeypatch):
|
||||
"""Network failures (e.g. EndpointConnectionError) surface as S3ConnectionError."""
|
||||
from botocore.exceptions import EndpointConnectionError
|
||||
|
||||
fake = _FakeS3Client()
|
||||
fake.head_bucket_error = EndpointConnectionError(
|
||||
endpoint_url="http://localhost:9000"
|
||||
)
|
||||
with pytest.raises(S3ConnectionError) as excinfo:
|
||||
_make_with_fake(monkeypatch, fake)
|
||||
msg = str(excinfo.value)
|
||||
assert "Cannot connect" in msg
|
||||
assert "localhost:9000" in msg
|
||||
assert "EndpointConnectionError" in msg
|
||||
|
||||
|
||||
def test_credentials_error_surfaces_credentials_hint(monkeypatch):
|
||||
"""Credential-related boto3 errors mention the S3_* env vars in the hint."""
|
||||
from botocore.exceptions import NoCredentialsError
|
||||
|
||||
fake = _FakeS3Client()
|
||||
fake.head_bucket_error = NoCredentialsError()
|
||||
with pytest.raises(S3ConnectionError) as excinfo:
|
||||
_make_with_fake(monkeypatch, fake)
|
||||
msg = str(excinfo.value)
|
||||
assert "S3_ACCESS_KEY" in msg
|
||||
assert "S3_SECRET_KEY" in msg
|
||||
|
||||
|
||||
def test_connectivity_succeeds_silently_when_bucket_exists(monkeypatch):
|
||||
"""If head_bucket succeeds (bucket exists), no create is called."""
|
||||
fake = _FakeS3Client()
|
||||
fake._buckets.add("test-bucket") # simulate pre-existing bucket
|
||||
create_called = {"flag": False}
|
||||
original_create = fake.create_bucket
|
||||
|
||||
def tracking_create(Bucket):
|
||||
create_called["flag"] = True
|
||||
return original_create(Bucket)
|
||||
|
||||
fake.create_bucket = tracking_create
|
||||
_make_with_fake(monkeypatch, fake)
|
||||
assert create_called["flag"] is False, "create_bucket must not be called when bucket already exists"
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Unit tests for the SnapshotStore (manual-commit model)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from snapshot.storage import create_storage
|
||||
from snapshot.store import SnapshotStore
|
||||
|
||||
|
||||
def make_store(tmp_path):
|
||||
"""Create a SnapshotStore backed by a fresh local adapter under tmp_path."""
|
||||
storage = create_storage(
|
||||
"local",
|
||||
root_dir=tmp_path,
|
||||
local_root=str(tmp_path / "snaps"),
|
||||
)
|
||||
return SnapshotStore(storage)
|
||||
|
||||
|
||||
SAMPLE_NB = {
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "c1",
|
||||
"source": "print('hi')",
|
||||
"outputs": [{"output_type": "stream", "text": "hi"}],
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c2",
|
||||
"source": "# title",
|
||||
"metadata": {},
|
||||
},
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
}
|
||||
|
||||
|
||||
def test_sanitize_strips_code_outputs_and_exec(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
entry = store.commit("nb.ipynb", SAMPLE_NB, name="v1")
|
||||
nb = store.get_notebook("nb.ipynb", entry["id"])
|
||||
code = next(c for c in nb["cells"] if c["cell_type"] == "code")
|
||||
assert code["outputs"] == []
|
||||
assert code["execution_count"] is None
|
||||
md = next(c for c in nb["cells"] if c["cell_type"] == "markdown")
|
||||
assert md["source"] == "# title"
|
||||
assert md["id"] == "c2"
|
||||
|
||||
|
||||
def test_hash_stable_regardless_of_outputs(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
nb_a = {
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "c1",
|
||||
"source": "x = 1",
|
||||
"outputs": [{"output_type": "stream", "text": "1"}],
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
}
|
||||
nb_b = {
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "c1",
|
||||
"source": "x = 1",
|
||||
"outputs": [
|
||||
{"output_type": "display_data", "data": {"text/plain": "9999"}}
|
||||
],
|
||||
"execution_count": 42,
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
}
|
||||
e1 = store.commit("hash_a.ipynb", nb_a, name="a")
|
||||
e2 = store.commit("hash_b.ipynb", nb_b, name="b")
|
||||
assert e1["hash"] == e2["hash"]
|
||||
|
||||
|
||||
def test_commit_list_content_roundtrip(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
entry = store.commit("rt.ipynb", SAMPLE_NB, name="first", description="hello")
|
||||
assert entry["name"] == "first"
|
||||
assert entry["description"] == "hello"
|
||||
assert entry["id"].startswith("v_")
|
||||
|
||||
versions = store.list_versions("rt.ipynb")
|
||||
assert len(versions) == 1
|
||||
assert versions[0]["id"] == entry["id"]
|
||||
assert versions[0]["name"] == "first"
|
||||
assert versions[0]["description"] == "hello"
|
||||
|
||||
nb = store.get_notebook("rt.ipynb", entry["id"])
|
||||
assert nb["nbformat"] == 4
|
||||
code = next(c for c in nb["cells"] if c["cell_type"] == "code")
|
||||
assert code["outputs"] == []
|
||||
|
||||
|
||||
def test_duplicate_content_skipped(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
e1 = store.commit("dup.ipynb", SAMPLE_NB, name="first")
|
||||
# Second commit with the same content should be skipped, not saved.
|
||||
import pytest as _pytest
|
||||
from snapshot.store import SnapshotUnchangedError
|
||||
with _pytest.raises(SnapshotUnchangedError):
|
||||
store.commit("dup.ipynb", SAMPLE_NB, name="second")
|
||||
versions = store.list_versions("dup.ipynb")
|
||||
assert len(versions) == 1
|
||||
assert versions[0]["id"] == e1["id"]
|
||||
assert versions[0]["name"] == "first"
|
||||
|
||||
|
||||
def test_edited_content_still_saves(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
e1 = store.commit("edit.ipynb", SAMPLE_NB, name="v1")
|
||||
edited = {
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"id": "c1",
|
||||
"source": "x = 2", # different source → different hash
|
||||
"outputs": [],
|
||||
"execution_count": None,
|
||||
"metadata": {},
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c2",
|
||||
"source": "# title",
|
||||
"metadata": {},
|
||||
},
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
}
|
||||
e2 = store.commit("edit.ipynb", edited, name="v2")
|
||||
assert e1["id"] != e2["id"]
|
||||
assert e1["hash"] != e2["hash"]
|
||||
assert len(store.list_versions("edit.ipynb")) == 2
|
||||
|
||||
|
||||
def test_first_commit_never_skipped(tmp_path):
|
||||
"""No prior version exists — must always save regardless of content."""
|
||||
store = make_store(tmp_path)
|
||||
entry = store.commit("first.ipynb", SAMPLE_NB, name="only")
|
||||
assert entry["id"].startswith("v_")
|
||||
assert len(store.list_versions("first.ipynb")) == 1
|
||||
|
||||
|
||||
def test_manifest_rebuild_from_files(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
entry = store.commit("rebuild.ipynb", SAMPLE_NB, name="only")
|
||||
# Tamper: delete manifest directly via the storage adapter
|
||||
from snapshot.storage import validate_key
|
||||
|
||||
manifest_key = "rebuild.ipynb/manifest.json"
|
||||
validate_key(manifest_key)
|
||||
store._storage.delete(manifest_key)
|
||||
|
||||
versions = store.list_versions("rebuild.ipynb")
|
||||
assert len(versions) == 1
|
||||
assert versions[0]["id"] == entry["id"]
|
||||
assert versions[0]["name"] == "only"
|
||||
|
||||
# Manifest should have been rewritten by the rebuild
|
||||
rebuilt = store.list_versions("rebuild.ipynb")
|
||||
assert len(rebuilt) == 1
|
||||
|
||||
|
||||
def test_get_notebook_missing_raises_key_error(tmp_path):
|
||||
store = make_store(tmp_path)
|
||||
with pytest.raises(KeyError):
|
||||
store.get_notebook("missing.ipynb", "v_0_doesnotexist")
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import url('base.css');
|
||||
@@ -0,0 +1 @@
|
||||
import './base.css';
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./tsconfig"
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user