├── .git-blame-ignore-revs ├── .github ├── codeql │ └── codeql-config.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── downstream.yml │ ├── enforce-label.yml │ ├── fix-license-header.yml │ ├── prep-release.yml │ ├── publish-changelog.yml │ ├── publish-release.yml │ └── tests.yml ├── .gitignore ├── .licenserc.yaml ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASE.md ├── docs ├── Makefile ├── make.bat └── source │ ├── api │ ├── app.rst │ ├── config.rst │ ├── handlers.rst │ ├── index.rst │ ├── process.rst │ ├── rest.rst │ └── spec.rst │ ├── conf.py │ └── index.rst ├── jupyterlab_server ├── __init__.py ├── __main__.py ├── _version.py ├── app.py ├── config.py ├── handlers.py ├── licenses_app.py ├── licenses_handler.py ├── listings_handler.py ├── process.py ├── process_app.py ├── py.typed ├── pytest_plugin.py ├── rest-api.yml ├── server.py ├── settings_handler.py ├── settings_utils.py ├── spec.py ├── templates │ ├── 403.html │ ├── error.html │ └── index.html ├── test_data │ ├── app-settings │ │ └── overrides.json │ ├── schemas │ │ └── @jupyterlab │ │ │ ├── apputils-extension │ │ │ └── themes.json │ │ │ ├── codemirror-extension │ │ │ └── commands.json │ │ │ ├── shortcuts-extension │ │ │ ├── package.json.orig │ │ │ └── plugin.json │ │ │ ├── translation-extension │ │ │ └── plugin.json │ │ │ └── unicode-extension │ │ │ └── plugin.json │ └── workspaces │ │ ├── foo-2c26.jupyterlab-workspace │ │ └── foo-92dd.jupyterlab-workspace ├── test_utils.py ├── themes_handler.py ├── translation_utils.py ├── translations_handler.py ├── workspaces_app.py └── workspaces_handler.py ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── test_config.py ├── test_labapp.py ├── test_licenses_api.py ├── test_listings_api.py ├── test_process.py ├── test_settings_api.py ├── test_themes_api.py ├── test_translation_api.py ├── test_translation_utils.py ├── test_workspaces_api.py ├── test_workspaces_app.py └── translations ├── jupyterlab-language-pack-es_CO ├── MANIFEST.in ├── jupyterlab_language_pack_es_CO │ ├── __init__.py │ └── locale │ │ └── es_CO │ │ └── LC_MESSAGES │ │ ├── jupyterlab.json │ │ ├── jupyterlab.mo │ │ ├── jupyterlab.po │ │ ├── jupyterlab_some_package.json │ │ ├── jupyterlab_some_package.mo │ │ └── jupyterlab_some_package.po └── setup.py └── jupyterlab-some-package ├── MANIFEST.in ├── jupyterlab_some_package ├── __init__.py └── locale │ └── es_CO │ └── LC_MESSAGES │ └── jupyterlab_some_package.json └── setup.py /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Initial pre-commit reformat: https://github.com/jupyterlab/jupyterlab_server/pull/257 2 | 1b0e2577b123e3cf34e6e9490e0604ad4c8ba596 3 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: "My CodeQL config" 2 | 3 | queries: 4 | - uses: security-and-quality 5 | 6 | paths-ignore: 7 | - docs 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | actions: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "pip" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | groups: 16 | actions: 17 | patterns: 18 | - "*" 19 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [main] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [main] 14 | schedule: 15 | - cron: "0 8 * * 3" 16 | 17 | permissions: 18 | security-events: write 19 | 20 | jobs: 21 | analyze: 22 | name: Analyze 23 | runs-on: ubuntu-20.04 24 | 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | # Override automatic language detection by changing the below list 29 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 30 | language: ["python"] 31 | # Learn more... 32 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 33 | 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v4 37 | with: 38 | # We must fetch at least the immediate parents so that if this is 39 | # a pull request then we can checkout the head. 40 | fetch-depth: 2 41 | 42 | # Initializes the CodeQL tools for scanning. 43 | - name: Initialize CodeQL 44 | uses: github/codeql-action/init@v3 45 | with: 46 | languages: ${{ matrix.language }} 47 | config-file: ./.github/codeql/codeql-config.yml 48 | 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v3 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | -------------------------------------------------------------------------------- /.github/workflows/downstream.yml: -------------------------------------------------------------------------------- 1 | name: Test downstream projects 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | 8 | jobs: 9 | downstream: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 15 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Base Setup 18 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 19 | 20 | - name: Test jupyterlab 21 | uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 22 | with: 23 | package_name: jupyterlab 24 | test_command: "python -m jupyterlab.browser_check --no-browser-test" 25 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/fix-license-header.yml: -------------------------------------------------------------------------------- 1 | name: Fix License Headers 2 | 3 | on: 4 | pull_request_target: 5 | 6 | jobs: 7 | header-license-fix: 8 | runs-on: ubuntu-20.04 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | - name: Configure git to use https 21 | run: git config --global hub.protocol https 22 | 23 | - name: Checkout the branch from the PR that triggered the job 24 | run: gh pr checkout ${{ github.event.pull_request.number }} 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Fix License Header 29 | # pin to include https://github.com/apache/skywalking-eyes/pull/168 30 | uses: apache/skywalking-eyes/header@ed436a5593c63a25f394ea29da61b0ac3731a9fe 31 | with: 32 | mode: fix 33 | 34 | - name: List files changed 35 | id: files-changed 36 | shell: bash -l {0} 37 | run: | 38 | set -ex 39 | export CHANGES=$(git status --porcelain | tee modified.log | wc -l) 40 | cat modified.log 41 | # Remove the log otherwise it will be committed 42 | rm modified.log 43 | 44 | echo "N_CHANGES=${CHANGES}" >> $GITHUB_OUTPUT 45 | 46 | git diff 47 | 48 | - name: Commit any changes 49 | if: steps.files-changed.outputs.N_CHANGES != '0' 50 | shell: bash -l {0} 51 | run: | 52 | git config user.name "github-actions[bot]" 53 | git config user.email "github-actions[bot]@users.noreply.github.com" 54 | 55 | git pull --no-tags 56 | 57 | git add * 58 | git commit -m "Automatic application of license header" 59 | 60 | git config push.default upstream 61 | git push 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | -------------------------------------------------------------------------------- /.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-changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Publish Changelog" 2 | on: 3 | release: 4 | types: [published] 5 | 6 | workflow_dispatch: 7 | inputs: 8 | branch: 9 | description: "The branch to target" 10 | required: false 11 | 12 | jobs: 13 | publish_changelog: 14 | runs-on: ubuntu-latest 15 | environment: release 16 | steps: 17 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 18 | 19 | - uses: actions/create-github-app-token@v1 20 | id: app-token 21 | with: 22 | app-id: ${{ vars.APP_ID }} 23 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 24 | 25 | - name: Publish changelog 26 | id: publish-changelog 27 | uses: jupyter-server/jupyter_releaser/.github/actions/publish-changelog@v2 28 | with: 29 | token: ${{ steps.app-token.outputs.token }} 30 | branch: ${{ github.event.inputs.branch }} 31 | 32 | - name: "** Next Step **" 33 | run: | 34 | echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" 35 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | schedule: 8 | - cron: "0 8 * * *" 9 | 10 | defaults: 11 | run: 12 | shell: bash -eux {0} 13 | 14 | jobs: 15 | test: 16 | name: ${{ matrix.os }} ${{ matrix.python-version }} 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | os: [ubuntu-latest, windows-latest, macos-latest] 22 | python-version: ["3.8", "3.12"] 23 | include: 24 | - os: windows-latest 25 | python-version: "3.9" 26 | - os: ubuntu-latest 27 | python-version: "pypy-3.9" 28 | - os: ubuntu-latest 29 | python-version: "3.10" 30 | - os: macos-latest 31 | python-version: "3.11" 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | - name: Base Setup 36 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 37 | - name: Run the tests 38 | if: ${{ !startsWith(matrix.python-version, 'pypy') }} 39 | run: hatch run cov:test 40 | - name: Run the tests on pypy 41 | if: ${{ startsWith(matrix.python-version, 'pypy') }} 42 | run: hatch run cov:nowarn 43 | - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 44 | 45 | coverage_report: 46 | name: Combine & check coverage 47 | needs: test 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: jupyterlab/maintainer-tools/.github/actions/report-coverage@v1 52 | with: 53 | fail_under: 80 54 | 55 | test_lint: 56 | name: Test Lint 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v4 60 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 61 | - name: Run Linters 62 | run: | 63 | hatch run typing:test 64 | hatch run lint:build 65 | pipx run interrogate -v . 66 | pipx run doc8 --max-line-length=200 67 | 68 | docs: 69 | runs-on: ubuntu-latest 70 | steps: 71 | - uses: actions/checkout@v4 72 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 73 | - run: hatch run docs:build 74 | 75 | check_release: 76 | runs-on: ubuntu-latest 77 | steps: 78 | - name: Checkout 79 | uses: actions/checkout@v4 80 | - name: Base Setup 81 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 82 | - name: Install Dependencies 83 | run: | 84 | pip install -e . 85 | - name: Check Release 86 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 87 | with: 88 | token: ${{ secrets.GITHUB_TOKEN }} 89 | 90 | test_miniumum_versions: 91 | name: Test Minimum Versions 92 | timeout-minutes: 20 93 | runs-on: ubuntu-latest 94 | steps: 95 | - uses: actions/checkout@v4 96 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 97 | with: 98 | dependency_type: minimum 99 | - name: Run the unit tests 100 | run: | 101 | hatch -vv run test:nowarn || hatch run test:nowarn --lf 102 | 103 | test_prereleases: 104 | name: Test Prereleases 105 | runs-on: ubuntu-latest 106 | timeout-minutes: 20 107 | steps: 108 | - uses: actions/checkout@v4 109 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 110 | with: 111 | dependency_type: pre 112 | - name: Run the tests 113 | run: | 114 | hatch run test:nowarn || hatch run test:nowarn --lf 115 | 116 | make_sdist: 117 | name: Make SDist 118 | runs-on: ubuntu-latest 119 | timeout-minutes: 10 120 | steps: 121 | - uses: actions/checkout@v4 122 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 123 | - uses: jupyterlab/maintainer-tools/.github/actions/make-sdist@v1 124 | 125 | test_sdist: 126 | runs-on: ubuntu-latest 127 | needs: [make_sdist] 128 | name: Install from SDist and Test 129 | timeout-minutes: 20 130 | steps: 131 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 132 | - uses: jupyterlab/maintainer-tools/.github/actions/test-sdist@v1 133 | with: 134 | test_command: hatch run test:test || hatch run test:test --lf 135 | - run: 136 | # Ensure that the pytest plugin is importable. 137 | python -c "from jupyterlab_server import pytest_plugin" 138 | 139 | check_links: 140 | runs-on: ubuntu-latest 141 | timeout-minutes: 10 142 | steps: 143 | - uses: actions/checkout@v4 144 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 145 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 146 | 147 | tests_check: # This job does nothing and is only used for the branch protection 148 | if: always() 149 | needs: 150 | - coverage_report 151 | - test_lint 152 | - check_links 153 | - docs 154 | - check_release 155 | - test_miniumum_versions 156 | - test_prereleases 157 | - test_sdist 158 | runs-on: ubuntu-latest 159 | steps: 160 | - name: Decide whether the needed jobs succeeded or failed 161 | uses: re-actors/alls-green@release/v1 162 | with: 163 | jobs: ${{ toJSON(needs) }} 164 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | build 3 | dist 4 | _build 5 | docs/man/*.gz 6 | .cache 7 | *.py[co] 8 | __pycache__ 9 | *.egg-info 10 | *~ 11 | *.bak 12 | .ipynb_checkpoints 13 | .tox 14 | .DS_Store 15 | \#*# 16 | .#* 17 | .coverage 18 | .cache 19 | .pytest_cache 20 | 21 | # generated changelog 22 | docs/source/changelog.md 23 | 24 | # generated cli docs 25 | docs/source/api/app-config.rst 26 | 27 | # jetbrains IDE stuff 28 | *.iml 29 | .idea/ 30 | 31 | # ms IDE stuff 32 | *.code-workspace 33 | .history 34 | .vscode 35 | -------------------------------------------------------------------------------- /.licenserc.yaml: -------------------------------------------------------------------------------- 1 | header: 2 | license: 3 | spdx-id: BSD-3-Clause 4 | copyright-owner: Jupyter Development Team 5 | software-name: JupyterLab 6 | content: | 7 | Copyright (c) Jupyter Development Team. 8 | Distributed under the terms of the Modified BSD License. 9 | 10 | paths-ignore: 11 | - "**/*.json" 12 | - "**/*.md" 13 | - "**/*.po" 14 | - "**/*.svg" 15 | - "**/*.yml" 16 | - "**/*.yaml" 17 | - "**/.*" 18 | - "**/MANIFEST.in" 19 | - "LICENSE" 20 | - "jupyterlab_server/test_data" 21 | - "jupyterlab_server/py.typed" 22 | 23 | comment: on-failure 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autoupdate_commit_msg: "chore: update pre-commit hooks" 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: check-case-conflict 10 | - id: check-ast 11 | - id: check-docstring-first 12 | - id: check-executables-have-shebangs 13 | - id: check-added-large-files 14 | - id: check-case-conflict 15 | - id: check-merge-conflict 16 | - id: check-json 17 | - id: check-toml 18 | - id: check-yaml 19 | - id: debug-statements 20 | - id: end-of-file-fixer 21 | - id: trailing-whitespace 22 | 23 | - repo: https://github.com/python-jsonschema/check-jsonschema 24 | rev: 0.27.4 25 | hooks: 26 | - id: check-github-workflows 27 | 28 | - repo: https://github.com/executablebooks/mdformat 29 | rev: 0.7.17 30 | hooks: 31 | - id: mdformat 32 | additional_dependencies: 33 | [mdformat-gfm, mdformat-frontmatter, mdformat-footnote] 34 | 35 | - repo: https://github.com/pre-commit/mirrors-prettier 36 | rev: "v4.0.0-alpha.8" 37 | hooks: 38 | - id: prettier 39 | types_or: [yaml, json] 40 | 41 | - repo: https://github.com/adamchainz/blacken-docs 42 | rev: "1.16.0" 43 | hooks: 44 | - id: blacken-docs 45 | additional_dependencies: [black==23.7.0] 46 | 47 | - repo: https://github.com/codespell-project/codespell 48 | rev: "v2.2.6" 49 | hooks: 50 | - id: codespell 51 | args: ["-L", "sur,nd"] 52 | 53 | - repo: https://github.com/pre-commit/pygrep-hooks 54 | rev: "v1.10.0" 55 | hooks: 56 | - id: rst-backticks 57 | - id: rst-directive-colons 58 | - id: rst-inline-touching-normal 59 | 60 | - repo: https://github.com/pre-commit/mirrors-mypy 61 | rev: "v1.8.0" 62 | hooks: 63 | - id: mypy 64 | files: "^jupyterlab_server" 65 | stages: [manual] 66 | args: ["--install-types", "--non-interactive"] 67 | additional_dependencies: 68 | [ 69 | "traitlets>=5.3", 70 | "jupyter_server>=2.10.1", 71 | "openapi_core", 72 | "json5", 73 | "pytest", 74 | "werkzeug", 75 | "ruamel.yaml", 76 | "importlib_metadata", 77 | ] 78 | 79 | - repo: https://github.com/astral-sh/ruff-pre-commit 80 | rev: v0.2.0 81 | hooks: 82 | - id: ruff 83 | types_or: [python, jupyter] 84 | args: ["--fix", "--show-fixes"] 85 | - id: ruff-format 86 | types_or: [python, jupyter] 87 | 88 | - repo: https://github.com/scientific-python/cookie 89 | rev: "2024.01.24" 90 | hooks: 91 | - id: sp-repo-review 92 | additional_dependencies: ["repo-review[cli]"] 93 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: docs/source/conf.py 10 | 11 | python: 12 | install: 13 | # install itself with pip install . 14 | - method: pip 15 | path: . 16 | extra_requirements: 17 | - docs 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you're reading this section, you're probably interested in contributing to 4 | Jupyter. Welcome and thanks for your interest in contributing! 5 | 6 | Please take a look at the Contributor documentation, familiarize yourself with 7 | using the Jupyter Server, and introduce yourself on the mailing list and 8 | share what area of the project you are interested in working on. 9 | 10 | For general documentation about contributing to Jupyter projects, see the 11 | [Project Jupyter Contributor Documentation](https://jupyter.readthedocs.io/en/latest/contributing/content-contributor.html). 12 | 13 | ## Development Install 14 | 15 | ```shell 16 | git clone https://github.com/jupyterlab/jupyterlab_server.git 17 | cd jupyterlab_server 18 | pip install -e . 19 | ``` 20 | 21 | ## Testing 22 | 23 | It is probably best to create a virtual environment to create a local test setup. There are multiple tools for creating a Python virtual environment out there from which you can choose the one you like best. 24 | 25 | To create a local test setup run the following commands (inside your virtual environment, if you chose to create one): 26 | 27 | ```shell 28 | git clone https://github.com/jupyterlab/jupyterlab_server.git 29 | cd jupyterlab_server 30 | pip install -e .[test] # install test dependencies 31 | hatch run cov:test # optionally, arguments of the pytest CLI can be added 32 | ``` 33 | 34 | ## Code Styling 35 | 36 | `jupyterlab_server` has adopted automatic code formatting so you shouldn't 37 | need to worry too much about your code style. 38 | As long as your code is valid, 39 | the pre-commit hook should take care of how it should look. 40 | `pre-commit` and its associated hooks will automatically be installed when 41 | you run `pip install -e ".[test]"` 42 | 43 | To install `pre-commit` manually, run the following:: 44 | 45 | ```shell 46 | pip install pre-commit 47 | pre-commit install 48 | ``` 49 | 50 | You can invoke the pre-commit hook by hand at any time with: 51 | 52 | ```shell 53 | pre-commit run 54 | ``` 55 | 56 | which should run any autoformatting on your code 57 | and tell you about any errors it couldn't fix automatically. 58 | You may also install [black integration](https://github.com/psf/black#editor-integration) 59 | into your text editor to format code automatically. 60 | 61 | If you have already committed files before setting up the pre-commit 62 | hook with `pre-commit install`, you can fix everything up using 63 | `pre-commit run --all-files`. You need to make the fixing commit 64 | yourself after that. 65 | 66 | Some of the hooks only run on CI by default, but you can invoke them by 67 | running with the `--hook-stage manual` argument. 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2017, Project Jupyter Contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jupyterlab server 2 | 3 | [![Build Status](https://github.com/jupyterlab/jupyterlab_server/workflows/Tests/badge.svg?branch=master)](https://github.com/jupyterlab/jupyterlab_server/actions?query=branch%3Amaster+workflow%3A%22Tests%22) 4 | [![Documentation Status](https://readthedocs.org/projects/jupyterlab-server/badge/?version=stable)](http://jupyterlab-server.readthedocs.io/en/stable/) 5 | 6 | ## Motivation 7 | 8 | JupyterLab Server sits between JupyterLab and Jupyter Server, and provides a 9 | set of REST API handlers and utilities that are used by JupyterLab. It is a separate project in order to 10 | accommodate creating JupyterLab-like applications from a more limited scope. 11 | 12 | ## Install 13 | 14 | `pip install jupyterlab_server` 15 | 16 | To include optional `openapi` dependencies, use: 17 | 18 | `pip install jupyterlab_server[openapi]` 19 | 20 | To include optional `pytest_plugin` dependencies, use: 21 | 22 | `pip install jupyterlab_server[test]` 23 | 24 | ## Usage 25 | 26 | See the full documentation for [API docs](https://jupyterlab-server.readthedocs.io/en/stable/api/index.html) and [REST endpoint descriptions](https://jupyterlab-server.readthedocs.io/en/stable/api/rest.html). 27 | 28 | ## Extending the Application 29 | 30 | Subclass the `LabServerApp` and provide additional traits and handlers as appropriate for your application. 31 | 32 | ## Contribution 33 | 34 | Please see `CONTRIBUTING.md` for details. 35 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a JupyterLab Server Release 2 | 3 | ## Using `jupyter_releaser` 4 | 5 | The recommended way to make a release is to use [`jupyter_releaser`](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html) 6 | 7 | ## Manual Release 8 | 9 | ### Set up 10 | 11 | ``` 12 | pip install pipx 13 | git pull origin $(git branch --show-current) 14 | git clean -dffx 15 | ``` 16 | 17 | ### Update the version and apply the tag 18 | 19 | ``` 20 | echo "Enter new version" 21 | read script_version 22 | pipx run hatch version ${script_version} 23 | git tag -a ${script_version} -m ${script_version} 24 | ``` 25 | 26 | ### Build the artifacts 27 | 28 | ``` 29 | rm -rf dist 30 | pipx run build . 31 | ``` 32 | 33 | ### Publish the artifacts to pypi 34 | 35 | ``` 36 | pipx run twine check dist/* 37 | pipx run twine upload dist/* 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | # Minimal makefile for Sphinx documentation 5 | # 6 | 7 | # You can set these variables from the command line, and also 8 | # from the environment for the first two. 9 | SPHINXOPTS ?= 10 | SPHINXBUILD ?= sphinx-build 11 | SOURCEDIR = source 12 | BUILDDIR = build 13 | 14 | # Put it first so that "make" without argument is like "make help". 15 | help: 16 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 17 | 18 | .PHONY: help Makefile 19 | 20 | # Catch-all target: route all unknown targets to Sphinx using the new 21 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 22 | %: Makefile 23 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 24 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | rem Copyright (c) Jupyter Development Team. 2 | rem Distributed under the terms of the Modified BSD License. 3 | 4 | @ECHO OFF 5 | 6 | pushd %~dp0 7 | 8 | REM Command file for Sphinx documentation 9 | 10 | if "%SPHINXBUILD%" == "" ( 11 | set SPHINXBUILD=sphinx-build 12 | ) 13 | set SOURCEDIR=source 14 | set BUILDDIR=build 15 | 16 | if "%1" == "" goto help 17 | 18 | %SPHINXBUILD% >NUL 2>NUL 19 | if errorlevel 9009 ( 20 | echo. 21 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 22 | echo.installed, then set the SPHINXBUILD environment variable to point 23 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 24 | echo.may add the Sphinx directory to PATH. 25 | echo. 26 | echo.If you don't have Sphinx installed, grab it from 27 | echo.http://sphinx-doc.org/ 28 | exit /b 1 29 | ) 30 | 31 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 32 | goto end 33 | 34 | :help 35 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 36 | 37 | :end 38 | popd 39 | -------------------------------------------------------------------------------- /docs/source/api/app.rst: -------------------------------------------------------------------------------- 1 | .. Copyright (c) Jupyter Development Team. 2 | .. Distributed under the terms of the Modified BSD License. 3 | 4 | =========== 5 | Application 6 | =========== 7 | 8 | Module: :mod:`jupyterlab_server.app` 9 | ==================================== 10 | 11 | .. automodule:: jupyterlab_server.app 12 | 13 | .. currentmodule:: jupyterlab_server.app 14 | 15 | :class:`LabServerApp` 16 | --------------------- 17 | 18 | .. autoconfigurable:: LabServerApp 19 | -------------------------------------------------------------------------------- /docs/source/api/config.rst: -------------------------------------------------------------------------------- 1 | .. Copyright (c) Jupyter Development Team. 2 | .. Distributed under the terms of the Modified BSD License. 3 | 4 | ====== 5 | Config 6 | ====== 7 | 8 | Module: :mod:`jupyterlab_server.config` 9 | ======================================= 10 | 11 | .. automodule:: jupyterlab_server.config 12 | 13 | .. currentmodule:: jupyterlab_server.config 14 | 15 | .. autofunction:: get_package_url 16 | 17 | .. autofunction:: get_federated_extensions 18 | 19 | .. autofunction:: get_static_page_config 20 | 21 | .. autofunction:: get_page_config 22 | 23 | .. autofunction:: write_page_config 24 | 25 | :class:`LabConfig` 26 | --------------------- 27 | 28 | .. autoconfigurable:: LabConfig 29 | -------------------------------------------------------------------------------- /docs/source/api/handlers.rst: -------------------------------------------------------------------------------- 1 | .. Copyright (c) Jupyter Development Team. 2 | .. Distributed under the terms of the Modified BSD License. 3 | 4 | ======== 5 | Handlers 6 | ======== 7 | 8 | Module: :mod:`jupyterlab_server.handlers` 9 | ========================================= 10 | 11 | .. automodule:: jupyterlab_server.handlers 12 | 13 | .. currentmodule:: jupyterlab_server.handlers 14 | 15 | .. autoclass:: LabHandler 16 | :members: 17 | 18 | .. autofunction:: add_handlers 19 | 20 | .. autofunction:: is_url 21 | 22 | 23 | Module: :mod:`jupyterlab_server.listings_handler` 24 | ================================================= 25 | 26 | .. automodule:: jupyterlab_server.listings_handler 27 | 28 | .. currentmodule:: jupyterlab_server.listings_handler 29 | 30 | .. autoclass:: ListingsHandler 31 | :members: 32 | 33 | .. autofunction:: fetch_listings 34 | 35 | 36 | Module: :mod:`jupyterlab_server.settings_handler` 37 | ================================================= 38 | 39 | .. automodule:: jupyterlab_server.settings_handler 40 | 41 | .. currentmodule:: jupyterlab_server.settings_handler 42 | 43 | .. autoclass:: SettingsHandler 44 | :members: 45 | 46 | .. autofunction:: get_settings 47 | 48 | 49 | Module: :mod:`jupyterlab_server.themes_handler` 50 | ================================================= 51 | 52 | .. automodule:: jupyterlab_server.themes_handler 53 | 54 | .. currentmodule:: jupyterlab_server.themes_handler 55 | 56 | .. autoclass:: ThemesHandler 57 | :members: 58 | 59 | 60 | Module: :mod:`jupyterlab_server.translations_handler` 61 | ===================================================== 62 | 63 | .. automodule:: jupyterlab_server.translations_handler 64 | 65 | .. currentmodule:: jupyterlab_server.translations_handler 66 | 67 | .. autoclass:: TranslationsHandler 68 | :members: 69 | 70 | 71 | Module: :mod:`jupyterlab_server.workspaces_handler` 72 | ===================================================== 73 | 74 | .. automodule:: jupyterlab_server.workspaces_handler 75 | 76 | .. currentmodule:: jupyterlab_server.workspaces_handler 77 | 78 | .. autoclass:: WorkspacesHandler 79 | :members: 80 | 81 | .. autofunction:: slugify 82 | -------------------------------------------------------------------------------- /docs/source/api/index.rst: -------------------------------------------------------------------------------- 1 | .. Copyright (c) Jupyter Development Team. 2 | .. Distributed under the terms of the Modified BSD License. 3 | 4 | --------------------- 5 | JupyterLab Server API 6 | --------------------- 7 | 8 | JupyterLab Server API Reference: 9 | 10 | .. toctree:: 11 | :maxdepth: 1 12 | :caption: Contents: 13 | 14 | app-config 15 | app 16 | config 17 | handlers 18 | process 19 | rest 20 | spec 21 | -------------------------------------------------------------------------------- /docs/source/api/process.rst: -------------------------------------------------------------------------------- 1 | .. Copyright (c) Jupyter Development Team. 2 | .. Distributed under the terms of the Modified BSD License. 3 | 4 | ======= 5 | Process 6 | ======= 7 | 8 | Module: :mod:`jupyterlab_server.process` 9 | ======================================== 10 | 11 | .. automodule:: jupyterlab_server.process 12 | 13 | .. currentmodule:: jupyterlab_server.process 14 | 15 | .. autoclass:: Process 16 | :members: 17 | 18 | .. autoclass:: WatchHelper 19 | :members: 20 | 21 | .. autofunction:: which 22 | 23 | 24 | Module: :mod:`jupyterlab_server.process_app` 25 | ============================================ 26 | 27 | .. automodule:: jupyterlab_server.process_app 28 | 29 | .. currentmodule:: jupyterlab_server.process_app 30 | 31 | :class:`ProcessApp` 32 | ------------------- 33 | 34 | .. autoconfigurable:: ProcessApp 35 | -------------------------------------------------------------------------------- /docs/source/api/rest.rst: -------------------------------------------------------------------------------- 1 | .. Copyright (c) Jupyter Development Team. 2 | .. Distributed under the terms of the Modified BSD License. 3 | 4 | -------- 5 | REST API 6 | -------- 7 | 8 | The same JupyterLab Server API spec, as found here, is available in an interactive form 9 | `here (on swagger's petstore) `__. 10 | The `OpenAPI Initiative`_ (fka Swagger™) is a project used to describe 11 | and document RESTful APIs. 12 | 13 | .. openapi:: ../../../jupyterlab_server/rest-api.yml 14 | :examples: 15 | 16 | 17 | .. _OpenAPI Initiative: https://www.openapis.org/ 18 | -------------------------------------------------------------------------------- /docs/source/api/spec.rst: -------------------------------------------------------------------------------- 1 | .. Copyright (c) Jupyter Development Team. 2 | .. Distributed under the terms of the Modified BSD License. 3 | 4 | ============= 5 | OpenAPI Specs 6 | ============= 7 | 8 | Module: :mod:`jupyterlab_server.spec` 9 | ===================================== 10 | 11 | .. automodule:: jupyterlab_server.spec 12 | 13 | .. currentmodule:: jupyterlab_server.spec 14 | 15 | .. autofunction:: get_openapi_spec 16 | 17 | .. autofunction:: get_openapi_spec_dict 18 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | # Configuration file for the Sphinx documentation builder. 5 | # 6 | # This file only contains a selection of the most common options. For a full 7 | # list see the documentation: 8 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 9 | 10 | # -- Path setup -------------------------------------------------------------- 11 | 12 | # If extensions (or modules to document with autodoc) are in another directory, 13 | # add these directories to sys.path here. If the directory is relative to the 14 | # documentation root, use os.path.abspath to make it absolute, like shown here. 15 | # 16 | # import os 17 | # import sys 18 | # sys.path.insert(0, os.path.abspath('.')) 19 | import os.path as osp 20 | import shutil 21 | import sys 22 | 23 | HERE = osp.abspath(osp.dirname(__file__)) 24 | sys.path.insert(0, osp.join(HERE, "..", "..")) 25 | 26 | from jupyterlab_server import LabServerApp, _version # noqa: E402 27 | 28 | # -- Project information ----------------------------------------------------- 29 | 30 | project = "JupyterLab Server" 31 | copyright = "2021, Project Jupyter" 32 | author = "Project Jupyter" 33 | 34 | # The short X.Y version. 35 | version = "%i.%i" % _version.version_info[:2] 36 | # The full version, including alpha/beta/rc tags. 37 | release = _version.__version__ 38 | 39 | # -- General configuration --------------------------------------------------- 40 | 41 | # Add any Sphinx extension module names here, as strings. They can be 42 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 43 | # ones. 44 | extensions = [ 45 | "myst_parser", 46 | "autodoc_traits", 47 | "sphinx.ext.autodoc", 48 | "sphinx.ext.intersphinx", 49 | "sphinxcontrib.openapi", 50 | "sphinx.ext.napoleon", 51 | "sphinx.ext.mathjax", 52 | "sphinx_copybutton", 53 | ] 54 | 55 | try: 56 | import enchant # noqa: F401 57 | 58 | extensions += ["sphinxcontrib.spelling"] 59 | except ImportError: 60 | pass 61 | 62 | 63 | myst_enable_extensions = ["html_image"] 64 | 65 | # Add any paths that contain templates here, relative to this directory. 66 | templates_path = ["_templates"] 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | # This pattern also affects html_static_path and html_extra_path. 71 | exclude_patterns = [] 72 | 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | 76 | # The theme to use for HTML and HTML Help pages. See the documentation for 77 | # a list of builtin themes. 78 | # 79 | # 80 | html_theme = "pydata_sphinx_theme" 81 | 82 | # Add an Edit this Page button 83 | html_theme_options = { 84 | "use_edit_page_button": True, 85 | "navigation_with_keys": False, 86 | } 87 | 88 | # Output for github to be used in links 89 | html_context = { 90 | "github_user": "jupyterlab", # Username 91 | "github_repo": "jupyterlab_server", # Repo name 92 | "github_version": "master", # Version 93 | "doc_path": "/docs/source/", # Path in the checkout to the docs root 94 | } 95 | 96 | # This option generates errors when methods do not have docstrings, 97 | # so disable 98 | numpydoc_show_class_members = False 99 | 100 | config_header = """\ 101 | .. _api-full-config: 102 | 103 | 104 | Config file and command line options 105 | ==================================== 106 | 107 | The JupyterLab Server can be run with a variety of command line arguments. 108 | A list of available options can be found below in the :ref:`options section 109 | `. 110 | 111 | Defaults for these options can also be set by creating a file named 112 | ``jupyter_jupyterlab_server_config.py`` in your Jupyter folder. The Jupyter 113 | folder is in your home directory, ``~/.jupyter``. 114 | 115 | To create a ``jupyter_jupyterlab_server_config.py`` file, with all the defaults 116 | commented out, you can use the following command line:: 117 | 118 | $ python -m jupyterlab_server --generate-config 119 | 120 | 121 | .. _options: 122 | 123 | Options 124 | ------- 125 | 126 | This list of options can be generated by running the following and hitting 127 | enter:: 128 | 129 | $ python -m jupyterlab_server --help-all 130 | 131 | """ 132 | 133 | 134 | def setup(app): # noqa: ARG001 135 | dest = osp.join(HERE, "changelog.md") 136 | shutil.copy(osp.join(HERE, "..", "..", "CHANGELOG.md"), dest) 137 | destination = osp.join(HERE, "api/app-config.rst") 138 | with open(destination, "w") as f: 139 | f.write(config_header) 140 | f.write(LabServerApp().document_config_options()) 141 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Copyright (c) Jupyter Development Team. 2 | .. Distributed under the terms of the Modified BSD License. 3 | 4 | .. jupyterlab_server documentation master file, created by 5 | sphinx-quickstart on Tue Mar 30 03:25:58 2021. 6 | You can adapt this file completely to your liking, but it should at least 7 | contain the root ``toctree`` directive. 8 | 9 | Welcome to JupyterLab Server's documentation! 10 | ============================================= 11 | 12 | .. toctree:: 13 | :maxdepth: 1 14 | :caption: Contents: 15 | 16 | changelog 17 | api/index 18 | 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | -------------------------------------------------------------------------------- /jupyterlab_server/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | from typing import Any 4 | 5 | from ._version import __version__ 6 | from .app import LabServerApp 7 | from .handlers import LabConfig, LabHandler, add_handlers 8 | from .licenses_app import LicensesApp 9 | from .spec import get_openapi_spec, get_openapi_spec_dict # noqa: F401 10 | from .translation_utils import translator 11 | from .workspaces_app import WorkspaceExportApp, WorkspaceImportApp, WorkspaceListApp 12 | from .workspaces_handler import WORKSPACE_EXTENSION, slugify 13 | 14 | __all__ = [ 15 | "__version__", 16 | "add_handlers", 17 | "LabConfig", 18 | "LabHandler", 19 | "LabServerApp", 20 | "LicensesApp", 21 | "slugify", 22 | "translator", 23 | "WORKSPACE_EXTENSION", 24 | "WorkspaceExportApp", 25 | "WorkspaceImportApp", 26 | "WorkspaceListApp", 27 | ] 28 | 29 | 30 | def _jupyter_server_extension_points() -> Any: 31 | return [{"module": "jupyterlab_server", "app": LabServerApp}] 32 | -------------------------------------------------------------------------------- /jupyterlab_server/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """CLI entry point for jupyterlab server.""" 5 | import sys 6 | 7 | from jupyterlab_server.app import main 8 | 9 | sys.exit(main()) # type:ignore[no-untyped-call] 10 | -------------------------------------------------------------------------------- /jupyterlab_server/_version.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """ 5 | store the current version info of the server. 6 | 7 | """ 8 | import re 9 | 10 | __version__ = "2.27.3" 11 | 12 | # Build up version_info tuple for backwards compatibility 13 | pattern = r"(?P\d+).(?P\d+).(?P\d+)(?P.*)" 14 | match = re.match(pattern, __version__) 15 | assert match is not None 16 | parts: list = [int(match[part]) for part in ["major", "minor", "patch"]] 17 | if match["rest"]: 18 | parts.append(match["rest"]) 19 | version_info = tuple(parts) 20 | -------------------------------------------------------------------------------- /jupyterlab_server/app.py: -------------------------------------------------------------------------------- 1 | """JupyterLab Server Application""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | 6 | from glob import glob 7 | from os.path import relpath 8 | from typing import Any 9 | 10 | from jupyter_server.extension.application import ExtensionApp, ExtensionAppJinjaMixin 11 | from jupyter_server.utils import url_path_join as ujoin 12 | from traitlets import Dict, Integer, Unicode, observe 13 | 14 | from ._version import __version__ 15 | from .handlers import LabConfig, add_handlers 16 | 17 | 18 | class LabServerApp(ExtensionAppJinjaMixin, LabConfig, ExtensionApp): 19 | """A Lab Server Application that runs out-of-the-box""" 20 | 21 | name = "jupyterlab_server" 22 | extension_url = "/lab" 23 | app_name = "JupyterLab Server Application" # type:ignore[assignment] 24 | file_url_prefix = "/lab/tree" # type:ignore[assignment] 25 | 26 | @property 27 | def app_namespace(self) -> str: # type:ignore[override] 28 | return self.name 29 | 30 | default_url = Unicode("/lab", help="The default URL to redirect to from `/`") 31 | 32 | # Should your extension expose other server extensions when launched directly? 33 | load_other_extensions = True 34 | 35 | app_version = Unicode("", help="The version of the application.").tag(default=__version__) 36 | 37 | blacklist_uris = Unicode( 38 | "", config=True, help="Deprecated, use `LabServerApp.blocked_extensions_uris`" 39 | ) 40 | 41 | blocked_extensions_uris = Unicode( 42 | "", 43 | config=True, 44 | help=""" 45 | A list of comma-separated URIs to get the blocked extensions list 46 | 47 | .. versionchanged:: 2.0.0 48 | `LabServerApp.blacklist_uris` renamed to `blocked_extensions_uris` 49 | """, 50 | ) 51 | 52 | whitelist_uris = Unicode( 53 | "", config=True, help="Deprecated, use `LabServerApp.allowed_extensions_uris`" 54 | ) 55 | 56 | allowed_extensions_uris = Unicode( 57 | "", 58 | config=True, 59 | help=""" 60 | "A list of comma-separated URIs to get the allowed extensions list 61 | 62 | .. versionchanged:: 2.0.0 63 | `LabServerApp.whitetlist_uris` renamed to `allowed_extensions_uris` 64 | """, 65 | ) 66 | 67 | listings_refresh_seconds = Integer( 68 | 60 * 60, config=True, help="The interval delay in seconds to refresh the lists" 69 | ) 70 | 71 | listings_request_options = Dict( 72 | {}, 73 | config=True, 74 | help="The optional kwargs to use for the listings HTTP requests \ 75 | as described on https://2.python-requests.org/en/v2.7.0/api/#requests.request", 76 | ) 77 | 78 | _deprecated_aliases = { 79 | "blacklist_uris": ("blocked_extensions_uris", "1.2"), 80 | "whitelist_uris": ("allowed_extensions_uris", "1.2"), 81 | } 82 | 83 | # Method copied from 84 | # https://github.com/jupyterhub/jupyterhub/blob/d1a85e53dccfc7b1dd81b0c1985d158cc6b61820/jupyterhub/auth.py#L143-L161 85 | @observe(*list(_deprecated_aliases)) 86 | def _deprecated_trait(self, change: Any) -> None: 87 | """observer for deprecated traits""" 88 | old_attr = change.name 89 | new_attr, version = self._deprecated_aliases.get(old_attr) # type:ignore[misc] 90 | new_value = getattr(self, new_attr) 91 | if new_value != change.new: 92 | # only warn if different 93 | # protects backward-compatible config from warnings 94 | # if they set the same value under both names 95 | self.log.warning( 96 | "%s.%s is deprecated in JupyterLab %s, use %s.%s instead", 97 | self.__class__.__name__, 98 | old_attr, 99 | version, 100 | self.__class__.__name__, 101 | new_attr, 102 | ) 103 | 104 | setattr(self, new_attr, change.new) 105 | 106 | def initialize_settings(self) -> None: 107 | """Initialize the settings: 108 | 109 | set the static files as immutable, since they should have all hashed name. 110 | """ 111 | immutable_cache = set(self.settings.get("static_immutable_cache", [])) 112 | 113 | # Set lab static files as immutables 114 | immutable_cache.add(self.static_url_prefix) 115 | 116 | # Set extensions static files as immutables 117 | for extension_path in self.labextensions_path + self.extra_labextensions_path: 118 | extensions_url = [ 119 | ujoin(self.labextensions_url, relpath(path, extension_path)) 120 | for path in glob(f"{extension_path}/**/static", recursive=True) 121 | ] 122 | 123 | immutable_cache.update(extensions_url) 124 | 125 | self.settings.update({"static_immutable_cache": list(immutable_cache)}) 126 | 127 | def initialize_templates(self) -> None: 128 | """Initialize templates.""" 129 | self.static_paths = [self.static_dir] 130 | self.template_paths = [self.templates_dir] 131 | 132 | def initialize_handlers(self) -> None: 133 | """Initialize handlers.""" 134 | add_handlers(self.handlers, self) 135 | 136 | 137 | main = launch_new_instance = LabServerApp.launch_instance 138 | -------------------------------------------------------------------------------- /jupyterlab_server/handlers.py: -------------------------------------------------------------------------------- 1 | """JupyterLab Server handlers""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import os 8 | import pathlib 9 | import warnings 10 | from functools import lru_cache 11 | from typing import TYPE_CHECKING, Any 12 | from urllib.parse import urlparse 13 | 14 | from jupyter_server.base.handlers import FileFindHandler, JupyterHandler 15 | from jupyter_server.extension.handler import ExtensionHandlerJinjaMixin, ExtensionHandlerMixin 16 | from jupyter_server.utils import url_path_join as ujoin 17 | from tornado import template, web 18 | 19 | from .config import LabConfig, get_page_config, recursive_update 20 | from .licenses_handler import LicensesHandler, LicensesManager 21 | from .listings_handler import ListingsHandler, fetch_listings 22 | from .settings_handler import SettingsHandler 23 | from .settings_utils import _get_overrides 24 | from .themes_handler import ThemesHandler 25 | from .translations_handler import TranslationsHandler 26 | from .workspaces_handler import WorkspacesHandler, WorkspacesManager 27 | 28 | if TYPE_CHECKING: 29 | from .app import LabServerApp 30 | # ----------------------------------------------------------------------------- 31 | # Module globals 32 | # ----------------------------------------------------------------------------- 33 | 34 | MASTER_URL_PATTERN = ( 35 | r"/(?P{}|doc)(?P/workspaces/[a-zA-Z0-9\-\_]+)?(?P/tree/.*)?" 36 | ) 37 | 38 | DEFAULT_TEMPLATE = template.Template( 39 | """ 40 | 41 | 42 | 43 | 44 | Error 45 | 46 | 47 |

