├── .yarnrc.yml ├── style ├── index.js ├── index.css ├── icons │ └── md │ │ ├── gallery.svg │ │ ├── repository.svg │ │ └── LICENSE └── base.css ├── setup.py ├── babel.config.js ├── jupyterlab_gallery ├── tests │ ├── __init__.py │ └── test_handlers.py ├── git_askpass.py ├── __init__.py ├── hub.py ├── app.py ├── git_utils.py ├── handlers.py ├── manager.py └── gitpuller.py ├── src ├── svg.d.ts ├── __tests__ │ └── jupyterlab_gallery.spec.ts ├── icons.ts ├── types │ └── index.ts ├── handler.ts ├── index.ts └── gallery.tsx ├── CHANGELOG.md ├── tsconfig.test.json ├── .prettierignore ├── binder ├── start ├── environment.yml ├── postBuild └── jupyter_gallery_config.json ├── jupyter-config └── server-config │ └── jupyterlab_gallery.json ├── ui-tests ├── tests │ ├── jupyterlab_gallery.spec.ts-snapshots │ │ ├── odd-cases-linux.png │ │ ├── in-launchpad-linux.png │ │ ├── on-hover-fresh-linux.png │ │ ├── on-hover-cloned-linux.png │ │ └── on-hover-updates-pending-linux.png │ └── jupyterlab_gallery.spec.ts ├── playwright.config.js ├── jupyter_server_test_config.py ├── package.json └── README.md ├── install.json ├── conftest.py ├── .github └── workflows │ ├── binder-on-pr.yml │ ├── enforce-label.yml │ ├── check-release.yml │ ├── prep-release.yml │ ├── publish-release.yml │ ├── update-integration-tests.yml │ └── build.yml ├── schema └── plugin.json ├── .copier-answers.yml ├── tsconfig.json ├── jest.config.js ├── LICENSE ├── .gitignore ├── RELEASE.md ├── pyproject.toml ├── package.json └── README.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | __import__("setuptools").setup() 2 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import url('base.css'); 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@jupyterlab/testutils/lib/babel.config'); 2 | -------------------------------------------------------------------------------- /jupyterlab_gallery/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Python unit tests for jupyterlab_gallery.""" 2 | -------------------------------------------------------------------------------- /src/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const script: string; 3 | export default script; 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "types": ["jest"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | !/package.json 6 | jupyterlab_gallery 7 | -------------------------------------------------------------------------------- /binder/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo $@ 6 | 7 | exec jupyter-lab "${@:4}" --config binder/jupyter_gallery_config.json 8 | -------------------------------------------------------------------------------- /jupyter-config/server-config/jupyterlab_gallery.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyterlab_gallery": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/odd-cases-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jupyterlab-gallery/HEAD/ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/odd-cases-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/in-launchpad-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jupyterlab-gallery/HEAD/ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/in-launchpad-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/on-hover-fresh-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jupyterlab-gallery/HEAD/ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/on-hover-fresh-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/on-hover-cloned-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jupyterlab-gallery/HEAD/ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/on-hover-cloned-linux.png -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupyterlab_gallery", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyterlab_gallery" 5 | } 6 | -------------------------------------------------------------------------------- /ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/on-hover-updates-pending-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nebari-dev/jupyterlab-gallery/HEAD/ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/on-hover-updates-pending-linux.png -------------------------------------------------------------------------------- /style/icons/md/gallery.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest_plugins = ("pytest_jupyter.jupyter_server",) 4 | 5 | 6 | @pytest.fixture 7 | def jp_server_config(jp_server_config): 8 | return {"ServerApp": {"jpserver_extensions": {"jupyterlab_gallery": True}}} 9 | -------------------------------------------------------------------------------- /src/__tests__/jupyterlab_gallery.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example of [Jest](https://jestjs.io/docs/getting-started) unit tests 3 | */ 4 | 5 | describe('jupyterlab-gallery', () => { 6 | it('should be tested', () => { 7 | expect(1 + 1).toEqual(2); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /jupyterlab_gallery/git_askpass.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | what = sys.argv[1].lower() 7 | 8 | if "username" in what: 9 | print(os.environ["GIT_PULLER_ACCOUNT"]) 10 | if "password" in what: 11 | print(os.environ["GIT_PULLER_TOKEN"]) 12 | -------------------------------------------------------------------------------- /.github/workflows/binder-on-pr.yml: -------------------------------------------------------------------------------- 1 | name: Binder Badge 2 | on: 3 | pull_request_target: 4 | types: [opened] 5 | 6 | jobs: 7 | binder: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - uses: jupyterlab/maintainer-tools/.github/actions/binder-link@v1 13 | with: 14 | github_token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/enforce-label.yml: -------------------------------------------------------------------------------- 1 | name: Enforce PR label 2 | 3 | on: 4 | pull_request: 5 | types: [labeled, unlabeled, opened, edited, synchronize] 6 | jobs: 7 | enforce-label: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - name: enforce-triage-label 13 | uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 14 | -------------------------------------------------------------------------------- /ui-tests/playwright.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for Playwright using default from @jupyterlab/galata 3 | */ 4 | const baseConfig = require('@jupyterlab/galata/lib/playwright-config'); 5 | 6 | module.exports = { 7 | ...baseConfig, 8 | webServer: { 9 | command: 'jlpm start', 10 | url: 'http://localhost:8888/lab', 11 | timeout: 120 * 1000, 12 | reuseExistingServer: !process.env.CI 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/icons.ts: -------------------------------------------------------------------------------- 1 | import { LabIcon } from '@jupyterlab/ui-components'; 2 | import gallerySvgstr from '../style/icons/md/gallery.svg'; 3 | import repositorySvgstr from '../style/icons/md/repository.svg'; 4 | 5 | export const galleryIcon = new LabIcon({ 6 | name: 'jupyterlab-gallery:gallery', 7 | svgstr: gallerySvgstr 8 | }); 9 | 10 | export const repositoryIcon = new LabIcon({ 11 | name: 'jupyterlab-gallery:repository', 12 | svgstr: repositorySvgstr 13 | }); 14 | -------------------------------------------------------------------------------- /ui-tests/jupyter_server_test_config.py: -------------------------------------------------------------------------------- 1 | """Server configuration for integration tests. 2 | 3 | !! Never use this configuration in production because it 4 | opens the server to the world and provide access to JupyterLab 5 | JavaScript objects through the global window variable. 6 | """ 7 | 8 | from jupyterlab.galata import configure_jupyter_server 9 | 10 | configure_jupyter_server(c) 11 | 12 | # Uncomment to set server log level to debug level 13 | # c.ServerApp.log_level = "DEBUG" 14 | -------------------------------------------------------------------------------- /schema/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter.lab.shortcuts": [], 3 | "jupyter.lab.setting-icon": "jupyterlab-gallery:gallery", 4 | "jupyter.lab.setting-icon-label": "Gallery", 5 | "title": "Gallery", 6 | "description": "jupyterlab-gallery settings.", 7 | "type": "object", 8 | "properties": { 9 | "endpoint": { 10 | "title": "Server endpoint", 11 | "type": "string", 12 | "default": "jupyterlab-gallery" 13 | } 14 | }, 15 | "additionalProperties": false 16 | } 17 | -------------------------------------------------------------------------------- /ui-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterlab-gallery-ui-tests", 3 | "version": "1.0.0", 4 | "description": "JupyterLab jupyterlab-gallery Integration Tests", 5 | "private": true, 6 | "scripts": { 7 | "start": "jupyter lab --config jupyter_server_test_config.py", 8 | "test": "jlpm playwright test", 9 | "test:update": "jlpm playwright test --update-snapshots" 10 | }, 11 | "devDependencies": { 12 | "@jupyterlab/galata": "^5.0.5", 13 | "@playwright/test": "^1.37.0", 14 | "jupyterlab-gallery": "link:../src/types" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | # a mybinder.org-ready environment for demoing jupyterlab_gallery 2 | # this environment may also be used locally on Linux/MacOS/Windows, e.g. 3 | # 4 | # conda env update --file binder/environment.yml 5 | # conda activate jupyterlab-gallery-demo 6 | # 7 | name: jupyterlab-gallery-demo 8 | 9 | channels: 10 | - conda-forge 11 | 12 | dependencies: 13 | # runtime dependencies 14 | - python >=3.10,<3.11.0a0 15 | - jupyterlab >=4.0.0,<5 16 | # labextension build dependencies 17 | - nodejs >=18,<19 18 | - pip 19 | - wheel 20 | - pip: 21 | - jupyterlab-launchpad 22 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v4.3.0 3 | _src_path: https://github.com/jupyterlab/extension-template 4 | author_email: internal-it@quansight.com 5 | author_name: Nebari development team 6 | has_binder: true 7 | has_settings: true 8 | kind: server 9 | labextension_name: jupyterlab-gallery 10 | project_short_description: A JupyterLab gallery extension for presenting and downloading 11 | examples from remote repositories 12 | python_name: jupyterlab_gallery 13 | repository: https://github.com/nebari-dev/jupyterlab-gallery 14 | test: true 15 | 16 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface IGalleryReply { 2 | title: string; 3 | apiVersion: string; 4 | exhibitsConfigured: boolean; 5 | hideGalleryWithoutExhibits: boolean; 6 | } 7 | 8 | export interface IExhibitReply { 9 | exhibits: IExhibit[]; 10 | } 11 | 12 | export interface IExhibit { 13 | // from configuration file 14 | homepage?: string; 15 | title: string; 16 | description?: string; 17 | icon?: string; 18 | // state from server 19 | id: number; 20 | isCloned: boolean; 21 | localPath: string; 22 | revision?: string; 23 | lastUpdated?: string; 24 | updatesAvailable?: boolean; 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "rootDir": "src", 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "target": "ES2018" 21 | }, 22 | "include": ["src/*", "src/*/*"] 23 | } 24 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const jestJupyterLab = require('@jupyterlab/testutils/lib/jest-config'); 2 | 3 | const esModules = [ 4 | '@codemirror', 5 | '@jupyter/ydoc', 6 | '@jupyterlab/', 7 | 'lib0', 8 | 'nanoid', 9 | 'vscode-ws-jsonrpc', 10 | 'y-protocols', 11 | 'y-websocket', 12 | 'yjs' 13 | ].join('|'); 14 | 15 | const baseConfig = jestJupyterLab(__dirname); 16 | 17 | module.exports = { 18 | ...baseConfig, 19 | automock: false, 20 | collectCoverageFrom: [ 21 | 'src/**/*.{ts,tsx}', 22 | '!src/**/*.d.ts', 23 | '!src/**/.ipynb_checkpoints/*' 24 | ], 25 | coverageReporters: ['lcov', 'text'], 26 | testRegex: 'src/.*/.*.spec.ts[x]?$', 27 | transformIgnorePatterns: [`/node_modules/(?!${esModules}).+`] 28 | }; 29 | -------------------------------------------------------------------------------- /jupyterlab_gallery/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._version import __version__ 3 | except ImportError: 4 | # Fallback when using the package in dev mode without installing 5 | # in editable mode with pip. It is highly recommended to install 6 | # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs 7 | import warnings 8 | 9 | warnings.warn("Importing 'jupyterlab_gallery' outside a proper installation.") 10 | __version__ = "dev" 11 | from .app import GalleryApp 12 | 13 | 14 | def _jupyter_labextension_paths(): 15 | return [{"src": "labextension", "dest": "jupyterlab-gallery"}] 16 | 17 | 18 | def _jupyter_server_extension_points(): 19 | return [{"module": "jupyterlab_gallery", "app": GalleryApp}] 20 | -------------------------------------------------------------------------------- /style/icons/md/repository.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/check-release.yml: -------------------------------------------------------------------------------- 1 | name: Check Release 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | branches: ["*"] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | check_release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Base Setup 19 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 20 | - name: Check Release 21 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 22 | with: 23 | 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: Upload Distributions 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: jupyterlab_gallery-releaser-dist-${{ github.run_number }} 30 | path: .jupyter_releaser_checkout/dist 31 | -------------------------------------------------------------------------------- /jupyterlab_gallery/hub.py: -------------------------------------------------------------------------------- 1 | from jupyterhub.singleuser.mixins import make_singleuser_app 2 | from jupyter_server.serverapp import ServerApp 3 | from .app import GalleryApp 4 | 5 | 6 | class classproperty(property): 7 | def __get__(self, owner_self, owner_cls): 8 | return self.fget(owner_cls) 9 | 10 | 11 | class HubGalleryApp(GalleryApp): 12 | # This is in a separate file because traitlets metaclass 13 | # will read from each property on start, including serverapp_class, 14 | # which will have side effects for the server if run outside of juypterhub 15 | 16 | @classproperty 17 | def serverapp_class(cls): 18 | """If jupyterhub is installed, apply the jupyterhub patches, 19 | 20 | but only do this when this property is accessed, which is when 21 | the gallery is used as a standalone app. 22 | """ 23 | 24 | if cls._server_cls is None: 25 | cls._server_cls = make_singleuser_app(ServerApp) 26 | return cls._server_cls 27 | 28 | _server_cls = None 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Nebari development team 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ perform a development install of jupyterlab_gallery 3 | 4 | On Binder, this will run _after_ the environment has been fully created from 5 | the environment.yml in this directory. 6 | 7 | This script should also run locally on Linux/MacOS/Windows: 8 | 9 | python3 binder/postBuild 10 | """ 11 | import subprocess 12 | import sys 13 | from pathlib import Path 14 | 15 | 16 | ROOT = Path.cwd() 17 | 18 | def _(*args, **kwargs): 19 | """ Run a command, echoing the args 20 | 21 | fails hard if something goes wrong 22 | """ 23 | print("\n\t", " ".join(args), "\n") 24 | return_code = subprocess.call(args, **kwargs) 25 | if return_code != 0: 26 | print("\nERROR", return_code, " ".join(args)) 27 | sys.exit(return_code) 28 | 29 | # verify the environment is self-consistent before even starting 30 | _(sys.executable, "-m", "pip", "check") 31 | 32 | # install the labextension 33 | _(sys.executable, "-m", "pip", "install", "-e", ".") 34 | _(sys.executable, "-m", "jupyter", "labextension", "develop", "--overwrite", ".") 35 | _( 36 | sys.executable, 37 | "-m", 38 | "jupyter", 39 | "server", 40 | "extension", 41 | "enable", 42 | "jupyterlab_gallery", 43 | ) 44 | 45 | # verify the environment the extension didn't break anything 46 | _(sys.executable, "-m", "pip", "check") 47 | 48 | # list the extensions 49 | _("jupyter", "server", "extension", "list") 50 | 51 | # initially list installed extensions to determine if there are any surprises 52 | _("jupyter", "labextension", "list") 53 | 54 | 55 | print("JupyterLab with jupyterlab_gallery is ready to run with:\n") 56 | print("\tjupyter lab\n") 57 | -------------------------------------------------------------------------------- /jupyterlab_gallery/app.py: -------------------------------------------------------------------------------- 1 | from jupyter_server.extension.application import ExtensionApp 2 | from jupyter_server.serverapp import ServerApp 3 | from .handlers import ExhibitsHandler, GalleryHandler, PullHandler 4 | from .manager import GalleryManager 5 | 6 | 7 | class GalleryApp(ExtensionApp): 8 | name = "gallery" 9 | 10 | handlers = [ 11 | ("jupyterlab-gallery/gallery", GalleryHandler), 12 | ("jupyterlab-gallery/exhibits", ExhibitsHandler), 13 | ("jupyterlab-gallery/pull", PullHandler), 14 | ] 15 | 16 | default_url = "/jupyterlab-gallery/gallery" 17 | load_other_extensions = False 18 | 19 | def initialize_settings(self): 20 | self.log.info("Configured gallery manager") 21 | gallery_manager = GalleryManager( 22 | log=self.log, root_dir=self.serverapp.root_dir, config=self.config 23 | ) 24 | self.settings.update({"gallery_manager": gallery_manager}) 25 | 26 | def initialize_handlers(self): 27 | # setting nbapp is needed for nbgitpuller 28 | self.serverapp.web_app.settings["nbapp"] = self.serverapp 29 | self.log.info(f"Registered {self.name} server extension") 30 | 31 | @classmethod 32 | def make_serverapp(cls, **kwargs) -> ServerApp: 33 | """Instantiate the ServerApp 34 | 35 | Override to disable default_services which would give access 36 | to files on the disk (`contents` service) or allow execution 37 | code (`kernels` service). 38 | """ 39 | server_app = super().make_serverapp(**kwargs) 40 | assert isinstance(server_app, cls.serverapp_class) 41 | assert len(server_app.default_services) > 1 42 | server_app.default_services = ("auth", "security") 43 | return server_app 44 | -------------------------------------------------------------------------------- /.github/workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | # silent: 16 | # description: "Set a placeholder in the changelog and don't publish the release." 17 | # required: false 18 | # type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | jobs: 27 | prep_release: 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: write 31 | steps: 32 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 33 | 34 | - name: Prep Release 35 | id: prep-release 36 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | version_spec: ${{ github.event.inputs.version_spec }} 40 | # silent: ${{ github.event.inputs.silent }} 41 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 42 | branch: ${{ github.event.inputs.branch }} 43 | since: ${{ github.event.inputs.since }} 44 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 45 | 46 | - name: "** Next Step **" 47 | run: | 48 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 49 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | environment: release 19 | permissions: 20 | id-token: write 21 | steps: 22 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - uses: actions/create-github-app-token@v1 25 | id: app-token 26 | with: 27 | app-id: ${{ vars.APP_ID }} 28 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 29 | 30 | - name: Populate Release 31 | id: populate-release 32 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 33 | with: 34 | token: ${{ steps.app-token.outputs.token }} 35 | branch: ${{ github.event.inputs.branch }} 36 | release_url: ${{ github.event.inputs.release_url }} 37 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 38 | 39 | - name: Finalize Release 40 | id: finalize-release 41 | env: 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 44 | with: 45 | token: ${{ steps.app-token.outputs.token }} 46 | release_url: ${{ steps.populate-release.outputs.release_url }} 47 | 48 | - name: "** Next Step **" 49 | if: ${{ success() }} 50 | run: | 51 | echo "Verify the final release" 52 | echo ${{ steps.finalize-release.outputs.release_url }} 53 | 54 | - name: "** Failure Message **" 55 | if: ${{ failure() }} 56 | run: | 57 | echo "Failed to Publish the Draft Release Url:" 58 | echo ${{ steps.populate-release.outputs.release_url }} 59 | -------------------------------------------------------------------------------- /jupyterlab_gallery/tests/test_handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import mock 3 | import pytest 4 | 5 | from jupyter_server.utils import url_path_join 6 | 7 | from jupyterlab_gallery.manager import GalleryManager 8 | 9 | 10 | async def test_exhibits(jp_fetch): 11 | response = await jp_fetch("jupyterlab-gallery", "exhibits") 12 | assert response.code == 200 13 | payload = json.loads(response.body) 14 | assert isinstance(payload["exhibits"], list) 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "exhibit", 19 | [ 20 | { 21 | "git": "https://github.com/nebari-dev/nebari.git", 22 | "homepage": "https://github.com/nebari-dev/nebari", 23 | }, 24 | { 25 | "git": "https://github.com/nebari-dev/nebari.git", 26 | "homepage": "https://github.com/nebari-dev/nebari", 27 | "icon": None, 28 | }, 29 | ], 30 | ) 31 | async def test_exhibit_generate_github_icon(jp_serverapp, jp_fetch, exhibit): 32 | with mock.patch.object(GalleryManager, "exhibits", [exhibit]): 33 | response = await jp_fetch("jupyterlab-gallery", "exhibits") 34 | assert response.code == 200 35 | payload = json.loads(response.body) 36 | assert len(payload["exhibits"]) == 1 37 | assert ( 38 | payload["exhibits"][0]["icon"] 39 | == "https://opengraph.githubassets.com/1/nebari-dev/nebari" 40 | ) 41 | 42 | 43 | async def test_gallery(jp_fetch): 44 | response = await jp_fetch("jupyterlab-gallery", "gallery") 45 | assert response.code == 200 46 | payload = json.loads(response.body) 47 | assert payload["apiVersion"] == "1.0" 48 | 49 | 50 | async def test_pull_token_can_be_used_instead_of_xsrf( 51 | jp_serverapp, jp_base_url, http_server_client 52 | ): 53 | token = jp_serverapp.identity_provider.token 54 | response = await http_server_client.fetch( 55 | url_path_join(jp_base_url, "jupyterlab-gallery", "pull"), 56 | body=b'{"exhibit_id": 100}', 57 | method="POST", 58 | headers={"Authorization": f"token {token}", "Cookie": ""}, 59 | raise_error=False, 60 | ) 61 | assert response.code == 406 62 | payload = json.loads(response.body) 63 | assert payload["message"] == "exhibit_id 100 not found" 64 | -------------------------------------------------------------------------------- /jupyterlab_gallery/git_utils.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from pathlib import Path 3 | from subprocess import run 4 | from threading import Lock 5 | from typing import Optional 6 | import re 7 | import os 8 | 9 | 10 | def extract_repository_owner(git_url: str) -> str: 11 | fragments = git_url.strip("/").split("/") 12 | return fragments[-2] if len(fragments) >= 2 else "" 13 | 14 | 15 | def extract_repository_name(git_url: str) -> str: 16 | fragment = git_url.split("/")[-1] 17 | if fragment.endswith(".git"): 18 | return fragment[:-4] 19 | return fragment 20 | 21 | 22 | def has_updates(repo_path: Path) -> bool: 23 | try: 24 | run( 25 | "git fetch origin $(git branch --show-current) --quiet", 26 | cwd=repo_path, 27 | shell=True, 28 | ) 29 | result = run( 30 | "git status -b --porcelain -u n --ignored n", 31 | cwd=repo_path, 32 | capture_output=True, 33 | shell=True, 34 | ) 35 | except FileNotFoundError: 36 | return False 37 | data = re.match( 38 | r"^## (.*?)( \[(ahead (?P\d+))?(, )?(behind (?P\d+))?\])?$", 39 | result.stdout.decode("utf-8"), 40 | ) 41 | if not data: 42 | return False 43 | return data["behind"] is not None 44 | 45 | 46 | _git_credential_lock = Lock() 47 | 48 | 49 | @contextmanager 50 | def git_credentials(token: Optional[str], account: Optional[str]): 51 | if token and account: 52 | _git_credential_lock.acquire() 53 | try: 54 | path = Path(__file__).parent 55 | os.environ["GIT_ASKPASS"] = str(path / "git_askpass.py") 56 | os.environ["GIT_PULLER_ACCOUNT"] = account 57 | os.environ["GIT_PULLER_TOKEN"] = token 58 | # do not prompt user if askpass fails as this would 59 | # dead lock execution! 60 | os.environ["GIT_TERMINAL_PROMPT"] = "0" 61 | yield 62 | finally: 63 | del os.environ["GIT_PULLER_ACCOUNT"] 64 | del os.environ["GIT_PULLER_TOKEN"] 65 | del os.environ["GIT_TERMINAL_PROMPT"] 66 | del os.environ["GIT_ASKPASS"] 67 | _git_credential_lock.release() 68 | else: 69 | yield 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.log 5 | .eslintcache 6 | .stylelintcache 7 | *.egg-info/ 8 | .ipynb_checkpoints 9 | *.tsbuildinfo 10 | jupyterlab_gallery/labextension 11 | # Version file is handled by hatchling 12 | jupyterlab_gallery/_version.py 13 | 14 | # Integration tests 15 | ui-tests/test-results/ 16 | ui-tests/playwright-report/ 17 | 18 | # Created by https://www.gitignore.io/api/python 19 | # Edit at https://www.gitignore.io/?templates=python 20 | 21 | ### Python ### 22 | # Byte-compiled / optimized / DLL files 23 | __pycache__/ 24 | *.py[cod] 25 | *$py.class 26 | 27 | # C extensions 28 | *.so 29 | 30 | # Distribution / packaging 31 | .Python 32 | build/ 33 | develop-eggs/ 34 | dist/ 35 | downloads/ 36 | eggs/ 37 | .eggs/ 38 | lib/ 39 | lib64/ 40 | parts/ 41 | sdist/ 42 | var/ 43 | wheels/ 44 | pip-wheel-metadata/ 45 | share/python-wheels/ 46 | .installed.cfg 47 | *.egg 48 | MANIFEST 49 | 50 | # PyInstaller 51 | # Usually these files are written by a python script from a template 52 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 53 | *.manifest 54 | *.spec 55 | 56 | # Installer logs 57 | pip-log.txt 58 | pip-delete-this-directory.txt 59 | 60 | # Unit test / coverage reports 61 | htmlcov/ 62 | .tox/ 63 | .nox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage/ 69 | coverage.xml 70 | *.cover 71 | .hypothesis/ 72 | .pytest_cache/ 73 | 74 | # Translations 75 | *.mo 76 | *.pot 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # celery beat schedule file 91 | celerybeat-schedule 92 | 93 | # SageMath parsed files 94 | *.sage.py 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # Mr Developer 104 | .mr.developer.cfg 105 | .project 106 | .pydevproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | .dmypy.json 114 | dmypy.json 115 | 116 | # Pyre type checker 117 | .pyre/ 118 | 119 | # End of https://www.gitignore.io/api/python 120 | 121 | # OSX files 122 | .DS_Store 123 | 124 | # Yarn cache 125 | .yarn/ 126 | -------------------------------------------------------------------------------- /binder/jupyter_gallery_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "GalleryManager": { 3 | "title": "Tutorials", 4 | "destination": "tutorials", 5 | "exhibits": [ 6 | { 7 | "git": "https://github.com/numba/nvidia-cuda-tutorial.git", 8 | "homepage": "https://github.com/numba/nvidia-cuda-tutorial", 9 | "title": "Numba for CUDA Programmers", 10 | "description": "Nvidia contributed CUDA tutorial for Numba" 11 | }, 12 | { 13 | "git": "https://github.com/yunjey/pytorch-tutorial.git", 14 | "homepage": "https://github.com/yunjey/pytorch-tutorial", 15 | "title": "PyTorch Tutorial", 16 | "description": "PyTorch Tutorial for Deep Learning Researchers", 17 | "icon": "https://github.com/yunjey/pytorch-tutorial/raw/master/logo/pytorch_logo_2018.svg" 18 | }, 19 | { 20 | "git": "https://github.com/jupyter-widgets/tutorial.git", 21 | "homepage": "https://github.com/jupyter-widgets/tutorial", 22 | "title": "Jupyter Widgets Tutorial", 23 | "description": "The Jupyter Widget Ecosystem" 24 | }, 25 | { 26 | "git": "https://github.com/amueller/scipy-2018-sklearn.git", 27 | "homepage": "https://github.com/amueller/scipy-2018-sklearn", 28 | "title": "Scikit-learn Tutorial 2018", 29 | "description": "SciPy 2018 Scikit-learn Tutorial" 30 | }, 31 | { 32 | "git": "https://github.com/jupyter-widgets/tutorial.git", 33 | "title": "Example without description" 34 | }, 35 | { 36 | "git": "https://gitlab.gnome.org/GNOME/atomix.git", 37 | "title": "GNOME atomix", 38 | "description": "Example without icon" 39 | }, 40 | { 41 | "git": "https://github.com/nebari-dev/nebari.git", 42 | "homepage": "https://github.com/nebari-dev/nebari", 43 | "title": "Empty icon", 44 | "description": "Empty icon should show social card for GitHub repos", 45 | "icon": "" 46 | }, 47 | { 48 | "git": "https://github.com/jupyterlab/jupyterlab.git", 49 | "homepage": "https://github.com/jupyterlab/jupyterlab", 50 | "title": "JupyterLab branch 3.6.x", 51 | "branch": "3.6.x", 52 | "description": "Cloning a specific branch with 'branch' argument" 53 | }, 54 | { 55 | "git": "https://github.com/nebari-dev/nebari.git", 56 | "homepage": "https://github.com/nebari-dev/nebari", 57 | "title": "Nebari with shallow clone", 58 | "depth": 1, 59 | "description": "Cloning with 'depth = 1' for faster checkout" 60 | } 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release of jupyterlab_gallery 2 | 3 | The extension can be published to `PyPI` and `npm` manually or using the [Jupyter Releaser](https://github.com/jupyter-server/jupyter_releaser). 4 | 5 | ## Manual release 6 | 7 | ### Python package 8 | 9 | This extension can be distributed as Python packages. All of the Python 10 | packaging instructions are in the `pyproject.toml` file to wrap your extension in a 11 | Python package. Before generating a package, you first need to install some tools: 12 | 13 | ```bash 14 | pip install build twine hatch 15 | ``` 16 | 17 | Bump the version using `hatch`. By default this will create a tag. 18 | See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details. 19 | 20 | ```bash 21 | hatch version 22 | ``` 23 | 24 | Make sure to clean up all the development files before building the package: 25 | 26 | ```bash 27 | jlpm clean:all 28 | ``` 29 | 30 | You could also clean up the local git repository: 31 | 32 | ```bash 33 | git clean -dfX 34 | ``` 35 | 36 | To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do: 37 | 38 | ```bash 39 | python -m build 40 | ``` 41 | 42 | > `python setup.py sdist bdist_wheel` is deprecated and will not work for this package. 43 | 44 | Then to upload the package to PyPI, do: 45 | 46 | ```bash 47 | twine upload dist/* 48 | ``` 49 | 50 | ### NPM package 51 | 52 | To publish the frontend part of the extension as a NPM package, do: 53 | 54 | ```bash 55 | npm login 56 | npm publish --access public 57 | ``` 58 | 59 | ## Automated releases with the Jupyter Releaser 60 | 61 | The extension repository should already be compatible with the Jupyter Releaser. But 62 | the GitHub repository and the package managers need to be properly set up. Please 63 | follow the instructions of the Jupyter Releaser [checklist](https://jupyter-releaser.readthedocs.io/en/latest/how_to_guides/convert_repo_from_repo.html). 64 | 65 | Here is a summary of the steps to cut a new release: 66 | 67 | - Go to the Actions panel 68 | - Run the "Step 1: Prep Release" workflow 69 | - Check the draft changelog 70 | - Run the "Step 2: Publish Release" workflow 71 | 72 | > [!NOTE] 73 | > Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html) 74 | > for more information. 75 | 76 | ## Publishing to `conda-forge` 77 | 78 | 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 79 | 80 | Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically. 81 | -------------------------------------------------------------------------------- /jupyterlab_gallery/handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import cast 3 | 4 | from jupyter_server.base.handlers import APIHandler 5 | from .gitpuller import SyncHandlerBase 6 | from .manager import GalleryManager 7 | import tornado 8 | 9 | 10 | # We do not want to expose `git_url` as it may contain PAT; 11 | # we want an allow-list over block-list to avoid exposing PAT in case 12 | # if the author of the config makes a typo like `giit` instead of `git`. 13 | EXPOSED_EXHIBIT_KEYS = ["homepage", "title", "description", "icon"] 14 | 15 | 16 | class BaseHandler(APIHandler): 17 | @property 18 | def gallery_manager(self) -> GalleryManager: 19 | return cast(GalleryManager, self.settings["gallery_manager"]) 20 | 21 | 22 | class GalleryHandler(BaseHandler): 23 | @tornado.web.authenticated 24 | def get(self): 25 | self.finish( 26 | json.dumps( 27 | { 28 | "title": self.gallery_manager.title, 29 | "exhibitsConfigured": len(self.gallery_manager.exhibits) != 0, 30 | "hideGalleryWithoutExhibits": self.gallery_manager.hide_gallery_without_exhibits, 31 | "apiVersion": "1.0", 32 | } 33 | ) 34 | ) 35 | 36 | 37 | class ExhibitsHandler(BaseHandler): 38 | @tornado.web.authenticated 39 | def get(self): 40 | self.finish( 41 | json.dumps( 42 | { 43 | "exhibits": [ 44 | self._prepare_exhibit(exhibit_config, exhibit_id=i) 45 | for i, exhibit_config in enumerate( 46 | self.gallery_manager.exhibits 47 | ) 48 | ] 49 | } 50 | ) 51 | ) 52 | 53 | def _prepare_exhibit(self, exhibit, exhibit_id: int) -> dict: 54 | exposed_config = {k: v for k, v in exhibit.items() if k in EXPOSED_EXHIBIT_KEYS} 55 | return { 56 | **exposed_config, 57 | **self.gallery_manager.get_exhibit_data(exhibit), 58 | "id": exhibit_id, 59 | } 60 | 61 | 62 | class PullHandler(BaseHandler, SyncHandlerBase): 63 | @tornado.web.authenticated 64 | async def post(self): 65 | data = self.get_json_body() 66 | exhibit_id = data["exhibit_id"] 67 | try: 68 | exhibit = self.gallery_manager.exhibits[exhibit_id] 69 | except IndexError: 70 | self.set_status(406) 71 | self.finish(json.dumps({"message": f"exhibit_id {exhibit_id} not found"})) 72 | return 73 | 74 | branch = exhibit.get("branch") 75 | depth = exhibit.get("depth") 76 | 77 | if depth: 78 | depth = int(depth) 79 | 80 | return await super()._pull( 81 | repo=exhibit["git"], 82 | targetpath=str(self.gallery_manager.get_local_path(exhibit)), 83 | exhibit_id=exhibit_id, 84 | account=exhibit.get("account"), 85 | token=exhibit.get("token"), 86 | branch=branch, 87 | depth=depth, 88 | ) 89 | 90 | @tornado.web.authenticated 91 | async def get(self): 92 | return await super()._stream() 93 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version>=0.3.2"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jupyterlab-gallery" 7 | readme = "README.md" 8 | license = { file = "LICENSE" } 9 | requires-python = ">=3.8" 10 | classifiers = [ 11 | "Framework :: Jupyter", 12 | "Framework :: Jupyter :: JupyterLab", 13 | "Framework :: Jupyter :: JupyterLab :: 4", 14 | "Framework :: Jupyter :: JupyterLab :: Extensions", 15 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 16 | "License :: OSI Approved :: BSD License", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | ] 25 | dependencies = [ 26 | "jupyter_server>=2.0.1,<3", 27 | "nbgitpuller>=1.2.1", 28 | "GitPython>=3.1.43" 29 | ] 30 | dynamic = ["version", "description", "authors", "urls", "keywords"] 31 | 32 | [project.optional-dependencies] 33 | test = [ 34 | "coverage", 35 | "pytest", 36 | "pytest-asyncio", 37 | "pytest-cov", 38 | "pytest-jupyter[server]>=0.6.0" 39 | ] 40 | dev = ["ruff==0.4.4"] 41 | 42 | [project.scripts] 43 | jupyterlab-gallery = "jupyterlab_gallery:GalleryApp.launch_instance" 44 | jupyterhub-gallery = "jupyterlab_gallery.hub:HubGalleryApp.launch_instance" 45 | 46 | [tool.hatch.version] 47 | source = "nodejs" 48 | 49 | [tool.hatch.metadata.hooks.nodejs] 50 | fields = ["description", "authors", "urls"] 51 | 52 | [tool.hatch.build.targets.sdist] 53 | artifacts = ["jupyterlab_gallery/labextension"] 54 | exclude = [".github", "binder"] 55 | 56 | [tool.hatch.build.targets.wheel.shared-data] 57 | "jupyterlab_gallery/labextension" = "share/jupyter/labextensions/jupyterlab-gallery" 58 | "install.json" = "share/jupyter/labextensions/jupyterlab-gallery/install.json" 59 | "jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d" 60 | 61 | [tool.hatch.build.hooks.version] 62 | path = "jupyterlab_gallery/_version.py" 63 | 64 | [tool.hatch.build.hooks.jupyter-builder] 65 | dependencies = ["hatch-jupyter-builder>=0.5"] 66 | build-function = "hatch_jupyter_builder.npm_builder" 67 | ensured-targets = [ 68 | "jupyterlab_gallery/labextension/static/style.js", 69 | "jupyterlab_gallery/labextension/package.json", 70 | ] 71 | skip-if-exists = ["jupyterlab_gallery/labextension/static/style.js"] 72 | 73 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 74 | build_cmd = "build:prod" 75 | npm = ["jlpm"] 76 | 77 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 78 | build_cmd = "install:extension" 79 | npm = ["jlpm"] 80 | source_dir = "src" 81 | build_dir = "jupyterlab_gallery/labextension" 82 | 83 | [tool.jupyter-releaser.options] 84 | version_cmd = "hatch version" 85 | 86 | [tool.jupyter-releaser.hooks] 87 | before-build-npm = [ 88 | "python -m pip install 'jupyterlab>=4.0.0,<5'", 89 | "jlpm", 90 | "jlpm build:prod" 91 | ] 92 | before-build-python = ["jlpm clean:all"] 93 | 94 | [tool.check-wheel-contents] 95 | ignore = ["W002"] 96 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import { URLExt } from '@jupyterlab/coreutils'; 2 | import { fetchEventSource } from '@microsoft/fetch-event-source'; 3 | 4 | import { ServerConnection } from '@jupyterlab/services'; 5 | 6 | /** 7 | * Call the API extension 8 | * 9 | * @param endPoint API REST end point for the extension 10 | * @param init Initial values for the request 11 | * @returns The response body interpreted as JSON 12 | */ 13 | export async function requestAPI( 14 | endPoint: string, 15 | namespace: string, 16 | init: RequestInit = {} 17 | ): Promise { 18 | // Make request to Jupyter API 19 | const settings = ServerConnection.makeSettings(); 20 | const requestUrl = URLExt.join(settings.baseUrl, namespace, endPoint); 21 | 22 | let response: Response; 23 | try { 24 | response = await ServerConnection.makeRequest(requestUrl, init, settings); 25 | } catch (error) { 26 | throw new ServerConnection.NetworkError(error as any); 27 | } 28 | 29 | let data: any = await response.text(); 30 | 31 | if (data.length > 0) { 32 | try { 33 | data = JSON.parse(data); 34 | } catch (error) { 35 | console.log('Not a JSON response body.', response); 36 | } 37 | } 38 | 39 | if (!response.ok) { 40 | throw new ServerConnection.ResponseError(response, data.message || data); 41 | } 42 | 43 | return data; 44 | } 45 | 46 | export interface IProgress { 47 | progress: number; 48 | message: string; 49 | } 50 | 51 | export interface IProgressStreamMessage { 52 | output: IProgress; 53 | phase: 'progress'; 54 | exhibit_id: number; 55 | } 56 | 57 | export interface ITextStreamMessage { 58 | output?: string; 59 | phase: 'error' | 'finished' | 'syncing'; 60 | exhibit_id: number; 61 | } 62 | 63 | export type IStreamMessage = IProgressStreamMessage | ITextStreamMessage; 64 | 65 | export interface IEventStream { 66 | close: () => void; 67 | promise: Promise; 68 | } 69 | 70 | export function eventStream( 71 | endPoint = '', 72 | onStream: (message: IStreamMessage) => void, 73 | onError: (error: Event) => void, 74 | namespace: string 75 | ): IEventStream { 76 | const settings = ServerConnection.makeSettings(); 77 | let requestUrl = URLExt.join(settings.baseUrl, namespace, endPoint); 78 | const xsrfTokenMatch = document.cookie.match('\\b_xsrf=([^;]*)\\b'); 79 | if (xsrfTokenMatch) { 80 | const fullUrl = new URL(requestUrl); 81 | fullUrl.searchParams.append('_xsrf', xsrfTokenMatch[1]); 82 | requestUrl = fullUrl.toString(); 83 | } 84 | const controller = new AbortController(); 85 | const promise = fetchEventSource(requestUrl, { 86 | onmessage: event => { 87 | const data = JSON.parse(event.data); 88 | onStream(data); 89 | }, 90 | onerror: error => { 91 | onError(error); 92 | }, 93 | headers: { 94 | Authorization: `token ${settings.token}` 95 | }, 96 | signal: controller.signal 97 | }); 98 | const close = () => { 99 | controller.abort(); 100 | }; 101 | // https://bugzilla.mozilla.org/show_bug.cgi?id=833462 102 | window.addEventListener('beforeunload', () => { 103 | close(); 104 | }); 105 | return { 106 | close, 107 | promise 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /.github/workflows/update-integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Update Playwright Snapshots 2 | 3 | on: 4 | issue_comment: 5 | types: [created, edited] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | update-snapshots: 13 | if: > 14 | ( 15 | github.event.issue.author_association == 'OWNER' || 16 | github.event.issue.author_association == 'COLLABORATOR' || 17 | github.event.issue.author_association == 'MEMBER' 18 | ) && github.event.issue.pull_request && contains(github.event.comment.body, 'please update snapshots') 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: React to the triggering comment 23 | run: | 24 | gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions --raw-field 'content=+1' 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | with: 31 | token: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Get PR Info 34 | id: pr 35 | env: 36 | PR_NUMBER: ${{ github.event.issue.number }} 37 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | GH_REPO: ${{ github.repository }} 39 | COMMENT_AT: ${{ github.event.comment.created_at }} 40 | run: | 41 | pr="$(gh api /repos/${GH_REPO}/pulls/${PR_NUMBER})" 42 | head_sha="$(echo "$pr" | jq -r .head.sha)" 43 | pushed_at="$(echo "$pr" | jq -r .pushed_at)" 44 | 45 | if [[ $(date -d "$pushed_at" +%s) -gt $(date -d "$COMMENT_AT" +%s) ]]; then 46 | echo "Updating is not allowed because the PR was pushed to (at $pushed_at) after the triggering comment was issued (at $COMMENT_AT)" 47 | exit 1 48 | fi 49 | 50 | echo "head_sha=$head_sha" >> $GITHUB_OUTPUT 51 | 52 | - name: Checkout the branch from the PR that triggered the job 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | run: gh pr checkout ${{ github.event.issue.number }} 56 | 57 | - name: Validate the fetched branch HEAD revision 58 | env: 59 | EXPECTED_SHA: ${{ steps.pr.outputs.head_sha }} 60 | run: | 61 | actual_sha="$(git rev-parse HEAD)" 62 | 63 | if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then 64 | echo "The HEAD of the checked out branch ($actual_sha) differs from the HEAD commit available at the time when trigger comment was submitted ($EXPECTED_SHA)" 65 | exit 1 66 | fi 67 | 68 | - name: Base Setup 69 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 70 | 71 | - name: Install dependencies 72 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" jupyterlab-launchpad 73 | 74 | - name: Install extension 75 | run: | 76 | set -eux 77 | jlpm 78 | python -m pip install . 79 | 80 | - uses: jupyterlab/maintainer-tools/.github/actions/update-snapshots@v1 81 | with: 82 | github_token: ${{ secrets.GITHUB_TOKEN }} 83 | # Playwright knows how to start JupyterLab server 84 | start_server_script: 'null' 85 | test_folder: ui-tests 86 | npm_client: jlpm 87 | -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- 1 | /* 2 | See the JupyterLab Developer Guide for useful CSS Patterns: 3 | 4 | https://jupyterlab.readthedocs.io/en/stable/developer/css.html 5 | */ 6 | .jp-Gallery { 7 | display: grid; 8 | grid-template-columns: repeat(auto-fit, minmax(100px, 200px)); 9 | grid-auto-rows: 1fr; 10 | justify-content: center; 11 | } 12 | 13 | .jp-Exhibit { 14 | --jp-exhibit-bottom-reserved: 2.6lh; 15 | --jp-exhibit-bottom-collapsed: 2.3lh; 16 | --jp-exhibit-bottom-expanded: 3.2lh; 17 | 18 | border: 1px solid var(--jp-border-color1); 19 | border-radius: 4px; 20 | margin: 6px; 21 | padding: 4px 8px; 22 | max-width: 200px; 23 | position: relative; 24 | display: flex; 25 | flex-direction: column; 26 | overflow: hidden; 27 | padding-bottom: var(--jp-exhibit-bottom-reserved); 28 | min-width: 150px; 29 | } 30 | 31 | .jp-Exhibit-title { 32 | margin: 2px 4px; 33 | } 34 | 35 | .jp-Exhibit-description { 36 | padding: 2px; 37 | min-height: 1.6lh; 38 | cursor: default; 39 | } 40 | 41 | .jp-Exhibit-icon > img, 42 | .jp-Exhibit-icon > .jp-exhibitPlaceholder > svg { 43 | max-width: 100%; 44 | } 45 | 46 | .jp-Exhibit-icon > .jp-exhibitPlaceholder > svg { 47 | min-width: 40px; 48 | block-size: 100%; 49 | } 50 | 51 | .jp-exhibitPlaceholder { 52 | height: 100%; 53 | } 54 | 55 | .jp-Exhibit-icon { 56 | display: flex; 57 | justify-content: center; 58 | align-items: center; 59 | height: 100%; 60 | } 61 | 62 | .jp-Exhibit-middle { 63 | flex-grow: 1; 64 | position: relative; 65 | margin: 4px 0; 66 | } 67 | 68 | .jp-Launcher-openExample .jp-Gallery { 69 | width: 100%; 70 | } 71 | 72 | .jp-Exhibit-progressbar-filler { 73 | background: var(--jp-success-color2); 74 | height: 1em; 75 | transition: width 1s; 76 | } 77 | 78 | .jp-Exhibit-progressbar { 79 | border: 1px solid var(--jp-layout-color3); 80 | background: var(--jp-layout-color2); 81 | width: 100%; 82 | border-radius: 2px; 83 | position: relative; 84 | } 85 | 86 | .jp-Exhibit-progressbar-filler > .jp-Exhibit-progressbar-error { 87 | background: var(--jp-error-color1); 88 | } 89 | 90 | .jp-Exhibit-progressMessage { 91 | color: black; 92 | max-height: 1.2em; 93 | font-size: 85%; 94 | position: absolute; 95 | top: 0; 96 | padding: 0 2px; 97 | overflow: hidden; 98 | text-overflow: ellipsis; 99 | } 100 | 101 | .jp-Exhibit-buttons { 102 | position: absolute; 103 | width: 100%; 104 | height: 100%; 105 | top: 0; 106 | left: 0; 107 | display: flex; 108 | opacity: 0; 109 | box-sizing: border-box; 110 | transition-property: opacity; 111 | transition-duration: 200ms; 112 | border-radius: inherit; 113 | padding: 0 40px; 114 | } 115 | 116 | .jp-Exhibit .jp-Exhibit-icon { 117 | transition-property: filter; 118 | transition-duration: 200ms; 119 | } 120 | 121 | .jp-Exhibit:hover .jp-Exhibit-icon { 122 | filter: blur(10px); 123 | } 124 | 125 | .jp-Exhibit:hover .jp-Exhibit-buttons { 126 | opacity: 1; 127 | } 128 | 129 | .jp-Exhibit-bottom { 130 | position: absolute; 131 | transition-property: max-height; 132 | transition-duration: 200ms; 133 | max-height: var(--jp-exhibit-bottom-collapsed); 134 | bottom: 0; 135 | margin-left: -8px; 136 | padding: 0 4px; 137 | width: calc(100% - 8px); 138 | } 139 | 140 | .jp-Exhibit:hover .jp-Exhibit-bottom { 141 | max-height: var(--jp-exhibit-bottom-expanded); 142 | } 143 | 144 | .jp-Exhibit-buttons > .jp-Button { 145 | margin: auto; 146 | outline: 2px solid transparent; 147 | background: var(--jp-layout-color2); 148 | border: 1px solid var(--jp-border-color1); 149 | height: 35px; 150 | width: 35px; 151 | } 152 | 153 | .jp-Exhibit-buttons > .jp-Button:hover { 154 | background: var(--jp-layout-color0); 155 | } 156 | 157 | .jp-Exhibit-buttons > .jp-Button:disabled { 158 | opacity: 0.3; 159 | } 160 | 161 | .jp-Exhibit-buttons .jp-spinningIcon > svg { 162 | animation: jp-waiting-spin 3s linear infinite; 163 | } 164 | 165 | @keyframes jp-waiting-spin { 166 | 100% { 167 | transform: rotate(360deg); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin 4 | } from '@jupyterlab/application'; 5 | import { ITranslator, nullTranslator } from '@jupyterlab/translation'; 6 | import { ISettingRegistry } from '@jupyterlab/settingregistry'; 7 | import { IFileBrowserCommands } from '@jupyterlab/filebrowser'; 8 | 9 | import { ILauncher } from '@jupyterlab/launcher'; 10 | import type { INewLauncher } from 'jupyterlab-new-launcher/lib/types'; 11 | 12 | import { GalleryWidget } from './gallery'; 13 | import { galleryIcon } from './icons'; 14 | import { IGalleryReply } from './types'; 15 | import { requestAPI } from './handler'; 16 | 17 | function isNewLauncher(launcher: ILauncher): launcher is INewLauncher { 18 | return 'addSection' in launcher; 19 | } 20 | 21 | /** 22 | * Initialization data for the jupyterlab-gallery extension. 23 | */ 24 | const plugin: JupyterFrontEndPlugin = { 25 | id: 'jupyterlab-gallery:plugin', 26 | description: 27 | 'A JupyterLab gallery extension for presenting and downloading examples from remote repositories', 28 | autoStart: true, 29 | requires: [ISettingRegistry], 30 | optional: [IFileBrowserCommands, ITranslator, ILauncher], 31 | activate: async ( 32 | app: JupyterFrontEnd, 33 | settingRegistry: ISettingRegistry, 34 | fileBrowserCommands: IFileBrowserCommands | null, 35 | translator: ITranslator | null, 36 | launcher: ILauncher | null 37 | ) => { 38 | console.log('JupyterLab extension jupyterlab-gallery is activated!'); 39 | 40 | let settings: ISettingRegistry.ISettings; 41 | try { 42 | settings = await settingRegistry.load(plugin.id); 43 | console.log('jupyterlab-gallery settings loaded:', settings.composite); 44 | } catch (reason) { 45 | console.error('Failed to load settings for jupyterlab-gallery.', reason); 46 | return; 47 | } 48 | const serverAPI = settings.composite.endpoint as string; 49 | 50 | translator = translator ?? nullTranslator; 51 | const trans = translator.load('jupyterlab-gallery'); 52 | const widget = new GalleryWidget({ 53 | trans, 54 | openPath: (path: string) => { 55 | if (!fileBrowserCommands) { 56 | // TODO: Notebook v7 support 57 | throw Error('filebrowser not available'); 58 | } 59 | app.commands.execute(fileBrowserCommands.openPath, { path }); 60 | }, 61 | fileChanged: app.serviceManager.contents.fileChanged, 62 | refreshFileBrowser: () => { 63 | return app.commands.execute('filebrowser:refresh'); 64 | }, 65 | serverAPI 66 | }); 67 | 68 | const data = await requestAPI('gallery', serverAPI); 69 | const expectedVersion = '1.0'; 70 | if (data.apiVersion !== expectedVersion) { 71 | console.warn( 72 | `jupyter-gallery API version out of sync, expected ${expectedVersion}, got ${data.apiVersion}` 73 | ); 74 | } 75 | const title = data.title === 'Gallery' ? trans.__('Gallery') : data.title; 76 | 77 | // hide the widget if no exhibits are configured 78 | if (data.hideGalleryWithoutExhibits && !data.exhibitsConfigured) { 79 | console.log( 80 | 'Gallery extension will not add any UI elements because no exhibits are configured' 81 | ); 82 | return; 83 | } 84 | // add the widget to sidebar before waiting for server reply to reduce UI jitter 85 | if (launcher && isNewLauncher(launcher)) { 86 | launcher.addSection({ 87 | title, 88 | className: 'jp-Launcher-openExample', 89 | icon: galleryIcon, 90 | id: 'gallery', 91 | rank: 2.5, 92 | render: () => { 93 | return widget.render(); 94 | } 95 | }); 96 | } else { 97 | // fallback to placing it in the sidebar if new launcher is not installed 98 | widget.id = 'jupyterlab-gallery:sidebar'; 99 | widget.title.icon = galleryIcon; 100 | widget.title.caption = title; 101 | widget.show(); 102 | app.shell.add(widget, 'left', { rank: 850 }); 103 | } 104 | } 105 | }; 106 | 107 | export default plugin; 108 | -------------------------------------------------------------------------------- /ui-tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration Testing 2 | 3 | This folder contains the integration tests of the extension. 4 | 5 | They are defined using [Playwright](https://playwright.dev/docs/intro) test runner 6 | and [Galata](https://github.com/jupyterlab/jupyterlab/tree/main/galata) helper. 7 | 8 | The Playwright configuration is defined in [playwright.config.js](./playwright.config.js). 9 | 10 | The JupyterLab server configuration to use for the integration test is defined 11 | in [jupyter_server_test_config.py](./jupyter_server_test_config.py). 12 | 13 | The default configuration will produce video for failing tests and an HTML report. 14 | 15 | > There is a new experimental UI mode that you may fall in love with; see [that video](https://www.youtube.com/watch?v=jF0yA-JLQW0). 16 | 17 | ## Run the tests 18 | 19 | > All commands are assumed to be executed from the root directory 20 | 21 | To run the tests, you need to: 22 | 23 | 1. Compile the extension: 24 | 25 | ```sh 26 | jlpm install 27 | jlpm build:prod 28 | ``` 29 | 30 | > Check the extension is installed in JupyterLab. 31 | 32 | 2. Install test dependencies (needed only once): 33 | 34 | ```sh 35 | cd ./ui-tests 36 | jlpm install 37 | jlpm playwright install 38 | cd .. 39 | ``` 40 | 41 | 3. Execute the [Playwright](https://playwright.dev/docs/intro) tests: 42 | 43 | ```sh 44 | cd ./ui-tests 45 | jlpm playwright test 46 | ``` 47 | 48 | Test results will be shown in the terminal. In case of any test failures, the test report 49 | will be opened in your browser at the end of the tests execution; see 50 | [Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter) 51 | for configuring that behavior. 52 | 53 | ## Update the tests snapshots 54 | 55 | > All commands are assumed to be executed from the root directory 56 | 57 | If you are comparing snapshots to validate your tests, you may need to update 58 | the reference snapshots stored in the repository. To do that, you need to: 59 | 60 | 1. Compile the extension: 61 | 62 | ```sh 63 | jlpm install 64 | jlpm build:prod 65 | ``` 66 | 67 | > Check the extension is installed in JupyterLab. 68 | 69 | 2. Install test dependencies (needed only once): 70 | 71 | ```sh 72 | cd ./ui-tests 73 | jlpm install 74 | jlpm playwright install 75 | cd .. 76 | ``` 77 | 78 | 3. Execute the [Playwright](https://playwright.dev/docs/intro) command: 79 | 80 | ```sh 81 | cd ./ui-tests 82 | jlpm playwright test -u 83 | ``` 84 | 85 | > Some discrepancy may occurs between the snapshots generated on your computer and 86 | > the one generated on the CI. To ease updating the snapshots on a PR, you can 87 | > type `please update playwright snapshots` to trigger the update by a bot on the CI. 88 | > Once the bot has computed new snapshots, it will commit them to the PR branch. 89 | 90 | ## Create tests 91 | 92 | > All commands are assumed to be executed from the root directory 93 | 94 | To create tests, the easiest way is to use the code generator tool of playwright: 95 | 96 | 1. Compile the extension: 97 | 98 | ```sh 99 | jlpm install 100 | jlpm build:prod 101 | ``` 102 | 103 | > Check the extension is installed in JupyterLab. 104 | 105 | 2. Install test dependencies (needed only once): 106 | 107 | ```sh 108 | cd ./ui-tests 109 | jlpm install 110 | jlpm playwright install 111 | cd .. 112 | ``` 113 | 114 | 3. Start the server: 115 | 116 | ```sh 117 | cd ./ui-tests 118 | jlpm start 119 | ``` 120 | 121 | 4. Execute the [Playwright code generator](https://playwright.dev/docs/codegen) in **another terminal**: 122 | 123 | ```sh 124 | cd ./ui-tests 125 | jlpm playwright codegen localhost:8888 126 | ``` 127 | 128 | ## Debug tests 129 | 130 | > All commands are assumed to be executed from the root directory 131 | 132 | To debug tests, a good way is to use the inspector tool of playwright: 133 | 134 | 1. Compile the extension: 135 | 136 | ```sh 137 | jlpm install 138 | jlpm build:prod 139 | ``` 140 | 141 | > Check the extension is installed in JupyterLab. 142 | 143 | 2. Install test dependencies (needed only once): 144 | 145 | ```sh 146 | cd ./ui-tests 147 | jlpm install 148 | jlpm playwright install 149 | cd .. 150 | ``` 151 | 152 | 3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug): 153 | 154 | ```sh 155 | cd ./ui-tests 156 | jlpm playwright test --debug 157 | ``` 158 | 159 | ## Upgrade Playwright and the browsers 160 | 161 | To update the web browser versions, you must update the package `@playwright/test`: 162 | 163 | ```sh 164 | cd ./ui-tests 165 | jlpm up "@playwright/test" 166 | jlpm playwright install 167 | ``` 168 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: '*' 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Base Setup 22 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - name: Install dependencies 25 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 26 | 27 | - name: Lint the extension 28 | run: | 29 | set -eux 30 | jlpm 31 | jlpm run lint:check 32 | 33 | - name: Test the extension 34 | run: | 35 | set -eux 36 | jlpm run test 37 | 38 | - name: Build the extension 39 | run: | 40 | set -eux 41 | python -m pip install .[test] 42 | 43 | pytest -vv -r ap --cov jupyterlab_gallery 44 | jupyter server extension list 45 | jupyter server extension list 2>&1 | grep -ie "jupyterlab_gallery.*OK" 46 | 47 | jupyter labextension list 48 | jupyter labextension list 2>&1 | grep -ie "jupyterlab-gallery.*OK" 49 | python -m jupyterlab.browser_check 50 | 51 | - name: Package the extension 52 | run: | 53 | set -eux 54 | 55 | pip install build 56 | python -m build 57 | pip uninstall -y "jupyterlab_gallery" jupyterlab 58 | 59 | - name: Upload extension packages 60 | uses: actions/upload-artifact@v4 61 | with: 62 | name: extension-artifacts 63 | path: dist/jupyterlab_gallery* 64 | if-no-files-found: error 65 | 66 | test_isolated: 67 | needs: build 68 | runs-on: ubuntu-latest 69 | 70 | steps: 71 | - name: Install Python 72 | uses: actions/setup-python@v5 73 | with: 74 | python-version: '3.9' 75 | architecture: 'x64' 76 | - uses: actions/download-artifact@v4 77 | with: 78 | name: extension-artifacts 79 | - name: Install and Test 80 | run: | 81 | set -eux 82 | # Remove NodeJS, twice to take care of system and locally installed node versions. 83 | sudo rm -rf $(which node) 84 | sudo rm -rf $(which node) 85 | 86 | pip install "jupyterlab>=4.0.0,<5" jupyterlab_gallery*.whl 87 | 88 | 89 | jupyter server extension list 90 | jupyter server extension list 2>&1 | grep -ie "jupyterlab_gallery.*OK" 91 | 92 | jupyter labextension list 93 | jupyter labextension list 2>&1 | grep -ie "jupyterlab-gallery.*OK" 94 | python -m jupyterlab.browser_check --no-browser-test 95 | 96 | integration-tests: 97 | name: Integration tests 98 | needs: build 99 | runs-on: ubuntu-latest 100 | 101 | env: 102 | PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/pw-browsers 103 | 104 | steps: 105 | - name: Checkout 106 | uses: actions/checkout@v4 107 | 108 | - name: Base Setup 109 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 110 | 111 | - name: Download extension package 112 | uses: actions/download-artifact@v4 113 | with: 114 | name: extension-artifacts 115 | 116 | - name: Install the extension 117 | run: | 118 | set -eux 119 | python -m pip install "jupyterlab>=4.0.0,<5" jupyterlab_gallery*.whl jupyterlab-launchpad 120 | 121 | - name: Install dependencies 122 | working-directory: ui-tests 123 | env: 124 | YARN_ENABLE_IMMUTABLE_INSTALLS: 0 125 | PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 126 | run: jlpm install 127 | 128 | - name: Set up browser cache 129 | uses: actions/cache@v4 130 | with: 131 | path: | 132 | ${{ github.workspace }}/pw-browsers 133 | key: ${{ runner.os }}-${{ hashFiles('ui-tests/yarn.lock') }} 134 | 135 | - name: Install browser 136 | run: jlpm playwright install chromium 137 | working-directory: ui-tests 138 | 139 | - name: Execute integration tests 140 | working-directory: ui-tests 141 | run: | 142 | jlpm playwright test 143 | 144 | - name: Upload Playwright Test report 145 | if: always() 146 | uses: actions/upload-artifact@v4 147 | with: 148 | name: jupyterlab_gallery-playwright-tests 149 | path: | 150 | ui-tests/test-results 151 | ui-tests/playwright-report 152 | 153 | check_links: 154 | name: Check Links 155 | runs-on: ubuntu-latest 156 | timeout-minutes: 15 157 | steps: 158 | - uses: actions/checkout@v4 159 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 160 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 161 | -------------------------------------------------------------------------------- /jupyterlab_gallery/manager.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from datetime import datetime 3 | from pathlib import Path 4 | from typing import Optional 5 | from threading import Thread 6 | 7 | from traitlets.config.configurable import LoggingConfigurable 8 | from traitlets import Dict, List, Unicode, Bool, Int 9 | 10 | from .git_utils import ( 11 | extract_repository_owner, 12 | extract_repository_name, 13 | git_credentials, 14 | has_updates, 15 | ) 16 | 17 | 18 | class GalleryManager(LoggingConfigurable): 19 | _has_updates: dict[str, Optional[bool]] = defaultdict(lambda: None) 20 | 21 | def __init__(self, *args, **kwargs): 22 | super().__init__(*args, **kwargs) 23 | self._background_tasks = set() 24 | 25 | root_dir = Unicode( 26 | config=False, 27 | allow_none=True, 28 | ) 29 | 30 | exhibits = List( 31 | Dict( 32 | per_key_traits={ 33 | "git": Unicode(help="Git URL used for cloning"), 34 | "title": Unicode(help="Name of the exhibit"), 35 | "homepage": Unicode( 36 | help="User-facing URL to open if any", allow_none=True 37 | ), 38 | "description": Unicode(help="Short description", allow_none=True), 39 | "token": Unicode( 40 | help="Personal access token - required if the repository is private", 41 | allow_none=True, 42 | ), 43 | "account": Unicode( 44 | help="Username or name of application - required if the repository is private", 45 | allow_none=True, 46 | ), 47 | # TODO: validate path exists 48 | "icon": Unicode( 49 | help="Path to an svg or png, or base64 encoded string", 50 | allow_none=True, 51 | ), 52 | "branch": Unicode( 53 | default_value=None, help="Branch to use", allow_none=True 54 | ), 55 | "depth": Int( 56 | default_value=None, help="Depth of the clone", allow_none=True 57 | ), 58 | # other ideas: `path_in_repository`, `documentation_url` 59 | } 60 | ), 61 | config=True, 62 | allow_none=False, 63 | default_value=[], 64 | ) 65 | 66 | destination = Unicode( 67 | help="The directory into which the exhibits will be cloned", 68 | default_value="gallery", 69 | config=True, 70 | ) 71 | 72 | title = Unicode( 73 | help="The the display name of the Gallery widget", 74 | default_value="Gallery", 75 | config=True, 76 | ) 77 | 78 | hide_gallery_without_exhibits = Bool( 79 | help="Hide Gallery if no exhibits are configured", 80 | default_value=False, 81 | config=True, 82 | ) 83 | 84 | def get_local_path(self, exhibit) -> Path: 85 | clone_destination = Path(self.destination) 86 | repository_name = extract_repository_name(exhibit["git"]) 87 | return clone_destination / repository_name 88 | 89 | def _check_updates(self, exhibit): 90 | local_path = self.get_local_path(exhibit) 91 | with git_credentials( 92 | account=exhibit.get("account"), token=exhibit.get("token") 93 | ): 94 | self._has_updates[local_path] = has_updates(local_path) 95 | 96 | def get_exhibit_data(self, exhibit): 97 | data = {} 98 | 99 | if "icon" not in exhibit or not exhibit["icon"]: 100 | homepage = exhibit.get("homepage") 101 | if homepage and homepage.startswith("https://github.com/"): 102 | repository_name = extract_repository_name(exhibit["git"]) 103 | repository_owner = extract_repository_owner(homepage) 104 | data["icon"] = ( 105 | f"https://opengraph.githubassets.com/1/{repository_owner}/{repository_name}" 106 | ) 107 | 108 | local_path = self.get_local_path(exhibit) 109 | 110 | data["localPath"] = str(local_path) 111 | exists = local_path.exists() 112 | data["isCloned"] = exists 113 | if exists: 114 | fetch_head = local_path / ".git" / "FETCH_HEAD" 115 | head = local_path / ".git" / "HEAD" 116 | date_head = fetch_head if fetch_head.exists() else head 117 | if date_head.exists(): 118 | data["lastUpdated"] = datetime.fromtimestamp( 119 | date_head.stat().st_mtime 120 | ).isoformat() 121 | data["updatesAvailable"] = self._has_updates[local_path] 122 | 123 | def check_updates(): 124 | self._check_updates(exhibit) 125 | 126 | Thread(target=check_updates).start() 127 | return data 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterlab-gallery", 3 | "version": "0.6.3", 4 | "description": "A JupyterLab gallery extension for presenting and downloading examples from remote repositories", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/nebari-dev/jupyterlab-gallery", 11 | "bugs": { 12 | "url": "https://github.com/nebari-dev/jupyterlab-gallery/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "author": { 16 | "name": "Nebari development team", 17 | "email": "internal-it@quansight.com" 18 | }, 19 | "files": [ 20 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 21 | "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}", 22 | "src/**/*.{ts,tsx}", 23 | "schema/*.json" 24 | ], 25 | "main": "lib/index.js", 26 | "types": "lib/index.d.ts", 27 | "style": "style/index.css", 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/nebari-dev/jupyterlab-gallery.git" 31 | }, 32 | "scripts": { 33 | "build": "jlpm build:lib && jlpm build:labextension:dev", 34 | "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension", 35 | "build:labextension": "jupyter labextension build .", 36 | "build:labextension:dev": "jupyter labextension build --development True .", 37 | "build:lib": "tsc --sourceMap", 38 | "build:lib:prod": "tsc", 39 | "clean": "jlpm clean:lib", 40 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 41 | "clean:lintcache": "rimraf .eslintcache .stylelintcache", 42 | "clean:labextension": "rimraf jupyterlab_gallery/labextension jupyterlab_gallery/_version.py", 43 | "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache", 44 | "eslint": "jlpm eslint:check --fix", 45 | "eslint:check": "eslint . --cache --ext .ts,.tsx", 46 | "install:extension": "jlpm build", 47 | "lint": "jlpm stylelint && jlpm prettier && jlpm eslint", 48 | "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check", 49 | "prettier": "jlpm prettier:base --write --list-different", 50 | "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 51 | "prettier:check": "jlpm prettier:base --check", 52 | "stylelint": "jlpm stylelint:check --fix", 53 | "stylelint:check": "stylelint --cache \"style/**/*.css\"", 54 | "test": "jest --coverage", 55 | "watch": "run-p watch:src watch:labextension", 56 | "watch:src": "tsc -w --sourceMap", 57 | "watch:labextension": "jupyter labextension watch ." 58 | }, 59 | "packageManager": "yarn@3.5.0", 60 | "dependencies": { 61 | "@jupyterlab/application": "^4.0.0", 62 | "@jupyterlab/coreutils": "^6.0.0", 63 | "@jupyterlab/services": "^7.0.0", 64 | "@jupyterlab/settingregistry": "^4.0.0", 65 | "@microsoft/fetch-event-source": "^2.0.1" 66 | }, 67 | "devDependencies": { 68 | "@jupyterlab/builder": "^4.0.0", 69 | "@jupyterlab/launcher": "^4.0.0", 70 | "@jupyterlab/testutils": "^4.0.0", 71 | "@types/jest": "^29.2.0", 72 | "@types/json-schema": "^7.0.11", 73 | "@types/react": "^18.0.26", 74 | "@types/react-addons-linked-state-mixin": "^0.14.22", 75 | "@typescript-eslint/eslint-plugin": "^6.1.0", 76 | "@typescript-eslint/parser": "^6.1.0", 77 | "css-loader": "^6.7.1", 78 | "eslint": "^8.36.0", 79 | "eslint-config-prettier": "^8.8.0", 80 | "eslint-plugin-prettier": "^5.0.0", 81 | "jest": "^29.2.0", 82 | "jupyterlab-new-launcher": "^0.4.0", 83 | "mkdirp": "^1.0.3", 84 | "npm-run-all": "^4.1.5", 85 | "prettier": "^3.0.0", 86 | "rimraf": "^5.0.1", 87 | "source-map-loader": "^1.0.2", 88 | "style-loader": "^3.3.1", 89 | "stylelint": "^15.10.1", 90 | "stylelint-config-recommended": "^13.0.0", 91 | "stylelint-config-standard": "^34.0.0", 92 | "stylelint-csstree-validator": "^3.0.0", 93 | "stylelint-prettier": "^4.0.0", 94 | "typescript": "~5.0.2", 95 | "yjs": "^13.5.0" 96 | }, 97 | "sideEffects": [ 98 | "style/*.css", 99 | "style/index.js" 100 | ], 101 | "styleModule": "style/index.js", 102 | "publishConfig": { 103 | "access": "public" 104 | }, 105 | "jupyterlab": { 106 | "discovery": { 107 | "server": { 108 | "managers": [ 109 | "pip" 110 | ], 111 | "base": { 112 | "name": "jupyterlab_gallery" 113 | } 114 | } 115 | }, 116 | "extension": true, 117 | "outputDir": "jupyterlab_gallery/labextension", 118 | "schemaDir": "schema" 119 | }, 120 | "eslintIgnore": [ 121 | "node_modules", 122 | "dist", 123 | "coverage", 124 | "**/*.d.ts", 125 | "tests", 126 | "**/__tests__", 127 | "ui-tests" 128 | ], 129 | "eslintConfig": { 130 | "extends": [ 131 | "eslint:recommended", 132 | "plugin:@typescript-eslint/eslint-recommended", 133 | "plugin:@typescript-eslint/recommended", 134 | "plugin:prettier/recommended" 135 | ], 136 | "parser": "@typescript-eslint/parser", 137 | "parserOptions": { 138 | "project": "tsconfig.json", 139 | "sourceType": "module" 140 | }, 141 | "plugins": [ 142 | "@typescript-eslint" 143 | ], 144 | "rules": { 145 | "@typescript-eslint/naming-convention": [ 146 | "error", 147 | { 148 | "selector": "interface", 149 | "format": [ 150 | "PascalCase" 151 | ], 152 | "custom": { 153 | "regex": "^I[A-Z]", 154 | "match": true 155 | } 156 | } 157 | ], 158 | "@typescript-eslint/no-unused-vars": [ 159 | "warn", 160 | { 161 | "args": "none" 162 | } 163 | ], 164 | "@typescript-eslint/no-explicit-any": "off", 165 | "@typescript-eslint/no-namespace": "off", 166 | "@typescript-eslint/no-use-before-define": "off", 167 | "@typescript-eslint/quotes": [ 168 | "error", 169 | "single", 170 | { 171 | "avoidEscape": true, 172 | "allowTemplateLiterals": false 173 | } 174 | ], 175 | "curly": [ 176 | "error", 177 | "all" 178 | ], 179 | "eqeqeq": "error", 180 | "prefer-arrow-callback": "error" 181 | } 182 | }, 183 | "prettier": { 184 | "singleQuote": true, 185 | "trailingComma": "none", 186 | "arrowParens": "avoid", 187 | "endOfLine": "auto", 188 | "overrides": [ 189 | { 190 | "files": "package.json", 191 | "options": { 192 | "tabWidth": 4 193 | } 194 | } 195 | ] 196 | }, 197 | "stylelint": { 198 | "extends": [ 199 | "stylelint-config-recommended", 200 | "stylelint-config-standard", 201 | "stylelint-prettier/recommended" 202 | ], 203 | "plugins": [ 204 | "stylelint-csstree-validator" 205 | ], 206 | "rules": { 207 | "csstree/validator": true, 208 | "property-no-vendor-prefix": null, 209 | "selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$", 210 | "selector-no-vendor-prefix": null, 211 | "value-no-vendor-prefix": null 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /ui-tests/tests/jupyterlab_gallery.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jupyterlab/galata'; 2 | import { Page } from '@playwright/test'; 3 | import type { 4 | IGalleryReply, 5 | IExhibitReply, 6 | IExhibit 7 | } from 'jupyterlab-gallery'; 8 | 9 | interface IServerSideExhibit { 10 | git: string; 11 | homepage?: string; 12 | title: string; 13 | description?: string; 14 | icon?: string; 15 | branch?: string; 16 | depth?: number; 17 | } 18 | 19 | const niceExhibitConfigs: IServerSideExhibit[] = [ 20 | { 21 | git: 'https://github.com/numba/nvidia-cuda-tutorial.git', 22 | homepage: 'https://github.com/numba/nvidia-cuda-tutorial', 23 | title: 'Numba for CUDA', 24 | description: 'Nvidia contributed CUDA tutorial for Numba' 25 | }, 26 | { 27 | git: 'https://github.com/yunjey/pytorch-tutorial.git', 28 | homepage: 'https://github.com/yunjey/pytorch-tutorial', 29 | title: 'PyTorch Tutorial', 30 | description: 'PyTorch Tutorial for Deep Learning Researchers', 31 | icon: 'https://github.com/yunjey/pytorch-tutorial/raw/master/logo/pytorch_logo_2018.svg' 32 | }, 33 | { 34 | git: 'https://github.com/jupyter-widgets/tutorial.git', 35 | homepage: 'https://github.com/jupyter-widgets/tutorial', 36 | title: 'Jupyter Widgets Tutorial', 37 | description: 'The Jupyter Widget Ecosystem' 38 | }, 39 | { 40 | git: 'https://github.com/amueller/scipy-2018-sklearn.git', 41 | homepage: 'https://github.com/amueller/scipy-2018-sklearn', 42 | title: 'Scikit-learn Tutorial 2018', 43 | description: 'SciPy 2018 Scikit-learn Tutorial' 44 | }, 45 | { 46 | git: 'https://github.com/nebari-dev/nebari.git', 47 | homepage: 'https://github.com/nebari-dev/nebari', 48 | title: 'Nebari', 49 | description: 'Nebari - your open source data science platform', 50 | icon: 'https://raw.githubusercontent.com/nebari-dev/nebari-design/main/logo-mark/horizontal/Nebari-Logo-Horizontal-Lockup.svg' 51 | }, 52 | { 53 | git: 'https://github.com/jupyterlab/jupyterlab.git', 54 | homepage: 'https://github.com/jupyterlab/jupyterlab/', 55 | title: 'JupyterLab', 56 | description: 57 | 'JupyterLab is a highly extensible, feature-rich notebook authoring application and editing environment, and is a part of Project Jupyter', 58 | icon: 'https://raw.githubusercontent.com/jupyterlab/jupyterlab/main/packages/ui-components/style/icons/jupyter/jupyter.svg' 59 | } 60 | ]; 61 | 62 | const edgeCaseExhibitConfigs: IServerSideExhibit[] = [ 63 | { 64 | git: 'https://github.com/krassowski/example-private-repository.git', 65 | homepage: 'https://github.com/krassowski/example-private-repository/', 66 | title: 'Private repository' 67 | }, 68 | { 69 | git: 'https://github.com/jupyter-widgets/tutorial.git', 70 | title: 'Example without description' 71 | }, 72 | { 73 | git: 'https://gitlab.gnome.org/GNOME/atomix.git', 74 | title: 'GNOME atomix', 75 | description: 'Example without icon' 76 | }, 77 | { 78 | git: 'https://github.com/nebari-dev/nebari.git', 79 | homepage: 'https://github.com/nebari-dev/nebari', 80 | title: 'Empty icon', 81 | description: 'Empty icon should show social card for GitHub repos', 82 | icon: '' 83 | } 84 | ]; 85 | function mockExhibit(config: IServerSideExhibit, id: number): IExhibit { 86 | const git_url = config.git.split('/'); 87 | const repo_name = git_url.pop()!.split('.')[0]; 88 | const repo_owner = git_url.pop(); 89 | 90 | return { 91 | homepage: config.homepage, 92 | title: config.title, 93 | description: config.description, 94 | icon: 95 | config.icon ?? 96 | `https://opengraph.githubassets.com/1/${repo_owner}/${repo_name}`, 97 | id, 98 | isCloned: false, 99 | localPath: 'clone/path/' + repo_name, 100 | revision: 'revision-id' 101 | }; 102 | } 103 | 104 | const niceExhibits: IExhibit[] = niceExhibitConfigs.map((config, index) => { 105 | return mockExhibit(config, index); 106 | }); 107 | 108 | const edgeCaseExhibits: IExhibit[] = edgeCaseExhibitConfigs.map( 109 | (config, index) => { 110 | return mockExhibit(config, index); 111 | } 112 | ); 113 | 114 | const defaultGalleryReplies: IGalleryReply = { 115 | title: 'Gallery', 116 | exhibitsConfigured: true, 117 | hideGalleryWithoutExhibits: false, 118 | apiVersion: '1.0' 119 | }; 120 | 121 | const defaultExhibitsReply: IExhibitReply = { 122 | exhibits: niceExhibits 123 | }; 124 | 125 | async function mockGalleryEndpoint( 126 | page: Page, 127 | gallery: Partial = {} 128 | ): Promise { 129 | await page.route(/\/jupyterlab-gallery\/gallery/, (route, request) => { 130 | switch (request.method()) { 131 | case 'GET': 132 | return route.fulfill({ 133 | status: 200, 134 | body: JSON.stringify({ 135 | ...defaultGalleryReplies, 136 | ...gallery 137 | }) 138 | }); 139 | default: 140 | return route.continue(); 141 | } 142 | }); 143 | } 144 | 145 | async function mockExhibitsEndpoint( 146 | page: Page, 147 | exhibits: Partial = {} 148 | ): Promise { 149 | await page.route(/\/jupyterlab-gallery\/exhibits/, (route, request) => { 150 | switch (request.method()) { 151 | case 'GET': 152 | return route.fulfill({ 153 | status: 200, 154 | body: JSON.stringify({ 155 | ...defaultExhibitsReply, 156 | ...exhibits 157 | }) 158 | }); 159 | default: 160 | return route.continue(); 161 | } 162 | }); 163 | } 164 | 165 | test.describe('Integration with jupyterlab-launchpad', () => { 166 | /** 167 | * Don't load JupyterLab webpage before running the tests. 168 | */ 169 | test.use({ autoGoto: false }); 170 | 171 | const EXAMPLE_CARD = 1; 172 | 173 | test('Launchpad integration', async ({ page }) => { 174 | await mockGalleryEndpoint(page); 175 | await mockExhibitsEndpoint(page); 176 | 177 | await page.goto(); 178 | 179 | // collapse the "create empty" section 180 | await page.locator('.jp-Launcher-openByType summary').click(); 181 | // wait for animations to complete 182 | await page.waitForTimeout(400); 183 | 184 | const launcher = page.locator('.jp-LauncherBody'); 185 | 186 | const mainMenu = page.locator('#jp-MainMenu'); 187 | 188 | // move the mouse away from the summary button 189 | await mainMenu.hover(); 190 | await page.waitForTimeout(100); 191 | 192 | expect(await launcher.screenshot()).toMatchSnapshot('in-launchpad.png'); 193 | }); 194 | 195 | test('On hover - fresh', async ({ page }) => { 196 | await mockGalleryEndpoint(page); 197 | await mockExhibitsEndpoint(page, { 198 | exhibits: [niceExhibits[EXAMPLE_CARD]] 199 | }); 200 | 201 | await page.goto(); 202 | 203 | const card = page.locator('.jp-Exhibit').first(); 204 | await card.hover(); 205 | expect(await card.screenshot()).toMatchSnapshot('on-hover-fresh.png'); 206 | }); 207 | 208 | test('On hover - cloned', async ({ page }) => { 209 | await mockGalleryEndpoint(page); 210 | await mockExhibitsEndpoint(page, { 211 | exhibits: [{ ...niceExhibits[EXAMPLE_CARD], isCloned: true }] 212 | }); 213 | 214 | await page.goto(); 215 | 216 | const card = page.locator('.jp-Exhibit').first(); 217 | await card.hover(); 218 | expect(await card.screenshot()).toMatchSnapshot('on-hover-cloned.png'); 219 | }); 220 | 221 | test('On hover - updates pending', async ({ page }) => { 222 | await mockGalleryEndpoint(page); 223 | await mockExhibitsEndpoint(page, { 224 | exhibits: [ 225 | { 226 | ...niceExhibits[EXAMPLE_CARD], 227 | isCloned: true, 228 | updatesAvailable: true 229 | } 230 | ] 231 | }); 232 | 233 | await page.goto(); 234 | 235 | const card = page.locator('.jp-Exhibit').first(); 236 | await card.hover(); 237 | expect(await card.screenshot()).toMatchSnapshot( 238 | 'on-hover-updates-pending.png' 239 | ); 240 | }); 241 | 242 | test('Odd cases', async ({ page }) => { 243 | await mockGalleryEndpoint(page, { title: 'Edge cases' }); 244 | await mockExhibitsEndpoint(page, { exhibits: edgeCaseExhibits }); 245 | await page.goto(); 246 | // wait for pictures to settle 247 | await page.waitForTimeout(400); 248 | const gallery = page.locator('.jp-Gallery'); 249 | expect(await gallery.screenshot()).toMatchSnapshot('odd-cases.png'); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jupyterlab-gallery 2 | 3 | ![Extension status](https://img.shields.io/badge/status-ready-success 'Ready to be used') 4 | [![Github Actions Status](https://github.com/nebari-dev/jupyterlab-gallery/workflows/Build/badge.svg)](https://github.com/nebari-dev/jupyterlab-gallery/actions/workflows/build.yml) 5 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/nebari-dev/jupyterlab-gallery/main?urlpath=lab) 6 | 7 | A JupyterLab gallery extension for presenting and downloading examples from remote repositories 8 | 9 | This extension is composed of a Python package named `jupyterlab-gallery` 10 | for the server extension and a NPM package named `jupyterlab-gallery` 11 | for the frontend extension. 12 | 13 | When [`jupyterlab-launchpad`](https://github.com/nebari-dev/jupyterlab-launchpad) is installed, the gallery will be added as a "Gallery" section in the launcher: 14 | 15 | ![in launchpad][in-launchpad] 16 | 17 | Otherwise it will be shown in the left sidebar. 18 | 19 | [in-launchpad]: https://raw.githubusercontent.com/nebari-dev/jupyterlab-gallery/main/ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/in-launchpad-linux.png 20 | 21 | ## Usage 22 | 23 | Hover over the tile with exhibit that you are interested in to reveal a "Download" button: 24 | 25 | ![hover before cloning][hover-fresh] 26 | 27 | Clicking this button will start the download process, which you can monitor by tracking the progress bar that shows up in the tile. 28 | 29 | After cloning has completed, hover over the tile again to reveal "Open Folder" and "Update" buttons: 30 | 31 | ![hover after cloning][hover-cloned] 32 | 33 | The update button becomes active once a new version of the cloned repository becomes available (once new commits are pushed to the tracked branch): 34 | 35 | ![hover with updates][hover-update] 36 | 37 | [hover-fresh]: https://raw.githubusercontent.com/nebari-dev/jupyterlab-gallery/main/ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/on-hover-fresh-linux.png 38 | [hover-cloned]: https://raw.githubusercontent.com/nebari-dev/jupyterlab-gallery/main/ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/on-hover-cloned-linux.png 39 | [hover-update]: https://raw.githubusercontent.com/nebari-dev/jupyterlab-gallery/main/ui-tests/tests/jupyterlab_gallery.spec.ts-snapshots/on-hover-updates-pending-linux.png 40 | 41 | ## Configuration 42 | 43 | You can configure the gallery with the following traitlets: 44 | 45 | - `GalleryManager.exhibits`: controls the tiles shown in the gallery 46 | - `GalleryManager.destination`: defined the path into which the exhibits will be cloned (by default `/gallery`) 47 | - `GalleryManager.title`: the display name of the widget (by default "Gallery") 48 | 49 | These traitlets can be passed from the command line, a JSON file (`.json`) or a Python file (`.py`). 50 | 51 | You must name the file `jupyter_gallery_config.py` or `jupyter_gallery_config.json` and place it in one of the paths returned by `jupyter --paths` under the `config` section. 52 | 53 | An example Python file would include: 54 | 55 | ```python 56 | c.GalleryManager.title = "Examples" 57 | c.GalleryManager.destination = "examples" 58 | c.GalleryManager.exhibits = [ 59 | { 60 | "git": "https://github.com/jupyterlab/jupyterlab.git", 61 | "homepage": "https://github.com/jupyterlab/jupyterlab/", 62 | "title": "JupyterLab", 63 | "description": "JupyterLab is a highly extensible, feature-rich notebook authoring application and editing environment.", 64 | "icon": "https://raw.githubusercontent.com/jupyterlab/jupyterlab/main/packages/ui-components/style/icons/jupyter/jupyter.svg" 65 | }, 66 | { 67 | "git": "https://github.com/my_org/private-tutorial.git", 68 | "account": "name-of-the-account-or-app-owning-the-token", 69 | "token": "access-token-for-example-starting-with-github_pat_", 70 | "title": "My private tutorial", 71 | "description": "A tutorial which is not public.", 72 | }, 73 | { 74 | "git": "https://github.com/my_org/public-tutorial.git", 75 | "title": "My tutorial", 76 | "branch": "v2024", 77 | "depth": 1 78 | } 79 | ] 80 | ``` 81 | 82 | Using the Python file enables injecting the personal access token (PAT) into the `token` stanza if you prefer to store it in an environment variable rather than in the configuration file (recommended). 83 | 84 | The gallery application backend can be run as a standalone server app by executing: 85 | 86 | ```bash 87 | jupyterlab-gallery 88 | ``` 89 | 90 | To run it as a single-user server in the JupyterHub context use: 91 | 92 | ```bash 93 | jupyterhub-gallery 94 | ``` 95 | 96 | For additional help see the `GalleryManager` section in the output of: 97 | 98 | ```bash 99 | jupyterlab-gallery --help-all 100 | ``` 101 | 102 | ## Requirements 103 | 104 | - JupyterLab >= 4.0.0 105 | 106 | ## Install 107 | 108 | To install the extension, execute: 109 | 110 | ```bash 111 | pip install jupyterlab-gallery 112 | ``` 113 | 114 | ## Uninstall 115 | 116 | To remove the extension, execute: 117 | 118 | ```bash 119 | pip uninstall jupyterlab-gallery 120 | ``` 121 | 122 | ## Troubleshoot 123 | 124 | If you are seeing the frontend extension, but it is not working, check 125 | that the server extension is enabled: 126 | 127 | ```bash 128 | jupyter server extension list 129 | ``` 130 | 131 | If the server extension is installed and enabled, but you are not seeing 132 | the frontend extension, check the frontend extension is installed: 133 | 134 | ```bash 135 | jupyter labextension list 136 | ``` 137 | 138 | ## Contributing 139 | 140 | ### Development install 141 | 142 | Note: You will need NodeJS to build the extension package. 143 | 144 | The `jlpm` command is JupyterLab's pinned version of 145 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use 146 | `yarn` or `npm` in lieu of `jlpm` below. 147 | 148 | ```bash 149 | # Clone the repo to your local environment 150 | # Change directory to the jupyterlab-gallery directory 151 | # Install package in development mode 152 | pip install -e ".[test]" 153 | # Link your development version of the extension with JupyterLab 154 | jupyter labextension develop . --overwrite 155 | # Server extension must be manually installed in develop mode 156 | jupyter server extension enable jupyterlab_gallery 157 | # Rebuild extension Typescript source after making changes 158 | jlpm build 159 | ``` 160 | 161 | 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. 162 | 163 | ```bash 164 | # Watch the source directory in one terminal, automatically rebuilding when needed 165 | jlpm watch 166 | # Run JupyterLab in another terminal 167 | jupyter lab 168 | ``` 169 | 170 | 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). 171 | 172 | 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: 173 | 174 | ```bash 175 | jupyter lab build --minimize=False 176 | ``` 177 | 178 | ### Development uninstall 179 | 180 | ```bash 181 | # Server extension must be manually disabled in develop mode 182 | jupyter server extension disable jupyterlab_gallery 183 | pip uninstall jupyterlab-gallery 184 | ``` 185 | 186 | In development mode, you will also need to remove the symlink created by `jupyter labextension develop` 187 | command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions` 188 | folder is located. Then you can remove the symlink named `jupyterlab-gallery` within that folder. 189 | 190 | ### Testing the extension 191 | 192 | #### Server tests 193 | 194 | This extension is using [Pytest](https://docs.pytest.org/) for Python code testing. 195 | 196 | Install test dependencies (needed only once): 197 | 198 | ```sh 199 | pip install -e ".[test]" 200 | # Each time you install the Python package, you need to restore the front-end extension link 201 | jupyter labextension develop . --overwrite 202 | ``` 203 | 204 | To execute them, run: 205 | 206 | ```sh 207 | pytest -vv -r ap --cov jupyterlab-gallery 208 | ``` 209 | 210 | #### Frontend tests 211 | 212 | This extension is using [Jest](https://jestjs.io/) for JavaScript code testing. 213 | 214 | To execute them, execute: 215 | 216 | ```sh 217 | jlpm 218 | jlpm test 219 | ``` 220 | 221 | #### Integration tests 222 | 223 | This extension uses [Playwright](https://playwright.dev/docs/intro) for the integration tests (aka user level tests). 224 | More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab. 225 | 226 | More information are provided within the [ui-tests](./ui-tests/README.md) README. 227 | 228 | ### Packaging the extension 229 | 230 | See [RELEASE](RELEASE.md) 231 | -------------------------------------------------------------------------------- /src/gallery.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ReactWidget, showErrorMessage } from '@jupyterlab/apputils'; 3 | import { 4 | Button, 5 | UseSignal, 6 | folderIcon, 7 | downloadIcon, 8 | refreshIcon 9 | } from '@jupyterlab/ui-components'; 10 | import { Contents } from '@jupyterlab/services'; 11 | import { IStream, Stream, Signal } from '@lumino/signaling'; 12 | import { TranslationBundle } from '@jupyterlab/translation'; 13 | import { IExhibit } from './types'; 14 | import { IExhibitReply } from './types'; 15 | import { 16 | requestAPI, 17 | eventStream, 18 | IStreamMessage, 19 | IProgress, 20 | IEventStream 21 | } from './handler'; 22 | import { repositoryIcon } from './icons'; 23 | 24 | interface IActions { 25 | download(exhibit: IExhibit): Promise; 26 | open(exhibit: IExhibit): Promise; 27 | } 28 | 29 | export class GalleryWidget extends ReactWidget { 30 | constructor( 31 | protected options: { 32 | trans: TranslationBundle; 33 | openPath: (path: string) => void; 34 | fileChanged: Contents.IManager['fileChanged']; 35 | refreshFileBrowser: () => Promise; 36 | serverAPI: string; 37 | } 38 | ) { 39 | super(); 40 | const { trans, fileChanged } = options; 41 | this._trans = trans; 42 | this._status = trans.__('Gallery loading...'); 43 | this._actions = { 44 | open: async (exhibit: IExhibit) => { 45 | options.openPath(exhibit.localPath); 46 | // TODO: should it open the directory in the file browser? 47 | // should it also open a readme for this repository? 48 | }, 49 | download: async (exhibit: IExhibit) => { 50 | const done = new Promise((resolve, reject) => { 51 | const promiseResolver = (_: GalleryWidget, e: IStreamMessage) => { 52 | if (e.exhibit_id === exhibit.id) { 53 | if (e.phase === 'finished') { 54 | resolve(); 55 | this._stream.disconnect(promiseResolver); 56 | } else if (e.phase === 'error') { 57 | reject(); 58 | this._stream.disconnect(promiseResolver); 59 | } 60 | } 61 | }; 62 | this._stream.connect(promiseResolver); 63 | }); 64 | const xsrfTokenMatch = document.cookie.match('\\b_xsrf=([^;]*)\\b'); 65 | const args: Record = { 66 | exhibit_id: exhibit.id 67 | }; 68 | if (xsrfTokenMatch) { 69 | args['_xsrf'] = xsrfTokenMatch[1]; 70 | } 71 | await requestAPI('pull', this.options.serverAPI, { 72 | method: 'POST', 73 | body: JSON.stringify(args) 74 | }); 75 | await done; 76 | } 77 | }; 78 | // if user deletes a directory, reload the state 79 | fileChanged.connect((_, args) => { 80 | if (args.type === 'delete') { 81 | this._load(); 82 | } 83 | }); 84 | this._eventSource = eventStream( 85 | 'pull', 86 | message => { 87 | this._stream.emit(message); 88 | }, 89 | error => { 90 | // TODO 91 | console.error(error); 92 | }, 93 | this.options.serverAPI 94 | ); 95 | this._stream.connect(this._reloadOnFinish); 96 | void this._load(); 97 | } 98 | 99 | dispose() { 100 | super.dispose(); 101 | this._eventSource.close(); 102 | this._stream.disconnect(this._reloadOnFinish); 103 | } 104 | 105 | private _reloadOnFinish = async (_: GalleryWidget, e: IStreamMessage) => { 106 | if (e.phase === 'finished') { 107 | await this._load(); 108 | await this.options.refreshFileBrowser(); 109 | } 110 | }; 111 | 112 | private _eventSource: IEventStream; 113 | 114 | private async _load() { 115 | try { 116 | const data = await requestAPI( 117 | 'exhibits', 118 | this.options.serverAPI 119 | ); 120 | this.exhibits = data.exhibits; 121 | const allStatusesKnown = this.exhibits.every( 122 | exhibit => 123 | !exhibit.isCloned || typeof exhibit.updatesAvailable === 'boolean' 124 | ); 125 | if (!allStatusesKnown) { 126 | setTimeout(() => this._load(), 1000); 127 | } 128 | } catch (reason) { 129 | this._status = `jupyterlab_gallery server failed:\n${reason}`; 130 | } 131 | this.update(); 132 | } 133 | 134 | get exhibits(): IExhibit[] | null { 135 | return this._exhibits; 136 | } 137 | 138 | set exhibits(value: IExhibit[] | null) { 139 | this._exhibits = value; 140 | } 141 | 142 | update() { 143 | super.update(); 144 | this._update.emit(); 145 | } 146 | 147 | render(): JSX.Element { 148 | return ( 149 | 150 | {() => { 151 | if (this.exhibits) { 152 | return ( 153 | 159 | ); 160 | } 161 | return ( 162 |
{this._status}
163 | ); 164 | }} 165 |
166 | ); 167 | } 168 | private _trans: TranslationBundle; 169 | private _update = new Signal(this); 170 | private _exhibits: IExhibit[] | null = null; 171 | private _status: string; 172 | private _actions: IActions; 173 | private _stream: Stream = new Stream(this); 174 | } 175 | 176 | function Gallery(props: { 177 | exhibits: IExhibit[]; 178 | actions: IActions; 179 | progressStream: IStream; 180 | trans: TranslationBundle; 181 | }): JSX.Element { 182 | return ( 183 |
184 | {props.exhibits.map(exhibit => ( 185 | 192 | ))} 193 |
194 | ); 195 | } 196 | 197 | interface IProgressState extends IProgress { 198 | state?: 'error'; 199 | } 200 | 201 | function Exhibit(props: { 202 | exhibit: IExhibit; 203 | actions: IActions; 204 | progressStream: IStream; 205 | trans: TranslationBundle; 206 | }): JSX.Element { 207 | const { exhibit, actions } = props; 208 | const [progress, setProgress] = React.useState(null); 209 | const [progressMessage, setProgressMessage] = React.useState(''); 210 | 211 | React.useEffect(() => { 212 | const listenToStreams = (_: GalleryWidget, message: IStreamMessage) => { 213 | const exhibitId = message.exhibit_id; 214 | if (exhibitId !== exhibit.id) { 215 | return; 216 | } 217 | 218 | switch (message.phase) { 219 | case 'error': 220 | showErrorMessage( 221 | 'Could not download', 222 | message.output ?? 'Unknown error' 223 | ); 224 | break; 225 | case 'progress': 226 | setProgress(message.output); 227 | setProgressMessage(message.output.message); 228 | break; 229 | case 'finished': 230 | setProgress(null); 231 | break; 232 | default: 233 | console.warn('Unhandled message', message); 234 | break; 235 | } 236 | }; 237 | props.progressStream.connect(listenToStreams); 238 | return () => { 239 | props.progressStream.disconnect(listenToStreams); 240 | }; 241 | }); 242 | const updateStatusKnown = typeof exhibit.updatesAvailable === 'boolean'; 243 | return ( 244 |
245 |

{exhibit.title}

246 |
247 |
248 | {exhibit.icon ? ( 249 | {exhibit.title} 250 | ) : ( 251 | 252 | )} 253 |
254 |
255 | {!exhibit.isCloned ? ( 256 | 278 | ) : ( 279 | <> 280 | 289 | 321 | 322 | )} 323 |
324 |
325 |
326 | {progress ? ( 327 |
335 |
339 |
{progressMessage}
340 |
341 | ) : null} 342 |
{exhibit.description}
343 |
344 |
345 | ); 346 | } 347 | -------------------------------------------------------------------------------- /style/icons/md/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /jupyterlab_gallery/gitpuller.py: -------------------------------------------------------------------------------- 1 | # Based on https://github.com/jupyterhub/nbgitpuller/blob/main/nbgitpuller/handlers.py 2 | # which is distributed under BSD 3-Clause License 3 | # Copyright (c) 2017, YuviPanda, Peter Veerman 4 | # 5 | # Modified to allow: 6 | # - restricting which repositories can be cloned 7 | # - reconnecting to the event stream when refreshing the browser 8 | # - handling multiple waiting pulls 9 | from tornado import gen, web, locks 10 | import asyncio 11 | import logging 12 | import traceback 13 | 14 | import threading 15 | import json 16 | import os 17 | from queue import Queue, Empty 18 | from collections import defaultdict 19 | from typing import Optional, TypedDict 20 | 21 | import git 22 | from jupyter_server.base.handlers import JupyterHandler 23 | from nbgitpuller.pull import GitPuller 24 | from tornado.iostream import StreamClosedError 25 | 26 | from .git_utils import git_credentials 27 | 28 | 29 | class CloneProgress(git.RemoteProgress): 30 | def __init__(self): 31 | self.queue = Queue() 32 | self.max_stage = 0.01 33 | self.prev_stage = 0 34 | super().__init__() 35 | 36 | def update(self, op_code: int, cur_count, max_count=None, message=""): 37 | if op_code & git.RemoteProgress.BEGIN: 38 | new_stage = None 39 | if op_code & git.RemoteProgress.COUNTING: 40 | new_stage = 0.05 41 | elif op_code & git.RemoteProgress.COMPRESSING: 42 | new_stage = 0.10 43 | elif op_code & git.RemoteProgress.RECEIVING: 44 | new_stage = 0.90 45 | elif op_code & git.RemoteProgress.RESOLVING: 46 | new_stage = 1 47 | 48 | if new_stage: 49 | self.prev_stage = self.max_stage 50 | self.max_stage = new_stage 51 | 52 | if isinstance(cur_count, (int, float)) and isinstance(max_count, (int, float)): 53 | progress = self.prev_stage + cur_count / max_count * ( 54 | self.max_stage - self.prev_stage 55 | ) 56 | self.queue.put( 57 | Update( 58 | progress=progress, 59 | message=message, 60 | ) 61 | ) 62 | # self.queue.join() 63 | 64 | 65 | class ProgressGitPuller(GitPuller): 66 | def __init__( 67 | self, git_url, repo_dir, token: Optional[str], account: Optional[str], **kwargs 68 | ): 69 | self._token = token 70 | self._account = account 71 | # it will attempt to resolve default branch which requires credentials too 72 | with git_credentials(token=self._token, account=self._account): 73 | super().__init__(git_url, repo_dir, **kwargs) 74 | 75 | def initialize_repo(self): 76 | logging.info("Repo {} doesn't exist. Cloning...".format(self.repo_dir)) 77 | progress = CloneProgress() 78 | 79 | def clone_task(): 80 | with git_credentials(token=self._token, account=self._account): 81 | try: 82 | git.Repo.clone_from( 83 | self.git_url, 84 | self.repo_dir, 85 | branch=self.branch_name, 86 | depth=self.depth, 87 | progress=progress, 88 | ) 89 | except Exception as e: 90 | progress.queue.put(e) 91 | finally: 92 | progress.queue.put(None) 93 | 94 | threading.Thread(target=clone_task).start() 95 | # TODO: add configurable timeout 96 | # timeout = 60 97 | 98 | while True: 99 | item = progress.queue.get(True) # , timeout) 100 | if item is None: 101 | break 102 | yield item 103 | 104 | logging.info("Repo {} initialized".format(self.repo_dir)) 105 | 106 | def update(self): 107 | with git_credentials(token=self._token, account=self._account): 108 | yield from super().update() 109 | 110 | 111 | class Update(TypedDict): 112 | progress: float 113 | message: str 114 | 115 | 116 | class SyncHandlerBase(JupyterHandler): 117 | def __init__(self, *args, **kwargs): 118 | super().__init__(*args, **kwargs) 119 | 120 | if "pull_status_queues" not in self.settings: 121 | self.settings["pull_status_queues"] = defaultdict(Queue) 122 | 123 | if "last_message" not in self.settings: 124 | self.settings["last_message"] = {} 125 | 126 | # We use this lock to make sure that only one sync operation 127 | # can be happening at a time. Git doesn't like concurrent use! 128 | if "git_lock" not in self.settings: 129 | self.settings["git_lock"] = locks.Lock() 130 | 131 | if "enqueue_task" not in self.settings: 132 | task = asyncio.create_task(self._enqueue_messages()) 133 | self.settings["enqueue_task"] = task 134 | task.add_done_callback(lambda task: self.settings.pop("enqueue_task")) 135 | 136 | def get_login_url(self): 137 | # raise on failed auth, not redirect 138 | # can't redirect EventStream to login 139 | # same as Jupyter's APIHandler 140 | raise web.HTTPError(403) 141 | 142 | @property 143 | def git_lock(self): 144 | return self.settings["git_lock"] 145 | 146 | async def _pull( 147 | self, 148 | repo: str, 149 | targetpath: str, 150 | exhibit_id: int, 151 | token: Optional[str], 152 | account: Optional[str], 153 | branch: Optional[str], 154 | depth: Optional[int], 155 | ): 156 | q = self.settings["pull_status_queues"][exhibit_id] 157 | try: 158 | q.put_nowait(Update(progress=0.01, message="Waiting for a lock")) 159 | await self.git_lock.acquire(5) 160 | q.put_nowait(Update(progress=0.02, message="Lock acquired")) 161 | except gen.TimeoutError: 162 | q.put_nowait( 163 | gen.TimeoutError( 164 | "Another git operations is currently running, try again in a few minutes" 165 | ) 166 | ) 167 | return 168 | 169 | try: 170 | # The default working directory is the directory from which Jupyter 171 | # server is launched, which is not the same as the root notebook 172 | # directory assuming either --notebook-dir= is used from the 173 | # command line or c.NotebookApp.notebook_dir is set in the jupyter 174 | # configuration. This line assures that all repos are cloned 175 | # relative to server_root_dir/, 176 | # so that all repos are always in scope after cloning. Sometimes 177 | # server_root_dir will include things like `~` and so the path 178 | # must be expanded. 179 | repo_parent_dir = os.path.join( 180 | os.path.expanduser(self.settings["server_root_dir"]), 181 | os.getenv("NBGITPULLER_PARENTPATH", ""), 182 | ) 183 | repo_dir = os.path.join(repo_parent_dir, targetpath or repo.split("/")[-1]) 184 | 185 | gp = ProgressGitPuller( 186 | repo, 187 | repo_dir, 188 | branch=branch, 189 | depth=depth, 190 | parent=self.settings["nbapp"], 191 | # our additions 192 | token=token, 193 | account=account, 194 | ) 195 | 196 | def pull(): 197 | try: 198 | for update in gp.pull(): 199 | q.put_nowait(update) 200 | # Sentinel when we're done 201 | q.put_nowait(None) 202 | except Exception as e: 203 | raise e 204 | 205 | self.gp_thread = threading.Thread(target=pull) 206 | self.gp_thread.start() 207 | except Exception as e: 208 | q.put_nowait(e) 209 | finally: 210 | self.git_lock.release() 211 | 212 | async def emit(self, data: dict): 213 | serialized_data = json.dumps(data) 214 | self.write("data: {}\n\n".format(serialized_data)) 215 | await self.flush() 216 | 217 | async def _enqueue_messages(self): 218 | last_message = self.settings["last_message"] 219 | queues = self.settings["pull_status_queues"] 220 | while True: 221 | empty_queues = 0 222 | # copy to avoid error due to size change during iteration: 223 | queues_view = queues.copy() 224 | for exhibit_id, q in queues_view.items(): 225 | # try to consume next message 226 | try: 227 | progress = q.get_nowait() 228 | except Empty: 229 | empty_queues += 1 230 | continue 231 | 232 | if progress is None: 233 | msg = {"phase": "finished", "exhibit_id": exhibit_id} 234 | del self.settings["pull_status_queues"][exhibit_id] 235 | elif isinstance(progress, dict): 236 | msg = { 237 | "output": progress, 238 | "phase": "progress", 239 | "exhibit_id": exhibit_id, 240 | } 241 | elif isinstance(progress, Exception): 242 | msg = { 243 | "phase": "error", 244 | "exhibit_id": exhibit_id, 245 | "message": str(progress), 246 | "output": "\n".join( 247 | [ 248 | line.strip() 249 | for line in traceback.format_exception( 250 | type(progress), progress, progress.__traceback__ 251 | ) 252 | ] 253 | ), 254 | } 255 | else: 256 | msg = { 257 | "output": progress, 258 | "phase": "syncing", 259 | "exhibit_id": exhibit_id, 260 | } 261 | 262 | last_message[exhibit_id] = msg 263 | 264 | if empty_queues == len(queues_view): 265 | await gen.sleep(0.1) 266 | 267 | async def _stream(self): 268 | # We gonna send out event streams! 269 | self.set_header("content-type", "text/event-stream") 270 | self.set_header("cache-control", "no-cache") 271 | 272 | # https://bugzilla.mozilla.org/show_bug.cgi?id=833462 273 | await self.emit({"phase": "connected"}) 274 | 275 | last_message = self.settings["last_message"] 276 | last_message_sent = {} 277 | 278 | # stream new messages as they are put on respective queues 279 | while True: 280 | messages_view = last_message.copy() 281 | unchanged = 0 282 | for exhibit_id, msg in messages_view.items(): 283 | # emit an update if anything changed 284 | if last_message_sent.get(exhibit_id) == msg: 285 | unchanged += 1 286 | continue 287 | last_message_sent[exhibit_id] = msg 288 | try: 289 | await self.emit(msg) 290 | except StreamClosedError as e: 291 | # this is expected to happen whenever client closes (e.g. user 292 | # closes the browser or refreshes the tab with JupterLab) 293 | if e.real_error: 294 | self.warn.info( 295 | f"git puller stream got closed with error {e.real_error}" 296 | ) 297 | else: 298 | self.log.info("git puller stream closed") 299 | # return to stop reading messages, so that the next 300 | # client who connects can consume them 301 | return 302 | if unchanged == len(messages_view): 303 | await gen.sleep(0.1) 304 | --------------------------------------------------------------------------------