Cannot find template: "{{name}}"

48 |

In "{{path}}"

49 | 50 | 51 | """ 52 | ) 53 | 54 | 55 | def is_url(url: str) -> bool: 56 | """Test whether a string is a full url (e.g. https://nasa.gov) 57 | 58 | https://stackoverflow.com/a/52455972 59 | """ 60 | try: 61 | result = urlparse(url) 62 | return all([result.scheme, result.netloc]) 63 | except ValueError: 64 | return False 65 | 66 | 67 | class LabHandler(ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler): 68 | """Render the JupyterLab View.""" 69 | 70 | @lru_cache # noqa: B019 71 | def get_page_config(self) -> dict[str, Any]: 72 | """Construct the page config object""" 73 | self.application.store_id = getattr( # type:ignore[attr-defined] 74 | self.application, "store_id", 0 75 | ) 76 | config = LabConfig() 77 | app: LabServerApp = self.extensionapp # type:ignore[assignment] 78 | settings_dir = app.app_settings_dir 79 | # Handle page config data. 80 | page_config = self.settings.setdefault("page_config_data", {}) 81 | terminals = self.settings.get("terminals_available", False) 82 | server_root = self.settings.get("server_root_dir", "") 83 | server_root = server_root.replace(os.sep, "/") 84 | base_url = self.settings.get("base_url") 85 | 86 | # Remove the trailing slash for compatibility with html-webpack-plugin. 87 | full_static_url = self.static_url_prefix.rstrip("/") 88 | page_config.setdefault("fullStaticUrl", full_static_url) 89 | 90 | page_config.setdefault("terminalsAvailable", terminals) 91 | page_config.setdefault("ignorePlugins", []) 92 | page_config.setdefault("serverRoot", server_root) 93 | page_config["store_id"] = self.application.store_id # type:ignore[attr-defined] 94 | 95 | server_root = os.path.normpath(os.path.expanduser(server_root)) 96 | preferred_path = "" 97 | try: 98 | preferred_path = self.serverapp.contents_manager.preferred_dir 99 | except Exception: 100 | # FIXME: Remove fallback once CM.preferred_dir is ubiquitous. 101 | try: 102 | # Remove the server_root from app pref dir 103 | if self.serverapp.preferred_dir and self.serverapp.preferred_dir != server_root: 104 | preferred_path = ( 105 | pathlib.Path(self.serverapp.preferred_dir) 106 | .relative_to(server_root) 107 | .as_posix() 108 | ) 109 | except Exception: # noqa: S110 110 | pass 111 | # JupyterLab relies on an unset/default path being "/" 112 | page_config["preferredPath"] = preferred_path or "/" 113 | 114 | self.application.store_id += 1 # type:ignore[attr-defined] 115 | 116 | mathjax_config = self.settings.get("mathjax_config", "TeX-AMS_HTML-full,Safe") 117 | # TODO Remove CDN usage. 118 | mathjax_url = self.mathjax_url 119 | if not mathjax_url: 120 | mathjax_url = "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js" 121 | 122 | page_config.setdefault("mathjaxConfig", mathjax_config) 123 | page_config.setdefault("fullMathjaxUrl", mathjax_url) 124 | 125 | # Put all our config in page_config 126 | for name in config.trait_names(): 127 | page_config[_camelCase(name)] = getattr(app, name) 128 | 129 | # Add full versions of all the urls 130 | for name in config.trait_names(): 131 | if not name.endswith("_url"): 132 | continue 133 | full_name = _camelCase("full_" + name) 134 | full_url = getattr(app, name) 135 | if base_url is not None and not is_url(full_url): 136 | # Relative URL will be prefixed with base_url 137 | full_url = ujoin(base_url, full_url) 138 | page_config[full_name] = full_url 139 | 140 | # Update the page config with the data from disk 141 | labextensions_path = app.extra_labextensions_path + app.labextensions_path 142 | recursive_update( 143 | page_config, get_page_config(labextensions_path, settings_dir, logger=self.log) 144 | ) 145 | 146 | # modify page config with custom hook 147 | page_config_hook = self.settings.get("page_config_hook", None) 148 | if page_config_hook: 149 | page_config = page_config_hook(self, page_config) 150 | 151 | return page_config 152 | 153 | @web.authenticated 154 | @web.removeslash 155 | def get( 156 | self, mode: str | None = None, workspace: str | None = None, tree: str | None = None 157 | ) -> None: 158 | """Get the JupyterLab html page.""" 159 | workspace = "default" if workspace is None else workspace.replace("/workspaces/", "") 160 | tree_path = "" if tree is None else tree.replace("/tree/", "") 161 | 162 | page_config = self.get_page_config() 163 | 164 | # Add parameters parsed from the URL 165 | if mode == "doc": 166 | page_config["mode"] = "single-document" 167 | else: 168 | page_config["mode"] = "multiple-document" 169 | page_config["workspace"] = workspace 170 | page_config["treePath"] = tree_path 171 | 172 | # Write the template with the config. 173 | tpl = self.render_template("index.html", page_config=page_config) # type:ignore[no-untyped-call] 174 | self.write(tpl) 175 | 176 | 177 | class NotFoundHandler(LabHandler): 178 | """A handler for page not found.""" 179 | 180 | @lru_cache # noqa: B019 181 | def get_page_config(self) -> dict[str, Any]: 182 | """Get the page config.""" 183 | # Making a copy of the page_config to ensure changes do not affect the original 184 | page_config = super().get_page_config().copy() 185 | page_config["notFoundUrl"] = self.request.path 186 | return page_config 187 | 188 | 189 | def add_handlers(handlers: list[Any], extension_app: LabServerApp) -> None: 190 | """Add the appropriate handlers to the web app.""" 191 | # Normalize directories. 192 | for name in LabConfig.class_trait_names(): 193 | if not name.endswith("_dir"): 194 | continue 195 | value = getattr(extension_app, name) 196 | setattr(extension_app, name, value.replace(os.sep, "/")) 197 | 198 | # Normalize urls 199 | # Local urls should have a leading slash but no trailing slash 200 | for name in LabConfig.class_trait_names(): 201 | if not name.endswith("_url"): 202 | continue 203 | value = getattr(extension_app, name) 204 | if is_url(value): 205 | continue 206 | if not value.startswith("/"): 207 | value = "/" + value 208 | if value.endswith("/"): 209 | value = value[:-1] 210 | setattr(extension_app, name, value) 211 | 212 | url_pattern = MASTER_URL_PATTERN.format(extension_app.app_url.replace("/", "")) 213 | handlers.append((url_pattern, LabHandler)) 214 | 215 | # Cache all or none of the files depending on the `cache_files` setting. 216 | no_cache_paths = [] if extension_app.cache_files else ["/"] 217 | 218 | # Handle federated lab extensions. 219 | labextensions_path = extension_app.extra_labextensions_path + extension_app.labextensions_path 220 | labextensions_url = ujoin(extension_app.labextensions_url, "(.*)") 221 | handlers.append( 222 | ( 223 | labextensions_url, 224 | FileFindHandler, 225 | {"path": labextensions_path, "no_cache_paths": no_cache_paths}, 226 | ) 227 | ) 228 | 229 | # Handle local settings. 230 | if extension_app.schemas_dir: 231 | # Load overrides once, rather than in each copy of the settings handler 232 | overrides, error = _get_overrides(extension_app.app_settings_dir) 233 | 234 | if error: 235 | overrides_warning = "Failed loading overrides: %s" 236 | extension_app.log.warning(overrides_warning, error) 237 | 238 | settings_config: dict[str, Any] = { 239 | "app_settings_dir": extension_app.app_settings_dir, 240 | "schemas_dir": extension_app.schemas_dir, 241 | "settings_dir": extension_app.user_settings_dir, 242 | "labextensions_path": labextensions_path, 243 | "overrides": overrides, 244 | } 245 | 246 | # Handle requests for the list of settings. Make slash optional. 247 | settings_path = ujoin(extension_app.settings_url, "?") 248 | handlers.append((settings_path, SettingsHandler, settings_config)) 249 | 250 | # Handle requests for an individual set of settings. 251 | setting_path = ujoin(extension_app.settings_url, "(?P.+)") 252 | handlers.append((setting_path, SettingsHandler, settings_config)) 253 | 254 | # Handle translations. 255 | # Translations requires settings as the locale source of truth is stored in it 256 | if extension_app.translations_api_url: 257 | # Handle requests for the list of language packs available. 258 | # Make slash optional. 259 | translations_path = ujoin(extension_app.translations_api_url, "?") 260 | handlers.append((translations_path, TranslationsHandler, settings_config)) 261 | 262 | # Handle requests for an individual language pack. 263 | translations_lang_path = ujoin(extension_app.translations_api_url, "(?P.*)") 264 | handlers.append((translations_lang_path, TranslationsHandler, settings_config)) 265 | 266 | # Handle saved workspaces. 267 | if extension_app.workspaces_dir: 268 | workspaces_config = {"manager": WorkspacesManager(extension_app.workspaces_dir)} 269 | 270 | # Handle requests for the list of workspaces. Make slash optional. 271 | workspaces_api_path = ujoin(extension_app.workspaces_api_url, "?") 272 | handlers.append((workspaces_api_path, WorkspacesHandler, workspaces_config)) 273 | 274 | # Handle requests for an individually named workspace. 275 | workspace_api_path = ujoin(extension_app.workspaces_api_url, "(?P.+)") 276 | handlers.append((workspace_api_path, WorkspacesHandler, workspaces_config)) 277 | 278 | # Handle local listings. 279 | 280 | settings_config = extension_app.settings.get("config", {}).get("LabServerApp", {}) 281 | blocked_extensions_uris: str = settings_config.get("blocked_extensions_uris", "") 282 | allowed_extensions_uris: str = settings_config.get("allowed_extensions_uris", "") 283 | 284 | if (blocked_extensions_uris) and (allowed_extensions_uris): 285 | warnings.warn( 286 | "Simultaneous blocked_extensions_uris and allowed_extensions_uris is not supported. Please define only one of those.", 287 | stacklevel=2, 288 | ) 289 | import sys 290 | 291 | sys.exit(-1) 292 | 293 | ListingsHandler.listings_refresh_seconds = settings_config.get( 294 | "listings_refresh_seconds", 60 * 60 295 | ) 296 | ListingsHandler.listings_request_opts = settings_config.get("listings_request_options", {}) 297 | listings_url = ujoin(extension_app.listings_url) 298 | listings_path = ujoin(listings_url, "(.*)") 299 | 300 | if blocked_extensions_uris: 301 | ListingsHandler.blocked_extensions_uris = set(blocked_extensions_uris.split(",")) 302 | if allowed_extensions_uris: 303 | ListingsHandler.allowed_extensions_uris = set(allowed_extensions_uris.split(",")) 304 | 305 | fetch_listings(None) 306 | 307 | if ( 308 | len(ListingsHandler.blocked_extensions_uris) > 0 309 | or len(ListingsHandler.allowed_extensions_uris) > 0 310 | ): 311 | from tornado import ioloop 312 | 313 | callback_time = ListingsHandler.listings_refresh_seconds * 1000 314 | ListingsHandler.pc = ioloop.PeriodicCallback( 315 | lambda: fetch_listings(None), # type:ignore[assignment] 316 | callback_time=callback_time, 317 | jitter=0.1, 318 | ) 319 | ListingsHandler.pc.start() # type:ignore[attr-defined] 320 | 321 | handlers.append((listings_path, ListingsHandler, {})) 322 | 323 | # Handle local themes. 324 | if extension_app.themes_dir: 325 | themes_url = extension_app.themes_url 326 | themes_path = ujoin(themes_url, "(.*)") 327 | handlers.append( 328 | ( 329 | themes_path, 330 | ThemesHandler, 331 | { 332 | "themes_url": themes_url, 333 | "path": extension_app.themes_dir, 334 | "labextensions_path": labextensions_path, 335 | "no_cache_paths": no_cache_paths, 336 | }, 337 | ) 338 | ) 339 | 340 | # Handle licenses. 341 | if extension_app.licenses_url: 342 | licenses_url = extension_app.licenses_url 343 | licenses_path = ujoin(licenses_url, "(.*)") 344 | handlers.append( 345 | (licenses_path, LicensesHandler, {"manager": LicensesManager(parent=extension_app)}) 346 | ) 347 | 348 | # Let the lab handler act as the fallthrough option instead of a 404. 349 | fallthrough_url = ujoin(extension_app.app_url, r".*") 350 | handlers.append((fallthrough_url, NotFoundHandler)) 351 | 352 | 353 | def _camelCase(base: str) -> str: 354 | """Convert a string to camelCase. 355 | https://stackoverflow.com/a/20744956 356 | """ 357 | output = "".join(x for x in base.title() if x.isalpha()) 358 | return output[0].lower() + output[1:] 359 | -------------------------------------------------------------------------------- /jupyterlab_server/licenses_app.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """A license reporting CLI 5 | 6 | Mostly ready-to-use, the downstream must provide the location of the application's 7 | static resources. Licenses from an app's federated_extensions will also be discovered 8 | as configured in `labextensions_path` and `extra_labextensions_path`. 9 | 10 | from traitlets import default 11 | from jupyterlab_server import LicensesApp 12 | 13 | class MyLicensesApp(LicensesApp): 14 | version = "0.1.0" 15 | 16 | @default("static_dir") 17 | def _default_static_dir(self): 18 | return "my-static/" 19 | 20 | class MyApp(JupyterApp, LabConfig): 21 | ... 22 | subcommands = dict( 23 | licenses=(MyLicensesApp, MyLicensesApp.description.splitlines()[0]) 24 | ) 25 | 26 | """ 27 | from typing import Any 28 | 29 | from jupyter_core.application import JupyterApp, base_aliases, base_flags 30 | from traitlets import Bool, Enum, Instance, Unicode 31 | 32 | from ._version import __version__ 33 | from .config import LabConfig 34 | from .licenses_handler import LicensesManager 35 | 36 | 37 | class LicensesApp(JupyterApp, LabConfig): 38 | """A license management app.""" 39 | 40 | version = __version__ 41 | 42 | description = """ 43 | Report frontend licenses 44 | """ 45 | 46 | static_dir = Unicode("", config=True, help="The static directory from which to show licenses") 47 | 48 | full_text = Bool(False, config=True, help="Also print out full license text (if available)") 49 | 50 | report_format = Enum( 51 | ["markdown", "json", "csv"], "markdown", config=True, help="Reporter format" 52 | ) 53 | 54 | bundles_pattern = Unicode(".*", config=True, help="A regular expression of bundles to print") 55 | 56 | licenses_manager = Instance(LicensesManager) 57 | 58 | aliases = { 59 | **base_aliases, 60 | "bundles": "LicensesApp.bundles_pattern", 61 | "report-format": "LicensesApp.report_format", 62 | } 63 | 64 | flags = { 65 | **base_flags, 66 | "full-text": ( 67 | {"LicensesApp": {"full_text": True}}, 68 | "Print out full license text (if available)", 69 | ), 70 | "json": ( 71 | {"LicensesApp": {"report_format": "json"}}, 72 | "Print out report as JSON (implies --full-text)", 73 | ), 74 | "csv": ( 75 | {"LicensesApp": {"report_format": "csv"}}, 76 | "Print out report as CSV (implies --full-text)", 77 | ), 78 | } 79 | 80 | def initialize(self, *args: Any, **kwargs: Any) -> None: 81 | """Initialize the app.""" 82 | super().initialize(*args, **kwargs) 83 | self.init_licenses_manager() 84 | 85 | def init_licenses_manager(self) -> None: 86 | """Initialize the license manager.""" 87 | self.licenses_manager = LicensesManager( 88 | parent=self, 89 | ) 90 | 91 | def start(self) -> None: 92 | """Start the app.""" 93 | report = self.licenses_manager.report( 94 | report_format=self.report_format, 95 | full_text=self.full_text, 96 | bundles_pattern=self.bundles_pattern, 97 | )[0] 98 | print(report) 99 | self.exit(0) 100 | -------------------------------------------------------------------------------- /jupyterlab_server/licenses_handler.py: -------------------------------------------------------------------------------- 1 | """Manager and Tornado handlers for license reporting.""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import asyncio 8 | import csv 9 | import io 10 | import json 11 | import mimetypes 12 | import re 13 | from concurrent.futures import ThreadPoolExecutor 14 | from pathlib import Path 15 | from typing import TYPE_CHECKING, Any 16 | 17 | from jupyter_server.base.handlers import APIHandler 18 | from tornado import web 19 | from traitlets import List, Unicode 20 | from traitlets.config import LoggingConfigurable 21 | 22 | from .config import get_federated_extensions 23 | 24 | # this is duplicated in @juptyerlab/builder 25 | DEFAULT_THIRD_PARTY_LICENSE_FILE = "third-party-licenses.json" 26 | UNKNOWN_PACKAGE_NAME = "UNKNOWN" 27 | 28 | if mimetypes.guess_extension("text/markdown") is None: # pragma: no cover 29 | # for python <3.8 https://bugs.python.org/issue39324 30 | mimetypes.add_type("text/markdown", ".md") 31 | 32 | 33 | class LicensesManager(LoggingConfigurable): 34 | """A manager for listing the licenses for all frontend end code distributed 35 | by an application and any federated extensions 36 | """ 37 | 38 | executor = ThreadPoolExecutor(max_workers=1) 39 | 40 | third_party_licenses_files = List( 41 | Unicode(), 42 | default_value=[ 43 | DEFAULT_THIRD_PARTY_LICENSE_FILE, 44 | f"static/{DEFAULT_THIRD_PARTY_LICENSE_FILE}", 45 | ], 46 | help="the license report data in built app and federated extensions", 47 | ) 48 | 49 | @property 50 | def federated_extensions(self) -> dict[str, Any]: 51 | """Lazily load the currrently-available federated extensions. 52 | 53 | This is expensive, but probably the only way to be sure to get 54 | up-to-date license information for extensions installed interactively. 55 | """ 56 | if TYPE_CHECKING: 57 | from .app import LabServerApp 58 | 59 | assert isinstance(self.parent, LabServerApp) 60 | 61 | per_paths = [ 62 | self.parent.labextensions_path, 63 | self.parent.extra_labextensions_path, 64 | ] 65 | labextensions_path = [extension for extensions in per_paths for extension in extensions] 66 | return get_federated_extensions(labextensions_path) 67 | 68 | async def report_async( 69 | self, report_format: str = "markdown", bundles_pattern: str = ".*", full_text: bool = False 70 | ) -> tuple[str, str]: 71 | """Asynchronous wrapper around the potentially slow job of locating 72 | and encoding all of the licenses 73 | """ 74 | return await asyncio.wrap_future( 75 | self.executor.submit( 76 | self.report, 77 | report_format=report_format, 78 | bundles_pattern=bundles_pattern, 79 | full_text=full_text, 80 | ) 81 | ) 82 | 83 | def report(self, report_format: str, bundles_pattern: str, full_text: bool) -> tuple[str, str]: 84 | """create a human- or machine-readable report""" 85 | bundles = self.bundles(bundles_pattern=bundles_pattern) 86 | if report_format == "json": 87 | return self.report_json(bundles), "application/json" 88 | if report_format == "csv": 89 | return self.report_csv(bundles), "text/csv" 90 | if report_format == "markdown": 91 | return ( 92 | self.report_markdown(bundles, full_text=full_text), 93 | "text/markdown", 94 | ) 95 | 96 | msg = f"Unsupported report format {report_format}." 97 | raise ValueError(msg) 98 | 99 | def report_json(self, bundles: dict[str, Any]) -> str: 100 | """create a JSON report 101 | TODO: SPDX 102 | """ 103 | return json.dumps({"bundles": bundles}, indent=2, sort_keys=True) 104 | 105 | def report_csv(self, bundles: dict[str, Any]) -> str: 106 | """create a CSV report""" 107 | outfile = io.StringIO() 108 | fieldnames = ["name", "versionInfo", "licenseId", "extractedText"] 109 | writer = csv.DictWriter(outfile, fieldnames=["bundle", *fieldnames]) 110 | writer.writeheader() 111 | for bundle_name, bundle in bundles.items(): 112 | for package in bundle["packages"]: 113 | writer.writerow( 114 | { 115 | "bundle": bundle_name, 116 | **{field: package.get(field, "") for field in fieldnames}, 117 | } 118 | ) 119 | return outfile.getvalue() 120 | 121 | def report_markdown(self, bundles: dict[str, Any], full_text: bool = True) -> str: 122 | """create a markdown report""" 123 | lines = [] 124 | library_names = [ 125 | len(package.get("name", UNKNOWN_PACKAGE_NAME)) 126 | for bundle_name, bundle in bundles.items() 127 | for package in bundle.get("packages", []) 128 | ] 129 | longest_name = max(library_names) if library_names else 1 130 | 131 | for bundle_name, bundle in bundles.items(): 132 | # TODO: parametrize template 133 | lines += [f"# {bundle_name}", ""] 134 | 135 | packages = bundle.get("packages", []) 136 | if not packages: 137 | lines += ["> No licenses found", ""] 138 | continue 139 | 140 | for package in packages: 141 | name = package.get("name", UNKNOWN_PACKAGE_NAME).strip() 142 | version_info = package.get("versionInfo", UNKNOWN_PACKAGE_NAME).strip() 143 | license_id = package.get("licenseId", UNKNOWN_PACKAGE_NAME).strip() 144 | extracted_text = package.get("extractedText", "") 145 | 146 | lines += [ 147 | "## " 148 | + ( 149 | "\t".join( 150 | [ 151 | f"""**{name}**""".ljust(longest_name), 152 | f"""`{version_info}`""".ljust(20), 153 | license_id, 154 | ] 155 | ) 156 | ) 157 | ] 158 | 159 | if full_text: 160 | if not extracted_text: 161 | lines += ["", "> No license text available", ""] 162 | else: 163 | lines += ["", "", "
", extracted_text, "
", ""] 164 | return "\n".join(lines) 165 | 166 | def license_bundle(self, path: Path, bundle: str | None) -> dict[str, Any]: 167 | """Return the content of a packages's license bundles""" 168 | bundle_json: dict = {"packages": []} 169 | checked_paths = [] 170 | 171 | for license_file in self.third_party_licenses_files: 172 | licenses_path = path / license_file 173 | self.log.debug("Loading licenses from %s", licenses_path) 174 | if not licenses_path.exists(): 175 | checked_paths += [licenses_path] 176 | continue 177 | 178 | try: 179 | file_json = json.loads(licenses_path.read_text(encoding="utf-8")) 180 | except Exception as err: 181 | self.log.warning( 182 | "Failed to open third-party licenses for %s: %s\n%s", 183 | bundle, 184 | licenses_path, 185 | err, 186 | ) 187 | continue 188 | 189 | try: 190 | bundle_json["packages"].extend(file_json["packages"]) 191 | except Exception as err: 192 | self.log.warning( 193 | "Failed to find packages for %s: %s\n%s", 194 | bundle, 195 | licenses_path, 196 | err, 197 | ) 198 | continue 199 | 200 | if not bundle_json["packages"]: 201 | self.log.warning("Third-party licenses not found for %s: %s", bundle, checked_paths) 202 | 203 | return bundle_json 204 | 205 | def app_static_info(self) -> tuple[Path | None, str | None]: 206 | """get the static directory for this app 207 | 208 | This will usually be in `static_dir`, but may also appear in the 209 | parent of `static_dir`. 210 | """ 211 | if TYPE_CHECKING: 212 | from .app import LabServerApp 213 | 214 | assert isinstance(self.parent, LabServerApp) 215 | path = Path(self.parent.static_dir) 216 | package_json = path / "package.json" 217 | if not package_json.exists(): 218 | parent_package_json = path.parent / "package.json" 219 | if parent_package_json.exists(): 220 | package_json = parent_package_json 221 | else: 222 | return None, None 223 | name = json.loads(package_json.read_text(encoding="utf-8"))["name"] 224 | return path, name 225 | 226 | def bundles(self, bundles_pattern: str = ".*") -> dict[str, Any]: 227 | """Read all of the licenses 228 | TODO: schema 229 | """ 230 | bundles = { 231 | name: self.license_bundle(Path(ext["ext_path"]), name) 232 | for name, ext in self.federated_extensions.items() 233 | if re.match(bundles_pattern, name) 234 | } 235 | 236 | app_path, app_name = self.app_static_info() 237 | if app_path is not None: 238 | assert app_name is not None 239 | if re.match(bundles_pattern, app_name): 240 | bundles[app_name] = self.license_bundle(app_path, app_name) 241 | 242 | if not bundles: 243 | self.log.warning("No license bundles found at all") 244 | 245 | return bundles 246 | 247 | 248 | class LicensesHandler(APIHandler): 249 | """A handler for serving licenses used by the application""" 250 | 251 | def initialize(self, manager: LicensesManager) -> None: 252 | """Initialize the handler.""" 253 | super().initialize() 254 | self.manager = manager 255 | 256 | @web.authenticated 257 | async def get(self, _args: Any) -> None: 258 | """Return all the frontend licenses""" 259 | full_text = bool(json.loads(self.get_argument("full_text", "true"))) 260 | report_format = self.get_argument("format", "json") 261 | bundles_pattern = self.get_argument("bundles", ".*") 262 | download = bool(json.loads(self.get_argument("download", "0"))) 263 | 264 | report, mime = await self.manager.report_async( 265 | report_format=report_format, 266 | bundles_pattern=bundles_pattern, 267 | full_text=full_text, 268 | ) 269 | 270 | if TYPE_CHECKING: 271 | from .app import LabServerApp 272 | 273 | assert isinstance(self.manager.parent, LabServerApp) 274 | 275 | if download: 276 | filename = "{}-licenses{}".format( 277 | self.manager.parent.app_name.lower(), mimetypes.guess_extension(mime) 278 | ) 279 | self.set_attachment_header(filename) 280 | self.write(report) 281 | await self.finish(_mime_type=mime) 282 | 283 | async def finish( # type:ignore[override] 284 | self, _mime_type: str, *args: Any, **kwargs: Any 285 | ) -> Any: 286 | """Overload the regular finish, which (sensibly) always sets JSON""" 287 | self.update_api_activity() 288 | self.set_header("Content-Type", _mime_type) 289 | return await super(APIHandler, self).finish(*args, **kwargs) 290 | -------------------------------------------------------------------------------- /jupyterlab_server/listings_handler.py: -------------------------------------------------------------------------------- 1 | """Tornado handlers for listing extensions.""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import json 8 | from logging import Logger 9 | 10 | import requests 11 | import tornado 12 | from jupyter_server.base.handlers import APIHandler 13 | 14 | LISTINGS_URL_SUFFIX = "@jupyterlab/extensionmanager-extension/listings.json" 15 | 16 | 17 | def fetch_listings(logger: Logger | None) -> None: 18 | """Fetch the listings for the extension manager.""" 19 | if not logger: 20 | from traitlets import log 21 | 22 | logger = log.get_logger() # type:ignore[assignment] 23 | assert logger is not None 24 | if len(ListingsHandler.blocked_extensions_uris) > 0: 25 | blocked_extensions = [] 26 | for blocked_extensions_uri in ListingsHandler.blocked_extensions_uris: 27 | logger.info( 28 | "Fetching blocked_extensions from %s", ListingsHandler.blocked_extensions_uris 29 | ) 30 | r = requests.request( 31 | "GET", blocked_extensions_uri, **ListingsHandler.listings_request_opts 32 | ) 33 | j = json.loads(r.text) 34 | for b in j["blocked_extensions"]: 35 | blocked_extensions.append(b) 36 | ListingsHandler.blocked_extensions = blocked_extensions 37 | if len(ListingsHandler.allowed_extensions_uris) > 0: 38 | allowed_extensions = [] 39 | for allowed_extensions_uri in ListingsHandler.allowed_extensions_uris: 40 | logger.info( 41 | "Fetching allowed_extensions from %s", ListingsHandler.allowed_extensions_uris 42 | ) 43 | r = requests.request( 44 | "GET", allowed_extensions_uri, **ListingsHandler.listings_request_opts 45 | ) 46 | j = json.loads(r.text) 47 | for w in j["allowed_extensions"]: 48 | allowed_extensions.append(w) 49 | ListingsHandler.allowed_extensions = allowed_extensions 50 | ListingsHandler.listings = json.dumps( # type:ignore[attr-defined] 51 | { 52 | "blocked_extensions_uris": list(ListingsHandler.blocked_extensions_uris), 53 | "allowed_extensions_uris": list(ListingsHandler.allowed_extensions_uris), 54 | "blocked_extensions": ListingsHandler.blocked_extensions, 55 | "allowed_extensions": ListingsHandler.allowed_extensions, 56 | } 57 | ) 58 | 59 | 60 | class ListingsHandler(APIHandler): 61 | """An handler that returns the listings specs.""" 62 | 63 | """Below fields are class level fields that are accessed and populated 64 | by the initialization and the fetch_listings methods. 65 | Some fields are initialized before the handler creation in the 66 | handlers.py#add_handlers method. 67 | Having those fields predefined reduces the guards in the methods using 68 | them. 69 | """ 70 | # The list of blocked_extensions URIS. 71 | blocked_extensions_uris: set = set() 72 | # The list of allowed_extensions URIS. 73 | allowed_extensions_uris: set = set() 74 | # The blocked extensions extensions. 75 | blocked_extensions: list = [] 76 | # The allowed extensions extensions. 77 | allowed_extensions: list = [] 78 | # The provider request options to be used for the request library. 79 | listings_request_opts: dict = {} 80 | # The callback time for the periodic callback in seconds. 81 | listings_refresh_seconds: int 82 | # The PeriodicCallback that schedule the call to fetch_listings method. 83 | pc = None 84 | 85 | @tornado.web.authenticated 86 | def get(self, path: str) -> None: 87 | """Get the listings for the extension manager.""" 88 | self.set_header("Content-Type", "application/json") 89 | if path == LISTINGS_URL_SUFFIX: 90 | self.write(ListingsHandler.listings) # type:ignore[attr-defined] 91 | else: 92 | raise tornado.web.HTTPError(400) 93 | -------------------------------------------------------------------------------- /jupyterlab_server/process.py: -------------------------------------------------------------------------------- 1 | """JupyterLab Server process handler""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import atexit 8 | import logging 9 | import os 10 | import re 11 | import signal 12 | import subprocess 13 | import sys 14 | import threading 15 | import time 16 | import weakref 17 | from logging import Logger 18 | from shutil import which as _which 19 | from typing import Any 20 | 21 | from tornado import gen 22 | 23 | try: 24 | import pty 25 | except ImportError: 26 | pty = None # type:ignore[assignment] 27 | 28 | if sys.platform == "win32": 29 | list2cmdline = subprocess.list2cmdline 30 | else: 31 | 32 | def list2cmdline(cmd_list: list[str]) -> str: 33 | """Shim for list2cmdline on posix.""" 34 | import shlex 35 | 36 | return " ".join(map(shlex.quote, cmd_list)) 37 | 38 | 39 | def which(command: str, env: dict[str, str] | None = None) -> str: 40 | """Get the full path to a command. 41 | 42 | Parameters 43 | ---------- 44 | command: str 45 | The command name or path. 46 | env: dict, optional 47 | The environment variables, defaults to `os.environ`. 48 | """ 49 | env = env or os.environ # type:ignore[assignment] 50 | path = env.get("PATH") or os.defpath # type:ignore[union-attr] 51 | command_with_path = _which(command, path=path) 52 | 53 | # Allow nodejs as an alias to node. 54 | if command == "node" and not command_with_path: 55 | command = "nodejs" 56 | command_with_path = _which("nodejs", path=path) 57 | 58 | if not command_with_path: 59 | if command in ["nodejs", "node", "npm"]: 60 | msg = "Please install Node.js and npm before continuing installation. You may be able to install Node.js from your package manager, from conda, or directly from the Node.js website (https://nodejs.org)." 61 | raise ValueError(msg) 62 | raise ValueError("The command was not found or was not " + "executable: %s." % command) 63 | return os.path.abspath(command_with_path) 64 | 65 | 66 | class Process: 67 | """A wrapper for a child process.""" 68 | 69 | _procs: weakref.WeakSet = weakref.WeakSet() 70 | _pool = None 71 | 72 | def __init__( 73 | self, 74 | cmd: list[str], 75 | logger: Logger | None = None, 76 | cwd: str | None = None, 77 | kill_event: threading.Event | None = None, 78 | env: dict[str, str] | None = None, 79 | quiet: bool = False, 80 | ) -> None: 81 | """Start a subprocess that can be run asynchronously. 82 | 83 | Parameters 84 | ---------- 85 | cmd: list 86 | The command to run. 87 | logger: :class:`~logger.Logger`, optional 88 | The logger instance. 89 | cwd: string, optional 90 | The cwd of the process. 91 | env: dict, optional 92 | The environment for the process. 93 | kill_event: :class:`~threading.Event`, optional 94 | An event used to kill the process operation. 95 | quiet: bool, optional 96 | Whether to suppress output. 97 | """ 98 | if not isinstance(cmd, (list, tuple)): 99 | msg = "Command must be given as a list" # type:ignore[unreachable] 100 | raise ValueError(msg) 101 | 102 | if kill_event and kill_event.is_set(): 103 | msg = "Process aborted" 104 | raise ValueError(msg) 105 | 106 | self.logger = logger or self.get_log() 107 | self._last_line = "" 108 | if not quiet: 109 | self.logger.info("> %s", list2cmdline(cmd)) 110 | self.cmd = cmd 111 | 112 | kwargs = {} 113 | if quiet: 114 | kwargs["stdout"] = subprocess.DEVNULL 115 | 116 | self.proc = self._create_process(cwd=cwd, env=env, **kwargs) 117 | self._kill_event = kill_event or threading.Event() 118 | 119 | Process._procs.add(self) 120 | 121 | def terminate(self) -> int: 122 | """Terminate the process and return the exit code.""" 123 | proc = self.proc 124 | 125 | # Kill the process. 126 | if proc.poll() is None: 127 | os.kill(proc.pid, signal.SIGTERM) 128 | 129 | # Wait for the process to close. 130 | try: 131 | proc.wait(timeout=2.0) 132 | except subprocess.TimeoutExpired: 133 | if os.name == "nt": # noqa: SIM108 134 | sig = signal.SIGBREAK # type:ignore[attr-defined] 135 | else: 136 | sig = signal.SIGKILL 137 | 138 | if proc.poll() is None: 139 | os.kill(proc.pid, sig) 140 | 141 | finally: 142 | if self in Process._procs: 143 | Process._procs.remove(self) 144 | 145 | return proc.wait() 146 | 147 | def wait(self) -> int: 148 | """Wait for the process to finish. 149 | 150 | Returns 151 | ------- 152 | The process exit code. 153 | """ 154 | proc = self.proc 155 | kill_event = self._kill_event 156 | while proc.poll() is None: 157 | if kill_event.is_set(): 158 | self.terminate() 159 | msg = "Process was aborted" 160 | raise ValueError(msg) 161 | time.sleep(1.0) 162 | return self.terminate() 163 | 164 | @gen.coroutine 165 | def wait_async(self) -> Any: 166 | """Asynchronously wait for the process to finish.""" 167 | proc = self.proc 168 | kill_event = self._kill_event 169 | while proc.poll() is None: 170 | if kill_event.is_set(): 171 | self.terminate() 172 | msg = "Process was aborted" 173 | raise ValueError(msg) 174 | yield gen.sleep(1.0) 175 | 176 | raise gen.Return(self.terminate()) 177 | 178 | def _create_process(self, **kwargs: Any) -> subprocess.Popen[str]: 179 | """Create the process.""" 180 | cmd = list(self.cmd) 181 | kwargs.setdefault("stderr", subprocess.STDOUT) 182 | 183 | cmd[0] = which(cmd[0], kwargs.get("env")) 184 | 185 | if os.name == "nt": 186 | kwargs["shell"] = True 187 | 188 | return subprocess.Popen(cmd, **kwargs) # noqa: S603 189 | 190 | @classmethod 191 | def _cleanup(cls: type[Process]) -> None: 192 | """Clean up the started subprocesses at exit.""" 193 | for proc in list(cls._procs): 194 | proc.terminate() 195 | 196 | def get_log(self) -> Logger: 197 | """Get our logger.""" 198 | if hasattr(self, "logger") and self.logger is not None: 199 | return self.logger 200 | # fallback logger 201 | self.logger = logging.getLogger("jupyterlab") 202 | self.logger.setLevel(logging.INFO) 203 | return self.logger 204 | 205 | 206 | class WatchHelper(Process): 207 | """A process helper for a watch process.""" 208 | 209 | def __init__( 210 | self, 211 | cmd: list[str], 212 | startup_regex: str, 213 | logger: Logger | None = None, 214 | cwd: str | None = None, 215 | kill_event: threading.Event | None = None, 216 | env: dict[str, str] | None = None, 217 | ) -> None: 218 | """Initialize the process helper. 219 | 220 | Parameters 221 | ---------- 222 | cmd: list 223 | The command to run. 224 | startup_regex: string 225 | The regex to wait for at startup. 226 | logger: :class:`~logger.Logger`, optional 227 | The logger instance. 228 | cwd: string, optional 229 | The cwd of the process. 230 | env: dict, optional 231 | The environment for the process. 232 | kill_event: callable, optional 233 | A function to call to check if we should abort. 234 | """ 235 | super().__init__(cmd, logger=logger, cwd=cwd, kill_event=kill_event, env=env) 236 | 237 | if pty is None: 238 | self._stdout = self.proc.stdout # type:ignore[unreachable] 239 | 240 | while 1: 241 | line = self._stdout.readline().decode("utf-8") # type:ignore[has-type] 242 | if not line: 243 | msg = "Process ended improperly" 244 | raise RuntimeError(msg) 245 | print(line.rstrip()) 246 | if re.match(startup_regex, line): 247 | break 248 | 249 | self._read_thread = threading.Thread(target=self._read_incoming, daemon=True) 250 | self._read_thread.start() 251 | 252 | def terminate(self) -> int: 253 | """Terminate the process.""" 254 | proc = self.proc 255 | 256 | if proc.poll() is None: 257 | if os.name != "nt": 258 | # Kill the process group if we started a new session. 259 | os.killpg(os.getpgid(proc.pid), signal.SIGTERM) 260 | else: 261 | os.kill(proc.pid, signal.SIGTERM) 262 | 263 | # Wait for the process to close. 264 | try: 265 | proc.wait() 266 | finally: 267 | if self in Process._procs: 268 | Process._procs.remove(self) 269 | 270 | return proc.returncode 271 | 272 | def _read_incoming(self) -> None: 273 | """Run in a thread to read stdout and print""" 274 | fileno = self._stdout.fileno() # type:ignore[has-type] 275 | while 1: 276 | try: 277 | buf = os.read(fileno, 1024) 278 | except OSError as e: 279 | self.logger.debug("Read incoming error %s", e) 280 | return 281 | 282 | if not buf: 283 | return 284 | 285 | print(buf.decode("utf-8"), end="") 286 | 287 | def _create_process(self, **kwargs: Any) -> subprocess.Popen[str]: 288 | """Create the watcher helper process.""" 289 | kwargs["bufsize"] = 0 290 | 291 | if pty is not None: 292 | master, slave = pty.openpty() 293 | kwargs["stderr"] = kwargs["stdout"] = slave 294 | kwargs["start_new_session"] = True 295 | self._stdout = os.fdopen(master, "rb") # type:ignore[has-type] 296 | else: 297 | kwargs["stdout"] = subprocess.PIPE # type:ignore[unreachable] 298 | 299 | if os.name == "nt": 300 | startupinfo = subprocess.STARTUPINFO() 301 | startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 302 | kwargs["startupinfo"] = startupinfo 303 | kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP 304 | kwargs["shell"] = True 305 | 306 | return super()._create_process(**kwargs) 307 | 308 | 309 | # Register the cleanup handler. 310 | atexit.register(Process._cleanup) 311 | -------------------------------------------------------------------------------- /jupyterlab_server/process_app.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """A lab app that runs a sub process for a demo or a test.""" 5 | from __future__ import annotations 6 | 7 | import sys 8 | from typing import Any 9 | 10 | from jupyter_server.extension.application import ExtensionApp, ExtensionAppJinjaMixin 11 | from tornado.ioloop import IOLoop 12 | 13 | from .handlers import LabConfig, add_handlers 14 | from .process import Process 15 | 16 | 17 | class ProcessApp(ExtensionAppJinjaMixin, LabConfig, ExtensionApp): 18 | """A jupyterlab app that runs a separate process and exits on completion.""" 19 | 20 | load_other_extensions = True 21 | 22 | # Do not open a browser for process apps 23 | open_browser = False # type:ignore[assignment] 24 | 25 | def get_command(self) -> tuple[list[str], dict[str, Any]]: 26 | """Get the command and kwargs to run with `Process`. 27 | This is intended to be overridden. 28 | """ 29 | return [sys.executable, "--version"], {} 30 | 31 | def initialize_settings(self) -> None: 32 | """Start the application.""" 33 | IOLoop.current().add_callback(self._run_command) 34 | 35 | def initialize_handlers(self) -> None: 36 | """Initialize the handlers.""" 37 | add_handlers(self.handlers, self) # type:ignore[arg-type] 38 | 39 | def _run_command(self) -> None: 40 | command, kwargs = self.get_command() 41 | kwargs.setdefault("logger", self.log) 42 | future = Process(command, **kwargs).wait_async() 43 | IOLoop.current().add_future(future, self._process_finished) 44 | 45 | def _process_finished(self, future: Any) -> None: 46 | try: 47 | IOLoop.current().stop() 48 | sys.exit(future.result()) 49 | except Exception as e: 50 | self.log.error(str(e)) 51 | sys.exit(1) 52 | -------------------------------------------------------------------------------- /jupyterlab_server/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab_server/bca8978ffd44093bc4e7c790d8ee064d2f2b492a/jupyterlab_server/py.typed -------------------------------------------------------------------------------- /jupyterlab_server/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """pytest fixtures.""" 5 | from __future__ import annotations 6 | 7 | import json 8 | import os 9 | import os.path as osp 10 | import shutil 11 | from os.path import join as pjoin 12 | from pathlib import Path 13 | from typing import Any, Callable 14 | 15 | import pytest 16 | from jupyter_server.serverapp import ServerApp 17 | 18 | from jupyterlab_server import LabServerApp 19 | 20 | pytest_plugins = ["pytest_jupyter.jupyter_server"] 21 | 22 | 23 | def mkdir(tmp_path: Path, *parts: str) -> Path: 24 | """Util for making a directory.""" 25 | path = tmp_path.joinpath(*parts) 26 | if not path.exists(): 27 | path.mkdir(parents=True) 28 | return path 29 | 30 | 31 | HERE = os.path.abspath(os.path.dirname(__file__)) 32 | 33 | app_settings_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, "app_settings")) 34 | user_settings_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, "user_settings")) 35 | schemas_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, "schemas")) 36 | workspaces_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, "workspaces")) 37 | labextensions_dir = pytest.fixture(lambda tmp_path: mkdir(tmp_path, "labextensions_dir")) 38 | 39 | 40 | @pytest.fixture 41 | def make_labserver_extension_app( 42 | jp_root_dir: Path, 43 | jp_template_dir: Path, 44 | app_settings_dir: Path, 45 | user_settings_dir: Path, 46 | schemas_dir: Path, 47 | workspaces_dir: Path, 48 | labextensions_dir: Path, 49 | ) -> Callable[..., LabServerApp]: 50 | """Return a factory function for a labserver extension app.""" 51 | 52 | def _make_labserver_extension_app(**kwargs: Any) -> LabServerApp: # noqa: ARG001 53 | """Factory function for lab server extension apps.""" 54 | return LabServerApp( 55 | static_dir=str(jp_root_dir), 56 | templates_dir=str(jp_template_dir), 57 | app_url="/lab", 58 | app_settings_dir=str(app_settings_dir), 59 | user_settings_dir=str(user_settings_dir), 60 | schemas_dir=str(schemas_dir), 61 | workspaces_dir=str(workspaces_dir), 62 | extra_labextensions_path=[str(labextensions_dir)], 63 | ) 64 | 65 | # Create the index files. 66 | index = jp_template_dir.joinpath("index.html") 67 | index.write_text( 68 | """ 69 | 70 | 71 | 72 | {{page_config['appName'] | e}} 73 | 74 | 75 | {# Copy so we do not modify the page_config with updates. #} 76 | {% set page_config_full = page_config.copy() %} 77 | 78 | {# Set a dummy variable - we just want the side effect of the update. #} 79 | {% set _ = page_config_full.update(baseUrl=base_url, wsUrl=ws_url) %} 80 | 81 | 84 | 85 | 86 | 96 | 97 | 98 | """ 99 | ) 100 | 101 | # Copy the schema files. 102 | src = pjoin(HERE, "test_data", "schemas", "@jupyterlab") 103 | dst = pjoin(str(schemas_dir), "@jupyterlab") 104 | if os.path.exists(dst): 105 | shutil.rmtree(dst) 106 | shutil.copytree(src, dst) 107 | 108 | # Create the federated extensions 109 | for name in ["apputils-extension", "codemirror-extension"]: 110 | target_name = name + "-federated" 111 | target = pjoin(str(labextensions_dir), "@jupyterlab", target_name) 112 | src = pjoin(HERE, "test_data", "schemas", "@jupyterlab", name) 113 | dst = pjoin(target, "schemas", "@jupyterlab", target_name) 114 | if osp.exists(dst): 115 | shutil.rmtree(dst) 116 | shutil.copytree(src, dst) 117 | with open(pjoin(target, "package.orig.json"), "w") as fid: 118 | data = dict(name=target_name, jupyterlab=dict(extension=True)) 119 | json.dump(data, fid) 120 | 121 | # Copy the overrides file. 122 | src = pjoin(HERE, "test_data", "app-settings", "overrides.json") 123 | dst = pjoin(str(app_settings_dir), "overrides.json") 124 | if os.path.exists(dst): 125 | os.remove(dst) 126 | shutil.copyfile(src, dst) 127 | 128 | # Copy workspaces. 129 | ws_path = pjoin(HERE, "test_data", "workspaces") 130 | for item in os.listdir(ws_path): 131 | src = pjoin(ws_path, item) 132 | dst = pjoin(str(workspaces_dir), item) 133 | if os.path.exists(dst): 134 | os.remove(dst) 135 | shutil.copy(src, str(workspaces_dir)) 136 | 137 | return _make_labserver_extension_app 138 | 139 | 140 | @pytest.fixture 141 | def labserverapp( 142 | jp_serverapp: ServerApp, make_labserver_extension_app: Callable[..., LabServerApp] 143 | ) -> LabServerApp: 144 | """A lab server app.""" 145 | app = make_labserver_extension_app() 146 | app._link_jupyter_server_extension(jp_serverapp) 147 | app.initialize() # type:ignore[no-untyped-call] 148 | return app 149 | -------------------------------------------------------------------------------- /jupyterlab_server/rest-api.yml: -------------------------------------------------------------------------------- 1 | # see me at: https://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterlab/jupyterlab_server/main/jupyterlab_server/rest-api.yml#/default 2 | openapi: "3.0.3" 3 | info: 4 | title: JupyterLab Server 5 | description: The REST API for JupyterLab Server 6 | version: 1.0.0 7 | license: 8 | name: BSD-3-Clause 9 | 10 | paths: 11 | /lab/api/listings/%40jupyterlab/extensionmanager-extension/listings.json: 12 | get: 13 | summary: Get Extension Listings Specs 14 | description: | 15 | Gets the list of extension metadata for the application 16 | responses: 17 | "200": 18 | description: The Extension Listing specs 19 | content: 20 | application/json: 21 | schema: 22 | properties: 23 | blocked_extension_uris: 24 | type: array 25 | description: list of blocked extension uris 26 | items: 27 | type: string 28 | allowed_extension_uris: 29 | type: array 30 | description: list of allowed extension uris 31 | items: 32 | type: string 33 | blocked_extensions: 34 | type: array 35 | description: list of blocked extensions 36 | items: 37 | $ref: "#/components/schemas/ListEntry" 38 | allowed_extensions: 39 | type: array 40 | description: list of blocked extensions 41 | items: 42 | $ref: "#/components/schemas/ListEntry" 43 | 44 | /lab/api/settings/: 45 | get: 46 | summary: Get Settings List 47 | description: | 48 | Gets the list of all application settings data 49 | responses: 50 | "200": 51 | description: The Application Settings Data 52 | content: 53 | application/json: 54 | schema: 55 | properties: 56 | settings: 57 | type: array 58 | description: List of application settings entries 59 | items: 60 | $ref: "#/components/schemas/SettingsEntry" 61 | 62 | /lab/api/settings/{schema_name}: 63 | parameters: 64 | - name: schema_name 65 | description: Schema Name 66 | in: path 67 | required: true 68 | schema: 69 | type: string 70 | get: 71 | summary: Get the settings data for a given schema 72 | description: | 73 | Gets the settings data for a given schema 74 | responses: 75 | "200": 76 | description: The Settings Data 77 | content: 78 | application/json: 79 | schema: 80 | $ref: "#/components/schemas/SettingsEntry" 81 | put: 82 | summary: Override the settings data for a given schema 83 | description: | 84 | Overrides the settings data for a given schema 85 | requestBody: 86 | required: true 87 | description: raw settings data 88 | content: 89 | application/json: 90 | schema: 91 | type: object 92 | properties: 93 | raw: 94 | type: string 95 | responses: 96 | "204": 97 | description: The setting has been updated 98 | 99 | /lab/api/themes/{theme_file}: 100 | parameters: 101 | - name: theme_file 102 | description: Theme file path 103 | in: path 104 | required: true 105 | schema: 106 | type: string 107 | get: 108 | summary: Get a static theme file 109 | description: | 110 | Gets the static theme file at a given path 111 | responses: 112 | "200": 113 | description: The Theme File 114 | 115 | /lab/api/translations/: 116 | get: 117 | summary: Get Translation Bundles 118 | description: | 119 | Gets the list of translation bundles 120 | responses: 121 | "200": 122 | description: The Extension Listing specs 123 | content: 124 | application/json: 125 | schema: 126 | type: object 127 | properties: 128 | data: 129 | type: object 130 | additionalProperties: 131 | $ref: "#/components/schemas/TranslationEntry" 132 | message: 133 | type: string 134 | 135 | /lab/api/translations/{locale}: 136 | parameters: 137 | - name: locale 138 | description: Locale name 139 | in: path 140 | required: true 141 | schema: 142 | type: string 143 | get: 144 | summary: Get the translation data for locale 145 | description: | 146 | Gets the translation data for a given locale 147 | responses: 148 | "200": 149 | description: The Local Data 150 | content: 151 | application/json: 152 | schema: 153 | type: object 154 | properties: 155 | data: 156 | type: object 157 | message: 158 | type: string 159 | 160 | /lab/api/workspaces/: 161 | get: 162 | summary: Get Workspace Data 163 | description: | 164 | Gets the list of workspace data 165 | responses: 166 | "200": 167 | description: The Workspace specs 168 | content: 169 | application/json: 170 | schema: 171 | type: object 172 | properties: 173 | workspaces: 174 | type: object 175 | properties: 176 | ids: 177 | type: array 178 | items: 179 | type: string 180 | values: 181 | type: array 182 | items: 183 | $ref: "#/components/schemas/Workspace" 184 | 185 | /lab/api/workspaces/{space_name}: 186 | parameters: 187 | - name: space_name 188 | description: Workspace name 189 | in: path 190 | required: true 191 | schema: 192 | type: string 193 | get: 194 | summary: Get the workspace data for name 195 | description: | 196 | Gets the workspace data for a given workspace name 197 | responses: 198 | "200": 199 | description: The Workspace Data 200 | content: 201 | application/json: 202 | schema: 203 | $ref: "#/components/schemas/Workspace" 204 | put: 205 | summary: Override the workspace data for a given name 206 | description: | 207 | Overrides the workspace data for a given workspace name 208 | requestBody: 209 | required: true 210 | description: raw workspace data 211 | content: 212 | application/json: 213 | schema: 214 | $ref: "#/components/schemas/Workspace" 215 | responses: 216 | "204": 217 | description: The workspace has been updated 218 | 219 | delete: 220 | summary: Delete the workspace data for a given name 221 | description: | 222 | Deletes the workspace data for a given workspace name 223 | responses: 224 | "204": 225 | description: The workspace has been deleted 226 | /lab/api/licenses/: 227 | get: 228 | summary: License report 229 | description: | 230 | Get the third-party licenses for the core application and all federated 231 | extensions 232 | parameters: 233 | - name: full_text 234 | description: Return full license texts 235 | in: query 236 | schema: 237 | type: boolean 238 | - name: format 239 | in: query 240 | description: The format in which to report licenses 241 | schema: 242 | type: string 243 | enum: 244 | - csv 245 | - json 246 | - markdown 247 | - name: bundles 248 | description: A regular expression to limit the names of bundles reported 249 | in: query 250 | schema: 251 | type: string 252 | - name: download 253 | in: query 254 | description: Whether to set a representative filename header 255 | schema: 256 | type: boolean 257 | responses: 258 | "200": 259 | description: A license report 260 | content: 261 | application/markdown: 262 | schema: 263 | type: string 264 | text/csv: 265 | schema: 266 | type: string 267 | application/json: 268 | schema: 269 | $ref: "#/components/schemas/LicenseBundles" 270 | 271 | components: 272 | schemas: 273 | ListEntry: 274 | type: object 275 | properties: 276 | name: 277 | type: string 278 | regexp: 279 | type: string 280 | type: 281 | type: string 282 | reason: 283 | type: string 284 | creation_date: 285 | type: string 286 | last_update_date: 287 | type: string 288 | SettingsEntry: 289 | type: object 290 | properties: 291 | id: 292 | type: string 293 | schema: 294 | type: object 295 | version: 296 | type: string 297 | raw: 298 | type: string 299 | settings: 300 | type: object 301 | warning: 302 | type: string 303 | nullable: true 304 | last_modified: 305 | type: string 306 | nullable: true 307 | created: 308 | type: string 309 | nullable: true 310 | TranslationEntry: 311 | type: object 312 | properties: 313 | data: 314 | type: object 315 | properties: 316 | displayName: 317 | type: string 318 | nativeName: 319 | type: string 320 | message: 321 | type: string 322 | Workspace: 323 | type: object 324 | properties: 325 | data: 326 | type: object 327 | metadata: 328 | type: object 329 | properties: 330 | id: 331 | type: string 332 | last_modified: 333 | type: string 334 | created: 335 | type: string 336 | LicenseBundles: 337 | type: object 338 | properties: 339 | bundles: 340 | type: object 341 | additionalProperties: 342 | type: object 343 | properties: 344 | packages: 345 | type: array 346 | items: 347 | type: object 348 | properties: 349 | extractedText: 350 | type: string 351 | licenseId: 352 | type: string 353 | name: 354 | type: string 355 | versionInfo: 356 | type: string 357 | -------------------------------------------------------------------------------- /jupyterlab_server/server.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """Server api.""" 5 | # FIXME TODO Deprecated remove this file for the next major version 6 | # Downstream package must import those items from `jupyter_server` directly 7 | from jupyter_server import _tz as tz 8 | from jupyter_server.base.handlers import ( 9 | APIHandler, 10 | FileFindHandler, 11 | JupyterHandler, 12 | json_errors, 13 | ) 14 | from jupyter_server.extension.serverextension import ( 15 | GREEN_ENABLED, 16 | GREEN_OK, 17 | RED_DISABLED, 18 | RED_X, 19 | ) 20 | from jupyter_server.serverapp import ServerApp, aliases, flags 21 | from jupyter_server.utils import url_escape, url_path_join 22 | -------------------------------------------------------------------------------- /jupyterlab_server/settings_handler.py: -------------------------------------------------------------------------------- 1 | """Tornado handlers for frontend config storage.""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import json 8 | from typing import Any 9 | 10 | from jsonschema import ValidationError 11 | from jupyter_server.extension.handler import ExtensionHandlerJinjaMixin, ExtensionHandlerMixin 12 | from tornado import web 13 | 14 | from .settings_utils import SchemaHandler, get_settings, save_settings 15 | from .translation_utils import translator 16 | 17 | 18 | class SettingsHandler(ExtensionHandlerMixin, ExtensionHandlerJinjaMixin, SchemaHandler): 19 | """A settings API handler.""" 20 | 21 | def initialize( # type:ignore[override] 22 | self, 23 | name: str, 24 | app_settings_dir: str, 25 | schemas_dir: str, 26 | settings_dir: str, 27 | labextensions_path: list[str], 28 | overrides: dict[str, Any] | None = None, 29 | **kwargs: Any, # noqa: ARG002 30 | ) -> None: 31 | """Initialize the handler.""" 32 | SchemaHandler.initialize( 33 | self, app_settings_dir, schemas_dir, settings_dir, labextensions_path, overrides 34 | ) 35 | ExtensionHandlerMixin.initialize(self, name) 36 | 37 | @web.authenticated 38 | def get(self, schema_name: str = "") -> Any: 39 | """ 40 | Get setting(s) 41 | 42 | Parameters 43 | ---------- 44 | schema_name: str 45 | The id of a unique schema to send, added to the URL 46 | 47 | ## NOTES: 48 | An optional argument `ids_only=true` can be provided in the URL to get only the 49 | ids of the schemas instead of the content. 50 | """ 51 | # Need to be update here as translator locale is not change when a new locale is put 52 | # from frontend 53 | locale = self.get_current_locale() 54 | translator.set_locale(locale) 55 | 56 | ids_only = self.get_argument("ids_only", "") == "true" 57 | 58 | result, warnings = get_settings( 59 | self.app_settings_dir, 60 | self.schemas_dir, 61 | self.settings_dir, 62 | labextensions_path=self.labextensions_path, 63 | schema_name=schema_name, 64 | overrides=self.overrides, 65 | translator=translator.translate_schema, 66 | ids_only=ids_only, 67 | ) 68 | 69 | # Print all warnings. 70 | for w in warnings: 71 | if w: 72 | self.log.warning(w) 73 | 74 | return self.finish(json.dumps(result)) 75 | 76 | @web.authenticated 77 | def put(self, schema_name: str) -> None: 78 | """Update a setting""" 79 | overrides = self.overrides 80 | schemas_dir = self.schemas_dir 81 | settings_dir = self.settings_dir 82 | settings_error = "No current settings directory" 83 | invalid_json_error = "Failed parsing JSON payload: %s" 84 | invalid_payload_format_error = ( 85 | "Invalid format for JSON payload. Must be in the form {'raw': ...}" 86 | ) 87 | validation_error = "Failed validating input: %s" 88 | 89 | if not settings_dir: 90 | raise web.HTTPError(500, settings_error) 91 | 92 | raw_payload = self.request.body.strip().decode("utf-8") 93 | try: 94 | raw_settings = json.loads(raw_payload)["raw"] 95 | save_settings( 96 | schemas_dir, 97 | settings_dir, 98 | schema_name, 99 | raw_settings, 100 | overrides, 101 | self.labextensions_path, 102 | ) 103 | except json.decoder.JSONDecodeError as e: 104 | raise web.HTTPError(400, invalid_json_error % str(e)) from None 105 | except (KeyError, TypeError): 106 | raise web.HTTPError(400, invalid_payload_format_error) from None 107 | except ValidationError as e: 108 | raise web.HTTPError(400, validation_error % str(e)) from None 109 | 110 | self.set_status(204) 111 | -------------------------------------------------------------------------------- /jupyterlab_server/spec.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """OpenAPI spec utils.""" 5 | from __future__ import annotations 6 | 7 | import os 8 | import typing 9 | from pathlib import Path 10 | 11 | if typing.TYPE_CHECKING: 12 | from openapi_core.spec.paths import Spec 13 | 14 | HERE = Path(os.path.dirname(__file__)).resolve() 15 | 16 | 17 | def get_openapi_spec() -> Spec: 18 | """Get the OpenAPI spec object.""" 19 | from openapi_core.spec.paths import Spec 20 | 21 | openapi_spec_dict = get_openapi_spec_dict() 22 | return Spec.from_dict(openapi_spec_dict) # type:ignore[arg-type] 23 | 24 | 25 | def get_openapi_spec_dict() -> dict[str, typing.Any]: 26 | """Get the OpenAPI spec as a dictionary.""" 27 | from ruamel.yaml import YAML 28 | 29 | path = HERE / "rest-api.yml" 30 | yaml = YAML(typ="safe") 31 | return yaml.load(path.read_text(encoding="utf-8")) 32 | -------------------------------------------------------------------------------- /jupyterlab_server/templates/403.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 403 Forbidden 11 | 12 | 13 |

Sorry ..

14 |

.. you are not allowed to see this content!

15 | 16 | 17 | -------------------------------------------------------------------------------- /jupyterlab_server/templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | {% block title %}{{page_title | escape}{% endblock %} 12 | 13 | {% block favicon %}{% endblock %} 14 | 15 | 16 | 17 | 18 | 19 | {% block stylesheet %} 20 | 26 | {% endblock %} 27 | {% block site %} 28 | 29 |
30 | {% block h1_error %} 31 |

{{status_code | escape }} : {{status_message | escape }}

32 | {% endblock h1_error %} 33 | {% block error_detail %} 34 | {% if message %} 35 |

The error was:

36 |
37 |
{{message | escape }}
38 |
39 | {% endif %} 40 | {% endblock %} 41 | 42 | 43 | {% endblock %} 44 | 45 | {% block script %} 46 | 55 | {% endblock script %} 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /jupyterlab_server/templates/index.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | {% block title %}{{page_title | escape }}{% endblock %} 11 | 12 | {% block stylesheet %} {% for css_file in css_files %} 13 | 14 | {% endfor %} {% endblock %} {# Copy so we do not modify the page_config with 15 | updates. #} {% set page_config_full = page_config.copy() %} {# Set a dummy 16 | variable - we just want the side effect of the update. #} {% set _ = 17 | page_config_full.update(baseUrl=base_url, wsUrl=ws_url) %} 18 | 19 | 22 | 23 | {% block favicon %} {% endblock %} {% for js_file in js_files %} 24 | 29 | {% endfor %} {% block meta %} {% endblock %} 30 | 31 | 32 | 33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /jupyterlab_server/test_data/app-settings/overrides.json: -------------------------------------------------------------------------------- 1 | { 2 | "@jupyterlab/apputils-extension:themes": { 3 | "theme": "JupyterLab Dark", 4 | "codeCellConfig": { 5 | "lineNumbers": true 6 | } 7 | }, 8 | "@jupyterlab/unicode-extension:plugin": { 9 | "comment": "Here are some languages with unicode in their names: id: Bahasa Indonesia, ms: Bahasa Melayu, bs: Bosanski, ca: Català, cs: Čeština, da: Dansk, de: Deutsch, et: Eesti, en: English, es: Español, fil: Filipino, fr: Français, it: Italiano, hu: Magyar, nl: Nederlands, no: Norsk, pl: Polski, pt-br: Português (Brasil), pt: Português (Portugal), ro: Română, fi: Suomi, sv: Svenska, vi: Tiếng Việt, tr: Türkçe, el: Ελληνικά, ru: Русский, sr: Српски, uk: Українська, he: עברית, ar: العربية, th: ไทย, ko: 한국어, ja: 日本語, zh: 中文(中国), zh-tw: 中文(台灣)" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /jupyterlab_server/test_data/schemas/@jupyterlab/apputils-extension/themes.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Theme", 3 | "description": "Theme manager settings.", 4 | "properties": { 5 | "theme": { 6 | "type": "string", 7 | "title": "Selected Theme", 8 | "default": "JupyterLab Light" 9 | }, 10 | "codeCellConfig": { 11 | "title": "Code Cell Configuration", 12 | "description": "The configuration for all code cells.", 13 | "$ref": "#/definitions/editorConfig", 14 | "default": { 15 | "autoClosingBrackets": true, 16 | "cursorBlinkRate": 530, 17 | "fontFamily": null, 18 | "fontSize": null, 19 | "lineHeight": null, 20 | "lineNumbers": false, 21 | "lineWrap": "off", 22 | "matchBrackets": true, 23 | "readOnly": false, 24 | "insertSpaces": true, 25 | "tabSize": 4, 26 | "wordWrapColumn": 80, 27 | "rulers": [], 28 | "codeFolding": false, 29 | "lineWiseCopyCut": true 30 | } 31 | } 32 | }, 33 | "type": "object" 34 | } 35 | -------------------------------------------------------------------------------- /jupyterlab_server/test_data/schemas/@jupyterlab/codemirror-extension/commands.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter.lab.setting-icon-class": "jp-TextEditorIcon", 3 | "jupyter.lab.setting-icon-label": "CodeMirror", 4 | "title": "CodeMirror", 5 | "description": "Text editor settings for all CodeMirror editors.", 6 | "properties": { 7 | "keyMap": { "type": "string", "title": "Key Map", "default": "default" }, 8 | "theme": { "type": "string", "title": "Theme", "default": "default" } 9 | }, 10 | "type": "object" 11 | } 12 | -------------------------------------------------------------------------------- /jupyterlab_server/test_data/schemas/@jupyterlab/shortcuts-extension/package.json.orig: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jupyterlab/shortcuts-extension", 3 | "version": "test-version" 4 | } 5 | -------------------------------------------------------------------------------- /jupyterlab_server/test_data/schemas/@jupyterlab/translation-extension/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter.lab.setting-icon": "ui-components:settings", 3 | "jupyter.lab.setting-icon-label": "Language", 4 | "title": "Language", 5 | "description": "Language settings.", 6 | "type": "object", 7 | "properties": { 8 | "locale": { 9 | "type": "string", 10 | "title": "Language locale", 11 | "description": "Set the interface display language. Examples: 'es_CO', 'fr'.", 12 | "default": "en" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /jupyterlab_server/test_data/schemas/@jupyterlab/unicode-extension/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter.lab.setting-icon": "ui-components:unicode", 3 | "jupyter.lab.setting-icon-label": "Unicode", 4 | "title": "Unicode", 5 | "description": "Unicode", 6 | "type": "object", 7 | "properties": { 8 | "comment": { 9 | "type": "string", 10 | "title": "Comment", 11 | "description": "Here are some languages with unicode in their names: id: Bahasa Indonesia, ms: Bahasa Melayu, bs: Bosanski, ca: Català, cs: Čeština, da: Dansk, de: Deutsch, et: Eesti, en: English, es: Español, fil: Filipino, fr: Français, it: Italiano, hu: Magyar, nl: Nederlands, no: Norsk, pl: Polski, pt-br: Português (Brasil), pt: Português (Portugal), ro: Română, fi: Suomi, sv: Svenska, vi: Tiếng Việt, tr: Türkçe, el: Ελληνικά, ru: Русский, sr: Српски, uk: Українська, he: עברית, ar: العربية, th: ไทย, ko: 한국어, ja: 日本語, zh: 中文(中国), zh-tw: 中文(台灣)", 12 | "default": "no comment" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /jupyterlab_server/test_data/workspaces/foo-2c26.jupyterlab-workspace: -------------------------------------------------------------------------------- 1 | {"data": {}, "metadata": {"id": "foo"}} 2 | -------------------------------------------------------------------------------- /jupyterlab_server/test_data/workspaces/foo-92dd.jupyterlab-workspace: -------------------------------------------------------------------------------- 1 | {"data": {}, "metadata": {"id": "f/o/o/"}} 2 | -------------------------------------------------------------------------------- /jupyterlab_server/test_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """Testing utils.""" 5 | from __future__ import annotations 6 | 7 | import json 8 | import os 9 | import sys 10 | from http.cookies import SimpleCookie 11 | from pathlib import Path 12 | from urllib.parse import parse_qs, urlparse 13 | 14 | import tornado.httpclient 15 | import tornado.web 16 | from openapi_core import V30RequestValidator, V30ResponseValidator 17 | from openapi_core.spec.paths import Spec 18 | from openapi_core.validation.request.datatypes import RequestParameters 19 | from tornado.httpclient import HTTPRequest, HTTPResponse 20 | from werkzeug.datastructures import Headers, ImmutableMultiDict 21 | 22 | from jupyterlab_server.spec import get_openapi_spec 23 | 24 | HERE = Path(os.path.dirname(__file__)).resolve() 25 | 26 | with open(HERE / "test_data" / "app-settings" / "overrides.json", encoding="utf-8") as fid: 27 | big_unicode_string = json.load(fid)["@jupyterlab/unicode-extension:plugin"]["comment"] 28 | 29 | 30 | class TornadoOpenAPIRequest: 31 | """ 32 | Converts a torando request to an OpenAPI one 33 | """ 34 | 35 | def __init__(self, request: HTTPRequest, spec: Spec): 36 | """Initialize the request.""" 37 | self.request = request 38 | self.spec = spec 39 | if request.url is None: 40 | msg = "Request URL is missing" # type:ignore[unreachable] 41 | raise RuntimeError(msg) 42 | self._url_parsed = urlparse(request.url) 43 | 44 | cookie: SimpleCookie = SimpleCookie() 45 | cookie.load(request.headers.get("Set-Cookie", "")) 46 | cookies = {} 47 | for key, morsel in cookie.items(): 48 | cookies[key] = morsel.value 49 | 50 | # extract the path 51 | o = urlparse(request.url) 52 | 53 | # gets deduced by path finder against spec 54 | path: dict = {} 55 | 56 | self.parameters = RequestParameters( 57 | query=ImmutableMultiDict(parse_qs(o.query)), 58 | header=dict(request.headers), 59 | cookie=ImmutableMultiDict(cookies), 60 | path=path, 61 | ) 62 | 63 | @property 64 | def content_type(self) -> str: 65 | return "application/json" 66 | 67 | @property 68 | def host_url(self) -> str: 69 | url = self.request.url 70 | return url[: url.index("/lab")] 71 | 72 | @property 73 | def path(self) -> str: 74 | # extract the best matching url 75 | # work around lack of support for path parameters which can contain slashes 76 | # https://github.com/OAI/OpenAPI-Specification/issues/892 77 | url = None 78 | o = urlparse(self.request.url) 79 | for path_ in self.spec["paths"]: 80 | if url: 81 | continue # type:ignore[unreachable] 82 | has_arg = "{" in path_ 83 | path = path_[: path_.index("{")] if has_arg else path_ 84 | if path in o.path: 85 | u = o.path[o.path.index(path) :] 86 | if not has_arg and len(u) == len(path): 87 | url = u 88 | if has_arg and not u.endswith("/"): 89 | url = u[: len(path)] + r"foo" 90 | 91 | if url is None: 92 | msg = f"Could not find matching pattern for {o.path}" 93 | raise ValueError(msg) 94 | return url 95 | 96 | @property 97 | def method(self) -> str: 98 | method = self.request.method 99 | return method and method.lower() or "" 100 | 101 | @property 102 | def body(self) -> bytes | None: 103 | if self.request.body is None: 104 | return None # type:ignore[unreachable] 105 | if not isinstance(self.request.body, bytes): 106 | msg = "Request body is invalid" # type:ignore[unreachable] 107 | raise AssertionError(msg) 108 | return self.request.body 109 | 110 | @property 111 | def mimetype(self) -> str: 112 | # Order matters because all tornado requests 113 | # include Accept */* which does not necessarily match the content type 114 | request = self.request 115 | return ( 116 | request.headers.get("Content-Type") 117 | or request.headers.get("Accept") 118 | or "application/json" 119 | ) 120 | 121 | 122 | class TornadoOpenAPIResponse: 123 | """A tornado open API response.""" 124 | 125 | def __init__(self, response: HTTPResponse): 126 | """Initialize the response.""" 127 | self.response = response 128 | 129 | @property 130 | def data(self) -> bytes | None: 131 | if not isinstance(self.response.body, bytes): 132 | msg = "Response body is invalid" # type:ignore[unreachable] 133 | raise AssertionError(msg) 134 | return self.response.body 135 | 136 | @property 137 | def status_code(self) -> int: 138 | return int(self.response.code) 139 | 140 | @property 141 | def content_type(self) -> str: 142 | return "application/json" 143 | 144 | @property 145 | def mimetype(self) -> str: 146 | return str(self.response.headers.get("Content-Type", "application/json")) 147 | 148 | @property 149 | def headers(self) -> Headers: 150 | return Headers(dict(self.response.headers)) 151 | 152 | 153 | def validate_request(response: HTTPResponse) -> None: 154 | """Validate an API request""" 155 | openapi_spec = get_openapi_spec() 156 | 157 | request = TornadoOpenAPIRequest(response.request, openapi_spec) 158 | V30RequestValidator(openapi_spec).validate(request) 159 | 160 | torn_response = TornadoOpenAPIResponse(response) 161 | V30ResponseValidator(openapi_spec).validate(request, torn_response) 162 | 163 | 164 | def maybe_patch_ioloop() -> None: 165 | """a windows 3.8+ patch for the asyncio loop""" 166 | if ( 167 | sys.platform.startswith("win") 168 | and tornado.version_info < (6, 1) 169 | and sys.version_info >= (3, 8) 170 | ): 171 | try: 172 | from asyncio import WindowsProactorEventLoopPolicy, WindowsSelectorEventLoopPolicy 173 | except ImportError: 174 | pass 175 | # not affected 176 | else: 177 | from asyncio import get_event_loop_policy, set_event_loop_policy 178 | 179 | if type(get_event_loop_policy()) is WindowsProactorEventLoopPolicy: 180 | # WindowsProactorEventLoopPolicy is not compatible with tornado 6 181 | # fallback to the pre-3.8 default of Selector 182 | set_event_loop_policy(WindowsSelectorEventLoopPolicy()) 183 | 184 | 185 | def expected_http_error( 186 | error: Exception, expected_code: int, expected_message: str | None = None 187 | ) -> bool: 188 | """Check that the error matches the expected output error.""" 189 | e = error.value # type:ignore[attr-defined] 190 | if isinstance(e, tornado.web.HTTPError): 191 | if expected_code != e.status_code: 192 | return False 193 | if expected_message is not None and expected_message != str(e): 194 | return False 195 | return True 196 | if any( 197 | [ 198 | isinstance(e, tornado.httpclient.HTTPClientError), 199 | isinstance(e, tornado.httpclient.HTTPError), 200 | ] 201 | ): 202 | if expected_code != e.code: 203 | return False 204 | if expected_message: 205 | message = json.loads(e.response.body.decode())["message"] 206 | if expected_message != message: 207 | return False 208 | return True 209 | 210 | return False 211 | -------------------------------------------------------------------------------- /jupyterlab_server/themes_handler.py: -------------------------------------------------------------------------------- 1 | """Tornado handlers for dynamic theme loading.""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import os 8 | import re 9 | from glob import glob 10 | from typing import Any, Generator 11 | from urllib.parse import urlparse 12 | 13 | from jupyter_server.base.handlers import FileFindHandler 14 | from jupyter_server.utils import url_path_join as ujoin 15 | 16 | 17 | class ThemesHandler(FileFindHandler): 18 | """A file handler that mangles local urls in CSS files.""" 19 | 20 | def initialize( 21 | self, 22 | path: str | list[str], 23 | default_filename: str | None = None, 24 | no_cache_paths: list[str] | None = None, 25 | themes_url: str | None = None, 26 | labextensions_path: list[str] | None = None, 27 | **kwargs: Any, # noqa: ARG002 28 | ) -> None: 29 | """Initialize the handler.""" 30 | # Get all of the available theme paths in order 31 | labextensions_path = labextensions_path or [] 32 | ext_paths: list[str] = [] 33 | for ext_dir in labextensions_path: 34 | theme_pattern = ext_dir + "/**/themes" 35 | ext_paths.extend(path for path in glob(theme_pattern, recursive=True)) 36 | 37 | # Add the core theme path last 38 | if not isinstance(path, list): 39 | path = [path] 40 | path = ext_paths + path 41 | 42 | FileFindHandler.initialize( 43 | self, path, default_filename=default_filename, no_cache_paths=no_cache_paths 44 | ) 45 | self.themes_url = themes_url 46 | 47 | def get_content( # type:ignore[override] 48 | self, abspath: str, start: int | None = None, end: int | None = None 49 | ) -> bytes | Generator[bytes, None, None]: 50 | """Retrieve the content of the requested resource which is located 51 | at the given absolute path. 52 | 53 | This method should either return a byte string or an iterator 54 | of byte strings. 55 | """ 56 | base, ext = os.path.splitext(abspath) 57 | if ext != ".css": 58 | return FileFindHandler.get_content(abspath, start, end) 59 | 60 | return self._get_css() 61 | 62 | def get_content_size(self) -> int: 63 | """Retrieve the total size of the resource at the given path.""" 64 | assert self.absolute_path is not None 65 | base, ext = os.path.splitext(self.absolute_path) 66 | if ext != ".css": 67 | return FileFindHandler.get_content_size(self) 68 | return len(self._get_css()) 69 | 70 | def _get_css(self) -> bytes: 71 | """Get the mangled css file contents.""" 72 | assert self.absolute_path is not None 73 | with open(self.absolute_path, "rb") as fid: 74 | data = fid.read().decode("utf-8") 75 | 76 | if not self.themes_url: 77 | return b"" 78 | 79 | basedir = os.path.dirname(self.path).replace(os.sep, "/") 80 | basepath = ujoin(self.themes_url, basedir) 81 | 82 | # Replace local paths with mangled paths. 83 | # We only match strings that are local urls, 84 | # e.g. `url('../foo.css')`, `url('images/foo.png')` 85 | pattern = r"url\('(.*)'\)|url\('(.*)'\)" 86 | 87 | def replacer(m: Any) -> Any: 88 | """Replace the matched relative url with the mangled url.""" 89 | group = m.group() 90 | # Get the part that matched 91 | part = next(g for g in m.groups() if g) 92 | 93 | # Ignore urls that start with `/` or have a protocol like `http`. 94 | parsed = urlparse(part) 95 | if part.startswith("/") or parsed.scheme: 96 | return group 97 | 98 | return group.replace(part, ujoin(basepath, part)) 99 | 100 | return re.sub(pattern, replacer, data).encode("utf-8") 101 | -------------------------------------------------------------------------------- /jupyterlab_server/translations_handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Translation handler. 3 | """ 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | from __future__ import annotations 7 | 8 | import json 9 | import traceback 10 | from functools import partial 11 | 12 | import tornado 13 | 14 | from .settings_utils import SchemaHandler 15 | from .translation_utils import ( 16 | SYS_LOCALE, 17 | get_language_pack, 18 | get_language_packs, 19 | is_valid_locale, 20 | translator, 21 | ) 22 | 23 | 24 | class TranslationsHandler(SchemaHandler): 25 | """An API handler for translations.""" 26 | 27 | @tornado.web.authenticated 28 | async def get(self, locale: str | None = None) -> None: 29 | """ 30 | Get installed language packs. 31 | 32 | If `locale` is equals to "default", the default locale will be used. 33 | 34 | Parameters 35 | ---------- 36 | locale: str, optional 37 | If no locale is provided, it will list all the installed language packs. 38 | Default is `None`. 39 | """ 40 | data: dict 41 | data, message = {}, "" 42 | try: 43 | current_loop = tornado.ioloop.IOLoop.current() 44 | if locale is None: 45 | data, message = await current_loop.run_in_executor( 46 | None, 47 | partial(get_language_packs, display_locale=self.get_current_locale()), 48 | ) 49 | else: 50 | locale = locale or SYS_LOCALE 51 | if locale == "default": 52 | locale = SYS_LOCALE 53 | data, message = await current_loop.run_in_executor( 54 | None, partial(get_language_pack, locale) 55 | ) 56 | if data == {} and not message: 57 | if is_valid_locale(locale): 58 | message = f"Language pack '{locale}' not installed!" 59 | else: 60 | message = f"Language pack '{locale}' not valid!" 61 | elif is_valid_locale(locale): 62 | # only change locale if the language pack is installed and valid 63 | translator.set_locale(locale) 64 | except Exception: 65 | message = traceback.format_exc() 66 | 67 | self.set_status(200) 68 | self.finish(json.dumps({"data": data, "message": message})) 69 | -------------------------------------------------------------------------------- /jupyterlab_server/workspaces_app.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """A workspace management CLI""" 5 | from __future__ import annotations 6 | 7 | import json 8 | import sys 9 | import warnings 10 | from pathlib import Path 11 | from typing import Any 12 | 13 | from jupyter_core.application import JupyterApp 14 | from traitlets import Bool, Unicode 15 | 16 | from ._version import __version__ 17 | from .config import LabConfig 18 | from .workspaces_handler import WorkspacesManager 19 | 20 | # Default workspace ID 21 | # Needs to match PageConfig.defaultWorkspace define in packages/coreutils/src/pageconfig.ts 22 | DEFAULT_WORKSPACE = "default" 23 | 24 | 25 | class WorkspaceListApp(JupyterApp, LabConfig): 26 | """An app to list workspaces.""" 27 | 28 | version = __version__ 29 | description = """ 30 | Print all the workspaces available 31 | 32 | If '--json' flag is passed in, a single 'json' object is printed. 33 | If '--jsonlines' flag is passed in, 'json' object of each workspace separated by a new line is printed. 34 | If nothing is passed in, workspace ids list is printed. 35 | """ 36 | flags = dict( 37 | jsonlines=( 38 | {"WorkspaceListApp": {"jsonlines": True}}, 39 | ("Produce machine-readable JSON Lines output."), 40 | ), 41 | json=( 42 | {"WorkspaceListApp": {"json": True}}, 43 | ("Produce machine-readable JSON object output."), 44 | ), 45 | ) 46 | 47 | jsonlines = Bool( 48 | False, 49 | config=True, 50 | help=( 51 | "If True, the output will be a newline-delimited JSON (see https://jsonlines.org/) of objects, " 52 | "one per JupyterLab workspace, each with the details of the relevant workspace" 53 | ), 54 | ) 55 | json = Bool( 56 | False, 57 | config=True, 58 | help=( 59 | "If True, each line of output will be a JSON object with the " 60 | "details of the workspace." 61 | ), 62 | ) 63 | 64 | def initialize(self, *args: Any, **kwargs: Any) -> None: 65 | """Initialize the app.""" 66 | super().initialize(*args, **kwargs) 67 | self.manager = WorkspacesManager(self.workspaces_dir) 68 | 69 | def start(self) -> None: 70 | """Start the app.""" 71 | workspaces = self.manager.list_workspaces() 72 | if self.jsonlines: 73 | for workspace in workspaces: 74 | print(json.dumps(workspace)) 75 | elif self.json: 76 | print(json.dumps(workspaces)) 77 | else: 78 | for workspace in workspaces: 79 | print(workspace["metadata"]["id"]) 80 | 81 | 82 | class WorkspaceExportApp(JupyterApp, LabConfig): 83 | """A workspace export app.""" 84 | 85 | version = __version__ 86 | description = """ 87 | Export a JupyterLab workspace 88 | 89 | If no arguments are passed in, this command will export the default 90 | workspace. 91 | If a workspace name is passed in, this command will export that workspace. 92 | If no workspace is found, this command will export an empty workspace. 93 | """ 94 | 95 | def initialize(self, *args: Any, **kwargs: Any) -> None: 96 | """Initialize the app.""" 97 | super().initialize(*args, **kwargs) 98 | self.manager = WorkspacesManager(self.workspaces_dir) 99 | 100 | def start(self) -> None: 101 | """Start the app.""" 102 | if len(self.extra_args) > 1: # pragma: no cover 103 | warnings.warn("Too many arguments were provided for workspace export.") 104 | self.exit(1) 105 | 106 | raw = DEFAULT_WORKSPACE if not self.extra_args else self.extra_args[0] 107 | try: 108 | workspace = self.manager.load(raw) 109 | print(json.dumps(workspace)) 110 | except Exception: # pragma: no cover 111 | self.log.error(json.dumps(dict(data=dict(), metadata=dict(id=raw)))) 112 | 113 | 114 | class WorkspaceImportApp(JupyterApp, LabConfig): 115 | """A workspace import app.""" 116 | 117 | version = __version__ 118 | description = """ 119 | Import a JupyterLab workspace 120 | 121 | This command will import a workspace from a JSON file. The format of the 122 | file must be the same as what the export functionality emits. 123 | """ 124 | workspace_name = Unicode( 125 | None, 126 | config=True, 127 | allow_none=True, 128 | help=""" 129 | Workspace name. If given, the workspace ID in the imported 130 | file will be replaced with a new ID pointing to this 131 | workspace name. 132 | """, 133 | ) 134 | 135 | aliases = {"name": "WorkspaceImportApp.workspace_name"} 136 | 137 | def initialize(self, *args: Any, **kwargs: Any) -> None: 138 | """Initialize the app.""" 139 | super().initialize(*args, **kwargs) 140 | self.manager = WorkspacesManager(self.workspaces_dir) 141 | 142 | def start(self) -> None: 143 | """Start the app.""" 144 | if len(self.extra_args) != 1: # pragma: no cover 145 | self.log.info("One argument is required for workspace import.") 146 | self.exit(1) 147 | 148 | with self._smart_open() as fid: 149 | try: # to load, parse, and validate the workspace file. 150 | workspace = self._validate(fid) 151 | except Exception as e: # pragma: no cover 152 | self.log.info("%s is not a valid workspace:\n%s", fid.name, e) 153 | self.exit(1) 154 | 155 | try: 156 | workspace_path = self.manager.save(workspace["metadata"]["id"], json.dumps(workspace)) 157 | except Exception as e: # pragma: no cover 158 | self.log.info("Workspace could not be exported:\n%s", e) 159 | self.exit(1) 160 | 161 | self.log.info("Saved workspace: %s", workspace_path) 162 | 163 | def _smart_open(self) -> Any: 164 | file_name = self.extra_args[0] 165 | 166 | if file_name == "-": # pragma: no cover 167 | return sys.stdin 168 | 169 | file_path = Path(file_name).resolve() 170 | 171 | if not file_path.exists(): # pragma: no cover 172 | self.log.info("%s does not exist.", file_name) 173 | self.exit(1) 174 | 175 | return file_path.open(encoding="utf-8") 176 | 177 | def _validate(self, data: Any) -> Any: 178 | workspace = json.load(data) 179 | 180 | if "data" not in workspace: 181 | msg = "The `data` field is missing." 182 | raise Exception(msg) 183 | 184 | # If workspace_name is set in config, inject the 185 | # name into the workspace metadata. 186 | if self.workspace_name is not None and self.workspace_name: 187 | workspace["metadata"] = {"id": self.workspace_name} 188 | elif "id" not in workspace["metadata"]: 189 | msg = "The `id` field is missing in `metadata`." 190 | raise Exception(msg) 191 | 192 | return workspace 193 | -------------------------------------------------------------------------------- /jupyterlab_server/workspaces_handler.py: -------------------------------------------------------------------------------- 1 | """Tornado handlers for frontend config storage.""" 2 | 3 | # Copyright (c) Jupyter Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import hashlib 8 | import json 9 | import re 10 | import unicodedata 11 | import urllib 12 | from pathlib import Path 13 | from typing import Any 14 | 15 | from jupyter_server import _tz as tz 16 | from jupyter_server.base.handlers import APIHandler 17 | from jupyter_server.extension.handler import ExtensionHandlerJinjaMixin, ExtensionHandlerMixin 18 | from jupyter_server.utils import url_path_join as ujoin 19 | from tornado import web 20 | from traitlets.config import LoggingConfigurable 21 | 22 | # The JupyterLab workspace file extension. 23 | WORKSPACE_EXTENSION = ".jupyterlab-workspace" 24 | 25 | 26 | def _list_workspaces(directory: Path, prefix: str) -> list[dict[str, Any]]: 27 | """ 28 | Return the list of workspaces in a given directory beginning with the 29 | given prefix. 30 | """ 31 | workspaces: list = [] 32 | if not directory.exists(): 33 | return workspaces 34 | 35 | items = [ 36 | item 37 | for item in directory.iterdir() 38 | if item.name.startswith(prefix) and item.name.endswith(WORKSPACE_EXTENSION) 39 | ] 40 | items.sort() 41 | 42 | for slug in items: 43 | workspace_path: Path = directory / slug 44 | if workspace_path.exists(): 45 | workspace = _load_with_file_times(workspace_path) 46 | workspaces.append(workspace) 47 | 48 | return workspaces 49 | 50 | 51 | def _load_with_file_times(workspace_path: Path) -> dict: 52 | """ 53 | Load workspace JSON from disk, overwriting the `created` and `last_modified` 54 | metadata with current file stat information 55 | """ 56 | stat = workspace_path.stat() 57 | with workspace_path.open(encoding="utf-8") as fid: 58 | workspace = json.load(fid) 59 | workspace["metadata"].update( 60 | last_modified=tz.utcfromtimestamp(stat.st_mtime).isoformat(), 61 | created=tz.utcfromtimestamp(stat.st_ctime).isoformat(), 62 | ) 63 | return workspace 64 | 65 | 66 | def slugify( 67 | raw: str, base: str = "", sign: bool = True, max_length: int = 128 - len(WORKSPACE_EXTENSION) 68 | ) -> str: 69 | """ 70 | Use the common superset of raw and base values to build a slug shorter 71 | than max_length. By default, base value is an empty string. 72 | Convert spaces to hyphens. Remove characters that aren't alphanumerics 73 | underscores, or hyphens. Convert to lowercase. Strip leading and trailing 74 | whitespace. 75 | Add an optional short signature suffix to prevent collisions. 76 | Modified from Django utils: 77 | https://github.com/django/django/blob/master/django/utils/text.py 78 | """ 79 | raw = raw if raw.startswith("/") else "/" + raw 80 | signature = "" 81 | if sign: 82 | data = raw[1:] # Remove initial slash that always exists for digest. 83 | signature = "-" + hashlib.sha256(data.encode("utf-8")).hexdigest()[:4] 84 | base = (base if base.startswith("/") else "/" + base).lower() 85 | raw = raw.lower() 86 | common = 0 87 | limit = min(len(base), len(raw)) 88 | while common < limit and base[common] == raw[common]: 89 | common += 1 90 | value = ujoin(base[common:], raw) 91 | value = urllib.parse.unquote(value) 92 | value = unicodedata.normalize("NFKC", value).encode("ascii", "ignore").decode("ascii") 93 | value = re.sub(r"[^\w\s-]", "", value).strip() 94 | value = re.sub(r"[-\s]+", "-", value) 95 | return value[: max_length - len(signature)] + signature 96 | 97 | 98 | class WorkspacesManager(LoggingConfigurable): 99 | """A manager for workspaces.""" 100 | 101 | def __init__(self, path: str) -> None: 102 | """Initialize a workspaces manager with content in ``path``.""" 103 | super() 104 | if not path: 105 | msg = "Workspaces directory is not set" 106 | raise ValueError(msg) 107 | self.workspaces_dir = Path(path) 108 | 109 | def delete(self, space_name: str) -> None: 110 | """Remove a workspace ``space_name``.""" 111 | slug = slugify(space_name) 112 | workspace_path = self.workspaces_dir / (slug + WORKSPACE_EXTENSION) 113 | 114 | if not workspace_path.exists(): 115 | msg = f"Workspace {space_name!r} ({slug!r}) not found" 116 | raise FileNotFoundError(msg) 117 | 118 | # to delete the workspace file. 119 | workspace_path.unlink() 120 | 121 | def list_workspaces(self) -> list: 122 | """List all available workspaces.""" 123 | prefix = slugify("", sign=False) 124 | return _list_workspaces(self.workspaces_dir, prefix) 125 | 126 | def load(self, space_name: str) -> dict: 127 | """Load the workspace ``space_name``.""" 128 | slug = slugify(space_name) 129 | workspace_path = self.workspaces_dir / (slug + WORKSPACE_EXTENSION) 130 | 131 | if workspace_path.exists(): 132 | # to load and parse the workspace file. 133 | return _load_with_file_times(workspace_path) 134 | _id = space_name if space_name.startswith("/") else "/" + space_name 135 | return dict(data=dict(), metadata=dict(id=_id)) 136 | 137 | def save(self, space_name: str, raw: str) -> Path: 138 | """Save the ``raw`` data as workspace ``space_name``.""" 139 | if not self.workspaces_dir.exists(): 140 | self.workspaces_dir.mkdir(parents=True) 141 | 142 | workspace = {} 143 | 144 | # Make sure the data is valid JSON. 145 | try: 146 | decoder = json.JSONDecoder() 147 | workspace = decoder.decode(raw) 148 | except Exception as e: 149 | raise ValueError(str(e)) from e 150 | 151 | # Make sure metadata ID matches the workspace name. 152 | # Transparently support an optional initial root `/`. 153 | metadata_id = workspace["metadata"]["id"] 154 | metadata_id = metadata_id if metadata_id.startswith("/") else "/" + metadata_id 155 | metadata_id = urllib.parse.unquote(metadata_id) 156 | if metadata_id != "/" + space_name: 157 | message = f"Workspace metadata ID mismatch: expected {space_name!r} got {metadata_id!r}" 158 | raise ValueError(message) 159 | 160 | slug = slugify(space_name) 161 | workspace_path = self.workspaces_dir / (slug + WORKSPACE_EXTENSION) 162 | 163 | # Write the workspace data to a file. 164 | workspace_path.write_text(raw, encoding="utf-8") 165 | 166 | return workspace_path 167 | 168 | 169 | class WorkspacesHandler(ExtensionHandlerMixin, ExtensionHandlerJinjaMixin, APIHandler): 170 | """A workspaces API handler.""" 171 | 172 | def initialize(self, name: str, manager: WorkspacesManager, **kwargs: Any) -> None: # noqa: ARG002 173 | """Initialize the handler.""" 174 | super().initialize(name) 175 | self.manager = manager 176 | 177 | @web.authenticated 178 | def delete(self, space_name: str) -> None: 179 | """Remove a workspace""" 180 | if not space_name: 181 | raise web.HTTPError(400, "Workspace name is required for DELETE") 182 | 183 | try: 184 | self.manager.delete(space_name) 185 | return self.set_status(204) 186 | except FileNotFoundError as e: 187 | raise web.HTTPError(404, str(e)) from e 188 | except Exception as e: # pragma: no cover 189 | raise web.HTTPError(500, str(e)) from e 190 | 191 | @web.authenticated 192 | async def get(self, space_name: str = "") -> Any: 193 | """Get workspace(s) data""" 194 | 195 | try: 196 | if not space_name: 197 | workspaces = self.manager.list_workspaces() 198 | ids = [] 199 | values = [] 200 | for workspace in workspaces: 201 | ids.append(workspace["metadata"]["id"]) 202 | values.append(workspace) 203 | return self.finish(json.dumps({"workspaces": {"ids": ids, "values": values}})) 204 | 205 | workspace = self.manager.load(space_name) 206 | return self.finish(json.dumps(workspace)) 207 | except Exception as e: # pragma: no cover 208 | raise web.HTTPError(500, str(e)) from e 209 | 210 | @web.authenticated 211 | def put(self, space_name: str = "") -> None: 212 | """Update workspace data""" 213 | if not space_name: 214 | raise web.HTTPError(400, "Workspace name is required for PUT.") 215 | 216 | raw = self.request.body.strip().decode("utf-8") 217 | 218 | # Make sure the data is valid JSON. 219 | try: 220 | self.manager.save(space_name, raw) 221 | except ValueError as e: 222 | raise web.HTTPError(400, str(e)) from e 223 | except Exception as e: # pragma: no cover 224 | raise web.HTTPError(500, str(e)) from e 225 | 226 | self.set_status(204) 227 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | [build-system] 5 | requires = ["hatchling>=1.7"] 6 | build-backend = "hatchling.build" 7 | 8 | [project] 9 | name = "jupyterlab_server" 10 | dynamic = ["version"] 11 | license = { file = "LICENSE" } 12 | description = "A set of server components for JupyterLab and JupyterLab like applications." 13 | keywords = ["jupyter", "jupyterlab"] 14 | classifiers = [ 15 | "Framework :: Jupyter", 16 | "Framework :: Jupyter :: JupyterLab", 17 | "Intended Audience :: Developers", 18 | "Intended Audience :: Science/Research", 19 | "Intended Audience :: System Administrators", 20 | "License :: OSI Approved :: BSD License", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3 :: Only", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Typing :: Typed"] 29 | requires-python = ">=3.8" 30 | dependencies = [ 31 | "babel>=2.10", 32 | "importlib_metadata>=4.8.3;python_version<\"3.10\"", 33 | "jinja2>=3.0.3", 34 | "json5>=0.9.0", 35 | "jsonschema>=4.18.0", 36 | "jupyter_server>=1.21,<3", 37 | "packaging>=21.3", 38 | "requests>=2.31", 39 | ] 40 | 41 | [[project.authors]] 42 | name = "Jupyter Development Team" 43 | email = "jupyter@googlegroups.com" 44 | 45 | [project.readme] 46 | file = "README.md" 47 | content-type = "text/markdown" 48 | 49 | [project.urls] 50 | Homepage = "https://jupyterlab-server.readthedocs.io" 51 | Documentation = "https://jupyterlab-server.readthedocs.io" 52 | Funding = "https://numfocus.org/donate-to-jupyter" 53 | Source = "https://github.com/jupyterlab/jupyterlab_server" 54 | Tracker = "https://github.com/jupyterlab/jupyterlab_server/issues" 55 | 56 | [project.optional-dependencies] 57 | docs = [ 58 | "autodoc-traits", 59 | "pydata_sphinx_theme", 60 | "sphinx", 61 | "sphinx-copybutton", 62 | "sphinxcontrib-openapi>0.8", 63 | "myst-parser", 64 | "mistune<4", 65 | "jinja2<3.2.0" 66 | ] 67 | openapi = [ 68 | "openapi_core~=0.18.0", 69 | "ruamel.yaml", 70 | ] 71 | test = [ 72 | "hatch", 73 | "ipykernel", 74 | "pytest-jupyter[server]>=0.6.2", 75 | "openapi_core~=0.18.0", 76 | "openapi-spec-validator>=0.6.0,<0.8.0", 77 | "sphinxcontrib_spelling", 78 | "requests_mock", 79 | "ruamel.yaml", 80 | "pytest>=7.0,<8", 81 | "pytest-console-scripts", 82 | "pytest-cov", 83 | "pytest-timeout", 84 | "strict-rfc3339", 85 | "werkzeug", 86 | ] 87 | 88 | [tool.hatch.version] 89 | path = "jupyterlab_server/_version.py" 90 | validate-bump = false 91 | 92 | [tool.hatch.envs.docs] 93 | features = ["docs"] 94 | [tool.hatch.envs.docs.scripts] 95 | build = "make -C docs html SPHINXOPTS='-W'" 96 | 97 | [tool.hatch.envs.test] 98 | features = ["test"] 99 | [tool.hatch.envs.test.scripts] 100 | test = "python -m pytest -vv {args}" 101 | nowarn = "test -W default {args}" 102 | 103 | [tool.hatch.envs.cov] 104 | features = ["test"] 105 | dependencies = ["coverage", "pytest-cov"] 106 | [tool.hatch.envs.cov.scripts] 107 | test = "python -m pytest -vv --cov jupyterlab_server --cov-branch --cov-report term-missing:skip-covered {args}" 108 | nowarn = "test -W default {args}" 109 | 110 | [tool.hatch.envs.lint] 111 | detached = true 112 | dependencies = ["pre-commit"] 113 | [tool.hatch.envs.lint.scripts] 114 | build = [ 115 | "pre-commit run --all-files ruff", 116 | "pre-commit run --all-files ruff-format" 117 | ] 118 | 119 | [tool.hatch.envs.typing] 120 | dependencies = [ "pre-commit"] 121 | detached = true 122 | [tool.hatch.envs.typing.scripts] 123 | test = "pre-commit run --all-files --hook-stage manual mypy" 124 | 125 | [tool.pytest.ini_options] 126 | minversion = "6.0" 127 | xfail_strict = true 128 | log_cli_level = "info" 129 | addopts = [ 130 | "-ra", "--durations=10", "--color=yes", "--doctest-modules", 131 | "--showlocals", "--strict-markers", "--strict-config" 132 | ] 133 | testpaths = [ 134 | "tests/" 135 | ] 136 | timeout = 300 137 | # Restore this setting to debug failures 138 | # timeout_method = "thread" 139 | filterwarnings = [ 140 | "error", 141 | "ignore:ServerApp.preferred_dir config is deprecated:FutureWarning", 142 | # From openapi_schema_validator 143 | "module:write property is deprecated:DeprecationWarning", 144 | "module:read property is deprecated:DeprecationWarning", 145 | # From tornado.netutil.bind_sockets 146 | "module:unclosed \s*(?P.*?)\s*', 33 | html, 34 | ).group( # type: ignore 35 | "data" 36 | ) 37 | ) 38 | 39 | 40 | async def test_lab_handler(notebooks, jp_fetch): 41 | r = await jp_fetch("lab", "jlab_test_notebooks") 42 | assert r.code == 200 43 | # Check that the lab template is loaded 44 | html = r.body.decode() 45 | assert "Files" in html 46 | assert "JupyterLab Server Application" in html 47 | 48 | 49 | async def test_page_config(labserverapp, jp_fetch): 50 | r = await jp_fetch("lab") 51 | assert r.code == 200 52 | # Check that the lab template is loaded 53 | html = r.body.decode() 54 | page_config = extract_page_config(html) 55 | assert not page_config["treePath"] 56 | assert page_config["preferredPath"] == "/" 57 | 58 | def ispath(p): 59 | return p.endswith(("Dir", "Path")) or p == "serverRoot" 60 | 61 | nondirs = {k: v for k, v in page_config.items() if not ispath(k)} 62 | assert nondirs == { 63 | "appName": "JupyterLab Server Application", 64 | "appNamespace": "jupyterlab_server", 65 | "appUrl": "/lab", 66 | "appVersion": "", 67 | "baseUrl": "/a%40b/", 68 | "cacheFiles": True, 69 | "disabledExtensions": [], 70 | "federated_extensions": [], 71 | "fullAppUrl": "/a%40b/lab", 72 | "fullLabextensionsUrl": "/a%40b/lab/extensions", 73 | "fullLicensesUrl": "/a%40b/lab/api/licenses", 74 | "fullListingsUrl": "/a%40b/lab/api/listings", 75 | "fullMathjaxUrl": "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js", 76 | "fullSettingsUrl": "/a%40b/lab/api/settings", 77 | "fullStaticUrl": "/a%40b/static/jupyterlab_server", 78 | "fullThemesUrl": "/a%40b/lab/api/themes", 79 | "fullTranslationsApiUrl": "/a%40b/lab/api/translations", 80 | "fullTreeUrl": "/a%40b/lab/tree", 81 | "fullWorkspacesApiUrl": "/a%40b/lab/api/workspaces", 82 | "ignorePlugins": [], 83 | "labextensionsUrl": "/lab/extensions", 84 | "licensesUrl": "/lab/api/licenses", 85 | "listingsUrl": "/lab/api/listings", 86 | "mathjaxConfig": "TeX-AMS_HTML-full,Safe", 87 | "mode": "multiple-document", 88 | "notebookStartsKernel": True, 89 | "settingsUrl": "/lab/api/settings", 90 | "store_id": 0, 91 | "terminalsAvailable": True, 92 | "themesUrl": "/lab/api/themes", 93 | "translationsApiUrl": "/lab/api/translations", 94 | "treeUrl": "/lab/tree", 95 | "workspace": "default", 96 | "workspacesApiUrl": "/lab/api/workspaces", 97 | "wsUrl": "", 98 | } 99 | 100 | 101 | @pytest.fixture 102 | def serverapp_preferred_dir(jp_server_config, jp_root_dir): 103 | preferred_dir = Path(jp_root_dir, "my", "preferred_dir") 104 | preferred_dir.mkdir(parents=True, exist_ok=True) 105 | jp_server_config.ServerApp.preferred_dir = str(preferred_dir) 106 | return preferred_dir 107 | 108 | 109 | async def test_app_preferred_dir(serverapp_preferred_dir, labserverapp, jp_fetch): 110 | r = await jp_fetch("lab") 111 | assert r.code == 200 112 | # Check that the lab template is loaded 113 | html = r.body.decode() 114 | page_config = extract_page_config(html) 115 | api_path = str(serverapp_preferred_dir.relative_to(labserverapp.serverapp.root_dir).as_posix()) 116 | assert page_config["preferredPath"] == api_path 117 | 118 | 119 | async def test_contents_manager_preferred_dir(jp_root_dir, labserverapp, jp_fetch): 120 | preferred_dir = Path(jp_root_dir, "my", "preferred_dir") 121 | preferred_dir.mkdir(parents=True, exist_ok=True) 122 | try: 123 | _ = labserverapp.serverapp.contents_manager.preferred_dir 124 | labserverapp.serverapp.contents_manager.preferred_dir = str(preferred_dir) 125 | except AttributeError: 126 | pytest.skip("Skipping contents manager test, trait not present") 127 | 128 | r = await jp_fetch("lab") 129 | assert r.code == 200 130 | # Check that the lab template is loaded 131 | html = r.body.decode() 132 | page_config = extract_page_config(html) 133 | api_path = str(preferred_dir.relative_to(labserverapp.serverapp.root_dir).as_posix()) 134 | assert page_config["preferredPath"] == api_path 135 | 136 | 137 | async def test_notebook_handler(notebooks, jp_fetch): 138 | for nbpath in notebooks: 139 | r = await jp_fetch("lab", nbpath) 140 | assert r.code == 200 141 | # Check that the lab template is loaded 142 | html = r.body.decode() 143 | assert "JupyterLab Server Application" in html 144 | 145 | 146 | async def test_404(notebooks, jp_fetch): 147 | with pytest.raises(tornado.httpclient.HTTPClientError) as e: 148 | await jp_fetch("foo") 149 | assert expected_http_error(e, 404) 150 | -------------------------------------------------------------------------------- /tests/test_licenses_api.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """Test the Settings service API. 5 | """ 6 | import csv 7 | import io 8 | import json 9 | import mimetypes 10 | 11 | import mistune 12 | import pytest 13 | 14 | from jupyterlab_server import LicensesApp 15 | from jupyterlab_server.licenses_handler import DEFAULT_THIRD_PARTY_LICENSE_FILE, LicensesManager 16 | 17 | # utilities 18 | 19 | FULL_ENTRY = ( 20 | ("name", "@jupyterlab/foo"), 21 | ("versionInfo", "0.0.1"), 22 | ("licenseId", "BSD-3-Clause"), 23 | ("extractedText", "> license text goes here"), 24 | ) 25 | 26 | 27 | def _read_csv(csv_text): 28 | with io.StringIO() as csvfile: 29 | csvfile.write(csv_text) 30 | csvfile.seek(0) 31 | return [*csv.DictReader(csvfile)] 32 | 33 | 34 | def _make_static_dir(app, tmp_path, has_licenses=True, license_json=None, package_in_app=False): 35 | app_dir = tmp_path / "app" 36 | static_dir = app_dir / "static" 37 | static_dir.mkdir(parents=True) 38 | 39 | package_text = json.dumps({"name": "@jupyterlab/top", "version": "0.0.1"}) 40 | package_json = (app_dir if package_in_app else static_dir) / "package.json" 41 | package_json.write_text(package_text, encoding="utf-8") 42 | 43 | if has_licenses: 44 | (static_dir / DEFAULT_THIRD_PARTY_LICENSE_FILE).write_text( 45 | license_json or _good_license_json(), 46 | encoding="utf-8", 47 | ) 48 | 49 | app.static_dir = str(static_dir) 50 | 51 | 52 | def _good_license_json(): 53 | return json.dumps({"packages": [dict(FULL_ENTRY[:i]) for i in range(1 + len(FULL_ENTRY))]}) 54 | 55 | 56 | @pytest.fixture( 57 | params=[ 58 | ["application/json", "json", json.loads], 59 | ["text/csv", "csv", _read_csv], 60 | ["text/markdown", "markdown", mistune.markdown], 61 | ] 62 | ) 63 | def mime_format_parser(request): 64 | return request.param 65 | 66 | 67 | @pytest.fixture(params=[True, False]) 68 | def has_licenses(request): 69 | return request.param 70 | 71 | 72 | @pytest.fixture 73 | def licenses_app(tmp_path, has_licenses): 74 | app = LicensesApp() 75 | _make_static_dir(app, tmp_path, has_licenses) 76 | return app 77 | 78 | 79 | # the actual tests 80 | 81 | 82 | @pytest.mark.parametrize("has_static_dir", [True, False]) 83 | @pytest.mark.parametrize("full_text", ["true", "false"]) 84 | @pytest.mark.parametrize("bundles_pattern", ["", "@jupyterlab/.*", "nothing"]) 85 | async def test_get_license_report( 86 | mime_format_parser, 87 | has_static_dir, 88 | has_licenses, 89 | full_text, 90 | bundles_pattern, 91 | jp_fetch, 92 | labserverapp, 93 | tmp_path, 94 | ): 95 | if has_static_dir: 96 | _make_static_dir(labserverapp, tmp_path, has_licenses) 97 | mime, fmt, parse = mime_format_parser 98 | params = {"format": fmt, "full_text": full_text} 99 | if bundles_pattern: 100 | params["bundles"] = bundles_pattern 101 | r = await jp_fetch("lab", "api", "licenses/", params=params) 102 | assert r.code == 200 103 | assert r.headers["Content-type"] == mime 104 | res = r.body.decode() 105 | assert parse(res) is not None 106 | 107 | 108 | async def test_download_license_report( 109 | jp_fetch, 110 | labserverapp, 111 | mime_format_parser, 112 | ): 113 | mime, fmt, parse = mime_format_parser 114 | params = {"format": fmt, "download": "1"} 115 | r = await jp_fetch("lab", "api", "licenses/", params=params) 116 | assert r.code == 200 117 | assert r.headers["Content-type"] == mime 118 | extension = mimetypes.guess_extension(mime) 119 | assert extension, f"no extension guessed for {mime}" 120 | assert extension in r.headers["Content-Disposition"], f"{r.headers}" 121 | 122 | 123 | async def test_dev_mode_license_report( 124 | jp_fetch, 125 | labserverapp, 126 | tmp_path, 127 | ): 128 | _make_static_dir(labserverapp, tmp_path, package_in_app=True) 129 | r = await jp_fetch("lab", "api", "licenses/") 130 | assert r.code == 200 131 | 132 | 133 | @pytest.mark.parametrize( 134 | "license_json", 135 | [ 136 | "// leading comment\n" + _good_license_json(), 137 | _good_license_json().replace("packages", "whatever"), 138 | ], 139 | ) 140 | async def test_malformed_license_report( 141 | license_json, 142 | jp_fetch, 143 | labserverapp, 144 | tmp_path, 145 | ): 146 | _make_static_dir(labserverapp, tmp_path, license_json=license_json) 147 | r = await jp_fetch("lab", "api", "licenses/") 148 | assert r.code == 200 149 | 150 | 151 | async def test_licenses_cli(licenses_app, capsys, mime_format_parser): 152 | mime, fmt, parse = mime_format_parser 153 | args = [] 154 | if fmt != "markdown": 155 | args += [f"--{fmt}"] 156 | licenses_app.initialize(args) 157 | 158 | with pytest.raises(SystemExit) as exited: 159 | licenses_app.start() 160 | 161 | assert exited.type == SystemExit 162 | assert exited.value.code == 0 163 | 164 | captured = capsys.readouterr() 165 | assert parse(captured.out) is not None 166 | 167 | 168 | @pytest.fixture 169 | def a_fake_labextension(tmp_path): 170 | """just enough of an extension to be parsed""" 171 | ext_name = "@an-org/an-extension" 172 | ext_path = tmp_path / ext_name 173 | package_data = {"name": ext_name} 174 | bundle_data = {"packages": [dict(FULL_ENTRY)]} 175 | 176 | package_json = ext_path / "package.json" 177 | third_party_licenses = ext_path / "static" / DEFAULT_THIRD_PARTY_LICENSE_FILE 178 | 179 | third_party_licenses.parent.mkdir(parents=True) 180 | 181 | package_json.write_text(json.dumps(package_data), encoding="utf-8") 182 | third_party_licenses.write_text(json.dumps(bundle_data), encoding="utf-8") 183 | 184 | return ext_path, ext_name 185 | 186 | 187 | @pytest.fixture 188 | def a_licenses_manager(): 189 | return LicensesManager() 190 | 191 | 192 | def test_labextension_bundle(a_fake_labextension, a_licenses_manager): 193 | ext_path, ext_name = a_fake_labextension 194 | bundle = a_licenses_manager.license_bundle(ext_path, ext_name) 195 | assert bundle["packages"][0]["name"] == dict(FULL_ENTRY)["name"] 196 | -------------------------------------------------------------------------------- /tests/test_listings_api.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import json 5 | 6 | import requests_mock 7 | 8 | from jupyterlab_server.listings_handler import ListingsHandler, fetch_listings 9 | from jupyterlab_server.test_utils import validate_request 10 | 11 | 12 | async def test_get_listing(jp_fetch, labserverapp): 13 | url = r"lab/api/listings/@jupyterlab/extensionmanager-extension/listings.json" 14 | r = await jp_fetch(*url.split("/")) 15 | validate_request(r) 16 | 17 | 18 | def test_fetch_listings(): 19 | ListingsHandler.allowed_extensions_uris = ["http://foo"] # type:ignore 20 | ListingsHandler.blocked_extensions_uris = ["http://bar"] # type:ignore 21 | with requests_mock.Mocker() as m: 22 | data = dict(blocked_extensions=[]) # type:ignore 23 | m.get("http://bar", text=json.dumps(data)) 24 | data = dict(allowed_extensions=[]) 25 | m.get("http://foo", text=json.dumps(data)) 26 | fetch_listings(None) 27 | ListingsHandler.allowed_extensions_uris = [] # type:ignore 28 | ListingsHandler.blocked_extensions_uris = [] # type:ignore 29 | -------------------------------------------------------------------------------- /tests/test_process.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import os 5 | import sys 6 | import warnings 7 | 8 | import pytest 9 | 10 | from jupyterlab_server.process import Process, WatchHelper, which 11 | from jupyterlab_server.process_app import ProcessApp 12 | 13 | 14 | def test_which(): 15 | assert which("jupyter") 16 | 17 | 18 | async def test_process(): 19 | p = Process([sys.executable, "--version"]) 20 | p.get_log().info("test") 21 | assert p.wait() == 0 22 | 23 | p = Process([sys.executable, "--version"]) 24 | p.get_log().info("test") 25 | assert await p.wait_async() == 0 26 | assert p.terminate() == 0 27 | 28 | 29 | @pytest.mark.skipif(os.name == "nt", reason="Fails on Windows") 30 | async def test_watch_helper(): 31 | helper = WatchHelper([sys.executable, "-i"], ">>>") 32 | helper.terminate() 33 | helper.wait() 34 | 35 | 36 | def test_process_app(): 37 | class TestApp(ProcessApp): 38 | name = "tests" 39 | 40 | app = TestApp() 41 | app.initialize_server([]) 42 | try: 43 | app.initialize() 44 | with pytest.raises(SystemExit): 45 | app.start() 46 | # Kandle exception on older versions of server. 47 | except Exception as e: 48 | # Convert to warning so the test will pass on min version test. 49 | warnings.warn(str(e), stacklevel=2) 50 | -------------------------------------------------------------------------------- /tests/test_settings_api.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """Test the Settings service API. 5 | """ 6 | import json 7 | from pathlib import Path 8 | 9 | import json5 # type:ignore 10 | import pytest 11 | from strict_rfc3339 import rfc3339_to_timestamp # type:ignore 12 | from tornado.httpclient import HTTPClientError 13 | 14 | from jupyterlab_server.test_utils import big_unicode_string, expected_http_error, validate_request 15 | 16 | 17 | async def test_get_settings_overrides_dicts(jp_fetch, labserverapp): 18 | # Check that values that are dictionaries in overrides.json are 19 | # merged with the schema. 20 | id = "@jupyterlab/apputils-extension:themes" 21 | r = await jp_fetch("lab", "api", "settings", id) 22 | validate_request(r) 23 | res = r.body.decode() 24 | data = json.loads(res) 25 | assert data["id"] == id 26 | schema = data["schema"] 27 | # Check that overrides.json file is respected. 28 | assert schema["properties"]["codeCellConfig"]["default"]["lineNumbers"] is True 29 | assert len(schema["properties"]["codeCellConfig"]["default"]) == 15 30 | 31 | 32 | @pytest.mark.parametrize("ext", ["json", "json5"]) 33 | async def test_get_settings_overrides_d_dicts( 34 | jp_fetch, jp_serverapp, make_labserver_extension_app, ext 35 | ): 36 | # Check that values that are dictionaries in overrides.d/*.json are 37 | # merged with the schema. 38 | labserverapp = make_labserver_extension_app() 39 | labserverapp._link_jupyter_server_extension(jp_serverapp) 40 | id = "@jupyterlab/apputils-extension:themes" 41 | overrides_d = Path(labserverapp.app_settings_dir) / "overrides.d" 42 | overrides_d.mkdir(exist_ok=True, parents=True) 43 | for i in range(10): 44 | text = json.dumps({id: {"codeCellConfig": {"cursorBlinkRate": 530 + i}}}) 45 | if ext == "json5": 46 | text += "\n// a comment" 47 | (overrides_d / f"foo-{i}.{ext}").write_text(text, encoding="utf-8") 48 | # Initialize labserverapp only after the overrides were created 49 | labserverapp.initialize() 50 | r = await jp_fetch("lab", "api", "settings", id) 51 | validate_request(r) 52 | res = r.body.decode() 53 | data = json.loads(res) 54 | assert data["id"] == id 55 | schema = data["schema"] 56 | # Check that the last overrides.d/*.json file is respected. 57 | assert schema["properties"]["codeCellConfig"]["default"]["cursorBlinkRate"] == 539 58 | 59 | 60 | async def test_get_settings(jp_fetch, labserverapp): 61 | id = "@jupyterlab/apputils-extension:themes" 62 | r = await jp_fetch("lab", "api", "settings", id) 63 | validate_request(r) 64 | res = r.body.decode() 65 | data = json.loads(res) 66 | assert data["id"] == id 67 | schema = data["schema"] 68 | # Check that overrides.json file is respected. 69 | assert schema["properties"]["theme"]["default"] == "JupyterLab Dark" 70 | assert "raw" in res 71 | 72 | 73 | async def test_get_federated(jp_fetch, labserverapp): 74 | id = "@jupyterlab/apputils-extension-federated:themes" 75 | r = await jp_fetch("lab", "api", "settings", id) 76 | validate_request(r) 77 | res = r.body.decode() 78 | assert "raw" in res 79 | 80 | 81 | async def test_get_bad(jp_fetch, labserverapp): 82 | with pytest.raises(HTTPClientError) as e: 83 | await jp_fetch("foo") 84 | assert expected_http_error(e, 404) 85 | 86 | 87 | async def test_listing(jp_fetch, labserverapp): 88 | ids = [ 89 | "@jupyterlab/apputils-extension:themes", 90 | "@jupyterlab/apputils-extension-federated:themes", 91 | "@jupyterlab/codemirror-extension:commands", 92 | "@jupyterlab/codemirror-extension-federated:commands", 93 | "@jupyterlab/shortcuts-extension:plugin", 94 | "@jupyterlab/translation-extension:plugin", 95 | "@jupyterlab/unicode-extension:plugin", 96 | ] 97 | versions = ["N/A", "N/A", "test-version"] 98 | r = await jp_fetch("lab", "api", "settings/") 99 | validate_request(r) 100 | res = r.body.decode() 101 | response = json.loads(res) 102 | response_ids = [item["id"] for item in response["settings"]] 103 | response_schemas = [item["schema"] for item in response["settings"]] 104 | response_versions = [item["version"] for item in response["settings"]] 105 | assert set(response_ids) == set(ids) 106 | assert all(response_schemas) 107 | assert set(response_versions) == set(versions) 108 | last_modifieds = [item["last_modified"] for item in response["settings"]] 109 | createds = [item["created"] for item in response["settings"]] 110 | assert {None} == set(last_modifieds + createds) 111 | 112 | 113 | async def test_listing_ids(jp_fetch, labserverapp): 114 | ids = [ 115 | "@jupyterlab/apputils-extension:themes", 116 | "@jupyterlab/apputils-extension-federated:themes", 117 | "@jupyterlab/codemirror-extension:commands", 118 | "@jupyterlab/codemirror-extension-federated:commands", 119 | "@jupyterlab/shortcuts-extension:plugin", 120 | "@jupyterlab/translation-extension:plugin", 121 | "@jupyterlab/unicode-extension:plugin", 122 | ] 123 | r = await jp_fetch("lab", "api", "settings/", params={"ids_only": "true"}) 124 | validate_request(r) 125 | res = r.body.decode() 126 | response = json.loads(res) 127 | response_ids = [item["id"] for item in response["settings"]] 128 | # Checks the IDs list is correct 129 | assert set(response_ids) == set(ids) 130 | 131 | # Checks there is only the 'id' key in each item 132 | assert all( 133 | (len(item.keys()) == 1 and next(iter(item.keys())) == "id") for item in response["settings"] 134 | ) 135 | 136 | 137 | async def test_patch(jp_fetch, labserverapp): 138 | id = "@jupyterlab/shortcuts-extension:plugin" 139 | 140 | r = await jp_fetch( 141 | "lab", "api", "settings", id, method="PUT", body=json.dumps(dict(raw=json5.dumps(dict()))) 142 | ) 143 | validate_request(r) 144 | 145 | r = await jp_fetch( 146 | "lab", 147 | "api", 148 | "settings", 149 | id, 150 | method="GET", 151 | ) 152 | validate_request(r) 153 | data = json.loads(r.body.decode()) 154 | first_created = rfc3339_to_timestamp(data["created"]) 155 | first_modified = rfc3339_to_timestamp(data["last_modified"]) 156 | 157 | r = await jp_fetch( 158 | "lab", "api", "settings", id, method="PUT", body=json.dumps(dict(raw=json5.dumps(dict()))) 159 | ) 160 | validate_request(r) 161 | 162 | r = await jp_fetch( 163 | "lab", 164 | "api", 165 | "settings", 166 | id, 167 | method="GET", 168 | ) 169 | validate_request(r) 170 | data = json.loads(r.body.decode()) 171 | second_created = rfc3339_to_timestamp(data["created"]) 172 | second_modified = rfc3339_to_timestamp(data["last_modified"]) 173 | 174 | assert first_created <= second_created 175 | assert first_modified < second_modified 176 | 177 | r = await jp_fetch( 178 | "lab", 179 | "api", 180 | "settings/", 181 | method="GET", 182 | ) 183 | validate_request(r) 184 | data = json.loads(r.body.decode()) 185 | listing = data["settings"] 186 | list_data = next(item for item in listing if item["id"] == id) 187 | # TODO(@echarles) Check this... 188 | 189 | 190 | # assert list_data['created'] == data['created'] 191 | # assert list_data['last_modified'] == data['last_modified'] 192 | 193 | 194 | async def test_patch_unicode(jp_fetch, labserverapp): 195 | id = "@jupyterlab/unicode-extension:plugin" 196 | settings = dict(comment=big_unicode_string[::-1]) 197 | payload = dict(raw=json5.dumps(settings)) 198 | 199 | r = await jp_fetch("lab", "api", "settings", id, method="PUT", body=json.dumps(payload)) 200 | validate_request(r) 201 | 202 | r = await jp_fetch( 203 | "lab", 204 | "api", 205 | "settings", 206 | id, 207 | method="GET", 208 | ) 209 | validate_request(r) 210 | data = json.loads(r.body.decode()) 211 | assert data["settings"]["comment"] == big_unicode_string[::-1] 212 | 213 | 214 | async def test_patch_wrong_id(jp_fetch, labserverapp): 215 | with pytest.raises(HTTPClientError) as e: 216 | await jp_fetch("foo", method="PUT", body=json.dumps(dict(raw=json5.dumps(dict())))) 217 | assert expected_http_error(e, 404) 218 | 219 | 220 | async def test_patch_bad_data(jp_fetch, labserverapp): 221 | with pytest.raises(HTTPClientError) as e: 222 | settings = dict(keyMap=10) 223 | payload = dict(raw=json5.dumps(settings)) 224 | await jp_fetch("foo", method="PUT", body=json.dumps(payload)) 225 | assert expected_http_error(e, 404) 226 | 227 | 228 | async def test_patch_invalid_payload_format(jp_fetch, labserverapp): 229 | id = "@jupyterlab/apputils-extension:themes" 230 | 231 | with pytest.raises(HTTPClientError) as e: 232 | settings = dict(keyMap=10) 233 | payload = dict(foo=json5.dumps(settings)) 234 | await jp_fetch("lab", "api", "settings", id, method="PUT", body=json.dumps(payload)) 235 | assert expected_http_error(e, 400) 236 | 237 | 238 | async def test_patch_invalid_json(jp_fetch, labserverapp): 239 | id = "@jupyterlab/apputils-extension:themes" 240 | 241 | with pytest.raises(HTTPClientError) as e: 242 | payload_str = "eh" 243 | await jp_fetch("lab", "api", "settings", id, method="PUT", body=json.dumps(payload_str)) 244 | assert expected_http_error(e, 400) 245 | -------------------------------------------------------------------------------- /tests/test_themes_api.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from unittest.mock import Mock 5 | 6 | from tornado.httpserver import HTTPRequest 7 | from tornado.web import Application 8 | 9 | from jupyterlab_server.test_utils import validate_request 10 | from jupyterlab_server.themes_handler import ThemesHandler 11 | 12 | 13 | async def test_get_theme(jp_fetch, labserverapp): 14 | r = await jp_fetch("lab", "api", "themes", "@jupyterlab", "foo", "index.css") 15 | validate_request(r) 16 | 17 | 18 | def test_themes_handler(tmp_path): 19 | app = Application() 20 | request = HTTPRequest(connection=Mock()) 21 | data_path = f"{tmp_path}/test.txt" 22 | with open(data_path, "w") as fid: 23 | fid.write("hi") 24 | handler = ThemesHandler(app, request, path=str(tmp_path)) 25 | handler.absolute_path = data_path 26 | handler.get_content_size() 27 | handler.get_content("test.txt") 28 | 29 | css_path = f"{tmp_path}/test.css" 30 | with open(css_path, "w") as fid: 31 | fid.write("url('./foo.css')") 32 | handler.absolute_path = css_path 33 | handler.path = "/" 34 | handler.themes_url = "foo" 35 | content = handler.get_content(css_path) 36 | assert content == b"url('foo/./foo.css')" 37 | -------------------------------------------------------------------------------- /tests/test_translation_api.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """Test the translations service API.""" 5 | 6 | import json 7 | import os 8 | import shutil 9 | import subprocess 10 | import sys 11 | 12 | import pytest 13 | 14 | from jupyterlab_server.test_utils import maybe_patch_ioloop, validate_request 15 | from jupyterlab_server.translation_utils import ( 16 | _get_installed_language_pack_locales, 17 | _get_installed_package_locales, 18 | get_display_name, 19 | get_installed_packages_locale, 20 | get_language_pack, 21 | get_language_packs, 22 | is_valid_locale, 23 | merge_locale_data, 24 | translator, 25 | ) 26 | 27 | maybe_patch_ioloop() 28 | 29 | # Constants 30 | HERE = os.path.abspath(os.path.dirname(__file__)) 31 | 32 | if not os.path.exists(os.path.join(HERE, "translations")): 33 | pytest.skip("skipping translation tests", allow_module_level=True) 34 | 35 | 36 | def setup_module(module): 37 | """setup any state specific to the execution of this module.""" 38 | for pkg in ["jupyterlab-some-package", "jupyterlab-language-pack-es_CO"]: 39 | src = os.path.join(HERE, "translations", pkg) 40 | subprocess.Popen([sys.executable, "-m", "pip", "install", src]).communicate() # noqa: S603 41 | 42 | 43 | def teardown_module(module): 44 | """teardown any state that was previously setup.""" 45 | for pkg in ["jupyterlab-some-package", "jupyterlab-language-pack-es_CO"]: 46 | subprocess.Popen( 47 | [sys.executable, "-m", "pip", "uninstall", pkg, "-y"] # noqa: S603 48 | ).communicate() 49 | 50 | 51 | @pytest.fixture(autouse=True) 52 | def before_after_test(schemas_dir, user_settings_dir, labserverapp): # noqa: PT004 53 | # Code that will run before any test. 54 | 55 | # Copy the schema files. 56 | test_data = os.path.join(HERE, "..", "jupyterlab_server", "test_data") 57 | test_data = os.path.abspath(test_data) 58 | src = os.path.join(test_data, "schemas", "@jupyterlab") 59 | dst = os.path.join(str(schemas_dir), "@jupyterlab") 60 | if os.path.exists(dst): 61 | shutil.rmtree(dst) 62 | 63 | shutil.copytree(src, dst) 64 | 65 | # Copy the overrides file. 66 | src = os.path.join(test_data, "app-settings", "overrides.json") 67 | dst = os.path.join(str(user_settings_dir), "overrides.json") 68 | 69 | if os.path.exists(dst): 70 | os.remove(dst) 71 | 72 | shutil.copyfile(src, dst) 73 | 74 | # A test function will be run at this point. 75 | 76 | return 77 | 78 | # Code that will run after your test. 79 | # N/A 80 | 81 | 82 | async def test_get(jp_fetch): 83 | r = await jp_fetch("lab", "api", "translations/") 84 | validate_request(r) 85 | data = json.loads(r.body.decode())["data"] 86 | assert "en" in data 87 | 88 | 89 | async def test_get_locale(jp_fetch): 90 | locale = "es_CO" 91 | r = await jp_fetch("lab", "api", "translations", locale) 92 | validate_request(r) 93 | data = json.loads(r.body.decode())["data"] 94 | assert "jupyterlab" in data 95 | assert data["jupyterlab"][""]["language"] == locale 96 | 97 | assert "jupyterlab_some_package" in data 98 | assert data["jupyterlab_some_package"][""]["version"] == "0.1.0" 99 | assert data["jupyterlab_some_package"][""]["language"] == locale 100 | 101 | 102 | async def test_get_locale_bad(jp_fetch): 103 | r = await jp_fetch("lab", "api", "translations", "foo_BAR") 104 | validate_request(r) 105 | data = json.loads(r.body.decode())["data"] 106 | assert data == {} 107 | 108 | 109 | async def test_get_locale_not_installed(jp_fetch): 110 | r = await jp_fetch("lab", "api", "translations", "es_AR") 111 | validate_request(r) 112 | result = json.loads(r.body.decode()) 113 | assert "not installed" in result["message"] 114 | assert result["data"] == {} 115 | 116 | 117 | async def test_get_locale_not_valid(jp_fetch): 118 | r = await jp_fetch("lab", "api", "translations", "foo_BAR") 119 | validate_request(r) 120 | result = json.loads(r.body.decode()) 121 | assert "not valid" in result["message"] 122 | assert result["data"] == {} 123 | 124 | 125 | # --- Backend locale 126 | # ------------------------------------------------------------------------ 127 | async def test_backend_locale(jp_fetch): 128 | locale = "es_CO" 129 | await jp_fetch("lab", "api", "translations", locale) 130 | trans = translator.load("jupyterlab") 131 | result = trans.__("MORE ABOUT PROJECT JUPYTER") 132 | assert result == "Más sobre el proyecto jupyter" 133 | 134 | 135 | async def test_backend_locale_extension(jp_fetch): 136 | locale = "es_CO" 137 | await jp_fetch("lab", "api", "translations", locale) 138 | trans = translator.load("jupyterlab_some_package") 139 | result = trans.__("BOOM") 140 | assert result == "Foo bar 2" 141 | 142 | 143 | # --- Utils testing 144 | # ------------------------------------------------------------------------ 145 | def test_get_installed_language_pack_locales_passes(): 146 | data, message = _get_installed_language_pack_locales() 147 | assert "es_CO" in data 148 | assert not message 149 | 150 | 151 | def test_get_installed_package_locales(): 152 | data, message = _get_installed_package_locales() 153 | assert "jupyterlab_some_package" in data 154 | assert os.path.isdir(data["jupyterlab_some_package"]) 155 | assert not message 156 | 157 | 158 | def test_get_installed_packages_locale(): 159 | data, message = get_installed_packages_locale("es_CO") 160 | assert "jupyterlab_some_package" in data 161 | assert "" in data["jupyterlab_some_package"] 162 | assert not message 163 | 164 | 165 | def test_get_language_packs(): 166 | data, message = get_language_packs("en") 167 | assert "en" in data 168 | assert "es_CO" in data 169 | assert not message 170 | 171 | 172 | def test_get_language_pack(): 173 | data, message = get_language_pack("es_CO") 174 | assert "jupyterlab" in data 175 | assert "jupyterlab_some_package" in data 176 | assert "" in data["jupyterlab"] 177 | assert "" in data["jupyterlab_some_package"] 178 | assert not message 179 | 180 | 181 | # --- Utils 182 | # ------------------------------------------------------------------------ 183 | def test_merge_locale_data(): 184 | some_package_data_1 = { 185 | "": {"domain": "some_package", "version": "1.0.0"}, 186 | "FOO": ["BAR"], 187 | } 188 | some_package_data_2 = { 189 | "": {"domain": "some_package", "version": "1.1.0"}, 190 | "SPAM": ["BAR"], 191 | } 192 | some_package_data_3 = { 193 | "": {"domain": "some_different_package", "version": "1.4.0"}, 194 | "SPAM": ["BAR"], 195 | } 196 | # Package data 2 has a newer version so it should update the package data 1 197 | result = merge_locale_data(some_package_data_1, some_package_data_2) 198 | assert "SPAM" in result 199 | assert "FOO" in result 200 | 201 | # Package data 2 has a older version so it should not update the package data 2 202 | result = merge_locale_data(some_package_data_2, some_package_data_1) 203 | assert "SPAM" in result 204 | assert "FOO" not in result 205 | 206 | # Package data 3 is a different package (domain) so it should not update package data 2 207 | result = merge_locale_data(some_package_data_2, some_package_data_3) 208 | assert result == some_package_data_2 209 | 210 | 211 | def test_is_valid_locale_valid(): 212 | assert is_valid_locale("en") 213 | assert is_valid_locale("es") 214 | assert is_valid_locale("es_CO") 215 | 216 | 217 | def test_is_valid_locale_invalid(): 218 | assert not is_valid_locale("foo_SPAM") 219 | assert not is_valid_locale("bar") 220 | 221 | 222 | def test_get_display_name_valid(): 223 | assert get_display_name("en", "en") == "English" 224 | assert get_display_name("en", "es") == "Inglés" 225 | assert get_display_name("en", "es_CO") == "Inglés" 226 | assert get_display_name("en", "fr") == "Anglais" 227 | assert get_display_name("es", "en") == "Spanish" 228 | assert get_display_name("fr", "en") == "French" 229 | assert get_display_name("pl_pl", "en") == "Polish (Poland)" 230 | 231 | 232 | def test_get_display_name_invalid(): 233 | assert get_display_name("en", "foo") == "English" 234 | assert get_display_name("foo", "en") == "English" 235 | assert get_display_name("foo", "bar") == "English" 236 | -------------------------------------------------------------------------------- /tests/test_translation_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | 5 | from jupyterlab_server.translation_utils import ( 6 | TranslationBundle, 7 | get_installed_packages_locale, 8 | get_language_packs, 9 | translator, 10 | ) 11 | 12 | 13 | def test_get_installed_packages_locale(jp_environ): 14 | get_installed_packages_locale("es_co") 15 | 16 | 17 | def test_get_language_packs(jp_environ): 18 | get_language_packs("es_co") 19 | 20 | 21 | def test_translation_bundle(): 22 | bundle = TranslationBundle("foo", "bar") 23 | bundle.update_locale("fizz") 24 | bundle.gettext("hi") 25 | bundle.ngettext("hi", "his", 1) 26 | bundle.npgettext("foo", "bar", "bars", 2) 27 | bundle.pgettext("foo", "bar") 28 | 29 | 30 | def test_translator(): 31 | t = translator() 32 | t.load("foo") 33 | t.normalize_domain("bar") 34 | t.set_locale("fizz") 35 | t.translate_schema({}) 36 | -------------------------------------------------------------------------------- /tests/test_workspaces_api.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """Test the kernels service API.""" 5 | import json 6 | import os 7 | 8 | import pytest 9 | import tornado.httpclient 10 | from strict_rfc3339 import rfc3339_to_timestamp # type:ignore 11 | 12 | from jupyterlab_server.test_utils import ( 13 | big_unicode_string, 14 | expected_http_error, 15 | maybe_patch_ioloop, 16 | validate_request, 17 | ) 18 | 19 | maybe_patch_ioloop() 20 | 21 | 22 | async def test_delete(jp_fetch, labserverapp): 23 | orig = "f/o/o" 24 | copy = "baz" 25 | r = await jp_fetch("lab", "api", "workspaces", orig) 26 | validate_request(r) 27 | res = r.body.decode() 28 | data = json.loads(res) 29 | data["metadata"]["id"] = copy 30 | r2 = await jp_fetch("lab", "api", "workspaces", copy, method="PUT", body=json.dumps(data)) 31 | assert r2.code == 204 32 | r3 = await jp_fetch( 33 | "lab", 34 | "api", 35 | "workspaces", 36 | copy, 37 | method="DELETE", 38 | ) 39 | assert r3.code == 204 40 | with pytest.raises(tornado.httpclient.HTTPClientError) as e: 41 | await jp_fetch( 42 | "lab", 43 | "api", 44 | "workspaces", 45 | "does_not_exist", 46 | method="DELETE", 47 | ) 48 | assert expected_http_error(e, 404) 49 | 50 | with pytest.raises(tornado.httpclient.HTTPClientError) as e: 51 | await jp_fetch( 52 | "lab", 53 | "api", 54 | "workspaces", 55 | "", 56 | method="DELETE", 57 | ) 58 | assert expected_http_error(e, 400) 59 | 60 | 61 | async def test_get_non_existant(jp_fetch, labserverapp): 62 | id = "foo" 63 | 64 | r = await jp_fetch("lab", "api", "workspaces", id) 65 | validate_request(r) 66 | data = json.loads(r.body.decode()) 67 | 68 | r2 = await jp_fetch("lab", "api", "workspaces", id, method="PUT", body=json.dumps(data)) 69 | validate_request(r2) 70 | 71 | r3 = await jp_fetch("lab", "api", "workspaces", id) 72 | validate_request(r3) 73 | data = json.loads(r3.body.decode()) 74 | first_metadata = data["metadata"] 75 | first_created = rfc3339_to_timestamp(first_metadata["created"]) 76 | first_modified = rfc3339_to_timestamp(first_metadata["last_modified"]) 77 | 78 | r4 = await jp_fetch("lab", "api", "workspaces", id, method="PUT", body=json.dumps(data)) 79 | validate_request(r4) 80 | 81 | r5 = await jp_fetch("lab", "api", "workspaces", id) 82 | validate_request(r5) 83 | data = json.loads(r5.body.decode()) 84 | second_metadata = data["metadata"] 85 | second_created = rfc3339_to_timestamp(second_metadata["created"]) 86 | second_modified = rfc3339_to_timestamp(second_metadata["last_modified"]) 87 | 88 | assert first_created <= second_created 89 | assert first_modified < second_modified 90 | 91 | 92 | @pytest.mark.skipif(os.name == "nt", reason="Temporal failure on windows") 93 | async def test_get(jp_fetch, labserverapp): 94 | id = "foo" 95 | r = await jp_fetch("lab", "api", "workspaces", id) 96 | validate_request(r) 97 | data = json.loads(r.body.decode()) 98 | metadata = data["metadata"] 99 | assert metadata["id"] == id 100 | assert rfc3339_to_timestamp(metadata["created"]) 101 | assert rfc3339_to_timestamp(metadata["last_modified"]) 102 | 103 | r2 = await jp_fetch("lab", "api", "workspaces", id) 104 | validate_request(r2) 105 | data = json.loads(r.body.decode()) 106 | assert data["metadata"]["id"] == id 107 | 108 | 109 | async def test_listing(jp_fetch, labserverapp): 110 | # ID fields are from workspaces/*.jupyterlab-workspace 111 | listing = {"foo", "f/o/o/"} 112 | r = await jp_fetch("lab", "api", "workspaces/") 113 | validate_request(r) 114 | res = r.body.decode() 115 | data = json.loads(res) 116 | output = set(data["workspaces"]["ids"]) 117 | assert output == listing 118 | 119 | 120 | async def test_listing_dates(jp_fetch, labserverapp): 121 | r = await jp_fetch("lab", "api", "workspaces") 122 | data = json.loads(r.body.decode()) 123 | values = data["workspaces"]["values"] 124 | workspaces = [ 125 | [ws["metadata"].get("last_modified"), ws["metadata"].get("created")] for ws in values 126 | ] 127 | times = [time for workspace in workspaces for time in workspace] 128 | assert None not in times 129 | [rfc3339_to_timestamp(t) for t in times] 130 | 131 | 132 | async def test_put(jp_fetch, labserverapp): 133 | id = "foo" 134 | r = await jp_fetch("lab", "api", "workspaces", id) 135 | assert r.code == 200 136 | res = r.body.decode() 137 | data = json.loads(res) 138 | data["metadata"]["big-unicode-string"] = big_unicode_string[::-1] 139 | r2 = await jp_fetch("lab", "api", "workspaces", id, method="PUT", body=json.dumps(data)) 140 | assert r2.code == 204 141 | 142 | 143 | async def test_bad_put(jp_fetch, labserverapp): 144 | orig = "foo" 145 | copy = "bar" 146 | r = await jp_fetch("lab", "api", "workspaces", orig) 147 | assert r.code == 200 148 | res = r.body.decode() 149 | data = json.loads(res) 150 | with pytest.raises(tornado.httpclient.HTTPClientError) as e: 151 | await jp_fetch("lab", "api", "workspaces", copy, method="PUT", body=json.dumps(data)) 152 | assert expected_http_error(e, 400) 153 | 154 | 155 | async def test_blank_put(jp_fetch, labserverapp): 156 | orig = "foo" 157 | r = await jp_fetch("lab", "api", "workspaces", orig) 158 | assert r.code == 200 159 | res = r.body.decode() 160 | data = json.loads(res) 161 | with pytest.raises(tornado.httpclient.HTTPClientError) as e: 162 | await jp_fetch("lab", "api", "workspaces", method="PUT", body=json.dumps(data)) 163 | assert expected_http_error(e, 400) 164 | -------------------------------------------------------------------------------- /tests/test_workspaces_app.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import json 5 | import os 6 | import sys 7 | 8 | from jupyterlab_server.workspaces_app import ( 9 | WorkspaceExportApp, 10 | WorkspaceImportApp, 11 | WorkspaceListApp, 12 | ) 13 | 14 | 15 | def test_workspace_apps(jp_environ, tmp_path): 16 | sys.argv = [sys.argv[0]] 17 | 18 | data = { 19 | "data": { 20 | "layout-restorer:data": { 21 | "main": { 22 | "dock": { 23 | "type": "tab-area", 24 | "currentIndex": 1, 25 | "widgets": ["notebook:Untitled1.ipynb"], 26 | }, 27 | "current": "notebook:Untitled1.ipynb", 28 | }, 29 | "down": {"size": 0, "widgets": []}, 30 | "left": { 31 | "collapsed": False, 32 | "current": "filebrowser", 33 | "widgets": [ 34 | "filebrowser", 35 | "running-sessions", 36 | "@jupyterlab/toc:plugin", 37 | "extensionmanager.main-view", 38 | ], 39 | }, 40 | "right": { 41 | "collapsed": True, 42 | "widgets": ["jp-property-inspector", "debugger-sidebar"], 43 | }, 44 | "relativeSizes": [0.17370242214532872, 0.8262975778546713, 0], 45 | }, 46 | "notebook:Untitled1.ipynb": { 47 | "data": {"path": "Untitled1.ipynb", "factory": "Notebook"} 48 | }, 49 | }, 50 | "metadata": {"id": "default"}, 51 | } 52 | 53 | data_file = os.path.join(tmp_path, "test.json") 54 | with open(data_file, "w") as fid: 55 | json.dump(data, fid) 56 | 57 | app = WorkspaceImportApp(workspaces_dir=str(tmp_path)) 58 | app.initialize() 59 | app.extra_args = [data_file] 60 | app.start() 61 | 62 | app1 = WorkspaceExportApp(workspaces_dir=str(tmp_path)) 63 | app1.initialize() 64 | app1.start() 65 | 66 | app2 = WorkspaceListApp(workspaces_dir=str(tmp_path)) 67 | app2.initialize() 68 | app2.start() 69 | 70 | app2.jsonlines = True 71 | app2.start() 72 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-language-pack-es_CO/MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include *.md 2 | recursive-include *.txt 3 | recursive-include jupyterlab_language_pack_es_CO *.json 4 | recursive-include jupyterlab_language_pack_es_CO *.mo 5 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-language-pack-es_CO/jupyterlab_language_pack_es_CO/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-language-pack-es_CO/jupyterlab_language_pack_es_CO/locale/es_CO/LC_MESSAGES/jupyterlab.json: -------------------------------------------------------------------------------- 1 | { 2 | "": { 3 | "domain": "jupyterlab", 4 | "language": "es_CO", 5 | "plural_forms": "nplurals=2; plural=(n != 1);", 6 | "version": "2.2.0" 7 | }, 8 | "ABOUT PROJECT JUPYTER": ["SOBRE EL PROYECTO JUPYTER"] 9 | } 10 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-language-pack-es_CO/jupyterlab_language_pack_es_CO/locale/es_CO/LC_MESSAGES/jupyterlab.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab_server/bca8978ffd44093bc4e7c790d8ee064d2f2b492a/tests/translations/jupyterlab-language-pack-es_CO/jupyterlab_language_pack_es_CO/locale/es_CO/LC_MESSAGES/jupyterlab.mo -------------------------------------------------------------------------------- /tests/translations/jupyterlab-language-pack-es_CO/jupyterlab_language_pack_es_CO/locale/es_CO/LC_MESSAGES/jupyterlab.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: jupyterlab\n" 4 | "Content-Type: text/plain; charset=UTF-8\n" 5 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 6 | "Language-Team: Spanish\n" 7 | "Language: es_CO\n" 8 | "POT-Creation-Date: \n" 9 | "PO-Revision-Date: \n" 10 | "Last-Translator: \n" 11 | "MIME-Version: 1.0\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "X-Generator: Poedit 2.4.1\n" 14 | 15 | #: /example 16 | msgid "MORE ABOUT PROJECT JUPYTER" 17 | msgstr "Más sobre el proyecto jupyter" 18 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-language-pack-es_CO/jupyterlab_language_pack_es_CO/locale/es_CO/LC_MESSAGES/jupyterlab_some_package.json: -------------------------------------------------------------------------------- 1 | { 2 | "": { 3 | "domain": "jupyterlab_some_package", 4 | "language": "es_CO", 5 | "plural_forms": "nplurals=2; plural=(n != 1);", 6 | "version": "0.0.1" 7 | }, 8 | "BOOM": ["FOO BAR"] 9 | } 10 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-language-pack-es_CO/jupyterlab_language_pack_es_CO/locale/es_CO/LC_MESSAGES/jupyterlab_some_package.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab_server/bca8978ffd44093bc4e7c790d8ee064d2f2b492a/tests/translations/jupyterlab-language-pack-es_CO/jupyterlab_language_pack_es_CO/locale/es_CO/LC_MESSAGES/jupyterlab_some_package.mo -------------------------------------------------------------------------------- /tests/translations/jupyterlab-language-pack-es_CO/jupyterlab_language_pack_es_CO/locale/es_CO/LC_MESSAGES/jupyterlab_some_package.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: jupyterlab_some_package\n" 4 | "Content-Type: text/plain; charset=UTF-8\n" 5 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 6 | "Language-Team: Spanish\n" 7 | "Language: es_CO\n" 8 | "POT-Creation-Date: \n" 9 | "PO-Revision-Date: \n" 10 | "Last-Translator: \n" 11 | "MIME-Version: 1.0\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "X-Generator: Poedit 2.4.1\n" 14 | 15 | #: /example 16 | msgid "BOOM" 17 | msgstr "Foo bar 2" 18 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-language-pack-es_CO/setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from setuptools import setup 5 | 6 | setup( 7 | name="jupyterlab_language_pack_es_CO", 8 | version="0.1.0", 9 | packages=["jupyterlab_language_pack_es_CO"], 10 | include_package_data=True, 11 | entry_points={ 12 | "jupyterlab.languagepack": [ 13 | "es_CO = jupyterlab_language_pack_es_CO", 14 | ] 15 | }, 16 | ) 17 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-some-package/MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include *.md 2 | recursive-include *.txt 3 | recursive-include jupyterlab_some_package *.json 4 | recursive-include jupyterlab_some_package *.mo 5 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-some-package/jupyterlab_some_package/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-some-package/jupyterlab_some_package/locale/es_CO/LC_MESSAGES/jupyterlab_some_package.json: -------------------------------------------------------------------------------- 1 | { 2 | "": { 3 | "domain": "jupyterlab_some_package", 4 | "language": "es_CO", 5 | "plural_forms": "nplurals=2; plural=(n != 1);", 6 | "version": "0.1.0" 7 | }, 8 | "BOOM": ["SHAKA LAKA"] 9 | } 10 | -------------------------------------------------------------------------------- /tests/translations/jupyterlab-some-package/setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from setuptools import setup 5 | 6 | setup( 7 | name="jupyterlab-some-package", 8 | version="0.1.0", 9 | packages=["jupyterlab_some_package"], 10 | include_package_data=True, 11 | entry_points={"jupyterlab.locale": ["jupyterlab_some_package = jupyterlab_some_package"]}, 12 | ) 13 | --------------------------------------------------------------------------------