pax_global_header00006660000000000000000000000064147312653420014521gustar00rootroot0000000000000052 comment=8a6f60774599b4d72ad449f98ab2214d2d035bb4 jupyter-server-jupyter_server-e5c7e2b/000077500000000000000000000000001473126534200203135ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/.git-blame-ignore-revs000066400000000000000000000001071473126534200244110ustar00rootroot00000000000000# Initial pre-commit reformat 42fe3bb4188a1fbd1810674776e7855cd529b8fc jupyter-server-jupyter_server-e5c7e2b/.gitconfig000066400000000000000000000000641473126534200222650ustar00rootroot00000000000000[blame] ignoreRevsFile = .git-blame-ignore-revs jupyter-server-jupyter_server-e5c7e2b/.github/000077500000000000000000000000001473126534200216535ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/.github/dependabot.yml000066400000000000000000000004501473126534200245020ustar00rootroot00000000000000version: 2 updates: # GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" groups: actions: patterns: - "*" # Python - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" jupyter-server-jupyter_server-e5c7e2b/.github/workflows/000077500000000000000000000000001473126534200237105ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/.github/workflows/downstream.yml000066400000000000000000000101131473126534200266120ustar00rootroot00000000000000name: Downstream Tests on: push: pull_request: jobs: nbclassic: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Test nbclassic uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 with: package_name: nbclassic test_command: pip install pytest-jupyter[server] && pytest -vv -raXxs -W default --durations 10 --color=yes - name: Test run nbclassic run: | pip install nbclassic pip install --force-reinstall "." # Make sure we can start and kill the nbclassic server jupyter nbclassic --no-browser & TASK_PID=$! # Make sure the task is running ps -p $TASK_PID || exit 1 sleep 5 kill $TASK_PID wait $TASK_PID notebook: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Test notebook uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 with: package_name: notebook package_download_extra_args: "--pre" test_command: pip install pytest-jupyter[server] && pytest -vv -raXxs -W default --durations 10 --color=yes jupyterlab_server: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - run: pip install pytest-jupyter[server] - name: Test jupyterlab_server uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 with: package_name: jupyterlab_server test_command: pip install pytest-jupyter[server] && pytest -vv -raXxs -W default --durations 10 --color=yes jupyterlab: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Test jupyterlab uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 with: package_name: jupyterlab test_command: "python -m jupyterlab.browser_check --no-browser-test" jupyter_server_terminals: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Test jupyter_server_terminals uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 with: test_command: pip install pytest-jupyter[server] && pytest -vv -raXxs -W default --durations 10 --color=yes package_name: jupyter_server_terminals jupytext: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Test jupytext uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 with: package_name: jupytext test_command: pip install pytest-jupyter[server] gitpython pre-commit && python -m ipykernel install --name jupytext-dev --user && pytest -vv -raXxs -W default --durations 10 --ignore=tests/functional/others --color=yes downstream_check: # This job does nothing and is only used for the branch protection if: always() needs: - jupyterlab - jupyter_server_terminals - jupyterlab_server - notebook - nbclassic - jupytext runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} jupyter-server-jupyter_server-e5c7e2b/.github/workflows/enforce-label.yml000066400000000000000000000005001473126534200271240ustar00rootroot00000000000000name: Enforce PR label on: pull_request: types: [labeled, unlabeled, opened, edited, synchronize] jobs: enforce-label: runs-on: ubuntu-latest permissions: pull-requests: write steps: - name: enforce-triage-label uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 jupyter-server-jupyter_server-e5c7e2b/.github/workflows/prep-release.yml000066400000000000000000000032311473126534200270160ustar00rootroot00000000000000name: "Step 1: Prep Release" on: workflow_dispatch: inputs: version_spec: description: "New Version Specifier" default: "next" required: false branch: description: "The branch to target" required: false post_version_spec: description: "Post Version Specifier" required: false silent: description: "Set a placeholder in the changelog and don't publish the release." required: false type: boolean since: description: "Use PRs with activity since this date or git reference" required: false since_last_stable: description: "Use PRs with activity since the last stable git tag" required: false type: boolean jobs: prep_release: runs-on: ubuntu-latest permissions: contents: write steps: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Prep Release id: prep-release uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 with: token: ${{ secrets.GITHUB_TOKEN }} version_spec: ${{ github.event.inputs.version_spec }} silent: ${{ github.event.inputs.silent }} post_version_spec: ${{ github.event.inputs.post_version_spec }} target: ${{ github.event.inputs.target }} branch: ${{ github.event.inputs.branch }} since: ${{ github.event.inputs.since }} since_last_stable: ${{ github.event.inputs.since_last_stable }} - name: "** Next Step **" run: | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" jupyter-server-jupyter_server-e5c7e2b/.github/workflows/publish-changelog.yml000066400000000000000000000016401473126534200300270ustar00rootroot00000000000000name: "Publish Changelog" on: release: types: [published] workflow_dispatch: inputs: branch: description: "The branch to target" required: false jobs: publish_changelog: runs-on: ubuntu-latest environment: release steps: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - uses: actions/create-github-app-token@v1 id: app-token with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Publish changelog id: publish-changelog uses: jupyter-server/jupyter_releaser/.github/actions/publish-changelog@v2 with: token: ${{ steps.app-token.outputs.token }} branch: ${{ github.event.inputs.branch }} - name: "** Next Step **" run: | echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" jupyter-server-jupyter_server-e5c7e2b/.github/workflows/publish-release.yml000066400000000000000000000034061473126534200275220ustar00rootroot00000000000000name: "Step 2: Publish Release" on: workflow_dispatch: inputs: branch: description: "The target branch" required: false release_url: description: "The URL of the draft GitHub release" required: false steps_to_skip: description: "Comma separated list of steps to skip" required: false jobs: publish_release: runs-on: ubuntu-latest environment: release permissions: id-token: write steps: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - uses: actions/create-github-app-token@v1 id: app-token with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Populate Release id: populate-release uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 with: token: ${{ steps.app-token.outputs.token }} branch: ${{ github.event.inputs.branch }} release_url: ${{ github.event.inputs.release_url }} steps_to_skip: ${{ github.event.inputs.steps_to_skip }} - name: Finalize Release id: finalize-release uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 with: token: ${{ steps.app-token.outputs.token }} release_url: ${{ steps.populate-release.outputs.release_url }} - name: "** Next Step **" if: ${{ success() }} run: | echo "Verify the final release" echo ${{ steps.finalize-release.outputs.release_url }} - name: "** Failure Message **" if: ${{ failure() }} run: | echo "Failed to Publish the Draft Release Url:" echo ${{ steps.populate-release.outputs.release_url }} jupyter-server-jupyter_server-e5c7e2b/.github/workflows/python-tests.yml000066400000000000000000000161471473126534200271250ustar00rootroot00000000000000name: Jupyter Server Tests on: push: branches: ["main"] pull_request: schedule: - cron: "0 8 * * *" defaults: run: shell: bash -eux {0} jobs: build: runs-on: ${{ matrix.os }} timeout-minutes: 20 strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.9", "3.11", "3.12"] include: - os: windows-latest python-version: "3.9" - os: ubuntu-latest python-version: "pypy-3.9" - os: macos-latest python-version: "3.10" - os: ubuntu-latest python-version: "3.12" steps: - name: Checkout uses: actions/checkout@v4 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Install nbconvert dependencies on Linux if: startsWith(runner.os, 'Linux') run: | sudo apt-get update sudo apt-get install texlive-plain-generic inkscape texlive-xetex sudo apt-get install xvfb x11-utils libxkbcommon-x11-0 # pandoc is not up to date in the ubuntu repos, so we install directly wget https://github.com/jgm/pandoc/releases/download/3.1.2/pandoc-3.1.2-1-amd64.deb && sudo dpkg -i pandoc-3.1.2-1-amd64.deb - name: Run the tests on posix if: ${{ !startsWith(matrix.python-version, 'pypy') && !startsWith(matrix.os, 'windows') }} run: hatch run cov:test --cov-fail-under 75 || hatch -v run test:test --lf - name: Run the tests on pypy if: ${{ startsWith(matrix.python-version, 'pypy') }} run: hatch run test:nowarn || hatch -v run test:nowarn --lf - name: Run the tests on windows if: ${{ startsWith(matrix.os, 'windows') }} run: hatch run cov:nowarn -s || hatch -v run cov:nowarn --lf - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 test_docs: name: Test Docs runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Install Dependencies run: | sudo apt-get update sudo apt-get install enchant-2 # for spell checking - name: Build API docs run: | hatch -v run docs:api # If this fails run `hatch run docs:api` locally # and commit. git status --porcelain git status -s | grep "A" && exit 1 git status -s | grep "M" && exit 1 echo "API docs done" - run: hatch -v run docs:build test_lint: name: Test Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Run Linters run: | hatch -v run typing:test hatch fmt pipx run interrogate -v . pipx run doc8 --max-line-length=200 --ignore-path=docs/source/other/full-config.rst npm install -g eslint eslint . test_examples: name: Test Examples timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Install the Python dependencies for the examples run: | pip install -e ".[test]" cd examples/simple && pip install -e . - name: Run the tests for the examples run: | python -m pytest examples/simple test_minimum_versions: name: Test Minimum Versions timeout-minutes: 20 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 with: dependency_type: minimum - name: Run the unit tests run: | hatch -vv run test:nowarn || hatch -v run test:nowarn --lf test_prereleases: name: Test Prereleases runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 with: dependency_type: pre - name: Run the tests run: | hatch run test:nowarn || hatch -v run test:nowarn --lf make_sdist: name: Make SDist runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - uses: jupyterlab/maintainer-tools/.github/actions/make-sdist@v1 test_sdist: runs-on: ubuntu-latest needs: [make_sdist] name: Install from SDist and Test timeout-minutes: 20 steps: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - uses: jupyterlab/maintainer-tools/.github/actions/test-sdist@v1 with: package_spec: -vv . test_command: hatch run test:test || hatch -v run test:test --lf check_release: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Install Dependencies run: | pip install -e . - name: Check Release uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 with: token: ${{ secrets.GITHUB_TOKEN }} check_links: name: Check Links runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 integration_check: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest] python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Run the tests run: hatch -v run cov:integration - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 integration_check_pypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 with: python_version: "pypy-3.9" - name: Run the tests run: hatch -v run test:nowarn --integration_tests=true coverage: runs-on: ubuntu-latest needs: - integration_check - build steps: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/report-coverage@v1 with: fail_under: 80 tests_check: # This job does nothing and is only used for the branch protection if: always() needs: - coverage - integration_check_pypy - test_docs - test_lint - test_examples - test_minimum_versions - test_prereleases - check_links - check_release - test_sdist runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} jupyter-server-jupyter_server-e5c7e2b/.gitignore000066400000000000000000000013411473126534200223020ustar00rootroot00000000000000MANIFEST build dist _build docs/man/*.gz docs/source/api/generated docs/source/config.rst docs/gh-pages jupyter_server/i18n/*/LC_MESSAGES/*.mo jupyter_server/i18n/*/LC_MESSAGES/nbjs.json jupyter_server/static/style/*.min.css* node_modules *.py[co] __pycache__ *.egg-info *~ *.bak .ipynb_checkpoints .tox .DS_Store \#*# .#* .coverage* .pytest_cache src *.swp *.map Read the Docs config.rst /.project /.pydevproject # copied changelog file docs/source/other/changelog.md # full config is generated on demand docs/source/other/full-config.rst # jetbrains ide stuff *.iml .idea/ # vscode ide stuff *.code-workspace .history .vscode/* !.vscode/*.template # Compiled static file in example. examples/simple/simple_ext1/static/bundle.js jupyter-server-jupyter_server-e5c7e2b/.mailmap000066400000000000000000000250661473126534200217450ustar00rootroot00000000000000A. J. Holyoake ajholyoake Aaron Culich Aaron Culich Aron Ahmadia ahmadia Benjamin Ragan-Kelley Benjamin Ragan-Kelley Min RK Benjamin Ragan-Kelley MinRK Barry Wark Barry Wark Ben Edwards Ben Edwards Bradley M. Froehle Bradley M. Froehle Bradley M. Froehle Bradley Froehle Brandon Parsons Brandon Parsons Brian E. Granger Brian Granger Brian E. Granger Brian Granger <> Brian E. Granger bgranger <> Brian E. Granger bgranger Christoph Gohlke cgohlke Cyrille Rossant rossant Damián Avila damianavila Damián Avila damianavila Damon Allen damontallen Darren Dale darren.dale <> Darren Dale Darren Dale <> Dav Clark Dav Clark <> Dav Clark Dav Clark David Hirschfeld dhirschfeld David P. Sanders David P. Sanders David Warde-Farley David Warde-Farley <> Doug Blank Doug Blank Eugene Van den Bulke Eugene Van den Bulke Evan Patterson Evan Patterson Evan Patterson Evan Patterson Evan Patterson epatters Evan Patterson epatters Ernie French Ernie French Ernie French ernie french Ernie French ernop Fernando Perez Fernando Perez Fernando Perez Fernando Perez fperez <> Fernando Perez fptest <> Fernando Perez fptest1 <> Fernando Perez Fernando Perez Fernando Perez Fernando Perez <> Fernando Perez Fernando Perez Frank Murphy Frank Murphy Gabriel Becker gmbecker Gael Varoquaux gael.varoquaux <> Gael Varoquaux gvaroquaux Gael Varoquaux Gael Varoquaux <> Ingolf Becker watercrossing Jake Vanderplas Jake Vanderplas Jakob Gager jakobgager Jakob Gager jakobgager Jakob Gager jakobgager Jason Grout Jason Grout Jason Gors jason gors Jason Gors jgors Jens Hedegaard Nielsen Jens Hedegaard Nielsen Jens Hedegaard Nielsen Jens H Nielsen Jens Hedegaard Nielsen Jens H. Nielsen Jez Ng Jez Ng Jonathan Frederic Jonathan Frederic Jonathan Frederic Jonathan Frederic Jonathan Frederic Jonathan Frederic Jonathan Frederic jon Jonathan Frederic U-Jon-PC\Jon Jonathan March Jonathan March Jonathan March jdmarch Jörgen Stenarson Jörgen Stenarson Jörgen Stenarson Jorgen Stenarson Jörgen Stenarson Jorgen Stenarson <> Jörgen Stenarson jstenar Jörgen Stenarson jstenar <> Jörgen Stenarson Jörgen Stenarson Juergen Hasch juhasch Juergen Hasch juhasch Julia Evans Julia Evans Kester Tong KesterTong Kyle Kelley Kyle Kelley Kyle Kelley rgbkrk Laurent Dufréchou Laurent Dufréchou Laurent Dufréchou laurent dufrechou <> Laurent Dufréchou laurent.dufrechou <> Laurent Dufréchou Laurent Dufrechou <> Laurent Dufréchou laurent.dufrechou@gmail.com <> Laurent Dufréchou ldufrechou Lorena Pantano Lorena Luis Pedro Coelho Luis Pedro Coelho Marc Molla marcmolla Martín Gaitán Martín Gaitán Matthias Bussonnier Matthias BUSSONNIER Matthias Bussonnier Bussonnier Matthias Matthias Bussonnier Matthias BUSSONNIER Matthias Bussonnier Matthias Bussonnier Michael Droettboom Michael Droettboom Nicholas Bollweg Nicholas Bollweg (Nick) Nicolas Rougier Nikolay Koldunov Nikolay Koldunov Omar Andrés Zapata Mesa Omar Andres Zapata Mesa Omar Andrés Zapata Mesa Omar Andres Zapata Mesa Pankaj Pandey Pankaj Pandey Pascal Schetelat pascal-schetelat Paul Ivanov Paul Ivanov Pauli Virtanen Pauli Virtanen <> Pauli Virtanen Pauli Virtanen Pierre Gerold Pierre Gerold Pietro Berkes Pietro Berkes Piti Ongmongkolkul piti118 Prabhu Ramachandran Prabhu Ramachandran <> Puneeth Chaganti Puneeth Chaganti Robert Kern rkern <> Robert Kern Robert Kern Robert Kern Robert Kern Robert Kern Robert Kern <> Robert Marchman Robert Marchman Satrajit Ghosh Satrajit Ghosh Satrajit Ghosh Satrajit Ghosh Scott Sanderson Scott Sanderson smithj1 smithj1 smithj1 smithj1 Steven Johnson stevenJohnson Steven Silvester blink1073 S. Weber s8weber Stefan van der Walt Stefan van der Walt Silvia Vinyes Silvia Silvia Vinyes silviav12 Sylvain Corlay Sylvain Corlay sylvain.corlay Ted Drain TD22057 Théophile Studer Théophile Studer Thomas Kluyver Thomas Thomas Spura Thomas Spura Timo Paulssen timo vds vds2212 vds vds Ville M. Vainio Ville M. Vainio ville Ville M. Vainio ville Ville M. Vainio vivainio <> Ville M. Vainio Ville M. Vainio Ville M. Vainio Ville M. Vainio Walter Doerwald walter.doerwald <> Walter Doerwald Walter Doerwald <> W. Trevor King W. Trevor King Yoval P. y-p jupyter-server-jupyter_server-e5c7e2b/.pre-commit-config.yaml000066400000000000000000000040021473126534200245700ustar00rootroot00000000000000ci: autoupdate_schedule: monthly autoupdate_commit_msg: "chore: update pre-commit hooks" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-case-conflict - id: check-ast - id: check-docstring-first - id: check-executables-have-shebangs - id: check-added-large-files - id: check-case-conflict - id: check-merge-conflict - id: check-json - id: check-toml - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.28.6 hooks: - id: check-github-workflows - repo: https://github.com/executablebooks/mdformat rev: 0.7.17 hooks: - id: mdformat additional_dependencies: [mdformat-gfm, mdformat-frontmatter, mdformat-footnote] - repo: https://github.com/pre-commit/mirrors-prettier rev: "v4.0.0-alpha.8" hooks: - id: prettier types_or: [yaml, html, json] - repo: https://github.com/codespell-project/codespell rev: "v2.3.0" hooks: - id: codespell args: ["-L", "sur,nd"] - repo: https://github.com/pre-commit/pygrep-hooks rev: "v1.10.0" hooks: - id: rst-backticks - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/pre-commit/mirrors-mypy rev: "v1.10.1" hooks: - id: mypy files: jupyter_server stages: [manual] additional_dependencies: ["traitlets>=5.13", "jupyter_core>=5.5", "jupyter_client>=8.5"] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.5.0 hooks: - id: ruff types_or: [python, jupyter] args: ["--fix", "--show-fixes"] - id: ruff-format types_or: [python, jupyter] - repo: https://github.com/scientific-python/cookie rev: "2024.04.23" hooks: - id: sp-repo-review additional_dependencies: ["repo-review[cli]"] jupyter-server-jupyter_server-e5c7e2b/.prettierignore000066400000000000000000000000241473126534200233520ustar00rootroot00000000000000**/templates/*.html jupyter-server-jupyter_server-e5c7e2b/.readthedocs.yaml000066400000000000000000000005561473126534200235500ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.9" sphinx: configuration: docs/source/conf.py python: install: # install itself with pip install . - method: pip path: . extra_requirements: - docs formats: - epub - htmlzip # TODO: evaluate, see https://github.com/jupyter-server/jupyter_server/issues/1378 # - pdf jupyter-server-jupyter_server-e5c7e2b/CHANGELOG.md000066400000000000000000007432571473126534200221460ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. ## 2.15.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.9.1...f23b3392624001c8fba6623e19f526a98b4a07ba)) ### Enhancements made - Better error message when starting kernel for session. [#1478](https://github.com/jupyter-server/jupyter_server/pull/1478) ([@Carreau](https://github.com/Carreau)) - Add a traitlet to disable recording HTTP request metrics [#1472](https://github.com/jupyter-server/jupyter_server/pull/1472) ([@yuvipanda](https://github.com/yuvipanda)) - prometheus: Expose 3 activity metrics [#1471](https://github.com/jupyter-server/jupyter_server/pull/1471) ([@yuvipanda](https://github.com/yuvipanda)) - Add prometheus info metrics listing server extensions + versions [#1470](https://github.com/jupyter-server/jupyter_server/pull/1470) ([@yuvipanda](https://github.com/yuvipanda)) - Add prometheus metric with version information [#1467](https://github.com/jupyter-server/jupyter_server/pull/1467) ([@yuvipanda](https://github.com/yuvipanda)) - Better hash format error message [#1442](https://github.com/jupyter-server/jupyter_server/pull/1442) ([@fcollonval](https://github.com/fcollonval)) - Removing excessive logging from reading local files [#1420](https://github.com/jupyter-server/jupyter_server/pull/1420) ([@lresende](https://github.com/lresende)) - Do not include token in dashboard link, when available [#1406](https://github.com/jupyter-server/jupyter_server/pull/1406) ([@minrk](https://github.com/minrk)) - Add an option to have authentication enabled for all endpoints by default [#1392](https://github.com/jupyter-server/jupyter_server/pull/1392) ([@krassowski](https://github.com/krassowski)) - websockets: add configurations for ping interval and timeout [#1391](https://github.com/jupyter-server/jupyter_server/pull/1391) ([@oliver-sanders](https://github.com/oliver-sanders)) - log extension import time at debug level unless it's actually slow [#1375](https://github.com/jupyter-server/jupyter_server/pull/1375) ([@minrk](https://github.com/minrk)) - Add support for async Authorizers (part 2) [#1374](https://github.com/jupyter-server/jupyter_server/pull/1374) ([@Zsailer](https://github.com/Zsailer)) - Support async Authorizers [#1373](https://github.com/jupyter-server/jupyter_server/pull/1373) ([@Zsailer](https://github.com/Zsailer)) - Support get file(notebook) md5 [#1363](https://github.com/jupyter-server/jupyter_server/pull/1363) ([@Wh1isper](https://github.com/Wh1isper)) - Update kernel env to reflect changes in session [#1354](https://github.com/jupyter-server/jupyter_server/pull/1354) ([@blink1073](https://github.com/blink1073)) ### Bugs fixed - Return HTTP 400 when attempting to post an event with an unregistered schema [#1463](https://github.com/jupyter-server/jupyter_server/pull/1463) ([@afshin](https://github.com/afshin)) - write server extension list to stdout [#1451](https://github.com/jupyter-server/jupyter_server/pull/1451) ([@minrk](https://github.com/minrk)) - don't let ExtensionApp jpserver_extensions be overridden by config [#1447](https://github.com/jupyter-server/jupyter_server/pull/1447) ([@minrk](https://github.com/minrk)) - Pass session_id during Websocket connect [#1440](https://github.com/jupyter-server/jupyter_server/pull/1440) ([@gogasca](https://github.com/gogasca)) - Do not log environment variables passed to kernels [#1437](https://github.com/jupyter-server/jupyter_server/pull/1437) ([@krassowski](https://github.com/krassowski)) - extensions: render default templates with default static_url [#1435](https://github.com/jupyter-server/jupyter_server/pull/1435) ([@minrk](https://github.com/minrk)) - Improve the busy/idle execution state tracking for kernels. [#1429](https://github.com/jupyter-server/jupyter_server/pull/1429) ([@ojarjur](https://github.com/ojarjur)) - Ignore zero-length page_config.json, restore previous behavior of crashing for invalid JSON [#1405](https://github.com/jupyter-server/jupyter_server/pull/1405) ([@holzman](https://github.com/holzman)) - Don't crash on invalid JSON in page_config (#1403) [#1404](https://github.com/jupyter-server/jupyter_server/pull/1404) ([@holzman](https://github.com/holzman)) - Fix color in windows log console with colorama [#1397](https://github.com/jupyter-server/jupyter_server/pull/1397) ([@hansepac](https://github.com/hansepac)) - Fix log arguments for gateway client error [#1385](https://github.com/jupyter-server/jupyter_server/pull/1385) ([@minrk](https://github.com/minrk)) - Import User unconditionally [#1384](https://github.com/jupyter-server/jupyter_server/pull/1384) ([@yuvipanda](https://github.com/yuvipanda)) - Fix a typo in error message [#1381](https://github.com/jupyter-server/jupyter_server/pull/1381) ([@krassowski](https://github.com/krassowski)) - avoid unhandled error on some invalid paths [#1369](https://github.com/jupyter-server/jupyter_server/pull/1369) ([@minrk](https://github.com/minrk)) - Change md5 to hash and hash_algorithm, fix incompatibility [#1367](https://github.com/jupyter-server/jupyter_server/pull/1367) ([@Wh1isper](https://github.com/Wh1isper)) - ContentsHandler return 404 rather than raise exc [#1357](https://github.com/jupyter-server/jupyter_server/pull/1357) ([@bloomsa](https://github.com/bloomsa)) - Force legacy ws subprotocol when using gateway [#1311](https://github.com/jupyter-server/jupyter_server/pull/1311) ([@epignot](https://github.com/epignot)) ### Maintenance and upkeep improvements - Donation link NF -> LF [#1485](https://github.com/jupyter-server/jupyter_server/pull/1485) ([@Carreau](https://github.com/Carreau)) - Handle newer jupyter_events wants string version, drop 3.8 [#1481](https://github.com/jupyter-server/jupyter_server/pull/1481) ([@Carreau](https://github.com/Carreau)) - Ignore unclosed sqlite connection in traits [#1477](https://github.com/jupyter-server/jupyter_server/pull/1477) ([@cjwatson](https://github.com/cjwatson)) - chore: update pre-commit hooks [#1441](https://github.com/jupyter-server/jupyter_server/pull/1441) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - chore: update pre-commit hooks [#1427](https://github.com/jupyter-server/jupyter_server/pull/1427) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Use hatch fmt command [#1424](https://github.com/jupyter-server/jupyter_server/pull/1424) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1421](https://github.com/jupyter-server/jupyter_server/pull/1421) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix jupytext and lint CI failures [#1413](https://github.com/jupyter-server/jupyter_server/pull/1413) ([@blink1073](https://github.com/blink1073)) - Set all min deps [#1411](https://github.com/jupyter-server/jupyter_server/pull/1411) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1409](https://github.com/jupyter-server/jupyter_server/pull/1409) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Update pytest requirement from \<8,>=7.0 to >=7.0,\<9 [#1402](https://github.com/jupyter-server/jupyter_server/pull/1402) ([@dependabot](https://github.com/dependabot)) - Pin to Pytest 7 [#1401](https://github.com/jupyter-server/jupyter_server/pull/1401) ([@blink1073](https://github.com/blink1073)) - Update release workflows [#1399](https://github.com/jupyter-server/jupyter_server/pull/1399) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1390](https://github.com/jupyter-server/jupyter_server/pull/1390) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Improve warning handling [#1386](https://github.com/jupyter-server/jupyter_server/pull/1386) ([@blink1073](https://github.com/blink1073)) - Simplify the jupytext downstream test [#1383](https://github.com/jupyter-server/jupyter_server/pull/1383) ([@mwouts](https://github.com/mwouts)) - Fix test param for pytest-xdist [#1382](https://github.com/jupyter-server/jupyter_server/pull/1382) ([@tornaria](https://github.com/tornaria)) - Update pre-commit deps [#1380](https://github.com/jupyter-server/jupyter_server/pull/1380) ([@blink1073](https://github.com/blink1073)) - Use ruff docstring-code-format [#1377](https://github.com/jupyter-server/jupyter_server/pull/1377) ([@blink1073](https://github.com/blink1073)) - Update for tornado 6.4 [#1372](https://github.com/jupyter-server/jupyter_server/pull/1372) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1370](https://github.com/jupyter-server/jupyter_server/pull/1370) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Update ruff and typings [#1365](https://github.com/jupyter-server/jupyter_server/pull/1365) ([@blink1073](https://github.com/blink1073)) - Clean up ruff config [#1358](https://github.com/jupyter-server/jupyter_server/pull/1358) ([@blink1073](https://github.com/blink1073)) - Add more typings [#1356](https://github.com/jupyter-server/jupyter_server/pull/1356) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1355](https://github.com/jupyter-server/jupyter_server/pull/1355) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Clean up config and address warnings [#1353](https://github.com/jupyter-server/jupyter_server/pull/1353) ([@blink1073](https://github.com/blink1073)) - Clean up lint and typing [#1351](https://github.com/jupyter-server/jupyter_server/pull/1351) ([@blink1073](https://github.com/blink1073)) - Update typing for traitlets 5.13 [#1350](https://github.com/jupyter-server/jupyter_server/pull/1350) ([@blink1073](https://github.com/blink1073)) - Update typings and fix tests [#1344](https://github.com/jupyter-server/jupyter_server/pull/1344) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - add comments to explain signal handling under jupyterhub [#1452](https://github.com/jupyter-server/jupyter_server/pull/1452) ([@oliver-sanders](https://github.com/oliver-sanders)) - Update documentation for `cookie_secret` [#1433](https://github.com/jupyter-server/jupyter_server/pull/1433) ([@krassowski](https://github.com/krassowski)) - Add Changelog for 2.14.1 [#1430](https://github.com/jupyter-server/jupyter_server/pull/1430) ([@blink1073](https://github.com/blink1073)) - Update simple extension examples: \_jupyter_server_extension_points [#1426](https://github.com/jupyter-server/jupyter_server/pull/1426) ([@manics](https://github.com/manics)) - Link to GitHub repo from the docs [#1415](https://github.com/jupyter-server/jupyter_server/pull/1415) ([@krassowski](https://github.com/krassowski)) - docs: list server extensions [#1412](https://github.com/jupyter-server/jupyter_server/pull/1412) ([@oliver-sanders](https://github.com/oliver-sanders)) - Update simple extension README to cd into correct subdirectory [#1410](https://github.com/jupyter-server/jupyter_server/pull/1410) ([@markypizz](https://github.com/markypizz)) - Add deprecation note for `ServerApp.preferred_dir` [#1396](https://github.com/jupyter-server/jupyter_server/pull/1396) ([@krassowski](https://github.com/krassowski)) - Replace \_jupyter_server_extension_paths in apidocs [#1393](https://github.com/jupyter-server/jupyter_server/pull/1393) ([@manics](https://github.com/manics)) - fix "Shutdown" -> "Shut down" [#1389](https://github.com/jupyter-server/jupyter_server/pull/1389) ([@Timeroot](https://github.com/Timeroot)) - Enable htmlzip and epub on readthedocs [#1379](https://github.com/jupyter-server/jupyter_server/pull/1379) ([@bollwyvl](https://github.com/bollwyvl)) - Update api docs with md5 param [#1364](https://github.com/jupyter-server/jupyter_server/pull/1364) ([@Wh1isper](https://github.com/Wh1isper)) - typo: ServerApp [#1361](https://github.com/jupyter-server/jupyter_server/pull/1361) ([@IITII](https://github.com/IITII)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-10-25&to=2024-12-20&type=c)) [@afshin](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aafshin+updated%3A2023-10-25..2024-12-20&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-10-25..2024-12-20&type=Issues) | [@bloomsa](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abloomsa+updated%3A2023-10-25..2024-12-20&type=Issues) | [@bollwyvl](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abollwyvl+updated%3A2023-10-25..2024-12-20&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2023-10-25..2024-12-20&type=Issues) | [@cjwatson](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acjwatson+updated%3A2023-10-25..2024-12-20&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2023-10-25..2024-12-20&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adependabot+updated%3A2023-10-25..2024-12-20&type=Issues) | [@epignot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aepignot+updated%3A2023-10-25..2024-12-20&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afcollonval+updated%3A2023-10-25..2024-12-20&type=Issues) | [@gogasca](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Agogasca+updated%3A2023-10-25..2024-12-20&type=Issues) | [@hansepac](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ahansepac+updated%3A2023-10-25..2024-12-20&type=Issues) | [@holzman](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aholzman+updated%3A2023-10-25..2024-12-20&type=Issues) | [@IITII](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AIITII+updated%3A2023-10-25..2024-12-20&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2023-10-25..2024-12-20&type=Issues) | [@lresende](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Alresende+updated%3A2023-10-25..2024-12-20&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amanics+updated%3A2023-10-25..2024-12-20&type=Issues) | [@markypizz](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amarkypizz+updated%3A2023-10-25..2024-12-20&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-10-25..2024-12-20&type=Issues) | [@mwouts](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amwouts+updated%3A2023-10-25..2024-12-20&type=Issues) | [@ojarjur](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aojarjur+updated%3A2023-10-25..2024-12-20&type=Issues) | [@oliver-sanders](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aoliver-sanders+updated%3A2023-10-25..2024-12-20&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2023-10-25..2024-12-20&type=Issues) | [@Timeroot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ATimeroot+updated%3A2023-10-25..2024-12-20&type=Issues) | [@tornaria](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Atornaria+updated%3A2023-10-25..2024-12-20&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-10-25..2024-12-20&type=Issues) | [@Wh1isper](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AWh1isper+updated%3A2023-10-25..2024-12-20&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ayuvipanda+updated%3A2023-10-25..2024-12-20&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2023-10-25..2024-12-20&type=Issues) ## 2.14.2 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.14.1...b961d4eb499071c0c60e24f429c20d1e6a908a32)) ### Bugs fixed - Pass session_id during Websocket connect [#1440](https://github.com/jupyter-server/jupyter_server/pull/1440) ([@gogasca](https://github.com/gogasca)) - Do not log environment variables passed to kernels [#1437](https://github.com/jupyter-server/jupyter_server/pull/1437) ([@krassowski](https://github.com/krassowski)) ### Maintenance and upkeep improvements - chore: update pre-commit hooks [#1441](https://github.com/jupyter-server/jupyter_server/pull/1441) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - chore: update pre-commit hooks [#1427](https://github.com/jupyter-server/jupyter_server/pull/1427) ([@pre-commit-ci](https://github.com/pre-commit-ci)) ### Documentation improvements - Update documentation for `cookie_secret` [#1433](https://github.com/jupyter-server/jupyter_server/pull/1433) ([@krassowski](https://github.com/krassowski)) - Add Changelog for 2.14.1 [#1430](https://github.com/jupyter-server/jupyter_server/pull/1430) ([@blink1073](https://github.com/blink1073)) - Update simple extension examples: \_jupyter_server_extension_points [#1426](https://github.com/jupyter-server/jupyter_server/pull/1426) ([@manics](https://github.com/manics)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2024-05-31&to=2024-07-12&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2024-05-31..2024-07-12&type=Issues) | [@gogasca](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Agogasca+updated%3A2024-05-31..2024-07-12&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2024-05-31..2024-07-12&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amanics+updated%3A2024-05-31..2024-07-12&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2024-05-31..2024-07-12&type=Issues) ## 2.14.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.14.0...f1379164fa209bc4bfeadf43ab0e7f473b03a0ce)) ### Enhancements made - Removing excessive logging from reading local files [#1420](https://github.com/jupyter-server/jupyter_server/pull/1420) ([@lresende](https://github.com/lresende)) ### Security Fix - [Filefind: avoid handling absolute paths](https://github.com/jupyter-server/jupyter_server/security/advisories/GHSA-hrw6-wg82-cm62) ### Maintenance and upkeep improvements - Use hatch fmt command [#1424](https://github.com/jupyter-server/jupyter_server/pull/1424) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1421](https://github.com/jupyter-server/jupyter_server/pull/1421) ([@pre-commit-ci](https://github.com/pre-commit-ci)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2024-04-11&to=2024-05-31&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2024-04-11..2024-05-31&type=Issues) | [@lresende](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Alresende+updated%3A2024-04-11..2024-05-31&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2024-04-11..2024-05-31&type=Issues) ## 2.14.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.13.0...074628806d6b2ec3304d60ab5cfba1c326f67730)) ### Enhancements made - Do not include token in dashboard link, when available [#1406](https://github.com/jupyter-server/jupyter_server/pull/1406) ([@minrk](https://github.com/minrk)) ### Bugs fixed - Ignore zero-length page_config.json, restore previous behavior of crashing for invalid JSON [#1405](https://github.com/jupyter-server/jupyter_server/pull/1405) ([@holzman](https://github.com/holzman)) - Don't crash on invalid JSON in page_config (#1403) [#1404](https://github.com/jupyter-server/jupyter_server/pull/1404) ([@holzman](https://github.com/holzman)) ### Maintenance and upkeep improvements - Fix jupytext and lint CI failures [#1413](https://github.com/jupyter-server/jupyter_server/pull/1413) ([@blink1073](https://github.com/blink1073)) - Set all min deps [#1411](https://github.com/jupyter-server/jupyter_server/pull/1411) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1409](https://github.com/jupyter-server/jupyter_server/pull/1409) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Update pytest requirement from \<8,>=7.0 to >=7.0,\<9 [#1402](https://github.com/jupyter-server/jupyter_server/pull/1402) ([@dependabot](https://github.com/dependabot)) - Pin to Pytest 7 [#1401](https://github.com/jupyter-server/jupyter_server/pull/1401) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Link to GitHub repo from the docs [#1415](https://github.com/jupyter-server/jupyter_server/pull/1415) ([@krassowski](https://github.com/krassowski)) - docs: list server extensions [#1412](https://github.com/jupyter-server/jupyter_server/pull/1412) ([@oliver-sanders](https://github.com/oliver-sanders)) - Update simple extension README to cd into correct subdirectory [#1410](https://github.com/jupyter-server/jupyter_server/pull/1410) ([@markypizz](https://github.com/markypizz)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2024-03-04&to=2024-04-11&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2024-03-04..2024-04-11&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adependabot+updated%3A2024-03-04..2024-04-11&type=Issues) | [@holzman](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aholzman+updated%3A2024-03-04..2024-04-11&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2024-03-04..2024-04-11&type=Issues) | [@markypizz](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amarkypizz+updated%3A2024-03-04..2024-04-11&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2024-03-04..2024-04-11&type=Issues) | [@oliver-sanders](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aoliver-sanders+updated%3A2024-03-04..2024-04-11&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2024-03-04..2024-04-11&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2024-03-04..2024-04-11&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2024-03-04..2024-04-11&type=Issues) ## 2.13.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.12.5...1369a5364d36a977fbec5957ed21d69acbbeda5a)) ### Enhancements made - Add an option to have authentication enabled for all endpoints by default [#1392](https://github.com/jupyter-server/jupyter_server/pull/1392) ([@krassowski](https://github.com/krassowski)) - websockets: add configurations for ping interval and timeout [#1391](https://github.com/jupyter-server/jupyter_server/pull/1391) ([@oliver-sanders](https://github.com/oliver-sanders)) ### Bugs fixed - Fix color in windows log console with colorama [#1397](https://github.com/jupyter-server/jupyter_server/pull/1397) ([@hansepac](https://github.com/hansepac)) ### Maintenance and upkeep improvements - Update release workflows [#1399](https://github.com/jupyter-server/jupyter_server/pull/1399) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1390](https://github.com/jupyter-server/jupyter_server/pull/1390) ([@pre-commit-ci](https://github.com/pre-commit-ci)) ### Documentation improvements - Add deprecation note for `ServerApp.preferred_dir` [#1396](https://github.com/jupyter-server/jupyter_server/pull/1396) ([@krassowski](https://github.com/krassowski)) - Replace \_jupyter_server_extension_paths in apidocs [#1393](https://github.com/jupyter-server/jupyter_server/pull/1393) ([@manics](https://github.com/manics)) - fix "Shutdown" -> "Shut down" [#1389](https://github.com/jupyter-server/jupyter_server/pull/1389) ([@Timeroot](https://github.com/Timeroot)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2024-01-16&to=2024-03-04&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2024-01-16..2024-03-04&type=Issues) | [@hansepac](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ahansepac+updated%3A2024-01-16..2024-03-04&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2024-01-16..2024-03-04&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amanics+updated%3A2024-01-16..2024-03-04&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2024-01-16..2024-03-04&type=Issues) | [@oliver-sanders](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aoliver-sanders+updated%3A2024-01-16..2024-03-04&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2024-01-16..2024-03-04&type=Issues) | [@Timeroot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ATimeroot+updated%3A2024-01-16..2024-03-04&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2024-01-16..2024-03-04&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ayuvipanda+updated%3A2024-01-16..2024-03-04&type=Issues) ## 2.12.5 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.12.4...a3a9d3deea7a798d13fe09a41e53f6f825caf21b)) ### Maintenance and upkeep improvements - Improve warning handling [#1386](https://github.com/jupyter-server/jupyter_server/pull/1386) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2024-01-11&to=2024-01-16&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2024-01-11..2024-01-16&type=Issues) ## 2.12.4 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.12.3...7bb21b45392c889b5c87eb0d1b48662a497ba15a)) ### Bugs fixed - Fix log arguments for gateway client error [#1385](https://github.com/jupyter-server/jupyter_server/pull/1385) ([@minrk](https://github.com/minrk)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2024-01-09&to=2024-01-11&type=c)) [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2024-01-09..2024-01-11&type=Issues) ## 2.12.3 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.12.2...99b9126853b69aafb700b4c92b50b83b7ca00e32)) ### Bugs fixed - Import User unconditionally [#1384](https://github.com/jupyter-server/jupyter_server/pull/1384) ([@yuvipanda](https://github.com/yuvipanda)) ### Maintenance and upkeep improvements - Simplify the jupytext downstream test [#1383](https://github.com/jupyter-server/jupyter_server/pull/1383) ([@mwouts](https://github.com/mwouts)) - Fix test param for pytest-xdist [#1382](https://github.com/jupyter-server/jupyter_server/pull/1382) ([@tornaria](https://github.com/tornaria)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2024-01-04&to=2024-01-09&type=c)) [@mwouts](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amwouts+updated%3A2024-01-04..2024-01-09&type=Issues) | [@tornaria](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Atornaria+updated%3A2024-01-04..2024-01-09&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2024-01-04..2024-01-09&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ayuvipanda+updated%3A2024-01-04..2024-01-09&type=Issues) ## 2.12.2 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.12.1...49915685531ce90baae9d2a4d6baa9c533beffcc)) ### Bugs fixed - Fix a typo in error message [#1381](https://github.com/jupyter-server/jupyter_server/pull/1381) ([@krassowski](https://github.com/krassowski)) - Force legacy ws subprotocol when using gateway [#1311](https://github.com/jupyter-server/jupyter_server/pull/1311) ([@epignot](https://github.com/epignot)) ### Maintenance and upkeep improvements - Update pre-commit deps [#1380](https://github.com/jupyter-server/jupyter_server/pull/1380) ([@blink1073](https://github.com/blink1073)) - Use ruff docstring-code-format [#1377](https://github.com/jupyter-server/jupyter_server/pull/1377) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Enable htmlzip and epub on readthedocs [#1379](https://github.com/jupyter-server/jupyter_server/pull/1379) ([@bollwyvl](https://github.com/bollwyvl)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-12-06&to=2024-01-04&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-12-06..2024-01-04&type=Issues) | [@bollwyvl](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abollwyvl+updated%3A2023-12-06..2024-01-04&type=Issues) | [@epignot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aepignot+updated%3A2023-12-06..2024-01-04&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2023-12-06..2024-01-04&type=Issues) ## 2.12.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.12.0...a59beb9b7bf3decc00af782821561435f47bbb16)) ### Enhancements made - log extension import time at debug level unless it's actually slow [#1375](https://github.com/jupyter-server/jupyter_server/pull/1375) ([@minrk](https://github.com/minrk)) - Add support for async Authorizers (part 2) [#1374](https://github.com/jupyter-server/jupyter_server/pull/1374) ([@Zsailer](https://github.com/Zsailer)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-12-05&to=2023-12-06&type=c)) [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-12-05..2023-12-06&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2023-12-05..2023-12-06&type=Issues) ## 2.12.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.11.2...3bd347b6f2ead5897a18c6171db1174eaaf6176d)) ### Enhancements made - Support async Authorizers [#1373](https://github.com/jupyter-server/jupyter_server/pull/1373) ([@Zsailer](https://github.com/Zsailer)) ### Maintenance and upkeep improvements - Update for tornado 6.4 [#1372](https://github.com/jupyter-server/jupyter_server/pull/1372) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1370](https://github.com/jupyter-server/jupyter_server/pull/1370) ([@pre-commit-ci](https://github.com/pre-commit-ci)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-12-04&to=2023-12-05&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-12-04..2023-12-05&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2023-12-04..2023-12-05&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2023-12-04..2023-12-05&type=Issues) ## 2.11.2 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.11.1)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-11-27&to=2023-12-04&type=c)) ## 2.11.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.11.0...40a95e5f39d3f167bebf9232da9fab64818ba97d)) ### Bugs fixed - avoid unhandled error on some invalid paths [#1369](https://github.com/jupyter-server/jupyter_server/pull/1369) ([@minrk](https://github.com/minrk)) - Change md5 to hash and hash_algorithm, fix incompatibility [#1367](https://github.com/jupyter-server/jupyter_server/pull/1367) ([@Wh1isper](https://github.com/Wh1isper)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-11-21&to=2023-11-27&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-11-21..2023-11-27&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afcollonval+updated%3A2023-11-21..2023-11-27&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-11-21..2023-11-27&type=Issues) | [@Wh1isper](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AWh1isper+updated%3A2023-11-21..2023-11-27&type=Issues) ## 2.11.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.10.1...e7c0f331d4cbf82eb1a9e9bc6c260faabda0255a)) ### Enhancements made - Support get file(notebook) md5 [#1363](https://github.com/jupyter-server/jupyter_server/pull/1363) ([@Wh1isper](https://github.com/Wh1isper)) ### Maintenance and upkeep improvements - Update ruff and typings [#1365](https://github.com/jupyter-server/jupyter_server/pull/1365) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Update api docs with md5 param [#1364](https://github.com/jupyter-server/jupyter_server/pull/1364) ([@Wh1isper](https://github.com/Wh1isper)) - typo: ServerApp [#1361](https://github.com/jupyter-server/jupyter_server/pull/1361) ([@IITII](https://github.com/IITII)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-11-15&to=2023-11-21&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-11-15..2023-11-21&type=Issues) | [@IITII](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AIITII+updated%3A2023-11-15..2023-11-21&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-11-15..2023-11-21&type=Issues) | [@Wh1isper](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AWh1isper+updated%3A2023-11-15..2023-11-21&type=Issues) ## 2.10.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.10.0...9f8ff2886903a6744c5eb483f9e5bd7e63d5d015)) ### Bugs fixed - ContentsHandler return 404 rather than raise exc [#1357](https://github.com/jupyter-server/jupyter_server/pull/1357) ([@bloomsa](https://github.com/bloomsa)) ### Maintenance and upkeep improvements - Clean up ruff config [#1358](https://github.com/jupyter-server/jupyter_server/pull/1358) ([@blink1073](https://github.com/blink1073)) - Add more typings [#1356](https://github.com/jupyter-server/jupyter_server/pull/1356) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1355](https://github.com/jupyter-server/jupyter_server/pull/1355) ([@pre-commit-ci](https://github.com/pre-commit-ci)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-11-06&to=2023-11-15&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-11-06..2023-11-15&type=Issues) | [@bloomsa](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abloomsa+updated%3A2023-11-06..2023-11-15&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2023-11-06..2023-11-15&type=Issues) ## 2.10.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.9.1...e71e95884483c7ce2d9fd5ee83059a0269741aa1)) ### Enhancements made - Update kernel env to reflect changes in session [#1354](https://github.com/jupyter-server/jupyter_server/pull/1354) ([@blink1073](https://github.com/blink1073)) ### Maintenance and upkeep improvements - Clean up config and address warnings [#1353](https://github.com/jupyter-server/jupyter_server/pull/1353) ([@blink1073](https://github.com/blink1073)) - Clean up lint and typing [#1351](https://github.com/jupyter-server/jupyter_server/pull/1351) ([@blink1073](https://github.com/blink1073)) - Update typing for traitlets 5.13 [#1350](https://github.com/jupyter-server/jupyter_server/pull/1350) ([@blink1073](https://github.com/blink1073)) - Update typings and fix tests [#1344](https://github.com/jupyter-server/jupyter_server/pull/1344) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-10-25&to=2023-11-06&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-10-25..2023-11-06&type=Issues) ## 2.9.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.9.0...bb293ec5cac5b277259f27e458da60fa8a926f46)) ### Bugs fixed - Revert "Update kernel env to reflect changes in session." [#1346](https://github.com/jupyter-server/jupyter_server/pull/1346) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-10-25&to=2023-10-25&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-10-25..2023-10-25&type=Issues) ## 2.9.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.8.0...3438ddb16575155e98fc4f49700fff420088c8b0)) ### Enhancements made - Ability to configure cull_idle_timeout with kernelSpec [#1342](https://github.com/jupyter-server/jupyter_server/pull/1342) ([@akshaychitneni](https://github.com/akshaychitneni)) - Update kernel env to reflect changes in session. [#1341](https://github.com/jupyter-server/jupyter_server/pull/1341) ([@Carreau](https://github.com/Carreau)) ### Bugs fixed - Run Gateway token renewers even if the auth token is empty. [#1340](https://github.com/jupyter-server/jupyter_server/pull/1340) ([@ojarjur](https://github.com/ojarjur)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-10-16&to=2023-10-25&type=c)) [@akshaychitneni](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aakshaychitneni+updated%3A2023-10-16..2023-10-25&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2023-10-16..2023-10-25&type=Issues) | [@ojarjur](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aojarjur+updated%3A2023-10-16..2023-10-25&type=Issues) ## 2.8.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.7.3...a984e0771da5db4a14e9ac86a392ad3592b863e5)) ### Enhancements made - Added Logs for get_os_path closes issue [#1336](https://github.com/jupyter-server/jupyter_server/pull/1336) ([@jayeshsingh9767](https://github.com/jayeshsingh9767)) ### Bugs fixed - Avoid showing "No answer for 5s" when shutdown is slow [#1320](https://github.com/jupyter-server/jupyter_server/pull/1320) ([@minrk](https://github.com/minrk)) ### Maintenance and upkeep improvements - Update typings for mypy 1.6 [#1337](https://github.com/jupyter-server/jupyter_server/pull/1337) ([@blink1073](https://github.com/blink1073)) - chore: update pre-commit hooks [#1334](https://github.com/jupyter-server/jupyter_server/pull/1334) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Add typings to commonly used APIs [#1333](https://github.com/jupyter-server/jupyter_server/pull/1333) ([@blink1073](https://github.com/blink1073)) - Update typings for traitlets 5.10 [#1330](https://github.com/jupyter-server/jupyter_server/pull/1330) ([@blink1073](https://github.com/blink1073)) - Adopt sp-repo-review [#1324](https://github.com/jupyter-server/jupyter_server/pull/1324) ([@blink1073](https://github.com/blink1073)) - Bump actions/checkout from 3 to 4 [#1321](https://github.com/jupyter-server/jupyter_server/pull/1321) ([@dependabot](https://github.com/dependabot)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-08-31&to=2023-10-16&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-08-31..2023-10-16&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adependabot+updated%3A2023-08-31..2023-10-16&type=Issues) | [@jayeshsingh9767](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajayeshsingh9767+updated%3A2023-08-31..2023-10-16&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-08-31..2023-10-16&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2023-08-31..2023-10-16&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-08-31..2023-10-16&type=Issues) ## 2.7.3 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.7.2...e72bf7187e396605f46ba59567543ef6386e8920)) ### New features added - Support external kernels [#1305](https://github.com/jupyter-server/jupyter_server/pull/1305) ([@davidbrochart](https://github.com/davidbrochart)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-08-18&to=2023-08-31&type=c)) [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2023-08-18..2023-08-31&type=Issues) ## 2.7.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.7.0...d8f4856c32b895106eac58c9c5768afd0e2f6465)) ### Bugs fixed - accessing API version should not count as activity [#1315](https://github.com/jupyter-server/jupyter_server/pull/1315) ([@minrk](https://github.com/minrk)) - Make kernel_id as a conditional optional field [#1300](https://github.com/jupyter-server/jupyter_server/pull/1300) ([@allstrive](https://github.com/allstrive)) - Reference current_user to detect auth [#1294](https://github.com/jupyter-server/jupyter_server/pull/1294) ([@bhperry](https://github.com/bhperry)) ### Maintenance and upkeep improvements - send2trash now supports deleting from different filesystem type(#1290) [#1291](https://github.com/jupyter-server/jupyter_server/pull/1291) ([@wqj97](https://github.com/wqj97)) ### Documentation improvements - Add root `/api/` endpoint to REST spec [#1312](https://github.com/jupyter-server/jupyter_server/pull/1312) ([@minrk](https://github.com/minrk)) - Fix broken link in doc [#1307](https://github.com/jupyter-server/jupyter_server/pull/1307) ([@Hind-M](https://github.com/Hind-M)) - Rename notebook.auth.security.passwd->jupyter_server.auth.passwd in docs [#1306](https://github.com/jupyter-server/jupyter_server/pull/1306) ([@mathbunnyru](https://github.com/mathbunnyru)) - Update notes link [#1298](https://github.com/jupyter-server/jupyter_server/pull/1298) ([@krassowski](https://github.com/krassowski)) - docs: fix broken hyperlink to Tornado [#1297](https://github.com/jupyter-server/jupyter_server/pull/1297) ([@emmanuel-ferdman](https://github.com/emmanuel-ferdman)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-06-27&to=2023-08-15&type=c)) [@allstrive](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aallstrive+updated%3A2023-06-27..2023-08-15&type=Issues) | [@bhperry](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abhperry+updated%3A2023-06-27..2023-08-15&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-06-27..2023-08-15&type=Issues) | [@emmanuel-ferdman](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aemmanuel-ferdman+updated%3A2023-06-27..2023-08-15&type=Issues) | [@Hind-M](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AHind-M+updated%3A2023-06-27..2023-08-15&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2023-06-27..2023-08-15&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2023-06-27..2023-08-15&type=Issues) | [@mathbunnyru](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amathbunnyru+updated%3A2023-06-27..2023-08-15&type=Issues) | [@matthewwiese](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amatthewwiese+updated%3A2023-06-27..2023-08-15&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-06-27..2023-08-15&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2023-06-27..2023-08-15&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-06-27..2023-08-15&type=Issues) | [@wqj97](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awqj97+updated%3A2023-06-27..2023-08-15&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2023-06-27..2023-08-15&type=Issues) ## 2.7.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.6.0...b652f8d08530bd60ecf4cfffe6c32939fd94eb41)) ### Bugs fixed - Add missing events to gateway client [#1288](https://github.com/jupyter-server/jupyter_server/pull/1288) ([@allstrive](https://github.com/allstrive)) ### Maintenance and upkeep improvements - Handle test failures [#1289](https://github.com/jupyter-server/jupyter_server/pull/1289) ([@blink1073](https://github.com/blink1073)) - Try testing against python 3.12 [#1282](https://github.com/jupyter-server/jupyter_server/pull/1282) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Remove frontend doc [#1292](https://github.com/jupyter-server/jupyter_server/pull/1292) ([@fcollonval](https://github.com/fcollonval)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-05-25&to=2023-06-27&type=c)) [@allstrive](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aallstrive+updated%3A2023-05-25..2023-06-27&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-05-25..2023-06-27&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afcollonval+updated%3A2023-05-25..2023-06-27&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2023-05-25..2023-06-27&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-05-25..2023-06-27&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2023-05-25..2023-06-27&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-05-25..2023-06-27&type=Issues) ## 2.6.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.5.0...35b8e9cb68eec48fe9a017ac128cb776c2ead195)) ### New features added - Emit events from the kernels service and gateway client [#1252](https://github.com/jupyter-server/jupyter_server/pull/1252) ([@rajmusuku](https://github.com/rajmusuku)) ### Enhancements made - Allows immutable cache for static files in a directory [#1268](https://github.com/jupyter-server/jupyter_server/pull/1268) ([@brichet](https://github.com/brichet)) - Merge the gateway handlers into the standard handlers. [#1261](https://github.com/jupyter-server/jupyter_server/pull/1261) ([@ojarjur](https://github.com/ojarjur)) - Gateway manager retry kernel updates [#1256](https://github.com/jupyter-server/jupyter_server/pull/1256) ([@ojarjur](https://github.com/ojarjur)) - Use debug-level messages for generating anonymous users [#1254](https://github.com/jupyter-server/jupyter_server/pull/1254) ([@hbcarlos](https://github.com/hbcarlos)) - Define a CURRENT_JUPYTER_HANDLER context var [#1251](https://github.com/jupyter-server/jupyter_server/pull/1251) ([@Zsailer](https://github.com/Zsailer)) ### Bugs fixed - Don't instantiate an unused Future in gateway connection trait [#1276](https://github.com/jupyter-server/jupyter_server/pull/1276) ([@minrk](https://github.com/minrk)) - Write server list to stdout [#1275](https://github.com/jupyter-server/jupyter_server/pull/1275) ([@minrk](https://github.com/minrk)) - Make the kernel_websocket_protocol flag reusable. [#1264](https://github.com/jupyter-server/jupyter_server/pull/1264) ([@ojarjur](https://github.com/ojarjur)) - Register websocket handler from same module as kernel handlers [#1249](https://github.com/jupyter-server/jupyter_server/pull/1249) ([@kevin-bates](https://github.com/kevin-bates)) - Re-enable websocket ping/pong from the server [#1243](https://github.com/jupyter-server/jupyter_server/pull/1243) ([@Zsailer](https://github.com/Zsailer)) - Fix italics in operators security sections [#1242](https://github.com/jupyter-server/jupyter_server/pull/1242) ([@kevin-bates](https://github.com/kevin-bates)) - Fix calculation of schema location [#1239](https://github.com/jupyter-server/jupyter_server/pull/1239) ([@lresende](https://github.com/lresende)) ### Maintenance and upkeep improvements - Fix DeprecationWarning from pytest-console-scripts [#1281](https://github.com/jupyter-server/jupyter_server/pull/1281) ([@frenzymadness](https://github.com/frenzymadness)) - Remove docutils and mistune pins [#1278](https://github.com/jupyter-server/jupyter_server/pull/1278) ([@blink1073](https://github.com/blink1073)) - Update docutils requirement from \<0.20 to \<0.21 [#1277](https://github.com/jupyter-server/jupyter_server/pull/1277) ([@dependabot](https://github.com/dependabot)) - Use Python 3.9 for the readthedocs builds [#1269](https://github.com/jupyter-server/jupyter_server/pull/1269) ([@ojarjur](https://github.com/ojarjur)) - Fix coverage handling [#1257](https://github.com/jupyter-server/jupyter_server/pull/1257) ([@blink1073](https://github.com/blink1073)) - chore: delete `.gitmodules` [#1248](https://github.com/jupyter-server/jupyter_server/pull/1248) ([@SauravMaheshkar](https://github.com/SauravMaheshkar)) - chore: move `babel` and `eslint` configuration under `package.json` [#1246](https://github.com/jupyter-server/jupyter_server/pull/1246) ([@SauravMaheshkar](https://github.com/SauravMaheshkar)) ### Documentation improvements - Fix typo in docs [#1270](https://github.com/jupyter-server/jupyter_server/pull/1270) ([@davidbrochart](https://github.com/davidbrochart)) - Fix typo [#1262](https://github.com/jupyter-server/jupyter_server/pull/1262) ([@davidbrochart](https://github.com/davidbrochart)) - Extends the IP documentation [#1258](https://github.com/jupyter-server/jupyter_server/pull/1258) ([@hbcarlos](https://github.com/hbcarlos)) - Fix italics in operators security sections [#1242](https://github.com/jupyter-server/jupyter_server/pull/1242) ([@kevin-bates](https://github.com/kevin-bates)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-03-16&to=2023-05-25&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-03-16..2023-05-25&type=Issues) | [@brichet](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abrichet+updated%3A2023-03-16..2023-05-25&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2023-03-16..2023-05-25&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2023-03-16..2023-05-25&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adependabot+updated%3A2023-03-16..2023-05-25&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2023-03-16..2023-05-25&type=Issues) | [@frenzymadness](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afrenzymadness+updated%3A2023-03-16..2023-05-25&type=Issues) | [@hbcarlos](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ahbcarlos+updated%3A2023-03-16..2023-05-25&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2023-03-16..2023-05-25&type=Issues) | [@lresende](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Alresende+updated%3A2023-03-16..2023-05-25&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-03-16..2023-05-25&type=Issues) | [@ojarjur](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aojarjur+updated%3A2023-03-16..2023-05-25&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2023-03-16..2023-05-25&type=Issues) | [@rajmusuku](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Arajmusuku+updated%3A2023-03-16..2023-05-25&type=Issues) | [@SauravMaheshkar](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ASauravMaheshkar+updated%3A2023-03-16..2023-05-25&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-03-16..2023-05-25&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ayuvipanda+updated%3A2023-03-16..2023-05-25&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2023-03-16..2023-05-25&type=Issues) ## 2.5.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.4.0...dc1eee8715dfe674560789caa5123dc895717ca1)) ### Enhancements made - Enable KernelSpecResourceHandler to be async [#1236](https://github.com/jupyter-server/jupyter_server/pull/1236) ([@Zsailer](https://github.com/Zsailer)) - Added error propagation to gateway_request function [#1233](https://github.com/jupyter-server/jupyter_server/pull/1233) ([@broden-wanner](https://github.com/broden-wanner)) ### Maintenance and upkeep improvements - Update ruff [#1230](https://github.com/jupyter-server/jupyter_server/pull/1230) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-03-06&to=2023-03-16&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-03-06..2023-03-16&type=Issues) | [@broden-wanner](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abroden-wanner+updated%3A2023-03-06..2023-03-16&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2023-03-06..2023-03-16&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-03-06..2023-03-16&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2023-03-06..2023-03-16&type=Issues) ## 2.4.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.3.0...4d311b2c91e055e7b4690d8100f7fe85381f06da)) ### Enhancements made - Skip dir size check if not enumerable [#1227](https://github.com/jupyter-server/jupyter_server/pull/1227) ([@vidartf](https://github.com/vidartf)) - Optimize hidden checks [#1226](https://github.com/jupyter-server/jupyter_server/pull/1226) ([@vidartf](https://github.com/vidartf)) - Enable users to copy both files and directories [#1190](https://github.com/jupyter-server/jupyter_server/pull/1190) ([@kenyaachon](https://github.com/kenyaachon)) ### Bugs fixed - Fix port selection [#1229](https://github.com/jupyter-server/jupyter_server/pull/1229) ([@blink1073](https://github.com/blink1073)) - Fix priority of deprecated NotebookApp.notebook_dir behind ServerApp.root_dir (#1223 [#1223](https://github.com/jupyter-server/jupyter_server/pull/1223) ([@minrk](https://github.com/minrk)) - Ensure content-type properly reflects gateway kernelspec resources [#1219](https://github.com/jupyter-server/jupyter_server/pull/1219) ([@kevin-bates](https://github.com/kevin-bates)) ### Maintenance and upkeep improvements - fix docs build [#1225](https://github.com/jupyter-server/jupyter_server/pull/1225) ([@blink1073](https://github.com/blink1073)) - Fix ci failures [#1222](https://github.com/jupyter-server/jupyter_server/pull/1222) ([@blink1073](https://github.com/blink1073)) - Clean up license [#1218](https://github.com/jupyter-server/jupyter_server/pull/1218) ([@dcsaba89](https://github.com/dcsaba89)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-02-15&to=2023-03-06&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-02-15..2023-03-06&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2023-02-15..2023-03-06&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2023-02-15..2023-03-06&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2023-02-15..2023-03-06&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2023-02-15..2023-03-06&type=Issues) | [@dcsaba89](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adcsaba89+updated%3A2023-02-15..2023-03-06&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2023-02-15..2023-03-06&type=Issues) | [@kenyaachon](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akenyaachon+updated%3A2023-02-15..2023-03-06&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2023-02-15..2023-03-06&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-02-15..2023-03-06&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2023-02-15..2023-03-06&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-02-15..2023-03-06&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2023-02-15..2023-03-06&type=Issues) ## 2.3.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.2.1...968c56c8c69aa545f7fe93243331fe140dac7c90)) ### Enhancements made - Support IPV6 in \_find_http_port() [#1207](https://github.com/jupyter-server/jupyter_server/pull/1207) ([@schnell18](https://github.com/schnell18)) ### Bugs fixed - Redact tokens, etc. in url parameters from request logs [#1212](https://github.com/jupyter-server/jupyter_server/pull/1212) ([@minrk](https://github.com/minrk)) - Fix get_loader returning None when load_jupyter_server_extension is not found (#1193)Co-authored-by: pre-commit-ci\[bot\] \<66853113+pre-commit-ci\[bot\]@users.noreply.github.com> [#1193](https://github.com/jupyter-server/jupyter_server/pull/1193) ([@cmd-ntrf](https://github.com/cmd-ntrf)) ### Maintenance and upkeep improvements - update LICENSE [#1197](https://github.com/jupyter-server/jupyter_server/pull/1197) ([@dcsaba89](https://github.com/dcsaba89)) - Add license 2 (#1196 [#1196](https://github.com/jupyter-server/jupyter_server/pull/1196) ([@dcsaba89](https://github.com/dcsaba89)) ### Documentation improvements - Update jupyterhub security link [#1200](https://github.com/jupyter-server/jupyter_server/pull/1200) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-02-02&to=2023-02-15&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-02-02..2023-02-15&type=Issues) | [@cmd-ntrf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acmd-ntrf+updated%3A2023-02-02..2023-02-15&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2023-02-02..2023-02-15&type=Issues) | [@dcsaba89](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adcsaba89+updated%3A2023-02-02..2023-02-15&type=Issues) | [@meeseeksdev](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ameeseeksdev+updated%3A2023-02-02..2023-02-15&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-02-02..2023-02-15&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2023-02-02..2023-02-15&type=Issues) | [@schnell18](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aschnell18+updated%3A2023-02-02..2023-02-15&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-02-02..2023-02-15&type=Issues) ## 2.2.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.2.0...0f9556b48d7699bd2d246222067b1cb215d44c28)) ### Maintenance and upkeep improvements - Delete the extra "or" in front of the second url [#1194](https://github.com/jupyter-server/jupyter_server/pull/1194) ([@jonnygrout](https://github.com/jonnygrout)) - remove upper bound on anyio [#1192](https://github.com/jupyter-server/jupyter_server/pull/1192) ([@minrk](https://github.com/minrk)) - Adopt more lint rules [#1189](https://github.com/jupyter-server/jupyter_server/pull/1189) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-01-31&to=2023-02-02&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-01-31..2023-02-02&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2023-01-31..2023-02-02&type=Issues) | [@jonnygrout](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajonnygrout+updated%3A2023-01-31..2023-02-02&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-01-31..2023-02-02&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-01-31..2023-02-02&type=Issues) ## 2.2.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.1.0...b6c1edb0b205f8d53f1a2e81abb997bfc693f144)) ### Enhancements made - Only load enabled extension packages [#1180](https://github.com/jupyter-server/jupyter_server/pull/1180) ([@minrk](https://github.com/minrk)) - Pass in a logger to get_metadata [#1176](https://github.com/jupyter-server/jupyter_server/pull/1176) ([@yuvipanda](https://github.com/yuvipanda)) ### Bugs fixed - Don't assume that resources entries are relative [#1182](https://github.com/jupyter-server/jupyter_server/pull/1182) ([@ojarjur](https://github.com/ojarjur)) ### Maintenance and upkeep improvements - Updates for client 8 [#1188](https://github.com/jupyter-server/jupyter_server/pull/1188) ([@blink1073](https://github.com/blink1073)) - Use repr in logging for exception. [#1185](https://github.com/jupyter-server/jupyter_server/pull/1185) ([@Carreau](https://github.com/Carreau)) - Update example npm deps [#1184](https://github.com/jupyter-server/jupyter_server/pull/1184) ([@blink1073](https://github.com/blink1073)) - Fix docs and examples [#1183](https://github.com/jupyter-server/jupyter_server/pull/1183) ([@blink1073](https://github.com/blink1073)) - Update jupyter client api docs links [#1179](https://github.com/jupyter-server/jupyter_server/pull/1179) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-01-13&to=2023-01-31&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-01-13..2023-01-31&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2023-01-13..2023-01-31&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2023-01-13..2023-01-31&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2023-01-13..2023-01-31&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2023-01-13..2023-01-31&type=Issues) | [@ojarjur](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aojarjur+updated%3A2023-01-13..2023-01-31&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2023-01-13..2023-01-31&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ayuvipanda+updated%3A2023-01-13..2023-01-31&type=Issues) ## 2.1.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.7...34f509d8da1710039634bc16f5336570c4861bcd)) ### Bugs fixed - Fix preferred_dir for sync contents manager [#1173](https://github.com/jupyter-server/jupyter_server/pull/1173) ([@vidartf](https://github.com/vidartf)) ### Maintenance and upkeep improvements - Update typing and warning handling [#1174](https://github.com/jupyter-server/jupyter_server/pull/1174) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Add api docs [#1159](https://github.com/jupyter-server/jupyter_server/pull/1159) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2023-01-12&to=2023-01-12&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2023-01-12..2023-01-12&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2023-01-12..2023-01-12&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2023-01-12..2023-01-12&type=Issues) ## 2.0.7 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.6...5cce2afcbeeb44581e9b29ab27fef75a12d651ca)) ### Enhancements made - Log how long each extension module takes to import [#1171](https://github.com/jupyter-server/jupyter_server/pull/1171) ([@yuvipanda](https://github.com/yuvipanda)) - Set JPY_SESSION_NAME to full notebook path. [#1100](https://github.com/jupyter-server/jupyter_server/pull/1100) ([@Carreau](https://github.com/Carreau)) ### Bugs fixed - Reapply preferred_dir fix, now with better backwards compatibility [#1162](https://github.com/jupyter-server/jupyter_server/pull/1162) ([@vidartf](https://github.com/vidartf)) ### Maintenance and upkeep improvements - Update example to use hatch [#1169](https://github.com/jupyter-server/jupyter_server/pull/1169) ([@blink1073](https://github.com/blink1073)) - Clean up docs build and typing [#1168](https://github.com/jupyter-server/jupyter_server/pull/1168) ([@blink1073](https://github.com/blink1073)) - Fix check release by ignoring duplicate file name in wheel [#1163](https://github.com/jupyter-server/jupyter_server/pull/1163) ([@blink1073](https://github.com/blink1073)) - Fix broken link in warning message [#1158](https://github.com/jupyter-server/jupyter_server/pull/1158) ([@consideRatio](https://github.com/consideRatio)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-12-29&to=2023-01-12&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-12-29..2023-01-12&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2022-12-29..2023-01-12&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2022-12-29..2023-01-12&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AconsideRatio+updated%3A2022-12-29..2023-01-12&type=Issues) | [@meeseeksdev](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ameeseeksdev+updated%3A2022-12-29..2023-01-12&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-12-29..2023-01-12&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2022-12-29..2023-01-12&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-12-29..2023-01-12&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ayuvipanda+updated%3A2022-12-29..2023-01-12&type=Issues) ## 2.0.6 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.5...73d577610cda544e85139842c4186f7e77197440)) ### Bugs fixed - Iterate through set of apps in `extension_manager.any_activity` method [#1157](https://github.com/jupyter-server/jupyter_server/pull/1157) ([@mahendrapaipuri](https://github.com/mahendrapaipuri)) ### Maintenance and upkeep improvements - Handle flake8-errmsg [#1155](https://github.com/jupyter-server/jupyter_server/pull/1155) ([@blink1073](https://github.com/blink1073)) - Add spelling and docstring enforcement [#1147](https://github.com/jupyter-server/jupyter_server/pull/1147) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Add spelling and docstring enforcement [#1147](https://github.com/jupyter-server/jupyter_server/pull/1147) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-12-23&to=2022-12-29&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-12-23..2022-12-29&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2022-12-23..2022-12-29&type=Issues) | [@mahendrapaipuri](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amahendrapaipuri+updated%3A2022-12-23..2022-12-29&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-12-23..2022-12-29&type=Issues) ## 2.0.5 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.4...ec9029f07fe377ebb86b77e0eadd159fc9288c98)) ### Bugs fixed - Remove `end` kwarg after migration from print to info [#1151](https://github.com/jupyter-server/jupyter_server/pull/1151) ([@krassowski](https://github.com/krassowski)) ### Maintenance and upkeep improvements - Import ensure-sync directly from dependence. [#1149](https://github.com/jupyter-server/jupyter_server/pull/1149) ([@Carreau](https://github.com/Carreau)) - Update deprecation warning [#1148](https://github.com/jupyter-server/jupyter_server/pull/1148) ([@Carreau](https://github.com/Carreau)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-12-21&to=2022-12-23&type=c)) [@Carreau](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2022-12-21..2022-12-23&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2022-12-21..2022-12-23&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2022-12-21..2022-12-23&type=Issues) ## 2.0.4 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.3...53377e25efe0faf4e2a984254ca2c301aeea096d)) ### Bugs fixed - Fix handling of extension last activity [#1145](https://github.com/jupyter-server/jupyter_server/pull/1145) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-12-21&to=2022-12-21&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-12-21..2022-12-21&type=Issues) ## 2.0.3 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.2...e35fbbc238a5b96d869c574fe8b8eb27b9605a05)) ### Bugs fixed - Restore default writing of browser open redirect file, add opt-in to skip [#1144](https://github.com/jupyter-server/jupyter_server/pull/1144) ([@bollwyvl](https://github.com/bollwyvl)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-12-20&to=2022-12-21&type=c)) [@bollwyvl](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abollwyvl+updated%3A2022-12-20..2022-12-21&type=Issues) ## 2.0.2 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.1...b5b7c5e9141698ab0206f74b8944972cbc4cf6fe)) ### Bugs fixed - Raise errors on individual problematic extensions when listing extension [#1139](https://github.com/jupyter-server/jupyter_server/pull/1139) ([@Zsailer](https://github.com/Zsailer)) - Find an available port before starting event loop [#1136](https://github.com/jupyter-server/jupyter_server/pull/1136) ([@blink1073](https://github.com/blink1073)) - only write browser files if we're launching the browser [#1133](https://github.com/jupyter-server/jupyter_server/pull/1133) ([@hhuuggoo](https://github.com/hhuuggoo)) - Logging message used to list sessions fails with template error [#1132](https://github.com/jupyter-server/jupyter_server/pull/1132) ([@vindex10](https://github.com/vindex10)) - Include base_url at start of kernelspec resources path [#1124](https://github.com/jupyter-server/jupyter_server/pull/1124) ([@bloomsa](https://github.com/bloomsa)) ### Maintenance and upkeep improvements - Fix lint rule [#1128](https://github.com/jupyter-server/jupyter_server/pull/1128) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-12-08&to=2022-12-20&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-12-08..2022-12-20&type=Issues) | [@bloomsa](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abloomsa+updated%3A2022-12-08..2022-12-20&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2022-12-08..2022-12-20&type=Issues) | [@hhuuggoo](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ahhuuggoo+updated%3A2022-12-08..2022-12-20&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-12-08..2022-12-20&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2022-12-08..2022-12-20&type=Issues) | [@vindex10](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avindex10+updated%3A2022-12-08..2022-12-20&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-12-08..2022-12-20&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-12-08..2022-12-20&type=Issues) ## 2.0.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0...a400c0e0de56b1abe821ce26875fad9e7e711596)) ### Enhancements made - \[Gateway\] Remove redundant list kernels request during session poll [#1112](https://github.com/jupyter-server/jupyter_server/pull/1112) ([@kevin-bates](https://github.com/kevin-bates)) ### Maintenance and upkeep improvements - Fix jupyter_core pinning [#1122](https://github.com/jupyter-server/jupyter_server/pull/1122) ([@ophie200](https://github.com/ophie200)) - Update docutils requirement from \<0.19 to \<0.20 [#1120](https://github.com/jupyter-server/jupyter_server/pull/1120) ([@dependabot](https://github.com/dependabot)) - Adopt ruff and use less pre-commit [#1114](https://github.com/jupyter-server/jupyter_server/pull/1114) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-12-06&to=2022-12-08&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-12-06..2022-12-08&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2022-12-06..2022-12-08&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adependabot+updated%3A2022-12-06..2022-12-08&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-12-06..2022-12-08&type=Issues) | [@ofek](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aofek+updated%3A2022-12-06..2022-12-08&type=Issues) | [@ophie200](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aophie200+updated%3A2022-12-06..2022-12-08&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-12-06..2022-12-08&type=Issues) ## 2.0.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/6d0803b...312327fc498e3b96f7334c36b2623389d4f79b33)) ### Enhancements made - Introduce ServerKernelManager class [#1101](https://github.com/jupyter-server/jupyter_server/pull/1101) ([@kevin-bates](https://github.com/kevin-bates)) - New configurable/overridable kernel ZMQ+Websocket connection API [#1047](https://github.com/jupyter-server/jupyter_server/pull/1047) ([@Zsailer](https://github.com/Zsailer)) - Pass kernel environment to `cwd_for_path` method [#1046](https://github.com/jupyter-server/jupyter_server/pull/1046) ([@divyansshhh](https://github.com/divyansshhh)) - Better Handling of Asyncio [#1035](https://github.com/jupyter-server/jupyter_server/pull/1035) ([@blink1073](https://github.com/blink1073)) - Add authorization to AuthenticatedFileHandler [#1021](https://github.com/jupyter-server/jupyter_server/pull/1021) ([@jiajunjie](https://github.com/jiajunjie)) - \[Gateway\] Add support for gateway token renewal [#985](https://github.com/jupyter-server/jupyter_server/pull/985) ([@kevin-bates](https://github.com/kevin-bates)) - Make it easier to pass custom env variables to kernel [#981](https://github.com/jupyter-server/jupyter_server/pull/981) ([@divyansshhh](https://github.com/divyansshhh)) - Accept and manage cookies when requesting gateways [#969](https://github.com/jupyter-server/jupyter_server/pull/969) ([@wjsi](https://github.com/wjsi)) - Emit events from the Contents Service [#954](https://github.com/jupyter-server/jupyter_server/pull/954) ([@Zsailer](https://github.com/Zsailer)) - Retry certain errors between server and gateway [#944](https://github.com/jupyter-server/jupyter_server/pull/944) ([@kevin-bates](https://github.com/kevin-bates)) - Allow new file types [#895](https://github.com/jupyter-server/jupyter_server/pull/895) ([@davidbrochart](https://github.com/davidbrochart)) - Make it easier for extensions to customize the ServerApp [#879](https://github.com/jupyter-server/jupyter_server/pull/879) ([@minrk](https://github.com/minrk)) - Adds anonymous users [#863](https://github.com/jupyter-server/jupyter_server/pull/863) ([@hbcarlos](https://github.com/hbcarlos)) - switch to jupyter_events [#862](https://github.com/jupyter-server/jupyter_server/pull/862) ([@Zsailer](https://github.com/Zsailer)) - consolidate auth config on IdentityProvider [#825](https://github.com/jupyter-server/jupyter_server/pull/825) ([@minrk](https://github.com/minrk)) ### Bugs fixed - Fix kernel WebSocket protocol [#1110](https://github.com/jupyter-server/jupyter_server/pull/1110) ([@davidbrochart](https://github.com/davidbrochart)) - Defer webbrowser import [#1095](https://github.com/jupyter-server/jupyter_server/pull/1095) ([@blink1073](https://github.com/blink1073)) - Use handle_outgoing_message for ZMQ replies [#1089](https://github.com/jupyter-server/jupyter_server/pull/1089) ([@Zsailer](https://github.com/Zsailer)) - Call `ports_changed` on the multi-kernel-manager instead of the kernel manager [#1088](https://github.com/jupyter-server/jupyter_server/pull/1088) ([@Zsailer](https://github.com/Zsailer)) - Add more websocket connection tests and fix bugs [#1085](https://github.com/jupyter-server/jupyter_server/pull/1085) ([@blink1073](https://github.com/blink1073)) - Tornado WebSocketHandler fixup [#1083](https://github.com/jupyter-server/jupyter_server/pull/1083) ([@davidbrochart](https://github.com/davidbrochart)) - persist userid cookie when auth is disabled [#1076](https://github.com/jupyter-server/jupyter_server/pull/1076) ([@minrk](https://github.com/minrk)) - Fix rename_file and delete_file to handle hidden files properly [#1073](https://github.com/jupyter-server/jupyter_server/pull/1073) ([@yacchin1205](https://github.com/yacchin1205)) - Add more coverage [#1069](https://github.com/jupyter-server/jupyter_server/pull/1069) ([@blink1073](https://github.com/blink1073)) - Increase nbconvert and checkpoints coverage [#1066](https://github.com/jupyter-server/jupyter_server/pull/1066) ([@blink1073](https://github.com/blink1073)) - Fix min version check again [#1049](https://github.com/jupyter-server/jupyter_server/pull/1049) ([@blink1073](https://github.com/blink1073)) - Fallback new file type to file for contents put [#1013](https://github.com/jupyter-server/jupyter_server/pull/1013) ([@a3626a](https://github.com/a3626a)) - Fix some typos in release instructions [#1003](https://github.com/jupyter-server/jupyter_server/pull/1003) ([@kevin-bates](https://github.com/kevin-bates)) - Wrap the concurrent futures in an asyncio future [#1001](https://github.com/jupyter-server/jupyter_server/pull/1001) ([@blink1073](https://github.com/blink1073)) - \[Gateway\] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) - fix issues with jupyter_events 0.5.0 [#972](https://github.com/jupyter-server/jupyter_server/pull/972) ([@Zsailer](https://github.com/Zsailer)) - Correct content-type headers [#965](https://github.com/jupyter-server/jupyter_server/pull/965) ([@epignot](https://github.com/epignot)) - Don't validate certs for when stopping server [#959](https://github.com/jupyter-server/jupyter_server/pull/959) ([@Zsailer](https://github.com/Zsailer)) - Parse list value for `terminado_settings` [#949](https://github.com/jupyter-server/jupyter_server/pull/949) ([@krassowski](https://github.com/krassowski)) - Fix bug in `api/contents` requests for an allowed copy [#939](https://github.com/jupyter-server/jupyter_server/pull/939) ([@kiersten-stokes](https://github.com/kiersten-stokes)) - Fix error that prevents posting to `api/contents` endpoint with no body [#937](https://github.com/jupyter-server/jupyter_server/pull/937) ([@kiersten-stokes](https://github.com/kiersten-stokes)) - avoid creating asyncio.Lock at import time [#935](https://github.com/jupyter-server/jupyter_server/pull/935) ([@minrk](https://github.com/minrk)) - Fix `get_kernel_path` for `AsyncFileManager`s. [#929](https://github.com/jupyter-server/jupyter_server/pull/929) ([@thetorpedodog](https://github.com/thetorpedodog)) - Fix c.GatewayClient.url snippet syntax [#917](https://github.com/jupyter-server/jupyter_server/pull/917) ([@rickwierenga](https://github.com/rickwierenga)) - Add back support for kernel launch timeout pad [#910](https://github.com/jupyter-server/jupyter_server/pull/910) ([@CiprianAnton](https://github.com/CiprianAnton)) - Notify ChannelQueue that the response router thread is finishing [#896](https://github.com/jupyter-server/jupyter_server/pull/896) ([@CiprianAnton](https://github.com/CiprianAnton)) - Make ChannelQueue.get_msg true async [#892](https://github.com/jupyter-server/jupyter_server/pull/892) ([@CiprianAnton](https://github.com/CiprianAnton)) - Check for serverapp for reraise flag [#887](https://github.com/jupyter-server/jupyter_server/pull/887) ([@vidartf](https://github.com/vidartf)) ### Maintenance and upkeep improvements - Make tests less sensitive to default kernel name [#1118](https://github.com/jupyter-server/jupyter_server/pull/1118) ([@blink1073](https://github.com/blink1073)) - Tweak codecov settings [#1113](https://github.com/jupyter-server/jupyter_server/pull/1113) ([@blink1073](https://github.com/blink1073)) - Bump minimatch from 3.0.4 to 3.1.2 [#1109](https://github.com/jupyter-server/jupyter_server/pull/1109) ([@dependabot](https://github.com/dependabot)) - Add skip-if-exists config [#1108](https://github.com/jupyter-server/jupyter_server/pull/1108) ([@blink1073](https://github.com/blink1073)) - Use pytest-jupyter [#1099](https://github.com/jupyter-server/jupyter_server/pull/1099) ([@blink1073](https://github.com/blink1073)) - Clean up release instructions and coverage handling [#1098](https://github.com/jupyter-server/jupyter_server/pull/1098) ([@blink1073](https://github.com/blink1073)) - Import ensure_async from jupyter_core [#1093](https://github.com/jupyter-server/jupyter_server/pull/1093) ([@davidbrochart](https://github.com/davidbrochart)) - Add more tests [#1092](https://github.com/jupyter-server/jupyter_server/pull/1092) ([@blink1073](https://github.com/blink1073)) - Fix coverage upload [#1091](https://github.com/jupyter-server/jupyter_server/pull/1091) ([@blink1073](https://github.com/blink1073)) - Add base handler tests [#1090](https://github.com/jupyter-server/jupyter_server/pull/1090) ([@blink1073](https://github.com/blink1073)) - Add more websocket connection tests and fix bugs [#1085](https://github.com/jupyter-server/jupyter_server/pull/1085) ([@blink1073](https://github.com/blink1073)) - Use base setup dependency type [#1084](https://github.com/jupyter-server/jupyter_server/pull/1084) ([@blink1073](https://github.com/blink1073)) - Add more serverapp tests [#1079](https://github.com/jupyter-server/jupyter_server/pull/1079) ([@blink1073](https://github.com/blink1073)) - Add more gateway tests [#1078](https://github.com/jupyter-server/jupyter_server/pull/1078) ([@blink1073](https://github.com/blink1073)) - More cleanup [#1077](https://github.com/jupyter-server/jupyter_server/pull/1077) ([@blink1073](https://github.com/blink1073)) - Fix hatch scripts and windows workflow run [#1074](https://github.com/jupyter-server/jupyter_server/pull/1074) ([@blink1073](https://github.com/blink1073)) - use recommended github-workflows checker [#1071](https://github.com/jupyter-server/jupyter_server/pull/1071) ([@blink1073](https://github.com/blink1073)) - Add more coverage [#1069](https://github.com/jupyter-server/jupyter_server/pull/1069) ([@blink1073](https://github.com/blink1073)) - More coverage [#1067](https://github.com/jupyter-server/jupyter_server/pull/1067) ([@blink1073](https://github.com/blink1073)) - Increase nbconvert and checkpoints coverage [#1066](https://github.com/jupyter-server/jupyter_server/pull/1066) ([@blink1073](https://github.com/blink1073)) - Test downstream jupyter_server_terminals [#1065](https://github.com/jupyter-server/jupyter_server/pull/1065) ([@blink1073](https://github.com/blink1073)) - Test notebook prerelease [#1064](https://github.com/jupyter-server/jupyter_server/pull/1064) ([@blink1073](https://github.com/blink1073)) - MAINT: remove python 3.4 branch [#1061](https://github.com/jupyter-server/jupyter_server/pull/1061) ([@Carreau](https://github.com/Carreau)) - Bump actions/checkout from 2 to 3 [#1056](https://github.com/jupyter-server/jupyter_server/pull/1056) ([@dependabot](https://github.com/dependabot)) - Bump actions/setup-python from 2 to 4 [#1055](https://github.com/jupyter-server/jupyter_server/pull/1055) ([@dependabot](https://github.com/dependabot)) - Bump pre-commit/action from 2.0.0 to 3.0.0 [#1054](https://github.com/jupyter-server/jupyter_server/pull/1054) ([@dependabot](https://github.com/dependabot)) - Add dependabot file [#1053](https://github.com/jupyter-server/jupyter_server/pull/1053) ([@blink1073](https://github.com/blink1073)) - Use global env for min version check [#1048](https://github.com/jupyter-server/jupyter_server/pull/1048) ([@blink1073](https://github.com/blink1073)) - Clean up handling of synchronous managers [#1044](https://github.com/jupyter-server/jupyter_server/pull/1044) ([@blink1073](https://github.com/blink1073)) - Clean up config files [#1031](https://github.com/jupyter-server/jupyter_server/pull/1031) ([@blink1073](https://github.com/blink1073)) - Make node optional [#1030](https://github.com/jupyter-server/jupyter_server/pull/1030) ([@blink1073](https://github.com/blink1073)) - Use admin github token for releaser [#1025](https://github.com/jupyter-server/jupyter_server/pull/1025) ([@blink1073](https://github.com/blink1073)) - CI Cleanup [#1023](https://github.com/jupyter-server/jupyter_server/pull/1023) ([@blink1073](https://github.com/blink1073)) - Use mdformat instead of prettier [#1022](https://github.com/jupyter-server/jupyter_server/pull/1022) ([@blink1073](https://github.com/blink1073)) - Add pyproject validation [#1020](https://github.com/jupyter-server/jupyter_server/pull/1020) ([@blink1073](https://github.com/blink1073)) - Remove hardcoded client install in CI [#1019](https://github.com/jupyter-server/jupyter_server/pull/1019) ([@blink1073](https://github.com/blink1073)) - Handle client 8 pending kernels [#1014](https://github.com/jupyter-server/jupyter_server/pull/1014) ([@blink1073](https://github.com/blink1073)) - Use releaser v2 tag [#1010](https://github.com/jupyter-server/jupyter_server/pull/1010) ([@blink1073](https://github.com/blink1073)) - Use hatch environments to simplify test, coverage, and docs build [#1007](https://github.com/jupyter-server/jupyter_server/pull/1007) ([@blink1073](https://github.com/blink1073)) - Update to version2 releaser [#1006](https://github.com/jupyter-server/jupyter_server/pull/1006) ([@blink1073](https://github.com/blink1073)) - Do not use dev version yet [#999](https://github.com/jupyter-server/jupyter_server/pull/999) ([@blink1073](https://github.com/blink1073)) - Add workflows for simplified publish [#993](https://github.com/jupyter-server/jupyter_server/pull/993) ([@blink1073](https://github.com/blink1073)) - Remove hardcoded client install [#991](https://github.com/jupyter-server/jupyter_server/pull/991) ([@blink1073](https://github.com/blink1073)) - Test with client 8 updates [#988](https://github.com/jupyter-server/jupyter_server/pull/988) ([@blink1073](https://github.com/blink1073)) - Switch to using hatchling version command [#984](https://github.com/jupyter-server/jupyter_server/pull/984) ([@blink1073](https://github.com/blink1073)) - Run downstream tests in parallel [#973](https://github.com/jupyter-server/jupyter_server/pull/973) ([@blink1073](https://github.com/blink1073)) - Update pytest_plugin with fixtures to test auth in core and extensions [#956](https://github.com/jupyter-server/jupyter_server/pull/956) ([@akshaychitneni](https://github.com/akshaychitneni)) - Fix docs build [#952](https://github.com/jupyter-server/jupyter_server/pull/952) ([@blink1073](https://github.com/blink1073)) - Fix flake8 v5 compat [#941](https://github.com/jupyter-server/jupyter_server/pull/941) ([@blink1073](https://github.com/blink1073)) - Improve logging of bare exceptions and other cleanups. [#922](https://github.com/jupyter-server/jupyter_server/pull/922) ([@thetorpedodog](https://github.com/thetorpedodog)) - Use more explicit version template for pyproject [#919](https://github.com/jupyter-server/jupyter_server/pull/919) ([@blink1073](https://github.com/blink1073)) - Fix handling of dev version [#913](https://github.com/jupyter-server/jupyter_server/pull/913) ([@blink1073](https://github.com/blink1073)) - Fix owasp link [#908](https://github.com/jupyter-server/jupyter_server/pull/908) ([@blink1073](https://github.com/blink1073)) - default to system node version in precommit [#906](https://github.com/jupyter-server/jupyter_server/pull/906) ([@dlqqq](https://github.com/dlqqq)) - Test python 3.11 on ubuntu [#839](https://github.com/jupyter-server/jupyter_server/pull/839) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Remove left over from notebook [#1117](https://github.com/jupyter-server/jupyter_server/pull/1117) ([@fcollonval](https://github.com/fcollonval)) - Fix wording [#1037](https://github.com/jupyter-server/jupyter_server/pull/1037) ([@fcollonval](https://github.com/fcollonval)) - Fix GitHub actions badge link [#1011](https://github.com/jupyter-server/jupyter_server/pull/1011) ([@blink1073](https://github.com/blink1073)) - Pin docutils to fix docs build [#1004](https://github.com/jupyter-server/jupyter_server/pull/1004) ([@blink1073](https://github.com/blink1073)) - Update server extension disable instructions [#998](https://github.com/jupyter-server/jupyter_server/pull/998) ([@3coins](https://github.com/3coins)) - Update index.rst [#970](https://github.com/jupyter-server/jupyter_server/pull/970) ([@razrotenberg](https://github.com/razrotenberg)) - Fix typo in IdentityProvider documentation [#915](https://github.com/jupyter-server/jupyter_server/pull/915) ([@danielyahn](https://github.com/danielyahn)) - docs: document the logging_config trait [#844](https://github.com/jupyter-server/jupyter_server/pull/844) ([@oliver-sanders](https://github.com/oliver-sanders)) ### Deprecated features - \[Gateway\] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-06-23&to=2022-12-06&type=c)) [@3coins](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3A3coins+updated%3A2022-06-23..2022-12-06&type=Issues) | [@a3626a](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aa3626a+updated%3A2022-06-23..2022-12-06&type=Issues) | [@akshaychitneni](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aakshaychitneni+updated%3A2022-06-23..2022-12-06&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-06-23..2022-12-06&type=Issues) | [@bloomsa](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abloomsa+updated%3A2022-06-23..2022-12-06&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2022-06-23..2022-12-06&type=Issues) | [@CiprianAnton](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACiprianAnton+updated%3A2022-06-23..2022-12-06&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2022-06-23..2022-12-06&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-06-23..2022-12-06&type=Issues) | [@danielyahn](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adanielyahn+updated%3A2022-06-23..2022-12-06&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2022-06-23..2022-12-06&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adependabot+updated%3A2022-06-23..2022-12-06&type=Issues) | [@divyansshhh](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adivyansshhh+updated%3A2022-06-23..2022-12-06&type=Issues) | [@dlqqq](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adlqqq+updated%3A2022-06-23..2022-12-06&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2022-06-23..2022-12-06&type=Issues) | [@ellisonbg](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aellisonbg+updated%3A2022-06-23..2022-12-06&type=Issues) | [@epignot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aepignot+updated%3A2022-06-23..2022-12-06&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afcollonval+updated%3A2022-06-23..2022-12-06&type=Issues) | [@hbcarlos](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ahbcarlos+updated%3A2022-06-23..2022-12-06&type=Issues) | [@jiajunjie](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajiajunjie+updated%3A2022-06-23..2022-12-06&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-06-23..2022-12-06&type=Issues) | [@kiersten-stokes](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akiersten-stokes+updated%3A2022-06-23..2022-12-06&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2022-06-23..2022-12-06&type=Issues) | [@meeseeksdev](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ameeseeksdev+updated%3A2022-06-23..2022-12-06&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-06-23..2022-12-06&type=Issues) | [@ofek](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aofek+updated%3A2022-06-23..2022-12-06&type=Issues) | [@oliver-sanders](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aoliver-sanders+updated%3A2022-06-23..2022-12-06&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-06-23..2022-12-06&type=Issues) | [@razrotenberg](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Arazrotenberg+updated%3A2022-06-23..2022-12-06&type=Issues) | [@rickwierenga](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Arickwierenga+updated%3A2022-06-23..2022-12-06&type=Issues) | [@thetorpedodog](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Athetorpedodog+updated%3A2022-06-23..2022-12-06&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2022-06-23..2022-12-06&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-06-23..2022-12-06&type=Issues) | [@wjsi](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awjsi+updated%3A2022-06-23..2022-12-06&type=Issues) | [@yacchin1205](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ayacchin1205+updated%3A2022-06-23..2022-12-06&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-06-23..2022-12-06&type=Issues) ## 2.0.0rc8 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0rc7...d2c974a4580e9269580a632a3c8258e99792e279)) ### Enhancements made - Introduce ServerKernelManager class [#1101](https://github.com/jupyter-server/jupyter_server/pull/1101) ([@kevin-bates](https://github.com/kevin-bates)) ### Bugs fixed - Defer webbrowser import [#1095](https://github.com/jupyter-server/jupyter_server/pull/1095) ([@blink1073](https://github.com/blink1073)) ### Maintenance and upkeep improvements - Use pytest-jupyter [#1099](https://github.com/jupyter-server/jupyter_server/pull/1099) ([@blink1073](https://github.com/blink1073)) - Clean up release instructions and coverage handling [#1098](https://github.com/jupyter-server/jupyter_server/pull/1098) ([@blink1073](https://github.com/blink1073)) - Add more tests [#1092](https://github.com/jupyter-server/jupyter_server/pull/1092) ([@blink1073](https://github.com/blink1073)) - Fix coverage upload [#1091](https://github.com/jupyter-server/jupyter_server/pull/1091) ([@blink1073](https://github.com/blink1073)) - Add base handler tests [#1090](https://github.com/jupyter-server/jupyter_server/pull/1090) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-11-23&to=2022-11-29&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-11-23..2022-11-29&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2022-11-23..2022-11-29&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-11-23..2022-11-29&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-11-23..2022-11-29&type=Issues) ## 2.0.0rc7 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0rc6...339038b532ec928b59861f9426a8ba1214454741)) ### Bugs fixed - Use handle_outgoing_message for ZMQ replies [#1089](https://github.com/jupyter-server/jupyter_server/pull/1089) ([@Zsailer](https://github.com/Zsailer)) - Call `ports_changed` on the multi-kernel-manager instead of the kernel manager [#1088](https://github.com/jupyter-server/jupyter_server/pull/1088) ([@Zsailer](https://github.com/Zsailer)) - Add more websocket connection tests and fix bugs [#1085](https://github.com/jupyter-server/jupyter_server/pull/1085) ([@blink1073](https://github.com/blink1073)) ### Maintenance and upkeep improvements - Add more websocket connection tests and fix bugs [#1085](https://github.com/jupyter-server/jupyter_server/pull/1085) ([@blink1073](https://github.com/blink1073)) - Use base setup dependency type [#1084](https://github.com/jupyter-server/jupyter_server/pull/1084) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-11-21&to=2022-11-23&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-11-21..2022-11-23&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2022-11-21..2022-11-23&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-11-21..2022-11-23&type=Issues) ## 2.0.0rc6 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0rc5...cd060da67aa6e3e5d8ff791f0a559a91282be2b3)) ### Bugs fixed - Tornado WebSocketHandler fixup [#1083](https://github.com/jupyter-server/jupyter_server/pull/1083) ([@davidbrochart](https://github.com/davidbrochart)) ### Maintenance and upkeep improvements ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-11-21&to=2022-11-21&type=c)) [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2022-11-21..2022-11-21&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2022-11-21..2022-11-21&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-11-21..2022-11-21&type=Issues) ## 2.0.0rc5 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0rc4...12f7c1d47e0ca76f8c39dfd1499142e8b6df09ee)) ### Enhancements made - New configurable/overridable kernel ZMQ+Websocket connection API [#1047](https://github.com/jupyter-server/jupyter_server/pull/1047) ([@Zsailer](https://github.com/Zsailer)) - Add authorization to AuthenticatedFileHandler [#1021](https://github.com/jupyter-server/jupyter_server/pull/1021) ([@jiajunjie](https://github.com/jiajunjie)) ### Bugs fixed - persist userid cookie when auth is disabled [#1076](https://github.com/jupyter-server/jupyter_server/pull/1076) ([@minrk](https://github.com/minrk)) - Fix rename_file and delete_file to handle hidden files properly [#1073](https://github.com/jupyter-server/jupyter_server/pull/1073) ([@yacchin1205](https://github.com/yacchin1205)) - Add more coverage [#1069](https://github.com/jupyter-server/jupyter_server/pull/1069) ([@blink1073](https://github.com/blink1073)) - Increase nbconvert and checkpoints coverage [#1066](https://github.com/jupyter-server/jupyter_server/pull/1066) ([@blink1073](https://github.com/blink1073)) ### Maintenance and upkeep improvements - Add more serverapp tests [#1079](https://github.com/jupyter-server/jupyter_server/pull/1079) ([@blink1073](https://github.com/blink1073)) - Add more gateway tests [#1078](https://github.com/jupyter-server/jupyter_server/pull/1078) ([@blink1073](https://github.com/blink1073)) - More cleanup [#1077](https://github.com/jupyter-server/jupyter_server/pull/1077) ([@blink1073](https://github.com/blink1073)) - Fix hatch scripts and windows workflow run [#1074](https://github.com/jupyter-server/jupyter_server/pull/1074) ([@blink1073](https://github.com/blink1073)) - use recommended github-workflows checker [#1071](https://github.com/jupyter-server/jupyter_server/pull/1071) ([@blink1073](https://github.com/blink1073)) - Add more coverage [#1069](https://github.com/jupyter-server/jupyter_server/pull/1069) ([@blink1073](https://github.com/blink1073)) - More coverage [#1067](https://github.com/jupyter-server/jupyter_server/pull/1067) ([@blink1073](https://github.com/blink1073)) - Increase nbconvert and checkpoints coverage [#1066](https://github.com/jupyter-server/jupyter_server/pull/1066) ([@blink1073](https://github.com/blink1073)) - Test downstream jupyter_server_terminals [#1065](https://github.com/jupyter-server/jupyter_server/pull/1065) ([@blink1073](https://github.com/blink1073)) - Test notebook prerelease [#1064](https://github.com/jupyter-server/jupyter_server/pull/1064) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - docs: document the logging_config trait [#844](https://github.com/jupyter-server/jupyter_server/pull/844) ([@oliver-sanders](https://github.com/oliver-sanders)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-11-10&to=2022-11-21&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-11-10..2022-11-21&type=Issues) | [@codecov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov+updated%3A2022-11-10..2022-11-21&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-11-10..2022-11-21&type=Issues) | [@jiajunjie](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajiajunjie+updated%3A2022-11-10..2022-11-21&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-11-10..2022-11-21&type=Issues) | [@oliver-sanders](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aoliver-sanders+updated%3A2022-11-10..2022-11-21&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-11-10..2022-11-21&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-11-10..2022-11-21&type=Issues) | [@yacchin1205](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ayacchin1205+updated%3A2022-11-10..2022-11-21&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-11-10..2022-11-21&type=Issues) ## 2.0.0rc4 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0rc3...f6b732c652e0b5a600ff0d3f60c6a34173d8d6a5)) ### Enhancements made - Pass kernel environment to `cwd_for_path` method [#1046](https://github.com/jupyter-server/jupyter_server/pull/1046) ([@divyansshhh](https://github.com/divyansshhh)) - Better Handling of Asyncio [#1035](https://github.com/jupyter-server/jupyter_server/pull/1035) ([@blink1073](https://github.com/blink1073)) ### Bugs fixed - Fix min version check again [#1049](https://github.com/jupyter-server/jupyter_server/pull/1049) ([@blink1073](https://github.com/blink1073)) ### Maintenance and upkeep improvements - MAINT: remove python 3.4 branch [#1061](https://github.com/jupyter-server/jupyter_server/pull/1061) ([@Carreau](https://github.com/Carreau)) - Bump actions/checkout from 2 to 3 [#1056](https://github.com/jupyter-server/jupyter_server/pull/1056) ([@dependabot](https://github.com/dependabot)) - Bump actions/setup-python from 2 to 4 [#1055](https://github.com/jupyter-server/jupyter_server/pull/1055) ([@dependabot](https://github.com/dependabot)) - Bump pre-commit/action from 2.0.0 to 3.0.0 [#1054](https://github.com/jupyter-server/jupyter_server/pull/1054) ([@dependabot](https://github.com/dependabot)) - Add dependabot file [#1053](https://github.com/jupyter-server/jupyter_server/pull/1053) ([@blink1073](https://github.com/blink1073)) - Use global env for min version check [#1048](https://github.com/jupyter-server/jupyter_server/pull/1048) ([@blink1073](https://github.com/blink1073)) - Clean up handling of synchronous managers [#1044](https://github.com/jupyter-server/jupyter_server/pull/1044) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Fix wording [#1037](https://github.com/jupyter-server/jupyter_server/pull/1037) ([@fcollonval](https://github.com/fcollonval)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-10-17&to=2022-11-10&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-10-17..2022-11-10&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2022-10-17..2022-11-10&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-10-17..2022-11-10&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adependabot+updated%3A2022-10-17..2022-11-10&type=Issues) | [@divyansshhh](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adivyansshhh+updated%3A2022-10-17..2022-11-10&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afcollonval+updated%3A2022-10-17..2022-11-10&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-10-17..2022-11-10&type=Issues) ## 2.0.0rc3 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0rc2...fc0ac3236fdd92778ea765db6e8982212c8389ee)) ### Maintenance and upkeep improvements - Clean up config files [#1031](https://github.com/jupyter-server/jupyter_server/pull/1031) ([@blink1073](https://github.com/blink1073)) - Make node optional [#1030](https://github.com/jupyter-server/jupyter_server/pull/1030) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-10-11&to=2022-10-17&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-10-11..2022-10-17&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-10-11..2022-10-17&type=Issues) ## 2.0.0rc2 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0rc1...32de53beae1e9396dd3111b17222ec802b122f0b)) ### Bugs fixed - Fallback new file type to file for contents put [#1013](https://github.com/jupyter-server/jupyter_server/pull/1013) ([@a3626a](https://github.com/a3626a)) - Fix some typos in release instructions [#1003](https://github.com/jupyter-server/jupyter_server/pull/1003) ([@kevin-bates](https://github.com/kevin-bates)) ### Maintenance and upkeep improvements - Use admin github token for releaser [#1025](https://github.com/jupyter-server/jupyter_server/pull/1025) ([@blink1073](https://github.com/blink1073)) - CI Cleanup [#1023](https://github.com/jupyter-server/jupyter_server/pull/1023) ([@blink1073](https://github.com/blink1073)) - Use mdformat instead of prettier [#1022](https://github.com/jupyter-server/jupyter_server/pull/1022) ([@blink1073](https://github.com/blink1073)) - Add pyproject validation [#1020](https://github.com/jupyter-server/jupyter_server/pull/1020) ([@blink1073](https://github.com/blink1073)) - Remove hardcoded client install in CI [#1019](https://github.com/jupyter-server/jupyter_server/pull/1019) ([@blink1073](https://github.com/blink1073)) - Handle client 8 pending kernels [#1014](https://github.com/jupyter-server/jupyter_server/pull/1014) ([@blink1073](https://github.com/blink1073)) - Use releaser v2 tag [#1010](https://github.com/jupyter-server/jupyter_server/pull/1010) ([@blink1073](https://github.com/blink1073)) - Use hatch environments to simplify test, coverage, and docs build [#1007](https://github.com/jupyter-server/jupyter_server/pull/1007) ([@blink1073](https://github.com/blink1073)) - Update to version2 releaser [#1006](https://github.com/jupyter-server/jupyter_server/pull/1006) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Fix GitHub actions badge link [#1011](https://github.com/jupyter-server/jupyter_server/pull/1011) ([@blink1073](https://github.com/blink1073)) - Pin docutils to fix docs build [#1004](https://github.com/jupyter-server/jupyter_server/pull/1004) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-09-27&to=2022-10-11&type=c)) [@a3626a](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aa3626a+updated%3A2022-09-27..2022-10-11&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-09-27..2022-10-11&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-09-27..2022-10-11&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-09-27..2022-10-11&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-09-27..2022-10-11&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-09-27..2022-10-11&type=Issues) ## 2.0.0rc1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0rc0...dd8a6937651170e2cea38a2fecbecc2a1a4f655f)) ### Enhancements made - \[Gateway\] Add support for gateway token renewal [#985](https://github.com/jupyter-server/jupyter_server/pull/985) ([@kevin-bates](https://github.com/kevin-bates)) - Make it easier to pass custom env variables to kernel [#981](https://github.com/jupyter-server/jupyter_server/pull/981) ([@divyansshhh](https://github.com/divyansshhh)) ### Bugs fixed - Wrap the concurrent futures in an asyncio future [#1001](https://github.com/jupyter-server/jupyter_server/pull/1001) ([@blink1073](https://github.com/blink1073)) - \[Gateway\] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) ### Maintenance and upkeep improvements - Do not use dev version yet [#999](https://github.com/jupyter-server/jupyter_server/pull/999) ([@blink1073](https://github.com/blink1073)) - Add workflows for simplified publish [#993](https://github.com/jupyter-server/jupyter_server/pull/993) ([@blink1073](https://github.com/blink1073)) - Remove hardcoded client install [#991](https://github.com/jupyter-server/jupyter_server/pull/991) ([@blink1073](https://github.com/blink1073)) - Test with client 8 updates [#988](https://github.com/jupyter-server/jupyter_server/pull/988) ([@blink1073](https://github.com/blink1073)) - Switch to using hatchling version command [#984](https://github.com/jupyter-server/jupyter_server/pull/984) ([@blink1073](https://github.com/blink1073)) - Test python 3.11 on ubuntu [#839](https://github.com/jupyter-server/jupyter_server/pull/839) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Update server extension disable instructions [#998](https://github.com/jupyter-server/jupyter_server/pull/998) ([@3coins](https://github.com/3coins)) ### Deprecated features - \[Gateway\] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-09-13&to=2022-09-27&type=c)) [@3coins](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3A3coins+updated%3A2022-09-13..2022-09-27&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-09-13..2022-09-27&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-09-13..2022-09-27&type=Issues) | [@divyansshhh](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adivyansshhh+updated%3A2022-09-13..2022-09-27&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-09-13..2022-09-27&type=Issues) | [@meeseeksdev](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ameeseeksdev+updated%3A2022-09-13..2022-09-27&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-09-13..2022-09-27&type=Issues) ## 2.0.0rc0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0b1...90905e116a2ae49b35b49c360614b0831498477b)) ### New features added - Identity API at /api/me [#671](https://github.com/jupyter-server/jupyter_server/pull/671) ([@minrk](https://github.com/minrk)) ### Enhancements made - Accept and manage cookies when requesting gateways [#969](https://github.com/jupyter-server/jupyter_server/pull/969) ([@wjsi](https://github.com/wjsi)) - Emit events from the Contents Service [#954](https://github.com/jupyter-server/jupyter_server/pull/954) ([@Zsailer](https://github.com/Zsailer)) - Retry certain errors between server and gateway [#944](https://github.com/jupyter-server/jupyter_server/pull/944) ([@kevin-bates](https://github.com/kevin-bates)) - Allow new file types [#895](https://github.com/jupyter-server/jupyter_server/pull/895) ([@davidbrochart](https://github.com/davidbrochart)) - Adds anonymous users [#863](https://github.com/jupyter-server/jupyter_server/pull/863) ([@hbcarlos](https://github.com/hbcarlos)) - switch to jupyter_events [#862](https://github.com/jupyter-server/jupyter_server/pull/862) ([@Zsailer](https://github.com/Zsailer)) - Make it easier for extensions to customize the ServerApp [#879](https://github.com/jupyter-server/jupyter_server/pull/879) ([@minrk](https://github.com/minrk)) - consolidate auth config on IdentityProvider [#825](https://github.com/jupyter-server/jupyter_server/pull/825) ([@minrk](https://github.com/minrk)) - Show import error when failing to load an extension [#878](https://github.com/jupyter-server/jupyter_server/pull/878) ([@minrk](https://github.com/minrk)) - Add the root_dir value to the logging message in case of non compliant preferred_dir [#804](https://github.com/jupyter-server/jupyter_server/pull/804) ([@echarles](https://github.com/echarles)) - Hydrate a Kernel Manager when calling GatewayKernelManager.start_kernel with a kernel_id [#788](https://github.com/jupyter-server/jupyter_server/pull/788) ([@Zsailer](https://github.com/Zsailer)) - Remove terminals in favor of jupyter_server_terminals extension [#651](https://github.com/jupyter-server/jupyter_server/pull/651) ([@Zsailer](https://github.com/Zsailer)) ### Bugs fixed - fix issues with jupyter_events 0.5.0 [#972](https://github.com/jupyter-server/jupyter_server/pull/972) ([@Zsailer](https://github.com/Zsailer)) - Correct content-type headers [#965](https://github.com/jupyter-server/jupyter_server/pull/965) ([@epignot](https://github.com/epignot)) - Don't validate certs for when stopping server [#959](https://github.com/jupyter-server/jupyter_server/pull/959) ([@Zsailer](https://github.com/Zsailer)) - Parse list value for `terminado_settings` [#949](https://github.com/jupyter-server/jupyter_server/pull/949) ([@krassowski](https://github.com/krassowski)) - Fix bug in `api/contents` requests for an allowed copy [#939](https://github.com/jupyter-server/jupyter_server/pull/939) ([@kiersten-stokes](https://github.com/kiersten-stokes)) - Fix error that prevents posting to `api/contents` endpoint with no body [#937](https://github.com/jupyter-server/jupyter_server/pull/937) ([@kiersten-stokes](https://github.com/kiersten-stokes)) - avoid creating asyncio.Lock at import time [#935](https://github.com/jupyter-server/jupyter_server/pull/935) ([@minrk](https://github.com/minrk)) - Fix `get_kernel_path` for `AsyncFileManager`s. [#929](https://github.com/jupyter-server/jupyter_server/pull/929) ([@thetorpedodog](https://github.com/thetorpedodog)) - Check for serverapp for reraise flag [#887](https://github.com/jupyter-server/jupyter_server/pull/887) ([@vidartf](https://github.com/vidartf)) - Notify ChannelQueue that the response router thread is finishing [#896](https://github.com/jupyter-server/jupyter_server/pull/896) ([@CiprianAnton](https://github.com/CiprianAnton)) - Make ChannelQueue.get_msg true async [#892](https://github.com/jupyter-server/jupyter_server/pull/892) ([@CiprianAnton](https://github.com/CiprianAnton)) - Fix gateway kernel shutdown [#874](https://github.com/jupyter-server/jupyter_server/pull/874) ([@kevin-bates](https://github.com/kevin-bates)) - Defer preferred_dir validation until root_dir is set [#826](https://github.com/jupyter-server/jupyter_server/pull/826) ([@kevin-bates](https://github.com/kevin-bates)) - missing required arguments in utils.fetch [#798](https://github.com/jupyter-server/jupyter_server/pull/798) ([@minrk](https://github.com/minrk)) ### Maintenance and upkeep improvements - Run downstream tests in parallel [#973](https://github.com/jupyter-server/jupyter_server/pull/973) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#971](https://github.com/jupyter-server/jupyter_server/pull/971) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - \[pre-commit.ci\] pre-commit autoupdate [#963](https://github.com/jupyter-server/jupyter_server/pull/963) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Update pytest_plugin with fixtures to test auth in core and extensions [#956](https://github.com/jupyter-server/jupyter_server/pull/956) ([@akshaychitneni](https://github.com/akshaychitneni)) - \[pre-commit.ci\] pre-commit autoupdate [#955](https://github.com/jupyter-server/jupyter_server/pull/955) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix docs build [#952](https://github.com/jupyter-server/jupyter_server/pull/952) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#945](https://github.com/jupyter-server/jupyter_server/pull/945) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - \[pre-commit.ci\] pre-commit autoupdate [#942](https://github.com/jupyter-server/jupyter_server/pull/942) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix flake8 v5 compat [#941](https://github.com/jupyter-server/jupyter_server/pull/941) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#938](https://github.com/jupyter-server/jupyter_server/pull/938) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - \[pre-commit.ci\] pre-commit autoupdate [#928](https://github.com/jupyter-server/jupyter_server/pull/928) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - \[pre-commit.ci\] pre-commit autoupdate [#902](https://github.com/jupyter-server/jupyter_server/pull/902) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - \[pre-commit.ci\] pre-commit autoupdate [#894](https://github.com/jupyter-server/jupyter_server/pull/894) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Normalize os_path [#886](https://github.com/jupyter-server/jupyter_server/pull/886) ([@martinRenou](https://github.com/martinRenou)) - \[pre-commit.ci\] pre-commit autoupdate [#885](https://github.com/jupyter-server/jupyter_server/pull/885) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - suppress tornado deprecation warnings [#882](https://github.com/jupyter-server/jupyter_server/pull/882) ([@minrk](https://github.com/minrk)) - Fix lint [#867](https://github.com/jupyter-server/jupyter_server/pull/867) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#866](https://github.com/jupyter-server/jupyter_server/pull/866) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix sphinx 5.0 support [#865](https://github.com/jupyter-server/jupyter_server/pull/865) ([@blink1073](https://github.com/blink1073)) - Add license metadata and file [#827](https://github.com/jupyter-server/jupyter_server/pull/827) ([@blink1073](https://github.com/blink1073)) - CI cleanup [#824](https://github.com/jupyter-server/jupyter_server/pull/824) ([@blink1073](https://github.com/blink1073)) - Switch to flit [#823](https://github.com/jupyter-server/jupyter_server/pull/823) ([@blink1073](https://github.com/blink1073)) - Remove unused pytest-mock dependency [#814](https://github.com/jupyter-server/jupyter_server/pull/814) ([@mgorny](https://github.com/mgorny)) - Remove duplicate requests requirement from setup.cfg [#813](https://github.com/jupyter-server/jupyter_server/pull/813) ([@mgorny](https://github.com/mgorny)) - \[pre-commit.ci\] pre-commit autoupdate [#802](https://github.com/jupyter-server/jupyter_server/pull/802) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Add helper jobs for branch protection [#797](https://github.com/jupyter-server/jupyter_server/pull/797) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#793](https://github.com/jupyter-server/jupyter_server/pull/793) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Centralize app cleanup [#792](https://github.com/jupyter-server/jupyter_server/pull/792) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#785](https://github.com/jupyter-server/jupyter_server/pull/785) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Clean up pre-commit [#782](https://github.com/jupyter-server/jupyter_server/pull/782) ([@blink1073](https://github.com/blink1073)) - Add mypy check [#779](https://github.com/jupyter-server/jupyter_server/pull/779) ([@blink1073](https://github.com/blink1073)) - Use new post-version-spec from jupyter_releaser [#777](https://github.com/jupyter-server/jupyter_server/pull/777) ([@blink1073](https://github.com/blink1073)) - Give write permissions to enforce label workflow [#776](https://github.com/jupyter-server/jupyter_server/pull/776) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#775](https://github.com/jupyter-server/jupyter_server/pull/775) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Add explicit handling of warnings [#771](https://github.com/jupyter-server/jupyter_server/pull/771) ([@blink1073](https://github.com/blink1073)) - Use test-sdist from maintainer-tools [#769](https://github.com/jupyter-server/jupyter_server/pull/769) ([@blink1073](https://github.com/blink1073)) - Add pyupgrade and doc8 hooks [#768](https://github.com/jupyter-server/jupyter_server/pull/768) ([@blink1073](https://github.com/blink1073)) - update some metadata fields, sort deps [#675](https://github.com/jupyter-server/jupyter_server/pull/675) ([@bollwyvl](https://github.com/bollwyvl)) ### Documentation improvements - Fix typo in IdentityProvider documentation [#915](https://github.com/jupyter-server/jupyter_server/pull/915) ([@danielyahn](https://github.com/danielyahn)) - Add Session workflows documentation [#808](https://github.com/jupyter-server/jupyter_server/pull/808) ([@andreyvelich](https://github.com/andreyvelich)) - Add Jupyter Server Architecture diagram [#801](https://github.com/jupyter-server/jupyter_server/pull/801) ([@andreyvelich](https://github.com/andreyvelich)) - Fix path for full config doc [#800](https://github.com/jupyter-server/jupyter_server/pull/800) ([@andreyvelich](https://github.com/andreyvelich)) - Fix contributing guide for building the docs [#794](https://github.com/jupyter-server/jupyter_server/pull/794) ([@andreyvelich](https://github.com/andreyvelich)) - Update team meetings doc [#772](https://github.com/jupyter-server/jupyter_server/pull/772) ([@willingc](https://github.com/willingc)) - Update documentation about registering file save hooks [#770](https://github.com/jupyter-server/jupyter_server/pull/770) ([@davidbrochart](https://github.com/davidbrochart)) ### Other merged PRs - Update index.rst [#970](https://github.com/jupyter-server/jupyter_server/pull/970) ([@razrotenberg](https://github.com/razrotenberg)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-09-01&to=2022-09-13&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-09-01..2022-09-13&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-09-01..2022-09-13&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2022-09-01..2022-09-13&type=Issues) | [@epignot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aepignot+updated%3A2022-09-01..2022-09-13&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2022-09-01..2022-09-13&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-09-01..2022-09-13&type=Issues) | [@razrotenberg](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Arazrotenberg+updated%3A2022-09-01..2022-09-13&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-09-01..2022-09-13&type=Issues) | [@wjsi](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awjsi+updated%3A2022-09-01..2022-09-13&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-09-01..2022-09-13&type=Issues) ## 2.0.0b1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0b0...644540b4128e8295e5cedf75e7d7d1c04ba9b3ea)) ### Enhancements made - Emit events from the Contents Service [#954](https://github.com/jupyter-server/jupyter_server/pull/954) ([@Zsailer](https://github.com/Zsailer)) - Retry certain errors between server and gateway [#944](https://github.com/jupyter-server/jupyter_server/pull/944) ([@kevin-bates](https://github.com/kevin-bates)) - Allow new file types [#895](https://github.com/jupyter-server/jupyter_server/pull/895) ([@davidbrochart](https://github.com/davidbrochart)) - Adds anonymous users [#863](https://github.com/jupyter-server/jupyter_server/pull/863) ([@hbcarlos](https://github.com/hbcarlos)) - switch to jupyter_events [#862](https://github.com/jupyter-server/jupyter_server/pull/862) ([@Zsailer](https://github.com/Zsailer)) ### Bugs fixed - Fix bug in `api/contents` requests for an allowed copy [#939](https://github.com/jupyter-server/jupyter_server/pull/939) ([@kiersten-stokes](https://github.com/kiersten-stokes)) - Fix error that prevents posting to `api/contents` endpoint with no body [#937](https://github.com/jupyter-server/jupyter_server/pull/937) ([@kiersten-stokes](https://github.com/kiersten-stokes)) - avoid creating asyncio.Lock at import time [#935](https://github.com/jupyter-server/jupyter_server/pull/935) ([@minrk](https://github.com/minrk)) - Fix `get_kernel_path` for `AsyncFileManager`s. [#929](https://github.com/jupyter-server/jupyter_server/pull/929) ([@thetorpedodog](https://github.com/thetorpedodog)) - Check for serverapp for reraise flag [#887](https://github.com/jupyter-server/jupyter_server/pull/887) ([@vidartf](https://github.com/vidartf)) ### Maintenance and upkeep improvements - Update pytest_plugin with fixtures to test auth in core and extensions [#956](https://github.com/jupyter-server/jupyter_server/pull/956) ([@akshaychitneni](https://github.com/akshaychitneni)) - \[pre-commit.ci\] pre-commit autoupdate [#955](https://github.com/jupyter-server/jupyter_server/pull/955) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix docs build [#952](https://github.com/jupyter-server/jupyter_server/pull/952) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#945](https://github.com/jupyter-server/jupyter_server/pull/945) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - \[pre-commit.ci\] pre-commit autoupdate [#942](https://github.com/jupyter-server/jupyter_server/pull/942) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix flake8 v5 compat [#941](https://github.com/jupyter-server/jupyter_server/pull/941) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#938](https://github.com/jupyter-server/jupyter_server/pull/938) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - \[pre-commit.ci\] pre-commit autoupdate [#928](https://github.com/jupyter-server/jupyter_server/pull/928) ([@pre-commit-ci](https://github.com/pre-commit-ci)) ### Documentation improvements - Fix typo in IdentityProvider documentation [#915](https://github.com/jupyter-server/jupyter_server/pull/915) ([@danielyahn](https://github.com/danielyahn)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-07-14&to=2022-09-01&type=c)) [@akshaychitneni](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aakshaychitneni+updated%3A2022-07-14..2022-09-01&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-07-14..2022-09-01&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-07-14..2022-09-01&type=Issues) | [@danielyahn](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adanielyahn+updated%3A2022-07-14..2022-09-01&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2022-07-14..2022-09-01&type=Issues) | [@dlqqq](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adlqqq+updated%3A2022-07-14..2022-09-01&type=Issues) | [@hbcarlos](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ahbcarlos+updated%3A2022-07-14..2022-09-01&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-07-14..2022-09-01&type=Issues) | [@kiersten-stokes](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akiersten-stokes+updated%3A2022-07-14..2022-09-01&type=Issues) | [@meeseeksdev](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ameeseeksdev+updated%3A2022-07-14..2022-09-01&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-07-14..2022-09-01&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-07-14..2022-09-01&type=Issues) | [@thetorpedodog](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Athetorpedodog+updated%3A2022-07-14..2022-09-01&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2022-07-14..2022-09-01&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-07-14..2022-09-01&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-07-14..2022-09-01&type=Issues) ## 2.0.0b0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0a2...cb9c5edff77c2146d0e66425a82c7b6125b5039e)) ### Enhancements made - Make it easier for extensions to customize the ServerApp [#879](https://github.com/jupyter-server/jupyter_server/pull/879) ([@minrk](https://github.com/minrk)) - consolidate auth config on IdentityProvider [#825](https://github.com/jupyter-server/jupyter_server/pull/825) ([@minrk](https://github.com/minrk)) ### Bugs fixed - Fix c.GatewayClient.url snippet syntax [#917](https://github.com/jupyter-server/jupyter_server/pull/917) ([@rickwierenga](https://github.com/rickwierenga)) - Add back support for kernel launch timeout pad [#910](https://github.com/jupyter-server/jupyter_server/pull/910) ([@CiprianAnton](https://github.com/CiprianAnton)) ### Maintenance and upkeep improvements - Improve logging of bare exceptions and other cleanups. [#922](https://github.com/jupyter-server/jupyter_server/pull/922) ([@thetorpedodog](https://github.com/thetorpedodog)) - Use more explicit version template for pyproject [#919](https://github.com/jupyter-server/jupyter_server/pull/919) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#916](https://github.com/jupyter-server/jupyter_server/pull/916) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix handling of dev version [#913](https://github.com/jupyter-server/jupyter_server/pull/913) ([@blink1073](https://github.com/blink1073)) - Fix owasp link [#908](https://github.com/jupyter-server/jupyter_server/pull/908) ([@blink1073](https://github.com/blink1073)) - default to system node version in precommit [#906](https://github.com/jupyter-server/jupyter_server/pull/906) ([@dlqqq](https://github.com/dlqqq)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-07-05&to=2022-07-14&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-07-05..2022-07-14&type=Issues) | [@CiprianAnton](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACiprianAnton+updated%3A2022-07-05..2022-07-14&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-07-05..2022-07-14&type=Issues) | [@dlqqq](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adlqqq+updated%3A2022-07-05..2022-07-14&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-07-05..2022-07-14&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-07-05..2022-07-14&type=Issues) | [@rickwierenga](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Arickwierenga+updated%3A2022-07-05..2022-07-14&type=Issues) | [@thetorpedodog](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Athetorpedodog+updated%3A2022-07-05..2022-07-14&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-07-05..2022-07-14&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-07-05..2022-07-14&type=Issues) ## 2.0.0a2 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0a1...bd1e7d70b64716097c6b064b2fd5dc67e23d2320)) ### Enhancements made - Show import error when failing to load an extension [#878](https://github.com/jupyter-server/jupyter_server/pull/878) ([@minrk](https://github.com/minrk)) ### Bugs fixed - Notify ChannelQueue that the response router thread is finishing [#896](https://github.com/jupyter-server/jupyter_server/pull/896) ([@CiprianAnton](https://github.com/CiprianAnton)) - Make ChannelQueue.get_msg true async [#892](https://github.com/jupyter-server/jupyter_server/pull/892) ([@CiprianAnton](https://github.com/CiprianAnton)) - Fix gateway kernel shutdown [#874](https://github.com/jupyter-server/jupyter_server/pull/874) ([@kevin-bates](https://github.com/kevin-bates)) ### Maintenance and upkeep improvements - \[pre-commit.ci\] pre-commit autoupdate [#902](https://github.com/jupyter-server/jupyter_server/pull/902) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - \[pre-commit.ci\] pre-commit autoupdate [#894](https://github.com/jupyter-server/jupyter_server/pull/894) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Normalize os_path [#886](https://github.com/jupyter-server/jupyter_server/pull/886) ([@martinRenou](https://github.com/martinRenou)) - \[pre-commit.ci\] pre-commit autoupdate [#885](https://github.com/jupyter-server/jupyter_server/pull/885) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - suppress tornado deprecation warnings [#882](https://github.com/jupyter-server/jupyter_server/pull/882) ([@minrk](https://github.com/minrk)) - Fix lint [#867](https://github.com/jupyter-server/jupyter_server/pull/867) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#866](https://github.com/jupyter-server/jupyter_server/pull/866) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix sphinx 5.0 support [#865](https://github.com/jupyter-server/jupyter_server/pull/865) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Add changelog for 2.0.0a1 [#870](https://github.com/jupyter-server/jupyter_server/pull/870) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-06-07&to=2022-07-05&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-06-07..2022-07-05&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2022-06-07..2022-07-05&type=Issues) | [@CiprianAnton](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACiprianAnton+updated%3A2022-06-07..2022-07-05&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-06-07..2022-07-05&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2022-06-07..2022-07-05&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2022-06-07..2022-07-05&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-06-07..2022-07-05&type=Issues) | [@martinRenou](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AmartinRenou+updated%3A2022-06-07..2022-07-05&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-06-07..2022-07-05&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-06-07..2022-07-05&type=Issues) ## 2.0.0a1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v2.0.0a0...v2.0.0a1) - Address security advisory [GHSA-q874-g24w-4q9g](https://github.com/jupyter-server/jupyter_server/security/advisories/GHSA-q874-g24w-4q9g). ## 2.0.0a0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.16.0...3e64fa5eef7fba9f8e17c30cec688254adf913bd)) ### New features added - Identity API at /api/me [#671](https://github.com/jupyter-server/jupyter_server/pull/671) ([@minrk](https://github.com/minrk)) ### Enhancements made - Add the root_dir value to the logging message in case of non compliant preferred_dir [#804](https://github.com/jupyter-server/jupyter_server/pull/804) ([@echarles](https://github.com/echarles)) - Hydrate a Kernel Manager when calling GatewayKernelManager.start_kernel with a kernel_id [#788](https://github.com/jupyter-server/jupyter_server/pull/788) ([@Zsailer](https://github.com/Zsailer)) - Remove terminals in favor of jupyter_server_terminals extension [#651](https://github.com/jupyter-server/jupyter_server/pull/651) ([@Zsailer](https://github.com/Zsailer)) ### Bugs fixed - Defer preferred_dir validation until root_dir is set [#826](https://github.com/jupyter-server/jupyter_server/pull/826) ([@kevin-bates](https://github.com/kevin-bates)) - missing required arguments in utils.fetch [#798](https://github.com/jupyter-server/jupyter_server/pull/798) ([@minrk](https://github.com/minrk)) ### Maintenance and upkeep improvements - Add license metadata and file [#827](https://github.com/jupyter-server/jupyter_server/pull/827) ([@blink1073](https://github.com/blink1073)) - CI cleanup [#824](https://github.com/jupyter-server/jupyter_server/pull/824) ([@blink1073](https://github.com/blink1073)) - Switch to flit [#823](https://github.com/jupyter-server/jupyter_server/pull/823) ([@blink1073](https://github.com/blink1073)) - Remove unused pytest-mock dependency [#814](https://github.com/jupyter-server/jupyter_server/pull/814) ([@mgorny](https://github.com/mgorny)) - Remove duplicate requests requirement from setup.cfg [#813](https://github.com/jupyter-server/jupyter_server/pull/813) ([@mgorny](https://github.com/mgorny)) - \[pre-commit.ci\] pre-commit autoupdate [#802](https://github.com/jupyter-server/jupyter_server/pull/802) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Add helper jobs for branch protection [#797](https://github.com/jupyter-server/jupyter_server/pull/797) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#793](https://github.com/jupyter-server/jupyter_server/pull/793) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Centralize app cleanup [#792](https://github.com/jupyter-server/jupyter_server/pull/792) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#785](https://github.com/jupyter-server/jupyter_server/pull/785) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Clean up pre-commit [#782](https://github.com/jupyter-server/jupyter_server/pull/782) ([@blink1073](https://github.com/blink1073)) - Add mypy check [#779](https://github.com/jupyter-server/jupyter_server/pull/779) ([@blink1073](https://github.com/blink1073)) - Use new post-version-spec from jupyter_releaser [#777](https://github.com/jupyter-server/jupyter_server/pull/777) ([@blink1073](https://github.com/blink1073)) - Give write permissions to enforce label workflow [#776](https://github.com/jupyter-server/jupyter_server/pull/776) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#775](https://github.com/jupyter-server/jupyter_server/pull/775) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Add explicit handling of warnings [#771](https://github.com/jupyter-server/jupyter_server/pull/771) ([@blink1073](https://github.com/blink1073)) - Use test-sdist from maintainer-tools [#769](https://github.com/jupyter-server/jupyter_server/pull/769) ([@blink1073](https://github.com/blink1073)) - Add pyupgrade and doc8 hooks [#768](https://github.com/jupyter-server/jupyter_server/pull/768) ([@blink1073](https://github.com/blink1073)) - update some metadata fields, sort deps [#675](https://github.com/jupyter-server/jupyter_server/pull/675) ([@bollwyvl](https://github.com/bollwyvl)) ### Documentation improvements - Add Session workflows documentation [#808](https://github.com/jupyter-server/jupyter_server/pull/808) ([@andreyvelich](https://github.com/andreyvelich)) - Add Jupyter Server Architecture diagram [#801](https://github.com/jupyter-server/jupyter_server/pull/801) ([@andreyvelich](https://github.com/andreyvelich)) - Fix path for full config doc [#800](https://github.com/jupyter-server/jupyter_server/pull/800) ([@andreyvelich](https://github.com/andreyvelich)) - Fix contributing guide for building the docs [#794](https://github.com/jupyter-server/jupyter_server/pull/794) ([@andreyvelich](https://github.com/andreyvelich)) - Update team meetings doc [#772](https://github.com/jupyter-server/jupyter_server/pull/772) ([@willingc](https://github.com/willingc)) - Update documentation about registering file save hooks [#770](https://github.com/jupyter-server/jupyter_server/pull/770) ([@davidbrochart](https://github.com/davidbrochart)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-03-29&to=2022-05-03&type=c)) [@andreyvelich](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aandreyvelich+updated%3A2022-03-29..2022-05-03&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-03-29..2022-05-03&type=Issues) | [@bollwyvl](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Abollwyvl+updated%3A2022-03-29..2022-05-03&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-03-29..2022-05-03&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2022-03-29..2022-05-03&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2022-03-29..2022-05-03&type=Issues) | [@hbcarlos](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ahbcarlos+updated%3A2022-03-29..2022-05-03&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-03-29..2022-05-03&type=Issues) | [@meeseeksdev](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ameeseeksdev+updated%3A2022-03-29..2022-05-03&type=Issues) | [@mgorny](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amgorny+updated%3A2022-03-29..2022-05-03&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-03-29..2022-05-03&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Apre-commit-ci+updated%3A2022-03-29..2022-05-03&type=Issues) | [@SylvainCorlay](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ASylvainCorlay+updated%3A2022-03-29..2022-05-03&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-03-29..2022-05-03&type=Issues) | [@Wh1isper](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AWh1isper+updated%3A2022-03-29..2022-05-03&type=Issues) | [@willingc](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awillingc+updated%3A2022-03-29..2022-05-03&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-03-29..2022-05-03&type=Issues) ## 1.17.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.16.0...2b296099777d50aa86f67faf94d5cbfde906b169)) ### Enhancements made - Add the root_dir value to the logging message in case of non compliant preferred_dir [#804](https://github.com/jupyter-server/jupyter_server/pull/804) ([@echarles](https://github.com/echarles)) ### Bugs fixed - missing required arguments in utils.fetch [#798](https://github.com/jupyter-server/jupyter_server/pull/798) ([@minrk](https://github.com/minrk)) ### Maintenance and upkeep improvements - Add helper jobs for branch protection [#797](https://github.com/jupyter-server/jupyter_server/pull/797) ([@blink1073](https://github.com/blink1073)) - \[pre-commit.ci\] pre-commit autoupdate [#793](https://github.com/jupyter-server/jupyter_server/pull/793) ([@pre-commit-ci\[bot\]](https://github.com/apps/pre-commit-ci)) - Update branch references and links [#791](https://github.com/jupyter-server/jupyter_server/pull/791) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-03-29&to=2022-04-27&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-03-29..2022-04-27&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-03-29..2022-04-27&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2022-03-29..2022-04-27&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2022-03-29..2022-04-27&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-03-29..2022-04-27&type=Issues) | [@meeseeksdev](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ameeseeksdev+updated%3A2022-03-29..2022-04-27&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ameeseeksmachine+updated%3A2022-03-29..2022-04-27&type=Issues) | [@Wh1isper](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AWh1isper+updated%3A2022-03-29..2022-04-27&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-03-29..2022-04-27&type=Issues) ## 1.16.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.15.6...d32b887ae2c3b77fe3ae67ba79c3d3c6713c0d8a)) ### New features added - add hook to observe pending sessions [#751](https://github.com/jupyter-server/jupyter_server/pull/751) ([@Zsailer](https://github.com/Zsailer)) ### Enhancements made - Add `max-age` Cache-Control header to kernel logos [#760](https://github.com/jupyter-server/jupyter_server/pull/760) ([@divyansshhh](https://github.com/divyansshhh)) ### Bugs fixed - Regression in connection URL calculation in ServerApp [#761](https://github.com/jupyter-server/jupyter_server/pull/761) ([@jhamet93](https://github.com/jhamet93)) - Include explicit package data [#757](https://github.com/jupyter-server/jupyter_server/pull/757) ([@blink1073](https://github.com/blink1073)) - Ensure terminal cwd exists [#755](https://github.com/jupyter-server/jupyter_server/pull/755) ([@fcollonval](https://github.com/fcollonval)) - make 'cwd' param for TerminalManager absolute [#749](https://github.com/jupyter-server/jupyter_server/pull/749) ([@rccern](https://github.com/rccern)) - wait to cleanup kernels after kernel is finished pending [#748](https://github.com/jupyter-server/jupyter_server/pull/748) ([@Zsailer](https://github.com/Zsailer)) ### Maintenance and upkeep improvements - Skip jsonschema in CI [#766](https://github.com/jupyter-server/jupyter_server/pull/766) ([@blink1073](https://github.com/blink1073)) - Remove redundant job and problematic check [#765](https://github.com/jupyter-server/jupyter_server/pull/765) ([@blink1073](https://github.com/blink1073)) - Update pre-commit [#764](https://github.com/jupyter-server/jupyter_server/pull/764) ([@blink1073](https://github.com/blink1073)) - Install pre-commit automatically [#763](https://github.com/jupyter-server/jupyter_server/pull/763) ([@blink1073](https://github.com/blink1073)) - Add pytest opts and use isort [#762](https://github.com/jupyter-server/jupyter_server/pull/762) ([@blink1073](https://github.com/blink1073)) - Ensure minimal nbconvert support jinja2 v2 & v3 [#756](https://github.com/jupyter-server/jupyter_server/pull/756) ([@fcollonval](https://github.com/fcollonval)) - Fix error handler in simple extension examples [#750](https://github.com/jupyter-server/jupyter_server/pull/750) ([@andreyvelich](https://github.com/andreyvelich)) - Clean up workflows [#747](https://github.com/jupyter-server/jupyter_server/pull/747) ([@blink1073](https://github.com/blink1073)) - Remove Redundant Dir_Exists Invocation When Creating New Files with ContentsManager [#720](https://github.com/jupyter-server/jupyter_server/pull/720) ([@jhamet93](https://github.com/jhamet93)) ### Other merged PRs - Handle importstring pre/post save hooks [#754](https://github.com/jupyter-server/jupyter_server/pull/754) ([@dleen](https://github.com/dleen)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-03-16&to=2022-03-29&type=c)) [@andreyvelich](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aandreyvelich+updated%3A2022-03-16..2022-03-29&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-03-16..2022-03-29&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-03-16..2022-03-29&type=Issues) | [@divyansshhh](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adivyansshhh+updated%3A2022-03-16..2022-03-29&type=Issues) | [@dleen](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adleen+updated%3A2022-03-16..2022-03-29&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afcollonval+updated%3A2022-03-16..2022-03-29&type=Issues) | [@jhamet93](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajhamet93+updated%3A2022-03-16..2022-03-29&type=Issues) | [@meeseeksdev](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ameeseeksdev+updated%3A2022-03-16..2022-03-29&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-03-16..2022-03-29&type=Issues) | [@rccern](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Arccern+updated%3A2022-03-16..2022-03-29&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-03-16..2022-03-29&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-03-16..2022-03-29&type=Issues) ## 1.15.6 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.15.5...7fbaa767c71302cc756bdf1fb11fcbf1b3768dcc)) ### Bugs fixed - Missing warning when no authorizer in found ZMQ handlers [#744](https://github.com/jupyter-server/jupyter_server/pull/744) ([@Zsailer](https://github.com/Zsailer)) ### Maintenance and upkeep improvements - More CI Cleanup [#742](https://github.com/jupyter-server/jupyter_server/pull/742) ([@blink1073](https://github.com/blink1073)) - Clean up downstream tests [#741](https://github.com/jupyter-server/jupyter_server/pull/741) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-03-16&to=2022-03-16&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-03-16..2022-03-16&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-03-16..2022-03-16&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-03-16..2022-03-16&type=Issues) ## 1.15.5 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.15.4...67f6140ebca60bfde0fb068f10b428320c6c67cd)) ### Bugs fixed - Relax type checking on ExtensionApp.serverapp [#739](https://github.com/jupyter-server/jupyter_server/pull/739) ([@minrk](https://github.com/minrk)) - raise no-authorization warning once and allow disabled authorization [#738](https://github.com/jupyter-server/jupyter_server/pull/738) ([@Zsailer](https://github.com/Zsailer)) ### Maintenance and upkeep improvements - Fix sdist test [#736](https://github.com/jupyter-server/jupyter_server/pull/736) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-03-15&to=2022-03-16&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-03-15..2022-03-16&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-03-15..2022-03-16&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-03-15..2022-03-16&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-03-15..2022-03-16&type=Issues) ## 1.15.3 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.15.2...461b55146888b58a84c6cfc70c9bbfdb3e8d6aba)) ### Bugs fixed - Fix server-extension paths (3rd time's the charm) [#734](https://github.com/jupyter-server/jupyter_server/pull/734) ([@minrk](https://github.com/minrk)) - Revert "Server extension paths (#730)" [#732](https://github.com/jupyter-server/jupyter_server/pull/732) ([@blink1073](https://github.com/blink1073)) ### Maintenance and upkeep improvements - Avoid usage of ipython_genutils [#718](https://github.com/jupyter-server/jupyter_server/pull/718) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-03-14&to=2022-03-14&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-03-14..2022-03-14&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-03-14..2022-03-14&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-03-14..2022-03-14&type=Issues) ## 1.15.2 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.15.1...9711822e157bd2ff2d4f8abe4c12a013223230e0)) ### Bugs fixed - Server extension paths [#730](https://github.com/jupyter-server/jupyter_server/pull/730) ([@minrk](https://github.com/minrk)) - allow handlers to work without an authorizer in the Tornado settings [#717](https://github.com/jupyter-server/jupyter_server/pull/717) ([@Zsailer](https://github.com/Zsailer)) ### Maintenance and upkeep improvements - Skip nbclassic downstream tests for now [#725](https://github.com/jupyter-server/jupyter_server/pull/725) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-03-14&to=2022-03-14&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-03-14..2022-03-14&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-03-14..2022-03-14&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-03-14..2022-03-14&type=Issues) ## 1.15.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.15.0...262cb46b95de2443c8552d80d6adade6bde5734b)) ### Bugs fixed - Revert "Reuse ServerApp.config_file_paths for consistency (#715)" [#728](https://github.com/jupyter-server/jupyter_server/pull/728) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-03-14&to=2022-03-14&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-03-14..2022-03-14&type=Issues) ## 1.15.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.13.5...6e0e49e720ee6626b5233df716b0fdb891b79793)) ### New features added - Add authorization layer to server request handlers [#165](https://github.com/jupyter-server/jupyter_server/pull/165) ([@Zsailer](https://github.com/Zsailer)) ### Enhancements made - Validate notebooks once per fetch or save [#724](https://github.com/jupyter-server/jupyter_server/pull/724) ([@kevin-bates](https://github.com/kevin-bates)) - Register pre/post save hooks, call them sequentially [#696](https://github.com/jupyter-server/jupyter_server/pull/696) ([@davidbrochart](https://github.com/davidbrochart)) ### Bugs fixed - Implement Required Methods in Async Manner [#721](https://github.com/jupyter-server/jupyter_server/pull/721) ([@jhamet93](https://github.com/jhamet93)) - Call pre_save_hook only on first chunk of large files [#716](https://github.com/jupyter-server/jupyter_server/pull/716) ([@davidbrochart](https://github.com/davidbrochart)) - Reuse ServerApp.config_file_paths for consistency [#715](https://github.com/jupyter-server/jupyter_server/pull/715) ([@minrk](https://github.com/minrk)) - serverapp: Use .absolute() instead of .resolve() for symlinks [#712](https://github.com/jupyter-server/jupyter_server/pull/712) ([@EricCousineau-TRI](https://github.com/EricCousineau-TRI)) - Fall back to legacy protocol if selected_subprotocol raises exception [#706](https://github.com/jupyter-server/jupyter_server/pull/706) ([@davidbrochart](https://github.com/davidbrochart)) - Fix FilesHandler not meet RFC 6713 [#701](https://github.com/jupyter-server/jupyter_server/pull/701) ([@Wh1isper](https://github.com/Wh1isper)) ### Maintenance and upkeep improvements - Clean up CI [#723](https://github.com/jupyter-server/jupyter_server/pull/723) ([@blink1073](https://github.com/blink1073)) - Clean up activity recording [#722](https://github.com/jupyter-server/jupyter_server/pull/722) ([@blink1073](https://github.com/blink1073)) - Clean up Dependency Handling [#707](https://github.com/jupyter-server/jupyter_server/pull/707) ([@blink1073](https://github.com/blink1073)) - Add Minimum Requirements Test [#704](https://github.com/jupyter-server/jupyter_server/pull/704) ([@blink1073](https://github.com/blink1073)) - Clean up handling of tests [#700](https://github.com/jupyter-server/jupyter_server/pull/700) ([@blink1073](https://github.com/blink1073)) - Refresh precommit [#698](https://github.com/jupyter-server/jupyter_server/pull/698) ([@blink1073](https://github.com/blink1073)) - Use pytest-github-actions-annotate-failures [#694](https://github.com/jupyter-server/jupyter_server/pull/694) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements - Add WebSocket wire protocol documentation [#693](https://github.com/jupyter-server/jupyter_server/pull/693) ([@davidbrochart](https://github.com/davidbrochart)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-02-05&to=2022-03-14&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-02-05..2022-03-14&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-02-05..2022-03-14&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2022-02-05..2022-03-14&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2022-02-05..2022-03-14&type=Issues) | [@EricCousineau-TRI](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AEricCousineau-TRI+updated%3A2022-02-05..2022-03-14&type=Issues) | [@jhamet93](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajhamet93+updated%3A2022-02-05..2022-03-14&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2022-02-05..2022-03-14&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2022-02-05..2022-03-14&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2022-02-05..2022-03-14&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-02-05..2022-03-14&type=Issues) | [@Wh1isper](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AWh1isper+updated%3A2022-02-05..2022-03-14&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-02-05..2022-03-14&type=Issues) ## 1.13.5 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.13.4...cbc54fa3a99abdf1d2a8b5e35d33a74dd524d905)) ### Enhancements made - Protocol alignment [#657](https://github.com/jupyter-server/jupyter_server/pull/657) ([@davidbrochart](https://github.com/davidbrochart)) ### Bugs fixed - Fix to remove potential memory leak on Jupyter Server ZMQChannelHandler code [#682](https://github.com/jupyter-server/jupyter_server/pull/682) ([@Vishwajeet0510](https://github.com/Vishwajeet0510)) - Pin pywintpy for now [#681](https://github.com/jupyter-server/jupyter_server/pull/681) ([@blink1073](https://github.com/blink1073)) - Fix the non-writable path deletion error [#670](https://github.com/jupyter-server/jupyter_server/pull/670) ([@vkaidalov](https://github.com/vkaidalov)) - make unit tests backwards compatible without pending kernels [#669](https://github.com/jupyter-server/jupyter_server/pull/669) ([@Zsailer](https://github.com/Zsailer)) ### Maintenance and upkeep improvements - Clean up full install test [#689](https://github.com/jupyter-server/jupyter_server/pull/689) ([@blink1073](https://github.com/blink1073)) - Update trigger_precommit.yml [#687](https://github.com/jupyter-server/jupyter_server/pull/687) ([@blink1073](https://github.com/blink1073)) - Add Auto Pre-Commit [#685](https://github.com/jupyter-server/jupyter_server/pull/685) ([@blink1073](https://github.com/blink1073)) - Fix a typo [#683](https://github.com/jupyter-server/jupyter_server/pull/683) ([@krassowski](https://github.com/krassowski)) - (temporarily) skip pending kernels unit tests on Windows CI [#673](https://github.com/jupyter-server/jupyter_server/pull/673) ([@Zsailer](https://github.com/Zsailer)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-01-21&to=2022-02-05&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2022-01-21..2022-02-05&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-01-21..2022-02-05&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2022-01-21..2022-02-05&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2022-01-21..2022-02-05&type=Issues) | [@github-actions](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Agithub-actions+updated%3A2022-01-21..2022-02-05&type=Issues) | [@jasongrout](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajasongrout+updated%3A2022-01-21..2022-02-05&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2022-01-21..2022-02-05&type=Issues) | [@maartenbreddels](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amaartenbreddels+updated%3A2022-01-21..2022-02-05&type=Issues) | [@SylvainCorlay](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ASylvainCorlay+updated%3A2022-01-21..2022-02-05&type=Issues) | [@Vishwajeet0510](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AVishwajeet0510+updated%3A2022-01-21..2022-02-05&type=Issues) | [@vkaidalov](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avkaidalov+updated%3A2022-01-21..2022-02-05&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2022-01-21..2022-02-05&type=Issues) | [@Wh1isper](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AWh1isper+updated%3A2022-01-21..2022-02-05&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-01-21..2022-02-05&type=Issues) ## 1.13.4 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.13.3...d2015290b80bfdaa6ebb990cdccc0155921696f5)) ### Bugs fixed - Fix nbconvert handler run_sync() [#667](https://github.com/jupyter-server/jupyter_server/pull/667) ([@davidbrochart](https://github.com/davidbrochart)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-01-14&to=2022-01-21&type=c)) [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2022-01-14..2022-01-21&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2022-01-14..2022-01-21&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-01-14..2022-01-21&type=Issues) ## 1.13.3 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.13.2...ee01e1955c8881b46075c78f1fbc932fa234bc72)) ### Enhancements made - More updates to unit tests for pending kernels work [#662](https://github.com/jupyter-server/jupyter_server/pull/662) ([@Zsailer](https://github.com/Zsailer)) ### Bugs fixed - bump traitlets dependency [#663](https://github.com/jupyter-server/jupyter_server/pull/663) ([@Zsailer](https://github.com/Zsailer)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2022-01-12&to=2022-01-14&type=c)) [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2022-01-12..2022-01-14&type=Issues) ## 1.13.2 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.13.1...362d100ff24c1da7ef4cbd171c213e9570e8c289)) ### Enhancements made - Don't block the event loop when exporting with nbconvert [#655](https://github.com/jupyter-server/jupyter_server/pull/655) ([@davidbrochart](https://github.com/davidbrochart)) - Add more awaits for pending kernel in unit tests [#654](https://github.com/jupyter-server/jupyter_server/pull/654) ([@Zsailer](https://github.com/Zsailer)) - Print IPv6 url as hostname or enclosed in brackets [#652](https://github.com/jupyter-server/jupyter_server/pull/652) ([@op3](https://github.com/op3)) ### Bugs fixed - Run pre_save_hook before model check [#643](https://github.com/jupyter-server/jupyter_server/pull/643) ([@davidbrochart](https://github.com/davidbrochart)) - handle KeyError when get session [#641](https://github.com/jupyter-server/jupyter_server/pull/641) ([@ccw630](https://github.com/ccw630)) ### Maintenance and upkeep improvements - Clean up deprecations [#650](https://github.com/jupyter-server/jupyter_server/pull/650) ([@blink1073](https://github.com/blink1073)) - Update branch references [#646](https://github.com/jupyter-server/jupyter_server/pull/646) ([@blink1073](https://github.com/blink1073)) - pyproject.toml: clarify build system version [#634](https://github.com/jupyter-server/jupyter_server/pull/634) ([@adamjstewart](https://github.com/adamjstewart)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-12-09&to=2022-01-12&type=c)) [@adamjstewart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aadamjstewart+updated%3A2021-12-09..2022-01-12&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-12-09..2022-01-12&type=Issues) | [@ccw630](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Accw630+updated%3A2021-12-09..2022-01-12&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-12-09..2022-01-12&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2021-12-09..2022-01-12&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2021-12-09..2022-01-12&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afcollonval+updated%3A2021-12-09..2022-01-12&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-12-09..2022-01-12&type=Issues) | [@op3](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aop3+updated%3A2021-12-09..2022-01-12&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2021-12-09..2022-01-12&type=Issues) | [@Wh1isper](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AWh1isper+updated%3A2021-12-09..2022-01-12&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-12-09..2022-01-12&type=Issues) ## 1.13.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.13.0...affd5d9a2e6d718baa2185518256f51921fd4484)) ### Bugs fixed - nudge both the shell and control channels [#636](https://github.com/jupyter-server/jupyter_server/pull/636) ([@Zsailer](https://github.com/Zsailer)) ### Maintenance and upkeep improvements - Fix macos pypy check [#632](https://github.com/jupyter-server/jupyter_server/pull/632) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-12-06&to=2021-12-09&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-12-06..2021-12-09&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-12-06..2021-12-09&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-12-06..2021-12-09&type=Issues) ## 1.13.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.12.1...b51969f16f04375d52cb029d72f90174141c760d)) ### Enhancements made - Persistent session storage [#614](https://github.com/jupyter-server/jupyter_server/pull/614) ([@Zsailer](https://github.com/Zsailer)) ### Bugs fixed - Nudge on the control channel instead of the shell [#628](https://github.com/jupyter-server/jupyter_server/pull/628) ([@JohanMabille](https://github.com/JohanMabille)) ### Maintenance and upkeep improvements - Clean up downstream tests [#629](https://github.com/jupyter-server/jupyter_server/pull/629) ([@blink1073](https://github.com/blink1073)) - Clean up version info handling [#620](https://github.com/jupyter-server/jupyter_server/pull/620) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-11-26&to=2021-12-06&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-11-26..2021-12-06&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-11-26..2021-12-06&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2021-11-26..2021-12-06&type=Issues) | [@JohanMabille](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AJohanMabille+updated%3A2021-11-26..2021-12-06&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajtpio+updated%3A2021-11-26..2021-12-06&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-11-26..2021-12-06&type=Issues) ## 1.12.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.12.0...ead83374b3b874bdf4ea47fca5aee1ecb5940a85)) ### Bugs fixed - Await `_finish_kernel_start` [#617](https://github.com/jupyter-server/jupyter_server/pull/617) ([@jtpio](https://github.com/jtpio)) ### Maintenance and upkeep improvements - Update to Python 3.10 in the CI workflows [#618](https://github.com/jupyter-server/jupyter_server/pull/618) ([@jtpio](https://github.com/jtpio)) - Use `maintainer-tools` base setup action [#616](https://github.com/jupyter-server/jupyter_server/pull/616) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-11-23&to=2021-11-26&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-11-23..2021-11-26&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-11-23..2021-11-26&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajtpio+updated%3A2021-11-23..2021-11-26&type=Issues) ## 1.12.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.11.2...758dba6f8873f60c1ca41057b4be108da5a6ff1a)) ### Enhancements made - Consistent logging method [#607](https://github.com/jupyter-server/jupyter_server/pull/607) ([@mwakaba2](https://github.com/mwakaba2)) - Use pending kernels [#593](https://github.com/jupyter-server/jupyter_server/pull/593) ([@blink1073](https://github.com/blink1073)) ### Bugs fixed - Set `xsrf` cookie on base url [#612](https://github.com/jupyter-server/jupyter_server/pull/612) ([@minrk](https://github.com/minrk)) - Update `jpserver_extensions` trait to work with `traitlets` 5.x [#610](https://github.com/jupyter-server/jupyter_server/pull/610) ([@Zsailer](https://github.com/Zsailer)) - Fix `allow_origin_pat` property to properly parse regex [#603](https://github.com/jupyter-server/jupyter_server/pull/603) ([@havok2063](https://github.com/havok2063)) ### Maintenance and upkeep improvements - Enforce labels on PRs [#613](https://github.com/jupyter-server/jupyter_server/pull/613) ([@blink1073](https://github.com/blink1073)) - Normalize file name and path in `test_api` [#608](https://github.com/jupyter-server/jupyter_server/pull/608) ([@toonn](https://github.com/toonn)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-11-01&to=2021-11-23&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-11-01..2021-11-23&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-11-01..2021-11-23&type=Issues) | [@havok2063](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ahavok2063+updated%3A2021-11-01..2021-11-23&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2021-11-01..2021-11-23&type=Issues) | [@mwakaba2](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amwakaba2+updated%3A2021-11-01..2021-11-23&type=Issues) | [@toonn](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Atoonn+updated%3A2021-11-01..2021-11-23&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2021-11-01..2021-11-23&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-11-01..2021-11-23&type=Issues) ## 1.11.2 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.11.1...fda4cc5a96703bb4e871a5a622ef6031c7f6385b)) ### Bugs fixed - Fix \\s deprecation warning [#600](https://github.com/jupyter-server/jupyter_server/pull/600) ([@Zsailer](https://github.com/Zsailer)) - Remove requests-unixsocket dependency [#599](https://github.com/jupyter-server/jupyter_server/pull/599) ([@kevin-bates](https://github.com/kevin-bates)) - bugfix: dir_exists is never awaited [#597](https://github.com/jupyter-server/jupyter_server/pull/597) ([@stdll00](https://github.com/stdll00)) - Fix missing await when call 'async_replace_file' [#595](https://github.com/jupyter-server/jupyter_server/pull/595) ([@Wh1isper](https://github.com/Wh1isper)) - add a pytest fixture for capturing logging stream [#588](https://github.com/jupyter-server/jupyter_server/pull/588) ([@Zsailer](https://github.com/Zsailer)) ### Maintenance and upkeep improvements - Avoid dependency on NBConvert versions for REST API test [#601](https://github.com/jupyter-server/jupyter_server/pull/601) ([@Zsailer](https://github.com/Zsailer)) - Bump ansi-regex from 5.0.0 to 5.0.1 [#590](https://github.com/jupyter-server/jupyter_server/pull/590) ([@dependabot](https://github.com/dependabot)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-10-04&to=2021-11-01&type=c)) [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-10-04..2021-11-01&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adependabot+updated%3A2021-10-04..2021-11-01&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-10-04..2021-11-01&type=Issues) | [@stdll00](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Astdll00+updated%3A2021-10-04..2021-11-01&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2021-10-04..2021-11-01&type=Issues) | [@Wh1isper](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AWh1isper+updated%3A2021-10-04..2021-11-01&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-10-04..2021-11-01&type=Issues) ## 1.11.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.11.0...f4c3889658c1daad1d8966438d1f1b98b3f60641)) ### Bugs fixed - Do not log connection error if the kernel is already shutdown [#584](https://github.com/jupyter-server/jupyter_server/pull/584) ([@martinRenou](https://github.com/martinRenou)) - \[BUG\]: allow None for min_open_files_limit trait [#587](https://github.com/jupyter-server/jupyter_server/pull/587) ([@Zsailer](https://github.com/Zsailer)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-09-09&to=2021-10-04&type=c)) [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-09-09..2021-10-04&type=Issues) | [@martinRenou](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AmartinRenou+updated%3A2021-09-09..2021-10-04&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-09-09..2021-10-04&type=Issues) ## 1.11.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.10.2...1863fde11af7971d040ad50ad015caa83b6c7d54)) ### Enhancements made - Allow non-empty directory deletion through settings [#574](https://github.com/jupyter-server/jupyter_server/pull/574) ([@fcollonval](https://github.com/fcollonval)) ### Bugs fixed - pytest_plugin: allow user specified headers in jp_ws_fetch [#580](https://github.com/jupyter-server/jupyter_server/pull/580) ([@oliver-sanders](https://github.com/oliver-sanders)) - Shutdown kernels/terminals on api/shutdown [#579](https://github.com/jupyter-server/jupyter_server/pull/579) ([@martinRenou](https://github.com/martinRenou)) - pytest: package conftest [#576](https://github.com/jupyter-server/jupyter_server/pull/576) ([@oliver-sanders](https://github.com/oliver-sanders)) - Set stacklevel on warning to point to the right place. [#572](https://github.com/jupyter-server/jupyter_server/pull/572) ([@Carreau](https://github.com/Carreau)) - Respect reraise setting [#571](https://github.com/jupyter-server/jupyter_server/pull/571) ([@vidartf](https://github.com/vidartf)) ### Maintenance and upkeep improvements - Fix jupyter_client warning [#581](https://github.com/jupyter-server/jupyter_server/pull/581) ([@martinRenou](https://github.com/martinRenou)) - Add Pre-Commit Config [#575](https://github.com/jupyter-server/jupyter_server/pull/575) ([@fcollonval](https://github.com/fcollonval)) - Clean up link checking [#569](https://github.com/jupyter-server/jupyter_server/pull/569) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-08-02&to=2021-09-09&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-08-02..2021-09-09&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2021-08-02..2021-09-09&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-08-02..2021-09-09&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afcollonval+updated%3A2021-08-02..2021-09-09&type=Issues) | [@martinRenou](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AmartinRenou+updated%3A2021-08-02..2021-09-09&type=Issues) | [@oliver-sanders](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aoliver-sanders+updated%3A2021-08-02..2021-09-09&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2021-08-02..2021-09-09&type=Issues) ## 1.10.2 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.10.1...7956dc51d8239b7b9e8de3b22ceb4473bbf1d4e5)) ### Bugs fixed - fix: make command line aliases work again [#564](https://github.com/jupyter-server/jupyter_server/pull/564) ([@mariobuikhuizen](https://github.com/mariobuikhuizen)) - decode bytes from secure cookie [#562](https://github.com/jupyter-server/jupyter_server/pull/562) ([@oliver-sanders](https://github.com/oliver-sanders)) ### Maintenance and upkeep improvements - Add the needed space in the welcome message [#561](https://github.com/jupyter-server/jupyter_server/pull/561) ([@echarles](https://github.com/echarles)) - Update check-release workflow [#558](https://github.com/jupyter-server/jupyter_server/pull/558) ([@afshin](https://github.com/afshin)) ### Documentation improvements - Fix typo in allow_password_change help [#559](https://github.com/jupyter-server/jupyter_server/pull/559) ([@manics](https://github.com/manics)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-07-23&to=2021-08-02&type=c)) [@afshin](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aafshin+updated%3A2021-07-23..2021-08-02&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-07-23..2021-08-02&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2021-07-23..2021-08-02&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amanics+updated%3A2021-07-23..2021-08-02&type=Issues) | [@mariobuikhuizen](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amariobuikhuizen+updated%3A2021-07-23..2021-08-02&type=Issues) | [@oliver-sanders](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aoliver-sanders+updated%3A2021-07-23..2021-08-02&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2021-07-23..2021-08-02&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-07-23..2021-08-02&type=Issues) ## 1.10.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.10.0...42a195665aa8ae218fce4ec8165f19e734a9edaf)) ### Bugs fixed - Protect against unset spec [#556](https://github.com/jupyter-server/jupyter_server/pull/556) ([@fcollonval](https://github.com/fcollonval)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-07-22&to=2021-07-23&type=c)) [@fcollonval](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afcollonval+updated%3A2021-07-22..2021-07-23&type=Issues) ## 1.10.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.9.0...c9ee2a45e9a8f04215c2f3901f90cdc7b8fdc9c6)) ### Enhancements made - PR: Add a new preferred-dir traitlet [#549](https://github.com/jupyter-server/jupyter_server/pull/549) ([@goanpeca](https://github.com/goanpeca)) - stop hook for extensions [#526](https://github.com/jupyter-server/jupyter_server/pull/526) ([@oliver-sanders](https://github.com/oliver-sanders)) - extensions: allow extensions in namespace packages [#523](https://github.com/jupyter-server/jupyter_server/pull/523) ([@oliver-sanders](https://github.com/oliver-sanders)) ### Bugs fixed - Fix examples/simple test execution [#552](https://github.com/jupyter-server/jupyter_server/pull/552) ([@davidbrochart](https://github.com/davidbrochart)) - Rebuild package-lock, fixing local setup [#548](https://github.com/jupyter-server/jupyter_server/pull/548) ([@martinRenou](https://github.com/martinRenou)) ### Maintenance and upkeep improvements - small test changes [#541](https://github.com/jupyter-server/jupyter_server/pull/541) ([@oliver-sanders](https://github.com/oliver-sanders)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-06-24&to=2021-07-21&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-06-24..2021-07-21&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-06-24..2021-07-21&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2021-06-24..2021-07-21&type=Issues) | [@goanpeca](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Agoanpeca+updated%3A2021-06-24..2021-07-21&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-06-24..2021-07-21&type=Issues) | [@martinRenou](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AmartinRenou+updated%3A2021-06-24..2021-07-21&type=Issues) | [@oliver-sanders](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aoliver-sanders+updated%3A2021-06-24..2021-07-21&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2021-06-24..2021-07-21&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-06-24..2021-07-21&type=Issues) ## 1.9.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.8.0...f712734c4f7005f6a844abec9f57b993e7b004b0)) ### Enhancements made - enable a way to run a task when an io_loop is created [#531](https://github.com/jupyter-server/jupyter_server/pull/531) ([@eastonsuo](https://github.com/eastonsuo)) - adds `GatewayClient.auth_scheme` configurable [#529](https://github.com/jupyter-server/jupyter_server/pull/529) ([@telamonian](https://github.com/telamonian)) - \[Notebook port 4835\] Add UNIX socket support to notebook server [#525](https://github.com/jupyter-server/jupyter_server/pull/525) ([@jtpio](https://github.com/jtpio)) ### Bugs fixed - Fix nbconvert handler [#545](https://github.com/jupyter-server/jupyter_server/pull/545) ([@davidbrochart](https://github.com/davidbrochart)) - Fixes AsyncContentsManager#exists [#542](https://github.com/jupyter-server/jupyter_server/pull/542) ([@icankeep](https://github.com/icankeep)) ### Maintenance and upkeep improvements - argon2 as an optional dependency [#532](https://github.com/jupyter-server/jupyter_server/pull/532) ([@vidartf](https://github.com/vidartf)) - Test Downstream Packages [#528](https://github.com/jupyter-server/jupyter_server/pull/528) ([@blink1073](https://github.com/blink1073)) - fix jp_ws_fetch not work by its own #441 [#527](https://github.com/jupyter-server/jupyter_server/pull/527) ([@eastonsuo](https://github.com/eastonsuo)) ### Documentation improvements - Update link to meeting notes [#535](https://github.com/jupyter-server/jupyter_server/pull/535) ([@krassowski](https://github.com/krassowski)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-05-20&to=2021-06-24&type=c)) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-05-20..2021-06-24&type=Issues) | [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-05-20..2021-06-24&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2021-05-20..2021-06-24&type=Issues) | [@eastonsuo](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aeastonsuo+updated%3A2021-05-20..2021-06-24&type=Issues) | [@icankeep](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aicankeep+updated%3A2021-05-20..2021-06-24&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajtpio+updated%3A2021-05-20..2021-06-24&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-05-20..2021-06-24&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akrassowski+updated%3A2021-05-20..2021-06-24&type=Issues) | [@telamonian](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Atelamonian+updated%3A2021-05-20..2021-06-24&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2021-05-20..2021-06-24&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2021-05-20..2021-06-24&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-05-20..2021-06-24&type=Issues) ## 1.8.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.7.0...b063117a3a48ea67371c62e492f4637e44157586)) ### Enhancements made - Expose a public property to sort extensions deterministically. [#522](https://github.com/jupyter-server/jupyter_server/pull/522) ([@Zsailer](https://github.com/Zsailer)) ### Bugs fixed - init_httpserver at the end of initialize [#517](https://github.com/jupyter-server/jupyter_server/pull/517) ([@minrk](https://github.com/minrk)) ### Maintenance and upkeep improvements - Upgrade anyio to 3.1 for all py versions [#521](https://github.com/jupyter-server/jupyter_server/pull/521) ([@mwakaba2](https://github.com/mwakaba2)) - Enable Server Tests on Windows [#519](https://github.com/jupyter-server/jupyter_server/pull/519) ([@jtpio](https://github.com/jtpio)) - restore preference for SelectorEventLoop on Windows [#513](https://github.com/jupyter-server/jupyter_server/pull/513) ([@minrk](https://github.com/minrk)) - set default config dir name [#504](https://github.com/jupyter-server/jupyter_server/pull/504) ([@minrk](https://github.com/minrk)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-05-10&to=2021-05-20&type=c)) [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-05-10..2021-05-20&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajtpio+updated%3A2021-05-10..2021-05-20&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2021-05-10..2021-05-20&type=Issues) | [@mwakaba2](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amwakaba2+updated%3A2021-05-10..2021-05-20&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2021-05-10..2021-05-20&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2021-05-10..2021-05-20&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-05-10..2021-05-20&type=Issues) ## 1.7.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.7.0a2...afae85a7bb8c45f7610cd38b60d6075bb623490b)) ### Bugs fixed - Fix for recursive symlink - (port Notebook 4670) [#497](https://github.com/jupyter-server/jupyter_server/pull/497) ([@kevin-bates](https://github.com/kevin-bates)) ### Enhancements made - Make nbconvert root handler asynchronous [#512](https://github.com/jupyter-server/jupyter_server/pull/512) ([@hMED22](https://github.com/hMED22)) - Refactor gateway kernel management to achieve a degree of consistency [#483](https://github.com/jupyter-server/jupyter_server/pull/483) ([@kevin-bates](https://github.com/kevin-bates)) ### Maintenance and upkeep improvements - Remove Packaging Dependency [#515](https://github.com/jupyter-server/jupyter_server/pull/515) ([@jtpio](https://github.com/jtpio)) - Use kernel_id for new kernel if it doesn't exist in MappingKernelManager.start_kernel [#511](https://github.com/jupyter-server/jupyter_server/pull/511) ([@the-higgs](https://github.com/the-higgs)) - Include backtrace in debug output when extension fails to load [#506](https://github.com/jupyter-server/jupyter_server/pull/506) ([@candlerb](https://github.com/candlerb)) - ExtensionPoint: return True on successful validate() [#503](https://github.com/jupyter-server/jupyter_server/pull/503) ([@minrk](https://github.com/minrk)) - ExtensionManager: load default config manager by default [#502](https://github.com/jupyter-server/jupyter_server/pull/502) ([@minrk](https://github.com/minrk)) - Prep for Release Helper Usage [#494](https://github.com/jupyter-server/jupyter_server/pull/494) ([@jtpio](https://github.com/jtpio)) - Typo in shutdown with answer_yes [#491](https://github.com/jupyter-server/jupyter_server/pull/491) ([@kiendang](https://github.com/kiendang)) - Remove some of ipython_genutils no-op. [#440](https://github.com/jupyter-server/jupyter_server/pull/440) ([@Carreau](https://github.com/Carreau)) - Drop dependency on pywin32 [#514](https://github.com/jupyter-server/jupyter_server/pull/514) ([@kevin-bates](https://github.com/kevin-bates)) - Upgrade anyio to v3 [#492](https://github.com/jupyter-server/jupyter_server/pull/492) ([@mwakaba2](https://github.com/mwakaba2)) - Add Appropriate Token Permission for CodeQL Workflow [#489](https://github.com/jupyter-server/jupyter_server/pull/489) ([@afshin](https://github.com/afshin)) ### Documentation improvements - DOC: Autoreformat docstrings. [#493](https://github.com/jupyter-server/jupyter_server/pull/493) ([@Carreau](https://github.com/Carreau)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-04-22&to=2021-05-10&type=c)) [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-05-06..2021-05-10&type=Issues) | [@hMED22](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AhMED22+updated%3A2021-05-06..2021-05-10&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajtpio+updated%3A2021-05-06..2021-05-10&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-05-06..2021-05-10&type=Issues) | [@the-higgs](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Athe-higgs+updated%3A2021-05-06..2021-05-10&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2021-05-06..2021-05-10&type=Issues) [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-05-01..2021-05-05&type=Issues) | [@candlerb](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acandlerb+updated%3A2021-05-01..2021-05-05&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-05-01..2021-05-05&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2021-05-01..2021-05-05&type=Issues) | [@mwakaba2](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amwakaba2+updated%3A2021-05-01..2021-05-05&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-05-01..2021-05-05&type=Issues) | [@kiendang](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akiendang+updated%3A2021-04-21..2021-05-01&type=Issues) | \[@Carreau\] (https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2021-04-21..2021-05-01&type=Issues) ## 1.6.4 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.6.3...68a64ea13be5d0d86460f04e0c47eb0b6662a0af)) ### Bugs fixed - Fix loading of sibling extensions [#485](https://github.com/jupyter-server/jupyter_server/pull/485) ([@afshin](https://github.com/afshin)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-04-21&to=2021-04-21&type=c)) [@afshin](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aafshin+updated%3A2021-04-21..2021-04-21&type=Issues) ## 1.6.3 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/v1.6.2...aa2636795ae1d87e3055febb3931f891dd6b4451)) ### Merges - Gate anyio version. [2b51ee3](https://github.com/jupyter-server/jupyter_server/commit/2b51ee37bdad305cb349e246c8ba94381cdb2048) - Fix activity tracking and nudge issues when kernel ports change on restarts [#482](https://github.com/jupyter-server/jupyter_server/pull/482) ([@kevin-bates](https://github.com/kevin-bates)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-04-16&to=2021-04-21&type=c)) [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-04-16..2021-04-21&type=Issues) ## 1.6.2 ### Enhancements made - Tighten xsrf checks [#478](https://github.com/jupyter-server/jupyter_server/pull/478) ([@jtpio](https://github.com/jtpio)) ### Bugs fixed - Re-enable support for answer_yes flag [#479](https://github.com/jupyter-server/jupyter_server/pull/479) ([@jtpio](https://github.com/jtpio)) ### Maintenance and upkeep improvements - Use Jupyter Packaging [#477](https://github.com/jupyter-server/jupyter_server/pull/477) ([@jtpio](https://github.com/jtpio)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-04-12&to=2021-04-16&type=c)) [@jtpio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajtpio+updated%3A2021-04-12..2021-04-16&type=Issues) ## 1.6.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.6.0...2756a29c5fdcfa62a3492004627541089d53d14f)) ### Merged PRs - Fix race condition with async kernel management [#472](https://github.com/jupyter-server/jupyter_server/pull/472) ([@jtpio](https://github.com/jtpio)) - Fix kernel lookup [#475](https://github.com/jupyter-server/jupyter_server/pull/475) ([@davidbrochart](https://github.com/davidbrochart)) - Add Extension App Aliases to Server App [#473](https://github.com/jupyter-server/jupyter_server/pull/473) ([@jtpio](https://github.com/jtpio)) - Correct 'Content-Type' headers [#471](https://github.com/jupyter-server/jupyter_server/pull/471) ([@faucct](https://github.com/faucct)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-04-08&to=2021-04-12&type=c)) [@codecov-io](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-io+updated%3A2021-04-08..2021-04-12&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Adavidbrochart+updated%3A2021-04-08..2021-04-12&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2021-04-08..2021-04-12&type=Issues) | [@faucct](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Afaucct+updated%3A2021-04-08..2021-04-12&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajtpio+updated%3A2021-04-08..2021-04-12&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2021-04-08..2021-04-12&type=Issues) ## 1.6.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.5.1...724c38ec08c15cf1ed3c2efb2ad5c11f684f2cda)) ### New features added - Add env variable support for port options [#461](https://github.com/jupyter-server/jupyter_server/pull/461) ([@afshin](https://github.com/afshin)) ### Enhancements made - Add support for JUPYTER_TOKEN_FILE [#462](https://github.com/jupyter-server/jupyter_server/pull/462) ([@afshin](https://github.com/afshin)) ### Maintenance and upkeep improvements - Remove unnecessary future imports [#464](https://github.com/jupyter-server/jupyter_server/pull/464) ([@afshin](https://github.com/afshin)) ### Documentation improvements - Add Changelog to Sphinx Docs [#465](https://github.com/jupyter-server/jupyter_server/pull/465) ([@afshin](https://github.com/afshin)) - Update description for kernel restarted in the API docs [#463](https://github.com/jupyter-server/jupyter_server/pull/463) ([@jtpio](https://github.com/jtpio)) - Delete the extra “or” that prevents easy cut-and-paste of URLs. [#460](https://github.com/jupyter-server/jupyter_server/pull/460) ([@jasongrout](https://github.com/jasongrout)) - Add descriptive log for port unavailable and port-retries=0 [#459](https://github.com/jupyter-server/jupyter_server/pull/459) ([@afshin](https://github.com/afshin)) ### Other merged PRs - Add ReadTheDocs config [#468](https://github.com/jupyter-server/jupyter_server/pull/468) ([@jtpio](https://github.com/jtpio)) - Update MappingKM.restart_kernel to accept now kwarg [#404](https://github.com/jupyter-server/jupyter_server/pull/404) ([@vidartf](https://github.com/vidartf)) ### Contributors to this release ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-03-24&to=2021-04-08&type=c)) [@afshin](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aafshin+updated%3A2021-03-24..2021-04-08&type=Issues) | [@codecov-io](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-io+updated%3A2021-03-24..2021-04-08&type=Issues) | [@echarles](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aecharles+updated%3A2021-03-24..2021-04-08&type=Issues) | [@jasongrout](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajasongrout+updated%3A2021-03-24..2021-04-08&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajtpio+updated%3A2021-03-24..2021-04-08&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-03-24..2021-04-08&type=Issues) | [@vidartf](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Avidartf+updated%3A2021-03-24..2021-04-08&type=Issues) ## 1.5.1 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.5.0...c3303cde880ecd1103118b8c7f9e5ebc19f0d1ba)) **Merged pull requests:** - Ensure jupyter config dir exists [#454](https://github.com/jupyter-server/jupyter_server/pull/454) ([@afshin](https://github.com/afshin)) - Allow `pre_save_hook` to cancel save with `HTTPError` [#456](https://github.com/jupyter-server/jupyter_server/pull/456) ([@minrk](https://github.com/minrk)) **Contributors to this release:** ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-03-23&to=2021-03-24&type=c)) [@afshin](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aafshin+updated%3A2021-03-23..2021-03-24&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2021-03-23..2021-03-24&type=Issues) ## 1.5.0 ([Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.4.1...74801f479d7bb89c5afd4c020c52e614cc566da5)) **Merged pull requests:** - Add Styling to the HTML Pages [#452](https://github.com/jupyter-server/jupyter_server/pull/452) ([@afshin](https://github.com/afshin)) - Implement password hashing with `argon2-cffi` [#450](https://github.com/jupyter-server/jupyter_server/pull/450) ([@afshin](https://github.com/afshin)) - Escape user input in handlers flagged during code scans [#449](https://github.com/jupyter-server/jupyter_server/pull/449) ([@kevin-bates](https://github.com/kevin-bates)) - Fix for the terminal shutdown issue [#446](https://github.com/jupyter-server/jupyter_server/pull/446) ([@afshin](https://github.com/afshin)) - Update the branch filter for the CI badge [#445](https://github.com/jupyter-server/jupyter_server/pull/445) ([@jtpio](https://github.com/jtpio)) - Fix for `UnboundLocalError` in shutdown [#444](https://github.com/jupyter-server/jupyter_server/pull/444) ([@afshin](https://github.com/afshin)) - Update CI badge and fix broken link [#443](https://github.com/jupyter-server/jupyter_server/pull/443) ([@blink1073](https://github.com/blink1073)) - Fix syntax typo [#442](https://github.com/jupyter-server/jupyter_server/pull/442) ([@kiendang](https://github.com/kiendang)) - Port terminal culling from Notebook [#438](https://github.com/jupyter-server/jupyter_server/pull/438) ([@kevin-bates](https://github.com/kevin-bates)) - More complex handling of `open_browser` from extension applications [#433](https://github.com/jupyter-server/jupyter_server/pull/433) ([@afshin](https://github.com/afshin)) - Correction in Changelog [#429](https://github.com/jupyter-server/jupyter_server/pull/429) ([@Zsailer](https://github.com/Zsailer)) - Rename translation function alias [#428](https://github.com/jupyter-server/jupyter_server/pull/428) ([@sngyo](https://github.com/sngyo)) **Contributors to this release:** ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-02-22&to=2021-03-23&type=c)) [@afshin](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aafshin+updated%3A2021-02-22..2021-03-23&type=Issues) | [@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-02-22..2021-03-23&type=Issues) | [@codecov-io](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-io+updated%3A2021-02-22..2021-03-23&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajtpio+updated%3A2021-02-22..2021-03-23&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-02-22..2021-03-23&type=Issues) | [@kiendang](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akiendang+updated%3A2021-02-22..2021-03-23&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2021-02-22..2021-03-23&type=Issues) | [@sngyo](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Asngyo+updated%3A2021-02-22..2021-03-23&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-02-22..2021-03-23&type=Issues) ## [1.4.1](https://github.com/jupyter-server/jupyter_server/tree/1.4.1) (2021-02-22) [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.4.0...bc252d33de2f647f98d048dc32888f0a83f005ac) **Merged pull requests:** - Update README.md [#425](https://github.com/jupyter-server/jupyter_server/pull/425) ([@BobinMathew](https://github.com/BobinMathew)) - Solve UnboundLocalError in launch_browser() [#421](https://github.com/jupyter-server/jupyter_server/pull/421) ([@jamesmishra](https://github.com/jamesmishra)) - Add file_to_run to server extension docs [#420](https://github.com/jupyter-server/jupyter_server/pull/420) ([@Zsailer](https://github.com/Zsailer)) - Remove outdated reference to \_jupyter_server_extension_paths in docs [#419](https://github.com/jupyter-server/jupyter_server/pull/419) ([@Zsailer](https://github.com/Zsailer)) **Contributors to this release:** ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-02-18&to=2021-02-22&type=c)) [@jamesmishra](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajamesmishra+updated%3A2021-02-18..2021-02-22&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-02-18..2021-02-22&type=Issues) ## [1.4.0](https://github.com/jupyter-server/jupyter_server/tree/1.4.0) (2021-02-18) [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.3.0...HEAD) **Merged pull requests:** - Add Tests to Distribution [#416](https://github.com/jupyter-server/jupyter_server/pull/416) ([afshin](https://github.com/afshin)) - Enable extensions to control the file_to_run [#415](https://github.com/jupyter-server/jupyter_server/pull/415) ([afshin](https://github.com/afshin)) - add missing template for view.html [#414](https://github.com/jupyter-server/jupyter_server/pull/414) ([minrk](https://github.com/minrk)) - Remove obsoleted asyncio-patch fixture [#412](https://github.com/jupyter-server/jupyter_server/pull/412) ([kevin-bates](https://github.com/kevin-bates)) - Emit deprecation warning on old name [#411](https://github.com/jupyter-server/jupyter_server/pull/411) ([fcollonval](https://github.com/fcollonval)) - Correct logging message position [#410](https://github.com/jupyter-server/jupyter_server/pull/410) ([fcollonval](https://github.com/fcollonval)) - Update 1.3.0 Changelog to include broken 1.2.3 PRs [#408](https://github.com/jupyter-server/jupyter_server/pull/408) ([kevin-bates](https://github.com/kevin-bates)) - \[Gateway\] Track only this server's kernels [#407](https://github.com/jupyter-server/jupyter_server/pull/407) ([kevin-bates](https://github.com/kevin-bates)) - Update manager.py: more descriptive warnings when extensions fail to load [#396](https://github.com/jupyter-server/jupyter_server/pull/396) ([alberti42](https://github.com/alberti42)) ## [1.3.0](https://github.com/jupyter-server/jupyter_server/tree/1.3.0) (2021-02-04) [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.2.2...HEAD) **Merged pull requests (includes those from broken 1.2.3 release):** - Special case ExtensionApp that starts the ServerApp [#401](https://github.com/jupyter-server/jupyter_server/pull/401) ([afshin](https://github.com/afshin)) - only use deprecated notebook_dir config if root_dir is not set [#400](https://github.com/jupyter-server/jupyter_server/pull/400) ([minrk](https://github.com/minrk)) - Use async kernel manager by default [#399](https://github.com/jupyter-server/jupyter_server/pull/399) ([kevin-bates](https://github.com/kevin-bates)) - Revert Session.username default value change [#398](https://github.com/jupyter-server/jupyter_server/pull/398) ([mwakaba2](https://github.com/mwakaba2)) - Re-enable default_url in ExtensionApp [#393](https://github.com/jupyter-server/jupyter_server/pull/393) ([afshin](https://github.com/afshin)) - Enable notebook ContentsManager in jupyter_server [#392](https://github.com/jupyter-server/jupyter_server/pull/392) ([afshin](https://github.com/afshin)) - Use jupyter_server_config.json as config file in the update password api [#390](https://github.com/jupyter-server/jupyter_server/pull/390) ([echarles](https://github.com/echarles)) - Increase culling test idle timeout [#388](https://github.com/jupyter-server/jupyter_server/pull/388) ([kevin-bates](https://github.com/kevin-bates)) - update changelog for 1.2.2 [#387](https://github.com/jupyter-server/jupyter_server/pull/387) ([Zsailer](https://github.com/Zsailer)) ## [1.2.3](https://github.com/jupyter-server/jupyter_server/tree/1.2.3) (2021-01-29) This was a broken release and was yanked from PyPI. [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.2.2...HEAD) **Merged pull requests:** - Re-enable default_url in ExtensionApp [#393](https://github.com/jupyter-server/jupyter_server/pull/393) ([afshin](https://github.com/afshin)) - Enable notebook ContentsManager in jupyter_server [#392](https://github.com/jupyter-server/jupyter_server/pull/392) ([afshin](https://github.com/afshin)) - Use jupyter_server_config.json as config file in the update password api [#390](https://github.com/jupyter-server/jupyter_server/pull/390) ([echarles](https://github.com/echarles)) - Increase culling test idle timeout [#388](https://github.com/jupyter-server/jupyter_server/pull/388) ([kevin-bates](https://github.com/kevin-bates)) - update changelog for 1.2.2 [#387](https://github.com/jupyter-server/jupyter_server/pull/387) ([Zsailer](https://github.com/Zsailer)) ## [1.2.2](https://github.com/jupyter-server/jupyter_server/tree/1.2.2) (2021-01-14) **Merged pull requests:** - Apply missing ensure_async to root session handler methods [#386](https://github.com/jupyter-server/jupyter_server/pull/386) ([kevin-bates](https://github.com/kevin-bates)) - Update changelog to 1.2.1 [#385](https://github.com/jupyter-server/jupyter_server/pull/385) ([Zsailer](https://github.com/Zsailer)) - Fix application exit [#384](https://github.com/jupyter-server/jupyter_server/pull/384) ([afshin](https://github.com/afshin)) - Replace secure_write, is_hidden, exists with jupyter_core's [#382](https://github.com/jupyter-server/jupyter_server/pull/382) ([kevin-bates](https://github.com/kevin-bates)) - Add --autoreload flag [#380](https://github.com/jupyter-server/jupyter_server/pull/380) ([afshin](https://github.com/afshin)) ## [1.2.1](https://github.com/jupyter-server/jupyter_server/tree/1.2.1) (2021-01-08) [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.2.0...1.2.1) **Merged pull requests:** - Enable extensions to set debug and open-browser flags [#379](https://github.com/jupyter-server/jupyter_server/pull/379) ([afshin](https://github.com/afshin)) - Add reconnection to Gateway [#378](https://github.com/jupyter-server/jupyter_server/pull/378) ([oyvsyo](https://github.com/oyvsyo)) ## [1.2.0](https://github.com/jupyter-server/jupyter_server/tree/1.2.0) (2021-01-07) [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.1.4...1.2.0) **Merged pull requests:** - Flip default value for open_browser in extensions [#377](https://github.com/jupyter-server/jupyter_server/pull/377) ([ajbozarth](https://github.com/ajbozarth)) - Improve Handling of the soft limit on open file handles [#376](https://github.com/jupyter-server/jupyter_server/pull/376) ([afshin](https://github.com/afshin)) - Handle open_browser trait in ServerApp and ExtensionApp differently [#375](https://github.com/jupyter-server/jupyter_server/pull/375) ([afshin](https://github.com/afshin)) - Add setting to disable redirect file browser launch [#374](https://github.com/jupyter-server/jupyter_server/pull/374) ([afshin](https://github.com/afshin)) - Make trust handle use ensure_async [#373](https://github.com/jupyter-server/jupyter_server/pull/373) ([vidartf](https://github.com/vidartf)) ## [1.1.4](https://github.com/jupyter-server/jupyter_server/tree/1.1.4) (2021-01-04) [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.1.3...1.1.4) **Merged pull requests:** - Update the link to paths documentation [#371](https://github.com/jupyter-server/jupyter_server/pull/371) ([krassowski](https://github.com/krassowski)) - IPythonHandler -> JupyterHandler [#370](https://github.com/jupyter-server/jupyter_server/pull/370) ([krassowski](https://github.com/krassowski)) - use setuptools find_packages, exclude tests, docs and examples from dist [#368](https://github.com/jupyter-server/jupyter_server/pull/368) ([bollwyvl](https://github.com/bollwyvl)) - Update serverapp.py [#367](https://github.com/jupyter-server/jupyter_server/pull/367) ([michaelaye](https://github.com/michaelaye)) ## [1.1.3](https://github.com/jupyter-server/jupyter_server/tree/1.1.3) (2020-12-23) [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.1.2...1.1.3) **Merged pull requests:** - Culling: ensure last_activity attr exists before use [#365](https://github.com/jupyter-server/jupyter_server/pull/365) ([afshin](https://github.com/afshin)) ## [1.1.2](https://github.com/jupyter-server/jupyter_server/tree/1.1.2) (2020-12-21) [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.0.11...1.1.2) **Merged pull requests:** - Nudge kernel with info request until we receive IOPub messages [#361](https://github.com/jupyter-server/jupyter_server/pull/361) ([SylvainCorlay](https://github.com/SylvainCorlay)) ## [1.1.1](https://github.com/jupyter-server/jupyter_server/tree/1.1.1) (2020-12-16) [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.1.0...1.1.1) **Merged pull requests:** - Fix: await possible async dir_exists method [#363](https://github.com/jupyter-server/jupyter_server/pull/363) ([mwakaba2](https://github.com/mwakaba2)) ## 1.1.0 (2020-12-11) [Full Changelog](https://github.com/jupyter-server/jupyter_server/compare/1.0.10...1.1.0) **Merged pull requests:** - Restore pytest plugin from pytest-jupyter [#360](https://github.com/jupyter-server/jupyter_server/pull/360) ([kevin-bates](https://github.com/kevin-bates)) - Fix upgrade packaging dependencies build step [#354](https://github.com/jupyter-server/jupyter_server/pull/354) ([mwakaba2](https://github.com/mwakaba2)) - Await \_connect and inline read_messages callback to \_connect [#350](https://github.com/jupyter-server/jupyter_server/pull/350) ([ricklamers](https://github.com/ricklamers)) - Update release instructions and dev version [#348](https://github.com/jupyter-server/jupyter_server/pull/348) ([kevin-bates](https://github.com/kevin-bates)) - Fix test_trailing_slash [#346](https://github.com/jupyter-server/jupyter_server/pull/346) ([kevin-bates](https://github.com/kevin-bates)) - Apply security advisory fix to master [#345](https://github.com/jupyter-server/jupyter_server/pull/345) ([kevin-bates](https://github.com/kevin-bates)) - Allow toggling auth for prometheus metrics [#344](https://github.com/jupyter-server/jupyter_server/pull/344) ([yuvipanda](https://github.com/yuvipanda)) - Port Notebook PRs 5565 and 5588 - terminal shell heuristics [#343](https://github.com/jupyter-server/jupyter_server/pull/343) ([kevin-bates](https://github.com/kevin-bates)) - Port gateway updates from notebook (PRs 5317 and 5484) [#341](https://github.com/jupyter-server/jupyter_server/pull/341) ([kevin-bates](https://github.com/kevin-bates)) - add check_origin handler to gateway WebSocketChannelsHandler [#340](https://github.com/jupyter-server/jupyter_server/pull/340) ([ricklamers](https://github.com/ricklamers)) - Remove pytest11 entrypoint and plugin, require tornado 6.1, remove asyncio patch, CI work [#339](https://github.com/jupyter-server/jupyter_server/pull/339) ([bollwyvl](https://github.com/bollwyvl)) - Switch fixtures to use those in pytest-jupyter to avoid collisions [#335](https://github.com/jupyter-server/jupyter_server/pull/335) ([kevin-bates](https://github.com/kevin-bates)) - Enable CodeQL runs on all pushed branches [#333](https://github.com/jupyter-server/jupyter_server/pull/333) ([kevin-bates](https://github.com/kevin-bates)) - Asynchronous Contents API [#324](https://github.com/jupyter-server/jupyter_server/pull/324) ([mwakaba2](https://github.com/mwakaba2)) ## 1.0.6 (2020-11-18) 1.0.6 is a security release, fixing one vulnerability: ### Changed - Fix open redirect vulnerability GHSA-grfj-wjv9-4f9v (CVE-2020-26232) ## 1.0 (2020-9-18) ### Added. - Added a basic, styled `login.html` template. ([220](https://github.com/jupyter/jupyter_server/pull/220), [295](https://github.com/jupyter/jupyter_server/pull/295)) - Added new extension manager API for handling server extensions. ([248](https://github.com/jupyter/jupyter_server/pull/248), [265](https://github.com/jupyter/jupyter_server/pull/265), [275](https://github.com/jupyter/jupyter_server/pull/275), [303](https://github.com/jupyter/jupyter_server/pull/303)) - The favicon and Jupyter logo are now available under jupyter_server's static namespace. ([284](https://github.com/jupyter/jupyter_server/pull/284)) ### Changed. - `load_jupyter_server_extension` should be renamed to `_load_jupyter_server_extension` in server extensions. Server now throws a warning when the old name is used. ([213](https://github.com/jupyter/jupyter_server/pull/213)) - Docs for server extensions now recommend using `authenticated` decorator for handlers. ([219](https://github.com/jupyter/jupyter_server/pull/219)) - `_load_jupyter_server_paths` should be renamed to `_load_jupyter_server_points` in server extensions. ([277](https://github.com/jupyter/jupyter_server/pull/277)) - `static_url_prefix` in ExtensionApps is now a configurable trait. ([289](https://github.com/jupyter/jupyter_server/pull/289)) - `extension_name` trait was removed in favor of `name`. ([232](https://github.com/jupyter/jupyter_server/pull/232)) - Dropped support for Python 3.5. ([296](https://github.com/jupyter/jupyter_server/pull/296)) - Made the `config_dir_name` trait configurable in `ConfigManager`. ([297](https://github.com/jupyter/jupyter_server/pull/297)) ### Removed for now removed features. - Removed ipykernel as a dependency of jupyter_server. ([255](https://github.com/jupyter/jupyter_server/pull/255)) ### Fixed for any bug fixes. - Prevent a re-definition of prometheus metrics if `notebook` package already imports them. ([#210](https://github.com/jupyter/jupyter_server/pull/210)) - Fixed `terminals` REST API unit tests that weren't shutting down properly. ([221](https://github.com/jupyter/jupyter_server/pull/221)) - Fixed jupyter_server on Windows for Python \< 3.7. Added patch to handle subprocess cleanup. ([240](https://github.com/jupyter/jupyter_server/pull/240)) - `base_url` was being duplicated when getting a url path from the `ServerApp`. ([280](https://github.com/jupyter/jupyter_server/pull/280)) - Extension URLs are now properly prefixed with `base_url`. Previously, all `static` paths were not. ([285](https://github.com/jupyter/jupyter_server/pull/285)) - Changed ExtensionApp mixin to inherit from `HasTraits`. This broke in traitlets 5.0 ([294](https://github.com/jupyter/jupyter_server/pull/294)) - Replaces `urlparse` with `url_path_join` to prevent URL squashing issues. ([304](https://github.com/jupyter/jupyter_server/pull/304)) ## \[0.3\] - 2020-4-22 ### Added - ([#191](https://github.com/jupyter/jupyter_server/pull/191)) Async kernel management is now possible using the `AsyncKernelManager` from `jupyter_client` - ([#201](https://github.com/jupyter/jupyter_server/pull/201)) Parameters can now be passed to new terminals created by the `terminals` REST API. ### Changed - ([#196](https://github.com/jupyter/jupyter_server/pull/196)) Documentation was rewritten + refactored to use pydata_sphinx_theme. - ([#174](https://github.com/jupyter/jupyter_server/pull/174)) `ExtensionHandler` was changed to an Mixin class, i.e. `ExtensionHandlerMixin` ### Removed - ([#194](https://github.com/jupyter/jupyter_server/pull/194)) The bundlerextension entry point was removed. ## \[0.2.1\] - 2020-1-10 ### Added - **pytest-plugin** for Jupyter Server. - Allows one to write async/await syntax in tests functions. - Some particularly useful fixtures include: - `serverapp`: a default ServerApp instance that handles setup+teardown. - `configurable_serverapp`: a function that returns a ServerApp instance. - `fetch`: an awaitable function that tests makes requests to the server API - `create_notebook`: a function that writes a notebook to a given temporary file path. ## \[0.2.0\] - 2019-12-19 ### Added - `extension` submodule ([#48](https://github.com/jupyter/jupyter_server/pull/48)) - ExtensionApp - configurable JupyterApp-subclass for server extensions - Most useful for Jupyter frontends, like Notebook, JupyterLab, nteract, voila etc. - Launch with entrypoints - Configure from file or CLI - Add custom templates, static assets, handlers, etc. - Static assets are served behind a `/static/` endpoint. - Run server extensions in "standalone mode" ([#70](https://github.com/jupyter/jupyter_server/pull/70) and [#76](https://github.com/jupyter/jupyter_server/pull/76)) - ExtensionHandler - tornado handlers for extensions. - Finds static assets at `/static/` ### Changed - `jupyter serverextension ` entrypoint has been changed to `jupyter server extension `. - `toggle_jupyter_server` and `validate_jupyter_server` function no longer take a Logger object as an argument. - Changed testing framework from nosetests to pytest ([#152](https://github.com/jupyter/jupyter_server/pull/152)) - Depend on pytest-tornasync extension for handling tornado/asyncio eventloop - Depend on pytest-console-scripts for testing CLI entrypoints - Added Github actions as a testing framework along side Travis and Azure ([#146](https://github.com/jupyter/jupyter_server/pull/146)) ### Removed - Removed the option to update `root_dir` trait in FileContentsManager and MappingKernelManager in ServerApp ([#135](https://github.com/jupyter/jupyter_server/pull/135)) ### Fixed - Synced Jupyter Server with Notebook PRs in batches (ended on 2019-09-27) - [Batch 1](https://github.com/jupyter/jupyter_server/pull/95) - [Batch 2](https://github.com/jupyter/jupyter_server/pull/97) - [Batch 3](https://github.com/jupyter/jupyter_server/pull/98) - [Batch 4](https://github.com/jupyter/jupyter_server/pull/99) - [Batch 5](https://github.com/jupyter/jupyter_server/pull/103) - [Batch 6](https://github.com/jupyter/jupyter_server/pull/104) - [Batch 7](https://github.com/jupyter/jupyter_server/pull/105) - [Batch 8](https://github.com/jupyter/jupyter_server/pull/106) ### Security - Added a "secure_write to function for cookie/token saves ([#77](https://github.com/jupyter/jupyter_server/pull/77)) jupyter-server-jupyter_server-e5c7e2b/CONTRIBUTING.rst000066400000000000000000000117641473126534200227650ustar00rootroot00000000000000General Jupyter contributor guidelines ====================================== If you're reading this section, you're probably interested in contributing to Jupyter. Welcome and thanks for your interest in contributing! Please take a look at the Contributor documentation, familiarize yourself with using the Jupyter Server, and introduce yourself on the mailing list and share what area of the project you are interested in working on. For general documentation about contributing to Jupyter projects, see the `Project Jupyter Contributor Documentation`__. __ https://jupyter.readthedocs.io/en/latest/contributing/content-contributor.html Setting Up a Development Environment ==================================== Installing the Jupyter Server ----------------------------- The development version of the server requires `node `_ and `pip `_. Once you have installed the dependencies mentioned above, use the following steps:: pip install --upgrade pip git clone https://github.com/jupyter/jupyter_server cd jupyter_server pip install -e ".[test]" If you are using a system-wide Python installation and you only want to install the server for you, you can add ``--user`` to the install commands. Once you have done this, you can launch the main branch of Jupyter server from any directory in your system with:: jupyter server Code Styling and Quality Checks ------------------------------- ``jupyter_server`` has adopted automatic code formatting so you shouldn't need to worry too much about your code style. As long as your code is valid, the pre-commit hook should take care of how it should look. ``pre-commit`` and its associated hooks will automatically be installed when you run ``pip install -e ".[test]"`` To install ``pre-commit`` hook manually, run the following:: pre-commit install You can invoke the pre-commit hook by hand at any time with:: pre-commit run which should run any autoformatting on your code and tell you about any errors it couldn't fix automatically. You may also install `black integration `_ into your text editor to format code automatically. If you have already committed files before setting up the pre-commit hook with ``pre-commit install``, you can fix everything up using ``pre-commit run --all-files``. You need to make the fixing commit yourself after that. Some of the hooks only run on CI by default, but you can invoke them by running with the ``--hook-stage manual`` argument. There are three hatch scripts that can be run locally as well: ``hatch run lint:build`` will enforce styling. ``hatch run typing:test`` will run the type checker. Troubleshooting the Installation -------------------------------- If you do not see that your Jupyter Server is not running on dev mode, it's possible that you are running other instances of Jupyter Server. You can try the following steps: 1. Uninstall all instances of the jupyter_server package. These include any installations you made using pip or conda 2. Run ``python -m pip install -e .`` in the jupyter_server repository to install the jupyter_server from there 3. Run ``npm run build`` to make sure the Javascript and CSS are updated and compiled 4. Launch with ``python -m jupyter_server --port 8989``, and check that the browser is pointing to ``localhost:8989`` (rather than the default 8888). You don't necessarily have to launch with port 8989, as long as you use a port that is neither the default nor in use, then it should be fine. 5. Verify the installation with the steps in the previous section. Running Tests ============= Install dependencies:: pip install -e .[test] pip install -e examples/simple # to test the examples To run the Python tests, use:: pytest pytest examples/simple # to test the examples You can also run the tests using ``hatch`` without installing test dependencies in your local environment:: pip install hatch hatch run test:test The command takes any argument that you can give to ``pytest``, e.g.:: hatch run test:test -k name_of_method_to_test You can also drop into a shell in the test environment by running:: hatch -e test shell Building the Docs ================= Install the docs requirements using ``pip``:: pip install .[doc] Once you have installed the required packages, you can build the docs with:: cd docs make html You can also run the tests using ``hatch`` without installing test dependencies in your local environment. pip install hatch hatch run docs:build You can also drop into a shell in the docs environment by running:: hatch -e docs shell After that, the generated HTML files will be available at ``build/html/index.html``. You may view the docs in your browser. Windows users can find ``make.bat`` in the ``docs`` folder. You should also have a look at the `Project Jupyter Documentation Guide`__. __ https://jupyter.readthedocs.io/en/latest/contributing/content-contributor.html jupyter-server-jupyter_server-e5c7e2b/LICENSE000066400000000000000000000030641473126534200213230ustar00rootroot00000000000000BSD 3-Clause License - Copyright (c) 2001-2015, IPython Development Team - Copyright (c) 2015-, Jupyter Development Team All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. jupyter-server-jupyter_server-e5c7e2b/README.md000066400000000000000000000071031473126534200215730ustar00rootroot00000000000000# Jupyter Server [![Build Status](https://github.com/jupyter-server/jupyter_server/actions/workflows/python-tests.yml/badge.svg?query=branch%3Amain++)](https://github.com/jupyter-server/jupyter_server/actions/workflows/python-tests.yml/badge.svg?query=branch%3Amain++) [![Documentation Status](https://readthedocs.org/projects/jupyter-server/badge/?version=latest)](http://jupyter-server.readthedocs.io/en/latest/?badge=latest) The Jupyter Server provides the backend (i.e. the core services, APIs, and REST endpoints) for Jupyter web applications like Jupyter notebook, JupyterLab, and Voila. For more information, read our [documentation here](http://jupyter-server.readthedocs.io/en/latest/?badge=latest). ## Installation and Basic usage To install the latest release locally, make sure you have [pip installed](https://pip.readthedocs.io/en/stable/installing/) and run: ``` pip install jupyter_server ``` Jupyter Server currently supports Python>=3.6 on Linux, OSX and Windows. ### Versioning and Branches If Jupyter Server is a dependency of your project/application, it is important that you pin it to a version that works for your application. Currently, Jupyter Server only has minor and patch versions. Different minor versions likely include API-changes while patch versions do not change API. When a new minor version is released on PyPI, a branch for that version will be created in this repository, and the version of the main branch will be bumped to the next minor version number. That way, the main branch always reflects the latest un-released version. To see the changes between releases, checkout the [CHANGELOG](https://github.com/jupyter/jupyter_server/blob/main/CHANGELOG.md). ## Usage - Running Jupyter Server ### Running in a local installation Launch with: ``` jupyter server ``` ### Testing See [CONTRIBUTING](https://github.com/jupyter-server/jupyter_server/blob/main/CONTRIBUTING.rst#running-tests). ## Contributing If you are interested in contributing to the project, see [`CONTRIBUTING.rst`](CONTRIBUTING.rst). ## Team Meetings and Roadmap - When: Thursdays [8:00am, Pacific time](https://www.thetimezoneconverter.com/?t=8%3A00%20am&tz=San%20Francisco&) - Where: [Jovyan Zoom](https://zoom.us/my/jovyan?pwd=c0JZTHlNdS9Sek9vdzR3aTJ4SzFTQT09) - What: [Meeting notes](https://github.com/jupyter-server/team-compass/issues/45) See our tentative [roadmap here](https://github.com/jupyter/jupyter_server/issues/127). ## About the Jupyter Development Team The Jupyter Development Team is the set of all contributors to the Jupyter project. This includes all of the Jupyter subprojects. The core team that coordinates development on GitHub can be found here: https://github.com/jupyter/. ## Our Copyright Policy Jupyter uses a shared copyright model. Each contributor maintains copyright over their contributions to Jupyter. But, it is important to note that these contributions are typically only changes to the repositories. Thus, the Jupyter source code, in its entirety is not the copyright of any single person or institution. Instead, it is the collective copyright of the entire Jupyter Development Team. If individual contributors want to maintain a record of what changes/contributions they have specific copyright on, they should indicate their copyright in the commit message of the change, when they commit the change to one of the Jupyter repositories. With this in mind, the following banner should be used in any source code file to indicate the copyright and license terms: ``` # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. ``` jupyter-server-jupyter_server-e5c7e2b/RELEASE.md000066400000000000000000000020311473126534200217110ustar00rootroot00000000000000# Making a Jupyter Server Release ## Using `jupyter_releaser` 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). Note that we must use manual versions since Jupyter Releaser does not yet support "next" or "patch" when dev versions are used. ## Manual Release To create a manual release, perform the following steps: ### Set up ```bash pip install hatch twine build git pull origin $(git branch --show-current) git clean -dffx ``` ### Update the version and apply the tag ```bash echo "Enter new version" read new_version hatch version ${new_version} git tag -a ${new_version} -m "Release ${new_version}" ``` ### Build the artifacts ```bash rm -rf dist python -m build . ``` ### Update the version back to dev ```bash echo "Enter dev version" read dev_version hatch version ${dev_version} git push origin $(git branch --show-current) ``` ### Publish the artifacts to pypi ```bash twine check dist/* twine upload dist/* ``` jupyter-server-jupyter_server-e5c7e2b/docs/000077500000000000000000000000001473126534200212435ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/Makefile000066400000000000000000000170741473126534200227140ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage spelling gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " applehelp to make an Apple Help Book" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" @echo " spelling to spell check the documentation" clean: rm -rf $(BUILDDIR)/* rm -rf source/config.rst html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/JupyterNotebook.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/JupyterNotebook.qhc" applehelp: $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp @echo @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." @echo "N.B. You won't be able to view it unless you put it in" \ "~/Library/Documentation/Help or install it in your application" \ "bundle." devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/JupyterNotebook" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/JupyterNotebook" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." spelling: $(SPHINXBUILD) -b spelling $(ALLSPHINXOPTS) $(BUILDDIR)/spelling @echo "Spell check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/spelling/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." coverage: $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage @echo "Testing of coverage in the sources finished, look at the " \ "results in $(BUILDDIR)/coverage/python.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." jupyter-server-jupyter_server-e5c7e2b/docs/README.md000066400000000000000000000002451473126534200225230ustar00rootroot00000000000000# Jupyter Server Docs Sources Read [this page](https://jupyter-server.readthedocs.io/en/latest/contributors/contributing.html#building-the-docs) to build the docs. jupyter-server-jupyter_server-e5c7e2b/docs/make.bat000066400000000000000000000161471473126534200226610ustar00rootroot00000000000000@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source set I18NSPHINXOPTS=%SPHINXOPTS% source if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled echo. coverage to run coverage check of the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) REM Check if sphinx-build is available and fallback to Python version if any %SPHINXBUILD% 2> nul if errorlevel 9009 goto sphinx_python goto sphinx_ok :sphinx_python set SPHINXBUILD=python -m sphinx.__init__ %SPHINXBUILD% 2> nul if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) :sphinx_ok if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\JupyterNotebook.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\JupyterNotebook.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdf" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdfja" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf-ja cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) if "%1" == "coverage" ( %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage if errorlevel 1 exit /b 1 echo. echo.Testing of coverage in the sources finished, look at the ^ results in %BUILDDIR%/coverage/python.txt. goto end ) if "%1" == "xml" ( %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml if errorlevel 1 exit /b 1 echo. echo.Build finished. The XML files are in %BUILDDIR%/xml. goto end ) if "%1" == "pseudoxml" ( %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml if errorlevel 1 exit /b 1 echo. echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. goto end ) :end jupyter-server-jupyter_server-e5c7e2b/docs/source/000077500000000000000000000000001473126534200225435ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/source/_static/000077500000000000000000000000001473126534200241715ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/source/_static/.gitkeep000066400000000000000000000000001473126534200256100ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/source/_static/jupyter_server_logo.svg000066400000000000000000000243201473126534200310230ustar00rootroot00000000000000 image/svg+xml logo.svg logo.svg Created using Figma 0.90 server jupyter-server-jupyter_server-e5c7e2b/docs/source/api/000077500000000000000000000000001473126534200233145ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.auth.rst000066400000000000000000000016131473126534200300770ustar00rootroot00000000000000jupyter\_server.auth package ============================ Submodules ---------- .. automodule:: jupyter_server.auth.authorizer :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.auth.decorator :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.auth.identity :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.auth.login :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.auth.logout :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.auth.security :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.auth.utils :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.auth :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.base.rst000066400000000000000000000011511473126534200300450ustar00rootroot00000000000000jupyter\_server.base package ============================ Submodules ---------- .. automodule:: jupyter_server.base.call_context :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.base.handlers :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.base.websocket :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.base.zmqhandlers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.base :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.extension.rst000066400000000000000000000015331473126534200311530ustar00rootroot00000000000000jupyter\_server.extension package ================================= Submodules ---------- .. automodule:: jupyter_server.extension.application :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.extension.config :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.extension.handler :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.extension.manager :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.extension.serverextension :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.extension.utils :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.extension :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.files.rst000066400000000000000000000004661473126534200302450ustar00rootroot00000000000000jupyter\_server.files package ============================= Submodules ---------- .. automodule:: jupyter_server.files.handlers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.files :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.gateway.rst000066400000000000000000000011771473126534200306040ustar00rootroot00000000000000jupyter\_server.gateway package =============================== Submodules ---------- .. automodule:: jupyter_server.gateway.connections :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.gateway.gateway_client :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.gateway.handlers :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.gateway.managers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.gateway :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.i18n.rst000066400000000000000000000002661473126534200277200ustar00rootroot00000000000000jupyter\_server.i18n package ============================ Module contents --------------- .. automodule:: jupyter_server.i18n :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.kernelspecs.rst000066400000000000000000000005161473126534200314550ustar00rootroot00000000000000jupyter\_server.kernelspecs package =================================== Submodules ---------- .. automodule:: jupyter_server.kernelspecs.handlers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.kernelspecs :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.nbconvert.rst000066400000000000000000000005061473126534200311360ustar00rootroot00000000000000jupyter\_server.nbconvert package ================================= Submodules ---------- .. automodule:: jupyter_server.nbconvert.handlers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.nbconvert :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.prometheus.rst000066400000000000000000000006711473126534200313340ustar00rootroot00000000000000jupyter\_server.prometheus package ================================== Submodules ---------- .. automodule:: jupyter_server.prometheus.log_functions :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.prometheus.metrics :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.prometheus :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.rst000066400000000000000000000021251473126534200271360ustar00rootroot00000000000000jupyter\_server package ======================= Subpackages ----------- .. toctree:: :maxdepth: 4 jupyter_server.auth jupyter_server.base jupyter_server.extension jupyter_server.files jupyter_server.gateway jupyter_server.i18n jupyter_server.kernelspecs jupyter_server.nbconvert jupyter_server.prometheus jupyter_server.services jupyter_server.view Submodules ---------- .. automodule:: jupyter_server.config_manager :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.log :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.serverapp :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.traittypes :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.transutils :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.utils :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.api.rst000066400000000000000000000005221473126534200315270ustar00rootroot00000000000000jupyter\_server.services.api package ==================================== Submodules ---------- .. automodule:: jupyter_server.services.api.handlers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services.api :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.config.rst000066400000000000000000000007151473126534200322270ustar00rootroot00000000000000jupyter\_server.services.config package ======================================= Submodules ---------- .. automodule:: jupyter_server.services.config.handlers :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.config.manager :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services.config :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.contents.rst000066400000000000000000000020441473126534200326140ustar00rootroot00000000000000jupyter\_server.services.contents package ========================================= Submodules ---------- .. automodule:: jupyter_server.services.contents.checkpoints :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.contents.filecheckpoints :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.contents.fileio :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.contents.filemanager :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.contents.handlers :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.contents.largefilemanager :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.contents.manager :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services.contents :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.events.rst000066400000000000000000000005361473126534200322670ustar00rootroot00000000000000jupyter\_server.services.events package ======================================= Submodules ---------- .. automodule:: jupyter_server.services.events.handlers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services.events :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.kernels.connection.rst000066400000000000000000000011751473126534200345640ustar00rootroot00000000000000jupyter\_server.services.kernels.connection package =================================================== Submodules ---------- .. automodule:: jupyter_server.services.kernels.connection.abc :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.kernels.connection.base :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.kernels.connection.channels :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services.kernels.connection :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.kernels.rst000066400000000000000000000012601473126534200324210ustar00rootroot00000000000000jupyter\_server.services.kernels package ======================================== Subpackages ----------- .. toctree:: :maxdepth: 4 jupyter_server.services.kernels.connection Submodules ---------- .. automodule:: jupyter_server.services.kernels.handlers :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.kernels.kernelmanager :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.kernels.websocket :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services.kernels :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.kernelspecs.rst000066400000000000000000000005621473126534200333000ustar00rootroot00000000000000jupyter\_server.services.kernelspecs package ============================================ Submodules ---------- .. automodule:: jupyter_server.services.kernelspecs.handlers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services.kernelspecs :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.nbconvert.rst000066400000000000000000000005521473126534200327610ustar00rootroot00000000000000jupyter\_server.services.nbconvert package ========================================== Submodules ---------- .. automodule:: jupyter_server.services.nbconvert.handlers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services.nbconvert :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.rst000066400000000000000000000012701473126534200307600ustar00rootroot00000000000000jupyter\_server.services package ================================ Subpackages ----------- .. toctree:: :maxdepth: 4 jupyter_server.services.api jupyter_server.services.config jupyter_server.services.contents jupyter_server.services.events jupyter_server.services.kernels jupyter_server.services.kernelspecs jupyter_server.services.nbconvert jupyter_server.services.security jupyter_server.services.sessions Submodules ---------- .. automodule:: jupyter_server.services.shutdown :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.security.rst000066400000000000000000000005461473126534200326330ustar00rootroot00000000000000jupyter\_server.services.security package ========================================= Submodules ---------- .. automodule:: jupyter_server.services.security.handlers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services.security :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.services.sessions.rst000066400000000000000000000007361473126534200326330ustar00rootroot00000000000000jupyter\_server.services.sessions package ========================================= Submodules ---------- .. automodule:: jupyter_server.services.sessions.handlers :members: :undoc-members: :show-inheritance: .. automodule:: jupyter_server.services.sessions.sessionmanager :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.services.sessions :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/jupyter_server.view.rst000066400000000000000000000004621473126534200301110ustar00rootroot00000000000000jupyter\_server.view package ============================ Submodules ---------- .. automodule:: jupyter_server.view.handlers :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: jupyter_server.view :members: :undoc-members: :show-inheritance: jupyter-server-jupyter_server-e5c7e2b/docs/source/api/modules.rst000066400000000000000000000001171473126534200255150ustar00rootroot00000000000000jupyter_server ============== .. toctree:: :maxdepth: 4 jupyter_server jupyter-server-jupyter_server-e5c7e2b/docs/source/conf.py000066400000000000000000000313101473126534200240400ustar00rootroot00000000000000# Jupyter Server documentation build configuration file, created by # sphinx-quickstart on Mon Apr 13 09:51:11 2015. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import os.path as osp import shutil import sys HERE = osp.abspath(osp.dirname(__file__)) sys.path.insert(0, osp.join(HERE, "..", "")) from jupyter_server._version import version_info # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "myst_parser", "sphinx.ext.autodoc", "sphinx.ext.doctest", "sphinx.ext.intersphinx", "sphinx.ext.autosummary", "sphinx.ext.mathjax", "sphinx.ext.napoleon", "IPython.sphinxext.ipython_console_highlighting", "sphinxcontrib_github_alt", "sphinxcontrib.openapi", "sphinxemoji.sphinxemoji", "sphinx_autodoc_typehints", ] try: import enchant # type:ignore[import-not-found] extensions += ["sphinxcontrib.spelling"] except ImportError: pass myst_enable_extensions = ["html_image"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] source_suffix = [".rst", ".ipynb"] # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "Jupyter Server" copyright = "2020, Jupyter Team, https://jupyter.org" # noqa: A001 author = "The Jupyter Team" # ghissue config github_project_url = "https://github.com/jupyter-server/jupyter_server" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = f"{version_info[0]}.{version_info[1]}" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all # documents. default_role = "literal" # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "default" # highlight_language = 'python3' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # # Add custom note for each doc page # rst_prolog = "" # rst_prolog += """ # .. important:: # This documentation covers Jupyter Server, an **early developer preview**, # and is not suitable for general usage yet. Features and implementation are # subject to change. # For production use cases, please use the stable notebook server in the # `Jupyter Notebook repo `_ # and `Jupyter Notebook documentation `_. # """ # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' html_theme = "pydata_sphinx_theme" html_logo = "_static/jupyter_server_logo.svg" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { "icon_links": [ { "name": "GitHub", "url": "https://github.com/jupyter-server/jupyter_server", "icon": "fab fa-github-square", } ], "navigation_with_keys": False, "use_edit_page_button": True, } # Output for github to be used in links html_context = { "github_user": "jupyter-server", # Username "github_repo": "jupyter_server", # Repo name "github_version": "main", # Version "doc_path": "docs/source/", # Path in the checkout to the docs root } # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # NOTE: Sphinx's 'make html' builder will throw a warning about an unfound # _static directory. Do not remove or comment out html_static_path # since it is needed to properly generate _static in the build directory html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' # html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value # html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = "JupyterServerdoc" # -- Options for LaTeX output --------------------------------------------- # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ( master_doc, "JupyterServer.tex", "Jupyter Server Documentation", "https://jupyter.org", "manual", ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [(master_doc, "jupyterserver", "Jupyter Server Documentation", [author], 1)] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for link checks ---------------------------------------------- linkcheck_ignore = [r"http://127\.0\.0\.1/*"] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "JupyterServer", "Jupyter Server Documentation", author, "JupyterServer", "One line description of project.", "Miscellaneous", ), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False intersphinx_mapping = { "python": ("https://docs.python.org/", None), "ipython": ("https://ipython.readthedocs.io/en/stable/", None), "nbconvert": ("https://nbconvert.readthedocs.io/en/stable/", None), "nbformat": ("https://nbformat.readthedocs.io/en/stable/", None), "jupyter_core": ("https://jupyter-core.readthedocs.io/en/stable/", None), "tornado": ("https://www.tornadoweb.org/en/stable/", None), "traitlets": ("https://traitlets.readthedocs.io/en/stable/", None), } spelling_lang = "en_US" spelling_word_list_filename = "spelling_wordlist.txt" # import before any doc is built, so _ is guaranteed to be injected import jupyter_server.transutils CONFIG_HEADER = """\ .. _other-full-config: Config file and command line options ==================================== The Jupyter Server can be run with a variety of command line arguments. A list of available options can be found below in the :ref:`options section `. Defaults for these options can also be set by creating a file named ``jupyter_server_config.py`` in your Jupyter folder. The Jupyter folder is in your home directory, ``~/.jupyter``. To create a ``jupyter_server_config.py`` file, with all the defaults commented out, you can use the following command line:: $ jupyter server --generate-config .. _options: Options ------- This list of options can be generated by running the following and hitting enter:: $ jupyter server --help-all """ def setup(app): dest = osp.join(HERE, "other", "changelog.md") shutil.copy(osp.join(HERE, "..", "..", "CHANGELOG.md"), dest) # Generate full-config docs. from jupyter_server.serverapp import ServerApp destination = os.path.join(HERE, "other/full-config.rst") with open(destination, "w") as f: f.write(CONFIG_HEADER) f.write(ServerApp().document_config_options()) jupyter-server-jupyter_server-e5c7e2b/docs/source/contributors/000077500000000000000000000000001473126534200253005ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/source/contributors/contributing.rst000066400000000000000000000000721473126534200305400ustar00rootroot00000000000000.. highlight:: sh .. include:: ../../../CONTRIBUTING.rst jupyter-server-jupyter_server-e5c7e2b/docs/source/contributors/index.rst000066400000000000000000000004251473126534200271420ustar00rootroot00000000000000Documentation for Contributors ------------------------------ These pages target people who are interested in contributing directly to the Jupyter Server Project. .. toctree:: :caption: Contributors :maxdepth: 1 :name: contributors team-meetings contributing jupyter-server-jupyter_server-e5c7e2b/docs/source/contributors/team-meetings.rst000066400000000000000000000023741473126534200305770ustar00rootroot00000000000000.. _contributors-team-meetings-roadmap-calendar: Team Meetings, Road Map and Calendar ==================================== Many of the lead Jupyter Server developers meet weekly over Zoom. These meetings are open to everyone. To see when the next meeting is happening and how to attend, watch this Github issue: https://github.com/jupyter-server/team-compass/issues/15 Meeting Notes ------------- - `2022 `_ - `2021 `_ - `2019-2020 `_ Roadmap ------- Also check out Jupyter Server's roadmap where we track future plans for Jupyter Server: `Jupyter Server 2.0 Discussion `_ `Archived roadmap `_ Jupyter Calendar ---------------- .. raw:: html jupyter-server-jupyter_server-e5c7e2b/docs/source/developers/000077500000000000000000000000001473126534200247135ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/source/developers/architecture.rst000066400000000000000000000207621473126534200301360ustar00rootroot00000000000000.. _architecture: Architecture Diagrams ===================== This page describes the Jupyter Server architecture and the main workflows. This information is useful for developers who want to understand how Jupyter Server components are connected and how the principal workflows look like. To make changes for these diagrams, use `the Draw.io `_ open source tool to edit the png file. Jupyter Server Architecture --------------------------- The Jupyter Server system can be seen in the figure below: .. image:: ../images/jupyter-server-architecture.drawio.png :alt: Jupyter Server Architecture :width: 100% :align: center Jupyter Server contains the following components: - **ServerApp** is the main Tornado-based application which connects all components together. - **Config Manager** initializes configuration for the ServerApp. You can define custom classes for the Jupyter Server managers using this config and change ServerApp settings. Follow :ref:`the Config File Guide ` to learn about configuration settings and how to build custom config. - **Custom Extensions** allow you to create the custom Server's REST API endpoints. Follow :ref:`the Extension Guide ` to know more about extending ServerApp with extra request handlers. - **Gateway Server** is a web server that, when configured, provides access to Jupyter kernels running on other hosts. There are different ways to create a gateway server. If your ServerApp needs to communicate with remote kernels residing within resource-managed clusters, you can use `Enterprise Gateway `_, otherwise, you can use `Kernel Gateway `_, where kernels run locally to the gateway server. - **Contents Manager and File Contents Manager** are responsible for serving Notebook on the file system. Session Manager uses Contents Manager to receive kernel path. Follow :ref:`the Contents API guide ` to learn about Contents Manager. - **Session Manager** processes users' Sessions. When a user starts a new kernel, Session Manager starts a process to provision kernel for the user and generates a new Session ID. Each opened Notebook has a separate Session, but different Notebook kernels can use the same Session. That is useful if the user wants to share data across various opened Notebooks. Session Manager uses SQLite3 database to store the Session information. The database is stored in memory by default, but can be configured to save to disk. - **Mapping Kernel Manager** is responsible for managing the lifecycles of the kernels running within the ServerApp. It starts a new kernel for a user's Session and facilitates interrupt, restart, and shutdown operations against the kernel. - **Jupyter Client** library is used by Jupyter Server to work with the Notebook kernels. - **Kernel Manager** manages a single kernel for the Notebook. To know more about Kernel Manager, follow `the Jupyter Client APIs documentation `_. - **Kernel Spec Manager** parses files with JSON specification for a kernels, and provides a list of available kernel configurations. To learn about Kernel Spec Manager, check `the Jupyter Client guide `_. Create Session Workflow ----------------------- The create Session workflow can be seen in the figure below: .. image:: ../images/session-create.drawio.png :alt: Create Session Workflow :width: 90% :align: center When a user starts a new kernel, the following steps occur: #. The Notebook client sends |create_session|_ request to Jupyter Server. This request has all necessary data, such as Notebook name, type, path, and kernel name. #. **Session Manager** asks **Contents Manager** for the kernel file system path based on the input data. #. **Session Manager** sends kernel path to **Mapping Kernel Manager**. #. **Mapping Kernel Manager** starts the kernel create process by using **Multi Kernel Manager** and **Kernel Manager**. You can learn more about **Multi Kernel Manager** in `the Jupyter Client APIs `_. #. **Kernel Manager** uses the provisioner layer to launch a new kernel. #. **Kernel Provisioner** is responsible for launching kernels based on the kernel specification. If the kernel specification doesn't define a provisioner, it uses `Local Provisioner `_ to launch the kernel. You can use `Kernel Provisioner Base `_ and `Kernel Provisioner Factory `_ to create custom provisioners. #. **Kernel Spec Manager** gets the kernel specification from the JSON file. The specification is located in ``kernel.json`` file. #. Once **Kernel Provisioner** launches the kernel, **Kernel Manager** generates the new kernel ID for **Session Manager**. #. **Session Manager** saves the new Session data to the SQLite3 database (Session ID, Notebook path, Notebook name, Notebook type, and kernel ID). #. Notebook client receives the created Session data. .. _create_session: https://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyter_server/master/jupyter_server/services/api/api.yaml#/sessions/post_api_sessions .. |create_session| replace:: the *POST /api/sessions* Delete Session Workflow ----------------------- The delete Session workflow can be seen in the figure below: .. image:: ../images/session-delete.drawio.png :alt: Delete Session Workflow :width: 80% :align: center When a user stops a kernel, the following steps occur: #. The Notebook client sends |delete_session|_ request to Jupyter Server. This request has the Session ID that kernel is currently using. #. **Session Manager** gets the Session data from the SQLite3 database and sends the kernel ID to **Mapping Kernel Manager**. #. **Mapping Kernel Manager** starts the kernel shutdown process by using **Multi Kernel Manager** and **Kernel Manager**. #. **Kernel Manager** determines the mode of interrupt from the **Kernel Spec Manager**. It supports ``Signal`` and ``Message`` interrupt modes. By default, the ``Signal`` interrupt mode is being used. - When the interrupt mode is ``Signal``, the **Kernel Provisioner** interrupts the kernel with the ``SIGINT`` operating system signal (although other provisioner implementations may use a different approach). - When interrupt mode is ``Message``, Session sends the `"interrupt_request" `_ message on the control channel. #. After interrupting kernel, Session sends the `"shutdown_request" `_ message on the control channel. #. **Kernel Manager** waits for the kernel shutdown. After the timeout, and if it detects the kernel process is still running, the **Kernel Manager** terminates the kernel sending a ``SIGTERM`` operating system signal (or provisioner equivalent). If it finds the kernel process has not terminated, the **Kernel Manager** will follow up with a ``SIGKILL`` operating system signal (or provisioner equivalent) to ensure the kernel's termination. #. **Kernel Manager** cleans up the kernel resources. It removes kernel's interprocess communication ports, closes control socket, and releases Shell, IOPub, StdIn, Control, and Heartbeat ports. #. When shutdown is finished, **Session Manager** deletes the Session data from the SQLite3 database and responses 204 status code to the Notebook client. .. _delete_session: https://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyter_server/master/jupyter_server/services/api/api.yaml#/sessions/delete_api_sessions__session_ .. |delete_session| replace:: the *DELETE /api/sessions/{session_id}* jupyter-server-jupyter_server-e5c7e2b/docs/source/developers/contents.rst000066400000000000000000000313471473126534200273120ustar00rootroot00000000000000.. _contents_api: Contents API ============ .. currentmodule:: jupyter_server.services.contents The Jupyter Notebook web application provides a graphical interface for creating, opening, renaming, and deleting files in a virtual filesystem. The :class:`~manager.ContentsManager` class defines an abstract API for translating these interactions into operations on a particular storage medium. The default implementation, :class:`~filemanager.FileContentsManager`, uses the local filesystem of the server for storage and straightforwardly serializes notebooks into JSON. Users can override these behaviors by supplying custom subclasses of ContentsManager. This section describes the interface implemented by ContentsManager subclasses. We refer to this interface as the **Contents API**. Data Model ---------- .. currentmodule:: jupyter_server.services.contents.manager Filesystem Entities ~~~~~~~~~~~~~~~~~~~ .. _notebook models: ContentsManager methods represent virtual filesystem entities as dictionaries, which we refer to as **models**. Models may contain the following entries: +--------------------+------------+-------------------------------+ | Key | Type | Info | +====================+============+===============================+ | **name** | unicode | Basename of the entity. | +--------------------+------------+-------------------------------+ | **path** | unicode | Full | | | | (:ref:`API-style`) | | | | path to the entity. | +--------------------+------------+-------------------------------+ | **type** | unicode | The entity type. One of | | | | ``"notebook"``, ``"file"`` or | | | | ``"directory"``. | +--------------------+------------+-------------------------------+ | **created** | datetime | Creation date of the entity. | +--------------------+------------+-------------------------------+ | **last_modified** | datetime | Last modified date of the | | | | entity. | +--------------------+------------+-------------------------------+ | **content** | variable | The "content" of the entity. | | | | (:ref:`See | | | | Below`) | +--------------------+------------+-------------------------------+ | **mimetype** | unicode or | The mimetype of ``content``, | | | ``None`` | if any. (:ref:`See | | | | Below`) | +--------------------+------------+-------------------------------+ | **format** | unicode or | The format of ``content``, | | | ``None`` | if any. (:ref:`See | | | | Below`) | +--------------------+------------+-------------------------------+ | [optional] | | | | **hash** | unicode or | The hash of the contents. | | | ``None`` | It cannot be null if | | | | ``hash_algorithm`` is | | | | defined. | +--------------------+------------+-------------------------------+ | [optional] | | | | **hash_algorithm** | unicode or | The algorithm used to compute | | | ``None`` | hash value. | | | | It cannot be null | | | | if ``hash`` is defined. | +--------------------+------------+-------------------------------+ .. _modelcontent: Certain model fields vary in structure depending on the ``type`` field of the model. There are three model types: **notebook**, **file**, and **directory**. - ``notebook`` models - The ``format`` field is always ``"json"``. - The ``mimetype`` field is always ``None``. - The ``content`` field contains a :class:`nbformat.notebooknode.NotebookNode` representing the .ipynb file represented by the model. See the `NBFormat`_ documentation for a full description. - The ``hash`` field a hexdigest string of the hash value of the file. If ``ContentManager.get`` not support hash, it should always be ``None``. - ``hash_algorithm`` is the algorithm used to compute the hash value. - ``file`` models - The ``format`` field is either ``"text"`` or ``"base64"``. - The ``mimetype`` field is ``text/plain`` for text-format models and ``application/octet-stream`` for base64-format models. - The ``content`` field is always of type ``unicode``. For text-format file models, ``content`` simply contains the file's bytes after decoding as UTF-8. Non-text (``base64``) files are read as bytes, base64 encoded, and then decoded as UTF-8. - The ``hash`` field a hexdigest string of the hash value of the file. If ``ContentManager.get`` not support hash, it should always be ``None``. - ``hash_algorithm`` is the algorithm used to compute the hash value. - ``directory`` models - The ``format`` field is always ``"json"``. - The ``mimetype`` field is always ``None``. - The ``content`` field contains a list of :ref:`content-free` models representing the entities in the directory. - The ``hash`` field is always ``None``. .. note:: .. _contentfree: In certain circumstances, we don't need the full content of an entity to complete a Contents API request. In such cases, we omit the ``mimetype``, ``content``, and ``format`` keys from the model. This most commonly occurs when listing a directory, in which circumstance we represent files within the directory as content-less models to avoid having to recursively traverse and serialize the entire filesystem. **Sample Models** .. code-block:: python # Notebook Model with Content and Hash { "content": { "metadata": {}, "nbformat": 4, "nbformat_minor": 0, "cells": [ { "cell_type": "markdown", "metadata": {}, "source": "Some **Markdown**", }, ], }, "created": datetime(2015, 7, 25, 19, 50, 19, 19865), "format": "json", "last_modified": datetime(2015, 7, 25, 19, 50, 19, 19865), "mimetype": None, "name": "a.ipynb", "path": "foo/a.ipynb", "type": "notebook", "writable": True, "hash": "f5e43a0b1c2e7836ab3b4d6b1c35c19e2558688de15a6a14e137a59e4715d34b", "hash_algorithm": "sha256", } # Notebook Model without Content { "content": None, "created": datetime.datetime(2015, 7, 25, 20, 17, 33, 271931), "format": None, "last_modified": datetime.datetime(2015, 7, 25, 20, 17, 33, 271931), "mimetype": None, "name": "a.ipynb", "path": "foo/a.ipynb", "type": "notebook", "writable": True, } API Paths ~~~~~~~~~ .. _apipaths: ContentsManager methods represent the locations of filesystem resources as **API-style paths**. Such paths are interpreted as relative to the root directory of the notebook server. For compatibility across systems, the following guarantees are made: * Paths are always ``unicode``, not ``bytes``. * Paths are not URL-escaped. * Paths are always forward-slash (/) delimited, even on Windows. * Leading and trailing slashes are stripped. For example, ``/foo/bar/buzz/`` becomes ``foo/bar/buzz``. * The empty string (``""``) represents the root directory. Writing a Custom ContentsManager -------------------------------- The default ContentsManager is designed for users running the notebook as an application on a personal computer. It stores notebooks as .ipynb files on the local filesystem, and it maps files and directories in the Notebook UI to files and directories on disk. It is possible to override how notebooks are stored by implementing your own custom subclass of ``ContentsManager``. For example, if you deploy the notebook in a context where you don't trust or don't have access to the filesystem of the notebook server, it's possible to write your own ContentsManager that stores notebooks and files in a database. Required Methods ~~~~~~~~~~~~~~~~ A minimal complete implementation of a custom :class:`~manager.ContentsManager` must implement the following methods: .. autosummary:: ContentsManager.get ContentsManager.save ContentsManager.delete_file ContentsManager.rename_file ContentsManager.file_exists ContentsManager.dir_exists ContentsManager.is_hidden You may be required to specify a Checkpoints object, as the default one, ``FileCheckpoints``, could be incompatible with your custom ContentsManager. Customizing Checkpoints ----------------------- .. currentmodule:: jupyter_server.services.contents.checkpoints Customized Checkpoint definitions allows behavior to be altered and extended. The ``Checkpoints`` and ``GenericCheckpointsMixin`` classes (from :mod:`jupyter_server.services.contents.checkpoints`) have reusable code and are intended to be used together, but require the following methods to be implemented. .. autosummary:: Checkpoints.rename_checkpoint Checkpoints.list_checkpoints Checkpoints.delete_checkpoint GenericCheckpointsMixin.create_file_checkpoint GenericCheckpointsMixin.create_notebook_checkpoint GenericCheckpointsMixin.get_file_checkpoint GenericCheckpointsMixin.get_notebook_checkpoint No-op example ~~~~~~~~~~~~~ Here is an example of a no-op checkpoints object - note the mixin comes first. The docstrings indicate what each method should do or return for a more complete implementation. .. code-block:: python class NoOpCheckpoints(GenericCheckpointsMixin, Checkpoints): """requires the following methods:""" def create_file_checkpoint(self, content, format, path): """-> checkpoint model""" def create_notebook_checkpoint(self, nb, path): """-> checkpoint model""" def get_file_checkpoint(self, checkpoint_id, path): """-> {'type': 'file', 'content': , 'format': {'text', 'base64'}}""" def get_notebook_checkpoint(self, checkpoint_id, path): """-> {'type': 'notebook', 'content': }""" def delete_checkpoint(self, checkpoint_id, path): """deletes a checkpoint for a file""" def list_checkpoints(self, path): """returns a list of checkpoint models for a given file, default just does one per file """ return [] def rename_checkpoint(self, checkpoint_id, old_path, new_path): """renames checkpoint from old path to new path""" See ``GenericFileCheckpoints`` in :mod:`notebook.services.contents.filecheckpoints` for a more complete example. Testing ------- .. currentmodule:: jupyter_server.services.contents.tests :mod:`jupyter_server.services.contents.tests` includes several test suites written against the abstract Contents API. This means that an excellent way to test a new ContentsManager subclass is to subclass our tests to make them use your ContentsManager. .. note:: PGContents_ is an example of a complete implementation of a custom ``ContentsManager``. It stores notebooks and files in PostgreSQL_ and encodes directories as SQL relations. PGContents also provides an example of how to reuse the notebook's tests. .. _NBFormat: https://nbformat.readthedocs.io/en/latest/index.html .. _PGContents: https://github.com/quantopian/pgcontents .. _PostgreSQL: https://www.postgresql.org/ Asynchronous Support -------------------- An asynchronous version of the Contents API is available to run slow IO processes concurrently. - :class:`~manager.AsyncContentsManager` - :class:`~filemanager.AsyncFileContentsManager` - :class:`~largefilemanager.AsyncLargeFileManager` - :class:`~checkpoints.AsyncCheckpoints` - :class:`~checkpoints.AsyncGenericCheckpointsMixin` .. note:: .. _asynccontents: In most cases, the non-asynchronous Contents API is performant for local filesystems. However, if the Jupyter Notebook web application is interacting with a high-latent virtual filesystem, you may see performance gains by using the asynchronous version. For example, if you're experiencing terminal lag in the web application due to the slow and blocking file operations, the asynchronous version can reduce the lag. Before opting in, comparing both non-async and async options' performances is recommended. jupyter-server-jupyter_server-e5c7e2b/docs/source/developers/dependency.rst000066400000000000000000000014731473126534200275700ustar00rootroot00000000000000Depending on Jupyter Server =========================== If your project depends directly on Jupyter Server, be sure to watch Jupyter Server's ChangeLog and pin your project to a version that works for your application. Major releases represent possible backwards-compatibility breaking API changes or features. When a new major version in released on PyPI, a branch for that version will be created in this repository, and the version of the master branch will be bumped to the next major version number. That way, the master branch always reflects the latest un-released version. To install the latest patch of a given version: .. code-block:: console > pip install jupyter_server --upgrade To pin your jupyter_server install to a specific version: .. code-block:: console > pip install jupyter_server==1.0.0 jupyter-server-jupyter_server-e5c7e2b/docs/source/developers/extensions.rst000066400000000000000000000502161473126534200276500ustar00rootroot00000000000000.. _extensions: ================= Server Extensions ================= A Jupyter Server extension is typically a module or package that extends to Server’s REST API/endpoints—i.e. adds extra request handlers to Server’s Tornado Web Application. For examples of jupyter server extensions, see the :ref:`homepage `. To get started writing your own extension, see the simple examples in the `examples folder `_ in the GitHub jupyter_server repository. Authoring a basic server extension ================================== The simplest way to write a Jupyter Server extension is to write an extension module with a ``_load_jupyter_server_extension`` function. This function should take a single argument, an instance of the ``ServerApp``. .. code-block:: python def _load_jupyter_server_extension(serverapp: jupyter_server.serverapp.ServerApp): """ This function is called when the extension is loaded. """ pass Adding extension endpoints -------------------------- The easiest way to add endpoints and handle incoming requests is to subclass the ``JupyterHandler`` (which itself is a subclass of Tornado's ``RequestHandler``). .. code-block:: python from jupyter_server.base.handlers import JupyterHandler import tornado class MyExtensionHandler(JupyterHandler): @tornado.web.authenticated def get(self): ... @tornado.web.authenticated def post(self): ... .. note:: It is best practice to wrap each handler method with the ``authenticated`` decorator to ensure that each request is authenticated by the server. Then add this handler to Jupyter Server's Web Application through the ``_load_jupyter_server_extension`` function. .. code-block:: python def _load_jupyter_server_extension(serverapp: jupyter_server.serverapp.ServerApp): """ This function is called when the extension is loaded. """ handlers = [("/myextension/hello", MyExtensionHandler)] serverapp.web_app.add_handlers(".*$", handlers) Making an extension discoverable -------------------------------- To make this extension discoverable to Jupyter Server, first define a ``_jupyter_server_extension_points()`` function at the root of the module/ package. This function returns metadata describing how to load the extension. Usually, this requires a ``module`` key with the import path to the extension's ``_load_jupyter_server_extension`` function. .. code-block:: python def _jupyter_server_extension_points(): """ Returns a list of dictionaries with metadata describing where to find the `_load_jupyter_server_extension` function. """ return [{"module": "my_extension"}] Second, add the extension to the ServerApp's ``jpserver_extensions`` trait. This can be manually added by users in their ``jupyter_server_config.py`` file, .. code-block:: python c.ServerApp.jpserver_extensions = {"my_extension": True} or loaded from a JSON file in the ``jupyter_server_config.d`` directory under one of `Jupyter's paths`_. (See the `Distributing a server extension`_ section for details on how to automatically enabled your extension when users install it.) .. code-block:: python {"ServerApp": {"jpserver_extensions": {"my_extension": true}}} Authoring a configurable extension application ============================================== Some extensions are full-fledged client applications that sit on top of the Jupyter Server. For example, `JupyterLab `_ is a server extension. It can be launched from the command line, configured by CLI or config files, and serves+loads static assets behind the server (i.e. html templates, Javascript, etc.) Jupyter Server offers a convenient base class, ``ExtensionsApp``, that handles most of the boilerplate code for building such extensions. Anatomy of an ``ExtensionApp`` ------------------------------ An ExtensionApp: - has traits. - is configurable (from file or CLI) - has a name (see the ``name`` trait). - has an entrypoint, ``jupyter ``. - can serve static content from the ``/static//`` endpoint. - can add new endpoints to the Jupyter Server. The basic structure of an ExtensionApp is shown below: .. code-block:: python from jupyter_server.extension.application import ExtensionApp class MyExtensionApp(ExtensionApp): # -------------- Required traits -------------- name = "myextension" default_url = "/myextension" load_other_extensions = True file_url_prefix = "/render" # --- ExtensionApp traits you can configure --- static_paths = [...] template_paths = [...] settings = {...} handlers = [...] # ----------- add custom traits below --------- ... def initialize_settings(self): ... # Update the self.settings trait to pass extra # settings to the underlying Tornado Web Application. self.settings.update({"": ...}) def initialize_handlers(self): ... # Extend the self.handlers trait self.handlers.extend(...) def initialize_templates(self): ... # Change the jinja templating environment async def stop_extension(self): ... # Perform any required shut down steps The ``ExtensionApp`` uses the following methods and properties to connect your extension to the Jupyter server. You do not need to define a ``_load_jupyter_server_extension`` function for these apps. Instead, overwrite the pieces below to add your custom settings, handlers and templates: Methods * ``initialize_settings()``: adds custom settings to the Tornado Web Application. * ``initialize_handlers()``: appends handlers to the Tornado Web Application. * ``initialize_templates()``: initialize the templating engine (e.g. jinja2) for your frontend. * ``stop_extension()``: called on server shut down. Properties * ``name``: the name of the extension * ``default_url``: the default URL for this extension—i.e. the landing page for this extension when launched from the CLI. * ``load_other_extensions``: a boolean enabling/disabling other extensions when launching this extension directly. * ``file_url_prefix``: the prefix URL added when opening a document directly from the command line. For example, classic Notebook uses ``/notebooks`` to open a document at http://localhost:8888/notebooks/path/to/notebook.ipynb. ``ExtensionApp`` request handlers --------------------------------- ``ExtensionApp`` Request Handlers have a few extra properties. * ``config``: the ExtensionApp's config object. * ``server_config``: the ServerApp's config object. * ``name``: the name of the extension to which this handler is linked. * ``static_url()``: a method that returns the url to static files (prefixed with ``/static/``). Jupyter Server provides a convenient mixin class for adding these properties to any ``JupyterHandler``. For example, the basic server extension handler in the section above becomes: .. code-block:: python from jupyter_server.base.handlers import JupyterHandler from jupyter_server.extension.handler import ExtensionHandlerMixin import tornado class MyExtensionHandler(ExtensionHandlerMixin, JupyterHandler): @tornado.web.authenticated def get(self): ... @tornado.web.authenticated def post(self): ... Jinja templating from frontend extensions ----------------------------------------- Many Jupyter frontend applications use Jinja for basic HTML templating. Since this is common enough, Jupyter Server provides some extra mixin that integrate Jinja with Jupyter server extensions. Use ``ExtensionAppJinjaMixin`` to automatically add a Jinja templating environment to an ``ExtensionApp``. This adds a ``_jinja2_env`` setting to Tornado Web Server's settings that will be used by request handlers. .. code-block:: python from jupyter_server.extension.application import ExtensionApp, ExtensionAppJinjaMixin class MyExtensionApp(ExtensionAppJinjaMixin, ExtensionApp): ... Pair the example above with ``ExtensionHandlers`` that also inherit the ``ExtensionHandlerJinjaMixin`` mixin. This will automatically load HTML templates from the Jinja templating environment created by the ``ExtensionApp``. .. code-block:: python from jupyter_server.base.handlers import JupyterHandler from jupyter_server.extension.handler import ( ExtensionHandlerMixin, ExtensionHandlerJinjaMixin, ) import tornado class MyExtensionHandler( ExtensionHandlerMixin, ExtensionHandlerJinjaMixin, JupyterHandler ): @tornado.web.authenticated def get(self): ... @tornado.web.authenticated def post(self): ... .. note:: The mixin classes in this example must come before the base classes, ``ExtensionApp`` and ``ExtensionHandler``. Making an ``ExtensionApp`` discoverable --------------------------------------- To make an ``ExtensionApp`` discoverable by Jupyter Server, add the ``app`` key+value pair to the ``_jupyter_server_extension_points()`` function example above: .. code-block:: python from myextension import MyExtensionApp def _jupyter_server_extension_points(): """ Returns a list of dictionaries with metadata describing where to find the `_load_jupyter_server_extension` function. """ return [{"module": "myextension", "app": MyExtensionApp}] Launching an ``ExtensionApp`` ----------------------------- To launch the application, simply call the ``ExtensionApp``'s ``launch_instance`` method. .. code-block:: python launch_instance = MyFrontend.launch_instance launch_instance() To make your extension executable from anywhere on your system, point an entry-point at the ``launch_instance`` method in the extension's ``setup.py``: .. code-block:: python from setuptools import setup setup( name="myfrontend", # ... entry_points={ "console_scripts": ["jupyter-myextension = myextension:launch_instance"] }, ) ``ExtensionApp`` as a classic Notebook server extension ------------------------------------------------------- An extension that extends ``ExtensionApp`` should still work with the old Tornado server from the classic Jupyter Notebook. The ``ExtensionApp`` class provides a method, ``load_classic_server_extension``, that handles the extension initialization. Simply define a ``load_jupyter_server_extension`` reference pointing at the ``load_classic_server_extension`` method: .. code-block:: python # This is typically defined in the root `__init__.py` # file of the extension package. load_jupyter_server_extension = MyExtensionApp.load_classic_server_extension If the extension is enabled, the extension will be loaded when the server starts. Distributing a server extension =============================== Putting it all together, authors can distribute their extension following this steps: 1. Add a ``_jupyter_server_extension_points()`` function at the extension's root. This function should likely live in the ``__init__.py`` found at the root of the extension package. It will look something like this: .. code-block:: python # Found in the __init__.py of package def _jupyter_server_extension_points(): return [{"module": "myextension.app", "app": MyExtensionApp}] 2. Create an extension by writing a ``_load_jupyter_server_extension()`` function or subclassing ``ExtensionApp``. This is where the extension logic will live (i.e. custom extension handlers, config, etc). See the sections above for more information on how to create an extension. 3. Add the following JSON config file to the extension package. The file should be named after the extension (e.g. ``myextension.json``) and saved in a subdirectory of the package with the prefix: ``jupyter-config/jupyter_server_config.d/``. The extension package will have a similar structure to this example: .. code-block:: myextension ├── myextension/ │ ├── __init__.py │ └── app.py ├── jupyter-config/ │ └── jupyter_server_config.d/ │ └── myextension.json └── setup.py The contents of the JSON file will tell Jupyter Server to load the extension when a user installs the package: .. code-block:: json { "ServerApp": { "jpserver_extensions": { "myextension": true } } } When the extension is installed, this JSON file will be copied to the ``jupyter_server_config.d`` directory found in one of `Jupyter's paths`_. Users can toggle the enabling/disableing of extension using the command: .. code-block:: console jupyter server extension disable myextension which will change the boolean value in the JSON file above. 4. Create a ``setup.py`` that automatically enables the extension. Add a few extra lines the extension package's ``setup`` function .. code-block:: python from setuptools import setup setup( name="myextension", # ... include_package_data=True, data_files=[ ( "etc/jupyter/jupyter_server_config.d", ["jupyter-config/jupyter_server_config.d/myextension.json"], ), ], ) .. links .. _`Jupyter's paths`: https://jupyter.readthedocs.io/en/latest/use/jupyter-directories.html Migrating an extension to use Jupyter Server ============================================ If you're a developer of a `classic Notebook Server`_ extension, your extension should be able to work with *both* the classic notebook server and ``jupyter_server``. There are a few key steps to make this happen: 1. Point Jupyter Server to the ``load_jupyter_server_extension`` function with a new reference name. The ``load_jupyter_server_extension`` function was the key to loading a server extension in the classic Notebook Server. Jupyter Server expects the name of this function to be prefixed with an underscore—i.e. ``_load_jupyter_server_extension``. You can easily achieve this by adding a reference to the old function name with the new name in the same module. .. code-block:: python def load_jupyter_server_extension(nb_server_app): ... # Reference the old function name with the new function name. _load_jupyter_server_extension = load_jupyter_server_extension 2. Add new data files to your extension package that enable it with Jupyter Server. This new file can go next to your classic notebook server data files. Create a new sub-directory, ``jupyter_server_config.d``, and add a new ``.json`` file there: .. raw:: html
        myextension
        ├── myextension/
        │   ├── __init__.py
        │   └── app.py
        ├── jupyter-config/
        │   └── jupyter_notebook_config.d/
        │       └── myextension.json
        │   └── jupyter_server_config.d/└── myextension.json
        └── setup.py
        
The new ``.json`` file should look something like this (you'll notice the changes in the configured class and trait names): .. code-block:: json { "ServerApp": { "jpserver_extensions": { "myextension": true } } } Update your extension package's ``setup.py`` so that the data-files are moved into the jupyter configuration directories when users download the package. .. code-block:: python from setuptools import setup setup( name="myextension", # ... include_package_data=True, data_files=[ ( "etc/jupyter/jupyter_server_config.d", ["jupyter-config/jupyter_server_config.d/myextension.json"], ), ( "etc/jupyter/jupyter_notebook_config.d", ["jupyter-config/jupyter_notebook_config.d/myextension.json"], ), ], ) 3. (Optional) Point extension at the new favicon location. The favicons in the Jupyter Notebook have been moved to a new location in Jupyter Server. If your extension is using one of these icons, you'll want to add a set of redirect handlers this. (In ``ExtensionApp``, this is handled automatically). This usually means adding a chunk to your ``load_jupyter_server_extension`` function similar to this: .. code-block:: python def load_jupyter_server_extension(nb_server_app): web_app = nb_server_app.web_app host_pattern = ".*$" base_url = web_app.settings["base_url"] # Add custom extensions handler. custom_handlers = [ # ... ] # Favicon redirects. favicon_redirects = [ ( url_path_join(base_url, "/static/favicons/favicon.ico"), RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon.ico" ) }, ), ( url_path_join(base_url, "/static/favicons/favicon-busy-1.ico"), RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-busy-1.ico" ) }, ), ( url_path_join(base_url, "/static/favicons/favicon-busy-2.ico"), RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-busy-2.ico" ) }, ), ( url_path_join(base_url, "/static/favicons/favicon-busy-3.ico"), RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-busy-3.ico" ) }, ), ( url_path_join(base_url, "/static/favicons/favicon-file.ico"), RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-file.ico" ) }, ), ( url_path_join(base_url, "/static/favicons/favicon-notebook.ico"), RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-notebook.ico" ) }, ), ( url_path_join(base_url, "/static/favicons/favicon-terminal.ico"), RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-terminal.ico" ) }, ), ( url_path_join(base_url, "/static/logo/logo.png"), RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/logo.png")}, ), ] web_app.add_handlers(host_pattern, custom_handlers + favicon_redirects) .. _`classic Notebook Server`: https://jupyter-notebook.readthedocs.io/en/v6.5.4/extending/handlers.html jupyter-server-jupyter_server-e5c7e2b/docs/source/developers/index.rst000066400000000000000000000006671473126534200265650ustar00rootroot00000000000000Documentation for Developers ---------------------------- These pages target people writing Jupyter Web applications and server extensions, or people who need to dive deeper in Jupyter Server's REST API and configuration system. .. toctree:: :caption: Developers :maxdepth: 1 :name: developers architecture dependency rest-api extensions savehooks contents websocket-protocols API Docs <../api/modules> jupyter-server-jupyter_server-e5c7e2b/docs/source/developers/rest-api.rst000066400000000000000000000004141473126534200271700ustar00rootroot00000000000000The REST API ============ An interactive version is available `here `_. .. openapi:: ../../../jupyter_server/services/api/api.yaml jupyter-server-jupyter_server-e5c7e2b/docs/source/developers/savehooks.rst000066400000000000000000000060341473126534200274520ustar00rootroot00000000000000File save hooks =============== You can configure functions that are run whenever a file is saved. There are two hooks available: * ``ContentsManager.pre_save_hook`` runs on the API path and model with content. This can be used for things like stripping output that people don't like adding to VCS noise. * ``FileContentsManager.post_save_hook`` runs on the filesystem path and model without content. This could be used to commit changes after every save, for instance. They are both called with keyword arguments:: pre_save_hook(model=model, path=path, contents_manager=cm) post_save_hook(model=model, os_path=os_path, contents_manager=cm) Examples -------- These can both be added to :file:`jupyter_server_config.py`. A pre-save hook for stripping output:: def scrub_output_pre_save(model, **kwargs): """scrub output before saving notebooks""" # only run on notebooks if model['type'] != 'notebook': return # only run on nbformat v4 if model['content']['nbformat'] != 4: return for cell in model['content']['cells']: if cell['cell_type'] != 'code': continue cell['outputs'] = [] cell['execution_count'] = None c.FileContentsManager.pre_save_hook = scrub_output_pre_save A post-save hook to make a script equivalent whenever the notebook is saved (replacing the ``--script`` option in older versions of the notebook): .. code-block:: python import io import os from jupyter_server.utils import to_api_path _script_exporter = None def script_post_save(model, os_path, contents_manager, **kwargs): """convert notebooks to Python script after save with nbconvert replaces `ipython notebook --script` """ from nbconvert.exporters.script import ScriptExporter if model["type"] != "notebook": return global _script_exporter if _script_exporter is None: _script_exporter = ScriptExporter(parent=contents_manager) log = contents_manager.log base, ext = os.path.splitext(os_path) py_fname = base + ".py" script, resources = _script_exporter.from_filename(os_path) script_fname = base + resources.get("output_extension", ".txt") log.info("Saving script /%s", to_api_path(script_fname, contents_manager.root_dir)) with io.open(script_fname, "w", encoding="utf-8") as f: f.write(script) c.FileContentsManager.post_save_hook = script_post_save This could be a simple call to ``jupyter nbconvert --to script``, but spawning the subprocess every time is quite slow. .. note:: Assigning a new hook to e.g. ``c.FileContentsManager.pre_save_hook`` will override any existing one. If you want to add new hooks and keep existing ones, you should use e.g.: .. code-block:: python contents_manager.register_pre_save_hook(script_pre_save) contents_manager.register_post_save_hook(script_post_save) Hooks will then be called in the order they were registered. jupyter-server-jupyter_server-e5c7e2b/docs/source/developers/websocket-protocols.rst000066400000000000000000000121541473126534200314600ustar00rootroot00000000000000.. _websocket_protocols: WebSocket kernel wire protocols =============================== The Jupyter Server needs to pass messages between kernels and the Jupyter web application. Kernels use ZeroMQ sockets, and the web application uses a WebSocket. ZeroMQ wire protocol -------------------- The kernel wire protocol over ZeroMQ takes advantage of multipart messages, allowing to decompose a message into parts and to send and receive them unmerged. The following table shows the message format (the beginning has been omitted for clarity): .. list-table:: Format of a kernel message over ZeroMQ socket (indices refer to parts, not bytes) :header-rows: 1 * - ... - 0 - 1 - 2 - 3 - 4 - 5 - ... * - ... - header - parent_header - metadata - content - buffer_0 - buffer_1 - ... See also the `Jupyter Client documentation `_. Note that a set of ZeroMQ sockets, one for each channel (shell, iopub, etc.), are multiplexed into one WebSocket. Thus, the channel name must be encoded in WebSocket messages. WebSocket protocol negotiation ------------------------------ When opening a WebSocket, the Jupyter web application can optionally provide a list of subprotocols it supports (see e.g. the `MDN documentation `_). If nothing is provided (empty list), then the Jupyter Server assumes the default protocol will be used. Otherwise, the Jupyter Server must select one of the provided subprotocols, or none of them. If none of them is selected, the Jupyter Server must reply with an empty string, which means that the default protocol will be used. Default WebSocket protocol -------------------------- The Jupyter Server must support the default protocol, in which a kernel message is serialized over WebSocket as follows: .. list-table:: Format of a kernel message over WebSocket (indices refer to bytes) :header-rows: 1 * - 0 - 4 - 8 - ... - offset_0 - offset_1 - offset_2 - ... * - offset_0 - offset_1 - offset_2 - ... - msg - buffer_0 - buffer_1 - ... Where: * ``offset_0`` is the position of the kernel message (``msg``) from the beginning of this message, in bytes. * ``offset_1`` is the position of the first binary buffer (``buffer_0``) from the beginning of this message, in bytes (optional). * ``offset_2`` is the position of the second binary buffer (``buffer_1``) from the beginning of this message, in bytes (optional). * ``msg`` is the kernel message, excluding binary buffers and including the channel name, as a UTF8-encoded stringified JSON. * ``buffer_0`` is the first binary buffer (optional). * ``buffer_1`` is the second binary buffer (optional). The message can be deserialized by parsing ``msg`` as a JSON object (after decoding it to a string): .. code-block:: python msg = { "channel": channel, "header": header, "parent_header": parent_header, "metadata": metadata, "content": content, } Then retrieving the channel name, and updating with the buffers, if any: .. code-block:: python buffers = { [ buffer_0, buffer_1 # ... ] } ``v1.kernel.websocket.jupyter.org`` protocol -------------------------------------------- The Jupyter Server can optionally support the ``v1.kernel.websocket.jupyter.org`` protocol, in which a kernel message is serialized over WebSocket as follows: .. list-table:: Format of a kernel message over WebSocket (indices refer to bytes) :header-rows: 1 * - 0 - 8 - 16 - ... - 8*offset_number - offset_0 - offset_1 - offset_2 - offset_3 - offset_4 - offset_5 - offset_6 - ... * - offset_number - offset_0 - offset_1 - ... - offset_n - channel - header - parent_header - metadata - content - buffer_0 - buffer_1 - ... Where: * ``offset_number`` is a 64-bit (little endian) unsigned integer. * ``offset_0`` to ``offset_n`` are 64-bit (little endian) unsigned integers (with ``n=offset_number-1``). * ``channel`` is a UTF-8 encoded string containing the channel for the message (shell, iopub, etc.). * ``header``, ``parent_header``, ``metadata``, and ``content`` are UTF-8 encoded JSON text representing the given part of a message in the Jupyter message protocol. * ``offset_n`` is the number of bytes in the message. * The message can be deserialized from the ``bin_msg`` serialized message as follows (Python code): .. code-block:: python import json channel = bin_msg[offset_0:offset_1].decode("utf-8") header = json.loads(bin_msg[offset_1:offset_2]) parent_header = json.loads(bin_msg[offset_2:offset_3]) metadata = json.loads(bin_msg[offset_3:offset_4]) content = json.loads(bin_msg[offset_4:offset_5]) buffer_0 = bin_msg[offset_5:offset_6] buffer_1 = bin_msg[offset_6:offset_7] # ... last_buffer = bin_msg[offset_n_minus_1:offset_n] jupyter-server-jupyter_server-e5c7e2b/docs/source/images/000077500000000000000000000000001473126534200240105ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/source/images/jupyter-server-architecture.drawio.png000066400000000000000000001313431473126534200334750ustar00rootroot00000000000000PNG  IHDRnsRGBtEXtmxfile%3Cmxfile%20host%3D%22app.diagrams.net%22%20modified%3D%222022-04-25T21%3A20%3A25.395Z%22%20agent%3D%225.0%20(Macintosh%3B%20Intel%20Mac%20OS%20X%2010_15_7)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F100.0.4896.127%20Safari%2F537.36%22%20etag%3D%2200p4XlgABFALfqSHIkgb%22%20version%3D%2217.4.6%22%20type%3D%22device%22%3E%3Cdiagram%20id%3D%22v7lz4ehFuV7winlhwBuH%22%20name%3D%22Page-1%22%3E3Vpdd6I4GP41Xk4PJIB6aW3tfoxnZ487Z2cuo0RkiiQbQtX%2B%2Bk0gfBkq2CpavZG8CSF5nvcz0IPj9faJIbqaEhcHPWC42x586AFgAmiJPynZpZK%2BqQQe8101qBDM%2FFeshIaSxr6Lo8pATkjAfVoVLkgY4gWvyBBjZFMdtiRB9akUeVgTzBYo0KX%2F%2Bi5fpdIB6Bfy37DvrbInm84w7VmjbLDaSbRCLtmURPCxB8eMEJ5erbdjHEjwMlzS%2ByZv9OYLYzjkbW6YbH9Qm%2F9y4mj0OgaP1Cavv39RZLygIFYb7gEnEPPdz%2BWS%2BU7h4PwXy3XeL0nIv0QJSyMxANhUMH1f9IsrT%2F7PMHvBbERpNp1Y1zzrVHjkkwNG4tDFcp2m6N6sfI5nFC1k70aolZCt%2BDpQ3YgtlJpAWzQjzshzzg3IJWMSEJZMDyeTgWEYavXqXmDnCylDqFAVa%2Bd4WxIpSJ8wWWPOdmKI6gUDRa%2FSbwuq9qbQFmA5qWxV1hRLDURKQ7187oJEcaF4PIJTs19D6h7i2BVarpqE8RXxSIiCx0J6X3AikSvGfCWEKiZ%2BYc53Ck8Uc1LlqQS2KWnBoTuSBinaIQlxKpn4cm%2FJI6q0uXiJ4kRzdILfpI0j5mHerO5y9wfJZThA3H%2Bp%2BoA6otSt34gvllIohTGsKIVt7HEdkZgtsLprj%2B58GR%2FQAPsqNeAthttpRisNSIE9AE3%2FUpryMZN2rpLQDky6LaHnNmnLgBWTdvp7Jp1q1NlMus6np5E1oihsE6tN8EasfkIcb5DEaBz4Ev8iZKdzX0%2FUTrQOBb4XisZCrBWz04RxaA3u7CrBhh7IzcyvlwP5%2BeI4bDZ6T5BA2%2B8%2Fz5PRPJvBOIiLvZ%2Fe9GtQyRAoo%2BKcDRWjGZWqo2vQTE3xjOTX0k0dZq5Z%2F8o4ghocz6dd5k3hmPVW1dWs0dZuUQYayuM44mQtZI9bjsPIJ2F0A8CDKvDg4sAPbjxjEgSy3Q91W9L4KRt3fTtrP2zLvQ871Xp%2FqqVy4sbcObO6K0meoe7pxiRc%2Bp6QTVGIPJFEXNAAj85VTNAiJptdxmSoe7kiqUyPgj4VwjXZYJcYf6drG02ng%2BkzXXnwn6k12e1axetzZ4N9%2Bw5UcTFrIqxj2xl6ZWTAfsV0OvVr4euPUrfDBxfHqt9hOo9KETuHFug1yBRR6ofSef6JWZgc9%2Bde1EFrCWU4j2hdxdhUI0Y0PcJf%2BltJ1Yd40KpEibS%2FQMFIdax9102C%2FOkZy3qHe9lo26woF57eVN5fUx4HytG%2BBV4dVkD3uDMcyYT9QOJwVSrOCa3V78Oq8fGi9Yyc6JnG7O9AQA41JqIVovJysQt84foZbPb78zRIfJ3nArR49pLQ8VfMxTQ4y0cUY3YbBvODrHeXY0dQk1kTlIVA2aBq3kvZoC6YnC2WWKbGUQcVGN76PC%2BTxPXPkryokGQjK5DeqqyGDmworZLWN8x8AZg0yIe648tzHpof9NGN9V2WRzUWeNnA7gu82g3qb7v%2BiOlOMlAcaXdVfbzbJ58o54BWvzGO5u%2BsT%2B6zazehv7rIE8eZiIbXUYWfCH5ruAe%2Fc%2Bm6Ua%2BN9LT908MOnQvCXp%2BoDDXchY%2FH0iOJeCD2H90WAbbVIuHo%2BFSq7uOm7hOQTxT%2FjZbxP1Puazng1V8J3qSR7Z8ndGlh9VvQq7FLWFjdy5HDNqZTdPIcuasPiESz%2BIY0%2Fdqk%2BBIXPv4P%3C%2Fdiagram%3E%3C%2Fmxfile%3ED# IDATx^v{F^!~:+`XPP bǂ,][d|hA~ I b#fA28~ )CEp -j2$$&4 n&ABA~ LGd:^$fAI"Kߒ*H_L:͛7 +&M$"lT. ^=fAfoQo|!ѱcG\tE8wߙ}_;V[ʹ[n'? 7z6fϞCgϞXdEB(+q4 'GdD y;O>'SجY3A裏 /oB{z1[ol;4kݺߦ橷FsACO?a饗W_}e2Q(j_lAQm$@Ib4- A 9s7oLC=4oѷo_K&?-nv~?hr)XtEZkkߏn f‹/vdeW4_r%q'ZF7kƩ!dpմ,Hܧ4- A I `ƌ I :L᪫,rf{}o_6^. :ѣꫯF N>݈>u{mf[o5e"B^0~d9ϓmf.۴ic&\{aޑQ͚qjH"$ !!\5>4 釷0~B46Ϳym$~ot 7㎫_~W^?8vuyr-&6 3 pHCޟf8|M1WHRM2e_|q#`)$s30a+wW^i2,첋&_s55vj($$?Y_" ."ÉsT%oZ.IrXBCjժcs5CGyf^OPim ;.a8q79^b-ХKbmlRH~gh۶i!!EE<+* y3 ;..t !6 |0~z+M6 e7xЫQyҠ[psжj+ t< }]OB)$QAx嗃Ϛ#Drcna9spG1Nee%z)3 0'O6H BB o9 k>۠ڜCㅄ$E/CfnD(h5E7O()pxfm–[x x>Gr}2Laa(dCss>|Y"0pg.Oj+8 6̈@ @^mYB k|IOI87s!Y%,2 I/)\9br$N/e|Qnd5d4\jB4&$o6fG} L1n:fxY? NbsL[M>0d]^~Ndwkfnj>Xvv^ƴ[O?tl6޶2{キG?KMr(7_f)#Y p ^c[.ll_mp\W8Ef9).rOᐶg■΍l I# 4zU}M1A-H([(hO=0 {g({mzdȑ#M駟6h=ίjVr(*qQ}ya^$JÕ P2C1̹cJ J_{a].!de8?'E{g#4 I^EBwbHH N I\"I1Al'S5T(vyg# z;' If`8u us)(UfX<3=5 cF9Sq(oM7p+lC}.Rf5,3<$f{#d URH③}Pe֓DIEc_%B[p$\%$$'Yi}j1AZ O BQ8 )|9Y,n[olCD +[9(ȹlyI'wLɍ)9 \s3'33E0%-xB=_RIH8tO!IL$Wp[PH҅ÙL!ߘLP6UB20XN# hLHRdQo̯j,BF(rhs9\eXkLHr5*i?c9ϐC~Fa}n}adf6_yI QdXOHr&xMv{ ÕgFYWf8BALpNGad-]x,$2{ژTF2kHT$ddV=~ I.41`C/pn$PHq=ny̨BB'4b@.;vlb?[phnCQ,"m" I[M Iε*NhWI myOI[ynfYyMV⚙Jf#y4d-]_!ynOS I7dMVN@Btj!-`fܯjP$QLxYKf((() I^$z_XeƐ†[C[py z<{9s1 4Bȶښ#Y(j[/liJ۬0Rr579q؛s.; mUYvt"a<{8П$iMj+I$$-jAyP\Bߺq½؞'((8D id QWD ,Trؘ}`Vs+>q+0m,Ͷno0X'$+Χ ܑz&@$$㠮s:C ͂W[1$ RJ ,noVϓ(e BGpq ;pJsr^Ad\O; 3Ň8 ~:HH*2M ͂r{Fr๭R%~sAPHrj`,(>ܿ;p7FxxfUf&'6}Ya ~f$) JB2W2 I#"'fA9Of$$,$=-(3$7ܛO-9egi>J!/*o[IHx1ԙ' !64 4{6~sMHr$mEn8O!y-(Ed!!9j(Q~h hn3S\-m ʝx,.-% !]߫Nl HH㺆fٳgsR/}T_~_j3g_*φz{o\$$EZqk=e%fARVe>YUA5+[mnv3?P$F6FK@B2Zjqi$/ɼ4-˂_gzp6?#M$URDxp!V5 pL@Ito9~ ?X c@@B2 ^TBH %o$ Lߒ7Y]: H I[fIu7'"@@B u w YKt7 #8.t !6i$A-É7 2P"&A@B2nQH o$.DXptD:HHÏEHi$!$4M$!8[2&K' !Y:C`i$ vKQ7 w[dTHHN.4 wnY&ARz|тu.xA6F ͂$6e8q&AR[Pd"HH&M22*i$Q1s4Mą nH tQI ͂$$DfI$"3R~Kdu$$KgL ͂$n)jz&ARNVߜt* 2@)%fA.-K$HJ8Z⠮s@@B/Ȇ Љ}6z;|D qL$G2C@B23VG" II&4 dzDVKH*F@B2kW!@!9vXQI0eTI6d!& !5a/" " " )% !RǪ[" " " "5 ɨ }H) ɔ:V HHFMX틀@J HHԱDM@B2jj_D@D@D@RJ@B2UD@D@D@D jQV" " " "R)u%" " " QLc-dԄվdJn@$$&E@D@D@D $$SXuKD@D@D@& !5a/" " " )% !RǪ[" " " "5 ɨ }H) ɔ:V HHFMX틀@J HHԱDM@B2jj_D@D@D@RJ@B2UD@D@D@D jQV" " " "R)u%" " " Q]H3cǎM)^u+@UUs@ι$2\?\d.V" 9usBHtѣ$?b O1gݽjPD @9yIv?*#P]]m|벐T/T̥73@yN IFs>4]!샋uѦ8}s#EBҞR pݾ }Q _mR숀@ tĎ#pݾ }Q] Iŀ@ LvE8:H\/H_TWBR1 "mq<$$s>) ՕT d@4 l\住#tuEu%$" &3-Br֬Y駟+f;}AJH*K?ѪU+,2LK/pg{5Zk#駟n5{9xהּK~̙3{K/5ЫW/+犻8:H]/H_TWBR1`Fmc9'p)x t޽vgwqipcVE]>7t]tQ|Xc5BƆnW_=:(zq<-$yqS < /0q|xѵkWkGa7Ҧ'$yDxzpZiY1} :ivSN9܄6{Wc饗w܁S⫯‚ .hi/H_TWBR1`'$;09Gyd~wlvx'M3aÆaÍ7hͼ={N3>1bD{رcW>C罞`FHy;VZx5 ۷ǝwi6\p9υ>lX28\RpXzkF7N:e˖8K.]}裏bڴiFq4_~h;ڮ];{`(y5\cmd4 IDATPHcnl{7bo2o I}LIL_xCk8?ԤI⋘8qofsbƓ74 qM75}k![nܾkFE2@ɗB6w WJH*$_C=d-?y„ }܃yo糤iW^F.>LbT:;ѿ#,O=Э[7z湣ycFq<-$Xc^0>!"S|ZuUHÌ%o?#ȘE'(3v͑lMa|zEJ;s yR: Ç!ɛ =S1hРz!I N^SNXl0rHc7GL_Po ?Zh!#ZYy<nžo(cGPuEu%$ |&5k *?0nQXRhrq(7MĀƐ!C=PiHH2Z|8f9묳"fP5m?l3-BCN|#d5Va^‹bo~0_yz!Iqȹ&{챇ib@0'2S_na3̤ HRRΘ1ož]Cj+#y0=mdXAYqM65B✗_$S mD”8:/+!(%,C}]s,N9 !.r?GrHr4(=>/~G,R糑R\|gI& 0I_p믿n6W-LKf gAHջwo9|@OPbjӦ6rǎ;9 7𯷂)/2 Io؃pxb7c=fycb!إ p/bOHRR4 ɓN: YþXΥdJa< Igy%e:er~dԗڏ@7b}qѦb6gurs{mo3s'$9ݓ9gqu5 i|}}=4CO< {n{0+K^>Wh+n )v'Ɋ8/;|c6bY>^,:")9qRܼHH&F"k#Mm*fsN{-pǏ7ӋY ~̅;Gs۸q[{-r_m^9̌!pJT{0i5i3G8664)_ I.᳎#*nX!I2F,u 8N@frϞ=PCx1Hq)b B d\iaGU8T¹/x+o\\dD۾{_f9Bydpgnz=W_AV ՕT # s .fav+83qd+|~qKr9|\(ľ38iMx\I$LDY qݾ }Q] I@<) ՕT d@4 l\住#tuEu%$" &3MB21y rݾ }Q] Iŀ@ LvE8:H\/H_TWBR1 "mq<$$s>) ՕT d@4 l\住#tuEu%$" &3MB2ʘw)_3ٿ-1+-DiQێ#tuEu%$" &3MBV <0cD`DOpK@V+o,>x;GPJH*D@M gd)1g'>5KMn4k ,ܢ.Xx u_V)j݀5wZe?6I ՕT d@4 01C{;+t(owɵM| 0Zi8б7Yo`^quNn_b@D xIHw_U{TEvXsWAZ_׀|~uU+(Y[eGPJH*D@M guҲ-Zro\ Lv*isԾ}Al&!Y,^xZU{[jϵ΃ GhmA;/+!68iНOFD ­(>OM=n6: wݾ }Q] Iŀ@ L,s>zގ]nt';ڸ`}7q ՕT d@4 ɆbIÀWpq3/A\Es#tuEu%$" &3MB2?^}n V올|70:^we%] ͦd ;VO0K*uۊ J=,P$$s11*,g2z]j ,` C5a ׅCVqѿm˪o[|A#vjU \J#߃~Yg8`HڅA>X)6;m?4mwul7k_6LJJrTWW[)|Ke$㈉ s~X!߷6<tPY t)}$;l?4Q8߬mdH\7%k72'o,:?ܮ=ul^^f ]tݾ }Q _6nOqTfPYlv{~<$!Q<Xg#~ع]w6Zquκn_dk}^=T ~ABңC ;YU5GPJHJH&*}^rH&R~ݞK;|;i "^s;pemƶl.tYfoC[B2?#rav%!)!vFl]7b]mٯ!`o۳KRmnGiL`lu \?w5ݺuk\wum^}ߍ={i瑐[:,Dl'ϕmn |!W~b2iC܏L4k̞Y; ɲ +/9//SN9If(;<#:,#(?xxfhC{쳏9cǎˍHk?,{CV矏aN8\}n)v}w\veF6$($;8cG˖-L ,`RSSclO;4Ef7oZ SL1BrV2 _!!_Hb ( XdEd .AȢlӦMd= 0k[`ڴiFQ.om6Ff$?s#0Y(^u8p,EdFl:t<<``~p;첋,^y (s/~IBϝ;N&zlv{rZyPlHHVVgyoV66 6:VDHHl^t^FBAqvۙ!jOH~Fhrnol2\e$L3vg}Hf[hāz>OH s| VXaIo63wi'3O}ca)/L\!IqܥK3B&ߕ,Tάڥ77럀mnOO$$ WfX;BH8ͲF@G)!YK6/\!ԯڦp\~$3uvN25n5 |C *fV[m5B34CfpҤIFklYBC۹xY#FDž;ɹ>J;B k~dsK>3(.66n"~5~^ҜǗux&~~ݞAW{4ra7=%WzsaN6bhop$krd$$^q\tšm Ifs'$lgюU.l̂n׮bOsqͿdm&9\mn/_383gKGO?%wŶld!CGL7O=w7_brEcx2"!YT]cFqE~=fnnnsI'e•&p67&aa1WB2HpVcv{ Ix|枴w㼗Ӵ~ݞ~\\e))!p?ȭӦK䏗 KHEW(UFEڶv{ૡF I׾xLftsG hQ`t~aQ#8ܢ 9#E{$mv{~:-!iKH6D2wN#e~17c96?(!Y+j*X#md=kPꄤ_bF`="GtM j`Oɏ)xFlrg ._g_ 'mzT۾~mQ I?dQq\tERk\mlg JtWC/jQrN/q ~pB?f%E2n/oq[3o[~._㗻lׯWBRBOGP15f7l~[x>xgV_Mj߶f|.~;P~OSH6E/7p-҈S~,bo I I?qNAXMC3+O<vm76G+!oYe;6mאtW^Jo8vuQ`s% }>3Ŏ[ ~>}5cۥuzhtXBRBOGP15($ n~%_vnO۞As/k3rb}Y8o,|[po;͞u]L)EnOJV!?U< /DNq׵]nKAVW;#]߫W/B-=cv{ Iͅ/^~J+Dž1;}!#,E0 IΩ;vﯹsO{=s7`~v7鯄8 ]'bkQH;ٜC1غuk3~͓9!qE5{fȯpCM417UIwYg%R[f3K:Λ)roK~E_t@sqb'VkuUH'$C|W*ݤ۱ibBwW|潋a8DQB_cO>ټpymfӵW"=`HBζl~$t8:Ga57eq )$9F9o/o*ӆނo 'h?s~JG= I^kuQH /8,I[9ByfǍg2J^Qn¬;c)3MoiL>'O6ߏg{`;61wq6➬F۱i=ȭ8*}r,x/䗾x>3~뇡mnO$$%$I:quc]܂b3YoPwd!)5>w}w[8߮=!+"q„ &ɽ8tN!6m!aN~bC"']a$>Y?5!v.^,okJߑ 1k sB|eƓ߲]nL_/M ~#h^d;6m>jRV~0'Au>OH[ѣ6=zζ'$:>@))(LddlZ$c[nf^x_FiB|Υ.\E+ mé `~#" o=p/E?~ݞKHJHu ƺf_&3sȽ8\ I=ss\΃3338ݐd>.H >4_}U3)>78aVZ{gͰ73?'!$CKŞLs=gqxW^1sgYC.R-|!a4m5fs'$ל9\)ƛ'$9F yOHP&38zqX61dFA{G7镄8 ]'bkqNaȅ+V)9ߏ5|B>옥\|!IO}qpxif&)9g9'9`={yi\ ϫ VFҵ\Hخ];#EWK($ ei;MbCӞ.¶P&bCw0r: .Ebmb%!,U{^A/ B7(>l5k c977oїmr+j XMugޣ@\j̃Pa{.+J2Õ|83;0YlL-\m¶ ۹ If̽]we=s$30)3dL>_8/JL!рBOB%^@nϏC$$Iqp87=dt,)Ǐgϭ bVy%[mʌcq8B|)o%bIHC74sC$kۦ Iم\$$H2#}BX2,O>i“qAYHHs1E#Ng+yO7M <ނ~sI@ے=@i IBx"<|2n9Pߞ9•%oxFY n-ͻaև%Pf}8$CX͍pxq.yX ɢ׻틸 0w\}ts1fmc`f+ ei7.||O~Ʉ^3s61aco ӧM^ IBC 7f͛]7o(7iaGQ/$_~e+2۹p Df?3 V˹B۷77Ls#i4X$$^q\tERk\mlg ~h\Α!n).N;+{a#!iZ^[.1iL`l5Хܗo@fby\tyg/Fe$9͍l9dž2|K+l[JpcUWNX͎òrZ~GF.!.Hp"\mlՠT#s8Kmn/Lh66%THl~oBr1 GNf.յn|!ICRB~&䐶W'8\B2\p gCEڶv{~Nlv{az=@ðI-B|;>DQR(*9 Zf ۟q1!Yhߵ-V̯0pk~YAB2\p gCEڶv{~Nlv{az=@ðI-B[Op93 М}8Ǒbr7Pp8F+9禘p9?cYRr?An snSyS)!.H]\G!md=?\Utf0=aHHڤP!IyQGUWqșl-͆466\ĭ$1b<_KHrh_#|I{f9CtR,rk "0:p6'6ԅo8u.׶MUuJ'`o ܭҼh66%THrK~".w1 Ǚj[ >*Wes _֡Cn Cnl"Bc{ƀX.E(Erjo.8!\F 7ߢFBh.׶M ["`o ԙY4  I*$=9!m.#Lr17!~b_ \BYM-ɟ]\=^tEdu[HUMEڶv{a@lv{:r ݂UHzlsu$$$Q`mWC7%}qjmnO_%$%$I:quc]/H_Tw~.׶MSmn<윥{ڱ~ݞJH :s.hVkcUMlA:}AGV${1f(M ?ܮV&fkA/+!)!v{!lKmv{~JHz \@OsλwwXlA:}Aɹ l{lɶԶl燮d. s~X~y`{j9\S#tuEu%$%$sؾn/9$mmn] \J#A?ܬ8ε \56; uݾ }Q] I \=Kd[joCWB23zX}G? ݫ3g(V vݾ }Q] I \=Kd[joCWB2ҋ/Y+&W臣;uF^ 4_7 Xq8:H_]/H_TWBRB29W{C2ٖЕl҃4hy Kk8I`nGPJHJH&*}^rH&R~ݞ( Q`kMZ0-vA:}Aɹ l{lɶԶl燮dc |2f']~O׊ȟ>=^w\9SA/+!)!v{!lKmv{~JHġbntlzTwo\_M`Fg#gTAέp1ld轢3mnO^*~ݞ ~(}:t_eiج7DIS?-ثfj9,GP1uEu'md=QyCAg7񐄤J^~-"jtQ`]vܵvwv'o {/NgfAp Օvr%d-7+!R~;BUڛ{YΘ̘|7<˴γp qrX_c tݾ }Q] I \=Kd[joCWBBu~6Sae7(-4k,l`L_k;Wo_L皻kt-GPJHVVV* D{ AA 8W~HJHάj&2z+աd6]R -8Uu}N93Ƹ_6y%=2.$5xLf,k?~Xm.eX.;Le]r%WKC`iosѿmb{cǎ͚kSߪK*B[Ue$PRl?4CR@߬])kq@Z qLk49ү8:H]/H_Tw~.E;"  EB2l/ 0\/H_TWBR1 "mq<$$s>) ՕT d@4 l\住#tuEu%$" &3MB21y rݾ }Q] Iŀ@ LvE8:H\/H_TWBR1 "mq<$$s>) ՕT d@4 l\住#tuEu%$" &3MB21y rݾ }Q] Iŀ@ LvE8:H\/H_TWBR1 "mq<$$s>) ՕT d@4 l\住#tuEu%$" &3MB21y rݾ }Q] Iŀ@ LvE8:H\/H_TWBR1 "mq<$$s>) ՕT d@4 l\住#tuEu%$" &3)!YYYHi﫫Aߎ=ڹ^t?c O1gŭjDDrtp]HzRyBR^焐;vlT,ծC>ι%\?\$U" yuϋ]H" " " " $ !Ljd." " " "L HH " " " $ !Ljd." " " "L HH " " " $ !Ljd." " " "L HH " " " $ !Ljd." " " "L HH " " " $ !Lj|Bm۶b7LM`>!ٲeKn[-D@D@D@D@b' ! d$d2&E@D@D@D v@@2 HH&oZD@D@D@b' ! d$d2&E@D@D@D v@@2 4($j3f ` 5e;N$" " " "FHV7X$vՔ I  F7UD@D@HH*$D@Da (B@BR!"" E@B26$"Hf@i$$K㧣E@K@B2UD@,R͈d\& !iH  ɴxRddhհ@ HH&܁2_D z3D@I@B2~" e$ !YF:@HH&]2VD qP9E@@@B2 ^" N."0 I#D@ !d{$$,pc9" t2DDUzFvM@B2n" tE2PD &1iE@C@B29" % !Y^:@ HH&i2YD,$$˂Y'H2 ${ODI@B2Jj[D $$SFuBD @U" " !.7" HHcD@RJ@B2UD@J& !Y2B5 "vi'"dXr:ND 3$$3juTD cTs\cU]D@2A@B2nV'E@BxqLA(BD@D $$S^uNDՉJhG@j HHֵ@(KlK@* HHҭԉ-&D@D u$$SRuHDub6ՌdܩΈX$@8NLخL+(։~Vs" " p:!"\jAjRD@O@B2.TD@""@xe<9sYH4 DOƋDH:1yJQ" "Xu ubԈϥE@D $$6-"PՉp>BD@G@B2q." e"@xiX XsbF ~?*G.=ǘa16|K9s9 ';_b=LMED LSեcB:Z#aǣc۵:nF[`*05xtQdܩd@M56 5ت\h2r"K -,0Pdb,}o?| eZç˨seQ'N@B:R5(6;_q孀:˷m,ФitƨeC`?My udr*1O#" I7 +DQ5[+FW$?S> LyvWYUTax2:#+E $$q5x4ASllxAOv[/D'/;;CqVTMD 1@)E858~@cMT<=|6͛'{ \RQ;1Y.% !^ߪg $PSʺ/lc|-hw@{#E^v^em༊j)U*"`ejNB.06֝69"Ls:FCہ ԊjN'"\,O jt0~{mMRCuCJ$P3}.0"![Q%E@J$ !Y"@.6rg6;^ б&uQ'9mJkHED &1iMK4CmHp{ EFW;][Bs/z"`]jMT|( hk`[v+z*8Q3!"P~g3f@ݪ,~f%W0u]J gÇx_@w.$$C@!"@M5xۊcô& H#gDO}_{TT)A( pY2NND>i0lz4'e}.9(@@BR!D^@">x`^fr sg4,+n,k֌6s"̚8m''Y" !%oe%PKfu6DvD ϛ3ckm HP@@B ulƃfH>E-fq;:{>qs@HHfc D)t@m3/d>  *QU%P f㙍uVTa$,EUzFv9O,8",E@'p2_pCB N@M5YYh`DD I?xZ|QE5NU\" !7dKos@epA[e>ӱ%PS ~ouX7=qH4m *F"E &1iMбdwF֋@V {ϖ{mtu|T!Y.g7dOjƃַ3lZ *OʺY(P%b}&VHVW&gy{@v6T" q1aC=I!e\y]x&ZHVUE+0sÅ/75cM; 4iZj:^D Ns,TkzXb?̉z^Z&@sU_ r{4:˅UB\u(# I 2[OWBra,iGH@BRBVSE,G3z\X%$EZ2,cTQ?%$ь/@.V ryD$$%$n>U_ r{4:˅UB\u(# I_BR_)cLZ~[rEE*++#8M@BҷԦ徣*}GB2ɮN rVS)~z꩸2©JHSlŘi I+H1aX{VH^p&˘[_|q3$O?_r :rr)+糑+,N۟Z$$%$S;99!bԩh޼9VXa4i?53gYdJka5Y۷o]~X`YfY￿N;턑#GgWČ3%$%$3}v>s:3B7/w^}j[n 't Kfg%XfΜ +^z)8 \wuի\6@]O$EbKfx\믿E/'yKPv ;|G}nc=ִkW_[lM6٤~+ܹ|x؇߿s[`eYƴMz1ϵɃ|I<\ jL8s?ylPd #<zkK(1lS(?c?؞[|1_y1yd,|qAa 7t+=49!I@_=F}.ByOB4vmѵkW~jL>p۴i;X}BoŢW:M\uU,lJ8Cl+~{У(<# f 7tS#[nxE?`0~C-_B%?ӌW_ݬg\Ugyq.]ko7$$%$M΄?b!MM3R lvFĭJ&#K烍 P>VYer!MMt;cFDo~ RخƦc9I>7nO 3ClmpN$̬0=]vcl̲,&B\LQK*lkϢ$c|σ<;c2q_>cXa͒/$󏥐 zW' ]Ν;/,~bw I tEtބ}NgBHN4 ڵ3C@ eQH{={Ā ?oPZke)$df5kf2묳|B 7 !@aH1GsmgqaGaÆ駟5!p_| L x2eqy9i}䃞X%lm\*$ ^ cs( SR+_(xy] ń:<')1ֽ½.awN^a;?y!лWxmdx^xYSe+쳏5#$)ǯw޸k I\;nyN{  I r=D>Ot&cv*,=̻b͙O>7)9|ƹXw̶0I!ɇoƜwB QK$j{mޞx֭y>jǹkI!ɡ>f]9x(}`03) IΑ8p1kLHΛ7-`6D's'RCs>Y8៙ >9D||B1 Çr~avg~S:Օ@ք$ ,^!g.zuyM..cf+̚k[-ˍ7.uߣYffWo^/e B {G>/k?ދO}B1 IfLZj*~[:BҹuO./H j榰~g fKsZli2qx8=m4#&LP?D\ŕFH 0&h0 @ f19L јh `XZ$"dL`zM6AD =4z:LW9:{U[={o>KjfNwT$$](~#vd*^fhl~+Bґ"Fr!釛 fZ^׃9ؕ}.9ݼ)t?::$ JrqtvN6B?5G]6 @[!,Ol g&\bR)$-Jůf]+R ;U# Icx}uRHG7s̓:(+)$]Re: dyC8wN9?9rq-$o{wJ7B5E(>:}y7-NKR!i稩mp)_gRuY{cfֺ@QHYT?#Bnn&Vy?]is:GFH^Ku,!56ȶ.o6oqJB2WrtY1VY#YwxǟJ!zk9M[9wQ>|xB)Bo;Zӹ}Bx\78Gz¾Zhmȣto)vYdʇ{c]>R~KEmwmkl)1AX(ezNoOxH\g`+}++JtB2-WIq֡rfa]'GU IsÆ K"שs${ֻӣ<|4dCd3$\QFʷw[9P=.gk+7_bШvOid<ԸN9]k&}.f̽>($_*{訚Sh\Qr*BQTU]5*_iF_7ɤoԩFH.`ʛ]e*wN@rʹzT{o$|'GsdCd3$=3ZoΈ8S)9,o.;=-{Oh]ΆXV=݈RQR[O46}BBÛ:;3;ћVyNW+VHOo)?;9&v6:k@FH#F$jk:{f^~xoſw7:Z=Ɇ j$Ⱦ>͟?f]/[~{K\| +/]zhwo8GH0TH*ׇ3*.-sQ|NpŸPR6o.YzIݽL}m7o@4+g;k鷻bi"ˆ.f׈dbv-awGlNgCNצ\_M(?ebOYtQK3 ;]X}^o5j{xc\H@q T,oY4{G+M;iHɆ j$g[獤vkӟ{r7:Ϩr|73/c_l]oWLSGc؟ޠ(|LH|7 3>"Bѿz]gMtT'x2n=g;; ݮZidhJ%Ժ@CÑw!5t 8Z4RG 9ǩ Ye<&ሞ#SLg%.k)/=:1u(L{u'R}JVG.}jKuWojf/ŧSxA~Xs!WOS B24Þd5JHVsonɸ֏ռI_f#>z%)~G- -b 'H|D9&ox۲Kci$=/\H se&(vkk!_Ms}SPs!Y'"Pk&܌lTBGD]m!Y 7ɹ缦ohN[[H:5̆Ot;F$7YH:r笖 I4hңl3.tusWu^{핼9}S^ztraFHV㚺 Ժ@ $ " SeWHVˤMդ(\woK}ܖKH]w\d I >Zۢͧ04~7FH Z]޼e *E >F.|ŠSN53_cZgN{w%7YHzӍ_~u9Bo柮Zid3@ Ap3B PGwBB7ޜbQߜh7Ө~"$-\h1W,$9ͧ&IΑIzS_{R+}㿶5~7u%Si5N}Y\ZTowZ~pwj]Uvg!$ 5;7AVm( !i[^!M!9V4A_$v"?4 /J~P\i=g"$3ZQ 3S!%U{fYJE=+i}\ m(@JϬ##ջ)\dc.# %ұW3,-b3{!/|XR<@ $ϔsH(.V#2" {P]d~ ڵJPs-.T)p19#PR:}I×]GMӅ@S $NHT_%64@ ㎑Ə9 E] $#w CTzN,i1K go<(線&mBQs@# $IrOTK:P-% h9ב^o3N/uPdB2Cd*'P*j@i-\J=&x͔<`h4d_ Tҭ ol{&@KLQbt%v0(2L!a2(UԦ43!G(];}{ag4B@)RQJF)Wbi@֑^#^W(j۾q 7ɼyICu4t4rl@5GI65Pkǹ!D&¥kZaƑ@Җh&&=rG4㨟'9}C y_̿f/y~fg6}L@. ܲY6\&} ITfnNfOd2Y7WHy[; C&d@'ILnw%p=d7\CZHDdԂ# %e30(ͽ% e3?5ݝȑ!PI!ɚ@d7ϙ|-c0\Jω|\΅X2 $f.NΙtu,_]ڍ#~<@, @plbkK?j-!07n?Tze|j$o ]|YL`ƻ[Ґ̵?8 7=Z?&xxwv $(5@)L̙k:L@ @ 7q5zI!K`\B2>g@uq*@ 9v>S%d@B%@sIV $k@h-YJVIBҎkҺOOl N4M%}^z0Fok+A#@D2s.eB@T*iaYIkRAcUy>   d0d@]%&?PZҠ!AR!؀d<(M&O&M^_i俜PhLK! $cB V-:#7?I^(M)C)H3|@ @I@]K:B.8LDiMbR#K# m9`5 *d.DHTJL4g1]ztR2@-dG9VW,M/ܲٞ۝X $C@ b\$_eZ7yU2:K1dg8d@RYMɮ\,ӌ{NzE:s黺 ڂzɸ݉B2O`"&P*/~ O:2dN:ʓP;2n ʌ (4MhaexOm)G%iha|FB`xNԮTҎē./HL@ P@Y@%mm.V9kIP(jD~A0s@H1RQKZRO-cL}ҙCm j̄% 1Xi?>2Nrn m5a#& 9*%vMO?o_\3v'?,cwӞ{/B3<+B/.Յ2y#${ś! $Y@]ڭJZqukSO=Z/nnmu^kyܸqp uj]vI"W_}cD$o|5,$?_veky#$kM7 $Y@]]N{;#h̘1?~V[m5qZdEF_ԶnkFo: s=N:I -jD-b:C瞛[O_~y2G?Zz饵niod3<3O;Мsa믯E]T\rx75bĈ!y's12e=ľKvva}gZgu>SOMJj{@dq 9$+@=BҢiZ77{+ ZfeW^[o5Ii;i_y啵'b]wݥO(IUwqu}}mG$~饗yG&"ҢO?OS}g]C gc .{wy*B̸BE!6$VpwZݠ#kV"7lDTu'ONij ; ꬳҝwޙD8?7PsjڛcU0a,,F^{m;Бr~upKE^+ϩZ!yOtۮa Z IhůYY>suyn"d5@uGHx;jyD0Y$: IvK,Dj5s95';mhL3͔D0]N;%"΢R6wBҵWZj$R m%\2I|_n-:HI|;߰袋!i1,{1ɺV-7C"lI@N #$]@ LƎZHBA '&B)h:Ȣ"- x㍓?%QI FG.t )w IG6tMI깲9&4ǷؕB"[Vȵn(Ek,91mN!0GH&"iUW9眓=* 6 F IGGbvz'|:>C7|I-#N{oI;]s'H6pСFG-]訢SNolqDqyؤZCÆ Kl/~֋bv$RHz5X#Ѯ{|71}?pĆ=?|v}f5rq BWBEB.|#qt9}CHFB&P $Cv@ RYNm5Ntfc~EHl@Q !R>UIu'R1 K:'0SiRA ma@.ɺq3 P*yIKj Ҡ:IOKg/~@% @56@`:RQKJ\*3XB'eu[tn(5"tsB2l`'Pja*d MqQwة m:%<E!Y>nJk9M(6:ɐ#O$M,h=O!(I?Ԇ'Hk5o/In90q@a CIG/R6WI7%Q\,0B^\nz4- m  : $ǭWJE])i{ BFЄF-7٪ E}@ *ɨ܅@h-zHZm_i56oݲٞ1z3o/ pW@ $ݕL]attk6,5. ðd ^R:y͂äNd_G腿Hw)XI#s/0 2O!y3A=RQH讛\yi٭ޘ̟G/L!={S(ꑼ`@ $Ϙ [vtTtE~ " $M&^&M$M(weMBPh  T@H$ (5 [%P6uw +aih*dS9 PN =VQIU*)ؐ5Oiݧzϧ{[^ɂTK\#;XOa2I!I2)@$Ki PA!ɒ 9$+@,@I  $kM@́" Pd}.dv} @H6$@#̜K hF?@ +Y$F!4t DN!1h>d3 'd~j@ $6CAQ@HF.ZA! @!h)dK38 0d4@ 0@x  $s@@H B% 3B`|@ x]@ $[a!x $B}K!ٷ B2Ba2 '}A $cC$l&]2A! 72 @ MJ@ $Of4Bq, (dF˴ $FH@ $aJ!Y+9rC!W3Q@@ $sf TG!Y'rL!c3u@[I  $Y":'de@@H &b[Wzto'8cNY_] "K +RcwB @jFǍ , lŴ"s &a+#/9@"$K%1Ps &a+#/9B2B!$#t&C @IB22\dNdH!S0 @HF0ddJ!c0 @HFT": ! dN$DF!ÈHF0L@: dD""0@HL@$8LR: ! dN$DF!HmG0L@: dD"0@HL@$8vdN!0@HFJi $t &A 2fs: ! dN$DF!: !(d,DD!RSHF4L@: dd#"0@Ĥ@D9ddL!s0 @HFr3ImG4L@: dd#0@Ĥ@D9vdL!s0 @HF(R: !8d<D@!*MF2Ba2$ )%$LW^yEw4lE6ki,<!dnHM&!駟W~uLnV矯VZ ˴;!>tM[NH_|7\rN;4rwܡ9眳/8vNgرcu&ky]v%rkw_7Fm~SOMyꩧ~ov}gGկk?^ ɪQq! $@u]mV_~e"_YP2,[O{キ$ZO&bnM6lq9\sO"N+:C5`}yuתIGKR"=D9Rsf}d>Hs1;D9Yޞ{9 2$1@L$mm]nq}߿j%LD'|?d{o";xKn !Y-)#d}@赐D(9v%?iYhY8*nsyI"tmcetm#t->-O>$:锵~dG#BzkIH'MDH!ZI!J lIHZY9՜6o6q ]G%rK|ɏ-*:m]>S2m}QG%k{ٻ]??Z7 RdKũEE^eq_4|VYe$%o&M7>HE*zSQ,=~n,m/l|b$k $$)8oh饗NRĕ5 z#`v;^p7V-&pnYǵn2w[g]-Nidz>'Vkb",k_gP#Y5z.!dy@KH;xw$iiŝYj,y@uc L I}NUg!$Q@H;B K2)$悐̺Bo83 L!w: ! dN$DF!l.B2Ba2$ ) $#sB2Ba2% 1 $#rVj*B2@`"#aD$#t&C P@YB2"gY $vA HUn&B2@`"#a#t&C P@YB2"gڎY $vA HEj;BGa2' A $#pRHF4L@: ddB2@`"#aB2P`""YD$#t&C @IB22a @ $u fA "ɈED2Bga2& 9H $#qT#t&C @IB22ڎa @ $u fA "ɈEj;Bga2& 9H $#qB2pa" I&R#0@HL@d!Y,#3;@@]R>n$%B-Ѻ!z,S/׮IENDB`jupyter-server-jupyter_server-e5c7e2b/docs/source/images/session-create.drawio.png000066400000000000000000002606171473126534200307420ustar00rootroot00000000000000PNG  IHDRW7MsRGBtEXtmxfile%3Cmxfile%20host%3D%22app.diagrams.net%22%20modified%3D%222022-04-26T20%3A10%3A25.009Z%22%20agent%3D%225.0%20(Macintosh%3B%20Intel%20Mac%20OS%20X%2010_15_7)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F100.0.4896.127%20Safari%2F537.36%22%20etag%3D%22kh9DXfUYfV6EwJ4-k7LP%22%20version%3D%2217.4.6%22%20type%3D%22device%22%3E%3Cdiagram%20id%3D%22v7lz4ehFuV7winlhwBuH%22%20name%3D%22Page-1%22%3E7V1be6M4Ev01fow%2FxJ3HtNPp2Z2kOzOZ%2BXZ73mRbsZlgYLDciffXr2QkQEg20LFsnOCXmELc6lSdukg4I2uyev2SwXR5n8xRNDKN%2BevIuhmZJjAtm%2Fyhki2TBK6VSxZZOGeyUvAY%2Fg8xocGkm3CO1sJAnCQRDlNROEviGM2wIINZlryIw56SSLxqChdIEjzOYCRL%2FxPO8TKX%2BqZXyn9B4WLJrwzcIN%2Bzgnwwe5L1Es6Tl4rI%2BjyyJlmS4Pzb6nWCIqo9rpf8uNs9e4sby1CM2xzw%2Faud%2FrX%2BM8KL5ebX5eLa%2B%2FPeuSIIsbvDW%2F7IaE40wDaTDC%2BTRRLD6HMp%2FZQlm3iO6HkNslWOuUuSlAgBEf6NMN4yOOEGJ0S0xKuI7SW3nG3%2FW934Tk82dvjmzSs7eb61ZVtPSYzZSQFR4Kc1hhm%2BpjATSZzEiMtuwygqzjDnI2YRXK%2FD2R%2FLMM53sGFgd1iWPKNJEiUZkczRE9xEuJBz7OlF0WuIi3sn3yu3TrbKO6cb%2FMZlsBh%2B62STzdABhLg9kadaIHxgoMNcjcJXuQQzhi8oWSGiSjIgQxHE4Q%2FRwCHzk0Uxjh1KdAe3lQFpEsZ4XTnzAxWQAcznXZfZO%2FN4x6mZZcN4yxDGky%2F5HfCtyqOUop2pdzB7rtQfMNowPdzDNA3jBRH%2BirJ4R2P3MCbunUkOIpr%2FyzLE6DGFOxRfCA2Kpi6bbNXORvR56UdpaT9QhkPCRddRuCAWe4Ope%2B23JTocvR7Enu0lVxWUbnts%2B6XkOMtnsmWV3zhaKnupYNYdEh9IkEiKXxDNp%2B0fv4gKcMrPYBxUi%2B3bou0CWS0AqNQCtKnFlNTSaJ8NFlkEIjWj1swTTIzPbzTPw3g3G%2B0ZtW8pjNLN40IKY0Ht7j8bGsp3Gr1a71R6TQYAM30td5JvC%2Fr3kQapCtfk5yT3mJ82H3SAeEAz8cBsxoC1HCV8EtS3t37ORHWjgAzgGYGU2JsOjPled2y4RuVjig4p%2B6PnjX1ftglbm0k45%2BcpR2Rv03bOTlOupJUJMSK0SxUunKicPbD1iqg8TUT1BVVpynigVc17Zav2QF8MWwWGBMsJyruDZZpYhFWrNaCjWttLw42VF%2Be0S6m8ik6DupLqOl5T5aWLqN4bRXVPnXx%2FDEQCMn2ZgXzrtAwEgCqH7hknnap19PNk5HktyYgXjMcjozfB78n%2Bfk%2B0E7bps%2FQtPezeZrEDMSE4fz%2FB8zXx77uraLuj7TokHRQBd8%2BfAgIe5gcCfgsB8ySvkYB5b7knBAx4TjBkXILDz8gAGMb0%2B%2BEZmvYEYPlgbDgCAdiGd3YCCOTO%2BkOW%2FAjXYUIf3zTu4PZDzHK4jiWBoZzlMA1tWMjJcOlGVVB6lQpp8JX6VItn%2BOdOjQJbE1HewU08W15ubqQBfscw6rmS58gWcHKqlNvYhXs%2BpmjW22JFA0T14sX1ZfZUe6i2OeLicn1OZU%2Fc3%2FzZpTQ%2FnwnzGNaYCXNv6kkmHOidI8n5YaD2XRnsBOVHzIg96%2FwlcaCrCfLvx29fBxtotoGgBzYgLwHrXShp7oqwEWXwMCRb0NMOCdr2o7mv9SQIAEP7BNQQCEastLbGXNvc8W05hzx5PxSo2P%2FiPP%2Fc%2FVA%2Bz9RMAFa%2FCMDUVeYXBPCvm8H96dyH2OFRTUaf3Pdt1SrjHKuQo%2FTw7fGPEZ00uYVpSBFEa9qVW1dADfciSpSDa7DVdV2v4FfhfJ5TCiImxtYAUhDYgg9yXufTyLmh5yIssmY4SoAzzngiPFAX1eA%2FBrk7YncAqKY2TWVvVRu2Kseu87qUOlWQEin9cJPmcEkuZmMdmgOsljfGXlG%2Ffx9Vy3d1Mc9fSTHGrstfRKHHgbEBrNGhF1PoxgPKQoJBs%2FM3BgVw9CVTFXNyFNbEZd1WVklLoWxfnCkAfCUfP0X%2B5Oyo0lDlE9WX5tZnEnLNSCc61vIqR1XVHCO4fUUvNLXNqfDiwtsRCM%2B23TFPZbiVWIHEea5zwnDmyIxXINTTRvUxQo8Bxr5YV5iK6GNa7thUoAF8XXBYcnLx%2BFtEtC2%2FY0m0nNKvs20UEhfJrGb%2FmObOdDctBHD2vNi52LcNJqcpsgIGmNMGwMKHOtQGXUpAbxyIiULhDVWnsWSYtKHkyHM8cppw%2BvKvD00dx2kZvrml96Smc1Tv5Bwj6k0yBDH62IGPtnFqPmwpfNgDJwx8wDZbOPG%2BZomOlL9IwC2Lb7ME3Dj8ZrjYuVGl45WqgL7p1aUsoFtDdm%2FZft2CQb0MbZvgW7VZcbO%2BHEVzgg%2Fk6PU1wWiaJM9EOolCxN4ReXdruYJxUP3I6%2BosV7UYQdtSLhUDHWUOcZNu8W5dXgnsx4s6piv6mSLiBAq49UUcXdNGDO47OP2IMFuePbZ7hrSuxQG%2Fk9tLPijOthfUQ%2FDZcQ7kjPEEv3xUJIpCn%2FZgilgByHR6UiqaLVNBs2UmeJpC0dTF4sPbT5bpSkn2ufujwO3Bjwo5Zn1uwZa0YhrW2AGV7FYxKaovkwWOyi32dSw3WbT9lMHZM%2FX7JqMVLfxovWXW4sxJdB5mBA7anSF3gNZYac8N9tH%2BLWsSyDxx4boCTqsYU0Uw0EVrQDntc5EAIqgLwP6gZe%2FPMKlOy3CRy6Z1QetQZahCVSkhd1nu3R1Rbm824dwWDlZHNHaLWV1CBkr3TWSq50spp9IGnfAsqVm9uuJhVyT1oO08et%2B7B82bW0auBJwhPFvme%2BMkW8GoNqCmJbaPTqNccSuoHqvQYivA2GExXKH9ysaEtcZhuo2nSo23VSQRpx9RuXibHlBuXHZRBtV2VW2av9q7R7VEK3OIIfkzWPCb1PzMywZR0d6gw44xUX7I6zSN0BXJ2XcpwvxqvUt1VBeDK5r7xNN13rI29se8N8XPAce%2B4Xg4OKdbvNzN%2FA5QtoTSu3mzjmq1y%2F5VtRF6onvOv6aWbt%2FCVRjROmOSbLIwn8NBL8UD6ahHTcVr856ijaqxe6D8SbcTN1mKlzqKJovcemrTZNFX9LktfgbtMkr0rj0Wbw%2FIrXos56zTXXl55YVC1rWr0h6ys%2BKzf9nXVBk8O%2FZHjlu0yz2Sd5wCDLX5UJu%2FEzUPtfkBHZ6gfpO2T1XMKZva7xW%2BxpptKM66J7K7jKj6%2B%2FDiz2DIKa6ydPuJNyHIZvmPxfKlp%2BX%2FZ7M%2B%2Fx8%3D%3C%2Fdiagram%3E%3C%2Fmxfile%3E IDATx^] Uc۾)DuN"|P4CH|LHLJhNA PS):^mka {\wg{=A 7eՂ drVgv9漩2r=2z2yA@fNil,_(B Y' AC8mc|R@ !C) ={ҜrX!0BHNA ! ত`JBHS Hr[dR !;@inZ4r Hr[|R!hH12-A@m !}UTA@@nB;2@@ i@7&WgRJe۶mòep衇CIdPA !y_!}UTTRʕ?2eʨ+'ԭ[6mB~~mk _~yLoTZ^֭Hry晘_1vؒ=\8SOaѢExWQlY/8/'N:Q{i!}B.B*@BvZEׯ*UjHׯ_?}8X[o7|:u67V3t馛9SO>͚5SڵkNM05O?4)5Zѣv+WҥK1}t|6l^|E<!CLv>f"лwofA !yO!}U !{ !%z뭷uVҢ"{JzW*Mƍ1m4EH>T^]\veʜ^\\O?TW^yzBZpm۶-M?|1^n帟}"Ԑv5ߏݻJG5kخԐd?+u?$nM+\kVC?TةS*;pȽ(샀ܼ!˪=!F0aT&{jfΜLӿ}ɞo߾X|94MSZRzjE5x嗕"˗W /Xv\fsΜ9 G 5$X{v Rs"ɦDQF!dO_*)N:ɱ;ًX+B.B*@M6QISO=UR I?zپ}"VsŪUJ)54G U Z&M@b64ߩa%ywܸqӧjBS8MBsy\g^{ ;w &mnfy @RLIHU6wߍo]CA_!͛BinZ ҝ;w*94QIQ &iD9̚5K]GӅ *z׮]o$j.I֭[L @emǥ霚Z\΅4G (x饗 'FSMHwuPJ$\71$¨}]2Zi7n2K3SNьhX]‹"!BinZ4r Hr[|R!hH12-A@m !}UTA@@nB;2@@ i@7F%!jѐ= @ Bm}RBH}X(! ȴA B/47]V-RA@$BH-OJE@4 6BHss˪EC* D@i I !b 4#F@inwYhHA ! >)!C,ѐtcdZ !B.  !'%we" ҀnLKr!BHsseբ!{@@" 4 PDCЍi @n# 47_inZ4r Hr[|R!hH12-A@m !}UTA@@nB;2@@ i@7F%!LА #e5kG~erՀC5 WZl `/UT* T; (W5U#'6,qvT0>K'']B0lL!-SCC`p`;Mi&Ѓ)j_  8ez}0o0X`7CG8 =K6?X<[mA gN@!1j;R6!)Z "g%!u'5 Ng6` ou?3-!fM擞직 @T=u/I7GS7eܧG>&< g?O^;n8?ce[N>|X3د,~prQƳ pv/MR=1υWZi]ҴŸ z8dtr@Qa.턎-%3tbg7\ {F_~دhx,jh[;Lͨ{DjJ߾4S,ԋ̼>fޞɌA }tDp4=}F[f5KW>Cw0UИjo[OZظP7!39LC@%A`}} kS,BHS x@BiR*M0p qmy eW_/_ǝ1Щp53)XQ"!ٓǏ(ISm0py9IE#@}aZSҔf+d")F = d1p~)S஻o⭋1&~B`Pvu@l3/v dJc6lL48 !;vqBHsse*ڧBqg뮻g}6&M; [lA{ax뭷PlY3+ʕ+sūOUl&v WQ:/p=z|8_G9CZ#n0pSҔf+d")F R&W LKLꫯF U6nܨ%⧟~:K.ԩy)z+ -_<}|w߿?>T^UVUd.S틋UO>$}Qǃ>H2n M&?2">̽KkAK+&Ŀ>snY|{i4/7E"4E@l!NH=!eIuya T;v+PfMlݺo& ~AT 6("jIL˗C4%SV^7x/7IW  NV^l=Zdׯt\\OkA`rg#m-'+mG;&uBH]C!mI{@ }=~۶m :H׎[zh駟ݻJ_|BԨQv+W/"}H0`e~h hTEbθ/6\%3 !i2eOH=!5n|h"9ܾ};?VR{F۶mɞ裏Ʒ~N:J3J駟Pn]tGI&hӦ zw}gu>{6mf͚ڵ .DFϦY* hv{rez'8뙸 р&_jsӄRϞHiFmLC|3gk\pJyꩧ?ڵ2G7iӦx衇o"d뭷 UV)):w㏕'|RBB֧O%~e8prkĈjD'Q={D.O욀s93fg zꩧвe˘k=Bؽ+WRߠ@R@PFS'Oi8{~k@bR*=vZe6l§2pD}7FSJ{?+ٞ/ER"ҐEH>|8 _fM[KEhР ЇرcAW>v{"4gB.!*%&zd)6mRRSH:H&Ie;FG _r~g?)i{n{ʟ]ʔ) 4()7nlKH͛>>3&);3U'| j;UJ;TCJB53)tCaf>~ez3fL#(kZ%F|ytMiF3?b}$tO:'!F$zB̞De„ uW$@B[$CvڵkYf*fQ0]7쪫W\RzÃYRO('x|ǖUzS^(IF*9gc=Ve+g5Z'BHefB IFɋF3"}%@VJHSr+6$L=K!!f=??_o$$BXmA#$!5;sLeA;֎tO:w!K)BYlHHr+!}f3&!$StӠ?2iD^`BJRSC|ϚXX,^xᅪ8e`>ĕiΝ4v4{"ٽ,믿*?b\ ya i)InZaAPfݎf,Ka~^R+N+B=Bm, E@xJojϞ=JzI#! z-o_)/?JM`J&H:Cĉji>}!)Eڥ7#!5ӠQ{!eėi՘ӗMԌR!(d YAHAI|Qg7M[T .^eL_$4)H~LEm ѣG.ԐnٲEQOw,&i 9izKo(3 4뺮c :S! Ğ!u|;KCoB K!5$Q>W^I$7kBJ {94RsArQ|0ʖ-RXf͚ʇԕ&&fiТ#iݼy3:ꨄ3lO&rO! X6d !EMHgdD1SIiׯ2ߒ28s(VXQ N4e֩SG15gvy# )#o&L*9Hi2Ȫ[.< ithoa&-@i&Kꫪ*Xg>Y?ۋ/X7V?SEHU0&˶m۪ _*G>MbL;4Se&`g@Gݽ #Yr+]B(RNizϱх؆rKH=!C@}< 6l5jSSidcc̨XfLT4f9?_əaݸlO6gGb| 3@@p Ȥ=;GvH\ ozmNQe=|ФK~E_ճ%ƃxղzl}KE@ipFf/'&] h7ړՙM6۷ o(K2ZHg3 iTeCJU0%M 2_veڍ;n8&4ӴU·6À:% /*|{i? K;w.lk>Q_0h;1VBŒ@iFnLROHW^oRmKSO$ Ν;fuIH}ȴ=&!.b׮]\ ;tP曪"32eSB1,j7v2Hp3XXkఆw;5{@L#I:WL}+O* ܲ0"1iPxpR@.fɔ=A Sر3[$!e47o\DBJNLcM$˯HQ\ر"%eӳ>n3fP&xVaݸAMs1e $둷2({uNx>Ul&v ~3.+P(g}H+{-<x`}JBR3lL$R+DPЬ%GRvH$7."EZj5Vhgut6'ɍw)^@Sg&f@ m;kB`Pďvn9 W Fg0(fjf@SKtBHS w`B%#ZeZ.e?Λh_dNJ~"Դ7Ltw1 wk[qWXQ8Ah"!ٓǏGtyMF ܽsLm@@ZaKDi8BHL IDAT2!Bh ,h#[|nc.kI_?iF`R`@ݰvdԍ k>hi7=IHrl@ (4 H#gC /@Ӱ,r;K‹3f w9x%'=/~yhAeC08&o_@3t{!IC f6" >BɚQCfls.檅n^ p/s2n n M_RicMiB7 47]VN܃4LC1HJێMMftB0ud7鉝p2n&_0I(i V|d IF`Jtif? ;bjVR]{¤$gn&>7֣[Oҏ#a2VY.Yj΋\q)HBH )ҔC.`R)?o$+:+0Q;y|'wyF}0o0X`1u_68KjV:jPLGM&e{eVMך^ڌ),Xi2h\[=:H!MvmYGH :^Ӂ+UqtOʏ3Ԑ9/̄gV齢7_%:a\U$ 0_=[eWJo!.7&JsA 6Nʗ4WEg%4U2=M 5J|%M +TR)_@З `2O1e't <74: 44pɽ𩤯 05UI9G\;"9,|6k e`Fͷ&9> m;#V>K">"P=K 1EOH&E):H\R ! I^!ypFE::.y`hIX+Bi>t!#қscx02XIܩ gfׁ14U -!MZZ 2H~H0ieA)IIdy-Xe2VvA^h^%)™&1tYfq&Y%& ͽm~M-^$$O:LJ+/zۖhL7ˈ;ۼ_*So)OK©ަpߙQi(%::J$YML]JAr\H Ib*gP;J ɩP{2"$P `24V/2RW?ےѼN'fq11e{}f>$L+j.&Chӷ?٘3_Jr0@'3iUgjH@f)r8lQVo$ě~{tM 0ʙ$,c~x]8Z~MHHBExBg+a'!vKt{!f 玨~[G󏇇!N#BHn6 Ar\# ~&!_)| QhV狙HyZ:FQ;oh 2AUfcRKʾ"ɥB 5 V B'-j̀LjS[Yr#Q,0ʱig$V|=*A1?Sf`1jVZZ&!%)3^=[( @{DHJ@! "“yںX /Z,04g&}ݨq/'$ԐB? aJ&EI$1&Hi$v)ɗOR6c #9PRif(Z[l,3@$>$1mb>gZ\H _-VE$t)*-fOx5i'vD:ZL_Re{$PR$.BAE'C"CeGH k77B2RjHL-'II|4H"d,2!dig$,DԢ nI/LęđĘþXĚcz(RH>W^C/};IjdVG󣖔aG_Wle.$vYZ$ "N*vS5%yugd^NpB6$骩ո Ћoֱg_\Q-^t~szx2&g+-[2QI'ѐY 5$$^$g^S,OSjF?Y2hQ43b"5zj0#7M3eHo'En5TN$^f$$$Tgx ]W{%^g{Dm IWM]Ƶf>Q-礟X8}h9h OӃēD B̜fMʋkiRRx=ȫsP+ ϔ>#N#BH]nUM sMe0MZLA a4u'>&Z4^e(dA/Va+|I0 *Q>&6 #ho/hkD:f/A(Ĉɜi*d:4cq4F{jb0 FfEmdȏ&!5#}OR޷^XR=ƣV鱄#*06^K!IluԪFT/cbIp@S|G7;zvb1Q~(&qfլMR:V˅v&!$o~bk$CNa<ߍ&JF 'g4P wYQA1:C;M2O~~gR^x=W-^I="fppFV5~Y'=dFw ?U 5H_[9KYNIc5p^LD΀ j7Z샸ӟ_͌f~J ShßԐ2X~xw3b9+A3'(/d$$& 䉩_h"&IyYBS7k\SCȪ(̡HLMa:c5Ig#cu-Cy$I&)dFZCBN]f&)qH/}5-wCCj?M$B쉯UswVz;=H3ۘ2]&9L&!W佒j!7DHc͊yMY';VtŮW5?7N|f 1r~V0%^X?ng< 1$K*z"{Ӄ$ BMhJԊc g=WAD+.(!nWWz/X[$XpF\Vo*4S="XSOl$iB(󃒜d$ҪBx^Rw$.LMsR@--UMamk hiqz4!DJRxHFnWl{D@J@! "“K to!9LBJR#cW(:N#BH]NrP I!4锨eA!^1JC's3V~ΌIr]):}!uMR9898\ޏn;=H܎CՆvrY2KjEYQ/#]zsJ]zO㕆Nc!ƽ{4RZW2IN#BH]n=Esc{܇R Pw$LGRzZx*GǶ*jWҙBK(3Y.23$]Imf#5J|,l XA#5dܯYQb:ލ="4qA".B;=H@HzP :hZ ܃r4BeU*1w G1- z/khrv%נp"P  :tlRyS*GkU:4VI8"+v%Qڮ6]8Xq#Uf&YšZR Z~g$(fU<FTF{D- #}kAt2kX TmJ lZWzs^h]ef(,=Q{-y(w!q>G+lUB ,bUvOiay;FUC)a]˦44}Э'!,g=3ʃ9!N#BH]>$rp@iά>98\޷ z=<.[ht+Pp0-$ F_ l2MI-^ ϧa" \48Põ `QSCZ<׸>GlW٪!.>J@]ڮ)5`Yl lwM%iBi_e3^Z!~q {AoJz#tT5~8P^,3aZ9C*ͥjM{ͲhݎabAZ>GYٮ]etZ\ DK2%JCk\?n="R[DAk%d|(:4 E: B*!gDa"6(Wh;"־-`cd B=kV%cz_!="卐%Ӥ IDATD%c# Udi@aVdrF&z(W=Ur[~viH* GI'뭑~:pv1S97+(W`/.ڂY.AiH\D%RUzw4E)00%4ы՚ Z͈gڙLe2$0Xj 0:h\ o{osF S*qSFWc8hL+o,IBN׸ ċ$4&-l+V@ʕqx֧gM1 pg#=Jw6D'3L7rpW+.Ƿ8sf0[DetT@ FMԫqCw"'%b `}%XHefV:jA\,`ۛ%}EuUoj:B$ҕIB\,jo{^Ӄ$'΅ʯ@~ 8/2~1|^r%ݻ7ԩe˖᫯Gg =vd߯&¤gu,XݻcڵN2wu~bO i8!0%Nk8kǸСCѼys|дGY)'4.` ׋Zxj$L1 _J>Kk;YhQYJo'~T3r~ibd7 ƓX48xRg&&g6I\M4)a0uZ8ʨ<ۀS2}͏}ɢ4,.p*U(jBOq / &EG< *KD98FuS~pDN~֝lذ+VTO;v,lK,Av0~x̛7O=>34j>"C ҥKrJl߾s٧Y-r Z<3@Ϣ,X_ {nݺSNk+RuIHtf '>}Zj1n8~{yff͚aѨUY9|j=T5WRXסaR&#uU $[*:NUMX&pZڕ~8t y}!NiL%f/*ɺ-]:4r@#Y=\?p0=nRVU&䙄t?@7ƾKFN@1\ntiKMDHpM UfHiaAxgeqBH=1^X@Crph)?8ooId?zwRnG{_p1ǨuQiIO?tGm6 ߿?>tI]""]t1c .T$GŹ瞫4~>a1_=I{Lf՘@g Tc{ܣG,^*T(!wN=Tf,A"GQpG ه>둇7q|GRF~cHhQQʗ/SN2ٓ^p;ydEFo߾xzj96]+.Zxlv U ~FLB3(ɏ~R5lPi8躎U*bo?qh$VHw jR!2|]JTPEZL0_iu ᠧؕɁch T$+,uiU1d 0L4eNmjH%4[단eդfWAӰ7JgY%c\o5.N4Vw4eM? K)rm_t\J3U,]=vt;TT# I&5JtV~rhjULmoN !ud|@Ã#prDI4xo-[t(SQi^9]f2GSy,oR͌戾B() iD)hhRވIHyvrhڴ j=о=-ʠATߏӧO<ӦMUW]6Jz3WLLpC0_Q)u WnM~])P$E8d ov)IE@jHbX$8 Z \, #$ pJr8$av8Xa-M$ˈycʌ< _He5ѥ^E* kU}~DYB焥o@W%\{"4.D^`$_~$rprp}<98$TT'%%= hpx +@eWZUBHtGcذa0 l$zJ76o$Ǜn W$4Ղ~ԎSqAQC9DeYhժx8q6zuf)Pv]?zntV4aGU AF~u߄tϞ=o h%)&Z0.&|FY3eѢE1 O;x&BH2~Hx㍊2)5f= nݺBh߸qu]>\< iqV@ tׯܬ)n*<>La=0돍*,z{cpM E[ms9A'eN%{DCCjhib2{ne7͹tlٲرc kD6B VP&#Bnu!:0ferS2&ۋ˷į(%Ŷv/hE1qѕ4$U40 śxe=$0_\QT +z!VDuRJ0,WLeBfb7֦pm21@x{r[*O/U2Y'"Ԥo89H"'㧆ԷE{ݱR VR!(-ۆ,֪ 1`D ` ܹXΓY8LR!c$;ss2]D,׹@fNޫc.4l{TلM UD_>qPQ8W"G! B%BH}GѱzZܤR \]LTC>ız#{0/* $GdUIǴ\ ٛ߼*5Xab!PROĢMc;/!.0_= gm,4iN!BH#ګi:.]Yt+v%IHmlPdxRԢ2;$2*hUrIխX!U2R `-9`jƮЭh!M& U 5،wt'Ф?8=H !Mdن  ^^1!4JveqIIL/glEB oL=J?X.4K\RJN2g1## \Vԫ5J57z5c@^pM#%!BL櫲W1wwQ1UvK#͓CA5!R* DֲgIȓO>Y%]'O0tٓxLHkcܬ(Vi&X}yoV8404,0ó%Qrx~i5K]r]%+G;RGOL i@ŪMB"4!/5 mE1I_98'" 񓐚dqƊИC.1n32_i:ts BHD7mڄ_|Q+(|coo4<&44~5,4bLM !"esy$+gX98\4wӃ$r4/M :K%֋K GvfReA rTJ+/1y>XVeş;wøqT~RAgu!'Cv9hР8 dYj=njJ[=xo<#*A>)L4I75]tQhyO4)3>+%,^|Uu\J'=7ו^Lѻ RZK\_DԒllR N?7ߌW^yEEd-s4(T9QGU5i&aի2$E&1"A!%ݶmZ̜9StBH!eեJ 1fYx9x`|x'1uT$/ďÇcǎ;w*5d{ڴijlK;v,Hdߚ=; i 7sϭ kHpBr CH]:HXOAEHgI4`kתO>yd\|Ūo 6TăHPY&7},zjZ$uOyᇱl2U:jZ'N:)KkB:>~-e!Z$JI7a| VZ᭷R>vkKf?\iͶ/\PP90[A-W)>쳱yf'2 0o0ݨǬh=6iPGP9H\(ӃdBGK6k FR$ԒVTIi5۷o/ ׫؎BB|reʧ̙3G˶ +ƒ\PY#zOtht+pA={'ɜ&N;v?[n(HDQqw7 )w֭[^oG.i;<nrH ?2."/HҐI#="A@i($8%* ܲ"OuQ F4WЬ[Oz^!)MQjyhBԚRꚐ8X TĬ'ZAt8yu-^Xo%fz6&$vfԪUK}h'{Mk;& )]=H:{ qoK,Q}8"UGi!x*I2h Wp̟QZA[}f2!.w> I lBtz^[<]N%u͝$3C*EL9+P) OVthڴ Ro ʌ3@M… UKԊt|駣f͚xWj*Ew"-z.De(xZCcn׮](,,Tib<=bp$k N"дNmyOӇM6*o߾-}Qn]wt`i~/z|% 9*ɯ9FOX(ˋUXɫR^)'3^0/~ zN#BH]lt=^U?M-d~`˭j.NҞ qv/,V)LN )[mVP@&rMa0 #_#QJ 7'vJJ/K/o̷)ZHNhv>{҇{gj۵k4)Kܭy%m夶w7}f?_Q 7ܠ>>JCMwsX/j!_|\tT tG[U d)P:_cPX~<23ԫRV$#+LǨ[Oh;#퓝#y4ֺܺ{D$ ՃrVQ̀Iu`nÍ"y @8!<'&C&`ɺ329+h ꫌%k{T/@%Í ~8:\U@^8]ij ZbEG]3ӯ⹖]ۀ~5U -Tro:H=q9tE`k0RKɠHO6H$M=OSUAo/+FZHt-ixDٮb + `|AJ2$l ^ꇾ5Mp"5zb6lW!.m8}!uy' z0pZ.6̝ &m%=-7,Yr`c"aE*Fe?aMIHʪX& e"{ JRK>s14pٖ&ӆJKRJ)KرJx z0;MZuk!uAOg!? G8ݛoBy"ix$3o>|tODhH7kJf("xf҅=n&:?,4 H[Xs띾G]rCJ8bsڛO?K\2,Kl3/ Y4np'+xђ%h I)bRdϾHfYAUJ(rA*&ZhuzXK(Yop;X~[TIc-ejąЍkgGИ2$0(fk+'HZ6K~,}ְgndK@NH>1 TTXZ2YeVbՆu'C8}!uy 1^hۡ 1] Vz>@,j& %5,A%8ҌBu>0BS 1[\@FzT_D%0L>Qۜ jӀB*.PF d1GY.%!ݍr[~v)ꉢGG' {$z-xij2RUޒ/I@uSlua59̟~t٢~"I ="4 . A‡|Y/KS̓BC3/(=hL )M$\i&=7MRߔU3԰ G_h8zrglpNX C#HJێ>M)5:h2R6_HV8VȂ Q2P vH"YD&d*IP:D^c]??55D_ IDAT (LJvȀU ?D$ҤnIsyyx34'4Z(5Ĝ}Ҽ#bӃ$%6~*=+2XvoQBLô!l5fM{X bF`fw+k B*sɀkѐ' q `p󲄅eEMHiq=t`Ӄ$L~@*P~wf _3'C )w=3PPhx p\څ6& h=/"_`NӄR&Kij&ZNr9OܢL6;qz$YLj R LbJ3QBeV` u?ENp8, ಼QʼnO e*'@i>][Mˁ tC/w!q{7h4 ="fA@i($.B)k"$A@pB/:amT{OFi)t%tUj\^K@wRA00 q9+i.8CAlp+=AC5Dp杨rqx!*;TU2w ^kN^5|Jf.q.C' Dۡa%tTeJ%4]׫~wR{p0T#-E! IۄfBVr@%lEYR$.BAE'Q!S:BZ<5Հ+qK|\*zt XG8$ڋD·匤 }kiS moI)GZ7 -i甏ÀЕB+}'21 eCU-VD·匤 }k9ɨR N[$oD2^j ]hT z MT#JR[ !M+k}:H\K:EAX)$ )C)ou|\=UYz!R (,fLhXX i,dв֧嬤 <nGŗHFO@r*=M򨝧'nQ8l6` J!W3,!M* #]ܚ5Ki. с1+!-nP!aLBJ-iM|jc45ܼr^uOD;Er+7dZ !M-k̓DqɊxǃ. A DピO0 9HB_37f˲ q_T =Xzt8 p%g+*!Ie}yhhA҅ R"E8<{3Sѕ!`7I/iD$Dқ8O.9K#C KCEM߅&$HG yw&:0WzOtg 9 xHf#@AJo;8gP* FYq/f?{gnS1dNI!S R")T4HQR)2SJQ4TYDd]g}w=}ҽ{ߵw0n$teIAPTKaP0A,<"5B,)z]bmY.tMU.!{+@a)5<ޥ{nKE #,cNd)]ٶDF} *Ba׋$b)ϋ~/4Įo;`v+_`sb4:p*軉QR;P3Qt8BNՖ@z p/" I+Ebj2h"" ڸVdt|[;BIRFUq0rA H$f3 =\?:"JpRgCw'K5ʄPUq0rA H$Zr}|Mi|F&@e{-dɫH) H`)Hl<37FDI0( jB HR cjhb /nΧҢR H>2@ H -kq$!-iGAK0+ jN IJflہrcE%8~5Omٲ`1;\Еmvә H9ڡ ϋ1xw8xȟ3=NP G* us*lqe*0dRLXi> 2r ZR,N];H5,'WfR, ]i)`**!pRG uRo'!hPK) `T+ upRG uRo'v-PT})`#ֱcǰo>\~WғR H(y1?D2P OiZnݺzĉeʔAse^9'Ν6m`JѼyСC8po_8pgĈڵkXR#Q@@Kl( qR " H3SN9s/@v_n˼?ؿ?֯_ZjY#|@:uTs=IW\+Vc/5YĆY!"4s/Rn/rTڵkc׮]xꩧ0|Zy裏pQt/"\.W_x1iӦ̙3x{ɓ&rꫯ7tik8~8y<#9B P/B)˟*UA~-Y?F&{7~Ûoip%iqi4i B֬Yzj."|7nB$#tƷmffFw^\p~ g}-[L_6ܹ˔ЫR ') uhW+ x p m\2gDׄw5?'}&:پ}{̑#G_9n8y睦iB-#G1@a\uUge˖d)Ke䖑/BJx&?6mJ䛬h_:u tsh#}caDz-0ٳg7@~武.RS ;RM') uhW+ x x7nLZg lqdV%J0J+FRJ%)\m:"%?@M.:-Z?ܤH)`Kh&Xl}RB4%9v,3J_Yx=e>)#E5@J؜={ɵe9uI `*Ok'Gi<* X"BSbLtP%;<`*noѴiSZPcri?_|&OK\OʕKR.|n(~&O9vX$R ԙSEd*#>,}֭ф){59ԍ@ʥz QS_L]u}8R@@G)W@f H3S&ȥkF%}Q-ZM<~rYuI q:A29ǝNr>ηDΈ+K,Yf\δtL!eO)7iu_~i*y*0Ι3DpYAry[~N,فBW{#&\$4GĈaTb=79v%nJ~PُJL( aR@@!Ս0 6l X1XpTJî(R,T Jf?;ma6]Y֟j4d ՀhdUi Xf`X/r)WVJJJF>#QN6/;kJpHX>R) Քpbyv붃xW+|Zm2/t~o{x/Vo&/,0+u+ԏ#ҰIl4`3ƣa  zmQ0&.7|U. u =PS]){)Pj2nE{imi@뺁b5#hD 0 lXܵbRKU1@ }yKU@@j-Bܧ[PuWKLְ35KNR@@jtl4nFϜ4ZN@肹ƻT#б3:sܝ굀Ԧ#/ El{90X.6Jw_yRMgԦeF\j#-, Xpuc/lL|v/ qWi+ tlX608jMJ\F T4oҨȮN4J‡ڭ4TY_@j»9jf<+t; S1?}a(^8.(B#:rCvZ@i7p` V W!ٽu,xz;hiO7Y5kЮ];,Y$'|ogT7oo{'4}tTT KwڵkQ|yԩS?S~o6̞=˗/7mڡH0JgԢ1Y06_[I6;iz&PYiO7Y޽{qW ߿?rm@׭[77^ SƍAgʕ+㥗^j *T0m۶ _~7},\4PE( hf*N}nQW1짭՟:Mߏ9lΣ'U C ׯ}lڴ)E4t̙ =`k׮Xx1 2D! o>\r FK/ 40mh~ygϞX~=nVFB{a믿0gTV 1W^yŊѣn7&O/ wܑB ֪U6mf3fg…I@Jh;iԨi=ZF ?#jԨ{7xcڴixW+W.}_1eʔÇѶm[. _=tb"3fW_EHz H-:f]Tj4F,=MV@@z /4ЗV9q*TX/Y~bŊhذ!7on%K|7晷z _"E'@˖-`}1=]7ƃ>h&TΝ;@gȑ#G> %8>KӦMQ~}Č'/f͚/B51={lٲeQ^=tр1}[j__^zرcG>C?ʕCM}tx%x29~x̚5˘B_ ԡYf&rի C=7^K@j e~|\ۋ|~_ fwz!r_~1mYf5=p &yעM6fg> RI>Ā/BKXr72'uDڽ{wEH-ɘU C =r"Ұa2y|D {ߚNF_~ip$72-X@("gTr#SN Lu]&-V$|r _]., Hh)`JYO2ߔ?cߌr#L)6l0@J{ _}c4q HcWކ H-?V,peaQQnW TmYKai%U1@2RHheE%P sM]z@c79=zM{^F0L wed>kfK|ޗ(/wL+S $X%{e!Lro}j =L-~6 bT 0jԉo2Z4@(g!ӷE e+! & h|@J(+p%0=pƦ=?i1 zbk7V@% j0Hi; b7.g˖-K+q2|#QF~>}ڴH !K9sK2F0"tFosP>K _FNK,i|%2JeF $˟_/*Ӗxb7VrHQIϤV@@jќHسy0!moW75v0'_@@ۯk}9NF@Q>RoXl.('o PpP][B@jI ElE 1t_w+.\./X/Lo<3#= <=ߟɺȑ70B=qCh6~M.f H-pCَ߁]ˁ=kV x\Zѻ@꡵=ti C"#XطI@jEI%2i"xqRAQ%WH-q4TdԦD@jӁRPd-Nf cJiL HJOS@@jlp29wba|L* a(iZ4+ eNݢ$ XX5 HcrX27J@FzB@9d(sO0-[,)15# _+=%{瀓ɾ[< cJiL HJO H-N2'nZ,)15# _+=) |8ʜK@jj>poLǍJpb...<|ˍE65Y42N2'nt5@q7* \]8 `6 7jx]Tp}ƺܘՆ4\JjPd-N7x@3,v=`]n,MG8Ƶ;I˪J\y{%U]n SM@j(8ʜE) |%݋T]h j<  \ +y$X)k؏Ԧ+@'P#c=TlbSe`+'FGrzT" eNݢ+@= **c1e oÀ'+b4A@ja &unԦZ`}s͒WԊ" eNݢ @qc,vƖ:rZq 6d7]o`>$M0@qcFfUTs`ZrU(Z4N2'nt6&(ӭeڨ|"r+va42 FH=}4;{T2T`/[}P{EPd-NҤezh@{i4£g#cTiQF H=n\ `- =Z'*)|I@jѸ;ʜE)Z@G뙊5fuON(rc1 Hm:Q1hJml^͞/t{JD"C[K.A94СCضmJ,\b}hDHCSwm00.4ĉ4zȒ>بԦ qMbtxO@iE+|FREc$1֬Yc ;1`\s Հ??~$of3ŋӧ={v[&Mm~pB9suak%Krh޼9jq }>I Mja$K.Xt)6n܈9s ]v8qlϞ=q1x3*Zѽ{w+}@ 7'# ]u@=IO*(#@/JaTZ*h7QΝe#銀"2F2۷o:u*^~e$$$`&I{pxСC$ 矱k./_> Ӏ_|=2F۶m EZj8~I/дiS ww Lw9NMq&jWe4d @q Gt<:F.7E5EJe6l@2eW6mLO#mܸ~r-RJ4hPrѭ[7-}7~СCQ\9;v4%lٲ)S0x`(XB;Hi70==*~)?7p(^zIUWԦ"@uq ~j1dg&Ѡ]#R$^}rRm JKxtRc!Cճ.jaҗYrb`fɚ5kpC Wv .p|RL^ٳM(.Ñ#G2j3bŊ)֭k߿˗M4ŋqE{&9~ |m?1 Y M͸eсU c ~to…{P%:7M,ثW/T?\< w47h;2_~&M>.]l8پ}{2hH,+eGy첯WN:e~?8q=unvwwvRΛʼ-j?7G'郐U#*UԅGnPBj|wЗ|`@v H+Xwwg{pAn(]i|7$ "9wupˏ?h"SP/v6Gq~C W ǢW@+uxҟs{y͑ǀ+Ձllj`D^ <6sRˉHZX)_J|7ߠvs"u2;y"wO;w6;y4?o^*,̯㙋rL!#3 % HYq"l._D@p2_t̗Xb=z4Rgܹ1|X77B1-[ӄSi0ߏ:!@8r;;nzXnPk @lR~y<y|p@@HzϷO|]c^@ڵkR齧.e$ 2.W?c:4zʼo9 UV5b{$nlڴɤ\KQkGEy$PJV|Y:y@aQ! Ms{0%X 74ˉ_Q4M49%C8"E'wS?)0tϞ=g\=FU8`40ͨ'lhi 7^Oh{3HxztMY΃Si0ߏ:N2't4@WYHz-s/qxw懩|g 6B{|Ŵ5k%Jo49" wP;i(/F,k ll`,PQɹ_`3x{EBS79O̩x<]wK7%(~嗤LzGp1yѣGMˍ̟#H͛2Jݲe0J[jOhh>rY6]cD@ʗR/%$0ʔ:E MOWc=nft4pzyrŐclT=~7ywLqH8|+uLず=۲sKoɞ}22ϔjFiK{7A~g[O?IOa+ ryF/W RӦM3݀K"3{?E"~lU x g8@ c([`ܭ<@A@!C S!5wU}.{Nmn qY;H <<ܛȲĕ"٘32R(K(I\~'L#M Ɲ|U|T_P\ip[Pd|H1;]ʫV2ҙ&!M. ѓ'O6ʗ _xiEHH!eȳH:<1Bz"A_Pd-JɛL!%|T"]|s5W2S^RpYy@|Gj`Vp@PG7Bʈ4,)wջMο.ٳg'!ǣMS ,z9pѴ4 q̴4lo=!(pIQ4L MKjIdz" H3sy{`+,jI+@(! Ut;ʜEl9MMp&?Gc QRsc ܘߚS$ચ@ʽ<w[FY A吚ji8 d t\X8ݱec$,W^yY̙gA&6BK=+ǁG4./9@#FyMZG;)?rY?/tga.7OX!M/B]|iȍL\nbD; a)c֯MMwٟ5:?d(sϔj>{>j}rs)WfŜPi)||c/y|OgvSsHkժe4hҪ%qt@7343fO5)\@n crF%ƒ4B;ϟ9= u9@Ke9Q4=Ý细18qy %~ww> C(hR$4=̌ :ʜE׬@{>b+A8<7.tHמ<' Fol:^z\ ?1zX:9ςL~Nivވ€jq|%@ T5]xo |@RҀaq=w;i;,4hkBYx( (hX=Oo8ydX5M͊z? @RqD}92#꣭; ^iXt`@rCe-b@1 Hc,4fԎQKā? $A h?O倽.7EJ!J6M7 oqދݜM*aU @ĿkֆՇnnQ\z{BU@i `Hãsx).{~ynZgT2,䃟ֱOrZ<)}@E \ /ˊ\nHο͏#>X!p@bP) M||x|=P}E.7*ح4@t9 #dIFF)h72%U@Yvl{yqqo:xaǾDLhiݻPz S; τq"H gΜ1s)k֬aS4es M|i:7r_q(70O-QQ ; {< VJJJօHxs^}\٪*W Z@J?^gnt:7v#̓A@jG  rw{_~sdTJ.m!<5*%Լ x0h-OEQ献,MOL\ iFSpf*p9\qwMQT nFbԓ 9k5խkY/ʷFmc= *S P%rsJ~>bt%`- #Ν; 6Ν;%K9r f͚e3[lΕ< H+qZZUUn|0W:ŖNlv-rw460%4'S"[ҹ7Kv/*2_y͑xi!й gO7z.ҪQ MM6&B)7:݆TKGW1R?y.7VY4'vR.HYVXa]|hҤ  mXb=z4~G,^s PƍCB,oir ;v5k̒ᅬ 99˗Gƍk׮6lkn~ZYܹ3^z%f\HSi%[8p &w:Y*ClF:碠OC= FKSGO ڐC) \yOp6x*;RE@ ]4Kn-'%͚5ڵk~ԬY۶m3@()S/3GA [nń "3g= ,~h.PрZ `N`t hUҨ%\ޔp? *Ə1㉀4,@~&0._Z>> ݰafhh&Tf=O<ƞ=z 7oƘ1Ea4DLl3Iݱo`2994H4 (vgH&K .ɓ j *THHﲏ88xoNWE@`z( ω q HWW[rJx;<= Kf@ڴiS*U.YCnL(7.u䊦eQ ݱ0nRK@l$ ) q Hү~\4(M$%9EH̙-[ .l62 >˖-Ô)SSbժU)!%`?S Np%+ Hs Hs\9R+tZ[o.,Yt dɒ&ϔ; ,hneڷox+BAi% J6W Hs\V)۞r7n:P ̢+]o*(4n瀀4>V@* Hw-K. @3vtD`Z㝗E*]5ҠstEEHs9W@y5fW6tlMnI@A@힙ViH94>^@* qFHa,kǔJUSiCy~E@. ρ Hs\R+r8T"8|2Pw o4,2:i|4>U@* $s{y{%#q^/ʣ, u ҰI騆9Wi|4@x|7!ٕ貪@@3Nlv.x{\:D@I#ח4rZG'i$Վj_:4,W3?bl3@" DָoT@C, q*BiZ}Z {c3۲.j_R%q9:+c^҈EH3"稀4rZSOxsHs\9J1ZG Hsd9W4f43bH#&uʎsNѧWZ#]\(Z-M:fh^KR]4-FPs_;-[U6lŒ.9 R}'ԾcMP>_ߝr@*@@ #`D8#{םkWCK cgj*RԾ]@j߱R՟,$@@K?~~u_bi U HV-Zq< pTVLv[xer ,0M>7FTysL Ϯ9ix% w H;vѴ\@j+'9wO4kv `ZQCc@{# `T@tG16 61G@j@1RC?/ eA'1䖟zQzǰ2M@j Z4(Myx-zF922R:~lN@q H;hZ.  =pC/Bj~n۶ 'NW\ .Y霞J7FUBV@@vi@@* \=N}. HèۀݷY̙3Ѯ];:tȴ'O 2?pmpB9suXtL\x`PԆyԾ]@j߱0bw}[n}ڶmk"| :tٳgQFa1gϞ8vFlėO NaX@ЈT@jyR]4-Q'O (tG1vm(U.\ҥ 3fSO=r8p Vjk?~&M`РAx0f=|޽{#[lAa*] (*ϟ>,͛`غu+4h =܃#GVZ֭j7l؀ƍ~˕+ۣcǎx0p? US@@n6% p&, Eri_6h>>F}gHGmuŨ^:/,Y UH;(_Ӛ5k RRFI 5kK.F2}2]QKROVOTlz{nA@ H;hZ. ?{y!5Ztihzv~m5kL4Z:w}'Ǎg",=z8H˖-?#E|!b*v7z{nA@R }8:d0l;);!nhbo22zϞ=%JHe@vZ\s53gnVڵ+ : |nɓ''VP< eNjݺuUe{ժUS  6c&ӣ4#ix]˗/G*U 4>f v(?c6%ҽ{pfI[4mV2jذaXd իgrG^ze)S LSNW_}eSNȕ+&M@a&tc݂d@nz4P4F$vil ]ߵkפsHJk6uցK,֭['9?o-[4'rñl2SJp%Ϥ )5jO?5\¬Mظ/04R@ YiF"x2H_+#G|;};wa6<={`Ț5͑#b$vf}2e2o,Ai hHrT a6ad9aS2 Hc~@)VaS% TʨEH# rlt! LR R HäT@$ ߅T@TJp* Ri4B~RiRY(©4Lj HysҾ}Ƥ)a&n-ه HV 4T]sN84]:t0爆<ӿcƌ g634{B4LSIH( @]<:t(r̉/ڵ_m WP<։75 RLv9*0M>nogb5kDb޼y< FܹsꫯoK/dW}(رcѦM,Z\9CٳM߹s6ilWdm%Dm/+HxpS& :fQ) ?݋ eB$oڴiIw6l%ϜիmK"E*\p!>cTP?3:<3i~UCgQF^E]!C e7ח͛k׮ nNOo {*S@@ :fQ) X6h>>Ϝ9c]yQ̜9,s VZhժym.zaOeD{nzS rԵ^{PBmlҥf@a\uUgrtٲe&] おm Rԁ^.;Z-هi \R*Fׯ_o"7nLZg `ry1mY%JݻQT)bʟr>a@:rH˗/GJLjժ'0?۴ii#d ^wy Pj@ (B! :fQ)`q"8yxO@i>u+ QO.*90鈹oDFWZe6'({m\d ɗCz]wPo[|y|(W\tZ`Dy WAD13C@* udR@@90-rP/pSZg(s8EA[ha"9r)Spsw o:rrSz͛9\ urp_y@ִSR1#/ :fQ)s㛁n=اMҍcw [* iRixgZ"a 1Pi;IsfS{3PM m3=>aN@* tRSR H-$_d`ZtMn +% icس& D4RsMHP@@j868JH)#xaT>a4V Hcun.)`Rkt5No sha ,0M>02^#<9&g4 H30 H-}xPj;PVN~8i>@ݗ#d:fRLv9*\T])K#euڼ!P/(^'<:fRHrT H#=6V8ޭ/g~l3@[C&) ud H3FsXY >MK;{N OQ@jW-* HvW rHCO@$FR% Fus)q\J-* HvW 44T[ DRi$Վj_RiT':W@@qաZi٭T@j9+{@h HCO@$FR% Fus)q\J-* HvW 44T[ DRi$Վj_RiT':W{"@+0dFo?n Z4ɣR Ԗh)Pq?RiOr9(R(P,/9q4[E H( gR) @ H5=} H;{eR RM)`_R^Y.jHP@@* ,'jH* wr) "R . Hb" ) "R H\ H) ҸrB H)R}g,R@ (B9 B4.&R@ (B9 쫀T@j+˥jHP@@* ,'jH* wr) "R . Hb" ) bPO_TYTPVTm;̏V,r`ˍes H3+鋺8 =Ff" >vn T@@* ̕R@ ĖI(r {28kNlv.Y8ҼJ* :pe) ªǍ^^3x x?P|XY68yk Ϻ0(&QR 6 Hcsf*) 졀Ǎt2| ȑƇrk8P[R  Hxz5) ,UH Theih|$`>SGxvH)]@.=3un#VXq606o.%|́v*֬Y &;w\4l;wD,Ypγf2-[t\2^z%s=ҀGD_ H~M=$@@bȑoPv$nj!Ch۶-z ˅믿]to)SCܹ3Ǐ#&M`РAp\+ѣGŋ;wn̟?ժU3XnQT=R 3~.*6oo&&M֭u_q:t(ʕ+ۣcǎx={vɓǀfNpeL26lN?ǎÁPH<hٲ%^u1*گ_?&~ԬY۶m3@()sFS/3য়~2M1%a֭:7ET@ĴQ) @ @pBʕ7qlٲ?RGxݷoR$#> ݰa*" HhD H8P Ma8qgNg0;-eiΜ9i"E%K46 [ Zk]@بW@@* I.0cf+Y-h=+9b S5) :hU) BRFW#P 䝐rT/:KF.tr%G.g H=%Asx0 e}!AU`m|礀 H4!)釲8u[ 5U"tˍM]J H)zJ H)@KR #!@کS'Jj'`O\x b nȑ`~mz뭸 <ٳg7ò }Tkɒ%M7x* wW^Dtҥ ڶmǔS Dǎ <fMHjժȗ//+W4Bς5k4H=5*:ef%רQK={6VZe_>FK/,'>cR˿`Sє}Q8:B˃>h~_vm"Epb4DBA/H %2m6O?[l1e8pDߏIG!֭*Wll袋L; G6~){FYcݺuF;\`@ݻw]|Yw˞ɓ'%\M^~I cָqcc/0M8H}4XTk e2>7]wu |8]DF)Er ĩbŊ駟6h)b ׯ7 D"%01ŚDHaeďc,^xo~e[ rl2e[noep'ҽ{ؼ曍\N@1jHO>iNȩoѢEӁ43{0L ްaRFtcƺ)q@ڲeK1U `Y@,5P.[ -Z0Q;)n(k"qit8ѣ21Rib$eF)a t9[o{!'<3~f;{G2@r"r|G++ ZZ}eZj5 ֥KB )Sifpy: \J e47nիW7_4"'pĵRwdnBH+ Z# U7| #t1G %p<^3积F$*9uLcPB'HeoΜ9&Hc$5֔}O"Ay .LPF)<3 N\IzZkA={d \eOt zN3dFl3Rj#ӌ/õt#1P }Xi|. ?i!?܅NctvjuS\OI+zKq$q )הrMB,pN+tcŵFnj!%qSwPɵR̩tFn~g"\hz_-3TFg ӜҎ$%S.SʑsHg^}vrzK~0 S5k\ 5R/Lm\;J mժP07V@, HOF@>CF8}uD?Cb7&)!> `Y@,5R) @(`SH) B4Xn˟jT}@ H) r4XTkR@ HP( Z# UR@ Bi, ?R  HfiHP@@,7 HOF@> `Y@,5R) @(`SH) B4Xn˟jT}@ H) r4XTkR@ HP( Z# UR@ Bi, ?R  HfiHP@@,7 HOF@> `Y@,56Ч>W[>ȍGxT3ɌU}uD^W` @`/aXkyaYc= ٱ* 3Z/ ?#R~X*K[FTc5Yge H5 HPUed)B뎆i8z|O?[ԟ~e4XTk lӵRYQ@>g_7?. Ԛ (Ba%r||ܗnU HwiHb(`%'a|?b~`MH+`l$; X9U֐:uKMٻGH3dF GM@!g`Uz7K.F Vt'U HS HRV庥4O<ؿ?;y FBcjgoΝ;i&,Y7|3>Cvm%\[PBO4i~QETEV_rN٥rS Yx̘aZ~=jTT N?tnjߑ#GYJ: ms-bf\ez%/E*R|y?1/:O?E 믿G.]@_vZTV@:sL4m~vgd,0^*s.<H#0zUWaʔ)ș3sw}ҥKg ~.8j'7NPQR9Cآlٲ _(Qʕ< 5kĻkjժYf o_ 6)O=f̘HMm֬ߢ} ֩SÆ 9眃_~?z꩸+̿y晨ҫ `ǎܹ3,X`[bEHm*$ɂMH9֮Z3^ydՏ/_jժ4iƏ{ygU !8ؽѷo_S>Y}._m9U*UгgO\s5fdR#l>fL2&l͌~G4hڮ\2=/zK.JU9c`ر~̘;v~L-Y$z聋.k6_fȞ=7Lj#p饗ߏGy@kԨaʛ6mkժUfŲc/.W`i;<Ǜ4^_Y+; >}Rr?a^rx4}VkH)##*_}Y{ZH,A@yfd˖DI9}vKe'|b^L|nݺr&r5_|=D"K verOX}%Wr@>'ڵ+},p aiٲe'RNsM6:,̙3*; IDATr2'L`ʝ8q1`4dҬ>P.*m;p!pv,{r/?ᘼ{˟~ vRg<B" Y HQ? H+YRNq(#ŋ7QP$2R08-!MHGi"5FVRcdV3&FUciR+/|gLrR@_GS+,Had&}Ǧsʛow??ע֭;1 ,FV@z)qP ΢D4zSc=[l>_0=C&?ę׸rA {MGie[CסLP=Ѝ%r_n^.K.5;߹7H#늬ߌL' ]v}q*Νk㙎=jM:@r"r \Fy1_ѢE׼^Rp=*yyݻw/6_NjEۏ GcիK?I Ng'\[r6zYfHcӵkW3*$"gB8Ol3!\)=υD"uh}"j{Y}$ S@Z^Hkf5 嚴y)qF0Y#t=(=)iat˗… !IH6\,7H̟? 64`˨"uh=ɂMc"Qѷz@dd@/yܰ/h+W. HO/RdD Puıck9K,#\O޺uk3[/-Z@޽r)gB\[=g,O)א?!jl!N3V2㋈5d8n۶d Bd@ʼ05:tk-~ˉu3ўRI˜Zl1 k"ğKFp[d}Hَ͛5[O?>r Or&$';c@s]vhԨ9mP㪸a0 HE&7#WXa7ws#iq d& MDe<+ di| HkRS<tit)?DZ@4O<nh#G0z.|7U)BO+B,5!R+"`hsDaV&ڹv~=V~MU .BʝP <~m)Y~9gϞk}Լel+ʨjjLYW`As+ h8p c @+IHV_rnج:) [ϭV& us+ uFWRB %LyN`=ϧ)!7p3gۜ"wZHœ9s ,H&?)Q{eʔ17Fvi~ g~1d]l{ GaMu+Y}ɹeM^˖-3t~mNsjh+A8{՗[vϓ͜V& u{- _SR)^t){=s=nݺW_}D?׭[g#@N]xFW-jr)#gIیN<sł y p*ן Ha*؄U7moR=hoR{Ti+H WY'J|̔|H RRn.Nn`܌҆ Xb9r5]tA֭ͦ)nJjѢz1c T=׈2jʺyF M5BM@/g>-U7,r )?I mժڵkFa֬Y)S KX@:o<1@f *WXa;LS/:t]SNEZ.7՗ϓ\V& uۃ/ WOD3ÇO|NZđ#Gm6\tEfݻmڴ)ܹE1{ϥ9sLLǟs@U`풦@~M)D@2UQ-ڥ/71q-)wsĉUCCe V& cY- ?eoKظq#}3M_bEs8~$:jKb%i*!suX~:/ Mq!u\boW`%ɺ/M@O+B,5wX}Ʌ\>_6_>`oR]@,5R X}IX) gت4X~SkR X V& uoNժ5N)rR@Sn)z< ybyQ~R^CMKƔCP&i(F Hެg%u zk ^H|X[WzGP ĭ4n՗\0U vsת!8QRvgY-4K./ݦMMtcVk1iUK H]+ N!Nk;kmjSVߖ% dx !V_r!ϗ͗}6EH6Ǭ:& vIK{ZW<:;imgMVp,>* WOߕf%`ˑ6IVǪ9U)eU[ HR#Z}y2#УV& 3m0E@j*S H=c%zUUs ߪͩZN)rR@c9k5p[_o\@Bye/?CV_r lW >XŇV& )D@WnZ|=O6Ib8(((z#Z}9b uT\cvZvZUU H- K%- 8i\*`&`r`(1(TVìl5F%޽{ѴiS4k M4E]tr*ѱ* Mjxy6ԳaI*} 3Қwz^[&de۽X9X2 8/W‰mVX`j֬iP뮘p5V==VY"]@/ڬ6j ,֊Aia~#Z^ `-*8'XBQ` tDk* Md4yY}$ S@:t,-;| pYZħWMf Tn 4c8z G +ظqckoֻJz ?  p\xm;26^P{pewKG(C3rOXM΅.Uirnj.W=#U;@Cyv6@C{n!JV_W[`ٸCyX~2$Xw_>XMΉ.Uirnj.W=#U;@x0P+PgIs!ٳ NGͨ5v &p`}z},\c59'طoF9sw܁ٳgnݺ}7ߜeҙ3gcuy< Ցß~)4h_U@ʓRU HW^_矏:=z4*T`'\_~_=~iL4 !CWvgE52E K,Af0j(o>S}ݱvZԩSÆ 9clLtSOW\a͛7*U« `ǎܹ3x9[bEHS? Pc*TcU@fnPW@ʨѪU+W_}5/nt!֯_wy۷(}qgرcѨQ#ȑs6 <-ZHwÇK.h۶-ƌwN,X;vM7݄hE0p@\R@|T2e Xo߾Z*g} _~+WTԉ^2SҐ3Ǜ/ u\bUb|K/3f?6Rծ]SN5Q@a4@(飏>j̻˔Ք=R +lڴ E1=6oތlٲ(.]aɥwѿ|'VySVnPre9]ǎ3˨y' MCuRUiƒmvBav2e: 1:65||z4 FFLSv2ߣk׮Xx1ME#E"2ɔU4˞5kĸq)INϝ;L3=zV2TW,g„ ')pZH#|y͚5RH,pH5V4lԃNI B=̓'> mݺ@rAQӆ Xb`t믿6G5q>HEٱO\ 6`&.\Dd4 Wnl"v 7ly֪v[ʜNƪTykRo0ymDyN'7+~f]&wֿț7hD%N/[̀+J2}dTf@e|R 2COgUz)\2D`!Էz ӧOG˖-=ϼ{!:)"9 Ҡ!/O@/;XM̟\^KTo/ ՀSs]7gH=yn`X}Uj-pT\c5~yd HwK1|zs`;@v@щ%\j,ntmkWF7#cU@/*= 0B'-yn`mim(X>'I[V6kf:UiR?yKƣV,0lsiK80xc@!-68d2R[}uT2:jHRjCyw@)e-Rk[5 8g!< bN,ڢQvkqԌT" MҪ'U xH KӢj'ySw94*spdI j`}$HOKwƿ>W(8eݫAQX|1]?mmwNlN9c>XX:1+S@!J/R_E oQ ǙΨFG6ۗ'J4j Tg\ 8sUcU@CR߸JƩ?4ҘM o^־)&3J5ʷ^HCG,VVH5VoM@jK\-P@XZvCHR9r  Vt/9 T+rkC~" +dM Hm X}Ʌ]??_>~ﱬ˟j65K*`%q*sGduP~:V Mܪ, (B\՗&2W@>gϰ7?iV@@a%r||ܗnӔ?Պ:& vIK{ZW<:;imgMVp,>* WOߕf%`ˑ6IVǪ9U)eU[ HR#Z}y2#УV& 3m0E@j*S H=c%zUUs ߪͩZN)rR@@K#͐ ( ' 7i)RDTR@@)w/[* [UНV& uoN* uJYRHV_riH@<O@,=joR9S6";M@ߜU@ꔲ*-n)z< ybyQ~zș6" AD)ROPdAеt?g&\-2V+`SlRO[2VG4^둿,s׭MξdǪ49K@UȮdfr IDATH5B"VH~>ZYuY#Lj6C@ ?#S H)0`hH/ WOEHo,R HciF4XTk!UR @ H'm4,O;! R)xw i4իWGvwcٲe  6D˖-q&MٳѵkW8|?AK.ɴ?f͚`%fϞ-2[~}1^z76F߹sg͛7. -UYǪUЯ_?|'L9.4,X;v~_}l/JH%R H26atx) ii5QBA@ו"իg@cʓ'-ZDJك֬YcgF֐v/ƫj"\zWg1@ڪU(PqYg[ncKƍMĔ &Nh$ G۟QEH \FYgΜ;2\̈.vol ~2}v&,߄hK}]## ?Ԥ>`QiժUMԑ`MN[y$xzv@!C fZ?zSӏ?h ŋ7>@JUe]v?z! :/>L:մ% +\4,cD@Oמi)AD7!|Ìu~2s癖,Yl,< N|byV@@cLڵW_}3gqƦQ˖-Cʕ?$d@i~F,?CYg9s[n10 L'N4իWÇ㥗^Œ3|,0ɍN/D`imx_h͛-[6%r۷;ED] eʔ1; HVTZ5j/oF = HZ-??oIS|LEo u:u͔7xx |l>4W\x3RB\`3S"dd-(B fC=Cs*j2za'pHUXȴ}j5]"wJ:,Oxa4s-)3|p٦Mp3J<ӄܵeOY2\NSe~i I]@t!tCVS~3Z +( u\+)a^LR/zE6%@i{Mן"*z7,9W.-ōMd3.cb<$ zIzmnEU H H]vMֹX#E@,5>:4gq&3mKz2wP7IȴAיE@t}FgFrhs3R哴X@@5e/G{tAb;! VO˟jM#*gJ˕+g'LPS駟C{i\d_e?Ĩ*gź<扇 SN9uyߎ˗rh޽3rkDy=׸ SO=ƍs? H]J#>w+sj֖iuZRyDXU pA<`H($zHyg}5}=i$_vm4kL>l)h"y/3N|X~߸qcs-(s DǎqM7.7p@sH>!@\fML*4)|)nH6h!t|&MM & ?УG<@JM<~̙:C[\zYgaΜ9[ D0y}ժÚ,QR^ZLOh'/O ޼yQҥKcx饗0bAnt.[|noOVyoz/*y AxMIHgÙWt37'1}ꪫۛlقG}k"W2?7R,[t~9 rV3&F[ ¼~{φV_r6"R|bmΪ69#H=aRݼ t.]ju5t&n:3Xp{N3ZhQkM?\OʈgB 6Ν =z, U?֏UP Xң6*`oRၢpLU@iz:Qkf#@J:הrswrӁLD46lŊȑ#ͦ(Aҥ Znm6MqSR-лwo3)Ft…ʨ)QTR[ S`g5zfM@jC\.N@T H\I$j ڵCF0k,#&rʔ)fa4Λ7lLb*PY+V|.`;/jqԩSQV-˟d%;9ts;U H HS@49&483qa/-s@Μ91g7Pخ|n))Ъ)qS*LjU"B vML\K5qD~Fx?{U HXV HO&`Ѝ77+V4GnoV_r֣缣|ߛH>9ss76i,ׯ k7xcIʸ38nBؑ̇cW^ɴRUi=l}RgUW@k<)'rn{*C=GA17%rI mE\.&ޜe9?Yŕ+W[;VǪ4?) O>e%Xaܰx<&2 ")py{#=n T9nynΝ͑h#,F$9M x4ɀ4Zn> +ze@@YɺX_|aY^z7|D?}]iƜAMO>gu]سg8Q2|nhLg֭U4XNSkNCAB./x&org6G"x%rKsc͘ed@s| Ld^˔qT.\8 Ȧ(ܖ7o^&N\yZK eݻiϰaLy ӧOTvGkdu H}9\ci(B>`%z}(@<@ei5kut{X΄ NRN}3̝;d$d@4Z@('/x뭷c\ @y\ cx[Nk\R'0"{7Et1v[@")ZդLEHS&7+z/NZt ̔x_|Ѭ2dB ռV2viz>W6ekN׮]k~R;ŋs IDATszkJ#@~Lfn#e H?Yw!uDG0q*gormdff\ʨ%#lI*嵼}LNJfu*xڴi& +W.sV0#L\3)xN3HƔrIיr*7_qA !ʷx[, W)=~Cv Hs;w$r7h&2*zX)mUi =̽2iQ@@ꌮ)K7 V& cY- ?mj } >@>Xԟ~ojMl!ݱصؿ 8z(8>Α ]8,Ps@U`풦@~M)DҔIRtB׀JD.Vsƹ@@@1KT XV& u5, uEvUmK" *y9tP}o}9g_hlAV_rR|mĪʯP@g{5?: u@{5r dpd_qJ;Ǝd%g *# ۮڬM@j'Q~)m X:.U;5OҰ%'/GrX[TRm[eV& (H@ 7.j, d4\fUӁc Xn՗eT@S.-Z7xg\!ClR@w׶(f|:Wv(/9K++ nR~Zv z2;UHkZiENTJgܖ6+VK.銕5sפTU H-RϹDYT{@:9r;h pY큥c̀3n՗\+k Iob~Zss tj93 Xl^ +~o1PjRKRerUUܪIKɌROEFYP[@:#wnxBBuCCӎªBRKRerUUܪIKɌROEFYP[@:(o3pWoA#fy]6%e՗\R* ʟtV& MZzOfz-2ʂRJ4d=ݶZhRȲ- t\՗\q( { I`o$Dp#ӒR;@v&0)P>*ФLS?Z jpí0 ;V׭CY=퟿ȕe7[|~ڪ~vֶ HHOK)a _HK;$'.7} |*/%HOt\kRn'|VB~D@@,5wt0`n7ZWФ}6mB…qg$]Nflق;~Z*Q;jFLV_r Vwۗ _ibsexOe>z m}􎯁eg2s]yZM@>! ?՚ѧO 6,ݯUT P\ȑ#µ^+Wo߾صk,Y~#~jLvњO9e-8+I?jpVX T*`o`u,iH׮]kjժ6Qӎ;L2رc:w |xgPbEԬY? u +ֳ>۔fׯ{9߿+V0׫W#F@V& V˟jM@"'xL3k;;p@+W?.ǎCPFHcMٳիWcĉ&*J̝;֭[@2̙c6mƍg}6f}K.ſo,ZaTv͸{~]+16{(|'r}rLK@Ls{ٲe1ez_kW\pիV2Jup21_ѢE)X@zw)^z)]Ѝ7Xb`=jrm믿>B_wMD^5kNx@@-Y'ΰ830p pmoU V& Kf<$ dh HVFH/_ӧ K?d˞S<٬q}ѰaCZʜY K76:DkNkԨa%r=*9rhk1tИ:tj2q3 7`6^u]q-1?#ocr|,P=P05 66P G>pLLH)#m۶̙3&!!wj4SO=e~tWu~? ri$]q;kH?c[{Q㦫{ԨQ# /o1PjB4ox% 6Rq웇q Si5m6f4z|0"Qh7rSF7NsCT3RPwF"ENzx, 0_6#.<#f. _:-ek󭂍ƨ7iRA/$#P p@@my4O?-%\ׁׂNmvzROjFZJ'$XVv{W@@jQST;r%yHp}@HJgI[Cʵ6&`c)**M@>xT@'Ą. jcr*M4s375U=~EfVxKx4|pó@l*$l̎mC gl܅:vfltۭM@ٞaҤdS&+ ];(Qh5Òy̴) Yɳ+LV_rYV撁ośRJxwoʲ$m^<Wes\cM]gϞIԔD馅7k>rƹ@@@1X: &*vHȒin[jnZ8-qD L%M]<ŀ3TڌF`2 ԩ (xLؾ}''LZ.Ѩ[n$h@w^4mԜcۤId)'l=Ϝ]0X*y9LHf8zط ؾW$hlV& & u2fl_ann(.r|"@MIٔK.l1c&щ@_, /͛7#[l&J۷o7v0˔)c H|+Pd"p{F:n8n]veO.kaLj:0pϴ͆th1 H- Ҁ9T́XPۑ D<2ė\phdSUzǣf'rׯ2bHcSoʌSޜoРN&m݆;wEnXܤ)+w&ٔ=ob s:_%ChDTEP xCZp`6@-X(^^&>+J+X*PSD[)"<HxoNz$gf̙=3kIs8?KKK(geejvv/R)M"7|Ai$۩S'\UJw:B:xf0IT8NqpO!дu]+=$t t|L K#!󀴤A1rؾ-;M@6JOPҢ *B?e/7]5kLD"U.59d͕ibnݺ\=[XXr:a*,wyʉ&WKg@fT+/?/v'BjϝUmO [Պ F]*u^ .WNW^&}GE_}U 4PyFŠ++ocjJդ]W1rb:rEIu k-Ud35O177ث 'JKշ2;7 v0fG*vt`A?y/`PVVrxMc%X %d@*9r{@DFٳgwUHWR3VM^rDO9[Ђ?-n=%7zh8  H7XoKcn}HW٫0.ȹ╙֧nϴ@taKNgTFCyv0 J =RLD;MoRޝ@jhm$#Z.5kލguٱBn6]QP9W\R?y}/e$oWm<^9$M9ܔ#FT RcVJp+yUI=*J u)\^+@ ZaΟj'Z.o M~|Vs 8rR@*uZt \G=CꔽDJ{쩮e&Mԁ"ryerI"R4NRO>(@ :ԂHUu9/ ghNyWek]߬@Z&%Sq&%)%QR*'ݫ.9R[6΁py /t]J'+iprĶH puy.EId |$~F H=) U !ulE |u!bX;"2~`LYO^&$|8%ӯQԂ D%~FJ ()Sӕ@)@_x 3i@\@NFyWR 'Zj+i߿hu:"hu|myX48)Ii H}>H(@jR "|؈Np[{{V|jnW'ù,4~O .k+:v L[ ^gw߿ vxn sU73m߾\ "t(,,TuClr\1*vK Q~8N Wt>*'vEf]|pi[@Nf5sL@F(w.k2M9s@n2O0rJU_[nży_@꾦@atR %ZIKEK/uf knZ \96Hy̜9o:uTW_}W5H;vÇcڴiXp!~ӟ"++ o0~x 2Dͳn:;VEb{W)C0c Zp ԂHnu)skVIN.W{wqt8p`^rg^kU+ :>(ԝ> .R\}xmLrcTaF+C.L2G;C v Q}ԂDb( fˆ{R@^w} ,(֭5k֨vmhذ!f͚)FIDAT/-[( ~Uz-tGE:uT~D\vwޭ"%{'V^/~h77I< @[p wb %0 肍JXA\wH׮]mӦ 䠑n֢E ر;k ,?C,Y; - \KntF ) ;V@jA2%0 ^:0z>PĉO[Vy;wV?v!(p D;@k.4i;@ꫯVR?/++͛Ѿ}{L:; ͽ 89uԶF @Db( 6jfĪ ]smg'(J[~=P%t굾J$U4Y/BdZzu9Θ1{Q=ye?/vd7vX0 HK>mE<>Uxr*L rPj_.DKt, Re$nq4vw&7nDffё#G;TV%♞t۶m~exu̘14ISRVʳ:=Cg XPoR+*48@ ]V>6ͽ@7tRrMW|=` @@nfK6ѩSP\\ǏS߿-ի;ژ@Cu6^~#:AΞzcCè:i9A^IO0AKݿ,\\^wI ($Kjin`U@T^5 tqLߌp ^ zWk,v7r H( w'oR{ucI&8Iy5zpl?Kv%!(e " 1ϋ}.پ|~Y&Q @Xz#Ht Pl ]6 ؐ^Q@קҨ5ѷ{>vY@%$ 5ӀJ=@n)݋ыZm*i~#jo`q.A Rx[ ps.пnVC0|B` Xam)t4ppE?R>l.I P\Nf).? E]|yjX÷16@1n08 MR%wAyiɍeHe6:ߛ@j'Q~m)OZ{J 9kX2IπT6Ɋ1&} m!kR, Yn`! -X.))S`ߤ@`oO' H=Y@/>9 H Oj^UU@ҧKRY@)4up?v6˕R @Gop=@pA75ɭD/\ NFo*{ps^e~\ P>7 Lԁ!Hs"ۗ {u * \ v } 7#ٍD 554̡ۙY@wn!rI-+/m6/9˱ 8 ~#:!RC)` G7d=rmFwЁp⢋.p'NO?EÆ +kh'v6s]HnΡi$}?WBAAAM6ٳqM7]r% eT^]l۶mkqaԩSǑ"&.POy*+e9_ DH7mڄ͛VZ HG3f`/B|8v^~e&MR}֭[+ ݷo&N{L>}_O7׭P](7?~jUWr^< [ڵDEn݊nݺȧe:uJ_+ ۷/~?Ff~=\JtͫU ^)yR @ڲeKhO~r-я~T#:uT̜9|Y>z1o< Nk,]@Su !|!tƖq DH۴i/sPƴi0~xM^O>}Y9kNEM/^k|m.D[=v7FH+HPs@dȑ#Q ׯ!~$I+))Ycɒ%(..ƨQ*ٳ'?5!ׯy믣C! g) 6tF o^J JYROOݱcy<*J$9fu`IN \OK?3Yf!D PZB&MPXXH * 6tF o^J JYݻRO癗)(D0̊x%E@b20IS`c emq(ԱtH4PJj,4D0Rp4`/Bi꡹Vs{8t tTK>Q@WY2@jI&v f(EX9 HVLݼX<8u2V_k6N4 h&ԢPRÀ 11;>|n`u㇁{>6e,;YH]'F+@ 5=#ږ Wl ޝ 9.&8- o$_TJa J$_0yl kRr"*`Rc8m8pRP v&\)*]z=P7He6+8\ ۫yoKHm]@ yN ? 'Hjh@z5-n dtd?ROdT8ƹD |lV$\-{ˎ(CjӁ-F;0uؓ Yiwp Zs]rB*`R#(c8P!5TРE h/pԙn"TSB2@GrT p="p+?? " HhiH# H#P(@ 'wC 3@ #@"4\&˟ 44p9@.r7?? " HhiH# H#P(@ 'wC 3@ #@"4\&˟܍G@Ja@ޤde8TRR#4n6p0 9P z SH}K{@ꉅ4e 0B2HҔKRO>(`;T P H- nҀ8fZV@jY*vT Wip}WpqPŤT Ps 'H1&+ޡmT P $!H qpMkRr"*@ HNT FpIKB2 G  &%'T \8@D51Yr 5HF _xlV@` P@JmT 2 HCIkQIENDB`jupyter-server-jupyter_server-e5c7e2b/docs/source/images/session-delete.drawio.png000066400000000000000000002545071473126534200307420ustar00rootroot00000000000000PNG  IHDR wsRGBOtEXtmxfile%3Cmxfile%20host%3D%22app.diagrams.net%22%20modified%3D%222022-04-27T13%3A00%3A38.997Z%22%20agent%3D%225.0%20(Macintosh%3B%20Intel%20Mac%20OS%20X%2010_15_7)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F100.0.4896.127%20Safari%2F537.36%22%20etag%3D%22s7VvU8-QHC3Mw98d8tYS%22%20version%3D%2217.4.6%22%20type%3D%22device%22%3E%3Cdiagram%20id%3D%22v7lz4ehFuV7winlhwBuH%22%20name%3D%22Page-1%22%3E7V1de5pIFP41XpYHZvjysjExm92m7dbstr3qgzJVtoSxgEnsr98ZGT6GGQUVEE29CQwwwnnPec%2FHHMwAjh5fbkNnubjHLvIHQHVfBvB6AICmApv8oSPrZMQ0QTIwDz03GVLzgYn3C6VXstGV56KIjSVDMcZ%2B7C35wRkOAjSLuTEnDPEzf9p37LvcwNKZI2FgMnN8cfSz58aLZNQGVj7%2BB%2FLmi%2FSbNXOYHHl00pPZk0QLx8XPhSF4M4CjEOM42Xp8GSGfCo%2BXy3jL0ezGQhTEdS6I8CzUJ3i6mI%2F16ezD3Yfw880b9hhPjr9iDzxBUeThgAzeOwF5upDdfbxORfK88GI0WTozuv9MYB%2FAq0X86JM9jWxmz6mSne84iBmoGqAH4xD%2FQCPs43AzGdRG6o2qZkdSGdNzn1AYewSJt743D8hYjOk3iY%2FNJEFPRy%2BFISaGW4QfURyuySnsKLAYJEwnyV0k%2B885wlBl5yyK6Nps0GFaNc%2FmzgVPNpjs98BBF3AYANMnX3vlkY053bi%2BeXfzcDOgKI%2BdJRkeRwlUEf1q64rtfSPfY12nV5ND2QQCkERcMY%2Bdw0Q9I8IlyIsYPHquSy%2B%2FClHk%2FXKmm6kofEvsBfFGKsbVwLimc61iHDHoBeQDHNBZvnu%2BXx4qaUwDeENtyOGt6bqAN5DhDdqCWwMC3u9xjKYY%2FyCjI99DVJhlvEK8ClzkMolXmGGl5ambT8eWB4fKsPixeEOU2KEps0O1NWDgVkOMlk7A4WH%2BXFHu3kj6TaLpb8kJGli%2B5AdT4%2FtztVxTkwJqDnRmosnUW6w0R12rRt0JZwx0aEihFdRgPLYTNSgrjMAFTeBv8pZoioAPJXjrrcG9nXcbgfudM32NMENLV%2FSeIW20hPQncnv4leKsW5TO%2B4WzKUgcuSSiZ7s4jBd4jgPHv8lHr3jHmp%2FzDlPHt0HiPxTHayZPGtnwOKEXL%2F5CL1cMtve1cOT6hc282VmLoTHYXBW4b2nCkodCZGTs0ce%2FVgWoXfTdWSXqKyjFVmQjvApnqIb7i51wjuIaJ1LZ7tSUEPlO7D3x2VTjqAOZ2y6pwZygvKyv9FlKyYLcQTFrqxNlykyBxj%2BGIQlojNYSi2G1ZLowkFrKvVuJdyJfqdp2Tc1O0e%2BJZtsCfJO%2FfeIzoIAiybuXdHO29j0CVgir3cs0gfXdNBtwZj%2FmG7A%2FrGIyTZa8MUdi1EksMhd0NKj1PZFpE0ek5h%2FR9Ewoml1r6Xw%2FjK6MVHMehsAXrr%2BkXo3ufM29H93NXd5mb13c%2B4hCj4iZBiGZVxSdZ%2BYvpc6zaXZIy42V9GD3ih3S2248qr0lQgBqVgs8%2B9D2WJpZ86FskVdMJWWbTgLcLGDJIb9GPjGnIl6vB5j0KDQV3Sw4AJ2LxfqA24nirpSoc27O2HUnUf%2BO1w6C2bRODDO0tP2AlnjkiEg9LuG%2FGStoQEFDZr5DeGf2sPACTlG0mopSOzut66RNo186YZ9YJ0yoN6sTPOJF1Wgq2aurE9mi8JnphGW2FLn9hcJgswZ%2Fd30Bcdv%2BpWfAr%2B3qkjWlrl2%2FJSbxIiEINlOQOc8Fu9IxxYagaOpvVEVVwSH2nmdlFrn%2FukVNunMccxR44ghPAWqyglWTFAraY0jK2elYbe5g3%2FCRrprnymsAXUmrhuladXmW5NnZhbli1prLKs2ViEeYi2DlrAunsbX9HbetSu85t5lkxtyCMskeYVR1aixCjHJJdrXVPHqizYSKFd0qLPY3o9hQK%2FUQcNMCSMOd%2FKBttaLym2frXunbChoAzZeTiaZhOjiJnXhFZKCOsIteYyyhW7YyBDzKaX5XMCEbKqrVYTgBxKLfhHboADWN%2FS4YE0Axgb3DxBTbC%2B6d5dIL5gVYtnZU7tXY1WG7lmbw5KrbYuNc5vw5DwKslgQNQbXbb3ul19BsTiwk1BLEomkSsWSDzYtFXADPtG6yRLMdyte3dt7dsFfr7glBaKubLFmNuaMsGq6WdPv%2BMlzysVinR0lCXyz%2Bq7zTNsTqv2Uptt2hf4CifxDghGMRugV%2BnJIY7ACn0AD9QwMqBidJ05QwHZTUUlpbWTdOUkvtaUE81arKMgesW%2BfopviZhhWnadJLtwstC9vz7gPXz85dYXSzXwpzmq7Og7tcDgR2j0rpETpQdxUN9qvH0zj1yupFEEBt8Pu1XAbFmkdlVn3axIbklLHjBdXV2j3iMZMvOfYh7xS7kZpJeSaLVUzgCQrlkzPLdtpQAFsliQ6vA5aoA10nN0aN7vtue8z3o9ND%2FfzhNAxqB%2B5qr2gYSMqcKz%2F26hQ5L46OgcpXR3tAx6CtBaKkAvWbiTPsgdFHJjYlS0NEGI64JtTLd%2F%2BbAMbma%2FNm2idQsebd2qv%2Bphgh3aMooj878low0YdQeE1UAovsTf8mYHn%2BJ%2Fx3je4nL2tsPXx6Gt19uXczx9p1HVGMPWoFLdIo4MAU0BJjD6mI7FNGGlD8ZZoswvgY4iePvujQuyijAWPRLN5SLEMSVVgSUxmabUEha19sIqgoroBk4D575Cmp2bN2lCwhvLu9e%2F9QiD2meeBxZvFII7EnUFS%2BDcKyRUrVAGFevZ0ARMoZsvizBENhdb72UrxEWtu9TSkCOL0B6aJUtr2p1Tf62gnyfmvx3Ypc9sNmgUvT0zT4Kre7CYyTE5aXEtW3EP1coSjmuExCSOfDPEcCnP2uk6KC4s868dSUFpNPyUyiQnTOTGXXbg87ZKbtId7ZEJMElEpVtk5HTNL7EbvWj%2BCliBXJL4OWGoT3fGhJWrHrmJegzVeypLwkK2S2ZiVA7KTreX35cBVONGD%2FPsb2hC8W%2Bcde4EU0LcuX5S6PS7YBkXlvRVW3vneii68ZWoYybKkHXX7%2FPQhxDMgvUfeASsQY53KppEa0063wxXBn5CMnoDqofkJJrXLXD92eL5nsjkx6TyZpY8d5dFsd2UnVSF09rfK0XlffXLr3O7ql2NdmL01te8sSlHqNSucf%2FXqjXOlAtQe73EaSfbVwV0ZfqYRJhN3B6o4c6FfdMdQI0GmKVI10S%2Bt4ZDf%2F5xUJA%2BT%2FAgTe%2FA8%3D%3C%2Fdiagram%3E%3C%2Fmxfile%3ER IDATx^ L?c[leȾ E)mRb*ID(Bhf.dwY1Ϙy3sfޙy0Ͻg|<}9):pDDQ@:HyA'%UEDQ $X< A uSC=rJ-( S, !qk",  =J-( @BBBQ`!t YQ@* GN=: ,QjDQ@ zXУQ@* GN=: ,QjDQ@ zXУQ@* GN=: ,Qj R+W;SNd v9?ȝ;7n*E ,S`A ztZ̙͋3cÆ Ȑ!eɓȚ5qs?ѦMdѣ]Kw ;3lǏǓO>3g&^װaCL8 [oM6aȔ)n݊%KeٳgB (QDPM ,Q`A1Y aСC4h \r7yW^ؾ}{DaW^˗mrvڸz*Lŋcxhox7qFL:K.EjpM7e+*nNAU.7Ŭ zL+GG=:d-|1mۦތa᫯كubѸtׯ;vUV/_T2G\rGXfjuȑCSOaʕ(SFJk_*TvZ</ $hŨS.]:szPISa~[Y(ୀgB`A ztZ g̘gbI`a޽۔}q |wj7ߠ`*3Ϡu*Dp)/ > 8Ŋ /-[{/_<^}UUQ9rgK,QBBnpwॗ^믿>laY裏Tb˖-*oc/xz^~i]vx裏*O?*ŷ~<.\@cABy)GG=:d-&,_pa̚5Ka/\PY|+_aNÆ ݻp8w߿_M(u;VyKШQ#YdQs&[o?CMԄB'm_ ,;qƩ>X8O?g+AkٲeRJ!|dPJ=мys5I#\;S,^Ç?^Mz+V}a '1c(w<, q &$ 75$̝;>j+L ,y(Gǘ8@ٙBX`k `=:'PNLjdb!aСʣ@WU6mZtAAskJ~PacǎLp|7~W2mV%N2P|r4nXAWɋwﮒ-a{ӧO1r+WgH kyxɜ+ %xUf?1cF^ Cp̞=9/G 2-?uLvw&<`jw*w~Հ*0  pKH]*DoPsߥKJf6j= ,1`  ߣgTX˞_i 3$q_j@@Q]}c^$b<0})V 7(T.=c-̝w@Lף=XrҽQ+f,ѓE`@f@9:z)uLo lڝGrzzR<4O< + y wM]EEF`A!=:Fwz`B!zF#ca*G~ M.)@ӛBOv+pb'0;7@CSfr]`B+@%w_S߆%5. zXУct`(LϽ$3VXݻwO?G9'ڴiޠ+c"U> S;rN> `i7H6ؚ>oQ8ԝcRnD5X# fR ,̛7zXDG-f.H(#ƕw{^:IgRwr =7DLjbl=jD!Q`Aϓ%GC%йsgÜ9sP@9suł vZ̘12eg}r>@SرZ—_~ /"6oެ1brʅI&aΝ&MUW a]ڽk`5m৾@ǀCJ^ܻٝ+6|pj Nj}vDpu]8 SȘxhDX## ;8x ;vD_X18qBMjժ^u|wXhҥKX~= :4<i`P/k{\o|ɍa/FIg=4,1fa`G?(wމlٲW_U= x,0vxW'&%2 SO})#vUNŋa̘1gC [XJ`N:U߄@JLxhҥ\5 ~H2[o?6XٵU z ,!i*yp0a{1d̘Rɓ',&1"0? z+ /`g Ei&VD< ႅ`N2 火]wp>h׮ZK(꘠, ћBo+W%~-F *UJ}=nSڷoK fY& Ֆ ,T1 ,x {fʉm&z L"l5XW\J3LE@T֬YUx&0*x8'DARbBaL&f… L f=`!6XQM z#GGEX`aر`:sV.ulNt =1 0,0g,p?zZCBrKK',4nXmLB s~̜ʥ+VPQ,XwY=XQ@v=:Z(‚՗N3aR+=HM3@mۦVj7HC`aXYટ&M(-o0? epSnGAo<`!6XHclXH`!@\rI[(‚՗zy2Ǐ|A<B0ٳ@uӽ8dN☻o0a\JLguP_.M%LtGZ& ?S`Aabҁ;*1fr.G*\rEIa\XJaV91==$o,|C>}Aa+_ȕA s PqW5w3ʓ 9bMx#7kL^&ŕ/_^m!:5Oha|ߺr!,sn.- bjjFXT^']Cl]`!D# a8u9 jժ!0r1ͷ~z̥s\enۨQ#5wp:| yvcEN*gX*S'"JIFB=jGy!t-0a'B6L% 22gow3~jBry zf^˲eTF7'}#\i~޺uk/R, i0\@@N)zT1.f W/wXCq)-k&s:-n>Pll ]!_'N,@`A}+n.4\52/Ҥ˷ \4@Y[3Rg٤"Gn=:FvLȔHcN\s哜yؐYxzW]po@0ʃ6 "#?]vx9zX&s?@oWhw޻T!|ObKEGN?Ft zXУct`}Z{d$E*G~ Ϛ /Z(n'v˺ C(er]`B+@$z*`(]ĵX# u\tS=#Z6|̭@%@E`@f@9'5[?԰"'u>4(DTUpuPPkL )  zt,flo$(|!hZ LkNvӔ Fu_j@@-0Xwa@@@,x: Kz#cja22p9-mqv5oP( fn`.@L@/3f:]xڊZ/w|8حң@O:B= ,! $=[e IDAT(^3Zt_  D#Q@P/q(fe ࢱ|o#E'Q R ,DJii'9G `YDp#&nM̿xW1~ƿQ@`!"2K#7Ppܢ( ;a! ikg?D+ v 2!O%@<)@@k&E \ yE!Q@Hh# DCh.mz+@@ѻ|"( $QDGF P]V"( $QdG" D\K. P( $Q "(q".4C¿0DQ (0A``(B(q".4CBt{=vDQI&9J"B%}(-lwPDD$ d%RD+ qɥA 1($ I pZ" D\K. P <@G( \S] Y(q".4C=9zWQzx+OLywi`>h x Y=QU<6sؑAooԏ8 lyy6Vl=aƼy3Vw; A2~‘aW=$ j׃|@jOozMsҘ]NA yq3=M]2Qp+|aB܊#.` e}to m{3':js<̊;Ÿ:NfQq<^#y 'Ha9Qn9r|ԌuPƄ}ND Ǻ,-`M]=uz*?g;p/BL6> ?O>ssuSg3㚝 G=\0C, =0|F$|̪ )@XҠ8mV9?gߪWJ=Kw8)1s< '98Y1<%he1{F̷z>kf#Npr`U2

,ts7.< 9F0߂pEM (,P1e_A}Neb9?b<.zUO]I bz0K 3gAϏw! 2ad2ɏD) 6i(@@`\7b2ɘ8ltDȉ ~&g/c9yrBcl_1C!t{N췿v"C |ˤgɉzV7xϐ `8l̓9 ?` C2!CGGc"46$їP sc! 3 GW`2ZU1EWj 7͐WyX &Px狶y{ɴ'?¦BؤS'B 8S]'inYƹ98are@w~r<1C!t0~Grzϓ5Ɖ,CwW#|5|6J1#@w5|fF?a Zm9H9v&cBK [0Ias I 8qy C&fމPB-h>sfb:K' JгDj)@XҠp'b/N$|%,0a<ggF2ja2!o\6ȄenY8MLP#0N ͚ 'yN E Pİ ey P/%~/ pg2"c9 o>J;P L`ih?X!D|GaxH maH F=K͹9sXh# h^'E \'cF88,t/0ްq"<w 8'iBW; $!?QI{ܹ5w> x \As%%ԅ̈́HaN0ݨ1&&(r|Iz,8n0Ao9|ۥ-?zϾ3isA.me؄^B8IXeE=;;@pRpE]ԑI_$@DXҘLLx)<7zң?3y EP x=='\*IEzDwy@I0a΃]QfsiUߝoWO , .1\dV I"B%}(@@XcC8VoV聡wguωWa2t 1\d6=A{hF.BW@`!t  0#{ f"޴J\ S0g W9 `*E \wd,Mg\ ,=z ,DO{i=q`KW\J`H"㎠ [YfKDk ɕ,\E#E \yrEK-%0p)@XҠ7^܅Q( \SUܗ;?J"B%v./jB{Y{fm3`o#fu…HTK)@ 0(C/Fˤh$*]sP+"S>FGO$%XcCw% \h\j7q.׸YDE)RTL-pǀLQBܚpG=R'{ynr dd<!քrgg7G[qb΃Nu8UT*06Mr<" ghp|888dCp AXãy) ˩Nl!@@.xŁ[x^QC끭_KrI04Ո!<V=gma!L, ;b 0  ,ārx8P{po_ u8Tǐ/$0̳0TJ\NuwW8)n.~Lv8Y}bIFrb:9M@i@_.ƙ(v0Sp`cC<<Q;ðS>ƿ:Ԯ8u)1B {h.'t]ā||fS߳ !q8^fn G;Gv/5t)1B(<`\4OŕrCNfw2/!I7T̖ \xp"hm[#❦D|?"n5(9ڒˉ%j[@M.?1s;jSTTGEa{ An 60R]t9V+~&=*^%qMez\ h>S+z&Y"^|ÇǢEp-ࡇB͚5p8pq|P\9⋸{}/G50Ĉ"Q=?G#j8XdX .:(ςYdɂܹs#W\ݻZ2$iӦa߾}!xyϣ `? ,!nI),yI ͐!E8*mX"8',PF +l?ńƇ˔)snͮ6,8pN59s&.\uS kӞE`fM+`S'履~Z-2 㡕*UR B uժUó>9s樷 z$u\\ղeK3ڤImjBw/Sga AeȐ!\jk_6J*Wɓ'~1Ü5ٳgK/۫glG`!Ч)6,(_&=,YR=-YittL`A#@c?06lb ݜ<}Qjʕ{Aoe=S3`dH* , ԩS|oftIςΧ~u ,hF1WlG)+WeZf659y?*P |{LlB 'իWɜ3z$ca}*7B7m۶m޽[ J^*Xh2bZ*DkR 30o퉡vWo_}U",p7qqYpf= O 9xÂj' z0.,L0! ,У@o^^=3 *Xh2bZ*DkR .]RɋL2;*==5мysW==OV 0@Xy,^}?7 n,pU"2e½ 0|L@ 9WQ㏪Lj[ B79`!xs k׮Uފ{k 7BpU(,p"lyQLP x\\ @%0=3 wMN|9!U 5U.7 ,RA3f']xe˖ o1 +ЕτH-xh.pR&,8qB@@ƌJ 4{t/vzO*BPY@a~*0޵v)pHt+^f J%Cիꥤ,DV`!mJHlGr@P/) 1cJs phs #L$,ZKw[$ҫPf<#r0n,!~Cy20 IDAT:h Cd&%@^(l<.;Ś5kg{1gΜQaB< 1BLp91 ,lPdaX+"MmtBZֲ ,pn ?p&һư鉣p"'1r2b똌K@ݡwy6alȑ'17tS _r0k/.wf2ު‡F²e,04EnCP^`Ai*k-=S'DN(5JɩL6 Ep)psi1b|k',л`9$w@JիWO"'|N}. s9FzaBĉR=!NZS/$Uu98P+ct˞ÉYSWzÅI<Нϰ]Ld\x0d \po$a*y#Xxw&rGTs2K,=z, ApGSz&ҬY3c#,p -[q>-_6AkEJ( 3=WÅ(to1>0otf qf#Q+ ·(Ro\ BϝE=aU\2dHN\2 H &rgQN`^^ z)$Y6`9B7p4!Aa{4̰$lɖѥ[Ց9"uTZruGZt˕Y Foz`\yc< @),8+KGܻzsy""`b#'e Zt:D+v${+VL%ܡ\yz˄9!s2+ d&\N ! 0*CnƭVJUa7F \n̥<Lrd0|o噳 6_`w9Ay34TMrUR؇}p; 0.4F'j& r;rNY)W`` |&fҁ1u<23.e=1`R#=&L3y  8 }LWJ܏ ̸y6 ~ɍ/Hbִr'(ME".<<6pYą*z*J@;rzlGR&fȐ!ɵs2ak10gΜju),Z$3jrd B1a%8sY'C!#z''I)d)s dk[@ͤ9ʼnz' "Ӫ X |?еU;hD r XttJ7Qmױv0;1É_c7BW _w^"3b*!wG0JG7]N 3y*6vس X=>^v  [ "Jr ^ Lk0 @EJ* 7,t@\h1́2oq v*0y8O@+sB3df(Ij FA#`;aS}RXɌK2s41@@!L\N\]_Hq+p<k*2Dg'X}ˉ3u+*#< Q5M.2}NF)@6v"@ėhٺ#NhT/&F-f nT҈z50NV s> ӵ]kw3n(sdggyb`If7 ЭpϵGy _E4khYH%? LC+RMXMWڨ8#Ucfg[ۑ0\W*f> RT:r *k[(F7Qk^HQS@`!j[aLV9f+ޒ+;WІGW!p_z߆ˉQ LaވEos>,:p>8_C!%Xc[h܃z<}lRеkWGkGtRo֬Yqi?_r%z-̚5 W\1~xo{kժ>|8.\}NHm݆… m6z뭨PAתUTK,wܡ<&{/O 6LG!O<a 7m4\|7СC:ujlذ>ҥcǎXz`/ bĈȕ+&M;wE4iԸXO$%=XHZV`bN,~a5լYŋWW\.yȞ=;^~eOxaz 6msb׮]jrfԩSa /(`(_s 2eʤB!?̧ó &g֝%K5isC)X(]4ԩnݺ>S׫w}hѢ W8NJ ֭[?D9>PT;ǦMu3<֭[_ ԯ_?/a8.`c ,Xx|{7Yu3TVMr- 8!pUmVMlܸsҥK}~7r3gzj4hjo„ HHHPo*s˔)s,| 8sBg/F x@y-6 >OmdΜ~7'xG "(ݻWMc=rȑ#H.^y?aeݪ-}޿җa@"Ś ,X.J`!P,x%89򭘅N M.yNeܸq<'Ot<ӧ SYfjϞ=`a֭(Y'5k֨7zoaa(Aȉ'nz6Cر#<.ٿ5j( b'C*#BO9r$Raa"mڴ `w>Sk?q;{? 6gn߾=13GkϞ=ysWhQa׾hРϋ+ Uo& >8һAX 0A Xb+J!,0AA!lٲEAY  Xk '__= }#,0g  Lѣ [9s? A,|BXbC  naq`&Wa(QB*+RuU1xRxk@z&JF`cLfx"N8*x=!\ `Vsf/:(PsUjș3&tWx$W8}Noz7o)K"Yx?g{G'Q@`AѪ%|A58jW`!C%T1w. =&/R쯀m(`c ,xu ].zTܘ*dV@`Xll<(g , 6']L{\`X@) `o ,~ 66t]3mpO`Ɠq .`c ,xuQ Xll?O. ę 6'`cIE8S@`XllhwY6Ɵ33efvd2rd}) `3yuW`X"u-ѭ-IA췸A}pK- MƠc[{W`X]޽wKnu{ 5B,\ p:ﳇ Cx`;RD ~ ll?Ow׷~ l?ꥤ *冄%Bm{r`It? ThZ{rX ll?OGO5 /@5:@:-H{ߊk\Do ) `1; )J ,X % (=;'dLwAf4x >5QoJ`!& !ݛTZ_>Xv7CnH(yTp[ag[28B. '`c㥴|KZ^ᠶ(tgJk{~9?Q^+Ĵ 6'`c㥤`lNP$$(%5XZZN|ziS@`AQH`!*iT`AnPP 3 m&Kw91⋶oIg6 v^ ,^޽Xll@~/J+<Q8mfO`K_6~ \xzcϰ/ T_c , 6\׿ys4>ʉarEྡ5 ,`c ,xu}aA+:| ] \ ZǨsX '`c g٭@1:XcXYXI[ ,۬ 6,d^FX?C/ 2B Y`FXll@`rW`Dop 9_ \.r% sXIll?/X80 p{'c'$O2Hh58Ɲ!sM*`c ,xY L9o>pe,; 9N;[gmRO`K ,i,VxCNVnnba8dU`!ErYb˙$ ,7a^< ,>|=*݀zY{Vg@>ݽ]`v w !g[,`` rFكʏܹ ,Ek՟4Rٷ`j2wPPuAYFcȤA( hE`BHiWRMt<X5X9YxRcf@ề"o젏oY<ھ Y U<dO=4( A(V!ECmZ`!T-z0&w?\= غ 8I Ts,R.wwV\rm!݅227b ,DL4$Y#SBdtx+Lkؽ {w9%ޅ!%n&O&W;$6H @)/%: ,DXp ,h4 ,DR{ܷ8px#phphpfhb#m@ʼBw{=4) I(U#%u4+CE  B=ʃ8ܞBBJ=N Zxe \_ TM ,XcX={ OON>{S< IDAT[nAƌ W@JJPVCoh൳ VB}XR8bƌcbƍj͚5o2eʨo۶ ?0/_@ݺu'p¸|2ҦM3f]~w\r5jHVpBj*TX-Z]wݕ2# ,L/]-`#c'x K.Žދ~ jºuСC?~.]?|Iu ==zի}vdȐA¦MWŋ 4^|E?J۶myf[oرcȑ#GX\+])`3yuW`XcXztdr`̙߿?ĴiӔ׀@`#G w?~kzY[zooM9s߾}jz ֿw^+W-Zs=56k *?>o$wCXk8 C \ŌV|j cǢrjʃ e.`+r Y0``,1^A޽! 9 Я_Nٮ]sw;p ԩSѨQs8YFELdt  P/P7nX kG'. $d6Te`$`ŢǔA w>;߀k1q1cp)%F'x?' ofYDb! zڇR,(TȂUȂ,A$R,|o-^K5\! zp"EȂ(DȂb_'! ]N! zL"dAZ,p=5"['[YȂ{PRȂR! .WLȂHF? GIȂ}|SȂYЃ)BG!Bh: Yr Yc! zp"EȂ(DȂb_'! ]N! zL"dAZYYU$d>V)dAI|K h_O#MCE G=FtI.5+dA|G~xxjऋ?HS $КWo)RD"!ܛDȂ{PRȂR! .WwdaZ`hDYzi=ۀ^G ,1=8Kt!^! @cdR p@4R&tTBBȂ%EBP ,/iJ@ץ@#4֯R PرyTȂSYЃcYHD6mEi~2`I+-,1=8Kt!^! @3Yc⩑Fv3= I7 ߈hu|]_i(dA9,1]R, y Y`|;@"ktKBF>z7; ٷo-FȂ{PRȂR! .Ww!7뗯"*< dftI-`f?`WF)^! z"dA"d!]kWȂYy50z9oxC,&z@HUS#5V:j PJ4p\ǐ, dA"d!]kWȂM! VS ff;7D9BV~ |? XND#*DNMȂ YЃcYHDw;zAtwJULR |?X4شs"'[CJ Y=8Kt!^! @4,X޺&2b#j&P%(zI!9yfu49; $dA,1]R, y Yd!Q@]?S\©5" $ʜx\hI_?P\v}4,$*Vi! zpL! B^CB4`Aq`75|Pi(qt$>GF-v${w~{_p`l+jOS 0=FJ{XbJ:Bc燒B`:Yp \b:L3ۿ ࿼*uRDG+p#8م,1]R, y Yb" v!ٷ+!ػ````+ʐ?@`DpOȂ! zpL! B^CB4VPHB.)B҅z,hQȂ,EȂ YЃcYHD! z@ ! z+dA"d!]kWȂ,1Rكŋn;?+AȂK n=Z=tI.5+dAYbYc\! zpL! B^CB4(dA"dAq,1]R, y ,>ذ ر٘lE& <(_] R߀u -?7GNL2_4OJRZnBe"nW< (y>"dƱ 5Kh#% կ戯WR@֑;N< wދRV6PuN5rv*d`, VO?hr R>x.`/ 6pPjMUu vh7~]! 7Q Y0~% ;G[f:Ƞ5|ߠHK_\J?L޻ [1|PYƕSiŎToEM))d!kPȂV8S+,d" ǶԺ<?&\Իh+6+?ƶd?6಑׌OX<8(y:P\cԏM(d`,XS$ ?*mu0[-#ǀfV]-7gZ>K?|xkdfjm\an`(ŌFI(5Fαߐ Tij7@+&uy/ US6o@!*YpJ Y\(0yd(pڃt]Gqʕ+A .DN Tmt…Y6>No]Jsq'=iP`B _`Q"Zyk={bիWǓO>뮻.|_>N<Ąyg@O[:\<hr&~m۶+sGSO= (kٸꪫXGf";=Ovv?e'k8EȂs|S"0dZ4 ߷z :uĉѪU+lݺ z!̛7|b 裏kѮ. YuF*( <;΄ ̙bҥSN\Κ5K̶m >“()}P7~T! x! A#'3v[9 {,zx 6~: o!C`ǎر#{1?8N8h܃0U"D>$E!`Y7h514YYgu*~zs=AC~`Gb+\c X6p0IcI! i_CB4. Of\q NSݛWKBNO>^aÆOƍ7ވ[oUMS.]஻nÈS?̉+ /VO9|aGHLsI&(]"ӧOyf,^8/`GK}qyTo };失4j! @L@E]SW IXr%N:$ZJk .@ _~995|\bzt~~ pk7jh+F.3P# ?aMC9V*cG6ZZL: YH'-d!y &!d~g ^>0n8kǚ5k͞}Y,_\Zj<Ė\ǹꔐG8 ༇Sswxd{ƍѺuk\{j:),`G _-tMnr-&,T@IȂ6H`Q d\S"5d7H$~z]yֺxW i$$zRWOw9wqS< C7`u߬YWשSG ɏ+,h# ! )SY0;A\:tRu4_U7} 7ܠ"Pw@'=Zd_nj3_+a$W_}U_x<43f}['cc:iY dXS#4da(`bUcmc.\ŋW90͞?P]7=x`w}o2djԨX_,<`^kDu Wk׮ ӠA0w\C郎c+^NvPd\ر3PJ&?/F6Z?f['h: Yr,>x1P6[w EUoVQZ55xXMJb^=͈W_}.\_~9,𢡅 ˥~Kf7Y& #N\ 28-BiEiuW&lv ck䛁W?fGIȂ}|3dAVek&ę$[nQ!233zsO?t~~U~.tdT"TX۷ QȂ&iHM70dg/0S+_\) PqdKf) KbG׎N1/{8&d(XS$u굷u#S vi N|tǢHyF;LK$=?|,xO"ڟpz$pL=ZW_! fRȂ 4Y]kKJɱQ(qNTxMvr*/9xPX;#dhIJxLtVVI@~x4xFFpL;,/$KCȂ bǠJ8q"2r|6m4E Xv Y0B =ǠJ曑t7x#M]B'KE@@@ٚV[T ! )Z*#Bhꔷi-. +#|h{! >4:,krEm($kͫa'Qʎ,8)! !&! d$kTܻ'3v=À8NF j[+d&D@Ȃl[d;)큫*LtMz hn,k;\B4("C DnD~ئ? ~>'CUJkF@Ȃf@EYY0^6 .nj-r&yD4J!BRT;daժU8餓obŊJɓqW_~gϞ w T^c*Ut馛Pn],[,﷚5k}xGPH]@Ȃ! ~9B"WSuQ;?=4iҥKh1p@!XxB~DU!n}RU\p_E 2,~l@7nya͚5\jIvZdddBڵn:6lPB۷/f̘?d{hԨ8TZ999 >sk:ӄ TDDȂ0N(VW~IB܏uB{9x㍨_>jԨO>D Č(35o>,YDE!yyW! T-yde*U`#ؿ?;/qLC}bH$ B@Bƺ! l `\8rJ5Ϝ93 p$I## K/"$L6 ]v"6mMfsػw҇QF'HF LBv(!q$ ! 2?,p#x >j$7\p|I=z@?^mul׮]P,ۻwoK/4,y晸Ԣ̄IBBҘa%iTERc.5CY چ I$ŋEyڹsZxǣL2-!d-r(3(@ IBBen6V'Yg mj%d&Piɶ>|GZ4J4" d!K,z??`He`z^|qF_|fd;<+C IBBen76' <4oeuj770{ %:E,mplOmRE `B 1T¹fԺ<#`\H;Af ~l:A *,olME:~T:J9[-=j,q+Jyo*~H$ B@BmFc;0# ˜,YsN@ IBBensG20d Ƣ3[!D \ RA *,f56oJjsD@fði%x0o8 Uꟑ5s]r#[ J I d! .zR$ē+4JU nkؿ ~!)/# _-R A *,66!-ǬFgdaYʋV!q$ ! 2w0{Dd8DMn@fȾƭ؍p,OVO)VV)H@ХJ!iK#N } gUW]?l?ԩs9.b|gXhׯ~"\! d^;KQzupеG}֭[hG⧟~ž={Pve' &MB6m0h <IO$`֬YhժտHղePn]U_޸q#ʗ/]]H=BRsH[u7b3g" ~aL85j 7܀[n'jm#FPƍCɒ%o_|j«ףW^8aygwJ6ӄ ODsw9h7+ /+/b lC Aٲe믃ąQ… NSSOǛow{= ;; 4PdaÆHD;/!n߱c:v{Lk׮]tAΝ} BU+ }1Ǩi $ ݻw'b>ATy5>s\h߾=nV5<(T(2D͚5Uw}W Np0 rʕC=?-bCG; M *VĆ(/TDGU-@ P~86iKV !byf,^ >&f͚)lI=1$ )>Kҕ%Od}<ܘK/Űap') /_|I$A Yե"@W,f[n-Z(ArjPco09󻯿|f^,|+V 򋒱sNVM(qQG .[oNX >̛7k֬Qu1D}ڵPmn:EL8po3f9pSֽދF)VI2|pE `\1BrGTؑ,p 0raE,X9g̘1 pgHFHHHv+}I^{5e bB@h;I@fKc"`q-+ 6m͛7W#3{5q>,pa}&` WV O=8 !& +WT$qF81 [F&|+fň?dI~82qp&Lͩk_28(@Ada˖-\s5 cNq)aWA$KķJ*yd!>!QaƩ 9Yz" ÍuD'k+BvTA f,·~뮻Nr59i.[/CJRoVd!1|(0 o\M8Б\DF6HB854~xtMq# nF)X!uFM3۷O뭩dr7o,pjS\8]np^pjEqꩧ*ǎGb)+HCu$ R$!ik3HH"aE@BX-/펋Y Yx7[dC sQi۹(Dq9͝(9o |KO՛3dF MCDod9y 3g3?ɀ@4o\{vnڴ)!Yԋ# –QʺKPhQa#Y \a`Y׊Íĉ6!q!Ɍ(B|pᇻ83*|D9 sg}ΩsaHA` F Mo:8,pC9bX3@0A0X$8p%?YغuZq*Ebfcvrʀ6\ā'!)S2\h1E$|$Lm\@СC|F@B/O2Ǫu B7U&|C N3dM\tDždVlWFt>$$ŋw#BsQ2eʸ$t1KF,,U).$BR~Ƃpc]$j 4M:볘Liz5Y0efJ32Qg3C֦4}Z\iAs0p`sELi0I nBf6DZf7k-snBine΁s 2n$,$ Y(,u6;jmLW̕$ Y0:q\44 Xdشi?xݢ=$ Yp*Guoߎ[[iРAx衇 /஻+}ٸꪫ`͚5op5?_NVI'~ +VT?M<W\qh>}lb+<[=Xo7~xvX`x 4io^~e c_Xd2;SYO<ȯI;ƍ7;OE 穧RzyvaXt)wM=NeׯGiivk7h赾~GE>z^}h۶-F\vex~C{Ur;Dn4[,"kytB\Mзo_583:pG*Gr n߾J.@jհe)SF(;Kv1tPŖ9/W41 qw= l-*T[=@O?/Tˣ>;u:uꨇ$3ӧOUcy%,fVgv/^l4rrr ñ?V-Z(i,*THٝt饗O>7x#nV<D~M_Effo,Y|&>!^zI=a;)I_|wΜ90]&1ͮ|ׯȗ.:SuիWW믊Lt_x~CRggܸqocMn} 'd%$ " q 9% !Y|8͛:T\Y$deڵP׭[W_}U  3fe{hԨ8pLoִKCL nufْJ(@ Y_88sGO?^{"G+Jk"E|/(ȯIHHDkԝ.qmK<}8s&!eGg>u7+ҺqF-ZTna~Ǯ_;1[9Cg^?ߪb/Pҿ8HkIcgTF Fh +ϕt+dD-@2z0Y`gPtd)Y s"ל;lFN";s3FL@Rܹsh'oGrs9m!mi{϶˯ \o?!J3ƷDd &Y  ofYEj<F"qLzRIvITpJŮ_;yI:zQ%]voW={*~  ,~~sg+b-.i-h[dZ^=qΟ?_P`,,Xo XNɂS 2P>35o>BtXoY`X>NX9UR%o*`9zAن p'*27&Sځd1S|YgiӦyoP|mOD k.,׹\PEΝ JЧX$0vԏS1q# $Svډ_:tZ_?e_iM=[nETVit$TRj[Y(ȯI@9M@};wTd%Y/qc\07;wVQ2뮻{VqHNevsRμ^G 'ֹkݺN_,_$/+f;ym7 Yh5 d]V8d rH 3ёQӑ93d7u̸ 8% ,73q C>s|K Zg˺O@p|uvcY>N%˭;g97PЎ " 5A6[{A,H5W&e-IFLd) *N N1@Ek'~nNЙk}ꏌD?3aE;\g$ Q U/q ia&F%2k=nĝ$nm3ҬB8)g: \g[t%N}}(Y$ނ 9ZB忌S%2h.|o}?|zH AR>T߰<_֫dD:FD !n". ┝W4Wvk覢nunBZ]y:{nH͑qSr<Ċ*iv3M߄Аۅȑ g$j&d!OZ02QgF3Tiv3M_CBA!"`enr4}+ɴ%Hv'P6,DuTYJ`7Z.!dA${+GjZ: Rivҧ9wN?͹BtafK[)p{-0lf22Qgt6}OsHnε 3[rJla3:4Gf7?+}Gvs]ْcV g L|L٦94A_Ӝ?~s,̖;R>[`de6lJݜk-dAfع2f&,ui@g3n~W4珄\k! 0%έA63`Ms:ivҧ9$`7Z YЅ-9vn }Dm#L>#9ZȂ.̴1>``N*4i'4<ˡLv1N}:32Qg63Uiv3M_]~!}.$#d!y }%!շl:봙Li t^II!f)4NADDCDY'XLi[ܷ&HvfL|LYٌgLH@ MȂ""!`eR4}-[$ YpRR3&>X&lF3nkSxt&d02Qgw V)f ݄,),ul6#řf75)X&:*eL7X5A~ %5#`eΚf8fF:JnB{{M^ec衯tG]*#TK^%3u(C??8lBi:}ToDx~_= @jE1%^OY_or5Gτ,vn^yjDc"$1L> \#dRdAmskDB㪨¢EbȂYbО?Jzj# 4h5\rA2.`u\P\kp`dn k6]ݻi&z߾}(R$ؿ? .Uca!qH嚅tفvZq(Wc~f*N2 r^9(\86^]!oNm^Eg/ࡇرc:5kM7݄|vBɒ%zjTV6k֬7|k_e,Yz!''Ƕ+@=zUKY}":>g~[o)aZjW^Qdt޼yhԨTHt}ј$dQo9XٲeobŊI>΋?~\ԖUk[i?|(&NQm<@@@5*2}!-:c=]vE>}>|:FIc5_}ɓ'r0d<3h޼F\uU?~|w޸[ԙ&1:+{ww 6Ă 5i$8'#Fc&<3믿%\6>!B! iՌ܋po;|wTKIKd'K_cdmвeK{*tdMCpj:A`YNc0M2ݻwŋCU q]q?TdrplNMp a}$(L0!1_"5'tZhNȼ뮻TB /tfW~B oqi)Z:ĥ^ؓO>7x#nV5MI ik,о}{E`#="*憶O0A:)$/ӧOW>yf$AU!纝XM;/HZ˂GLȂnX1 x PRCԱN-VʏvZ')Ie&K_Ζ, qơDc6mzcX/駸j4IdnE)`>`U/C|u֩~ҥ8㔎ŊI|BO?^{-m"!5o$ +WTD+#J/rƁo+VPo!,@_e$?ꨣOR5 AdF2è,XΚ5D(otA#d~&dY79~;p `d959LIwӑ=i]ô\˔)H.8,X*!^5HDa`-iܹj7;ѣG照971)vd87P-E9 'Ck^#CxBW&NѾ?x#sG*?tB, $YI.k#8A_Km۪$ \<ƷwyG("NAB§(f/ Lu\vej+SHkq9ň VB,@"([/_^E>GwMDM@?B$5jTƐPY dG;n.\aewOR| K/oQgkq#  pA;a`) :7C}$?폝3SƍG9e\@\ƹmn믿pG YHय़|rZSNq0:uѿ19wT\?*K2Dd>]\)U;{ wiB<&YT 8ƭ/d? Y@zMxX̬NR34;pnmbȗ Ըpo~LJoobcmUgOT;;G\ΕvmhѢLP.2N$$%EJSȈi*k_hSړѪDoܑmss:l&NmXpvܩt;rc6I06lP$':?d~VB$ ^n=Á q*l%nRO?X7"q1@p,_M_o 0}*E>!|Rщ'=Bkg9E'$τ,L!za?Ffoba7d7n?:|dۄ QWUIB#ӇKSug 2BŹRLȂ#!zdX rըQC4'9`D'As~޸dL\p\mwRBC`I'+2_?ƍJ YH28 ^n=ʿѺ[S}b`B%fYYdSv*%1t › !53o(Ԑ,W[bm*xp,@]H H쐅^zV0N\L<ٞtڴ-3ע\ÑY4G Ypԑ:(U(RҩD߷غXIV ~Xg{ WLC,}k ,nL`(`d`FR)wx9@@iwkڭgD_YAY.܋oQD~czSJ:DͭHkZ,r%u?Kb)c !"&>X&*mL7^UA!`e(4}r ݄,x("&>X&*mL7^UA!`e(4}r ݄,x("&>X&*mL7^UA!`e(4}r ݄,x("&>X&*mL7^UA!`e(4}r ݄,x("&>X&*mL7^UA!`e(4}r ݄,x("&>X&*mL7^UA!`e(4}r ݄,x("&>X&*mL7^UA!`e(4}r ݄,x("&>X&*mL7^UA!`e(4}r ݄,x("&>X&*mL7^UA[ɂsJt"0d`z>{{M l@$_?˸R$?+& ,+Ҏd!0,ઑR(g$ iQ )L,$Pl>49L5If#`,R c߾}% @x4Dx)-! dA"I0! [P~3g9s3眙a&BH@ҲeKpᇧmG9 m60EȐ!C hXviτ&(。gd5 zm%38 /y?98 7yAp&7aѢEoRJа~z dګZ ^ziZO~ws϶<ҥKa,r-^7)<。X]M>]֭knWp!wO?dN8x] >0LF2ݪ] 4S7˔)S Of̘akѢGT=~؂_].\r|SOL Iۏ )qu]'֭]raƐ[{9Yj1۷o/lcgV9&AO/ڵk.ի9_^{:W/nVwygJ K7ٲe0"w;8P#~AT"'p /qc¶XN:Ɍ!u I/\{5~b??fY mk娣2J3y w_N:l<oK2tPJ`u<ƈ2d>׀ Vc>\r% &L09s\uUf"dRѲ+ȍWFW5 < ¦Md} /a.xLI'EgS7ѾbX㑰^IKvs@Bv˿@ef矍ĝzWh.cVDrMH"?xbT.\(;v0FE݀ ς?JjDjQK|?ހB_믿\rezMx̎8ЀN;ܛ }"/1g [hP,~.]sx0b߼yV K,ϡk?0+V УGV^m0zFSdNֳ#42o<9rW ',XaR^-qgϞ&Ea20vmU-XDްav]~QD^(W\q ױcGWt#0U۱o׮]8 *ӅC@v?T\|ȥA⦇zp^Y ,XXxy/, af+*IB2f͚ Ώ$Ѐg'Onjc$OԩY^Td%H^;N@BÆ Mހi#rw]HO E?$P< 6hs@$ qU /dΊ7`7.UCGbddB4q$&j68 Uka0'0"ceH#o8ꫯ8>Da24>4PZ,X`) m;=Ixl"\Wti3.0Ѿhk ㎌1ǎFAE[5~wwJPoB2bIcd{GdXRHZ$)Aa{׀g6l,g`kj}q#nY̞=x2p;(P%3@ s(2mqo ߜpHor!pk< ȁW3f0m8 s=t7k s5M 4&C>m۶E'p(vHj/[W9sѢEAI~kQ($=c1'iQd38 #)@E LNded?. t>\ҙp&Iy9;mL2b{Z&q{&lVtM75dMk/}Oe{܇@"ĉ-?IP FpХKs9`w w8ً 8㏦\ք@6s@cG`Z3g4NS4sO 8ޢLQg3v Bvr=8 5ef   EX,Xr!Gc9Bk ?pw@'9``R-6@sB%a*@z?Ng dZT l$XPIwB,dDJ.+~rpsVÆ M&_,IoL\3n v8Tp6_QD-vYkos29֭3׏˦n"H{BB~R-K@WEsdMKW|RB&B:R`Z_k7.~VҸc&x^uș? LpLDߚggͳ=zM\ꪫL,LxƏovM[~L%kv,̊s9DŽN(Xb&g/O VI©( )j 4`!)X^p1:>g!*XǕ`7zT&SGP"/b{%ɏpȂja/@mi(H?SYH[?6{Q?c:|dnU{ycͥ ^dS!miC~T(XȏC]V0!ˆA`T>38զ%ɰ =`!COYXrΟIWz_M^i.o*XH'=hKL #)d{ڠjuIp`ԩFF@a&gTR%ZQ?86z_yk:|eo*,0!(ĄLX9@B'2aI&.J,yAmVrԁlq' -[LNΨ%KY4T<>l3M ,Q` 9)XpLNh Ā5k: &M2s% Av,,S.yR,8&'K.;1qǀL-8^T̍7 +H,!' )?LbD&b۲eK{,_}UVM F8JK9`!)XpCN S4rOn@GS 'AJxZ$"GOw馷Wx:+D) f&8QpgAӨ IDATiI={4GTSZ47䥞7䤞V2aQrfkҤI~f\r%¥T\rY` )XpCN SN0L|s1p$_ "k[|eR^5j(kxƎ*XpC ܐ,{6+ŋ7`ǎ&$0r{iӥK7:` i+XpCN S*rU3aԨQڵkI1[.xƳ)S̘1ܢE Ó#8";䀂 % I ܐTrGmVы-2p4dR RS_ހAVj^zNC͟ QP)$A$@L2s۶mU2kך PNMsC ܐzWΟ?Ls1VZ^5zf͚e7nlZzШx ԏ,Uԁ#s\#Db CeΝDZpSi۷o7}{ ˗7}k@rj7䤞7o6  0WTLZ򣹴9rH,YbС 9(Q"-k#瀂  ,$¥=+@)| SN5\|4{(fz-r H83Lpj7䤞r~i3nܸ41FGJ,iέޚi䀂 % I ܐ.r׬YcðaL|6m.n6ʼӦMCkٲeC ,!S nIcrJ7&M2?7MnL+VL7)l2C/l~Y8mJ@9`!2H p)@ 0HJ>}KѢEͤ|]weҾ}zn* 2}GԦO&(RԳ2IŋW_5dzf>Fɓ H9sie˖&O ڐPD GJD(R smcƌ1~kyM7Ptiߺo6l8M͚5MmMsC~YpCNYpLNA"={ L `<ңGB nTrRF D>~xC! @!B MҤISwj ZP,!' )&aDILH RBpW'[>P,!' )nٲL7dvD m;:/hrp@` 9)XpLN.;c 3O2Ő{E;*g( 40 v.tYi ,P(QHR6l LQƁAа~zCYny a0(J2 oXo86)Jd%;jϋ¹LBtGI ֮]kÐ!C u\J*.{7.mVLDIrj\ĕĄjc=s@A90ìYLU\5ҔZj0Ź[f,4@= lɒ%RreCiJ|O?(c=f ۷o… OBΝ^rMsO,8"O}^)nӦMƍ˦Mvy ˩*۷oh[e… Bnܺuk̺_ԬYs7;c2؀+Ѧ}Qn];v<_Ļ=\9rzQ'sF‹:X^Хu+b<]w[NJ*e믿![6``GӠP䇞f-X঳:HvdРA$}/l&{=zűkc`03[~A< x@m۶5kQGIΝ3pWHϞ=eÆ f$6o,쳏L27k0w}g<'J#oӦMzFM1hVq+b”!]y.ծ];7oޓ .,F9, fwj_! z`A0{lYtTXѷo_Ylƻ@nʕsϙ=Suf&`{5uǕ\R%ٱc H"g;v(]t1 Ai hg̘g70^zKn&_M=vaOʵ^kVL}`DC*?H͏)-q@u%_ >,8~gw}wiӦ 6 ĪWc`_JP]Z PX\Bg|06Vaƌ8Rz` РAOaĉ&@W_  ,PE,'TA~Uۋ"s"2/X;0o<=zy 0믿;VZrUWw!s1, IO|:th.X5jTyE0XA=TWؕ"EO-첪\X3_` F;xkG I7N!ŋ7?G} Hdr ,D4 bbp &ko^x ˻+u1 YS%!H(by 8,XX~ޛk ye+b@9GbaGXPa x–`X,#@U4W{`ABTsPp-@,?'FhK/Ny'Mr X2'eBs@Oޭ6˳= Zj% Hƴ`2 @H$r'枳pGĬ?XHG/#,X`0V,H,?WHX5!Y/{*~iyU_669v"#m%[G~ 0GLmfbTOGg~1kSw+ɍ y $̖Xv/{䦦j?TBdo~ E+.p=UF_9`0aQ )Tk%RE[Hk,)2<@g=$RKMIҩgi|Ц ,k#2(RH"${~fCI74;9|6Ү+jc M Xj""G<} -yˁoJmi i֖FUWԦQ>70{E 'Tz9d sُ[i|VMWԦCmq>XX1[dX_ȱW58h7.˩,V&_JɁڴt4m9l #/Sۋ4^kkIs`ǎ?HB{wBE4iILIҤimo&{*Us&߉m[wBGm`a4O2%wӖm_%/b\M;vp rG].]*f͒.۹ࠃz*^ԩ#'|@sѢE}_paojժIȕW^)Wu]'˗/OEWٺI亏E*M$J=hp9໮$`RcO>*8 ҥ\xᅩV{L6MWo|ƍ϶<裆~ZntE4"s"rvѦMٺuKqN~+C?T$X@A>rH)VYpB9裓jkh"9$IZUdzo74<]Wi0?%Jn:3A3Y+/C~`_bwK [S際8h ټZ|N, +E1-^X7o.Gޥ,W^.m)VԩS gtI2w\ySO_~Y*};|.[1Lh{ yP~k`U{ٲerE fdڵkg<7ppЁ\j֬{ЯR"%s9Za8໮$`R? UT?P49%u˖-һwo馛»٩S'6lh_ҥf7<@ꫯ+=zȼyz=*T҄m Xex6|p@}glr~mcygZ|y6a99Sd2x8h kTMd"ǔIg[nF45ɿ-Z\`&=Pٳ߀ 7({Xr.^zO6m2!|Mg'1`7D@>bK~ӦMdcsRb}'`ԉ`otb{9bVZ}l1.Xu-DDŘOW aa9fA_ xZnz78*X <b͛7I>^b͚5b,G/̊ 0a?3~ &n4q}G&L _~7/X`@\r)1cƘ1Y.xY*5[F5vȥDq)ZY9૮$hR-&E<;/) #&o֙ =2 d|^ɂEL;< F2`BX˞^a+k,lL>=!͚53H>3 ; (Y;Vx}=/4wdzH ;0sL3Ew}K aVŀ `aڵFgW^2|S/ʏ`ƌFぅ@sUW7"|7@[ܠƍN‘ <駟'F~;=N*  D@nݺeB{Oxa狇 C:sW]IЦ)+ ,!Pbނ;nb_@,(vS<0gTD~J=8YxGhc[ -DX b>Ì[v)kxDcwMs,FHF7 m=y gyqOQp31 ,ռE,.V\IpTC "1((=]`!FVx"H,؄ Y C:sW]IЦ\xR.D= IM zPbVwqYRcD9Xc0^"" # 0Mp,Hnnx v\%\,|(?#)$@cz,ͤL W? x`a=DZW2=Þ0aeaB(m0^0 $O<#<Ty=   j%ޅ[I!'B8B] [y-n[NH-#I$G_ MւJ'{p,m,23+&W&f=DžIGB2v2c3l1cH%4¼F@#&XGB$&+{f;fBHHhixȂ iI z8,Dl1cU%"ΗPC8f,9 3(bV =W9;F^ vrtMSv Sc+>K瀂i QԱ o,Wic6MB5"7a+$یńuίbwl`b9,M7}"i8f,$-^bD l3bg!˖8% lsQNii$1M]{4I!W6X"wfCrl` >C$Xlud; ۹ޝ_4 90]VŽ0.cdK2IdmG4qvMSB{#6 \O &tvςc mxp*`f_)K\ BV8@{^ly*(Zy8Iܟ@rgp"aSm{6 ! I]-yMF`! gr`I/A19ȂpPNb 1y ug4 "]Fq5(9S,8#0If/w,ri7 9B,<[$|MdpDXlG1B)crg7N;dQK[V-< 0~9As8 gPwALC###nBǁKԍǖ Sj9c!ji,ɕOa%eIԳ{C2? ,$d97!8pA {#$^nVU{2M6(-b)X/&_'4DrJ>wCpG 2r02 y,INbBxg 2x}Z&X^-n/^;Y@ VaҵyYǩra "b]Zz+%xdE/?HFΧp^Wp&xD9dȐOʈ,d,xC D9B~_Ys{4+=್MF s/=:4`!3,`.X!|瀕xF 0aСC͕լ"p>G#m`ⷧ˒'g pp#`W!"mE,p{&6I& f֭8hFH.s#xC<Usi,|f#B:X β`s8W~ر-sKe,Ɏ۶m3Px:3_B 6-X[ɍLL&HHΒEbpa'5jFnH7^zƃa= x; R8p Y"lvэgodO X+$!^xʽp̦ |%2E/μCˠ{.*Mb-X߿(Q<àdpRm%. %`a"~yHٜW +"@pW]I ve,p-4+qrL&B!*q{;iFeN EPuF< 6]߀dBdƓ^n,pWmF\< | Xp<$HQ1.XټZ|v,e'6ǟ$xH shn}^jB"+)D$P yHD:\NSfJ6-*#nv& E >9+sX~s[#+m(퍕ysH$0zh&tl %rȯV-F7&{r "J#;Ka,z A.xYxq 7ab&Ml+D[inmE E^!\ 1 k" `Dw'8 [/y^A `wBԸQɐ|yOJ6-/X`B+`Bx5Y-3"r&Z6& b<`!Q;i31Qg5A!\A"$yvJ0]~1S`x I@3 BdcMvZX;BcKn[.DomLlLA6XX6Mz"ELWBڀID 9 *tuu~;֍"+l$r"zNkﺒag} |( S qc-L&h&jl{ -2smXtI]tx2j(͵PO4z#K6apJsi%t6m$G^(;"i4 S6D:%^H+}7Zo9] M vWW,a Iy;–T ᖼ`!պ<{Q>XX1[dX9^ȱ%y&\(3ffH*.i&ZҢ+!i$K$I~ޅLBh֬Y6ŴMs, D&+WE*Ϩ>hp`TMGDtUez8HMKXӖ6-`Qk#2|1CѼM7[ni:ΦmZy:8V]QشHE)sf8mDf!6 @f:8 %16Ү+0MsS-CdPi|sNDNEZ+2Ǹl@څ" F~Fd˺HOTi֙dDWԦE6B>L"?LO;QdEmt`R"'ۢj8șEM=:Yr22$K>dLWԦBqM 'X]2Ed"b ex6~Hi&T,(2+j *Bn+￈l+= @b""rq" Sv@tEmZ۴ jV25,]u% . BڅqU0r@u%Q"9*XPz1!eJHn B$ڝ9J } %=Uzc`e=SCՕ 6dCO,LI;s< P]qAJJz`A*9z+!lȺ凞*Xhw+y* 8ₔF?TUsL )TWB*ؐu=U2%$?VT.p@u))~詂ի+RT!z`!dJI~ 7\ RRS WY?V35 P] `C-?TBȔD<XSo4 T~ gjHR[~詂)v'y1Bp+.HIiCO,^e=XYԐ2@u% YS !SNc`%OTW\臞 ,A@ݥcrgnA&Si0"pIq9MK XP*\Y-X,j}_p-ӽҦ2ͬGSeԩR~}9a^ xˁʤS弳Kóզy][+XZXݥ[7uN(ns@mSrԁ\r .Ԧ ܐ*q@Bb|S 2-$ׁ$qr PhPCDH7Wx:+L9HjӒY&PB&^6u`yr@98M H` 9 RcSrʁڴ`+`g7;&0%W9in(7䤞*q@Bb|S 2-$ׁ$qr PhPnKOptDNJr@9,$ƧL?LK u`%0}\94ԦZH.b;v=QrE(Xp@jӧOuJ *$w|7m4S=QrE(XpDj{キlٲe7j/.#P2rE(XpDj6\ A8"@%S9P8 /2I P(怂7!*9`! Eh!)ra(XpHxyCpHxJr@9p  Eh1)ra(XpLx6!*9+XfM0I_t<1xqraITٵ[ ױ1P}*(rwпw@0:LD"r@0(}إdz@ZN`uC/R"HdS* ;AЧ:E9d`H0 ;A֚M"SHXʊ-UF{],qGKyS*P Pq >4J>u&r *]>!4In BVڑT9- +T%KYHxbIWRz@gD] 2]nKꃤO pi@*XC^uI:$}R)q HRGBP5A% R >)X@V|r)[-Z4W4p HRBcA% > 窃O |6?37|cZ袋wrG{)%J^YƎ+P¥pJժUwQO~wٸqٖG}Ttb-⼺Q T2A' >낽)rR^=Y`<f5kgB[5k.Ғ84  (q2rH)VXLII .\`_24Qzu/ [Sh5XQ O׵ 铂 /u]'֭RJXya4|A7_iݺ 2+'xB ( *F76=\3)z2n8ڵ8묳̻|?x`YbO2i$1bx>s"i@zN8N9_)VԩSDNs?,z2tP۷wq33VvZLډB F)?TREm۶,̙3GySN9E[իҸqcydZoN:ɢEaÆf .]Zy3>'CL%XP}R}RH9QGe]yƀ׮];{\`@1cō7h\LV@]w%\s\}Qo׮ C|w}vc;t W\qS6l` pݥG=XcgKt Tim.<쳲&eǎ w-x_xMsRR%>]DosO%{qBni&w}ͤ̈́ͤܲeK/ׯokfꫯ v@ѐ!CX!C@TF iڴG>3fx\rr˥^j~.h 4h aNUn 7⃩ҥdUNB/X >L?11bچ f9V{vaOʵ^k<[lŋG L"3fdD.]*= H+LBlO&JƝٳgomt0f |t`s^>39g>Ȅ /7j,EyFe VZ 80,{챇2zO-=L㋺.p ]V)b <,[̴w+W1x3)ܒi#gSKIIB*#.w-ʴb *+{իo@dL`1fW 7)nhߗ/_>,*~w11|&<PP&3gΔ1cƤлp!҄qgecwoa&eРAw0t駟  Vt@X @?xhz.`+$gW"acN 1x4*P]JSޭn֛pd'ɐ?B]^x$ $ H ~g_4  xB'o޼X`> @!̇'-Zqw G T;Xէ' ~Z|aƽj W0#y]L`b+VhVH N$:8ӣ~'枳jUVf|?Ĉb 3;Y}I`5kf $ڂIr"&X:˪=9 x , 7,M4wyǐDŽ%=xLHR6>Ova0FUPc ; ~--v}>UTT)XHvԥD8_Bg,+exv h'wώ{^C W?R}}RP~TDRK#NMLya`8:]}k:H2`"ټZdp;"#[D>%ʊ('iˁ .e ,o³-wױ" `alarq"^ZA7E޸,6Dʟ uAi ߿#͛"?'*_J9XF9D]J+Xi;3^qR|oI #/^υoɞ{)J[ *ypBE4i?_6aZ/GxHD9HD_?uo,tT&'\;* A;6-Y5Wdm#9H=W `a4O2%=| }Q38Ct"^xgm~2m4^.u.7n>裏~i[<#m(ҿM"},Ri@,|sbԽ7g] LYTCy}Ej)A%G=Dveہvߊ|ȬE=D=iOnmE E^ ?%Jn:3A3Y+/C~`_.1/zJnV&w[ƍ"MxNn: /K.D."TR~FӻlQYnW\R8'm"?Y}[ Oo_+2oN}3a*?_?E<(2o'^'rpN>%B`r9XtB"M~AT"~4hs-[wrM7!"f͒N:ɢEaÆҿ)] d1bxW_I+Bz!͋Y8B `XN8i۶ V2M:9餓|>||=#o3}-_?#/,sr)^{Gbt*|z{qt HdOk6&pwot/sbfy/}ŐQ {4{E苳UD< ԧ.XXȠj"Wi8~&;UV_U:9묳^zիWKroK/x 6m$t̀{ǚI>qp 2p@ObPk&r @e˖qꩧ/w}L#]"[}H1GW/qS:v(?<f^lY39}ѲrJ4hL2 ҥKA}QGRL;wԨQ#&X5j G(VZҹsg2 vԬYx0pi0 Μ.9%K4jysθ,_:J=7CݥFRn_x ~]DF  t;k?vQHC"wJ%L>-Td9j16= ydwڿf8VBqȐ'S;D :/>p6LxZ'鑼eoϛ ̜9S7nl< }vB C̙c ?\SR`lٲf[+7x^+wW^(=az+=P9 X8蠃L8v)?c߮];Oa*^d9;^aa@uM[=ԃ.,< 5Y4^N">ebRpatȌ~9_JPu.yO$R.v~3Xմ6j'^^SP |7|&SB  v}Y@<UV͸?s7nԩSǀVJd&q&y#@ۀr}m%bȑ,:v5\}H"+2Ixaѫb%rOeXRo< `{q}E:,ɹ/ŃB{&t//GydaъM4^xT\cTZD8n㎑O[ 3Ӈ"/-Rt4IUW]eCqkpHȓPe9;Z/r͔` , `L .:0QG {ϧb ୱ$<3n;S0DN.RbEBۺu e,и;n<[(d"·$ڵk[-dX5:(… cEMfq.3DN^R$Dws| 0ɳM65kלcYm|&$L"$y= gϖѣG˓O>i&v 6b`–J&g&mk d,`-<î [|ЇGCͮ ԏWP{.u5 2 }al}YO<#D,DfGLH;$vD< _t9=\_L~rСF0BSgر&/0!.|t&=%A;kwH)W #ԧtͰ~M, "x"KwY b:=V)cF6<-val"O?5v [HВčmFG xcE#ϹI@$="sDJI>G8 L&W&f\Ŋˏw&}[]r+hټyY1 6.a[b}O?hVihĻ 7ɗX_8X#=䉑X1Sdi"q±`ɛ-!&y7olMO(XHXs톰@W0(,9g49$qG4 ;\㮞H8υa*["l΂=g!֮3} ɱlk'd6y= ȁ!ik s4q.GsPEBK'X,QybrrI-w9V7oHy djVAf&9v"`!y5ȸ'pjomff(Xv:+V댝]!a/1 s[ȥ ysB \,$t-Y0MfX+Wfo&]lu:]\ Π@vP?YqsOk}:1]2%*MOƎ=Q~NBBĂzV,$JO ^梄!Ha߷ DL5GO* Hv?>,ˀtI%<<]#Ioz-sz#@=h8YxlP%&Fnb ^v> %oΝhq7{#(]t9~cp6@'8?29.XeIPs&!(XH|',$3 Y 8ٖr)N,$t&:&Q$B&L$p="NI,[%c/x&z#[fhF={IO>$ qZ%&r#lcb; E0 }h4AmX>\d6oTI Y SV DW୓LʬY$8x _ıп븓#+0 0 f$iIaudHLlkBL]0!O`Ha2@H*,0N?t% Pb_11B Kh@K"`=̝;W*jB#$^lqo! 'iͳ2hO,j&؜8A⫞H'40#E?F/%_S`!yĹܚDUٿHĊK4!2QE&8FBm=;<X`%t@1[$p-˟ț= f0&DA0r2 L\mKxƳ"hS<~_ `B(x|jLpY D&z@BzrB [%MrM]޼`3dbd6`Sɽʊ`!q1{b5x9ͫE9hכklYMȻ", |n,pH 0r< 8,p:IK#!H^Xm4lx)Xhٲء S&WhC' s'‚x`!oճޅx)X8N/ @N1/ZȳEJ$iU itD> M)ܑ &Lq_ougx,$1I>%G! -@'9K/ ' mE E-uQ | Pu[dXQxާ+|tL6$@!^ %tUh֬Yn(פ2YSPI ;Za'BagFbs M) e&lNv)6 hrVGN5rbNk"p`юڵ[{ M 0jmԨI XWa'ܻ!qMQvQ<S\9v4XL↰&hddOm!T¼G B[x8+ ;ғ5 zdkeOm/h@ąIv5%]h`!պR~oBvTi,jR7EO&f//H2a4M6iCJ>XWFǻ72 `OWCS@Fa,Bc<Јf0vv¢['*[' ђm^:iOx(^V߿NƟs.~08s!0(OxTMiV-9jpuabCbwtړmSΡ[7ЧӦMr) I_8L2 Wq0睹_\L¡h5Sp*?Y&5((J8 |e)fy[j_Ů5/< U<5Y`qB .IDATɓ9,10g#̝aN[gt+swXpnX#exWc1՞u4ɛˁǥwVJ/~k床>ׂi wFJOTMV<.|cH,y*gBLf.(y/,sTZ#%?NDk\0'#| p>}\xȂs Q(X0M%W=7E  H=rw)Gg8W|&?a޼y}IGF7;|돫]OY8@@v+-9lo%j-' K{ΐ1ChHBn$ Z8bE1逤y]1^,>8bT$PG;θqQs9'[E.q).~:o~kۂ7zǁV j9fc[S,>? GZNqplda;G+6~ 8״7'= < pA.; hC{ ,֮hknm?VjSf}a\+ТKSSl~eɷojs\V֤*O9W尃S,8(/ \0Śn@_w0jbWiC wz0mCZg kʪ6 K|vC#BWY*ϗo9}ToN}lޘnM`vБ_#]] ,u b,UΓOon76b,y mi@*_;OF+L8܏d)m,u`b?:Xv[HZ# _c)X`.ّ{\ mX''`C }(ee᳀~\y~&r_Frt[SBL, si&0j`ۭ(?_ܱx A*BtKX|mK7\/Ɯo\ʕ!KEi!LPNPXҿD hL*k} x_BKJ=~hBF`~,uHG7MD?~x{;!; I{Q5#`Ksvd2-w &ܮOKocacWl[h\jxVp9 8r66|[peXb!,]ZO:dab5įm_ߣֺFc:*cZ]o?6mQ6P- ̠ $R@%&`C.h?7 ~&']Sd( )%:"(BGK8z_ -+~8HO \X&`C*,;"YBE|fLK$`9Rd[!,uHB\$+O ݭ!%GKs,ƒBucR`_, the source code we describe in this code. * `Jupyter Notebook Github Repo `_ , the source code for the classic Notebook. * `JupyterLab Github Repo `_, the JupyterLab server which runs on the Jupyter Server. Introduction ------------ Jupyter Server is the backend that provides the core services, APIs, and `REST endpoints`_ for Jupyter web applications. .. note:: Jupyter Server is a replacement for the Tornado Web Server in `Jupyter Notebook`_. Jupyter web applications should move to using Jupyter Server. For help, see the :ref:`migrate_from_notebook` page. .. _Tornado: https://www.tornadoweb.org/en/stable/ .. _Jupyter Notebook: https://github.com/jupyter/notebook .. _REST endpoints: https://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyter_server/main/jupyter_server/services/api/api.yaml Applications ------------ Jupyter Server extensions can use the framework and services provided by Jupyter Server to create applications and services. Examples of Jupyter Server extensions include: .. _examples of jupyter server extensions: `Jupyter Lab `_ JupyterLab computational environment. `Jupyter Resource Usage `_ Jupyter Notebook Extension for monitoring your own resource usage. `Jupyter Scheduler `_ Run Jupyter notebooks as jobs. `jupyter-collaboration `_ A Jupyter Server Extension Providing Support for Y Documents. `NbClassic `_ Jupyter notebook as a Jupyter Server extension. `Cylc UI Server `_ A Jupyter Server extension that serves the cylc-ui web application for monitoring and controlling Cylc workflows. For more information on extensions, see :ref:`extensions`. Who's this for? --------------- The Jupyter Server is a highly technical piece of the Jupyter Stack, so we've separated documentation to help specific personas: 1. :ref:`Users `: people using Jupyter web applications. 2. :ref:`Operators `: people deploying or serving Jupyter web applications to others. 3. :ref:`Developers `: people writing Jupyter Server extensions and web applications. 4. :ref:`Contributors `: people contributing directly to the Jupyter Server library. If you finds gaps in our documentation, please open an issue (or better, a pull request) on the Jupyter Server `Github repo `_. Table of Contents ----------------- .. toctree:: :maxdepth: 2 Users Operators Developers Contributors Other jupyter-server-jupyter_server-e5c7e2b/docs/source/operators/000077500000000000000000000000001473126534200245615ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/source/operators/configuring-extensions.rst000066400000000000000000000041431473126534200320240ustar00rootroot00000000000000.. _configure-multiple-extensions: Configuring Extensions ====================== Some Jupyter Server extensions are also configurable applications. There are two ways to configure such extensions: i) pass arguments to the extension's entry point or ii) list configurable options in a Jupyter config file. Jupyter Server looks for an extension's config file in a set of specific paths. Use the ``jupyter`` entry point to list these paths: .. code-block:: console > jupyter --paths config: /Users/username/.jupyter /usr/local/etc/jupyter /etc/jupyter data: /Users/username/Library/Jupyter /usr/local/share/jupyter /usr/share/jupyter runtime: /Users/username/Library/Jupyter/runtime Extension config from file -------------------------- Jupyter Server expects the file to be named after the extension's name like so: ``jupyter_{name}_config``. For example, the Jupyter Notebook's config file is ``jupyter_notebook_config``. Configuration files can be Python or JSON files. In Python config files, each trait will be prefixed with ``c.`` that links the trait to the config loader. For example, Jupyter Notebook config might look like: .. code-block:: python # jupyter_notebook_config.py c.NotebookApp.mathjax_enabled = False A Jupyter Server will automatically load config for each enabled extension. You can configure each extension by creating their corresponding Jupyter config file. Extension config on the command line ------------------------------------ Server extension applications can also be configured from the command line, and multiple extension can be configured at the same time. Simply pass the traits (with their appropriate prefix) to the ``jupyter server`` entrypoint, e.g.: .. code-block:: console > jupyter server --ServerApp.port=9999 --MyExtension1.trait=False --MyExtension2.trait=True This will also work with any extension entrypoints that allow other extensions to run side-by-side, e.g.: .. code-block:: console > jupyter myextension --ServerApp.port=9999 --MyExtension1.trait=False --MyExtension2.trait=True jupyter-server-jupyter_server-e5c7e2b/docs/source/operators/configuring-logging.rst000066400000000000000000000073701473126534200312600ustar00rootroot00000000000000.. _configurable_logging: Configuring Logging =================== Jupyter Server (and Jupyter Server extension applications such as Jupyter Lab) are Traitlets applications. By default Traitlets applications log to stderr. You can configure them to log to other locations e.g. log files. Logging is configured via the ``logging_config`` "trait" which accepts a :py:func:`logging.config.dictConfig` object. For more information look for ``Application.logging_config`` in :ref:`other-full-config`. Examples -------- .. _configurable_logging.jupyter_server: Jupyter Server ^^^^^^^^^^^^^^ A minimal example which logs Jupyter Server output to a file: .. code-block:: python c.ServerApp.logging_config = { "version": 1, "handlers": { "logfile": { "class": "logging.FileHandler", "level": "DEBUG", "filename": "jupyter_server.log", }, }, "loggers": { "ServerApp": { "level": "DEBUG", "handlers": ["console", "logfile"], }, }, } .. note:: To keep the default behaviour of logging to stderr ensure the ``console`` handler (provided by Traitlets) is included in the list of handlers. .. warning:: Be aware that the ``ServerApp`` log may contain security tokens. If redirecting to log files ensure they have appropriate permissions. .. _configurable_logging.extension_applications: Jupyter Server Extension Applications (e.g. Jupyter Lab) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ An example which logs both Jupyter Server and Jupyter Lab output to a file: .. note:: Because Jupyter Server and its extension applications are separate Traitlets applications their logging must be configured separately. .. code-block:: python c.ServerApp.logging_config = { "version": 1, "handlers": { "logfile": { "class": "logging.FileHandler", "level": "DEBUG", "filename": "jupyter_server.log", "formatter": "my_format", }, }, "formatters": { "my_format": { "format": "%(asctime)s %(levelname)-8s %(name)-15s %(message)s", "datefmt": "%Y-%m-%d %H:%M:%S", }, }, "loggers": { "ServerApp": { "level": "DEBUG", "handlers": ["console", "logfile"], }, }, } c.LabApp.logging_config = { "version": 1, "handlers": { "logfile": { "class": "logging.FileHandler", "level": "DEBUG", "filename": "jupyter_server.log", "formatter": "my_format", }, }, "formatters": { "my_format": { "format": "%(asctime)s %(levelname)-8s %(name)-15s %(message)s", "datefmt": "%Y-%m-%d %H:%M:%S", }, }, "loggers": { "LabApp": { "level": "DEBUG", "handlers": ["console", "logfile"], }, }, } .. note:: The configured application name should match the logger name e.g. ``c.LabApp.logging_config`` defines a logger called ``LabApp``. .. tip:: This diff modifies the example to log Jupyter Server and Jupyter Lab output to different files: .. code-block:: diff --- before +++ after c.LabApp.logging_config = { 'version': 1, 'handlers': { 'logfile': { 'class': 'logging.FileHandler', 'level': 'DEBUG', - 'filename': 'jupyter_server.log', + 'filename': 'jupyter_lab.log', 'formatter': 'my_format', }, }, jupyter-server-jupyter_server-e5c7e2b/docs/source/operators/index.rst000066400000000000000000000005771473126534200264330ustar00rootroot00000000000000Documentation for Operators =========================== These pages are targeted at people using, configuring, and/or deploying multiple Jupyter Web Application with Jupyter Server. .. toctree:: :caption: Operators :maxdepth: 1 :name: operators multiple-extensions configuring-extensions migrate-from-nbserver public-server security configuring-logging jupyter-server-jupyter_server-e5c7e2b/docs/source/operators/ipython_security.asc000066400000000000000000000060611473126534200306750ustar00rootroot00000000000000-----BEGIN PGP PUBLIC KEY BLOCK----- Version: GnuPG v2.0.22 (GNU/Linux) mQINBFMx2LoBEAC9xU8JiKI1VlCJ4PT9zqhU5nChQZ06/bj1BBftiMJG07fdGVO0 ibOn4TrCoRYaeRlet0UpHzxT4zDa5h3/usJaJNTSRwtWePw2o7Lik8J+F3LionRf 8Jz81WpJ+81Klg4UWKErXjBHsu/50aoQm6ZNYG4S2nwOmMVEC4nc44IAA0bb+6kW saFKKzEDsASGyuvyutdyUHiCfvvh5GOC2h9mXYvl4FaMW7K+d2UgCYERcXDNy7C1 Bw+uepQ9ELKdG4ZpvonO6BNr1BWLln3wk93AQfD5qhfsYRJIyj0hJlaRLtBU3i6c xs+gQNF4mPmybpPSGuOyUr4FYC7NfoG7IUMLj+DYa6d8LcMJO+9px4IbdhQvzGtC qz5av1TX7/+gnS4L8C9i1g8xgI+MtvogngPmPY4repOlK6y3l/WtxUPkGkyYkn3s RzYyE/GJgTwuxFXzMQs91s+/iELFQq/QwmEJf+g/QYfSAuM+lVGajEDNBYVAQkxf gau4s8Gm0GzTZmINilk+7TxpXtKbFc/Yr4A/fMIHmaQ7KmJB84zKwONsQdVv7Jjj 0dpwu8EIQdHxX3k7/Q+KKubEivgoSkVwuoQTG15X9xrOsDZNwfOVQh+JKazPvJtd SNfep96r9t/8gnXv9JI95CGCQ8lNhXBUSBM3BDPTbudc4b6lFUyMXN0mKQARAQAB tCxJUHl0aG9uIFNlY3VyaXR5IFRlYW0gPHNlY3VyaXR5QGlweXRob24ub3JnPokC OAQTAQIAIgUCUzHYugIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQEwJc LcmZYkjuXg//R/t6nMNQmf9W1h52IVfUbRAVmvZ5d063hQHKV2dssxtnA2dRm/x5 JZu8Wz7ZrEZpyqwRJO14sxN1/lC3v+zs9XzYXr2lBTZuKCPIBypYVGIynCuWJBQJ rWnfG4+u1RHahnjqlTWTY1C/le6v7SjAvCb6GbdA6k4ZL2EJjQlRaHDmzw3rV/+l LLx6/tYzIsotuflm/bFumyOMmpQQpJjnCkWIVjnRICZvuAn97jLgtTI0+0Rzf4Zb k2BwmHwDRqWCTTcRI9QvTl8AzjW+dNImN22TpGOBPfYj8BCZ9twrpKUbf+jNqJ1K THQzFtpdJ6SzqiFVm74xW4TKqCLkbCQ/HtVjTGMGGz/y7KTtaLpGutQ6XE8SSy6P EffSb5u+kKlQOWaH7Mc3B0yAojz6T3j5RSI8ts6pFi6pZhDg9hBfPK2dT0v/7Mkv E1Z7q2IdjZnhhtGWjDAMtDDn2NbY2wuGoa5jAWAR0WvIbEZ3kOxuLE5/ZOG1FyYm noJRliBz7038nT92EoD5g1pdzuxgXtGCpYyyjRZwaLmmi4CvA+oThKmnqWNY5lyY ricdNHDiyEXK0YafJL1oZgM86MSb0jKJMp5U11nUkUGzkroFfpGDmzBwAzEPgeiF 40+qgsKB9lqwb3G7PxvfSi3XwxfXgpm1cTyEaPSzsVzve3d1xeqb7Yq5Ag0EUzHY ugEQALQ5FtLdNoxTxMsgvrRr1ejLiUeRNUfXtN1TYttOfvAhfBVnszjtkpIW8DCB JF/bA7ETiH8OYYn/Fm6MPI5H64IHEncpzxjf57jgpXd9CA9U2OMk/P1nve5zYchP QmP2fJxeAWr0aRH0Mse5JS5nCkh8Xv4nAjsBYeLTJEVOb1gPQFXOiFcVp3gaKAzX GWOZ/mtG/uaNsabH/3TkcQQEgJefd11DWgMB7575GU+eME7c6hn3FPITA5TC5HUX azvjv/PsWGTTVAJluJ3fUDvhpbGwYOh1uV0rB68lPpqVIro18IIJhNDnccM/xqko 4fpJdokdg4L1wih+B04OEXnwgjWG8OIphR/oL/+M37VV2U7Om/GE6LGefaYccC9c tIaacRQJmZpG/8RsimFIY2wJ07z8xYBITmhMmOt0bLBv0mU0ym5KH9Dnru1m9QDO AHwcKrDgL85f9MCn+YYw0d1lYxjOXjf+moaeW3izXCJ5brM+MqVtixY6aos3YO29 J7SzQ4aEDv3h/oKdDfZny21jcVPQxGDui8sqaZCi8usCcyqWsKvFHcr6vkwaufcm 3Knr2HKVotOUF5CDZybopIz1sJvY/5Dx9yfRmtivJtglrxoDKsLi1rQTlEQcFhCS ACjf7txLtv03vWHxmp4YKQFkkOlbyhIcvfPVLTvqGerdT2FHABEBAAGJAh8EGAEC AAkFAlMx2LoCGwwACgkQEwJcLcmZYkgK0BAAny0YUugpZldiHzYNf8I6p2OpiDWv ZHaguTTPg2LJSKaTd+5UHZwRFIWjcSiFu+qTGLNtZAdcr0D5f991CPvyDSLYgOwb Jm2p3GM2KxfECWzFbB/n/PjbZ5iky3+5sPlOdBR4TkfG4fcu5GwUgCkVe5u3USAk C6W5lpeaspDz39HAPRSIOFEX70+xV+6FZ17B7nixFGN+giTpGYOEdGFxtUNmHmf+ waJoPECyImDwJvmlMTeP9jfahlB6Pzaxt6TBZYHetI/JR9FU69EmA+XfCSGt5S+0 Eoc330gpsSzo2VlxwRCVNrcuKmG7PsFFANok05ssFq1/Djv5rJ++3lYb88b8HSP2 3pQJPrM7cQNU8iPku9yLXkY5qsoZOH+3yAia554Dgc8WBhp6fWh58R0dIONQxbbo apNdwvlI8hKFB7TiUL6PNShE1yL+XD201iNkGAJXbLMIC1ImGLirUfU267A3Cop5 hoGs179HGBcyj/sKA3uUIFdNtP+NndaP3v4iYhCitdVCvBJMm6K3tW88qkyRGzOk 4PW422oyWKwbAPeMk5PubvEFuFAIoBAFn1zecrcOg85RzRnEeXaiemmmH8GOe1Xu Kh+7h8XXyG6RPFy8tCcLOTk+miTqX+4VWy+kVqoS2cQ5IV8WsJ3S7aeIy0H89Z8n 5vmLc+Ibz+eT+rM= =XVDe -----END PGP PUBLIC KEY BLOCK----- jupyter-server-jupyter_server-e5c7e2b/docs/source/operators/migrate-from-nbserver.rst000066400000000000000000000030221473126534200315250ustar00rootroot00000000000000.. _migrate_from_notebook: Migrating from Notebook Server ============================== To migrate from notebook server to plain jupyter server, follow these steps: - Rename your ``jupyter_notebook_config.py`` file to ``jupyter_server_config.py``. - Rename all ``c.NotebookApp`` traits to ``c.ServerApp``. For example if you have the following ``jupyter_notebook_config.py``. .. code-block:: python c.NotebookApp.allow_credentials = False c.NotebookApp.port = 8889 c.NotebookApp.password_required = True You will have to create the following ``jupyter_server_config.py`` file. .. code-block:: python c.ServerApp.allow_credentials = False c.ServerApp.port = 8889 c.ServerApp.password_required = True Running Jupyter Notebook on Jupyter Server ========================================== If you want to switch to Jupyter Server, but you still want to serve `Jupyter Notebook `_ to users, you can try `NBClassic `_. NBClassic is a Jupyter Server extension that serves the Notebook frontend (i.e. all static assets) on top of Jupyter Server. It even loads Jupyter Notebook's config files. .. warning:: NBClassic will only work for a limited time. Jupyter Server is likely to evolve beyond a point where Jupyter Notebook frontend will no longer work with the underlying server. Consider switching to `JupyterLab `_ or `nteract `_ where there is active development happening. jupyter-server-jupyter_server-e5c7e2b/docs/source/operators/multiple-extensions.rst000066400000000000000000000066271473126534200313560ustar00rootroot00000000000000 .. _managing-multiple-extensions: Managing multiple extensions ---------------------------- One of the major benefits of Jupyter Server is that you can run serve multiple Jupyter frontend applications above the same Tornado web server. That's because every Jupyter frontend application is now a server extension. When you run a Jupyter Server with multiple extensions enabled, each extension appends its own set of handlers and static assets to the server. Listing extensions ~~~~~~~~~~~~~~~~~~ When you install a Jupyter Server extension, it *should* automatically add itself to your list of enabled extensions. You can see a list of installed extensions by calling: .. code-block:: console > jupyter server extension list config dir: /Users/username/etc/jupyter myextension enabled - Validating myextension... myextension OK Enabling/disabling extensions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You enable/disable an extension using the following commands: .. code-block:: console > jupyter server extension enable myextension Enabling: myextension - Validating myextension... myextension OK - Extension successfully enabled. > jupyter server extension disable myextension Disabling: jupyter_home - Validating jupyter_home... jupyter_home OK - Extension successfully disabled. Running an extensions from its entrypoint ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Extensions that are also Jupyter applications (i.e. Notebook, JupyterLab, Voila, etc.) can be launched from a CLI entrypoint. For example, launch Jupyter Notebook using: .. code-block:: console > jupyter notebook Jupyter Server will automatically start a server and the browser will be routed to Jupyter Notebook's default URL (typically, ``/tree``). Other enabled extension will still be available to the user. The entrypoint simply offers a more direct (backwards compatible) launching mechanism. Launching a server with multiple extensions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If multiple extensions are enabled, a Jupyter Server can be launched directly: .. code-block:: console > jupyter server [I 2020-03-23 15:44:53.290 ServerApp] Serving notebooks from local directory: /Users/username/path [I 2020-03-23 15:44:53.290 ServerApp] Jupyter Server 0.3.0.dev is running at: [I 2020-03-23 15:44:53.290 ServerApp] http://localhost:8888/?token=<...> [I 2020-03-23 15:44:53.290 ServerApp] or http://127.0.0.1:8888/?token=<...> [I 2020-03-23 15:44:53.290 ServerApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation). [I 2020-03-23 15:44:53.290 ServerApp] Welcome to Project Jupyter! Explore the various tools available and their corresponding documentation. If you are interested in contributing to the platform, please visit the communityresources section at https://jupyter.org/community.html. [C 2020-03-23 15:44:53.296 ServerApp] To access the server, open this file in a browser: file:///Users/username/pathjpserver-####-open.html Or copy and paste one of these URLs: http://localhost:8888/?token=<...> or http://127.0.0.1:8888/?token=<...> Extensions can also be enabled manually from the Jupyter Server entrypoint using the ``jpserver_extensions`` trait: .. code-block:: console > jupyter server --ServerApp.jpserver_extensions="myextension=True" jupyter-server-jupyter_server-e5c7e2b/docs/source/operators/public-server.rst000066400000000000000000000422451473126534200301040ustar00rootroot00000000000000.. _working_remotely: Running a public Jupyter Server =============================== The Jupyter Server uses a :ref:`two-process kernel architecture ` based on ZeroMQ_, as well as Tornado_ for serving HTTP requests. .. note:: By default, Jupyter Server runs locally at 127.0.0.1:8888 and is accessible only from ``localhost``. You may access the server from the browser using ``http://127.0.0.1:8888``. This document describes how you can :ref:`secure a Jupyter server ` and how to :ref:`run it on a public interface `. .. important:: **This is not the multi-user server you are looking for**. This document describes how you can run a public server with a single user. This should only be done by someone who wants remote access to their personal machine. Even so, doing this requires a thorough understanding of the set-ups limitations and security implications. If you allow multiple users to access a Jupyter server as it is described in this document, their commands may collide, clobber and overwrite each other. If you want a multi-user server, the official solution is JupyterHub_. To use JupyterHub, you need a Unix server (typically Linux) running somewhere that is accessible to your users on a network. This may run over the public internet, but doing so introduces additional `security concerns `_. .. _ZeroMQ: https://zeromq.org/ .. _Tornado: http://www.tornadoweb.org/en/stable/ .. _JupyterHub: https://jupyterhub.readthedocs.io/en/latest/ .. _Jupyter_server_security: Securing a Jupyter server ------------------------- You can protect your Jupyter server with a simple single password. As of notebook 5.0 this can be done automatically. To set up a password manually you can configure the :attr:`ServerApp.password` setting in :file:`jupyter_server_config.py`. Prerequisite: A Jupyter server configuration file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Check to see if you have a Jupyter server configuration file, :file:`jupyter_server_config.py`. The default location for this file is your Jupyter folder located in your home directory: - Windows: :file:`C:\\Users\\USERNAME\\.jupyter\\jupyter_server_config.py` - OS X: :file:`/Users/USERNAME/.jupyter/jupyter_server_config.py` - Linux: :file:`/home/USERNAME/.jupyter/jupyter_server_config.py` If you don't already have a Jupyter folder, or if your Jupyter folder doesn't contain a Jupyter server configuration file, run the following command:: $ jupyter server --generate-config This command will create the Jupyter folder if necessary, and create a Jupyter server configuration file, :file:`jupyter_server_config.py`, in this folder. Automatic Password setup ~~~~~~~~~~~~~~~~~~~~~~~~ As of notebook 5.3, the first time you log-in using a token, the server should give you the opportunity to setup a password from the user interface. You will be presented with a form asking for the current *token*, as well as your *new password*; enter both and click on ``Login and setup new password``. Next time you need to log in you'll be able to use the new password instead of the login token, otherwise follow the procedure to set a password from the command line. The ability to change the password at first login time may be disabled by integrations by setting the ``--ServerApp.allow_password_change=False`` Starting at notebook version 5.0, you can enter and store a password for your server with a single command. :command:`jupyter server password` will prompt you for your password and record the hashed password in your :file:`jupyter_server_config.json`. .. code-block:: bash $ jupyter server password Enter password: **** Verify password: **** [JupyterPasswordApp] Wrote hashed password to /Users/you/.jupyter/jupyter_server_config.json This can be used to reset a lost password; or if you believe your credentials have been leaked and desire to change your password. Changing your password will invalidate all logged-in sessions after a server restart. .. _hashed-pw: Preparing a hashed password ~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can prepare a hashed password manually, using the function :func:`jupyter_server.auth.passwd`: .. code-block:: pycon >>> from jupyter_server.auth import passwd >>> passwd() Enter password: Verify password: 'sha1:67c9e60bb8b6:9ffede0825894254b2e042ea597d771089e11aed' .. caution:: :func:`~jupyter_server.auth.passwd` when called with no arguments will prompt you to enter and verify your password such as in the above code snippet. Although the function can also be passed a string as an argument such as ``passwd('mypassword')``, please **do not** pass a string as an argument inside an IPython session, as it will be saved in your input history. Adding hashed password to your notebook configuration file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can then add the hashed password to your :file:`jupyter_server_config.py`. The default location for this file :file:`jupyter_server_config.py` is in your Jupyter folder in your home directory, ``~/.jupyter``, e.g.:: c.ServerApp.password = u'sha1:67c9e60bb8b6:9ffede0825894254b2e042ea597d771089e11aed' Automatic password setup will store the hash in ``jupyter_server_config.json`` while this method stores the hash in ``jupyter_server_config.py``. The ``.json`` configuration options take precedence over the ``.py`` one, thus the manual password may not take effect if the Json file has a password set. Using SSL for encrypted communication ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When using a password, it is a good idea to also use SSL with a web certificate, so that your hashed password is not sent unencrypted by your browser. .. important:: Web security is rapidly changing and evolving. We provide this document as a convenience to the user, and recommend that the user keep current on changes that may impact security, such as new releases of OpenSSL. The Open Web Application Security Project (`OWASP`_) website is a good resource on general security issues and web practices. You can start the notebook to communicate via a secure protocol mode by setting the ``certfile`` option to your self-signed certificate, i.e. ``mycert.pem``, with the command:: $ jupyter server --certfile=mycert.pem --keyfile mykey.key .. tip:: A self-signed certificate can be generated with ``openssl``. For example, the following command will create a certificate valid for 365 days with both the key and certificate data written to the same file:: $ openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout mykey.key -out mycert.pem When starting the notebook server, your browser may warn that your self-signed certificate is insecure or unrecognized. If you wish to have a fully compliant self-signed certificate that will not raise warnings, it is possible (but rather involved) to create one, as explained in detail in this `tutorial`_. Alternatively, you may use `Let's Encrypt`_ to acquire a free SSL certificate and follow the steps in :ref:`using-lets-encrypt` to set up a public server. .. _OWASP: https://owasp.org/sitemap/ .. _tutorial: https://arstechnica.com/information-technology/2009/12/how-to-get-set-with-a-secure-sertificate-for-free/ .. _jupyter_public_server: Running a public notebook server -------------------------------- If you want to access your notebook server remotely via a web browser, you can do so by running a public notebook server. For optimal security when running a public notebook server, you should first secure the server with a password and SSL/HTTPS as described in :ref:`jupyter_server_security`. Start by creating a certificate file and a hashed password, as explained in :ref:`jupyter_server_security`. If you don't already have one, create a config file for the notebook using the following command line:: $ jupyter server --generate-config In the ``~/.jupyter`` directory, edit the notebook config file, ``jupyter_server_config.py``. By default, the notebook config file has all fields commented out. The minimum set of configuration options that you should uncomment and edit in :file:`jupyter_server_config.py` is the following:: # Set options for certfile, ip, password, and toggle off # browser auto-opening c.ServerApp.certfile = u'/absolute/path/to/your/certificate/mycert.pem' c.ServerApp.keyfile = u'/absolute/path/to/your/certificate/mykey.key' # Set ip to '*' to bind on all interfaces (ips) for the public server c.ServerApp.ip = '*' c.ServerApp.password = u'sha1:bcd259ccf...' c.ServerApp.open_browser = False # It is a good idea to set a known, fixed port for server access c.ServerApp.port = 9999 You can then start the notebook using the ``jupyter server`` command. .. _using-lets-encrypt: Using Let's Encrypt ~~~~~~~~~~~~~~~~~~~ `Let's Encrypt`_ provides free SSL/TLS certificates. You can also set up a public server using a `Let's Encrypt`_ certificate. :ref:`jupyter_public_server` will be similar when using a Let's Encrypt certificate with a few configuration changes. Here are the steps: 1. Create a `Let's Encrypt certificate `_. 2. Use :ref:`hashed-pw` to create one. 3. If you don't already have config file for the notebook, create one using the following command: .. code-block:: bash $ jupyter server --generate-config 4. In the ``~/.jupyter`` directory, edit the notebook config file, ``jupyter_server_config.py``. By default, the notebook config file has all fields commented out. The minimum set of configuration options that you should to uncomment and edit in :file:`jupyter_server_config.py` is the following:: # Set options for certfile, ip, password, and toggle off # browser auto-opening c.ServerApp.certfile = u'/absolute/path/to/your/certificate/fullchain.pem' c.ServerApp.keyfile = u'/absolute/path/to/your/certificate/privkey.pem' # Set ip to '*' to bind on all interfaces (ips) for the public server c.ServerApp.ip = '*' c.ServerApp.password = u'sha1:bcd259ccf...' c.ServerApp.open_browser = False # It is a good idea to set a known, fixed port for server access c.ServerApp.port = 9999 You can then start the notebook using the ``jupyter server`` command. .. important:: **Use 'https'.** Keep in mind that when you enable SSL support, you must access the notebook server over ``https://``, not over plain ``http://``. The startup message from the server prints a reminder in the console, but *it is easy to overlook this detail and think the server is for some reason non-responsive*. **When using SSL, always access the notebook server with 'https://'.** You may now access the public server by pointing your browser to ``https://your.host.com:9999`` where ``your.host.com`` is your public server's domain. .. _`Let's Encrypt`: https://letsencrypt.org Firewall Setup ~~~~~~~~~~~~~~ To function correctly, the firewall on the computer running the jupyter notebook server must be configured to allow connections from client machines on the access port ``c.ServerApp.port`` set in :file:`jupyter_server_config.py` to allow connections to the web interface. The firewall must also allow connections from 127.0.0.1 (localhost) on ports from 49152 to 65535. These ports are used by the server to communicate with the notebook kernels. The kernel communication ports are chosen randomly by ZeroMQ, and may require multiple connections per kernel, so a large range of ports must be accessible. Running the notebook with a customized URL prefix ------------------------------------------------- The notebook dashboard, which is the landing page with an overview of the notebooks in your working directory, is typically found and accessed at the default URL ``http://localhost:8888/``. If you prefer to customize the URL prefix for the notebook dashboard, you can do so through modifying ``jupyter_server_config.py``. For example, if you prefer that the notebook dashboard be located with a sub-directory that contains other ipython files, e.g. ``http://localhost:8888/ipython/``, you can do so with configuration options like the following (see above for instructions about modifying ``jupyter_server_config.py``): .. code-block:: python c.ServerApp.base_url = "/ipython/" Embedding the notebook in another website ----------------------------------------- Sometimes you may want to embed the notebook somewhere on your website, e.g. in an IFrame. To do this, you may need to override the Content-Security-Policy to allow embedding. Assuming your website is at ``https://mywebsite.example.com``, you can embed the notebook on your website with the following configuration setting in :file:`jupyter_server_config.py`: .. code-block:: python c.ServerApp.tornado_settings = { "headers": { "Content-Security-Policy": "frame-ancestors https://mywebsite.example.com 'self' " } } Using a gateway server for kernel management -------------------------------------------- You are now able to redirect the management of your kernels to a Gateway Server (i.e., `Jupyter Kernel Gateway `_ or `Jupyter Enterprise Gateway `_) simply by specifying a Gateway url via the following command-line option: .. code-block:: bash $ jupyter notebook --gateway-url=http://my-gateway-server:8888 the environment: .. code-block:: bash JUPYTER_GATEWAY_URL=http://my-gateway-server:8888 or in :file:`jupyter_notebook_config.py`: .. code-block:: python c.GatewayClient.url = "http://my-gateway-server:8888" When provided, all kernel specifications will be retrieved from the specified Gateway server and all kernels will be managed by that server. This option enables the ability to target kernel processes against managed clusters while allowing for the notebook's management to remain local to the Notebook server. Known issues ------------ Proxies ~~~~~~~ When behind a proxy, especially if your system or browser is set to autodetect the proxy, the notebook web application might fail to connect to the server's websockets, and present you with a warning at startup. In this case, you need to configure your system not to use the proxy for the server's address. For example, in Firefox, go to the Preferences panel, Advanced section, Network tab, click 'Settings...', and add the address of the Jupyter server to the 'No proxy for' field. Content-Security-Policy (CSP) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Certain `security guidelines `_ recommend that servers use a Content-Security-Policy (CSP) header to prevent cross-site scripting vulnerabilities, specifically limiting to ``default-src: https:`` when possible. This directive causes two problems with Jupyter. First, it disables execution of inline javascript code, which is used extensively by Jupyter. Second, it limits communication to the https scheme, and prevents WebSockets from working because they communicate via the wss scheme (or ws for insecure communication). Jupyter uses WebSockets for interacting with kernels, so when you visit a server with such a CSP, your browser will block attempts to use wss, which will cause you to see "Connection failed" messages from jupyter notebooks, or simply no response from jupyter terminals. By looking in your browser's javascript console, you can see any error messages that will explain what is failing. To avoid these problem, you need to add ``'unsafe-inline'`` and ``connect-src https: wss:`` to your CSP header, at least for pages served by jupyter. (That is, you can leave your CSP unchanged for other parts of your website.) Note that multiple CSP headers are allowed, but successive CSP headers can only restrict the policy; they cannot loosen it. For example, if your server sends both of these headers Content-Security-Policy "default-src https: 'unsafe-inline'" Content-Security-Policy "connect-src https: wss:" the first policy will already eliminate wss connections, so the second has no effect. Therefore, you can't simply add the second header; you have to actually modify your CSP header to look more like this: Content-Security-Policy "default-src https: 'unsafe-inline'; connect-src https: wss:" Docker CMD ~~~~~~~~~~ Using ``jupyter server`` as a `Docker CMD `_ results in kernels repeatedly crashing, likely due to a lack of `PID reaping `_. To avoid this, use the `tini `_ ``init`` as your Dockerfile ``ENTRYPOINT``:: # Add Tini. Tini operates as a process subreaper for jupyter. This prevents # kernel crashes. ENV TINI_VERSION v0.6.0 ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /usr/bin/tini RUN chmod +x /usr/bin/tini ENTRYPOINT ["/usr/bin/tini", "--"] EXPOSE 8888 CMD ["jupyter", "server", "--port=8888", "--no-browser", "--ip=0.0.0.0"] jupyter-server-jupyter_server-e5c7e2b/docs/source/operators/security.rst000066400000000000000000000465171473126534200271770ustar00rootroot00000000000000.. _server_security: Security in the Jupyter Server ============================== Since access to the Jupyter Server means access to running arbitrary code, it is important to restrict access to the server. For this reason, Jupyter Server uses a token-based authentication that is **on by default**. .. note:: If you enable a password for your server, token authentication is not enabled by default. When token authentication is enabled, the server uses a token to authenticate requests. This token can be provided to login to the server in three ways: - in the ``Authorization`` header, e.g.:: Authorization: token abcdef... - In a URL parameter, e.g.:: https://my-server/tree/?token=abcdef... - In the password field of the login form that will be shown to you if you are not logged in. When you start a Jupyter server with token authentication enabled (default), a token is generated to use for authentication. This token is logged to the terminal, so that you can copy/paste the URL into your browser:: [I 11:59:16.597 ServerApp] The Jupyter Server is running at: http://localhost:8888/?token=c8de56fa4deed24899803e93c227592aef6538f93025fe01 If the Jupyter server is going to open your browser automatically, an *additional* token is generated for launching the browser. This additional token can be used only once, and is used to set a cookie for your browser once it connects. After your browser has made its first request with this one-time-token, the token is discarded and a cookie is set in your browser. At any later time, you can see the tokens and URLs for all of your running servers with :command:`jupyter server list`:: $ jupyter server list Currently running servers: http://localhost:8888/?token=abc... :: /home/you/notebooks https://0.0.0.0:9999/?token=123... :: /tmp/public http://localhost:8889/ :: /tmp/has-password For servers with token-authentication enabled, the URL in the above listing will include the token, so you can copy and paste that URL into your browser to login. If a server has no token (e.g. it has a password or has authentication disabled), the URL will not include the token argument. Once you have visited this URL, a cookie will be set in your browser and you won't need to use the token again, unless you switch browsers, clear your cookies, or start a Jupyter server on a new port. Alternatives to token authentication ------------------------------------ If a generated token doesn't work well for you, you can set a password for your server. :command:`jupyter server password` will prompt you for a password, and store the hashed password in your :file:`jupyter_server_config.json`. It is possible disable authentication altogether by setting the token and password to empty strings, but this is **NOT RECOMMENDED**, unless authentication or access restrictions are handled at a different layer in your web application: .. sourcecode:: python c.ServerApp.token = "" c.ServerApp.password = "" Authentication and Authorization -------------------------------- .. versionadded:: 2.0 There are two steps to deciding whether to allow a given request to be happen. The first step is "Authentication" (identifying who is making the request). This is handled by the :class:`jupyter_server.auth.IdentityProvider`. Whether a given user is allowed to take a specific action is called "Authorization", and is handled separately, by an :class:`~jupyter_server.auth.Authorizer`. These two classes may work together, as the information returned by the IdentityProvider is given to the Authorizer when it makes its decisions. Authentication always takes precedence because if no user is authenticated, no authorization checks need to be made, as all requests requiring *authorization* must first complete *authentication*. Identity Providers ****************** The :class:`jupyter_server.auth.IdentityProvider` class is responsible for the "authentication" step, identifying the user making the request, and constructing information about them. It principally implements two methods. .. autoclass:: jupyter_server.auth.IdentityProvider .. automethod:: get_user .. automethod:: identity_model The first is :meth:`jupyter_server.auth.IdentityProvider.get_user`. This method is given a RequestHandler, and is responsible for deciding whether there is an authenticated user making the request. If the request is authenticated, it should return a :class:`jupyter_server.auth.User` object representing the authenticated user. It should return None if the request is not authenticated. The default implementation accepts token or password authentication. This User object will be available as ``self.current_user`` in any request handler. Request methods decorated with tornado's ``@web.authenticated`` decorator will only be allowed if this method returns something. The User object will be a Python :py:class:`dataclasses.dataclass` - ``jupyter_server.auth.User``: .. autoclass:: jupyter_server.auth.User A custom IdentityProvider *may* return a custom subclass. The next method an identity provider has is :meth:`~jupyter_server.auth.IdentityProvider.identity_model`. ``identity_model(user)`` is responsible for transforming the user object returned from ``.get_user()`` into a standard identity model dictionary, for use in the ``/api/me`` endpoint. If your user object is a simple username string or a dict with a ``username`` field, you may not need to implement this method, as the default implementation will suffice. Any required fields missing from the dict returned by this method will be filled-out with defaults. Only ``username`` is strictly required, if that is all the information the identity provider has available. Missing will be derived according to: - if ``name`` is missing, use ``username`` - if ``display_name`` is missing, use ``name`` Other required fields will be filled with ``None``. Identity Model ^^^^^^^^^^^^^^ The identity model is the model accessed at ``/api/me``, and describes the currently authenticated user. It has the following fields: username (string) Unique string identifying the user. Must be non-empty. name (string) For-humans name of the user. May be the same as ``username`` in systems where only usernames are available. display_name (string) Alternate rendering of name for display, such as a nickname. Often the same as ``name``. initials (string or null) Short string of initials. Initials should not be derived automatically due to localization issues. May be ``null`` if unavailable. avatar_url (string or null) URL of an avatar image to be used for the user. May be ``null`` if unavailable. color (string or null) A CSS color string to use as a preferred color, such as for collaboration cursors. May be ``null`` if unavailable. The default implementation of the identity provider is stateless, meaning it doesn't store user information on the server side. Instead, it utilizes session cookies to generate and store random user information on the client side. When a user logs in or authenticates, the server generates a session cookie that is stored on the client side. This session cookie is used to keep track of the identity model between requests. If the client does not support session cookies or fails to send the cookie in subsequent requests, the server will treat each request as coming from a new anonymous user and generate a new set of random user information for each request. To ensure proper functionality of the identity model and to maintain user context between requests, it's important for clients to support session cookies and send it in subsequent requests. Failure to do so may result in the server generating a new anonymous user for each request, leading to loss of user context. Authorization ************* Authorization is the second step in allowing an action, after a user has been *authenticated* by the IdentityProvider. Authorization in Jupyter Server serves to provide finer grained control of access to its API resources. With authentication, requests are accepted if the current user is known by the server. Thus it can restrain access to specific users, but there is no way to give allowed users more or less permissions. Jupyter Server provides a thin and extensible authorization layer which checks if the current user is authorized to make a specific request. .. autoclass:: jupyter_server.auth.Authorizer .. automethod:: is_authorized This is done by calling a ``is_authorized(handler, user, action, resource)`` method before each request handler. Each request is labeled as either a "read", "write", or "execute" ``action``: - "read" wraps all ``GET`` and ``HEAD`` requests. In general, read permissions grants access to read but not modify anything about the given resource. - "write" wraps all ``POST``, ``PUT``, ``PATCH``, and ``DELETE`` requests. In general, write permissions grants access to modify the given resource. - "execute" wraps all requests to ZMQ/Websocket channels (terminals and kernels). Execute is a special permission that usually corresponds to arbitrary execution, such as via a kernel or terminal. These permissions should generally be considered sufficient to perform actions equivalent to ~all other permissions via other means. The ``resource`` being accessed refers to the resource name in the Jupyter Server's API endpoints. In most cases, this is the field after ``/api/``. For instance, values for ``resource`` in the endpoints provided by the base Jupyter Server package, and the corresponding permissions: .. list-table:: :header-rows: 1 * - resource - read - write - execute - endpoints * - *resource name* - *what can you do with read permissions?* - *what can you do with write permissions?* - *what can you do with execute permissions, if anything?* - ``/api/...`` *what endpoints are governed by this resource?* * - api - read server status (last activity, number of kernels, etc.), OpenAPI specification - - - ``/api/status``, ``/api/spec.yaml`` * - csp - - report content-security-policy violations - - ``/api/security/csp-report`` * - config - read frontend configuration, such as for notebook extensions - modify frontend configuration - - ``/api/config`` * - contents - read files - modify files (create, modify, delete) - - ``/api/contents``, ``/view``, ``/files`` * - kernels - list kernels, get status of kernels - start, stop, and restart kernels - Connect to kernel websockets, send/recv kernel messages. **This generally means arbitrary code execution, and should usually be considered equivalent to having all other permissions.** - ``/api/kernels`` * - kernelspecs - read, list information about available kernels - - - ``/api/kernelspecs`` * - nbconvert - render notebooks to other formats via nbconvert. **Note: depending on server-side configuration, this *could* involve execution.** - - - ``/api/nbconvert`` * - server - - Shutdown the server - - ``/api/shutdown`` * - sessions - list current sessions (association of documents to kernels) - create, modify, and delete existing sessions, which includes starting, stopping, and deleting kernels. - - ``/api/sessions`` * - terminals - list running terminals and their last activity - start new terminals, stop running terminals - Connect to terminal websockets, execute code in a shell. **This generally means arbitrary code execution, and should usually be considered equivalent to having all other permissions.** - ``/api/terminals`` Extensions may define their own resources. Extension resources should start with ``extension_name:`` to avoid namespace conflicts. If ``is_authorized(...)`` returns ``True``, the request is made; otherwise, a ``HTTPError(403)`` (403 means "Forbidden") error is raised, and the request is blocked. By default, authorization is turned off—i.e. ``is_authorized()`` always returns ``True`` and all authenticated users are allowed to make all types of requests. To turn-on authorization, pass a class that inherits from ``Authorizer`` to the ``ServerApp.authorizer_class`` parameter, implementing a ``is_authorized()`` method with your desired authorization logic, as follows: .. sourcecode:: python from jupyter_server.auth import Authorizer class MyAuthorizationManager(Authorizer): """Class for authorizing access to resources in the Jupyter Server. All authorizers used in Jupyter Server should inherit from AuthorizationManager and, at the very minimum, override and implement an `is_authorized` method with the following signature. The `is_authorized` method is called by the `@authorized` decorator in JupyterHandler. If it returns True, the incoming request to the server is accepted; if it returns False, the server returns a 403 (Forbidden) error code. """ def is_authorized( self, handler: JupyterHandler, user: Any, action: str, resource: str ) -> bool: """A method to determine if `user` is authorized to perform `action` (read, write, or execute) on the `resource` type. Parameters ------------ user : usually a dict or string A truthy model representing the authenticated user. A username string by default, but usually a dict when integrating with an auth provider. action : str the category of action for the current request: read, write, or execute. resource : str the type of resource (i.e. contents, kernels, files, etc.) the user is requesting. Returns True if user authorized to make request; otherwise, returns False. """ return True # implement your authorization logic here The ``is_authorized()`` method will automatically be called whenever a handler is decorated with ``@authorized`` (from ``jupyter_server.auth``), similarly to the ``@authenticated`` decorator for authentication (from ``tornado.web``). Security in notebook documents ============================== As Jupyter Server become more popular for sharing and collaboration, the potential for malicious people to attempt to exploit the notebook for their nefarious purposes increases. IPython 2.0 introduced a security model to prevent execution of untrusted code without explicit user input. The problem ----------- The whole point of Jupyter is arbitrary code execution. We have no desire to limit what can be done with a notebook, which would negatively impact its utility. Unlike other programs, a Jupyter notebook document includes output. Unlike other documents, that output exists in a context that can execute code (via Javascript). The security problem we need to solve is that no code should execute just because a user has **opened** a notebook that **they did not write**. Like any other program, once a user decides to execute code in a notebook, it is considered trusted, and should be allowed to do anything. Our security model ------------------ - Untrusted HTML is always sanitized - Untrusted Javascript is never executed - HTML and Javascript in Markdown cells are never trusted - **Outputs** generated by the user are trusted - Any other HTML or Javascript (in Markdown cells, output generated by others) is never trusted - The central question of trust is "Did the current user do this?" The details of trust -------------------- When a notebook is executed and saved, a signature is computed from a digest of the notebook's contents plus a secret key. This is stored in a database, writable only by the current user. By default, this is located at:: ~/.local/share/jupyter/nbsignatures.db # Linux ~/Library/Jupyter/nbsignatures.db # OS X %APPDATA%/jupyter/nbsignatures.db # Windows Each signature represents a series of outputs which were produced by code the current user executed, and are therefore trusted. When you open a notebook, the server computes its signature, and checks if it's in the database. If a match is found, HTML and Javascript output in the notebook will be trusted at load, otherwise it will be untrusted. Any output generated during an interactive session is trusted. Updating trust ************** A notebook's trust is updated when the notebook is saved. If there are any untrusted outputs still in the notebook, the notebook will not be trusted, and no signature will be stored. If all untrusted outputs have been removed (either via ``Clear Output`` or re-execution), then the notebook will become trusted. While trust is updated per output, this is only for the duration of a single session. A newly loaded notebook file is either trusted or not in its entirety. Explicit trust ************** Sometimes re-executing a notebook to generate trusted output is not an option, either because dependencies are unavailable, or it would take a long time. Users can explicitly trust a notebook in two ways: - At the command-line, with:: jupyter trust /path/to/notebook.ipynb - After loading the untrusted notebook, with ``File / Trust Notebook`` These two methods simply load the notebook, compute a new signature, and add that signature to the user's database. Reporting security issues ------------------------- If you find a security vulnerability in Jupyter, either a failure of the code to properly implement the model described here, or a failure of the model itself, please report it to security@ipython.org. If you prefer to encrypt your security reports, you can use :download:`this PGP public key `. Affected use cases ------------------ Some use cases that work in Jupyter 1.0 became less convenient in 2.0 as a result of the security changes. We do our best to minimize these annoyances, but security is always at odds with convenience. Javascript and CSS in Markdown cells ************************************ While never officially supported, it had become common practice to put hidden Javascript or CSS styling in Markdown cells, so that they would not be visible on the page. Since Markdown cells are now sanitized (by `Google Caja `__), all Javascript (including click event handlers, etc.) and CSS will be stripped. We plan to provide a mechanism for notebook themes, but in the meantime styling the notebook can only be done via either ``custom.css`` or CSS in HTML output. The latter only have an effect if the notebook is trusted, because otherwise the output will be sanitized just like Markdown. Collaboration ************* When collaborating on a notebook, people probably want to see the outputs produced by their colleagues' most recent executions. Since each collaborator's key will differ, this will result in each share starting in an untrusted state. There are three basic approaches to this: - re-run notebooks when you get them (not always viable) - explicitly trust notebooks via ``jupyter trust`` or the notebook menu (annoying, but easy) - share a notebook signatures database, and use configuration dedicated to the collaboration while working on the project. To share a signatures database among users, you can configure: .. code-block:: python c.NotebookNotary.data_dir = "/path/to/signature_dir" to specify a non-default path to the SQLite database (of notebook hashes, essentially). jupyter-server-jupyter_server-e5c7e2b/docs/source/other/000077500000000000000000000000001473126534200236645ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/source/other/faq.rst000066400000000000000000000006621473126534200251710ustar00rootroot00000000000000.. _faq: Frequently asked questions ========================== Here is a list of questions we think you might have. This list will always be growing, so please feel free to add your question+anwer to this page! |:rocket:| Can I configure multiple extensions at once? -------------------------------------------- Checkout our "Operator" docs on how to :ref:`configure extensions `. |:closed_book:| jupyter-server-jupyter_server-e5c7e2b/docs/source/other/index.rst000066400000000000000000000002031473126534200255200ustar00rootroot00000000000000Other helpful documentation --------------------------- .. toctree:: :maxdepth: 1 links faq full-config changelog jupyter-server-jupyter_server-e5c7e2b/docs/source/other/links.rst000066400000000000000000000006661473126534200255460ustar00rootroot00000000000000List of helpful links ===================== * :ref:`Frequently Asked Questions ` * `Jupyter Server Github Repo `_ * `JupyterLab Github Repo `_ * `Jupyter Notebook Github Repo `_ * `Jupyterhub Github Repo `_ * `Jupyter Zoom Channel `_ jupyter-server-jupyter_server-e5c7e2b/docs/source/users/000077500000000000000000000000001473126534200237045ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/docs/source/users/configuration.rst000066400000000000000000000036211473126534200273070ustar00rootroot00000000000000.. _user-configuring-a-jupyter-server: Configuring a Jupyter Server ============================ Using a Jupyter config file --------------------------- By default, Jupyter Server looks for server-specific configuration in a ``jupyter_server_config`` file located on a Jupyter path. To list the paths where Jupyter Server will look, run: .. code-block:: console $ jupyter --paths config: /Users/username/.jupyter /usr/local/etc/jupyter /etc/jupyter data: /Users/username/Library/Jupyter /usr/local/share/jupyter /usr/share/jupyter runtime: /Users/username/Library/Jupyter/runtime The paths under ``config`` are listed in order of precedence. If the same trait is listed in multiple places, it will be set to the value from the file with the highest precedence. Jupyter Server uses IPython's traitlets system for configuration. Traits can be listed in a Python or JSON config file. You can quickly create a ``jupyter_server_config.py`` file in the ``.jupyter`` directory, with all the defaults commented out, use the following command: .. code-block:: console $ jupyter server --generate-config In Python files, these traits will have the prefix ``c.ServerApp``. For example, your configuration file could look like: .. code-block:: python # inside a jupyter_server_config.py file. c.ServerApp.port = 9999 The same configuration in JSON, looks like: .. code-block:: json { "ServerApp": { "port": 9999 } } Using the CLI ------------- Alternatively, you can configure Jupyter Server when launching from the command line using CLI args. Prefix each argument with ``--ServerApp`` like so: .. code-block:: console $ jupyter server --ServerApp.port=9999 Full configuration list ----------------------- See the full list of configuration options for the server :ref:`here `. jupyter-server-jupyter_server-e5c7e2b/docs/source/users/help.rst000066400000000000000000000004401473126534200253640ustar00rootroot00000000000000.. _user-getting-help: Getting Help ============ If you run into any issues or bugs, please open an `issue on Github `_. We'd also love to have you come by our :ref:`Team Meetings `. jupyter-server-jupyter_server-e5c7e2b/docs/source/users/index.rst000066400000000000000000000005721473126534200255510ustar00rootroot00000000000000Documentation for Users ======================= The Jupyter Server is a highly technical piece of the Jupyter Stack, so users probably won't import or install this library directly. These pages are to meant to help you in case you run into issues or bugs. .. toctree:: :caption: Users :maxdepth: 1 :name: users installation configuration launching help jupyter-server-jupyter_server-e5c7e2b/docs/source/users/installation.rst000066400000000000000000000011361473126534200271400ustar00rootroot00000000000000.. _user-installation: Installation ============ Most Jupyter users will **never need to install Jupyter Server manually**. Jupyter Web applications will include the (correct version) of Jupyter Server as a dependency. It's best to let those applications handle installation, because they may require a specific version of Jupyter Server. If you decide to install manually, run: .. code-block:: bash pip install jupyter_server You upgrade or downgrade to a specific version of Jupyter Server by adding an operator to the command above: .. code-block:: bash pip install jupyter_server==1.0 jupyter-server-jupyter_server-e5c7e2b/docs/source/users/launching.rst000066400000000000000000000033001473126534200264020ustar00rootroot00000000000000.. _user-launching-a-bare-jupyter-server: Launching a bare Jupyter Server =============================== Most of the time, you won't need to start the Jupyter Server directly. Jupyter Web Applications (like Jupyter Notebook, Jupyterlab, Voila, etc.) come with their own entry points that start a server automatically. Sometimes, though, it can be useful to start Jupyter Server directly when you want to run multiple Jupyter Web applications at the same time. For more details, see the :ref:`Managing multiple extensions ` page. If these extensions are enabled, you can simple run the following: .. code-block:: bash > jupyter server [I 2020-03-20 15:48:20.903 ServerApp] Serving notebooks from local directory: /Users/username/home [I 2020-03-20 15:48:20.903 ServerApp] Jupyter Server 1.0.0 is running at: [I 2020-03-20 15:48:20.903 ServerApp] http://localhost:8888/?token=<...> [I 2020-03-20 15:48:20.903 ServerApp] or http://127.0.0.1:8888/?token=<...> [I 2020-03-20 15:48:20.903 ServerApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation). [I 2020-03-20 15:48:20.903 ServerApp] Welcome to Project Jupyter! Explore the various tools available and their corresponding documentation. If you are interested in contributing to the platform, please visit the communityresources section at https://jupyter.org/community.html. [C 2020-03-20 15:48:20.907 ServerApp] To access the server, open this file in a browser: file:///Users/username/jpserver-###-open.html Or copy and paste one of these URLs: http://localhost:8888/?token=<...> or http://127.0.0.1:8888/?token=<...> jupyter-server-jupyter_server-e5c7e2b/eslint.config.mjs000066400000000000000000000010201473126534200235610ustar00rootroot00000000000000export default [ { "languageOptions": { "parserOptions": { "ecmaVersion": 6, "sourceType": "module" } }, "rules": { "semi": 1, "no-cond-assign": 2, "no-debugger": 2, "comma-dangle": 0, "no-unreachable": 2 }, "ignores": [ "*.min.js", "*components*", "*node_modules*", "*built*", "*build*" ] } ]; jupyter-server-jupyter_server-e5c7e2b/examples/000077500000000000000000000000001473126534200221315ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/authorization/000077500000000000000000000000001473126534200250315ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/authorization/README.md000066400000000000000000000050471473126534200263160ustar00rootroot00000000000000# Authorization in a simple Jupyter Notebook Server This folder contains the following examples: 1. a "read-only" Jupyter Notebook Server 1. a read/write Server without the ability to execute code on kernels. 1. a "temporary notebook server", i.e. read and execute notebooks but cannot save/write files. ## How does it work? To add a custom authorization system to the Jupyter Server, you will need to write your own `Authorizer` subclass and pass it to Jupyter's configuration system (i.e. by file or CLI). The examples below demonstrate some basic implementations of an `Authorizer`. ```python from jupyter_server.auth import Authorizer class MyCustomAuthorizer(Authorizer): """Custom authorization manager.""" # Define my own method here for handling authorization. # The argument signature must have `self`, `handler`, `user`, `action`, and `resource`. def is_authorized(self, handler, user, action, resource): """My override for handling authorization in Jupyter services.""" # Add logic here to check if user is allowed. # For example, here is an example of a read-only server if action != "read": return False return True # Pass this custom class to Jupyter Server c.ServerApp.authorizer_class = MyCustomAuthorizer ``` In the `jupyter_nbclassic_readonly_config.py` ## Try it out! ### Read-only example 1. Install nbclassic using `pip`. pip install nbclassic 1. Navigate to the jupyter_authorized_server `examples/` folder. 1. Launch nbclassic and load `jupyter_nbclassic_readonly_config.py`: jupyter nbclassic --config=jupyter_nbclassic_readonly_config.py 1. Try creating a notebook, running a notebook in a cell, etc. You should see a `403: Forbidden` error. ### Read+Write example 1. Install nbclassic using `pip`. pip install nbclassic 1. Navigate to the jupyter_authorized_server `examples/` folder. 1. Launch nbclassic and load `jupyter_nbclassic_rw_config.py`: jupyter nbclassic --config=jupyter_nbclassic_rw_config.py 1. Try running a cell in a notebook. You should see a `403: Forbidden` error. ### Temporary notebook server example This configuration allows everything except saving files. 1. Install nbclassic using `pip`. pip install nbclassic 1. Navigate to the jupyter_authorized_server `examples/` folder. 1. Launch nbclassic and load `jupyter_temporary_config.py`: jupyter nbclassic --config=jupyter_temporary_config.py 1. Edit a notebook, run a cell, etc. Everything works fine. Then try to save your changes... you should see a `403: Forbidden` error. jupyter-server-jupyter_server-e5c7e2b/examples/authorization/jupyter_nbclassic_readonly_config.py000066400000000000000000000006621473126534200343540ustar00rootroot00000000000000"""Nbclassic read only auth example.""" from jupyter_server.auth import Authorizer class ReadOnly(Authorizer): """Authorizer that makes Jupyter Server a read-only server.""" def is_authorized(self, handler, user, action, resource): """Only allows `read` operations.""" if action != "read": return False return True c.ServerApp.authorizer_class = ReadOnly # type:ignore[name-defined] jupyter-server-jupyter_server-e5c7e2b/examples/authorization/jupyter_nbclassic_rw_config.py000066400000000000000000000007441473126534200331700ustar00rootroot00000000000000"""Nbclassic read/write auth example.""" from jupyter_server.auth import Authorizer class ReadWriteOnly(Authorizer): """Authorizer class that makes Jupyter Server a read/write-only server.""" def is_authorized(self, handler, user, action, resource): """Only allows `read` and `write` operations.""" if action not in {"read", "write"}: return False return True c.ServerApp.authorizer_class = ReadWriteOnly # type:ignore[name-defined] jupyter-server-jupyter_server-e5c7e2b/examples/authorization/jupyter_temporary_config.py000066400000000000000000000010121473126534200325260ustar00rootroot00000000000000"""Nbclassic temporary server auth example.""" from jupyter_server.auth import Authorizer class TemporaryServerPersonality(Authorizer): """Authorizer that prevents modifying files via the contents service""" def is_authorized(self, handler, user, action, resource): """Allow everything but write on contents""" if action == "write" and resource == "contents": return False return True c.ServerApp.authorizer_class = TemporaryServerPersonality # type:ignore[name-defined] jupyter-server-jupyter_server-e5c7e2b/examples/identity/000077500000000000000000000000001473126534200237625ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/identity/system_password/000077500000000000000000000000001473126534200272305ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/identity/system_password/README.md000066400000000000000000000006121473126534200305060ustar00rootroot00000000000000# Jupyter login with system password This `jupyter_server_config.py` defines and enables a `SystemPasswordIdentityProvider`. This IdentityProvider checks the entered password against your system password using PAM. Only the current user's password (the user the server is running as) is accepted. The result is a User whose name matches the system user, rather than a randomly generated one. jupyter-server-jupyter_server-e5c7e2b/examples/identity/system_password/jupyter_server_config.py000066400000000000000000000020361473126534200342200ustar00rootroot00000000000000"""Jupyter server system password identity provider example.""" import pwd from getpass import getuser from pamela import PAMError, authenticate from jupyter_server.auth.identity import IdentityProvider, User class SystemPasswordIdentityProvider(IdentityProvider): """A system password identity provider.""" # no need to generate a default token (token can still be used, but it's opt-in) need_token = False def process_login_form(self, handler): """Process a login form.""" username = getuser() password = handler.get_argument("password", "") try: authenticate(username, password) except PAMError as e: self.log.error(f"Failed login for {username}: {e}") return None user_info = pwd.getpwnam(username) # get human name from pwd, if not empty return User(username=username, name=user_info.pw_gecos or username) c = get_config() # type: ignore[name-defined] c.ServerApp.identity_provider_class = SystemPasswordIdentityProvider jupyter-server-jupyter_server-e5c7e2b/examples/simple/000077500000000000000000000000001473126534200234225ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/README.md000066400000000000000000000160661473126534200247120ustar00rootroot00000000000000# Jupyter Server Simple Extension Example This folder contains example of simple extensions on top of Jupyter Server and review configuration aspects. ## Install You need `python3` to build and run the server extensions. ```bash # Clone, create a conda env and install from source. git clone https://github.com/jupyter/jupyter_server && \ cd jupyter_server/examples/simple && \ conda create -y -n jupyter-server-example python=3.9 && \ conda activate jupyter-server-example && \ pip install -e .[test] ``` **OPTIONAL** If you want to build the Typescript code, you need [npm](https://www.npmjs.com) on your local environment. Compiled javascript is provided as artifact in this repository, so this Typescript build step is optional. The Typescript source and configuration have been taken from https://github.com/markellekelly/jupyter-server-example. ```bash npm install && \ npm run build ``` ## No Extension Ensure Jupyter Server is starting without any extension enabled. ```bash # Run this command from a shell. jupyter server ``` Browse the default home page, it should show a white page in your browser with the following content: `A Jupyter Server is running.` ```bash # Jupyter Server default Home Page. open http://localhost:8888 ``` ## Extension 1 ```bash # Start the jupyter server activating simple_ext1 extension. jupyter server --ServerApp.jpserver_extensions="{'simple_ext1': True}" ``` Now you can render `Extension 1` Server content in your browser. ```bash # Home page as defined by default_url = '/default'. open http://localhost:8888/simple_ext1/default # HTML static page. open http://localhost:8888/static/simple_ext1/home.html open http://localhost:8888/static/simple_ext1/test.html # Content from Handlers. open http://localhost:8888/simple_ext1/params/test?var1=foo # Content from Template. open http://localhost:8888/simple_ext1/template1/test # Content from Template with Typescript. open http://localhost:8888/simple_ext1/typescript # Error content. open http://localhost:8888/simple_ext1/nope # Redirect. open http://localhost:8888/simple_ext1/redirect # Favicon static content. open http://localhost:8888/static/simple_ext1/favicon.ico ``` You can also start the server extension with python modules. ```bash python -m simple_ext1 ``` To live reload the server as you change the extension, you can also enable [the `debug` mode for Tornado](https://www.tornadoweb.org/en/stable/guide/running.html#debug-mode-and-automatic-reloading): ```bash jupyter server --ServerApp.jpserver_extensions="{'simple_ext1': True}" --ServerApp.tornado_settings="{'debug': True}" ``` ## Extension 1 and Extension 2 The following command starts both the `simple_ext1` and `simple_ext2` extensions. ```bash # Start the jupyter server, it will load both simple_ext1 and simple_ext2 based on the provided trait. jupyter server --ServerApp.jpserver_extensions="{'simple_ext1': True, 'simple_ext2': True}" ``` Check that the previous `Extension 1` content is still available and that you can also render `Extension 2` Server content in your browser. ```bash # HTML static page. open http://localhost:8888/static/simple_ext2/test.html # Content from Handlers. open http://localhost:8888/simple_ext2/params/test?var1=foo ``` ## Work with Entrypoints Optionally, you can copy `simple_ext1.json` and `simple_ext2.json` configuration to your env `etc` folder and start only Extension 1, which will also start Extension 2. ```bash pip uninstall -y jupyter_server_example && \ python setup.py install && \ cp -r ./etc $(dirname $(which jupyter))/.. ``` ```bash # Start the jupyter server extension simple_ext1, it will also load simple_ext2 because of load_other_extensions = True.. # When you invoke with the entrypoint, the default url will be opened in your browser. jupyter simple-ext1 ``` ## Configuration Stop any running server (with `CTRL+C`) and start with additional configuration on the command line. The provided settings via CLI will override the configuration that reside in the files (`jupyter_server_example1_config.py`...) ```bash jupyter simple-ext1 --SimpleApp1.configA="ConfigA from command line" ``` Check the log, it should return on startup print the Config object. The content of the Config is based on the trait you have defined via the `CLI` and in the `jupyter_server_example1_config.py`. ``` [SimpleApp1] Config {'SimpleApp1': {'configA': 'ConfigA from file', 'configB': 'ConfigB from file', 'configC': 'ConfigC from file'}} [SimpleApp1] Config {'SimpleApp1': {'configA': 'ConfigA from file', 'configB': 'ConfigB from file', 'configC': 'ConfigC from file'}} [SimpleApp2] WARNING | Config option `configD` not recognized by `SimpleApp2`. Did you mean one of: `configA, configB, configC`? [SimpleApp2] Config {'SimpleApp2': {'configD': 'ConfigD from file'}} [SimpleApp1] Config {'SimpleApp1': {'configA': 'ConfigA from command line', 'configB': 'ConfigB from file', 'configC': 'ConfigC from file'}} ``` ## Only Extension 2 Now stop again the server and start with only `Extension 2`. ```bash # Start the jupyter server extension simple_ext2, it will NOT load simple_ext1 because of load_other_extensions = False. jupyter simple-ext2 ``` Try with the above links to check that only Extension 2 is responding (Extension 1 URLs should give you an 404 error). ## Extension 11 extends Extension 1 `Extension 11` extends `Extension 1` and brings a few more configs. ```bash # TODO `--generate-config` returns an exception `"The ExtensionApp has not ServerApp "` jupyter simple-ext11 --generate-config && vi ~/.jupyter/jupyter_config.py`. ``` The generated configuration should contains the following. ```bash # TODO ``` The `hello`, `ignore_js` and `simple11_dir` are traits defined on the SimpleApp11 class. It also implements additional flags and aliases for these traits. - The `--hello` flag will log on startup `Hello Simple11 - You have provided the --hello flag or defined a c.SimpleApp1.hello == True` - The `ignore_js` flag - The `--simple11-dir` alias will set `SimpleExt11.simple11_dir` settings Stop any running server and then start the simple-ext11. ```bash jupyter simple-ext11 --hello --simple11-dir any_folder # You can also launch with a module python -m simple_ext11 --hello # TODO FIX the following command, simple11 does not work launching with jpserver_extensions parameter. jupyter server --ServerApp.jpserver_extensions="{'simple_ext11': True}" --hello --simple11-dir any_folder ``` Ensure the following URLs respond correctly. ```bash # Jupyter Server Home Page. open http://localhost:8888/ # TODO Fix Default URL, it does not show on startup. # Home page as defined by default_url = '/default'. open http://localhost:8888/simple_ext11/default # Content from Handlers. open http://localhost:8888/simple_ext11/params/test?var1=foo # Content from Template. open http://localhost:8888/simple_ext11/template1/test # Content from Template with Typescript. open http://localhost:8888/simple_ext11/typescript # Error content. open http://localhost:8888/simple_ext11/nope # Redirect. open http://localhost:8888/simple_ext11/redirect # Favicon static content. open http://localhost:8888/static/simple_ext11/favicon.ico ``` jupyter-server-jupyter_server-e5c7e2b/examples/simple/conftest.py000066400000000000000000000001171473126534200256200ustar00rootroot00000000000000"""Pytest configuration.""" pytest_plugins = ["jupyter_server.pytest_plugin"] jupyter-server-jupyter_server-e5c7e2b/examples/simple/etc/000077500000000000000000000000001473126534200241755ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/etc/jupyter/000077500000000000000000000000001473126534200256775ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/etc/jupyter/jupyter_server_config.d/000077500000000000000000000000001473126534200325365ustar00rootroot00000000000000simple_ext1.json000066400000000000000000000001261473126534200356030ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/etc/jupyter/jupyter_server_config.d{ "ServerApp": { "jpserver_extensions": { "simple_ext1": true } } } simple_ext11.json000066400000000000000000000001271473126534200356650ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/etc/jupyter/jupyter_server_config.d{ "ServerApp": { "jpserver_extensions": { "simple_ext11": true } } } simple_ext2.json000066400000000000000000000001261473126534200356040ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/etc/jupyter/jupyter_server_config.d{ "ServerApp": { "jpserver_extensions": { "simple_ext2": true } } } jupyter-server-jupyter_server-e5c7e2b/examples/simple/jupyter_server_config.py000066400000000000000000000006721473126534200304160ustar00rootroot00000000000000"""Configuration file for jupyter-server extensions.""" # ------------------------------------------------------------------------------ # Application(SingletonConfigurable) configuration # ------------------------------------------------------------------------------ # The date format used by logging formatters for %(asctime)s c.Application.log_datefmt = ( # type:ignore[name-defined] "%Y-%m-%d %H:%M:%S Simple_Extensions_Example" ) jupyter-server-jupyter_server-e5c7e2b/examples/simple/jupyter_simple_ext11_config.py000066400000000000000000000001321473126534200314120ustar00rootroot00000000000000"""Jupyter server config.""" c.SimpleApp11.ignore_js = True # type:ignore[name-defined] jupyter-server-jupyter_server-e5c7e2b/examples/simple/jupyter_simple_ext1_config.py000066400000000000000000000004761473126534200313440ustar00rootroot00000000000000"""Jupyter server config.""" c.SimpleApp1.configA = "ConfigA from file" # type:ignore[name-defined] c.SimpleApp1.configB = "ConfigB from file" # type:ignore[name-defined] c.SimpleApp1.configC = "ConfigC from file" # type:ignore[name-defined] c.SimpleApp1.configD = "ConfigD from file" # type:ignore[name-defined] jupyter-server-jupyter_server-e5c7e2b/examples/simple/jupyter_simple_ext2_config.py000066400000000000000000000001461473126534200313370ustar00rootroot00000000000000"""Jupyter server config.""" c.SimpleApp2.configD = "ConfigD from file" # type:ignore[name-defined] jupyter-server-jupyter_server-e5c7e2b/examples/simple/package.json000066400000000000000000000005211473126534200257060ustar00rootroot00000000000000{ "name": "jupyter-server-example", "version": "0.0.1", "private": true, "scripts": { "build": "tsc -p src && webpack", "clean": "rimraf build", "prepublishOnly": "npm run build" }, "dependencies": {}, "devDependencies": { "webpack": "^5.72.0", "webpack-cli": "^5.0.0", "typescript": "~4.7.3" } } jupyter-server-jupyter_server-e5c7e2b/examples/simple/pyproject.toml000066400000000000000000000017311473126534200263400ustar00rootroot00000000000000[build-system] requires = ["hatchling","hatch-nodejs-version"] build-backend = "hatchling.build" [project] name = "jupyter-server-example" description = "Jupyter Server Example" readme = "README.md" license = "MIT" requires-python = ">=3.9" dependencies = [ "jinja2", "jupyter_server", ] dynamic = ["version"] [project.optional-dependencies] test = [ "pytest", "pytest-asyncio", ] [project.scripts] jupyter-simple-ext1 = "simple_ext1.application:main" jupyter-simple-ext11 = "simple_ext11.application:main" jupyter-simple-ext2 = "simple_ext2.application:main" [tool.hatch.version] source = "nodejs" [tool.hatch.build.targets.wheel.shared-data] "etc/jupyter/jupyter_server_config.d" = "etc/jupyter/jupyter_server_config.d" [tool.hatch.build.targets.wheel] packages = ["simple_ext1", "simple_ext2", "simple_ext11"] [tool.hatch.build.hooks.jupyter-builder] dependencies = [ "hatch-jupyter-builder>=0.8.2", ] build-function = "hatch_jupyter_builder.npm_builder" jupyter-server-jupyter_server-e5c7e2b/examples/simple/pytest.ini000066400000000000000000000001141473126534200254470ustar00rootroot00000000000000[pytest] # Disable any upper exclusion. norecursedirs = asyncio_mode = auto jupyter-server-jupyter_server-e5c7e2b/examples/simple/setup.py000066400000000000000000000001341473126534200251320ustar00rootroot00000000000000# setup.py shim for use with applications that require it. __import__("setuptools").setup() jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/000077500000000000000000000000001473126534200256545ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/__init__.py000066400000000000000000000002241473126534200277630ustar00rootroot00000000000000from .application import SimpleApp1 def _jupyter_server_extension_points(): return [{"module": "simple_ext1.application", "app": SimpleApp1}] jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/__main__.py000066400000000000000000000001421473126534200277430ustar00rootroot00000000000000"""Application cli main.""" from .application import main if __name__ == "__main__": main() jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/application.py000066400000000000000000000042061473126534200305330ustar00rootroot00000000000000"""Jupyter server example application.""" import os from traitlets import Unicode from jupyter_server.extension.application import ExtensionApp, ExtensionAppJinjaMixin from .handlers import ( DefaultHandler, ErrorHandler, ParameterHandler, RedirectHandler, TemplateHandler, TypescriptHandler, ) DEFAULT_STATIC_FILES_PATH = os.path.join(os.path.dirname(__file__), "static") DEFAULT_TEMPLATE_FILES_PATH = os.path.join(os.path.dirname(__file__), "templates") class SimpleApp1(ExtensionAppJinjaMixin, ExtensionApp): """A simple jupyter server application.""" # The name of the extension. name = "simple_ext1" # The url that your extension will serve its homepage. extension_url = "/simple_ext1/default" # Should your extension expose other server extensions when launched directly? load_other_extensions = True # Local path to static files directory. static_paths = [DEFAULT_STATIC_FILES_PATH] # type:ignore[assignment] # Local path to templates directory. template_paths = [DEFAULT_TEMPLATE_FILES_PATH] # type:ignore[assignment] configA = Unicode("", config=True, help="Config A example.") configB = Unicode("", config=True, help="Config B example.") configC = Unicode("", config=True, help="Config C example.") def initialize_handlers(self): """Initialize handlers.""" self.handlers.extend( [ (rf"/{self.name}/default", DefaultHandler), (rf"/{self.name}/params/(.+)$", ParameterHandler), (rf"/{self.name}/template1/(.*)$", TemplateHandler), (rf"/{self.name}/redirect", RedirectHandler), (rf"/{self.name}/typescript/?", TypescriptHandler), (rf"/{self.name}/(.*)", ErrorHandler), ] ) def initialize_settings(self): """Initialize settings.""" self.log.info(f"Config {self.config}") # ----------------------------------------------------------------------------- # Main entry point # ----------------------------------------------------------------------------- main = launch_new_instance = SimpleApp1.launch_instance jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/handlers.py000066400000000000000000000050011473126534200300220ustar00rootroot00000000000000"""Jupyter server example handlers.""" from jupyter_server.auth import authorized from jupyter_server.base.handlers import JupyterHandler from jupyter_server.extension.handler import ExtensionHandlerJinjaMixin, ExtensionHandlerMixin from jupyter_server.utils import url_escape class DefaultHandler(ExtensionHandlerMixin, JupyterHandler): """Default API handler.""" auth_resource = "simple_ext1:default" @authorized def get(self): """Get the extension response.""" # The name of the extension to which this handler is linked. self.log.info(f"Extension Name in {self.name} Default Handler: {self.name}") # A method for getting the url to static files (prefixed with /static/). self.log.info( "Static URL for / in simple_ext1 Default Handler: %s", self.static_url(path="/"), ) self.write("

Hello Simple 1 - I am the default...

") self.write(f"Config in {self.name} Default Handler: {self.config}") class RedirectHandler(ExtensionHandlerMixin, JupyterHandler): """A redirect handler.""" def get(self): """Handle a redirect.""" self.redirect(f"/static/{self.name}/favicon.ico") class ParameterHandler(ExtensionHandlerMixin, JupyterHandler): """A parameterized handler.""" def get(self, matched_part=None, *args, **kwargs): """Handle a get with parameters.""" var1 = self.get_argument("var1", default="") components = [x for x in self.request.path.split("/") if x] self.write("

Hello Simple App 1 from Handler.

") self.write(f"

matched_part: {url_escape(matched_part)}

") self.write(f"

var1: {url_escape(var1)}

") self.write(f"

components: {components}

") class BaseTemplateHandler(ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler): """The base template handler.""" class TypescriptHandler(BaseTemplateHandler): """A typescript handler.""" def get(self): """Get the typescript template.""" self.write(self.render_template("typescript.html")) class TemplateHandler(BaseTemplateHandler): """A template handler.""" def get(self, path): """Optionally, you can print(self.get_template('simple1.html'))""" self.write(self.render_template("simple1.html", path=path)) class ErrorHandler(BaseTemplateHandler): """An error handler.""" def get(self, path): """Write_error renders template from error.html file.""" self.write_error(400) jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/static/000077500000000000000000000000001473126534200271435ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/static/favicon.ico000066400000000000000000000764461473126534200313050ustar00rootroot00000000000000 hF 00 %V@@ (B:(  @&w&wW&ww&ww&wW&w&w%&w&w&w&w&w&w&w&w&w%&wO&w&w&w&w&w&w&w&w&w&w&wO&wA&w&w&wW'w&x&wW&w&w&wA'y &w&w)&w)&w'y 'x&z'x'x'x'x&z'x'y &w&w)&w)&w'y &wA&w&w&wW&w&x&wW&w&w&wA&wO&w&w&w&w&w&w&w&w&w&w&wO&w%&w&w&w&w&w&w&w&w&w%&w&wW&ww&ww&wW'w( @ (w &xW&w&w&w&w&w&w&w&w&xW(w 'y#&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y#+y &w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w+y 'x!&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x!'y1&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y1(x%&w&w&w&w&w&w'wM'z'z&wM&w&w&w&w&w&w(x%'| &w&w&w&w'w)(w)&w&w&w&w'| &w&w&w(x&z&w&w&w'y%&w'x5(x5&w'y%'xg&z'x'xe+x +x +x +x 'xe'x&z'xg'y%&w(x5'x5&w'y%&w&w&w'x(x&w&w&w'| &w&w&w&w(x''w)&w&w&w&w'| (x%&w&w&w&w&w&w&wM'z&{'wM&w&w&w&w&w&w(x%'y1&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y1'x!&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x!+y &w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w+y 'y#&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y#(w &xW&w&w&w&w&w&w&w&w'wU(w (0` %+z 'x/'xO&wa&wm%wy%wy&wm&wa'xO'x/+z (w &wE&w&w&w&w&w&w&w&w&w&w&w&w&w&x&wE(w )z 'y3&xy&w&w&w&w&w&w&w&w&w&w%w&w&w%w&w&w%w&w&xy'y3)z )y!&w&w&w&v&w%w&v&w%w&v&w%w%v&w%w%v&w%w%v&w%w&v&w&w)y!+y &xY&w%v%v&w%w%v&w%w%v&w%w%v&w&v&w&w&v&w&w&v&w&w&v&w&v&w&xY+y 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w%w&w&w%w&w&w%w&w&w%w&w&w&w&w'x.'y/&w&v&v&w&v&w%w&v&w%w&v&w%w&v&w%w%v&w%w%v&w%w%v&w%w%v&w%w%v&w%w&w'y/.'z%&w&w&w&w&w%w%v&w%w%v&w&w&w&w&w&w&w&w&w&w&w&w%v&v&w&w&v&w&w&v&w&w&w&w'z'(w&w&w&w&w&w&w&w&w&w'w'wI'|(z'xG&w}&w&w&v&w%w&w&w%w&w'w&x&w&v&v&w%v&v&w'x_'w+(w*}(x+&w]&w&v&w%v&w%w&w&w'|'wu&w&w&w&w'ws'w/'w)w(w-&wo&v&w&w&w'wu'|&vI&w&w&w&xk(x&z &wi&w&w&v&wI& &w%w'x'y+&w)'w&w&w& 'yM&w&wi*z+x'wg&w'yM)}&x&y)'x)&w)}(x#(x+(x+(x#+x+x+x+w*w+x+x+x(x#(x+(x+(x#)}&w'x)&z)&x)}'yM&w'wg+x)x&wg&w'yM& &w&w'w&w)'y+&w&w&w% &vG&w&v&w'wi'x 'x&xk&w&w&v&wI'|&wu&w&w&w&w&wo(x-)y'w&x/&ws&w&w&w&w'wu'|'x&w%w&w&w&w&w&w&w](x)*}(w'w+'x_&w&w&v&w&v&w&w&w(x&w%w&w&w%w&w&w&w&w&v}'xG(y&}&xI'v&w&w&w&w&v&w&w&v&w(x'z'&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'z'.'y/&w%w&w&w&w&w&v&w&w&v&w&w&v&w&w&v&w&v&w&w&v&w&w&v&w&w&v&w&w&v&w&w'y/-'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&v&w&w&v&w&w&v&w&w&v&w&w&w&w'x+y &xY&w&w&w%w%w&w%w%w&w%w%w&w%w&w&v&w&w&v&w&w&v&w&w&v%v&w&xY+y )z!&w&w&w%w&w&w%w&w&w%w&w&w&w&w%w&w&w%w&w&w%w%w&w&w)z!)z 'y3&wy&w&w%w%w&w%w%w&w%w&w&w&w&w&w&w&w&w&w&wy&y3)z (w &wE&w&w&w&w&w&w&w&w%w&w&w&w&w'w'wE(w +z 'x/'xO&va&wm&wy&wy&wm&wa'xO'x/+z (@ B3 (xA'x{'x'w&w&x&w&w&x&w'w'x'x{(xA3 (w-&x'x'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'x&x(w-. 'yc&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'yc. 3 'y'w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'w'y3 )zY&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w)zY+y+&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x+y+(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 3'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x&x'x'w&x(yu'wi'wi(yu&x'x'w'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x3'x&w&w&w&w&w&w&w&w&w&w&w'w'w}'xC')z'zA(w{&x'x&w&w&w&w&w&w&w&w&w&w'x(x&w&w&w&w&w&w&w&w'x'x}(w-*}+(w{&w&w&w&w&w&w&w&w&w(x'xi&w&w&w&w&w&w&w'w}(y'+x%(w{'w&w&w&w&w&w&w'xi'|!'w&w&w&w&w'x(w[@@&xW&w&w&w&w&w'w'|!'w&w&w&w&x(xG&zC'w&w&w&w'w'wi&w&w'x'yo@U&wk'w&w&w'wi&'x&w&x.1y'x&w'x&(y&x&x'x}&x(y. &x&zI'xI&w. (ye)xE)xE(ye+x%+x%+x%+x%(ye)xE)xE(ye. &w'xI&zI&x. (y&x'x}&x&x(y&'x&w'x1y,z&x&w'x&'wi&w&w'w&wkU@'yo'x&w&w'wi'w&w&w&w'w'xC(xG&x&w&w&w'w'|!'w&w&w&w&w&w&xWU3(w['x&w&w&w&w'w'|!'xi&w&w&w&w&w&w'w(xy,|#(y''w}&w&w&w&w&w&w&w'xi(x&w&w&w&w&w&w&w&w&x(w{*}+(w-'x}'x&w&w&w&w&w&w&w&w(x'x&w&w&w&w&w&w&w&w&w&w'w&w(w{'zA)z'&zC'w}'w&w&w&w&w&w&w&w&w&w&w&w'x3'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'w'x&x(yu'wi'wi(yu&x'w'x&x&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x3. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS+y+&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x+y+)zY&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w)zY3 'y'w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'y3 . 'xc&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'xc. (w-&x'x'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'x'w(w-3 (xA'x{'x'w&w&x&w&w&x&w'w'x'x{(xA3 jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/static/home.html000066400000000000000000000000541473126534200307600ustar00rootroot00000000000000

Welcome to Simple App 1 Home Page.

jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/static/index.d.ts000066400000000000000000000000371473126534200310440ustar00rootroot00000000000000declare function main(): void; jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/static/index.js000066400000000000000000000002301473126534200306030ustar00rootroot00000000000000function main() { let div = document.getElementById("mydiv"); div.innerText = "Hello from Typescript"; } window.addEventListener("load", main); jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/static/test.html000066400000000000000000000000611473126534200310050ustar00rootroot00000000000000

Hello Simple App 1 from test HTML page.

jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/static/tsconfig.tsbuildinfo000066400000000000000000000112261473126534200332250ustar00rootroot00000000000000{ "program": { "fileInfos": { "../../node_modules/typescript/lib/lib.es5.d.ts": { "version": "ff5688d6b2fcfef06842a395d7ff4d5730d45b724d4c48913118c889829052a1", "signature": "ff5688d6b2fcfef06842a395d7ff4d5730d45b724d4c48913118c889829052a1" }, "../../node_modules/typescript/lib/lib.es2015.d.ts": { "version": "7994d44005046d1413ea31d046577cdda33b8b2470f30281fd9c8b3c99fe2d96", "signature": "7994d44005046d1413ea31d046577cdda33b8b2470f30281fd9c8b3c99fe2d96" }, "../../node_modules/typescript/lib/lib.dom.d.ts": { "version": "2d53f3741e5a4f78a90f623387d71a1cc809bb258f10cdaec034b67cbf71022f", "signature": "2d53f3741e5a4f78a90f623387d71a1cc809bb258f10cdaec034b67cbf71022f" }, "../../node_modules/typescript/lib/lib.es2015.core.d.ts": { "version": "4ab19088d508f9e62bfc61c157e8a65b2afaefa251ecca315e7d20b5b97b256f", "signature": "4ab19088d508f9e62bfc61c157e8a65b2afaefa251ecca315e7d20b5b97b256f" }, "../../node_modules/typescript/lib/lib.es2015.collection.d.ts": { "version": "dd94d8ef48c562389eb58af8df3a3a34d11367f7c818192aa5f16470d469e3f0", "signature": "dd94d8ef48c562389eb58af8df3a3a34d11367f7c818192aa5f16470d469e3f0" }, "../../node_modules/typescript/lib/lib.es2015.generator.d.ts": { "version": "765e0e9c9d74cf4d031ca8b0bdb269a853e7d81eda6354c8510218d03db12122", "signature": "765e0e9c9d74cf4d031ca8b0bdb269a853e7d81eda6354c8510218d03db12122" }, "../../node_modules/typescript/lib/lib.es2015.iterable.d.ts": { "version": "285958e7699f1babd76d595830207f18d719662a0c30fac7baca7df7162a9210", "signature": "285958e7699f1babd76d595830207f18d719662a0c30fac7baca7df7162a9210" }, "../../node_modules/typescript/lib/lib.es2015.promise.d.ts": { "version": "e6b8ff2798f8ebd7a1c7afd8671f2cb67ee1901c422f5964d74b0b34c6574ea2", "signature": "e6b8ff2798f8ebd7a1c7afd8671f2cb67ee1901c422f5964d74b0b34c6574ea2" }, "../../node_modules/typescript/lib/lib.es2015.proxy.d.ts": { "version": "5e72f949a89717db444e3bd9433468890068bb21a5638d8ab15a1359e05e54fe", "signature": "5e72f949a89717db444e3bd9433468890068bb21a5638d8ab15a1359e05e54fe" }, "../../node_modules/typescript/lib/lib.es2015.reflect.d.ts": { "version": "f5b242136ae9bfb1cc99a5971cccc44e99947ae6b5ef6fd8aa54b5ade553b976", "signature": "f5b242136ae9bfb1cc99a5971cccc44e99947ae6b5ef6fd8aa54b5ade553b976" }, "../../node_modules/typescript/lib/lib.es2015.symbol.d.ts": { "version": "9ae2860252d6b5f16e2026d8a2c2069db7b2a3295e98b6031d01337b96437230", "signature": "9ae2860252d6b5f16e2026d8a2c2069db7b2a3295e98b6031d01337b96437230" }, "../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts": { "version": "3e0a459888f32b42138d5a39f706ff2d55d500ab1031e0988b5568b0f67c2303", "signature": "3e0a459888f32b42138d5a39f706ff2d55d500ab1031e0988b5568b0f67c2303" }, "../../src/index.ts": { "version": "a5398b1577287a9a5a7e190a9a7283ee67b12fcc0dbc6d2cac55ef25ed166bb2", "signature": "ed4b087ea2a2e4a58647864cf512c7534210bfc2f9d236a2f9ed5245cf7a0896" } }, "options": { "outDir": "./", "allowSyntheticDefaultImports": true, "composite": true, "declaration": true, "noImplicitAny": true, "noEmitOnError": true, "noUnusedLocals": true, "esModuleInterop": true, "preserveWatchOutput": true, "module": 1, "moduleResolution": 2, "target": 2, "lib": [ "lib.dom.d.ts", "lib.es2015.d.ts" ], "jsx": 2, "types": [], "project": "../../src", "configFilePath": "../../src/tsconfig.json" }, "referencedMap": {}, "exportedModulesMap": {}, "semanticDiagnosticsPerFile": [ "../../node_modules/typescript/lib/lib.es5.d.ts", "../../node_modules/typescript/lib/lib.es2015.d.ts", "../../node_modules/typescript/lib/lib.dom.d.ts", "../../node_modules/typescript/lib/lib.es2015.core.d.ts", "../../node_modules/typescript/lib/lib.es2015.collection.d.ts", "../../node_modules/typescript/lib/lib.es2015.generator.d.ts", "../../node_modules/typescript/lib/lib.es2015.iterable.d.ts", "../../node_modules/typescript/lib/lib.es2015.promise.d.ts", "../../node_modules/typescript/lib/lib.es2015.proxy.d.ts", "../../node_modules/typescript/lib/lib.es2015.reflect.d.ts", "../../node_modules/typescript/lib/lib.es2015.symbol.d.ts", "../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts", "../../src/index.ts" ] }, "version": "3.6.4" } jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/templates/000077500000000000000000000000001473126534200276525ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/templates/error.html000066400000000000000000000007111473126534200316700ustar00rootroot00000000000000{% extends "page.html" %} {% block site %}
{% block h1_error %}

Error Page

{{status_code}} : {{status_message}}

{% endblock h1_error %} {% block error_detail %} {% if message %}

{% trans %}The error was:{% endtrans %}

{{message}}
{% endif %} {% endblock error_detail %}
{% endblock %} jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/templates/page.html000066400000000000000000000011171473126534200314540ustar00rootroot00000000000000 {% block title %}Jupyter Server 1{% endblock %} {% block favicon %}{% endblock %} {% block meta %} {% endblock %}
{% block site %} {% endblock site %}
{% block after_site %} {% endblock after_site %} jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/templates/simple1.html000066400000000000000000000007451473126534200321200ustar00rootroot00000000000000

Hello Simple App 1 from Template.

Path: {{path}}

jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext1/templates/typescript.html000066400000000000000000000010621473126534200327450ustar00rootroot00000000000000

Hello world!

jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext11/000077500000000000000000000000001473126534200257355ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext11/__init__.py000066400000000000000000000002651473126534200300510ustar00rootroot00000000000000"""Extension entry point.""" from .application import SimpleApp11 def _jupyter_server_extension_points(): return [{"module": "simple_ext11.application", "app": SimpleApp11}] jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext11/__main__.py000066400000000000000000000001421473126534200300240ustar00rootroot00000000000000"""Application cli main.""" from .application import main if __name__ == "__main__": main() jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext11/application.py000066400000000000000000000044041473126534200306140ustar00rootroot00000000000000"""A Jupyter Server example application.""" import os from simple_ext1.application import SimpleApp1 # type:ignore[import-not-found] from traitlets import Bool, Unicode, observe from jupyter_server.serverapp import aliases, flags DEFAULT_STATIC_FILES_PATH = os.path.join(os.path.dirname(__file__), "./../simple_ext1/static") DEFAULT_TEMPLATE_FILES_PATH = os.path.join(os.path.dirname(__file__), "./../simple_ext1/templates") class SimpleApp11(SimpleApp1): """A simple application.""" flags["hello"] = ({"SimpleApp11": {"hello": True}}, "Say hello on startup.") aliases.update( { "simple11-dir": "SimpleApp11.simple11_dir", } ) # The name of the extension. name = "simple_ext11" # The url that your extension will serve its homepage. extension_url = "/simple_ext11/default" # Local path to static files directory. static_paths = [DEFAULT_STATIC_FILES_PATH] # Local path to templates directory. template_paths = [DEFAULT_TEMPLATE_FILES_PATH] simple11_dir = Unicode("", config=True, help="Simple directory") hello = Bool( False, config=True, help="Say hello", ) ignore_js = Bool( False, config=True, help="Ignore Javascript", ) @observe("ignore_js") def _update_ignore_js(self, change): """TODO Does the observe work?""" self.log.info(f"ignore_js has just changed: {change}") @property def simple11_dir_formatted(self): return "/" + self.simple11_dir def initialize_settings(self): """Initialize settings.""" self.log.info(f"hello: {self.hello}") if self.hello is True: self.log.info( "Hello Simple11: You have launched with --hello flag or defined 'c.SimpleApp1.hello == True' in your config file" ) self.log.info(f"ignore_js: {self.ignore_js}") super().initialize_settings() def initialize_handlers(self): """Initialize handlers.""" super().initialize_handlers() # ----------------------------------------------------------------------------- # Main entry point # ----------------------------------------------------------------------------- main = launch_new_instance = SimpleApp11.launch_instance jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/000077500000000000000000000000001473126534200256555ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/__init__.py000066400000000000000000000003051473126534200277640ustar00rootroot00000000000000"""The extension entry point.""" from .application import SimpleApp2 def _jupyter_server_extension_points(): return [ {"module": "simple_ext2.application", "app": SimpleApp2}, ] jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/__main__.py000066400000000000000000000001461473126534200277500ustar00rootroot00000000000000"""The application cli main.""" from .application import main if __name__ == "__main__": main() jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/application.py000066400000000000000000000034321473126534200305340ustar00rootroot00000000000000"""A simple Jupyter Server extension example.""" import os from traitlets import Unicode from jupyter_server.extension.application import ExtensionApp, ExtensionAppJinjaMixin from .handlers import ErrorHandler, IndexHandler, ParameterHandler, TemplateHandler DEFAULT_STATIC_FILES_PATH = os.path.join(os.path.dirname(__file__), "static") DEFAULT_TEMPLATE_FILES_PATH = os.path.join(os.path.dirname(__file__), "templates") class SimpleApp2(ExtensionAppJinjaMixin, ExtensionApp): """A simple application.""" # The name of the extension. name = "simple_ext2" # The url that your extension will serve its homepage. extension_url = "/simple_ext2" # Should your extension expose other server extensions when launched directly? load_other_extensions = True # Local path to static files directory. static_paths = [DEFAULT_STATIC_FILES_PATH] # type:ignore[assignment] # Local path to templates directory. template_paths = [DEFAULT_TEMPLATE_FILES_PATH] # type:ignore[assignment] configD = Unicode("", config=True, help="Config D example.") def initialize_handlers(self): """Initialize handlers.""" self.handlers.extend( [ (r"/simple_ext2/params/(.+)$", ParameterHandler), (r"/simple_ext2/template", TemplateHandler), (r"/simple_ext2/?", IndexHandler), (r"/simple_ext2/(.*)", ErrorHandler), ] ) def initialize_settings(self): """Initialize settings.""" self.log.info(f"Config {self.config}") # ----------------------------------------------------------------------------- # Main entry point # ----------------------------------------------------------------------------- main = launch_new_instance = SimpleApp2.launch_instance jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/handlers.py000066400000000000000000000027451473126534200300370ustar00rootroot00000000000000"""API handlers for the Jupyter Server example.""" from jupyter_server.base.handlers import JupyterHandler from jupyter_server.extension.handler import ExtensionHandlerJinjaMixin, ExtensionHandlerMixin from jupyter_server.utils import url_escape class ParameterHandler(ExtensionHandlerMixin, JupyterHandler): """A parameterized handler.""" def get(self, matched_part=None, *args, **kwargs): """Get a parameterized response.""" var1 = self.get_argument("var1", default="") components = [x for x in self.request.path.split("/") if x] self.write("

Hello Simple App 2 from Handler.

") self.write(f"

matched_part: {url_escape(matched_part)}

") self.write(f"

var1: {url_escape(var1)}

") self.write(f"

components: {components}

") class BaseTemplateHandler(ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler): """A base template handler.""" class IndexHandler(BaseTemplateHandler): """The root API handler.""" def get(self): """Get the root response.""" self.write(self.render_template("index.html")) class TemplateHandler(BaseTemplateHandler): """A template handler.""" def get(self, path): """Get the template for the path.""" self.write(self.render_template("simple_ext2.html", path=path)) class ErrorHandler(BaseTemplateHandler): """An error handler.""" def get(self, path): """Handle the error.""" self.write_error(400) jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/static/000077500000000000000000000000001473126534200271445ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/static/favicon.ico000066400000000000000000000764461473126534200313060ustar00rootroot00000000000000 hF 00 %V@@ (B:(  @&w&wW&ww&ww&wW&w&w%&w&w&w&w&w&w&w&w&w%&wO&w&w&w&w&w&w&w&w&w&w&wO&wA&w&w&wW'w&x&wW&w&w&wA'y &w&w)&w)&w'y 'x&z'x'x'x'x&z'x'y &w&w)&w)&w'y &wA&w&w&wW&w&x&wW&w&w&wA&wO&w&w&w&w&w&w&w&w&w&w&wO&w%&w&w&w&w&w&w&w&w&w%&w&wW&ww&ww&wW'w( @ (w &xW&w&w&w&w&w&w&w&w&xW(w 'y#&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y#+y &w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w+y 'x!&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x!'y1&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y1(x%&w&w&w&w&w&w'wM'z'z&wM&w&w&w&w&w&w(x%'| &w&w&w&w'w)(w)&w&w&w&w'| &w&w&w(x&z&w&w&w'y%&w'x5(x5&w'y%'xg&z'x'xe+x +x +x +x 'xe'x&z'xg'y%&w(x5'x5&w'y%&w&w&w'x(x&w&w&w'| &w&w&w&w(x''w)&w&w&w&w'| (x%&w&w&w&w&w&w&wM'z&{'wM&w&w&w&w&w&w(x%'y1&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y1'x!&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x!+y &w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w+y 'y#&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y#(w &xW&w&w&w&w&w&w&w&w'wU(w (0` %+z 'x/'xO&wa&wm%wy%wy&wm&wa'xO'x/+z (w &wE&w&w&w&w&w&w&w&w&w&w&w&w&w&x&wE(w )z 'y3&xy&w&w&w&w&w&w&w&w&w&w%w&w&w%w&w&w%w&w&xy'y3)z )y!&w&w&w&v&w%w&v&w%w&v&w%w%v&w%w%v&w%w%v&w%w&v&w&w)y!+y &xY&w%v%v&w%w%v&w%w%v&w%w%v&w&v&w&w&v&w&w&v&w&w&v&w&v&w&xY+y 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w%w&w&w%w&w&w%w&w&w%w&w&w&w&w'x.'y/&w&v&v&w&v&w%w&v&w%w&v&w%w&v&w%w%v&w%w%v&w%w%v&w%w%v&w%w%v&w%w&w'y/.'z%&w&w&w&w&w%w%v&w%w%v&w&w&w&w&w&w&w&w&w&w&w&w%v&v&w&w&v&w&w&v&w&w&w&w'z'(w&w&w&w&w&w&w&w&w&w'w'wI'|(z'xG&w}&w&w&v&w%w&w&w%w&w'w&x&w&v&v&w%v&v&w'x_'w+(w*}(x+&w]&w&v&w%v&w%w&w&w'|'wu&w&w&w&w'ws'w/'w)w(w-&wo&v&w&w&w'wu'|&vI&w&w&w&xk(x&z &wi&w&w&v&wI& &w%w'x'y+&w)'w&w&w& 'yM&w&wi*z+x'wg&w'yM)}&x&y)'x)&w)}(x#(x+(x+(x#+x+x+x+w*w+x+x+x(x#(x+(x+(x#)}&w'x)&z)&x)}'yM&w'wg+x)x&wg&w'yM& &w&w'w&w)'y+&w&w&w% &vG&w&v&w'wi'x 'x&xk&w&w&v&wI'|&wu&w&w&w&w&wo(x-)y'w&x/&ws&w&w&w&w'wu'|'x&w%w&w&w&w&w&w&w](x)*}(w'w+'x_&w&w&v&w&v&w&w&w(x&w%w&w&w%w&w&w&w&w&v}'xG(y&}&xI'v&w&w&w&w&v&w&w&v&w(x'z'&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'z'.'y/&w%w&w&w&w&w&v&w&w&v&w&w&v&w&w&v&w&v&w&w&v&w&w&v&w&w&v&w&w&v&w&w'y/-'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&v&w&w&v&w&w&v&w&w&v&w&w&w&w'x+y &xY&w&w&w%w%w&w%w%w&w%w%w&w%w&w&v&w&w&v&w&w&v&w&w&v%v&w&xY+y )z!&w&w&w%w&w&w%w&w&w%w&w&w&w&w%w&w&w%w&w&w%w%w&w&w)z!)z 'y3&wy&w&w%w%w&w%w%w&w%w&w&w&w&w&w&w&w&w&w&wy&y3)z (w &wE&w&w&w&w&w&w&w&w%w&w&w&w&w'w'wE(w +z 'x/'xO&va&wm&wy&wy&wm&wa'xO'x/+z (@ B3 (xA'x{'x'w&w&x&w&w&x&w'w'x'x{(xA3 (w-&x'x'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'x&x(w-. 'yc&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'yc. 3 'y'w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'w'y3 )zY&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w)zY+y+&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x+y+(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 3'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x&x'x'w&x(yu'wi'wi(yu&x'x'w'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x3'x&w&w&w&w&w&w&w&w&w&w&w'w'w}'xC')z'zA(w{&x'x&w&w&w&w&w&w&w&w&w&w'x(x&w&w&w&w&w&w&w&w'x'x}(w-*}+(w{&w&w&w&w&w&w&w&w&w(x'xi&w&w&w&w&w&w&w'w}(y'+x%(w{'w&w&w&w&w&w&w'xi'|!'w&w&w&w&w'x(w[@@&xW&w&w&w&w&w'w'|!'w&w&w&w&x(xG&zC'w&w&w&w'w'wi&w&w'x'yo@U&wk'w&w&w'wi&'x&w&x.1y'x&w'x&(y&x&x'x}&x(y. &x&zI'xI&w. (ye)xE)xE(ye+x%+x%+x%+x%(ye)xE)xE(ye. &w'xI&zI&x. (y&x'x}&x&x(y&'x&w'x1y,z&x&w'x&'wi&w&w'w&wkU@'yo'x&w&w'wi'w&w&w&w'w'xC(xG&x&w&w&w'w'|!'w&w&w&w&w&w&xWU3(w['x&w&w&w&w'w'|!'xi&w&w&w&w&w&w'w(xy,|#(y''w}&w&w&w&w&w&w&w'xi(x&w&w&w&w&w&w&w&w&x(w{*}+(w-'x}'x&w&w&w&w&w&w&w&w(x'x&w&w&w&w&w&w&w&w&w&w'w&w(w{'zA)z'&zC'w}'w&w&w&w&w&w&w&w&w&w&w&w'x3'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'w'x&x(yu'wi'wi(yu&x'w'x&x&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x3. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS+y+&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x+y+)zY&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w)zY3 'y'w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'y3 . 'xc&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'xc. (w-&x'x'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'x'w(w-3 (xA'x{'x'w&w&x&w&w&x&w'w'x'x{(xA3 jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/static/test.html000066400000000000000000000000611473126534200310060ustar00rootroot00000000000000

Hello Simple App 2 from test HTML page.

jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/templates/000077500000000000000000000000001473126534200276535ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/templates/error.html000066400000000000000000000006611473126534200316750ustar00rootroot00000000000000{% extends "page.html" %} {% block site %}
{% block h1_error %}

{{status_code}} : {{status_message}}

{% endblock h1_error %} {% block error_detail %} {% if message %}

{% trans %}The error was:{% endtrans %}

{{message}}
{% endif %} {% endblock error_detail %}
{% endblock %} jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/templates/index.html000066400000000000000000000000671473126534200316530ustar00rootroot00000000000000

Hello Extension 2 from HTML Index Static Page

jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/templates/page.html000066400000000000000000000011171473126534200314550ustar00rootroot00000000000000 {% block title %}Jupyter Server 1{% endblock %} {% block favicon %}{% endblock %} {% block meta %} {% endblock %}
{% block site %} {% endblock site %}
{% block after_site %} {% endblock after_site %} jupyter-server-jupyter_server-e5c7e2b/examples/simple/simple_ext2/templates/simple_ext2.html000066400000000000000000000000701473126534200327710ustar00rootroot00000000000000

Hello Extension 2 from Simple HTML Static Page

jupyter-server-jupyter_server-e5c7e2b/examples/simple/src/000077500000000000000000000000001473126534200242115ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/src/index.ts000066400000000000000000000002251473126534200256670ustar00rootroot00000000000000function main() { let div = document.getElementById("mydiv"); div.innerText = "Hello from Typescript"; } window.addEventListener("load", main); jupyter-server-jupyter_server-e5c7e2b/examples/simple/src/tsconfig.json000066400000000000000000000007301473126534200267200ustar00rootroot00000000000000{ "compilerOptions": { "outDir": "../simple_ext1/static", "allowSyntheticDefaultImports": true, "composite": true, "declaration": true, "noImplicitAny": true, "noEmitOnError": true, "noUnusedLocals": true, "esModuleInterop": true, "preserveWatchOutput": true, "module": "commonjs", "moduleResolution": "node", "target": "es2015", "lib": ["dom", "es2015"], "jsx": "react", "types": [] }, "include": ["*"] } jupyter-server-jupyter_server-e5c7e2b/examples/simple/tests/000077500000000000000000000000001473126534200245645ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/examples/simple/tests/test_handlers.py000066400000000000000000000031551473126534200300010ustar00rootroot00000000000000"""Tests for the simple handler.""" import pytest @pytest.fixture def jp_server_auth_resources(jp_server_auth_core_resources): """The server auth resources.""" for url_regex in [ "/simple_ext1/default", ]: jp_server_auth_core_resources[url_regex] = "simple_ext1:default" return jp_server_auth_core_resources @pytest.fixture def jp_server_config(jp_template_dir, jp_server_authorizer): """The server config.""" return { "ServerApp": { "jpserver_extensions": {"simple_ext1": True}, "authorizer_class": jp_server_authorizer, }, } async def test_handler_default(jp_fetch, jp_serverapp): """Test the default handler.""" jp_serverapp.authorizer.permissions = { "actions": ["read"], "resources": [ "simple_ext1:default", ], } r = await jp_fetch("simple_ext1/default", method="GET") assert r.code == 200 assert r.body.decode().index("Hello Simple 1 - I am the default...") > -1 async def test_handler_template(jp_fetch): """Test the template handler.""" path = "/custom/path" r = await jp_fetch(f"simple_ext1/template1/{path}", method="GET") assert r.code == 200 assert r.body.decode().index(f"Path: {path}") > -1 async def test_handler_typescript(jp_fetch): """Test the typescript handler.""" r = await jp_fetch("simple_ext1/typescript", method="GET") assert r.code == 200 async def test_handler_error(jp_fetch): """Test the error handler.""" r = await jp_fetch("simple_ext1/nope", method="GET") assert r.body.decode().index("400 : Bad Request") > -1 jupyter-server-jupyter_server-e5c7e2b/examples/simple/webpack.config.js000066400000000000000000000003441473126534200266410ustar00rootroot00000000000000module.exports = { entry: ["./simple_ext1/static/index.js"], output: { path: require("path").join(__dirname, "simple_ext1", "static"), filename: "bundle.js", hashFunction: 'sha256' }, mode: "development", }; jupyter-server-jupyter_server-e5c7e2b/jupyter_server/000077500000000000000000000000001473126534200234035ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/__init__.py000066400000000000000000000014031473126534200255120ustar00rootroot00000000000000"""The Jupyter Server""" import os import pathlib DEFAULT_STATIC_FILES_PATH = os.path.join(os.path.dirname(__file__), "static") DEFAULT_TEMPLATE_PATH_LIST = [ os.path.dirname(__file__), os.path.join(os.path.dirname(__file__), "templates"), ] DEFAULT_JUPYTER_SERVER_PORT = 8888 JUPYTER_SERVER_EVENTS_URI = "https://events.jupyter.org/jupyter_server" DEFAULT_EVENTS_SCHEMA_PATH = pathlib.Path(__file__).parent / "event_schemas" from ._version import __version__, version_info from .base.call_context import CallContext __all__ = [ "DEFAULT_STATIC_FILES_PATH", "DEFAULT_TEMPLATE_PATH_LIST", "DEFAULT_JUPYTER_SERVER_PORT", "JUPYTER_SERVER_EVENTS_URI", "DEFAULT_EVENTS_SCHEMA_PATH", "__version__", "version_info", "CallContext", ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/__main__.py000066400000000000000000000002321473126534200254720ustar00rootroot00000000000000"""The main entry point for Jupyter Server.""" if __name__ == "__main__": from jupyter_server import serverapp as app app.launch_new_instance() jupyter-server-jupyter_server-e5c7e2b/jupyter_server/_sysinfo.py000066400000000000000000000046551473126534200256200ustar00rootroot00000000000000""" Utilities for getting information about Jupyter and the system it's running in. """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import os import platform import subprocess import sys import jupyter_server def pkg_commit_hash(pkg_path): """Get short form of commit hash given directory `pkg_path` We get the commit hash from git if it's a repo. If this fail, we return a not-found placeholder tuple Parameters ---------- pkg_path : str directory containing package only used for getting commit from active repo Returns ------- hash_from : str Where we got the hash from - description hash_str : str short form of hash """ # maybe we are in a repository, check for a .git folder p = os.path cur_path = None par_path = pkg_path while cur_path != par_path: cur_path = par_path if p.exists(p.join(cur_path, ".git")): try: proc = subprocess.Popen( ["git", "rev-parse", "--short", "HEAD"], # noqa: S607 stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=pkg_path, ) repo_commit, _ = proc.communicate() except OSError: repo_commit = None if repo_commit: return "repository", repo_commit.strip().decode("ascii") else: return "", "" par_path = p.dirname(par_path) return "", "" def pkg_info(pkg_path): """Return dict describing the context of this package Parameters ---------- pkg_path : str path containing __init__.py for package Returns ------- context : dict with named parameters of interest """ src, hsh = pkg_commit_hash(pkg_path) return { "jupyter_server_version": jupyter_server.__version__, "jupyter_server_path": pkg_path, "commit_source": src, "commit_hash": hsh, "sys_version": sys.version, "sys_executable": sys.executable, "sys_platform": sys.platform, "platform": platform.platform(), "os_name": os.name, } def get_sys_info(): """Return useful information about the system as a dict.""" p = os.path path = p.realpath(p.dirname(p.abspath(p.join(jupyter_server.__file__)))) return pkg_info(path) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/_tz.py000066400000000000000000000020411473126534200245460ustar00rootroot00000000000000""" Timezone utilities Just UTC-awareness right now """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations from datetime import datetime, timedelta, timezone, tzinfo # constant for zero offset ZERO = timedelta(0) class tzUTC(tzinfo): # noqa: N801 """tzinfo object for UTC (zero offset)""" def utcoffset(self, d: datetime | None) -> timedelta: """Compute utcoffset.""" return ZERO def dst(self, d: datetime | None) -> timedelta: """Compute dst.""" return ZERO def utcnow() -> datetime: """Return timezone-aware UTC timestamp""" return datetime.now(timezone.utc) def utcfromtimestamp(timestamp: float) -> datetime: return datetime.fromtimestamp(timestamp, timezone.utc) UTC = tzUTC() # type:ignore[abstract] def isoformat(dt: datetime) -> str: """Return iso-formatted timestamp Like .isoformat(), but uses Z for UTC instead of +00:00 """ return dt.isoformat().replace("+00:00", "Z") jupyter-server-jupyter_server-e5c7e2b/jupyter_server/_version.py000066400000000000000000000007671473126534200256130ustar00rootroot00000000000000""" store the current version info of the server. """ import re # Version string must appear intact for automatic versioning __version__ = "2.15.0" # Build up version_info tuple for backwards compatibility pattern = r"(?P\d+).(?P\d+).(?P\d+)(?P.*)" match = re.match(pattern, __version__) assert match is not None parts: list[object] = [int(match[part]) for part in ["major", "minor", "patch"]] if match["rest"]: parts.append(match["rest"]) version_info = tuple(parts) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/auth/000077500000000000000000000000001473126534200243445ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/auth/__init__.py000066400000000000000000000001611473126534200264530ustar00rootroot00000000000000from .authorizer import * from .decorator import authorized from .identity import * from .security import passwd jupyter-server-jupyter_server-e5c7e2b/jupyter_server/auth/__main__.py000066400000000000000000000036241473126534200264430ustar00rootroot00000000000000"""The cli for auth.""" import argparse import sys import warnings from getpass import getpass from jupyter_core.paths import jupyter_config_dir from traitlets.log import get_logger from jupyter_server.auth import passwd # type:ignore[attr-defined] from jupyter_server.config_manager import BaseJSONConfigManager def set_password(args): """Set a password.""" password = args.password while not password: password1 = getpass("" if args.quiet else "Provide password: ") password_repeat = getpass("" if args.quiet else "Repeat password: ") if password1 != password_repeat: warnings.warn("Passwords do not match, try again", stacklevel=2) elif len(password1) < 4: warnings.warn("Please provide at least 4 characters", stacklevel=2) else: password = password1 password_hash = passwd(password) cfg = BaseJSONConfigManager(config_dir=jupyter_config_dir()) cfg.update( "jupyter_server_config", { "ServerApp": { "password": password_hash, } }, ) if not args.quiet: log = get_logger() log.info("password stored in config dir: %s" % jupyter_config_dir()) def main(argv): """The main cli handler.""" parser = argparse.ArgumentParser(argv[0]) subparsers = parser.add_subparsers() parser_password = subparsers.add_parser( "password", help="sets a password for your jupyter server" ) parser_password.add_argument( "password", help="password to set, if not given, a password will be queried for (NOTE: this may not be safe)", nargs="?", ) parser_password.add_argument("--quiet", help="suppress messages", action="store_true") parser_password.set_defaults(function=set_password) args = parser.parse_args(argv[1:]) args.function(args) if __name__ == "__main__": main(sys.argv) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/auth/authorizer.py000066400000000000000000000051631473126534200271170ustar00rootroot00000000000000"""An Authorizer for use in the Jupyter server. The default authorizer (AllowAllAuthorizer) allows all authenticated requests .. versionadded:: 2.0 """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations from typing import TYPE_CHECKING from traitlets import Instance from traitlets.config import LoggingConfigurable from .identity import IdentityProvider, User if TYPE_CHECKING: from collections.abc import Awaitable from jupyter_server.base.handlers import JupyterHandler class Authorizer(LoggingConfigurable): """Base class for authorizing access to resources in the Jupyter Server. All authorizers used in Jupyter Server should inherit from this base class and, at the very minimum, implement an ``is_authorized`` method with the same signature as in this base class. The ``is_authorized`` method is called by the ``@authorized`` decorator in JupyterHandler. If it returns True, the incoming request to the server is accepted; if it returns False, the server returns a 403 (Forbidden) error code. The authorization check will only be applied to requests that have already been authenticated. .. versionadded:: 2.0 """ identity_provider = Instance(IdentityProvider) def is_authorized( self, handler: JupyterHandler, user: User, action: str, resource: str ) -> Awaitable[bool] | bool: """A method to determine if ``user`` is authorized to perform ``action`` (read, write, or execute) on the ``resource`` type. Parameters ---------- user : jupyter_server.auth.User An object representing the authenticated user, as returned by :meth:`jupyter_server.auth.IdentityProvider.get_user`. action : str the category of action for the current request: read, write, or execute. resource : str the type of resource (i.e. contents, kernels, files, etc.) the user is requesting. Returns ------- bool True if user authorized to make request; False, otherwise """ raise NotImplementedError class AllowAllAuthorizer(Authorizer): """A no-op implementation of the Authorizer This authorizer allows all authenticated requests. .. versionadded:: 2.0 """ def is_authorized( self, handler: JupyterHandler, user: User, action: str, resource: str ) -> bool: """This method always returns True. All authenticated users are allowed to do anything in the Jupyter Server. """ return True jupyter-server-jupyter_server-e5c7e2b/jupyter_server/auth/decorator.py000066400000000000000000000104351473126534200267030ustar00rootroot00000000000000"""Decorator for layering authorization into JupyterHandlers.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import asyncio from functools import wraps from typing import Any, Callable, Optional, TypeVar, Union, cast from jupyter_core.utils import ensure_async from tornado.log import app_log from tornado.web import HTTPError from .utils import HTTP_METHOD_TO_AUTH_ACTION FuncT = TypeVar("FuncT", bound=Callable[..., Any]) def authorized( action: Optional[Union[str, FuncT]] = None, resource: Optional[str] = None, message: Optional[str] = None, ) -> FuncT: """A decorator for tornado.web.RequestHandler methods that verifies whether the current user is authorized to make the following request. Helpful for adding an 'authorization' layer to a REST API. .. versionadded:: 2.0 Parameters ---------- action : str the type of permission or action to check. resource: str or None the name of the resource the action is being authorized to access. message : str or none a message for the unauthorized action. """ def wrapper(method): @wraps(method) async def inner(self, *args, **kwargs): # default values for action, resource nonlocal action nonlocal resource nonlocal message if action is None: http_method = self.request.method.upper() action = HTTP_METHOD_TO_AUTH_ACTION[http_method] if resource is None: resource = self.auth_resource if message is None: message = f"User is not authorized to {action} on resource: {resource}." user = self.current_user if not user: app_log.warning("Attempting to authorize request without authentication!") raise HTTPError(status_code=403, log_message=message) # If the user is allowed to do this action, # call the method. authorized = await ensure_async( self.authorizer.is_authorized(self, user, action, resource) ) if authorized: out = method(self, *args, **kwargs) # If the method is a coroutine, await it if asyncio.iscoroutine(out): return await out return out # else raise an exception. else: raise HTTPError(status_code=403, log_message=message) return inner if callable(action): method = action action = None # no-arguments `@authorized` decorator called return cast(FuncT, wrapper(method)) return cast(FuncT, wrapper) def allow_unauthenticated(method: FuncT) -> FuncT: """A decorator for tornado.web.RequestHandler methods that allows any user to make the following request. Selectively disables the 'authentication' layer of REST API which is active when `ServerApp.allow_unauthenticated_access = False`. To be used exclusively on endpoints which may be considered public, for example the login page handler. .. versionadded:: 2.13 Parameters ---------- method : bound callable the endpoint method to remove authentication from. """ @wraps(method) def wrapper(self, *args, **kwargs): return method(self, *args, **kwargs) setattr(wrapper, "__allow_unauthenticated", True) return cast(FuncT, wrapper) def ws_authenticated(method: FuncT) -> FuncT: """A decorator for websockets derived from `WebSocketHandler` that authenticates user before allowing to proceed. Differently from tornado.web.authenticated, does not redirect to the login page, which would be meaningless for websockets. .. versionadded:: 2.13 Parameters ---------- method : bound callable the endpoint method to add authentication for. """ @wraps(method) def wrapper(self, *args, **kwargs): user = self.current_user if user is None: self.log.warning("Couldn't authenticate WebSocket connection") raise HTTPError(403) return method(self, *args, **kwargs) setattr(wrapper, "__allow_unauthenticated", False) return cast(FuncT, wrapper) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/auth/identity.py000066400000000000000000000646641473126534200265670ustar00rootroot00000000000000"""Identity Provider interface This defines the _authentication_ layer of Jupyter Server, to be used in combination with Authorizer for _authorization_. .. versionadded:: 2.0 """ from __future__ import annotations import binascii import datetime import json import os import re import sys import typing as t import uuid from dataclasses import asdict, dataclass from http.cookies import Morsel from tornado import escape, httputil, web from traitlets import Bool, Dict, Type, Unicode, default from traitlets.config import LoggingConfigurable from jupyter_server.transutils import _i18n from .security import passwd_check, set_password from .utils import get_anonymous_username _non_alphanum = re.compile(r"[^A-Za-z0-9]") @dataclass class User: """Object representing a User This or a subclass should be returned from IdentityProvider.get_user """ username: str # the only truly required field # these fields are filled from username if not specified # name is the 'real' name of the user name: str = "" # display_name is a shorter name for us in UI, # if different from name. e.g. a nickname display_name: str = "" # these fields are left as None if undefined initials: str | None = None avatar_url: str | None = None color: str | None = None # TODO: extension fields? # ext: Dict[str, Dict[str, Any]] = field(default_factory=dict) def __post_init__(self): self.fill_defaults() def fill_defaults(self): """Fill out default fields in the identity model - Ensures all values are defined - Fills out derivative values for name fields fields - Fills out null values for optional fields """ # username is the only truly required field if not self.username: msg = f"user.username must not be empty: {self}" raise ValueError(msg) # derive name fields from username -> name -> display name if not self.name: self.name = self.username if not self.display_name: self.display_name = self.name def _backward_compat_user(got_user: t.Any) -> User: """Backward-compatibility for LoginHandler.get_user Prior to 2.0, LoginHandler.get_user could return anything truthy. Typically, this was either a simple string username, or a simple dict. Make some effort to allow common patterns to keep working. """ if isinstance(got_user, str): return User(username=got_user) elif isinstance(got_user, dict): kwargs = {} if "username" not in got_user and "name" in got_user: kwargs["username"] = got_user["name"] for field in User.__dataclass_fields__: if field in got_user: kwargs[field] = got_user[field] try: return User(**kwargs) except TypeError: msg = f"Unrecognized user: {got_user}" raise ValueError(msg) from None else: msg = f"Unrecognized user: {got_user}" raise ValueError(msg) class IdentityProvider(LoggingConfigurable): """ Interface for providing identity management and authentication. Two principle methods: - :meth:`~jupyter_server.auth.IdentityProvider.get_user` returns a :class:`~.User` object for successful authentication, or None for no-identity-found. - :meth:`~jupyter_server.auth.IdentityProvider.identity_model` turns a :class:`~jupyter_server.auth.User` into a JSONable dict. The default is to use :py:meth:`dataclasses.asdict`, and usually shouldn't need override. Additional methods can customize authentication. .. versionadded:: 2.0 """ cookie_name: str | Unicode[str, str | bytes] = Unicode( "", config=True, help=_i18n("Name of the cookie to set for persisting login. Default: username-${Host}."), ) cookie_options = Dict( config=True, help=_i18n( "Extra keyword arguments to pass to `set_secure_cookie`." " See tornado's set_secure_cookie docs for details." ), ) secure_cookie: bool | Bool[bool | None, bool | int | None] = Bool( None, allow_none=True, config=True, help=_i18n( "Specify whether login cookie should have the `secure` property (HTTPS-only)." "Only needed when protocol-detection gives the wrong answer due to proxies." ), ) get_secure_cookie_kwargs = Dict( config=True, help=_i18n( "Extra keyword arguments to pass to `get_secure_cookie`." " See tornado's get_secure_cookie docs for details." ), ) token: str | Unicode[str, str | bytes] = Unicode( "", help=_i18n( """Token used for authenticating first-time connections to the server. The token can be read from the file referenced by JUPYTER_TOKEN_FILE or set directly with the JUPYTER_TOKEN environment variable. When no password is enabled, the default is to generate a new, random token. Setting to an empty string disables authentication altogether, which is NOT RECOMMENDED. Prior to 2.0: configured as ServerApp.token """ ), ).tag(config=True) login_handler_class = Type( default_value="jupyter_server.auth.login.LoginFormHandler", klass=web.RequestHandler, config=True, help=_i18n("The login handler class to use, if any."), ) logout_handler_class = Type( default_value="jupyter_server.auth.logout.LogoutHandler", klass=web.RequestHandler, config=True, help=_i18n("The logout handler class to use."), ) token_generated = False @default("token") def _token_default(self): if os.getenv("JUPYTER_TOKEN"): self.token_generated = False return os.environ["JUPYTER_TOKEN"] if os.getenv("JUPYTER_TOKEN_FILE"): self.token_generated = False with open(os.environ["JUPYTER_TOKEN_FILE"]) as token_file: return token_file.read() if not self.need_token: # no token if password is enabled self.token_generated = False return "" else: self.token_generated = True return binascii.hexlify(os.urandom(24)).decode("ascii") need_token: bool | Bool[bool, t.Union[bool, int]] = Bool(True) def get_user(self, handler: web.RequestHandler) -> User | None | t.Awaitable[User | None]: """Get the authenticated user for a request Must return a :class:`jupyter_server.auth.User`, though it may be a subclass. Return None if the request is not authenticated. _may_ be a coroutine """ return self._get_user(handler) # not sure how to have optional-async type signature # on base class with `async def` without splitting it into two methods async def _get_user(self, handler: web.RequestHandler) -> User | None: """Get the user.""" if getattr(handler, "_jupyter_current_user", None): # already authenticated return t.cast(User, handler._jupyter_current_user) # type:ignore[attr-defined] _token_user: User | None | t.Awaitable[User | None] = self.get_user_token(handler) if isinstance(_token_user, t.Awaitable): _token_user = await _token_user token_user: User | None = _token_user # need second variable name to collapse type _cookie_user = self.get_user_cookie(handler) if isinstance(_cookie_user, t.Awaitable): _cookie_user = await _cookie_user cookie_user: User | None = _cookie_user # prefer token to cookie if both given, # because token is always explicit user = token_user or cookie_user if user is not None and token_user is not None: # if token-authenticated, persist user_id in cookie # if it hasn't already been stored there if user != cookie_user: self.set_login_cookie(handler, user) # Record that the current request has been authenticated with a token. # Used in is_token_authenticated above. handler._token_authenticated = True # type:ignore[attr-defined] if user is None: # If an invalid cookie was sent, clear it to prevent unnecessary # extra warnings. But don't do this on a request with *no* cookie, # because that can erroneously log you out (see gh-3365) cookie_name = self.get_cookie_name(handler) cookie = handler.get_cookie(cookie_name) if cookie is not None: self.log.warning(f"Clearing invalid/expired login cookie {cookie_name}") self.clear_login_cookie(handler) if not self.auth_enabled: # Completely insecure! No authentication at all. # No need to warn here, though; validate_security will have already done that. user = self.generate_anonymous_user(handler) # persist user on first request # so the user data is stable for a given browser session self.set_login_cookie(handler, user) return user def identity_model(self, user: User) -> dict[str, t.Any]: """Return a User as an Identity model""" # TODO: validate? return asdict(user) def get_handlers(self) -> list[tuple[str, object]]: """Return list of additional handlers for this identity provider For example, an OAuth callback handler. """ handlers = [] if self.login_available: handlers.append((r"/login", self.login_handler_class)) if self.logout_available: handlers.append((r"/logout", self.logout_handler_class)) return handlers def user_to_cookie(self, user: User) -> str: """Serialize a user to a string for storage in a cookie If overriding in a subclass, make sure to define user_from_cookie as well. Default is just the user's username. """ # default: username is enough cookie = json.dumps( { "username": user.username, "name": user.name, "display_name": user.display_name, "initials": user.initials, "color": user.color, } ) return cookie def user_from_cookie(self, cookie_value: str) -> User | None: """Inverse of user_to_cookie""" user = json.loads(cookie_value) return User( user["username"], user["name"], user["display_name"], user["initials"], None, user["color"], ) def get_cookie_name(self, handler: web.RequestHandler) -> str: """Return the login cookie name Uses IdentityProvider.cookie_name, if defined. Default is to generate a string taking host into account to avoid collisions for multiple servers on one hostname with different ports. """ if self.cookie_name: return self.cookie_name else: return _non_alphanum.sub("-", f"username-{handler.request.host}") def set_login_cookie(self, handler: web.RequestHandler, user: User) -> None: """Call this on handlers to set the login cookie for success""" cookie_options = {} cookie_options.update(self.cookie_options) cookie_options.setdefault("httponly", True) # tornado <4.2 has a bug that considers secure==True as soon as # 'secure' kwarg is passed to set_secure_cookie secure_cookie = self.secure_cookie if secure_cookie is None: secure_cookie = handler.request.protocol == "https" if secure_cookie: cookie_options.setdefault("secure", True) cookie_options.setdefault("path", handler.base_url) # type:ignore[attr-defined] cookie_name = self.get_cookie_name(handler) handler.set_secure_cookie(cookie_name, self.user_to_cookie(user), **cookie_options) def _force_clear_cookie( self, handler: web.RequestHandler, name: str, path: str = "/", domain: str | None = None ) -> None: """Deletes the cookie with the given name. Tornado's cookie handling currently (Jan 2018) stores cookies in a dict keyed by name, so it can only modify one cookie with a given name per response. The browser can store multiple cookies with the same name but different domains and/or paths. This method lets us clear multiple cookies with the same name. Due to limitations of the cookie protocol, you must pass the same path and domain to clear a cookie as were used when that cookie was set (but there is no way to find out on the server side which values were used for a given cookie). """ name = escape.native_str(name) expires = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=365) morsel: Morsel[t.Any] = Morsel() morsel.set(name, "", '""') morsel["expires"] = httputil.format_timestamp(expires) morsel["path"] = path if domain: morsel["domain"] = domain handler.add_header("Set-Cookie", morsel.OutputString()) def clear_login_cookie(self, handler: web.RequestHandler) -> None: """Clear the login cookie, effectively logging out the session.""" cookie_options = {} cookie_options.update(self.cookie_options) path = cookie_options.setdefault("path", handler.base_url) # type:ignore[attr-defined] cookie_name = self.get_cookie_name(handler) handler.clear_cookie(cookie_name, path=path) if path and path != "/": # also clear cookie on / to ensure old cookies are cleared # after the change in path behavior. # N.B. This bypasses the normal cookie handling, which can't update # two cookies with the same name. See the method above. self._force_clear_cookie(handler, cookie_name) def get_user_cookie( self, handler: web.RequestHandler ) -> User | None | t.Awaitable[User | None]: """Get user from a cookie Calls user_from_cookie to deserialize cookie value """ _user_cookie = handler.get_secure_cookie( self.get_cookie_name(handler), **self.get_secure_cookie_kwargs, ) if not _user_cookie: return None user_cookie = _user_cookie.decode() # TODO: try/catch in case of change in config? try: return self.user_from_cookie(user_cookie) except Exception as e: # log bad cookie itself, only at debug-level self.log.debug(f"Error unpacking user from cookie: cookie={user_cookie}", exc_info=True) self.log.error(f"Error unpacking user from cookie: {e}") return None auth_header_pat = re.compile(r"(token|bearer)\s+(.+)", re.IGNORECASE) def get_token(self, handler: web.RequestHandler) -> str | None: """Get the user token from a request Default: - in URL parameters: ?token= - in header: Authorization: token """ user_token = handler.get_argument("token", "") if not user_token: # get it from Authorization header m = self.auth_header_pat.match(handler.request.headers.get("Authorization", "")) if m: user_token = m.group(2) return user_token async def get_user_token(self, handler: web.RequestHandler) -> User | None: """Identify the user based on a token in the URL or Authorization header Returns: - uuid if authenticated - None if not """ token = t.cast("str | None", handler.token) # type:ignore[attr-defined] if not token: return None # check login token from URL argument or Authorization header user_token = self.get_token(handler) authenticated = False if user_token == token: # token-authenticated, set the login cookie self.log.debug( "Accepting token-authenticated request from %s", handler.request.remote_ip, ) authenticated = True if authenticated: # token does not correspond to user-id, # which is stored in a cookie. # still check the cookie for the user id _user = self.get_user_cookie(handler) if isinstance(_user, t.Awaitable): _user = await _user user: User | None = _user if user is None: user = self.generate_anonymous_user(handler) return user else: return None def generate_anonymous_user(self, handler: web.RequestHandler) -> User: """Generate a random anonymous user. For use when a single shared token is used, but does not identify a user. """ user_id = uuid.uuid4().hex moon = get_anonymous_username() name = display_name = f"Anonymous {moon}" initials = f"A{moon[0]}" color = None handler.log.debug(f"Generating new user for token-authenticated request: {user_id}") # type:ignore[attr-defined] return User(user_id, name, display_name, initials, None, color) def should_check_origin(self, handler: web.RequestHandler) -> bool: """Should the Handler check for CORS origin validation? Origin check should be skipped for token-authenticated requests. Returns: - True, if Handler must check for valid CORS origin. - False, if Handler should skip origin check since requests are token-authenticated. """ return not self.is_token_authenticated(handler) def is_token_authenticated(self, handler: web.RequestHandler) -> bool: """Returns True if handler has been token authenticated. Otherwise, False. Login with a token is used to signal certain things, such as: - permit access to REST API - xsrf protection - skip origin-checks for scripts """ # ensure get_user has been called, so we know if we're token-authenticated handler.current_user # noqa: B018 return getattr(handler, "_token_authenticated", False) def validate_security( self, app: t.Any, ssl_options: dict[str, t.Any] | None = None, ) -> None: """Check the application's security. Show messages, or abort if necessary, based on the security configuration. """ if not app.ip: warning = "WARNING: The Jupyter server is listening on all IP addresses" if ssl_options is None: app.log.warning(f"{warning} and not using encryption. This is not recommended.") if not self.auth_enabled: app.log.warning( f"{warning} and not using authentication. " "This is highly insecure and not recommended." ) elif not self.auth_enabled: app.log.warning( "All authentication is disabled." " Anyone who can connect to this server will be able to run code." ) def process_login_form(self, handler: web.RequestHandler) -> User | None: """Process login form data Return authenticated User if successful, None if not. """ typed_password = handler.get_argument("password", default="") user = None if not self.auth_enabled: self.log.warning("Accepting anonymous login because auth fully disabled!") return self.generate_anonymous_user(handler) if self.token and self.token == typed_password: return t.cast(User, self.user_for_token(typed_password)) # type:ignore[attr-defined] return user @property def auth_enabled(self): """Is authentication enabled? Should always be True, but may be False in rare, insecure cases where requests with no auth are allowed. Previously: LoginHandler.get_login_available """ return True @property def login_available(self): """Whether a LoginHandler is needed - and therefore whether the login page should be displayed.""" return self.auth_enabled @property def logout_available(self): """Whether a LogoutHandler is needed.""" return True class PasswordIdentityProvider(IdentityProvider): """A password identity provider.""" hashed_password = Unicode( "", config=True, help=_i18n( """ Hashed password to use for web authentication. To generate, type in a python/IPython shell: from jupyter_server.auth import passwd; passwd() The string should be of the form type:salt:hashed-password. """ ), ) password_required = Bool( False, config=True, help=_i18n( """ Forces users to use a password for the Jupyter server. This is useful in a multi user environment, for instance when everybody in the LAN can access each other's machine through ssh. In such a case, serving on localhost is not secure since any user can connect to the Jupyter server via ssh. """ ), ) allow_password_change = Bool( True, config=True, help=_i18n( """ Allow password to be changed at login for the Jupyter server. While logging in with a token, the Jupyter server UI will give the opportunity to the user to enter a new password at the same time that will replace the token login mechanism. This can be set to False to prevent changing password from the UI/API. """ ), ) @default("need_token") def _need_token_default(self): return not bool(self.hashed_password) @property def login_available(self) -> bool: """Whether a LoginHandler is needed - and therefore whether the login page should be displayed.""" return self.auth_enabled @property def auth_enabled(self) -> bool: """Return whether any auth is enabled""" return bool(self.hashed_password or self.token) def passwd_check(self, password): """Check password against our stored hashed password""" return passwd_check(self.hashed_password, password) def process_login_form(self, handler: web.RequestHandler) -> User | None: """Process login form data Return authenticated User if successful, None if not. """ typed_password = handler.get_argument("password", default="") new_password = handler.get_argument("new_password", default="") user = None if not self.auth_enabled: self.log.warning("Accepting anonymous login because auth fully disabled!") return self.generate_anonymous_user(handler) if self.passwd_check(typed_password) and not new_password: return self.generate_anonymous_user(handler) elif self.token and self.token == typed_password: user = self.generate_anonymous_user(handler) if new_password and self.allow_password_change: config_dir = handler.settings.get("config_dir", "") config_file = os.path.join(config_dir, "jupyter_server_config.json") self.hashed_password = set_password(new_password, config_file=config_file) self.log.info(_i18n(f"Wrote hashed password to {config_file}")) return user def validate_security( self, app: t.Any, ssl_options: dict[str, t.Any] | None = None, ) -> None: """Handle security validation.""" super().validate_security(app, ssl_options) if self.password_required and (not self.hashed_password): self.log.critical( _i18n("Jupyter servers are configured to only be run with a password.") ) self.log.critical(_i18n("Hint: run the following command to set a password")) self.log.critical(_i18n("\t$ python -m jupyter_server.auth password")) sys.exit(1) class LegacyIdentityProvider(PasswordIdentityProvider): """Legacy IdentityProvider for use with custom LoginHandlers Login configuration has moved from LoginHandler to IdentityProvider in Jupyter Server 2.0. """ # settings must be passed for settings = Dict() @default("settings") def _default_settings(self): return { "token": self.token, "password": self.hashed_password, } @default("login_handler_class") def _default_login_handler_class(self): from .login import LegacyLoginHandler return LegacyLoginHandler @property def auth_enabled(self): return self.login_available def get_user(self, handler: web.RequestHandler) -> User | None: """Get the user.""" user = self.login_handler_class.get_user(handler) # type:ignore[attr-defined] if user is None: return None return _backward_compat_user(user) @property def login_available(self) -> bool: return bool( self.login_handler_class.get_login_available( # type:ignore[attr-defined] self.settings ) ) def should_check_origin(self, handler: web.RequestHandler) -> bool: """Whether we should check origin.""" return bool(self.login_handler_class.should_check_origin(handler)) # type:ignore[attr-defined] def is_token_authenticated(self, handler: web.RequestHandler) -> bool: """Whether we are token authenticated.""" return bool(self.login_handler_class.is_token_authenticated(handler)) # type:ignore[attr-defined] def validate_security( self, app: t.Any, ssl_options: dict[str, t.Any] | None = None, ) -> None: """Validate security.""" if self.password_required and (not self.hashed_password): self.log.critical( _i18n("Jupyter servers are configured to only be run with a password.") ) self.log.critical(_i18n("Hint: run the following command to set a password")) self.log.critical(_i18n("\t$ python -m jupyter_server.auth password")) sys.exit(1) self.login_handler_class.validate_security( # type:ignore[attr-defined] app, ssl_options ) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/auth/login.py000066400000000000000000000277621473126534200260440ustar00rootroot00000000000000"""Tornado handlers for logging into the Jupyter Server.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import os import re import uuid from urllib.parse import urlparse from tornado.escape import url_escape from ..base.handlers import JupyterHandler from .decorator import allow_unauthenticated from .security import passwd_check, set_password class LoginFormHandler(JupyterHandler): """The basic tornado login handler accepts login form, passed to IdentityProvider.process_login_form. """ def _render(self, message=None): """Render the login form.""" self.write( self.render_template( "login.html", next=url_escape(self.get_argument("next", default=self.base_url)), message=message, ) ) def _redirect_safe(self, url, default=None): """Redirect if url is on our PATH Full-domain redirects are allowed if they pass our CORS origin checks. Otherwise use default (self.base_url if unspecified). """ if default is None: default = self.base_url # protect chrome users from mishandling unescaped backslashes. # \ is not valid in urls, but some browsers treat it as / # instead of %5C, causing `\\` to behave as `//` url = url.replace("\\", "%5C") # urllib and browsers interpret extra '/' in the scheme separator (`scheme:///host/path`) # differently. # urllib gives scheme=scheme, netloc='', path='/host/path', while # browsers get scheme=scheme, netloc='host', path='/path' # so make sure ':///*' collapses to '://' by splitting and stripping any additional leading slash # don't allow any kind of `:/` shenanigans by splitting on ':' only # and replacing `:/*` with exactly `://` if ":" in url: scheme, _, rest = url.partition(":") url = f"{scheme}://{rest.lstrip('/')}" parsed = urlparse(url) # full url may be `//host/path` (empty scheme == same scheme as request) # or `https://host/path` # or even `https:///host/path` (invalid, but accepted and ambiguously interpreted) if (parsed.scheme or parsed.netloc) or not (parsed.path + "/").startswith(self.base_url): # require that next_url be absolute path within our path allow = False # OR pass our cross-origin check if parsed.scheme or parsed.netloc: # if full URL, run our cross-origin check: origin = f"{parsed.scheme}://{parsed.netloc}" origin = origin.lower() if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: allow = bool(re.match(self.allow_origin_pat, origin)) if not allow: # not allowed, use default self.log.warning("Not allowing login redirect to %r" % url) url = default self.redirect(url) @allow_unauthenticated def get(self): """Get the login form.""" if self.current_user: next_url = self.get_argument("next", default=self.base_url) self._redirect_safe(next_url) else: self._render() @allow_unauthenticated def post(self): """Post a login.""" user = self.current_user = self.identity_provider.process_login_form(self) if user is None: self.set_status(401) self._render(message={"error": "Invalid credentials"}) return self.log.info(f"User {user.username} logged in.") self.identity_provider.set_login_cookie(self, user) next_url = self.get_argument("next", default=self.base_url) self._redirect_safe(next_url) class LegacyLoginHandler(LoginFormHandler): """Legacy LoginHandler, implementing most custom auth configuration. Deprecated in jupyter-server 2.0. Login configuration has moved to IdentityProvider. """ @property def hashed_password(self): return self.password_from_settings(self.settings) def passwd_check(self, a, b): """Check a passwd.""" return passwd_check(a, b) @allow_unauthenticated def post(self): """Post a login form.""" typed_password = self.get_argument("password", default="") new_password = self.get_argument("new_password", default="") if self.get_login_available(self.settings): if self.passwd_check(self.hashed_password, typed_password) and not new_password: self.set_login_cookie(self, uuid.uuid4().hex) elif self.token and self.token == typed_password: self.set_login_cookie(self, uuid.uuid4().hex) if new_password and getattr(self.identity_provider, "allow_password_change", False): config_dir = self.settings.get("config_dir", "") config_file = os.path.join(config_dir, "jupyter_server_config.json") if hasattr(self.identity_provider, "hashed_password"): self.identity_provider.hashed_password = self.settings["password"] = ( set_password(new_password, config_file=config_file) ) self.log.info("Wrote hashed password to %s" % config_file) else: self.set_status(401) self._render(message={"error": "Invalid credentials"}) return next_url = self.get_argument("next", default=self.base_url) self._redirect_safe(next_url) @classmethod def set_login_cookie(cls, handler, user_id=None): """Call this on handlers to set the login cookie for success""" cookie_options = handler.settings.get("cookie_options", {}) cookie_options.setdefault("httponly", True) # tornado <4.2 has a bug that considers secure==True as soon as # 'secure' kwarg is passed to set_secure_cookie if handler.settings.get("secure_cookie", handler.request.protocol == "https"): cookie_options.setdefault("secure", True) cookie_options.setdefault("path", handler.base_url) handler.set_secure_cookie(handler.cookie_name, user_id, **cookie_options) return user_id auth_header_pat = re.compile(r"token\s+(.+)", re.IGNORECASE) @classmethod def get_token(cls, handler): """Get the user token from a request Default: - in URL parameters: ?token= - in header: Authorization: token """ user_token = handler.get_argument("token", "") if not user_token: # get it from Authorization header m = cls.auth_header_pat.match(handler.request.headers.get("Authorization", "")) if m: user_token = m.group(1) return user_token @classmethod def should_check_origin(cls, handler): """DEPRECATED in 2.0, use IdentityProvider API""" return not cls.is_token_authenticated(handler) @classmethod def is_token_authenticated(cls, handler): """DEPRECATED in 2.0, use IdentityProvider API""" if getattr(handler, "_user_id", None) is None: # ensure get_user has been called, so we know if we're token-authenticated handler.current_user # noqa: B018 return getattr(handler, "_token_authenticated", False) @classmethod def get_user(cls, handler): """DEPRECATED in 2.0, use IdentityProvider API""" # Can't call this get_current_user because it will collide when # called on LoginHandler itself. if getattr(handler, "_user_id", None): return handler._user_id token_user_id = cls.get_user_token(handler) cookie_user_id = cls.get_user_cookie(handler) # prefer token to cookie if both given, # because token is always explicit user_id = token_user_id or cookie_user_id if token_user_id: # if token-authenticated, persist user_id in cookie # if it hasn't already been stored there if user_id != cookie_user_id: cls.set_login_cookie(handler, user_id) # Record that the current request has been authenticated with a token. # Used in is_token_authenticated above. handler._token_authenticated = True if user_id is None: # If an invalid cookie was sent, clear it to prevent unnecessary # extra warnings. But don't do this on a request with *no* cookie, # because that can erroneously log you out (see gh-3365) if handler.get_cookie(handler.cookie_name) is not None: handler.log.warning("Clearing invalid/expired login cookie %s", handler.cookie_name) handler.clear_login_cookie() if not handler.login_available: # Completely insecure! No authentication at all. # No need to warn here, though; validate_security will have already done that. user_id = "anonymous" # cache value for future retrievals on the same request handler._user_id = user_id return user_id @classmethod def get_user_cookie(cls, handler): """DEPRECATED in 2.0, use IdentityProvider API""" get_secure_cookie_kwargs = handler.settings.get("get_secure_cookie_kwargs", {}) user_id = handler.get_secure_cookie(handler.cookie_name, **get_secure_cookie_kwargs) if user_id: user_id = user_id.decode() return user_id @classmethod def get_user_token(cls, handler): """DEPRECATED in 2.0, use IdentityProvider API""" token = handler.token if not token: return None # check login token from URL argument or Authorization header user_token = cls.get_token(handler) authenticated = False if user_token == token: # token-authenticated, set the login cookie handler.log.debug( "Accepting token-authenticated connection from %s", handler.request.remote_ip, ) authenticated = True if authenticated: # token does not correspond to user-id, # which is stored in a cookie. # still check the cookie for the user id user_id = cls.get_user_cookie(handler) if user_id is None: # no cookie, generate new random user_id user_id = uuid.uuid4().hex handler.log.info( f"Generating new user_id for token-authenticated request: {user_id}" ) return user_id else: return None @classmethod def validate_security(cls, app, ssl_options=None): """DEPRECATED in 2.0, use IdentityProvider API""" if not app.ip: warning = "WARNING: The Jupyter server is listening on all IP addresses" if ssl_options is None: app.log.warning(f"{warning} and not using encryption. This is not recommended.") if not app.password and not app.token: app.log.warning( f"{warning} and not using authentication. " "This is highly insecure and not recommended." ) elif not app.password and not app.token: app.log.warning( "All authentication is disabled." " Anyone who can connect to this server will be able to run code." ) @classmethod def password_from_settings(cls, settings): """DEPRECATED in 2.0, use IdentityProvider API""" return settings.get("password", "") @classmethod def get_login_available(cls, settings): """DEPRECATED in 2.0, use IdentityProvider API""" return bool(cls.password_from_settings(settings) or settings.get("token")) # deprecated import, so deprecated implementations get the Legacy class instead LoginHandler = LegacyLoginHandler jupyter-server-jupyter_server-e5c7e2b/jupyter_server/auth/logout.py000066400000000000000000000014211473126534200262250ustar00rootroot00000000000000"""Tornado handlers for logging out of the Jupyter Server.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from ..base.handlers import JupyterHandler from .decorator import allow_unauthenticated class LogoutHandler(JupyterHandler): """An auth logout handler.""" @allow_unauthenticated def get(self): """Handle a logout.""" self.identity_provider.clear_login_cookie(self) if self.login_available: message = {"info": "Successfully logged out."} else: message = {"warning": "Cannot log out. Jupyter Server authentication is disabled."} self.write(self.render_template("logout.html", message=message)) default_handlers = [(r"/logout", LogoutHandler)] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/auth/security.py000066400000000000000000000112051473126534200265640ustar00rootroot00000000000000""" Password generation for the Jupyter Server. """ import getpass import hashlib import json import os import random import traceback import warnings from contextlib import contextmanager from jupyter_core.paths import jupyter_config_dir from traitlets.config import Config from traitlets.config.loader import ConfigFileNotFound, JSONFileConfigLoader # Length of the salt in nr of hex chars, which implies salt_len * 4 # bits of randomness. salt_len = 12 def passwd(passphrase=None, algorithm="argon2"): """Generate hashed password and salt for use in server configuration. In the server configuration, set `c.ServerApp.password` to the generated string. Parameters ---------- passphrase : str Password to hash. If unspecified, the user is asked to input and verify a password. algorithm : str Hashing algorithm to use (e.g, 'sha1' or any argument supported by :func:`hashlib.new`, or 'argon2'). Returns ------- hashed_passphrase : str Hashed password, in the format 'hash_algorithm:salt:passphrase_hash'. Examples -------- >>> passwd("mypassword") # doctest: +ELLIPSIS 'argon2:...' """ if passphrase is None: for _ in range(3): p0 = getpass.getpass("Enter password: ") p1 = getpass.getpass("Verify password: ") if p0 == p1: passphrase = p0 break warnings.warn("Passwords do not match.", stacklevel=2) else: msg = "No matching passwords found. Giving up." raise ValueError(msg) if algorithm == "argon2": import argon2 ph = argon2.PasswordHasher( memory_cost=10240, time_cost=10, parallelism=8, ) h_ph = ph.hash(passphrase) return f"{algorithm}:{h_ph}" h = hashlib.new(algorithm) salt = ("%0" + str(salt_len) + "x") % random.getrandbits(4 * salt_len) h.update(passphrase.encode("utf-8") + salt.encode("ascii")) return f"{algorithm}:{salt}:{h.hexdigest()}" def passwd_check(hashed_passphrase, passphrase): """Verify that a given passphrase matches its hashed version. Parameters ---------- hashed_passphrase : str Hashed password, in the format returned by `passwd`. passphrase : str Passphrase to validate. Returns ------- valid : bool True if the passphrase matches the hash. Examples -------- >>> myhash = passwd("mypassword") >>> passwd_check(myhash, "mypassword") True >>> passwd_check(myhash, "otherpassword") False >>> passwd_check("sha1:0e112c3ddfce:a68df677475c2b47b6e86d0467eec97ac5f4b85a", "mypassword") True """ if hashed_passphrase.startswith("argon2:"): import argon2 import argon2.exceptions ph = argon2.PasswordHasher() try: return ph.verify(hashed_passphrase[7:], passphrase) except argon2.exceptions.VerificationError: return False try: algorithm, salt, pw_digest = hashed_passphrase.split(":", 2) except (ValueError, TypeError): return False try: h = hashlib.new(algorithm) except ValueError: return False if len(pw_digest) == 0: return False h.update(passphrase.encode("utf-8") + salt.encode("ascii")) return h.hexdigest() == pw_digest @contextmanager def persist_config(config_file=None, mode=0o600): """Context manager that can be used to modify a config object On exit of the context manager, the config will be written back to disk, by default with user-only (600) permissions. """ if config_file is None: config_file = os.path.join(jupyter_config_dir(), "jupyter_server_config.json") os.makedirs(os.path.dirname(config_file), exist_ok=True) loader = JSONFileConfigLoader(os.path.basename(config_file), os.path.dirname(config_file)) try: config = loader.load_config() except ConfigFileNotFound: config = Config() yield config with open(config_file, "w", encoding="utf8") as f: f.write(json.dumps(config, indent=2)) try: os.chmod(config_file, mode) except Exception: tb = traceback.format_exc() warnings.warn( f"Failed to set permissions on {config_file}:\n{tb}", RuntimeWarning, stacklevel=2 ) def set_password(password=None, config_file=None): """Ask user for password, store it in JSON configuration file""" hashed_password = passwd(password) with persist_config(config_file) as config: config.IdentityProvider.hashed_password = hashed_password return hashed_password jupyter-server-jupyter_server-e5c7e2b/jupyter_server/auth/utils.py000066400000000000000000000073371473126534200260700ustar00rootroot00000000000000"""A module with various utility methods for authorization in Jupyter Server.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import importlib import random import re import warnings def warn_disabled_authorization(): """DEPRECATED, does nothing""" warnings.warn( "jupyter_server.auth.utils.warn_disabled_authorization is deprecated", DeprecationWarning, stacklevel=2, ) HTTP_METHOD_TO_AUTH_ACTION = { "GET": "read", "HEAD": "read", "OPTIONS": "read", "POST": "write", "PUT": "write", "PATCH": "write", "DELETE": "write", "WEBSOCKET": "execute", } def get_regex_to_resource_map(): """Returns a dictionary with all of Jupyter Server's request handler URL regex patterns mapped to their resource name. e.g. { "/api/contents/": "contents", ...} """ from jupyter_server.serverapp import JUPYTER_SERVICE_HANDLERS modules = [] for mod_name in JUPYTER_SERVICE_HANDLERS.values(): if mod_name: modules.extend(mod_name) resource_map = {} for handler_module in modules: mod = importlib.import_module(handler_module) name = mod.AUTH_RESOURCE for handler in mod.default_handlers: url_regex = handler[0] resource_map[url_regex] = name # terminal plugin doesn't have importable url patterns # get these from terminal/__init__.py for url_regex in [ r"/terminals/websocket/(\w+)", "/api/terminals", r"/api/terminals/(\w+)", ]: resource_map[url_regex] = "terminals" return resource_map def match_url_to_resource(url, regex_mapping=None): """Finds the JupyterHandler regex pattern that would match the given URL and returns the resource name (str) of that handler. e.g. /api/contents/... returns "contents" """ if not regex_mapping: regex_mapping = get_regex_to_resource_map() for regex, auth_resource in regex_mapping.items(): pattern = re.compile(regex) if pattern.fullmatch(url): return auth_resource # From https://en.wikipedia.org/wiki/Moons_of_Jupiter moons_of_jupyter = [ "Metis", "Adrastea", "Amalthea", "Thebe", "Io", "Europa", "Ganymede", "Callisto", "Themisto", "Leda", "Ersa", "Pandia", "Himalia", "Lysithea", "Elara", "Dia", "Carpo", "Valetudo", "Euporie", "Eupheme", # 'S/2003 J 18', # 'S/2010 J 2', "Helike", # 'S/2003 J 16', # 'S/2003 J 2', "Euanthe", # 'S/2017 J 7', "Hermippe", "Praxidike", "Thyone", "Thelxinoe", # 'S/2017 J 3', "Ananke", "Mneme", # 'S/2016 J 1', "Orthosie", "Harpalyke", "Iocaste", # 'S/2017 J 9', # 'S/2003 J 12', # 'S/2003 J 4', "Erinome", "Aitne", "Herse", "Taygete", # 'S/2017 J 2', # 'S/2017 J 6', "Eukelade", "Carme", # 'S/2003 J 19', "Isonoe", # 'S/2003 J 10', "Autonoe", "Philophrosyne", "Cyllene", "Pasithee", # 'S/2010 J 1', "Pasiphae", "Sponde", # 'S/2017 J 8', "Eurydome", # 'S/2017 J 5', "Kalyke", "Hegemone", "Kale", "Kallichore", # 'S/2011 J 1', # 'S/2017 J 1', "Chaldene", "Arche", "Eirene", "Kore", # 'S/2011 J 2', # 'S/2003 J 9', "Megaclite", "Aoede", # 'S/2003 J 23', "Callirrhoe", "Sinope", ] def get_anonymous_username() -> str: """ Get a random user-name based on the moons of Jupyter. This function returns names like "Anonymous Io" or "Anonymous Metis". """ return moons_of_jupyter[random.randint(0, len(moons_of_jupyter) - 1)] # noqa: S311 jupyter-server-jupyter_server-e5c7e2b/jupyter_server/base/000077500000000000000000000000001473126534200243155ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/base/__init__.py000066400000000000000000000000001473126534200264140ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/base/call_context.py000066400000000000000000000061521473126534200273520ustar00rootroot00000000000000"""Provides access to variables pertaining to specific call contexts.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from contextvars import Context, ContextVar, copy_context from typing import Any class CallContext: """CallContext essentially acts as a namespace for managing context variables. Although not required, it is recommended that any "file-spanning" context variable names (i.e., variables that will be set or retrieved from multiple files or services) be added as constants to this class definition. """ # Add well-known (file-spanning) names here. #: Provides access to the current request handler once set. JUPYTER_HANDLER: str = "JUPYTER_HANDLER" # A map of variable name to value is maintained as the single ContextVar. This also enables # easier management over maintaining a set of ContextVar instances, since the Context is a # map of ContextVar instances to their values, and the "name" is no longer a lookup key. _NAME_VALUE_MAP = "_name_value_map" _name_value_map: ContextVar[dict[str, Any]] = ContextVar(_NAME_VALUE_MAP) @classmethod def get(cls, name: str) -> Any: """Returns the value corresponding the named variable relative to this context. If the named variable doesn't exist, None will be returned. Parameters ---------- name : str The name of the variable to get from the call context Returns ------- value: Any The value associated with the named variable for this call context """ name_value_map = CallContext._get_map() if name in name_value_map: return name_value_map[name] return None # TODO: should this raise `LookupError` (or a custom error derived from said) @classmethod def set(cls, name: str, value: Any) -> None: """Sets the named variable to the specified value in the current call context. Parameters ---------- name : str The name of the variable to store into the call context value : Any The value of the variable to store into the call context Returns ------- None """ name_value_map = CallContext._get_map() name_value_map[name] = value @classmethod def context_variable_names(cls) -> list[str]: """Returns a list of variable names set for this call context. Returns ------- names: List[str] A list of variable names set for this call context. """ name_value_map = CallContext._get_map() return list(name_value_map.keys()) @classmethod def _get_map(cls) -> dict[str, Any]: """Get the map of names to their values from the _NAME_VALUE_MAP context var. If the map does not exist in the current context, an empty map is created and returned. """ ctx: Context = copy_context() if CallContext._name_value_map not in ctx: CallContext._name_value_map.set({}) return CallContext._name_value_map.get() jupyter-server-jupyter_server-e5c7e2b/jupyter_server/base/handlers.py000066400000000000000000001274431473126534200265020ustar00rootroot00000000000000"""Base Tornado handlers for the Jupyter server.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import functools import inspect import ipaddress import json import mimetypes import os import re import types import warnings from collections.abc import Awaitable, Coroutine, Sequence from http.client import responses from logging import Logger from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse import prometheus_client from jinja2 import TemplateNotFound from jupyter_core.paths import is_hidden from tornado import web from tornado.log import app_log from traitlets.config import Application import jupyter_server from jupyter_server import CallContext from jupyter_server._sysinfo import get_sys_info from jupyter_server._tz import utcnow from jupyter_server.auth.decorator import allow_unauthenticated, authorized from jupyter_server.auth.identity import User from jupyter_server.i18n import combine_translations from jupyter_server.services.security import csp_report_uri from jupyter_server.utils import ( ensure_async, filefind, url_escape, url_is_absolute, url_path_join, urldecode_unix_socket_path, ) if TYPE_CHECKING: from jupyter_client.kernelspec import KernelSpecManager from jupyter_events import EventLogger from jupyter_server_terminals.terminalmanager import TerminalManager from tornado.concurrent import Future from jupyter_server.auth.authorizer import Authorizer from jupyter_server.auth.identity import IdentityProvider from jupyter_server.serverapp import ServerApp from jupyter_server.services.config.manager import ConfigManager from jupyter_server.services.contents.manager import ContentsManager from jupyter_server.services.kernels.kernelmanager import AsyncMappingKernelManager from jupyter_server.services.sessions.sessionmanager import SessionManager # ----------------------------------------------------------------------------- # Top-level handlers # ----------------------------------------------------------------------------- _sys_info_cache = None def json_sys_info(): """Get sys info as json.""" global _sys_info_cache # noqa: PLW0603 if _sys_info_cache is None: _sys_info_cache = json.dumps(get_sys_info()) return _sys_info_cache def log() -> Logger: """Get the application log.""" if Application.initialized(): return cast(Logger, Application.instance().log) else: return app_log class AuthenticatedHandler(web.RequestHandler): """A RequestHandler with an authenticated user.""" @property def base_url(self) -> str: return cast(str, self.settings.get("base_url", "/")) @property def content_security_policy(self) -> str: """The default Content-Security-Policy header Can be overridden by defining Content-Security-Policy in settings['headers'] """ if "Content-Security-Policy" in self.settings.get("headers", {}): # user-specified, don't override return cast(str, self.settings["headers"]["Content-Security-Policy"]) return "; ".join( [ "frame-ancestors 'self'", # Make sure the report-uri is relative to the base_url "report-uri " + self.settings.get("csp_report_uri", url_path_join(self.base_url, csp_report_uri)), ] ) def set_default_headers(self) -> None: """Set the default headers.""" headers = {} headers["X-Content-Type-Options"] = "nosniff" headers.update(self.settings.get("headers", {})) headers["Content-Security-Policy"] = self.content_security_policy # Allow for overriding headers for header_name, value in headers.items(): try: self.set_header(header_name, value) except Exception as e: # tornado raise Exception (not a subclass) # if method is unsupported (websocket and Access-Control-Allow-Origin # for example, so just ignore) self.log.exception( # type:ignore[attr-defined] "Could not set default headers: %s", e ) @property def cookie_name(self) -> str: warnings.warn( """JupyterHandler.login_handler is deprecated in 2.0, use JupyterHandler.identity_provider. """, DeprecationWarning, stacklevel=2, ) return self.identity_provider.get_cookie_name(self) def force_clear_cookie(self, name: str, path: str = "/", domain: str | None = None) -> None: """Force a cookie clear.""" warnings.warn( """JupyterHandler.login_handler is deprecated in 2.0, use JupyterHandler.identity_provider. """, DeprecationWarning, stacklevel=2, ) self.identity_provider._force_clear_cookie(self, name, path=path, domain=domain) def clear_login_cookie(self) -> None: """Clear a login cookie.""" warnings.warn( """JupyterHandler.login_handler is deprecated in 2.0, use JupyterHandler.identity_provider. """, DeprecationWarning, stacklevel=2, ) self.identity_provider.clear_login_cookie(self) def get_current_user(self) -> str: """Get the current user.""" clsname = self.__class__.__name__ msg = ( f"Calling `{clsname}.get_current_user()` directly is deprecated in jupyter-server 2.0." " Use `self.current_user` instead (works in all versions)." ) if hasattr(self, "_jupyter_current_user"): # backward-compat: return _jupyter_current_user warnings.warn( msg, DeprecationWarning, stacklevel=2, ) return cast(str, self._jupyter_current_user) # haven't called get_user in prepare, raise raise RuntimeError(msg) def skip_check_origin(self) -> bool: """Ask my login_handler if I should skip the origin_check For example: in the default LoginHandler, if a request is token-authenticated, origin checking should be skipped. """ if self.request.method == "OPTIONS": # no origin-check on options requests, which are used to check origins! return True return not self.identity_provider.should_check_origin(self) @property def token_authenticated(self) -> bool: """Have I been authenticated with a token?""" return self.identity_provider.is_token_authenticated(self) @property def logged_in(self) -> bool: """Is a user currently logged in?""" user = self.current_user return bool(user and user != "anonymous") @property def login_handler(self) -> Any: """Return the login handler for this application, if any.""" warnings.warn( """JupyterHandler.login_handler is deprecated in 2.0, use JupyterHandler.identity_provider. """, DeprecationWarning, stacklevel=2, ) return self.identity_provider.login_handler_class @property def token(self) -> str | None: """Return the login token for this application, if any.""" return self.identity_provider.token @property def login_available(self) -> bool: """May a user proceed to log in? This returns True if login capability is available, irrespective of whether the user is already logged in or not. """ return cast(bool, self.identity_provider.login_available) @property def authorizer(self) -> Authorizer: if "authorizer" not in self.settings: warnings.warn( "The Tornado web application does not have an 'authorizer' defined " "in its settings. In future releases of jupyter_server, this will " "be a required key for all subclasses of `JupyterHandler`. For an " "example, see the jupyter_server source code for how to " "add an authorizer to the tornado settings: " "https://github.com/jupyter-server/jupyter_server/blob/" "653740cbad7ce0c8a8752ce83e4d3c2c754b13cb/jupyter_server/serverapp.py" "#L234-L256", stacklevel=2, ) from jupyter_server.auth import AllowAllAuthorizer self.settings["authorizer"] = AllowAllAuthorizer( config=self.settings.get("config", None), identity_provider=self.identity_provider, ) return cast("Authorizer", self.settings.get("authorizer")) @property def identity_provider(self) -> IdentityProvider: if "identity_provider" not in self.settings: warnings.warn( "The Tornado web application does not have an 'identity_provider' defined " "in its settings. In future releases of jupyter_server, this will " "be a required key for all subclasses of `JupyterHandler`. For an " "example, see the jupyter_server source code for how to " "add an identity provider to the tornado settings: " "https://github.com/jupyter-server/jupyter_server/blob/v2.0.0/" "jupyter_server/serverapp.py#L242", stacklevel=2, ) from jupyter_server.auth import IdentityProvider # no identity provider set, load default self.settings["identity_provider"] = IdentityProvider( config=self.settings.get("config", None) ) return cast("IdentityProvider", self.settings["identity_provider"]) class JupyterHandler(AuthenticatedHandler): """Jupyter-specific extensions to authenticated handling Mostly property shortcuts to Jupyter-specific settings. """ @property def config(self) -> dict[str, Any] | None: return cast("dict[str, Any] | None", self.settings.get("config", None)) @property def log(self) -> Logger: """use the Jupyter log by default, falling back on tornado's logger""" return log() @property def jinja_template_vars(self) -> dict[str, Any]: """User-supplied values to supply to jinja templates.""" return cast("dict[str, Any]", self.settings.get("jinja_template_vars", {})) @property def serverapp(self) -> ServerApp | None: return cast("ServerApp | None", self.settings["serverapp"]) # --------------------------------------------------------------- # URLs # --------------------------------------------------------------- @property def version_hash(self) -> str: """The version hash to use for cache hints for static files""" return cast(str, self.settings.get("version_hash", "")) @property def mathjax_url(self) -> str: url = cast(str, self.settings.get("mathjax_url", "")) if not url or url_is_absolute(url): return url return url_path_join(self.base_url, url) @property def mathjax_config(self) -> str: return cast(str, self.settings.get("mathjax_config", "TeX-AMS-MML_HTMLorMML-full,Safe")) @property def default_url(self) -> str: return cast(str, self.settings.get("default_url", "")) @property def ws_url(self) -> str: return cast(str, self.settings.get("websocket_url", "")) @property def contents_js_source(self) -> str: self.log.debug( "Using contents: %s", self.settings.get("contents_js_source", "services/contents"), ) return cast(str, self.settings.get("contents_js_source", "services/contents")) # --------------------------------------------------------------- # Manager objects # --------------------------------------------------------------- @property def kernel_manager(self) -> AsyncMappingKernelManager: return cast("AsyncMappingKernelManager", self.settings["kernel_manager"]) @property def contents_manager(self) -> ContentsManager: return cast("ContentsManager", self.settings["contents_manager"]) @property def session_manager(self) -> SessionManager: return cast("SessionManager", self.settings["session_manager"]) @property def terminal_manager(self) -> TerminalManager: return cast("TerminalManager", self.settings["terminal_manager"]) @property def kernel_spec_manager(self) -> KernelSpecManager: return cast("KernelSpecManager", self.settings["kernel_spec_manager"]) @property def config_manager(self) -> ConfigManager: return cast("ConfigManager", self.settings["config_manager"]) @property def event_logger(self) -> EventLogger: return cast("EventLogger", self.settings["event_logger"]) # --------------------------------------------------------------- # CORS # --------------------------------------------------------------- @property def allow_origin(self) -> str: """Normal Access-Control-Allow-Origin""" return cast(str, self.settings.get("allow_origin", "")) @property def allow_origin_pat(self) -> str | None: """Regular expression version of allow_origin""" return cast("str | None", self.settings.get("allow_origin_pat", None)) @property def allow_credentials(self) -> bool: """Whether to set Access-Control-Allow-Credentials""" return cast(bool, self.settings.get("allow_credentials", False)) def set_default_headers(self) -> None: """Add CORS headers, if defined""" super().set_default_headers() def set_cors_headers(self) -> None: """Add CORS headers, if defined Now that current_user is async (jupyter-server 2.0), must be called at the end of prepare(), instead of in set_default_headers. """ if self.allow_origin: self.set_header("Access-Control-Allow-Origin", self.allow_origin) elif self.allow_origin_pat: origin = self.get_origin() if origin and re.match(self.allow_origin_pat, origin): self.set_header("Access-Control-Allow-Origin", origin) elif self.token_authenticated and "Access-Control-Allow-Origin" not in self.settings.get( "headers", {} ): # allow token-authenticated requests cross-origin by default. # only apply this exception if allow-origin has not been specified. self.set_header("Access-Control-Allow-Origin", self.request.headers.get("Origin", "")) if self.allow_credentials: self.set_header("Access-Control-Allow-Credentials", "true") def set_attachment_header(self, filename: str) -> None: """Set Content-Disposition: attachment header As a method to ensure handling of filename encoding """ escaped_filename = url_escape(filename) self.set_header( "Content-Disposition", f"attachment; filename*=utf-8''{escaped_filename}", ) def get_origin(self) -> str | None: # Handle WebSocket Origin naming convention differences # The difference between version 8 and 13 is that in 8 the # client sends a "Sec-Websocket-Origin" header and in 13 it's # simply "Origin". if "Origin" in self.request.headers: origin = self.request.headers.get("Origin") else: origin = self.request.headers.get("Sec-Websocket-Origin", None) return origin # origin_to_satisfy_tornado is present because tornado requires # check_origin to take an origin argument, but we don't use it def check_origin(self, origin_to_satisfy_tornado: str = "") -> bool: """Check Origin for cross-site API requests, including websockets Copied from WebSocket with changes: - allow unspecified host/origin (e.g. scripts) - allow token-authenticated requests """ if self.allow_origin == "*" or self.skip_check_origin(): return True host = self.request.headers.get("Host") origin = self.request.headers.get("Origin") # If no header is provided, let the request through. # Origin can be None for: # - same-origin (IE, Firefox) # - Cross-site POST form (IE, Firefox) # - Scripts # The cross-site POST (XSRF) case is handled by tornado's xsrf_token if origin is None or host is None: return True origin = origin.lower() origin_host = urlparse(origin).netloc # OK if origin matches host if origin_host == host: return True # Check CORS headers if self.allow_origin: allow = bool(self.allow_origin == origin) elif self.allow_origin_pat: allow = bool(re.match(self.allow_origin_pat, origin)) else: # No CORS headers deny the request allow = False if not allow: self.log.warning( "Blocking Cross Origin API request for %s. Origin: %s, Host: %s", self.request.path, origin, host, ) return allow def check_referer(self) -> bool: """Check Referer for cross-site requests. Disables requests to certain endpoints with external or missing Referer. If set, allow_origin settings are applied to the Referer to whitelist specific cross-origin sites. Used on GET for api endpoints and /files/ to block cross-site inclusion (XSSI). """ if self.allow_origin == "*" or self.skip_check_origin(): return True host = self.request.headers.get("Host") referer = self.request.headers.get("Referer") if not host: self.log.warning("Blocking request with no host") return False if not referer: self.log.warning("Blocking request with no referer") return False referer_url = urlparse(referer) referer_host = referer_url.netloc if referer_host == host: return True # apply cross-origin checks to Referer: origin = f"{referer_url.scheme}://{referer_url.netloc}" if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: allow = bool(re.match(self.allow_origin_pat, origin)) else: # No CORS settings, deny the request allow = False if not allow: self.log.warning( "Blocking Cross Origin request for %s. Referer: %s, Host: %s", self.request.path, origin, host, ) return allow def check_xsrf_cookie(self) -> None: """Bypass xsrf cookie checks when token-authenticated""" if not hasattr(self, "_jupyter_current_user"): # Called too early, will be checked later return None if self.token_authenticated or self.settings.get("disable_check_xsrf", False): # Token-authenticated requests do not need additional XSRF-check # Servers without authentication are vulnerable to XSRF return None try: return super().check_xsrf_cookie() except web.HTTPError as e: if self.request.method in {"GET", "HEAD"}: # Consider Referer a sufficient cross-origin check for GET requests if not self.check_referer(): referer = self.request.headers.get("Referer") if referer: msg = f"Blocking Cross Origin request from {referer}." else: msg = "Blocking request from unknown origin" raise web.HTTPError(403, msg) from e else: raise def check_host(self) -> bool: """Check the host header if remote access disallowed. Returns True if the request should continue, False otherwise. """ if self.settings.get("allow_remote_access", False): return True # Remove port (e.g. ':8888') from host match = re.match(r"^(.*?)(:\d+)?$", self.request.host) assert match is not None host = match.group(1) # Browsers format IPv6 addresses like [::1]; we need to remove the [] if host.startswith("[") and host.endswith("]"): host = host[1:-1] # UNIX socket handling check_host = urldecode_unix_socket_path(host) if check_host.startswith("/") and os.path.exists(check_host): allow = True else: try: addr = ipaddress.ip_address(host) except ValueError: # Not an IP address: check against hostnames allow = host in self.settings.get("local_hostnames", ["localhost"]) else: allow = addr.is_loopback if not allow: self.log.warning( ( "Blocking request with non-local 'Host' %s (%s). " "If the server should be accessible at that name, " "set ServerApp.allow_remote_access to disable the check." ), host, self.request.host, ) return allow async def prepare(self, *, _redirect_to_login=True) -> Awaitable[None] | None: # type:ignore[override] """Prepare a response.""" # Set the current Jupyter Handler context variable. CallContext.set(CallContext.JUPYTER_HANDLER, self) if not self.check_host(): self.current_user = self._jupyter_current_user = None raise web.HTTPError(403) from jupyter_server.auth import IdentityProvider mod_obj = inspect.getmodule(self.get_current_user) assert mod_obj is not None user: User | None = None if type(self.identity_provider) is IdentityProvider and mod_obj.__name__ != __name__: # check for overridden get_current_user + default IdentityProvider # deprecated way to override auth (e.g. JupyterHub < 3.0) # allow deprecated, overridden get_current_user warnings.warn( "Overriding JupyterHandler.get_current_user is deprecated in jupyter-server 2.0." " Use an IdentityProvider class.", DeprecationWarning, stacklevel=1, ) user = User(self.get_current_user()) else: _user = self.identity_provider.get_user(self) if isinstance(_user, Awaitable): # IdentityProvider.get_user _may_ be async _user = await _user user = _user # self.current_user for tornado's @web.authenticated # self._jupyter_current_user for backward-compat in deprecated get_current_user calls # and our own private checks for whether .current_user has been set self.current_user = self._jupyter_current_user = user # complete initial steps which require auth to resolve first: self.set_cors_headers() if self.request.method not in {"GET", "HEAD", "OPTIONS"}: self.check_xsrf_cookie() if not self.settings.get("allow_unauthenticated_access", False): if not self.request.method: raise HTTPError(403) method = getattr(self, self.request.method.lower()) if not getattr(method, "__allow_unauthenticated", False): if _redirect_to_login: # reuse `web.authenticated` logic, which redirects to the login # page on GET and HEAD and otherwise raises 403 return web.authenticated(lambda _: super().prepare())(self) else: # raise 403 if user is not known without redirecting to login page user = self.current_user if user is None: self.log.warning( f"Couldn't authenticate {self.__class__.__name__} connection" ) raise web.HTTPError(403) return super().prepare() # --------------------------------------------------------------- # template rendering # --------------------------------------------------------------- def get_template(self, name): """Return the jinja template object for a given name""" return self.settings["jinja2_env"].get_template(name) def render_template(self, name, **ns): """Render a template by name.""" ns.update(self.template_namespace) template = self.get_template(name) return template.render(**ns) @property def template_namespace(self) -> dict[str, Any]: return dict( base_url=self.base_url, default_url=self.default_url, ws_url=self.ws_url, logged_in=self.logged_in, allow_password_change=getattr(self.identity_provider, "allow_password_change", False), auth_enabled=self.identity_provider.auth_enabled, login_available=self.identity_provider.login_available, token_available=bool(self.token), static_url=self.static_url, sys_info=json_sys_info(), contents_js_source=self.contents_js_source, version_hash=self.version_hash, xsrf_form_html=self.xsrf_form_html, token=self.token, xsrf_token=self.xsrf_token.decode("utf8"), nbjs_translations=json.dumps( combine_translations(self.request.headers.get("Accept-Language", "")) ), **self.jinja_template_vars, ) def get_json_body(self) -> dict[str, Any] | None: """Return the body of the request as JSON data.""" if not self.request.body: return None # Do we need to call body.decode('utf-8') here? body = self.request.body.strip().decode("utf-8") try: model = json.loads(body) except Exception as e: self.log.debug("Bad JSON: %r", body) self.log.error("Couldn't parse JSON", exc_info=True) raise web.HTTPError(400, "Invalid JSON in body of request") from e return cast("dict[str, Any]", model) def write_error(self, status_code: int, **kwargs: Any) -> None: """render custom error pages""" exc_info = kwargs.get("exc_info") message = "" status_message = responses.get(status_code, "Unknown HTTP Error") if exc_info: exception = exc_info[1] # get the custom message, if defined try: message = exception.log_message % exception.args except Exception: pass # construct the custom reason, if defined reason = getattr(exception, "reason", "") if reason: status_message = reason else: exception = "(unknown)" # build template namespace ns = { "status_code": status_code, "status_message": status_message, "message": message, "exception": exception, } self.set_header("Content-Type", "text/html") # render the template try: html = self.render_template("%s.html" % status_code, **ns) except TemplateNotFound: html = self.render_template("error.html", **ns) self.write(html) class APIHandler(JupyterHandler): """Base class for API handlers""" async def prepare(self) -> None: # type:ignore[override] """Prepare an API response.""" await super().prepare() if not self.check_origin(): raise web.HTTPError(404) def write_error(self, status_code: int, **kwargs: Any) -> None: """APIHandler errors are JSON, not human pages""" self.set_header("Content-Type", "application/json") message = responses.get(status_code, "Unknown HTTP Error") reply: dict[str, Any] = { "message": message, } exc_info = kwargs.get("exc_info") if exc_info: e = exc_info[1] if isinstance(e, HTTPError): reply["message"] = e.log_message or message reply["reason"] = e.reason else: reply["message"] = "Unhandled error" reply["reason"] = None # backward-compatibility: traceback field is present, # but always empty reply["traceback"] = "" self.log.warning("wrote error: %r", reply["message"], exc_info=True) self.finish(json.dumps(reply)) def get_login_url(self) -> str: """Get the login url.""" # if get_login_url is invoked in an API handler, # that means @web.authenticated is trying to trigger a redirect. # instead of redirecting, raise 403 instead. if not self.current_user: raise web.HTTPError(403) return super().get_login_url() @property def content_security_policy(self) -> str: csp = "; ".join( # noqa: FLY002 [ super().content_security_policy, "default-src 'none'", ] ) return csp # set _track_activity = False on API handlers that shouldn't track activity _track_activity = True def update_api_activity(self) -> None: """Update last_activity of API requests""" # record activity of authenticated requests if ( self._track_activity and getattr(self, "_jupyter_current_user", None) and self.get_argument("no_track_activity", None) is None ): self.settings["api_last_activity"] = utcnow() def finish(self, *args: Any, **kwargs: Any) -> Future[Any]: """Finish an API response.""" self.update_api_activity() # Allow caller to indicate content-type... set_content_type = kwargs.pop("set_content_type", "application/json") self.set_header("Content-Type", set_content_type) return super().finish(*args, **kwargs) @allow_unauthenticated def options(self, *args: Any, **kwargs: Any) -> None: """Get the options.""" if "Access-Control-Allow-Headers" in self.settings.get("headers", {}): self.set_header( "Access-Control-Allow-Headers", self.settings["headers"]["Access-Control-Allow-Headers"], ) else: self.set_header( "Access-Control-Allow-Headers", "accept, content-type, authorization, x-xsrftoken", ) self.set_header("Access-Control-Allow-Methods", "GET, PUT, POST, PATCH, DELETE, OPTIONS") # if authorization header is requested, # that means the request is token-authenticated. # avoid browser-side rejection of the preflight request. # only allow this exception if allow_origin has not been specified # and Jupyter server authentication is enabled. # If the token is not valid, the 'real' request will still be rejected. requested_headers = self.request.headers.get("Access-Control-Request-Headers", "").split( "," ) if ( requested_headers and any(h.strip().lower() == "authorization" for h in requested_headers) and ( # FIXME: it would be even better to check specifically for token-auth, # but there is currently no API for this. self.login_available ) and ( self.allow_origin or self.allow_origin_pat or "Access-Control-Allow-Origin" in self.settings.get("headers", {}) ) ): self.set_header("Access-Control-Allow-Origin", self.request.headers.get("Origin", "")) class Template404(JupyterHandler): """Render our 404 template""" async def prepare(self) -> None: # type:ignore[override] """Prepare a 404 response.""" await super().prepare() raise web.HTTPError(404) class AuthenticatedFileHandler(JupyterHandler, web.StaticFileHandler): """static files should only be accessible when logged in""" auth_resource = "contents" @property def content_security_policy(self) -> str: # In case we're serving HTML/SVG, confine any Javascript to a unique # origin so it can't interact with the Jupyter server. return super().content_security_policy + "; sandbox allow-scripts" @web.authenticated @authorized def head(self, path: str) -> Awaitable[None]: # type:ignore[override] """Get the head response for a path.""" self.check_xsrf_cookie() return super().head(path) @web.authenticated @authorized def get( # type:ignore[override] self, path: str, **kwargs: Any ) -> Awaitable[None]: """Get a file by path.""" self.check_xsrf_cookie() if os.path.splitext(path)[1] == ".ipynb" or self.get_argument("download", None): name = path.rsplit("/", 1)[-1] self.set_attachment_header(name) return web.StaticFileHandler.get(self, path, **kwargs) def get_content_type(self) -> str: """Get the content type.""" assert self.absolute_path is not None path = self.absolute_path.strip("/") if "/" in path: _, name = path.rsplit("/", 1) else: name = path if name.endswith(".ipynb"): return "application/x-ipynb+json" else: cur_mime = mimetypes.guess_type(name)[0] if cur_mime == "text/plain": return "text/plain; charset=UTF-8" else: return super().get_content_type() def set_headers(self) -> None: """Set the headers.""" super().set_headers() # disable browser caching, rely on 304 replies for savings if "v" not in self.request.arguments: self.add_header("Cache-Control", "no-cache") def compute_etag(self) -> str | None: """Compute the etag.""" return None def validate_absolute_path(self, root: str, absolute_path: str) -> str: """Validate and return the absolute path. Requires tornado 3.1 Adding to tornado's own handling, forbids the serving of hidden files. """ abs_path = super().validate_absolute_path(root, absolute_path) abs_root = os.path.abspath(root) assert abs_path is not None if not self.contents_manager.allow_hidden and is_hidden(abs_path, abs_root): self.log.info( "Refusing to serve hidden file, via 404 Error, use flag 'ContentsManager.allow_hidden' to enable" ) raise web.HTTPError(404) return abs_path def json_errors(method: Any) -> Any: # pragma: no cover """Decorate methods with this to return GitHub style JSON errors. This should be used on any JSON API on any handler method that can raise HTTPErrors. This will grab the latest HTTPError exception using sys.exc_info and then: 1. Set the HTTP status code based on the HTTPError 2. Create and return a JSON body with a message field describing the error in a human readable form. """ warnings.warn( "@json_errors is deprecated in notebook 5.2.0. Subclass APIHandler instead.", DeprecationWarning, stacklevel=2, ) @functools.wraps(method) def wrapper(self, *args, **kwargs): self.write_error = types.MethodType(APIHandler.write_error, self) return method(self, *args, **kwargs) return wrapper # ----------------------------------------------------------------------------- # File handler # ----------------------------------------------------------------------------- # to minimize subclass changes: HTTPError = web.HTTPError class FileFindHandler(JupyterHandler, web.StaticFileHandler): """subclass of StaticFileHandler for serving files from a search path The setting "static_immutable_cache" can be set up to serve some static file as immutable (e.g. file name containing a hash). The setting is a list of base URL, every static file URL starting with one of those will be immutable. """ # cache search results, don't search for files more than once _static_paths: dict[str, str] = {} root: tuple[str] # type:ignore[assignment] def set_headers(self) -> None: """Set the headers.""" super().set_headers() immutable_paths = self.settings.get("static_immutable_cache", []) # allow immutable cache for files if any(self.request.path.startswith(path) for path in immutable_paths): self.set_header("Cache-Control", "public, max-age=31536000, immutable") # disable browser caching, rely on 304 replies for savings elif "v" not in self.request.arguments or any( self.request.path.startswith(path) for path in self.no_cache_paths ): self.set_header("Cache-Control", "no-cache") def initialize( self, path: str | list[str], default_filename: str | None = None, no_cache_paths: list[str] | None = None, ) -> None: """Initialize the file find handler.""" self.no_cache_paths = no_cache_paths or [] if isinstance(path, str): path = [path] self.root = tuple(os.path.abspath(os.path.expanduser(p)) + os.sep for p in path) # type:ignore[assignment] self.default_filename = default_filename def compute_etag(self) -> str | None: """Compute the etag.""" return None # access is allowed as this class is used to serve static assets on login page # TODO: create an allow-list of files used on login page and remove this decorator @allow_unauthenticated def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]: return super().get(path, include_body) # access is allowed as this class is used to serve static assets on login page # TODO: create an allow-list of files used on login page and remove this decorator @allow_unauthenticated def head(self, path: str) -> Awaitable[None]: return super().head(path) @classmethod def get_absolute_path(cls, roots: Sequence[str], path: str) -> str: """locate a file to serve on our static file search path""" with cls._lock: if path in cls._static_paths: return cls._static_paths[path] try: abspath = os.path.abspath(filefind(path, roots)) except OSError: # IOError means not found return "" cls._static_paths[path] = abspath log().debug(f"Path {path} served from {abspath}") return abspath def validate_absolute_path(self, root: str, absolute_path: str) -> str | None: """check if the file should be served (raises 404, 403, etc.)""" if not absolute_path: raise web.HTTPError(404) for root in self.root: if (absolute_path + os.sep).startswith(root): break return super().validate_absolute_path(root, absolute_path) class APIVersionHandler(APIHandler): """An API handler for the server version.""" _track_activity = False @allow_unauthenticated def get(self) -> None: """Get the server version info.""" # not authenticated, so give as few info as possible self.finish(json.dumps({"version": jupyter_server.__version__})) class TrailingSlashHandler(web.RequestHandler): """Simple redirect handler that strips trailing slashes This should be the first, highest priority handler. """ @allow_unauthenticated def get(self) -> None: """Handle trailing slashes in a get.""" assert self.request.uri is not None path, *rest = self.request.uri.partition("?") # trim trailing *and* leading / # to avoid misinterpreting repeated '//' path = "/" + path.strip("/") new_uri = "".join([path, *rest]) self.redirect(new_uri) post = put = get class MainHandler(JupyterHandler): """Simple handler for base_url.""" @allow_unauthenticated def get(self) -> None: """Get the main template.""" html = self.render_template("main.html") self.write(html) post = put = get class FilesRedirectHandler(JupyterHandler): """Handler for redirecting relative URLs to the /files/ handler""" @staticmethod async def redirect_to_files(self: Any, path: str) -> None: """make redirect logic a reusable static method so it can be called from other handlers. """ cm = self.contents_manager if await ensure_async(cm.dir_exists(path)): # it's a *directory*, redirect to /tree url = url_path_join(self.base_url, "tree", url_escape(path)) else: orig_path = path # otherwise, redirect to /files parts = path.split("/") if not await ensure_async(cm.file_exists(path=path)) and "files" in parts: # redirect without files/ iff it would 404 # this preserves pre-2.0-style 'files/' links self.log.warning("Deprecated files/ URL: %s", orig_path) parts.remove("files") path = "/".join(parts) if not await ensure_async(cm.file_exists(path=path)): raise web.HTTPError(404) url = url_path_join(self.base_url, "files", url_escape(path)) self.log.debug("Redirecting %s to %s", self.request.path, url) self.redirect(url) @allow_unauthenticated async def get(self, path: str = "") -> None: return await self.redirect_to_files(self, path) class RedirectWithParams(web.RequestHandler): """Same as web.RedirectHandler, but preserves URL parameters""" def initialize(self, url: str, permanent: bool = True) -> None: """Initialize a redirect handler.""" self._url = url self._permanent = permanent @allow_unauthenticated def get(self) -> None: """Get a redirect.""" sep = "&" if "?" in self._url else "?" url = sep.join([self._url, self.request.query]) self.redirect(url, permanent=self._permanent) class PrometheusMetricsHandler(JupyterHandler): """ Return prometheus metrics for this server """ @allow_unauthenticated def get(self) -> None: """Get prometheus metrics.""" if self.settings["authenticate_prometheus"] and not self.logged_in: raise web.HTTPError(403) self.set_header("Content-Type", prometheus_client.CONTENT_TYPE_LATEST) self.write(prometheus_client.generate_latest(prometheus_client.REGISTRY)) class PublicStaticFileHandler(web.StaticFileHandler): """Same as web.StaticFileHandler, but decorated to acknowledge that auth is not required.""" @allow_unauthenticated def head(self, path: str) -> Awaitable[None]: return super().head(path) @allow_unauthenticated def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]: return super().get(path, include_body) # ----------------------------------------------------------------------------- # URL pattern fragments for reuse # ----------------------------------------------------------------------------- # path matches any number of `/foo[/bar...]` or just `/` or '' path_regex = r"(?P(?:(?:/[^/]+)+|/?))" # ----------------------------------------------------------------------------- # URL to handler mappings # ----------------------------------------------------------------------------- default_handlers = [ (r".*/", TrailingSlashHandler), (r"api", APIVersionHandler), (r"/(robots\.txt|favicon\.ico)", PublicStaticFileHandler), (r"/metrics", PrometheusMetricsHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/base/websocket.py000066400000000000000000000135001473126534200266540ustar00rootroot00000000000000"""Base websocket classes.""" import re import warnings from typing import Optional, no_type_check from urllib.parse import urlparse from tornado import ioloop, web from tornado.iostream import IOStream from jupyter_server.base.handlers import JupyterHandler from jupyter_server.utils import JupyterServerAuthWarning # ping interval for keeping websockets alive (30 seconds) WS_PING_INTERVAL = 30000 class WebSocketMixin: """Mixin for common websocket options""" ping_callback = None last_ping = 0.0 last_pong = 0.0 stream: Optional[IOStream] = None @property def ping_interval(self): """The interval for websocket keep-alive pings. Set ws_ping_interval = 0 to disable pings. """ return self.settings.get("ws_ping_interval", WS_PING_INTERVAL) # type:ignore[attr-defined] @property def ping_timeout(self): """If no ping is received in this many milliseconds, close the websocket connection (VPNs, etc. can fail to cleanly close ws connections). Default is max of 3 pings or 30 seconds. """ return self.settings.get( # type:ignore[attr-defined] "ws_ping_timeout", max(3 * self.ping_interval, WS_PING_INTERVAL) ) @no_type_check def check_origin(self, origin: Optional[str] = None) -> bool: """Check Origin == Host or Access-Control-Allow-Origin. Tornado >= 4 calls this method automatically, raising 403 if it returns False. """ if self.allow_origin == "*" or ( hasattr(self, "skip_check_origin") and self.skip_check_origin() ): return True host = self.request.headers.get("Host") if origin is None: origin = self.get_origin() # If no origin or host header is provided, assume from script if origin is None or host is None: return True origin = origin.lower() origin_host = urlparse(origin).netloc # OK if origin matches host if origin_host == host: return True # Check CORS headers if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: allow = bool(re.match(self.allow_origin_pat, origin)) else: # No CORS headers deny the request allow = False if not allow: self.log.warning( "Blocking Cross Origin WebSocket Attempt. Origin: %s, Host: %s", origin, host, ) return allow def clear_cookie(self, *args, **kwargs): """meaningless for websockets""" @no_type_check def _maybe_auth(self): """Verify authentication if required. Only used when the websocket class does not inherit from JupyterHandler. """ if not self.settings.get("allow_unauthenticated_access", False): if not self.request.method: raise web.HTTPError(403) method = getattr(self, self.request.method.lower()) if not getattr(method, "__allow_unauthenticated", False): # rather than re-using `web.authenticated` which also redirects # to login page on GET, just raise 403 if user is not known user = self.current_user if user is None: self.log.warning("Couldn't authenticate WebSocket connection") raise web.HTTPError(403) @no_type_check def prepare(self, *args, **kwargs): """Handle a get request.""" if not isinstance(self, JupyterHandler): should_authenticate = not self.settings.get("allow_unauthenticated_access", False) if "identity_provider" in self.settings and should_authenticate: warnings.warn( "WebSocketMixin sub-class does not inherit from JupyterHandler" " preventing proper authentication using custom identity provider.", JupyterServerAuthWarning, stacklevel=2, ) self._maybe_auth() return super().prepare(*args, **kwargs) return super().prepare(*args, **kwargs, _redirect_to_login=False) @no_type_check def open(self, *args, **kwargs): """Open the websocket.""" self.log.debug("Opening websocket %s", self.request.path) # start the pinging if self.ping_interval > 0: loop = ioloop.IOLoop.current() self.last_ping = loop.time() # Remember time of last ping self.last_pong = self.last_ping self.ping_callback = ioloop.PeriodicCallback( self.send_ping, self.ping_interval, ) self.ping_callback.start() return super().open(*args, **kwargs) @no_type_check def send_ping(self): """send a ping to keep the websocket alive""" if self.ws_connection is None and self.ping_callback is not None: self.ping_callback.stop() return if self.ws_connection.client_terminated: self.close() return # check for timeout on pong. Make sure that we really have sent a recent ping in # case the machine with both server and client has been suspended since the last ping. now = ioloop.IOLoop.current().time() since_last_pong = 1e3 * (now - self.last_pong) since_last_ping = 1e3 * (now - self.last_ping) if since_last_ping < 2 * self.ping_interval and since_last_pong > self.ping_timeout: self.log.warning("WebSocket ping timeout after %i ms.", since_last_pong) self.close() return self.ping(b"") self.last_ping = now def on_pong(self, data): """Handle a pong message.""" self.last_pong = ioloop.IOLoop.current().time() jupyter-server-jupyter_server-e5c7e2b/jupyter_server/base/zmqhandlers.py000066400000000000000000000010531473126534200272160ustar00rootroot00000000000000"""This module is deprecated in Jupyter Server 2.0""" # Raise a warning that this module is deprecated. import warnings from tornado.websocket import WebSocketHandler from jupyter_server.base.websocket import WebSocketMixin from jupyter_server.services.kernels.connection.base import ( deserialize_binary_message, deserialize_msg_from_ws_v1, serialize_binary_message, serialize_msg_to_ws_v1, ) warnings.warn( "jupyter_server.base.zmqhandlers module is deprecated in Jupyter Server 2.0", DeprecationWarning, stacklevel=2, ) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/config_manager.py000066400000000000000000000116501473126534200267170ustar00rootroot00000000000000"""Manager to read and modify config data in JSON files.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import copy import errno import glob import json import os import typing as t from traitlets.config import LoggingConfigurable from traitlets.traitlets import Bool, Unicode StrDict = dict[str, t.Any] def recursive_update(target: StrDict, new: StrDict) -> None: """Recursively update one dictionary using another. None values will delete their keys. """ for k, v in new.items(): if isinstance(v, dict): if k not in target: target[k] = {} recursive_update(target[k], v) if not target[k]: # Prune empty subdicts del target[k] elif v is None: target.pop(k, None) else: target[k] = v def remove_defaults(data: StrDict, defaults: StrDict) -> None: """Recursively remove items from dict that are already in defaults""" # copy the iterator, since data will be modified for key, value in list(data.items()): if key in defaults: if isinstance(value, dict): remove_defaults(data[key], defaults[key]) if not data[key]: # prune empty subdicts del data[key] elif value == defaults[key]: del data[key] class BaseJSONConfigManager(LoggingConfigurable): """General JSON config manager Deals with persisting/storing config in a json file with optionally default values in a {section_name}.d directory. """ config_dir = Unicode(".") read_directory = Bool(True) def ensure_config_dir_exists(self) -> None: """Will try to create the config_dir directory.""" try: os.makedirs(self.config_dir, 0o755) except OSError as e: if e.errno != errno.EEXIST: raise def file_name(self, section_name: str) -> str: """Returns the json filename for the section_name: {config_dir}/{section_name}.json""" return os.path.join(self.config_dir, section_name + ".json") def directory(self, section_name: str) -> str: """Returns the directory name for the section name: {config_dir}/{section_name}.d""" return os.path.join(self.config_dir, section_name + ".d") def get(self, section_name: str, include_root: bool = True) -> dict[str, t.Any]: """Retrieve the config data for the specified section. Returns the data as a dictionary, or an empty dictionary if the file doesn't exist. When include_root is False, it will not read the root .json file, effectively returning the default values. """ paths = [self.file_name(section_name)] if include_root else [] if self.read_directory: pattern = os.path.join(self.directory(section_name), "*.json") # These json files should be processed first so that the # {section_name}.json take precedence. # The idea behind this is that installing a Python package may # put a json file somewhere in the a .d directory, while the # .json file is probably a user configuration. paths = sorted(glob.glob(pattern)) + paths self.log.debug( "Paths used for configuration of %s: \n\t%s", section_name, "\n\t".join(paths), ) data: dict[str, t.Any] = {} for path in paths: if os.path.isfile(path) and os.path.getsize(path): with open(path, encoding="utf-8") as f: try: recursive_update(data, json.load(f)) except json.decoder.JSONDecodeError: self.log.warning("Invalid JSON in %s, skipping", path) return data def set(self, section_name: str, data: t.Any) -> None: """Store the given config data.""" filename = self.file_name(section_name) self.ensure_config_dir_exists() if self.read_directory: # we will modify data in place, so make a copy data = copy.deepcopy(data) defaults = self.get(section_name, include_root=False) remove_defaults(data, defaults) # Generate the JSON up front, since it could raise an exception, # in order to avoid writing half-finished corrupted data to disk. json_content = json.dumps(data, indent=2) with open(filename, "w", encoding="utf-8") as f: f.write(json_content) def update(self, section_name: str, new_data: t.Any) -> dict[str, t.Any]: """Modify the config section by recursively updating it with new_data. Returns the modified config data as a dictionary. """ data = self.get(section_name) recursive_update(data, new_data) self.set(section_name, data) return data jupyter-server-jupyter_server-e5c7e2b/jupyter_server/event_schemas/000077500000000000000000000000001473126534200262275ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/event_schemas/contents_service/000077500000000000000000000000001473126534200316045ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/event_schemas/contents_service/v1.yaml000066400000000000000000000044071473126534200330230ustar00rootroot00000000000000"$id": https://events.jupyter.org/jupyter_server/contents_service/v1 version: "1" title: Contents Manager activities personal-data: true description: | Record actions on files via the ContentsManager. The notebook ContentsManager REST API is used by all frontends to retrieve, save, list, delete and perform other actions on notebooks, directories, and other files through the UI. This is pluggable - the default acts on the file system, but can be replaced with a different ContentsManager implementation - to work on S3, Postgres, other object stores, etc. The events get recorded regardless of the ContentsManager implementation being used. Limitations: 1. This does not record all filesystem access, just the ones that happen explicitly via the notebook server's REST API. Users can (and often do) trivially access the filesystem in many other ways (such as `open()` calls in their code), so this is usually never a complete record. 2. As with all events recorded by the notebook server, users most likely have the ability to modify the code of the notebook server. Unless other security measures are in place, these events should be treated as user controlled and not used in high security areas. 3. Events are only recorded when an action succeeds. type: object required: - action - path properties: action: enum: - get - create - save - upload - rename - copy - delete description: | Action performed by the ContentsManager API. This is a required field. Possible values: 1. get Get contents of a particular file, or list contents of a directory. 2. save Save a file at path with contents from the client 3. rename Rename a file or directory from value in source_path to value in path. 4. copy Copy a file or directory from value in source_path to value in path. 5. delete Delete a file or empty directory at given path path: type: string description: | Logical path on which the operation was performed. This is a required field. source_path: type: string description: | Source path of an operation when action is 'copy' or 'rename' jupyter-server-jupyter_server-e5c7e2b/jupyter_server/event_schemas/gateway_client/000077500000000000000000000000001473126534200312265ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/event_schemas/gateway_client/v1.yaml000066400000000000000000000017331473126534200324440ustar00rootroot00000000000000"$id": https://events.jupyter.org/jupyter_server/gateway_client/v1 version: "1" title: Gateway Client activities. personal-data: true description: | Record events of a gateway client. type: object required: - status - msg properties: status: enum: - error - success description: | Status received by Gateway client based on the rest api operation to gateway kernel. This is a required field. Possible values: 1. error Error response from a rest api operation to gateway kernel. 2. success Success response from a rest api operation to gateway kernel. status_code: type: number description: | Http response codes from a rest api operation to gateway kernel. Examples: 200, 400, 502, 503, 599 etc. msg: type: string description: | Description of the event being emitted. gateway_url: type: string description: | Gateway url where the remote server exist. jupyter-server-jupyter_server-e5c7e2b/jupyter_server/event_schemas/kernel_actions/000077500000000000000000000000001473126534200312275ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/event_schemas/kernel_actions/v1.yaml000066400000000000000000000033461473126534200324470ustar00rootroot00000000000000"$id": https://events.jupyter.org/jupyter_server/kernel_actions/v1 version: "1" title: Kernel Manager activities personal-data: true description: | Record events of a kernel manager. type: object required: - action - msg properties: action: enum: - start - interrupt - shutdown - restart description: | Action performed by the Kernel Manager. This is a required field. Possible values: 1. start A kernel has been started with the given kernel id. 2. interrupt A kernel has been interrupted for the given kernel id. 3. shutdown A kernel has been shut down for the given kernel id. 4. restart A kernel has been restarted for the given kernel id. kernel_id: type: string description: | Kernel id. This is a required field for all actions and statuses except action start with status error. kernel_name: type: string description: | Name of the kernel. status: enum: - error - success description: | Status received from a rest api operation to kernel server. This is a required field. Possible values: 1. error Error response from a rest api operation to kernel server. 2. success Success response from a rest api operation to kernel server. status_code: type: number description: | Http response codes from a rest api operation to kernel server. Examples: 200, 400, 502, 503, 599 etc msg: type: string description: | Description of the event specified in action. if: not: properties: status: const: error action: const: start then: required: - kernel_id jupyter-server-jupyter_server-e5c7e2b/jupyter_server/extension/000077500000000000000000000000001473126534200254175ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/extension/__init__.py000066400000000000000000000000001473126534200275160ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/extension/application.py000066400000000000000000000543541473126534200303070ustar00rootroot00000000000000"""An extension application.""" from __future__ import annotations import logging import re import sys import typing as t from jinja2 import Environment, FileSystemLoader from jupyter_core.application import JupyterApp, NoStart from tornado.log import LogFormatter from tornado.web import RedirectHandler from traitlets import Any, Bool, Dict, HasTraits, List, Unicode, default from traitlets.config import Config from jupyter_server.serverapp import ServerApp from jupyter_server.transutils import _i18n from jupyter_server.utils import is_namespace_package, url_path_join from .handler import ExtensionHandlerMixin # ----------------------------------------------------------------------------- # Util functions and classes. # ----------------------------------------------------------------------------- def _preparse_for_subcommand(application_klass, argv): """Preparse command line to look for subcommands.""" # Read in arguments from command line. if len(argv) == 0: return None # Find any subcommands. if application_klass.subcommands and len(argv) > 0: # we have subcommands, and one may have been specified subc, subargv = argv[0], argv[1:] if re.match(r"^\w(\-?\w)*$", subc) and subc in application_klass.subcommands: # it's a subcommand, and *not* a flag or class parameter app = application_klass() app.initialize_subcommand(subc, subargv) return app.subapp def _preparse_for_stopping_flags(application_klass, argv): """Looks for 'help', 'version', and 'generate-config; commands in command line. If found, raises the help and version of current Application. This is useful for traitlets applications that have to parse the command line multiple times, but want to control when when 'help' and 'version' is raised. """ # Arguments after a '--' argument are for the script IPython may be # about to run, not IPython iteslf. For arguments parsed here (help and # version), we want to only search the arguments up to the first # occurrence of '--', which we're calling interpreted_argv. try: interpreted_argv = argv[: argv.index("--")] except ValueError: interpreted_argv = argv # Catch any help calls. if any(x in interpreted_argv for x in ("-h", "--help-all", "--help")): app = application_klass() app.print_help("--help-all" in interpreted_argv) app.exit(0) # Catch version commands if "--version" in interpreted_argv or "-V" in interpreted_argv: app = application_klass() app.print_version() app.exit(0) # Catch generate-config commands. if "--generate-config" in interpreted_argv: app = application_klass() app.write_default_config() app.exit(0) class ExtensionAppJinjaMixin(HasTraits): """Use Jinja templates for HTML templates on top of an ExtensionApp.""" jinja2_options = Dict( help=_i18n( """Options to pass to the jinja2 environment for this """ ) ).tag(config=True) @t.no_type_check def _prepare_templates(self): """Get templates defined in a subclass.""" self.initialize_templates() # Add templates to web app settings if extension has templates. if len(self.template_paths) > 0: self.settings.update({f"{self.name}_template_paths": self.template_paths}) # Create a jinja environment for logging html templates. self.jinja2_env = Environment( loader=FileSystemLoader(self.template_paths), extensions=["jinja2.ext.i18n"], autoescape=True, **self.jinja2_options, ) # Add the jinja2 environment for this extension to the tornado settings. self.settings.update({f"{self.name}_jinja2_env": self.jinja2_env}) # ----------------------------------------------------------------------------- # ExtensionApp # ----------------------------------------------------------------------------- class JupyterServerExtensionException(Exception): """Exception class for raising for Server extensions errors.""" # ----------------------------------------------------------------------------- # ExtensionApp # ----------------------------------------------------------------------------- class ExtensionApp(JupyterApp): """Base class for configurable Jupyter Server Extension Applications. ExtensionApp subclasses can be initialized two ways: - Extension is listed as a jpserver_extension, and ServerApp calls its load_jupyter_server_extension classmethod. This is the classic way of loading a server extension. - Extension is launched directly by calling its `launch_instance` class method. This method can be set as a entry_point in the extensions setup.py. """ # Subclasses should override this trait. Tells the server if # this extension allows other other extensions to be loaded # side-by-side when launched directly. load_other_extensions = True # A useful class property that subclasses can override to # configure the underlying Jupyter Server when this extension # is launched directly (using its `launch_instance` method). serverapp_config: dict[str, t.Any] = {} # Some subclasses will likely override this trait to flip # the default value to False if they don't offer a browser # based frontend. open_browser = Bool( help="""Whether to open in a browser after starting. The specific browser used is platform dependent and determined by the python standard library `webbrowser` module, unless it is overridden using the --browser (ServerApp.browser) configuration option. """ ).tag(config=True) @default("open_browser") def _default_open_browser(self): assert self.serverapp is not None return self.serverapp.config["ServerApp"].get("open_browser", True) @property def config_file_paths(self): """Look on the same path as our parent for config files""" # rely on parent serverapp, which should control all config loading assert self.serverapp is not None return self.serverapp.config_file_paths # The extension name used to name the jupyter config # file, jupyter_{name}_config. # This should also match the jupyter subcommand used to launch # this extension from the CLI, e.g. `jupyter {name}`. name: str | Unicode[str, str] = "ExtensionApp" # type:ignore[assignment] @classmethod def get_extension_package(cls): """Get an extension package.""" parts = cls.__module__.split(".") if is_namespace_package(parts[0]): # in this case the package name is `.`. return ".".join(parts[0:2]) return parts[0] @classmethod def get_extension_point(cls): """Get an extension point.""" return cls.__module__ # Extension URL sets the default landing page for this extension. extension_url = "/" default_url = Unicode().tag(config=True) @default("default_url") def _default_url(self): return self.extension_url file_url_prefix = Unicode("notebooks") # Is this linked to a serverapp yet? _linked = Bool(False) # Extension can configure the ServerApp from the command-line classes = [ ServerApp, ] # A ServerApp is not defined yet, but will be initialized below. serverapp: ServerApp | None = Any() # type:ignore[assignment] @default("serverapp") def _default_serverapp(self): # load the current global instance, if any if ServerApp.initialized(): try: return ServerApp.instance() except Exception: # error retrieving instance, e.g. MultipleInstanceError pass # serverapp accessed before it was defined, # declare an empty one return ServerApp() _log_formatter_cls = LogFormatter # type:ignore[assignment] @default("log_level") def _default_log_level(self): return logging.INFO @default("log_format") def _default_log_format(self): """override default log format to include date & time""" return ( "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s" ) static_url_prefix = Unicode( help="""Url where the static assets for the extension are served.""" ).tag(config=True) @default("static_url_prefix") def _default_static_url_prefix(self): static_url = f"static/{self.name}/" assert self.serverapp is not None return url_path_join(self.serverapp.base_url, static_url) static_paths = List( Unicode(), help="""paths to search for serving static files. This allows adding javascript/css to be available from the notebook server machine, or overriding individual files in the IPython """, ).tag(config=True) template_paths = List( Unicode(), help=_i18n( """Paths to search for serving jinja templates. Can be used to override templates from notebook.templates.""" ), ).tag(config=True) settings = Dict(help=_i18n("""Settings that will passed to the server.""")).tag(config=True) handlers: List[tuple[t.Any, ...]] = List( help=_i18n("""Handlers appended to the server.""") ).tag(config=True) def _config_file_name_default(self): """The default config file name.""" if not self.name: return "" return "jupyter_{}_config".format(self.name.replace("-", "_")) def initialize_settings(self): """Override this method to add handling of settings.""" def initialize_handlers(self): """Override this method to append handlers to a Jupyter Server.""" def initialize_templates(self): """Override this method to add handling of template files.""" def _prepare_config(self): """Builds a Config object from the extension's traits and passes the object to the webapp's settings as `_config`. """ traits = self.class_own_traits().keys() self.extension_config = Config({t: getattr(self, t) for t in traits}) self.settings[f"{self.name}_config"] = self.extension_config def _prepare_settings(self): """Prepare the settings.""" # Make webapp settings accessible to initialize_settings method assert self.serverapp is not None webapp = self.serverapp.web_app self.settings.update(**webapp.settings) # Add static and template paths to settings. self.settings.update( { f"{self.name}_static_paths": self.static_paths, f"{self.name}": self, } ) # Get setting defined by subclass using initialize_settings method. self.initialize_settings() # Update server settings with extension settings. webapp.settings.update(**self.settings) def _prepare_handlers(self): """Prepare the handlers.""" assert self.serverapp is not None webapp = self.serverapp.web_app # Get handlers defined by extension subclass. self.initialize_handlers() # prepend base_url onto the patterns that we match new_handlers = [] for handler_items in self.handlers: # Build url pattern including base_url pattern = url_path_join(webapp.settings["base_url"], handler_items[0]) handler = handler_items[1] # Get handler kwargs, if given kwargs: dict[str, t.Any] = {} if issubclass(handler, ExtensionHandlerMixin): kwargs["name"] = self.name try: kwargs.update(handler_items[2]) except IndexError: pass new_handler = (pattern, handler, kwargs) new_handlers.append(new_handler) # Add static endpoint for this extension, if static paths are given. if len(self.static_paths) > 0: # Append the extension's static directory to server handlers. static_url = url_path_join(self.static_url_prefix, "(.*)") # Construct handler. handler = ( static_url, webapp.settings["static_handler_class"], {"path": self.static_paths}, ) new_handlers.append(handler) webapp.add_handlers(".*$", new_handlers) def _prepare_templates(self): """Add templates to web app settings if extension has templates.""" if len(self.template_paths) > 0: self.settings.update({f"{self.name}_template_paths": self.template_paths}) self.initialize_templates() def _jupyter_server_config(self): """The jupyter server config.""" base_config = { "ServerApp": { "default_url": self.default_url, "open_browser": self.open_browser, "file_url_prefix": self.file_url_prefix, } } base_config["ServerApp"].update(self.serverapp_config) return base_config def _link_jupyter_server_extension(self, serverapp: ServerApp) -> None: """Link the ExtensionApp to an initialized ServerApp. The ServerApp is stored as an attribute and config is exchanged between ServerApp and `self` in case the command line contains traits for the ExtensionApp or the ExtensionApp's config files have server settings. Note, the ServerApp has not initialized the Tornado Web Application yet, so do not try to affect the `web_app` attribute. """ self.serverapp = serverapp # Load config from an ExtensionApp's config files. self.load_config_file() # ServerApp's config might have picked up # config for the ExtensionApp. We call # update_config to update ExtensionApp's # traits with these values found in ServerApp's # config. # ServerApp config ---> ExtensionApp traits self.update_config(self.serverapp.config) # Use ExtensionApp's CLI parser to find any extra # args that passed through ServerApp and # now belong to ExtensionApp. self.parse_command_line(self.serverapp.extra_args) # If any config should be passed upstream to the # ServerApp, do it here. # i.e. ServerApp traits <--- ExtensionApp config self.serverapp.update_config(self.config) # Acknowledge that this extension has been linked. self._linked = True def initialize(self): """Initialize the extension app. The corresponding server app and webapp should already be initialized by this step. - Appends Handlers to the ServerApp, - Passes config and settings from ExtensionApp to the Tornado web application - Points Tornado Webapp to templates and static assets. """ if not self.serverapp: msg = ( "This extension has no attribute `serverapp`. " "Try calling `.link_to_serverapp()` before calling " "`.initialize()`." ) raise JupyterServerExtensionException(msg) self._prepare_config() self._prepare_templates() self._prepare_settings() self._prepare_handlers() def start(self): """Start the underlying Jupyter server. Server should be started after extension is initialized. """ super().start() # Start the server. assert self.serverapp is not None self.serverapp.start() def current_activity(self): """Return a list of activity happening in this extension.""" return async def stop_extension(self): """Cleanup any resources managed by this extension.""" def stop(self): """Stop the underlying Jupyter server.""" assert self.serverapp is not None self.serverapp.stop() self.serverapp.clear_instance() @classmethod def _load_jupyter_server_extension(cls, serverapp): """Initialize and configure this extension, then add the extension's settings and handlers to the server's web application. """ extension_manager = serverapp.extension_manager try: # Get loaded extension from serverapp. point = extension_manager.extension_points[cls.name] extension = point.app except KeyError: extension = cls() extension._link_jupyter_server_extension(serverapp) extension.initialize() return extension @classmethod def load_classic_server_extension(cls, serverapp): """Enables extension to be loaded as classic Notebook (jupyter/notebook) extension.""" extension = cls() extension.serverapp = serverapp extension.load_config_file() extension.update_config(serverapp.config) extension.parse_command_line(serverapp.extra_args) # Add redirects to get favicons from old locations in the classic notebook server extension.handlers.extend( [ ( r"/static/favicons/favicon.ico", RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/favicon.ico")}, ), ( r"/static/favicons/favicon-busy-1.ico", RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-busy-1.ico" ) }, ), ( r"/static/favicons/favicon-busy-2.ico", RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-busy-2.ico" ) }, ), ( r"/static/favicons/favicon-busy-3.ico", RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-busy-3.ico" ) }, ), ( r"/static/favicons/favicon-file.ico", RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-file.ico" ) }, ), ( r"/static/favicons/favicon-notebook.ico", RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-notebook.ico", ) }, ), ( r"/static/favicons/favicon-terminal.ico", RedirectHandler, { "url": url_path_join( serverapp.base_url, "static/base/images/favicon-terminal.ico", ) }, ), ( r"/static/logo/logo.png", RedirectHandler, {"url": url_path_join(serverapp.base_url, "static/base/images/logo.png")}, ), ] ) extension.initialize() serverapp_class = ServerApp @classmethod def make_serverapp(cls, **kwargs: t.Any) -> ServerApp: """Instantiate the ServerApp Override to customize the ServerApp before it loads any configuration """ return cls.serverapp_class.instance(**kwargs) @classmethod def initialize_server(cls, argv=None, load_other_extensions=True, **kwargs): """Creates an instance of ServerApp and explicitly sets this extension to enabled=True (i.e. superseding disabling found in other config from files). The `launch_instance` method uses this method to initialize and start a server. """ jpserver_extensions = {cls.get_extension_package(): True} find_extensions = cls.load_other_extensions if "jpserver_extensions" in cls.serverapp_config: jpserver_extensions.update(cls.serverapp_config["jpserver_extensions"]) cls.serverapp_config["jpserver_extensions"] = jpserver_extensions find_extensions = False serverapp = cls.make_serverapp(jpserver_extensions=jpserver_extensions, **kwargs) serverapp.aliases.update(cls.aliases) # type:ignore[has-type] serverapp.initialize( argv=argv or [], starter_extension=cls.name, find_extensions=find_extensions, ) return serverapp @classmethod def launch_instance(cls, argv=None, **kwargs): """Launch the extension like an application. Initializes+configs a stock server and appends the extension to the server. Then starts the server and routes to extension's landing page. """ # Handle arguments. if argv is None: # noqa: SIM108 args = sys.argv[1:] # slice out extension config. else: args = argv # Handle all "stops" that could happen before # continuing to launch a server+extension. subapp = _preparse_for_subcommand(cls, args) if subapp: subapp.start() return # Check for help, version, and generate-config arguments # before initializing server to make sure these # arguments trigger actions from the extension not the server. _preparse_for_stopping_flags(cls, args) serverapp = cls.initialize_server(argv=args) # Log if extension is blocking other extensions from loading. if not cls.load_other_extensions: serverapp.log.info(f"{cls.name} is running without loading other extensions.") # Start the server. try: serverapp.start() except NoStart: pass jupyter-server-jupyter_server-e5c7e2b/jupyter_server/extension/config.py000066400000000000000000000024101473126534200272330ustar00rootroot00000000000000"""Extension config.""" from jupyter_server.services.config.manager import ConfigManager DEFAULT_SECTION_NAME = "jupyter_server_config" class ExtensionConfigManager(ConfigManager): """A manager class to interface with Jupyter Server Extension config found in a `config.d` folder. It is assumed that all configuration files in this directory are JSON files. """ def get_jpserver_extensions(self, section_name=DEFAULT_SECTION_NAME): """Return the jpserver_extensions field from all config files found.""" data = self.get(section_name) return data.get("ServerApp", {}).get("jpserver_extensions", {}) def enabled(self, name, section_name=DEFAULT_SECTION_NAME, include_root=True): """Is the extension enabled?""" extensions = self.get_jpserver_extensions(section_name) try: return extensions[name] except KeyError: return False def enable(self, name): """Enable an extension by name.""" data = {"ServerApp": {"jpserver_extensions": {name: True}}} self.update(name, data) def disable(self, name): """Disable an extension by name.""" data = {"ServerApp": {"jpserver_extensions": {name: False}}} self.update(name, data) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/extension/handler.py000066400000000000000000000131621473126534200274110ustar00rootroot00000000000000"""An extension handler.""" from __future__ import annotations from logging import Logger from typing import TYPE_CHECKING, Any, cast from jinja2 import Template from jinja2.exceptions import TemplateNotFound from jupyter_server.base.handlers import FileFindHandler if TYPE_CHECKING: from traitlets.config import Config from jupyter_server.extension.application import ExtensionApp from jupyter_server.serverapp import ServerApp class ExtensionHandlerJinjaMixin: """Mixin class for ExtensionApp handlers that use jinja templating for template rendering. """ def get_template(self, name: str) -> Template: """Return the jinja template object for a given name""" try: env = f"{self.name}_jinja2_env" # type:ignore[attr-defined] template = cast(Template, self.settings[env].get_template(name)) # type:ignore[attr-defined] return template except TemplateNotFound: return cast(Template, super().get_template(name)) # type:ignore[misc] class ExtensionHandlerMixin: """Base class for Jupyter server extension handlers. Subclasses can serve static files behind a namespaced endpoint: "/static//" This allows multiple extensions to serve static files under their own namespace and avoid intercepting requests for other extensions. """ settings: dict[str, Any] def initialize(self, name: str, *args: Any, **kwargs: Any) -> None: self.name = name try: super().initialize(*args, **kwargs) # type:ignore[misc] except TypeError: pass @property def extensionapp(self) -> ExtensionApp: return cast("ExtensionApp", self.settings[self.name]) @property def serverapp(self) -> ServerApp: key = "serverapp" return cast("ServerApp", self.settings[key]) @property def log(self) -> Logger: if not hasattr(self, "name"): return cast(Logger, super().log) # type:ignore[misc] # Attempt to pull the ExtensionApp's log, otherwise fall back to ServerApp. try: return cast(Logger, self.extensionapp.log) except AttributeError: return cast(Logger, self.serverapp.log) @property def config(self) -> Config: return cast("Config", self.settings[f"{self.name}_config"]) @property def server_config(self) -> Config: return cast("Config", self.settings["config"]) @property def base_url(self) -> str: return cast(str, self.settings.get("base_url", "/")) def render_template(self, name: str, **ns) -> str: """Override render template to handle static_paths If render_template is called with a template from the base environment (e.g. default error pages) make sure our extension-specific static_url is _not_ used. """ template = cast(Template, self.get_template(name)) # type:ignore[attr-defined] ns.update(self.template_namespace) # type:ignore[attr-defined] if template.environment is self.settings["jinja2_env"]: # default template environment, use default static_url ns["static_url"] = super().static_url # type:ignore[misc] return cast(str, template.render(**ns)) @property def static_url_prefix(self) -> str: return self.extensionapp.static_url_prefix @property def static_path(self) -> str: return cast(str, self.settings[f"{self.name}_static_paths"]) def static_url(self, path: str, include_host: bool | None = None, **kwargs: Any) -> str: """Returns a static URL for the given relative static file path. This method requires you set the ``{name}_static_path`` setting in your extension (which specifies the root directory of your static files). This method returns a versioned url (by default appending ``?v=``), which allows the static files to be cached indefinitely. This can be disabled by passing ``include_version=False`` (in the default implementation; other static file implementations are not required to support this, but they may support other options). By default this method returns URLs relative to the current host, but if ``include_host`` is true the URL returned will be absolute. If this handler has an ``include_host`` attribute, that value will be used as the default for all `static_url` calls that do not pass ``include_host`` as a keyword argument. """ key = f"{self.name}_static_paths" try: self.require_setting(key, "static_url") # type:ignore[attr-defined] except Exception as e: if key in self.settings: msg = ( "This extension doesn't have any static paths listed. Check that the " "extension's `static_paths` trait is set." ) raise Exception(msg) from None else: raise e get_url = self.settings.get("static_handler_class", FileFindHandler).make_static_url if include_host is None: include_host = getattr(self, "include_host", False) base = "" if include_host: base = self.request.protocol + "://" + self.request.host # type:ignore[attr-defined] # Hijack settings dict to send extension templates to extension # static directory. settings = { "static_path": self.static_path, "static_url_prefix": self.static_url_prefix, } return base + cast(str, get_url(settings, path, **kwargs)) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/extension/manager.py000066400000000000000000000333741473126534200274150ustar00rootroot00000000000000"""The extension manager.""" from __future__ import annotations import importlib from itertools import starmap from tornado.gen import multi from traitlets import Any, Bool, Dict, HasTraits, Instance, List, Unicode, default, observe from traitlets import validate as validate_trait from traitlets.config import LoggingConfigurable from .config import ExtensionConfigManager from .utils import ExtensionMetadataError, ExtensionModuleNotFound, get_loader, get_metadata class ExtensionPoint(HasTraits): """A simple API for connecting to a Jupyter Server extension point defined by metadata and importable from a Python package. """ _linked = Bool(False) _app = Any(None, allow_none=True) metadata = Dict() @validate_trait("metadata") def _valid_metadata(self, proposed): """Validate metadata.""" metadata = proposed["value"] # Verify that the metadata has a "name" key. try: self._module_name = metadata["module"] except KeyError: msg = "There is no 'module' key in the extension's metadata packet." raise ExtensionMetadataError(msg) from None try: self._module = importlib.import_module(self._module_name) except ImportError: msg = ( f"The submodule '{self._module_name}' could not be found. Are you " "sure the extension is installed?" ) raise ExtensionModuleNotFound(msg) from None # If the metadata includes an ExtensionApp, create an instance. if "app" in metadata: self._app = metadata["app"]() return metadata @property def linked(self): """Has this extension point been linked to the server. Will pull from ExtensionApp's trait, if this point is an instance of ExtensionApp. """ if self.app: return self.app._linked return self._linked @property def app(self): """If the metadata includes an `app` field""" return self._app @property def config(self): """Return any configuration provided by this extension point.""" if self.app: return self.app._jupyter_server_config() # At some point, we might want to add logic to load config from # disk when extensions don't use ExtensionApp. else: return {} @property def module_name(self): """Name of the Python package module where the extension's _load_jupyter_server_extension can be found. """ return self._module_name @property def name(self): """Name of the extension. If it's not provided in the metadata, `name` is set to the extensions' module name. """ if self.app: return self.app.name return self.metadata.get("name", self.module_name) @property def module(self): """The imported module (using importlib.import_module)""" return self._module def _get_linker(self): """Get a linker.""" if self.app: linker = self.app._link_jupyter_server_extension else: linker = getattr( self.module, # Search for a _link_jupyter_extension "_link_jupyter_server_extension", # Otherwise return a dummy function. lambda serverapp: None, ) return linker def _get_loader(self): """Get a loader.""" loc = self.app if not loc: loc = self.module loader = get_loader(loc) return loader def validate(self): """Check that both a linker and loader exists.""" try: self._get_linker() self._get_loader() except Exception: return False else: return True def link(self, serverapp): """Link the extension to a Jupyter ServerApp object. This looks for a `_link_jupyter_server_extension` function in the extension's module or ExtensionApp class. """ if not self.linked: linker = self._get_linker() linker(serverapp) # Store this extension as already linked. self._linked = True def load(self, serverapp): """Load the extension in a Jupyter ServerApp object. This looks for a `_load_jupyter_server_extension` function in the extension's module or ExtensionApp class. """ loader = self._get_loader() return loader(serverapp) class ExtensionPackage(LoggingConfigurable): """An API for interfacing with a Jupyter Server extension package. Usage: ext_name = "my_extensions" extpkg = ExtensionPackage(name=ext_name) """ name = Unicode(help="Name of the an importable Python package.") enabled = Bool(False, help="Whether the extension package is enabled.") _linked_points = Dict() extension_points = Dict() module = Any(allow_none=True, help="The module for this extension package. None if not enabled") metadata = List(Dict(), help="Extension metadata loaded from the extension package.") version = Unicode( help=""" The version of this extension package, if it can be found. Otherwise, an empty string. """, ) @default("version") def _load_version(self): if not self.enabled: return "" return getattr(self.module, "__version__", "") def __init__(self, **kwargs): """Initialize an extension package.""" super().__init__(**kwargs) if self.enabled: self._load_metadata() def _load_metadata(self): """Import package and load metadata Only used if extension package is enabled """ name = self.name try: self.module, self.metadata = get_metadata(name, logger=self.log) except ImportError as e: msg = ( f"The module '{name}' could not be found ({e}). Are you " "sure the extension is installed?" ) raise ExtensionModuleNotFound(msg) from None # Create extension point interfaces for each extension path. for m in self.metadata: point = ExtensionPoint(metadata=m) self.extension_points[point.name] = point return name def validate(self): """Validate all extension points in this package.""" return all(extension.validate() for extension in self.extension_points.values()) def link_point(self, point_name, serverapp): """Link an extension point.""" linked = self._linked_points.get(point_name, False) if not linked: point = self.extension_points[point_name] point.link(serverapp) def load_point(self, point_name, serverapp): """Load an extension point.""" point = self.extension_points[point_name] return point.load(serverapp) def link_all_points(self, serverapp): """Link all extension points.""" for point_name in self.extension_points: self.link_point(point_name, serverapp) def load_all_points(self, serverapp): """Load all extension points.""" return [self.load_point(point_name, serverapp) for point_name in self.extension_points] class ExtensionManager(LoggingConfigurable): """High level interface for findind, validating, linking, loading, and managing Jupyter Server extensions. Usage: m = ExtensionManager(config_manager=...) """ config_manager = Instance(ExtensionConfigManager, allow_none=True) serverapp = Any() # Use Any to avoid circular import of Instance(ServerApp) @default("config_manager") def _load_default_config_manager(self): config_manager = ExtensionConfigManager() self._load_config_manager(config_manager) return config_manager @observe("config_manager") def _config_manager_changed(self, change): if change.new: self._load_config_manager(change.new) # The `extensions` attribute provides a dictionary # with extension (package) names mapped to their ExtensionPackage interface # (see above). This manager simplifies the interaction between the # ServerApp and the extensions being appended. extensions = Dict( help=""" Dictionary with extension package names as keys and ExtensionPackage objects as values. """ ) @property def sorted_extensions(self): """Returns an extensions dictionary, sorted alphabetically.""" return dict(sorted(self.extensions.items())) # The `_linked_extensions` attribute tracks when each extension # has been successfully linked to a ServerApp. This helps prevent # extensions from being re-linked recursively unintentionally if another # extension attempts to link extensions again. linked_extensions = Dict( help=""" Dictionary with extension names as keys values are True if the extension is linked, False if not. """ ) @property def extension_apps(self): """Return mapping of extension names and sets of ExtensionApp objects.""" return { name: {point.app for point in extension.extension_points.values() if point.app} for name, extension in self.extensions.items() } @property def extension_points(self): """Return mapping of extension point names and ExtensionPoint objects.""" return { name: point for value in self.extensions.values() for name, point in value.extension_points.items() } def from_config_manager(self, config_manager): """Add extensions found by an ExtensionConfigManager""" # load triggered via config_manager trait observer self.config_manager = config_manager def _load_config_manager(self, config_manager): """Actually load our config manager""" jpserver_extensions = config_manager.get_jpserver_extensions() self.from_jpserver_extensions(jpserver_extensions) def from_jpserver_extensions(self, jpserver_extensions): """Add extensions from 'jpserver_extensions'-like dictionary.""" for name, enabled in jpserver_extensions.items(): self.add_extension(name, enabled=enabled) def add_extension(self, extension_name, enabled=False): """Try to add extension to manager, return True if successful. Otherwise, return False. """ try: extpkg = ExtensionPackage(name=extension_name, enabled=enabled) self.extensions[extension_name] = extpkg return True # Raise a warning if the extension cannot be loaded. except Exception as e: if self.serverapp and self.serverapp.reraise_server_extension_failures: raise self.log.warning( "%s | error adding extension (enabled: %s): %s", extension_name, enabled, e, exc_info=True, ) return False def link_extension(self, name): """Link an extension by name.""" linked = self.linked_extensions.get(name, False) extension = self.extensions[name] if not linked and extension.enabled: try: # Link extension and store links extension.link_all_points(self.serverapp) self.linked_extensions[name] = True self.log.info("%s | extension was successfully linked.", name) except Exception as e: if self.serverapp and self.serverapp.reraise_server_extension_failures: raise self.log.warning("%s | error linking extension: %s", name, e, exc_info=True) def load_extension(self, name): """Load an extension by name.""" extension = self.extensions.get(name) if extension and extension.enabled: try: extension.load_all_points(self.serverapp) except Exception as e: if self.serverapp and self.serverapp.reraise_server_extension_failures: raise self.log.warning( "%s | extension failed loading with message: %r", name, e, exc_info=True ) else: self.log.info("%s | extension was successfully loaded.", name) async def stop_extension(self, name, apps): """Call the shutdown hooks in the specified apps.""" for app in apps: self.log.debug("%s | extension app %r stopping", name, app.name) await app.stop_extension() self.log.debug("%s | extension app %r stopped", name, app.name) def link_all_extensions(self): """Link all enabled extensions to an instance of ServerApp """ # Sort the extension names to enforce deterministic linking # order. for name in self.sorted_extensions: self.link_extension(name) def load_all_extensions(self): """Load all enabled extensions and append them to the parent ServerApp. """ # Sort the extension names to enforce deterministic loading # order. for name in self.sorted_extensions: self.load_extension(name) async def stop_all_extensions(self): """Call the shutdown hooks in all extensions.""" await multi(list(starmap(self.stop_extension, sorted(dict(self.extension_apps).items())))) def any_activity(self): """Check for any activity currently happening across all extension applications.""" for _, apps in sorted(dict(self.extension_apps).items()): for app in apps: if app.current_activity(): return True jupyter-server-jupyter_server-e5c7e2b/jupyter_server/extension/serverextension.py000066400000000000000000000315601473126534200312410ustar00rootroot00000000000000"""Utilities for installing extensions""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import logging import os import sys import typing as t from jupyter_core.application import JupyterApp from jupyter_core.paths import ENV_CONFIG_PATH, SYSTEM_CONFIG_PATH, jupyter_config_dir from tornado.log import LogFormatter from traitlets import Bool from jupyter_server._version import __version__ from jupyter_server.extension.config import ExtensionConfigManager from jupyter_server.extension.manager import ExtensionManager, ExtensionPackage def _get_config_dir(user: bool = False, sys_prefix: bool = False) -> str: """Get the location of config files for the current context Returns the string to the environment Parameters ---------- user : bool [default: False] Get the user's .jupyter config directory sys_prefix : bool [default: False] Get sys.prefix, i.e. ~/.envs/my-env/etc/jupyter """ if user and sys_prefix: sys_prefix = False if user: extdir = jupyter_config_dir() elif sys_prefix: extdir = ENV_CONFIG_PATH[0] else: extdir = SYSTEM_CONFIG_PATH[0] return extdir def _get_extmanager_for_context( write_dir: str = "jupyter_server_config.d", user: bool = False, sys_prefix: bool = False ) -> tuple[str, ExtensionManager]: """Get an extension manager pointing at the current context Returns the path to the current context and an ExtensionManager object. Parameters ---------- write_dir : str [default: 'jupyter_server_config.d'] Name of config directory to write extension config. user : bool [default: False] Get the user's .jupyter config directory sys_prefix : bool [default: False] Get sys.prefix, i.e. ~/.envs/my-env/etc/jupyter """ config_dir = _get_config_dir(user=user, sys_prefix=sys_prefix) config_manager = ExtensionConfigManager( read_config_path=[config_dir], write_config_dir=os.path.join(config_dir, write_dir), ) extension_manager = ExtensionManager( config_manager=config_manager, ) return config_dir, extension_manager class ArgumentConflict(ValueError): pass _base_flags: dict[str, t.Any] = {} _base_flags.update(JupyterApp.flags) _base_flags.pop("y", None) _base_flags.pop("generate-config", None) _base_flags.update( { "user": ( { "BaseExtensionApp": { "user": True, } }, "Apply the operation only for the given user", ), "system": ( { "BaseExtensionApp": { "user": False, "sys_prefix": False, } }, "Apply the operation system-wide", ), "sys-prefix": ( { "BaseExtensionApp": { "sys_prefix": True, } }, "Use sys.prefix as the prefix for installing extensions (for environments, packaging)", ), "py": ( { "BaseExtensionApp": { "python": True, } }, "Install from a Python package", ), } ) _base_flags["python"] = _base_flags["py"] _base_aliases: dict[str, t.Any] = {} _base_aliases.update(JupyterApp.aliases) class BaseExtensionApp(JupyterApp): """Base extension installer app""" _log_formatter_cls = LogFormatter # type:ignore[assignment] flags = _base_flags aliases = _base_aliases version = __version__ user = Bool(False, config=True, help="Whether to do a user install") sys_prefix = Bool(True, config=True, help="Use the sys.prefix as the prefix") python = Bool(False, config=True, help="Install from a Python package") def _log_format_default(self) -> str: """A default format for messages""" return "%(message)s" @property def config_dir(self) -> str: # type:ignore[override] return _get_config_dir(user=self.user, sys_prefix=self.sys_prefix) # Constants for pretty print extension listing function. # Window doesn't support coloring in the commandline GREEN_ENABLED = "\033[32menabled\033[0m" if os.name != "nt" else "enabled" RED_DISABLED = "\033[31mdisabled\033[0m" if os.name != "nt" else "disabled" GREEN_OK = "\033[32mOK\033[0m" if os.name != "nt" else "ok" RED_X = "\033[31m X\033[0m" if os.name != "nt" else " X" # ------------------------------------------------------------------------------ # Public API # ------------------------------------------------------------------------------ def toggle_server_extension_python( import_name: str, enabled: bool | None = None, parent: t.Any = None, user: bool = False, sys_prefix: bool = True, ) -> None: """Toggle the boolean setting for a given server extension in a Jupyter config file. """ sys_prefix = False if user else sys_prefix config_dir = _get_config_dir(user=user, sys_prefix=sys_prefix) manager = ExtensionConfigManager( read_config_path=[config_dir], write_config_dir=os.path.join(config_dir, "jupyter_server_config.d"), ) if enabled: manager.enable(import_name) else: manager.disable(import_name) # ---------------------------------------------------------------------- # Applications # ---------------------------------------------------------------------- flags = {} flags.update(BaseExtensionApp.flags) flags.pop("y", None) flags.pop("generate-config", None) flags.update( { "user": ( { "ToggleServerExtensionApp": { "user": True, } }, "Perform the operation for the current user", ), "system": ( { "ToggleServerExtensionApp": { "user": False, "sys_prefix": False, } }, "Perform the operation system-wide", ), "sys-prefix": ( { "ToggleServerExtensionApp": { "sys_prefix": True, } }, "Use sys.prefix as the prefix for installing server extensions", ), "py": ( { "ToggleServerExtensionApp": { "python": True, } }, "Install from a Python package", ), } ) flags["python"] = flags["py"] _desc = "Enable/disable a server extension using frontend configuration files." class ToggleServerExtensionApp(BaseExtensionApp): """A base class for enabling/disabling extensions""" name = "jupyter server extension enable/disable" description = _desc flags = flags _toggle_value = Bool() _toggle_pre_message = "" _toggle_post_message = "" def toggle_server_extension(self, import_name: str) -> None: """Change the status of a named server extension. Uses the value of `self._toggle_value`. Parameters --------- import_name : str Importable Python module (dotted-notation) exposing the magic-named `load_jupyter_server_extension` function """ # Create an extension manager for this instance. config_dir, extension_manager = _get_extmanager_for_context( user=self.user, sys_prefix=self.sys_prefix ) try: self.log.info(f"{self._toggle_pre_message.capitalize()}: {import_name}") self.log.info(f"- Writing config: {config_dir}") # Validate the server extension. self.log.info(f" - Validating {import_name}...") # Interface with the Extension Package and validate. extpkg = ExtensionPackage(name=import_name) extpkg.validate() version = extpkg.version self.log.info(f" {import_name} {version} {GREEN_OK}") # Toggle extension config. config = extension_manager.config_manager if config: if self._toggle_value is True: config.enable(import_name) else: config.disable(import_name) # If successful, let's log. self.log.info(f" - Extension successfully {self._toggle_post_message}.") except Exception as err: self.log.error(f" {RED_X} Validation failed: {err}") def start(self) -> None: """Perform the App's actions as configured""" if not self.extra_args: sys.exit("Please specify a server extension/package to enable or disable") for arg in self.extra_args: self.toggle_server_extension(arg) class EnableServerExtensionApp(ToggleServerExtensionApp): """An App that enables (and validates) Server Extensions""" name = "jupyter server extension enable" description = """ Enable a server extension in configuration. Usage jupyter server extension enable [--system|--sys-prefix] """ _toggle_value = True # type:ignore[assignment] _toggle_pre_message = "enabling" _toggle_post_message = "enabled" class DisableServerExtensionApp(ToggleServerExtensionApp): """An App that disables Server Extensions""" name = "jupyter server extension disable" description = """ Disable a server extension in configuration. Usage jupyter server extension disable [--system|--sys-prefix] """ _toggle_value = False # type:ignore[assignment] _toggle_pre_message = "disabling" _toggle_post_message = "disabled" class ListServerExtensionsApp(BaseExtensionApp): """An App that lists (and validates) Server Extensions""" name = "jupyter server extension list" version = __version__ description = "List all server extensions known by the configuration system" def list_server_extensions(self) -> None: """List all enabled and disabled server extensions, by config path Enabled extensions are validated, potentially generating warnings. """ configurations = ( {"user": True, "sys_prefix": False}, {"user": False, "sys_prefix": True}, {"user": False, "sys_prefix": False}, ) for option in configurations: config_dir = _get_config_dir(**option) print(f"Config dir: {config_dir}") write_dir = "jupyter_server_config.d" config_manager = ExtensionConfigManager( read_config_path=[config_dir], write_config_dir=os.path.join(config_dir, write_dir), ) jpserver_extensions = config_manager.get_jpserver_extensions() for name, enabled in jpserver_extensions.items(): # Attempt to get extension metadata print(f" {name} {GREEN_ENABLED if enabled else RED_DISABLED}") try: print(f" - Validating {name}...") extension = ExtensionPackage(name=name, enabled=enabled) if not extension.validate(): msg = "validation failed" raise ValueError(msg) version = extension.version print(f" {name} {version} {GREEN_OK}") except Exception as err: self.log.debug("", exc_info=True) print(f" {RED_X} {err}") # Add a blank line between paths. self.log.info("") def start(self) -> None: """Perform the App's actions as configured""" self.list_server_extensions() _examples = """ jupyter server extension list # list all configured server extensions jupyter server extension enable --py # enable all server extensions in a Python package jupyter server extension disable --py # disable all server extensions in a Python package """ class ServerExtensionApp(BaseExtensionApp): """Root level server extension app""" name = "jupyter server extension" version = __version__ description: str = "Work with Jupyter server extensions" examples = _examples subcommands: dict[str, t.Any] = { "enable": (EnableServerExtensionApp, "Enable a server extension"), "disable": (DisableServerExtensionApp, "Disable a server extension"), "list": (ListServerExtensionsApp, "List server extensions"), } def start(self) -> None: """Perform the App's actions as configured""" super().start() # The above should have called a subcommand and raised NoStart; if we # get here, it didn't, so we should self.log.info a message. subcmds = ", ".join(sorted(self.subcommands)) sys.exit("Please supply at least one subcommand: %s" % subcmds) main = ServerExtensionApp.launch_instance if __name__ == "__main__": main() jupyter-server-jupyter_server-e5c7e2b/jupyter_server/extension/utils.py000066400000000000000000000076161473126534200271430ustar00rootroot00000000000000"""Extension utilities.""" import importlib import time import warnings class ExtensionLoadingError(Exception): """An extension loading error.""" class ExtensionMetadataError(Exception): """An extension metadata error.""" class ExtensionModuleNotFound(Exception): """An extension module not found error.""" class NotAnExtensionApp(Exception): """An error raised when a module is not an extension.""" def get_loader(obj, logger=None): """Looks for _load_jupyter_server_extension as an attribute of the object or module. Adds backwards compatibility for old function name missing the underscore prefix. """ try: return obj._load_jupyter_server_extension except AttributeError: pass try: func = obj.load_jupyter_server_extension except AttributeError: msg = "_load_jupyter_server_extension function was not found." raise ExtensionLoadingError(msg) from None warnings.warn( "A `_load_jupyter_server_extension` function was not " f"found in {obj!s}. Instead, a `load_jupyter_server_extension` " "function was found and will be used for now. This function " "name will be deprecated in future releases " "of Jupyter Server.", DeprecationWarning, stacklevel=2, ) return func def get_metadata(package_name, logger=None): """Find the extension metadata from an extension package. This looks for a `_jupyter_server_extension_points` function that returns metadata about all extension points within a Jupyter Server Extension package. If it doesn't exist, return a basic metadata packet given the module name. """ start_time = time.perf_counter() module = importlib.import_module(package_name) end_time = time.perf_counter() duration = end_time - start_time # Sometimes packages can take a *while* to import, so we report how long # each module took to import. This makes it much easier for users to report # slow loading modules upstream, as slow loading modules will block server startup if logger: log = logger.info if duration > 0.1 else logger.debug log(f"Extension package {package_name} took {duration:.4f}s to import") try: return module, module._jupyter_server_extension_points() except AttributeError: pass # For backwards compatibility, we temporarily allow # _jupyter_server_extension_paths. We will remove in # a later release of Jupyter Server. try: extension_points = module._jupyter_server_extension_paths() if logger: logger.warning( "A `_jupyter_server_extension_points` function was not " f"found in {package_name}. Instead, a `_jupyter_server_extension_paths` " "function was found and will be used for now. This function " "name will be deprecated in future releases " "of Jupyter Server." ) return module, extension_points except AttributeError: pass # Dynamically create metadata if the package doesn't # provide it. if logger: logger.debug( "A `_jupyter_server_extension_points` function was " f"not found in {package_name}, so Jupyter Server will look " "for extension points in the extension pacakge's " "root." ) return module, [{"module": package_name, "name": package_name}] def validate_extension(name): """Raises an exception is the extension is missing a needed hook or metadata field. An extension is valid if: 1) name is an importable Python package. 1) the package has a _jupyter_server_extension_points function 2) each extension path has a _load_jupyter_server_extension function If this works, nothing should happen. """ from .manager import ExtensionPackage return ExtensionPackage(name=name) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/files/000077500000000000000000000000001473126534200245055ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/files/__init__.py000066400000000000000000000000001473126534200266040ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/files/handlers.py000066400000000000000000000065521473126534200266670ustar00rootroot00000000000000"""Serve files directly from the ContentsManager.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import mimetypes from base64 import decodebytes from typing import TYPE_CHECKING from jupyter_core.utils import ensure_async from tornado import web from jupyter_server.auth.decorator import authorized from jupyter_server.base.handlers import JupyterHandler if TYPE_CHECKING: from collections.abc import Awaitable AUTH_RESOURCE = "contents" class FilesHandler(JupyterHandler, web.StaticFileHandler): """serve files via ContentsManager Normally used when ContentsManager is not a FileContentsManager. FileContentsManager subclasses use AuthenticatedFilesHandler by default, a subclass of StaticFileHandler. """ auth_resource = AUTH_RESOURCE @property def content_security_policy(self): """The content security policy.""" # In case we're serving HTML/SVG, confine any Javascript to a unique # origin so it can't interact with the notebook server. return super().content_security_policy + "; sandbox allow-scripts" @web.authenticated @authorized def head(self, path: str) -> Awaitable[None] | None: # type:ignore[override] """The head response.""" self.get(path, include_body=False) self.check_xsrf_cookie() return self.get(path, include_body=False) @web.authenticated @authorized async def get(self, path, include_body=True): """Get a file by path.""" # /files/ requests must originate from the same site self.check_xsrf_cookie() cm = self.contents_manager if not cm.allow_hidden and await ensure_async(cm.is_hidden(path)): self.log.info("Refusing to serve hidden file, via 404 Error") raise web.HTTPError(404) path = path.strip("/") if "/" in path: _, name = path.rsplit("/", 1) else: name = path model = await ensure_async(cm.get(path, type="file", content=include_body)) if self.get_argument("download", None): self.set_attachment_header(name) # get mimetype from filename if name.lower().endswith(".ipynb"): self.set_header("Content-Type", "application/x-ipynb+json") else: cur_mime, encoding = mimetypes.guess_type(name) if cur_mime == "text/plain": self.set_header("Content-Type", "text/plain; charset=UTF-8") # RFC 6713 if encoding == "gzip": self.set_header("Content-Type", "application/gzip") elif encoding is not None: self.set_header("Content-Type", "application/octet-stream") elif cur_mime is not None: self.set_header("Content-Type", cur_mime) elif model["format"] == "base64": self.set_header("Content-Type", "application/octet-stream") else: self.set_header("Content-Type", "text/plain; charset=UTF-8") if include_body: if model["format"] == "base64": b64_bytes = model["content"].encode("ascii") self.write(decodebytes(b64_bytes)) else: self.write(model["content"]) self.flush() default_handlers: list[JupyterHandler] = [] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/gateway/000077500000000000000000000000001473126534200250445ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/gateway/__init__.py000066400000000000000000000000001473126534200271430ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/gateway/connections.py000066400000000000000000000163011473126534200277410ustar00rootroot00000000000000"""Gateway connection classes.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import asyncio import logging import random from typing import Any, cast import tornado.websocket as tornado_websocket from tornado.concurrent import Future from tornado.escape import json_decode, url_escape, utf8 from tornado.httpclient import HTTPRequest from tornado.ioloop import IOLoop from traitlets import Bool, Instance, Int, Unicode from ..services.kernels.connection.base import BaseKernelWebsocketConnection from ..utils import url_path_join from .gateway_client import GatewayClient class GatewayWebSocketConnection(BaseKernelWebsocketConnection): """Web socket connection that proxies to a kernel/enterprise gateway.""" ws = Instance(klass=tornado_websocket.WebSocketClientConnection, allow_none=True) ws_future = Instance(klass=Future, allow_none=True) disconnected = Bool(False) retry = Int(0) # When opening ws connection to gateway, server already negotiated subprotocol with notebook client. # Same protocol must be used for client and gateway, so legacy ws subprotocol for client is enforced here. kernel_ws_protocol = Unicode("", allow_none=True, config=True) async def connect(self): """Connect to the socket.""" # websocket is initialized before connection self.ws = None ws_url = url_path_join( GatewayClient.instance().ws_url or "", GatewayClient.instance().kernels_endpoint, url_escape(self.kernel_id), "channels", ) if self.session_id: ws_url += f"?session_id={url_escape(self.session_id)}" self.log.info(f"Connecting to {ws_url}") kwargs: dict[str, Any] = {} kwargs = GatewayClient.instance().load_connection_args(**kwargs) request = HTTPRequest(ws_url, **kwargs) self.ws_future = cast("Future[Any]", tornado_websocket.websocket_connect(request)) self.ws_future.add_done_callback(self._connection_done) loop = IOLoop.current() loop.add_future(self.ws_future, lambda future: self._read_messages()) def _connection_done(self, fut): """Handle a finished connection.""" if ( not self.disconnected and fut.exception() is None ): # prevent concurrent.futures._base.CancelledError self.ws = fut.result() self.retry = 0 self.log.debug(f"Connection is ready: ws: {self.ws}") else: self.log.warning( "Websocket connection has been closed via client disconnect or due to error. " f"Kernel with ID '{self.kernel_id}' may not be terminated on GatewayClient: {GatewayClient.instance().url}" ) def disconnect(self): """Handle a disconnect.""" self.disconnected = True if self.ws is not None: # Close connection self.ws.close() elif self.ws_future and not self.ws_future.done(): # Cancel pending connection. Since future.cancel() is a noop on tornado, we'll track cancellation locally self.ws_future.cancel() self.log.debug(f"_disconnect: future cancelled, disconnected: {self.disconnected}") async def _read_messages(self): """Read messages from gateway server.""" while self.ws is not None: message = None if not self.disconnected: try: message = await self.ws.read_message() except Exception as e: self.log.error( f"Exception reading message from websocket: {e}" ) # , exc_info=True) if message is None: if not self.disconnected: self.log.warning(f"Lost connection to Gateway: {self.kernel_id}") break if isinstance(message, bytes): message = message.decode("utf8") self.handle_outgoing_message( message ) # pass back to notebook client (see self.on_open and WebSocketChannelsHandler.open) else: # ws cancelled - stop reading break # NOTE(esevan): if websocket is not disconnected by client, try to reconnect. if not self.disconnected and self.retry < GatewayClient.instance().gateway_retry_max: jitter = random.randint(10, 100) * 0.01 # noqa: S311 retry_interval = ( min( GatewayClient.instance().gateway_retry_interval * (2**self.retry), GatewayClient.instance().gateway_retry_interval_max, ) + jitter ) self.retry += 1 self.log.info( "Attempting to re-establish the connection to Gateway in %s secs (%s/%s): %s", retry_interval, self.retry, GatewayClient.instance().gateway_retry_max, self.kernel_id, ) await asyncio.sleep(retry_interval) loop = IOLoop.current() loop.spawn_callback(self.connect) def handle_outgoing_message(self, incoming_msg: str, *args: Any) -> None: """Send message to the notebook client.""" try: self.websocket_handler.write_message(incoming_msg) except tornado_websocket.WebSocketClosedError: if self.log.isEnabledFor(logging.DEBUG): msg_summary = GatewayWebSocketConnection._get_message_summary( json_decode(utf8(incoming_msg)) ) self.log.debug( f"Notebook client closed websocket connection - message dropped: {msg_summary}" ) def handle_incoming_message(self, message: str) -> None: """Send message to gateway server.""" if self.ws is None and self.ws_future is not None: loop = IOLoop.current() loop.add_future(self.ws_future, lambda future: self.handle_incoming_message(message)) else: self._write_message(message) def _write_message(self, message): """Send message to gateway server.""" try: if not self.disconnected and self.ws is not None: self.ws.write_message(message) except Exception as e: self.log.error(f"Exception writing message to websocket: {e}") # , exc_info=True) @staticmethod def _get_message_summary(message): """Get a summary of a message.""" summary = [] message_type = message["msg_type"] summary.append(f"type: {message_type}") if message_type == "status": summary.append(", state: {}".format(message["content"]["execution_state"])) elif message_type == "error": summary.append( ", {}:{}:{}".format( message["content"]["ename"], message["content"]["evalue"], message["content"]["traceback"], ) ) else: summary.append(", ...") # don't display potentially sensitive data return "".join(summary) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/gateway/gateway_client.py000066400000000000000000000772511473126534200304310ustar00rootroot00000000000000"""A kernel gateway client.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import asyncio import json import logging import os import typing as ty from abc import ABC, ABCMeta, abstractmethod from datetime import datetime, timezone from email.utils import parsedate_to_datetime from http.cookies import SimpleCookie from socket import gaierror from jupyter_events import EventLogger from tornado import web from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse from traitlets import ( Bool, Float, Instance, Int, TraitError, Type, Unicode, default, observe, validate, ) from traitlets.config import LoggingConfigurable, SingletonConfigurable from jupyter_server import DEFAULT_EVENTS_SCHEMA_PATH, JUPYTER_SERVER_EVENTS_URI ERROR_STATUS = "error" SUCCESS_STATUS = "success" STATUS_KEY = "status" STATUS_CODE_KEY = "status_code" MESSAGE_KEY = "msg" if ty.TYPE_CHECKING: from http.cookies import Morsel class GatewayTokenRenewerMeta(ABCMeta, type(LoggingConfigurable)): # type: ignore[misc] """The metaclass necessary for proper ABC behavior in a Configurable.""" class GatewayTokenRenewerBase( # type:ignore[misc] ABC, LoggingConfigurable, metaclass=GatewayTokenRenewerMeta ): """ Abstract base class for refreshing tokens used between this server and a Gateway server. Implementations requiring additional configuration can extend their class with appropriate configuration values or convey those values via appropriate environment variables relative to the implementation. """ @abstractmethod def get_token( self, auth_header_key: str, auth_scheme: ty.Union[str, None], auth_token: str, **kwargs: ty.Any, ) -> str: """ Given the current authorization header key, scheme, and token, this method returns a (potentially renewed) token for use against the Gateway server. """ class NoOpTokenRenewer(GatewayTokenRenewerBase): # type:ignore[misc] """NoOpTokenRenewer is the default value to the GatewayClient trait `gateway_token_renewer` and merely returns the provided token. """ def get_token( self, auth_header_key: str, auth_scheme: ty.Union[str, None], auth_token: str, **kwargs: ty.Any, ) -> str: """This implementation simply returns the current authorization token.""" return auth_token class GatewayClient(SingletonConfigurable): """This class manages the configuration. It's its own singleton class so that we can share these values across all objects. It also contains some options. helper methods to build request arguments out of the various config """ event_schema_id = JUPYTER_SERVER_EVENTS_URI + "/gateway_client/v1" event_logger = Instance(EventLogger).tag(config=True) @default("event_logger") def _default_event_logger(self): if self.parent and hasattr(self.parent, "event_logger"): # Event logger is attached from serverapp. return self.parent.event_logger else: # If parent does not have an event logger, create one. logger = EventLogger() schema_path = DEFAULT_EVENTS_SCHEMA_PATH / "gateway_client" / "v1.yaml" logger.register_event_schema(schema_path) self.log.info("Event is registered in GatewayClient.") return logger def emit(self, data): """Emit event using the core event schema from Jupyter Server's Gateway Client.""" self.event_logger.emit(schema_id=self.event_schema_id, data=data) url = Unicode( default_value=None, allow_none=True, config=True, help="""The url of the Kernel or Enterprise Gateway server where kernel specifications are defined and kernel management takes place. If defined, this Notebook server acts as a proxy for all kernel management and kernel specification retrieval. (JUPYTER_GATEWAY_URL env var) """, ) url_env = "JUPYTER_GATEWAY_URL" @default("url") def _url_default(self): return os.environ.get(self.url_env) @validate("url") def _url_validate(self, proposal): value = proposal["value"] # Ensure value, if present, starts with 'http' if value is not None and len(value) > 0 and not str(value).lower().startswith("http"): message = "GatewayClient url must start with 'http': '%r'" % value self.emit(data={STATUS_KEY: ERROR_STATUS, STATUS_CODE_KEY: 400, MESSAGE_KEY: message}) raise TraitError(message) return value ws_url = Unicode( default_value=None, allow_none=True, config=True, help="""The websocket url of the Kernel or Enterprise Gateway server. If not provided, this value will correspond to the value of the Gateway url with 'ws' in place of 'http'. (JUPYTER_GATEWAY_WS_URL env var) """, ) ws_url_env = "JUPYTER_GATEWAY_WS_URL" @default("ws_url") def _ws_url_default(self): default_value = os.environ.get(self.ws_url_env) if self.url is not None and default_value is None and self.gateway_enabled: default_value = self.url.lower().replace("http", "ws") return default_value @validate("ws_url") def _ws_url_validate(self, proposal): value = proposal["value"] # Ensure value, if present, starts with 'ws' if value is not None and len(value) > 0 and not str(value).lower().startswith("ws"): message = "GatewayClient ws_url must start with 'ws': '%r'" % value self.emit(data={STATUS_KEY: ERROR_STATUS, STATUS_CODE_KEY: 400, MESSAGE_KEY: message}) raise TraitError(message) return value kernels_endpoint_default_value = "/api/kernels" kernels_endpoint_env = "JUPYTER_GATEWAY_KERNELS_ENDPOINT" kernels_endpoint = Unicode( default_value=kernels_endpoint_default_value, config=True, help="""The gateway API endpoint for accessing kernel resources (JUPYTER_GATEWAY_KERNELS_ENDPOINT env var)""", ) @default("kernels_endpoint") def _kernels_endpoint_default(self): return os.environ.get(self.kernels_endpoint_env, self.kernels_endpoint_default_value) kernelspecs_endpoint_default_value = "/api/kernelspecs" kernelspecs_endpoint_env = "JUPYTER_GATEWAY_KERNELSPECS_ENDPOINT" kernelspecs_endpoint = Unicode( default_value=kernelspecs_endpoint_default_value, config=True, help="""The gateway API endpoint for accessing kernelspecs (JUPYTER_GATEWAY_KERNELSPECS_ENDPOINT env var)""", ) @default("kernelspecs_endpoint") def _kernelspecs_endpoint_default(self): return os.environ.get( self.kernelspecs_endpoint_env, self.kernelspecs_endpoint_default_value ) kernelspecs_resource_endpoint_default_value = "/kernelspecs" kernelspecs_resource_endpoint_env = "JUPYTER_GATEWAY_KERNELSPECS_RESOURCE_ENDPOINT" kernelspecs_resource_endpoint = Unicode( default_value=kernelspecs_resource_endpoint_default_value, config=True, help="""The gateway endpoint for accessing kernelspecs resources (JUPYTER_GATEWAY_KERNELSPECS_RESOURCE_ENDPOINT env var)""", ) @default("kernelspecs_resource_endpoint") def _kernelspecs_resource_endpoint_default(self): return os.environ.get( self.kernelspecs_resource_endpoint_env, self.kernelspecs_resource_endpoint_default_value, ) connect_timeout_default_value = 40.0 connect_timeout_env = "JUPYTER_GATEWAY_CONNECT_TIMEOUT" connect_timeout = Float( default_value=connect_timeout_default_value, config=True, help="""The time allowed for HTTP connection establishment with the Gateway server. (JUPYTER_GATEWAY_CONNECT_TIMEOUT env var)""", ) @default("connect_timeout") def _connect_timeout_default(self): return float(os.environ.get(self.connect_timeout_env, self.connect_timeout_default_value)) request_timeout_default_value = 42.0 request_timeout_env = "JUPYTER_GATEWAY_REQUEST_TIMEOUT" request_timeout = Float( default_value=request_timeout_default_value, config=True, help="""The time allowed for HTTP request completion. (JUPYTER_GATEWAY_REQUEST_TIMEOUT env var)""", ) @default("request_timeout") def _request_timeout_default(self): return float(os.environ.get(self.request_timeout_env, self.request_timeout_default_value)) client_key = Unicode( default_value=None, allow_none=True, config=True, help="""The filename for client SSL key, if any. (JUPYTER_GATEWAY_CLIENT_KEY env var) """, ) client_key_env = "JUPYTER_GATEWAY_CLIENT_KEY" @default("client_key") def _client_key_default(self): return os.environ.get(self.client_key_env) client_cert = Unicode( default_value=None, allow_none=True, config=True, help="""The filename for client SSL certificate, if any. (JUPYTER_GATEWAY_CLIENT_CERT env var) """, ) client_cert_env = "JUPYTER_GATEWAY_CLIENT_CERT" @default("client_cert") def _client_cert_default(self): return os.environ.get(self.client_cert_env) ca_certs = Unicode( default_value=None, allow_none=True, config=True, help="""The filename of CA certificates or None to use defaults. (JUPYTER_GATEWAY_CA_CERTS env var) """, ) ca_certs_env = "JUPYTER_GATEWAY_CA_CERTS" @default("ca_certs") def _ca_certs_default(self): return os.environ.get(self.ca_certs_env) http_user = Unicode( default_value=None, allow_none=True, config=True, help="""The username for HTTP authentication. (JUPYTER_GATEWAY_HTTP_USER env var) """, ) http_user_env = "JUPYTER_GATEWAY_HTTP_USER" @default("http_user") def _http_user_default(self): return os.environ.get(self.http_user_env) http_pwd = Unicode( default_value=None, allow_none=True, config=True, help="""The password for HTTP authentication. (JUPYTER_GATEWAY_HTTP_PWD env var) """, ) http_pwd_env = "JUPYTER_GATEWAY_HTTP_PWD" # noqa: S105 @default("http_pwd") def _http_pwd_default(self): return os.environ.get(self.http_pwd_env) headers_default_value = "{}" headers_env = "JUPYTER_GATEWAY_HEADERS" headers = Unicode( default_value=headers_default_value, allow_none=True, config=True, help="""Additional HTTP headers to pass on the request. This value will be converted to a dict. (JUPYTER_GATEWAY_HEADERS env var) """, ) @default("headers") def _headers_default(self): return os.environ.get(self.headers_env, self.headers_default_value) auth_header_key_default_value = "Authorization" auth_header_key = Unicode( config=True, help="""The authorization header's key name (typically 'Authorization') used in the HTTP headers. The header will be formatted as:: {'{auth_header_key}': '{auth_scheme} {auth_token}'} If the authorization header key takes a single value, `auth_scheme` should be set to None and 'auth_token' should be configured to use the appropriate value. (JUPYTER_GATEWAY_AUTH_HEADER_KEY env var)""", ) auth_header_key_env = "JUPYTER_GATEWAY_AUTH_HEADER_KEY" @default("auth_header_key") def _auth_header_key_default(self): return os.environ.get(self.auth_header_key_env, self.auth_header_key_default_value) auth_token_default_value = "" auth_token = Unicode( default_value=None, allow_none=True, config=True, help="""The authorization token used in the HTTP headers. The header will be formatted as:: {'{auth_header_key}': '{auth_scheme} {auth_token}'} (JUPYTER_GATEWAY_AUTH_TOKEN env var)""", ) auth_token_env = "JUPYTER_GATEWAY_AUTH_TOKEN" # noqa: S105 @default("auth_token") def _auth_token_default(self): return os.environ.get(self.auth_token_env, self.auth_token_default_value) auth_scheme_default_value = "token" # This value is purely for backwards compatibility auth_scheme = Unicode( allow_none=True, config=True, help="""The auth scheme, added as a prefix to the authorization token used in the HTTP headers. (JUPYTER_GATEWAY_AUTH_SCHEME env var)""", ) auth_scheme_env = "JUPYTER_GATEWAY_AUTH_SCHEME" @default("auth_scheme") def _auth_scheme_default(self): return os.environ.get(self.auth_scheme_env, self.auth_scheme_default_value) validate_cert_default_value = True validate_cert_env = "JUPYTER_GATEWAY_VALIDATE_CERT" validate_cert = Bool( default_value=validate_cert_default_value, config=True, help="""For HTTPS requests, determines if server's certificate should be validated or not. (JUPYTER_GATEWAY_VALIDATE_CERT env var)""", ) @default("validate_cert") def _validate_cert_default(self): return bool( os.environ.get(self.validate_cert_env, str(self.validate_cert_default_value)) not in ["no", "false"] ) allowed_envs_default_value = "" allowed_envs_env = "JUPYTER_GATEWAY_ALLOWED_ENVS" allowed_envs = Unicode( default_value=allowed_envs_default_value, config=True, help="""A comma-separated list of environment variable names that will be included, along with their values, in the kernel startup request. The corresponding `client_envs` configuration value must also be set on the Gateway server - since that configuration value indicates which environmental values to make available to the kernel. (JUPYTER_GATEWAY_ALLOWED_ENVS env var)""", ) @default("allowed_envs") def _allowed_envs_default(self): return os.environ.get( self.allowed_envs_env, os.environ.get("JUPYTER_GATEWAY_ENV_WHITELIST", self.allowed_envs_default_value), ) env_whitelist = Unicode( default_value=allowed_envs_default_value, config=True, help="""Deprecated, use `GatewayClient.allowed_envs`""", ) gateway_retry_interval_default_value = 1.0 gateway_retry_interval_env = "JUPYTER_GATEWAY_RETRY_INTERVAL" gateway_retry_interval = Float( default_value=gateway_retry_interval_default_value, config=True, help="""The time allowed for HTTP reconnection with the Gateway server for the first time. Next will be JUPYTER_GATEWAY_RETRY_INTERVAL multiplied by two in factor of numbers of retries but less than JUPYTER_GATEWAY_RETRY_INTERVAL_MAX. (JUPYTER_GATEWAY_RETRY_INTERVAL env var)""", ) @default("gateway_retry_interval") def _gateway_retry_interval_default(self): return float( os.environ.get( self.gateway_retry_interval_env, self.gateway_retry_interval_default_value, ) ) gateway_retry_interval_max_default_value = 30.0 gateway_retry_interval_max_env = "JUPYTER_GATEWAY_RETRY_INTERVAL_MAX" gateway_retry_interval_max = Float( default_value=gateway_retry_interval_max_default_value, config=True, help="""The maximum time allowed for HTTP reconnection retry with the Gateway server. (JUPYTER_GATEWAY_RETRY_INTERVAL_MAX env var)""", ) @default("gateway_retry_interval_max") def _gateway_retry_interval_max_default(self): return float( os.environ.get( self.gateway_retry_interval_max_env, self.gateway_retry_interval_max_default_value, ) ) gateway_retry_max_default_value = 5 gateway_retry_max_env = "JUPYTER_GATEWAY_RETRY_MAX" gateway_retry_max = Int( default_value=gateway_retry_max_default_value, config=True, help="""The maximum retries allowed for HTTP reconnection with the Gateway server. (JUPYTER_GATEWAY_RETRY_MAX env var)""", ) @default("gateway_retry_max") def _gateway_retry_max_default(self): return int(os.environ.get(self.gateway_retry_max_env, self.gateway_retry_max_default_value)) gateway_token_renewer_class_default_value = ( "jupyter_server.gateway.gateway_client.NoOpTokenRenewer" # noqa: S105 ) gateway_token_renewer_class_env = "JUPYTER_GATEWAY_TOKEN_RENEWER_CLASS" # noqa: S105 gateway_token_renewer_class = Type( klass=GatewayTokenRenewerBase, config=True, help="""The class to use for Gateway token renewal. (JUPYTER_GATEWAY_TOKEN_RENEWER_CLASS env var)""", ) @default("gateway_token_renewer_class") def _gateway_token_renewer_class_default(self): return os.environ.get( self.gateway_token_renewer_class_env, self.gateway_token_renewer_class_default_value ) launch_timeout_pad_default_value = 2.0 launch_timeout_pad_env = "JUPYTER_GATEWAY_LAUNCH_TIMEOUT_PAD" launch_timeout_pad = Float( default_value=launch_timeout_pad_default_value, config=True, help="""Timeout pad to be ensured between KERNEL_LAUNCH_TIMEOUT and request_timeout such that request_timeout >= KERNEL_LAUNCH_TIMEOUT + launch_timeout_pad. (JUPYTER_GATEWAY_LAUNCH_TIMEOUT_PAD env var)""", ) @default("launch_timeout_pad") def _launch_timeout_pad_default(self): return float( os.environ.get( self.launch_timeout_pad_env, self.launch_timeout_pad_default_value, ) ) accept_cookies_value = False accept_cookies_env = "JUPYTER_GATEWAY_ACCEPT_COOKIES" accept_cookies = Bool( default_value=accept_cookies_value, config=True, help="""Accept and manage cookies sent by the service side. This is often useful for load balancers to decide which backend node to use. (JUPYTER_GATEWAY_ACCEPT_COOKIES env var)""", ) @default("accept_cookies") def _accept_cookies_default(self): return bool( os.environ.get(self.accept_cookies_env, str(self.accept_cookies_value).lower()) not in ["no", "false"] ) _deprecated_traits = { "env_whitelist": ("allowed_envs", "2.0"), } # Method copied from # https://github.com/jupyterhub/jupyterhub/blob/d1a85e53dccfc7b1dd81b0c1985d158cc6b61820/jupyterhub/auth.py#L143-L161 @observe(*list(_deprecated_traits)) def _deprecated_trait(self, change): """observer for deprecated traits""" old_attr = change.name new_attr, version = self._deprecated_traits[old_attr] new_value = getattr(self, new_attr) if new_value != change.new: # only warn if different # protects backward-compatible config from warnings # if they set the same value under both names self.log.warning( f"{self.__class__.__name__}.{old_attr} is deprecated in jupyter_server " f"{version}, use {self.__class__.__name__}.{new_attr} instead" ) setattr(self, new_attr, change.new) @property def gateway_enabled(self): return bool(self.url is not None and len(self.url) > 0) # Ensure KERNEL_LAUNCH_TIMEOUT has a default value. KERNEL_LAUNCH_TIMEOUT = int(os.environ.get("KERNEL_LAUNCH_TIMEOUT", 40)) _connection_args: dict[str, ty.Any] # initialized on first use gateway_token_renewer: GatewayTokenRenewerBase def __init__(self, **kwargs): """Initialize a gateway client.""" super().__init__(**kwargs) self._connection_args = {} # initialized on first use self.gateway_token_renewer = self.gateway_token_renewer_class(parent=self, log=self.log) # type:ignore[abstract] # store of cookies with store time self._cookies: dict[str, tuple[Morsel[ty.Any], datetime]] = {} def init_connection_args(self): """Initialize arguments used on every request. Since these are primarily static values, we'll perform this operation once. """ # Ensure that request timeout and KERNEL_LAUNCH_TIMEOUT are in sync, taking the # greater value of the two and taking into account the following relation: # request_timeout = KERNEL_LAUNCH_TIME + padding minimum_request_timeout = ( float(GatewayClient.KERNEL_LAUNCH_TIMEOUT) + self.launch_timeout_pad ) if self.request_timeout < minimum_request_timeout: self.request_timeout = minimum_request_timeout elif self.request_timeout > minimum_request_timeout: GatewayClient.KERNEL_LAUNCH_TIMEOUT = int( self.request_timeout - self.launch_timeout_pad ) # Ensure any adjustments are reflected in env. os.environ["KERNEL_LAUNCH_TIMEOUT"] = str(GatewayClient.KERNEL_LAUNCH_TIMEOUT) if self.headers: self._connection_args["headers"] = json.loads(self.headers) if self.auth_header_key not in self._connection_args["headers"]: self._connection_args["headers"].update( {f"{self.auth_header_key}": f"{self.auth_scheme} {self.auth_token}"} ) self._connection_args["connect_timeout"] = self.connect_timeout self._connection_args["request_timeout"] = self.request_timeout self._connection_args["validate_cert"] = self.validate_cert if self.client_cert: self._connection_args["client_cert"] = self.client_cert self._connection_args["client_key"] = self.client_key if self.ca_certs: self._connection_args["ca_certs"] = self.ca_certs if self.http_user: self._connection_args["auth_username"] = self.http_user if self.http_pwd: self._connection_args["auth_password"] = self.http_pwd def load_connection_args(self, **kwargs): """Merges the static args relative to the connection, with the given keyword arguments. If static args have yet to be initialized, we'll do that here. """ if len(self._connection_args) == 0: self.init_connection_args() # Give token renewal a shot at renewing the token prev_auth_token = self.auth_token if self.auth_token is not None: try: self.auth_token = self.gateway_token_renewer.get_token( self.auth_header_key, self.auth_scheme, self.auth_token ) except Exception as ex: self.log.error( f"An exception occurred attempting to renew the " f"Gateway authorization token using an instance of class " f"'{self.gateway_token_renewer_class}'. The request will " f"proceed using the current token value. Exception was: {ex}" ) self.auth_token = prev_auth_token for arg, value in self._connection_args.items(): if arg == "headers": given_value = kwargs.setdefault(arg, {}) if isinstance(given_value, dict): given_value.update(value) # Ensure the auth header is current given_value.update( {f"{self.auth_header_key}": f"{self.auth_scheme} {self.auth_token}"} ) else: kwargs[arg] = value if self.accept_cookies: self._update_cookie_header(kwargs) return kwargs def update_cookies(self, cookie: SimpleCookie) -> None: """Update cookies from existing requests for load balancers""" if not self.accept_cookies: return store_time = datetime.now(tz=timezone.utc) for key, item in cookie.items(): # Convert "expires" arg into "max-age" to facilitate expiration management. # As "max-age" has precedence, ignore "expires" when "max-age" exists. if item.get("expires") and not item.get("max-age"): expire_timedelta = parsedate_to_datetime(item["expires"]) - store_time item["max-age"] = str(expire_timedelta.total_seconds()) self._cookies[key] = (item, store_time) def _clear_expired_cookies(self) -> None: """Clear expired cookies.""" check_time = datetime.now(tz=timezone.utc) expired_keys = [] for key, (morsel, store_time) in self._cookies.items(): cookie_max_age = morsel.get("max-age") if not cookie_max_age: continue expired_timedelta = check_time - store_time if expired_timedelta.total_seconds() > float(cookie_max_age): expired_keys.append(key) for key in expired_keys: self._cookies.pop(key) def _update_cookie_header(self, connection_args: dict[str, ty.Any]) -> None: """Update a cookie header.""" self._clear_expired_cookies() gateway_cookie_values = "; ".join( f"{name}={morsel.coded_value}" for name, (morsel, _time) in self._cookies.items() ) if gateway_cookie_values: headers = connection_args.get("headers", {}) # As headers are case-insensitive, we get existing name of cookie header, # or use "Cookie" by default. cookie_header_name = next( (header_key for header_key in headers if header_key.lower() == "cookie"), "Cookie", ) existing_cookie = headers.get(cookie_header_name) # merge gateway-managed cookies with cookies already in arguments if existing_cookie: gateway_cookie_values = existing_cookie + "; " + gateway_cookie_values headers[cookie_header_name] = gateway_cookie_values connection_args["headers"] = headers class RetryableHTTPClient: """ Inspired by urllib.util.Retry (https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html), this class is initialized with desired retry characteristics, uses a recursive method `fetch()` against an instance of `AsyncHTTPClient` which tracks the current retry count across applicable request retries. """ MAX_RETRIES_DEFAULT = 2 MAX_RETRIES_CAP = 10 # The upper limit to max_retries value. max_retries: int = int(os.getenv("JUPYTER_GATEWAY_MAX_REQUEST_RETRIES", MAX_RETRIES_DEFAULT)) max_retries = max(0, min(max_retries, MAX_RETRIES_CAP)) # Enforce boundaries retried_methods: set[str] = {"GET", "DELETE"} retried_errors: set[int] = {502, 503, 504, 599} retried_exceptions: set[type] = {ConnectionError} backoff_factor: float = 0.1 def __init__(self): """Initialize the retryable http client.""" self.retry_count: int = 0 self.client: AsyncHTTPClient = AsyncHTTPClient() async def fetch(self, endpoint: str, **kwargs: ty.Any) -> HTTPResponse: """ Retryable AsyncHTTPClient.fetch() method. When the request fails, this method will recurse up to max_retries times if the condition deserves a retry. """ self.retry_count = 0 return await self._fetch(endpoint, **kwargs) async def _fetch(self, endpoint: str, **kwargs: ty.Any) -> HTTPResponse: """ Performs the fetch against the contained AsyncHTTPClient instance and determines if retry is necessary on any exceptions. If so, retry is performed recursively. """ try: response: HTTPResponse = await self.client.fetch(endpoint, **kwargs) except Exception as e: is_retryable: bool = await self._is_retryable(kwargs["method"], e) if not is_retryable: raise e logging.getLogger("ServerApp").info( f"Attempting retry ({self.retry_count}) against " f"endpoint '{endpoint}'. Retried error: '{e!r}'" ) response = await self._fetch(endpoint, **kwargs) return response async def _is_retryable(self, method: str, exception: Exception) -> bool: """Determines if the given exception is retryable based on object's configuration.""" if method not in self.retried_methods: return False if self.retry_count == self.max_retries: return False # Determine if error is retryable... if isinstance(exception, HTTPClientError): hce: HTTPClientError = exception if hce.code not in self.retried_errors: return False elif not any(isinstance(exception, error) for error in self.retried_exceptions): return False # Is retryable, wait for backoff, then increment count await asyncio.sleep(self.backoff_factor * (2**self.retry_count)) self.retry_count += 1 return True async def gateway_request(endpoint: str, **kwargs: ty.Any) -> HTTPResponse: """Make an async request to kernel gateway endpoint, returns a response""" gateway_client = GatewayClient.instance() kwargs = gateway_client.load_connection_args(**kwargs) rhc = RetryableHTTPClient() try: response = await rhc.fetch(endpoint, **kwargs) gateway_client.emit( data={STATUS_KEY: SUCCESS_STATUS, STATUS_CODE_KEY: 200, MESSAGE_KEY: "success"} ) # Trap a set of common exceptions so that we can inform the user that their Gateway url is incorrect # or the server is not running. # NOTE: We do this here since this handler is called during the server's startup and subsequent refreshes # of the tree view. except HTTPClientError as e: gateway_client.emit( data={STATUS_KEY: ERROR_STATUS, STATUS_CODE_KEY: e.code, MESSAGE_KEY: str(e.message)} ) error_reason = ( f"Exception while attempting to connect to Gateway server url '{gateway_client.url}'" ) error_message = e.message if e.response: try: error_payload = json.loads(e.response.body) error_reason = error_payload.get("reason") or error_reason error_message = error_payload.get("message") or error_message except json.decoder.JSONDecodeError: error_reason = e.response.body.decode() raise web.HTTPError( e.code, f"Error from Gateway: [{error_message}] {error_reason}. " "Ensure gateway url is valid and the Gateway instance is running.", ) from e except ConnectionError as e: gateway_client.emit( data={STATUS_KEY: ERROR_STATUS, STATUS_CODE_KEY: 503, MESSAGE_KEY: str(e)} ) raise web.HTTPError( 503, f"ConnectionError was received from Gateway server url '{gateway_client.url}'. " "Check to be sure the Gateway instance is running.", ) from e except gaierror as e: gateway_client.emit( data={STATUS_KEY: ERROR_STATUS, STATUS_CODE_KEY: 404, MESSAGE_KEY: str(e)} ) raise web.HTTPError( 404, f"The Gateway server specified in the gateway_url '{gateway_client.url}' doesn't " f"appear to be valid. Ensure gateway url is valid and the Gateway instance is running.", ) from e except Exception as e: gateway_client.emit( data={STATUS_KEY: ERROR_STATUS, STATUS_CODE_KEY: 505, MESSAGE_KEY: str(e)} ) logging.getLogger("ServerApp").error( "Exception while trying to launch kernel via Gateway URL %s: %s", gateway_client.url, e, ) raise e if gateway_client.accept_cookies: # Update cookies on GatewayClient from server if configured. cookie_values = response.headers.get("Set-Cookie") if cookie_values: cookie: SimpleCookie = SimpleCookie() cookie.load(cookie_values) gateway_client.update_cookies(cookie) return response jupyter-server-jupyter_server-e5c7e2b/jupyter_server/gateway/handlers.py000066400000000000000000000272441473126534200272270ustar00rootroot00000000000000"""Gateway API handlers.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import asyncio import logging import mimetypes import os import random import warnings from typing import Any, Optional, cast from jupyter_client.session import Session from tornado import web from tornado.concurrent import Future from tornado.escape import json_decode, url_escape, utf8 from tornado.httpclient import HTTPRequest from tornado.ioloop import IOLoop, PeriodicCallback from tornado.websocket import WebSocketHandler, websocket_connect from traitlets.config.configurable import LoggingConfigurable from ..base.handlers import APIHandler, JupyterHandler from ..utils import url_path_join from .gateway_client import GatewayClient warnings.warn( "The jupyter_server.gateway.handlers module is deprecated and will not be supported in Jupyter Server 3.0", DeprecationWarning, stacklevel=2, ) # Keepalive ping interval (default: 30 seconds) GATEWAY_WS_PING_INTERVAL_SECS = int(os.getenv("GATEWAY_WS_PING_INTERVAL_SECS", "30")) class WebSocketChannelsHandler(WebSocketHandler, JupyterHandler): """Gateway web socket channels handler.""" session = None gateway = None kernel_id = None ping_callback = None def check_origin(self, origin=None): """Check origin for the socket.""" return JupyterHandler.check_origin(self, origin) def set_default_headers(self): """Undo the set_default_headers in JupyterHandler which doesn't make sense for websockets""" def get_compression_options(self): """Get the compression options for the socket.""" # use deflate compress websocket return {} def authenticate(self): """Run before finishing the GET request Extend this method to add logic that should fire before the websocket finishes completing. """ # authenticate the request before opening the websocket if self.current_user is None: self.log.warning("Couldn't authenticate WebSocket connection") raise web.HTTPError(403) if self.get_argument("session_id", None): assert self.session is not None self.session.session = self.get_argument("session_id") # type:ignore[unreachable] else: self.log.warning("No session ID specified") def initialize(self): """Initialize the socket.""" self.log.debug("Initializing websocket connection %s", self.request.path) self.session = Session(config=self.config) self.gateway = GatewayWebSocketClient(gateway_url=GatewayClient.instance().url) async def get(self, kernel_id, *args, **kwargs): """Get the socket.""" self.authenticate() self.kernel_id = kernel_id kwargs["kernel_id"] = kernel_id await super().get(*args, **kwargs) def send_ping(self): """Send a ping to the socket.""" if self.ws_connection is None and self.ping_callback is not None: self.ping_callback.stop() # type:ignore[unreachable] return self.ping(b"") def open(self, kernel_id, *args, **kwargs): """Handle web socket connection open to notebook server and delegate to gateway web socket handler""" self.ping_callback = PeriodicCallback(self.send_ping, GATEWAY_WS_PING_INTERVAL_SECS * 1000) self.ping_callback.start() assert self.gateway is not None self.gateway.on_open( kernel_id=kernel_id, message_callback=self.write_message, compression_options=self.get_compression_options(), ) def on_message(self, message): """Forward message to gateway web socket handler.""" assert self.gateway is not None self.gateway.on_message(message) def write_message(self, message, binary=False): """Send message back to notebook client. This is called via callback from self.gateway._read_messages.""" if self.ws_connection: # prevent WebSocketClosedError if isinstance(message, bytes): binary = True super().write_message(message, binary=binary) elif self.log.isEnabledFor(logging.DEBUG): msg_summary = WebSocketChannelsHandler._get_message_summary(json_decode(utf8(message))) self.log.debug( f"Notebook client closed websocket connection - message dropped: {msg_summary}" ) def on_close(self): """Handle a closing socket.""" self.log.debug("Closing websocket connection %s", self.request.path) assert self.gateway is not None self.gateway.on_close() super().on_close() @staticmethod def _get_message_summary(message): """Get a summary of a message.""" summary = [] message_type = message["msg_type"] summary.append(f"type: {message_type}") if message_type == "status": summary.append(", state: {}".format(message["content"]["execution_state"])) elif message_type == "error": summary.append( ", {}:{}:{}".format( message["content"]["ename"], message["content"]["evalue"], message["content"]["traceback"], ) ) else: summary.append(", ...") # don't display potentially sensitive data return "".join(summary) class GatewayWebSocketClient(LoggingConfigurable): """Proxy web socket connection to a kernel/enterprise gateway.""" def __init__(self, **kwargs): """Initialize the gateway web socket client.""" super().__init__() self.kernel_id = None self.ws = None self.ws_future: Future[Any] = Future() self.disconnected = False self.retry = 0 async def _connect(self, kernel_id, message_callback): """Connect to the socket.""" # websocket is initialized before connection self.ws = None self.kernel_id = kernel_id client = GatewayClient.instance() assert client.ws_url is not None ws_url = url_path_join( client.ws_url, client.kernels_endpoint, url_escape(kernel_id), "channels", ) self.log.info(f"Connecting to {ws_url}") kwargs: dict[str, Any] = {} kwargs = client.load_connection_args(**kwargs) request = HTTPRequest(ws_url, **kwargs) self.ws_future = cast("Future[Any]", websocket_connect(request)) self.ws_future.add_done_callback(self._connection_done) loop = IOLoop.current() loop.add_future(self.ws_future, lambda future: self._read_messages(message_callback)) def _connection_done(self, fut): """Handle a finished connection.""" if ( not self.disconnected and fut.exception() is None ): # prevent concurrent.futures._base.CancelledError self.ws = fut.result() self.retry = 0 self.log.debug(f"Connection is ready: ws: {self.ws}") else: self.log.warning( "Websocket connection has been closed via client disconnect or due to error. " f"Kernel with ID '{self.kernel_id}' may not be terminated on GatewayClient: {GatewayClient.instance().url}" ) def _disconnect(self): """Handle a disconnect.""" self.disconnected = True if self.ws is not None: # Close connection self.ws.close() elif not self.ws_future.done(): # Cancel pending connection. Since future.cancel() is a noop on tornado, we'll track cancellation locally self.ws_future.cancel() self.log.debug(f"_disconnect: future cancelled, disconnected: {self.disconnected}") async def _read_messages(self, callback): """Read messages from gateway server.""" while self.ws is not None: message = None if not self.disconnected: try: message = await self.ws.read_message() except Exception as e: self.log.error( f"Exception reading message from websocket: {e}" ) # , exc_info=True) if message is None: if not self.disconnected: self.log.warning(f"Lost connection to Gateway: {self.kernel_id}") break callback( message ) # pass back to notebook client (see self.on_open and WebSocketChannelsHandler.open) else: # ws cancelled - stop reading break # NOTE(esevan): if websocket is not disconnected by client, try to reconnect. if not self.disconnected and self.retry < GatewayClient.instance().gateway_retry_max: jitter = random.randint(10, 100) * 0.01 # noqa: S311 retry_interval = ( min( GatewayClient.instance().gateway_retry_interval * (2**self.retry), GatewayClient.instance().gateway_retry_interval_max, ) + jitter ) self.retry += 1 self.log.info( "Attempting to re-establish the connection to Gateway in %s secs (%s/%s): %s", retry_interval, self.retry, GatewayClient.instance().gateway_retry_max, self.kernel_id, ) await asyncio.sleep(retry_interval) loop = IOLoop.current() loop.spawn_callback(self._connect, self.kernel_id, callback) def on_open(self, kernel_id, message_callback, **kwargs): """Web socket connection open against gateway server.""" loop = IOLoop.current() loop.spawn_callback(self._connect, kernel_id, message_callback) def on_message(self, message): """Send message to gateway server.""" if self.ws is None: loop = IOLoop.current() loop.add_future(self.ws_future, lambda future: self._write_message(message)) else: self._write_message(message) def _write_message(self, message): """Send message to gateway server.""" try: if not self.disconnected and self.ws is not None: self.ws.write_message(message) except Exception as e: self.log.error(f"Exception writing message to websocket: {e}") # , exc_info=True) def on_close(self): """Web socket closed event.""" self._disconnect() class GatewayResourceHandler(APIHandler): """Retrieves resources for specific kernelspec definitions from kernel/enterprise gateway.""" @web.authenticated async def get(self, kernel_name, path, include_body=True): """Get a gateway resource by name and path.""" mimetype: Optional[str] = None ksm = self.kernel_spec_manager kernel_spec_res = await ksm.get_kernel_spec_resource( # type:ignore[attr-defined] kernel_name, path ) if kernel_spec_res is None: self.log.warning( f"Kernelspec resource '{path}' for '{kernel_name}' not found. Gateway may not support" " resource serving." ) else: mimetype = mimetypes.guess_type(path)[0] or "text/plain" self.finish(kernel_spec_res, set_content_type=mimetype) from ..services.kernels.handlers import _kernel_id_regex from ..services.kernelspecs.handlers import kernel_name_regex default_handlers = [ (r"/api/kernels/%s/channels" % _kernel_id_regex, WebSocketChannelsHandler), (r"/kernelspecs/%s/(?P.*)" % kernel_name_regex, GatewayResourceHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/gateway/managers.py000066400000000000000000001056051473126534200272220ustar00rootroot00000000000000"""Kernel gateway managers.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import asyncio import datetime import json import os from queue import Empty, Queue from threading import Thread from time import monotonic from typing import TYPE_CHECKING, Any, Optional, cast import websocket from jupyter_client.asynchronous.client import AsyncKernelClient from jupyter_client.clientabc import KernelClientABC from jupyter_client.kernelspec import KernelSpecManager from jupyter_client.managerabc import KernelManagerABC from jupyter_core.utils import ensure_async from tornado import web from tornado.escape import json_decode, json_encode, url_escape, utf8 from traitlets import DottedObjectName, Instance, Type, default from .._tz import UTC, utcnow from ..services.kernels.kernelmanager import ( AsyncMappingKernelManager, ServerKernelManager, emit_kernel_action_event, ) from ..services.sessions.sessionmanager import SessionManager from ..utils import url_path_join from .gateway_client import GatewayClient, gateway_request if TYPE_CHECKING: from logging import Logger class GatewayMappingKernelManager(AsyncMappingKernelManager): """Kernel manager that supports remote kernels hosted by Jupyter Kernel or Enterprise Gateway.""" # We'll maintain our own set of kernel ids _kernels: dict[str, GatewayKernelManager] = {} # type:ignore[assignment] @default("kernel_manager_class") def _default_kernel_manager_class(self): return "jupyter_server.gateway.managers.GatewayKernelManager" @default("shared_context") def _default_shared_context(self): return False # no need to share zmq contexts def __init__(self, **kwargs): """Initialize a gateway mapping kernel manager.""" super().__init__(**kwargs) self.kernels_url = url_path_join( GatewayClient.instance().url or "", GatewayClient.instance().kernels_endpoint or "" ) def remove_kernel(self, kernel_id): """Complete override since we want to be more tolerant of missing keys""" try: return self._kernels.pop(kernel_id) except KeyError: pass async def start_kernel(self, *, kernel_id=None, path=None, **kwargs): """Start a kernel for a session and return its kernel_id. Parameters ---------- kernel_id : uuid The uuid to associate the new kernel with. If this is not None, this kernel will be persistent whenever it is requested. path : API path The API path (unicode, '/' delimited) for the cwd. Will be transformed to an OS path relative to root_dir. """ self.log.info(f"Request start kernel: kernel_id={kernel_id}, path='{path}'") if kernel_id is None and path is not None: kwargs["cwd"] = self.cwd_for_path(path) km = self.kernel_manager_factory(parent=self, log=self.log) await km.start_kernel(kernel_id=kernel_id, **kwargs) kernel_id = km.kernel_id self._kernels[kernel_id] = km # Initialize culling if not already if not self._initialized_culler: self.initialize_culler() return kernel_id async def kernel_model(self, kernel_id): """Return a dictionary of kernel information described in the JSON standard model. Parameters ---------- kernel_id : uuid The uuid of the kernel. """ model = None km = self.get_kernel(str(kernel_id)) if km: # type:ignore[truthy-bool] model = km.kernel # type:ignore[attr-defined] return model async def list_kernels(self, **kwargs): """Get a list of running kernels from the Gateway server. We'll use this opportunity to refresh the models in each of the kernels we're managing. """ self.log.debug(f"Request list kernels: {self.kernels_url}") response = await gateway_request(self.kernels_url, method="GET") kernels = json_decode(response.body) # Refresh our models to those we know about, and filter # the return value with only our kernels. kernel_models = {} for model in kernels: kid = model["id"] if kid in self._kernels: await self._kernels[kid].refresh_model(model) kernel_models[kid] = model # Remove any of our kernels that may have been culled on the gateway server our_kernels = self._kernels.copy() culled_ids = [] for kid in our_kernels: if kid not in kernel_models: # The upstream kernel was not reported in the list of kernels. self.log.warning( f"Kernel {kid} not present in the list of kernels - possibly culled on Gateway server." ) try: # Try to directly refresh the model for this specific kernel in case # the upstream list of kernels was erroneously incomplete. # # That might happen if the case of a proxy that manages multiple # backends where there could be transient connectivity issues with # a single backend. # # Alternatively, it could happen if there is simply a bug in the # upstream gateway server. # # Either way, including this check improves our reliability in the # face of such scenarios. model = await self._kernels[kid].refresh_model() except web.HTTPError: model = None if model: kernel_models[kid] = model else: self.log.warning( f"Kernel {kid} no longer active - probably culled on Gateway server." ) self._kernels.pop(kid, None) culled_ids.append(kid) # TODO: Figure out what do with these. return list(kernel_models.values()) async def shutdown_kernel(self, kernel_id, now=False, restart=False): """Shutdown a kernel by its kernel uuid. Parameters ========== kernel_id : uuid The id of the kernel to shutdown. now : bool Shutdown the kernel immediately (True) or gracefully (False) restart : bool The purpose of this shutdown is to restart the kernel (True) """ km = self.get_kernel(kernel_id) await ensure_async(km.shutdown_kernel(now=now, restart=restart)) self.remove_kernel(kernel_id) async def restart_kernel(self, kernel_id, now=False, **kwargs): """Restart a kernel by its kernel uuid. Parameters ========== kernel_id : uuid The id of the kernel to restart. """ km = self.get_kernel(kernel_id) await ensure_async(km.restart_kernel(now=now, **kwargs)) async def interrupt_kernel(self, kernel_id, **kwargs): """Interrupt a kernel by its kernel uuid. Parameters ========== kernel_id : uuid The id of the kernel to interrupt. """ km = self.get_kernel(kernel_id) await ensure_async(km.interrupt_kernel()) async def shutdown_all(self, now=False): """Shutdown all kernels.""" kids = list(self._kernels) for kernel_id in kids: km = self.get_kernel(kernel_id) await ensure_async(km.shutdown_kernel(now=now)) self.remove_kernel(kernel_id) async def cull_kernels(self): """Override cull_kernels, so we can be sure their state is current.""" await self.list_kernels() await super().cull_kernels() class GatewayKernelSpecManager(KernelSpecManager): """A gateway kernel spec manager.""" def __init__(self, **kwargs): """Initialize a gateway kernel spec manager.""" super().__init__(**kwargs) base_endpoint = url_path_join( GatewayClient.instance().url or "", GatewayClient.instance().kernelspecs_endpoint ) self.base_endpoint = GatewayKernelSpecManager._get_endpoint_for_user_filter(base_endpoint) self.base_resource_endpoint = url_path_join( GatewayClient.instance().url or "", GatewayClient.instance().kernelspecs_resource_endpoint, ) @staticmethod def _get_endpoint_for_user_filter(default_endpoint): """Get the endpoint for a user filter.""" kernel_user = os.environ.get("KERNEL_USERNAME") if kernel_user: return f"{default_endpoint}?user={kernel_user}" return default_endpoint def _replace_path_kernelspec_resources(self, kernel_specs): """Helper method that replaces any gateway base_url with the server's base_url This enables clients to properly route through jupyter_server to a gateway for kernel resources such as logo files """ if not self.parent: return {} kernelspecs = kernel_specs["kernelspecs"] for kernel_name in kernelspecs: resources = kernelspecs[kernel_name]["resources"] for resource_name in resources: original_path = resources[resource_name] split_eg_base_url = str.rsplit(original_path, sep="/kernelspecs/", maxsplit=1) if len(split_eg_base_url) > 1: new_path = url_path_join( self.parent.base_url, "kernelspecs", split_eg_base_url[1] ) kernel_specs["kernelspecs"][kernel_name]["resources"][resource_name] = new_path if original_path != new_path: self.log.debug( f"Replaced original kernel resource path {original_path} with new " f"path {kernel_specs['kernelspecs'][kernel_name]['resources'][resource_name]}" ) return kernel_specs def _get_kernelspecs_endpoint_url(self, kernel_name=None): """Builds a url for the kernels endpoint Parameters ---------- kernel_name : kernel name (optional) """ if kernel_name: return url_path_join(self.base_endpoint, url_escape(kernel_name)) return self.base_endpoint async def get_all_specs(self): """Get all of the kernel specs for the gateway.""" fetched_kspecs = await self.list_kernel_specs() # get the default kernel name and compare to that of this server. # If different log a warning and reset the default. However, the # caller of this method will still return this server's value until # the next fetch of kernelspecs - at which time they'll match. if not self.parent: return {} km = self.parent.kernel_manager remote_default_kernel_name = fetched_kspecs.get("default") if remote_default_kernel_name != km.default_kernel_name: self.log.info( f"Default kernel name on Gateway server ({remote_default_kernel_name}) differs from " f"Notebook server ({km.default_kernel_name}). Updating to Gateway server's value." ) km.default_kernel_name = remote_default_kernel_name remote_kspecs = fetched_kspecs.get("kernelspecs") return remote_kspecs async def list_kernel_specs(self): """Get a list of kernel specs.""" kernel_spec_url = self._get_kernelspecs_endpoint_url() self.log.debug(f"Request list kernel specs at: {kernel_spec_url}") response = await gateway_request(kernel_spec_url, method="GET") kernel_specs = json_decode(response.body) kernel_specs = self._replace_path_kernelspec_resources(kernel_specs) return kernel_specs async def get_kernel_spec(self, kernel_name, **kwargs): """Get kernel spec for kernel_name. Parameters ---------- kernel_name : str The name of the kernel. """ kernel_spec_url = self._get_kernelspecs_endpoint_url(kernel_name=str(kernel_name)) self.log.debug(f"Request kernel spec at: {kernel_spec_url}") try: response = await gateway_request(kernel_spec_url, method="GET") except web.HTTPError as error: if error.status_code == 404: # Convert not found to KeyError since that's what the Notebook handler expects # message is not used, but might as well make it useful for troubleshooting msg = f"kernelspec {kernel_name} not found on Gateway server at: {GatewayClient.instance().url}" raise KeyError(msg) from None else: raise else: kernel_spec = json_decode(response.body) return kernel_spec async def get_kernel_spec_resource(self, kernel_name, path): """Get kernel spec for kernel_name. Parameters ---------- kernel_name : str The name of the kernel. path : str The name of the desired resource """ kernel_spec_resource_url = url_path_join( self.base_resource_endpoint, str(kernel_name), str(path) ) self.log.debug(f"Request kernel spec resource '{path}' at: {kernel_spec_resource_url}") try: response = await gateway_request(kernel_spec_resource_url, method="GET") except web.HTTPError as error: if error.status_code == 404: kernel_spec_resource = None else: raise else: kernel_spec_resource = response.body return kernel_spec_resource class GatewaySessionManager(SessionManager): """A gateway session manager.""" kernel_manager = Instance("jupyter_server.gateway.managers.GatewayMappingKernelManager") async def kernel_culled(self, kernel_id: str) -> bool: # typing: ignore """Checks if the kernel is still considered alive and returns true if it's not found.""" km: Optional[GatewayKernelManager] = None try: # Since we keep the models up-to-date via client polling, use that state to determine # if this kernel no longer exists on the gateway server rather than perform a redundant # fetch operation - especially since this is called at approximately the same interval. # This has the effect of reducing GET /api/kernels requests against the gateway server # by 50%! # Note that should the redundant polling be consolidated, or replaced with an event-based # notification model, this will need to be revisited. km = self.kernel_manager.get_kernel(kernel_id) except Exception: # Let exceptions here reflect culled kernel pass return km is None class GatewayKernelManager(ServerKernelManager): """Manages a single kernel remotely via a Gateway Server.""" kernel_id: Optional[str] = None # type:ignore[assignment] kernel = None @default("cache_ports") def _default_cache_ports(self): return False # no need to cache ports here def __init__(self, **kwargs): """Initialize the gateway kernel manager.""" super().__init__(**kwargs) self.kernels_url = url_path_join( GatewayClient.instance().url or "", GatewayClient.instance().kernels_endpoint ) self.kernel_url: str self.kernel = self.kernel_id = None # simulate busy/activity markers: self.execution_state = "starting" self.last_activity = utcnow() @property def has_kernel(self): """Has a kernel been started that we are managing.""" return self.kernel is not None client_class = DottedObjectName("jupyter_server.gateway.managers.GatewayKernelClient") client_factory = Type(klass="jupyter_server.gateway.managers.GatewayKernelClient") # -------------------------------------------------------------------------- # create a Client connected to our Kernel # -------------------------------------------------------------------------- def client(self, **kwargs): """Create a client configured to connect to our kernel""" kw: dict[str, Any] = {} kw.update(self.get_connection_info(session=True)) kw.update( { "connection_file": self.connection_file, "parent": self, } ) kw["kernel_id"] = self.kernel_id # add kwargs last, for manual overrides kw.update(kwargs) return self.client_factory(**kw) async def refresh_model(self, model=None): """Refresh the kernel model. Parameters ---------- model : dict The model from which to refresh the kernel. If None, the kernel model is fetched from the Gateway server. """ if model is None: self.log.debug("Request kernel at: %s" % self.kernel_url) try: response = await gateway_request(self.kernel_url, method="GET") except web.HTTPError as error: if error.status_code == 404: self.log.warning("Kernel not found at: %s" % self.kernel_url) model = None else: raise else: model = json_decode(response.body) self.log.debug("Kernel retrieved: %s" % model) if model: # Update activity markers self.last_activity = datetime.datetime.strptime( model["last_activity"], "%Y-%m-%dT%H:%M:%S.%fZ" ).replace(tzinfo=UTC) self.execution_state = model["execution_state"] if isinstance(self.parent, AsyncMappingKernelManager): # Update connections only if there's a mapping kernel manager parent for # this kernel manager. The current kernel manager instance may not have # a parent instance if, say, a server extension is using another application # (e.g., papermill) that uses a KernelManager instance directly. self.parent._kernel_connections[self.kernel_id] = int(model["connections"]) # type:ignore[index] self.kernel = model return model # -------------------------------------------------------------------------- # Kernel management # -------------------------------------------------------------------------- @emit_kernel_action_event( success_msg="Kernel {kernel_id} was started.", ) async def start_kernel(self, **kwargs): """Starts a kernel via HTTP in an asynchronous manner. Parameters ---------- `**kwargs` : optional keyword arguments that are passed down to build the kernel_cmd and launching the kernel (e.g. Popen kwargs). """ kernel_id = kwargs.get("kernel_id") if kernel_id is None: kernel_name = kwargs.get("kernel_name", "python3") self.log.debug("Request new kernel at: %s" % self.kernels_url) # Let KERNEL_USERNAME take precedent over http_user config option. if os.environ.get("KERNEL_USERNAME") is None and GatewayClient.instance().http_user: os.environ["KERNEL_USERNAME"] = GatewayClient.instance().http_user or "" payload_envs = os.environ.copy() payload_envs.update(kwargs.get("env", {})) # Add any env entries in this request # Build the actual env payload, filtering allowed_envs and those starting with 'KERNEL_' kernel_env = { k: v for (k, v) in payload_envs.items() if k.startswith("KERNEL_") or k in GatewayClient.instance().allowed_envs.split(",") } # Convey the full path to where this notebook file is located. if kwargs.get("cwd") is not None and kernel_env.get("KERNEL_WORKING_DIR") is None: kernel_env["KERNEL_WORKING_DIR"] = kwargs["cwd"] json_body = json_encode({"name": kernel_name, "env": kernel_env}) response = await gateway_request( self.kernels_url, method="POST", headers={"Content-Type": "application/json"}, body=json_body, ) self.kernel = json_decode(response.body) self.kernel_id = self.kernel["id"] self.kernel_url = url_path_join(self.kernels_url, url_escape(str(self.kernel_id))) self.log.info(f"GatewayKernelManager started kernel: {self.kernel_id}, args: {kwargs}") else: self.kernel_id = kernel_id self.kernel_url = url_path_join(self.kernels_url, url_escape(str(self.kernel_id))) self.kernel = await self.refresh_model() self.log.info(f"GatewayKernelManager using existing kernel: {self.kernel_id}") @emit_kernel_action_event( success_msg="Kernel {kernel_id} was shutdown.", ) async def shutdown_kernel(self, now=False, restart=False): """Attempts to stop the kernel process cleanly via HTTP.""" if self.has_kernel: self.log.debug("Request shutdown kernel at: %s", self.kernel_url) try: response = await gateway_request(self.kernel_url, method="DELETE") self.log.debug("Shutdown kernel response: %d %s", response.code, response.reason) except web.HTTPError as error: if error.status_code == 404: self.log.debug("Shutdown kernel response: kernel not found (ignored)") else: raise @emit_kernel_action_event( success_msg="Kernel {kernel_id} was restarted.", ) async def restart_kernel(self, **kw): """Restarts a kernel via HTTP.""" if self.has_kernel: assert self.kernel_url is not None kernel_url = self.kernel_url + "/restart" self.log.debug("Request restart kernel at: %s", kernel_url) response = await gateway_request( kernel_url, method="POST", headers={"Content-Type": "application/json"}, body=json_encode({}), ) self.log.debug("Restart kernel response: %d %s", response.code, response.reason) @emit_kernel_action_event( success_msg="Kernel {kernel_id} was interrupted.", ) async def interrupt_kernel(self): """Interrupts the kernel via an HTTP request.""" if self.has_kernel: assert self.kernel_url is not None kernel_url = self.kernel_url + "/interrupt" self.log.debug("Request interrupt kernel at: %s", kernel_url) response = await gateway_request( kernel_url, method="POST", headers={"Content-Type": "application/json"}, body=json_encode({}), ) self.log.debug("Interrupt kernel response: %d %s", response.code, response.reason) async def is_alive(self): """Is the kernel process still running?""" if self.has_kernel: # Go ahead and issue a request to get the kernel self.kernel = await self.refresh_model() self.log.debug(f"The kernel: {self.kernel} is alive.") return True else: # we don't have a kernel self.log.debug(f"The kernel: {self.kernel} no longer exists.") return False def cleanup_resources(self, restart=False): """Clean up resources when the kernel is shut down""" KernelManagerABC.register(GatewayKernelManager) class ChannelQueue(Queue): # type:ignore[type-arg] """A queue for a named channel.""" channel_name: Optional[str] = None response_router_finished: bool def __init__(self, channel_name: str, channel_socket: websocket.WebSocket, log: Logger): """Initialize a channel queue.""" super().__init__() self.channel_name = channel_name self.channel_socket = channel_socket self.log = log self.response_router_finished = False async def _async_get(self, timeout=None): """Asynchronously get from the queue.""" if timeout is None: timeout = float("inf") elif timeout < 0: msg = "'timeout' must be a non-negative number" raise ValueError(msg) end_time = monotonic() + timeout while True: try: return self.get(block=False) except Empty: if self.response_router_finished: msg = "Response router had finished" raise RuntimeError(msg) from None if monotonic() > end_time: raise await asyncio.sleep(0) async def get_msg(self, *args: Any, **kwargs: Any) -> dict[str, Any]: """Get a message from the queue.""" timeout = kwargs.get("timeout", 1) msg = await self._async_get(timeout=timeout) self.log.debug( "Received message on channel: %s, msg_id: %s, msg_type: %s", self.channel_name, msg["msg_id"], msg["msg_type"] if msg else "null", ) self.task_done() return cast("dict[str, Any]", msg) def send(self, msg: dict[str, Any]) -> None: """Send a message to the queue.""" message = json.dumps(msg, default=ChannelQueue.serialize_datetime).replace(" None: """Start the queue.""" def stop(self) -> None: """Stop the queue.""" if not self.empty(): # If unprocessed messages are detected, drain the queue collecting non-status # messages. If any remain that are not 'shutdown_reply' and this is not iopub # go ahead and issue a warning. msgs = [] while self.qsize(): msg = self.get_nowait() if msg["msg_type"] != "status": msgs.append(msg["msg_type"]) if self.channel_name == "iopub" and "shutdown_reply" in msgs: return if len(msgs): self.log.warning( f"Stopping channel '{self.channel_name}' with {len(msgs)} unprocessed non-status messages: {msgs}." ) def is_alive(self) -> bool: """Whether the queue is alive.""" return self.channel_socket is not None class HBChannelQueue(ChannelQueue): """A queue for the heartbeat channel.""" def is_beating(self) -> bool: """Whether the channel is beating.""" # Just use the is_alive status for now return self.is_alive() class GatewayKernelClient(AsyncKernelClient): """Communicates with a single kernel indirectly via a websocket to a gateway server. There are five channels associated with each kernel: * shell: for request/reply calls to the kernel. * iopub: for the kernel to publish results to frontends. * hb: for monitoring the kernel's heartbeat. * stdin: for frontends to reply to raw_input calls in the kernel. * control: for kernel management calls to the kernel. The messages that can be sent on these channels are exposed as methods of the client (KernelClient.execute, complete, history, etc.). These methods only send the message, they don't wait for a reply. To get results, use e.g. :meth:`get_shell_msg` to fetch messages from the shell channel. """ # flag for whether execute requests should be allowed to call raw_input: allow_stdin = False _channels_stopped: bool _channel_queues: Optional[dict[str, ChannelQueue]] _control_channel: Optional[ChannelQueue] # type:ignore[assignment] _hb_channel: Optional[ChannelQueue] # type:ignore[assignment] _stdin_channel: Optional[ChannelQueue] # type:ignore[assignment] _iopub_channel: Optional[ChannelQueue] # type:ignore[assignment] _shell_channel: Optional[ChannelQueue] # type:ignore[assignment] def __init__(self, kernel_id, **kwargs): """Initialize a gateway kernel client.""" super().__init__(**kwargs) self.kernel_id = kernel_id self.channel_socket: Optional[websocket.WebSocket] = None self.response_router: Optional[Thread] = None self._channels_stopped = False self._channel_queues = {} # -------------------------------------------------------------------------- # Channel management methods # -------------------------------------------------------------------------- async def start_channels(self, shell=True, iopub=True, stdin=True, hb=True, control=True): """Starts the channels for this kernel. For this class, we establish a websocket connection to the destination and set up the channel-based queues on which applicable messages will be posted. """ ws_url = url_path_join( GatewayClient.instance().ws_url or "", GatewayClient.instance().kernels_endpoint, url_escape(self.kernel_id), "channels", ) # Gather cert info in case where ssl is desired... ssl_options = { "ca_certs": GatewayClient.instance().ca_certs, "certfile": GatewayClient.instance().client_cert, "keyfile": GatewayClient.instance().client_key, } self.channel_socket = websocket.create_connection( ws_url, timeout=GatewayClient.instance().KERNEL_LAUNCH_TIMEOUT, enable_multithread=True, sslopt=ssl_options, ) await ensure_async( super().start_channels(shell=shell, iopub=iopub, stdin=stdin, hb=hb, control=control) ) self.response_router = Thread(target=self._route_responses) self.response_router.start() def stop_channels(self): """Stops all the running channels for this kernel. For this class, we close the websocket connection and destroy the channel-based queues. """ super().stop_channels() self._channels_stopped = True self.log.debug("Closing websocket connection") assert self.channel_socket is not None self.channel_socket.close() assert self.response_router is not None self.response_router.join() if self._channel_queues: self._channel_queues.clear() self._channel_queues = None # Channels are implemented via a ChannelQueue that is used to send and receive messages @property def shell_channel(self): """Get the shell channel object for this kernel.""" if self._shell_channel is None: self.log.debug("creating shell channel queue") assert self.channel_socket is not None self._shell_channel = ChannelQueue("shell", self.channel_socket, self.log) assert self._channel_queues is not None self._channel_queues["shell"] = self._shell_channel return self._shell_channel @property def iopub_channel(self): """Get the iopub channel object for this kernel.""" if self._iopub_channel is None: self.log.debug("creating iopub channel queue") assert self.channel_socket is not None self._iopub_channel = ChannelQueue("iopub", self.channel_socket, self.log) assert self._channel_queues is not None self._channel_queues["iopub"] = self._iopub_channel return self._iopub_channel @property def stdin_channel(self): """Get the stdin channel object for this kernel.""" if self._stdin_channel is None: self.log.debug("creating stdin channel queue") assert self.channel_socket is not None self._stdin_channel = ChannelQueue("stdin", self.channel_socket, self.log) assert self._channel_queues is not None self._channel_queues["stdin"] = self._stdin_channel return self._stdin_channel @property def hb_channel(self): """Get the hb channel object for this kernel.""" if self._hb_channel is None: self.log.debug("creating hb channel queue") assert self.channel_socket is not None self._hb_channel = HBChannelQueue("hb", self.channel_socket, self.log) assert self._channel_queues is not None self._channel_queues["hb"] = self._hb_channel return self._hb_channel @property def control_channel(self): """Get the control channel object for this kernel.""" if self._control_channel is None: self.log.debug("creating control channel queue") assert self.channel_socket is not None self._control_channel = ChannelQueue("control", self.channel_socket, self.log) assert self._channel_queues is not None self._channel_queues["control"] = self._control_channel return self._control_channel def _route_responses(self): """ Reads responses from the websocket and routes each to the appropriate channel queue based on the message's channel. It does this for the duration of the class's lifetime until the channels are stopped, at which time the socket is closed (unblocking the router) and the thread terminates. If shutdown happens to occur while processing a response (unlikely), termination takes place via the loop control boolean. """ try: while not self._channels_stopped: assert self.channel_socket is not None raw_message = self.channel_socket.recv() if not raw_message: break response_message = json_decode(utf8(raw_message)) channel = response_message["channel"] assert self._channel_queues is not None self._channel_queues[channel].put_nowait(response_message) except websocket.WebSocketConnectionClosedException: pass # websocket closure most likely due to shut down except BaseException as be: if not self._channels_stopped: self.log.warning(f"Unexpected exception encountered ({be})") # Notify channel queues that this thread had finished and no more messages are being received assert self._channel_queues is not None for channel_queue in self._channel_queues.values(): channel_queue.response_router_finished = True self.log.debug("Response router thread exiting...") KernelClientABC.register(GatewayKernelClient) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/000077500000000000000000000000001473126534200241625ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/README.md000066400000000000000000000131521473126534200254430ustar00rootroot00000000000000# Implementation Notes for Internationalization of Jupyter Notebook The implementation of i18n features for jupyter notebook is still a work-in-progress: - User interface strings are (mostly) handled - Console messages are not handled (their usefulness in a translated environment is questionable) - Tooling has to be refined However… ## How the language is selected ? 1. `jupyter notebook` command reads the `LANG` environment variable at startup, (`xx_XX` or just `xx` form, where `xx` is the language code you're wanting to run in). Hint: if running Windows, you can set it in PowerShell with `${Env:LANG} = "xx_XX"`. if running Ubuntu 14, you should set environment variable `LANGUAGE="xx_XX"`. 2. The preferred language for web pages in your browser settings (`xx`) is also used. At the moment, it has to be first in the list. ## Contributing and managing translations ### Requirements - _pybabel_ (could be installed `pip install babel`) - _po2json_ (could be installed with `npm install -g po2json`) **All i18n-related commands are done from the related directory :** ``` cd notebook/i18n/ ``` ### Message extraction The translatable material for notebook is split into 3 `.pot` files, as follows: - _notebook/i18n/notebook.pot_ - Console and startup messages, basically anything that is produced by Python code. - _notebook/i18n/nbui.pot_ - User interface strings, as extracted from the Jinja2 templates in _notebook/templates/\*.html_ - _noteook/i18n/nbjs.pot_ - JavaScript strings and dialogs, which contain much of the visible user interface for Jupyter notebook. To extract the messages from the source code whenever new material is added, use the `pybabel` command: ```shell pybabel extract -F babel_notebook.cfg -o notebook.pot --no-wrap --project Jupyter . pybabel extract -F babel_nbui.cfg -o nbui.pot --no-wrap --project Jupyter . pybabel extract -F babel_nbjs.cfg -o nbjs.pot --no-wrap --project Jupyter . ``` After this is complete you have 3 `.pot` files that you can give to a translator for your favorite language. ### Messages compilation After the source material has been translated, you should have 3 `.po` files with the same base names as the `.pot` files above. Put them in `notebook/i18n/${LANG}/LC_MESSAGES`, where `${LANG}` is the language code for your desired language ( i.e. German = "de", Japanese = "ja", etc. ). _notebook.po_ and _nbui.po_ need to be converted from `.po` to `.mo` format for use at runtime. ```shell pybabel compile -D notebook -f -l ${LANG} -i ${LANG}/LC_MESSAGES/notebook.po -o ${LANG}/LC_MESSAGES/notebook.mo pybabel compile -D nbui -f -l ${LANG} -i ${LANG}/LC_MESSAGES/nbui.po -o ${LANG}/LC_MESSAGES/nbui.mo ``` _nbjs.po_ needs to be converted to JSON for use within the JavaScript code, with _po2json_, as follows: ``` po2json -p -F -f jed1.x -d nbjs ${LANG}/LC_MESSAGES/nbjs.po ${LANG}/LC_MESSAGES/nbjs.json ``` When new languages get added, their language codes should be added to _notebook/i18n/nbjs.json_ under the `supported_languages` element. ### Tips for Jupyter developers The biggest "mistake" I found while doing i18n enablement was the habit of constructing UI messages from English "piece parts". For example, code like: ```javascript var msg = "Enter a new " + type + "name:"; ``` where `type` is either "file", "directory", or "notebook".... is problematic when doing translations, because the surrounding text may need to vary depending on the inserted word. In this case, you need to switch it and use complete phrases, as follows: ```javascript var rename_msg = function (type) { switch (type) { case "file": return _("Enter a new file name:"); case "directory": return _("Enter a new directory name:"); case "notebook": return _("Enter a new notebook name:"); default: return _("Enter a new name:"); } }; ``` Also you need to remember that adding an "s" or "es" to an English word to create the plural form doesn't translate well. Some languages have as many as 5 or 6 different plural forms for differing numbers, so using an API such as ngettext() is necessary in order to handle these cases properly. ### Known issues and future evolutions 1. Right now there are two different places where the desired language is set. At startup time, the Jupyter console's messages pay attention to the setting of the `${LANG}` environment variable as set in the shell at startup time. Unfortunately, this is also the time where the Jinja2 environment is set up, which means that the template stuff will always come from this setting. We really want to be paying attention to the browser's settings for the stuff that happens in the browser, so we need to be able to retrieve this information after the browser is started and somehow communicate this back to Jinja2. So far, I haven't yet figured out how to do this, which means that if the ${LANG} at startup doesn't match the browser's settings, you could potentially get a mix of languages in the UI ( never a good thing ). 1. We will need to decide if console messages should be translatable, and enable them if desired. 1. The keyboard shortcut editor was implemented after the i18n work was completed, so that portion does not have translation support at this time. 1. Babel's documentation has instructions on how to integrate messages extraction into your _setup.py_ so that eventually we can just do: ``` ./setup.py extract_messages ``` I hope to get this working at some point in the near future. 5. The conversions from `.po` to `.mo` probably can and should be done using `setup.py install`. Any questions or comments please let me know @JCEmmons on github (emmo@us.ibm.com) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/__init__.py000066400000000000000000000052351473126534200263000ustar00rootroot00000000000000"""Server functions for loading translations""" from __future__ import annotations import errno import json import re from collections import defaultdict from os.path import dirname from os.path import join as pjoin from typing import Any I18N_DIR = dirname(__file__) # Cache structure: # {'nbjs': { # Domain # 'zh-CN': { # Language code # : # ... # } # }} TRANSLATIONS_CACHE: dict[str, Any] = {"nbjs": {}} _accept_lang_re = re.compile( r""" (?P[a-zA-Z]{1,8}(-[a-zA-Z]{1,8})?) (\s*;\s*q\s*=\s* (?P[01](.\d+)?) )?""", re.VERBOSE, ) def parse_accept_lang_header(accept_lang): """Parses the 'Accept-Language' HTTP header. Returns a list of language codes in *ascending* order of preference (with the most preferred language last). """ by_q = defaultdict(list) for part in accept_lang.split(","): m = _accept_lang_re.match(part.strip()) if not m: continue lang, qvalue = m.group("lang", "qvalue") # Browser header format is zh-CN, gettext uses zh_CN lang = lang.replace("-", "_") qvalue = 1.0 if qvalue is None else float(qvalue) if qvalue == 0: continue # 0 means not accepted by_q[qvalue].append(lang) res = [] for _, langs in sorted(by_q.items()): res.extend(sorted(langs)) return res def load(language, domain="nbjs"): """Load translations from an nbjs.json file""" try: f = open(pjoin(I18N_DIR, language, "LC_MESSAGES", "nbjs.json"), encoding="utf-8") # noqa: SIM115 except OSError as e: if e.errno != errno.ENOENT: raise return {} with f: data = json.load(f) return data["locale_data"][domain] def cached_load(language, domain="nbjs"): """Load translations for one language, using in-memory cache if available""" domain_cache = TRANSLATIONS_CACHE[domain] try: return domain_cache[language] except KeyError: data = load(language, domain) domain_cache[language] = data return data def combine_translations(accept_language, domain="nbjs"): """Combine translations for multiple accepted languages. Returns data re-packaged in jed1.x format. """ lang_codes = parse_accept_lang_header(accept_language) combined: dict[str, Any] = {} for language in lang_codes: if language == "en": # en is default, all translations are in frontend. combined.clear() else: combined.update(cached_load(language, domain)) combined[""] = {"domain": "nbjs"} return {"domain": domain, "locale_data": {domain: combined}} jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/babel_nbui.cfg000066400000000000000000000001471473126534200267270ustar00rootroot00000000000000[jinja2: notebook/templates/**.html] encoding = utf-8 [extractors] jinja2 = jinja2.ext:babel_extract jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/babel_notebook.cfg000066400000000000000000000001021473126534200276010ustar00rootroot00000000000000[python: notebook/*.py] [python: notebook/services/contents/*.py] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/nbjs.json000066400000000000000000000002241473126534200260070ustar00rootroot00000000000000{ "domain": "nbjs", "supported_languages": ["zh-CN"], "locale_data": { "nbjs": { "": { "domain": "nbjs" } } } } jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/nbui.pot000066400000000000000000000356201473126534200256510ustar00rootroot00000000000000# Translations template for Jupyter. # Copyright (C) 2017 ORGANIZATION # This file is distributed under the same license as the Jupyter project. # FIRST AUTHOR , 2017. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: Jupyter VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2017-07-07 12:48-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.3.4\n" #: notebook/templates/404.html:3 msgid "You are requesting a page that does not exist!" msgstr "" #: notebook/templates/edit.html:37 msgid "current mode" msgstr "" #: notebook/templates/edit.html:48 notebook/templates/notebook.html:78 msgid "File" msgstr "" #: notebook/templates/edit.html:50 notebook/templates/tree.html:57 msgid "New" msgstr "" #: notebook/templates/edit.html:51 msgid "Save" msgstr "" #: notebook/templates/edit.html:52 notebook/templates/tree.html:36 msgid "Rename" msgstr "" #: notebook/templates/edit.html:53 notebook/templates/tree.html:38 msgid "Download" msgstr "" #: notebook/templates/edit.html:56 notebook/templates/notebook.html:131 #: notebook/templates/tree.html:41 msgid "Edit" msgstr "" #: notebook/templates/edit.html:58 msgid "Find" msgstr "" #: notebook/templates/edit.html:59 msgid "Find & Replace" msgstr "" #: notebook/templates/edit.html:61 msgid "Key Map" msgstr "" #: notebook/templates/edit.html:62 msgid "Default" msgstr "" #: notebook/templates/edit.html:63 msgid "Sublime Text" msgstr "" #: notebook/templates/edit.html:68 notebook/templates/notebook.html:159 #: notebook/templates/tree.html:40 msgid "View" msgstr "" #: notebook/templates/edit.html:70 notebook/templates/notebook.html:162 msgid "Show/Hide the logo and notebook title (above menu bar)" msgstr "" #: notebook/templates/edit.html:71 notebook/templates/notebook.html:163 msgid "Toggle Header" msgstr "" #: notebook/templates/edit.html:72 notebook/templates/notebook.html:171 msgid "Toggle Line Numbers" msgstr "" #: notebook/templates/edit.html:75 msgid "Language" msgstr "" #: notebook/templates/error.html:23 msgid "The error was:" msgstr "" #: notebook/templates/login.html:24 msgid "Password or token:" msgstr "" #: notebook/templates/login.html:26 msgid "Password:" msgstr "" #: notebook/templates/login.html:31 msgid "Log in" msgstr "" #: notebook/templates/login.html:39 msgid "No login available, you shouldn't be seeing this page." msgstr "" #: notebook/templates/logout.html:24 #, python-format msgid "Proceed to the dashboard" msgstr "" #: notebook/templates/logout.html:26 #, python-format msgid "Proceed to the login page" msgstr "" #: notebook/templates/notebook.html:62 msgid "Menu" msgstr "" #: notebook/templates/notebook.html:65 notebook/templates/notebook.html:254 msgid "Kernel" msgstr "" #: notebook/templates/notebook.html:68 msgid "This notebook is read-only" msgstr "" #: notebook/templates/notebook.html:81 msgid "New Notebook" msgstr "" #: notebook/templates/notebook.html:85 msgid "Opens a new window with the Dashboard view" msgstr "" #: notebook/templates/notebook.html:86 msgid "Open..." msgstr "" #: notebook/templates/notebook.html:90 msgid "Open a copy of this notebook's contents and start a new kernel" msgstr "" #: notebook/templates/notebook.html:91 msgid "Make a Copy..." msgstr "" #: notebook/templates/notebook.html:92 msgid "Rename..." msgstr "" #: notebook/templates/notebook.html:93 msgid "Save and Checkpoint" msgstr "" #: notebook/templates/notebook.html:96 msgid "Revert to Checkpoint" msgstr "" #: notebook/templates/notebook.html:106 msgid "Print Preview" msgstr "" #: notebook/templates/notebook.html:107 msgid "Download as" msgstr "" #: notebook/templates/notebook.html:109 msgid "Notebook (.ipynb)" msgstr "" #: notebook/templates/notebook.html:110 msgid "Script" msgstr "" #: notebook/templates/notebook.html:111 msgid "HTML (.html)" msgstr "" #: notebook/templates/notebook.html:112 msgid "Markdown (.md)" msgstr "" #: notebook/templates/notebook.html:113 msgid "reST (.rst)" msgstr "" #: notebook/templates/notebook.html:114 msgid "LaTeX (.tex)" msgstr "" #: notebook/templates/notebook.html:115 msgid "PDF via LaTeX (.pdf)" msgstr "" #: notebook/templates/notebook.html:118 msgid "Deploy as" msgstr "" #: notebook/templates/notebook.html:123 msgid "Trust the output of this notebook" msgstr "" #: notebook/templates/notebook.html:124 msgid "Trust Notebook" msgstr "" #: notebook/templates/notebook.html:127 msgid "Shutdown this notebook's kernel, and close this window" msgstr "" #: notebook/templates/notebook.html:128 msgid "Close and Halt" msgstr "" #: notebook/templates/notebook.html:133 msgid "Cut Cells" msgstr "" #: notebook/templates/notebook.html:134 msgid "Copy Cells" msgstr "" #: notebook/templates/notebook.html:135 msgid "Paste Cells Above" msgstr "" #: notebook/templates/notebook.html:136 msgid "Paste Cells Below" msgstr "" #: notebook/templates/notebook.html:137 msgid "Paste Cells & Replace" msgstr "" #: notebook/templates/notebook.html:138 msgid "Delete Cells" msgstr "" #: notebook/templates/notebook.html:139 msgid "Undo Delete Cells" msgstr "" #: notebook/templates/notebook.html:141 msgid "Split Cell" msgstr "" #: notebook/templates/notebook.html:142 msgid "Merge Cell Above" msgstr "" #: notebook/templates/notebook.html:143 msgid "Merge Cell Below" msgstr "" #: notebook/templates/notebook.html:145 msgid "Move Cell Up" msgstr "" #: notebook/templates/notebook.html:146 msgid "Move Cell Down" msgstr "" #: notebook/templates/notebook.html:148 msgid "Edit Notebook Metadata" msgstr "" #: notebook/templates/notebook.html:150 msgid "Find and Replace" msgstr "" #: notebook/templates/notebook.html:152 msgid "Cut Cell Attachments" msgstr "" #: notebook/templates/notebook.html:153 msgid "Copy Cell Attachments" msgstr "" #: notebook/templates/notebook.html:154 msgid "Paste Cell Attachments" msgstr "" #: notebook/templates/notebook.html:156 msgid "Insert Image" msgstr "" #: notebook/templates/notebook.html:166 msgid "Show/Hide the action icons (below menu bar)" msgstr "" #: notebook/templates/notebook.html:167 msgid "Toggle Toolbar" msgstr "" #: notebook/templates/notebook.html:170 msgid "Show/Hide line numbers in cells" msgstr "" #: notebook/templates/notebook.html:174 msgid "Cell Toolbar" msgstr "" #: notebook/templates/notebook.html:179 msgid "Insert" msgstr "" #: notebook/templates/notebook.html:182 msgid "Insert an empty Code cell above the currently active cell" msgstr "" #: notebook/templates/notebook.html:183 msgid "Insert Cell Above" msgstr "" #: notebook/templates/notebook.html:185 msgid "Insert an empty Code cell below the currently active cell" msgstr "" #: notebook/templates/notebook.html:186 msgid "Insert Cell Below" msgstr "" #: notebook/templates/notebook.html:189 msgid "Cell" msgstr "" #: notebook/templates/notebook.html:191 msgid "Run this cell, and move cursor to the next one" msgstr "" #: notebook/templates/notebook.html:192 msgid "Run Cells" msgstr "" #: notebook/templates/notebook.html:193 msgid "Run this cell, select below" msgstr "" #: notebook/templates/notebook.html:194 msgid "Run Cells and Select Below" msgstr "" #: notebook/templates/notebook.html:195 msgid "Run this cell, insert below" msgstr "" #: notebook/templates/notebook.html:196 msgid "Run Cells and Insert Below" msgstr "" #: notebook/templates/notebook.html:197 msgid "Run all cells in the notebook" msgstr "" #: notebook/templates/notebook.html:198 msgid "Run All" msgstr "" #: notebook/templates/notebook.html:199 msgid "Run all cells above (but not including) this cell" msgstr "" #: notebook/templates/notebook.html:200 msgid "Run All Above" msgstr "" #: notebook/templates/notebook.html:201 msgid "Run this cell and all cells below it" msgstr "" #: notebook/templates/notebook.html:202 msgid "Run All Below" msgstr "" #: notebook/templates/notebook.html:205 msgid "All cells in the notebook have a cell type. By default, new cells are created as 'Code' cells" msgstr "" #: notebook/templates/notebook.html:206 msgid "Cell Type" msgstr "" #: notebook/templates/notebook.html:209 msgid "Contents will be sent to the kernel for execution, and output will display in the footer of cell" msgstr "" #: notebook/templates/notebook.html:212 msgid "Contents will be rendered as HTML and serve as explanatory text" msgstr "" #: notebook/templates/notebook.html:213 notebook/templates/notebook.html:298 msgid "Markdown" msgstr "" #: notebook/templates/notebook.html:215 msgid "Contents will pass through nbconvert unmodified" msgstr "" #: notebook/templates/notebook.html:216 msgid "Raw NBConvert" msgstr "" #: notebook/templates/notebook.html:220 msgid "Current Outputs" msgstr "" #: notebook/templates/notebook.html:223 msgid "Hide/Show the output of the current cell" msgstr "" #: notebook/templates/notebook.html:224 notebook/templates/notebook.html:240 msgid "Toggle" msgstr "" #: notebook/templates/notebook.html:227 msgid "Scroll the output of the current cell" msgstr "" #: notebook/templates/notebook.html:228 notebook/templates/notebook.html:244 msgid "Toggle Scrolling" msgstr "" #: notebook/templates/notebook.html:231 msgid "Clear the output of the current cell" msgstr "" #: notebook/templates/notebook.html:232 notebook/templates/notebook.html:248 msgid "Clear" msgstr "" #: notebook/templates/notebook.html:236 msgid "All Output" msgstr "" #: notebook/templates/notebook.html:239 msgid "Hide/Show the output of all cells" msgstr "" #: notebook/templates/notebook.html:243 msgid "Scroll the output of all cells" msgstr "" #: notebook/templates/notebook.html:247 msgid "Clear the output of all cells" msgstr "" #: notebook/templates/notebook.html:257 msgid "Send Keyboard Interrupt (CTRL-C) to the Kernel" msgstr "" #: notebook/templates/notebook.html:258 msgid "Interrupt" msgstr "" #: notebook/templates/notebook.html:261 msgid "Restart the Kernel" msgstr "" #: notebook/templates/notebook.html:262 msgid "Restart" msgstr "" #: notebook/templates/notebook.html:265 msgid "Restart the Kernel and clear all output" msgstr "" #: notebook/templates/notebook.html:266 msgid "Restart & Clear Output" msgstr "" #: notebook/templates/notebook.html:269 msgid "Restart the Kernel and re-run the notebook" msgstr "" #: notebook/templates/notebook.html:270 msgid "Restart & Run All" msgstr "" #: notebook/templates/notebook.html:273 msgid "Reconnect to the Kernel" msgstr "" #: notebook/templates/notebook.html:274 msgid "Reconnect" msgstr "" #: notebook/templates/notebook.html:282 msgid "Change kernel" msgstr "" #: notebook/templates/notebook.html:287 msgid "Help" msgstr "" #: notebook/templates/notebook.html:290 msgid "A quick tour of the notebook user interface" msgstr "" #: notebook/templates/notebook.html:290 msgid "User Interface Tour" msgstr "" #: notebook/templates/notebook.html:291 msgid "Opens a tooltip with all keyboard shortcuts" msgstr "" #: notebook/templates/notebook.html:291 msgid "Keyboard Shortcuts" msgstr "" #: notebook/templates/notebook.html:292 msgid "Opens a dialog allowing you to edit Keyboard shortcuts" msgstr "" #: notebook/templates/notebook.html:292 msgid "Edit Keyboard Shortcuts" msgstr "" #: notebook/templates/notebook.html:297 msgid "Notebook Help" msgstr "" #: notebook/templates/notebook.html:303 msgid "Opens in a new window" msgstr "" #: notebook/templates/notebook.html:319 msgid "About Jupyter Notebook" msgstr "" #: notebook/templates/notebook.html:319 msgid "About" msgstr "" #: notebook/templates/page.html:114 msgid "Jupyter Notebook requires JavaScript." msgstr "" #: notebook/templates/page.html:115 msgid "Please enable it to proceed. " msgstr "" #: notebook/templates/page.html:121 msgid "dashboard" msgstr "" #: notebook/templates/page.html:132 msgid "Logout" msgstr "" #: notebook/templates/page.html:134 msgid "Login" msgstr "" #: notebook/templates/tree.html:23 msgid "Files" msgstr "" #: notebook/templates/tree.html:24 msgid "Running" msgstr "" #: notebook/templates/tree.html:25 msgid "Clusters" msgstr "" #: notebook/templates/tree.html:32 msgid "Select items to perform actions on them." msgstr "" #: notebook/templates/tree.html:35 msgid "Duplicate selected" msgstr "" #: notebook/templates/tree.html:35 msgid "Duplicate" msgstr "" #: notebook/templates/tree.html:36 msgid "Rename selected" msgstr "" #: notebook/templates/tree.html:37 msgid "Move selected" msgstr "" #: notebook/templates/tree.html:37 msgid "Move" msgstr "" #: notebook/templates/tree.html:38 msgid "Download selected" msgstr "" #: notebook/templates/tree.html:39 msgid "Shutdown selected notebook(s)" msgstr "" #: notebook/templates/notebook.html:278 #: notebook/templates/tree.html:39 msgid "Shutdown" msgstr "" #: notebook/templates/tree.html:40 msgid "View selected" msgstr "" #: notebook/templates/tree.html:41 msgid "Edit selected" msgstr "" #: notebook/templates/tree.html:42 msgid "Delete selected" msgstr "" #: notebook/templates/tree.html:50 msgid "Click to browse for a file to upload." msgstr "" #: notebook/templates/tree.html:51 msgid "Upload" msgstr "" #: notebook/templates/tree.html:65 msgid "Text File" msgstr "" #: notebook/templates/tree.html:68 msgid "Folder" msgstr "" #: notebook/templates/tree.html:72 msgid "Terminal" msgstr "" #: notebook/templates/tree.html:76 msgid "Terminals Unavailable" msgstr "" #: notebook/templates/tree.html:82 msgid "Refresh notebook list" msgstr "" #: notebook/templates/tree.html:90 msgid "Select All / None" msgstr "" #: notebook/templates/tree.html:93 msgid "Select..." msgstr "" #: notebook/templates/tree.html:98 msgid "Select All Folders" msgstr "" #: notebook/templates/tree.html:98 msgid " Folders" msgstr "" #: notebook/templates/tree.html:99 msgid "Select All Notebooks" msgstr "" #: notebook/templates/tree.html:99 msgid " All Notebooks" msgstr "" #: notebook/templates/tree.html:100 msgid "Select Running Notebooks" msgstr "" #: notebook/templates/tree.html:100 msgid " Running" msgstr "" #: notebook/templates/tree.html:101 msgid "Select All Files" msgstr "" #: notebook/templates/tree.html:101 msgid " Files" msgstr "" #: notebook/templates/tree.html:114 msgid "Last Modified" msgstr "" #: notebook/templates/tree.html:120 msgid "Name" msgstr "" #: notebook/templates/tree.html:130 msgid "Currently running Jupyter processes" msgstr "" #: notebook/templates/tree.html:134 msgid "Refresh running list" msgstr "" #: notebook/templates/tree.html:150 msgid "There are no terminals running." msgstr "" #: notebook/templates/tree.html:152 msgid "Terminals are unavailable." msgstr "" #: notebook/templates/tree.html:162 msgid "Notebooks" msgstr "" #: notebook/templates/tree.html:169 msgid "There are no notebooks running." msgstr "" #: notebook/templates/tree.html:178 msgid "Clusters tab is now provided by IPython parallel." msgstr "" #: notebook/templates/tree.html:179 msgid "See 'IPython parallel' for installation details." msgstr "" jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/notebook.pot000066400000000000000000000267001473126534200265330ustar00rootroot00000000000000# Translations template for Jupyter. # Copyright (C) 2017 ORGANIZATION # This file is distributed under the same license as the Jupyter project. # FIRST AUTHOR , 2017. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: Jupyter VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2017-07-08 21:52-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.3.4\n" #: jupyter_server/serverapp.py:53 msgid "The Jupyter Server requires tornado >= 4.0" msgstr "" #: jupyter_server/serverapp.py:57 msgid "The Jupyter Server requires tornado >= 4.0, but you have < 1.1.0" msgstr "" #: jupyter_server/serverapp.py:59 #, python-format msgid "The Jupyter Server requires tornado >= 4.0, but you have %s" msgstr "" #: jupyter_server/serverapp.py:389 msgid "List currently running Jupyter servers." msgstr "" #: jupyter_server/serverapp.py:393 msgid "Produce machine-readable JSON output." msgstr "" #: jupyter_server/serverapp.py:397 msgid "If True, each line of output will be a JSON object with the details from the server info file." msgstr "" #: jupyter_server/serverapp.py:402 msgid "Currently running servers:" msgstr "" #: jupyter_server/serverapp.py:419 msgid "Don't open the jupyter_server in a browser after startup." msgstr "" #: jupyter_server/serverapp.py:423 msgid "DISABLED: use %pylab or %matplotlib in the notebook to enable matplotlib." msgstr "" #: jupyter_server/serverapp.py:439 msgid "Allow the server to be run from root user." msgstr "" #: jupyter_server/serverapp.py:470 msgid "" "The Jupyter Server.\n" " \n" " This launches a Tornado-based Jupyter Server." msgstr "" #: jupyter_server/serverapp.py:540 msgid "Set the Access-Control-Allow-Credentials: true header" msgstr "" #: jupyter_server/serverapp.py:544 msgid "Whether to allow the user to run the Jupyter server as root." msgstr "" #: jupyter_server/serverapp.py:548 msgid "The default URL to redirect to from `/`" msgstr "" #: jupyter_server/serverapp.py:552 msgid "The IP address the Jupyter server will listen on." msgstr "" #: jupyter_server/serverapp.py:565 #, python-format msgid "" "Cannot bind to localhost, using 127.0.0.1 as default ip\n" "%s" msgstr "" #: jupyter_server/serverapp.py:579 msgid "The port the Jupyter server will listen on." msgstr "" #: jupyter_server/serverapp.py:583 msgid "The number of additional ports to try if the specified port is not available." msgstr "" #: jupyter_server/serverapp.py:587 msgid "The full path to an SSL/TLS certificate file." msgstr "" #: jupyter_server/serverapp.py:591 msgid "The full path to a private key file for usage with SSL/TLS." msgstr "" #: jupyter_server/serverapp.py:595 msgid "The full path to a certificate authority certificate for SSL/TLS client authentication." msgstr "" #: jupyter_server/serverapp.py:599 msgid "The file where the cookie secret is stored." msgstr "" #: jupyter_server/serverapp.py:628 #, python-format msgid "Writing Jupyter server cookie secret to %s" msgstr "" #: jupyter_server/serverapp.py:635 #, python-format msgid "Could not set permissions on %s" msgstr "" #: jupyter_server/serverapp.py:640 msgid "" "Token used for authenticating first-time connections to the server.\n" "\n" " When no password is enabled,\n" " the default is to generate a new, random token.\n" "\n" " Setting to an empty string disables authentication altogether, which is NOT RECOMMENDED.\n" " " msgstr "" #: jupyter_server/serverapp.py:650 msgid "" "One-time token used for opening a browser.\n" " Once used, this token cannot be used again.\n" " " msgstr "" #: jupyter_server/serverapp.py:726 msgid "" "Specify Where to open the server on startup. This is the\n" " `new` argument passed to the standard library method `webbrowser.open`.\n" " The behaviour is not guaranteed, but depends on browser support. Valid\n" " values are:\n" " 2 opens a new tab,\n" " 1 opens a new window,\n" " 0 opens in an existing window.\n" " See the `webbrowser.open` documentation for details.\n" " " msgstr "" #: jupyter_server/serverapp.py:742 msgid "" "\n" " webapp_settings is deprecated, use tornado_settings.\n" msgstr "" #: jupyter_server/serverapp.py:746 msgid "Supply overrides for the tornado.web.Application that the Jupyter server uses." msgstr "" #: jupyter_server/serverapp.py:750 msgid "" "\n" " Set the tornado compression options for websocket connections.\n" "\n" " This value will be returned from :meth:`WebSocketHandler.get_compression_options`.\n" " None (default) will disable compression.\n" " A dict (even an empty one) will enable compression.\n" "\n" " See the tornado docs for WebSocketHandler.get_compression_options for details.\n" " " msgstr "" #: jupyter_server/serverapp.py:761 msgid "Supply overrides for terminado. Currently only supports \"shell_command\"." msgstr "" #: jupyter_server/serverapp.py:764 msgid "Extra keyword arguments to pass to `set_secure_cookie`. See tornado's set_secure_cookie docs for details." msgstr "" #: jupyter_server/serverapp.py:768 msgid "" "Supply SSL options for the tornado HTTPServer.\n" " See the tornado docs for details." msgstr "" #: jupyter_server/serverapp.py:772 msgid "Supply extra arguments that will be passed to Jinja environment." msgstr "" #: jupyter_server/serverapp.py:776 msgid "Extra variables to supply to jinja templates when rendering." msgstr "" #: jupyter_server/serverapp.py:816 msgid "base_project_url is deprecated, use base_url" msgstr "" #: jupyter_server/serverapp.py:832 msgid "Path to search for custom.js, css" msgstr "" #: jupyter_server/serverapp.py:844 msgid "" "Extra paths to search for serving jinja templates.\n" "\n" " Can be used to override templates from jupyter_server.templates." msgstr "" #: jupyter_server/serverapp.py:900 #, python-format msgid "Using MathJax: %s" msgstr "" #: jupyter_server/serverapp.py:903 msgid "The MathJax.js configuration file that is to be used." msgstr "" #: jupyter_server/serverapp.py:908 #, python-format msgid "Using MathJax configuration file: %s" msgstr "" #: jupyter_server/serverapp.py:920 msgid "The kernel manager class to use." msgstr "" #: jupyter_server/serverapp.py:926 msgid "The session manager class to use." msgstr "" #: jupyter_server/serverapp.py:932 msgid "The config manager class to use" msgstr "" #: jupyter_server/serverapp.py:953 msgid "The login handler class to use." msgstr "" #: jupyter_server/serverapp.py:960 msgid "The logout handler class to use." msgstr "" #: jupyter_server/serverapp.py:964 msgid "Whether to trust or not X-Scheme/X-Forwarded-Proto and X-Real-Ip/X-Forwarded-For headerssent by the upstream reverse proxy. Necessary if the proxy handles SSL" msgstr "" #: jupyter_server/serverapp.py:976 msgid "" "\n" " DISABLED: use %pylab or %matplotlib in the notebook to enable matplotlib.\n" " " msgstr "" #: jupyter_server/serverapp.py:988 msgid "Support for specifying --pylab on the command line has been removed." msgstr "" #: jupyter_server/serverapp.py:990 msgid "Please use `%pylab{0}` or `%matplotlib{0}` in the notebook itself." msgstr "" #: jupyter_server/serverapp.py:995 msgid "The directory to use for notebooks and kernels." msgstr "" #: jupyter_server/serverapp.py:1018 #, python-format msgid "No such notebook dir: '%r'" msgstr "" #: jupyter_server/serverapp.py:1036 msgid "server_extensions is deprecated, use jpserver_extensions" msgstr "" #: jupyter_server/serverapp.py:1040 msgid "Dict of Python modules to load as notebook server extensions. Entry values can be used to enable and disable the loading of the extensions. The extensions will be loaded in alphabetical order." msgstr "" #: jupyter_server/serverapp.py:1049 msgid "Reraise exceptions encountered loading server extensions?" msgstr "" #: jupyter_server/serverapp.py:1052 msgid "" "(msgs/sec)\n" " Maximum rate at which messages can be sent on iopub before they are\n" " limited." msgstr "" #: jupyter_server/serverapp.py:1056 msgid "" "(bytes/sec)\n" " Maximum rate at which stream output can be sent on iopub before they are\n" " limited." msgstr "" #: jupyter_server/serverapp.py:1060 msgid "" "(sec) Time window used to \n" " check the message and data rate limits." msgstr "" #: jupyter_server/serverapp.py:1071 #, python-format msgid "No such file or directory: %s" msgstr "" #: jupyter_server/serverapp.py:1141 msgid "Notebook servers are configured to only be run with a password." msgstr "" #: jupyter_server/serverapp.py:1142 msgid "Hint: run the following command to set a password" msgstr "" #: jupyter_server/serverapp.py:1143 msgid "\t$ python -m jupyter_server.auth password" msgstr "" #: jupyter_server/serverapp.py:1181 #, python-format msgid "The port %i is already in use, trying another port." msgstr "" #: jupyter_server/serverapp.py:1184 #, python-format msgid "Permission to listen on port %i denied" msgstr "" #: jupyter_server/serverapp.py:1193 msgid "ERROR: the Jupyter server could not be started because no available port could be found." msgstr "" #: jupyter_server/serverapp.py:1199 msgid "[all ip addresses on your system]" msgstr "" #: jupyter_server/serverapp.py:1223 #, python-format msgid "Terminals not available (error was %s)" msgstr "" #: jupyter_server/serverapp.py:1259 msgid "interrupted" msgstr "" #: jupyter_server/serverapp.py:1261 msgid "y" msgstr "" #: jupyter_server/serverapp.py:1262 msgid "n" msgstr "" #: jupyter_server/serverapp.py:1263 #, python-format msgid "Shutdown this notebook server (%s/[%s])? " msgstr "" #: jupyter_server/serverapp.py:1269 msgid "Shutdown confirmed" msgstr "" #: jupyter_server/serverapp.py:1273 msgid "No answer for 5s:" msgstr "" #: jupyter_server/serverapp.py:1274 msgid "resuming operation..." msgstr "" #: jupyter_server/serverapp.py:1282 #, python-format msgid "received signal %s, stopping" msgstr "" #: jupyter_server/serverapp.py:1338 #, python-format msgid "Error loading server extension %s" msgstr "" #: jupyter_server/serverapp.py:1369 #, python-format msgid "Shutting down %d kernels" msgstr "" #: jupyter_server/serverapp.py:1375 #, python-format msgid "%d active kernel" msgid_plural "%d active kernels" msgstr[0] "" msgstr[1] "" #: jupyter_server/serverapp.py:1379 #, python-format msgid "" "The Jupyter Notebook is running at:\n" "\r" "%s" msgstr "" #: jupyter_server/serverapp.py:1426 msgid "Running as root is not recommended. Use --allow-root to bypass." msgstr "" #: jupyter_server/serverapp.py:1432 msgid "Use Control-C to stop this server and shut down all kernels (twice to skip confirmation)." msgstr "" #: jupyter_server/serverapp.py:1434 msgid "Welcome to Project Jupyter! Explore the various tools available and their corresponding documentation. If you are interested in contributing to the platform, please visit the communityresources section at http://jupyter.org/community.html." msgstr "" #: jupyter_server/serverapp.py:1445 #, python-format msgid "No web browser found: %s." msgstr "" #: jupyter_server/serverapp.py:1450 #, python-format msgid "%s does not exist" msgstr "" #: jupyter_server/serverapp.py:1484 msgid "Interrupted..." msgstr "" #: jupyter_server/services/contents/filemanager.py:506 #, python-format msgid "Serving notebooks from local directory: %s" msgstr "" #: jupyter_server/services/contents/manager.py:68 msgid "Untitled" msgstr "" jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/zh_CN/000077500000000000000000000000001473126534200251635ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/zh_CN/LC_MESSAGES/000077500000000000000000000000001473126534200267505ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/zh_CN/LC_MESSAGES/nbui.po000066400000000000000000000440331473126534200302510ustar00rootroot00000000000000# Translations template for Jupyter. # Copyright (C) 2017 ORGANIZATION # This file is distributed under the same license as the Jupyter project. # FIRST AUTHOR , 2017. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: Jupyter VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2017-08-25 02:53-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.5.0\n" #: notebook/templates/404.html:3 msgid "You are requesting a page that does not exist!" msgstr "请求的代码不存在!" #: notebook/templates/edit.html:37 msgid "current mode" msgstr "当前模式" #: notebook/templates/edit.html:48 notebook/templates/notebook.html:78 msgid "File" msgstr "文件" #: notebook/templates/edit.html:50 notebook/templates/tree.html:57 msgid "New" msgstr "新建" #: notebook/templates/edit.html:51 msgid "Save" msgstr "保存" #: notebook/templates/edit.html:52 notebook/templates/tree.html:36 msgid "Rename" msgstr "重命名" #: notebook/templates/edit.html:53 notebook/templates/tree.html:38 msgid "Download" msgstr "下载" #: notebook/templates/edit.html:56 notebook/templates/notebook.html:131 #: notebook/templates/tree.html:41 msgid "Edit" msgstr "编辑" #: notebook/templates/edit.html:58 msgid "Find" msgstr "查找" #: notebook/templates/edit.html:59 msgid "Find & Replace" msgstr "查找 & 替换" #: notebook/templates/edit.html:61 msgid "Key Map" msgstr "键值对" #: notebook/templates/edit.html:62 msgid "Default" msgstr "默认" #: notebook/templates/edit.html:63 msgid "Sublime Text" msgstr "代码编辑器" #: notebook/templates/edit.html:68 notebook/templates/notebook.html:159 #: notebook/templates/tree.html:40 msgid "View" msgstr "查看" #: notebook/templates/edit.html:70 notebook/templates/notebook.html:162 msgid "Show/Hide the logo and notebook title (above menu bar)" msgstr "显示/隐藏 标题和logo" #: notebook/templates/edit.html:71 notebook/templates/notebook.html:163 msgid "Toggle Header" msgstr "切换Header" #: notebook/templates/edit.html:72 notebook/templates/notebook.html:171 msgid "Toggle Line Numbers" msgstr "切换行号" #: notebook/templates/edit.html:75 msgid "Language" msgstr "语言" #: notebook/templates/error.html:23 msgid "The error was:" msgstr "错误:" #: notebook/templates/login.html:24 msgid "Password or token:" msgstr "密码或者token:" #: notebook/templates/login.html:26 msgid "Password:" msgstr "密码:" #: notebook/templates/login.html:31 msgid "Log in" msgstr "登录" #: notebook/templates/login.html:39 msgid "No login available, you shouldn't be seeing this page." msgstr "还没有登录, 请先登录." #: notebook/templates/logout.html:31 #, python-format msgid "Proceed to the dashboard" msgstr "进入 指示板" #: notebook/templates/logout.html:33 #, python-format msgid "Proceed to the login page" msgstr "进入 登录页面" #: notebook/templates/notebook.html:62 msgid "Menu" msgstr "菜单" #: notebook/templates/notebook.html:65 notebook/templates/notebook.html:254 msgid "Kernel" msgstr "服务" #: notebook/templates/notebook.html:68 msgid "This notebook is read-only" msgstr "这个代码是只读的" #: notebook/templates/notebook.html:81 msgid "New Notebook" msgstr "新建代码" #: notebook/templates/notebook.html:85 msgid "Opens a new window with the Dashboard view" msgstr "以仪表盘视角打开新的窗口" #: notebook/templates/notebook.html:86 msgid "Open..." msgstr "打开..." #: notebook/templates/notebook.html:90 msgid "Open a copy of this notebook's contents and start a new kernel" msgstr "打开代码内容的副本并启动一个新的服务" #: notebook/templates/notebook.html:91 msgid "Make a Copy..." msgstr "复制..." #: notebook/templates/notebook.html:92 msgid "Rename..." msgstr "重命名..." #: notebook/templates/notebook.html:93 msgid "Save and Checkpoint" msgstr "保存" #: notebook/templates/notebook.html:96 msgid "Revert to Checkpoint" msgstr "恢复" #: notebook/templates/notebook.html:106 msgid "Print Preview" msgstr "打印预览" #: notebook/templates/notebook.html:107 msgid "Download as" msgstr "下载" #: notebook/templates/notebook.html:109 msgid "Notebook (.ipynb)" msgstr "代码(.ipynb)" #: notebook/templates/notebook.html:110 msgid "Script" msgstr "脚本" #: notebook/templates/notebook.html:111 msgid "HTML (.html)" msgstr "" #: notebook/templates/notebook.html:112 msgid "Markdown (.md)" msgstr "" #: notebook/templates/notebook.html:113 msgid "reST (.rst)" msgstr "" #: notebook/templates/notebook.html:114 msgid "LaTeX (.tex)" msgstr "" #: notebook/templates/notebook.html:115 msgid "PDF via LaTeX (.pdf)" msgstr "" #: notebook/templates/notebook.html:118 msgid "Deploy as" msgstr "部署在" #: notebook/templates/notebook.html:123 msgid "Trust the output of this notebook" msgstr "信任代码的输出" #: notebook/templates/notebook.html:124 msgid "Trust Notebook" msgstr "信任代码" #: notebook/templates/notebook.html:127 msgid "Shutdown this notebook's kernel, and close this window" msgstr "关闭代码服务并关闭窗口" #: notebook/templates/notebook.html:128 msgid "Close and Halt" msgstr "关闭" #: notebook/templates/notebook.html:133 msgid "Cut Cells" msgstr "剪切代码块" #: notebook/templates/notebook.html:134 msgid "Copy Cells" msgstr "复制代码块" #: notebook/templates/notebook.html:135 msgid "Paste Cells Above" msgstr "粘贴到上面" #: notebook/templates/notebook.html:136 msgid "Paste Cells Below" msgstr "粘贴到下面" #: notebook/templates/notebook.html:137 msgid "Paste Cells & Replace" msgstr "粘贴代码块 & 替换" #: notebook/templates/notebook.html:138 msgid "Delete Cells" msgstr "删除代码块" #: notebook/templates/notebook.html:139 msgid "Undo Delete Cells" msgstr "撤销删除" #: notebook/templates/notebook.html:141 msgid "Split Cell" msgstr "分割代码块" #: notebook/templates/notebook.html:142 msgid "Merge Cell Above" msgstr "合并上面的代码块" #: notebook/templates/notebook.html:143 msgid "Merge Cell Below" msgstr "合并下面的代码块" #: notebook/templates/notebook.html:145 msgid "Move Cell Up" msgstr "上移代码块" #: notebook/templates/notebook.html:146 msgid "Move Cell Down" msgstr "下移代码块" #: notebook/templates/notebook.html:148 msgid "Edit Notebook Metadata" msgstr "编辑界面元数据" #: notebook/templates/notebook.html:150 msgid "Find and Replace" msgstr "查找并替换" #: notebook/templates/notebook.html:152 msgid "Cut Cell Attachments" msgstr "剪切附件" #: notebook/templates/notebook.html:153 msgid "Copy Cell Attachments" msgstr "复制附件" #: notebook/templates/notebook.html:154 msgid "Paste Cell Attachments" msgstr "粘贴附件" #: notebook/templates/notebook.html:156 msgid "Insert Image" msgstr "插入图片" #: notebook/templates/notebook.html:166 msgid "Show/Hide the action icons (below menu bar)" msgstr "显示/隐藏 操作图标" #: notebook/templates/notebook.html:167 msgid "Toggle Toolbar" msgstr "" #: notebook/templates/notebook.html:170 msgid "Show/Hide line numbers in cells" msgstr "显示/隐藏行号" #: notebook/templates/notebook.html:174 msgid "Cell Toolbar" msgstr "单元格工具栏" #: notebook/templates/notebook.html:179 msgid "Insert" msgstr "插入" #: notebook/templates/notebook.html:182 msgid "Insert an empty Code cell above the currently active cell" msgstr "在当前活动单元上插入一个空的代码单元格" #: notebook/templates/notebook.html:183 msgid "Insert Cell Above" msgstr "插入单元格上面" #: notebook/templates/notebook.html:185 msgid "Insert an empty Code cell below the currently active cell" msgstr "在当前活动单元下面插入一个空的代码单元格" #: notebook/templates/notebook.html:186 msgid "Insert Cell Below" msgstr "插入单元格下面" #: notebook/templates/notebook.html:189 msgid "Cell" msgstr "单元格" #: notebook/templates/notebook.html:191 msgid "Run this cell, and move cursor to the next one" msgstr "运行这个单元格,并将光标移到下一个" #: notebook/templates/notebook.html:192 msgid "Run Cells" msgstr "运行所有单元格" #: notebook/templates/notebook.html:193 msgid "Run this cell, select below" msgstr "运行此单元,选择以下选项" #: notebook/templates/notebook.html:194 msgid "Run Cells and Select Below" msgstr "运行单元格并自动选择下一个" #: notebook/templates/notebook.html:195 msgid "Run this cell, insert below" msgstr "运行单元格并选择以下" #: notebook/templates/notebook.html:196 msgid "Run Cells and Insert Below" msgstr "运行单元格并在下面插入" #: notebook/templates/notebook.html:197 msgid "Run all cells in the notebook" msgstr "运行所有的单元格" #: notebook/templates/notebook.html:198 msgid "Run All" msgstr "运行所有" #: notebook/templates/notebook.html:199 msgid "Run all cells above (but not including) this cell" msgstr "运行上面的所有单元(但不包括)这个单元格" #: notebook/templates/notebook.html:200 msgid "Run All Above" msgstr "运行上面的代码块" #: notebook/templates/notebook.html:201 msgid "Run this cell and all cells below it" msgstr "运行当前及以下代码块" #: notebook/templates/notebook.html:202 msgid "Run All Below" msgstr "运行下面的代码块" #: notebook/templates/notebook.html:205 msgid "All cells in the notebook have a cell type. By default, new cells are created as 'Code' cells" msgstr "代码里的所有单元格都有一个类型. 默认情况下, 新单元被创建为'Code'单元格" #: notebook/templates/notebook.html:206 msgid "Cell Type" msgstr "单元格类型" #: notebook/templates/notebook.html:209 msgid "Contents will be sent to the kernel for execution, and output will display in the footer of cell" msgstr "内容将被发送到内核以执行, 输出将显示在单元格的页脚." #: notebook/templates/notebook.html:212 msgid "Contents will be rendered as HTML and serve as explanatory text" msgstr "内容将以HTML形式呈现, 并作为解释性文本" #: notebook/templates/notebook.html:213 notebook/templates/notebook.html:298 msgid "Markdown" msgstr "标签" #: notebook/templates/notebook.html:215 msgid "Contents will pass through nbconvert unmodified" msgstr "内容将通过未经修改的nbconvert" #: notebook/templates/notebook.html:216 msgid "Raw NBConvert" msgstr "原生 NBConvert" #: notebook/templates/notebook.html:220 msgid "Current Outputs" msgstr "当前输出" #: notebook/templates/notebook.html:223 msgid "Hide/Show the output of the current cell" msgstr "隐藏/显示当前单元格输出" #: notebook/templates/notebook.html:224 notebook/templates/notebook.html:240 msgid "Toggle" msgstr "切换" #: notebook/templates/notebook.html:227 msgid "Scroll the output of the current cell" msgstr "滚动当前单元格的输出" #: notebook/templates/notebook.html:228 notebook/templates/notebook.html:244 msgid "Toggle Scrolling" msgstr "切换滚动" #: notebook/templates/notebook.html:231 msgid "Clear the output of the current cell" msgstr "清除当前单元格的输出" #: notebook/templates/notebook.html:232 notebook/templates/notebook.html:248 msgid "Clear" msgstr "清空" #: notebook/templates/notebook.html:236 msgid "All Output" msgstr "所有输出" #: notebook/templates/notebook.html:239 msgid "Hide/Show the output of all cells" msgstr "隐藏/显示 所有代码块的输出" #: notebook/templates/notebook.html:243 msgid "Scroll the output of all cells" msgstr "滚动所有单元格的输出" #: notebook/templates/notebook.html:247 msgid "Clear the output of all cells" msgstr "清空所有代码块的输出" #: notebook/templates/notebook.html:257 msgid "Send Keyboard Interrupt (CTRL-C) to the Kernel" msgstr "按下CTRL-C 中断服务" #: notebook/templates/notebook.html:258 msgid "Interrupt" msgstr "中断" #: notebook/templates/notebook.html:261 msgid "Restart the Kernel" msgstr "重启服务" #: notebook/templates/notebook.html:262 msgid "Restart" msgstr "重启" #: notebook/templates/notebook.html:265 msgid "Restart the Kernel and clear all output" msgstr "重启服务并清空所有输出" #: notebook/templates/notebook.html:266 msgid "Restart & Clear Output" msgstr "重启 & 清空输出" #: notebook/templates/notebook.html:269 msgid "Restart the Kernel and re-run the notebook" msgstr "重启服务并且重新运行代码" #: notebook/templates/notebook.html:270 msgid "Restart & Run All" msgstr "重启 & 运行所有" #: notebook/templates/notebook.html:273 msgid "Reconnect to the Kernel" msgstr "重新连接服务" #: notebook/templates/notebook.html:274 msgid "Reconnect" msgstr "重连" #: notebook/templates/notebook.html:282 msgid "Change kernel" msgstr "改变服务" #: notebook/templates/notebook.html:287 msgid "Help" msgstr "帮助" #: notebook/templates/notebook.html:290 msgid "A quick tour of the notebook user interface" msgstr "快速浏览一下notebook用户界面" #: notebook/templates/notebook.html:290 msgid "User Interface Tour" msgstr "用户界面之旅" #: notebook/templates/notebook.html:291 msgid "Opens a tooltip with all keyboard shortcuts" msgstr "打开所有快捷键提示信息" #: notebook/templates/notebook.html:291 msgid "Keyboard Shortcuts" msgstr "快捷键" #: notebook/templates/notebook.html:292 msgid "Opens a dialog allowing you to edit Keyboard shortcuts" msgstr "打开对话框编辑快捷键" #: notebook/templates/notebook.html:292 msgid "Edit Keyboard Shortcuts" msgstr "编辑快捷键" #: notebook/templates/notebook.html:297 msgid "Notebook Help" msgstr "帮助" #: notebook/templates/notebook.html:303 msgid "Opens in a new window" msgstr "在新窗口打开" #: notebook/templates/notebook.html:319 msgid "About Jupyter Notebook" msgstr "关于本程序" #: notebook/templates/notebook.html:319 msgid "About" msgstr "关于" #: notebook/templates/page.html:114 msgid "Jupyter Notebook requires JavaScript." msgstr "Jupyter Notebook需要的JavaScript." #: notebook/templates/page.html:115 msgid "Please enable it to proceed. " msgstr "请允许它继续." #: notebook/templates/page.html:122 msgid "dashboard" msgstr "指示板" #: notebook/templates/page.html:135 msgid "Logout" msgstr "注销" #: notebook/templates/page.html:137 msgid "Login" msgstr "登录" #: notebook/templates/tree.html:23 msgid "Files" msgstr "文件" #: notebook/templates/tree.html:24 msgid "Running" msgstr "运行" #: notebook/templates/tree.html:25 msgid "Clusters" msgstr "集群" #: notebook/templates/tree.html:32 msgid "Select items to perform actions on them." msgstr "选择操作对象." #: notebook/templates/tree.html:35 msgid "Duplicate selected" msgstr "复制选择的对象" #: notebook/templates/tree.html:35 msgid "Duplicate" msgstr "复制" #: notebook/templates/tree.html:36 msgid "Rename selected" msgstr "重命名" #: notebook/templates/tree.html:37 msgid "Move selected" msgstr "移动" #: notebook/templates/tree.html:37 msgid "Move" msgstr "移动" #: notebook/templates/tree.html:38 msgid "Download selected" msgstr "下载" #: notebook/templates/tree.html:39 msgid "Shutdown selected notebook(s)" msgstr "停止运行选择的notebook(s)" #: notebook/templates/notebook.html:278 #: notebook/templates/tree.html:39 msgid "Shutdown" msgstr "关闭" #: notebook/templates/tree.html:40 msgid "View selected" msgstr "查看" #: notebook/templates/tree.html:41 msgid "Edit selected" msgstr "编辑" #: notebook/templates/tree.html:42 msgid "Delete selected" msgstr "删除" #: notebook/templates/tree.html:50 msgid "Click to browse for a file to upload." msgstr "点击浏览文件上传" #: notebook/templates/tree.html:51 msgid "Upload" msgstr "上传" #: notebook/templates/tree.html:65 msgid "Text File" msgstr "文本文件" #: notebook/templates/tree.html:68 msgid "Folder" msgstr "文件夹" #: notebook/templates/tree.html:72 msgid "Terminal" msgstr "终端" #: notebook/templates/tree.html:76 msgid "Terminals Unavailable" msgstr "终端不可用" #: notebook/templates/tree.html:82 msgid "Refresh notebook list" msgstr "刷新笔记列表" #: notebook/templates/tree.html:90 msgid "Select All / None" msgstr "全选 / 全部选" #: notebook/templates/tree.html:93 msgid "Select..." msgstr "选择..." #: notebook/templates/tree.html:98 msgid "Select All Folders" msgstr "选择所有文件夹" #: notebook/templates/tree.html:98 msgid " Folders" msgstr "文件夹" #: notebook/templates/tree.html:99 msgid "Select All Notebooks" msgstr "选择所有笔记" #: notebook/templates/tree.html:99 msgid " All Notebooks" msgstr "所有笔记" #: notebook/templates/tree.html:100 msgid "Select Running Notebooks" msgstr "选择运行中的笔记" #: notebook/templates/tree.html:100 msgid " Running" msgstr "运行" #: notebook/templates/tree.html:101 msgid "Select All Files" msgstr "选择所有文件" #: notebook/templates/tree.html:101 msgid " Files" msgstr "文件" #: notebook/templates/tree.html:114 msgid "Last Modified" msgstr "最后修改" #: notebook/templates/tree.html:120 msgid "Name" msgstr "名字" #: notebook/templates/tree.html:130 msgid "Currently running Jupyter processes" msgstr "当前运行Jupyter" #: notebook/templates/tree.html:134 msgid "Refresh running list" msgstr "刷新运行列表" #: notebook/templates/tree.html:150 msgid "There are no terminals running." msgstr "没有终端运行" #: notebook/templates/tree.html:152 msgid "Terminals are unavailable." msgstr "终端不可用" #: notebook/templates/tree.html:162 msgid "Notebooks" msgstr "笔记" #: notebook/templates/tree.html:169 msgid "There are no notebooks running." msgstr "没有笔记正在运行" #: notebook/templates/tree.html:178 msgid "Clusters tab is now provided by IPython parallel." msgstr "集群标签现在由IPython并行提供." #: notebook/templates/tree.html:179 msgid "See 'IPython parallel' for installation details." msgstr "安装细节查看 'IPython parallel'." jupyter-server-jupyter_server-e5c7e2b/jupyter_server/i18n/zh_CN/LC_MESSAGES/notebook.po000066400000000000000000000337751473126534200311470ustar00rootroot00000000000000# Translations template for Jupyter. # Copyright (C) 2017 ORGANIZATION # This file is distributed under the same license as the Jupyter project. # FIRST AUTHOR , 2017. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: Jupyter VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2017-08-25 02:53-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.5.0\n" #: notebook/serverapp.py:49 msgid "The Jupyter Notebook requires tornado >= 4.0" msgstr "该程序要求 tornado 版本 >= 4.0" #: notebook/serverapp.py:53 msgid "The Jupyter Notebook requires tornado >= 4.0, but you have < 1.1.0" msgstr "该程序要求 tornado 版本 >= 4.0, 可是现实却是 < 1.1.0" #: notebook/serverapp.py:55 #, python-format msgid "The Jupyter Notebook requires tornado >= 4.0, but you have %s" msgstr "该程序要求 tornado 版本 >= 4.0, 可是现实却是 %s" #: notebook/serverapp.py:206 #, python-format msgid "Alternatively use `%s` when working on the notebook's Javascript and LESS" msgstr "在使用notebook的JavaScript和LESS时,可以替换使用 `%s` " #: notebook/serverapp.py:385 msgid "List currently running notebook servers." msgstr "列出当前运行的Notebook服务." #: notebook/serverapp.py:389 msgid "Produce machine-readable JSON list output." msgstr "生成机器可读的JSON输出." #: notebook/serverapp.py:391 msgid "Produce machine-readable JSON object on each line of output." msgstr "当前运行的服务" #: notebook/serverapp.py:395 msgid "If True, the output will be a JSON list of objects, one per active notebook server, each with the details from the relevant server info file." msgstr "如果是正确的,输出将是一个对象的JSON列表,一个活动的笔记本服务器,每一个都有相关的服务器信息文件的详细信息。" #: notebook/serverapp.py:399 msgid "If True, each line of output will be a JSON object with the details from the server info file. For a JSON list output, see the NbserverListApp.jsonlist configuration value" msgstr "如果是正确的,每一行输出将是一个JSON对象,其中有来自服务器信息文件的详细信息。对于一个JSON列表输出,请参阅NbserverListApp。jsonlist配置值" #: notebook/serverapp.py:425 msgid "Don't open the notebook in a browser after startup." msgstr "在启动服务以后不在浏览器中打开一个窗口." #: notebook/serverapp.py:429 msgid "DISABLED: use %pylab or %matplotlib in the notebook to enable matplotlib." msgstr "" #: notebook/serverapp.py:445 msgid "Allow the notebook to be run from root user." msgstr "允许notebook在root用户下运行." #: notebook/serverapp.py:476 msgid "" "The Jupyter HTML Notebook.\n" " \n" " This launches a Tornado based HTML Notebook Server that serves up an HTML5/Javascript Notebook client." msgstr "The Jupyter HTML Notebook.\n \n 这将启动一个基于tornado的HTML笔记本服务器,它提供一个html5/javascript笔记本客户端。" #: notebook/serverapp.py:546 msgid "Set the Access-Control-Allow-Credentials: true header" msgstr "设置Access-Control-Allow-Credentials:true报头" #: notebook/serverapp.py:550 msgid "Whether to allow the user to run the notebook as root." msgstr "是否允许notebook在root用户下运行." #: notebook/serverapp.py:554 msgid "The default URL to redirect to from `/`" msgstr "从 `/` 重定向到的默认URL " #: notebook/serverapp.py:558 msgid "The IP address the notebook server will listen on." msgstr "notebook服务会监听的IP地址." #: notebook/serverapp.py:571 #, python-format msgid "" "Cannot bind to localhost, using 127.0.0.1 as default ip\n" "%s" msgstr "不能绑定到localhost, 使用127.0.0.1作为默认的IP \n %s" #: notebook/serverapp.py:585 msgid "The port the notebook server will listen on." msgstr "notebook服务会监听的IP端口." #: notebook/serverapp.py:589 msgid "The number of additional ports to try if the specified port is not available." msgstr "如果指定的端口不可用,则要尝试其他端口的数量." #: notebook/serverapp.py:593 msgid "The full path to an SSL/TLS certificate file." msgstr "SSL/TLS 认证文件所在全路径." #: notebook/serverapp.py:597 msgid "The full path to a private key file for usage with SSL/TLS." msgstr "SSL/TLS 私钥文件所在全路径." #: notebook/serverapp.py:601 msgid "The full path to a certificate authority certificate for SSL/TLS client authentication." msgstr "用于ssl/tls客户端身份验证的证书颁发证书的完整路径." #: notebook/serverapp.py:605 msgid "The file where the cookie secret is stored." msgstr "存放cookie密钥的文件被保存了." #: notebook/serverapp.py:634 #, python-format msgid "Writing notebook server cookie secret to %s" msgstr "把notebook 服务cookie密码写入 %s" #: notebook/serverapp.py:641 #, python-format msgid "Could not set permissions on %s" msgstr "不能在 %s 设置权限" #: notebook/serverapp.py:646 msgid "" "Token used for authenticating first-time connections to the server.\n" "\n" " When no password is enabled,\n" " the default is to generate a new, random token.\n" "\n" " Setting to an empty string disables authentication altogether, which is NOT RECOMMENDED.\n" " " msgstr "" #: notebook/serverapp.py:656 msgid "" "One-time token used for opening a browser.\n" " Once used, this token cannot be used again.\n" " " msgstr "" #: notebook/serverapp.py:732 msgid "" "Specify Where to open the notebook on startup. This is the\n" " `new` argument passed to the standard library method `webbrowser.open`.\n" " The behaviour is not guaranteed, but depends on browser support. Valid\n" " values are:\n" " 2 opens a new tab,\n" " 1 opens a new window,\n" " 0 opens in an existing window.\n" " See the `webbrowser.open` documentation for details.\n" " " msgstr "" #: notebook/serverapp.py:752 msgid "Supply overrides for the tornado.web.Application that the Jupyter notebook uses." msgstr "" #: notebook/serverapp.py:756 msgid "" "\n" " Set the tornado compression options for websocket connections.\n" "\n" " This value will be returned from :meth:`WebSocketHandler.get_compression_options`.\n" " None (default) will disable compression.\n" " A dict (even an empty one) will enable compression.\n" "\n" " See the tornado docs for WebSocketHandler.get_compression_options for details.\n" " " msgstr "" #: notebook/serverapp.py:767 msgid "Supply overrides for terminado. Currently only supports \"shell_command\"." msgstr "" #: notebook/serverapp.py:770 msgid "Extra keyword arguments to pass to `set_secure_cookie`. See tornado's set_secure_cookie docs for details." msgstr "" #: notebook/serverapp.py:774 msgid "" "Supply SSL options for the tornado HTTPServer.\n" " See the tornado docs for details." msgstr "" #: notebook/serverapp.py:778 msgid "Supply extra arguments that will be passed to Jinja environment." msgstr "" #: notebook/serverapp.py:782 msgid "Extra variables to supply to jinja templates when rendering." msgstr "" #: notebook/serverapp.py:838 msgid "Path to search for custom.js, css" msgstr "" #: notebook/serverapp.py:850 msgid "" "Extra paths to search for serving jinja templates.\n" "\n" " Can be used to override templates from notebook.templates." msgstr "" #: notebook/serverapp.py:861 msgid "extra paths to look for Javascript notebook extensions" msgstr "" #: notebook/serverapp.py:906 #, python-format msgid "Using MathJax: %s" msgstr "" #: notebook/serverapp.py:909 msgid "The MathJax.js configuration file that is to be used." msgstr "" #: notebook/serverapp.py:914 #, python-format msgid "Using MathJax configuration file: %s" msgstr "" #: notebook/serverapp.py:920 msgid "The notebook manager class to use." msgstr "" #: notebook/serverapp.py:926 msgid "The kernel manager class to use." msgstr "" #: notebook/serverapp.py:932 msgid "The session manager class to use." msgstr "" #: notebook/serverapp.py:938 msgid "The config manager class to use" msgstr "" #: notebook/serverapp.py:959 msgid "The login handler class to use." msgstr "" #: notebook/serverapp.py:966 msgid "The logout handler class to use." msgstr "" #: notebook/serverapp.py:970 msgid "Whether to trust or not X-Scheme/X-Forwarded-Proto and X-Real-Ip/X-Forwarded-For headerssent by the upstream reverse proxy. Necessary if the proxy handles SSL" msgstr "" #: notebook/serverapp.py:982 msgid "" "\n" " DISABLED: use %pylab or %matplotlib in the notebook to enable matplotlib.\n" " " msgstr "" #: notebook/serverapp.py:994 msgid "Support for specifying --pylab on the command line has been removed." msgstr "" #: notebook/serverapp.py:996 msgid "Please use `%pylab{0}` or `%matplotlib{0}` in the notebook itself." msgstr "" #: notebook/serverapp.py:1001 msgid "The directory to use for notebooks and kernels." msgstr "用于笔记本和内核的目录。" #: notebook/serverapp.py:1024 #, python-format msgid "No such notebook dir: '%r'" msgstr "没有找到路径: '%r' " #: notebook/serverapp.py:1046 msgid "Dict of Python modules to load as notebook server extensions.Entry values can be used to enable and disable the loading of the extensions. The extensions will be loaded in alphabetical order." msgstr "将Python模块作为笔记本服务器扩展加载。可以使用条目值来启用和禁用扩展的加载。这些扩展将以字母顺序加载。" #: notebook/serverapp.py:1055 msgid "Reraise exceptions encountered loading server extensions?" msgstr "重新运行的异常会遇到加载服务器扩展吗?" #: notebook/serverapp.py:1058 msgid "" "(msgs/sec)\n" " Maximum rate at which messages can be sent on iopub before they are\n" " limited." msgstr "" #: notebook/serverapp.py:1062 msgid "" "(bytes/sec)\n" " Maximum rate at which stream output can be sent on iopub before they are\n" " limited." msgstr "" #: notebook/serverapp.py:1066 msgid "" "(sec) Time window used to \n" " check the message and data rate limits." msgstr "(sec)时间窗口被用来 \n 检查消息和数据速率限制." #: notebook/serverapp.py:1077 #, python-format msgid "No such file or directory: %s" msgstr "找不到文件或文件夹: %s" #: notebook/serverapp.py:1147 msgid "Notebook servers are configured to only be run with a password." msgstr "服务设置为只能使用密码运行." #: notebook/serverapp.py:1148 msgid "Hint: run the following command to set a password" msgstr "提示: 运行下面命令设置密码" #: notebook/serverapp.py:1149 msgid "\t$ python -m notebook.auth password" msgstr "" #: notebook/serverapp.py:1187 #, python-format msgid "The port %i is already in use, trying another port." msgstr "端口 %i 已经被站用, 请尝试其他端口." #: notebook/serverapp.py:1190 #, python-format msgid "Permission to listen on port %i denied" msgstr "监听端口 %i 失败" #: notebook/serverapp.py:1199 msgid "ERROR: the notebook server could not be started because no available port could be found." msgstr "错误: 服务启动失败因为没有找到可用的端口. " #: notebook/serverapp.py:1205 msgid "[all ip addresses on your system]" msgstr "[系统所有IP地址]" #: notebook/serverapp.py:1229 #, python-format msgid "Terminals not available (error was %s)" msgstr "终端不可用(错误: %s)" #: notebook/serverapp.py:1265 msgid "interrupted" msgstr "中断" #: notebook/serverapp.py:1267 msgid "y" msgstr "" #: notebook/serverapp.py:1268 msgid "n" msgstr "" #: notebook/serverapp.py:1269 #, python-format msgid "Shutdown this notebook server (%s/[%s])? " msgstr "关闭服务 (%s/[%s])" #: notebook/serverapp.py:1275 msgid "Shutdown confirmed" msgstr "关闭确定" #: notebook/serverapp.py:1279 msgid "No answer for 5s:" msgstr "5s 未响应" #: notebook/serverapp.py:1280 msgid "resuming operation..." msgstr "重启操作..." #: notebook/serverapp.py:1288 #, python-format msgid "received signal %s, stopping" msgstr "接受信号 %s, 正在停止" #: notebook/serverapp.py:1344 #, python-format msgid "Error loading server extension %s" msgstr "加载插件 %s 失败" #: notebook/serverapp.py:1375 #, python-format msgid "Shutting down %d kernel" msgid_plural "Shutting down %d kernels" msgstr[0] "关闭 %d 服务" msgstr[1] "关闭 %d 服务" #: notebook/serverapp.py:1383 #, python-format msgid "%d active kernel" msgid_plural "%d active kernels" msgstr[0] "%d 活跃的服务" msgstr[1] "%d 活跃的服务" #: notebook/serverapp.py:1387 #, python-format msgid "" "The Jupyter Notebook is running at:\n" "%s" msgstr "本程序运行在: %s" #: notebook/serverapp.py:1434 msgid "Running as root is not recommended. Use --allow-root to bypass." msgstr "不建议以root身份运行.使用--allow-root绕过过." #: notebook/serverapp.py:1440 msgid "Use Control-C to stop this server and shut down all kernels (twice to skip confirmation)." msgstr "使用control-c停止此服务器并关闭所有内核(两次跳过确认)." #: notebook/serverapp.py:1442 msgid "Welcome to Project Jupyter! Explore the various tools available and their corresponding documentation. If you are interested in contributing to the platform, please visit the communityresources section at http://jupyter.org/community.html." msgstr "欢迎来到项目Jupyter! 探索可用的各种工具及其相应的文档. 如果你有兴趣对这个平台,请访问http://jupyter.org/community.html community resources部分." #: notebook/serverapp.py:1453 #, python-format msgid "No web browser found: %s." msgstr "没有找到web浏览器: %s." #: notebook/serverapp.py:1458 #, python-format msgid "%s does not exist" msgstr "%s 不存在" #: notebook/serverapp.py:1492 msgid "Interrupted..." msgstr "已经中断..." #: notebook/services/contents/filemanager.py:525 #, python-format msgid "Serving notebooks from local directory: %s" msgstr "启动notebooks 在本地路径: %s" #: notebook/services/contents/manager.py:69 msgid "Untitled" msgstr "未命名" jupyter-server-jupyter_server-e5c7e2b/jupyter_server/kernelspecs/000077500000000000000000000000001473126534200257215ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/kernelspecs/__init__.py000066400000000000000000000000001473126534200300200ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/kernelspecs/handlers.py000066400000000000000000000053011473126534200300720ustar00rootroot00000000000000"""Kernelspecs API Handlers.""" import mimetypes from jupyter_core.utils import ensure_async from tornado import web from jupyter_server.auth.decorator import authorized from ..base.handlers import JupyterHandler from ..services.kernelspecs.handlers import kernel_name_regex AUTH_RESOURCE = "kernelspecs" class KernelSpecResourceHandler(web.StaticFileHandler, JupyterHandler): """A Kernelspec resource handler.""" SUPPORTED_METHODS = ("GET", "HEAD") # type:ignore[assignment] auth_resource = AUTH_RESOURCE def initialize(self): """Initialize a kernelspec resource handler.""" web.StaticFileHandler.initialize(self, path="") @web.authenticated @authorized async def get(self, kernel_name, path, include_body=True): """Get a kernelspec resource.""" ksm = self.kernel_spec_manager if path.lower().endswith(".png"): self.set_header("Cache-Control", f"max-age={60*60*24*30}") ksm = self.kernel_spec_manager if hasattr(ksm, "get_kernel_spec_resource"): # If the kernel spec manager defines a method to get kernelspec resources, # then use that instead of trying to read from disk. kernel_spec_res = await ksm.get_kernel_spec_resource(kernel_name, path) if kernel_spec_res is not None: # We have to explicitly specify the `absolute_path` attribute so that # the underlying StaticFileHandler methods can calculate an etag. self.absolute_path = path mimetype: str = mimetypes.guess_type(path)[0] or "text/plain" self.set_header("Content-Type", mimetype) self.finish(kernel_spec_res) return None else: self.log.warning( f"Kernelspec resource '{path}' for '{kernel_name}' not found. Kernel spec manager may" " not support resource serving. Falling back to reading from disk" ) try: kspec = await ensure_async(ksm.get_kernel_spec(kernel_name)) self.root = kspec.resource_dir except KeyError as e: raise web.HTTPError(404, "Kernel spec %s not found" % kernel_name) from e self.log.debug("Serving kernel resource from: %s", self.root) return await web.StaticFileHandler.get(self, path, include_body=include_body) @web.authenticated @authorized async def head(self, kernel_name, path): """Get the head info for a kernel resource.""" return await ensure_async(self.get(kernel_name, path, include_body=False)) default_handlers = [ (r"/kernelspecs/%s/(?P.*)" % kernel_name_regex, KernelSpecResourceHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/log.py000066400000000000000000000070611473126534200245420ustar00rootroot00000000000000"""Log utilities.""" # ----------------------------------------------------------------------------- # Copyright (c) Jupyter Development Team # # Distributed under the terms of the BSD License. The full license is in # the file LICENSE, distributed as part of this software. # ----------------------------------------------------------------------------- import json from urllib.parse import urlparse, urlunparse from tornado.log import access_log from .auth import User from .prometheus.log_functions import prometheus_log_method # url params to be scrubbed if seen # any url param that *contains* one of these # will be scrubbed from logs _SCRUB_PARAM_KEYS = {"token", "auth", "key", "code", "state", "xsrf"} def _scrub_uri(uri: str) -> str: """scrub auth info from uri""" parsed = urlparse(uri) if parsed.query: # check for potentially sensitive url params # use manual list + split rather than parsing # to minimally perturb original parts = parsed.query.split("&") changed = False for i, s in enumerate(parts): key, sep, value = s.partition("=") for substring in _SCRUB_PARAM_KEYS: if substring in key: parts[i] = f"{key}{sep}[secret]" changed = True if changed: parsed = parsed._replace(query="&".join(parts)) return urlunparse(parsed) return uri def log_request(handler, record_prometheus_metrics=True): """log a bit more information about each request than tornado's default - move static file get success to debug-level (reduces noise) - get proxied IP instead of proxy IP - log referer for redirect and failed requests - log user-agent for failed requests if record_prometheus_metrics is true, will record a histogram prometheus metric (http_request_duration_seconds) for each request handler """ status = handler.get_status() request = handler.request try: logger = handler.log except AttributeError: logger = access_log if status < 300 or status == 304: # Successes (or 304 FOUND) are debug-level log_method = logger.debug elif status < 400: log_method = logger.info elif status < 500: log_method = logger.warning else: log_method = logger.error request_time = 1000.0 * handler.request.request_time() ns = { "status": status, "method": request.method, "ip": request.remote_ip, "uri": _scrub_uri(request.uri), "request_time": request_time, } # log username # make sure we don't break anything # in case mixins cause current_user to not be a User somehow try: user = handler.current_user except Exception: user = None username = (user.username if isinstance(user, User) else "unknown") if user else "" ns["username"] = username msg = "{status} {method} {uri} ({username}@{ip}) {request_time:.2f}ms" if status >= 400: # log bad referrers ns["referer"] = _scrub_uri(request.headers.get("Referer", "None")) msg = msg + " referer={referer}" if status >= 500 and status != 502: # Log a subset of the headers if it caused an error. headers = {} for header in ["Host", "Accept", "Referer", "User-Agent"]: if header in request.headers: headers[header] = request.headers[header] log_method(json.dumps(headers, indent=2)) log_method(msg.format(**ns)) if record_prometheus_metrics: prometheus_log_method(handler) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/nbconvert/000077500000000000000000000000001473126534200254035ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/nbconvert/__init__.py000066400000000000000000000000001473126534200275020ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/nbconvert/handlers.py000066400000000000000000000155611473126534200275650ustar00rootroot00000000000000"""Tornado handlers for nbconvert.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import io import os import sys import zipfile from anyio.to_thread import run_sync from jupyter_core.utils import ensure_async from nbformat import from_dict from tornado import web from tornado.log import app_log from jupyter_server.auth.decorator import authorized from ..base.handlers import FilesRedirectHandler, JupyterHandler, path_regex AUTH_RESOURCE = "nbconvert" # datetime.strftime date format for jupyter # inlined from ipython_genutils if sys.platform == "win32": date_format = "%B %d, %Y" else: date_format = "%B %-d, %Y" def find_resource_files(output_files_dir): """Find the resource files in a directory.""" files = [] for dirpath, _, filenames in os.walk(output_files_dir): files.extend([os.path.join(dirpath, f) for f in filenames]) return files def respond_zip(handler, name, output, resources): """Zip up the output and resource files and respond with the zip file. Returns True if it has served a zip file, False if there are no resource files, in which case we serve the plain output file. """ # Check if we have resource files we need to zip output_files = resources.get("outputs", None) if not output_files: return False # Headers zip_filename = os.path.splitext(name)[0] + ".zip" handler.set_attachment_header(zip_filename) handler.set_header("Content-Type", "application/zip") handler.set_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") # Prepare the zip file buffer = io.BytesIO() zipf = zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) output_filename = os.path.splitext(name)[0] + resources["output_extension"] zipf.writestr(output_filename, output.encode("utf-8")) for filename, data in output_files.items(): zipf.writestr(os.path.basename(filename), data) zipf.close() handler.finish(buffer.getvalue()) return True def get_exporter(format, **kwargs): """get an exporter, raising appropriate errors""" # if this fails, will raise 500 try: from nbconvert.exporters.base import get_exporter except ImportError as e: raise web.HTTPError(500, "Could not import nbconvert: %s" % e) from e try: exporter = get_exporter(format) except KeyError as e: # should this be 400? raise web.HTTPError(404, "No exporter for format: %s" % format) from e try: return exporter(**kwargs) except Exception as e: app_log.exception("Could not construct Exporter: %s", exporter) raise web.HTTPError(500, "Could not construct Exporter: %s" % e) from e class NbconvertFileHandler(JupyterHandler): """An nbconvert file handler.""" auth_resource = AUTH_RESOURCE SUPPORTED_METHODS = ("GET",) # type:ignore[assignment] @web.authenticated @authorized async def get(self, format, path): """Get a notebook file in a desired format.""" self.check_xsrf_cookie() exporter = get_exporter(format, config=self.config, log=self.log) path = path.strip("/") # If the notebook relates to a real file (default contents manager), # give its path to nbconvert. if hasattr(self.contents_manager, "_get_os_path"): os_path = self.contents_manager._get_os_path(path) ext_resources_dir, basename = os.path.split(os_path) else: ext_resources_dir = None model = await ensure_async(self.contents_manager.get(path=path)) name = model["name"] if model["type"] != "notebook": # not a notebook, redirect to files return FilesRedirectHandler.redirect_to_files(self, path) nb = model["content"] self.set_header("Last-Modified", model["last_modified"]) # create resources dictionary mod_date = model["last_modified"].strftime(date_format) nb_title = os.path.splitext(name)[0] resource_dict = { "metadata": {"name": nb_title, "modified_date": mod_date}, "config_dir": self.application.settings["config_dir"], } if ext_resources_dir: resource_dict["metadata"]["path"] = ext_resources_dir # Exporting can take a while, delegate to a thread so we don't block the event loop try: output, resources = await run_sync( lambda: exporter.from_notebook_node(nb, resources=resource_dict) ) except Exception as e: self.log.exception("nbconvert failed: %r", e) raise web.HTTPError(500, "nbconvert failed: %s" % e) from e if respond_zip(self, name, output, resources): return None # Force download if requested if self.get_argument("download", "false").lower() == "true": filename = os.path.splitext(name)[0] + resources["output_extension"] self.set_attachment_header(filename) # MIME type if exporter.output_mimetype: self.set_header("Content-Type", "%s; charset=utf-8" % exporter.output_mimetype) self.set_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") self.finish(output) class NbconvertPostHandler(JupyterHandler): """An nbconvert post handler.""" SUPPORTED_METHODS = ("POST",) # type:ignore[assignment] auth_resource = AUTH_RESOURCE @web.authenticated @authorized async def post(self, format): """Convert a notebook file to a desired format.""" exporter = get_exporter(format, config=self.config) model = self.get_json_body() assert model is not None name = model.get("name", "notebook.ipynb") nbnode = from_dict(model["content"]) try: output, resources = await run_sync( lambda: exporter.from_notebook_node( nbnode, resources={ "metadata": {"name": name[: name.rfind(".")]}, "config_dir": self.application.settings["config_dir"], }, ) ) except Exception as e: raise web.HTTPError(500, "nbconvert failed: %s" % e) from e if respond_zip(self, name, output, resources): return # MIME type if exporter.output_mimetype: self.set_header("Content-Type", "%s; charset=utf-8" % exporter.output_mimetype) self.finish(output) # ----------------------------------------------------------------------------- # URL to handler mappings # ----------------------------------------------------------------------------- _format_regex = r"(?P\w+)" default_handlers = [ (r"/nbconvert/%s" % _format_regex, NbconvertPostHandler), (rf"/nbconvert/{_format_regex}{path_regex}", NbconvertFileHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/prometheus/000077500000000000000000000000001473126534200255765ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/prometheus/__init__.py000066400000000000000000000000001473126534200276750ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/prometheus/log_functions.py000066400000000000000000000020521473126534200310200ustar00rootroot00000000000000"""Log functions for prometheus""" from .metrics import HTTP_REQUEST_DURATION_SECONDS # type:ignore[unused-ignore] def prometheus_log_method(handler): """ Tornado log handler for recording RED metrics. We record the following metrics: Rate - the number of requests, per second, your services are serving. Errors - the number of failed requests per second. Duration - The amount of time each request takes expressed as a time interval. We use a fully qualified name of the handler as a label, rather than every url path to reduce cardinality. This function should be either the value of or called from a function that is the 'log_function' tornado setting. This makes it get called at the end of every request, allowing us to record the metrics we need. """ HTTP_REQUEST_DURATION_SECONDS.labels( method=handler.request.method, handler=f"{handler.__class__.__module__}.{type(handler).__name__}", status_code=handler.get_status(), ).observe(handler.request.request_time()) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/prometheus/metrics.py000066400000000000000000000053461473126534200276260ustar00rootroot00000000000000""" Prometheus metrics exported by Jupyter Server Read https://prometheus.io/docs/practices/naming/ for naming conventions for metrics & labels. """ from prometheus_client import Gauge, Histogram, Info from jupyter_server._version import version_info as server_version_info try: from notebook._version import version_info as notebook_version_info except ImportError: notebook_version_info = None if ( notebook_version_info is not None # No notebook package found and notebook_version_info < (7,) # Notebook package found, is version 6 # Notebook package found, but its version is the same as jupyter_server # version. This means some package (looking at you, nbclassic) has shimmed # the notebook package to instead be imports from the jupyter_server package. # In such cases, notebook.prometheus.metrics is actually *this file*, so # trying to import it will cause a circular import. So we don't. and notebook_version_info != server_version_info ): # Jupyter Notebook v6 also defined these metrics. Re-defining them results in a ValueError, # so we simply re-export them if we are co-existing with the notebook v6 package. # See https://github.com/jupyter/jupyter_server/issues/209 from notebook.prometheus.metrics import ( HTTP_REQUEST_DURATION_SECONDS, KERNEL_CURRENTLY_RUNNING_TOTAL, TERMINAL_CURRENTLY_RUNNING_TOTAL, ) else: HTTP_REQUEST_DURATION_SECONDS = Histogram( "http_request_duration_seconds", "duration in seconds for all HTTP requests", ["method", "handler", "status_code"], ) TERMINAL_CURRENTLY_RUNNING_TOTAL = Gauge( "terminal_currently_running_total", "counter for how many terminals are running", ) KERNEL_CURRENTLY_RUNNING_TOTAL = Gauge( "kernel_currently_running_total", "counter for how many kernels are running labeled by type", ["type"], ) # New prometheus metrics that do not exist in notebook v6 go here SERVER_INFO = Info("jupyter_server", "Jupyter Server Version information") SERVER_EXTENSION_INFO = Info( "jupyter_server_extension", "Jupyter Server Extensiom Version Information", ["name", "version", "enabled"], ) LAST_ACTIVITY = Gauge( "jupyter_server_last_activity_timestamp_seconds", "Timestamp of last seen activity on this Jupyter Server", ) SERVER_STARTED = Gauge( "jupyter_server_started_timestamp_seconds", "Timestamp of when this Jupyter Server was started" ) ACTIVE_DURATION = Gauge( "jupyter_server_active_duration_seconds", "Number of seconds this Jupyter Server has been active", ) __all__ = [ "HTTP_REQUEST_DURATION_SECONDS", "TERMINAL_CURRENTLY_RUNNING_TOTAL", "KERNEL_CURRENTLY_RUNNING_TOTAL", "SERVER_INFO", ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/py.typed000066400000000000000000000000001473126534200250700ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/pytest_plugin.py000066400000000000000000000034711473126534200266700ustar00rootroot00000000000000"""Pytest Fixtures exported by Jupyter Server.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json from pathlib import Path import pytest from jupyter_server.services.contents.filemanager import AsyncFileContentsManager from jupyter_server.services.contents.largefilemanager import AsyncLargeFileManager pytest_plugins = ["pytest_jupyter.jupyter_server"] some_resource = "The very model of a modern major general" sample_kernel_json = { "argv": ["cat", "{connection_file}"], "display_name": "Test kernel", } @pytest.fixture # type:ignore[misc] def jp_kernelspecs(jp_data_dir: Path) -> None: """Configures some sample kernelspecs in the Jupyter data directory.""" spec_names = ["sample", "sample2", "bad"] for name in spec_names: sample_kernel_dir = jp_data_dir.joinpath("kernels", name) sample_kernel_dir.mkdir(parents=True) # Create kernel json file sample_kernel_file = sample_kernel_dir.joinpath("kernel.json") kernel_json = sample_kernel_json.copy() if name == "bad": kernel_json["argv"] = ["non_existent_path"] sample_kernel_file.write_text(json.dumps(kernel_json)) # Create resources text sample_kernel_resources = sample_kernel_dir.joinpath("resource.txt") sample_kernel_resources.write_text(some_resource) @pytest.fixture(params=[True, False]) def jp_contents_manager(request, tmp_path): """Returns an AsyncFileContentsManager instance based on the use_atomic_writing parameter value.""" return AsyncFileContentsManager(root_dir=str(tmp_path), use_atomic_writing=request.param) @pytest.fixture def jp_large_contents_manager(tmp_path): """Returns an AsyncLargeFileManager instance.""" return AsyncLargeFileManager(root_dir=str(tmp_path)) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/serverapp.py000066400000000000000000003513441473126534200257760ustar00rootroot00000000000000"""A tornado based Jupyter server.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import datetime import errno import gettext import hashlib import hmac import ipaddress import json import logging import mimetypes import os import pathlib import random import re import select import signal import socket import stat import sys import threading import time import typing as t import urllib import warnings from base64 import encodebytes from functools import partial from pathlib import Path import jupyter_client from jupyter_client.kernelspec import KernelSpecManager from jupyter_client.manager import KernelManager from jupyter_client.session import Session from jupyter_core.application import JupyterApp, base_aliases, base_flags from jupyter_core.paths import jupyter_runtime_dir from jupyter_events.logger import EventLogger from nbformat.sign import NotebookNotary from tornado import httpserver, ioloop, web from tornado.httputil import url_concat from tornado.log import LogFormatter, access_log, app_log, gen_log from tornado.netutil import bind_sockets from tornado.routing import Matcher, Rule if not sys.platform.startswith("win"): from tornado.netutil import bind_unix_socket if sys.platform.startswith("win"): try: import colorama colorama.init() except ImportError: pass from traitlets import ( Any, Bool, Bytes, Dict, Float, Instance, Integer, List, TraitError, Type, Unicode, Union, default, observe, validate, ) from traitlets.config import Config from traitlets.config.application import boolean_flag, catch_config_error from jupyter_server import ( DEFAULT_EVENTS_SCHEMA_PATH, DEFAULT_JUPYTER_SERVER_PORT, DEFAULT_STATIC_FILES_PATH, DEFAULT_TEMPLATE_PATH_LIST, JUPYTER_SERVER_EVENTS_URI, __version__, ) from jupyter_server._sysinfo import get_sys_info from jupyter_server._tz import utcnow from jupyter_server.auth.authorizer import AllowAllAuthorizer, Authorizer from jupyter_server.auth.identity import ( IdentityProvider, LegacyIdentityProvider, PasswordIdentityProvider, ) from jupyter_server.auth.login import LoginHandler from jupyter_server.auth.logout import LogoutHandler from jupyter_server.base.handlers import ( FileFindHandler, MainHandler, RedirectWithParams, Template404, ) from jupyter_server.extension.config import ExtensionConfigManager from jupyter_server.extension.manager import ExtensionManager from jupyter_server.extension.serverextension import ServerExtensionApp from jupyter_server.gateway.connections import GatewayWebSocketConnection from jupyter_server.gateway.gateway_client import GatewayClient from jupyter_server.gateway.managers import ( GatewayKernelSpecManager, GatewayMappingKernelManager, GatewaySessionManager, ) from jupyter_server.log import log_request from jupyter_server.prometheus.metrics import ( ACTIVE_DURATION, LAST_ACTIVITY, SERVER_EXTENSION_INFO, SERVER_INFO, SERVER_STARTED, ) from jupyter_server.services.config import ConfigManager from jupyter_server.services.contents.filemanager import ( AsyncFileContentsManager, FileContentsManager, ) from jupyter_server.services.contents.largefilemanager import AsyncLargeFileManager from jupyter_server.services.contents.manager import AsyncContentsManager, ContentsManager from jupyter_server.services.kernels.connection.base import BaseKernelWebsocketConnection from jupyter_server.services.kernels.connection.channels import ZMQChannelsWebsocketConnection from jupyter_server.services.kernels.kernelmanager import ( AsyncMappingKernelManager, MappingKernelManager, ) from jupyter_server.services.sessions.sessionmanager import SessionManager from jupyter_server.utils import ( JupyterServerAuthWarning, check_pid, fetch, unix_socket_in_use, url_escape, url_path_join, urlencode_unix_socket_path, ) try: import resource except ImportError: # Windows resource = None # type:ignore[assignment] from jinja2 import Environment, FileSystemLoader from jupyter_core.paths import secure_write from jupyter_core.utils import ensure_async from jupyter_server.transutils import _i18n, trans from jupyter_server.utils import pathname2url, urljoin # the minimum viable tornado version: needs to be kept in sync with setup.py MIN_TORNADO = (6, 1, 0) try: import tornado assert tornado.version_info >= MIN_TORNADO except (ImportError, AttributeError, AssertionError) as e: # pragma: no cover raise ImportError(_i18n("The Jupyter Server requires tornado >=%s.%s.%s") % MIN_TORNADO) from e try: import resource except ImportError: # Windows resource = None # type:ignore[assignment] # ----------------------------------------------------------------------------- # Module globals # ----------------------------------------------------------------------------- _examples = """ jupyter server # start the server jupyter server --certfile=mycert.pem # use SSL/TLS certificate jupyter server password # enter a password to protect the server """ JUPYTER_SERVICE_HANDLERS = { "auth": None, "api": ["jupyter_server.services.api.handlers"], "config": ["jupyter_server.services.config.handlers"], "contents": ["jupyter_server.services.contents.handlers"], "files": ["jupyter_server.files.handlers"], "kernels": [ "jupyter_server.services.kernels.handlers", ], "kernelspecs": [ "jupyter_server.kernelspecs.handlers", "jupyter_server.services.kernelspecs.handlers", ], "nbconvert": [ "jupyter_server.nbconvert.handlers", "jupyter_server.services.nbconvert.handlers", ], "security": ["jupyter_server.services.security.handlers"], "sessions": ["jupyter_server.services.sessions.handlers"], "shutdown": ["jupyter_server.services.shutdown"], "view": ["jupyter_server.view.handlers"], "events": ["jupyter_server.services.events.handlers"], } # Added for backwards compatibility from classic notebook server. DEFAULT_SERVER_PORT = DEFAULT_JUPYTER_SERVER_PORT # ----------------------------------------------------------------------------- # Helper functions # ----------------------------------------------------------------------------- def random_ports(port: int, n: int) -> t.Generator[int, None, None]: """Generate a list of n random ports near the given port. The first 5 ports will be sequential, and the remaining n-5 will be randomly selected in the range [port-2*n, port+2*n]. """ for i in range(min(5, n)): yield port + i for _ in range(n - 5): yield max(1, port + random.randint(-2 * n, 2 * n)) # noqa: S311 def load_handlers(name: str) -> t.Any: """Load the (URL pattern, handler) tuples for each component.""" mod = __import__(name, fromlist=["default_handlers"]) return mod.default_handlers # ----------------------------------------------------------------------------- # The Tornado web application # ----------------------------------------------------------------------------- class ServerWebApplication(web.Application): """A server web application.""" def __init__( self, jupyter_app, default_services, kernel_manager, contents_manager, session_manager, kernel_spec_manager, config_manager, event_logger, extra_services, log, base_url, default_url, settings_overrides, jinja_env_options, *, authorizer=None, identity_provider=None, kernel_websocket_connection_class=None, websocket_ping_interval=None, websocket_ping_timeout=None, ): """Initialize a server web application.""" if identity_provider is None: warnings.warn( "identity_provider unspecified. Using default IdentityProvider." " Specify an identity_provider to avoid this message.", RuntimeWarning, stacklevel=2, ) identity_provider = IdentityProvider(parent=jupyter_app) if authorizer is None: warnings.warn( "authorizer unspecified. Using permissive AllowAllAuthorizer." " Specify an authorizer to avoid this message.", JupyterServerAuthWarning, stacklevel=2, ) authorizer = AllowAllAuthorizer(parent=jupyter_app, identity_provider=identity_provider) settings = self.init_settings( jupyter_app, kernel_manager, contents_manager, session_manager, kernel_spec_manager, config_manager, event_logger, extra_services, log, base_url, default_url, settings_overrides, jinja_env_options, authorizer=authorizer, identity_provider=identity_provider, kernel_websocket_connection_class=kernel_websocket_connection_class, websocket_ping_interval=websocket_ping_interval, websocket_ping_timeout=websocket_ping_timeout, ) handlers = self.init_handlers(default_services, settings) undecorated_methods = [] for matcher, handler, *_ in handlers: undecorated_methods.extend(self._check_handler_auth(matcher, handler)) if undecorated_methods: message = ( "Core endpoints without @allow_unauthenticated, @ws_authenticated, nor @web.authenticated:\n" + "\n".join(undecorated_methods) ) if jupyter_app.allow_unauthenticated_access: warnings.warn( message, JupyterServerAuthWarning, stacklevel=2, ) else: raise Exception(message) super().__init__(handlers, **settings) def add_handlers(self, host_pattern, host_handlers): undecorated_methods = [] for rule in host_handlers: if isinstance(rule, Rule): matcher = rule.matcher handler = rule.target else: matcher, handler, *_ = rule undecorated_methods.extend(self._check_handler_auth(matcher, handler)) if undecorated_methods and not self.settings["allow_unauthenticated_access"]: message = ( "Extension endpoints without @allow_unauthenticated, @ws_authenticated, nor @web.authenticated:\n" + "\n".join(undecorated_methods) ) warnings.warn( message, JupyterServerAuthWarning, stacklevel=2, ) return super().add_handlers(host_pattern, host_handlers) def init_settings( self, jupyter_app, kernel_manager, contents_manager, session_manager, kernel_spec_manager, config_manager, event_logger, extra_services, log, base_url, default_url, settings_overrides, jinja_env_options=None, *, authorizer=None, identity_provider=None, kernel_websocket_connection_class=None, websocket_ping_interval=None, websocket_ping_timeout=None, ): """Initialize settings for the web application.""" _template_path = settings_overrides.get( "template_path", jupyter_app.template_file_path, ) if isinstance(_template_path, str): _template_path = (_template_path,) template_path = [os.path.expanduser(path) for path in _template_path] jenv_opt: dict[str, t.Any] = {"autoescape": True} jenv_opt.update(jinja_env_options if jinja_env_options else {}) env = Environment( # noqa: S701 loader=FileSystemLoader(template_path), extensions=["jinja2.ext.i18n"], **jenv_opt ) sys_info = get_sys_info() base_dir = os.path.realpath(os.path.join(__file__, "..", "..")) nbui = gettext.translation( "nbui", localedir=os.path.join(base_dir, "jupyter_server/i18n"), fallback=True, ) env.install_gettext_translations(nbui, newstyle=False) if sys_info["commit_source"] == "repository": # don't cache (rely on 304) when working from master version_hash = "" else: # reset the cache on server restart utc = datetime.timezone.utc version_hash = datetime.datetime.now(tz=utc).strftime("%Y%m%d%H%M%S") now = utcnow() root_dir = contents_manager.root_dir home = os.path.expanduser("~") if root_dir.startswith(home + os.path.sep): # collapse $HOME to ~ root_dir = "~" + root_dir[len(home) :] settings = { # basics "log_function": partial( log_request, record_prometheus_metrics=jupyter_app.record_http_request_metrics ), "base_url": base_url, "default_url": default_url, "template_path": template_path, "static_path": jupyter_app.static_file_path, "static_custom_path": jupyter_app.static_custom_path, "static_handler_class": FileFindHandler, "static_url_prefix": url_path_join(base_url, "/static/"), "static_handler_args": { # don't cache custom.js "no_cache_paths": [url_path_join(base_url, "static", "custom")], }, "version_hash": version_hash, # kernel message protocol over websocket "kernel_ws_protocol": jupyter_app.kernel_ws_protocol, # rate limits "limit_rate": jupyter_app.limit_rate, "iopub_msg_rate_limit": jupyter_app.iopub_msg_rate_limit, "iopub_data_rate_limit": jupyter_app.iopub_data_rate_limit, "rate_limit_window": jupyter_app.rate_limit_window, # authentication "cookie_secret": jupyter_app.cookie_secret, "login_url": url_path_join(base_url, "/login"), "xsrf_cookies": True, "disable_check_xsrf": jupyter_app.disable_check_xsrf, "allow_unauthenticated_access": jupyter_app.allow_unauthenticated_access, "allow_remote_access": jupyter_app.allow_remote_access, "local_hostnames": jupyter_app.local_hostnames, "authenticate_prometheus": jupyter_app.authenticate_prometheus, # managers "kernel_manager": kernel_manager, "contents_manager": contents_manager, "session_manager": session_manager, "kernel_spec_manager": kernel_spec_manager, "config_manager": config_manager, "authorizer": authorizer, "identity_provider": identity_provider, "event_logger": event_logger, "kernel_websocket_connection_class": kernel_websocket_connection_class, "websocket_ping_interval": websocket_ping_interval, "websocket_ping_timeout": websocket_ping_timeout, # handlers "extra_services": extra_services, # Jupyter stuff "started": now, # place for extensions to register activity # so that they can prevent idle-shutdown "last_activity_times": {}, "jinja_template_vars": jupyter_app.jinja_template_vars, "websocket_url": jupyter_app.websocket_url, "shutdown_button": jupyter_app.quit_button, "config": jupyter_app.config, "config_dir": jupyter_app.config_dir, "allow_password_change": jupyter_app.allow_password_change, "server_root_dir": root_dir, "jinja2_env": env, "serverapp": jupyter_app, } # allow custom overrides for the tornado web app. settings.update(settings_overrides) if base_url and "xsrf_cookie_kwargs" not in settings: # default: set xsrf cookie on base_url settings["xsrf_cookie_kwargs"] = {"path": base_url} return settings def init_handlers(self, default_services, settings): """Load the (URL pattern, handler) tuples for each component.""" # Order matters. The first handler to match the URL will handle the request. handlers = [] # load extra services specified by users before default handlers for service in settings["extra_services"]: handlers.extend(load_handlers(service)) # Load default services. Raise exception if service not # found in JUPYTER_SERVICE_HANLDERS. for service in default_services: if service in JUPYTER_SERVICE_HANDLERS: locations = JUPYTER_SERVICE_HANDLERS[service] if locations is not None: for loc in locations: handlers.extend(load_handlers(loc)) else: msg = ( f"{service} is not recognized as a jupyter_server " "service. If this is a custom service, " "try adding it to the " "`extra_services` list." ) raise Exception(msg) # Add extra handlers from contents manager. handlers.extend(settings["contents_manager"].get_extra_handlers()) # And from identity provider handlers.extend(settings["identity_provider"].get_handlers()) # register base handlers last handlers.extend(load_handlers("jupyter_server.base.handlers")) if settings["default_url"] != settings["base_url"]: # set the URL that will be redirected from `/` handlers.append( ( r"/?", RedirectWithParams, { "url": settings["default_url"], "permanent": False, # want 302, not 301 }, ) ) else: handlers.append((r"/", MainHandler)) # prepend base_url onto the patterns that we match new_handlers = [] for handler in handlers: pattern = url_path_join(settings["base_url"], handler[0]) new_handler = (pattern, *list(handler[1:])) new_handlers.append(new_handler) # add 404 on the end, which will catch everything that falls through new_handlers.append((r"(.*)", Template404)) return new_handlers def last_activity(self): """Get a UTC timestamp for when the server last did something. Includes: API activity, kernel activity, kernel shutdown, and terminal activity. """ sources = [ self.settings["started"], self.settings["kernel_manager"].last_kernel_activity, ] # Any setting that ends with a key that ends with `_last_activity` is # counted here. This provides a hook for extensions to add a last activity # setting to the server. sources.extend( [val for key, val in self.settings.items() if key.endswith("_last_activity")] ) sources.extend(self.settings["last_activity_times"].values()) return max(sources) def _check_handler_auth( self, matcher: t.Union[str, Matcher], handler: type[web.RequestHandler] ): missing_authentication = [] for method_name in handler.SUPPORTED_METHODS: method = getattr(handler, method_name.lower()) is_unimplemented = method == web.RequestHandler._unimplemented_method is_allowlisted = hasattr(method, "__allow_unauthenticated") is_blocklisted = _has_tornado_web_authenticated(method) if not is_unimplemented and not is_allowlisted and not is_blocklisted: missing_authentication.append( f"- {method_name} of {handler.__name__} registered for {matcher}" ) return missing_authentication def _has_tornado_web_authenticated(method: t.Callable[..., t.Any]) -> bool: """Check if given method was decorated with @web.authenticated. Note: it is ok if we reject on @authorized @web.authenticated because the correct order is @web.authenticated @authorized. """ if not hasattr(method, "__wrapped__"): return False if not hasattr(method, "__code__"): return False code = method.__code__ if hasattr(code, "co_qualname"): # new in 3.11 return code.co_qualname.startswith("authenticated") # type:ignore[no-any-return] elif hasattr(code, "co_filename"): return code.co_filename.replace("\\", "/").endswith("tornado/web.py") return False class JupyterPasswordApp(JupyterApp): """Set a password for the Jupyter server. Setting a password secures the Jupyter server and removes the need for token-based authentication. """ description: str = __doc__ def _config_file_default(self): """the default config file.""" return os.path.join(self.config_dir, "jupyter_server_config.json") def start(self): """Start the password app.""" from jupyter_server.auth.security import set_password set_password(config_file=self.config_file) self.log.info("Wrote hashed password to %s" % self.config_file) def shutdown_server(server_info, timeout=5, log=None): """Shutdown a Jupyter server in a separate process. *server_info* should be a dictionary as produced by list_running_servers(). Will first try to request shutdown using /api/shutdown . On Unix, if the server is still running after *timeout* seconds, it will send SIGTERM. After another timeout, it escalates to SIGKILL. Returns True if the server was stopped by any means, False if stopping it failed (on Windows). """ url = server_info["url"] pid = server_info["pid"] try: shutdown_url = urljoin(url, "api/shutdown") if log: log.debug("POST request to %s", shutdown_url) fetch( shutdown_url, method="POST", body=b"", headers={"Authorization": "token " + server_info["token"]}, ) except Exception as ex: if not str(ex) == "Unknown URL scheme.": raise ex if log: log.debug("Was not a HTTP scheme. Treating as socket instead.") log.debug("POST request to %s", url) fetch( url, method="POST", body=b"", headers={"Authorization": "token " + server_info["token"]}, ) # Poll to see if it shut down. for _ in range(timeout * 10): if not check_pid(pid): if log: log.debug("Server PID %s is gone", pid) return True time.sleep(0.1) if sys.platform.startswith("win"): return False if log: log.debug("SIGTERM to PID %s", pid) os.kill(pid, signal.SIGTERM) # Poll to see if it shut down. for _ in range(timeout * 10): if not check_pid(pid): if log: log.debug("Server PID %s is gone", pid) return True time.sleep(0.1) if log: log.debug("SIGKILL to PID %s", pid) os.kill(pid, signal.SIGKILL) return True # SIGKILL cannot be caught class JupyterServerStopApp(JupyterApp): """An application to stop a Jupyter server.""" version: str = __version__ description: str = "Stop currently running Jupyter server for a given port" port = Integer( DEFAULT_JUPYTER_SERVER_PORT, config=True, help="Port of the server to be killed. Default %s" % DEFAULT_JUPYTER_SERVER_PORT, ) sock = Unicode("", config=True, help="UNIX socket of the server to be killed.") def parse_command_line(self, argv=None): """Parse command line options.""" super().parse_command_line(argv) if self.extra_args: try: self.port = int(self.extra_args[0]) except ValueError: # self.extra_args[0] was not an int, so it must be a string (unix socket). self.sock = self.extra_args[0] def shutdown_server(self, server): """Shut down a server.""" return shutdown_server(server, log=self.log) def _shutdown_or_exit(self, target_endpoint, server): """Handle a shutdown.""" self.log.info("Shutting down server on %s..." % target_endpoint) if not self.shutdown_server(server): sys.exit("Could not stop server on %s" % target_endpoint) @staticmethod def _maybe_remove_unix_socket(socket_path): """Try to remove a socket path.""" try: os.unlink(socket_path) except OSError: pass def start(self): """Start the server stop app.""" info = self.log.info servers = list(list_running_servers(self.runtime_dir, log=self.log)) if not servers: self.exit("There are no running servers (per %s)" % self.runtime_dir) for server in servers: if self.sock: sock = server.get("sock", None) if sock and sock == self.sock: self._shutdown_or_exit(sock, server) # Attempt to remove the UNIX socket after stopping. self._maybe_remove_unix_socket(sock) return elif self.port: port = server.get("port", None) if port == self.port: self._shutdown_or_exit(port, server) return current_endpoint = self.sock or self.port info(f"There is currently no server running on {current_endpoint}") info("Ports/sockets currently in use:") for server in servers: info(" - {}".format(server.get("sock") or server["port"])) self.exit(1) class JupyterServerListApp(JupyterApp): """An application to list running Jupyter servers.""" version: str = __version__ description: str = _i18n("List currently running Jupyter servers.") flags = { "jsonlist": ( {"JupyterServerListApp": {"jsonlist": True}}, _i18n("Produce machine-readable JSON list output."), ), "json": ( {"JupyterServerListApp": {"json": True}}, _i18n("Produce machine-readable JSON object on each line of output."), ), } jsonlist = Bool( False, config=True, help=_i18n( "If True, the output will be a JSON list of objects, one per " "active Jupyer server, each with the details from the " "relevant server info file." ), ) json = Bool( False, config=True, help=_i18n( "If True, each line of output will be a JSON object with the " "details from the server info file. For a JSON list output, " "see the JupyterServerListApp.jsonlist configuration value" ), ) def start(self): """Start the server list application.""" serverinfo_list = list(list_running_servers(self.runtime_dir, log=self.log)) if self.jsonlist: print(json.dumps(serverinfo_list, indent=2)) elif self.json: for serverinfo in serverinfo_list: print(json.dumps(serverinfo)) else: print("Currently running servers:") for serverinfo in serverinfo_list: url = serverinfo["url"] if serverinfo.get("token"): url = url + "?token=%s" % serverinfo["token"] print(url, "::", serverinfo["root_dir"]) # ----------------------------------------------------------------------------- # Aliases and Flags # ----------------------------------------------------------------------------- flags = dict(base_flags) flags["allow-root"] = ( {"ServerApp": {"allow_root": True}}, _i18n("Allow the server to be run from root user."), ) flags["no-browser"] = ( {"ServerApp": {"open_browser": False}, "ExtensionApp": {"open_browser": False}}, _i18n("Prevent the opening of the default url in the browser."), ) flags["debug"] = ( {"ServerApp": {"log_level": "DEBUG"}, "ExtensionApp": {"log_level": "DEBUG"}}, _i18n("Set debug level for the extension and underlying server applications."), ) flags["autoreload"] = ( {"ServerApp": {"autoreload": True}}, """Autoreload the webapp Enable reloading of the tornado webapp and all imported Python packages when any changes are made to any Python src files in server or extensions. """, ) # Add notebook manager flags flags.update( boolean_flag( "script", "FileContentsManager.save_script", "DEPRECATED, IGNORED", "DEPRECATED, IGNORED", ) ) aliases = dict(base_aliases) aliases.update( { "ip": "ServerApp.ip", "port": "ServerApp.port", "port-retries": "ServerApp.port_retries", "sock": "ServerApp.sock", "sock-mode": "ServerApp.sock_mode", "transport": "KernelManager.transport", "keyfile": "ServerApp.keyfile", "certfile": "ServerApp.certfile", "client-ca": "ServerApp.client_ca", "notebook-dir": "ServerApp.root_dir", "preferred-dir": "ServerApp.preferred_dir", "browser": "ServerApp.browser", "pylab": "ServerApp.pylab", "gateway-url": "GatewayClient.url", } ) # ----------------------------------------------------------------------------- # ServerApp # ----------------------------------------------------------------------------- class ServerApp(JupyterApp): """The Jupyter Server application class.""" name = "jupyter-server" version: str = __version__ description: str = _i18n( """The Jupyter Server. This launches a Tornado-based Jupyter Server.""" ) examples = _examples flags = Dict(flags) # type:ignore[assignment] aliases = Dict(aliases) # type:ignore[assignment] classes = [ KernelManager, Session, MappingKernelManager, KernelSpecManager, AsyncMappingKernelManager, ContentsManager, FileContentsManager, AsyncContentsManager, AsyncFileContentsManager, NotebookNotary, GatewayMappingKernelManager, GatewayKernelSpecManager, GatewaySessionManager, GatewayWebSocketConnection, GatewayClient, Authorizer, EventLogger, ZMQChannelsWebsocketConnection, ] subcommands: dict[str, t.Any] = { "list": ( JupyterServerListApp, JupyterServerListApp.description.splitlines()[0], ), "stop": ( JupyterServerStopApp, JupyterServerStopApp.description.splitlines()[0], ), "password": ( JupyterPasswordApp, JupyterPasswordApp.description.splitlines()[0], ), "extension": ( ServerExtensionApp, ServerExtensionApp.description.splitlines()[0], ), } # A list of services whose handlers will be exposed. # Subclasses can override this list to # expose a subset of these handlers. default_services = ( "api", "auth", "config", "contents", "files", "kernels", "kernelspecs", "nbconvert", "security", "sessions", "shutdown", "view", "events", ) _log_formatter_cls = LogFormatter # type:ignore[assignment] _stopping = Bool(False, help="Signal that we've begun stopping.") @default("log_level") def _default_log_level(self) -> int: return logging.INFO @default("log_format") def _default_log_format(self) -> str: """override default log format to include date & time""" return ( "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s" ) # file to be opened in the Jupyter server file_to_run = Unicode("", help="Open the named file when the application is launched.").tag( config=True ) file_url_prefix = Unicode( "notebooks", help="The URL prefix where files are opened directly." ).tag(config=True) # Network related information allow_origin = Unicode( "", config=True, help="""Set the Access-Control-Allow-Origin header Use '*' to allow any origin to access your server. Takes precedence over allow_origin_pat. """, ) allow_origin_pat = Unicode( "", config=True, help="""Use a regular expression for the Access-Control-Allow-Origin header Requests from an origin matching the expression will get replies with: Access-Control-Allow-Origin: origin where `origin` is the origin of the request. Ignored if allow_origin is set. """, ) allow_credentials = Bool( False, config=True, help=_i18n("Set the Access-Control-Allow-Credentials: true header"), ) allow_root = Bool( False, config=True, help=_i18n("Whether to allow the user to run the server as root."), ) autoreload = Bool( False, config=True, help=_i18n("Reload the webapp when changes are made to any Python src files."), ) default_url = Unicode("/", config=True, help=_i18n("The default URL to redirect to from `/`")) ip = Unicode( "localhost", config=True, help=_i18n("The IP address the Jupyter server will listen on."), ) @default("ip") def _default_ip(self) -> str: """Return localhost if available, 127.0.0.1 otherwise. On some (horribly broken) systems, localhost cannot be bound. """ s = socket.socket() try: s.bind(("localhost", 0)) except OSError as e: self.log.warning( _i18n("Cannot bind to localhost, using 127.0.0.1 as default ip\n%s"), e ) return "127.0.0.1" else: s.close() return "localhost" @validate("ip") def _validate_ip(self, proposal: t.Any) -> str: value = t.cast(str, proposal["value"]) if value == "*": value = "" return value custom_display_url = Unicode( "", config=True, help=_i18n( """Override URL shown to users. Replace actual URL, including protocol, address, port and base URL, with the given value when displaying URL to the users. Do not change the actual connection URL. If authentication token is enabled, the token is added to the custom URL automatically. This option is intended to be used when the URL to display to the user cannot be determined reliably by the Jupyter server (proxified or containerized setups for example).""" ), ) port_env = "JUPYTER_PORT" port_default_value = DEFAULT_JUPYTER_SERVER_PORT port = Integer( config=True, help=_i18n("The port the server will listen on (env: JUPYTER_PORT)."), ) @default("port") def _port_default(self) -> int: return int(os.getenv(self.port_env, self.port_default_value)) port_retries_env = "JUPYTER_PORT_RETRIES" port_retries_default_value = 50 port_retries = Integer( port_retries_default_value, config=True, help=_i18n( "The number of additional ports to try if the specified port is not " "available (env: JUPYTER_PORT_RETRIES)." ), ) @default("port_retries") def _port_retries_default(self) -> int: return int(os.getenv(self.port_retries_env, self.port_retries_default_value)) sock = Unicode("", config=True, help="The UNIX socket the Jupyter server will listen on.") sock_mode = Unicode( "0600", config=True, help="The permissions mode for UNIX socket creation (default: 0600).", ) @validate("sock_mode") def _validate_sock_mode(self, proposal: t.Any) -> t.Any: value = proposal["value"] try: converted_value = int(value.encode(), 8) assert all( ( # Ensure the mode is at least user readable/writable. bool(converted_value & stat.S_IRUSR), bool(converted_value & stat.S_IWUSR), # And isn't out of bounds. converted_value <= 2**12, ) ) except ValueError as e: raise TraitError( 'invalid --sock-mode value: %s, please specify as e.g. "0600"' % value ) from e except AssertionError as e: raise TraitError( "invalid --sock-mode value: %s, must have u+rw (0600) at a minimum" % value ) from e return value certfile = Unicode( "", config=True, help=_i18n("""The full path to an SSL/TLS certificate file."""), ) keyfile = Unicode( "", config=True, help=_i18n("""The full path to a private key file for usage with SSL/TLS."""), ) client_ca = Unicode( "", config=True, help=_i18n( """The full path to a certificate authority certificate for SSL/TLS client authentication.""" ), ) cookie_secret_file = Unicode( config=True, help=_i18n("""The file where the cookie secret is stored.""") ) @default("cookie_secret_file") def _default_cookie_secret_file(self) -> str: return os.path.join(self.runtime_dir, "jupyter_cookie_secret") cookie_secret = Bytes( b"", config=True, help="""The random bytes used to secure cookies. By default this is generated on first start of the server and persisted across server sessions by writing the cookie secret into the `cookie_secret_file` file. When using an executable config file you can override this to be random at each server restart. Note: Cookie secrets should be kept private, do not share config files with cookie_secret stored in plaintext (you can read the value from a file). """, ) @default("cookie_secret") def _default_cookie_secret(self) -> bytes: if os.path.exists(self.cookie_secret_file): with open(self.cookie_secret_file, "rb") as f: key = f.read() else: key = encodebytes(os.urandom(32)) self._write_cookie_secret_file(key) h = hmac.new(key, digestmod=hashlib.sha256) h.update(self.password.encode()) return h.digest() def _write_cookie_secret_file(self, secret: bytes) -> None: """write my secret to my secret_file""" self.log.info(_i18n("Writing Jupyter server cookie secret to %s"), self.cookie_secret_file) try: with secure_write(self.cookie_secret_file, True) as f: f.write(secret) except OSError as e: self.log.error( _i18n("Failed to write cookie secret to %s: %s"), self.cookie_secret_file, e, ) _token_set = False token = Unicode("", help=_i18n("""DEPRECATED. Use IdentityProvider.token""")).tag( config=True ) @observe("token") def _deprecated_token(self, change: t.Any) -> None: self._warn_deprecated_config(change, "IdentityProvider") @default("token") def _deprecated_token_access(self) -> str: warnings.warn( "ServerApp.token config is deprecated in jupyter-server 2.0. Use IdentityProvider.token", DeprecationWarning, stacklevel=3, ) return self.identity_provider.token min_open_files_limit = Integer( config=True, help=""" Gets or sets a lower bound on the open file handles process resource limit. This may need to be increased if you run into an OSError: [Errno 24] Too many open files. This is not applicable when running on Windows. """, allow_none=True, ) @default("min_open_files_limit") def _default_min_open_files_limit(self) -> t.Optional[int]: if resource is None: # Ignoring min_open_files_limit because the limit cannot be adjusted (for example, on Windows) return None # type:ignore[unreachable] soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) default_soft = 4096 if hard >= default_soft: return default_soft self.log.debug( "Default value for min_open_files_limit is ignored (hard=%r, soft=%r)", hard, soft, ) return soft max_body_size = Integer( 512 * 1024 * 1024, config=True, help=""" Sets the maximum allowed size of the client request body, specified in the Content-Length request header field. If the size in a request exceeds the configured value, a malformed HTTP message is returned to the client. Note: max_body_size is applied even in streaming mode. """, ) max_buffer_size = Integer( 512 * 1024 * 1024, config=True, help=""" Gets or sets the maximum amount of memory, in bytes, that is allocated for use by the buffer manager. """, ) password = Unicode( "", config=True, help="""DEPRECATED in 2.0. Use PasswordIdentityProvider.hashed_password""", ) password_required = Bool( False, config=True, help="""DEPRECATED in 2.0. Use PasswordIdentityProvider.password_required""", ) allow_password_change = Bool( True, config=True, help="""DEPRECATED in 2.0. Use PasswordIdentityProvider.allow_password_change""", ) def _warn_deprecated_config( self, change: t.Any, clsname: str, new_name: t.Optional[str] = None ) -> None: """Warn on deprecated config.""" if new_name is None: new_name = change.name if clsname not in self.config or new_name not in self.config[clsname]: # Deprecated config used, new config not used. # Use deprecated config, warn about new name. self.log.warning( f"ServerApp.{change.name} config is deprecated in 2.0. Use {clsname}.{new_name}." ) self.config[clsname][new_name] = change.new # Deprecated config used, new config also used. # Warn only if the values differ. # If the values are the same, assume intentional backward-compatible config. elif self.config[clsname][new_name] != change.new: self.log.warning( f"Ignoring deprecated ServerApp.{change.name} config. Using {clsname}.{new_name}." ) @observe("password") def _deprecated_password(self, change: t.Any) -> None: self._warn_deprecated_config(change, "PasswordIdentityProvider", new_name="hashed_password") @observe("password_required", "allow_password_change") def _deprecated_password_config(self, change: t.Any) -> None: self._warn_deprecated_config(change, "PasswordIdentityProvider") disable_check_xsrf = Bool( False, config=True, help="""Disable cross-site-request-forgery protection Jupyter server includes protection from cross-site request forgeries, requiring API requests to either: - originate from pages served by this server (validated with XSRF cookie and token), or - authenticate with a token Some anonymous compute resources still desire the ability to run code, completely without authentication. These services can disable all authentication and security checks, with the full knowledge of what that implies. """, ) _allow_unauthenticated_access_env = "JUPYTER_SERVER_ALLOW_UNAUTHENTICATED_ACCESS" allow_unauthenticated_access = Bool( True, config=True, help=f"""Allow unauthenticated access to endpoints without authentication rule. When set to `True` (default in jupyter-server 2.0, subject to change in the future), any request to an endpoint without an authentication rule (either `@tornado.web.authenticated`, or `@allow_unauthenticated`) will be permitted, regardless of whether user has logged in or not. When set to `False`, logging in will be required for access to each endpoint, excluding the endpoints marked with `@allow_unauthenticated` decorator. This option can be configured using `{_allow_unauthenticated_access_env}` environment variable: any non-empty value other than "true" and "yes" will prevent unauthenticated access to endpoints without `@allow_unauthenticated`. """, ) @default("allow_unauthenticated_access") def _allow_unauthenticated_access_default(self): if os.getenv(self._allow_unauthenticated_access_env): return os.environ[self._allow_unauthenticated_access_env].lower() in ["true", "yes"] return True allow_remote_access = Bool( config=True, help="""Allow requests where the Host header doesn't point to a local server By default, requests get a 403 forbidden response if the 'Host' header shows that the browser thinks it's on a non-local domain. Setting this option to True disables this check. This protects against 'DNS rebinding' attacks, where a remote web server serves you a page and then changes its DNS to send later requests to a local IP, bypassing same-origin checks. Local IP addresses (such as 127.0.0.1 and ::1) are allowed as local, along with hostnames configured in local_hostnames. """, ) @default("allow_remote_access") def _default_allow_remote(self) -> bool: """Disallow remote access if we're listening only on loopback addresses""" # if blank, self.ip was configured to "*" meaning bind to all interfaces, # see _valdate_ip if self.ip == "": return True try: addr = ipaddress.ip_address(self.ip) except ValueError: # Address is a hostname for info in socket.getaddrinfo(self.ip, self.port, 0, socket.SOCK_STREAM): addr = info[4][0] # type:ignore[assignment] try: parsed = ipaddress.ip_address(addr.split("%")[0]) # type:ignore[union-attr] except ValueError: self.log.warning("Unrecognised IP address: %r", addr) continue # Macs map localhost to 'fe80::1%lo0', a link local address # scoped to the loopback interface. For now, we'll assume that # any scoped link-local address is effectively local. if not ( parsed.is_loopback or (("%" in addr) and parsed.is_link_local) # type:ignore[operator] ): return True return False else: return not addr.is_loopback use_redirect_file = Bool( True, config=True, help="""Disable launching browser by redirect file For versions of notebook > 5.7.2, a security feature measure was added that prevented the authentication token used to launch the browser from being visible. This feature makes it difficult for other users on a multi-user system from running code in your Jupyter session as you. However, some environments (like Windows Subsystem for Linux (WSL) and Chromebooks), launching a browser using a redirect file can lead the browser failing to load. This is because of the difference in file structures/paths between the runtime and the browser. Disabling this setting to False will disable this behavior, allowing the browser to launch by using a URL and visible token (as before). """, ) local_hostnames = List( Unicode(), ["localhost"], config=True, help="""Hostnames to allow as local when allow_remote_access is False. Local IP addresses (such as 127.0.0.1 and ::1) are automatically accepted as local as well. """, ) open_browser = Bool( False, config=True, help="""Whether to open in a browser after starting. The specific browser used is platform dependent and determined by the python standard library `webbrowser` module, unless it is overridden using the --browser (ServerApp.browser) configuration option. """, ) browser = Unicode( "", config=True, help="""Specify what command to use to invoke a web browser when starting the server. If not specified, the default browser will be determined by the `webbrowser` standard library module, which allows setting of the BROWSER environment variable to override it. """, ) webbrowser_open_new = Integer( 2, config=True, help=_i18n( """Specify where to open the server on startup. This is the `new` argument passed to the standard library method `webbrowser.open`. The behaviour is not guaranteed, but depends on browser support. Valid values are: - 2 opens a new tab, - 1 opens a new window, - 0 opens in an existing window. See the `webbrowser.open` documentation for details. """ ), ) tornado_settings = Dict( config=True, help=_i18n( "Supply overrides for the tornado.web.Application that the Jupyter server uses." ), ) websocket_compression_options = Any( None, config=True, help=_i18n( """ Set the tornado compression options for websocket connections. This value will be returned from :meth:`WebSocketHandler.get_compression_options`. None (default) will disable compression. A dict (even an empty one) will enable compression. See the tornado docs for WebSocketHandler.get_compression_options for details. """ ), ) terminado_settings = Dict( Union([List(), Unicode()]), config=True, help=_i18n('Supply overrides for terminado. Currently only supports "shell_command".'), ) cookie_options = Dict( config=True, help=_i18n("DEPRECATED. Use IdentityProvider.cookie_options"), ) get_secure_cookie_kwargs = Dict( config=True, help=_i18n("DEPRECATED. Use IdentityProvider.get_secure_cookie_kwargs"), ) @observe("cookie_options", "get_secure_cookie_kwargs") def _deprecated_cookie_config(self, change: t.Any) -> None: self._warn_deprecated_config(change, "IdentityProvider") ssl_options = Dict( allow_none=True, config=True, help=_i18n( """Supply SSL options for the tornado HTTPServer. See the tornado docs for details.""" ), ) jinja_environment_options = Dict( config=True, help=_i18n("Supply extra arguments that will be passed to Jinja environment."), ) jinja_template_vars = Dict( config=True, help=_i18n("Extra variables to supply to jinja templates when rendering."), ) base_url = Unicode( "/", config=True, help="""The base URL for the Jupyter server. Leading and trailing slashes can be omitted, and will automatically be added. """, ) @validate("base_url") def _update_base_url(self, proposal: t.Any) -> str: value = t.cast(str, proposal["value"]) if not value.startswith("/"): value = "/" + value if not value.endswith("/"): value = value + "/" return value extra_static_paths = List( Unicode(), config=True, help="""Extra paths to search for serving static files. This allows adding javascript/css to be available from the Jupyter server machine, or overriding individual files in the IPython""", ) @property def static_file_path(self) -> list[str]: """return extra paths + the default location""" return [*self.extra_static_paths, DEFAULT_STATIC_FILES_PATH] static_custom_path = List(Unicode(), help=_i18n("""Path to search for custom.js, css""")) @default("static_custom_path") def _default_static_custom_path(self) -> list[str]: return [os.path.join(d, "custom") for d in (self.config_dir, DEFAULT_STATIC_FILES_PATH)] extra_template_paths = List( Unicode(), config=True, help=_i18n( """Extra paths to search for serving jinja templates. Can be used to override templates from jupyter_server.templates.""" ), ) @property def template_file_path(self) -> list[str]: """return extra paths + the default locations""" return self.extra_template_paths + DEFAULT_TEMPLATE_PATH_LIST extra_services = List( Unicode(), config=True, help=_i18n( """handlers that should be loaded at higher priority than the default services""" ), ) websocket_url = Unicode( "", config=True, help="""The base URL for websockets, if it differs from the HTTP server (hint: it almost certainly doesn't). Should be in the form of an HTTP origin: ws[s]://hostname[:port] """, ) quit_button = Bool( True, config=True, help="""If True, display controls to shut down the Jupyter server, such as menu items or buttons.""", ) contents_manager_class = Type( default_value=AsyncLargeFileManager, klass=ContentsManager, config=True, help=_i18n("The content manager class to use."), ) kernel_manager_class = Type( klass=MappingKernelManager, config=True, help=_i18n("The kernel manager class to use."), ) @default("kernel_manager_class") def _default_kernel_manager_class(self) -> t.Union[str, type[AsyncMappingKernelManager]]: if self.gateway_config.gateway_enabled: return "jupyter_server.gateway.managers.GatewayMappingKernelManager" return AsyncMappingKernelManager session_manager_class = Type( config=True, help=_i18n("The session manager class to use."), ) @default("session_manager_class") def _default_session_manager_class(self) -> t.Union[str, type[SessionManager]]: if self.gateway_config.gateway_enabled: return "jupyter_server.gateway.managers.GatewaySessionManager" return SessionManager kernel_websocket_connection_class = Type( klass=BaseKernelWebsocketConnection, config=True, help=_i18n("The kernel websocket connection class to use."), ) @default("kernel_websocket_connection_class") def _default_kernel_websocket_connection_class( self, ) -> t.Union[str, type[ZMQChannelsWebsocketConnection]]: if self.gateway_config.gateway_enabled: return "jupyter_server.gateway.connections.GatewayWebSocketConnection" return ZMQChannelsWebsocketConnection websocket_ping_interval = Integer( config=True, help=""" Configure the websocket ping interval in seconds. Websockets are long-lived connections that are used by some Jupyter Server extensions. Periodic pings help to detect disconnected clients and keep the connection active. If this is set to None, then no pings will be performed. When a ping is sent, the client has ``websocket_ping_timeout`` seconds to respond. If no response is received within this period, the connection will be closed from the server side. """, ) websocket_ping_timeout = Integer( config=True, help=""" Configure the websocket ping timeout in seconds. See ``websocket_ping_interval`` for details. """, ) config_manager_class = Type( default_value=ConfigManager, config=True, help=_i18n("The config manager class to use"), ) kernel_spec_manager = Instance(KernelSpecManager, allow_none=True) kernel_spec_manager_class = Type( config=True, help=""" The kernel spec manager class to use. Should be a subclass of `jupyter_client.kernelspec.KernelSpecManager`. The Api of KernelSpecManager is provisional and might change without warning between this version of Jupyter and the next stable one. """, ) @default("kernel_spec_manager_class") def _default_kernel_spec_manager_class(self) -> t.Union[str, type[KernelSpecManager]]: if self.gateway_config.gateway_enabled: return "jupyter_server.gateway.managers.GatewayKernelSpecManager" return KernelSpecManager login_handler_class = Type( default_value=LoginHandler, klass=web.RequestHandler, allow_none=True, config=True, help=_i18n("The login handler class to use."), ) logout_handler_class = Type( default_value=LogoutHandler, klass=web.RequestHandler, allow_none=True, config=True, help=_i18n("The logout handler class to use."), ) # TODO: detect deprecated login handler config authorizer_class = Type( default_value=AllowAllAuthorizer, klass=Authorizer, config=True, help=_i18n("The authorizer class to use."), ) identity_provider_class = Type( default_value=PasswordIdentityProvider, klass=IdentityProvider, config=True, help=_i18n("The identity provider class to use."), ) trust_xheaders = Bool( False, config=True, help=( _i18n( "Whether to trust or not X-Scheme/X-Forwarded-Proto and X-Real-Ip/X-Forwarded-For headers" "sent by the upstream reverse proxy. Necessary if the proxy handles SSL" ) ), ) event_logger = Instance( EventLogger, allow_none=True, help="An EventLogger for emitting structured event data from Jupyter Server and extensions.", ) info_file = Unicode() @default("info_file") def _default_info_file(self) -> str: info_file = "jpserver-%s.json" % os.getpid() return os.path.join(self.runtime_dir, info_file) no_browser_open_file = Bool( False, help="If True, do not write redirect HTML file disk, or show in messages." ) browser_open_file = Unicode() @default("browser_open_file") def _default_browser_open_file(self) -> str: basename = "jpserver-%s-open.html" % os.getpid() return os.path.join(self.runtime_dir, basename) browser_open_file_to_run = Unicode() @default("browser_open_file_to_run") def _default_browser_open_file_to_run(self) -> str: basename = "jpserver-file-to-run-%s-open.html" % os.getpid() return os.path.join(self.runtime_dir, basename) pylab = Unicode( "disabled", config=True, help=_i18n( """ DISABLED: use %pylab or %matplotlib in the notebook to enable matplotlib. """ ), ) @observe("pylab") def _update_pylab(self, change: t.Any) -> None: """when --pylab is specified, display a warning and exit""" backend = " %s" % change["new"] if change["new"] != "warn" else "" self.log.error( _i18n("Support for specifying --pylab on the command line has been removed.") ) self.log.error( _i18n("Please use `%pylab{0}` or `%matplotlib{0}` in the notebook itself.").format( backend ) ) self.exit(1) notebook_dir = Unicode(config=True, help=_i18n("DEPRECATED, use root_dir.")) @observe("notebook_dir") def _update_notebook_dir(self, change: t.Any) -> None: if self._root_dir_set: # only use deprecated config if new config is not set return self.log.warning(_i18n("notebook_dir is deprecated, use root_dir")) self.root_dir = change["new"] external_connection_dir = Unicode( None, allow_none=True, config=True, help=_i18n( "The directory to look at for external kernel connection files, if allow_external_kernels is True. " "Defaults to Jupyter runtime_dir/external_kernels. " "Make sure that this directory is not filled with left-over connection files, " "that could result in unnecessary kernel manager creations." ), ) allow_external_kernels = Bool( False, config=True, help=_i18n( "Whether or not to allow external kernels, whose connection files are placed in external_connection_dir." ), ) root_dir = Unicode(config=True, help=_i18n("The directory to use for notebooks and kernels.")) _root_dir_set = False @default("root_dir") def _default_root_dir(self) -> str: if self.file_to_run: self._root_dir_set = True return os.path.dirname(os.path.abspath(self.file_to_run)) else: return os.getcwd() def _normalize_dir(self, value: str) -> str: """Normalize a directory.""" # Strip any trailing slashes # *except* if it's root _, path = os.path.splitdrive(value) if path == os.sep: return value value = value.rstrip(os.sep) if not os.path.isabs(value): # If we receive a non-absolute path, make it absolute. value = os.path.abspath(value) return value @validate("root_dir") def _root_dir_validate(self, proposal: t.Any) -> str: value = self._normalize_dir(proposal["value"]) if not os.path.isdir(value): raise TraitError(trans.gettext("No such directory: '%r'") % value) return value @observe("root_dir") def _root_dir_changed(self, change: t.Any) -> None: # record that root_dir is set, # which affects loading of deprecated notebook_dir self._root_dir_set = True preferred_dir = Unicode( config=True, help=trans.gettext( "Preferred starting directory to use for notebooks and kernels. ServerApp.preferred_dir is deprecated in jupyter-server 2.0. Use FileContentsManager.preferred_dir instead" ), ) @default("preferred_dir") def _default_prefered_dir(self) -> str: return self.root_dir @validate("preferred_dir") def _preferred_dir_validate(self, proposal: t.Any) -> str: value = self._normalize_dir(proposal["value"]) if not os.path.isdir(value): raise TraitError(trans.gettext("No such preferred dir: '%r'") % value) return value @observe("server_extensions") def _update_server_extensions(self, change: t.Any) -> None: self.log.warning(_i18n("server_extensions is deprecated, use jpserver_extensions")) self.server_extensions = change["new"] jpserver_extensions = Dict( default_value={}, value_trait=Bool(), config=True, help=( _i18n( "Dict of Python modules to load as Jupyter server extensions." "Entry values can be used to enable and disable the loading of" "the extensions. The extensions will be loaded in alphabetical " "order." ) ), ) reraise_server_extension_failures = Bool( False, config=True, help=_i18n("Reraise exceptions encountered loading server extensions?"), ) kernel_ws_protocol = Unicode( allow_none=True, config=True, help=_i18n("DEPRECATED. Use ZMQChannelsWebsocketConnection.kernel_ws_protocol"), ) @observe("kernel_ws_protocol") def _deprecated_kernel_ws_protocol(self, change: t.Any) -> None: self._warn_deprecated_config(change, "ZMQChannelsWebsocketConnection") limit_rate = Bool( allow_none=True, config=True, help=_i18n("DEPRECATED. Use ZMQChannelsWebsocketConnection.limit_rate"), ) @observe("limit_rate") def _deprecated_limit_rate(self, change: t.Any) -> None: self._warn_deprecated_config(change, "ZMQChannelsWebsocketConnection") iopub_msg_rate_limit = Float( allow_none=True, config=True, help=_i18n("DEPRECATED. Use ZMQChannelsWebsocketConnection.iopub_msg_rate_limit"), ) @observe("iopub_msg_rate_limit") def _deprecated_iopub_msg_rate_limit(self, change: t.Any) -> None: self._warn_deprecated_config(change, "ZMQChannelsWebsocketConnection") iopub_data_rate_limit = Float( allow_none=True, config=True, help=_i18n("DEPRECATED. Use ZMQChannelsWebsocketConnection.iopub_data_rate_limit"), ) @observe("iopub_data_rate_limit") def _deprecated_iopub_data_rate_limit(self, change: t.Any) -> None: self._warn_deprecated_config(change, "ZMQChannelsWebsocketConnection") rate_limit_window = Float( allow_none=True, config=True, help=_i18n("DEPRECATED. Use ZMQChannelsWebsocketConnection.rate_limit_window"), ) @observe("rate_limit_window") def _deprecated_rate_limit_window(self, change: t.Any) -> None: self._warn_deprecated_config(change, "ZMQChannelsWebsocketConnection") shutdown_no_activity_timeout = Integer( 0, config=True, help=( "Shut down the server after N seconds with no kernels" "running and no activity. " "This can be used together with culling idle kernels " "(MappingKernelManager.cull_idle_timeout) to " "shutdown the Jupyter server when it's not in use. This is not " "precisely timed: it may shut down up to a minute later. " "0 (the default) disables this automatic shutdown." ), ) terminals_enabled = Bool( config=True, help=_i18n( """Set to False to disable terminals. This does *not* make the server more secure by itself. Anything the user can in a terminal, they can also do in a notebook. Terminals may also be automatically disabled if the terminado package is not available. """ ), ) @default("terminals_enabled") def _default_terminals_enabled(self) -> bool: return True authenticate_prometheus = Bool( True, help="""" Require authentication to access prometheus metrics. """, config=True, ) record_http_request_metrics = Bool( True, help=""" Record http_request_duration_seconds metric in the metrics endpoint. Since a histogram is exposed for each request handler, this can create a *lot* of metrics, creating operational challenges for multitenant deployments. Set to False to disable recording the http_request_duration_seconds metric. """, ) static_immutable_cache = List( Unicode(), help=""" Paths to set up static files as immutable. This allow setting up the cache control of static files as immutable. It should be used for static file named with a hash for instance. """, config=True, ) _starter_app = Instance( default_value=None, allow_none=True, klass="jupyter_server.extension.application.ExtensionApp", ) @property def starter_app(self) -> t.Any: """Get the Extension that started this server.""" return self._starter_app def parse_command_line(self, argv: t.Optional[list[str]] = None) -> None: """Parse the command line options.""" super().parse_command_line(argv) if self.extra_args: arg0 = self.extra_args[0] f = os.path.abspath(arg0) self.argv.remove(arg0) if not os.path.exists(f): self.log.critical(_i18n("No such file or directory: %s"), f) self.exit(1) # Use config here, to ensure that it takes higher priority than # anything that comes from the config dirs. c = Config() if os.path.isdir(f): c.ServerApp.root_dir = f elif os.path.isfile(f): c.ServerApp.file_to_run = f self.update_config(c) def init_configurables(self) -> None: """Initialize configurables.""" # If gateway server is configured, replace appropriate managers to perform redirection. To make # this determination, instantiate the GatewayClient config singleton. self.gateway_config = GatewayClient.instance(parent=self) if not issubclass( self.kernel_manager_class, AsyncMappingKernelManager, ): warnings.warn( "The synchronous MappingKernelManager class is deprecated and will not be supported in Jupyter Server 3.0", DeprecationWarning, stacklevel=2, ) if not issubclass( self.contents_manager_class, AsyncContentsManager, ): warnings.warn( "The synchronous ContentsManager classes are deprecated and will not be supported in Jupyter Server 3.0", DeprecationWarning, stacklevel=2, ) self.kernel_spec_manager = self.kernel_spec_manager_class( parent=self, ) kwargs = { "parent": self, "log": self.log, "connection_dir": self.runtime_dir, "kernel_spec_manager": self.kernel_spec_manager, } if jupyter_client.version_info > (8, 3, 0): # type:ignore[attr-defined] if self.allow_external_kernels: external_connection_dir = self.external_connection_dir if external_connection_dir is None: external_connection_dir = str(Path(self.runtime_dir) / "external_kernels") kwargs["external_connection_dir"] = external_connection_dir elif self.allow_external_kernels: self.log.warning( "Although allow_external_kernels=True, external kernels are not supported " "because jupyter-client's version does not allow them (should be >8.3.0)." ) self.kernel_manager = self.kernel_manager_class(**kwargs) self.contents_manager = self.contents_manager_class( parent=self, log=self.log, ) # Trigger a default/validation here explicitly while we still support the # deprecated trait on ServerApp (FIXME remove when deprecation finalized) self.contents_manager.preferred_dir # noqa: B018 self.session_manager = self.session_manager_class( parent=self, log=self.log, kernel_manager=self.kernel_manager, contents_manager=self.contents_manager, ) self.config_manager = self.config_manager_class( parent=self, log=self.log, ) identity_provider_kwargs = {"parent": self, "log": self.log} if ( self.login_handler_class is not LoginHandler and self.identity_provider_class is PasswordIdentityProvider ): # default identity provider, non-default LoginHandler # this indicates legacy custom LoginHandler config. # enable LegacyIdentityProvider, which defers to the LoginHandler for pre-2.0 behavior. self.identity_provider_class = LegacyIdentityProvider self.log.warning( f"Customizing authentication via ServerApp.login_handler_class={self.login_handler_class}" " is deprecated in Jupyter Server 2.0." " Use ServerApp.identity_provider_class." " Falling back on legacy authentication.", ) identity_provider_kwargs["login_handler_class"] = self.login_handler_class if self.logout_handler_class: identity_provider_kwargs["logout_handler_class"] = self.logout_handler_class elif self.login_handler_class is not LoginHandler: # non-default login handler ignored because also explicitly set identity provider self.log.warning( f"Ignoring deprecated config ServerApp.login_handler_class={self.login_handler_class}." " Superseded by ServerApp.identity_provider_class={self.identity_provider_class}." ) self.identity_provider = self.identity_provider_class(**identity_provider_kwargs) if self.identity_provider_class is LegacyIdentityProvider: # legacy config stored the password in tornado_settings self.tornado_settings["password"] = self.identity_provider.hashed_password # type:ignore[attr-defined] self.tornado_settings["token"] = self.identity_provider.token if self._token_set: self.log.warning( "ServerApp.token config is deprecated in jupyter-server 2.0. Use IdentityProvider.token" ) if self.identity_provider.token_generated: # default behavior: generated default token # preserve deprecated ServerApp.token config self.identity_provider.token_generated = False self.identity_provider.token = self.token else: # identity_provider didn't generate a default token, # that means it has some config that should take higher priority than deprecated ServerApp.token self.log.warning("Ignoring deprecated ServerApp.token config") self.authorizer = self.authorizer_class( parent=self, log=self.log, identity_provider=self.identity_provider ) def init_logging(self) -> None: """Initialize logging.""" # This prevents double log messages because tornado use a root logger that # self.log is a child of. The logging module dipatches log messages to a log # and all of its ancenstors until propagate is set to False. self.log.propagate = False for log in app_log, access_log, gen_log: # consistent log output name (ServerApp instead of tornado.access, etc.) log.name = self.log.name # hook up tornado 3's loggers to our app handlers logger = logging.getLogger("tornado") logger.propagate = True logger.parent = self.log logger.setLevel(self.log.level) def init_event_logger(self) -> None: """Initialize the Event Bus.""" self.event_logger = EventLogger(parent=self) # Load the core Jupyter Server event schemas # All event schemas must start with Jupyter Server's # events URI, `JUPYTER_SERVER_EVENTS_URI`. schema_ids = [ "https://events.jupyter.org/jupyter_server/contents_service/v1", "https://events.jupyter.org/jupyter_server/gateway_client/v1", "https://events.jupyter.org/jupyter_server/kernel_actions/v1", ] for schema_id in schema_ids: # Get the schema path from the schema ID. rel_schema_path = schema_id.replace(JUPYTER_SERVER_EVENTS_URI + "/", "") + ".yaml" schema_path = DEFAULT_EVENTS_SCHEMA_PATH / rel_schema_path # Use this pathlib object to register the schema self.event_logger.register_event_schema(schema_path) def init_webapp(self) -> None: """initialize tornado webapp""" self.tornado_settings["allow_origin"] = self.allow_origin self.tornado_settings["websocket_compression_options"] = self.websocket_compression_options if self.allow_origin_pat: self.tornado_settings["allow_origin_pat"] = re.compile(self.allow_origin_pat) self.tornado_settings["allow_credentials"] = self.allow_credentials self.tornado_settings["autoreload"] = self.autoreload # deprecate accessing these directly, in favor of identity_provider? self.tornado_settings["cookie_options"] = self.identity_provider.cookie_options self.tornado_settings["get_secure_cookie_kwargs"] = ( self.identity_provider.get_secure_cookie_kwargs ) self.tornado_settings["token"] = self.identity_provider.token if self.static_immutable_cache: self.tornado_settings["static_immutable_cache"] = self.static_immutable_cache # ensure default_url starts with base_url if not self.default_url.startswith(self.base_url): self.default_url = url_path_join(self.base_url, self.default_url) # Socket options validation. if self.sock: if self.port != DEFAULT_JUPYTER_SERVER_PORT: self.log.critical( ("Options --port and --sock are mutually exclusive. Aborting."), ) sys.exit(1) else: # Reset the default port if we're using a UNIX socket. self.port = 0 if self.open_browser: # If we're bound to a UNIX socket, we can't reliably connect from a browser. self.log.info( ("Ignoring --ServerApp.open_browser due to --sock being used."), ) if self.file_to_run: self.log.critical( ("Options --ServerApp.file_to_run and --sock are mutually exclusive."), ) sys.exit(1) if sys.platform.startswith("win"): self.log.critical( ( "Option --sock is not supported on Windows, but got value of %s. Aborting." % self.sock ), ) sys.exit(1) self.web_app = ServerWebApplication( self, self.default_services, self.kernel_manager, self.contents_manager, self.session_manager, self.kernel_spec_manager, self.config_manager, self.event_logger, self.extra_services, self.log, self.base_url, self.default_url, self.tornado_settings, self.jinja_environment_options, authorizer=self.authorizer, identity_provider=self.identity_provider, kernel_websocket_connection_class=self.kernel_websocket_connection_class, websocket_ping_interval=self.websocket_ping_interval, websocket_ping_timeout=self.websocket_ping_timeout, ) if self.certfile: self.ssl_options["certfile"] = self.certfile if self.keyfile: self.ssl_options["keyfile"] = self.keyfile if self.client_ca: self.ssl_options["ca_certs"] = self.client_ca if not self.ssl_options: # could be an empty dict or None # None indicates no SSL config self.ssl_options = None # type:ignore[assignment] else: # SSL may be missing, so only import it if it's to be used import ssl # PROTOCOL_TLS selects the highest ssl/tls protocol version that both the client and # server support. When PROTOCOL_TLS is not available use PROTOCOL_SSLv23. self.ssl_options.setdefault( "ssl_version", getattr(ssl, "PROTOCOL_TLS", ssl.PROTOCOL_SSLv23) ) if self.ssl_options.get("ca_certs", False): self.ssl_options.setdefault("cert_reqs", ssl.CERT_REQUIRED) self.identity_provider.validate_security(self, ssl_options=self.ssl_options) if isinstance(self.identity_provider, LegacyIdentityProvider): # LegacyIdentityProvider needs access to the tornado settings dict self.identity_provider.settings = self.web_app.settings def init_resources(self) -> None: """initialize system resources""" if resource is None: self.log.debug( # type:ignore[unreachable] "Ignoring min_open_files_limit because the limit cannot be adjusted (for example, on Windows)" ) return old_soft, old_hard = resource.getrlimit(resource.RLIMIT_NOFILE) soft = self.min_open_files_limit hard = old_hard if soft is not None and old_soft < soft: if hard < soft: hard = soft self.log.debug( f"Raising open file limit: soft {old_soft}->{soft}; hard {old_hard}->{hard}" ) resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard)) def _get_urlparts( self, path: t.Optional[str] = None, include_token: bool = False ) -> urllib.parse.ParseResult: """Constructs a urllib named tuple, ParseResult, with default values set by server config. The returned tuple can be manipulated using the `_replace` method. """ if self.sock: scheme = "http+unix" netloc = urlencode_unix_socket_path(self.sock) else: if not self.ip: ip = "localhost" # Handle nonexplicit hostname. elif self.ip in ("0.0.0.0", "::"): # noqa: S104 ip = "%s" % socket.gethostname() else: ip = f"[{self.ip}]" if ":" in self.ip else self.ip netloc = f"{ip}:{self.port}" scheme = "https" if self.certfile else "http" if not path: path = self.default_url query = None # Don't log full token if it came from config if include_token and self.identity_provider.token: token = ( self.identity_provider.token if self.identity_provider.token_generated else "..." ) query = urllib.parse.urlencode({"token": token}) # Build the URL Parts to dump. urlparts = urllib.parse.ParseResult( scheme=scheme, netloc=netloc, path=path, query=query or "", params="", fragment="" ) return urlparts @property def public_url(self) -> str: parts = self._get_urlparts(include_token=True) # Update with custom pieces. if self.custom_display_url: # Parse custom display_url custom = urllib.parse.urlparse(self.custom_display_url)._asdict() # Get pieces that are matter (non None) custom_updates = {key: item for key, item in custom.items() if item} # Update public URL parts with custom pieces. parts = parts._replace(**custom_updates) return parts.geturl() @property def local_url(self) -> str: parts = self._get_urlparts(include_token=True) # Update with custom pieces. if not self.sock: parts = parts._replace(netloc=f"127.0.0.1:{self.port}") return parts.geturl() @property def display_url(self) -> str: """Human readable string with URLs for interacting with the running Jupyter Server """ url = self.public_url + "\n " + self.local_url return url @property def connection_url(self) -> str: urlparts = self._get_urlparts(path=self.base_url) return urlparts.geturl() def init_signal(self) -> None: """Initialize signal handlers.""" if ( not sys.platform.startswith("win") and sys.stdin # type:ignore[truthy-bool] and sys.stdin.isatty() ): signal.signal(signal.SIGINT, self._handle_sigint) signal.signal(signal.SIGTERM, self._signal_stop) if hasattr(signal, "SIGUSR1"): # Windows doesn't support SIGUSR1 signal.signal(signal.SIGUSR1, self._signal_info) if hasattr(signal, "SIGINFO"): # only on BSD-based systems signal.signal(signal.SIGINFO, self._signal_info) def _handle_sigint(self, sig: t.Any, frame: t.Any) -> None: """SIGINT handler spawns confirmation dialog Note: JupyterHub replaces this method with _signal_stop in order to bypass the interactive prompt. https://github.com/jupyterhub/jupyterhub/pull/4864 """ # register more forceful signal handler for ^C^C case signal.signal(signal.SIGINT, self._signal_stop) # request confirmation dialog in bg thread, to avoid # blocking the App thread = threading.Thread(target=self._confirm_exit) thread.daemon = True thread.start() def _restore_sigint_handler(self) -> None: """callback for restoring original SIGINT handler""" signal.signal(signal.SIGINT, self._handle_sigint) def _confirm_exit(self) -> None: """confirm shutdown on ^C A second ^C, or answering 'y' within 5s will cause shutdown, otherwise original SIGINT handler will be restored. This doesn't work on Windows. """ info = self.log.info info(_i18n("interrupted")) # Check if answer_yes is set if self.answer_yes: self.log.critical(_i18n("Shutting down...")) # schedule stop on the main thread, # since this might be called from a signal handler self.stop(from_signal=True) return info(self.running_server_info()) yes = _i18n("y") no = _i18n("n") sys.stdout.write(_i18n("Shut down this Jupyter server (%s/[%s])? ") % (yes, no)) sys.stdout.flush() r, w, x = select.select([sys.stdin], [], [], 5) if r: line = sys.stdin.readline() if line.lower().startswith(yes) and no not in line.lower(): self.log.critical(_i18n("Shutdown confirmed")) # schedule stop on the main thread, # since this might be called from a signal handler self.stop(from_signal=True) return else: if self._stopping: # don't show 'no answer' if we're actually stopping, # e.g. ctrl-C ctrl-C return info(_i18n("No answer for 5s:")) info(_i18n("resuming operation...")) # no answer, or answer is no: # set it back to original SIGINT handler # use IOLoop.add_callback because signal.signal must be called # from main thread self.io_loop.add_callback_from_signal(self._restore_sigint_handler) def _signal_stop(self, sig: t.Any, frame: t.Any) -> None: """Handle a stop signal. Note: JupyterHub configures this method to be called for SIGINT. https://github.com/jupyterhub/jupyterhub/pull/4864 """ self.log.critical(_i18n("received signal %s, stopping"), sig) self.stop(from_signal=True) def _signal_info(self, sig: t.Any, frame: t.Any) -> None: """Handle an info signal.""" self.log.info(self.running_server_info()) def init_components(self) -> None: """Check the components submodule, and warn if it's unclean""" # TODO: this should still check, but now we use bower, not git submodule def find_server_extensions(self) -> None: """ Searches Jupyter paths for jpserver_extensions. """ # Walk through all config files looking for jpserver_extensions. # # Each extension will likely have a JSON config file enabling itself in # the "jupyter_server_config.d" directory. Find each of these and # merge there results in order of precedence. # # Load server extensions with ConfigManager. # This enables merging on keys, which we want for extension enabling. # Regular config loading only merges at the class level, # so each level clobbers the previous. manager = ExtensionConfigManager(read_config_path=self.config_file_paths) extensions = manager.get_jpserver_extensions() for modulename, enabled in sorted(extensions.items()): if modulename not in self.jpserver_extensions: self.config.ServerApp.jpserver_extensions.update({modulename: enabled}) self.jpserver_extensions.update({modulename: enabled}) def init_server_extensions(self) -> None: """ If an extension's metadata includes an 'app' key, the value must be a subclass of ExtensionApp. An instance of the class will be created at this step. The config for this instance will inherit the ServerApp's config object and load its own config. """ # Create an instance of the ExtensionManager. self.extension_manager = ExtensionManager(log=self.log, serverapp=self) self.extension_manager.from_jpserver_extensions(self.jpserver_extensions) self.extension_manager.link_all_extensions() def load_server_extensions(self) -> None: """Load any extensions specified by config. Import the module, then call the load_jupyter_server_extension function, if one exists. The extension API is experimental, and may change in future releases. """ self.extension_manager.load_all_extensions() def init_mime_overrides(self) -> None: # On some Windows machines, an application has registered incorrect # mimetypes in the registry. # Tornado uses this when serving .css and .js files, causing browsers to # reject these files. We know the mimetype always needs to be text/css for css # and application/javascript for JS, so we override it here # and explicitly tell the mimetypes to not trust the Windows registry if os.name == "nt": # do not trust windows registry, which regularly has bad info mimetypes.init(files=[]) # ensure css, js are correct, which are required for pages to function mimetypes.add_type("text/css", ".css") mimetypes.add_type("application/javascript", ".js") def shutdown_no_activity(self) -> None: """Shutdown server on timeout when there are no kernels or terminals.""" km = self.kernel_manager if len(km) != 0: return # Kernels still running if self.extension_manager.any_activity(): return seconds_since_active = (utcnow() - self.web_app.last_activity()).total_seconds() self.log.debug("No activity for %d seconds.", seconds_since_active) if seconds_since_active > self.shutdown_no_activity_timeout: self.log.info( "No kernels for %d seconds; shutting down.", seconds_since_active, ) self.stop() def init_shutdown_no_activity(self) -> None: """Initialize a shutdown on no activity.""" if self.shutdown_no_activity_timeout > 0: self.log.info( "Will shut down after %d seconds with no kernels.", self.shutdown_no_activity_timeout, ) pc = ioloop.PeriodicCallback(self.shutdown_no_activity, 60000) pc.start() @property def http_server(self) -> httpserver.HTTPServer: """An instance of Tornado's HTTPServer class for the Server Web Application.""" try: return self._http_server except AttributeError: msg = ( "An HTTPServer instance has not been created for the " "Server Web Application. To create an HTTPServer for this " "application, call `.init_httpserver()`." ) raise AttributeError(msg) from None def init_httpserver(self) -> None: """Creates an instance of a Tornado HTTPServer for the Server Web Application and sets the http_server attribute. """ # Check that a web_app has been initialized before starting a server. if not hasattr(self, "web_app"): msg = ( "A tornado web application has not be initialized. " "Try calling `.init_webapp()` first." ) raise AttributeError(msg) # Create an instance of the server. self._http_server = httpserver.HTTPServer( self.web_app, ssl_options=self.ssl_options, xheaders=self.trust_xheaders, max_body_size=self.max_body_size, max_buffer_size=self.max_buffer_size, ) # binding sockets must be called from inside an event loop if not self.sock: self._find_http_port() self.io_loop.add_callback(self._bind_http_server) def _bind_http_server(self) -> None: """Bind our http server.""" success = self._bind_http_server_unix() if self.sock else self._bind_http_server_tcp() if not success: self.log.critical( _i18n( "ERROR: the Jupyter server could not be started because " "no available port could be found." ) ) self.exit(1) def _bind_http_server_unix(self) -> bool: """Bind an http server on unix.""" if unix_socket_in_use(self.sock): self.log.warning(_i18n("The socket %s is already in use.") % self.sock) return False try: sock = bind_unix_socket(self.sock, mode=int(self.sock_mode.encode(), 8)) self.http_server.add_socket(sock) except OSError as e: if e.errno == errno.EADDRINUSE: self.log.warning(_i18n("The socket %s is already in use.") % self.sock) return False elif e.errno in (errno.EACCES, getattr(errno, "WSAEACCES", errno.EACCES)): self.log.warning(_i18n("Permission to listen on sock %s denied") % self.sock) return False else: raise else: return True def _bind_http_server_tcp(self) -> bool: """Bind a tcp server.""" self.http_server.listen(self.port, self.ip) return True def _find_http_port(self) -> None: """Find an available http port.""" success = False port = self.port for port in random_ports(self.port, self.port_retries + 1): try: sockets = bind_sockets(port, self.ip) sockets[0].close() except OSError as e: if e.errno == errno.EADDRINUSE: if self.port_retries: self.log.info( _i18n("The port %i is already in use, trying another port.") % port ) else: self.log.info(_i18n("The port %i is already in use.") % port) continue if e.errno in ( errno.EACCES, getattr(errno, "WSAEACCES", errno.EACCES), ): self.log.warning(_i18n("Permission to listen on port %i denied.") % port) continue raise else: success = True self.port = port break if not success: if self.port_retries: self.log.critical( _i18n( "ERROR: the Jupyter server could not be started because " "no available port could be found." ) ) else: self.log.critical( _i18n( "ERROR: the Jupyter server could not be started because " "port %i is not available." ) % port ) self.exit(1) @staticmethod def _init_asyncio_patch() -> None: """set default asyncio policy to be compatible with tornado Tornado 6.0 is not compatible with default asyncio ProactorEventLoop, which lacks basic *_reader methods. Tornado 6.1 adds a workaround to add these methods in a thread, but SelectorEventLoop should still be preferred to avoid the extra thread for ~all of our events, at least until asyncio adds *_reader methods to proactor. """ if sys.platform.startswith("win"): import asyncio try: from asyncio import WindowsProactorEventLoopPolicy, WindowsSelectorEventLoopPolicy except ImportError: pass # not affected else: if type(asyncio.get_event_loop_policy()) is WindowsProactorEventLoopPolicy: # prefer Selector to Proactor for tornado + pyzmq asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy()) def init_metrics(self) -> None: """ Initialize any prometheus metrics that need to be set up on server startup """ SERVER_INFO.info({"version": __version__}) for ext in self.extension_manager.extensions.values(): SERVER_EXTENSION_INFO.labels( name=ext.name, version=ext.version, enabled=str(ext.enabled).lower() ) started = self.web_app.settings["started"] SERVER_STARTED.set(started.timestamp()) LAST_ACTIVITY.set_function(lambda: self.web_app.last_activity().timestamp()) ACTIVE_DURATION.set_function( lambda: ( self.web_app.last_activity() - self.web_app.settings["started"] ).total_seconds() ) @catch_config_error def initialize( self, argv: t.Optional[list[str]] = None, find_extensions: bool = True, new_httpserver: bool = True, starter_extension: t.Any = None, ) -> None: """Initialize the Server application class, configurables, web application, and http server. Parameters ---------- argv : list or None CLI arguments to parse. find_extensions : bool If True, find and load extensions listed in Jupyter config paths. If False, only load extensions that are passed to ServerApp directly through the `argv`, `config`, or `jpserver_extensions` arguments. new_httpserver : bool If True, a tornado HTTPServer instance will be created and configured for the Server Web Application. This will set the http_server attribute of this class. starter_extension : str If given, it references the name of an extension point that started the Server. We will try to load configuration from extension point """ self._init_asyncio_patch() # Parse command line, load ServerApp config files, # and update ServerApp config. # preserve jpserver_extensions, which may have been set by starter_extension # don't let config clobber this value jpserver_extensions = self.jpserver_extensions.copy() super().initialize(argv=argv) self.jpserver_extensions.update(jpserver_extensions) if self._dispatching: return # initialize io loop as early as possible, # so configurables, extensions may reference the event loop self.init_ioloop() # Then, use extensions' config loading mechanism to # update config. ServerApp config takes precedence. if find_extensions: self.find_server_extensions() self.init_logging() self.init_event_logger() self.init_server_extensions() # Special case the starter extension and load # any server configuration is provides. if starter_extension: # Configure ServerApp based on named extension. point = self.extension_manager.extension_points[starter_extension] # Set starter_app property. if point.app: self._starter_app = point.app # Load any configuration that comes from the Extension point. self.update_config(Config(point.config)) # Initialize other pieces of the server. self.init_resources() self.init_configurables() self.init_components() self.init_webapp() self.init_signal() self.load_server_extensions() self.init_mime_overrides() self.init_shutdown_no_activity() self.init_metrics() if new_httpserver: self.init_httpserver() async def cleanup_kernels(self) -> None: """Shutdown all kernels. The kernels will shutdown themselves when this process no longer exists, but explicit shutdown allows the KernelManagers to cleanup the connection files. """ if not getattr(self, "kernel_manager", None): return n_kernels = len(self.kernel_manager.list_kernel_ids()) kernel_msg = trans.ngettext( "Shutting down %d kernel", "Shutting down %d kernels", n_kernels ) self.log.info(kernel_msg % n_kernels) await ensure_async(self.kernel_manager.shutdown_all()) async def cleanup_extensions(self) -> None: """Call shutdown hooks in all extensions.""" if not getattr(self, "extension_manager", None): return n_extensions = len(self.extension_manager.extension_apps) extension_msg = trans.ngettext( "Shutting down %d extension", "Shutting down %d extensions", n_extensions ) self.log.info(extension_msg % n_extensions) await ensure_async(self.extension_manager.stop_all_extensions()) def running_server_info(self, kernel_count: bool = True) -> str: """Return the current working directory and the server url information""" info = t.cast(str, self.contents_manager.info_string()) + "\n" if kernel_count: n_kernels = len(self.kernel_manager.list_kernel_ids()) kernel_msg = trans.ngettext("%d active kernel", "%d active kernels", n_kernels) info += kernel_msg % n_kernels info += "\n" # Format the info so that the URL fits on a single line in 80 char display info += _i18n(f"Jupyter Server {ServerApp.version} is running at:\n{self.display_url}") if self.gateway_config.gateway_enabled: info += ( _i18n("\nKernels will be managed by the Gateway server running at:\n%s") % self.gateway_config.url ) return info def server_info(self) -> dict[str, t.Any]: """Return a JSONable dict of information about this server.""" return { "url": self.connection_url, "hostname": self.ip if self.ip else "localhost", "port": self.port, "sock": self.sock, "secure": bool(self.certfile), "base_url": self.base_url, "token": self.identity_provider.token, "root_dir": os.path.abspath(self.root_dir), "password": bool(self.password), "pid": os.getpid(), "version": ServerApp.version, } def write_server_info_file(self) -> None: """Write the result of server_info() to the JSON file info_file.""" try: with secure_write(self.info_file) as f: json.dump(self.server_info(), f, indent=2, sort_keys=True) except OSError as e: self.log.error(_i18n("Failed to write server-info to %s: %r"), self.info_file, e) def remove_server_info_file(self) -> None: """Remove the jpserver-.json file created for this server. Ignores the error raised when the file has already been removed. """ try: os.unlink(self.info_file) except OSError as e: if e.errno != errno.ENOENT: raise def _resolve_file_to_run_and_root_dir(self) -> str: """Returns a relative path from file_to_run to root_dir. If root_dir and file_to_run are incompatible, i.e. on different subtrees, crash the app and log a critical message. Note that if root_dir is not configured and file_to_run is configured, root_dir will be set to the parent directory of file_to_run. """ rootdir_abspath = pathlib.Path(self.root_dir).absolute() file_rawpath = pathlib.Path(self.file_to_run) combined_path = (rootdir_abspath / file_rawpath).absolute() is_child = str(combined_path).startswith(str(rootdir_abspath)) if is_child: if combined_path.parent != rootdir_abspath: self.log.debug( "The `root_dir` trait is set to a directory that's not " "the immediate parent directory of `file_to_run`. Note that " "the server will start at `root_dir` and open the " "the file from the relative path to the `root_dir`." ) return str(combined_path.relative_to(rootdir_abspath)) self.log.critical( "`root_dir` and `file_to_run` are incompatible. They " "don't share the same subtrees. Make sure `file_to_run` " "is on the same path as `root_dir`." ) self.exit(1) return "" def _write_browser_open_file(self, url: str, fh: t.Any) -> None: """Write the browser open file.""" if self.identity_provider.token: url = url_concat(url, {"token": self.identity_provider.token}) url = url_path_join(self.connection_url, url) jinja2_env = self.web_app.settings["jinja2_env"] template = jinja2_env.get_template("browser-open.html") fh.write(template.render(open_url=url, base_url=self.base_url)) def write_browser_open_files(self) -> None: """Write an `browser_open_file` and `browser_open_file_to_run` files This can be used to open a file directly in a browser. """ # default_url contains base_url, but so does connection_url self.write_browser_open_file() # Create a second browser open file if # file_to_run is set. if self.file_to_run: # Make sure file_to_run and root_dir are compatible. file_to_run_relpath = self._resolve_file_to_run_and_root_dir() file_open_url = url_escape( url_path_join(self.file_url_prefix, *file_to_run_relpath.split(os.sep)) ) with open(self.browser_open_file_to_run, "w", encoding="utf-8") as f: self._write_browser_open_file(file_open_url, f) def write_browser_open_file(self) -> None: """Write an jpserver--open.html file This can be used to open the notebook in a browser """ # default_url contains base_url, but so does connection_url open_url = self.default_url[len(self.base_url) :] with open(self.browser_open_file, "w", encoding="utf-8") as f: self._write_browser_open_file(open_url, f) def remove_browser_open_files(self) -> None: """Remove the `browser_open_file` and `browser_open_file_to_run` files created for this server. Ignores the error raised when the file has already been removed. """ self.remove_browser_open_file() try: os.unlink(self.browser_open_file_to_run) except OSError as e: if e.errno != errno.ENOENT: raise def remove_browser_open_file(self) -> None: """Remove the jpserver--open.html file created for this server. Ignores the error raised when the file has already been removed. """ try: os.unlink(self.browser_open_file) except OSError as e: if e.errno != errno.ENOENT: raise def _prepare_browser_open(self) -> tuple[str, t.Optional[str]]: """Prepare to open the browser.""" if not self.use_redirect_file: uri = self.default_url[len(self.base_url) :] if self.identity_provider.token: uri = url_concat(uri, {"token": self.identity_provider.token}) if self.file_to_run: # noqa: SIM108 # Create a separate, temporary open-browser-file # pointing at a specific file. open_file = self.browser_open_file_to_run else: # otherwise, just return the usual open browser file. open_file = self.browser_open_file if self.use_redirect_file: assembled_url = urljoin("file:", pathname2url(open_file)) else: assembled_url = url_path_join(self.connection_url, uri) return assembled_url, open_file def launch_browser(self) -> None: """Launch the browser.""" # Deferred import for environments that do not have # the webbrowser module. import webbrowser try: browser = webbrowser.get(self.browser or None) except webbrowser.Error as e: self.log.warning(_i18n("No web browser found: %r.") % e) browser = None if not browser: return assembled_url, _ = self._prepare_browser_open() def target(): assert browser is not None browser.open(assembled_url, new=self.webbrowser_open_new) threading.Thread(target=target).start() def start_app(self) -> None: """Start the Jupyter Server application.""" super().start() if not self.allow_root: # check if we are running as root, and abort if it's not allowed try: uid = os.geteuid() except AttributeError: uid = -1 # anything nonzero here, since we can't check UID assume non-root if uid == 0: self.log.critical( _i18n("Running as root is not recommended. Use --allow-root to bypass.") ) self.exit(1) info = self.log.info for line in self.running_server_info(kernel_count=False).split("\n"): info(line) info( _i18n( "Use Control-C to stop this server and shut down all kernels (twice to skip confirmation)." ) ) if "dev" in __version__: info( _i18n( "Welcome to Project Jupyter! Explore the various tools available" " and their corresponding documentation. If you are interested" " in contributing to the platform, please visit the community" " resources section at https://jupyter.org/community.html." ) ) self.write_server_info_file() if not self.no_browser_open_file: self.write_browser_open_files() # Handle the browser opening. if self.open_browser and not self.sock: self.launch_browser() if self.identity_provider.token and self.identity_provider.token_generated: # log full URL with generated token, so there's a copy/pasteable link # with auth info. if self.sock: self.log.critical( "\n".join( [ "\n", "Jupyter Server is listening on %s" % self.display_url, "", ( "UNIX sockets are not browser-connectable, but you can tunnel to " f"the instance via e.g.`ssh -L 8888:{self.sock} -N user@this_host` and then " f"open e.g. {self.connection_url} in a browser." ), ] ) ) else: if self.no_browser_open_file: message = [ "\n", _i18n("To access the server, copy and paste one of these URLs:"), " %s" % self.display_url, ] else: message = [ "\n", _i18n( "To access the server, open this file in a browser:", ), " %s" % urljoin("file:", pathname2url(self.browser_open_file)), _i18n( "Or copy and paste one of these URLs:", ), " %s" % self.display_url, ] self.log.critical("\n".join(message)) async def _cleanup(self) -> None: """General cleanup of files, extensions and kernels created by this instance ServerApp. """ self.remove_server_info_file() self.remove_browser_open_files() await self.cleanup_extensions() await self.cleanup_kernels() try: await self.kernel_websocket_connection_class.close_all() # type:ignore[attr-defined] except AttributeError: # This can happen in two different scenarios: # # 1. During tests, where the _cleanup method is invoked without # the corresponding initialize method having been invoked. # 2. If the provided `kernel_websocket_connection_class` does not # implement the `close_all` class method. # # In either case, we don't need to do anything and just want to treat # the raised error as a no-op. pass if getattr(self, "kernel_manager", None): self.kernel_manager.__del__() if getattr(self, "session_manager", None): self.session_manager.close() if hasattr(self, "http_server"): # Stop a server if its set. self.http_server.stop() def start_ioloop(self) -> None: """Start the IO Loop.""" if sys.platform.startswith("win"): # add no-op to wake every 5s # to handle signals that may be ignored by the inner loop pc = ioloop.PeriodicCallback(lambda: None, 5000) pc.start() try: self.io_loop.start() except KeyboardInterrupt: self.log.info(_i18n("Interrupted...")) def init_ioloop(self) -> None: """init self.io_loop so that an extension can use it by io_loop.call_later() to create background tasks""" self.io_loop = ioloop.IOLoop.current() def start(self) -> None: """Start the Jupyter server app, after initialization This method takes no arguments so all configuration and initialization must be done prior to calling this method.""" self.start_app() self.start_ioloop() async def _stop(self) -> None: """Cleanup resources and stop the IO Loop.""" await self._cleanup() if getattr(self, "io_loop", None): self.io_loop.stop() def stop(self, from_signal: bool = False) -> None: """Cleanup resources and stop the server.""" # signal that stopping has begun self._stopping = True if hasattr(self, "http_server"): # Stop a server if its set. self.http_server.stop() if getattr(self, "io_loop", None): # use IOLoop.add_callback because signal.signal must be called # from main thread if from_signal: self.io_loop.add_callback_from_signal(self._stop) else: self.io_loop.add_callback(self._stop) def list_running_servers( runtime_dir: t.Optional[str] = None, log: t.Optional[logging.Logger] = None ) -> t.Generator[t.Any, None, None]: """Iterate over the server info files of running Jupyter servers. Given a runtime directory, find jpserver-* files in the security directory, and yield dicts of their information, each one pertaining to a currently running Jupyter server instance. """ if runtime_dir is None: runtime_dir = jupyter_runtime_dir() # The runtime dir might not exist if not os.path.isdir(runtime_dir): return for file_name in os.listdir(runtime_dir): if re.match("jpserver-(.+).json", file_name): with open(os.path.join(runtime_dir, file_name), encoding="utf-8") as f: # Handle race condition where file is being written. try: info = json.load(f) except json.JSONDecodeError: continue # Simple check whether that process is really still running # Also remove leftover files from IPython 2.x without a pid field if ("pid" in info) and check_pid(info["pid"]): yield info else: # If the process has died, try to delete its info file try: os.unlink(os.path.join(runtime_dir, file_name)) except OSError as e: if log: log.warning(_i18n("Deleting server info file failed: %s.") % e) # ----------------------------------------------------------------------------- # Main entry point # ----------------------------------------------------------------------------- main = launch_new_instance = ServerApp.launch_instance jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/000077500000000000000000000000001473126534200252265ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/__init__.py000066400000000000000000000000001473126534200273250ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/api/000077500000000000000000000000001473126534200257775ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/api/__init__.py000066400000000000000000000000001473126534200300760ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/api/api.yaml000066400000000000000000000700041473126534200274350ustar00rootroot00000000000000swagger: "2.0" info: title: Jupyter Server API description: Server API version: "5" contact: name: Jupyter Project url: https://jupyter.org # will be prefixed to all paths basePath: / produces: - application/json consumes: - application/json parameters: kernel: name: kernel_id required: true in: path description: kernel uuid type: string format: uuid session: name: session required: true in: path description: session uuid type: string format: uuid path: name: path required: true in: path description: file path type: string permissions: name: permissions type: string required: false in: query description: | JSON-serialized dictionary of `{"resource": ["action",]}` (dict of lists of strings) to check. The same dictionary structure will be returned, containing only the actions for which the user is authorized. checkpoint_id: name: checkpoint_id required: true in: path description: Checkpoint id for a file type: string section_name: name: section_name required: true in: path description: Name of config section type: string terminal_id: name: terminal_id required: true in: path description: ID of terminal session type: string paths: /api/: get: summary: Get the Jupyter Server version description: | This endpoint returns only the Jupyter Server version. It does not require any authentication. responses: 200: description: Jupyter Server version information schema: type: object properties: version: type: string description: The Jupyter Server version number as a string. /api/contents/{path}: parameters: - $ref: "#/parameters/path" get: summary: Get contents of file or directory description: "A client can optionally specify a type and/or format argument via URL parameter. When given, the Contents service shall return a model in the requested type and/or format. If the request cannot be satisfied, e.g. type=text is requested, but the file is binary, then the request shall fail with 400 and have a JSON response containing a 'reason' field, with the value 'bad format' or 'bad type', depending on what was requested." tags: - contents parameters: - name: type in: query description: File type ('file', 'directory') type: string enum: - file - directory - name: format in: query description: "How file content should be returned ('text', 'base64')" type: string enum: - text - base64 - name: content in: query description: "Return content (0 for no content, 1 for return content)" type: integer - name: hash in: query description: "May return hash hexdigest string of content and the hash algorithm (0 for no hash - default, 1 for return hash). It may be ignored by the content manager." type: integer responses: 404: description: No item found 400: description: Bad request schema: type: object properties: error: type: string description: Error condition reason: type: string description: Explanation of error reason 200: description: Contents of file or directory headers: Last-Modified: description: Last modified date for file type: string format: dateTime schema: $ref: "#/definitions/Contents" 500: description: Model key error post: summary: Create a new file in the specified path description: "A POST to /api/contents/path creates a New untitled, empty file or directory. A POST to /api/contents/path with body {'copy_from': '/path/to/OtherNotebook.ipynb'} creates a new copy of OtherNotebook in path." tags: - contents parameters: - name: model in: body description: Path of file to copy schema: type: object properties: copy_from: type: string ext: type: string type: type: string responses: 201: description: File created headers: Location: description: URL for the new file type: string format: url schema: $ref: "#/definitions/Contents" 404: description: No item found 400: description: Bad request schema: type: object properties: error: type: string description: Error condition reason: type: string description: Explanation of error reason patch: summary: Rename a file or directory without re-uploading content tags: - contents parameters: - name: path in: body required: true description: New path for file or directory. schema: type: object properties: path: type: string format: path description: New path for file or directory responses: 200: description: Path updated headers: Location: description: Updated URL for the file or directory type: string format: url schema: $ref: "#/definitions/Contents" 400: description: No data provided schema: type: object properties: error: type: string description: Error condition reason: type: string description: Explanation of error reason put: summary: Save or upload file. description: "Saves the file in the location specified by name and path. PUT is very similar to POST, but the requester specifies the name, whereas with POST, the server picks the name." tags: - contents parameters: - name: model in: body description: New path for file or directory schema: type: object properties: name: type: string description: The new filename if changed path: type: string description: New path for file or directory type: type: string description: Path dtype ('notebook', 'file', 'directory') format: type: string description: File format ('json', 'text', 'base64') content: type: string description: The actual body of the document excluding directory type responses: 200: description: File saved headers: Location: description: Updated URL for the file or directory type: string format: url schema: $ref: "#/definitions/Contents" 201: description: Path created headers: Location: description: URL for the file or directory type: string format: url schema: $ref: "#/definitions/Contents" 400: description: No data provided schema: type: object properties: error: type: string description: Error condition reason: type: string description: Explanation of error reason delete: summary: Delete a file in the given path tags: - contents responses: 204: description: File deleted headers: Location: description: URL for the removed file type: string format: url /api/contents/{path}/checkpoints: parameters: - $ref: "#/parameters/path" get: summary: Get a list of checkpoints for a file description: List checkpoints for a given file. There will typically be zero or one results. tags: - contents responses: 404: description: No item found 400: description: Bad request schema: type: object properties: error: type: string description: Error condition reason: type: string description: Explanation of error reason 200: description: List of checkpoints for a file schema: type: array items: $ref: "#/definitions/Checkpoints" 500: description: Model key error post: summary: Create a new checkpoint for a file description: "Create a new checkpoint with the current state of a file. With the default FileContentsManager, only one checkpoint is supported, so creating new checkpoints clobbers existing ones." tags: - contents responses: 201: description: Checkpoint created headers: Location: description: URL for the checkpoint type: string format: url schema: $ref: "#/definitions/Checkpoints" 404: description: No item found 400: description: Bad request schema: type: object properties: error: type: string description: Error condition reason: type: string description: Explanation of error reason /api/contents/{path}/checkpoints/{checkpoint_id}: post: summary: Restore a file to a particular checkpointed state parameters: - $ref: "#/parameters/path" - $ref: "#/parameters/checkpoint_id" tags: - contents responses: 204: description: Checkpoint restored 400: description: Bad request schema: type: object properties: error: type: string description: Error condition reason: type: string description: Explanation of error reason delete: summary: Delete a checkpoint parameters: - $ref: "#/parameters/path" - $ref: "#/parameters/checkpoint_id" tags: - contents responses: 204: description: Checkpoint deleted /api/sessions/{session}: parameters: - $ref: "#/parameters/session" get: summary: Get session tags: - sessions responses: 200: description: Session schema: $ref: "#/definitions/Session" patch: summary: "This can be used to rename the session." tags: - sessions parameters: - name: model in: body required: true schema: $ref: "#/definitions/Session" responses: 200: description: Session schema: $ref: "#/definitions/Session" 400: description: No data provided delete: summary: Delete a session tags: - sessions responses: 204: description: Session (and kernel) were deleted 410: description: "Kernel was deleted before the session, and the session was *not* deleted (TODO - check to make sure session wasn't deleted)" /api/sessions: get: summary: List available sessions tags: - sessions responses: 200: description: List of current sessions schema: type: array items: $ref: "#/definitions/Session" post: summary: "Create a new session, or return an existing session if a session of the same name already exists" tags: - sessions parameters: - name: session in: body schema: $ref: "#/definitions/Session" responses: 201: description: Session created or returned schema: $ref: "#/definitions/Session" headers: Location: description: URL for session commands type: string format: url 501: description: Session not available schema: type: object description: error message properties: message: type: string short_message: type: string /api/kernels: get: summary: List the JSON data for all kernels that are currently running tags: - kernels responses: 200: description: List of currently-running kernel uuids schema: type: array items: $ref: "#/definitions/Kernel" post: summary: Start a kernel and return the uuid tags: - kernels parameters: - name: options in: body schema: type: object required: - name properties: name: type: string description: Kernel spec name (defaults to default kernel spec for server) path: type: string description: API path from root to the cwd of the kernel responses: 201: description: Kernel started schema: $ref: "#/definitions/Kernel" headers: Location: description: Model for started kernel type: string format: url /api/kernels/{kernel_id}: parameters: - $ref: "#/parameters/kernel" get: summary: Get kernel information tags: - kernels responses: 200: description: Kernel information schema: $ref: "#/definitions/Kernel" delete: summary: Kill a kernel and delete the kernel id tags: - kernels responses: 204: description: Kernel deleted /api/kernels/{kernel_id}/interrupt: parameters: - $ref: "#/parameters/kernel" post: summary: Interrupt a kernel tags: - kernels responses: 204: description: Kernel interrupted /api/kernels/{kernel_id}/restart: parameters: - $ref: "#/parameters/kernel" post: summary: Restart a kernel tags: - kernels responses: 200: description: Kernel restarted headers: Location: description: URL for kernel commands type: string format: url schema: $ref: "#/definitions/Kernel" /api/kernelspecs: get: summary: Get kernel specs tags: - kernelspecs responses: 200: description: Kernel specs schema: type: object properties: default: type: string description: Default kernel name kernelspecs: type: object additionalProperties: $ref: "#/definitions/KernelSpec" /api/config/{section_name}: get: summary: Get a configuration section by name parameters: - $ref: "#/parameters/section_name" tags: - config responses: 200: description: Configuration object schema: type: object patch: summary: Update a configuration section by name tags: - config parameters: - $ref: "#/parameters/section_name" - name: configuration in: body schema: type: object responses: 200: description: Configuration object schema: type: object /api/terminals: get: summary: Get available terminals tags: - terminals responses: 200: description: A list of all available terminal ids. schema: type: array items: $ref: "#/definitions/Terminal" 403: description: Forbidden to access 404: description: Not found post: summary: Create a new terminal tags: - terminals responses: 200: description: Successfully created a new terminal schema: $ref: "#/definitions/Terminal" 403: description: Forbidden to access 404: description: Not found /api/terminals/{terminal_id}: get: summary: Get a terminal session corresponding to an id. tags: - terminals parameters: - $ref: "#/parameters/terminal_id" responses: 200: description: Terminal session with given id schema: $ref: "#/definitions/Terminal" 403: description: Forbidden to access 404: description: Not found delete: summary: Delete a terminal session corresponding to an id. tags: - terminals parameters: - $ref: "#/parameters/terminal_id" responses: 204: description: Successfully deleted terminal session 403: description: Forbidden to access 404: description: Not found /api/me: get: summary: | Get the identity of the currently authenticated user. If present, a `permissions` argument may be specified to check what actions the user currently is authorized to take. tags: - identity parameters: - $ref: "#/parameters/permissions" responses: 200: description: The user's identity and permissions schema: type: object properties: identity: $ref: "#/definitions/Identity" permissions: $ref: "#/definitions/Permissions" example: identity: username: minrk name: Min Ragan-Kelley display_name: Min RK initials: MRK avatar_url: null color: null permissions: contents: - read - write kernels: - read - write - execute /api/status: get: summary: Get the current status/activity of the server. tags: - status responses: 200: description: The current status of the server schema: $ref: "#/definitions/APIStatus" /api/spec.yaml: get: summary: Get the current spec for the notebook server's APIs. tags: - api-spec produces: - text/x-yaml responses: 200: description: The current spec for the notebook server's APIs. schema: type: file definitions: APIStatus: description: | Notebook server API status. Added in notebook 5.0. properties: started: type: string description: | ISO8601 timestamp indicating when the notebook server started. last_activity: type: string description: | ISO8601 timestamp indicating the last activity on the server, either on the REST API or kernel activity. connections: type: number description: | The total number of currently open connections to kernels. kernels: type: number description: | The total number of running kernels. Identity: description: The identity of the currently authenticated user properties: username: type: string description: | Unique string identifying the user name: type: string description: | For-humans name of the user. May be the same as `username` in systems where only usernames are available. display_name: type: string description: | Alternate rendering of name for display. Often the same as `name`. initials: type: string description: | Short string of initials. Initials should not be derived automatically due to localization issues. May be `null` if unavailable. avatar_url: type: string description: | URL of an avatar to be used for the user. May be `null` if unavailable. color: type: string description: | A CSS color string to use as a preferred color, such as for collaboration cursors. May be `null` if unavailable. Permissions: type: object description: | A dict of the form: `{"resource": ["action",]}` containing only the AUTHORIZED subset of resource+actions from the permissions specified in the request. If no permission checks were made in the request, this will be empty. additionalProperties: type: array items: type: string KernelSpec: description: Kernel spec (contents of kernel.json) properties: name: type: string description: Unique name for kernel KernelSpecFile: $ref: "#/definitions/KernelSpecFile" resources: type: object properties: kernel.js: type: string format: filename description: path for kernel.js file kernel.css: type: string format: filename description: path for kernel.css file logo-*: type: string format: filename description: path for logo file. Logo filenames are of the form `logo-widthxheight` KernelSpecFile: description: Kernel spec json file required: - argv - display_name - language properties: language: type: string description: The programming language which this kernel runs. This will be stored in notebook metadata. argv: type: array description: "A list of command line arguments used to start the kernel. The text `{connection_file}` in any argument will be replaced with the path to the connection file." items: type: string display_name: type: string description: "The kernel's name as it should be displayed in the UI. Unlike the kernel name used in the API, this can contain arbitrary unicode characters." codemirror_mode: type: string description: Codemirror mode. Can be a string *or* an valid Codemirror mode object. This defaults to the string from the `language` property. env: type: object description: A dictionary of environment variables to set for the kernel. These will be added to the current environment variables. additionalProperties: type: string help_links: type: array description: Help items to be displayed in the help menu in the notebook UI. items: type: object required: - text - url properties: text: type: string description: menu item link text url: type: string format: URL description: menu item link url Kernel: description: Kernel information required: - id - name properties: id: type: string format: uuid description: uuid of kernel name: type: string description: kernel spec name last_activity: type: string description: | ISO 8601 timestamp for the last-seen activity on this kernel. Use this in combination with execution_state == 'idle' to identify which kernels have been idle since a given time. Timestamps will be UTC, indicated 'Z' suffix. Added in notebook server 5.0. connections: type: number description: | The number of active connections to this kernel. execution_state: type: string description: | Current execution state of the kernel (typically 'idle' or 'busy', but may be other values, such as 'starting'). Added in notebook server 5.0. Session: description: A session type: object properties: id: type: string format: uuid path: type: string description: path to the session name: type: string description: name of the session type: type: string description: session type kernel: $ref: "#/definitions/Kernel" Contents: description: "A contents object. The content and format keys may be null if content is not contained. The hash maybe null if hash is not required. If type is 'file', then the mimetype will be null." type: object required: - type - name - path - writable - created - last_modified - mimetype - format - content properties: name: type: string description: "Name of file or directory, equivalent to the last part of the path" path: type: string description: Full path for file or directory type: type: string description: Type of content enum: - directory - file - notebook writable: type: boolean description: indicates whether the requester has permission to edit the file created: type: string description: Creation timestamp format: dateTime last_modified: type: string description: Last modified timestamp format: dateTime size: type: integer description: "The size of the file or notebook in bytes. If no size is provided, defaults to null." mimetype: type: string description: "The mimetype of a file. If content is not null, and type is 'file', this will contain the mimetype of the file, otherwise this will be null." content: type: string description: "The content, if requested (otherwise null). Will be an array if type is 'directory'" format: type: string description: Format of content (one of null, 'text', 'base64', 'json') hash: type: string description: "[optional] The hexdigest hash string of content, if requested (otherwise null). It cannot be null if hash_algorithm is defined." hash_algorithm: type: string description: "[optional] The algorithm used to produce the hash, if requested (otherwise null). It cannot be null if hash is defined." Checkpoints: description: A checkpoint object. type: object required: - id - last_modified properties: id: type: string description: Unique id for the checkpoint. last_modified: type: string description: Last modified timestamp format: dateTime Terminal: description: A Terminal object type: object required: - name properties: name: type: string description: name of terminal last_activity: type: string description: | ISO 8601 timestamp for the last-seen activity on this terminal. Use this to identify which terminals have been inactive since a given time. Timestamps will be UTC, indicated 'Z' suffix. jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/api/handlers.py000066400000000000000000000074551473126534200301640ustar00rootroot00000000000000"""Tornado handlers for api specifications.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json import os from typing import Any from jupyter_core.utils import ensure_async from tornado import web from jupyter_server._tz import isoformat, utcfromtimestamp from jupyter_server.auth.decorator import authorized from ...base.handlers import APIHandler, JupyterHandler AUTH_RESOURCE = "api" class APISpecHandler(web.StaticFileHandler, JupyterHandler): """A spec handler for the REST API.""" auth_resource = AUTH_RESOURCE def initialize(self): """Initialize the API spec handler.""" web.StaticFileHandler.initialize(self, path=os.path.dirname(__file__)) @web.authenticated @authorized def head(self): return self.get("api.yaml", include_body=False) @web.authenticated @authorized def get(self): """Get the API spec.""" self.log.warning("Serving api spec (experimental, incomplete)") return web.StaticFileHandler.get(self, "api.yaml") def get_content_type(self): """Get the content type.""" return "text/x-yaml" class APIStatusHandler(APIHandler): """An API status handler.""" auth_resource = AUTH_RESOURCE _track_activity = False @web.authenticated @authorized async def get(self): """Get the API status.""" # if started was missing, use unix epoch started = self.settings.get("started", utcfromtimestamp(0)) started = isoformat(started) kernels = await ensure_async(self.kernel_manager.list_kernels()) total_connections = sum(k["connections"] for k in kernels) last_activity = isoformat(self.application.last_activity()) # type:ignore[attr-defined] model = { "started": started, "last_activity": last_activity, "kernels": len(kernels), "connections": total_connections, } self.finish(json.dumps(model, sort_keys=True)) class IdentityHandler(APIHandler): """Get the current user's identity model""" @web.authenticated async def get(self): """Get the identity model.""" permissions_json: str = self.get_argument("permissions", "") bad_permissions_msg = f'permissions should be a JSON dict of {{"resource": ["action",]}}, got {permissions_json!r}' if permissions_json: try: permissions_to_check = json.loads(permissions_json) except ValueError as e: raise web.HTTPError(400, bad_permissions_msg) from e if not isinstance(permissions_to_check, dict): raise web.HTTPError(400, bad_permissions_msg) else: permissions_to_check = {} permissions: dict[str, list[str]] = {} user = self.current_user for resource, actions in permissions_to_check.items(): if ( not isinstance(resource, str) or not isinstance(actions, list) or not all(isinstance(action, str) for action in actions) ): raise web.HTTPError(400, bad_permissions_msg) allowed = permissions[resource] = [] for action in actions: authorized = await ensure_async( self.authorizer.is_authorized(self, user, action, resource) ) if authorized: allowed.append(action) identity: dict[str, Any] = self.identity_provider.identity_model(user) model = { "identity": identity, "permissions": permissions, } self.write(json.dumps(model)) default_handlers = [ (r"/api/spec.yaml", APISpecHandler), (r"/api/status", APIStatusHandler), (r"/api/me", IdentityHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/config/000077500000000000000000000000001473126534200264735ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/config/__init__.py000066400000000000000000000001001473126534200305730ustar00rootroot00000000000000from .manager import ConfigManager __all__ = ["ConfigManager"] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/config/handlers.py000066400000000000000000000025351473126534200306520ustar00rootroot00000000000000"""Tornado handlers for frontend config storage.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json from tornado import web from jupyter_server.auth.decorator import authorized from ...base.handlers import APIHandler AUTH_RESOURCE = "config" class ConfigHandler(APIHandler): """A config API handler.""" auth_resource = AUTH_RESOURCE @web.authenticated @authorized def get(self, section_name): """Get config by section name.""" self.set_header("Content-Type", "application/json") self.finish(json.dumps(self.config_manager.get(section_name))) @web.authenticated @authorized def put(self, section_name): """Set a config section by name.""" data = self.get_json_body() # Will raise 400 if content is not valid JSON self.config_manager.set(section_name, data) self.set_status(204) @web.authenticated @authorized def patch(self, section_name): """Update a config section by name.""" new_data = self.get_json_body() section = self.config_manager.update(section_name, new_data) self.finish(json.dumps(section)) # URL to handler mappings section_name_regex = r"(?P\w+)" default_handlers = [ (r"/api/config/%s" % section_name_regex, ConfigHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/config/manager.py000066400000000000000000000042661473126534200304670ustar00rootroot00000000000000"""Manager to read and modify frontend config data in JSON files.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import os.path import typing as t from jupyter_core.paths import jupyter_config_dir, jupyter_config_path from traitlets import Instance, List, Unicode, default, observe from traitlets.config import LoggingConfigurable from jupyter_server.config_manager import BaseJSONConfigManager, recursive_update class ConfigManager(LoggingConfigurable): """Config Manager used for storing frontend config""" config_dir_name = Unicode("serverconfig", help="""Name of the config directory.""").tag( config=True ) # Public API def get(self, section_name): """Get the config from all config sections.""" config: dict[str, t.Any] = {} # step through back to front, to ensure front of the list is top priority for p in self.read_config_path[::-1]: cm = BaseJSONConfigManager(config_dir=p) recursive_update(config, cm.get(section_name)) return config def set(self, section_name, data): """Set the config only to the user's config.""" return self.write_config_manager.set(section_name, data) def update(self, section_name, new_data): """Update the config only to the user's config.""" return self.write_config_manager.update(section_name, new_data) # Private API read_config_path = List(Unicode()) @default("read_config_path") def _default_read_config_path(self): return [os.path.join(p, self.config_dir_name) for p in jupyter_config_path()] write_config_dir = Unicode() @default("write_config_dir") def _default_write_config_dir(self): return os.path.join(jupyter_config_dir(), self.config_dir_name) write_config_manager = Instance(BaseJSONConfigManager) @default("write_config_manager") def _default_write_config_manager(self): return BaseJSONConfigManager(config_dir=self.write_config_dir) @observe("write_config_dir") def _update_write_config_dir(self, change): self.write_config_manager = BaseJSONConfigManager(config_dir=self.write_config_dir) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/contents/000077500000000000000000000000001473126534200270635ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/contents/__init__.py000066400000000000000000000000001473126534200311620ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/contents/checkpoints.py000066400000000000000000000207031473126534200317510ustar00rootroot00000000000000""" Classes for managing Checkpoints. """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from tornado.web import HTTPError from traitlets.config.configurable import LoggingConfigurable class Checkpoints(LoggingConfigurable): """ Base class for managing checkpoints for a ContentsManager. Subclasses are required to implement: create_checkpoint(self, contents_mgr, path) restore_checkpoint(self, contents_mgr, checkpoint_id, path) rename_checkpoint(self, checkpoint_id, old_path, new_path) delete_checkpoint(self, checkpoint_id, path) list_checkpoints(self, path) """ def create_checkpoint(self, contents_mgr, path): """Create a checkpoint.""" raise NotImplementedError def restore_checkpoint(self, contents_mgr, checkpoint_id, path): """Restore a checkpoint""" raise NotImplementedError def rename_checkpoint(self, checkpoint_id, old_path, new_path): """Rename a single checkpoint from old_path to new_path.""" raise NotImplementedError def delete_checkpoint(self, checkpoint_id, path): """delete a checkpoint for a file""" raise NotImplementedError def list_checkpoints(self, path): """Return a list of checkpoints for a given file""" raise NotImplementedError def rename_all_checkpoints(self, old_path, new_path): """Rename all checkpoints for old_path to new_path.""" for cp in self.list_checkpoints(old_path): self.rename_checkpoint(cp["id"], old_path, new_path) def delete_all_checkpoints(self, path): """Delete all checkpoints for the given path.""" for checkpoint in self.list_checkpoints(path): self.delete_checkpoint(checkpoint["id"], path) class GenericCheckpointsMixin: """ Helper for creating Checkpoints subclasses that can be used with any ContentsManager. Provides a ContentsManager-agnostic implementation of `create_checkpoint` and `restore_checkpoint` in terms of the following operations: - create_file_checkpoint(self, content, format, path) - create_notebook_checkpoint(self, nb, path) - get_file_checkpoint(self, checkpoint_id, path) - get_notebook_checkpoint(self, checkpoint_id, path) To create a generic CheckpointManager, add this mixin to a class that implement the above four methods plus the remaining Checkpoints API methods: - delete_checkpoint(self, checkpoint_id, path) - list_checkpoints(self, path) - rename_checkpoint(self, checkpoint_id, old_path, new_path) """ def create_checkpoint(self, contents_mgr, path): model = contents_mgr.get(path, content=True) type_ = model["type"] if type_ == "notebook": return self.create_notebook_checkpoint( model["content"], path, ) elif type_ == "file": return self.create_file_checkpoint( model["content"], model["format"], path, ) else: raise HTTPError(500, "Unexpected type %s" % type) def restore_checkpoint(self, contents_mgr, checkpoint_id, path): """Restore a checkpoint.""" type_ = contents_mgr.get(path, content=False)["type"] if type_ == "notebook": model = self.get_notebook_checkpoint(checkpoint_id, path) elif type_ == "file": model = self.get_file_checkpoint(checkpoint_id, path) else: raise HTTPError(500, "Unexpected type %s" % type_) contents_mgr.save(model, path) # Required Methods def create_file_checkpoint(self, content, format, path): """Create a checkpoint of the current state of a file Returns a checkpoint model for the new checkpoint. """ raise NotImplementedError def create_notebook_checkpoint(self, nb, path): """Create a checkpoint of the current state of a file Returns a checkpoint model for the new checkpoint. """ raise NotImplementedError def get_file_checkpoint(self, checkpoint_id, path): """Get the content of a checkpoint for a non-notebook file. Returns a dict of the form:: { 'type': 'file', 'content': , 'format': {'text','base64'}, } """ raise NotImplementedError def get_notebook_checkpoint(self, checkpoint_id, path): """Get the content of a checkpoint for a notebook. Returns a dict of the form:: { 'type': 'notebook', 'content': , } """ raise NotImplementedError class AsyncCheckpoints(Checkpoints): """ Base class for managing checkpoints for a ContentsManager asynchronously. """ async def create_checkpoint(self, contents_mgr, path): """Create a checkpoint.""" raise NotImplementedError async def restore_checkpoint(self, contents_mgr, checkpoint_id, path): """Restore a checkpoint""" raise NotImplementedError async def rename_checkpoint(self, checkpoint_id, old_path, new_path): """Rename a single checkpoint from old_path to new_path.""" raise NotImplementedError async def delete_checkpoint(self, checkpoint_id, path): """delete a checkpoint for a file""" raise NotImplementedError async def list_checkpoints(self, path): """Return a list of checkpoints for a given file""" raise NotImplementedError async def rename_all_checkpoints(self, old_path, new_path): """Rename all checkpoints for old_path to new_path.""" for cp in await self.list_checkpoints(old_path): await self.rename_checkpoint(cp["id"], old_path, new_path) async def delete_all_checkpoints(self, path): """Delete all checkpoints for the given path.""" for checkpoint in await self.list_checkpoints(path): await self.delete_checkpoint(checkpoint["id"], path) class AsyncGenericCheckpointsMixin(GenericCheckpointsMixin): """ Helper for creating Asynchronous Checkpoints subclasses that can be used with any ContentsManager. """ async def create_checkpoint(self, contents_mgr, path): model = await contents_mgr.get(path, content=True) type_ = model["type"] if type_ == "notebook": return await self.create_notebook_checkpoint( model["content"], path, ) elif type_ == "file": return await self.create_file_checkpoint( model["content"], model["format"], path, ) else: raise HTTPError(500, "Unexpected type %s" % type_) async def restore_checkpoint(self, contents_mgr, checkpoint_id, path): """Restore a checkpoint.""" content_model = await contents_mgr.get(path, content=False) type_ = content_model["type"] if type_ == "notebook": model = await self.get_notebook_checkpoint(checkpoint_id, path) elif type_ == "file": model = await self.get_file_checkpoint(checkpoint_id, path) else: raise HTTPError(500, "Unexpected type %s" % type_) await contents_mgr.save(model, path) # Required Methods async def create_file_checkpoint(self, content, format, path): """Create a checkpoint of the current state of a file Returns a checkpoint model for the new checkpoint. """ raise NotImplementedError async def create_notebook_checkpoint(self, nb, path): """Create a checkpoint of the current state of a file Returns a checkpoint model for the new checkpoint. """ raise NotImplementedError async def get_file_checkpoint(self, checkpoint_id, path): """Get the content of a checkpoint for a non-notebook file. Returns a dict of the form:: { 'type': 'file', 'content': , 'format': {'text','base64'}, } """ raise NotImplementedError async def get_notebook_checkpoint(self, checkpoint_id, path): """Get the content of a checkpoint for a notebook. Returns a dict of the form:: { 'type': 'notebook', 'content': , } """ raise NotImplementedError jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/contents/filecheckpoints.py000066400000000000000000000274221473126534200326160ustar00rootroot00000000000000""" File-based Checkpoints implementations. """ import os import shutil from anyio.to_thread import run_sync from jupyter_core.utils import ensure_dir_exists from tornado.web import HTTPError from traitlets import Unicode from jupyter_server import _tz as tz from .checkpoints import ( AsyncCheckpoints, AsyncGenericCheckpointsMixin, Checkpoints, GenericCheckpointsMixin, ) from .fileio import AsyncFileManagerMixin, FileManagerMixin class FileCheckpoints(FileManagerMixin, Checkpoints): """ A Checkpoints that caches checkpoints for files in adjacent directories. Only works with FileContentsManager. Use GenericFileCheckpoints if you want file-based checkpoints with another ContentsManager. """ checkpoint_dir = Unicode( ".ipynb_checkpoints", config=True, help="""The directory name in which to keep file checkpoints This is a path relative to the file's own directory. By default, it is .ipynb_checkpoints """, ) root_dir = Unicode(config=True) def _root_dir_default(self): if not self.parent: return os.getcwd() return self.parent.root_dir # ContentsManager-dependent checkpoint API def create_checkpoint(self, contents_mgr, path): """Create a checkpoint.""" checkpoint_id = "checkpoint" src_path = contents_mgr._get_os_path(path) dest_path = self.checkpoint_path(checkpoint_id, path) self._copy(src_path, dest_path) return self.checkpoint_model(checkpoint_id, dest_path) def restore_checkpoint(self, contents_mgr, checkpoint_id, path): """Restore a checkpoint.""" src_path = self.checkpoint_path(checkpoint_id, path) dest_path = contents_mgr._get_os_path(path) self._copy(src_path, dest_path) # ContentsManager-independent checkpoint API def rename_checkpoint(self, checkpoint_id, old_path, new_path): """Rename a checkpoint from old_path to new_path.""" old_cp_path = self.checkpoint_path(checkpoint_id, old_path) new_cp_path = self.checkpoint_path(checkpoint_id, new_path) if os.path.isfile(old_cp_path): self.log.debug( "Renaming checkpoint %s -> %s", old_cp_path, new_cp_path, ) with self.perm_to_403(): shutil.move(old_cp_path, new_cp_path) def delete_checkpoint(self, checkpoint_id, path): """delete a file's checkpoint""" path = path.strip("/") cp_path = self.checkpoint_path(checkpoint_id, path) if not os.path.isfile(cp_path): self.no_such_checkpoint(path, checkpoint_id) self.log.debug("unlinking %s", cp_path) with self.perm_to_403(): os.unlink(cp_path) def list_checkpoints(self, path): """list the checkpoints for a given file This contents manager currently only supports one checkpoint per file. """ path = path.strip("/") checkpoint_id = "checkpoint" os_path = self.checkpoint_path(checkpoint_id, path) if not os.path.isfile(os_path): return [] else: return [self.checkpoint_model(checkpoint_id, os_path)] # Checkpoint-related utilities def checkpoint_path(self, checkpoint_id, path): """find the path to a checkpoint""" path = path.strip("/") parent, name = ("/" + path).rsplit("/", 1) parent = parent.strip("/") basename, ext = os.path.splitext(name) filename = f"{basename}-{checkpoint_id}{ext}" os_path = self._get_os_path(path=parent) cp_dir = os.path.join(os_path, self.checkpoint_dir) with self.perm_to_403(): ensure_dir_exists(cp_dir) cp_path = os.path.join(cp_dir, filename) return cp_path def checkpoint_model(self, checkpoint_id, os_path): """construct the info dict for a given checkpoint""" stats = os.stat(os_path) last_modified = tz.utcfromtimestamp(stats.st_mtime) info = { "id": checkpoint_id, "last_modified": last_modified, } return info # Error Handling def no_such_checkpoint(self, path, checkpoint_id): raise HTTPError(404, f"Checkpoint does not exist: {path}@{checkpoint_id}") class AsyncFileCheckpoints(FileCheckpoints, AsyncFileManagerMixin, AsyncCheckpoints): async def create_checkpoint(self, contents_mgr, path): """Create a checkpoint.""" checkpoint_id = "checkpoint" src_path = contents_mgr._get_os_path(path) dest_path = self.checkpoint_path(checkpoint_id, path) await self._copy(src_path, dest_path) return await self.checkpoint_model(checkpoint_id, dest_path) async def restore_checkpoint(self, contents_mgr, checkpoint_id, path): """Restore a checkpoint.""" src_path = self.checkpoint_path(checkpoint_id, path) dest_path = contents_mgr._get_os_path(path) await self._copy(src_path, dest_path) async def checkpoint_model(self, checkpoint_id, os_path): """construct the info dict for a given checkpoint""" stats = await run_sync(os.stat, os_path) last_modified = tz.utcfromtimestamp(stats.st_mtime) info = { "id": checkpoint_id, "last_modified": last_modified, } return info # ContentsManager-independent checkpoint API async def rename_checkpoint(self, checkpoint_id, old_path, new_path): """Rename a checkpoint from old_path to new_path.""" old_cp_path = self.checkpoint_path(checkpoint_id, old_path) new_cp_path = self.checkpoint_path(checkpoint_id, new_path) if os.path.isfile(old_cp_path): self.log.debug( "Renaming checkpoint %s -> %s", old_cp_path, new_cp_path, ) with self.perm_to_403(): await run_sync(shutil.move, old_cp_path, new_cp_path) async def delete_checkpoint(self, checkpoint_id, path): """delete a file's checkpoint""" path = path.strip("/") cp_path = self.checkpoint_path(checkpoint_id, path) if not os.path.isfile(cp_path): self.no_such_checkpoint(path, checkpoint_id) self.log.debug("unlinking %s", cp_path) with self.perm_to_403(): await run_sync(os.unlink, cp_path) async def list_checkpoints(self, path): """list the checkpoints for a given file This contents manager currently only supports one checkpoint per file. """ path = path.strip("/") checkpoint_id = "checkpoint" os_path = self.checkpoint_path(checkpoint_id, path) if not os.path.isfile(os_path): return [] else: return [await self.checkpoint_model(checkpoint_id, os_path)] class GenericFileCheckpoints(GenericCheckpointsMixin, FileCheckpoints): """ Local filesystem Checkpoints that works with any conforming ContentsManager. """ def create_file_checkpoint(self, content, format, path): """Create a checkpoint from the current content of a file.""" path = path.strip("/") # only the one checkpoint ID: checkpoint_id = "checkpoint" os_checkpoint_path = self.checkpoint_path(checkpoint_id, path) self.log.debug("creating checkpoint for %s", path) with self.perm_to_403(): self._save_file(os_checkpoint_path, content, format=format) # return the checkpoint info return self.checkpoint_model(checkpoint_id, os_checkpoint_path) def create_notebook_checkpoint(self, nb, path): """Create a checkpoint from the current content of a notebook.""" path = path.strip("/") # only the one checkpoint ID: checkpoint_id = "checkpoint" os_checkpoint_path = self.checkpoint_path(checkpoint_id, path) self.log.debug("creating checkpoint for %s", path) with self.perm_to_403(): self._save_notebook(os_checkpoint_path, nb) # return the checkpoint info return self.checkpoint_model(checkpoint_id, os_checkpoint_path) def get_notebook_checkpoint(self, checkpoint_id, path): """Get a checkpoint for a notebook.""" path = path.strip("/") self.log.info("restoring %s from checkpoint %s", path, checkpoint_id) os_checkpoint_path = self.checkpoint_path(checkpoint_id, path) if not os.path.isfile(os_checkpoint_path): self.no_such_checkpoint(path, checkpoint_id) return { "type": "notebook", "content": self._read_notebook( os_checkpoint_path, as_version=4, ), } def get_file_checkpoint(self, checkpoint_id, path): """Get a checkpoint for a file.""" path = path.strip("/") self.log.info("restoring %s from checkpoint %s", path, checkpoint_id) os_checkpoint_path = self.checkpoint_path(checkpoint_id, path) if not os.path.isfile(os_checkpoint_path): self.no_such_checkpoint(path, checkpoint_id) content, format = self._read_file(os_checkpoint_path, format=None) # type: ignore[misc] return { "type": "file", "content": content, "format": format, } class AsyncGenericFileCheckpoints(AsyncGenericCheckpointsMixin, AsyncFileCheckpoints): """ Asynchronous Local filesystem Checkpoints that works with any conforming ContentsManager. """ async def create_file_checkpoint(self, content, format, path): """Create a checkpoint from the current content of a file.""" path = path.strip("/") # only the one checkpoint ID: checkpoint_id = "checkpoint" os_checkpoint_path = self.checkpoint_path(checkpoint_id, path) self.log.debug("creating checkpoint for %s", path) with self.perm_to_403(): await self._save_file(os_checkpoint_path, content, format=format) # return the checkpoint info return await self.checkpoint_model(checkpoint_id, os_checkpoint_path) async def create_notebook_checkpoint(self, nb, path): """Create a checkpoint from the current content of a notebook.""" path = path.strip("/") # only the one checkpoint ID: checkpoint_id = "checkpoint" os_checkpoint_path = self.checkpoint_path(checkpoint_id, path) self.log.debug("creating checkpoint for %s", path) with self.perm_to_403(): await self._save_notebook(os_checkpoint_path, nb) # return the checkpoint info return await self.checkpoint_model(checkpoint_id, os_checkpoint_path) async def get_notebook_checkpoint(self, checkpoint_id, path): """Get a checkpoint for a notebook.""" path = path.strip("/") self.log.info("restoring %s from checkpoint %s", path, checkpoint_id) os_checkpoint_path = self.checkpoint_path(checkpoint_id, path) if not os.path.isfile(os_checkpoint_path): self.no_such_checkpoint(path, checkpoint_id) return { "type": "notebook", "content": await self._read_notebook( os_checkpoint_path, as_version=4, ), } async def get_file_checkpoint(self, checkpoint_id, path): """Get a checkpoint for a file.""" path = path.strip("/") self.log.info("restoring %s from checkpoint %s", path, checkpoint_id) os_checkpoint_path = self.checkpoint_path(checkpoint_id, path) if not os.path.isfile(os_checkpoint_path): self.no_such_checkpoint(path, checkpoint_id) content, format = await self._read_file(os_checkpoint_path, format=None) # type: ignore[misc] return { "type": "file", "content": content, "format": format, } jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/contents/fileio.py000066400000000000000000000470211473126534200307100ustar00rootroot00000000000000""" Utilities for file-based Contents/Checkpoints managers. """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import errno import hashlib import os import shutil from base64 import decodebytes, encodebytes from contextlib import contextmanager from functools import partial import nbformat from anyio.to_thread import run_sync from tornado.web import HTTPError from traitlets import Bool, Enum from traitlets.config import Configurable from traitlets.config.configurable import LoggingConfigurable from jupyter_server.utils import ApiPath, to_api_path, to_os_path def replace_file(src, dst): """replace dst with src""" os.replace(src, dst) async def async_replace_file(src, dst): """replace dst with src asynchronously""" await run_sync(os.replace, src, dst) def copy2_safe(src, dst, log=None): """copy src to dst like shutil.copy2, but log errors in copystat instead of raising """ shutil.copyfile(src, dst) try: shutil.copystat(src, dst) except OSError: if log: log.debug("copystat on %s failed", dst, exc_info=True) async def async_copy2_safe(src, dst, log=None): """copy src to dst asynchronously like shutil.copy2, but log errors in copystat instead of raising """ await run_sync(shutil.copyfile, src, dst) try: await run_sync(shutil.copystat, src, dst) except OSError: if log: log.debug("copystat on %s failed", dst, exc_info=True) def path_to_intermediate(path): """Name of the intermediate file used in atomic writes. The .~ prefix will make Dropbox ignore the temporary file.""" dirname, basename = os.path.split(path) return os.path.join(dirname, ".~" + basename) def path_to_invalid(path): """Name of invalid file after a failed atomic write and subsequent read.""" dirname, basename = os.path.split(path) return os.path.join(dirname, basename + ".invalid") @contextmanager def atomic_writing(path, text=True, encoding="utf-8", log=None, **kwargs): """Context manager to write to a file only if the entire write is successful. This works by copying the previous file contents to a temporary file in the same directory, and renaming that file back to the target if the context exits with an error. If the context is successful, the new data is synced to disk and the temporary file is removed. Parameters ---------- path : str The target file to write to. text : bool, optional Whether to open the file in text mode (i.e. to write unicode). Default is True. encoding : str, optional The encoding to use for files opened in text mode. Default is UTF-8. **kwargs Passed to :func:`io.open`. """ # realpath doesn't work on Windows: https://bugs.python.org/issue9949 # Luckily, we only need to resolve the file itself being a symlink, not # any of its directories, so this will suffice: if os.path.islink(path): path = os.path.join(os.path.dirname(path), os.readlink(path)) tmp_path = path_to_intermediate(path) if os.path.isfile(path): copy2_safe(path, tmp_path, log=log) if text: # Make sure that text files have Unix linefeeds by default kwargs.setdefault("newline", "\n") fileobj = open(path, "w", encoding=encoding, **kwargs) # noqa: SIM115 else: fileobj = open(path, "wb", **kwargs) # noqa: SIM115 try: yield fileobj except BaseException: # Failed! Move the backup file back to the real path to avoid corruption fileobj.close() replace_file(tmp_path, path) raise # Flush to disk fileobj.flush() os.fsync(fileobj.fileno()) fileobj.close() # Written successfully, now remove the backup copy if os.path.isfile(tmp_path): os.remove(tmp_path) @contextmanager def _simple_writing(path, text=True, encoding="utf-8", log=None, **kwargs): """Context manager to write file without doing atomic writing (for weird filesystem eg: nfs). Parameters ---------- path : str The target file to write to. text : bool, optional Whether to open the file in text mode (i.e. to write unicode). Default is True. encoding : str, optional The encoding to use for files opened in text mode. Default is UTF-8. **kwargs Passed to :func:`io.open`. """ # realpath doesn't work on Windows: https://bugs.python.org/issue9949 # Luckily, we only need to resolve the file itself being a symlink, not # any of its directories, so this will suffice: if os.path.islink(path): path = os.path.join(os.path.dirname(path), os.readlink(path)) if text: # Make sure that text files have Unix linefeeds by default kwargs.setdefault("newline", "\n") fileobj = open(path, "w", encoding=encoding, **kwargs) # noqa: SIM115 else: fileobj = open(path, "wb", **kwargs) # noqa: SIM115 try: yield fileobj except BaseException: fileobj.close() raise fileobj.close() class FileManagerMixin(LoggingConfigurable, Configurable): """ Mixin for ContentsAPI classes that interact with the filesystem. Provides facilities for reading, writing, and copying files. Shared by FileContentsManager and FileCheckpoints. Note ---- Classes using this mixin must provide the following attributes: root_dir : unicode A directory against against which API-style paths are to be resolved. log : logging.Logger """ use_atomic_writing = Bool( True, config=True, help="""By default notebooks are saved on disk on a temporary file and then if successfully written, it replaces the old ones. This procedure, namely 'atomic_writing', causes some bugs on file system without operation order enforcement (like some networked fs). If set to False, the new notebook is written directly on the old one which could fail (eg: full filesystem or quota )""", ) hash_algorithm = Enum( # type: ignore[call-overload] hashlib.algorithms_available, default_value="sha256", config=True, help="Hash algorithm to use for file content, support by hashlib", ) @contextmanager def open(self, os_path, *args, **kwargs): """wrapper around io.open that turns permission errors into 403""" with self.perm_to_403(os_path), open(os_path, *args, **kwargs) as f: yield f @contextmanager def atomic_writing(self, os_path, *args, **kwargs): """wrapper around atomic_writing that turns permission errors to 403. Depending on flag 'use_atomic_writing', the wrapper perform an actual atomic writing or simply writes the file (whatever an old exists or not)""" with self.perm_to_403(os_path): kwargs["log"] = self.log if self.use_atomic_writing: with atomic_writing(os_path, *args, **kwargs) as f: yield f else: with _simple_writing(os_path, *args, **kwargs) as f: yield f @contextmanager def perm_to_403(self, os_path=""): """context manager for turning permission errors into 403.""" try: yield except OSError as e: if e.errno in {errno.EPERM, errno.EACCES}: # make 403 error message without root prefix # this may not work perfectly on unicode paths on Python 2, # but nobody should be doing that anyway. if not os_path: os_path = e.filename or "unknown file" path = to_api_path(os_path, root=self.root_dir) # type:ignore[attr-defined] raise HTTPError(403, "Permission denied: %s" % path) from e else: raise def _copy(self, src, dest): """copy src to dest like shutil.copy2, but log errors in copystat """ copy2_safe(src, dest, log=self.log) def _get_os_path(self, path): """Given an API path, return its file system path. Parameters ---------- path : str The relative API path to the named file. Returns ------- path : str Native, absolute OS path to for a file. Raises ------ 404: if path is outside root """ # This statement can cause excessive logging, uncomment if necessary when troubleshooting. # self.log.debug("Reading path from disk: %s", path) root = os.path.abspath(self.root_dir) # type:ignore[attr-defined] # to_os_path is not safe if path starts with a drive, since os.path.join discards first part if os.path.splitdrive(path)[0]: raise HTTPError(404, "%s is not a relative API path" % path) os_path = to_os_path(ApiPath(path), root) # validate os path # e.g. "foo\0" raises ValueError: embedded null byte try: os.lstat(os_path) except OSError: # OSError could be FileNotFound, PermissionError, etc. # those should raise (or not) elsewhere pass except ValueError: raise HTTPError(404, f"{path} is not a valid path") from None if not (os.path.abspath(os_path) + os.path.sep).startswith(root): raise HTTPError(404, "%s is outside root contents directory" % path) return os_path def _read_notebook( self, os_path, as_version=4, capture_validation_error=None, raw: bool = False ): """Read a notebook from an os path.""" answer = self._read_file(os_path, "text", raw=raw) try: nb = nbformat.reads( answer[0], as_version=as_version, capture_validation_error=capture_validation_error, ) return (nb, answer[2]) if raw else nb # type:ignore[misc] except Exception as e: e_orig = e # If use_atomic_writing is enabled, we'll guess that it was also # enabled when this notebook was written and look for a valid # atomic intermediate. tmp_path = path_to_intermediate(os_path) if not self.use_atomic_writing or not os.path.exists(tmp_path): raise HTTPError( 400, f"Unreadable Notebook: {os_path} {e_orig!r}", ) # Move the bad file aside, restore the intermediate, and try again. invalid_file = path_to_invalid(os_path) replace_file(os_path, invalid_file) replace_file(tmp_path, os_path) return self._read_notebook( os_path, as_version, capture_validation_error=capture_validation_error, raw=raw ) def _save_notebook(self, os_path, nb, capture_validation_error=None): """Save a notebook to an os_path.""" with self.atomic_writing(os_path, encoding="utf-8") as f: nbformat.write( nb, f, version=nbformat.NO_CONVERT, capture_validation_error=capture_validation_error, ) def _get_hash(self, byte_content: bytes) -> dict[str, str]: """Compute the hash hexdigest for the provided bytes. The hash algorithm is provided by the `hash_algorithm` attribute. Parameters ---------- byte_content : bytes The bytes to hash Returns ------- A dictionary to be appended to a model {"hash": str, "hash_algorithm": str}. """ algorithm = self.hash_algorithm h = hashlib.new(algorithm) h.update(byte_content) return {"hash": h.hexdigest(), "hash_algorithm": algorithm} def _read_file( self, os_path: str, format: str | None, raw: bool = False ) -> tuple[str | bytes, str] | tuple[str | bytes, str, bytes]: """Read a non-notebook file. Parameters ---------- os_path: str The path to be read. format: str If 'text', the contents will be decoded as UTF-8. If 'base64', the raw bytes contents will be encoded as base64. If 'byte', the raw bytes contents will be returned. If not specified, try to decode as UTF-8, and fall back to base64 raw: bool [Optional] If True, will return as third argument the raw bytes content Returns ------- (content, format, byte_content) It returns the content in the given format as well as the raw byte content. """ if not os.path.isfile(os_path): raise HTTPError(400, "Cannot read non-file %s" % os_path) with self.open(os_path, "rb") as f: bcontent = f.read() if format == "byte": # Not for http response but internal use return (bcontent, "byte", bcontent) if raw else (bcontent, "byte") if format is None or format == "text": # Try to interpret as unicode if format is unknown or if unicode # was explicitly requested. try: return ( (bcontent.decode("utf8"), "text", bcontent) if raw else ( bcontent.decode("utf8"), "text", ) ) except UnicodeError as e: if format == "text": raise HTTPError( 400, "%s is not UTF-8 encoded" % os_path, reason="bad format", ) from e return ( (encodebytes(bcontent).decode("ascii"), "base64", bcontent) if raw else ( encodebytes(bcontent).decode("ascii"), "base64", ) ) def _save_file(self, os_path, content, format): """Save content of a generic file.""" if format not in {"text", "base64"}: raise HTTPError( 400, "Must specify format of file contents as 'text' or 'base64'", ) try: if format == "text": bcontent = content.encode("utf8") else: b64_bytes = content.encode("ascii") bcontent = decodebytes(b64_bytes) except Exception as e: raise HTTPError(400, f"Encoding error saving {os_path}: {e}") from e with self.atomic_writing(os_path, text=False) as f: f.write(bcontent) class AsyncFileManagerMixin(FileManagerMixin): """ Mixin for ContentsAPI classes that interact with the filesystem asynchronously. """ async def _copy(self, src, dest): """copy src to dest like shutil.copy2, but log errors in copystat """ await async_copy2_safe(src, dest, log=self.log) async def _read_notebook( self, os_path, as_version=4, capture_validation_error=None, raw: bool = False ): """Read a notebook from an os path.""" answer = await self._read_file(os_path, "text", raw) try: nb = await run_sync( partial( nbformat.reads, as_version=as_version, capture_validation_error=capture_validation_error, ), answer[0], ) return (nb, answer[2]) if raw else nb # type:ignore[misc] except Exception as e: e_orig = e # If use_atomic_writing is enabled, we'll guess that it was also # enabled when this notebook was written and look for a valid # atomic intermediate. tmp_path = path_to_intermediate(os_path) if not self.use_atomic_writing or not os.path.exists(tmp_path): raise HTTPError( 400, f"Unreadable Notebook: {os_path} {e_orig!r}", ) # Move the bad file aside, restore the intermediate, and try again. invalid_file = path_to_invalid(os_path) await async_replace_file(os_path, invalid_file) await async_replace_file(tmp_path, os_path) answer = await self._read_notebook( os_path, as_version, capture_validation_error=capture_validation_error, raw=raw ) return answer async def _save_notebook(self, os_path, nb, capture_validation_error=None): """Save a notebook to an os_path.""" with self.atomic_writing(os_path, encoding="utf-8") as f: await run_sync( partial( nbformat.write, version=nbformat.NO_CONVERT, capture_validation_error=capture_validation_error, ), nb, f, ) async def _read_file( # type: ignore[override] self, os_path: str, format: str | None, raw: bool = False ) -> tuple[str | bytes, str] | tuple[str | bytes, str, bytes]: """Read a non-notebook file. Parameters ---------- os_path: str The path to be read. format: str If 'text', the contents will be decoded as UTF-8. If 'base64', the raw bytes contents will be encoded as base64. If 'byte', the raw bytes contents will be returned. If not specified, try to decode as UTF-8, and fall back to base64 raw: bool [Optional] If True, will return as third argument the raw bytes content Returns ------- (content, format, byte_content) It returns the content in the given format as well as the raw byte content. """ if not os.path.isfile(os_path): raise HTTPError(400, "Cannot read non-file %s" % os_path) with self.open(os_path, "rb") as f: bcontent = await run_sync(f.read) if format == "byte": # Not for http response but internal use return (bcontent, "byte", bcontent) if raw else (bcontent, "byte") if format is None or format == "text": # Try to interpret as unicode if format is unknown or if unicode # was explicitly requested. try: return ( (bcontent.decode("utf8"), "text", bcontent) if raw else ( bcontent.decode("utf8"), "text", ) ) except UnicodeError as e: if format == "text": raise HTTPError( 400, "%s is not UTF-8 encoded" % os_path, reason="bad format", ) from e return ( (encodebytes(bcontent).decode("ascii"), "base64", bcontent) if raw else (encodebytes(bcontent).decode("ascii"), "base64") ) async def _save_file(self, os_path, content, format): """Save content of a generic file.""" if format not in {"text", "base64"}: raise HTTPError( 400, "Must specify format of file contents as 'text' or 'base64'", ) try: if format == "text": bcontent = content.encode("utf8") else: b64_bytes = content.encode("ascii") bcontent = decodebytes(b64_bytes) except Exception as e: raise HTTPError(400, f"Encoding error saving {os_path}: {e}") from e with self.atomic_writing(os_path, text=False) as f: await run_sync(f.write, bcontent) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/contents/filemanager.py000066400000000000000000001336011473126534200317130ustar00rootroot00000000000000"""A contents manager that uses the local file system for storage.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import asyncio import errno import math import mimetypes import os import platform import shutil import stat import subprocess import sys import typing as t import warnings from datetime import datetime from pathlib import Path import nbformat from anyio.to_thread import run_sync from jupyter_core.paths import exists, is_file_hidden, is_hidden from send2trash import send2trash from tornado import web from traitlets import Bool, Int, TraitError, Unicode, default, validate from jupyter_server import _tz as tz from jupyter_server.base.handlers import AuthenticatedFileHandler from jupyter_server.transutils import _i18n from jupyter_server.utils import to_api_path from .filecheckpoints import AsyncFileCheckpoints, FileCheckpoints from .fileio import AsyncFileManagerMixin, FileManagerMixin from .manager import AsyncContentsManager, ContentsManager, copy_pat try: from os.path import samefile except ImportError: # windows from jupyter_server.utils import samefile_simple as samefile # type:ignore[assignment] _script_exporter = None class FileContentsManager(FileManagerMixin, ContentsManager): """A file contents manager.""" root_dir = Unicode(config=True) max_copy_folder_size_mb = Int(500, config=True, help="The max folder size that can be copied") @default("root_dir") def _default_root_dir(self): if not self.parent: return os.getcwd() return self.parent.root_dir @validate("root_dir") def _validate_root_dir(self, proposal): value = proposal["value"] if not os.path.isabs(value): # If we receive a non-absolute path, make it absolute. value = os.path.abspath(value) if not os.path.isdir(value): raise TraitError("%r is not a directory" % value) return value @default("preferred_dir") def _default_preferred_dir(self): if not self.parent: return "" try: value = self.parent.preferred_dir if value == self.parent.root_dir: value = None except AttributeError: pass else: if value is not None: warnings.warn( "ServerApp.preferred_dir config is deprecated in jupyter-server 2.0. Use FileContentsManager.preferred_dir instead", FutureWarning, stacklevel=3, ) try: path = Path(value) return path.relative_to(self.root_dir).as_posix() except ValueError: raise TraitError("%s is outside root contents directory" % value) from None return "" @validate("preferred_dir") def _validate_preferred_dir(self, proposal): # It should be safe to pass an API path through this method: proposal["value"] = to_api_path(proposal["value"], self.root_dir) return super()._validate_preferred_dir(proposal) @default("checkpoints_class") def _checkpoints_class_default(self): return FileCheckpoints delete_to_trash = Bool( True, config=True, help="""If True (default), deleting files will send them to the platform's trash/recycle bin, where they can be recovered. If False, deleting files really deletes them.""", ) always_delete_dir = Bool( False, config=True, help="""If True, deleting a non-empty directory will always be allowed. WARNING this may result in files being permanently removed; e.g. on Windows, if the data size is too big for the trash/recycle bin the directory will be permanently deleted. If False (default), the non-empty directory will be sent to the trash only if safe. And if ``delete_to_trash`` is True, the directory won't be deleted.""", ) @default("files_handler_class") def _files_handler_class_default(self): return AuthenticatedFileHandler @default("files_handler_params") def _files_handler_params_default(self): return {"path": self.root_dir} def is_hidden(self, path): """Does the API style path correspond to a hidden directory or file? Parameters ---------- path : str The path to check. This is an API path (`/` separated, relative to root_dir). Returns ------- hidden : bool Whether the path exists and is hidden. """ path = path.strip("/") os_path = self._get_os_path(path=path) return is_hidden(os_path, self.root_dir) def is_writable(self, path): """Does the API style path correspond to a writable directory or file? Parameters ---------- path : str The path to check. This is an API path (`/` separated, relative to root_dir). Returns ------- hidden : bool Whether the path exists and is writable. """ path = path.strip("/") os_path = self._get_os_path(path=path) try: return os.access(os_path, os.W_OK) except OSError: self.log.error("Failed to check write permissions on %s", os_path) return False def file_exists(self, path): """Returns True if the file exists, else returns False. API-style wrapper for os.path.isfile Parameters ---------- path : str The relative path to the file (with '/' as separator) Returns ------- exists : bool Whether the file exists. """ path = path.strip("/") os_path = self._get_os_path(path) return os.path.isfile(os_path) def dir_exists(self, path): """Does the API-style path refer to an extant directory? API-style wrapper for os.path.isdir Parameters ---------- path : str The path to check. This is an API path (`/` separated, relative to root_dir). Returns ------- exists : bool Whether the path is indeed a directory. """ path = path.strip("/") os_path = self._get_os_path(path=path) return os.path.isdir(os_path) def exists(self, path): """Returns True if the path exists, else returns False. API-style wrapper for os.path.exists Parameters ---------- path : str The API path to the file (with '/' as separator) Returns ------- exists : bool Whether the target exists. """ path = path.strip("/") os_path = self._get_os_path(path=path) return exists(os_path) def _base_model(self, path): """Build the common base of a contents model""" os_path = self._get_os_path(path) info = os.lstat(os_path) four_o_four = "file or directory does not exist: %r" % path if not self.allow_hidden and is_hidden(os_path, self.root_dir): self.log.info("Refusing to serve hidden file or directory %r, via 404 Error", os_path) raise web.HTTPError(404, four_o_four) try: # size of file size = info.st_size except (ValueError, OSError): self.log.warning("Unable to get size.") size = None try: last_modified = tz.utcfromtimestamp(info.st_mtime) except (ValueError, OSError): # Files can rarely have an invalid timestamp # https://github.com/jupyter/notebook/issues/2539 # https://github.com/jupyter/notebook/issues/2757 # Use the Unix epoch as a fallback so we don't crash. self.log.warning("Invalid mtime %s for %s", info.st_mtime, os_path) last_modified = datetime(1970, 1, 1, 0, 0, tzinfo=tz.UTC) try: created = tz.utcfromtimestamp(info.st_ctime) except (ValueError, OSError): # See above self.log.warning("Invalid ctime %s for %s", info.st_ctime, os_path) created = datetime(1970, 1, 1, 0, 0, tzinfo=tz.UTC) # Create the base model. model = {} model["name"] = path.rsplit("/", 1)[-1] model["path"] = path model["last_modified"] = last_modified model["created"] = created model["content"] = None model["format"] = None model["mimetype"] = None model["size"] = size model["writable"] = self.is_writable(path) model["hash"] = None model["hash_algorithm"] = None return model def _dir_model(self, path, content=True): """Build a model for a directory if content is requested, will include a listing of the directory """ os_path = self._get_os_path(path) four_o_four = "directory does not exist: %r" % path if not os.path.isdir(os_path): raise web.HTTPError(404, four_o_four) elif not self.allow_hidden and is_hidden(os_path, self.root_dir): self.log.info("Refusing to serve hidden directory %r, via 404 Error", os_path) raise web.HTTPError(404, four_o_four) model = self._base_model(path) model["type"] = "directory" model["size"] = None if content: model["content"] = contents = [] os_dir = self._get_os_path(path) for name in os.listdir(os_dir): try: os_path = os.path.join(os_dir, name) except UnicodeDecodeError as e: self.log.warning("failed to decode filename '%s': %r", name, e) continue try: st = os.lstat(os_path) except OSError as e: # skip over broken symlinks in listing if e.errno == errno.ENOENT: self.log.warning("%s doesn't exist", os_path) elif e.errno != errno.EACCES: # Don't provide clues about protected files self.log.warning("Error stat-ing %s: %r", os_path, e) continue if ( not stat.S_ISLNK(st.st_mode) and not stat.S_ISREG(st.st_mode) and not stat.S_ISDIR(st.st_mode) ): self.log.debug("%s not a regular file", os_path) continue try: if self.should_list(name) and ( self.allow_hidden or not is_file_hidden(os_path, stat_res=st) ): contents.append(self.get(path=f"{path}/{name}", content=False)) except OSError as e: # ELOOP: recursive symlink, also don't show failure due to permissions if e.errno not in [errno.ELOOP, errno.EACCES]: self.log.warning( "Unknown error checking if file %r is hidden", os_path, exc_info=True, ) model["format"] = "json" return model def _file_model(self, path, content=True, format=None, require_hash=False): """Build a model for a file if content is requested, include the file contents. format: If 'text', the contents will be decoded as UTF-8. If 'base64', the raw bytes contents will be encoded as base64. If not specified, try to decode as UTF-8, and fall back to base64 if require_hash is true, the model will include 'hash' """ model = self._base_model(path) model["type"] = "file" os_path = self._get_os_path(path) model["mimetype"] = mimetypes.guess_type(os_path)[0] bytes_content = None if content: content, format, bytes_content = self._read_file(os_path, format, raw=True) # type: ignore[misc] if model["mimetype"] is None: default_mime = { "text": "text/plain", "base64": "application/octet-stream", }[format] model["mimetype"] = default_mime model.update( content=content, format=format, ) if require_hash: if bytes_content is None: bytes_content, _ = self._read_file(os_path, "byte") # type: ignore[assignment,misc] model.update(**self._get_hash(bytes_content)) # type: ignore[arg-type] return model def _notebook_model(self, path, content=True, require_hash=False): """Build a notebook model if content is requested, the notebook content will be populated as a JSON structure (not double-serialized) if require_hash is true, the model will include 'hash' """ model = self._base_model(path) model["type"] = "notebook" os_path = self._get_os_path(path) bytes_content = None if content: validation_error: dict[str, t.Any] = {} nb, bytes_content = self._read_notebook( os_path, as_version=4, capture_validation_error=validation_error, raw=True ) self.mark_trusted_cells(nb, path) model["content"] = nb model["format"] = "json" self.validate_notebook_model(model, validation_error) if require_hash: if bytes_content is None: bytes_content, _ = self._read_file(os_path, "byte") # type: ignore[misc] model.update(**self._get_hash(bytes_content)) # type: ignore[arg-type] return model def get(self, path, content=True, type=None, format=None, require_hash=False): """Takes a path for an entity and returns its model Parameters ---------- path : str the API path that describes the relative path for the target content : bool Whether to include the contents in the reply type : str, optional The requested type - 'file', 'notebook', or 'directory'. Will raise HTTPError 400 if the content doesn't match. format : str, optional The requested format for file contents. 'text' or 'base64'. Ignored if this returns a notebook or directory model. require_hash: bool, optional Whether to include the hash of the file contents. Returns ------- model : dict the contents model. If content=True, returns the contents of the file or directory as well. """ path = path.strip("/") os_path = self._get_os_path(path) four_o_four = "file or directory does not exist: %r" % path if not self.exists(path): raise web.HTTPError(404, four_o_four) if not self.allow_hidden and is_hidden(os_path, self.root_dir): self.log.info("Refusing to serve hidden file or directory %r, via 404 Error", os_path) raise web.HTTPError(404, four_o_four) if os.path.isdir(os_path): if type not in (None, "directory"): raise web.HTTPError( 400, f"{path} is a directory, not a {type}", reason="bad type", ) model = self._dir_model(path, content=content) elif type == "notebook" or (type is None and path.endswith(".ipynb")): model = self._notebook_model(path, content=content, require_hash=require_hash) else: if type == "directory": raise web.HTTPError(400, "%s is not a directory" % path, reason="bad type") model = self._file_model( path, content=content, format=format, require_hash=require_hash ) self.emit(data={"action": "get", "path": path}) return model def _save_directory(self, os_path, model, path=""): """create a directory""" if not self.allow_hidden and is_hidden(os_path, self.root_dir): raise web.HTTPError(400, "Cannot create directory %r" % os_path) if not os.path.exists(os_path): with self.perm_to_403(): os.mkdir(os_path) elif not os.path.isdir(os_path): raise web.HTTPError(400, "Not a directory: %s" % (os_path)) else: self.log.debug("Directory %r already exists", os_path) def save(self, model, path=""): """Save the file model and return the model with no content.""" path = path.strip("/") self.run_pre_save_hooks(model=model, path=path) if "type" not in model: raise web.HTTPError(400, "No file type provided") if "content" not in model and model["type"] != "directory": raise web.HTTPError(400, "No file content provided") os_path = self._get_os_path(path) if not self.allow_hidden and is_hidden(os_path, self.root_dir): raise web.HTTPError(400, f"Cannot create file or directory {os_path!r}") self.log.debug("Saving %s", os_path) validation_error: dict[str, t.Any] = {} try: if model["type"] == "notebook": nb = nbformat.from_dict(model["content"]) self.check_and_sign(nb, path) self._save_notebook(os_path, nb, capture_validation_error=validation_error) # One checkpoint should always exist for notebooks. if not self.checkpoints.list_checkpoints(path): self.create_checkpoint(path) elif model["type"] == "file": # Missing format will be handled internally by _save_file. self._save_file(os_path, model["content"], model.get("format")) elif model["type"] == "directory": self._save_directory(os_path, model, path) else: raise web.HTTPError(400, "Unhandled contents type: %s" % model["type"]) except web.HTTPError: raise except Exception as e: self.log.error("Error while saving file: %s %s", path, e, exc_info=True) raise web.HTTPError(500, f"Unexpected error while saving file: {path} {e}") from e validation_message = None if model["type"] == "notebook": self.validate_notebook_model(model, validation_error=validation_error) validation_message = model.get("message", None) model = self.get(path, content=False) if validation_message: model["message"] = validation_message self.run_post_save_hooks(model=model, os_path=os_path) self.emit(data={"action": "save", "path": path}) return model def delete_file(self, path): """Delete file at path.""" path = path.strip("/") os_path = self._get_os_path(path) rm = os.unlink if not self.allow_hidden and is_hidden(os_path, self.root_dir): raise web.HTTPError(400, f"Cannot delete file or directory {os_path!r}") four_o_four = "file or directory does not exist: %r" % path if not self.exists(path): raise web.HTTPError(404, four_o_four) def is_non_empty_dir(os_path): if os.path.isdir(os_path): # A directory containing only leftover checkpoints is # considered empty. cp_dir = getattr(self.checkpoints, "checkpoint_dir", None) if set(os.listdir(os_path)) - {cp_dir}: return True return False if self.delete_to_trash: if not self.always_delete_dir and sys.platform == "win32" and is_non_empty_dir(os_path): # send2trash can really delete files on Windows, so disallow # deleting non-empty files. See Github issue 3631. raise web.HTTPError(400, "Directory %s not empty" % os_path) # send2trash now supports deleting directories. see #1290 if not self.is_writable(path): raise web.HTTPError(403, "Permission denied: %s" % path) from None self.log.debug("Sending %s to trash", os_path) try: send2trash(os_path) except OSError as e: raise web.HTTPError(400, "send2trash failed: %s" % e) from e return if os.path.isdir(os_path): # Don't permanently delete non-empty directories. if not self.always_delete_dir and is_non_empty_dir(os_path): raise web.HTTPError(400, "Directory %s not empty" % os_path) self.log.debug("Removing directory %s", os_path) with self.perm_to_403(): shutil.rmtree(os_path) else: self.log.debug("Unlinking file %s", os_path) with self.perm_to_403(): rm(os_path) def rename_file(self, old_path, new_path): """Rename a file.""" old_path = old_path.strip("/") new_path = new_path.strip("/") if new_path == old_path: return new_os_path = self._get_os_path(new_path) old_os_path = self._get_os_path(old_path) if not self.allow_hidden and ( is_hidden(old_os_path, self.root_dir) or is_hidden(new_os_path, self.root_dir) ): raise web.HTTPError(400, f"Cannot rename file or directory {old_os_path!r}") # Should we proceed with the move? if os.path.exists(new_os_path) and not samefile(old_os_path, new_os_path): raise web.HTTPError(409, "File already exists: %s" % new_path) # Move the file try: with self.perm_to_403(): shutil.move(old_os_path, new_os_path) except web.HTTPError: raise except Exception as e: raise web.HTTPError(500, f"Unknown error renaming file: {old_path} {e}") from e def info_string(self): """Get the information string for the manager.""" return _i18n("Serving notebooks from local directory: %s") % self.root_dir def get_kernel_path(self, path, model=None): """Return the initial API path of a kernel associated with a given notebook""" if self.dir_exists(path): return path parent_dir = path.rsplit("/", 1)[0] if "/" in path else "" return parent_dir def copy(self, from_path, to_path=None): """ Copy an existing file or directory and return its new model. If to_path not specified, it will be the parent directory of from_path. If copying a file and to_path is a directory, filename/directoryname will increment `from_path-Copy#.ext`. Considering multi-part extensions, the Copy# part will be placed before the first dot for all the extensions except `ipynb`. For easier manual searching in case of notebooks, the Copy# part will be placed before the last dot. from_path must be a full path to a file or directory. """ to_path_original = str(to_path) path = from_path.strip("/") if to_path is not None: to_path = to_path.strip("/") if "/" in path: from_dir, from_name = path.rsplit("/", 1) else: from_dir = "" from_name = path model = self.get(path) # limit the size of folders being copied to prevent a timeout error if model["type"] == "directory": self.check_folder_size(path) else: # let the super class handle copying files return super().copy(from_path=from_path, to_path=to_path) is_destination_specified = to_path is not None to_name = copy_pat.sub(".", from_name) if not is_destination_specified: to_path = from_dir if self.dir_exists(to_path): name = copy_pat.sub(".", from_name) to_name = super().increment_filename(name, to_path, insert="-Copy") to_path = f"{to_path}/{to_name}" return self._copy_dir( from_path=from_path, to_path_original=to_path_original, to_name=to_name, to_path=to_path, ) def _copy_dir(self, from_path, to_path_original, to_name, to_path): """ handles copying directories returns the model for the copied directory """ try: os_from_path = self._get_os_path(from_path.strip("/")) os_to_path = f'{self._get_os_path(to_path_original.strip("/"))}/{to_name}' shutil.copytree(os_from_path, os_to_path) model = self.get(to_path, content=False) except OSError as err: self.log.error(f"OSError in _copy_dir: {err}") raise web.HTTPError( 400, f"Can't copy '{from_path}' into Folder '{to_path}'", ) from err return model def check_folder_size(self, path): """ limit the size of folders being copied to be no more than the trait max_copy_folder_size_mb to prevent a timeout error """ limit_bytes = self.max_copy_folder_size_mb * 1024 * 1024 size = int(self._get_dir_size(self._get_os_path(path))) # convert from KB to Bytes for macOS size = size * 1024 if platform.system() == "Darwin" else size if size > limit_bytes: raise web.HTTPError( 400, f""" Can't copy folders larger than {self.max_copy_folder_size_mb}MB, "{path}" is {self._human_readable_size(size)} """, ) def _get_dir_size(self, path="."): """ calls the command line program du to get the directory size """ try: if platform.system() == "Darwin": # returns the size of the folder in KB result = subprocess.run( ["du", "-sk", path], # noqa: S607 capture_output=True, check=True, ).stdout.split() else: result = subprocess.run( ["du", "-s", "--block-size=1", path], # noqa: S607 capture_output=True, check=True, ).stdout.split() self.log.info(f"current status of du command {result}") size = result[0].decode("utf-8") except Exception: self.log.warning( "Not able to get the size of the %s directory. Copying might be slow if the directory is large!", path, ) return "0" return size def _human_readable_size(self, size): """ returns folder size in a human readable format """ if size == 0: return "0 Bytes" units = ["Bytes", "KB", "MB", "GB", "TB", "PB"] order = int(math.log2(size) / 10) if size else 0 return f"{size / (1 << (order * 10)):.4g} {units[order]}" class AsyncFileContentsManager(FileContentsManager, AsyncFileManagerMixin, AsyncContentsManager): """An async file contents manager.""" @default("checkpoints_class") def _checkpoints_class_default(self): return AsyncFileCheckpoints async def _dir_model(self, path, content=True): """Build a model for a directory if content is requested, will include a listing of the directory """ os_path = self._get_os_path(path) four_o_four = "directory does not exist: %r" % path if not os.path.isdir(os_path): raise web.HTTPError(404, four_o_four) elif not self.allow_hidden and is_hidden(os_path, self.root_dir): self.log.info("Refusing to serve hidden directory %r, via 404 Error", os_path) raise web.HTTPError(404, four_o_four) model = self._base_model(path) model["type"] = "directory" model["size"] = None if content: model["content"] = contents = [] os_dir = self._get_os_path(path) dir_contents = await run_sync(os.listdir, os_dir) for name in dir_contents: try: os_path = os.path.join(os_dir, name) except UnicodeDecodeError as e: self.log.warning("failed to decode filename '%s': %r", name, e) continue try: st = await run_sync(os.lstat, os_path) except OSError as e: # skip over broken symlinks in listing if e.errno == errno.ENOENT: self.log.warning("%s doesn't exist", os_path) elif e.errno != errno.EACCES: # Don't provide clues about protected files self.log.warning("Error stat-ing %s: %r", os_path, e) continue if ( not stat.S_ISLNK(st.st_mode) and not stat.S_ISREG(st.st_mode) and not stat.S_ISDIR(st.st_mode) ): self.log.debug("%s not a regular file", os_path) continue try: if self.should_list(name) and ( self.allow_hidden or not is_file_hidden(os_path, stat_res=st) ): contents.append(await self.get(path=f"{path}/{name}", content=False)) except OSError as e: # ELOOP: recursive symlink, also don't show failure due to permissions if e.errno not in [errno.ELOOP, errno.EACCES]: self.log.warning( "Unknown error checking if file %r is hidden", os_path, exc_info=True, ) model["format"] = "json" return model async def _file_model(self, path, content=True, format=None, require_hash=False): """Build a model for a file if content is requested, include the file contents. format: If 'text', the contents will be decoded as UTF-8. If 'base64', the raw bytes contents will be encoded as base64. If not specified, try to decode as UTF-8, and fall back to base64 if require_hash is true, the model will include 'hash' """ model = self._base_model(path) model["type"] = "file" os_path = self._get_os_path(path) model["mimetype"] = mimetypes.guess_type(os_path)[0] bytes_content = None if content: content, format, bytes_content = await self._read_file(os_path, format, raw=True) # type: ignore[misc] if model["mimetype"] is None: default_mime = { "text": "text/plain", "base64": "application/octet-stream", }[format] model["mimetype"] = default_mime model.update( content=content, format=format, ) if require_hash: if bytes_content is None: bytes_content, _ = await self._read_file(os_path, "byte") # type: ignore[assignment,misc] model.update(**self._get_hash(bytes_content)) # type: ignore[arg-type] return model async def _notebook_model(self, path, content=True, require_hash=False): """Build a notebook model if content is requested, the notebook content will be populated as a JSON structure (not double-serialized) """ model = self._base_model(path) model["type"] = "notebook" os_path = self._get_os_path(path) bytes_content = None if content: validation_error: dict[str, t.Any] = {} nb, bytes_content = await self._read_notebook( os_path, as_version=4, capture_validation_error=validation_error, raw=True ) self.mark_trusted_cells(nb, path) model["content"] = nb model["format"] = "json" self.validate_notebook_model(model, validation_error) if require_hash: if bytes_content is None: bytes_content, _ = await self._read_file(os_path, "byte") # type: ignore[misc] model.update(**(self._get_hash(bytes_content))) # type: ignore[arg-type] return model async def get(self, path, content=True, type=None, format=None, require_hash=False): """Takes a path for an entity and returns its model Parameters ---------- path : str the API path that describes the relative path for the target content : bool Whether to include the contents in the reply type : str, optional The requested type - 'file', 'notebook', or 'directory'. Will raise HTTPError 400 if the content doesn't match. format : str, optional The requested format for file contents. 'text' or 'base64'. Ignored if this returns a notebook or directory model. require_hash: bool, optional Whether to include the hash of the file contents. Returns ------- model : dict the contents model. If content=True, returns the contents of the file or directory as well. """ path = path.strip("/") if not self.exists(path): raise web.HTTPError(404, "No such file or directory: %s" % path) os_path = self._get_os_path(path) if os.path.isdir(os_path): if type not in (None, "directory"): raise web.HTTPError( 400, f"{path} is a directory, not a {type}", reason="bad type", ) model = await self._dir_model(path, content=content) elif type == "notebook" or (type is None and path.endswith(".ipynb")): model = await self._notebook_model(path, content=content, require_hash=require_hash) else: if type == "directory": raise web.HTTPError(400, "%s is not a directory" % path, reason="bad type") model = await self._file_model( path, content=content, format=format, require_hash=require_hash ) self.emit(data={"action": "get", "path": path}) return model async def _save_directory(self, os_path, model, path=""): """create a directory""" if not self.allow_hidden and is_hidden(os_path, self.root_dir): raise web.HTTPError(400, "Cannot create hidden directory %r" % os_path) if not os.path.exists(os_path): with self.perm_to_403(): await run_sync(os.mkdir, os_path) elif not os.path.isdir(os_path): raise web.HTTPError(400, "Not a directory: %s" % (os_path)) else: self.log.debug("Directory %r already exists", os_path) async def save(self, model, path=""): """Save the file model and return the model with no content.""" path = path.strip("/") self.run_pre_save_hooks(model=model, path=path) if "type" not in model: raise web.HTTPError(400, "No file type provided") if "content" not in model and model["type"] != "directory": raise web.HTTPError(400, "No file content provided") os_path = self._get_os_path(path) self.log.debug("Saving %s", os_path) validation_error: dict[str, t.Any] = {} try: if model["type"] == "notebook": nb = nbformat.from_dict(model["content"]) self.check_and_sign(nb, path) await self._save_notebook(os_path, nb, capture_validation_error=validation_error) # One checkpoint should always exist for notebooks. if not (await self.checkpoints.list_checkpoints(path)): await self.create_checkpoint(path) elif model["type"] == "file": # Missing format will be handled internally by _save_file. await self._save_file(os_path, model["content"], model.get("format")) elif model["type"] == "directory": await self._save_directory(os_path, model, path) else: raise web.HTTPError(400, "Unhandled contents type: %s" % model["type"]) except web.HTTPError: raise except Exception as e: self.log.error("Error while saving file: %s %s", path, e, exc_info=True) raise web.HTTPError(500, f"Unexpected error while saving file: {path} {e}") from e validation_message = None if model["type"] == "notebook": self.validate_notebook_model(model, validation_error=validation_error) validation_message = model.get("message", None) model = await self.get(path, content=False) if validation_message: model["message"] = validation_message self.run_post_save_hooks(model=model, os_path=os_path) self.emit(data={"action": "save", "path": path}) return model async def delete_file(self, path): """Delete file at path.""" path = path.strip("/") os_path = self._get_os_path(path) rm = os.unlink if not self.allow_hidden and is_hidden(os_path, self.root_dir): raise web.HTTPError(400, f"Cannot delete file or directory {os_path!r}") if not os.path.exists(os_path): raise web.HTTPError(404, "File or directory does not exist: %s" % os_path) async def is_non_empty_dir(os_path): if os.path.isdir(os_path): # A directory containing only leftover checkpoints is # considered empty. cp_dir = getattr(self.checkpoints, "checkpoint_dir", None) dir_contents = set(await run_sync(os.listdir, os_path)) if dir_contents - {cp_dir}: return True return False if self.delete_to_trash: if ( not self.always_delete_dir and sys.platform == "win32" and await is_non_empty_dir(os_path) ): # send2trash can really delete files on Windows, so disallow # deleting non-empty files. See Github issue 3631. raise web.HTTPError(400, "Directory %s not empty" % os_path) # send2trash now supports deleting directories. see #1290 if not self.is_writable(path): raise web.HTTPError(403, "Permission denied: %s" % path) from None self.log.debug("Sending %s to trash", os_path) try: send2trash(os_path) except OSError as e: raise web.HTTPError(400, "send2trash failed: %s" % e) from e return if os.path.isdir(os_path): # Don't permanently delete non-empty directories. if not self.always_delete_dir and await is_non_empty_dir(os_path): raise web.HTTPError(400, "Directory %s not empty" % os_path) self.log.debug("Removing directory %s", os_path) with self.perm_to_403(): await run_sync(shutil.rmtree, os_path) else: self.log.debug("Unlinking file %s", os_path) with self.perm_to_403(): await run_sync(rm, os_path) async def rename_file(self, old_path, new_path): """Rename a file.""" old_path = old_path.strip("/") new_path = new_path.strip("/") if new_path == old_path: return new_os_path = self._get_os_path(new_path) old_os_path = self._get_os_path(old_path) if not self.allow_hidden and ( is_hidden(old_os_path, self.root_dir) or is_hidden(new_os_path, self.root_dir) ): raise web.HTTPError(400, f"Cannot rename file or directory {old_os_path!r}") # Should we proceed with the move? if os.path.exists(new_os_path) and not samefile(old_os_path, new_os_path): raise web.HTTPError(409, "File already exists: %s" % new_path) # Move the file try: with self.perm_to_403(): await run_sync(shutil.move, old_os_path, new_os_path) except web.HTTPError: raise except Exception as e: raise web.HTTPError(500, f"Unknown error renaming file: {old_path} {e}") from e async def dir_exists(self, path): """Does a directory exist at the given path""" path = path.strip("/") os_path = self._get_os_path(path=path) return os.path.isdir(os_path) async def file_exists(self, path): """Does a file exist at the given path""" path = path.strip("/") os_path = self._get_os_path(path) return os.path.isfile(os_path) async def is_hidden(self, path): """Is path a hidden directory or file""" path = path.strip("/") os_path = self._get_os_path(path=path) return is_hidden(os_path, self.root_dir) async def get_kernel_path(self, path, model=None): """Return the initial API path of a kernel associated with a given notebook""" if await self.dir_exists(path): return path parent_dir = path.rsplit("/", 1)[0] if "/" in path else "" return parent_dir async def copy(self, from_path, to_path=None): """ Copy an existing file or directory and return its new model. If to_path not specified, it will be the parent directory of from_path. If copying a file and to_path is a directory, filename/directoryname will increment `from_path-Copy#.ext`. Considering multi-part extensions, the Copy# part will be placed before the first dot for all the extensions except `ipynb`. For easier manual searching in case of notebooks, the Copy# part will be placed before the last dot. from_path must be a full path to a file or directory. """ to_path_original = str(to_path) path = from_path.strip("/") if to_path is not None: to_path = to_path.strip("/") if "/" in path: from_dir, from_name = path.rsplit("/", 1) else: from_dir = "" from_name = path model = await self.get(path) # limit the size of folders being copied to prevent a timeout error if model["type"] == "directory": await self.check_folder_size(path) else: # let the super class handle copying files return await AsyncContentsManager.copy(self, from_path=from_path, to_path=to_path) is_destination_specified = to_path is not None to_name = copy_pat.sub(".", from_name) if not is_destination_specified: to_path = from_dir if await self.dir_exists(to_path): name = copy_pat.sub(".", from_name) to_name = await super().increment_filename(name, to_path, insert="-Copy") to_path = f"{to_path}/{to_name}" return await self._copy_dir( from_path=from_path, to_path_original=to_path_original, to_name=to_name, to_path=to_path, ) async def _copy_dir( self, from_path: str, to_path_original: str, to_name: str, to_path: str ) -> dict[str, t.Any]: """ handles copying directories returns the model for the copied directory """ try: os_from_path = self._get_os_path(from_path.strip("/")) os_to_path = f'{self._get_os_path(to_path_original.strip("/"))}/{to_name}' shutil.copytree(os_from_path, os_to_path) model = await self.get(to_path, content=False) except OSError as err: self.log.error(f"OSError in _copy_dir: {err}") raise web.HTTPError( 400, f"Can't copy '{from_path}' into read-only Folder '{to_path}'", ) from err return model # type:ignore[no-any-return] async def check_folder_size(self, path: str) -> None: """ limit the size of folders being copied to be no more than the trait max_copy_folder_size_mb to prevent a timeout error """ limit_bytes = self.max_copy_folder_size_mb * 1024 * 1024 size = int(await self._get_dir_size(self._get_os_path(path))) # convert from KB to Bytes for macOS size = size * 1024 if platform.system() == "Darwin" else size if size > limit_bytes: raise web.HTTPError( 400, f""" Can't copy folders larger than {self.max_copy_folder_size_mb}MB, "{path}" is {await self._human_readable_size(size)} """, ) async def _get_dir_size(self, path: str = ".") -> str: """ calls the command line program du to get the directory size """ try: if platform.system() == "Darwin": # returns the size of the folder in KB args = ["-sk", path] else: args = ["-s", "--block-size=1", path] proc = await asyncio.create_subprocess_exec( "du", *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, _ = await proc.communicate() result = await proc.wait() self.log.info(f"current status of du command {result}") assert result == 0 size = stdout.decode("utf-8").split()[0] except Exception: self.log.warning( "Not able to get the size of the %s directory. Copying might be slow if the directory is large!", path, ) return "0" return size async def _human_readable_size(self, size: int) -> str: """ returns folder size in a human readable format """ if size == 0: return "0 Bytes" units = ["Bytes", "KB", "MB", "GB", "TB", "PB"] order = int(math.log2(size) / 10) if size else 0 return f"{size / (1 << (order * 10)):.4g} {units[order]}" jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/contents/handlers.py000066400000000000000000000357241473126534200312500ustar00rootroot00000000000000"""Tornado handlers for the contents web service. Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-27%3A-Contents-Service """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json from http import HTTPStatus from typing import Any try: from jupyter_client.jsonutil import json_default except ImportError: from jupyter_client.jsonutil import date_default as json_default from jupyter_core.utils import ensure_async from tornado import web from jupyter_server.auth.decorator import allow_unauthenticated, authorized from jupyter_server.base.handlers import APIHandler, JupyterHandler, path_regex from jupyter_server.utils import url_escape, url_path_join AUTH_RESOURCE = "contents" def _validate_keys(expect_defined: bool, model: dict[str, Any], keys: list[str]): """ Validate that the keys are defined (i.e. not None) or not (i.e. None) """ if expect_defined: errors = [key for key in keys if model[key] is None] if errors: raise web.HTTPError( 500, f"Keys unexpectedly None: {errors}", ) else: errors = {key: model[key] for key in keys if model[key] is not None} # type: ignore[assignment] if errors: raise web.HTTPError( 500, f"Keys unexpectedly not None: {errors}", ) def validate_model(model, expect_content=False, expect_hash=False): """ Validate a model returned by a ContentsManager method. If expect_content is True, then we expect non-null entries for 'content' and 'format'. If expect_hash is True, then we expect non-null entries for 'hash' and 'hash_algorithm'. """ required_keys = { "name", "path", "type", "writable", "created", "last_modified", "mimetype", "content", "format", } if expect_hash: required_keys.update(["hash", "hash_algorithm"]) missing = required_keys - set(model.keys()) if missing: raise web.HTTPError( 500, f"Missing Model Keys: {missing}", ) content_keys = ["content", "format"] _validate_keys(expect_content, model, content_keys) if expect_hash: _validate_keys(expect_hash, model, ["hash", "hash_algorithm"]) class ContentsAPIHandler(APIHandler): """A contents API handler.""" auth_resource = AUTH_RESOURCE class ContentsHandler(ContentsAPIHandler): """A contents handler.""" def location_url(self, path): """Return the full URL location of a file. Parameters ---------- path : unicode The API path of the file, such as "foo/bar.txt". """ return url_path_join(self.base_url, "api", "contents", url_escape(path)) def _finish_model(self, model, location=True): """Finish a JSON request with a model, setting relevant headers, etc.""" if location: location = self.location_url(model["path"]) self.set_header("Location", location) self.set_header("Last-Modified", model["last_modified"]) self.set_header("Content-Type", "application/json") self.finish(json.dumps(model, default=json_default)) async def _finish_error(self, code, message): """Finish a JSON request with an error code and descriptive message""" self.set_status(code) self.write(message) await self.finish() @web.authenticated @authorized async def get(self, path=""): """Return a model for a file or directory. A directory model contains a list of models (without content) of the files and directories it contains. """ path = path or "" cm = self.contents_manager type = self.get_query_argument("type", default=None) if type not in {None, "directory", "file", "notebook"}: # fall back to file if unknown type type = "file" format = self.get_query_argument("format", default=None) if format not in {None, "text", "base64"}: raise web.HTTPError(400, "Format %r is invalid" % format) content_str = self.get_query_argument("content", default="1") if content_str not in {"0", "1"}: raise web.HTTPError(400, "Content %r is invalid" % content_str) content = int(content_str or "") hash_str = self.get_query_argument("hash", default="0") if hash_str not in {"0", "1"}: raise web.HTTPError( 400, f"Hash argument {hash_str!r} is invalid. It must be '0' or '1'." ) require_hash = int(hash_str) if not cm.allow_hidden and await ensure_async(cm.is_hidden(path)): await self._finish_error( HTTPStatus.NOT_FOUND, f"file or directory {path!r} does not exist" ) try: expect_hash = require_hash try: model = await ensure_async( self.contents_manager.get( path=path, type=type, format=format, content=content, require_hash=require_hash, ) ) except TypeError: # Fallback for ContentsManager not handling the require_hash argument # introduced in 2.11 expect_hash = False model = await ensure_async( self.contents_manager.get( path=path, type=type, format=format, content=content, ) ) validate_model(model, expect_content=content, expect_hash=expect_hash) self._finish_model(model, location=False) except web.HTTPError as exc: # 404 is okay in this context, catch exception and return 404 code to prevent stack trace on client if exc.status_code == HTTPStatus.NOT_FOUND: await self._finish_error( HTTPStatus.NOT_FOUND, f"file or directory {path!r} does not exist" ) raise @web.authenticated @authorized async def patch(self, path=""): """PATCH renames a file or directory without re-uploading content.""" cm = self.contents_manager model = self.get_json_body() if model is None: raise web.HTTPError(400, "JSON body missing") old_path = model.get("path") if ( old_path and not cm.allow_hidden and ( await ensure_async(cm.is_hidden(path)) or await ensure_async(cm.is_hidden(old_path)) ) ): raise web.HTTPError(400, f"Cannot rename file or directory {path!r}") model = await ensure_async(cm.update(model, path)) validate_model(model) self._finish_model(model) async def _copy(self, copy_from, copy_to=None): """Copy a file, optionally specifying a target directory.""" self.log.info( "Copying %r to %r", copy_from, copy_to or "", ) model = await ensure_async(self.contents_manager.copy(copy_from, copy_to)) self.set_status(201) validate_model(model) self._finish_model(model) async def _upload(self, model, path): """Handle upload of a new file to path""" self.log.info("Uploading file to %s", path) model = await ensure_async(self.contents_manager.new(model, path)) self.set_status(201) validate_model(model) self._finish_model(model) async def _new_untitled(self, path, type="", ext=""): """Create a new, empty untitled entity""" self.log.info("Creating new %s in %s", type or "file", path) model = await ensure_async( self.contents_manager.new_untitled(path=path, type=type, ext=ext) ) self.set_status(201) validate_model(model) self._finish_model(model) async def _save(self, model, path): """Save an existing file.""" chunk = model.get("chunk", None) if not chunk or chunk == -1: # Avoid tedious log information self.log.info("Saving file at %s", path) model = await ensure_async(self.contents_manager.save(model, path)) validate_model(model) self._finish_model(model) @web.authenticated @authorized async def post(self, path=""): """Create a new file in the specified path. POST creates new files. The server always decides on the name. POST /api/contents/path New untitled, empty file or directory. POST /api/contents/path with body {"copy_from" : "/path/to/OtherNotebook.ipynb"} New copy of OtherNotebook in path """ cm = self.contents_manager file_exists = await ensure_async(cm.file_exists(path)) if file_exists: raise web.HTTPError(400, "Cannot POST to files, use PUT instead.") model = self.get_json_body() if model: copy_from = model.get("copy_from") if copy_from: if not cm.allow_hidden and ( await ensure_async(cm.is_hidden(path)) or await ensure_async(cm.is_hidden(copy_from)) ): raise web.HTTPError(400, f"Cannot copy file or directory {path!r}") else: await self._copy(copy_from, path) else: ext = model.get("ext", "") type = model.get("type", "") if type not in {None, "", "directory", "file", "notebook"}: # fall back to file if unknown type type = "file" await self._new_untitled(path, type=type, ext=ext) else: await self._new_untitled(path) @web.authenticated @authorized async def put(self, path=""): """Saves the file in the location specified by name and path. PUT is very similar to POST, but the requester specifies the name, whereas with POST, the server picks the name. PUT /api/contents/path/Name.ipynb Save notebook at ``path/Name.ipynb``. Notebook structure is specified in `content` key of JSON request body. If content is not specified, create a new empty notebook. """ model = self.get_json_body() cm = self.contents_manager if model: if model.get("copy_from"): raise web.HTTPError(400, "Cannot copy with PUT, only POST") if not cm.allow_hidden and ( (model.get("path") and await ensure_async(cm.is_hidden(model.get("path")))) or await ensure_async(cm.is_hidden(path)) ): raise web.HTTPError(400, f"Cannot create file or directory {path!r}") exists = await ensure_async(self.contents_manager.file_exists(path)) if model.get("type", "") not in {None, "", "directory", "file", "notebook"}: # fall back to file if unknown type model["type"] = "file" if exists: await self._save(model, path) else: await self._upload(model, path) else: await self._new_untitled(path) @web.authenticated @authorized async def delete(self, path=""): """delete a file in the given path""" cm = self.contents_manager if not cm.allow_hidden and await ensure_async(cm.is_hidden(path)): raise web.HTTPError(400, f"Cannot delete file or directory {path!r}") self.log.warning("delete %s", path) await ensure_async(cm.delete(path)) self.set_status(204) self.finish() class CheckpointsHandler(ContentsAPIHandler): """A checkpoints API handler.""" @web.authenticated @authorized async def get(self, path=""): """get lists checkpoints for a file""" cm = self.contents_manager checkpoints = await ensure_async(cm.list_checkpoints(path)) data = json.dumps(checkpoints, default=json_default) self.finish(data) @web.authenticated @authorized async def post(self, path=""): """post creates a new checkpoint""" cm = self.contents_manager checkpoint = await ensure_async(cm.create_checkpoint(path)) data = json.dumps(checkpoint, default=json_default) location = url_path_join( self.base_url, "api/contents", url_escape(path), "checkpoints", url_escape(checkpoint["id"]), ) self.set_header("Location", location) self.set_status(201) self.finish(data) class ModifyCheckpointsHandler(ContentsAPIHandler): """A checkpoints modification handler.""" @web.authenticated @authorized async def post(self, path, checkpoint_id): """post restores a file from a checkpoint""" cm = self.contents_manager await ensure_async(cm.restore_checkpoint(checkpoint_id, path)) self.set_status(204) self.finish() @web.authenticated @authorized async def delete(self, path, checkpoint_id): """delete clears a checkpoint for a given file""" cm = self.contents_manager await ensure_async(cm.delete_checkpoint(checkpoint_id, path)) self.set_status(204) self.finish() class NotebooksRedirectHandler(JupyterHandler): """Redirect /api/notebooks to /api/contents""" SUPPORTED_METHODS = ( "GET", "PUT", "PATCH", "POST", "DELETE", ) # type:ignore[assignment] @allow_unauthenticated def get(self, path): """Handle a notebooks redirect.""" self.log.warning("/api/notebooks is deprecated, use /api/contents") self.redirect(url_path_join(self.base_url, "api/contents", url_escape(path))) put = patch = post = delete = get class TrustNotebooksHandler(JupyterHandler): """Handles trust/signing of notebooks""" @web.authenticated # type:ignore[misc] @authorized(resource=AUTH_RESOURCE) async def post(self, path=""): """Trust a notebook by path.""" cm = self.contents_manager await ensure_async(cm.trust_notebook(path)) self.set_status(201) self.finish() # ----------------------------------------------------------------------------- # URL to handler mappings # ----------------------------------------------------------------------------- _checkpoint_id_regex = r"(?P[\w-]+)" default_handlers = [ (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler), ( rf"/api/contents{path_regex}/checkpoints/{_checkpoint_id_regex}", ModifyCheckpointsHandler, ), (r"/api/contents%s/trust" % path_regex, TrustNotebooksHandler), (r"/api/contents%s" % path_regex, ContentsHandler), (r"/api/notebooks/?(.*)", NotebooksRedirectHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/contents/largefilemanager.py000066400000000000000000000134601473126534200327260ustar00rootroot00000000000000import base64 import os from anyio.to_thread import run_sync from tornado import web from jupyter_server.services.contents.filemanager import ( AsyncFileContentsManager, FileContentsManager, ) class LargeFileManager(FileContentsManager): """Handle large file upload.""" def save(self, model, path=""): """Save the file model and return the model with no content.""" chunk = model.get("chunk", None) if chunk is not None: path = path.strip("/") if chunk == 1: self.run_pre_save_hooks(model=model, path=path) if "type" not in model: raise web.HTTPError(400, "No file type provided") if model["type"] != "file": raise web.HTTPError( 400, 'File type "{}" is not supported for large file transfer'.format(model["type"]), ) if "content" not in model and model["type"] != "directory": raise web.HTTPError(400, "No file content provided") os_path = self._get_os_path(path) if chunk == -1: self.log.debug(f"Saving last chunk of file {os_path}") else: self.log.debug(f"Saving chunk {chunk} of file {os_path}") try: if chunk == 1: super()._save_file(os_path, model["content"], model.get("format")) else: self._save_large_file(os_path, model["content"], model.get("format")) except web.HTTPError: raise except Exception as e: self.log.error("Error while saving file: %s %s", path, e, exc_info=True) raise web.HTTPError(500, f"Unexpected error while saving file: {path} {e}") from e model = self.get(path, content=False) # Last chunk if chunk == -1: self.run_post_save_hooks(model=model, os_path=os_path) self.emit(data={"action": "save", "path": path}) return model else: return super().save(model, path) def _save_large_file(self, os_path, content, format): """Save content of a generic file.""" if format not in {"text", "base64"}: raise web.HTTPError( 400, "Must specify format of file contents as 'text' or 'base64'", ) try: if format == "text": bcontent = content.encode("utf8") else: b64_bytes = content.encode("ascii") bcontent = base64.b64decode(b64_bytes) except Exception as e: raise web.HTTPError(400, f"Encoding error saving {os_path}: {e}") from e with self.perm_to_403(os_path): if os.path.islink(os_path): os_path = os.path.join(os.path.dirname(os_path), os.readlink(os_path)) with open(os_path, "ab") as f: f.write(bcontent) class AsyncLargeFileManager(AsyncFileContentsManager): """Handle large file upload asynchronously""" async def save(self, model, path=""): """Save the file model and return the model with no content.""" chunk = model.get("chunk", None) if chunk is not None: path = path.strip("/") if chunk == 1: self.run_pre_save_hooks(model=model, path=path) if "type" not in model: raise web.HTTPError(400, "No file type provided") if model["type"] != "file": raise web.HTTPError( 400, 'File type "{}" is not supported for large file transfer'.format(model["type"]), ) if "content" not in model and model["type"] != "directory": raise web.HTTPError(400, "No file content provided") os_path = self._get_os_path(path) if chunk == -1: self.log.debug(f"Saving last chunk of file {os_path}") else: self.log.debug(f"Saving chunk {chunk} of file {os_path}") try: if chunk == 1: await super()._save_file(os_path, model["content"], model.get("format")) else: await self._save_large_file(os_path, model["content"], model.get("format")) except web.HTTPError: raise except Exception as e: self.log.error("Error while saving file: %s %s", path, e, exc_info=True) raise web.HTTPError(500, f"Unexpected error while saving file: {path} {e}") from e model = await self.get(path, content=False) # Last chunk if chunk == -1: self.run_post_save_hooks(model=model, os_path=os_path) self.emit(data={"action": "save", "path": path}) return model else: return await super().save(model, path) async def _save_large_file(self, os_path, content, format): """Save content of a generic file.""" if format not in {"text", "base64"}: raise web.HTTPError( 400, "Must specify format of file contents as 'text' or 'base64'", ) try: if format == "text": bcontent = content.encode("utf8") else: b64_bytes = content.encode("ascii") bcontent = base64.b64decode(b64_bytes) except Exception as e: raise web.HTTPError(400, f"Encoding error saving {os_path}: {e}") from e with self.perm_to_403(os_path): if os.path.islink(os_path): os_path = os.path.join(os.path.dirname(os_path), os.readlink(os_path)) with open(os_path, "ab") as f: # noqa: ASYNC101 await run_sync(f.write, bcontent) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/contents/manager.py000066400000000000000000001066371473126534200310640ustar00rootroot00000000000000"""A base class for contents managers.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import itertools import json import os import re import typing as t import warnings from fnmatch import fnmatch from jupyter_core.utils import ensure_async, run_sync from jupyter_events import EventLogger from nbformat import ValidationError, sign from nbformat import validate as validate_nb from nbformat.v4 import new_notebook from tornado.web import HTTPError, RequestHandler from traitlets import ( Any, Bool, Dict, Instance, List, TraitError, Type, Unicode, default, validate, ) from traitlets.config.configurable import LoggingConfigurable from jupyter_server import DEFAULT_EVENTS_SCHEMA_PATH, JUPYTER_SERVER_EVENTS_URI from jupyter_server.transutils import _i18n from jupyter_server.utils import import_item from ...files.handlers import FilesHandler from .checkpoints import AsyncCheckpoints, Checkpoints copy_pat = re.compile(r"\-Copy\d*\.") class ContentsManager(LoggingConfigurable): """Base class for serving files and directories. This serves any text or binary file, as well as directories, with special handling for JSON notebook documents. Most APIs take a path argument, which is always an API-style unicode path, and always refers to a directory. - unicode, not url-escaped - '/'-separated - leading and trailing '/' will be stripped - if unspecified, path defaults to '', indicating the root path. """ event_schema_id = JUPYTER_SERVER_EVENTS_URI + "/contents_service/v1" event_logger = Instance(EventLogger).tag(config=True) @default("event_logger") def _default_event_logger(self): if self.parent and hasattr(self.parent, "event_logger"): return self.parent.event_logger else: # If parent does not have an event logger, create one. logger = EventLogger() schema_path = DEFAULT_EVENTS_SCHEMA_PATH / "contents_service" / "v1.yaml" logger.register_event_schema(schema_path) return logger def emit(self, data): """Emit event using the core event schema from Jupyter Server's Contents Manager.""" self.event_logger.emit(schema_id=self.event_schema_id, data=data) root_dir = Unicode("/", config=True) preferred_dir = Unicode( "", config=True, help=_i18n( "Preferred starting directory to use for notebooks. This is an API path (`/` separated, relative to root dir)" ), ) @validate("preferred_dir") def _validate_preferred_dir(self, proposal): value = proposal["value"].strip("/") try: import inspect if inspect.iscoroutinefunction(self.dir_exists): dir_exists = run_sync(self.dir_exists)(value) else: dir_exists = self.dir_exists(value) except HTTPError as e: raise TraitError(e.log_message) from e if not dir_exists: raise TraitError(_i18n("Preferred directory not found: %r") % value) if self.parent: try: if value != self.parent.preferred_dir: self.parent.preferred_dir = os.path.join(self.root_dir, *value.split("/")) except TraitError: pass return value allow_hidden = Bool(False, config=True, help="Allow access to hidden files") notary = Instance(sign.NotebookNotary) @default("notary") def _notary_default(self): return sign.NotebookNotary(parent=self) hide_globs = List( Unicode(), [ "__pycache__", "*.pyc", "*.pyo", ".DS_Store", "*.so", "*.dylib", "*~", ], config=True, help=""" Glob patterns to hide in file and directory listings. """, ) untitled_notebook = Unicode( _i18n("Untitled"), config=True, help="The base name used when creating untitled notebooks.", ) untitled_file = Unicode( "untitled", config=True, help="The base name used when creating untitled files." ) untitled_directory = Unicode( "Untitled Folder", config=True, help="The base name used when creating untitled directories.", ) pre_save_hook = Any( None, config=True, allow_none=True, help="""Python callable or importstring thereof To be called on a contents model prior to save. This can be used to process the structure, such as removing notebook outputs or other side effects that should not be saved. It will be called as (all arguments passed by keyword):: hook(path=path, model=model, contents_manager=self) - model: the model to be saved. Includes file contents. Modifying this dict will affect the file that is stored. - path: the API path of the save destination - contents_manager: this ContentsManager instance """, ) @validate("pre_save_hook") def _validate_pre_save_hook(self, proposal): value = proposal["value"] if isinstance(value, str): value = import_item(self.pre_save_hook) if not callable(value): msg = "pre_save_hook must be callable" raise TraitError(msg) if callable(self.pre_save_hook): warnings.warn( f"Overriding existing pre_save_hook ({self.pre_save_hook.__name__}) with a new one ({value.__name__}).", stacklevel=2, ) return value post_save_hook = Any( None, config=True, allow_none=True, help="""Python callable or importstring thereof to be called on the path of a file just saved. This can be used to process the file on disk, such as converting the notebook to a script or HTML via nbconvert. It will be called as (all arguments passed by keyword):: hook(os_path=os_path, model=model, contents_manager=instance) - path: the filesystem path to the file just written - model: the model representing the file - contents_manager: this ContentsManager instance """, ) @validate("post_save_hook") def _validate_post_save_hook(self, proposal): value = proposal["value"] if isinstance(value, str): value = import_item(value) if not callable(value): msg = "post_save_hook must be callable" raise TraitError(msg) if callable(self.post_save_hook): warnings.warn( f"Overriding existing post_save_hook ({self.post_save_hook.__name__}) with a new one ({value.__name__}).", stacklevel=2, ) return value def run_pre_save_hook(self, model, path, **kwargs): """Run the pre-save hook if defined, and log errors""" warnings.warn( "run_pre_save_hook is deprecated, use run_pre_save_hooks instead.", DeprecationWarning, stacklevel=2, ) if self.pre_save_hook: try: self.log.debug("Running pre-save hook on %s", path) self.pre_save_hook(model=model, path=path, contents_manager=self, **kwargs) except HTTPError: # allow custom HTTPErrors to raise, # rejecting the save with a message. raise except Exception: # unhandled errors don't prevent saving, # which could cause frustrating data loss self.log.error("Pre-save hook failed on %s", path, exc_info=True) def run_post_save_hook(self, model, os_path): """Run the post-save hook if defined, and log errors""" warnings.warn( "run_post_save_hook is deprecated, use run_post_save_hooks instead.", DeprecationWarning, stacklevel=2, ) if self.post_save_hook: try: self.log.debug("Running post-save hook on %s", os_path) self.post_save_hook(os_path=os_path, model=model, contents_manager=self) except Exception: self.log.error("Post-save hook failed o-n %s", os_path, exc_info=True) msg = "fUnexpected error while running post hook save: {e}" raise HTTPError(500, msg) from None _pre_save_hooks: List[t.Any] = List() _post_save_hooks: List[t.Any] = List() def register_pre_save_hook(self, hook): """Register a pre save hook.""" if isinstance(hook, str): hook = import_item(hook) if not callable(hook): msg = "hook must be callable" raise RuntimeError(msg) self._pre_save_hooks.append(hook) def register_post_save_hook(self, hook): """Register a post save hook.""" if isinstance(hook, str): hook = import_item(hook) if not callable(hook): msg = "hook must be callable" raise RuntimeError(msg) self._post_save_hooks.append(hook) def run_pre_save_hooks(self, model, path, **kwargs): """Run the pre-save hooks if any, and log errors""" pre_save_hooks = [self.pre_save_hook] if self.pre_save_hook is not None else [] pre_save_hooks += self._pre_save_hooks for pre_save_hook in pre_save_hooks: try: self.log.debug("Running pre-save hook on %s", path) pre_save_hook(model=model, path=path, contents_manager=self, **kwargs) except HTTPError: # allow custom HTTPErrors to raise, # rejecting the save with a message. raise except Exception: # unhandled errors don't prevent saving, # which could cause frustrating data loss self.log.error( "Pre-save hook %s failed on %s", pre_save_hook.__name__, path, exc_info=True, ) def run_post_save_hooks(self, model, os_path): """Run the post-save hooks if any, and log errors""" post_save_hooks = [self.post_save_hook] if self.post_save_hook is not None else [] post_save_hooks += self._post_save_hooks for post_save_hook in post_save_hooks: try: self.log.debug("Running post-save hook on %s", os_path) post_save_hook(os_path=os_path, model=model, contents_manager=self) except Exception as e: self.log.error( "Post-save %s hook failed on %s", post_save_hook.__name__, os_path, exc_info=True, ) raise HTTPError(500, "Unexpected error while running post hook save: %s" % e) from e checkpoints_class = Type(Checkpoints, config=True) checkpoints = Instance(Checkpoints, config=True) checkpoints_kwargs = Dict(config=True) @default("checkpoints") def _default_checkpoints(self): return self.checkpoints_class(**self.checkpoints_kwargs) @default("checkpoints_kwargs") def _default_checkpoints_kwargs(self): return { "parent": self, "log": self.log, } files_handler_class = Type( FilesHandler, klass=RequestHandler, allow_none=True, config=True, help="""handler class to use when serving raw file requests. Default is a fallback that talks to the ContentsManager API, which may be inefficient, especially for large files. Local files-based ContentsManagers can use a StaticFileHandler subclass, which will be much more efficient. Access to these files should be Authenticated. """, ) files_handler_params = Dict( config=True, help="""Extra parameters to pass to files_handler_class. For example, StaticFileHandlers generally expect a `path` argument specifying the root directory from which to serve files. """, ) def get_extra_handlers(self): """Return additional handlers Default: self.files_handler_class on /files/.* """ handlers = [] if self.files_handler_class: handlers.append((r"/files/(.*)", self.files_handler_class, self.files_handler_params)) return handlers # ContentsManager API part 1: methods that must be # implemented in subclasses. def dir_exists(self, path): """Does a directory exist at the given path? Like os.path.isdir Override this method in subclasses. Parameters ---------- path : str The path to check Returns ------- exists : bool Whether the path does indeed exist. """ raise NotImplementedError def is_hidden(self, path): """Is path a hidden directory or file? Parameters ---------- path : str The path to check. This is an API path (`/` separated, relative to root dir). Returns ------- hidden : bool Whether the path is hidden. """ raise NotImplementedError def file_exists(self, path=""): """Does a file exist at the given path? Like os.path.isfile Override this method in subclasses. Parameters ---------- path : str The API path of a file to check for. Returns ------- exists : bool Whether the file exists. """ raise NotImplementedError def exists(self, path): """Does a file or directory exist at the given path? Like os.path.exists Parameters ---------- path : str The API path of a file or directory to check for. Returns ------- exists : bool Whether the target exists. """ return self.file_exists(path) or self.dir_exists(path) def get(self, path, content=True, type=None, format=None, require_hash=False): """Get a file or directory model. Parameters ---------- require_hash : bool Whether the file hash must be returned or not. *Changed in version 2.11*: The *require_hash* parameter was added. """ raise NotImplementedError def save(self, model, path): """ Save a file or directory model to path. Should return the saved model with no content. Save implementations should call self.run_pre_save_hook(model=model, path=path) prior to writing any data. """ raise NotImplementedError def delete_file(self, path): """Delete the file or directory at path.""" raise NotImplementedError def rename_file(self, old_path, new_path): """Rename a file or directory.""" raise NotImplementedError # ContentsManager API part 2: methods that have usable default # implementations, but can be overridden in subclasses. def delete(self, path): """Delete a file/directory and any associated checkpoints.""" path = path.strip("/") if not path: raise HTTPError(400, "Can't delete root") self.delete_file(path) self.checkpoints.delete_all_checkpoints(path) self.emit(data={"action": "delete", "path": path}) def rename(self, old_path, new_path): """Rename a file and any checkpoints associated with that file.""" self.rename_file(old_path, new_path) self.checkpoints.rename_all_checkpoints(old_path, new_path) self.emit(data={"action": "rename", "path": new_path, "source_path": old_path}) def update(self, model, path): """Update the file's path For use in PATCH requests, to enable renaming a file without re-uploading its contents. Only used for renaming at the moment. """ path = path.strip("/") new_path = model.get("path", path).strip("/") if path != new_path: self.rename(path, new_path) model = self.get(new_path, content=False) return model def info_string(self): """The information string for the manager.""" return "Serving contents" def get_kernel_path(self, path, model=None): """Return the API path for the kernel KernelManagers can turn this value into a filesystem path, or ignore it altogether. The default value here will start kernels in the directory of the notebook server. FileContentsManager overrides this to use the directory containing the notebook. """ return "" def increment_filename(self, filename, path="", insert=""): """Increment a filename until it is unique. Parameters ---------- filename : unicode The name of a file, including extension path : unicode The API path of the target's directory insert : unicode The characters to insert after the base filename Returns ------- name : unicode A filename that is unique, based on the input filename. """ # Extract the full suffix from the filename (e.g. .tar.gz) path = path.strip("/") basename, dot, ext = filename.rpartition(".") if ext != "ipynb": basename, dot, ext = filename.partition(".") suffix = dot + ext for i in itertools.count(): insert_i = f"{insert}{i}" if i else "" name = f"{basename}{insert_i}{suffix}" if not self.exists(f"{path}/{name}"): break return name def validate_notebook_model(self, model, validation_error=None): """Add failed-validation message to model""" try: # If we're given a validation_error dictionary, extract the exception # from it and raise the exception, else call nbformat's validate method # to determine if the notebook is valid. This 'else' condition may # pertain to server extension not using the server's notebook read/write # functions. if validation_error is not None: e = validation_error.get("ValidationError") if isinstance(e, ValidationError): raise e else: validate_nb(model["content"]) except ValidationError as e: model["message"] = "Notebook validation failed: {}:\n{}".format( str(e), json.dumps(e.instance, indent=1, default=lambda obj: ""), ) return model def new_untitled(self, path="", type="", ext=""): """Create a new untitled file or directory in path path must be a directory File extension can be specified. Use `new` to create files with a fully specified path (including filename). """ path = path.strip("/") if not self.dir_exists(path): raise HTTPError(404, "No such directory: %s" % path) model = {} if type: model["type"] = type if ext == ".ipynb": model.setdefault("type", "notebook") else: model.setdefault("type", "file") insert = "" if model["type"] == "directory": untitled = self.untitled_directory insert = " " elif model["type"] == "notebook": untitled = self.untitled_notebook ext = ".ipynb" elif model["type"] == "file": untitled = self.untitled_file else: raise HTTPError(400, "Unexpected model type: %r" % model["type"]) name = self.increment_filename(untitled + ext, path, insert=insert) path = f"{path}/{name}" return self.new(model, path) def new(self, model=None, path=""): """Create a new file or directory and return its model with no content. To create a new untitled entity in a directory, use `new_untitled`. """ path = path.strip("/") if model is None: model = {} if path.endswith(".ipynb"): model.setdefault("type", "notebook") else: model.setdefault("type", "file") # no content, not a directory, so fill out new-file model if "content" not in model and model["type"] != "directory": if model["type"] == "notebook": model["content"] = new_notebook() model["format"] = "json" else: model["content"] = "" model["type"] = "file" model["format"] = "text" model = self.save(model, path) return model def copy(self, from_path, to_path=None): """Copy an existing file and return its new model. If to_path not specified, it will be the parent directory of from_path. If to_path is a directory, filename will increment `from_path-Copy#.ext`. Considering multi-part extensions, the Copy# part will be placed before the first dot for all the extensions except `ipynb`. For easier manual searching in case of notebooks, the Copy# part will be placed before the last dot. from_path must be a full path to a file. """ path = from_path.strip("/") if to_path is not None: to_path = to_path.strip("/") if "/" in path: from_dir, from_name = path.rsplit("/", 1) else: from_dir = "" from_name = path model = self.get(path) model.pop("path", None) model.pop("name", None) if model["type"] == "directory": raise HTTPError(400, "Can't copy directories") is_destination_specified = to_path is not None if not is_destination_specified: to_path = from_dir if self.dir_exists(to_path): name = copy_pat.sub(".", from_name) to_name = self.increment_filename(name, to_path, insert="-Copy") to_path = f"{to_path}/{to_name}" elif is_destination_specified: if "/" in to_path: to_dir, to_name = to_path.rsplit("/", 1) if not self.dir_exists(to_dir): raise HTTPError(404, "No such parent directory: %s to copy file in" % to_dir) else: raise HTTPError(404, "No such directory: %s" % to_path) model = self.save(model, to_path) self.emit(data={"action": "copy", "path": to_path, "source_path": from_path}) return model def log_info(self): """Log the information string for the manager.""" self.log.info(self.info_string()) def trust_notebook(self, path): """Explicitly trust a notebook Parameters ---------- path : str The path of a notebook """ model = self.get(path) nb = model["content"] self.log.warning("Trusting notebook %s", path) self.notary.mark_cells(nb, True) self.check_and_sign(nb, path) def check_and_sign(self, nb, path=""): """Check for trusted cells, and sign the notebook. Called as a part of saving notebooks. Parameters ---------- nb : dict The notebook dict path : str The notebook's path (for logging) """ if self.notary.check_cells(nb): self.notary.sign(nb) else: self.log.warning("Notebook %s is not trusted", path) def mark_trusted_cells(self, nb, path=""): """Mark cells as trusted if the notebook signature matches. Called as a part of loading notebooks. Parameters ---------- nb : dict The notebook object (in current nbformat) path : str The notebook's path (for logging) """ trusted = self.notary.check_signature(nb) if not trusted: self.log.warning("Notebook %s is not trusted", path) self.notary.mark_cells(nb, trusted) def should_list(self, name): """Should this file/directory name be displayed in a listing?""" return not any(fnmatch(name, glob) for glob in self.hide_globs) # Part 3: Checkpoints API def create_checkpoint(self, path): """Create a checkpoint.""" return self.checkpoints.create_checkpoint(self, path) def restore_checkpoint(self, checkpoint_id, path): """ Restore a checkpoint. """ self.checkpoints.restore_checkpoint(self, checkpoint_id, path) def list_checkpoints(self, path): return self.checkpoints.list_checkpoints(path) def delete_checkpoint(self, checkpoint_id, path): return self.checkpoints.delete_checkpoint(checkpoint_id, path) class AsyncContentsManager(ContentsManager): """Base class for serving files and directories asynchronously.""" checkpoints_class = Type(AsyncCheckpoints, config=True) checkpoints = Instance(AsyncCheckpoints, config=True) checkpoints_kwargs = Dict(config=True) @default("checkpoints") def _default_checkpoints(self): return self.checkpoints_class(**self.checkpoints_kwargs) @default("checkpoints_kwargs") def _default_checkpoints_kwargs(self): return { "parent": self, "log": self.log, } # ContentsManager API part 1: methods that must be # implemented in subclasses. async def dir_exists(self, path): """Does a directory exist at the given path? Like os.path.isdir Override this method in subclasses. Parameters ---------- path : str The path to check Returns ------- exists : bool Whether the path does indeed exist. """ raise NotImplementedError async def is_hidden(self, path): """Is path a hidden directory or file? Parameters ---------- path : str The path to check. This is an API path (`/` separated, relative to root dir). Returns ------- hidden : bool Whether the path is hidden. """ raise NotImplementedError async def file_exists(self, path=""): """Does a file exist at the given path? Like os.path.isfile Override this method in subclasses. Parameters ---------- path : str The API path of a file to check for. Returns ------- exists : bool Whether the file exists. """ raise NotImplementedError async def exists(self, path): """Does a file or directory exist at the given path? Like os.path.exists Parameters ---------- path : str The API path of a file or directory to check for. Returns ------- exists : bool Whether the target exists. """ return await ensure_async(self.file_exists(path)) or await ensure_async( self.dir_exists(path) ) async def get(self, path, content=True, type=None, format=None, require_hash=False): """Get a file or directory model. Parameters ---------- require_hash : bool Whether the file hash must be returned or not. *Changed in version 2.11*: The *require_hash* parameter was added. """ raise NotImplementedError async def save(self, model, path): """ Save a file or directory model to path. Should return the saved model with no content. Save implementations should call self.run_pre_save_hook(model=model, path=path) prior to writing any data. """ raise NotImplementedError async def delete_file(self, path): """Delete the file or directory at path.""" raise NotImplementedError async def rename_file(self, old_path, new_path): """Rename a file or directory.""" raise NotImplementedError # ContentsManager API part 2: methods that have usable default # implementations, but can be overridden in subclasses. async def delete(self, path): """Delete a file/directory and any associated checkpoints.""" path = path.strip("/") if not path: raise HTTPError(400, "Can't delete root") await self.delete_file(path) await self.checkpoints.delete_all_checkpoints(path) self.emit(data={"action": "delete", "path": path}) async def rename(self, old_path, new_path): """Rename a file and any checkpoints associated with that file.""" await self.rename_file(old_path, new_path) await self.checkpoints.rename_all_checkpoints(old_path, new_path) self.emit(data={"action": "rename", "path": new_path, "source_path": old_path}) async def update(self, model, path): """Update the file's path For use in PATCH requests, to enable renaming a file without re-uploading its contents. Only used for renaming at the moment. """ path = path.strip("/") new_path = model.get("path", path).strip("/") if path != new_path: await self.rename(path, new_path) model = await self.get(new_path, content=False) return model async def increment_filename(self, filename, path="", insert=""): """Increment a filename until it is unique. Parameters ---------- filename : unicode The name of a file, including extension path : unicode The API path of the target's directory insert : unicode The characters to insert after the base filename Returns ------- name : unicode A filename that is unique, based on the input filename. """ # Extract the full suffix from the filename (e.g. .tar.gz) path = path.strip("/") basename, dot, ext = filename.rpartition(".") if ext != "ipynb": basename, dot, ext = filename.partition(".") suffix = dot + ext for i in itertools.count(): insert_i = f"{insert}{i}" if i else "" name = f"{basename}{insert_i}{suffix}" file_exists = await ensure_async(self.exists(f"{path}/{name}")) if not file_exists: break return name async def new_untitled(self, path="", type="", ext=""): """Create a new untitled file or directory in path path must be a directory File extension can be specified. Use `new` to create files with a fully specified path (including filename). """ path = path.strip("/") dir_exists = await ensure_async(self.dir_exists(path)) if not dir_exists: raise HTTPError(404, "No such directory: %s" % path) model = {} if type: model["type"] = type if ext == ".ipynb": model.setdefault("type", "notebook") else: model.setdefault("type", "file") insert = "" if model["type"] == "directory": untitled = self.untitled_directory insert = " " elif model["type"] == "notebook": untitled = self.untitled_notebook ext = ".ipynb" elif model["type"] == "file": untitled = self.untitled_file else: raise HTTPError(400, "Unexpected model type: %r" % model["type"]) name = await self.increment_filename(untitled + ext, path, insert=insert) path = f"{path}/{name}" return await self.new(model, path) async def new(self, model=None, path=""): """Create a new file or directory and return its model with no content. To create a new untitled entity in a directory, use `new_untitled`. """ path = path.strip("/") if model is None: model = {} if path.endswith(".ipynb"): model.setdefault("type", "notebook") else: model.setdefault("type", "file") # no content, not a directory, so fill out new-file model if "content" not in model and model["type"] != "directory": if model["type"] == "notebook": model["content"] = new_notebook() model["format"] = "json" else: model["content"] = "" model["type"] = "file" model["format"] = "text" model = await self.save(model, path) return model async def copy(self, from_path, to_path=None): """Copy an existing file and return its new model. If to_path not specified, it will be the parent directory of from_path. If to_path is a directory, filename will increment `from_path-Copy#.ext`. Considering multi-part extensions, the Copy# part will be placed before the first dot for all the extensions except `ipynb`. For easier manual searching in case of notebooks, the Copy# part will be placed before the last dot. from_path must be a full path to a file. """ path = from_path.strip("/") if to_path is not None: to_path = to_path.strip("/") if "/" in path: from_dir, from_name = path.rsplit("/", 1) else: from_dir = "" from_name = path model = await self.get(path) model.pop("path", None) model.pop("name", None) if model["type"] == "directory": raise HTTPError(400, "Can't copy directories") is_destination_specified = to_path is not None if not is_destination_specified: to_path = from_dir if await ensure_async(self.dir_exists(to_path)): name = copy_pat.sub(".", from_name) to_name = await self.increment_filename(name, to_path, insert="-Copy") to_path = f"{to_path}/{to_name}" elif is_destination_specified: if "/" in to_path: to_dir, to_name = to_path.rsplit("/", 1) if not await ensure_async(self.dir_exists(to_dir)): raise HTTPError(404, "No such parent directory: %s to copy file in" % to_dir) else: raise HTTPError(404, "No such directory: %s" % to_path) model = await self.save(model, to_path) self.emit(data={"action": "copy", "path": to_path, "source_path": from_path}) return model async def trust_notebook(self, path): """Explicitly trust a notebook Parameters ---------- path : str The path of a notebook """ model = await self.get(path) nb = model["content"] self.log.warning("Trusting notebook %s", path) self.notary.mark_cells(nb, True) self.check_and_sign(nb, path) # Part 3: Checkpoints API async def create_checkpoint(self, path): """Create a checkpoint.""" return await self.checkpoints.create_checkpoint(self, path) async def restore_checkpoint(self, checkpoint_id, path): """ Restore a checkpoint. """ await self.checkpoints.restore_checkpoint(self, checkpoint_id, path) async def list_checkpoints(self, path): """List the checkpoints for a path.""" return await self.checkpoints.list_checkpoints(path) async def delete_checkpoint(self, checkpoint_id, path): """Delete a checkpoint for a path by id.""" return await self.checkpoints.delete_checkpoint(checkpoint_id, path) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/events/000077500000000000000000000000001473126534200265325ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/events/__init__.py000066400000000000000000000000001473126534200306310ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/events/handlers.py000066400000000000000000000107051473126534200307070ustar00rootroot00000000000000"""A Websocket Handler for emitting Jupyter server events. .. versionadded:: 2.0 """ from __future__ import annotations import json from datetime import datetime from typing import TYPE_CHECKING, Any, Optional, cast from jupyter_core.utils import ensure_async from tornado import web, websocket from jupyter_server.auth.decorator import authorized, ws_authenticated from jupyter_server.base.handlers import JupyterHandler from ...base.handlers import APIHandler AUTH_RESOURCE = "events" if TYPE_CHECKING: import jupyter_events.logger class SubscribeWebsocket( JupyterHandler, websocket.WebSocketHandler, ): """Websocket handler for subscribing to events""" auth_resource = AUTH_RESOURCE async def pre_get(self): """Handles authorization when attempting to subscribe to events emitted by Jupyter Server's eventbus. """ user = self.current_user # authorize the user. authorized = await ensure_async( self.authorizer.is_authorized(self, user, "execute", "events") ) if not authorized: raise web.HTTPError(403) @ws_authenticated async def get(self, *args, **kwargs): """Get an event socket.""" await ensure_async(self.pre_get()) res = super().get(*args, **kwargs) if res is not None: await res async def event_listener( self, logger: jupyter_events.logger.EventLogger, schema_id: str, data: dict[str, Any] ) -> None: """Write an event message.""" capsule = dict(schema_id=schema_id, **data) self.write_message(json.dumps(capsule)) def open(self): """Routes events that are emitted by Jupyter Server's EventBus to a WebSocket client in the browser. """ self.event_logger.add_listener(listener=self.event_listener) def on_close(self): """Handle a socket close.""" self.event_logger.remove_listener(listener=self.event_listener) def validate_model( data: dict[str, Any], registry: jupyter_events.schema_registry.SchemaRegistry ) -> None: """Validates for required fields in the JSON request body and verifies that a registered schema/version exists""" required_keys = {"schema_id", "version", "data"} for key in required_keys: if key not in data: message = f"Missing `{key}` in the JSON request body." raise Exception(message) schema_id = cast(str, data.get("schema_id")) # The case where a given schema_id isn't found, # jupyter_events raises a useful error, so there's no need to # handle that case here. schema = registry.get(schema_id) version = str(cast(str, data.get("version"))) if schema.version != version: message = f"Unregistered version: {version!r}≠{schema.version!r} for `{schema_id}`" raise Exception(message) def get_timestamp(data: dict[str, Any]) -> Optional[datetime]: """Parses timestamp from the JSON request body""" try: if "timestamp" in data: timestamp = datetime.strptime(data["timestamp"], "%Y-%m-%dT%H:%M:%S%zZ") else: timestamp = None except Exception as e: raise web.HTTPError( 400, """Failed to parse timestamp from JSON request body, an ISO format datetime string with UTC offset is expected, for example, 2022-05-26T13:50:00+05:00Z""", ) from e return timestamp class EventHandler(APIHandler): """REST api handler for events""" auth_resource = AUTH_RESOURCE @web.authenticated @authorized async def post(self): """Emit an event.""" payload = self.get_json_body() if payload is None: raise web.HTTPError(400, "No JSON data provided") try: validate_model(payload, self.event_logger.schemas) self.event_logger.emit( schema_id=cast(str, payload.get("schema_id")), data=cast("dict[str, Any]", payload.get("data")), timestamp_override=get_timestamp(payload), ) self.set_status(204) self.finish() except Exception as e: # All known exceptions are raised by bad requests, e.g., bad # version, unregistered schema, invalid emission data payload, etc. raise web.HTTPError(400, str(e)) from e default_handlers = [ (r"/api/events", EventHandler), (r"/api/events/subscribe", SubscribeWebsocket), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernels/000077500000000000000000000000001473126534200266715ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernels/__init__.py000066400000000000000000000000001473126534200307700ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernels/connection/000077500000000000000000000000001473126534200310305ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernels/connection/__init__.py000066400000000000000000000000001473126534200331270ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernels/connection/abc.py000066400000000000000000000016311473126534200321300ustar00rootroot00000000000000from abc import ABC, abstractmethod from typing import Any class KernelWebsocketConnectionABC(ABC): """ This class defines a minimal interface that should be used to bridge the connection between Jupyter Server's websocket API and a kernel's ZMQ socket interface. """ websocket_handler: Any @abstractmethod async def connect(self): """Connect the kernel websocket to the kernel ZMQ connections""" @abstractmethod async def disconnect(self): """Disconnect the kernel websocket from the kernel ZMQ connections""" @abstractmethod def handle_incoming_message(self, incoming_msg: str) -> None: """Broker the incoming websocket message to the appropriate ZMQ channel.""" @abstractmethod def handle_outgoing_message(self, stream: str, outgoing_msg: list[Any]) -> None: """Broker outgoing ZMQ messages to the kernel websocket.""" jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernels/connection/base.py000066400000000000000000000126501473126534200323200ustar00rootroot00000000000000"""Kernel connection helpers.""" import json import struct from typing import Any from jupyter_client.session import Session from tornado.websocket import WebSocketHandler from traitlets import Float, Instance, Unicode, default from traitlets.config import LoggingConfigurable try: from jupyter_client.jsonutil import json_default except ImportError: from jupyter_client.jsonutil import date_default as json_default from jupyter_client.jsonutil import extract_dates from jupyter_server.transutils import _i18n from .abc import KernelWebsocketConnectionABC def serialize_binary_message(msg): """serialize a message as a binary blob Header: 4 bytes: number of msg parts (nbufs) as 32b int 4 * nbufs bytes: offset for each buffer as integer as 32b int Offsets are from the start of the buffer, including the header. Returns ------- The message serialized to bytes. """ # don't modify msg or buffer list in-place msg = msg.copy() buffers = list(msg.pop("buffers")) bmsg = json.dumps(msg, default=json_default).encode("utf8") buffers.insert(0, bmsg) nbufs = len(buffers) offsets = [4 * (nbufs + 1)] for buf in buffers[:-1]: offsets.append(offsets[-1] + len(buf)) offsets_buf = struct.pack("!" + "I" * (nbufs + 1), nbufs, *offsets) buffers.insert(0, offsets_buf) return b"".join(buffers) def deserialize_binary_message(bmsg): """deserialize a message from a binary blog Header: 4 bytes: number of msg parts (nbufs) as 32b int 4 * nbufs bytes: offset for each buffer as integer as 32b int Offsets are from the start of the buffer, including the header. Returns ------- message dictionary """ nbufs = struct.unpack("!i", bmsg[:4])[0] offsets = list(struct.unpack("!" + "I" * nbufs, bmsg[4 : 4 * (nbufs + 1)])) offsets.append(None) bufs = [] for start, stop in zip(offsets[:-1], offsets[1:]): bufs.append(bmsg[start:stop]) msg = json.loads(bufs[0].decode("utf8")) msg["header"] = extract_dates(msg["header"]) msg["parent_header"] = extract_dates(msg["parent_header"]) msg["buffers"] = bufs[1:] return msg def serialize_msg_to_ws_v1(msg_or_list, channel, pack=None): """Serialize a message using the v1 protocol.""" if pack: msg_list = [ pack(msg_or_list["header"]), pack(msg_or_list["parent_header"]), pack(msg_or_list["metadata"]), pack(msg_or_list["content"]), ] else: msg_list = msg_or_list channel = channel.encode("utf-8") offsets: list[Any] = [] offsets.append(8 * (1 + 1 + len(msg_list) + 1)) offsets.append(len(channel) + offsets[-1]) for msg in msg_list: offsets.append(len(msg) + offsets[-1]) offset_number = len(offsets).to_bytes(8, byteorder="little") offsets = [offset.to_bytes(8, byteorder="little") for offset in offsets] bin_msg = b"".join([offset_number, *offsets, channel, *msg_list]) return bin_msg def deserialize_msg_from_ws_v1(ws_msg): """Deserialize a message using the v1 protocol.""" offset_number = int.from_bytes(ws_msg[:8], "little") offsets = [ int.from_bytes(ws_msg[8 * (i + 1) : 8 * (i + 2)], "little") for i in range(offset_number) ] channel = ws_msg[offsets[0] : offsets[1]].decode("utf-8") msg_list = [ws_msg[offsets[i] : offsets[i + 1]] for i in range(1, offset_number - 1)] return channel, msg_list class BaseKernelWebsocketConnection(LoggingConfigurable): """A configurable base class for connecting Kernel WebSockets to ZMQ sockets.""" kernel_ws_protocol = Unicode( None, allow_none=True, config=True, help=_i18n( "Preferred kernel message protocol over websocket to use (default: None). " "If an empty string is passed, select the legacy protocol. If None, " "the selected protocol will depend on what the front-end supports " "(usually the most recent protocol supported by the back-end and the " "front-end)." ), ) @property def kernel_manager(self): """The kernel manager.""" return self.parent @property def multi_kernel_manager(self): """The multi kernel manager.""" return self.kernel_manager.parent @property def kernel_id(self): """The kernel id.""" return self.kernel_manager.kernel_id @property def session_id(self): """The session id.""" return self.session.session kernel_info_timeout = Float() @default("kernel_info_timeout") def _default_kernel_info_timeout(self): return self.multi_kernel_manager.kernel_info_timeout session = Instance(klass=Session, config=True) @default("session") def _default_session(self): return Session(config=self.config) websocket_handler = Instance(WebSocketHandler) async def connect(self): """Handle a connect.""" raise NotImplementedError async def disconnect(self): """Handle a disconnect.""" raise NotImplementedError def handle_incoming_message(self, incoming_msg: str) -> None: """Handle an incoming message.""" raise NotImplementedError def handle_outgoing_message(self, stream: str, outgoing_msg: list[Any]) -> None: """Handle an outgoing message.""" raise NotImplementedError KernelWebsocketConnectionABC.register(BaseKernelWebsocketConnection) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernels/connection/channels.py000066400000000000000000001014341473126534200332000ustar00rootroot00000000000000"""An implementation of a kernel connection.""" from __future__ import annotations import asyncio import json import time import typing as t import weakref from concurrent.futures import Future from textwrap import dedent from jupyter_client import protocol_version as client_protocol_version # type:ignore[attr-defined] from tornado import gen, web from tornado.ioloop import IOLoop from tornado.websocket import WebSocketClosedError from traitlets import Any, Bool, Dict, Float, Instance, Int, List, Unicode, default try: from jupyter_client.jsonutil import json_default except ImportError: from jupyter_client.jsonutil import date_default as json_default from jupyter_core.utils import ensure_async from jupyter_server.transutils import _i18n from ..websocket import KernelWebsocketHandler from .abc import KernelWebsocketConnectionABC from .base import ( BaseKernelWebsocketConnection, deserialize_binary_message, deserialize_msg_from_ws_v1, serialize_binary_message, serialize_msg_to_ws_v1, ) def _ensure_future(f): """Wrap a concurrent future as an asyncio future if there is a running loop.""" try: asyncio.get_running_loop() return asyncio.wrap_future(f) except RuntimeError: return f class ZMQChannelsWebsocketConnection(BaseKernelWebsocketConnection): """A Jupyter Server Websocket Connection""" limit_rate = Bool( True, config=True, help=_i18n( "Whether to limit the rate of IOPub messages (default: True). " "If True, use iopub_msg_rate_limit, iopub_data_rate_limit and/or rate_limit_window " "to tune the rate." ), ) iopub_msg_rate_limit = Float( 1000, config=True, help=_i18n( """(msgs/sec) Maximum rate at which messages can be sent on iopub before they are limited.""" ), ) iopub_data_rate_limit = Float( 1000000, config=True, help=_i18n( """(bytes/sec) Maximum rate at which stream output can be sent on iopub before they are limited.""" ), ) rate_limit_window = Float( 3, config=True, help=_i18n( """(sec) Time window used to check the message and data rate limits.""" ), ) websocket_handler = Instance(KernelWebsocketHandler) @property def write_message(self): """Alias to the websocket handler's write_message method.""" return self.websocket_handler.write_message # class-level registry of open sessions # allows checking for conflict on session-id, # which is used as a zmq identity and must be unique. _open_sessions: dict[str, KernelWebsocketHandler] = {} _open_sockets: t.MutableSet[ZMQChannelsWebsocketConnection] = weakref.WeakSet() _kernel_info_future: Future[t.Any] _close_future: Future[t.Any] channels = Dict({}) kernel_info_channel = Any(allow_none=True) _kernel_info_future = Instance(klass=Future) # type:ignore[assignment] @default("_kernel_info_future") def _default_kernel_info_future(self): """The default kernel info future.""" return Future() _close_future = Instance(klass=Future) # type:ignore[assignment] @default("_close_future") def _default_close_future(self): """The default close future.""" return Future() session_key = Unicode("") _iopub_window_msg_count = Int() _iopub_window_byte_count = Int() _iopub_msgs_exceeded = Bool(False) _iopub_data_exceeded = Bool(False) # Queue of (time stamp, byte count) # Allows you to specify that the byte count should be lowered # by a delta amount at some point in the future. _iopub_window_byte_queue: List[t.Any] = List([]) @classmethod async def close_all(cls): """Tornado does not provide a way to close open sockets, so add one.""" for connection in list(cls._open_sockets): connection.disconnect() await _ensure_future(connection._close_future) @property def subprotocol(self): """The sub protocol.""" try: protocol = self.websocket_handler.selected_subprotocol except Exception: protocol = None return protocol def create_stream(self): """Create a stream.""" identity = self.session.bsession for channel in ("iopub", "shell", "control", "stdin"): meth = getattr(self.kernel_manager, "connect_" + channel) self.channels[channel] = stream = meth(identity=identity) stream.channel = channel def nudge(self): """Nudge the zmq connections with kernel_info_requests Returns a Future that will resolve when we have received a shell or control reply and at least one iopub message, ensuring that zmq subscriptions are established, sockets are fully connected, and kernel is responsive. Keeps retrying kernel_info_request until these are both received. """ # Do not nudge busy kernels as kernel info requests sent to shell are # queued behind execution requests. # nudging in this case would cause a potentially very long wait # before connections are opened, # plus it is *very* unlikely that a busy kernel will not finish # establishing its zmq subscriptions before processing the next request. if getattr(self.kernel_manager, "execution_state", None) == "busy": self.log.debug("Nudge: not nudging busy kernel %s", self.kernel_id) f: Future[t.Any] = Future() f.set_result(None) return _ensure_future(f) # Use a transient shell channel to prevent leaking # shell responses to the front-end. shell_channel = self.kernel_manager.connect_shell() # Use a transient control channel to prevent leaking # control responses to the front-end. control_channel = self.kernel_manager.connect_control() # The IOPub used by the client, whose subscriptions we are verifying. iopub_channel = self.channels["iopub"] info_future: Future[t.Any] = Future() iopub_future: Future[t.Any] = Future() both_done = gen.multi([info_future, iopub_future]) def finish(_=None): """Ensure all futures are resolved which in turn triggers cleanup """ for f in (info_future, iopub_future): if not f.done(): f.set_result(None) def cleanup(_=None): """Common cleanup""" loop.remove_timeout(nudge_handle) iopub_channel.stop_on_recv() if not shell_channel.closed(): shell_channel.close() if not control_channel.closed(): control_channel.close() # trigger cleanup when both message futures are resolved both_done.add_done_callback(cleanup) def on_shell_reply(msg): """Handle nudge shell replies.""" self.log.debug("Nudge: shell info reply received: %s", self.kernel_id) if not info_future.done(): self.log.debug("Nudge: resolving shell future: %s", self.kernel_id) info_future.set_result(None) def on_control_reply(msg): """Handle nudge control replies.""" self.log.debug("Nudge: control info reply received: %s", self.kernel_id) if not info_future.done(): self.log.debug("Nudge: resolving control future: %s", self.kernel_id) info_future.set_result(None) def on_iopub(msg): """Handle nudge iopub replies.""" self.log.debug("Nudge: IOPub received: %s", self.kernel_id) if not iopub_future.done(): iopub_channel.stop_on_recv() self.log.debug("Nudge: resolving iopub future: %s", self.kernel_id) iopub_future.set_result(None) iopub_channel.on_recv(on_iopub) shell_channel.on_recv(on_shell_reply) control_channel.on_recv(on_control_reply) loop = IOLoop.current() # Nudge the kernel with kernel info requests until we get an IOPub message def nudge(count): """Nudge the kernel.""" count += 1 # check for stopped kernel if self.kernel_id not in self.multi_kernel_manager: self.log.debug("Nudge: cancelling on stopped kernel: %s", self.kernel_id) finish() return # check for closed zmq socket if shell_channel.closed(): self.log.debug("Nudge: cancelling on closed zmq socket: %s", self.kernel_id) finish() return # check for closed zmq socket if control_channel.closed(): self.log.debug("Nudge: cancelling on closed zmq socket: %s", self.kernel_id) finish() return if not both_done.done(): log = self.log.warning if count % 10 == 0 else self.log.debug log(f"Nudge: attempt {count} on kernel {self.kernel_id}") self.session.send(shell_channel, "kernel_info_request") self.session.send(control_channel, "kernel_info_request") nonlocal nudge_handle # type: ignore[misc] nudge_handle = loop.call_later(0.5, nudge, count) nudge_handle = loop.call_later(0, nudge, count=0) # resolve with a timeout if we get no response future = gen.with_timeout(loop.time() + self.kernel_info_timeout, both_done) # ensure we have no dangling resources or unresolved Futures in case of timeout future.add_done_callback(finish) return _ensure_future(future) async def _register_session(self): """Ensure we aren't creating a duplicate session. If a previous identical session is still open, close it to avoid collisions. This is likely due to a client reconnecting from a lost network connection, where the socket on our side has not been cleaned up yet. """ self.session_key = f"{self.kernel_id}:{self.session.session}" stale_handler = self._open_sessions.get(self.session_key) if stale_handler: self.log.warning("Replacing stale connection: %s", self.session_key) stale_handler.close() if ( self.kernel_id in self.multi_kernel_manager ): # only update open sessions if kernel is actively managed self._open_sessions[self.session_key] = t.cast( KernelWebsocketHandler, self.websocket_handler ) async def prepare(self): """Prepare a kernel connection.""" # check session collision: await self._register_session() # then request kernel info, waiting up to a certain time before giving up. # We don't want to wait forever, because browsers don't take it well when # servers never respond to websocket connection requests. if hasattr(self.kernel_manager, "ready"): ready = self.kernel_manager.ready if not isinstance(ready, asyncio.Future): ready = asyncio.wrap_future(ready) try: await ready except Exception as e: self.kernel_manager.execution_state = "dead" self.kernel_manager.reason = str(e) raise web.HTTPError(500, str(e)) from e t0 = time.time() while not await ensure_async(self.kernel_manager.is_alive()): await asyncio.sleep(0.1) if (time.time() - t0) > self.multi_kernel_manager.kernel_info_timeout: msg = "Kernel never reached an 'alive' state." raise TimeoutError(msg) self.session.key = self.kernel_manager.session.key future = self.request_kernel_info() def give_up(): """Don't wait forever for the kernel to reply""" if future.done(): return self.log.warning("Timeout waiting for kernel_info reply from %s", self.kernel_id) future.set_result({}) loop = IOLoop.current() loop.add_timeout(loop.time() + self.kernel_info_timeout, give_up) # actually wait for it await asyncio.wrap_future(future) def connect(self): """Handle a connection.""" self.multi_kernel_manager.notify_connect(self.kernel_id) # on new connections, flush the message buffer buffer_info = self.multi_kernel_manager.get_buffer(self.kernel_id, self.session_key) if buffer_info and buffer_info["session_key"] == self.session_key: self.log.info("Restoring connection for %s", self.session_key) if self.multi_kernel_manager.ports_changed(self.kernel_id): # If the kernel's ports have changed (some restarts trigger this) # then reset the channels so nudge() is using the correct iopub channel self.create_stream() else: # The kernel's ports have not changed; use the channels captured in the buffer self.channels = buffer_info["channels"] connected = self.nudge() def replay(value): replay_buffer = buffer_info["buffer"] if replay_buffer: self.log.info("Replaying %s buffered messages", len(replay_buffer)) for channel, msg_list in replay_buffer: stream = self.channels[channel] self.handle_outgoing_message(stream, msg_list) connected.add_done_callback(replay) else: try: self.create_stream() connected = self.nudge() except web.HTTPError as e: # Do not log error if the kernel is already shutdown, # as it's normal that it's not responding try: self.multi_kernel_manager.get_kernel(self.kernel_id) self.log.error("Error opening stream: %s", e) except KeyError: pass # WebSockets don't respond to traditional error codes so we # close the connection. for stream in self.channels.values(): if not stream.closed(): stream.close() self.disconnect() return None self.multi_kernel_manager.add_restart_callback(self.kernel_id, self.on_kernel_restarted) self.multi_kernel_manager.add_restart_callback( self.kernel_id, self.on_restart_failed, "dead" ) def subscribe(value): for stream in self.channels.values(): stream.on_recv_stream(self.handle_outgoing_message) connected.add_done_callback(subscribe) ZMQChannelsWebsocketConnection._open_sockets.add(self) return connected def close(self): """Close the connection.""" return self.disconnect() def disconnect(self): """Handle a disconnect.""" self.log.debug("Websocket closed %s", self.session_key) # unregister myself as an open session (only if it's really me) if self._open_sessions.get(self.session_key) is self.websocket_handler: self._open_sessions.pop(self.session_key) if self.kernel_id in self.multi_kernel_manager: self.multi_kernel_manager.notify_disconnect(self.kernel_id) self.multi_kernel_manager.remove_restart_callback( self.kernel_id, self.on_kernel_restarted, ) self.multi_kernel_manager.remove_restart_callback( self.kernel_id, self.on_restart_failed, "dead", ) # start buffering instead of closing if this was the last connection if ( self.kernel_id in self.multi_kernel_manager._kernel_connections and self.multi_kernel_manager._kernel_connections[self.kernel_id] == 0 ): self.multi_kernel_manager.start_buffering( self.kernel_id, self.session_key, self.channels ) ZMQChannelsWebsocketConnection._open_sockets.remove(self) self._close_future.set_result(None) return # This method can be called twice, once by self.kernel_died and once # from the WebSocket close event. If the WebSocket connection is # closed before the ZMQ streams are setup, they could be None. for stream in self.channels.values(): if stream is not None and not stream.closed(): stream.on_recv(None) stream.close() self.channels = {} try: ZMQChannelsWebsocketConnection._open_sockets.remove(self) self._close_future.set_result(None) except Exception: pass def handle_incoming_message(self, incoming_msg: str) -> None: """Handle incoming messages from Websocket to ZMQ Sockets.""" ws_msg = incoming_msg if not self.channels: # already closed, ignore the message self.log.debug("Received message on closed websocket %r", ws_msg) return if self.subprotocol == "v1.kernel.websocket.jupyter.org": channel, msg_list = deserialize_msg_from_ws_v1(ws_msg) msg = { "header": None, } else: if isinstance(ws_msg, bytes): # type:ignore[unreachable] msg = deserialize_binary_message(ws_msg) # type:ignore[unreachable] else: msg = json.loads(ws_msg) msg_list = [] channel = msg.pop("channel", None) if channel is None: self.log.warning("No channel specified, assuming shell: %s", msg) channel = "shell" if channel not in self.channels: self.log.warning("No such channel: %r", channel) return am = self.multi_kernel_manager.allowed_message_types ignore_msg = False if am: msg["header"] = self.get_part("header", msg["header"], msg_list) assert msg["header"] is not None if msg["header"]["msg_type"] not in am: # type:ignore[unreachable] self.log.warning( 'Received message of type "%s", which is not allowed. Ignoring.' % msg["header"]["msg_type"] ) ignore_msg = True if not ignore_msg: stream = self.channels[channel] if self.subprotocol == "v1.kernel.websocket.jupyter.org": self.session.send_raw(stream, msg_list) else: self.session.send(stream, msg) def handle_outgoing_message(self, stream: str, outgoing_msg: list[t.Any]) -> None: """Handle the outgoing messages from ZMQ sockets to Websocket.""" msg_list = outgoing_msg _, fed_msg_list = self.session.feed_identities(msg_list) if self.subprotocol == "v1.kernel.websocket.jupyter.org": msg = {"header": None, "parent_header": None, "content": None} else: msg = self.session.deserialize(fed_msg_list) if isinstance(stream, str): stream = self.channels[stream] channel = getattr(stream, "channel", None) parts = fed_msg_list[1:] self._on_error(channel, msg, parts) if self._limit_rate(channel, msg, parts): return if self.subprotocol == "v1.kernel.websocket.jupyter.org": self._on_zmq_reply(stream, parts) else: self._on_zmq_reply(stream, msg) def get_part(self, field, value, msg_list): """Get a part of a message.""" if value is None: field2idx = { "header": 0, "parent_header": 1, "content": 3, } value = self.session.unpack(msg_list[field2idx[field]]) return value def _reserialize_reply(self, msg_or_list, channel=None): """Reserialize a reply message using JSON. msg_or_list can be an already-deserialized msg dict or the zmq buffer list. If it is the zmq list, it will be deserialized with self.session. This takes the msg list from the ZMQ socket and serializes the result for the websocket. This method should be used by self._on_zmq_reply to build messages that can be sent back to the browser. """ if isinstance(msg_or_list, dict): # already unpacked msg = msg_or_list else: _, msg_list = self.session.feed_identities(msg_or_list) msg = self.session.deserialize(msg_list) if channel: msg["channel"] = channel if msg["buffers"]: buf = serialize_binary_message(msg) return buf else: return json.dumps(msg, default=json_default) def _on_zmq_reply(self, stream, msg_list): """Handle a zmq reply.""" # Sometimes this gets triggered when the on_close method is scheduled in the # eventloop but hasn't been called. if stream.closed(): self.log.warning("zmq message arrived on closed channel") self.disconnect() return channel = getattr(stream, "channel", None) if self.subprotocol == "v1.kernel.websocket.jupyter.org": bin_msg = serialize_msg_to_ws_v1(msg_list, channel) self.write_message(bin_msg, binary=True) else: try: msg = self._reserialize_reply(msg_list, channel=channel) except Exception: self.log.critical("Malformed message: %r" % msg_list, exc_info=True) else: try: self.write_message(msg, binary=isinstance(msg, bytes)) except WebSocketClosedError as e: self.log.warning(str(e)) def request_kernel_info(self): """send a request for kernel_info""" try: # check for previous request future = self.kernel_manager._kernel_info_future except AttributeError: self.log.debug("Requesting kernel info from %s", self.kernel_id) # Create a kernel_info channel to query the kernel protocol version. # This channel will be closed after the kernel_info reply is received. if self.kernel_info_channel is None: self.kernel_info_channel = self.multi_kernel_manager.connect_shell(self.kernel_id) assert self.kernel_info_channel is not None self.kernel_info_channel.on_recv(self._handle_kernel_info_reply) self.session.send(self.kernel_info_channel, "kernel_info_request") # store the future on the kernel, so only one request is sent self.kernel_manager._kernel_info_future = self._kernel_info_future else: if not future.done(): self.log.debug("Waiting for pending kernel_info request") future.add_done_callback(lambda f: self._finish_kernel_info(f.result())) return _ensure_future(self._kernel_info_future) def _handle_kernel_info_reply(self, msg): """process the kernel_info_reply enabling msg spec adaptation, if necessary """ idents, msg = self.session.feed_identities(msg) try: msg = self.session.deserialize(msg) except BaseException: self.log.error("Bad kernel_info reply", exc_info=True) self._kernel_info_future.set_result({}) return else: info = msg["content"] self.log.debug("Received kernel info: %s", info) if msg["msg_type"] != "kernel_info_reply" or "protocol_version" not in info: self.log.error("Kernel info request failed, assuming current %s", info) info = {} self._finish_kernel_info(info) # close the kernel_info channel, we don't need it anymore if self.kernel_info_channel: self.kernel_info_channel.close() self.kernel_info_channel = None def _finish_kernel_info(self, info): """Finish handling kernel_info reply Set up protocol adaptation, if needed, and signal that connection can continue. """ protocol_version = info.get("protocol_version", client_protocol_version) if protocol_version != client_protocol_version: self.session.adapt_version = int(protocol_version.split(".")[0]) self.log.info( f"Adapting from protocol version {protocol_version} (kernel {self.kernel_id}) to {client_protocol_version} (client)." ) if not self._kernel_info_future.done(): self._kernel_info_future.set_result(info) def write_stderr(self, error_message, parent_header): """Write a message to stderr.""" self.log.warning(error_message) err_msg = self.session.msg( "stream", content={"text": error_message + "\n", "name": "stderr"}, parent=parent_header, ) if self.subprotocol == "v1.kernel.websocket.jupyter.org": bin_msg = serialize_msg_to_ws_v1(err_msg, "iopub", self.session.pack) self.write_message(bin_msg, binary=True) else: err_msg["channel"] = "iopub" self.write_message(json.dumps(err_msg, default=json_default)) def _limit_rate(self, channel, msg, msg_list): """Limit the message rate on a channel.""" if not (self.limit_rate and channel == "iopub"): return False msg["header"] = self.get_part("header", msg["header"], msg_list) msg_type = msg["header"]["msg_type"] if msg_type == "status": msg["content"] = self.get_part("content", msg["content"], msg_list) if msg["content"].get("execution_state") == "idle": # reset rate limit counter on status=idle, # to avoid 'Run All' hitting limits prematurely. self._iopub_window_byte_queue = [] self._iopub_window_msg_count = 0 self._iopub_window_byte_count = 0 self._iopub_msgs_exceeded = False self._iopub_data_exceeded = False if msg_type not in {"status", "comm_open", "execute_input"}: # Remove the counts queued for removal. now = IOLoop.current().time() while len(self._iopub_window_byte_queue) > 0: queued = self._iopub_window_byte_queue[0] if now >= queued[0]: self._iopub_window_byte_count -= queued[1] self._iopub_window_msg_count -= 1 del self._iopub_window_byte_queue[0] else: # This part of the queue hasn't be reached yet, so we can # abort the loop. break # Increment the bytes and message count self._iopub_window_msg_count += 1 byte_count = sum(len(x) for x in msg_list) if msg_type == "stream" else 0 self._iopub_window_byte_count += byte_count # Queue a removal of the byte and message count for a time in the # future, when we are no longer interested in it. self._iopub_window_byte_queue.append((now + self.rate_limit_window, byte_count)) # Check the limits, set the limit flags, and reset the # message and data counts. msg_rate = float(self._iopub_window_msg_count) / self.rate_limit_window data_rate = float(self._iopub_window_byte_count) / self.rate_limit_window # Check the msg rate if self.iopub_msg_rate_limit > 0 and msg_rate > self.iopub_msg_rate_limit: if not self._iopub_msgs_exceeded: self._iopub_msgs_exceeded = True msg["parent_header"] = self.get_part( "parent_header", msg["parent_header"], msg_list ) self.write_stderr( dedent( f"""\ IOPub message rate exceeded. The Jupyter server will temporarily stop sending output to the client in order to avoid crashing it. To change this limit, set the config variable `--ServerApp.iopub_msg_rate_limit`. Current values: ServerApp.iopub_msg_rate_limit={self.iopub_msg_rate_limit} (msgs/sec) ServerApp.rate_limit_window={self.rate_limit_window} (secs) """ ), msg["parent_header"], ) # resume once we've got some headroom below the limit elif self._iopub_msgs_exceeded and msg_rate < (0.8 * self.iopub_msg_rate_limit): self._iopub_msgs_exceeded = False if not self._iopub_data_exceeded: self.log.warning("iopub messages resumed") # Check the data rate if self.iopub_data_rate_limit > 0 and data_rate > self.iopub_data_rate_limit: if not self._iopub_data_exceeded: self._iopub_data_exceeded = True msg["parent_header"] = self.get_part( "parent_header", msg["parent_header"], msg_list ) self.write_stderr( dedent( f"""\ IOPub data rate exceeded. The Jupyter server will temporarily stop sending output to the client in order to avoid crashing it. To change this limit, set the config variable `--ServerApp.iopub_data_rate_limit`. Current values: ServerApp.iopub_data_rate_limit={self.iopub_data_rate_limit} (bytes/sec) ServerApp.rate_limit_window={self.rate_limit_window} (secs) """ ), msg["parent_header"], ) # resume once we've got some headroom below the limit elif self._iopub_data_exceeded and data_rate < (0.8 * self.iopub_data_rate_limit): self._iopub_data_exceeded = False if not self._iopub_msgs_exceeded: self.log.warning("iopub messages resumed") # If either of the limit flags are set, do not send the message. if self._iopub_msgs_exceeded or self._iopub_data_exceeded: # we didn't send it, remove the current message from the calculus self._iopub_window_msg_count -= 1 self._iopub_window_byte_count -= byte_count self._iopub_window_byte_queue.pop(-1) return True return False def _send_status_message(self, status): """Send a status message.""" iopub = self.channels.get("iopub", None) if iopub and not iopub.closed(): # flush IOPub before sending a restarting/dead status message # ensures proper ordering on the IOPub channel # that all messages from the stopped kernel have been delivered iopub.flush() msg = self.session.msg("status", {"execution_state": status}) if self.subprotocol == "v1.kernel.websocket.jupyter.org": bin_msg = serialize_msg_to_ws_v1(msg, "iopub", self.session.pack) self.write_message(bin_msg, binary=True) else: msg["channel"] = "iopub" self.write_message(json.dumps(msg, default=json_default)) def on_kernel_restarted(self): """Handle a kernel restart.""" self.log.warning("kernel %s restarted", self.kernel_id) self._send_status_message("restarting") def on_restart_failed(self): """Handle a kernel restart failure.""" self.log.error("kernel %s restarted failed!", self.kernel_id) self._send_status_message("dead") def _on_error(self, channel, msg, msg_list): """Handle an error message.""" if self.multi_kernel_manager.allow_tracebacks: return if channel == "iopub": msg["header"] = self.get_part("header", msg["header"], msg_list) if msg["header"]["msg_type"] == "error": msg["content"] = self.get_part("content", msg["content"], msg_list) msg["content"]["ename"] = "ExecutionError" msg["content"]["evalue"] = "Execution error" msg["content"]["traceback"] = [self.kernel_manager.traceback_replacement_message] if self.subprotocol == "v1.kernel.websocket.jupyter.org": msg_list[3] = self.session.pack(msg["content"]) KernelWebsocketConnectionABC.register(ZMQChannelsWebsocketConnection) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernels/handlers.py000066400000000000000000000100561473126534200310450ustar00rootroot00000000000000"""Tornado handlers for kernels. Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-16%3A-Notebook-multi-directory-dashboard-and-URL-mapping#kernels-api """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json try: from jupyter_client.jsonutil import json_default except ImportError: from jupyter_client.jsonutil import date_default as json_default from jupyter_core.utils import ensure_async from tornado import web from jupyter_server.auth.decorator import authorized from jupyter_server.utils import url_escape, url_path_join from ...base.handlers import APIHandler from .websocket import KernelWebsocketHandler AUTH_RESOURCE = "kernels" class KernelsAPIHandler(APIHandler): """A kernels API handler.""" auth_resource = AUTH_RESOURCE class MainKernelHandler(KernelsAPIHandler): """The root kernel handler.""" @web.authenticated @authorized async def get(self): """Get the list of running kernels.""" km = self.kernel_manager kernels = await ensure_async(km.list_kernels()) self.finish(json.dumps(kernels, default=json_default)) @web.authenticated @authorized async def post(self): """Start a kernel.""" km = self.kernel_manager model = self.get_json_body() if model is None: model = {"name": km.default_kernel_name} else: model.setdefault("name", km.default_kernel_name) kernel_id = await ensure_async( km.start_kernel( # type:ignore[has-type] kernel_name=model["name"], path=model.get("path") ) ) model = await ensure_async(km.kernel_model(kernel_id)) location = url_path_join(self.base_url, "api", "kernels", url_escape(kernel_id)) self.set_header("Location", location) self.set_status(201) self.finish(json.dumps(model, default=json_default)) class KernelHandler(KernelsAPIHandler): """A kernel API handler.""" @web.authenticated @authorized async def get(self, kernel_id): """Get a kernel model.""" km = self.kernel_manager model = await ensure_async(km.kernel_model(kernel_id)) self.finish(json.dumps(model, default=json_default)) @web.authenticated @authorized async def delete(self, kernel_id): """Remove a kernel.""" km = self.kernel_manager await ensure_async(km.shutdown_kernel(kernel_id)) self.set_status(204) self.finish() class KernelActionHandler(KernelsAPIHandler): """A kernel action API handler.""" @web.authenticated @authorized async def post(self, kernel_id, action): """Interrupt or restart a kernel.""" km = self.kernel_manager if action == "interrupt": await ensure_async(km.interrupt_kernel(kernel_id)) # type:ignore[func-returns-value] self.set_status(204) if action == "restart": try: await km.restart_kernel(kernel_id) except Exception: message = "Exception restarting kernel" self.log.error(message, exc_info=True) self.write(json.dumps({"message": message, "traceback": ""})) self.set_status(500) else: model = await ensure_async(km.kernel_model(kernel_id)) self.write(json.dumps(model, default=json_default)) self.finish() # ----------------------------------------------------------------------------- # URL to handler mappings # ----------------------------------------------------------------------------- _kernel_id_regex = r"(?P\w+-\w+-\w+-\w+-\w+)" _kernel_action_regex = r"(?Prestart|interrupt)" default_handlers = [ (r"/api/kernels", MainKernelHandler), (r"/api/kernels/%s" % _kernel_id_regex, KernelHandler), ( rf"/api/kernels/{_kernel_id_regex}/{_kernel_action_regex}", KernelActionHandler, ), (r"/api/kernels/%s/channels" % _kernel_id_regex, KernelWebsocketHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernels/kernelmanager.py000066400000000000000000001063361473126534200320670ustar00rootroot00000000000000"""A MultiKernelManager for use in the Jupyter server - raises HTTPErrors - creates REST API models """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import asyncio import os import pathlib # noqa: TCH003 import typing as t import warnings from collections import defaultdict from datetime import datetime, timedelta from functools import partial, wraps from jupyter_client.ioloop.manager import AsyncIOLoopKernelManager from jupyter_client.multikernelmanager import AsyncMultiKernelManager, MultiKernelManager from jupyter_client.session import Session from jupyter_core.paths import exists from jupyter_core.utils import ensure_async from jupyter_events import EventLogger from jupyter_events.schema_registry import SchemaRegistryException from overrides import overrides from tornado import web from tornado.concurrent import Future from tornado.ioloop import IOLoop, PeriodicCallback from traitlets import ( Any, Bool, Dict, Float, Instance, Integer, List, TraitError, Unicode, default, validate, ) from jupyter_server import DEFAULT_EVENTS_SCHEMA_PATH from jupyter_server._tz import isoformat, utcnow from jupyter_server.prometheus.metrics import KERNEL_CURRENTLY_RUNNING_TOTAL from jupyter_server.utils import ApiPath, import_item, to_os_path class MappingKernelManager(MultiKernelManager): """A KernelManager that handles - File mapping - HTTP error handling - Kernel message filtering """ @default("kernel_manager_class") def _default_kernel_manager_class(self): return "jupyter_client.ioloop.IOLoopKernelManager" kernel_argv = List(Unicode()) root_dir = Unicode(config=True) _kernel_connections = Dict() _kernel_ports: dict[str, list[int]] = Dict() # type: ignore[assignment] _culler_callback = None _initialized_culler = False @default("root_dir") def _default_root_dir(self): if not self.parent: return os.getcwd() return self.parent.root_dir @validate("root_dir") def _update_root_dir(self, proposal): """Do a bit of validation of the root dir.""" value = proposal["value"] if not os.path.isabs(value): # If we receive a non-absolute path, make it absolute. value = os.path.abspath(value) if not exists(value) or not os.path.isdir(value): raise TraitError("kernel root dir %r is not a directory" % value) return value cull_idle_timeout = Integer( 0, config=True, help="""Timeout (in seconds) after which a kernel is considered idle and ready to be culled. Values of 0 or lower disable culling. Very short timeouts may result in kernels being culled for users with poor network connections.""", ) cull_interval_default = 300 # 5 minutes cull_interval = Integer( cull_interval_default, config=True, help="""The interval (in seconds) on which to check for idle kernels exceeding the cull timeout value.""", ) cull_connected = Bool( False, config=True, help="""Whether to consider culling kernels which have one or more connections. Only effective if cull_idle_timeout > 0.""", ) cull_busy = Bool( False, config=True, help="""Whether to consider culling kernels which are busy. Only effective if cull_idle_timeout > 0.""", ) buffer_offline_messages = Bool( True, config=True, help="""Whether messages from kernels whose frontends have disconnected should be buffered in-memory. When True (default), messages are buffered and replayed on reconnect, avoiding lost messages due to interrupted connectivity. Disable if long-running kernels will produce too much output while no frontends are connected. """, ) kernel_info_timeout = Float( 60, config=True, help="""Timeout for giving up on a kernel (in seconds). On starting and restarting kernels, we check whether the kernel is running and responsive by sending kernel_info_requests. This sets the timeout in seconds for how long the kernel can take before being presumed dead. This affects the MappingKernelManager (which handles kernel restarts) and the ZMQChannelsHandler (which handles the startup). """, ) _kernel_buffers = Any() @default("_kernel_buffers") def _default_kernel_buffers(self): return defaultdict(lambda: {"buffer": [], "session_key": "", "channels": {}}) last_kernel_activity = Instance( datetime, help="The last activity on any kernel, including shutting down a kernel", ) def __init__(self, **kwargs): """Initialize a kernel manager.""" self.pinned_superclass = MultiKernelManager self._pending_kernel_tasks = {} self.pinned_superclass.__init__(self, **kwargs) self.last_kernel_activity = utcnow() allowed_message_types = List( trait=Unicode(), config=True, help="""White list of allowed kernel message types. When the list is empty, all message types are allowed. """, ) allow_tracebacks = Bool( True, config=True, help=("Whether to send tracebacks to clients on exceptions.") ) traceback_replacement_message = Unicode( "An exception occurred at runtime, which is not shown due to security reasons.", config=True, help=("Message to print when allow_tracebacks is False, and an exception occurs"), ) # ------------------------------------------------------------------------- # Methods for managing kernels and sessions # ------------------------------------------------------------------------- def _handle_kernel_died(self, kernel_id): """notice that a kernel died""" self.log.warning("Kernel %s died, removing from map.", kernel_id) self.remove_kernel(kernel_id) def cwd_for_path(self, path, **kwargs): """Turn API path into absolute OS path.""" os_path = to_os_path(path, self.root_dir) # in the case of documents and kernels not being on the same filesystem, # walk up to root_dir if the paths don't exist while not os.path.isdir(os_path) and os_path != self.root_dir: os_path = os.path.dirname(os_path) return os_path async def _remove_kernel_when_ready(self, kernel_id, kernel_awaitable): """Remove a kernel when it is ready.""" await super()._remove_kernel_when_ready(kernel_id, kernel_awaitable) self._kernel_connections.pop(kernel_id, None) self._kernel_ports.pop(kernel_id, None) # TODO: DEC 2022: Revise the type-ignore once the signatures have been changed upstream # https://github.com/jupyter/jupyter_client/pull/905 async def _async_start_kernel( # type:ignore[override] self, *, kernel_id: str | None = None, path: ApiPath | None = None, **kwargs: str ) -> str: """Start a kernel for a session and return its kernel_id. Parameters ---------- kernel_id : uuid (str) The uuid to associate the new kernel with. If this is not None, this kernel will be persistent whenever it is requested. path : API path The API path (unicode, '/' delimited) for the cwd. Will be transformed to an OS path relative to root_dir. kernel_name : str The name identifying which kernel spec to launch. This is ignored if an existing kernel is returned, but it may be checked in the future. """ if kernel_id is None or kernel_id not in self: if path is not None: kwargs["cwd"] = self.cwd_for_path(path, env=kwargs.get("env", {})) if kernel_id is not None: assert kernel_id is not None, "Never Fail, but necessary for mypy " kwargs["kernel_id"] = kernel_id kernel_id = await self.pinned_superclass._async_start_kernel(self, **kwargs) self._kernel_connections[kernel_id] = 0 # add busy/activity markers: kernel = self.get_kernel(kernel_id) kernel.execution_state = "starting" # type:ignore[attr-defined] kernel.reason = "" # type:ignore[attr-defined] kernel.last_activity = utcnow() # type:ignore[attr-defined] self.log.info("Kernel started: %s", kernel_id) self.log.debug( "Kernel args (excluding env): %r", {k: v for k, v in kwargs.items() if k != "env"} ) env = kwargs.get("env", None) if env and isinstance(env, dict): # type:ignore[unreachable] self.log.debug("Kernel argument 'env' passed with: %r", list(env.keys())) # type:ignore[unreachable] task = asyncio.create_task(self._finish_kernel_start(kernel_id)) if not getattr(self, "use_pending_kernels", None): await task else: self._pending_kernel_tasks[kernel_id] = task # Increase the metric of number of kernels running # for the relevant kernel type by 1 KERNEL_CURRENTLY_RUNNING_TOTAL.labels(type=self._kernels[kernel_id].kernel_name).inc() else: self.log.info("Using existing kernel: %s", kernel_id) # Initialize culling if not already if not self._initialized_culler: self.initialize_culler() assert kernel_id is not None return kernel_id # see https://github.com/jupyter-server/jupyter_server/issues/1165 # this assignment is technically incorrect, but might need a change of API # in jupyter_client. start_kernel = _async_start_kernel # type:ignore[assignment] async def _finish_kernel_start(self, kernel_id): """Handle a kernel that finishes starting.""" km = self.get_kernel(kernel_id) if hasattr(km, "ready"): ready = km.ready if not isinstance(ready, asyncio.Future): ready = asyncio.wrap_future(ready) try: await ready except Exception: self.log.exception("Error waiting for kernel manager ready") return self._kernel_ports[kernel_id] = km.ports self.start_watching_activity(kernel_id) # register callback for failed auto-restart self.add_restart_callback( kernel_id, lambda: self._handle_kernel_died(kernel_id), "dead", ) def ports_changed(self, kernel_id): """Used by ZMQChannelsHandler to determine how to coordinate nudge and replays. Ports are captured when starting a kernel (via MappingKernelManager). Ports are considered changed (following restarts) if the referenced KernelManager is using a set of ports different from those captured at startup. If changes are detected, the captured set is updated and a value of True is returned. NOTE: Use is exclusive to ZMQChannelsHandler because this object is a singleton instance while ZMQChannelsHandler instances are per WebSocket connection that can vary per kernel lifetime. """ changed_ports = self._get_changed_ports(kernel_id) if changed_ports: # If changed, update captured ports and return True, else return False. self.log.debug("Port change detected for kernel: %s", kernel_id) self._kernel_ports[kernel_id] = changed_ports return True return False def _get_changed_ports(self, kernel_id): """Internal method to test if a kernel's ports have changed and, if so, return their values. This method does NOT update the captured ports for the kernel as that can only be done by ZMQChannelsHandler, but instead returns the new list of ports if they are different than those captured at startup. This enables the ability to conditionally restart activity monitoring immediately following a kernel's restart (if ports have changed). """ # Get current ports and return comparison with ports captured at startup. km = self.get_kernel(kernel_id) assert isinstance(km.ports, list) assert isinstance(self._kernel_ports[kernel_id], list) if km.ports != self._kernel_ports[kernel_id]: return km.ports return None def start_buffering(self, kernel_id, session_key, channels): """Start buffering messages for a kernel Parameters ---------- kernel_id : str The id of the kernel to stop buffering. session_key : str The session_key, if any, that should get the buffer. If the session_key matches the current buffered session_key, the buffer will be returned. channels : dict({'channel': ZMQStream}) The zmq channels whose messages should be buffered. """ if not self.buffer_offline_messages: for stream in channels.values(): stream.close() return self.log.info("Starting buffering for %s", session_key) self._check_kernel_id(kernel_id) # clear previous buffering state self.stop_buffering(kernel_id) buffer_info = self._kernel_buffers[kernel_id] # record the session key because only one session can buffer buffer_info["session_key"] = session_key # TODO: the buffer should likely be a memory bounded queue, we're starting with a list to keep it simple buffer_info["buffer"] = [] buffer_info["channels"] = channels # forward any future messages to the internal buffer def buffer_msg(channel, msg_parts): self.log.debug("Buffering msg on %s:%s", kernel_id, channel) buffer_info["buffer"].append((channel, msg_parts)) for channel, stream in channels.items(): stream.on_recv(partial(buffer_msg, channel)) def get_buffer(self, kernel_id, session_key): """Get the buffer for a given kernel Parameters ---------- kernel_id : str The id of the kernel to stop buffering. session_key : str, optional The session_key, if any, that should get the buffer. If the session_key matches the current buffered session_key, the buffer will be returned. """ self.log.debug("Getting buffer for %s", kernel_id) if kernel_id not in self._kernel_buffers: return None buffer_info = self._kernel_buffers[kernel_id] if buffer_info["session_key"] == session_key: # remove buffer self._kernel_buffers.pop(kernel_id) # only return buffer_info if it's a match return buffer_info else: self.stop_buffering(kernel_id) def stop_buffering(self, kernel_id): """Stop buffering kernel messages Parameters ---------- kernel_id : str The id of the kernel to stop buffering. """ self.log.debug("Clearing buffer for %s", kernel_id) self._check_kernel_id(kernel_id) if kernel_id not in self._kernel_buffers: return buffer_info = self._kernel_buffers.pop(kernel_id) # close buffering streams for stream in buffer_info["channels"].values(): if not stream.socket.closed: stream.on_recv(None) stream.close() msg_buffer = buffer_info["buffer"] if msg_buffer: self.log.info( "Discarding %s buffered messages for %s", len(msg_buffer), buffer_info["session_key"], ) async def _async_shutdown_kernel(self, kernel_id, now=False, restart=False): """Shutdown a kernel by kernel_id""" self._check_kernel_id(kernel_id) # Decrease the metric of number of kernels # running for the relevant kernel type by 1 KERNEL_CURRENTLY_RUNNING_TOTAL.labels(type=self._kernels[kernel_id].kernel_name).dec() if kernel_id in self._pending_kernel_tasks: task = self._pending_kernel_tasks.pop(kernel_id) task.cancel() self.stop_watching_activity(kernel_id) self.stop_buffering(kernel_id) return await self.pinned_superclass._async_shutdown_kernel( self, kernel_id, now=now, restart=restart ) shutdown_kernel = _async_shutdown_kernel async def _async_restart_kernel(self, kernel_id, now=False): """Restart a kernel by kernel_id""" self._check_kernel_id(kernel_id) await self.pinned_superclass._async_restart_kernel(self, kernel_id, now=now) kernel = self.get_kernel(kernel_id) # return a Future that will resolve when the kernel has successfully restarted channel = kernel.connect_shell() future: Future[Any] = Future() def finish(): """Common cleanup when restart finishes/fails for any reason.""" if not channel.closed(): # type:ignore[operator] channel.close() loop.remove_timeout(timeout) kernel.remove_restart_callback(on_restart_failed, "dead") kernel._pending_restart_cleanup = None # type:ignore[attr-defined] def on_reply(msg): self.log.debug("Kernel info reply received: %s", kernel_id) finish() if not future.done(): future.set_result(msg) def on_timeout(): self.log.warning("Timeout waiting for kernel_info_reply: %s", kernel_id) finish() if not future.done(): future.set_exception(TimeoutError("Timeout waiting for restart")) def on_restart_failed(): self.log.warning("Restarting kernel failed: %s", kernel_id) finish() if not future.done(): future.set_exception(RuntimeError("Restart failed")) kernel.add_restart_callback(on_restart_failed, "dead") kernel._pending_restart_cleanup = finish # type:ignore[attr-defined] kernel.session.send(channel, "kernel_info_request") channel.on_recv(on_reply) # type:ignore[operator] loop = IOLoop.current() timeout = loop.add_timeout(loop.time() + self.kernel_info_timeout, on_timeout) # Re-establish activity watching if ports have changed... if self._get_changed_ports(kernel_id) is not None: self.stop_watching_activity(kernel_id) self.start_watching_activity(kernel_id) return future restart_kernel = _async_restart_kernel def notify_connect(self, kernel_id): """Notice a new connection to a kernel""" if kernel_id in self._kernel_connections: self._kernel_connections[kernel_id] += 1 def notify_disconnect(self, kernel_id): """Notice a disconnection from a kernel""" if kernel_id in self._kernel_connections: self._kernel_connections[kernel_id] -= 1 def kernel_model(self, kernel_id): """Return a JSON-safe dict representing a kernel For use in representing kernels in the JSON APIs. """ self._check_kernel_id(kernel_id) kernel = self._kernels[kernel_id] model = { "id": kernel_id, "name": kernel.kernel_name, "last_activity": isoformat(kernel.last_activity), "execution_state": kernel.execution_state, "connections": self._kernel_connections.get(kernel_id, 0), } if getattr(kernel, "reason", None): model["reason"] = kernel.reason return model def list_kernels(self): """Returns a list of kernel_id's of kernels running.""" kernels = [] kernel_ids = self.pinned_superclass.list_kernel_ids(self) for kernel_id in kernel_ids: try: model = self.kernel_model(kernel_id) kernels.append(model) except (web.HTTPError, KeyError): # Probably due to a (now) non-existent kernel, continue building the list pass return kernels # override _check_kernel_id to raise 404 instead of KeyError def _check_kernel_id(self, kernel_id): """Check a that a kernel_id exists and raise 404 if not.""" if kernel_id not in self: raise web.HTTPError(404, "Kernel does not exist: %s" % kernel_id) # monitoring activity: untracked_message_types = List( trait=Unicode(), config=True, default_value=[ "comm_info_request", "comm_info_reply", "kernel_info_request", "kernel_info_reply", "shutdown_request", "shutdown_reply", "interrupt_request", "interrupt_reply", "debug_request", "debug_reply", "stream", "display_data", "update_display_data", "execute_input", "execute_result", "error", "status", "clear_output", "debug_event", "input_request", "input_reply", ], help="""List of kernel message types excluded from user activity tracking. This should be a superset of the message types sent on any channel other than the shell channel.""", ) def track_message_type(self, message_type): return message_type not in self.untracked_message_types def start_watching_activity(self, kernel_id): """Start watching IOPub messages on a kernel for activity. - update last_activity on every message - record execution_state from status messages """ kernel = self._kernels[kernel_id] # add busy/activity markers: kernel.execution_state = "starting" kernel.reason = "" kernel.last_activity = utcnow() kernel._activity_stream = kernel.connect_iopub() session = Session( config=kernel.session.config, key=kernel.session.key, ) def record_activity(msg_list): """Record an IOPub message arriving from a kernel""" idents, fed_msg_list = session.feed_identities(msg_list) msg = session.deserialize(fed_msg_list, content=False) msg_type = msg["header"]["msg_type"] parent_msg_type = msg.get("parent_header", {}).get("msg_type", None) if ( self.track_message_type(msg_type) or self.track_message_type(parent_msg_type) or kernel.execution_state == "busy" ): self.last_kernel_activity = kernel.last_activity = utcnow() if msg_type == "status": msg = session.deserialize(fed_msg_list) execution_state = msg["content"]["execution_state"] if self.track_message_type(parent_msg_type): kernel.execution_state = execution_state elif kernel.execution_state == "starting" and execution_state != "starting": # We always normalize post-starting execution state to "idle" # unless we know that the status is in response to one of our # tracked message types. kernel.execution_state = "idle" self.log.debug( "activity on %s: %s (%s)", kernel_id, msg_type, kernel.execution_state, ) else: self.log.debug("activity on %s: %s", kernel_id, msg_type) kernel._activity_stream.on_recv(record_activity) def stop_watching_activity(self, kernel_id): """Stop watching IOPub messages on a kernel for activity.""" kernel = self._kernels[kernel_id] if getattr(kernel, "_activity_stream", None): if not kernel._activity_stream.socket.closed: kernel._activity_stream.close() kernel._activity_stream = None if getattr(kernel, "_pending_restart_cleanup", None): kernel._pending_restart_cleanup() def initialize_culler(self): """Start idle culler if 'cull_idle_timeout' is greater than zero. Regardless of that value, set flag that we've been here. """ if ( not self._initialized_culler and self.cull_idle_timeout > 0 and self._culler_callback is None ): _ = IOLoop.current() if self.cull_interval <= 0: # handle case where user set invalid value self.log.warning( "Invalid value for 'cull_interval' detected (%s) - using default value (%s).", self.cull_interval, self.cull_interval_default, ) self.cull_interval = self.cull_interval_default self._culler_callback = PeriodicCallback(self.cull_kernels, 1000 * self.cull_interval) self.log.info( "Culling kernels with idle durations > %s seconds at %s second intervals ...", self.cull_idle_timeout, self.cull_interval, ) if self.cull_busy: self.log.info("Culling kernels even if busy") if self.cull_connected: self.log.info("Culling kernels even with connected clients") self._culler_callback.start() self._initialized_culler = True async def cull_kernels(self): """Handle culling kernels.""" self.log.debug( "Polling every %s seconds for kernels idle > %s seconds...", self.cull_interval, self.cull_idle_timeout, ) """Create a separate list of kernels to avoid conflicting updates while iterating""" for kernel_id in list(self._kernels): try: await self.cull_kernel_if_idle(kernel_id) except Exception as e: self.log.exception( "The following exception was encountered while checking the idle duration of kernel %s: %s", kernel_id, e, ) async def cull_kernel_if_idle(self, kernel_id): """Cull a kernel if it is idle.""" kernel = self._kernels[kernel_id] if getattr(kernel, "execution_state", None) == "dead": self.log.warning( "Culling '%s' dead kernel '%s' (%s).", kernel.execution_state, kernel.kernel_name, kernel_id, ) await ensure_async(self.shutdown_kernel(kernel_id)) return kernel_spec_metadata = kernel.kernel_spec.metadata cull_idle_timeout = kernel_spec_metadata.get("cull_idle_timeout", self.cull_idle_timeout) if hasattr( kernel, "last_activity" ): # last_activity is monkey-patched, so ensure that has occurred self.log.debug( "kernel_id=%s, kernel_name=%s, last_activity=%s", kernel_id, kernel.kernel_name, kernel.last_activity, ) dt_now = utcnow() dt_idle = dt_now - kernel.last_activity # Compute idle properties is_idle_time = dt_idle > timedelta(seconds=cull_idle_timeout) is_idle_execute = self.cull_busy or (kernel.execution_state != "busy") connections = self._kernel_connections.get(kernel_id, 0) is_idle_connected = self.cull_connected or not connections # Cull the kernel if all three criteria are met if is_idle_time and is_idle_execute and is_idle_connected: idle_duration = int(dt_idle.total_seconds()) self.log.warning( "Culling '%s' kernel '%s' (%s) with %d connections due to %s seconds of inactivity.", kernel.execution_state, kernel.kernel_name, kernel_id, connections, idle_duration, ) await ensure_async(self.shutdown_kernel(kernel_id)) # AsyncMappingKernelManager inherits as much as possible from MappingKernelManager, # overriding only what is different. class AsyncMappingKernelManager(MappingKernelManager, AsyncMultiKernelManager): # type:ignore[misc] """An asynchronous mapping kernel manager.""" @default("kernel_manager_class") def _default_kernel_manager_class(self): return "jupyter_server.services.kernels.kernelmanager.ServerKernelManager" @validate("kernel_manager_class") def _validate_kernel_manager_class(self, proposal): """A validator for the kernel manager class.""" km_class_value = proposal.value km_class = import_item(km_class_value) if not issubclass(km_class, ServerKernelManager): warnings.warn( f"KernelManager class '{km_class}' is not a subclass of 'ServerKernelManager'. Custom " "KernelManager classes should derive from 'ServerKernelManager' beginning with jupyter-server 2.0 " "or risk missing functionality. Continuing...", FutureWarning, stacklevel=3, ) return km_class_value def __init__(self, **kwargs): """Initialize an async mapping kernel manager.""" self.pinned_superclass = MultiKernelManager self._pending_kernel_tasks = {} self.pinned_superclass.__init__(self, **kwargs) self.last_kernel_activity = utcnow() def emit_kernel_action_event(success_msg: str = "") -> t.Callable[..., t.Any]: """Decorate kernel action methods to begin emitting jupyter kernel action events. Parameters ---------- success_msg: str A formattable string that's passed to the message field of the emitted event when the action succeeds. You can include the kernel_id, kernel_name, or action in the message using a formatted string argument, e.g. "{kernel_id} succeeded to {action}." error_msg: str A formattable string that's passed to the message field of the emitted event when the action fails. You can include the kernel_id, kernel_name, or action in the message using a formatted string argument, e.g. "{kernel_id} failed to {action}." """ def wrap_method(method): @wraps(method) async def wrapped_method(self, *args, **kwargs): """""" # Get the method name from the action = method.__name__.replace("_kernel", "") # If the method succeeds, emit a success event. try: out = await method(self, *args, **kwargs) data = { "kernel_name": self.kernel_name, "action": action, "status": "success", "msg": success_msg.format( kernel_id=self.kernel_id, kernel_name=self.kernel_name, action=action ), } if self.kernel_id: data["kernel_id"] = self.kernel_id self.emit( schema_id="https://events.jupyter.org/jupyter_server/kernel_actions/v1", data=data, ) return out # If the method fails, emit a failed event. except Exception as err: data = { "kernel_name": self.kernel_name, "action": action, "status": "error", "msg": str(err), } if self.kernel_id: data["kernel_id"] = self.kernel_id # If the exception is an HTTPError (usually via a gateway request) # log the status_code and HTTPError log_message. if isinstance(err, web.HTTPError): msg = err.log_message or "" data["status_code"] = err.status_code data["msg"] = msg self.emit( schema_id="https://events.jupyter.org/jupyter_server/kernel_actions/v1", data=data, ) raise err return wrapped_method return wrap_method class ServerKernelManager(AsyncIOLoopKernelManager): """A server-specific kernel manager.""" # Define activity-related attributes: execution_state = Unicode( None, allow_none=True, help="The current execution state of the kernel" ) reason = Unicode("", help="The reason for the last failure against the kernel") last_activity = Instance(datetime, help="The last activity on the kernel") # A list of pathlib objects, each pointing at an event # schema to register with this kernel manager's eventlogger. # This trait should not be overridden. @property def core_event_schema_paths(self) -> list[pathlib.Path]: return [DEFAULT_EVENTS_SCHEMA_PATH / "kernel_actions" / "v1.yaml"] # This trait is intended for subclasses to override and define # custom event schemas. extra_event_schema_paths: List[str] = List( default_value=[], help=""" A list of pathlib.Path objects pointing at to register with the kernel manager's eventlogger. """, ).tag(config=True) event_logger = Instance(EventLogger) @default("event_logger") def _default_event_logger(self): """Initialize the logger and ensure all required events are present.""" if ( self.parent is not None and self.parent.parent is not None and hasattr(self.parent.parent, "event_logger") ): logger = self.parent.parent.event_logger else: # If parent does not have an event logger, create one. logger = EventLogger() # Ensure that all the expected schemas are registered. If not, register them. schemas = self.core_event_schema_paths + self.extra_event_schema_paths for schema_path in schemas: # Try registering the event. try: logger.register_event_schema(schema_path) # Pass if it already exists. except SchemaRegistryException: pass return logger def emit(self, schema_id, data): """Emit an event from the kernel manager.""" self.event_logger.emit(schema_id=schema_id, data=data) @overrides @emit_kernel_action_event( success_msg="Kernel {kernel_id} was started.", ) async def start_kernel(self, *args, **kwargs): return await super().start_kernel(*args, **kwargs) @overrides @emit_kernel_action_event( success_msg="Kernel {kernel_id} was shutdown.", ) async def shutdown_kernel(self, *args, **kwargs): return await super().shutdown_kernel(*args, **kwargs) @overrides @emit_kernel_action_event( success_msg="Kernel {kernel_id} was restarted.", ) async def restart_kernel(self, *args, **kwargs): return await super().restart_kernel(*args, **kwargs) @overrides @emit_kernel_action_event( success_msg="Kernel {kernel_id} was interrupted.", ) async def interrupt_kernel(self, *args, **kwargs): return await super().interrupt_kernel(*args, **kwargs) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernels/websocket.py000066400000000000000000000067171473126534200312440ustar00rootroot00000000000000"""Tornado handlers for WebSocket <-> ZMQ sockets.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from jupyter_core.utils import ensure_async from tornado import web from tornado.websocket import WebSocketHandler from jupyter_server.auth.decorator import ws_authenticated from jupyter_server.base.handlers import JupyterHandler from jupyter_server.base.websocket import WebSocketMixin AUTH_RESOURCE = "kernels" class KernelWebsocketHandler(WebSocketMixin, WebSocketHandler, JupyterHandler): # type:ignore[misc] """The kernels websocket should connect""" auth_resource = AUTH_RESOURCE @property def kernel_websocket_connection_class(self): """The kernel websocket connection class.""" return self.settings.get("kernel_websocket_connection_class") def set_default_headers(self): """Undo the set_default_headers in JupyterHandler which doesn't make sense for websockets """ def get_compression_options(self): """Get the socket connection options.""" return self.settings.get("websocket_compression_options", None) async def pre_get(self): """Handle a pre_get.""" user = self.current_user # authorize the user. authorized = await ensure_async( self.authorizer.is_authorized(self, user, "execute", "kernels") ) if not authorized: raise web.HTTPError(403) kernel = self.kernel_manager.get_kernel(self.kernel_id) self.connection = self.kernel_websocket_connection_class( parent=kernel, websocket_handler=self, config=self.config ) if self.get_argument("session_id", None): self.connection.session.session = self.get_argument("session_id") else: self.log.warning("No session ID specified") # For backwards compatibility with older versions # of the websocket connection, call a prepare method if found. if hasattr(self.connection, "prepare"): await self.connection.prepare() @ws_authenticated async def get(self, kernel_id): """Handle a get request for a kernel.""" self.kernel_id = kernel_id await self.pre_get() await super().get(kernel_id=kernel_id) async def open(self, kernel_id): """Open a kernel websocket.""" # Need to call super here to make sure we # begin a ping-pong loop with the client. super().open() # Wait for the kernel to emit an idle status. self.log.info(f"Connecting to kernel {self.kernel_id}.") await self.connection.connect() def on_message(self, ws_message): """Get a kernel message from the websocket and turn it into a ZMQ message.""" self.connection.handle_incoming_message(ws_message) def on_close(self): """Handle a socket closure.""" self.connection.disconnect() self.connection = None def select_subprotocol(self, subprotocols): """Select the sub protocol for the socket.""" preferred_protocol = self.connection.kernel_ws_protocol if preferred_protocol is None: preferred_protocol = "v1.kernel.websocket.jupyter.org" elif preferred_protocol == "": preferred_protocol = None selected_subprotocol = preferred_protocol if preferred_protocol in subprotocols else None # None is the default, "legacy" protocol return selected_subprotocol jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernelspecs/000077500000000000000000000000001473126534200275445ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernelspecs/__init__.py000066400000000000000000000000001473126534200316430ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/kernelspecs/handlers.py000066400000000000000000000075201473126534200317220ustar00rootroot00000000000000"""Tornado handlers for kernel specifications. Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-25%3A-Registry-of-installed-kernels#rest-api """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import glob import json import os from typing import Any pjoin = os.path.join from jupyter_core.utils import ensure_async from tornado import web from jupyter_server.auth.decorator import authorized from ...base.handlers import APIHandler from ...utils import url_path_join, url_unescape AUTH_RESOURCE = "kernelspecs" def kernelspec_model(handler, name, spec_dict, resource_dir): """Load a KernelSpec by name and return the REST API model""" d = {"name": name, "spec": spec_dict, "resources": {}} # Add resource files if they exist for resource in ["kernel.js", "kernel.css"]: if os.path.exists(pjoin(resource_dir, resource)): d["resources"][resource] = url_path_join( handler.base_url, "kernelspecs", name, resource ) for logo_file in glob.glob(pjoin(resource_dir, "logo-*")): fname = os.path.basename(logo_file) no_ext, _ = os.path.splitext(fname) d["resources"][no_ext] = url_path_join(handler.base_url, "kernelspecs", name, fname) return d def is_kernelspec_model(spec_dict): """Returns True if spec_dict is already in proper form. This will occur when using a gateway.""" return ( isinstance(spec_dict, dict) and "name" in spec_dict and "spec" in spec_dict and "resources" in spec_dict ) class KernelSpecsAPIHandler(APIHandler): """A kernel spec API handler.""" auth_resource = AUTH_RESOURCE class MainKernelSpecHandler(KernelSpecsAPIHandler): """The root kernel spec handler.""" @web.authenticated @authorized async def get(self): """Get the list of kernel specs.""" ksm = self.kernel_spec_manager km = self.kernel_manager model: dict[str, Any] = {} model["default"] = km.default_kernel_name model["kernelspecs"] = specs = {} kspecs = await ensure_async(ksm.get_all_specs()) for kernel_name, kernel_info in kspecs.items(): try: if is_kernelspec_model(kernel_info): d = kernel_info else: d = kernelspec_model( self, kernel_name, kernel_info["spec"], kernel_info["resource_dir"], ) except Exception: self.log.error("Failed to load kernel spec: '%s'", kernel_name, exc_info=True) continue specs[kernel_name] = d self.set_header("Content-Type", "application/json") self.finish(json.dumps(model)) class KernelSpecHandler(KernelSpecsAPIHandler): """A handler for an individual kernel spec.""" @web.authenticated @authorized async def get(self, kernel_name): """Get a kernel spec model.""" ksm = self.kernel_spec_manager kernel_name = url_unescape(kernel_name) try: spec = await ensure_async(ksm.get_kernel_spec(kernel_name)) except KeyError as e: raise web.HTTPError(404, "Kernel spec %s not found" % kernel_name) from e if is_kernelspec_model(spec): model = spec else: model = kernelspec_model(self, kernel_name, spec.to_dict(), spec.resource_dir) self.set_header("Content-Type", "application/json") self.finish(json.dumps(model)) # URL to handler mappings kernel_name_regex = r"(?P[\w\.\-%]+)" default_handlers = [ (r"/api/kernelspecs", MainKernelSpecHandler), (r"/api/kernelspecs/%s" % kernel_name_regex, KernelSpecHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/nbconvert/000077500000000000000000000000001473126534200272265ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/nbconvert/__init__.py000066400000000000000000000000001473126534200313250ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/nbconvert/handlers.py000066400000000000000000000043361473126534200314060ustar00rootroot00000000000000"""API Handlers for nbconvert.""" import asyncio import json from anyio.to_thread import run_sync from tornado import web from jupyter_server.auth.decorator import authorized from ...base.handlers import APIHandler AUTH_RESOURCE = "nbconvert" class NbconvertRootHandler(APIHandler): """The nbconvert root API handler.""" auth_resource = AUTH_RESOURCE _exporter_lock: asyncio.Lock def initialize(self, **kwargs): """Initialize an nbconvert root handler.""" super().initialize(**kwargs) # share lock across instances of this handler class if not hasattr(self.__class__, "_exporter_lock"): self.__class__._exporter_lock = asyncio.Lock() self._exporter_lock = self.__class__._exporter_lock @web.authenticated @authorized async def get(self): """Get the list of nbconvert exporters.""" try: from nbconvert.exporters import base except ImportError as e: raise web.HTTPError(500, "Could not import nbconvert: %s" % e) from e res = {} # Some exporters use the filesystem when instantiating, delegate that # to a thread so we don't block the event loop for it. exporters = await run_sync(base.get_export_names) async with self._exporter_lock: for exporter_name in exporters: try: exporter_class = await run_sync(base.get_exporter, exporter_name) except ValueError: # I think the only way this will happen is if the entrypoint # is uninstalled while this method is running continue # XXX: According to the docs, it looks like this should be set to None # if the exporter shouldn't be exposed to the front-end and a friendly # name if it should. However, none of the built-in exports have it defined. # if not exporter_class.export_from_notebook: # continue res[exporter_name] = { "output_mimetype": exporter_class.output_mimetype, } self.finish(json.dumps(res)) default_handlers = [ (r"/api/nbconvert", NbconvertRootHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/security/000077500000000000000000000000001473126534200270755ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/security/__init__.py000066400000000000000000000004071473126534200312070ustar00rootroot00000000000000# URI for the CSP Report. Included here to prevent a cyclic dependency. # csp_report_uri is needed both by the BaseHandler (for setting the report-uri) # and by the CSPReportHandler (which depends on the BaseHandler). csp_report_uri = r"/api/security/csp-report" jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/security/handlers.py000066400000000000000000000017761473126534200312620ustar00rootroot00000000000000"""Tornado handlers for security logging.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from tornado import web from jupyter_server.auth.decorator import authorized from ...base.handlers import APIHandler from . import csp_report_uri AUTH_RESOURCE = "csp" class CSPReportHandler(APIHandler): """Accepts a content security policy violation report""" auth_resource = AUTH_RESOURCE _track_activity = False def skip_check_origin(self): """Don't check origin when reporting origin-check violations!""" return True def check_xsrf_cookie(self): """Don't check XSRF for CSP reports.""" return @web.authenticated @authorized def post(self): """Log a content security policy violation report""" self.log.warning( "Content security violation: %s", self.request.body.decode("utf8", "replace"), ) default_handlers = [(csp_report_uri, CSPReportHandler)] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/sessions/000077500000000000000000000000001473126534200270745ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/sessions/__init__.py000066400000000000000000000000001473126534200311730ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/sessions/handlers.py000066400000000000000000000203601473126534200312470ustar00rootroot00000000000000"""Tornado handlers for the sessions web service. Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-16%3A-Notebook-multi-directory-dashboard-and-URL-mapping#sessions-api """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import asyncio import json try: from jupyter_client.jsonutil import json_default except ImportError: from jupyter_client.jsonutil import date_default as json_default from jupyter_client.kernelspec import NoSuchKernel from jupyter_core.utils import ensure_async from tornado import web from jupyter_server.auth.decorator import authorized from jupyter_server.utils import url_path_join from ...base.handlers import APIHandler AUTH_RESOURCE = "sessions" class SessionsAPIHandler(APIHandler): """A Sessions API handler.""" auth_resource = AUTH_RESOURCE class SessionRootHandler(SessionsAPIHandler): """A Session Root API handler.""" @web.authenticated @authorized async def get(self): """Get a list of running sessions.""" sm = self.session_manager sessions = await ensure_async(sm.list_sessions()) self.finish(json.dumps(sessions, default=json_default)) @web.authenticated @authorized async def post(self): """Create a new session.""" # (unless a session already exists for the named session) sm = self.session_manager model = self.get_json_body() if model is None: raise web.HTTPError(400, "No JSON data provided") if "notebook" in model: self.log.warning("Sessions API changed, see updated swagger docs") model["type"] = "notebook" if "name" in model["notebook"]: model["path"] = model["notebook"]["name"] elif "path" in model["notebook"]: model["path"] = model["notebook"]["path"] try: # There is a high chance here that `path` is not a path but # a unique session id path = model["path"] except KeyError as e: raise web.HTTPError(400, "Missing field in JSON data: path") from e try: mtype = model["type"] except KeyError as e: raise web.HTTPError(400, "Missing field in JSON data: type") from e name = model.get("name", None) kernel = model.get("kernel", {}) kernel_name = kernel.get("name", None) kernel_id = kernel.get("id", None) if not kernel_id and not kernel_name: self.log.debug("No kernel specified, using default kernel") kernel_name = None exists = await ensure_async(sm.session_exists(path=path)) if exists: s_model = await sm.get_session(path=path) else: try: s_model = await sm.create_session( path=path, kernel_name=kernel_name, kernel_id=kernel_id, name=name, type=mtype, ) except NoSuchKernel: msg = ( "The '%s' kernel is not available. Please pick another " "suitable kernel instead, or install that kernel." % kernel_name ) status_msg = "%s not found" % kernel_name self.log.warning("Kernel not found: %s" % kernel_name) self.set_status(501) self.finish(json.dumps({"message": msg, "short_message": status_msg})) return except Exception as e: raise web.HTTPError(500, str(e)) from e location = url_path_join(self.base_url, "api", "sessions", s_model["id"]) self.set_header("Location", location) self.set_status(201) self.finish(json.dumps(s_model, default=json_default)) class SessionHandler(SessionsAPIHandler): """A handler for a single session.""" @web.authenticated @authorized async def get(self, session_id): """Get the JSON model for a single session.""" sm = self.session_manager model = await sm.get_session(session_id=session_id) self.finish(json.dumps(model, default=json_default)) @web.authenticated @authorized async def patch(self, session_id): """Patch updates sessions: - path updates session to track renamed paths - kernel.name starts a new kernel with a given kernelspec """ sm = self.session_manager km = self.kernel_manager model = self.get_json_body() if model is None: raise web.HTTPError(400, "No JSON data provided") # get the previous session model before = await sm.get_session(session_id=session_id) changes = {} if "notebook" in model and "path" in model["notebook"]: self.log.warning("Sessions API changed, see updated swagger docs") model["path"] = model["notebook"]["path"] model["type"] = "notebook" if "path" in model: changes["path"] = model["path"] if "name" in model: changes["name"] = model["name"] if "type" in model: changes["type"] = model["type"] if "kernel" in model: # Kernel id takes precedence over name. if model["kernel"].get("id") is not None: kernel_id = model["kernel"]["id"] if kernel_id not in km: raise web.HTTPError(400, "No such kernel: %s" % kernel_id) changes["kernel_id"] = kernel_id elif model["kernel"].get("name") is not None: kernel_name = model["kernel"]["name"] try: kernel_id = await sm.start_kernel_for_session( session_id, kernel_name=kernel_name, name=before["name"], path=before["path"], type=before["type"], ) changes["kernel_id"] = kernel_id except Exception as e: # the error message may contain sensitive information, so we want to # be careful with it, thus we only give the short repr of the exception # and the full traceback. # this should be fine as we are exposing here the same info as when we start a new kernel msg = "The '%s' kernel could not be started: %s" % ( kernel_name, repr(str(e)), ) status_msg = "Error starting kernel %s" % kernel_name self.log.error("Error starting kernel: %s", kernel_name) self.set_status(501) self.finish(json.dumps({"message": msg, "short_message": status_msg})) return await sm.update_session(session_id, **changes) s_model = await sm.get_session(session_id=session_id) if s_model["kernel"]["id"] != before["kernel"]["id"]: # kernel_id changed because we got a new kernel # shutdown the old one fut = asyncio.ensure_future(ensure_async(km.shutdown_kernel(before["kernel"]["id"]))) # If we are not using pending kernels, wait for the kernel to shut down if not getattr(km, "use_pending_kernels", None): await fut self.finish(json.dumps(s_model, default=json_default)) @web.authenticated @authorized async def delete(self, session_id): """Delete the session with given session_id.""" sm = self.session_manager try: await sm.delete_session(session_id) except KeyError as e: # the kernel was deleted but the session wasn't! raise web.HTTPError(410, "Kernel deleted before session") from e self.set_status(204) self.finish() # ----------------------------------------------------------------------------- # URL to handler mappings # ----------------------------------------------------------------------------- _session_id_regex = r"(?P\w+-\w+-\w+-\w+-\w+)" default_handlers = [ (r"/api/sessions/%s" % _session_id_regex, SessionHandler), (r"/api/sessions", SessionRootHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/sessions/sessionmanager.py000066400000000000000000000473411473126534200324750ustar00rootroot00000000000000"""A base class session manager.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import os import pathlib import uuid from typing import Any, NewType, Optional, Union, cast KernelName = NewType("KernelName", str) ModelName = NewType("ModelName", str) try: import sqlite3 except ImportError: # fallback on pysqlite2 if Python was build without sqlite from pysqlite2 import dbapi2 as sqlite3 # type:ignore[no-redef] from dataclasses import dataclass, fields from jupyter_core.utils import ensure_async from tornado import web from traitlets import Instance, TraitError, Unicode, validate from traitlets.config.configurable import LoggingConfigurable from jupyter_server.traittypes import InstanceFromClasses class KernelSessionRecordConflict(Exception): """Exception class to use when two KernelSessionRecords cannot merge because of conflicting data. """ @dataclass class KernelSessionRecord: """A record object for tracking a Jupyter Server Kernel Session. Two records that share a session_id must also share a kernel_id, while kernels can have multiple session (and thereby) session_ids associated with them. """ session_id: Optional[str] = None kernel_id: Optional[str] = None def __eq__(self, other: object) -> bool: """Whether a record equals another.""" if isinstance(other, KernelSessionRecord): condition1 = self.kernel_id and self.kernel_id == other.kernel_id condition2 = all( [ self.session_id == other.session_id, self.kernel_id is None or other.kernel_id is None, ] ) if any([condition1, condition2]): return True # If two records share session_id but have different kernels, this is # and ill-posed expression. This should never be true. Raise an exception # to inform the user. if all( [ self.session_id, self.session_id == other.session_id, self.kernel_id != other.kernel_id, ] ): msg = ( "A single session_id can only have one kernel_id " "associated with. These two KernelSessionRecords share the same " "session_id but have different kernel_ids. This should " "not be possible and is likely an issue with the session " "records." ) raise KernelSessionRecordConflict(msg) return False def update(self, other: "KernelSessionRecord") -> None: """Updates in-place a kernel from other (only accepts positive updates""" if not isinstance(other, KernelSessionRecord): msg = "'other' must be an instance of KernelSessionRecord." # type:ignore[unreachable] raise TypeError(msg) if other.kernel_id and self.kernel_id and other.kernel_id != self.kernel_id: msg = "Could not update the record from 'other' because the two records conflict." raise KernelSessionRecordConflict(msg) for field in fields(self): if hasattr(other, field.name) and getattr(other, field.name): setattr(self, field.name, getattr(other, field.name)) class KernelSessionRecordList: """An object for storing and managing a list of KernelSessionRecords. When adding a record to the list, the KernelSessionRecordList first checks if the record already exists in the list. If it does, the record will be updated with the new information; otherwise, it will be appended. """ _records: list[KernelSessionRecord] def __init__(self, *records: KernelSessionRecord): """Initialize a record list.""" self._records = [] for record in records: self.update(record) def __str__(self): """The string representation of a record list.""" return str(self._records) def __contains__(self, record: Union[KernelSessionRecord, str]) -> bool: """Search for records by kernel_id and session_id""" if isinstance(record, KernelSessionRecord) and record in self._records: return True if isinstance(record, str): for r in self._records: if record in [r.session_id, r.kernel_id]: return True return False def __len__(self): """The length of the record list.""" return len(self._records) def get(self, record: Union[KernelSessionRecord, str]) -> KernelSessionRecord: """Return a full KernelSessionRecord from a session_id, kernel_id, or incomplete KernelSessionRecord. """ if isinstance(record, str): for r in self._records: if record in (r.kernel_id, r.session_id): return r elif isinstance(record, KernelSessionRecord): for r in self._records: if record == r: return record msg = f"{record} not found in KernelSessionRecordList." raise ValueError(msg) def update(self, record: KernelSessionRecord) -> None: """Update a record in-place or append it if not in the list.""" try: idx = self._records.index(record) self._records[idx].update(record) except ValueError: self._records.append(record) def remove(self, record: KernelSessionRecord) -> None: """Remove a record if its found in the list. If it's not found, do nothing. """ if record in self._records: self._records.remove(record) class SessionManager(LoggingConfigurable): """A session manager.""" database_filepath = Unicode( default_value=":memory:", help=( "The filesystem path to SQLite Database file " "(e.g. /path/to/session_database.db). By default, the session " "database is stored in-memory (i.e. `:memory:` setting from sqlite3) " "and does not persist when the current Jupyter Server shuts down." ), ).tag(config=True) @validate("database_filepath") def _validate_database_filepath(self, proposal): """Validate a database file path.""" value = proposal["value"] if value == ":memory:": return value path = pathlib.Path(value) if path.exists(): # Verify that the database path is not a directory. if path.is_dir(): msg = "`database_filepath` expected a file path, but the given path is a directory." raise TraitError(msg) # Verify that database path is an SQLite 3 Database by checking its header. with open(value, "rb") as f: header = f.read(100) if not header.startswith(b"SQLite format 3") and header != b"": msg = "The given file is not an SQLite database file." raise TraitError(msg) return value kernel_manager = Instance("jupyter_server.services.kernels.kernelmanager.MappingKernelManager") contents_manager = InstanceFromClasses( [ "jupyter_server.services.contents.manager.ContentsManager", "notebook.services.contents.manager.ContentsManager", ] ) def __init__(self, *args, **kwargs): """Initialize a record list.""" super().__init__(*args, **kwargs) self._pending_sessions = KernelSessionRecordList() # Session database initialized below _cursor = None _connection = None _columns = {"session_id", "path", "name", "type", "kernel_id"} @property def cursor(self): """Start a cursor and create a database called 'session'""" if self._cursor is None: self._cursor = self.connection.cursor() self._cursor.execute( """CREATE TABLE IF NOT EXISTS session (session_id, path, name, type, kernel_id)""" ) return self._cursor @property def connection(self): """Start a database connection""" if self._connection is None: # Set isolation level to None to autocommit all changes to the database. self._connection = sqlite3.connect(self.database_filepath, isolation_level=None) self._connection.row_factory = sqlite3.Row return self._connection def close(self): """Close the sqlite connection""" if self._cursor is not None: self._cursor.close() self._cursor = None def __del__(self): """Close connection once SessionManager closes""" self.close() async def session_exists(self, path): """Check to see if the session of a given name exists""" exists = False self.cursor.execute("SELECT * FROM session WHERE path=?", (path,)) row = self.cursor.fetchone() if row is not None: # Note, although we found a row for the session, the associated kernel may have # been culled or died unexpectedly. If that's the case, we should delete the # row, thereby terminating the session. This can be done via a call to # row_to_model that tolerates that condition. If row_to_model returns None, # we'll return false, since, at that point, the session doesn't exist anyway. model = await self.row_to_model(row, tolerate_culled=True) if model is not None: exists = True return exists def new_session_id(self) -> str: """Create a uuid for a new session""" return str(uuid.uuid4()) async def create_session( self, path: Optional[str] = None, name: Optional[ModelName] = None, type: Optional[str] = None, kernel_name: Optional[KernelName] = None, kernel_id: Optional[str] = None, ) -> dict[str, Any]: """Creates a session and returns its model Parameters ---------- name: ModelName(str) Usually the model name, like the filename associated with current kernel. """ session_id = self.new_session_id() record = KernelSessionRecord(session_id=session_id) self._pending_sessions.update(record) if kernel_id is not None and kernel_id in self.kernel_manager: pass else: kernel_id = await self.start_kernel_for_session( session_id, path, name, type, kernel_name ) record.kernel_id = kernel_id self._pending_sessions.update(record) result = await self.save_session( session_id, path=path, name=name, type=type, kernel_id=kernel_id ) self._pending_sessions.remove(record) return cast(dict[str, Any], result) def get_kernel_env( self, path: Optional[str], name: Optional[ModelName] = None ) -> dict[str, str]: """Return the environment variables that need to be set in the kernel Parameters ---------- path : str the url path for the given session. name: ModelName(str), optional Here the name is likely to be the name of the associated file with the current kernel at startup time. """ if name is not None: cwd = self.kernel_manager.cwd_for_path(path) path = os.path.join(cwd, name) assert isinstance(path, str) return {**os.environ, "JPY_SESSION_NAME": path} async def start_kernel_for_session( self, session_id: str, path: Optional[str], name: Optional[ModelName], type: Optional[str], kernel_name: Optional[KernelName], ) -> str: """Start a new kernel for a given session. Parameters ---------- session_id : str uuid for the session; this method must be given a session_id path : str the path for the given session - seem to be a session id sometime. name : str Usually the model name, like the filename associated with current kernel. type : str the type of the session kernel_name : str the name of the kernel specification to use. The default kernel name will be used if not provided. """ # allow contents manager to specify kernels cwd kernel_path = await ensure_async(self.contents_manager.get_kernel_path(path=path)) kernel_env = self.get_kernel_env(path, name) kernel_id = await self.kernel_manager.start_kernel( path=kernel_path, kernel_name=kernel_name, env=kernel_env, ) return cast(str, kernel_id) async def save_session(self, session_id, path=None, name=None, type=None, kernel_id=None): """Saves the items for the session with the given session_id Given a session_id (and any other of the arguments), this method creates a row in the sqlite session database that holds the information for a session. Parameters ---------- session_id : str uuid for the session; this method must be given a session_id path : str the path for the given session name : str the name of the session type : str the type of the session kernel_id : str a uuid for the kernel associated with this session Returns ------- model : dict a dictionary of the session model """ self.cursor.execute( "INSERT INTO session VALUES (?,?,?,?,?)", (session_id, path, name, type, kernel_id), ) result = await self.get_session(session_id=session_id) return result async def get_session(self, **kwargs): """Returns the model for a particular session. Takes a keyword argument and searches for the value in the session database, then returns the rest of the session's info. Parameters ---------- **kwargs : dict must be given one of the keywords and values from the session database (i.e. session_id, path, name, type, kernel_id) Returns ------- model : dict returns a dictionary that includes all the information from the session described by the kwarg. """ if not kwargs: msg = "must specify a column to query" raise TypeError(msg) conditions = [] for column in kwargs: if column not in self._columns: msg = f"No such column: {column}" raise TypeError(msg) conditions.append("%s=?" % column) query = "SELECT * FROM session WHERE %s" % (" AND ".join(conditions)) # noqa: S608 self.cursor.execute(query, list(kwargs.values())) try: row = self.cursor.fetchone() except KeyError: # The kernel is missing, so the session just got deleted. row = None if row is None: q = [] for key, value in kwargs.items(): q.append(f"{key}={value!r}") raise web.HTTPError(404, "Session not found: %s" % (", ".join(q))) try: model = await self.row_to_model(row) except KeyError as e: raise web.HTTPError(404, "Session not found: %s" % str(e)) from e return model async def update_session(self, session_id, **kwargs): """Updates the values in the session database. Changes the values of the session with the given session_id with the values from the keyword arguments. Parameters ---------- session_id : str a uuid that identifies a session in the sqlite3 database **kwargs : str the key must correspond to a column title in session database, and the value replaces the current value in the session with session_id. """ await self.get_session(session_id=session_id) if not kwargs: # no changes return sets = [] for column in kwargs: if column not in self._columns: raise TypeError("No such column: %r" % column) sets.append("%s=?" % column) query = "UPDATE session SET %s WHERE session_id=?" % (", ".join(sets)) # noqa: S608 self.cursor.execute(query, [*list(kwargs.values()), session_id]) if hasattr(self.kernel_manager, "update_env"): self.cursor.execute( "SELECT path, name, kernel_id FROM session WHERE session_id=?", [session_id] ) path, name, kernel_id = self.cursor.fetchone() self.kernel_manager.update_env(kernel_id=kernel_id, env=self.get_kernel_env(path, name)) async def kernel_culled(self, kernel_id: str) -> bool: """Checks if the kernel is still considered alive and returns true if its not found.""" return kernel_id not in self.kernel_manager async def row_to_model(self, row, tolerate_culled=False): """Takes sqlite database session row and turns it into a dictionary""" kernel_culled: bool = await ensure_async(self.kernel_culled(row["kernel_id"])) if kernel_culled: # The kernel was culled or died without deleting the session. # We can't use delete_session here because that tries to find # and shut down the kernel - so we'll delete the row directly. # # If caller wishes to tolerate culled kernels, log a warning # and return None. Otherwise, raise KeyError with a similar # message. self.cursor.execute("DELETE FROM session WHERE session_id=?", (row["session_id"],)) msg = ( "Kernel '{kernel_id}' appears to have been culled or died unexpectedly, " "invalidating session '{session_id}'. The session has been removed.".format( kernel_id=row["kernel_id"], session_id=row["session_id"] ) ) if tolerate_culled: self.log.warning(f"{msg} Continuing...") return None raise KeyError(msg) kernel_model = await ensure_async(self.kernel_manager.kernel_model(row["kernel_id"])) model = { "id": row["session_id"], "path": row["path"], "name": row["name"], "type": row["type"], "kernel": kernel_model, } if row["type"] == "notebook": # Provide the deprecated API. model["notebook"] = {"path": row["path"], "name": row["name"]} return model async def list_sessions(self): """Returns a list of dictionaries containing all the information from the session database""" c = self.cursor.execute("SELECT * FROM session") result = [] # We need to use fetchall() here, because row_to_model can delete rows, # which messes up the cursor if we're iterating over rows. for row in c.fetchall(): try: model = await self.row_to_model(row) result.append(model) except KeyError: pass return result async def delete_session(self, session_id): """Deletes the row in the session database with given session_id""" record = KernelSessionRecord(session_id=session_id) self._pending_sessions.update(record) session = await self.get_session(session_id=session_id) await ensure_async(self.kernel_manager.shutdown_kernel(session["kernel"]["id"])) self.cursor.execute("DELETE FROM session WHERE session_id=?", (session_id,)) self._pending_sessions.remove(record) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/services/shutdown.py000066400000000000000000000012441473126534200274540ustar00rootroot00000000000000"""HTTP handler to shut down the Jupyter server.""" from tornado import ioloop, web from jupyter_server.auth.decorator import authorized from jupyter_server.base.handlers import JupyterHandler AUTH_RESOURCE = "server" class ShutdownHandler(JupyterHandler): """A shutdown API handler.""" auth_resource = AUTH_RESOURCE @web.authenticated @authorized async def post(self): """Shut down the server.""" self.log.info("Shutting down on /api/shutdown request.") if self.serverapp: await self.serverapp._cleanup() ioloop.IOLoop.current().stop() default_handlers = [ (r"/api/shutdown", ShutdownHandler), ] jupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/000077500000000000000000000000001473126534200246725ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/favicon.ico000066400000000000000000000764461473126534200270340ustar00rootroot00000000000000 hF 00 %V@@ (B:(  @&w&wW&ww&ww&wW&w&w%&w&w&w&w&w&w&w&w&w%&wO&w&w&w&w&w&w&w&w&w&w&wO&wA&w&w&wW'w&x&wW&w&w&wA'y &w&w)&w)&w'y 'x&z'x'x'x'x&z'x'y &w&w)&w)&w'y &wA&w&w&wW&w&x&wW&w&w&wA&wO&w&w&w&w&w&w&w&w&w&w&wO&w%&w&w&w&w&w&w&w&w&w%&w&wW&ww&ww&wW'w( @ (w &xW&w&w&w&w&w&w&w&w&xW(w 'y#&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y#+y &w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w+y 'x!&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x!'y1&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y1(x%&w&w&w&w&w&w'wM'z'z&wM&w&w&w&w&w&w(x%'| &w&w&w&w'w)(w)&w&w&w&w'| &w&w&w(x&z&w&w&w'y%&w'x5(x5&w'y%'xg&z'x'xe+x +x +x +x 'xe'x&z'xg'y%&w(x5'x5&w'y%&w&w&w'x(x&w&w&w'| &w&w&w&w(x''w)&w&w&w&w'| (x%&w&w&w&w&w&w&wM'z&{'wM&w&w&w&w&w&w(x%'y1&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y1'x!&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x!+y &w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w+y 'y#&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y#(w &xW&w&w&w&w&w&w&w&w'wU(w (0` %+z 'x/'xO&wa&wm%wy%wy&wm&wa'xO'x/+z (w &wE&w&w&w&w&w&w&w&w&w&w&w&w&w&x&wE(w )z 'y3&xy&w&w&w&w&w&w&w&w&w&w%w&w&w%w&w&w%w&w&xy'y3)z )y!&w&w&w&v&w%w&v&w%w&v&w%w%v&w%w%v&w%w%v&w%w&v&w&w)y!+y &xY&w%v%v&w%w%v&w%w%v&w%w%v&w&v&w&w&v&w&w&v&w&w&v&w&v&w&xY+y 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w%w&w&w%w&w&w%w&w&w%w&w&w&w&w'x.'y/&w&v&v&w&v&w%w&v&w%w&v&w%w&v&w%w%v&w%w%v&w%w%v&w%w%v&w%w%v&w%w&w'y/.'z%&w&w&w&w&w%w%v&w%w%v&w&w&w&w&w&w&w&w&w&w&w&w%v&v&w&w&v&w&w&v&w&w&w&w'z'(w&w&w&w&w&w&w&w&w&w'w'wI'|(z'xG&w}&w&w&v&w%w&w&w%w&w'w&x&w&v&v&w%v&v&w'x_'w+(w*}(x+&w]&w&v&w%v&w%w&w&w'|'wu&w&w&w&w'ws'w/'w)w(w-&wo&v&w&w&w'wu'|&vI&w&w&w&xk(x&z &wi&w&w&v&wI& &w%w'x'y+&w)'w&w&w& 'yM&w&wi*z+x'wg&w'yM)}&x&y)'x)&w)}(x#(x+(x+(x#+x+x+x+w*w+x+x+x(x#(x+(x+(x#)}&w'x)&z)&x)}'yM&w'wg+x)x&wg&w'yM& &w&w'w&w)'y+&w&w&w% &vG&w&v&w'wi'x 'x&xk&w&w&v&wI'|&wu&w&w&w&w&wo(x-)y'w&x/&ws&w&w&w&w'wu'|'x&w%w&w&w&w&w&w&w](x)*}(w'w+'x_&w&w&v&w&v&w&w&w(x&w%w&w&w%w&w&w&w&w&v}'xG(y&}&xI'v&w&w&w&w&v&w&w&v&w(x'z'&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'z'.'y/&w%w&w&w&w&w&v&w&w&v&w&w&v&w&w&v&w&v&w&w&v&w&w&v&w&w&v&w&w&v&w&w'y/-'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&v&w&w&v&w&w&v&w&w&v&w&w&w&w'x+y &xY&w&w&w%w%w&w%w%w&w%w%w&w%w&w&v&w&w&v&w&w&v&w&w&v%v&w&xY+y )z!&w&w&w%w&w&w%w&w&w%w&w&w&w&w%w&w&w%w&w&w%w%w&w&w)z!)z 'y3&wy&w&w%w%w&w%w%w&w%w&w&w&w&w&w&w&w&w&w&wy&y3)z (w &wE&w&w&w&w&w&w&w&w%w&w&w&w&w'w'wE(w +z 'x/'xO&va&wm&wy&wy&wm&wa'xO'x/+z (@ B3 (xA'x{'x'w&w&x&w&w&x&w'w'x'x{(xA3 (w-&x'x'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'x&x(w-. 'yc&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'yc. 3 'y'w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'w'y3 )zY&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w)zY+y+&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x+y+(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 3'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x&x'x'w&x(yu'wi'wi(yu&x'x'w'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x3'x&w&w&w&w&w&w&w&w&w&w&w'w'w}'xC')z'zA(w{&x'x&w&w&w&w&w&w&w&w&w&w'x(x&w&w&w&w&w&w&w&w'x'x}(w-*}+(w{&w&w&w&w&w&w&w&w&w(x'xi&w&w&w&w&w&w&w'w}(y'+x%(w{'w&w&w&w&w&w&w'xi'|!'w&w&w&w&w'x(w[@@&xW&w&w&w&w&w'w'|!'w&w&w&w&x(xG&zC'w&w&w&w'w'wi&w&w'x'yo@U&wk'w&w&w'wi&'x&w&x.1y'x&w'x&(y&x&x'x}&x(y. &x&zI'xI&w. (ye)xE)xE(ye+x%+x%+x%+x%(ye)xE)xE(ye. &w'xI&zI&x. (y&x'x}&x&x(y&'x&w'x1y,z&x&w'x&'wi&w&w'w&wkU@'yo'x&w&w'wi'w&w&w&w'w'xC(xG&x&w&w&w'w'|!'w&w&w&w&w&w&xWU3(w['x&w&w&w&w'w'|!'xi&w&w&w&w&w&w'w(xy,|#(y''w}&w&w&w&w&w&w&w'xi(x&w&w&w&w&w&w&w&w&x(w{*}+(w-'x}'x&w&w&w&w&w&w&w&w(x'x&w&w&w&w&w&w&w&w&w&w'w&w(w{'zA)z'&zC'w}'w&w&w&w&w&w&w&w&w&w&w&w'x3'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'w'x&x(yu'wi'wi(yu&x'w'x&x&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x3. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS+y+&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x+y+)zY&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w)zY3 'y'w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'y3 . 'xc&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'xc. (w-&x'x'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'x'w(w-3 (xA'x{'x'w&w&x&w&w&x&w'w'x'x{(xA3 jupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/favicons/000077500000000000000000000000001473126534200265025ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/favicons/favicon-busy-1.ico000066400000000000000000000021761473126534200317470ustar00rootroot00000000000000 h(  %%+,uv6  665jupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/favicons/favicon-busy-2.ico000066400000000000000000000021761473126534200317500ustar00rootroot00000000000000 h(  %%6665wu*+jupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/favicons/favicon-busy-3.ico000066400000000000000000000021761473126534200317510ustar00rootroot00000000000000 h(  %%6621226  5tw*- jupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/favicons/favicon-file.ico000066400000000000000000000021761473126534200315460ustar00rootroot00000000000000 h(  %%mim ߃߳ߣ߇jupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/favicons/favicon-notebook.ico000066400000000000000000000021761473126534200324470ustar00rootroot00000000000000 h(  %%@X-2i?0~jA1l4f=/Ãjupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/favicons/favicon-terminal.ico000066400000000000000000000021761473126534200324420ustar00rootroot00000000000000 h(  %%qcjupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/favicons/favicon.ico000066400000000000000000000764461473126534200306440ustar00rootroot00000000000000 hF 00 %V@@ (B:(  @&w&wW&ww&ww&wW&w&w%&w&w&w&w&w&w&w&w&w%&wO&w&w&w&w&w&w&w&w&w&w&wO&wA&w&w&wW'w&x&wW&w&w&wA'y &w&w)&w)&w'y 'x&z'x'x'x'x&z'x'y &w&w)&w)&w'y &wA&w&w&wW&w&x&wW&w&w&wA&wO&w&w&w&w&w&w&w&w&w&w&wO&w%&w&w&w&w&w&w&w&w&w%&w&wW&ww&ww&wW'w( @ (w &xW&w&w&w&w&w&w&w&w&xW(w 'y#&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y#+y &w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w+y 'x!&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x!'y1&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y1(x%&w&w&w&w&w&w'wM'z'z&wM&w&w&w&w&w&w(x%'| &w&w&w&w'w)(w)&w&w&w&w'| &w&w&w(x&z&w&w&w'y%&w'x5(x5&w'y%'xg&z'x'xe+x +x +x +x 'xe'x&z'xg'y%&w(x5'x5&w'y%&w&w&w'x(x&w&w&w'| &w&w&w&w(x''w)&w&w&w&w'| (x%&w&w&w&w&w&w&wM'z&{'wM&w&w&w&w&w&w(x%'y1&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y1'x!&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x!+y &w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w+y 'y#&w&w&w&w&w&w&w&w&w&w&w&w&w&w'y#(w &xW&w&w&w&w&w&w&w&w'wU(w (0` %+z 'x/'xO&wa&wm%wy%wy&wm&wa'xO'x/+z (w &wE&w&w&w&w&w&w&w&w&w&w&w&w&w&x&wE(w )z 'y3&xy&w&w&w&w&w&w&w&w&w&w%w&w&w%w&w&w%w&w&xy'y3)z )y!&w&w&w&v&w%w&v&w%w&v&w%w%v&w%w%v&w%w%v&w%w&v&w&w)y!+y &xY&w%v%v&w%w%v&w%w%v&w%w%v&w&v&w&w&v&w&w&v&w&w&v&w&v&w&xY+y 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w%w&w&w%w&w&w%w&w&w%w&w&w&w&w'x.'y/&w&v&v&w&v&w%w&v&w%w&v&w%w&v&w%w%v&w%w%v&w%w%v&w%w%v&w%w%v&w%w&w'y/.'z%&w&w&w&w&w%w%v&w%w%v&w&w&w&w&w&w&w&w&w&w&w&w%v&v&w&w&v&w&w&v&w&w&w&w'z'(w&w&w&w&w&w&w&w&w&w'w'wI'|(z'xG&w}&w&w&v&w%w&w&w%w&w'w&x&w&v&v&w%v&v&w'x_'w+(w*}(x+&w]&w&v&w%v&w%w&w&w'|'wu&w&w&w&w'ws'w/'w)w(w-&wo&v&w&w&w'wu'|&vI&w&w&w&xk(x&z &wi&w&w&v&wI& &w%w'x'y+&w)'w&w&w& 'yM&w&wi*z+x'wg&w'yM)}&x&y)'x)&w)}(x#(x+(x+(x#+x+x+x+w*w+x+x+x(x#(x+(x+(x#)}&w'x)&z)&x)}'yM&w'wg+x)x&wg&w'yM& &w&w'w&w)'y+&w&w&w% &vG&w&v&w'wi'x 'x&xk&w&w&v&wI'|&wu&w&w&w&w&wo(x-)y'w&x/&ws&w&w&w&w'wu'|'x&w%w&w&w&w&w&w&w](x)*}(w'w+'x_&w&w&v&w&v&w&w&w(x&w%w&w&w%w&w&w&w&w&v}'xG(y&}&xI'v&w&w&w&w&v&w&w&v&w(x'z'&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'z'.'y/&w%w&w&w&w&w&v&w&w&v&w&w&v&w&w&v&w&v&w&w&v&w&w&v&w&w&v&w&w&v&w&w'y/-'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&v&w&w&v&w&w&v&w&w&v&w&w&w&w'x+y &xY&w&w&w%w%w&w%w%w&w%w%w&w%w&w&v&w&w&v&w&w&v&w&w&v%v&w&xY+y )z!&w&w&w%w&w&w%w&w&w%w&w&w&w&w%w&w&w%w&w&w%w%w&w&w)z!)z 'y3&wy&w&w%w%w&w%w%w&w%w&w&w&w&w&w&w&w&w&w&wy&y3)z (w &wE&w&w&w&w&w&w&w&w%w&w&w&w&w'w'wE(w +z 'x/'xO&va&wm&wy&wy&wm&wa'xO'x/+z (@ B3 (xA'x{'x'w&w&x&w&w&x&w'w'x'x{(xA3 (w-&x'x'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'x&x(w-. 'yc&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'yc. 3 'y'w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'w'y3 )zY&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w)zY+y+&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x+y+(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 3'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x&x'x'w&x(yu'wi'wi(yu&x'x'w'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x3'x&w&w&w&w&w&w&w&w&w&w&w'w'w}'xC')z'zA(w{&x'x&w&w&w&w&w&w&w&w&w&w'x(x&w&w&w&w&w&w&w&w'x'x}(w-*}+(w{&w&w&w&w&w&w&w&w&w(x'xi&w&w&w&w&w&w&w'w}(y'+x%(w{'w&w&w&w&w&w&w'xi'|!'w&w&w&w&w'x(w[@@&xW&w&w&w&w&w'w'|!'w&w&w&w&x(xG&zC'w&w&w&w'w'wi&w&w'x'yo@U&wk'w&w&w'wi&'x&w&x.1y'x&w'x&(y&x&x'x}&x(y. &x&zI'xI&w. (ye)xE)xE(ye+x%+x%+x%+x%(ye)xE)xE(ye. &w'xI&zI&x. (y&x'x}&x&x(y&'x&w'x1y,z&x&w'x&'wi&w&w'w&wkU@'yo'x&w&w'wi'w&w&w&w'w'xC(xG&x&w&w&w'w'|!'w&w&w&w&w&w&xWU3(w['x&w&w&w&w'w'|!'xi&w&w&w&w&w&w'w(xy,|#(y''w}&w&w&w&w&w&w&w'xi(x&w&w&w&w&w&w&w&w&x(w{*}+(w-'x}'x&w&w&w&w&w&w&w&w(x'x&w&w&w&w&w&w&w&w&w&w'w&w(w{'zA)z'&zC'w}'w&w&w&w&w&w&w&w&w&w&w&w'x3'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'w'x&x(yu'wi'wi(yu&x'w'x&x&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x3. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x. 'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x(yS+y+&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x+y+)zY&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w)zY3 'y'w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'y3 . 'xc&x&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&w&x'xc. (w-&x'x'x&w&w&w&w&w&w&w&w&w&w&w&w&w&w'x'x'w(w-3 (xA'x{'x'w&w&x&w&w&x&w'w'x'x{(xA3 jupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/logo/000077500000000000000000000000001473126534200256325ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/static/logo/logo.png000066400000000000000000000134421473126534200273040ustar00rootroot00000000000000PNG  IHDR8vsRGBIDATx xTEN IwA(EaDaaW]$<$#0 #8HDQqp]a@QfQ"!7Iھ7uo;!:uԩsT:U}kM(dgg'k_ӌ]=ڄXtYq%`n5bfΜ9-80+6$@)07JHT89xXk"e7]>\ 8%d(.u2}^Qx u%h23\y<ƞpJ% ,=~3Wʖٷu~LAH4FKz\[e H,~)Jo⼔[7'NTQQ5U׵(WgzNNzMhJy Ø0U>45UF\VOznxDܚFe廱2ӑg1L(N~򙕁1*iy0|RD=M%$%6!aͿ0 0 +$12IJJcٳf*0ř.6*OV3fn(l׍XO2^W\C靇=Ʒ ^o'$\~ٹ̞Z:&b hB +J 腛9sf1dUIgu%`Tj#k!؝4-&Y1N" b(hCB0sFk-*qSjnʕ@Ӕ@Pd"\`qNrl ]c]J}={ghy#ҶF  =0Ec+'϶enڕ@S^e{(~N,Ai4-?߄#.гfTľl_x,Pi0GN]dÇw<ءC?|ܜThPŒn`8Xir|]|K6ƭ:ed_TR~h,etG.uF [m_}8"0e~[oZ"F[kxf & xuUUU:x:@[Myu6lp<}G;ϚXb.3qio_f =%O\"&XScQ,K;1|6ДC}< 17= 82|ڬENֵ׍* W=-Qb;nh1ԳW~EzPd`[_3#<~3#GP>Q!cR ٬'I6ali_Fվef2%nI%ѓ?UY֌RG_K?S^޽{f%; CW2eGӝ6,:L*y*H3ڮ<ڻ g-04-yL\GC~A,'AOoaXK:i_<ŀ9[?a_աS|c7gS L'^x1'xVҿ!7p` e0O%L- e078`[Me,Wc[ĕ|d_<[)_/ҲeVMZLŽ&g} SR ʓPTH:!%9ZήaFptjd^RkBmf/%x$&yÀ-i1cp)`Z /X$FSf`h۷>к{%$[K^ʕ+ѶjsIKr#K'B!bogτL84%1;DJgb3Lb'"7/t1rh U-^읎?TaGT3j5S*(yqa9I6ڧ컔}뭷vj&l+MxU7❏?AKPJYЄ&*KaS k̾> $/qC-/^Mo2::tfre.0;r-UXm]V>|7s!xG-[V$@aq mJA WOhfCv"\/'&Ha2H5C} 53bo^ɱJ{}yYրatkl_MFmT+ JM/\_x3V\9|~.]*& ,=Fͫ Oԩ[J>IrJ~棥 }"&wZ4![ָEnumr@Yg%&ޅ*<3(mwY0MZW8/+JHؿۂ6 Uhi"e ]ueK?lȷPC `'(r{2"'N<`t]K޳gOI7CLZT;*uh ' ܲzәPÌ o+|PqOw1du*PjOCF˺ v8P;a[lj!"f^3#1Mb6E\k 3!`z,|VYTI<#&3U8**P%SQyRVaqpuR iwulk 3/UA<%SNdq*m =uBu EvUIXj4ҕ˥ORq\Q˜ 8QF $G}]|u@QN>EEEo^5OSWYrGՒ^C 3Qf>֘o}PB+rn$$zw`F/ymS_.Ijnť!"M>M֯u& ?>oњ]nݺ6C)?/w(N\x>7F`ہLcA/pf O"}َ]~e - *41@'9~L^0 HQˀ#/Q+m7nVQrI =hsy|EZ~v {60%?&WfB_rEF[c P42%_z^}-;.gRYl}Dwi,7/"&8xw*>mkK^绕x+}Lq8YC 9Rg%x~!? …M6r :X G=\ӗ#< AR =]ahGJ-}q}ק|ر<bO}HQsl bR J2s0/PdE`覲SbrV0N?2Gz+Qtp܈ٶmEs"VK.A/WaB(oc#pWB$F*<iی/ GWrt8<ݛ2}Ibue4G*͘WQ /ngוTT Θ7.㴿?'fl{Io6b\*ѺuH+X-n"L6Oa_O[=@mG a-8pI-B~vRĔJ ; y LQA`Դ!TBB!ք1~͟j;=;oV-Q58,ָ@6ynLjs""8cjU1Nyggĉd 2 '@nf&tG$f?6n\-kS g2{l_?d/WNxӓs>_/BO}1Azi5Wx%*<,^L.6|J콌$K3`=}\)2(GyҐ͖$kL $%/&x5SpH 1xvIH7#;a"ՏgGMt>,kfyfCf7牥.cf-\SUeմl?z.hrݤ3䢲4rPĢR3rCwh6.p d@0]G/;k iu X5՟#Iz΁]x伧;}R>ud'h Eu>avTyF U3mdm]lTgx6K\ͣR5ֿj/0 +gNy̸yIĊ}JnrGKϟdT؊hڐr0A/ \ P$P@_)7/Ʈ,baDaڎcJ"K=bUƮ\ TK™ ::AC-/6rgƮN7 ?'o #header { display: block; background-color: #fff; position: relative; z-index: 100; } body > #header #header-container { display: flex; flex-direction: row; justify-content: space-between; padding: 5px; padding-top: 5px; padding-bottom: 5px; padding-bottom: 5px; padding-top: 5px; box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; } body > #header .header-bar { width: 100%; height: 1px; background: #e7e7e7; margin-bottom: -1px; } .navbar-brand { float: left; height: 30px; padding: 6px 0px; padding-top: 6px; padding-bottom: 6px; padding-left: 0px; font-size: 17px; line-height: 18px; } .navbar-brand, .navbar-nav > li > a { text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25); } .nav { padding-left: 0; margin-bottom: 0; list-style: none; } .center-nav { display: inline-block; margin-bottom: -4px; } div.error { margin: 2em; text-align: center; } div.error > h1 { font-size: 500%; line-height: normal; } div.error > p { font-size: 200%; line-height: normal; } jupyter-server-jupyter_server-e5c7e2b/jupyter_server/templates/000077500000000000000000000000001473126534200254015ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/templates/404.html000066400000000000000000000002221473126534200265720ustar00rootroot00000000000000{% extends "error.html" %} {% block error_detail %}

{% trans %}You are requesting a page that does not exist!{% endtrans %}

{% endblock %} jupyter-server-jupyter_server-e5c7e2b/jupyter_server/templates/browser-open.html000066400000000000000000000007731473126534200307200ustar00rootroot00000000000000{# This template is not served, but written as a file to open in the browser, passing the token without putting it in a command-line argument. #} Opening Jupyter Application

This page should redirect you to a Jupyter application. If it doesn't, click here to go to Jupyter.

jupyter-server-jupyter_server-e5c7e2b/jupyter_server/templates/error.html000066400000000000000000000012151473126534200274170ustar00rootroot00000000000000{% extends "page.html" %} {% block stylesheet %} {{super()}} {% endblock %} {% block site %}
{% block h1_error %}

{{status_code}} : {{status_message}}

{% endblock h1_error %} {% block error_detail %} {% if message %}

{% trans %}The error was:{% endtrans %}

{{message}}
{% endif %} {% endblock error_detail %}
{% endblock %} {% block script %} {% endblock script %} jupyter-server-jupyter_server-e5c7e2b/jupyter_server/templates/login.html000066400000000000000000000105641473126534200274050ustar00rootroot00000000000000{% extends "page.html" %} {% block stylesheet %} {% endblock %} {% block site %}
{% if login_available %} {# login_available means password-login is allowed. Show the form. #}
{% else %}

{% trans %}No login available, you shouldn't be seeing this page.{% endtrans %}

{% endif %} {% if message %}
{% for key in message %}
{{message[key]}}
{% endfor %}
{% endif %} {% if token_available %} {% block token_message %}

Token authentication is enabled

If no password has been configured, you need to open the server with its login token in the URL, or paste it above. This requirement will be lifted if you enable a password.

The command:

jupyter server list
will show you the URLs of running servers with their tokens, which you can copy and paste into your browser. For example:

Currently running servers:
http://localhost:8888/?token=c8de56fa... :: /Users/you/notebooks

or you can paste just the token value into the password field on this page.

See the documentation on how to enable a password in place of token authentication, if you would like to avoid dealing with random tokens.

Cookies are required for authenticated access to the Jupyter server.

{% if allow_password_change %}

{% trans %}Setup a Password{% endtrans %}

You can also setup a password by entering your token and a new password on the fields below:

{{ xsrf_form_html() | safe }}
{% endif %}
{% endblock token_message %} {% endif %}
{% endblock %} {% block script %} {% endblock %} jupyter-server-jupyter_server-e5c7e2b/jupyter_server/templates/logout.html000066400000000000000000000014671473126534200276100ustar00rootroot00000000000000{% extends "page.html" %} {# This template is rendered in response to an authenticated request, so the user is technically logged in. But when the user sees it, the cookie is cleared by the Javascript, so we should render this as if the user was logged out, without e.g. authentication tokens. #} {% set logged_in = False %} {% block stylesheet %} {% endblock %} {% block site %}
{% if message %} {% for key in message %}
{{message[key]}}
{% endfor %} {% endif %} {% if not login_available %} {% trans %}Proceed to the dashboard{% endtrans %}. {% else %} {% trans %}Proceed to the login page{% endtrans %}. {% endif %}
{% endblock %} jupyter-server-jupyter_server-e5c7e2b/jupyter_server/templates/main.html000066400000000000000000000002231473126534200272100ustar00rootroot00000000000000{% extends "page.html" %} {% block site %}

A Jupyter Server is running.

{% endblock site %} jupyter-server-jupyter_server-e5c7e2b/jupyter_server/templates/page.html000066400000000000000000000054221473126534200272060ustar00rootroot00000000000000 {% block title %}Jupyter Server{% endblock %} {% block favicon %} {% endblock %} {% block stylesheet %} {% endblock stylesheet %} {% block meta %} {% endblock meta %}
{% block site %} {% endblock site %}
{% block after_site %} {% endblock after_site %} {% block script %} {% endblock script %} jupyter-server-jupyter_server-e5c7e2b/jupyter_server/templates/view.html000066400000000000000000000010561473126534200272430ustar00rootroot00000000000000 {{page_title}}
jupyter-server-jupyter_server-e5c7e2b/jupyter_server/terminal/000077500000000000000000000000001473126534200252165ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/terminal/__init__.py000066400000000000000000000007211473126534200273270ustar00rootroot00000000000000"""Terminals support.""" import warnings # Shims from jupyter_server_terminals import api_handlers from jupyter_server_terminals.handlers import TermSocket from jupyter_server_terminals.terminalmanager import TerminalManager warnings.warn( "Terminals support has moved to `jupyter_server_terminals`", DeprecationWarning, stacklevel=2, ) def initialize(webapp, root_dir, connection_url, settings): """Included for backward compat, but no-op.""" jupyter-server-jupyter_server-e5c7e2b/jupyter_server/terminal/api_handlers.py000066400000000000000000000002321473126534200302160ustar00rootroot00000000000000"""Terminal API handlers.""" from jupyter_server_terminals.api_handlers import ( TerminalAPIHandler, TerminalHandler, TerminalRootHandler, ) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/terminal/handlers.py000066400000000000000000000003221473126534200273650ustar00rootroot00000000000000"""Tornado handlers for the terminal emulator.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from jupyter_server_terminals.handlers import TermSocket jupyter-server-jupyter_server-e5c7e2b/jupyter_server/terminal/terminalmanager.py000066400000000000000000000004321473126534200307350ustar00rootroot00000000000000"""A MultiTerminalManager for use in the notebook webserver - raises HTTPErrors - creates REST API models """ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from jupyter_server_terminals.terminalmanager import TerminalManager jupyter-server-jupyter_server-e5c7e2b/jupyter_server/traittypes.py000066400000000000000000000225511473126534200261720ustar00rootroot00000000000000"""Custom trait types.""" import inspect from ast import literal_eval from traitlets import Any, ClassBasedTraitType, TraitError, Undefined from traitlets.utils.descriptions import describe class TypeFromClasses(ClassBasedTraitType): # type:ignore[type-arg] """A trait whose value must be a subclass of a class in a specified list of classes.""" default_value: Any def __init__(self, default_value=Undefined, klasses=None, **kwargs): """Construct a Type trait A Type trait specifies that its values must be subclasses of a class in a list of possible classes. If only ``default_value`` is given, it is used for the ``klasses`` as well. If neither are given, both default to ``object``. Parameters ---------- default_value : class, str or None The default value must be a subclass of klass. If an str, the str must be a fully specified class name, like 'foo.bar.Bah'. The string is resolved into real class, when the parent :class:`HasTraits` class is instantiated. klasses : list of class, str [ default object ] Values of this trait must be a subclass of klass. The klass may be specified in a string like: 'foo.bar.MyClass'. The string is resolved into real class, when the parent :class:`HasTraits` class is instantiated. allow_none : bool [ default False ] Indicates whether None is allowed as an assignable value. """ if default_value is Undefined: new_default_value = object if (klasses is None) else klasses else: new_default_value = default_value if klasses is None: if (default_value is None) or (default_value is Undefined): klasses = [object] else: klasses = [default_value] # OneOfType requires a list of klasses to be specified (different than Type). if not isinstance(klasses, (list, tuple, set)): msg = "`klasses` must be a list of class names (type is str) or classes." raise TraitError(msg) for klass in klasses: if not (inspect.isclass(klass) or isinstance(klass, str)): msg = "A OneOfType trait must specify a list of classes." raise TraitError(msg) # Store classes. self.klasses = klasses super().__init__(new_default_value, **kwargs) def subclass_from_klasses(self, value): """Check that a given class is a subclasses found in the klasses list.""" return any(issubclass(value, klass) for klass in self.importable_klasses) def validate(self, obj, value): """Validates that the value is a valid object instance.""" if isinstance(value, str): try: value = self._resolve_string(value) except ImportError as e: emsg = ( f"The '{self.name}' trait of {obj} instance must be a type, but " f"{value!r} could not be imported" ) raise TraitError(emsg) from e try: if self.subclass_from_klasses(value): return value except Exception: pass self.error(obj, value) def info(self): """Returns a description of the trait.""" result = "a subclass of " for klass in self.klasses: if not isinstance(klass, str): klass = klass.__module__ + "." + klass.__name__ # noqa: PLW2901 result += f"{klass} or " # Strip the last "or" result = result.strip(" or ") # noqa: B005 if self.allow_none: return result + " or None" return result def instance_init(self, obj): """Initialize an instance.""" self._resolve_classes() super().instance_init(obj) def _resolve_classes(self): """Resolve all string names to actual classes.""" self.importable_klasses = [] for klass in self.klasses: if isinstance(klass, str): # Try importing the classes to compare. Silently, ignore if not importable. try: klass = self._resolve_string(klass) # noqa: PLW2901 self.importable_klasses.append(klass) except Exception: pass else: self.importable_klasses.append(klass) if isinstance(self.default_value, str): self.default_value = self._resolve_string(self.default_value) # type:ignore[arg-type] def default_value_repr(self): """The default value repr.""" value = self.default_value if isinstance(value, str): return repr(value) else: return repr(f"{value.__module__}.{value.__name__}") class InstanceFromClasses(ClassBasedTraitType): # type:ignore[type-arg] """A trait whose value must be an instance of a class in a specified list of classes. The value can also be an instance of a subclass of the specified classes. Subclasses can declare default classes by overriding the klass attribute """ def __init__(self, klasses=None, args=None, kw=None, **kwargs): """Construct an Instance trait. This trait allows values that are instances of a particular class or its subclasses. Our implementation is quite different from that of enthough.traits as we don't allow instances to be used for klass and we handle the ``args`` and ``kw`` arguments differently. Parameters ---------- klasses : list of classes or class_names (str) The class that forms the basis for the trait. Class names can also be specified as strings, like 'foo.bar.Bar'. args : tuple Positional arguments for generating the default value. kw : dict Keyword arguments for generating the default value. allow_none : bool [ default False ] Indicates whether None is allowed as a value. Notes ----- If both ``args`` and ``kw`` are None, then the default value is None. If ``args`` is a tuple and ``kw`` is a dict, then the default is created as ``klass(*args, **kw)``. If exactly one of ``args`` or ``kw`` is None, the None is replaced by ``()`` or ``{}``, respectively. """ # If class if klasses is None: # noqa: SIM114 self.klasses = klasses # Verify all elements are either classes or strings. elif all(inspect.isclass(k) or isinstance(k, str) for k in klasses): self.klasses = klasses else: raise TraitError( "The klasses attribute must be a list of class names or classes" " not: %r" % klasses ) if (kw is not None) and not isinstance(kw, dict): msg = "The 'kw' argument must be a dict or None." raise TraitError(msg) if (args is not None) and not isinstance(args, tuple): msg = "The 'args' argument must be a tuple or None." raise TraitError(msg) self.default_args = args self.default_kwargs = kw super().__init__(**kwargs) def instance_from_importable_klasses(self, value): """Check that a given class is a subclasses found in the klasses list.""" return any(isinstance(value, klass) for klass in self.importable_klasses) def validate(self, obj, value): """Validate an instance.""" if self.instance_from_importable_klasses(value): return value else: self.error(obj, value) def info(self): """Get the trait info.""" result = "an instance of " assert self.klasses is not None for klass in self.klasses: if isinstance(klass, str): result += klass else: result += describe("a", klass) result += " or " result = result.strip(" or ") # noqa: B005 if self.allow_none: result += " or None" return result def instance_init(self, obj): """Initialize the trait.""" self._resolve_classes() super().instance_init(obj) def _resolve_classes(self): """Resolve all string names to actual classes.""" self.importable_klasses = [] assert self.klasses is not None for klass in self.klasses: if isinstance(klass, str): # Try importing the classes to compare. Silently, ignore if not importable. try: klass = self._resolve_string(klass) # noqa: PLW2901 self.importable_klasses.append(klass) except Exception: pass else: self.importable_klasses.append(klass) def make_dynamic_default(self): """Make the dynamic default for the trait.""" if (self.default_args is None) and (self.default_kwargs is None): return None return self.klass( # type:ignore[attr-defined] *(self.default_args or ()), **(self.default_kwargs or {}) ) def default_value_repr(self): """Get the default value repr.""" return repr(self.make_dynamic_default()) def from_string(self, s): """Convert from a string.""" return literal_eval(s) jupyter-server-jupyter_server-e5c7e2b/jupyter_server/transutils.py000066400000000000000000000014001473126534200261600ustar00rootroot00000000000000"""Translation related utilities. When imported, injects _ to builtins""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import gettext import os import warnings def _trans_gettext_deprecation_helper(*args, **kwargs): """The trans gettext deprecation helper.""" warn_msg = "The alias `_()` will be deprecated. Use `_i18n()` instead." warnings.warn(warn_msg, FutureWarning, stacklevel=2) return trans.gettext(*args, **kwargs) # Set up message catalog access base_dir = os.path.realpath(os.path.join(__file__, "..", "..")) trans = gettext.translation( "notebook", localedir=os.path.join(base_dir, "notebook/i18n"), fallback=True ) _ = _trans_gettext_deprecation_helper _i18n = trans.gettext jupyter-server-jupyter_server-e5c7e2b/jupyter_server/utils.py000066400000000000000000000321271473126534200251220ustar00rootroot00000000000000"""Notebook related utilities""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations import errno import importlib.util import os import socket import sys import warnings from _frozen_importlib_external import _NamespacePath from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Any, NewType from urllib.parse import ( SplitResult, quote, unquote, urlparse, urlsplit, urlunsplit, ) from urllib.parse import ( urljoin as _urljoin, ) from urllib.request import pathname2url as _pathname2url from jupyter_core.utils import ensure_async as _ensure_async from packaging.version import Version from tornado.httpclient import AsyncHTTPClient, HTTPClient, HTTPRequest, HTTPResponse from tornado.netutil import Resolver if TYPE_CHECKING: from collections.abc import Generator, Sequence ApiPath = NewType("ApiPath", str) # Re-export urljoin = _urljoin pathname2url = _pathname2url ensure_async = _ensure_async def url_path_join(*pieces: str) -> str: """Join components of url into a relative url Use to prevent double slash when joining subpath. This will leave the initial and final / in place """ initial = pieces[0].startswith("/") final = pieces[-1].endswith("/") stripped = [s.strip("/") for s in pieces] result = "/".join(s for s in stripped if s) if initial: result = "/" + result if final: result = result + "/" if result == "//": result = "/" return result def url_is_absolute(url: str) -> bool: """Determine whether a given URL is absolute""" return urlparse(url).path.startswith("/") def path2url(path: str) -> str: """Convert a local file path to a URL""" pieces = [quote(p) for p in path.split(os.sep)] # preserve trailing / if pieces[-1] == "": pieces[-1] = "/" url = url_path_join(*pieces) return url def url2path(url: str) -> str: """Convert a URL to a local file path""" pieces = [unquote(p) for p in url.split("/")] path = os.path.join(*pieces) return path def url_escape(path: str) -> str: """Escape special characters in a URL path Turns '/foo bar/' into '/foo%20bar/' """ parts = path.split("/") return "/".join([quote(p) for p in parts]) def url_unescape(path: str) -> str: """Unescape special characters in a URL path Turns '/foo%20bar/' into '/foo bar/' """ return "/".join([unquote(p) for p in path.split("/")]) def samefile_simple(path: str, other_path: str) -> bool: """ Fill in for os.path.samefile when it is unavailable (Windows+py2). Do a case-insensitive string comparison in this case plus comparing the full stat result (including times) because Windows + py2 doesn't support the stat fields needed for identifying if it's the same file (st_ino, st_dev). Only to be used if os.path.samefile is not available. Parameters ---------- path : str representing a path to a file other_path : str representing a path to another file Returns ------- same: Boolean that is True if both path and other path are the same """ path_stat = os.stat(path) other_path_stat = os.stat(other_path) return path.lower() == other_path.lower() and path_stat == other_path_stat def to_os_path(path: ApiPath, root: str = "") -> str: """Convert an API path to a filesystem path If given, root will be prepended to the path. root must be a filesystem path already. """ parts = str(path).strip("/").split("/") parts = [p for p in parts if p != ""] # remove duplicate splits path_ = os.path.join(root, *parts) return os.path.normpath(path_) def to_api_path(os_path: str, root: str = "") -> ApiPath: """Convert a filesystem path to an API path If given, root will be removed from the path. root must be a filesystem path already. """ if os_path.startswith(root): os_path = os_path[len(root) :] parts = os_path.strip(os.path.sep).split(os.path.sep) parts = [p for p in parts if p != ""] # remove duplicate splits path = "/".join(parts) return ApiPath(path) def check_version(v: str, check: str) -> bool: """check version string v >= check If dev/prerelease tags result in TypeError for string-number comparison, it is assumed that the dependency is satisfied. Users on dev branches are responsible for keeping their own packages up to date. """ try: return bool(Version(v) >= Version(check)) except TypeError: return True # Copy of IPython.utils.process.check_pid: def _check_pid_win32(pid: int) -> bool: import ctypes # OpenProcess returns 0 if no such process (of ours) exists # positive int otherwise return bool(ctypes.windll.kernel32.OpenProcess(1, 0, pid)) # type:ignore[attr-defined] def _check_pid_posix(pid: int) -> bool: """Copy of IPython.utils.process.check_pid""" try: os.kill(pid, 0) except OSError as err: if err.errno == errno.ESRCH: return False elif err.errno == errno.EPERM: # Don't have permission to signal the process - probably means it exists return True raise else: return True if sys.platform == "win32": check_pid = _check_pid_win32 else: check_pid = _check_pid_posix async def run_sync_in_loop(maybe_async): """**DEPRECATED**: Use ``ensure_async`` from jupyter_core instead.""" warnings.warn( "run_sync_in_loop is deprecated since Jupyter Server 2.0, use 'ensure_async' from jupyter_core instead", DeprecationWarning, stacklevel=2, ) return ensure_async(maybe_async) def urlencode_unix_socket_path(socket_path: str) -> str: """Encodes a UNIX socket path string from a socket path for the `http+unix` URI form.""" return socket_path.replace("/", "%2F") def urldecode_unix_socket_path(socket_path: str) -> str: """Decodes a UNIX sock path string from an encoded sock path for the `http+unix` URI form.""" return socket_path.replace("%2F", "/") def urlencode_unix_socket(socket_path: str) -> str: """Encodes a UNIX socket URL from a socket path for the `http+unix` URI form.""" return "http+unix://%s" % urlencode_unix_socket_path(socket_path) def unix_socket_in_use(socket_path: str) -> bool: """Checks whether a UNIX socket path on disk is in use by attempting to connect to it.""" if not os.path.exists(socket_path): return False try: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.connect(socket_path) except OSError: return False else: return True finally: sock.close() @contextmanager def _request_for_tornado_client( urlstring: str, method: str = "GET", body: Any = None, headers: Any = None ) -> Generator[HTTPRequest, None, None]: """A utility that provides a context that handles HTTP, HTTPS, and HTTP+UNIX request. Creates a tornado HTTPRequest object with a URL that tornado's HTTPClients can accept. If the request is made to a unix socket, temporarily configure the AsyncHTTPClient to resolve the URL and connect to the proper socket. """ parts = urlsplit(urlstring) if parts.scheme in ["http", "https"]: pass elif parts.scheme == "http+unix": # If unix socket, mimic HTTP. parts = SplitResult( scheme="http", netloc=parts.netloc, path=parts.path, query=parts.query, fragment=parts.fragment, ) class UnixSocketResolver(Resolver): """A resolver that routes HTTP requests to unix sockets in tornado HTTP clients. Due to constraints in Tornados' API, the scheme of the must be `http` (not `http+unix`). Applications should replace the scheme in URLS before making a request to the HTTP client. """ def initialize(self, resolver): self.resolver = resolver def close(self): self.resolver.close() async def resolve(self, host, port, *args, **kwargs): return [(socket.AF_UNIX, urldecode_unix_socket_path(host))] resolver = UnixSocketResolver(resolver=Resolver()) AsyncHTTPClient.configure(None, resolver=resolver) else: msg = "Unknown URL scheme." raise Exception(msg) # Yield the request for the given client. url = urlunsplit(parts) request = HTTPRequest(url, method=method, body=body, headers=headers, validate_cert=False) yield request def fetch( urlstring: str, method: str = "GET", body: Any = None, headers: Any = None ) -> HTTPResponse: """ Send a HTTP, HTTPS, or HTTP+UNIX request to a Tornado Web Server. Returns a tornado HTTPResponse. """ with _request_for_tornado_client( urlstring, method=method, body=body, headers=headers ) as request: response = HTTPClient(AsyncHTTPClient).fetch(request) return response async def async_fetch( urlstring: str, method: str = "GET", body: Any = None, headers: Any = None, io_loop: Any = None ) -> HTTPResponse: """ Send an asynchronous HTTP, HTTPS, or HTTP+UNIX request to a Tornado Web Server. Returns a tornado HTTPResponse. """ with _request_for_tornado_client( urlstring, method=method, body=body, headers=headers ) as request: response = await AsyncHTTPClient(io_loop).fetch(request) return response def is_namespace_package(namespace: str) -> bool | None: """Is the provided namespace a Python Namespace Package (PEP420). https://www.python.org/dev/peps/pep-0420/#specification Returns `None` if module is not importable. """ # NOTE: using submodule_search_locations because the loader can be None try: spec = importlib.util.find_spec(namespace) except ValueError: # spec is not set - see https://docs.python.org/3/library/importlib.html#importlib.util.find_spec return None if not spec: # e.g. module not installed return None return isinstance(spec.submodule_search_locations, _NamespacePath) def filefind(filename: str, path_dirs: Sequence[str]) -> str: """Find a file by looking through a sequence of paths. For use in FileFindHandler. Iterates through a sequence of paths looking for a file and returns the full, absolute path of the first occurrence of the file. Absolute paths are not accepted for inputs. This function does not automatically try any paths, such as the cwd or the user's home directory. Parameters ---------- filename : str The filename to look for. Must be a relative path. path_dirs : sequence of str The sequence of paths to look in for the file. Walk through each element and join with ``filename``. Only after ensuring the path resolves within the directory is it checked for existence. Returns ------- Raises :exc:`OSError` or returns absolute path to file. """ file_path = Path(filename) # If the input is an absolute path, reject it if file_path.is_absolute(): msg = f"{filename} is absolute, filefind only accepts relative paths." raise OSError(msg) for path_str in path_dirs: path = Path(path_str).absolute() test_path = path / file_path # os.path.abspath resolves '..', but Path.absolute() doesn't # Path.resolve() does, but traverses symlinks, which we don't want test_path = Path(os.path.abspath(test_path)) if not test_path.is_relative_to(path): # points outside root, e.g. via `filename='../foo'` continue # make sure we don't call is_file before we know it's a file within a prefix # GHSA-hrw6-wg82-cm62 - can leak password hash on windows. if test_path.is_file(): return os.path.abspath(test_path) msg = f"File {filename!r} does not exist in any of the search paths: {path_dirs!r}" raise OSError(msg) def import_item(name: str) -> Any: """Import and return ``bar`` given the string ``foo.bar``. Calling ``bar = import_item("foo.bar")`` is the functional equivalent of executing the code ``from foo import bar``. Parameters ---------- name : str The fully qualified name of the module/package being imported. Returns ------- mod : module object The module that was imported. """ parts = name.rsplit(".", 1) if len(parts) == 2: # called with 'foo.bar....' package, obj = parts module = __import__(package, fromlist=[obj]) try: pak = getattr(module, obj) except AttributeError as e: raise ImportError("No module named %s" % obj) from e return pak else: # called with un-dotted string return __import__(parts[0]) class JupyterServerAuthWarning(RuntimeWarning): """Emitted when authentication configuration issue is detected. Intended for filtering out expected warnings in tests, including downstream tests, rather than for users to silence this warning. """ jupyter-server-jupyter_server-e5c7e2b/jupyter_server/view/000077500000000000000000000000001473126534200243555ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/jupyter_server/view/__init__.py000066400000000000000000000000571473126534200264700ustar00rootroot00000000000000"""Tornado handlers for viewing HTML files.""" jupyter-server-jupyter_server-e5c7e2b/jupyter_server/view/handlers.py000066400000000000000000000021041473126534200265240ustar00rootroot00000000000000"""Tornado handlers for viewing HTML files.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from jupyter_core.utils import ensure_async from tornado import web from jupyter_server.auth.decorator import authorized from ..base.handlers import JupyterHandler, path_regex from ..utils import url_escape, url_path_join AUTH_RESOURCE = "contents" class ViewHandler(JupyterHandler): """Render HTML files within an iframe.""" auth_resource = AUTH_RESOURCE @web.authenticated @authorized async def get(self, path): """Get a view on a given path.""" path = path.strip("/") if not await ensure_async(self.contents_manager.file_exists(path)): raise web.HTTPError(404, "File does not exist: %s" % path) basename = path.rsplit("/", 1)[-1] file_url = url_path_join(self.base_url, "files", url_escape(path)) self.write(self.render_template("view.html", file_url=file_url, page_title=basename)) default_handlers = [ (r"/view%s" % path_regex, ViewHandler), ] jupyter-server-jupyter_server-e5c7e2b/package-lock.json000066400000000000000000000660131473126534200235350ustar00rootroot00000000000000{ "name": "jupyter_server", "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "jupyter_server", "version": "1.0.0", "license": "BSD", "dependencies": { "bootstrap": "^3.4.0", "copyfiles": "^2.4.1" } }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "engines": { "node": ">=8" } }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dependencies": { "color-convert": "^2.0.1" }, "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/bootstrap": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz", "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==", "engines": { "node": ">=6" } }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": { "color-name": "~1.1.4" }, "engines": { "node": ">=7.0.0" } }, "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "node_modules/copyfiles": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", "dependencies": { "glob": "^7.0.5", "minimatch": "^3.0.3", "mkdirp": "^1.0.4", "noms": "0.0.0", "through2": "^2.0.1", "untildify": "^4.0.0", "yargs": "^16.1.0" }, "bin": { "copyfiles": "copyfiles", "copyup": "copyfiles" } }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", "engines": { "node": ">=6" } }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "engines": { "node": "6.* || 8.* || >= 10.*" } }, "node_modules/glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" }, "engines": { "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "engines": { "node": ">=8" } }, "node_modules/isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dependencies": { "brace-expansion": "^1.1.7" }, "engines": { "node": "*" } }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "bin": { "mkdirp": "bin/cmd.js" }, "engines": { "node": ">=10" } }, "node_modules/noms": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", "integrity": "sha1-2o69nzr51nYJGbJ9nNyAkqczKFk=", "dependencies": { "inherits": "^2.0.1", "readable-stream": "~1.0.31" } }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dependencies": { "wrappy": "1" } }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "engines": { "node": ">=0.10.0" } }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "node_modules/readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", "engines": { "node": ">=0.10.0" } }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/string_decoder": { "version": "0.10.31", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" }, "node_modules/string-width": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.0" }, "engines": { "node": ">=8" } }, "node_modules/strip-ansi": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", "dependencies": { "ansi-regex": "^5.0.0" }, "engines": { "node": ">=8" } }, "node_modules/through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "node_modules/through2/node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "node_modules/through2/node_modules/readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "node_modules/through2/node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", "engines": { "node": ">=8" } }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "engines": { "node": ">=0.4" } }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "engines": { "node": ">=10" } }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" }, "engines": { "node": ">=10" } }, "node_modules/yargs-parser": { "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "engines": { "node": ">=10" } } }, "dependencies": { "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "requires": { "color-convert": "^2.0.1" } }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "bootstrap": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz", "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==" }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "requires": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "requires": { "color-name": "~1.1.4" } }, "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "copyfiles": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", "requires": { "glob": "^7.0.5", "minimatch": "^3.0.3", "mkdirp": "^1.0.4", "noms": "0.0.0", "through2": "^2.0.1", "untildify": "^4.0.0", "yargs": "^16.1.0" } }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, "glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "requires": { "once": "^1.3.0", "wrappy": "1" } }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, "isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "requires": { "brace-expansion": "^1.1.7" } }, "mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, "noms": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", "integrity": "sha1-2o69nzr51nYJGbJ9nNyAkqczKFk=", "requires": { "inherits": "^2.0.1", "readable-stream": "~1.0.31" } }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "requires": { "wrappy": "1" } }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "string_decoder": { "version": "0.10.31", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" }, "string-width": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.0" } }, "strip-ansi": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", "requires": { "ansi-regex": "^5.0.0" } }, "through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", "requires": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" }, "dependencies": { "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "requires": { "safe-buffer": "~5.1.0" } } } }, "untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==" }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "requires": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" }, "yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "requires": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "yargs-parser": { "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" } } } jupyter-server-jupyter_server-e5c7e2b/package.json000066400000000000000000000007061473126534200226040ustar00rootroot00000000000000{ "name": "jupyter_server", "private": true, "version": "1.0.0", "license": "BSD", "scripts": { "build": "copyfiles -f node_modules/bootstrap/dist/css/*.min.* jupyter_server/static/style" }, "dependencies": { "bootstrap": "^3.4.0", "copyfiles": "^2.4.1" }, "eslintIgnore": [ "*.min.js", "*components*", "*node_modules*", "*built*", "*build*" ], "babel": { "presets": [ "es2015" ] } } jupyter-server-jupyter_server-e5c7e2b/pyproject.toml000066400000000000000000000147501473126534200232360ustar00rootroot00000000000000[build-system] requires = ["hatchling >=1.11"] build-backend = "hatchling.build" [project] name = "jupyter_server" dynamic = ["version"] readme = "README.md" license = { file = "LICENSE" } description = "The backend—i.e. core services, APIs, and REST endpoints—to Jupyter web applications." authors = [{name = "Jupyter Development Team", email = "jupyter@googlegroups.com"}] keywords = ["ipython", "jupyter"] classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Jupyter", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", ] requires-python = ">=3.9" dependencies = [ "anyio>=3.1.0", "argon2-cffi>=21.1", "jinja2>=3.0.3", "jupyter_client>=7.4.4", "jupyter_core>=4.12,!=5.0.*", "jupyter_server_terminals>=0.4.4", "nbconvert>=6.4.4", "nbformat>=5.3.0", "packaging>=22.0", "prometheus_client>=0.9", "pywinpty>=2.0.1;os_name=='nt'", "pyzmq>=24", "Send2Trash>=1.8.2", "terminado>=0.8.3", "tornado>=6.2.0", "traitlets>=5.6.0", "websocket-client>=1.7", "jupyter_events>=0.11.0", "overrides>=5.0" ] [project.urls] Homepage = "https://jupyter-server.readthedocs.io" Documentation = "https://jupyter-server.readthedocs.io" Funding = "https://jupyter.org/about#donate" Source = "https://github.com/jupyter-server/jupyter_server" Tracker = "https://github.com/jupyter-server/jupyter_server/issues" [project.optional-dependencies] test = [ "ipykernel", "pytest-console-scripts", "pytest-timeout", "pytest-jupyter[server]>=0.7", "pytest>=7.0,<9", "requests", "pre-commit", 'flaky' ] docs = [ "ipykernel", "jinja2", "jupyter_client", "jupyter_server", "myst-parser", "nbformat", "prometheus_client", "pydata_sphinx_theme", "Send2Trash", "sphinxcontrib-openapi>=0.8.0", "sphinxcontrib_github_alt", "sphinxcontrib-spelling", "sphinx-autodoc-typehints", "sphinxemoji", "tornado", # workaround for an unknown downstream library that is now # missing typing_extensions "typing_extensions" ] [project.scripts] jupyter-server = "jupyter_server.serverapp:main" [tool.hatch.envs.docs] features = ["docs"] [tool.hatch.envs.docs.scripts] build = "make -C docs html SPHINXOPTS='-W'" api = "sphinx-apidoc -o docs/source/api -f -E jupyter_server */terminal jupyter_server/pytest_plugin.py" [tool.hatch.envs.test] features = ["test"] [tool.hatch.envs.test.scripts] test = "python -m pytest -vv {args}" nowarn = "test -W default {args}" [tool.hatch.envs.typing] dependencies = [ "pre-commit"] detached = true [tool.hatch.envs.typing.scripts] test = "pre-commit run --all-files --hook-stage manual mypy" [tool.hatch.envs.cov] features = ["test"] dependencies = ["coverage[toml]", "pytest-cov"] [tool.hatch.envs.cov.scripts] test = "python -m pytest -vv --cov jupyter_server --cov-branch --cov-report term-missing:skip-covered {args}" nowarn = "test -W default {args}" integration = "test --integration_tests=true {args}" [tool.hatch.version] path = "jupyter_server/_version.py" validate-bump = false [tool.hatch.build] artifacts = ["jupyter_server/static/style"] [tool.hatch.build.hooks.jupyter-builder] dependencies = ["hatch-jupyter-builder>=0.8.1"] build-function = "hatch_jupyter_builder.npm_builder" ensured-targets = [ "jupyter_server/static/style/bootstrap.min.css", "jupyter_server/static/style/bootstrap-theme.min.css" ] skip-if-exists = ["jupyter_server/static/style/bootstrap.min.css"] install-pre-commit-hook = true optional-editable-build = true [tool.ruff] line-length = 100 [tool.ruff.format] docstring-code-format = true [tool.ruff.lint] ignore = ["ARG", "TRY", "RUF012", "TID252", "G", "INP001", "E402", "F401", "BLE001"] extend-select = [ "B", # flake8-bugbear "I", # isort "UP", # pyupgrade "G001", # no % or f formatting in logs, prevents sttructured logging ] unfixable = [ # Don't touch print statements "T201", # Don't touch unused imports "F401", # Don't touch noqa lines "RUF100" ] [tool.ruff.lint.extend-per-file-ignores] "jupyter_server/*" = ["S101", "RET", "S110", "UP031", "FBT", "FA100", "SLF001", "A002", "SIM105", "A001", "UP007", "PLR2004", "T201", "N818", "F403"] "tests/*" = ["UP031", "PT", 'EM', "TRY", "RET", "SLF", "C408", "F841", "FBT", "A002", "FLY", "N", "PERF", "ASYNC", "T201", "FA100", "E711", "INP", "TCH", "SIM105", "A001", "PLW0603"] "examples/*_config.py" = ["F821"] "examples/*" = ["N815"] [tool.pytest.ini_options] minversion = "6.0" xfail_strict = true log_cli_level = "info" addopts = [ "-ra", "--durations=10", "--color=yes", "--doctest-modules", "--showlocals", "--strict-markers", "--strict-config" ] testpaths = [ "tests/" ] timeout = 100 # Restore this setting to debug failures. timeout_method = "thread" filterwarnings = [ "error", "ignore:datetime.datetime.utc:DeprecationWarning", "module:add_callback_from_signal is deprecated:DeprecationWarning", "ignore::jupyter_server.utils.JupyterServerAuthWarning", # ignore unclosed sqlite in traits "ignore:unclosed database in True: """Mock an async fetch""" # Mock a hang for a half a second. await asyncio.sleep(0.5) return True async def is_authorized( self, handler: JupyterHandler, user: User, action: str, resource: str ) -> Awaitable[bool]: response = await self.mock_async_fetch() self.called = True return response @pytest.mark.parametrize( "jp_server_config,", [ { "ServerApp": {"authorizer_class": AsyncAuthorizerTest}, "jpserver_extensions": {"jupyter_server_terminals": True}, } ], ) async def test_async_authorizer( request, io_loop, send_request, tmp_path, jp_serverapp, ): code = await send_request("/api/status", method="GET") assert code == 200 # Ensure that the authorizor method finished its request. assert hasattr(jp_serverapp.authorizer, "called") assert jp_serverapp.authorizer.called is True jupyter-server-jupyter_server-e5c7e2b/tests/auth/test_identity.py000066400000000000000000000142731473126534200256670ustar00rootroot00000000000000import json import logging from contextlib import nullcontext from unittest import mock import pytest from jupyter_server.auth import IdentityProvider, User from jupyter_server.auth.identity import PasswordIdentityProvider, _backward_compat_user from jupyter_server.serverapp import ServerApp class CustomUser: def __init__(self, name): self.name = name @pytest.mark.parametrize( "old_user, expected", [ ( "str-name", {"username": "str-name", "name": "str-name", "display_name": "str-name"}, ), ( {"username": "user.username", "name": "user.name"}, { "username": "user.username", "name": "user.name", "display_name": "user.name", }, ), ( {"username": "user.username", "display_name": "display"}, { "username": "user.username", "name": "user.username", "display_name": "display", }, ), ({"name": "user.name"}, {"username": "user.name", "name": "user.name"}), ({"unknown": "value"}, ValueError), (CustomUser("custom_name"), ValueError), ], ) def test_identity_model(old_user, expected): if isinstance(expected, type) and issubclass(expected, Exception): with pytest.raises(expected): user = _backward_compat_user(old_user) return user = _backward_compat_user(old_user) idp = IdentityProvider() identity = idp.identity_model(user) print(identity) identity_subset = {key: identity[key] for key in expected} # type:ignore[union-attr] print(type(identity), type(identity_subset), type(expected)) assert identity_subset == expected @pytest.mark.parametrize( "fields, expected", [ ({"name": "user"}, TypeError), ( {"username": "user.username"}, { "username": "user.username", "name": "user.username", "initials": None, "avatar_url": None, "color": None, }, ), ( {"username": "user.username", "name": "user.name", "color": "#abcdef"}, { "username": "user.username", "name": "user.name", "display_name": "user.name", "color": "#abcdef", }, ), ( {"username": "user.username", "display_name": "display"}, { "username": "user.username", "name": "user.username", "display_name": "display", }, ), ], ) def test_user_defaults(fields, expected): if isinstance(expected, type) and issubclass(expected, Exception): with pytest.raises(expected): user = User(**fields) return user = User(**fields) # check expected fields for key in expected: # type:ignore[union-attr] assert getattr(user, key) == expected[key] # type:ignore[index] # check types for key in ("username", "name", "display_name"): value = getattr(user, key) assert isinstance(value, str) # don't allow empty strings assert value for key in ("initials", "avatar_url", "color"): value = getattr(user, key) assert value is None or isinstance(value, str) @pytest.fixture def identity_provider_class(): """Allow override in other test modules""" return PasswordIdentityProvider @pytest.mark.parametrize( "ip, token, ssl, warns", [ ("", "", None, "highly insecure"), ("", "", {"key": "x"}, "all IP addresses"), ("", "secret", None, "and not using encryption"), ("", "secret", {"key": "x"}, False), ("127.0.0.1", "secret", None, False), ], ) def test_validate_security( identity_provider_class, ip, token, ssl, warns, caplog, ): app = ServerApp(ip=ip, log=logging.getLogger()) idp = identity_provider_class(parent=app, token=token) app.identity_provider = idp with caplog.at_level(logging.WARNING): idp.validate_security(app, ssl_options=ssl) for record in caplog.records: print(record) if warns: assert len(caplog.records) > 0 if isinstance(warns, str): logged = "\n".join(record.msg for record in caplog.records) assert warns in logged else: assert len(caplog.records) == 0 @pytest.mark.parametrize( "password_set, password_required, ok", [ (True, False, True), (True, True, True), (False, False, True), (False, True, False), ], ) def test_password_required(identity_provider_class, password_set, password_required, ok): app = ServerApp() idp = identity_provider_class( parent=app, hashed_password="xxx" if password_set else "", password_required=password_required, ) app.identity_provider = idp ctx = nullcontext() if ok else pytest.raises(SystemExit) with ctx: idp.validate_security(app, ssl_options=None) async def test_auth_disabled(request, jp_serverapp, jp_fetch): idp = PasswordIdentityProvider( parent=jp_serverapp, hashed_password="", token="", ) assert not idp.auth_enabled with mock.patch.dict(jp_serverapp.web_app.settings, {"identity_provider": idp}): resp = await jp_fetch("/api/me", headers={"Authorization": "", "Cookie": ""}) user_info = json.loads(resp.body.decode("utf8")) # anonymous login sets a cookie assert "Set-Cookie" in resp.headers cookie = resp.headers["Set-Cookie"] # second request, with cookie keeps the same anonymous user resp = await jp_fetch("/api/me", headers={"Authorization": "", "Cookie": cookie}) user_info_repeat = json.loads(resp.body.decode("utf8")) assert user_info_repeat["identity"] == user_info["identity"] # another request, no cookie, new anonymous user resp = await jp_fetch("/api/me", headers={"Authorization": "", "Cookie": ""}) user_info_2 = json.loads(resp.body.decode("utf8")) assert user_info_2["identity"]["username"] != user_info["identity"]["username"] jupyter-server-jupyter_server-e5c7e2b/tests/auth/test_legacy_login.py000066400000000000000000000067661473126534200265020ustar00rootroot00000000000000""" Test legacy login config via ServerApp.login_handler_class """ import json import pytest from traitlets.config import Config from jupyter_server.auth.identity import LegacyIdentityProvider from jupyter_server.auth.login import LoginHandler from jupyter_server.auth.security import passwd from jupyter_server.serverapp import ServerApp # re-run some login tests with legacy login config from .test_identity import test_password_required, test_validate_security from .test_login import login, test_change_password, test_login_cookie, test_logout # Don't raise on deprecation warnings in this module testing deprecated behavior pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning") class CustomLoginHandler(LoginHandler): @classmethod def get_user(cls, handler): header_user = handler.request.headers.get("test-user") if header_user: if header_user == "super": return super().get_user(handler) return header_user else: return None @pytest.fixture def login_headers(): return {"test-user": "super"} @pytest.fixture def jp_server_config(): cfg = Config() cfg.ServerApp.login_handler_class = CustomLoginHandler return cfg @pytest.fixture def identity_provider_class(): # for tests imported from test_identity.py return LegacyIdentityProvider def test_legacy_identity_config(jp_serverapp): # setting login_handler_class sets LegacyIdentityProvider app = ServerApp() idp = jp_serverapp.identity_provider assert type(idp) is LegacyIdentityProvider assert idp.login_available assert idp.auth_enabled assert idp.token assert idp.get_handlers() == [ ("/login", idp.login_handler_class), ("/logout", idp.logout_handler_class), ] async def test_legacy_identity_api(jp_serverapp, jp_fetch): response = await jp_fetch("/api/me", headers={"test-user": "pinecone"}) assert response.code == 200 model = json.loads(response.body.decode("utf8")) assert model["identity"]["username"] == "pinecone" async def test_legacy_base_class(jp_serverapp, jp_fetch): response = await jp_fetch("/api/me", headers={"test-user": "super"}) assert "Set-Cookie" in response.headers cookie = response.headers["Set-Cookie"] assert response.code == 200 model = json.loads(response.body.decode("utf8")) user_id = model["identity"]["username"] # a random uuid assert user_id response = await jp_fetch("/api/me", headers={"test-user": "super", "Cookie": cookie}) model2 = json.loads(response.body.decode("utf8")) # second request, should trigger cookie auth assert model2["identity"] == model["identity"] def test_deprecated_config(jp_configurable_serverapp): cfg = Config() cfg.ServerApp.token = token = "asdf" cfg.ServerApp.password = password = passwd("secrets") app = jp_configurable_serverapp(config=cfg) assert app.identity_provider.token == token assert app.token == token assert app.identity_provider.hashed_password == password assert app.password == password def test_deprecated_config_priority(jp_configurable_serverapp): cfg = Config() cfg.ServerApp.token = "ignored" cfg.IdentityProvider.token = token = "idp_token" cfg.ServerApp.password = passwd("ignored") cfg.PasswordIdentityProvider.hashed_password = password = passwd("used") app = jp_configurable_serverapp(config=cfg) assert app.identity_provider.token == token assert app.identity_provider.hashed_password == password jupyter-server-jupyter_server-e5c7e2b/tests/auth/test_login.py000066400000000000000000000131251473126534200251410ustar00rootroot00000000000000"""Tests for login redirects""" import json from functools import partial from urllib.parse import urlencode import pytest from tornado.httpclient import HTTPClientError from tornado.httputil import parse_cookie, url_concat from jupyter_server.utils import url_path_join # override default config to ensure a non-empty base url is used @pytest.fixture def jp_base_url(): return "/a%40b/" @pytest.fixture def jp_server_config(jp_base_url): return { "ServerApp": { "base_url": jp_base_url, }, } async def _login( jp_serverapp, http_server_client, jp_base_url, login_headers, next="/", password=None, new_password=None, ): # first: request login page with no creds login_url = url_path_join(jp_base_url, "login") first = await http_server_client.fetch(login_url) cookie_header = first.headers["Set-Cookie"] cookies = parse_cookie(cookie_header) form = {"_xsrf": cookies.get("_xsrf")} if password is None: password = jp_serverapp.identity_provider.token if password: form["password"] = password if new_password: form["new_password"] = new_password # second, submit login form with credentials try: resp = await http_server_client.fetch( url_concat(login_url, {"next": next}), method="POST", body=urlencode(form), headers={"Cookie": cookie_header}, follow_redirects=False, ) except HTTPClientError as e: if e.code != 302: raise assert e.response is not None resp = e.response else: assert resp.code == 302, "Should have returned a redirect!" return resp @pytest.fixture def login_headers(): """Extra headers to pass to login Fixture so it can be overridden """ return {} @pytest.fixture def login(jp_serverapp, http_server_client, jp_base_url, login_headers): """Fixture to return a function to login to a Jupyter server by submitting the login page form """ return partial(_login, jp_serverapp, http_server_client, jp_base_url, login_headers) @pytest.mark.parametrize( "bad_next", ( r"\\tree", "//some-host", "//host{base_url}tree", "https://google.com", "/absolute/not/base_url", "https:///a%40b/extra/slash", ), ) async def test_next_bad(login, jp_base_url, bad_next): bad_next = bad_next.format(base_url=jp_base_url) resp = await login(bad_next) url = resp.headers["Location"] assert url == jp_base_url @pytest.mark.parametrize( "next_path", ( "tree/", "//{base_url}tree", "notebooks/notebook.ipynb", "tree//something", ), ) async def test_next_ok(login, jp_base_url, next_path): next_path = next_path.format(base_url=jp_base_url) expected = jp_base_url + next_path resp = await login(next=expected) actual = resp.headers["Location"] assert actual == expected async def test_login_cookie(login, jp_serverapp, jp_fetch, login_headers): resp = await login() assert "Set-Cookie" in resp.headers cookie = resp.headers["Set-Cookie"] headers = {"Cookie": cookie} headers.update(login_headers) id_resp = await jp_fetch("/api/me", headers=headers) assert id_resp.code == 200 model = json.loads(id_resp.body.decode("utf8")) assert model["identity"]["username"] with pytest.raises(HTTPClientError) as exc: resp = await login(password="incorrect") assert exc.value.code == 401 @pytest.mark.parametrize("allow_password_change", [True, False]) async def test_change_password(login, jp_serverapp, jp_base_url, jp_fetch, allow_password_change): new_password = "super-new-pass" jp_serverapp.identity_provider.allow_password_change = allow_password_change resp = await login(new_password=new_password) # second request if allow_password_change: resp = await login(password=new_password) assert resp.code == 302 else: with pytest.raises(HTTPClientError) as exc_info: resp = await login(password=new_password) assert exc_info.value.code == 401 async def test_logout(jp_serverapp, login, http_server_client, jp_base_url): jp_serverapp.identity_provider.cookie_name = "test-cookie" expected = jp_base_url resp = await login(next=jp_base_url) cookie_header = resp.headers["Set-Cookie"] cookies = parse_cookie(cookie_header) assert cookies.get("test-cookie") resp = await http_server_client.fetch(jp_base_url + "logout", headers={"Cookie": cookie_header}) assert resp.code == 200 cookie_header = resp.headers["Set-Cookie"] cookies = parse_cookie(cookie_header) assert not cookies.get("test-cookie") assert "Successfully logged out" in resp.body.decode("utf8") async def test_token_cookie_user_id(jp_serverapp, jp_fetch): token = jp_serverapp.identity_provider.token # first request with token, sets cookie with user-id resp = await jp_fetch("/") assert resp.code == 200 set_cookie = resp.headers["set-cookie"] headers = {"Cookie": set_cookie} # subsequent requests with cookie and no token # receive same user-id resp = await jp_fetch("/api/me", headers=headers) user_id = json.loads(resp.body.decode("utf8")) resp = await jp_fetch("/api/me", headers=headers) user_id2 = json.loads(resp.body.decode("utf8")) assert user_id["identity"] == user_id2["identity"] # new request, just token -> new user_id resp = await jp_fetch("/api/me") user_id3 = json.loads(resp.body.decode("utf8")) assert user_id["identity"] != user_id3["identity"] jupyter-server-jupyter_server-e5c7e2b/tests/auth/test_security.py000066400000000000000000000015071473126534200257010ustar00rootroot00000000000000from jupyter_server.auth.security import passwd, passwd_check def test_passwd_structure(): p = passwd("passphrase") algorithm, hashed = p.split(":") assert algorithm == "argon2", algorithm assert hashed.startswith("$argon2id$"), hashed def test_roundtrip(): p = passwd("passphrase") assert passwd_check(p, "passphrase") def test_bad(): p = passwd("passphrase") assert not passwd_check(p, p) assert not passwd_check(p, "a:b:c:d") assert not passwd_check(p, "a:b") def test_passwd_check_unicode(): # GH issue #4524 phash = "sha1:23862bc21dd3:7a415a95ae4580582e314072143d9c382c491e4f" assert passwd_check(phash, "łe¶ŧ←↓→") phash = "argon2:$argon2id$v=19$m=10240,t=10,p=8$qjjDiZUofUVVnrVYxacnbA$l5pQq1bJ8zglGT2uXP6iOg" assert passwd_check(phash, "łe¶ŧ←↓→") jupyter-server-jupyter_server-e5c7e2b/tests/auth/test_utils.py000066400000000000000000000016241473126534200251720ustar00rootroot00000000000000import pytest from jupyter_server.auth.utils import match_url_to_resource @pytest.mark.parametrize( "url,expected_resource", [ ("/api/kernels", "kernels"), ("/api/kernelspecs", "kernelspecs"), ("/api/contents", "contents"), ("/api/sessions", "sessions"), ("/api/terminals", "terminals"), ("/api/nbconvert", "nbconvert"), ("/api/config/x", "config"), ("/api/shutdown", "server"), ("/nbconvert/py", "nbconvert"), ], ) def test_match_url_to_resource(url, expected_resource): resource = match_url_to_resource(url) assert resource == expected_resource @pytest.mark.parametrize( "url", [ "/made/up/url", # Misspell. "/api/kernel", # Not a resource "/tree", ], ) def test_bad_match_url_to_resource(url): resource = match_url_to_resource(url) assert resource is None jupyter-server-jupyter_server-e5c7e2b/tests/base/000077500000000000000000000000001473126534200223675ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/base/test_call_context.py000066400000000000000000000104141473126534200264570ustar00rootroot00000000000000import asyncio from jupyter_server import CallContext from jupyter_server.auth.utils import get_anonymous_username from jupyter_server.base.handlers import JupyterHandler from jupyter_server.services.kernels.kernelmanager import AsyncMappingKernelManager async def test_jupyter_handler_contextvar(jp_fetch, monkeypatch): # Create some mock kernel Ids kernel1 = "x-x-x-x-x" kernel2 = "y-y-y-y-y" # We'll use this dictionary to track the current user within each request. context_tracker = { kernel1: {"started": "no user yet", "ended": "still no user", "user": None}, kernel2: {"started": "no user yet", "ended": "still no user", "user": None}, } # Monkeypatch the get_current_user method in Tornado's # request handler to return a random user name for # each request async def get_current_user(self): return get_anonymous_username() monkeypatch.setattr(JupyterHandler, "get_current_user", get_current_user) # Monkeypatch the kernel_model method to show that # the current context variable is truly local and # not contaminated by other asynchronous parallel requests. # Note that even though the current implementation of `kernel_model()` # is synchronous, we can convert this into an async method because the # kernel handler wraps the call to `kernel_model()` in `ensure_async()`. async def kernel_model(self, kernel_id): # Get the Jupyter Handler from the current context. current: JupyterHandler = CallContext.get(CallContext.JUPYTER_HANDLER) # Get the current user context_tracker[kernel_id]["user"] = current.current_user context_tracker[kernel_id]["started"] = current.current_user await asyncio.sleep(1.0) # Track the current user a few seconds later. We'll # verify that this user was unaffected by other parallel # requests. context_tracker[kernel_id]["ended"] = current.current_user return {"id": kernel_id, "name": "blah"} monkeypatch.setattr(AsyncMappingKernelManager, "kernel_model", kernel_model) # Make two requests in parallel. await asyncio.gather( jp_fetch("api", "kernels", kernel1), jp_fetch("api", "kernels", kernel2), ) # Assert that the two requests had different users assert context_tracker[kernel1]["user"] != context_tracker[kernel2]["user"] # Assert that the first request started+ended with the same user assert context_tracker[kernel1]["started"] == context_tracker[kernel1]["ended"] # Assert that the second request started+ended with the same user assert context_tracker[kernel2]["started"] == context_tracker[kernel2]["ended"] async def test_context_variable_names(): CallContext.set("foo", "bar") CallContext.set("foo2", "bar2") names = CallContext.context_variable_names() assert len(names) == 2 assert set(names) == {"foo", "foo2"} async def test_same_context_operations(): CallContext.set("foo", "bar") CallContext.set("foo2", "bar2") foo = CallContext.get("foo") assert foo == "bar" CallContext.set("foo", "bar2") assert CallContext.get("foo") == CallContext.get("foo2") async def test_multi_context_operations(): async def context1(): """The "slower" context. This ensures that, following the sleep, the context variable set prior to the sleep is still the expected value. If contexts are not managed properly, we should find that context2() has corrupted context1(). """ CallContext.set("foo", "bar1") await asyncio.sleep(1.0) assert CallContext.get("foo") == "bar1" context1_names = CallContext.context_variable_names() assert len(context1_names) == 1 async def context2(): """The "faster" context. This ensures that CallContext reflects the appropriate values of THIS context. """ CallContext.set("foo", "bar2") assert CallContext.get("foo") == "bar2" CallContext.set("foo2", "bar2") context2_names = CallContext.context_variable_names() assert len(context2_names) == 2 await asyncio.gather(context1(), context2()) # Assert that THIS context doesn't have any variables defined. names = CallContext.context_variable_names() assert len(names) == 0 jupyter-server-jupyter_server-e5c7e2b/tests/base/test_handlers.py000066400000000000000000000231261473126534200256040ustar00rootroot00000000000000"""Test Base Handlers""" import os import warnings from unittest.mock import MagicMock, patch import pytest from tornado.httpclient import HTTPClientError from tornado.httpserver import HTTPRequest from tornado.httputil import HTTPHeaders from jupyter_server.auth import AllowAllAuthorizer, IdentityProvider, User from jupyter_server.auth.decorator import allow_unauthenticated from jupyter_server.base.handlers import ( APIHandler, APIVersionHandler, AuthenticatedFileHandler, AuthenticatedHandler, FileFindHandler, FilesRedirectHandler, JupyterHandler, RedirectWithParams, ) from jupyter_server.serverapp import ServerApp from jupyter_server.utils import url_path_join def test_authenticated_handler(jp_serverapp): app: ServerApp = jp_serverapp request = HTTPRequest("OPTIONS") request.connection = MagicMock() handler = AuthenticatedHandler(app.web_app, request) for key in list(handler.settings): del handler.settings[key] handler.settings["headers"] = {"Content-Security-Policy": "foo"} assert handler.content_security_policy == "foo" assert handler.skip_check_origin() with warnings.catch_warnings(): warnings.simplefilter("ignore") assert handler.login_handler == handler.identity_provider.login_handler_class assert isinstance(handler.authorizer, AllowAllAuthorizer) assert isinstance(handler.identity_provider, IdentityProvider) def test_jupyter_handler(jp_serverapp): app: ServerApp = jp_serverapp headers = HTTPHeaders({"Origin": "foo"}) request = HTTPRequest("OPTIONS", headers=headers) request.connection = MagicMock() handler = JupyterHandler(app.web_app, request) for key in list(handler.settings): del handler.settings[key] handler.settings["mathjax_url"] = "foo" handler.settings["mathjax_config"] = "bar" assert handler.mathjax_url == "/foo" assert handler.mathjax_config == "bar" handler.settings["terminal_manager"] = None assert handler.terminal_manager is None handler.settings["allow_origin"] = True # type:ignore[unreachable] handler.set_cors_headers() handler.settings["allow_origin"] = False handler.settings["allow_origin_pat"] = "foo" handler.settings["allow_credentials"] = True handler.set_cors_headers() assert handler.check_referer() is True class NoAuthRulesHandler(JupyterHandler): def options(self) -> None: self.finish({}) def get(self) -> None: self.finish({}) class PermissiveHandler(JupyterHandler): @allow_unauthenticated def options(self) -> None: self.finish({}) @pytest.mark.parametrize( "jp_server_config", [{"ServerApp": {"allow_unauthenticated_access": True}}] ) async def test_jupyter_handler_auth_permissive(jp_serverapp, jp_fetch): app: ServerApp = jp_serverapp app.web_app.add_handlers( ".*$", [ (url_path_join(app.base_url, "no-rules"), NoAuthRulesHandler), (url_path_join(app.base_url, "permissive"), PermissiveHandler), ], ) # should always permit access when `@allow_unauthenticated` is used res = await jp_fetch("permissive", method="OPTIONS", headers={"Authorization": ""}) assert res.code == 200 # should allow access when no authentication rules are set up res = await jp_fetch("no-rules", method="OPTIONS", headers={"Authorization": ""}) assert res.code == 200 @pytest.mark.parametrize( "jp_server_config", [{"ServerApp": {"allow_unauthenticated_access": False}}] ) async def test_jupyter_handler_auth_required(jp_serverapp, jp_fetch): app: ServerApp = jp_serverapp app.web_app.add_handlers( ".*$", [ (url_path_join(app.base_url, "no-rules"), NoAuthRulesHandler), (url_path_join(app.base_url, "permissive"), PermissiveHandler), ], ) # should always permit access when `@allow_unauthenticated` is used res = await jp_fetch("permissive", method="OPTIONS", headers={"Authorization": ""}) assert res.code == 200 # should forbid access when no authentication rules are set up: # - by redirecting to login page for GET and HEAD res = await jp_fetch( "no-rules", method="GET", headers={"Authorization": ""}, follow_redirects=False, raise_error=False, ) assert res.code == 302 assert "/login" in res.headers["Location"] # - by returning 403 immediately for other requests with pytest.raises(HTTPClientError) as exception: res = await jp_fetch("no-rules", method="OPTIONS", headers={"Authorization": ""}) assert exception.value.code == 403 @pytest.mark.parametrize( "jp_server_config", [{"ServerApp": {"allow_unauthenticated_access": False}}] ) async def test_jupyter_handler_auth_calls_prepare(jp_serverapp, jp_fetch): app: ServerApp = jp_serverapp app.web_app.add_handlers( ".*$", [ (url_path_join(app.base_url, "no-rules"), NoAuthRulesHandler), (url_path_join(app.base_url, "permissive"), PermissiveHandler), ], ) # should call `super.prepare()` in `@allow_unauthenticated` code path with patch.object(JupyterHandler, "prepare", return_value=None) as mock: res = await jp_fetch("permissive", method="OPTIONS") assert res.code == 200 assert mock.call_count == 1 # should call `super.prepare()` in code path that checks authentication with patch.object(JupyterHandler, "prepare", return_value=None) as mock: res = await jp_fetch("no-rules", method="OPTIONS") assert res.code == 200 assert mock.call_count == 1 class IndiscriminateIdentityProvider(IdentityProvider): async def get_user(self, handler): return User(username="test") @pytest.mark.parametrize( "jp_server_config", [{"ServerApp": {"allow_unauthenticated_access": False}}] ) async def test_jupyter_handler_auth_respsects_identity_provider(jp_serverapp, jp_fetch): app: ServerApp = jp_serverapp app.web_app.add_handlers( ".*$", [(url_path_join(app.base_url, "no-rules"), NoAuthRulesHandler)], ) def fetch(): return jp_fetch("no-rules", method="OPTIONS", headers={"Authorization": ""}) # If no identity provider is set the following request should fail # because the default tornado user would not be found: with pytest.raises(HTTPClientError) as exception: await fetch() assert exception.value.code == 403 iidp = IndiscriminateIdentityProvider() # should allow access with the user set be the identity provider with patch.dict(jp_serverapp.web_app.settings, {"identity_provider": iidp}): res = await fetch() assert res.code == 200 def test_api_handler(jp_serverapp): app: ServerApp = jp_serverapp headers = HTTPHeaders({"Origin": "foo"}) request = HTTPRequest("OPTIONS", headers=headers) request.connection = MagicMock() handler = APIHandler(app.web_app, request) for key in list(handler.settings): del handler.settings[key] handler.options() async def test_authenticated_file_handler(jp_serverapp, tmpdir): app: ServerApp = jp_serverapp headers = HTTPHeaders({"Origin": "foo"}) request = HTTPRequest("HEAD", headers=headers) request.connection = MagicMock() test_file = tmpdir / "foo" with open(test_file, "w") as fid: fid.write("hello") handler = AuthenticatedFileHandler(app.web_app, request, path=str(tmpdir)) for key in list(handler.settings): if key != "contents_manager": del handler.settings[key] handler.check_xsrf_cookie = MagicMock() # type:ignore[method-assign] handler._jupyter_current_user = "foo" # type:ignore[assignment] with warnings.catch_warnings(): warnings.simplefilter("ignore") head = handler.head("foo") if head: await head assert handler.get_status() == 200 async def test_api_version_handler(jp_serverapp): app: ServerApp = jp_serverapp request = HTTPRequest("GET") request.connection = MagicMock() handler = APIVersionHandler(app.web_app, request) handler._transforms = [] handler.get() assert handler.get_status() == 200 async def test_files_redirect_handler(jp_serverapp): app: ServerApp = jp_serverapp request = HTTPRequest("GET") request.connection = MagicMock() test_file = os.path.join(app.contents_manager.root_dir, "foo") with open(test_file, "w") as fid: fid.write("hello") handler = FilesRedirectHandler(app.web_app, request) handler._transforms = [] await handler.get("foo") assert handler.get_status() == 302 def test_redirect_with_params(jp_serverapp): app: ServerApp = jp_serverapp request = HTTPRequest("GET") request.connection = MagicMock() request.query = "foo" handler = RedirectWithParams(app.web_app, request, url="foo") handler._transforms = [] handler.get() assert handler.get_status() == 301 async def test_static_handler(jp_serverapp, tmpdir): async def async_magic(): pass MagicMock.__await__ = lambda x: async_magic().__await__() test_file = tmpdir / "foo" with open(test_file, "w") as fid: fid.write("hello") app: ServerApp = jp_serverapp request = HTTPRequest("GET", str(test_file)) request.connection = MagicMock() handler = FileFindHandler(app.web_app, request, path=str(tmpdir)) handler._transforms = [] await handler.get("foo") assert handler._headers["Cache-Control"] == "no-cache" handler.settings["static_immutable_cache"] = [str(tmpdir)] await handler.get("foo") assert handler._headers["Cache-Control"] == "public, max-age=31536000, immutable" jupyter-server-jupyter_server-e5c7e2b/tests/base/test_websocket.py000066400000000000000000000136351473126534200257760ustar00rootroot00000000000000"""Test Base Websocket classes""" import logging import time from unittest.mock import MagicMock, patch import pytest from tornado.httpclient import HTTPClientError from tornado.httpserver import HTTPRequest from tornado.httputil import HTTPHeaders from tornado.websocket import WebSocketClosedError, WebSocketHandler from jupyter_server.auth import IdentityProvider, User from jupyter_server.auth.decorator import allow_unauthenticated from jupyter_server.base.handlers import JupyterHandler from jupyter_server.base.websocket import WebSocketMixin from jupyter_server.serverapp import ServerApp from jupyter_server.utils import JupyterServerAuthWarning, url_path_join class MockHandler(WebSocketMixin, WebSocketHandler): allow_origin = "*" allow_origin_pat = "" log = logging.getLogger() @pytest.fixture def mixin(jp_serverapp): app: ServerApp = jp_serverapp headers = HTTPHeaders({"Host": "foo"}) request = HTTPRequest("GET", headers=headers) request.connection = MagicMock() return MockHandler(app.web_app, request) def test_web_socket_mixin(mixin): assert mixin.check_origin("foo") is True mixin.allow_origin = "" assert mixin.check_origin("") is False mixin.allow_origin_pat = "foo" assert mixin.check_origin("foo") is True mixin.clear_cookie() assert mixin.get_status() == 200 def test_web_socket_mixin_ping(mixin): mixin.ws_connection = MagicMock() mixin.ws_connection.is_closing = lambda: False mixin.send_ping() def test_ping_client_terminated(mixin): mixin.ws_connection = MagicMock() mixin.ws_connection.client_terminated = True mixin.send_ping() with pytest.raises(WebSocketClosedError): mixin.write_message("hello") async def test_ping_client_timeout(mixin): mixin.on_pong("foo") mixin.settings["ws_ping_timeout"] = 0.1 time.sleep(0.3) mixin.ws_connection = MagicMock() mixin.ws_connection.is_closing = lambda: False mixin.send_ping() with pytest.raises(WebSocketClosedError): mixin.write_message("hello") class MockJupyterHandler(MockHandler, JupyterHandler): pass class NoAuthRulesWebsocketHandler(MockJupyterHandler): pass class PermissiveWebsocketHandler(MockJupyterHandler): @allow_unauthenticated def get(self, *args, **kwargs) -> None: return super().get(*args, **kwargs) @pytest.mark.parametrize( "jp_server_config", [{"ServerApp": {"allow_unauthenticated_access": True}}] ) async def test_websocket_auth_permissive(jp_serverapp, jp_ws_fetch): app: ServerApp = jp_serverapp app.web_app.add_handlers( ".*$", [ (url_path_join(app.base_url, "no-rules"), NoAuthRulesWebsocketHandler), (url_path_join(app.base_url, "permissive"), PermissiveWebsocketHandler), ], ) # should always permit access when `@allow_unauthenticated` is used ws = await jp_ws_fetch("permissive", headers={"Authorization": ""}) ws.close() # should allow access when no authentication rules are set up ws = await jp_ws_fetch("no-rules", headers={"Authorization": ""}) ws.close() @pytest.mark.parametrize( "jp_server_config", [{"ServerApp": {"allow_unauthenticated_access": False}}] ) async def test_websocket_auth_required(jp_serverapp, jp_ws_fetch): app: ServerApp = jp_serverapp app.web_app.add_handlers( ".*$", [ (url_path_join(app.base_url, "no-rules"), NoAuthRulesWebsocketHandler), (url_path_join(app.base_url, "permissive"), PermissiveWebsocketHandler), ], ) # should always permit access when `@allow_unauthenticated` is used ws = await jp_ws_fetch("permissive", headers={"Authorization": ""}) ws.close() # should forbid access when no authentication rules are set up with pytest.raises(HTTPClientError) as exception: ws = await jp_ws_fetch("no-rules", headers={"Authorization": ""}) assert exception.value.code == 403 class IndiscriminateIdentityProvider(IdentityProvider): async def get_user(self, handler): return User(username="test") @pytest.mark.parametrize( "jp_server_config", [{"ServerApp": {"allow_unauthenticated_access": False}}] ) async def test_websocket_auth_respsects_identity_provider(jp_serverapp, jp_ws_fetch): app: ServerApp = jp_serverapp app.web_app.add_handlers( ".*$", [(url_path_join(app.base_url, "no-rules"), NoAuthRulesWebsocketHandler)], ) def fetch(): return jp_ws_fetch("no-rules", headers={"Authorization": ""}) # If no identity provider is set the following request should fail # because the default tornado user would not be found: with pytest.raises(HTTPClientError) as exception: await fetch() assert exception.value.code == 403 iidp = IndiscriminateIdentityProvider() # should allow access with the user set be the identity provider with patch.dict(jp_serverapp.web_app.settings, {"identity_provider": iidp}): ws = await fetch() ws.close() class PermissivePlainWebsocketHandler(MockHandler): # note: inherits from MockHandler not MockJupyterHandler @allow_unauthenticated def get(self, *args, **kwargs) -> None: return super().get(*args, **kwargs) @pytest.mark.parametrize( "jp_server_config", [ { "ServerApp": { "allow_unauthenticated_access": False, "identity_provider": IndiscriminateIdentityProvider(), } } ], ) async def test_websocket_auth_warns_mixin_lacks_jupyter_handler(jp_serverapp, jp_ws_fetch): app: ServerApp = jp_serverapp app.web_app.add_handlers( ".*$", [(url_path_join(app.base_url, "permissive"), PermissivePlainWebsocketHandler)], ) with pytest.warns( JupyterServerAuthWarning, match="WebSocketMixin sub-class does not inherit from JupyterHandler", ): ws = await jp_ws_fetch("permissive", headers={"Authorization": ""}) ws.close() jupyter-server-jupyter_server-e5c7e2b/tests/conftest.py000066400000000000000000000073711473126534200236640ustar00rootroot00000000000000import os # isort: off # This must come before any Jupyter imports. os.environ["JUPYTER_PLATFORM_DIRS"] = "1" # isort: on import pytest from nbformat import writes from nbformat.v4 import new_notebook from tests.extension.mockextensions.app import MockExtensionApp # Enforce WinPTY for Windows terminals, since the ConPTY backend # dones not work in CI. os.environ["PYWINPTY_BACKEND"] = "1" pytest_plugins = ["jupyter_server.pytest_plugin"] def pytest_addoption(parser): parser.addoption( "--integration_tests", default=False, type=bool, help="only run tests with the 'integration_test' pytest mark.", ) def pytest_configure(config): # register an additional marker config.addinivalue_line("markers", "integration_test") def pytest_runtest_setup(item): is_integration_test = any(mark for mark in item.iter_markers(name="integration_test")) if item.config.getoption("--integration_tests") is True: if not is_integration_test: pytest.skip("Only running tests marked as 'integration_test'.") elif is_integration_test: pytest.skip( "Skipping this test because it's marked 'integration_test'. Run integration tests using the `--integration_tests` flag." ) mock_html = """ {% block title %}Jupyter Server 1{% endblock %} {% block meta %} {% endblock %}
{% block site %} {% endblock site %}
{% block after_site %} {% endblock after_site %} """ @pytest.fixture def mock_template(jp_template_dir): index = jp_template_dir.joinpath("index.html") index.write_text(mock_html) @pytest.fixture def extension_manager(jp_serverapp): return jp_serverapp.extension_manager @pytest.fixture def config_file(jp_config_dir): """""" f = jp_config_dir.joinpath("jupyter_mockextension_config.py") f.write_text("c.MockExtensionApp.mock_trait ='config from file'") return f @pytest.fixture(autouse=True) def jp_mockextension_cleanup(): yield MockExtensionApp.clear_instance() @pytest.fixture def contents_dir(tmp_path, jp_serverapp): return tmp_path / jp_serverapp.root_dir dirs = [ ("", "inroot"), ("Directory with spaces in", "inspace"), ("unicodé", "innonascii"), ("foo", "a"), ("foo", "b"), ("foo", "name with spaces"), ("foo", "unicodé"), ("foo/bar", "baz"), ("ordering", "A"), ("ordering", "b"), ("ordering", "C"), ("å b", "ç d"), ] @pytest.fixture def contents(contents_dir): # Create files in temporary directory paths: dict = {"notebooks": [], "textfiles": [], "blobs": [], "contents_dir": contents_dir} for d, name in dirs: p = contents_dir / d p.mkdir(parents=True, exist_ok=True) # Create a notebook nb = writes(new_notebook(), version=4) nbname = p.joinpath(f"{name}.ipynb") nbname.write_text(nb, encoding="utf-8") paths["notebooks"].append(nbname.relative_to(contents_dir)) # Create a text file txt = f"{name} text file" txtname = p.joinpath(f"{name}.txt") txtname.write_text(txt, encoding="utf-8") paths["textfiles"].append(txtname.relative_to(contents_dir)) # Create a random blob blob = name.encode("utf-8") + b"\xff" blobname = p.joinpath(f"{name}.blob") blobname.write_bytes(blob) paths["blobs"].append(blobname.relative_to(contents_dir)) paths["all"] = list(paths.values()) return paths @pytest.fixture def folders(): return list({item[0] for item in dirs}) jupyter-server-jupyter_server-e5c7e2b/tests/extension/000077500000000000000000000000001473126534200234715ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/extension/__init__.py000066400000000000000000000000001473126534200255700ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/000077500000000000000000000000001473126534200265425ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/__init__.py000066400000000000000000000013061473126534200306530ustar00rootroot00000000000000"""A mock extension module with a list of extensions to load in various tests. """ from .app import MockExtensionApp, MockExtensionNoTemplateApp # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_points(): return [ { "module": "tests.extension.mockextensions.app", "app": MockExtensionApp, }, { "module": "tests.extension.mockextensions.app", "app": MockExtensionNoTemplateApp, }, {"module": "tests.extension.mockextensions.mock1"}, {"module": "tests.extension.mockextensions.mock2"}, {"module": "tests.extension.mockextensions.mock3"}, ] jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/app.py000066400000000000000000000061211473126534200276740ustar00rootroot00000000000000from __future__ import annotations import os from jupyter_events import EventLogger from jupyter_events.schema_registry import SchemaRegistryException from tornado import web from traitlets import List, Unicode from jupyter_server.base.handlers import JupyterHandler from jupyter_server.extension.application import ExtensionApp, ExtensionAppJinjaMixin from jupyter_server.extension.handler import ExtensionHandlerJinjaMixin, ExtensionHandlerMixin STATIC_PATH = os.path.join(os.path.dirname(__file__), "static") EVENT_SCHEMA = """\ $id: https://events.jupyter.org/mockapp/v1/test version: '1' properties: msg: type: string required: - msg """ # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_points(): return [{"module": __name__, "app": MockExtensionApp}] class MockExtensionHandler(ExtensionHandlerMixin, JupyterHandler): def get(self): self.event_logger.emit( schema_id="https://events.jupyter.org/mockapp/v1/test", data={"msg": "Hello, world!"} ) self.finish(self.config.mock_trait) class MockExtensionTemplateHandler( ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler ): def get(self): self.write(self.render_template("index.html")) class MockExtensionErrorHandler(ExtensionHandlerMixin, JupyterHandler): def get(self): raise web.HTTPError(418) class MockExtensionApp(ExtensionAppJinjaMixin, ExtensionApp): name = "mockextension" template_paths: List[str] = List().tag(config=True) # type:ignore[assignment] static_paths = [STATIC_PATH] # type:ignore[assignment] mock_trait = Unicode("mock trait", config=True) loaded = False serverapp_config = { "jpserver_extensions": { "tests.extension.mockextensions.mock1": True, "tests.extension.mockextensions.app.mockextension_notemplate": True, } } @staticmethod def get_extension_package(): return "tests.extension.mockextensions" def initialize_settings(self): # Only add this event if it hasn't already been added. # Log the error if it fails, but don't crash the app. try: elogger: EventLogger = self.serverapp.event_logger # type:ignore[union-attr, assignment] elogger.register_event_schema(EVENT_SCHEMA) except SchemaRegistryException as err: self.log.error(err) def initialize_handlers(self): self.handlers.append(("/mock", MockExtensionHandler)) self.handlers.append(("/mock_template", MockExtensionTemplateHandler)) self.handlers.append(("/mock_error_template", MockExtensionErrorHandler)) self.loaded = True class MockExtensionNoTemplateApp(ExtensionApp): name = "mockextension_notemplate" loaded = False @staticmethod def get_extension_package(): return "tests.extension.mockextensions" def initialize_handlers(self): self.handlers.append(("/mock_error_notemplate", MockExtensionErrorHandler)) self.loaded = True if __name__ == "__main__": MockExtensionApp.launch_instance() jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/mock1.py000066400000000000000000000004511473126534200301260ustar00rootroot00000000000000"""A mock extension named `mock1` for testing purposes.""" # by the test functions. def _jupyter_server_extension_paths(): return [{"module": "tests.extension.mockextensions.mock1"}] def _load_jupyter_server_extension(serverapp): serverapp.mockI = True serverapp.mock_shared = "I" jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/mock2.py000066400000000000000000000004531473126534200301310ustar00rootroot00000000000000"""A mock extension named `mock2` for testing purposes.""" # by the test functions. def _jupyter_server_extension_paths(): return [{"module": "tests.extension.mockextensions.mock2"}] def _load_jupyter_server_extension(serverapp): serverapp.mockII = True serverapp.mock_shared = "II" jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/mock3.py000066400000000000000000000001651473126534200301320ustar00rootroot00000000000000"""A mock extension named `mock3` for testing purposes.""" def _load_jupyter_server_extension(serverapp): pass jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/mockext_both.py000066400000000000000000000004711473126534200316040ustar00rootroot00000000000000"""A mock extension named `mockext_both` for testing purposes.""" # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_paths(): return [{"module": "tests.extension.mockextensions.mockext_both"}] def _load_jupyter_server_extension(serverapp): pass jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/mockext_deprecated.py000066400000000000000000000004741473126534200327530ustar00rootroot00000000000000"""A mock extension named `mockext_py` for testing purposes.""" # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_paths(): return [{"module": "tests.extension.mockextensions.mockext_deprecated"}] def load_jupyter_server_extension(serverapp): pass jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/mockext_py.py000066400000000000000000000004651473126534200313030ustar00rootroot00000000000000"""A mock extension named `mockext_py` for testing purposes.""" # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_paths(): return [{"module": "tests.extension.mockextensions.mockext_py"}] def _load_jupyter_server_extension(serverapp): pass jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/mockext_sys.py000066400000000000000000000004661473126534200314720ustar00rootroot00000000000000"""A mock extension named `mockext_py` for testing purposes.""" # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_paths(): return [{"module": "tests.extension.mockextensions.mockext_sys"}] def _load_jupyter_server_extension(serverapp): pass jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/mockext_user.py000066400000000000000000000004711473126534200316260ustar00rootroot00000000000000"""A mock extension named `mockext_user` for testing purposes.""" # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_paths(): return [{"module": "tests.extension.mockextensions.mockext_user"}] def _load_jupyter_server_extension(serverapp): pass jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/static/000077500000000000000000000000001473126534200300315ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/extension/mockextensions/static/mock.txt000066400000000000000000000000241473126534200315170ustar00rootroot00000000000000mock static content jupyter-server-jupyter_server-e5c7e2b/tests/extension/test_app.py000066400000000000000000000136771473126534200257000ustar00rootroot00000000000000import json from io import StringIO from logging import StreamHandler from typing import Any from unittest import mock import pytest from traitlets.config import Config from jupyter_server.serverapp import ServerApp from .mockextensions.app import MockExtensionApp @pytest.fixture def jp_server_config(jp_template_dir): config = { "ServerApp": { "jpserver_extensions": {"tests.extension.mockextensions": True}, }, "MockExtensionApp": { "template_paths": [str(jp_template_dir)], "log_level": "DEBUG", }, } return config @pytest.fixture def mock_extension(extension_manager): name = "tests.extension.mockextensions" pkg = extension_manager.extensions[name] point = pkg.extension_points["mockextension"] app = point.app app.initialize() return app def test_initialize(jp_serverapp, jp_template_dir, mock_extension): # Check that settings and handlers were added to the mock extension. assert isinstance(mock_extension.serverapp, ServerApp) assert len(mock_extension.handlers) > 0 assert mock_extension.loaded assert mock_extension.template_paths == [str(jp_template_dir)] @pytest.mark.parametrize( "trait_name, trait_value, jp_argv", ( [ "mock_trait", "test mock trait", ["--MockExtensionApp.mock_trait=test mock trait"], ], ), ) def test_instance_creation_with_argv( trait_name, trait_value, jp_argv, mock_extension, ): assert getattr(mock_extension, trait_name) == trait_value def test_extensionapp_load_config_file( config_file, jp_serverapp, mock_extension, ): # Assert default config_file_paths is the same in the app and extension. assert mock_extension.config_file_paths == jp_serverapp.config_file_paths assert mock_extension.config_dir == jp_serverapp.config_dir assert mock_extension.config_file_name == "jupyter_mockextension_config" # Assert that the trait is updated by config file assert mock_extension.mock_trait == "config from file" def test_extensionapp_no_parent(): # make sure we can load config files, even when serverapp is not passed # relevant for e.g. shortcuts to config-loading app = MockExtensionApp() assert isinstance(app.config_file_paths, list) assert app.serverapp is not None OPEN_BROWSER_COMBINATIONS: Any = ( (True, {}), (True, {"ServerApp": {"open_browser": True}}), (False, {"ServerApp": {"open_browser": False}}), (True, {"MockExtensionApp": {"open_browser": True}}), (False, {"MockExtensionApp": {"open_browser": False}}), ( True, { "ServerApp": {"open_browser": True}, "MockExtensionApp": {"open_browser": True}, }, ), ( False, { "ServerApp": {"open_browser": True}, "MockExtensionApp": {"open_browser": False}, }, ), ( True, { "ServerApp": {"open_browser": False}, "MockExtensionApp": {"open_browser": True}, }, ), ( False, { "ServerApp": {"open_browser": False}, "MockExtensionApp": {"open_browser": False}, }, ), ) @pytest.mark.parametrize("expected_value, config", OPEN_BROWSER_COMBINATIONS) async def test_browser_open(monkeypatch, jp_environ, config, expected_value): with mock.patch("jupyter_server.serverapp.ServerApp.init_httpserver"): serverapp = MockExtensionApp.initialize_server(config=Config(config)) assert serverapp.open_browser == expected_value async def test_load_parallel_extensions(monkeypatch, jp_environ): with mock.patch("jupyter_server.serverapp.ServerApp.init_httpserver"): serverapp = MockExtensionApp.initialize_server() exts = serverapp.extension_manager.extensions assert "tests.extension.mockextensions.mock1" in exts assert "tests.extension.mockextensions" in exts exts = serverapp.jpserver_extensions assert exts["tests.extension.mockextensions.mock1"] assert exts["tests.extension.mockextensions"] async def test_stop_extension(jp_serverapp, caplog): """Test the stop_extension method. This should be fired by ServerApp.cleanup_extensions. """ calls = 0 # load extensions (make sure we only have the one extension loaded # as well as jp_serverapp.extension_manager.load_all_extensions() extension_name = "tests.extension.mockextensions" apps = set(jp_serverapp.extension_manager.extension_apps) assert apps == {"jupyter_server_terminals", extension_name} # add a stop_extension method for the extension app async def _stop(*args): nonlocal calls calls += 1 for apps in jp_serverapp.extension_manager.extension_apps.values(): for app in apps: if app: app.stop_extension = _stop # call cleanup_extensions, check the logging is correct caplog.clear() await jp_serverapp.cleanup_extensions() assert {msg for *_, msg in caplog.record_tuples} == { "Shutting down 2 extensions", "jupyter_server_terminals | extension app 'jupyter_server_terminals' stopping", f"{extension_name} | extension app 'mockextension' stopping", f"{extension_name} | extension app 'mockextension_notemplate' stopping", "jupyter_server_terminals | extension app 'jupyter_server_terminals' stopped", f"{extension_name} | extension app 'mockextension' stopped", f"{extension_name} | extension app 'mockextension_notemplate' stopped", } # check the shutdown method was called twice assert calls == 3 async def test_events(jp_serverapp, jp_fetch): stream = StringIO() handler = StreamHandler(stream) jp_serverapp.event_logger.register_handler(handler) await jp_fetch("mock") handler.flush() output = json.loads(stream.getvalue()) # Clear the sink. stream.truncate(0) stream.seek(0) assert output["msg"] == "Hello, world!" jupyter-server-jupyter_server-e5c7e2b/tests/extension/test_config.py000066400000000000000000000025711473126534200263540ustar00rootroot00000000000000import pytest from jupyter_core.paths import jupyter_config_path from jupyter_server.extension.config import ExtensionConfigManager # Use ServerApps environment because it monkeypatches # jupyter_core.paths and provides a config directory # that's not cross contaminating the user config directory. pytestmark = pytest.mark.usefixtures("jp_environ") @pytest.fixture def configd(jp_env_config_path): """A pathlib.Path object that acts like a jupyter_server_config.d folder.""" configd = jp_env_config_path.joinpath("jupyter_server_config.d") configd.mkdir() return configd ext1_json_config = """\ { "ServerApp": { "jpserver_extensions": { "ext1_config": true } } } """ @pytest.fixture def ext1_config(configd): config = configd.joinpath("ext1_config.json") config.write_text(ext1_json_config) ext2_json_config = """\ { "ServerApp": { "jpserver_extensions": { "ext2_config": false } } } """ @pytest.fixture def ext2_config(configd): config = configd.joinpath("ext2_config.json") config.write_text(ext2_json_config) def test_list_extension_from_configd(ext1_config, ext2_config): manager = ExtensionConfigManager(read_config_path=jupyter_config_path()) extensions = manager.get_jpserver_extensions() assert "ext2_config" in extensions assert "ext1_config" in extensions jupyter-server-jupyter_server-e5c7e2b/tests/extension/test_entrypoint.py000066400000000000000000000005371473126534200273220ustar00rootroot00000000000000import pytest # All test coroutines will be treated as marked. pytestmark = pytest.mark.script_launch_mode("subprocess") def test_server_extension_list(jp_environ, script_runner): ret = script_runner.run( [ "jupyter", "server", "extension", "list", ] ) assert ret.success jupyter-server-jupyter_server-e5c7e2b/tests/extension/test_handler.py000066400000000000000000000137451473126534200265310ustar00rootroot00000000000000from html.parser import HTMLParser import pytest from tornado.httpclient import HTTPClientError @pytest.fixture def jp_server_config(jp_template_dir): return { "ServerApp": {"jpserver_extensions": {"tests.extension.mockextensions": True}}, "MockExtensionApp": {"template_paths": [str(jp_template_dir)]}, } async def test_handler(jp_fetch): r = await jp_fetch("mock", method="GET") assert r.code == 200 assert r.body.decode() == "mock trait" async def test_handler_template(jp_fetch, mock_template): r = await jp_fetch("mock_template", method="GET") assert r.code == 200 @pytest.mark.parametrize( "jp_server_config", [ { "ServerApp": { "allow_unauthenticated_access": False, "jpserver_extensions": {"tests.extension.mockextensions": True}, } } ], ) async def test_handler_gets_blocked(jp_fetch, jp_server_config): # should redirect to login page if authorization token is missing r = await jp_fetch( "mock", method="GET", headers={"Authorization": ""}, follow_redirects=False, raise_error=False, ) assert r.code == 302 assert "/login" in r.headers["Location"] # should still work if authorization token is present r = await jp_fetch("mock", method="GET") assert r.code == 200 def test_serverapp_warns_of_unauthenticated_handler(jp_configurable_serverapp): # should warn about the handler missing decorator when unauthenticated access forbidden expected_warning = "Extension endpoints without @allow_unauthenticated, @ws_authenticated, nor @web.authenticated:" with pytest.warns(RuntimeWarning, match=expected_warning) as record: jp_configurable_serverapp(allow_unauthenticated_access=False) assert any( "GET of MockExtensionTemplateHandler registered for /a%40b/mock_template" in r.message.args[0] for r in record ) @pytest.mark.parametrize( "jp_server_config", [ { "ServerApp": {"jpserver_extensions": {"tests.extension.mockextensions": True}}, "MockExtensionApp": { # Change a trait in the MockExtensionApp using # the following config value. "mock_trait": "test mock trait" }, } ], ) async def test_handler_setting(jp_fetch, jp_server_config): # Test that the extension trait was picked up by the webapp. r = await jp_fetch("mock", method="GET") assert r.code == 200 assert r.body.decode() == "test mock trait" @pytest.mark.parametrize("jp_argv", (["--MockExtensionApp.mock_trait=test mock trait"],)) async def test_handler_argv(jp_fetch, jp_argv): # Test that the extension trait was picked up by the webapp. r = await jp_fetch("mock", method="GET") assert r.code == 200 assert r.body.decode() == "test mock trait" @pytest.mark.parametrize( "jp_server_config,jp_base_url", [ ( { "ServerApp": { "jpserver_extensions": {"tests.extension.mockextensions": True}, # Move extension handlers behind a url prefix "base_url": "test_prefix", }, "MockExtensionApp": { # Change a trait in the MockExtensionApp using # the following config value. "mock_trait": "test mock trait" }, }, "/test_prefix/", ) ], ) async def test_base_url(jp_fetch, jp_server_config, jp_base_url): # Test that the extension's handlers were properly prefixed r = await jp_fetch("mock", method="GET") assert r.code == 200 assert r.body.decode() == "test mock trait" # Test that the static namespace was prefixed by base_url r = await jp_fetch("static", "mockextension", "mock.txt", method="GET") assert r.code == 200 body = r.body.decode() assert "mock static content" in body class StylesheetFinder(HTMLParser): """Minimal HTML parser to find iframe.src attr""" def __init__(self): super().__init__() self.stylesheets = [] self.body_chunks = [] self.in_head = False self.in_body = False self.in_script = False def handle_starttag(self, tag, attrs): tag = tag.lower() if tag == "head": self.in_head = True elif tag == "body": self.in_body = True elif tag == "script": self.in_script = True elif self.in_head and tag.lower() == "link": attr_dict = dict(attrs) if attr_dict.get("rel", "").lower() == "stylesheet": self.stylesheets.append(attr_dict["href"]) def handle_endtag(self, tag): if tag == "head": self.in_head = False if tag == "body": self.in_body = False if tag == "script": self.in_script = False def handle_data(self, data): if self.in_body and not self.in_script: data = data.strip() if data: self.body_chunks.append(data) def find_stylesheets_body(html): """Find the href= attr of stylesheets and body text of an HTML document stylesheets are used to test static_url prefix """ finder = StylesheetFinder() finder.feed(html) return (finder.stylesheets, "\n".join(finder.body_chunks)) @pytest.mark.parametrize("error_url", ["mock_error_template", "mock_error_notemplate"]) async def test_error_render(jp_fetch, jp_serverapp, jp_base_url, error_url): with pytest.raises(HTTPClientError) as e: await jp_fetch(error_url, method="GET") r = e.value.response assert r.code == 418 assert r.headers["Content-Type"] == "text/html" html = r.body.decode("utf8") stylesheets, body = find_stylesheets_body(html) static_prefix = f"{jp_base_url}static/" assert stylesheets assert all(stylesheet.startswith(static_prefix) for stylesheet in stylesheets) assert str(r.code) in body jupyter-server-jupyter_server-e5c7e2b/tests/extension/test_launch.py000066400000000000000000000054411473126534200263600ustar00rootroot00000000000000"""Test launching Jupyter Server Applications through as ExtensionApp launch_instance. """ import os import subprocess import sys import time from binascii import hexlify from pathlib import Path import pytest import requests HERE = os.path.dirname(os.path.abspath(__file__)) @pytest.fixture def port(): return 9999 @pytest.fixture def token(): return hexlify(os.urandom(4)).decode("ascii") @pytest.fixture def auth_header(token): return {"Authorization": "token %s" % token} def wait_up(url, interval=0.1, check=None): while True: try: r = requests.get(url) except Exception: if check: assert check() # print("waiting for %s" % url) time.sleep(interval) else: break @pytest.fixture def launch_instance(request, port, token): def _run_in_subprocess(argv=None, add_token=True): argv = argv or [] def _kill_extension_app(): try: process.terminate() except OSError: # Already dead. pass process.wait(10) # Make sure all the fds get closed. for attr in ["stdout", "stderr", "stdin"]: fid = getattr(process, attr) if fid: fid.close() if add_token: argv.append(f'--IdentityProvider.token="{token}"') root = Path(HERE).parent.parent process = subprocess.Popen( [ sys.executable, "-m", "tests.extension.mockextensions.app", f"--port={port}", "--ip=127.0.0.1", "--no-browser", *argv, ], cwd=str(root), ) request.addfinalizer(_kill_extension_app) url = f"http://127.0.0.1:{port}" wait_up(url, check=lambda: process.poll() is None) return process return _run_in_subprocess @pytest.fixture def fetch(port, auth_header): def _get(endpoint): url = f"http://127.0.0.1:{port}" + endpoint return requests.get(url, headers=auth_header) return _get def test_launch_instance(launch_instance, fetch): launch_instance() r = fetch("/mock") assert r.status_code == 200 def test_base_url(launch_instance, fetch): launch_instance(["--ServerApp.base_url=/foo"]) r = fetch("/foo/mock") assert r.status_code == 200 def test_token_file(launch_instance, fetch, token): token_file = HERE / Path("token_file.txt") os.environ["JUPYTER_TOKEN_FILE"] = str(token_file) token_file.write_text(token, encoding="utf-8") launch_instance(add_token=False) r = fetch("/mock") del os.environ["JUPYTER_TOKEN_FILE"] token_file.unlink() assert r.status_code == 200 jupyter-server-jupyter_server-e5c7e2b/tests/extension/test_manager.py000066400000000000000000000136051473126534200265210ustar00rootroot00000000000000import os import sys from unittest import mock import pytest from jupyter_core.paths import jupyter_config_path from jupyter_server.extension.manager import ( ExtensionManager, ExtensionMetadataError, ExtensionModuleNotFound, ExtensionPackage, ExtensionPoint, ) # Use ServerApps environment because it monkeypatches # jupyter_core.paths and provides a config directory # that's not cross contaminating the user config directory. pytestmark = pytest.mark.usefixtures("jp_environ") def test_extension_point_api(): # Import mock extension metadata from .mockextensions import _jupyter_server_extension_points # Testing the first path (which is an extension app). metadata_list = _jupyter_server_extension_points() point = metadata_list[0] module = point["module"] app = point["app"] e = ExtensionPoint(metadata=point) assert e.module_name == module assert e.name == app.name assert app is not None assert callable(e.load) assert callable(e.link) assert e.validate() def test_extension_point_metadata_error(): # Missing the "module" key. bad_metadata = {"name": "nonexistent"} with pytest.raises(ExtensionMetadataError): ExtensionPoint(metadata=bad_metadata) def test_extension_point_notfound_error(): bad_metadata = {"module": "nonexistent"} with pytest.raises(ExtensionModuleNotFound): ExtensionPoint(metadata=bad_metadata) def test_extension_package_api(): # Import mock extension metadata from .mockextensions import _jupyter_server_extension_points # Testing the first path (which is an extension app). metadata_list = _jupyter_server_extension_points() path1 = metadata_list[0] app = path1["app"] e = ExtensionPackage(name="tests.extension.mockextensions", enabled=True) e.extension_points # noqa: B018 assert hasattr(e, "extension_points") assert len(e.extension_points) == len(metadata_list) assert app.name in e.extension_points assert e.validate() def test_extension_package_notfound_error(): with pytest.raises(ExtensionModuleNotFound): ExtensionPackage(name="nonexistent", enabled=True) # no raise if not enabled ExtensionPackage(name="nonexistent", enabled=False) def _normalize_path(path_list): return [p.rstrip(os.path.sep) for p in path_list] def test_extension_manager_api(jp_serverapp): jpserver_extensions = {"tests.extension.mockextensions": True} manager = ExtensionManager(serverapp=jp_serverapp) assert manager.config_manager expected = _normalize_path(os.path.join(jupyter_config_path()[0], "serverconfig")) assert _normalize_path(manager.config_manager.read_config_path[0]) == expected manager.from_jpserver_extensions(jpserver_extensions) assert len(manager.extensions) == 1 assert "tests.extension.mockextensions" in manager.extensions def test_extension_manager_linked_extensions(jp_serverapp): name = "tests.extension.mockextensions" manager = ExtensionManager(serverapp=jp_serverapp) manager.add_extension(name, enabled=True) manager.link_extension(name) assert name in manager.linked_extensions @pytest.mark.parametrize("has_app", [True, False]) def test_extension_manager_fail_add(jp_serverapp, has_app): name = "tests.extension.notanextension" manager = ExtensionManager(serverapp=jp_serverapp if has_app else None) manager.add_extension(name, enabled=True) # should only warn jp_serverapp.reraise_server_extension_failures = True if has_app: with pytest.raises(ExtensionModuleNotFound): assert manager.add_extension(name, enabled=True) is False else: assert manager.add_extension(name, enabled=True) is False @pytest.mark.parametrize("has_app", [True, False]) def test_extension_manager_fail_link(jp_serverapp, has_app): name = "tests.extension.mockextensions.app" with mock.patch( "tests.extension.mockextensions.app.MockExtensionApp.parse_command_line", side_effect=RuntimeError, ): manager = ExtensionManager(serverapp=jp_serverapp if has_app else None) manager.add_extension(name, enabled=True) manager.link_extension(name) # should only warn jp_serverapp.reraise_server_extension_failures = True if has_app: with pytest.raises(RuntimeError): manager.link_extension(name) else: manager.link_extension(name) @pytest.mark.parametrize("has_app", [True, False]) def test_extension_manager_fail_load(jp_serverapp, has_app): name = "tests.extension.mockextensions.app" with mock.patch( "tests.extension.mockextensions.app.MockExtensionApp.initialize_handlers", side_effect=RuntimeError, ): manager = ExtensionManager(serverapp=jp_serverapp if has_app else None) manager.add_extension(name, enabled=True) manager.link_extension(name) manager.load_extension(name) # should only warn jp_serverapp.reraise_server_extension_failures = True if has_app: with pytest.raises(RuntimeError): manager.load_extension(name) else: manager.load_extension(name) @pytest.mark.parametrize("has_app", [True, False]) def test_disable_no_import(jp_serverapp, has_app): # de-import modules so we can detect if they are re-imported disabled_ext = "tests.extension.mockextensions.mock1" enabled_ext = "tests.extension.mockextensions.mock2" sys.modules.pop(disabled_ext, None) sys.modules.pop(enabled_ext, None) manager = ExtensionManager(serverapp=jp_serverapp if has_app else None) manager.add_extension(disabled_ext, enabled=False) manager.add_extension(enabled_ext, enabled=True) assert disabled_ext not in sys.modules assert enabled_ext in sys.modules ext_pkg = manager.extensions[disabled_ext] assert ext_pkg.extension_points == {} assert ext_pkg.version == "" assert ext_pkg.metadata == [] jupyter-server-jupyter_server-e5c7e2b/tests/extension/test_serverextension.py000066400000000000000000000111241473126534200303440ustar00rootroot00000000000000from collections import OrderedDict import pytest try: from jupyter_core.paths import prefer_environment_over_user except ImportError: prefer_environment_over_user = None # type:ignore[assignment] from traitlets.tests.utils import check_help_all_output from jupyter_server.config_manager import BaseJSONConfigManager from jupyter_server.extension.serverextension import ( DisableServerExtensionApp, ListServerExtensionsApp, ServerExtensionApp, ToggleServerExtensionApp, _get_config_dir, toggle_server_extension_python, ) # Use ServerApps environment because it monkeypatches # jupyter_core.paths and provides a config directory # that's not cross contaminating the user config directory. pytestmark = pytest.mark.usefixtures("jp_environ") def test_help_output(): check_help_all_output("jupyter_server.extension.serverextension") check_help_all_output("jupyter_server.extension.serverextension", ["enable"]) check_help_all_output("jupyter_server.extension.serverextension", ["disable"]) check_help_all_output("jupyter_server.extension.serverextension", ["install"]) check_help_all_output("jupyter_server.extension.serverextension", ["uninstall"]) def get_config(sys_prefix=True): cm = BaseJSONConfigManager(config_dir=_get_config_dir(sys_prefix=sys_prefix)) data = cm.get("jupyter_server_config") return data.get("ServerApp", {}).get("jpserver_extensions", {}) def test_enable(jp_env_config_path, jp_extension_environ): toggle_server_extension_python("mock1", True) config = get_config() assert config["mock1"] def test_disable(jp_env_config_path, jp_extension_environ): toggle_server_extension_python("mock1", True) toggle_server_extension_python("mock1", False) config = get_config() assert not config["mock1"] @pytest.mark.skipif(prefer_environment_over_user is None, reason="Requires jupyter_core 5.0+") def test_merge_config(jp_env_config_path, jp_configurable_serverapp, jp_extension_environ): # Toggle each extension module with a JSON config file # at the sys-prefix config dir. toggle_server_extension_python( "tests.extension.mockextensions.mockext_sys", enabled=True, sys_prefix=True, ) toggle_server_extension_python( "tests.extension.mockextensions.mockext_user", enabled=True, user=True, ) # Write this configuration in two places, sys-prefix and user. # sys-prefix supersedes users, so the extension should be disabled # when these two configs merge. toggle_server_extension_python( "tests.extension.mockextensions.mockext_both", enabled=False, sys_prefix=True, ) toggle_server_extension_python( "tests.extension.mockextensions.mockext_both", enabled=True, user=True, ) mockext_py = "tests.extension.mockextensions.mockext_py" argv = ["--ServerApp.jpserver_extensions", f"{mockext_py}=True"] # Enable the last extension, mockext_py, using the CLI interface. app = jp_configurable_serverapp(config_dir=str(jp_env_config_path), argv=argv) # Verify that extensions are enabled and merged in proper order. extensions = app.jpserver_extensions assert extensions["tests.extension.mockextensions.mockext_user"] assert extensions["tests.extension.mockextensions.mockext_sys"] assert extensions["tests.extension.mockextensions.mockext_py"] # Merging should causes this extension to be disabled. if prefer_environment_over_user(): assert not extensions["tests.extension.mockextensions.mockext_both"] @pytest.mark.parametrize( "jp_server_config", [ { "ServerApp": { "jpserver_extensions": OrderedDict( [ ("tests.extension.mockextensions.mock2", True), ("tests.extension.mockextensions.mock1", True), ] ) } } ], ) def test_load_ordered(jp_serverapp, jp_server_config): assert jp_serverapp.mockII is True, "Mock II should have been loaded" assert jp_serverapp.mockI is True, "Mock I should have been loaded" assert jp_serverapp.mock_shared == "II", "Mock II should be loaded after Mock I" def test_server_extension_apps(jp_env_config_path, jp_extension_environ): app = ToggleServerExtensionApp() app.extra_args = ["mock1"] app.start() app2 = DisableServerExtensionApp() app2.extra_args = ["mock1"] app2.start() app3 = ListServerExtensionsApp() app3.start() def test_server_extension_app(): app = ServerExtensionApp() app.launch_instance(["list"]) jupyter-server-jupyter_server-e5c7e2b/tests/extension/test_utils.py000066400000000000000000000027651473126534200262540ustar00rootroot00000000000000import logging import pytest from jupyter_server.extension.utils import ( ExtensionLoadingError, get_loader, get_metadata, validate_extension, ) from tests.extension.mockextensions import mockext_deprecated, mockext_sys # Use ServerApps environment because it monkeypatches # jupyter_core.paths and provides a config directory # that's not cross contaminating the user config directory. pytestmark = pytest.mark.usefixtures("jp_environ") def test_validate_extension(): # enabled at sys level assert validate_extension("tests.extension.mockextensions.mockext_sys") # enabled at sys, disabled at user assert validate_extension("tests.extension.mockextensions.mockext_both") # enabled at user assert validate_extension("tests.extension.mockextensions.mockext_user") # enabled at Python assert validate_extension("tests.extension.mockextensions.mockext_py") def test_get_loader(): assert get_loader(mockext_sys) == mockext_sys._load_jupyter_server_extension with pytest.deprecated_call(): assert get_loader(mockext_deprecated) == mockext_deprecated.load_jupyter_server_extension with pytest.raises(ExtensionLoadingError): get_loader(object()) def test_get_metadata(): _, ext_points = get_metadata("tests.extension.mockextensions.mockext_sys") assert len(ext_points) _, ext_points = get_metadata("tests", logger=logging.getLogger()) point = ext_points[0] assert point["module"] == "tests" assert point["name"] == "tests" jupyter-server-jupyter_server-e5c7e2b/tests/namespace-package-test/000077500000000000000000000000001473126534200257575ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/namespace-package-test/README.md000066400000000000000000000001271473126534200272360ustar00rootroot00000000000000Blank namespace package for use in testing. https://www.python.org/dev/peps/pep-0420/ jupyter-server-jupyter_server-e5c7e2b/tests/namespace-package-test/setup.cfg000066400000000000000000000001171473126534200275770ustar00rootroot00000000000000[metadata] name = namespace-package-test [options] packages = find_namespace: jupyter-server-jupyter_server-e5c7e2b/tests/namespace-package-test/test_namespace/000077500000000000000000000000001473126534200307525ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/namespace-package-test/test_namespace/test_package/000077500000000000000000000000001473126534200334045ustar00rootroot00000000000000__init__.py000066400000000000000000000000001473126534200354240ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/namespace-package-test/test_namespace/test_packagejupyter-server-jupyter_server-e5c7e2b/tests/nbconvert/000077500000000000000000000000001473126534200234555ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/nbconvert/__init__.py000066400000000000000000000000001473126534200255540ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/nbconvert/test_handlers.py000066400000000000000000000100571473126534200266710ustar00rootroot00000000000000import json from base64 import encodebytes from shutil import which import pytest from nbformat import writes from nbformat.v4 import new_code_cell, new_markdown_cell, new_notebook, new_output from tornado.httpclient import HTTPClientError from ..utils import expected_http_error png_green_pixel = encodebytes( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00" b"\x00\x00\x01\x00\x00x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDAT" b"\x08\xd7c\x90\xfb\xcf\x00\x00\x02\\\x01\x1e.~d\x87\x00\x00\x00\x00IEND\xaeB`\x82" ).decode("ascii") @pytest.fixture def notebook(jp_root_dir): # Build sub directory. subdir = jp_root_dir / "foo" if not jp_root_dir.joinpath("foo").is_dir(): subdir.mkdir() # Build a notebook programmatically. nb = new_notebook() nb.cells.append(new_markdown_cell("Created by test ³")) cc1 = new_code_cell(source="print(2*6)") cc1.outputs.append(new_output(output_type="stream", text="12")) cc1.outputs.append( new_output( output_type="execute_result", data={"image/png": png_green_pixel}, execution_count=1, ) ) nb.cells.append(cc1) # Write file to tmp dir. nbfile = subdir / "testnb.ipynb" nbfile.write_text(writes(nb, version=4), encoding="utf-8") pytestmark = pytest.mark.skipif(not which("pandoc"), reason="Command 'pandoc' is not available") async def test_from_file(jp_fetch, notebook): r = await jp_fetch( "nbconvert", "html", "foo", "testnb.ipynb", method="GET", params={"download": False}, ) assert r.code == 200 assert "text/html" in r.headers["Content-Type"] assert "Created by test" in r.body.decode() assert "print" in r.body.decode() r = await jp_fetch( "nbconvert", "python", "foo", "testnb.ipynb", method="GET", params={"download": False}, ) assert r.code == 200 assert "text/x-python" in r.headers["Content-Type"] assert "print(2*6)" in r.body.decode() async def test_from_file_404(jp_fetch, notebook): with pytest.raises(HTTPClientError) as e: await jp_fetch( "nbconvert", "html", "foo", "thisdoesntexist.ipynb", method="GET", params={"download": False}, ) assert expected_http_error(e, 404) async def test_from_file_download(jp_fetch, notebook): r = await jp_fetch( "nbconvert", "python", "foo", "testnb.ipynb", method="GET", params={"download": True}, ) content_disposition = r.headers["Content-Disposition"] assert "attachment" in content_disposition assert "testnb.py" in content_disposition async def test_from_file_zip(jp_fetch, notebook): r = await jp_fetch( "nbconvert", "latex", "foo", "testnb.ipynb", method="GET", params={"download": True}, ) assert "application/zip" in r.headers["Content-Type"] assert ".zip" in r.headers["Content-Disposition"] async def test_from_post(jp_fetch, notebook): r = await jp_fetch( "api/contents/foo/testnb.ipynb", method="GET", ) nbmodel = json.loads(r.body.decode()) r = await jp_fetch("nbconvert", "html", method="POST", body=json.dumps(nbmodel)) assert r.code == 200 assert "text/html" in r.headers["Content-Type"] assert "Created by test" in r.body.decode() assert "print" in r.body.decode() r = await jp_fetch("nbconvert", "python", method="POST", body=json.dumps(nbmodel)) assert r.code == 200 assert "text/x-python" in r.headers["Content-Type"] assert "print(2*6)" in r.body.decode() async def test_from_post_zip(jp_fetch, notebook): r = await jp_fetch( "api/contents/foo/testnb.ipynb", method="GET", ) nbmodel = json.loads(r.body.decode()) r = await jp_fetch("nbconvert", "latex", method="POST", body=json.dumps(nbmodel)) assert "application/zip" in r.headers["Content-Type"] assert ".zip" in r.headers["Content-Disposition"] jupyter-server-jupyter_server-e5c7e2b/tests/services/000077500000000000000000000000001473126534200233005ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/__init__.py000066400000000000000000000000001473126534200253770ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/api/000077500000000000000000000000001473126534200240515ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/api/__init__.py000066400000000000000000000000001473126534200261500ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/api/test_api.py000066400000000000000000000123011473126534200262300ustar00rootroot00000000000000import json from collections.abc import Awaitable from unittest import mock import pytest from tornado.httpclient import HTTPError from jupyter_server.auth import Authorizer, IdentityProvider, User async def test_get_spec(jp_fetch): response = await jp_fetch("api", "spec.yaml", method="GET") assert response.code == 200 async def test_get_status(jp_fetch): response = await jp_fetch("api", "status", method="GET") assert response.code == 200 assert response.headers.get("Content-Type") == "application/json" status = json.loads(response.body.decode("utf8")) assert sorted(status.keys()) == [ "connections", "kernels", "last_activity", "started", ] assert status["connections"] == 0 assert status["kernels"] == 0 assert status["last_activity"].endswith("Z") assert status["started"].endswith("Z") class MockUser(User): permissions: dict[str, list[str]] class MockIdentityProvider(IdentityProvider): mock_user: MockUser async def get_user(self, handler): # super returns a UUID # return our mock user instead, as long as the request is authorized _authenticated = super().get_user(handler) if isinstance(_authenticated, Awaitable): _authenticated = await _authenticated authenticated = _authenticated if isinstance(self.mock_user, dict): self.mock_user = MockUser(**self.mock_user) if authenticated: return self.mock_user class MockAuthorizer(Authorizer): def is_authorized(self, handler, user, action, resource): permissions = user.permissions if permissions == "*": return True actions = permissions.get(resource, []) return action in actions @pytest.fixture def identity_provider(jp_serverapp): idp = MockIdentityProvider(parent=jp_serverapp) authorizer = MockAuthorizer(parent=jp_serverapp) with mock.patch.dict( jp_serverapp.web_app.settings, {"identity_provider": idp, "authorizer": authorizer}, ): yield idp @pytest.mark.parametrize( "identity, expected", [ ( {"username": "user.username"}, { "username": "user.username", "name": "user.username", "display_name": "user.username", }, ), ( {"username": "user", "name": "name", "display_name": "display"}, {"username": "user", "name": "name", "display_name": "display"}, ), ( None, 403, ), ], ) async def test_identity(jp_fetch, identity, expected, identity_provider): if identity: identity_provider.mock_user = MockUser(**identity) else: identity_provider.mock_user = None if isinstance(expected, int): with pytest.raises(HTTPError) as exc: await jp_fetch("api/me") print(exc) assert exc.value.code == expected return r = await jp_fetch("api/me") assert r.code == 200 response = json.loads(r.body.decode()) assert set(response.keys()) == {"identity", "permissions"} identity_model = response["identity"] print(identity_model) for key, value in expected.items(): assert identity_model[key] == value assert set(identity_model.keys()) == set(User.__dataclass_fields__) @pytest.mark.parametrize( "have_permissions, check_permissions, expected", [ ("*", None, {}), ( { "contents": ["read"], "kernels": ["read", "write"], "sessions": ["write"], }, { "contents": ["read", "write"], "kernels": ["read", "write", "execute"], "terminals": ["execute"], }, { "contents": ["read"], "kernels": ["read", "write"], "terminals": [], }, ), ("*", {"contents": ["write"]}, {"contents": ["write"]}), ], ) async def test_identity_permissions( jp_fetch, have_permissions, check_permissions, expected, identity_provider ): user = MockUser("username") user.permissions = have_permissions identity_provider.mock_user = user if check_permissions is not None: params = {"permissions": json.dumps(check_permissions)} else: params = None r = await jp_fetch("api/me", params=params) assert r is not None assert r.code == 200 response = json.loads(r.body.decode()) assert set(response.keys()) == {"identity", "permissions"} assert response["permissions"] == expected @pytest.mark.parametrize( "permissions", [ "", "[]", '"abc"', json.dumps({"resource": "action"}), json.dumps({"resource": [5]}), json.dumps({"resource": {}}), ], ) async def test_identity_bad_permissions(jp_fetch, permissions): with pytest.raises(HTTPError) as exc: await jp_fetch("api/me", params={"permissions": json.dumps(permissions)}) r = exc.value.response assert r is not None assert r.code == 400 reply = json.loads(r.body.decode()) assert "permissions should be a JSON dict" in reply["message"] jupyter-server-jupyter_server-e5c7e2b/tests/services/config/000077500000000000000000000000001473126534200245455ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/config/__init__.py000066400000000000000000000000001473126534200266440ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/config/test_api.py000066400000000000000000000025321473126534200267310ustar00rootroot00000000000000import json async def test_create_retrieve_config(jp_fetch): sample = {"foo": "bar", "baz": 73} response = await jp_fetch("api", "config", "example", method="PUT", body=json.dumps(sample)) assert response.code == 204 response2 = await jp_fetch( "api", "config", "example", method="GET", ) assert response2.code == 200 assert json.loads(response2.body.decode()) == sample async def test_modify(jp_fetch): sample = {"foo": "bar", "baz": 73, "sub": {"a": 6, "b": 7}, "sub2": {"c": 8}} modified_sample = { "foo": None, # should delete foo "baz": 75, "wib": [1, 2, 3], "sub": {"a": 8, "b": None, "d": 9}, "sub2": {"c": None}, # should delete sub2 } diff = {"baz": 75, "wib": [1, 2, 3], "sub": {"a": 8, "d": 9}} await jp_fetch("api", "config", "example", method="PUT", body=json.dumps(sample)) response2 = await jp_fetch( "api", "config", "example", method="PATCH", body=json.dumps(modified_sample) ) assert response2.code == 200 assert json.loads(response2.body.decode()) == diff async def test_get_unknown(jp_fetch): response = await jp_fetch( "api", "config", "nonexistent", method="GET", ) assert response.code == 200 assert json.loads(response.body.decode()) == {} jupyter-server-jupyter_server-e5c7e2b/tests/services/contents/000077500000000000000000000000001473126534200251355ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/contents/__init__.py000066400000000000000000000000001473126534200272340ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/contents/test_api.py000066400000000000000000001045461473126534200273310ustar00rootroot00000000000000import json import pathlib import sys import warnings from base64 import decodebytes, encodebytes from unicodedata import normalize from unittest.mock import patch import pytest import tornado from nbformat import from_dict from nbformat.v4 import new_markdown_cell, new_notebook from jupyter_server.utils import url_path_join from tests.conftest import dirs from ...utils import expected_http_error @pytest.fixture(autouse=True) def suppress_deprecation_warnings(): with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message="The synchronous ContentsManager", category=DeprecationWarning, ) yield def notebooks_only(dir_model): return [nb for nb in dir_model["content"] if nb["type"] == "notebook"] def dirs_only(dir_model): return [x for x in dir_model["content"] if x["type"] == "directory"] @pytest.fixture(params=["FileContentsManager", "AsyncFileContentsManager"]) def jp_argv(request): return [ "--ServerApp.contents_manager_class=jupyter_server.services.contents.filemanager." + request.param ] @pytest.mark.parametrize("path,name", dirs) async def test_list_notebooks(jp_fetch, contents, path, name): response = await jp_fetch( "api", "contents", path, method="GET", ) data = json.loads(response.body.decode()) nbs = notebooks_only(data) assert len(nbs) > 0 assert name + ".ipynb" in [normalize("NFC", n["name"]) for n in nbs] assert url_path_join(path, name + ".ipynb") in [normalize("NFC", n["path"]) for n in nbs] @pytest.mark.parametrize("path,name", dirs) async def test_get_dir_no_contents(jp_fetch, contents, path, name): response = await jp_fetch( "api", "contents", path, method="GET", params=dict( content="0", ), ) model = json.loads(response.body.decode()) assert model["path"] == path assert model["type"] == "directory" assert "content" in model assert model["content"] is None async def test_list_nonexistant_dir(jp_fetch, contents): with pytest.raises(tornado.httpclient.HTTPClientError): await jp_fetch( "api", "contents", "nonexistent", method="GET", ) @pytest.mark.parametrize("path,name", dirs) async def test_get_nb_contents(jp_fetch, contents, path, name): nbname = name + ".ipynb" nbpath = (path + "/" + nbname).lstrip("/") r = await jp_fetch("api", "contents", nbpath, method="GET", params=dict(content="1")) model = json.loads(r.body.decode()) assert model["name"] == nbname assert model["path"] == nbpath assert model["type"] == "notebook" assert "content" in model assert model["hash"] is None assert model["hash_algorithm"] is None assert model["format"] == "json" assert "metadata" in model["content"] assert isinstance(model["content"]["metadata"], dict) @pytest.mark.parametrize("path,name", dirs) async def test_get_nb_hash(jp_fetch, contents, path, name): nbname = name + ".ipynb" nbpath = (path + "/" + nbname).lstrip("/") r = await jp_fetch("api", "contents", nbpath, method="GET", params=dict(hash="1")) model = json.loads(r.body.decode()) assert model["name"] == nbname assert model["path"] == nbpath assert model["type"] == "notebook" assert model["hash"] assert model["hash_algorithm"] assert "metadata" in model["content"] assert isinstance(model["content"]["metadata"], dict) @pytest.mark.parametrize("path,name", dirs) async def test_get_nb_no_contents(jp_fetch, contents, path, name): nbname = name + ".ipynb" nbpath = (path + "/" + nbname).lstrip("/") r = await jp_fetch("api", "contents", nbpath, method="GET", params=dict(content="0")) model = json.loads(r.body.decode()) assert model["name"] == nbname assert model["path"] == nbpath assert model["type"] == "notebook" assert "hash" in model assert model["hash"] == None assert "hash_algorithm" in model assert "content" in model assert model["content"] is None async def test_get_nb_invalid(contents_dir, jp_fetch, contents): nb = { "nbformat": 4, "metadata": {}, "cells": [ { "cell_type": "wrong", "metadata": {}, } ], } nbpath = "å b/Validate tést.ipynb" (contents_dir / nbpath).write_text(json.dumps(nb)) r = await jp_fetch( "api", "contents", nbpath, method="GET", ) model = json.loads(r.body.decode()) assert model["path"] == nbpath assert model["type"] == "notebook" assert "content" in model assert "message" in model assert "validation failed" in model["message"].lower() async def test_get_contents_no_such_file(jp_fetch): with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", "foo/q.ipynb", method="GET", ) assert e.value.code == 404 @pytest.mark.parametrize("path,name", dirs) async def test_get_text_file_contents(jp_fetch, contents, path, name): txtname = name + ".txt" txtpath = (path + "/" + txtname).lstrip("/") r = await jp_fetch("api", "contents", txtpath, method="GET", params=dict(content="1")) model = json.loads(r.body.decode()) assert model["name"] == txtname assert model["path"] == txtpath assert "hash" in model assert model["hash"] == None assert "hash_algorithm" in model assert "content" in model assert model["format"] == "text" assert model["type"] == "file" assert model["content"] == f"{name} text file" with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", "foo/q.txt", method="GET", ) assert expected_http_error(e, 404) with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", "foo/bar/baz.blob", method="GET", params=dict(type="file", format="text"), ) assert expected_http_error(e, 400) @pytest.mark.parametrize("path,name", dirs) async def test_get_text_file_hash(jp_fetch, contents, path, name): txtname = name + ".txt" txtpath = (path + "/" + txtname).lstrip("/") r = await jp_fetch("api", "contents", txtpath, method="GET", params=dict(hash="1")) model = json.loads(r.body.decode()) assert model["name"] == txtname assert model["path"] == txtpath assert "hash" in model assert model["hash"] assert model["hash_algorithm"] assert model["format"] == "text" assert model["type"] == "file" async def test_get_404_hidden(jp_fetch, contents, contents_dir): # Create text files hidden_dir = contents_dir / ".hidden" hidden_dir.mkdir(parents=True, exist_ok=True) txt = "visible text file in hidden dir" txtname = hidden_dir.joinpath("visible.txt") txtname.write_text(txt, encoding="utf-8") txt2 = "hidden text file" txtname2 = contents_dir.joinpath(".hidden.txt") txtname2.write_text(txt2, encoding="utf-8") with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", ".hidden/visible.txt", method="GET", ) assert expected_http_error(e, 404) with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", ".hidden.txt", method="GET", ) assert expected_http_error(e, 404) @pytest.mark.parametrize("path,name", dirs) async def test_get_binary_file_contents(jp_fetch, contents, path, name): blobname = name + ".blob" blobpath = (path + "/" + blobname).lstrip("/") r = await jp_fetch("api", "contents", blobpath, method="GET", params=dict(content="1")) model = json.loads(r.body.decode()) assert model["name"] == blobname assert model["path"] == blobpath assert "content" in model assert "hash" in model assert model["hash"] == None assert "hash_algorithm" in model assert model["format"] == "base64" assert model["type"] == "file" data_out = decodebytes(model["content"].encode("ascii")) data_in = name.encode("utf-8") + b"\xff" assert data_in == data_out with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", "foo/q.txt", method="GET", ) assert expected_http_error(e, 404) async def test_get_bad_type(jp_fetch, contents): with pytest.raises(tornado.httpclient.HTTPClientError) as e: path = "unicodé" type = "file" await jp_fetch( "api", "contents", path, method="GET", params=dict(type=type), # This should be a directory, and thus throw and error ) assert expected_http_error(e, 400, f"{path} is a directory, not a {type}") with pytest.raises(tornado.httpclient.HTTPClientError) as e: path = "unicodé/innonascii.ipynb" type = "directory" await jp_fetch( "api", "contents", path, method="GET", params=dict(type=type), # This should be a file, and thus throw and error ) assert expected_http_error(e, 400, "%s is not a directory" % path) @pytest.fixture def _check_created(jp_base_url): def _inner(r, contents_dir, path, name, type="notebook"): fpath = path + "/" + name assert r.code == 201 location = jp_base_url + "api/contents/" + tornado.escape.url_escape(fpath, plus=False) assert r.headers["Location"] == location model = json.loads(r.body.decode()) assert model["name"] == name assert model["path"] == fpath assert model["type"] == type path = contents_dir + "/" + fpath if type == "directory": assert pathlib.Path(path).is_dir() else: assert pathlib.Path(path).is_file() return _inner async def test_create_untitled(jp_fetch, contents, contents_dir, _check_created): path = "å b" name = "Untitled.ipynb" r = await jp_fetch("api", "contents", path, method="POST", body=json.dumps({"ext": ".ipynb"})) _check_created(r, str(contents_dir), path, name, type="notebook") name = "Untitled1.ipynb" r = await jp_fetch("api", "contents", path, method="POST", body=json.dumps({"ext": ".ipynb"})) _check_created(r, str(contents_dir), path, name, type="notebook") path = "foo/bar" name = "Untitled.ipynb" r = await jp_fetch("api", "contents", path, method="POST", body=json.dumps({"ext": ".ipynb"})) _check_created(r, str(contents_dir), path, name, type="notebook") name = "untitled" r = await jp_fetch("api", "contents", path, method="POST", allow_nonstandard_methods=True) _check_created(r, str(contents_dir), path, name=name, type="file") async def test_create_untitled_txt(jp_fetch, contents, contents_dir, _check_created): name = "untitled.txt" path = "foo/bar" r = await jp_fetch("api", "contents", path, method="POST", body=json.dumps({"ext": ".txt"})) _check_created(r, str(contents_dir), path, name, type="file") r = await jp_fetch("api", "contents", path, name, method="GET") model = json.loads(r.body.decode()) assert model["type"] == "file" assert model["format"] == "text" assert model["content"] == "" async def test_upload(jp_fetch, contents, contents_dir, _check_created): nb = new_notebook() nbmodel = {"content": nb, "type": "notebook"} path = "å b" name = "Upload tést.ipynb" r = await jp_fetch("api", "contents", path, name, method="PUT", body=json.dumps(nbmodel)) _check_created(r, str(contents_dir), path, name) async def test_mkdir_untitled(jp_fetch, contents, contents_dir, _check_created): name = "Untitled Folder" path = "å b" r = await jp_fetch( "api", "contents", path, method="POST", body=json.dumps({"type": "directory"}) ) _check_created(r, str(contents_dir), path, name, type="directory") name = "Untitled Folder 1" r = await jp_fetch( "api", "contents", path, method="POST", body=json.dumps({"type": "directory"}) ) _check_created(r, str(contents_dir), path, name, type="directory") name = "Untitled Folder" path = "foo/bar" r = await jp_fetch( "api", "contents", path, method="POST", body=json.dumps({"type": "directory"}) ) _check_created(r, str(contents_dir), path, name, type="directory") async def test_mkdir(jp_fetch, contents, contents_dir, _check_created): name = "New ∂ir" path = "å b" r = await jp_fetch( "api", "contents", path, name, method="PUT", body=json.dumps({"type": "directory"}), ) _check_created(r, str(contents_dir), path, name, type="directory") async def test_mkdir_hidden_400(jp_fetch): with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", "å b/.hidden", method="PUT", body=json.dumps({"type": "directory"}), ) assert expected_http_error(e, 400) async def test_upload_txt(jp_fetch, contents, contents_dir, _check_created): body = "ünicode téxt" model = { "content": body, "format": "text", "type": "file", } path = "å b" name = "Upload tést.txt" await jp_fetch("api", "contents", path, name, method="PUT", body=json.dumps(model)) # check roundtrip r = await jp_fetch("api", "contents", path, name, method="GET") model = json.loads(r.body.decode()) assert model["type"] == "file" assert model["format"] == "text" assert model["path"] == path + "/" + name assert model["content"] == body async def test_upload_txt_hidden(jp_fetch, contents, contents_dir): with pytest.raises(tornado.httpclient.HTTPClientError) as e: body = "ünicode téxt" model = { "content": body, "format": "text", "type": "file", } path = ".hidden/Upload tést.txt" await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model)) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: body = "ünicode téxt" model = {"content": body, "format": "text", "type": "file", "path": ".hidden/test.txt"} path = "Upload tést.txt" await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model)) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: body = "ünicode téxt" model = { "content": body, "format": "text", "type": "file", } path = ".hidden.txt" await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model)) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: body = "ünicode téxt" model = {"content": body, "format": "text", "type": "file", "path": ".hidden.txt"} path = "Upload tést.txt" await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model)) assert expected_http_error(e, 400) async def test_upload_b64(jp_fetch, contents, contents_dir, _check_created): body = b"\xffblob" b64body = encodebytes(body).decode("ascii") model = { "content": b64body, "format": "base64", "type": "file", } path = "å b" name = "Upload tést.blob" await jp_fetch("api", "contents", path, name, method="PUT", body=json.dumps(model)) # check roundtrip r = await jp_fetch("api", "contents", path, name, method="GET") model = json.loads(r.body.decode()) assert model["type"] == "file" assert model["path"] == path + "/" + name assert model["format"] == "base64" decoded = decodebytes(model["content"].encode("ascii")) assert decoded == body async def test_copy(jp_fetch, contents, contents_dir, _check_created): path = "å b" name = "ç d.ipynb" copy = "ç d-Copy1.ipynb" r = await jp_fetch( "api", "contents", path, method="POST", body=json.dumps({"copy_from": path + "/" + name}), ) _check_created(r, str(contents_dir), path, copy, type="notebook") # Copy the same file name copy2 = "ç d-Copy2.ipynb" r = await jp_fetch( "api", "contents", path, method="POST", body=json.dumps({"copy_from": path + "/" + name}), ) _check_created(r, str(contents_dir), path, copy2, type="notebook") # copy a copy. copy3 = "ç d-Copy3.ipynb" r = await jp_fetch( "api", "contents", path, method="POST", body=json.dumps({"copy_from": path + "/" + copy2}), ) _check_created(r, str(contents_dir), path, copy3, type="notebook") async def test_copy_dir(jp_fetch, contents, contents_dir, _check_created): # created a nest copy of a the original folder dest_dir = "foo" path = "parent" response = await jp_fetch( "api", "contents", path, method="POST", body=json.dumps({"copy_from": dest_dir}) ) _check_created(response, str(contents_dir), path, dest_dir, type="directory") # copy to a folder where a similar name exists dest_dir = "foo" path = "parent" copy_dir = f"{dest_dir}-Copy1" response = await jp_fetch( "api", "contents", path, method="POST", body=json.dumps({"copy_from": dest_dir}) ) _check_created(response, str(contents_dir), path, copy_dir, type="directory") async def test_copy_path(jp_fetch, contents, contents_dir, _check_created): path1 = "foo" path2 = "å b" name = "a.ipynb" copy = "a-Copy1.ipynb" r = await jp_fetch( "api", "contents", path2, method="POST", body=json.dumps({"copy_from": path1 + "/" + name}), ) _check_created(r, str(contents_dir), path2, name, type="notebook") r = await jp_fetch( "api", "contents", path2, method="POST", body=json.dumps({"copy_from": path1 + "/" + name}), ) _check_created(r, str(contents_dir), path2, copy, type="notebook") async def test_copy_put_400(jp_fetch, contents, contents_dir, _check_created): with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", "å b/cøpy.ipynb", method="PUT", body=json.dumps({"copy_from": "å b/ç d.ipynb"}), ) assert expected_http_error(e, 400) async def test_copy_put_400_hidden( jp_fetch, contents, contents_dir, ): with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", ".hidden/old.txt", method="PUT", body=json.dumps({"copy_from": "new.txt"}), ) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", "old.txt", method="PUT", body=json.dumps({"copy_from": ".hidden/new.txt"}), ) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", ".hidden.txt", method="PUT", body=json.dumps({"copy_from": "new.txt"}), ) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", "old.txt", method="PUT", body=json.dumps({"copy_from": ".hidden.txt"}), ) assert expected_http_error(e, 400) async def test_copy_400_hidden( jp_fetch, contents, contents_dir, ): # Create text files hidden_dir = contents_dir / ".hidden" hidden_dir.mkdir(parents=True, exist_ok=True) txt = "visible text file in hidden dir" txtname = hidden_dir.joinpath("new.txt") txtname.write_text(txt, encoding="utf-8") paths = ["new.txt", ".hidden.txt"] for name in paths: txt = f"{name} text file" txtname = contents_dir.joinpath(f"{name}.txt") txtname.write_text(txt, encoding="utf-8") with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", ".hidden/old.txt", method="POST", body=json.dumps({"copy_from": "new.txt"}), ) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", "old.txt", method="POST", body=json.dumps({"copy_from": ".hidden/new.txt"}), ) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", ".hidden.txt", method="POST", body=json.dumps({"copy_from": "new.txt"}), ) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", "old.txt", method="POST", body=json.dumps({"copy_from": ".hidden.txt"}), ) assert expected_http_error(e, 400) @pytest.mark.parametrize("path,name", dirs) async def test_delete(jp_fetch, contents, contents_dir, path, name, _check_created): nbname = name + ".ipynb" nbpath = (path + "/" + nbname).lstrip("/") r = await jp_fetch( "api", "contents", nbpath, method="DELETE", ) assert r.code == 204 async def test_delete_dirs(jp_fetch, contents, folders): # Iterate over folders for name in sorted([*folders, "/"], key=len, reverse=True): r = await jp_fetch("api", "contents", name, method="GET") # Get JSON blobs for each content. listing = json.loads(r.body.decode())["content"] # Delete all content for model in listing: await jp_fetch("api", "contents", model["path"], method="DELETE") # Make sure all content has been deleted. r = await jp_fetch("api", "contents", method="GET") model = json.loads(r.body.decode()) assert model["content"] == [] @pytest.mark.xfail(sys.platform == "win32", reason="Deleting non-empty dirs on Windows") async def test_delete_non_empty_dir(jp_fetch, contents): # Delete a folder await jp_fetch("api", "contents", "å b", method="DELETE") # Check that the folder was been deleted. with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch("api", "contents", "å b", method="GET") assert expected_http_error(e, 404) async def test_delete_hidden_dir(jp_fetch, contents): with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch("api", "contents", ".hidden", method="DELETE") assert expected_http_error(e, 400) async def test_delete_hidden_file(jp_fetch, contents): # Test deleting file in a hidden directory with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch("api", "contents", ".hidden/test.txt", method="DELETE") assert expected_http_error(e, 400) # Test deleting a hidden file with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch("api", "contents", ".hidden.txt", method="DELETE") assert expected_http_error(e, 400) async def test_rename(jp_fetch, jp_base_url, contents, contents_dir): path = "foo" name = "a.ipynb" new_name = "z.ipynb" # Rename the file r = await jp_fetch( "api", "contents", path, name, method="PATCH", body=json.dumps({"path": path + "/" + new_name}), ) fpath = path + "/" + new_name assert r.code == 200 location = url_path_join(jp_base_url, "api/contents/", fpath) assert r.headers["Location"] == location model = json.loads(r.body.decode()) assert model["name"] == new_name assert model["path"] == fpath fpath = str(contents_dir / fpath) assert pathlib.Path(fpath).is_file() # Check that the files have changed r = await jp_fetch("api", "contents", path, method="GET") listing = json.loads(r.body.decode()) nbnames = [name["name"] for name in listing["content"]] assert "z.ipynb" in nbnames assert "a.ipynb" not in nbnames async def test_rename_400_hidden(jp_fetch, jp_base_url, contents, contents_dir): with pytest.raises(tornado.httpclient.HTTPClientError) as e: old_path = ".hidden/old.txt" new_path = "new.txt" # Rename the file r = await jp_fetch( "api", "contents", old_path, method="PATCH", body=json.dumps({"path": new_path}), ) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: old_path = "old.txt" new_path = ".hidden/new.txt" # Rename the file r = await jp_fetch( "api", "contents", old_path, method="PATCH", body=json.dumps({"path": new_path}), ) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: old_path = ".hidden.txt" new_path = "new.txt" # Rename the file r = await jp_fetch( "api", "contents", old_path, method="PATCH", body=json.dumps({"path": new_path}), ) assert expected_http_error(e, 400) with pytest.raises(tornado.httpclient.HTTPClientError) as e: old_path = "old.txt" new_path = ".hidden.txt" # Rename the file r = await jp_fetch( "api", "contents", old_path, method="PATCH", body=json.dumps({"path": new_path}), ) assert expected_http_error(e, 400) async def test_checkpoints_follow_file(jp_fetch, contents): path = "foo" name = "a.ipynb" # Read initial file. r = await jp_fetch("api", "contents", path, name, method="GET") model = json.loads(r.body.decode()) # Create a checkpoint of initial state r = await jp_fetch( "api", "contents", path, name, "checkpoints", method="POST", allow_nonstandard_methods=True, ) cp1 = json.loads(r.body.decode()) # Modify file and save. nbcontent = model["content"] nb = from_dict(nbcontent) hcell = new_markdown_cell("Created by test") nb.cells.append(hcell) nbmodel = {"content": nb, "type": "notebook"} await jp_fetch("api", "contents", path, name, method="PUT", body=json.dumps(nbmodel)) # List checkpoints r = await jp_fetch( "api", "contents", path, name, "checkpoints", method="GET", ) cps = json.loads(r.body.decode()) assert cps == [cp1] r = await jp_fetch("api", "contents", path, name, method="GET") model = json.loads(r.body.decode()) nbcontent = model["content"] nb = from_dict(nbcontent) assert nb.cells[0].source == "Created by test" async def test_rename_existing(jp_fetch, contents): with pytest.raises(tornado.httpclient.HTTPClientError) as e: path = "foo" name = "a.ipynb" new_name = "b.ipynb" # Rename the file await jp_fetch( "api", "contents", path, name, method="PATCH", body=json.dumps({"path": path + "/" + new_name}), ) assert expected_http_error(e, 409) async def test_save(jp_fetch, contents): r = await jp_fetch("api", "contents", "foo/a.ipynb", method="GET") model = json.loads(r.body.decode()) nbmodel = model["content"] nb = from_dict(nbmodel) nb.cells.append(new_markdown_cell("Created by test ³")) nbmodel = {"content": nb, "type": "notebook"} await jp_fetch("api", "contents", "foo/a.ipynb", method="PUT", body=json.dumps(nbmodel)) # Round trip. r = await jp_fetch("api", "contents", "foo/a.ipynb", method="GET") model = json.loads(r.body.decode()) newnb = from_dict(model["content"]) assert newnb.cells[0].source == "Created by test ³" async def test_checkpoints(jp_fetch, contents): path = "foo/a.ipynb" resp = await jp_fetch("api", "contents", path, method="GET") model = json.loads(resp.body.decode()) r = await jp_fetch( "api", "contents", path, "checkpoints", method="POST", allow_nonstandard_methods=True, ) assert r.code == 201 cp1 = json.loads(r.body.decode()) assert set(cp1) == {"id", "last_modified"} assert r.headers["Location"].split("/")[-1] == cp1["id"] # Modify it. nbcontent = model["content"] nb = from_dict(nbcontent) hcell = new_markdown_cell("Created by test") nb.cells.append(hcell) # Save it. nbmodel = {"content": nb, "type": "notebook"} await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(nbmodel)) # List checkpoints r = await jp_fetch("api", "contents", path, "checkpoints", method="GET") cps = json.loads(r.body.decode()) assert cps == [cp1] r = await jp_fetch("api", "contents", path, method="GET") nbcontent = json.loads(r.body.decode())["content"] nb = from_dict(nbcontent) assert nb.cells[0].source == "Created by test" # Restore Checkpoint cp1 r = await jp_fetch( "api", "contents", path, "checkpoints", cp1["id"], method="POST", allow_nonstandard_methods=True, ) assert r.code == 204 r = await jp_fetch("api", "contents", path, method="GET") nbcontent = json.loads(r.body.decode())["content"] nb = from_dict(nbcontent) assert nb.cells == [] # Delete cp1 r = await jp_fetch("api", "contents", path, "checkpoints", cp1["id"], method="DELETE") assert r.code == 204 r = await jp_fetch("api", "contents", path, "checkpoints", method="GET") cps = json.loads(r.body.decode()) assert cps == [] async def test_file_checkpoints(jp_fetch, contents): path = "foo/a.txt" resp = await jp_fetch("api", "contents", path, method="GET") orig_content = json.loads(resp.body.decode())["content"] r = await jp_fetch( "api", "contents", path, "checkpoints", method="POST", allow_nonstandard_methods=True, ) assert r.code == 201 cp1 = json.loads(r.body.decode()) assert set(cp1) == {"id", "last_modified"} assert r.headers["Location"].split("/")[-1] == cp1["id"] # Modify it. new_content = orig_content + "\nsecond line" model = { "content": new_content, "type": "file", "format": "text", } # Save it. await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model)) # List checkpoints r = await jp_fetch("api", "contents", path, "checkpoints", method="GET") cps = json.loads(r.body.decode()) assert cps == [cp1] r = await jp_fetch("api", "contents", path, method="GET") content = json.loads(r.body.decode())["content"] assert content == new_content # Restore Checkpoint cp1 r = await jp_fetch( "api", "contents", path, "checkpoints", cp1["id"], method="POST", allow_nonstandard_methods=True, ) assert r.code == 204 r = await jp_fetch("api", "contents", path, method="GET") restored_content = json.loads(r.body.decode())["content"] assert restored_content == orig_content # Delete cp1 r = await jp_fetch("api", "contents", path, "checkpoints", cp1["id"], method="DELETE") assert r.code == 204 r = await jp_fetch("api", "contents", path, "checkpoints", method="GET") cps = json.loads(r.body.decode()) assert cps == [] async def test_trust(jp_fetch, contents): # It should be able to trust a notebook that exists for path in contents["notebooks"]: r = await jp_fetch( "api", "contents", str(path), "trust", method="POST", allow_nonstandard_methods=True, ) assert r.code == 201 @patch( "jupyter_core.paths.is_hidden", side_effect=AssertionError("Should not call is_hidden if not important"), ) @patch( "jupyter_server.services.contents.filemanager.is_hidden", side_effect=AssertionError("Should not call is_hidden if not important"), ) async def test_regression_is_hidden(m1, m2, jp_fetch, jp_serverapp, contents, _check_created): # check that no is_hidden check runs if configured to allow hidden files contents_dir = contents["contents_dir"] hidden_dir = contents_dir / ".hidden" hidden_dir.mkdir(parents=True, exist_ok=True) txt = "visible text file in hidden dir" txtname = hidden_dir.joinpath("visible.txt") txtname.write_text(txt, encoding="utf-8") # Our role here is to check that the side-effect never triggers jp_serverapp.contents_manager.allow_hidden = True r = await jp_fetch( "api", "contents", ".hidden", ) assert r.code == 200 r = await jp_fetch( "api", "contents", ".hidden", method="POST", body=json.dumps( { "copy_from": ".hidden/visible.txt", } ), ) _check_created(r, str(contents_dir), ".hidden", "visible-Copy1.txt", type="file") r = await jp_fetch( "api", "contents", ".hidden", "visible-Copy1.txt", method="DELETE", ) assert r.code == 204 model = { "content": "foo", "format": "text", "type": "file", } r = await jp_fetch( "api", "contents", ".hidden", "new.txt", method="PUT", body=json.dumps(model) ) _check_created(r, str(contents_dir), ".hidden", "new.txt", type="file") # sanity check that is actually triggers when flag set to false jp_serverapp.contents_manager.allow_hidden = False with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( "api", "contents", ".hidden", ) assert expected_http_error(e, 500) jupyter-server-jupyter_server-e5c7e2b/tests/services/contents/test_checkpoints.py000066400000000000000000000075251473126534200310710ustar00rootroot00000000000000import pytest from jupyter_core.utils import ensure_async from nbformat import from_dict from nbformat.v4 import new_markdown_cell from jupyter_server.services.contents.filecheckpoints import ( AsyncFileCheckpoints, AsyncGenericFileCheckpoints, FileCheckpoints, GenericFileCheckpoints, ) from jupyter_server.services.contents.largefilemanager import ( AsyncLargeFileManager, LargeFileManager, ) param_pairs = [ (LargeFileManager, FileCheckpoints), (LargeFileManager, GenericFileCheckpoints), (AsyncLargeFileManager, AsyncFileCheckpoints), (AsyncLargeFileManager, AsyncGenericFileCheckpoints), ] @pytest.fixture(params=param_pairs) def contents_manager(request, contents): """Returns a LargeFileManager instance.""" file_manager, checkpoints_class = request.param root_dir = str(contents["contents_dir"]) return file_manager(root_dir=root_dir, checkpoints_class=checkpoints_class) async def test_checkpoints_follow_file(contents_manager): cm: LargeFileManager = contents_manager path = "foo/a.ipynb" # Read initial file. model = await ensure_async(cm.get(path)) # Create a checkpoint of initial state cp1 = await ensure_async(cm.create_checkpoint(path)) # Modify file and save. nbcontent = model["content"] nb = from_dict(nbcontent) hcell = new_markdown_cell("Created by test") nb.cells.append(hcell) nbmodel = {"content": nb, "type": "notebook"} await ensure_async(cm.save(nbmodel, path)) # List checkpoints cps = await ensure_async(cm.list_checkpoints(path)) assert cps == [cp1] model = await ensure_async(cm.get(path)) nbcontent = model["content"] nb = from_dict(nbcontent) assert nb.cells[0].source == "Created by test" async def test_nb_checkpoints(contents_manager): cm: LargeFileManager = contents_manager path = "foo/a.ipynb" model = await ensure_async(cm.get(path)) cp1 = await ensure_async(cm.create_checkpoint(path)) assert set(cp1) == {"id", "last_modified"} # Modify it. nbcontent = model["content"] nb = from_dict(nbcontent) hcell = new_markdown_cell("Created by test") nb.cells.append(hcell) # Save it. nbmodel = {"content": nb, "type": "notebook"} await ensure_async(cm.save(nbmodel, path)) # List checkpoints cps = await ensure_async(cm.list_checkpoints(path)) assert cps == [cp1] nbcontent = await ensure_async(cm.get(path)) nb = from_dict(nbcontent["content"]) assert nb.cells[0].source == "Created by test" # Restore Checkpoint cp1 await ensure_async(cm.restore_checkpoint(cp1["id"], path)) nbcontent = await ensure_async(cm.get(path)) nb = from_dict(nbcontent["content"]) assert nb.cells == [] # Delete cp1 await ensure_async(cm.delete_checkpoint(cp1["id"], path)) cps = await ensure_async(cm.list_checkpoints(path)) assert cps == [] async def test_file_checkpoints(contents_manager): cm: LargeFileManager = contents_manager path = "foo/a.txt" model = await ensure_async(cm.get(path)) orig_content = model["content"] cp1 = await ensure_async(cm.create_checkpoint(path)) assert set(cp1) == {"id", "last_modified"} # Modify and save it. model["content"] = new_content = orig_content + "\nsecond line" await ensure_async(cm.save(model, path)) # List checkpoints cps = await ensure_async(cm.list_checkpoints(path)) assert cps == [cp1] model = await ensure_async(cm.get(path)) assert model["content"] == new_content # Restore Checkpoint cp1 await ensure_async(cm.restore_checkpoint(cp1["id"], path)) restored_content = await ensure_async(cm.get(path)) assert restored_content["content"] == orig_content # Delete cp1 await ensure_async(cm.delete_checkpoint(cp1["id"], path)) cps = await ensure_async(cm.list_checkpoints(path)) assert cps == [] jupyter-server-jupyter_server-e5c7e2b/tests/services/contents/test_config.py000066400000000000000000000033041473126534200300130ustar00rootroot00000000000000import pytest from jupyter_server.services.contents.checkpoints import AsyncCheckpoints from jupyter_server.services.contents.filecheckpoints import ( AsyncFileCheckpoints, AsyncGenericFileCheckpoints, ) from jupyter_server.services.contents.manager import AsyncContentsManager @pytest.fixture(params=[AsyncGenericFileCheckpoints, AsyncFileCheckpoints]) def jp_server_config(request): return {"FileContentsManager": {"checkpoints_class": request.param}} def test_config_did_something(jp_server_config, jp_serverapp): assert isinstance( jp_serverapp.contents_manager.checkpoints, jp_server_config["FileContentsManager"]["checkpoints_class"], ) def example_pre_save_hook(): pass def example_post_save_hook(): pass @pytest.mark.parametrize( "jp_server_config", [ { "ContentsManager": { "pre_save_hook": "tests.services.contents.test_config.example_pre_save_hook", "post_save_hook": "tests.services.contents.test_config.example_post_save_hook", }, } ], ) def test_pre_post_save_hook_config(jp_serverapp, jp_server_config): assert jp_serverapp.contents_manager.pre_save_hook.__name__ == "example_pre_save_hook" assert jp_serverapp.contents_manager.post_save_hook.__name__ == "example_post_save_hook" def test_async_contents_manager(jp_configurable_serverapp): config = {"ContentsManager": {"checkpoints_class": AsyncCheckpoints}} argv = [ "--ServerApp.contents_manager_class=jupyter_server.services.contents.manager.AsyncContentsManager" ] app = jp_configurable_serverapp(config=config, argv=argv) assert isinstance(app.contents_manager, AsyncContentsManager) jupyter-server-jupyter_server-e5c7e2b/tests/services/contents/test_fileio.py000066400000000000000000000137041473126534200300220ustar00rootroot00000000000000import json import logging import os import stat import sys import pytest from nbformat import validate from nbformat.v4 import new_notebook from tornado.web import HTTPError from jupyter_server.services.contents.fileio import ( AsyncFileManagerMixin, FileManagerMixin, atomic_writing, path_to_intermediate, path_to_invalid, ) umask = 0 def test_atomic_writing(tmp_path): class CustomExc(Exception): pass f1 = tmp_path / "penguin" f1.write_text("Before") if os.name != "nt": os.chmod(str(f1), 0o701) orig_mode = stat.S_IMODE(os.stat(str(f1)).st_mode) f2 = tmp_path / "flamingo" try: os.symlink(str(f1), str(f2)) have_symlink = True except (AttributeError, NotImplementedError, OSError): # AttributeError: Python doesn't support it # NotImplementedError: The system doesn't support it # OSError: The user lacks the privilege (Windows) have_symlink = False with pytest.raises(CustomExc), atomic_writing(str(f1)) as f: f.write("Failing write") raise CustomExc with open(str(f1)) as f: assert f.read() == "Before" with atomic_writing(str(f1)) as f: f.write("Overwritten") with open(str(f1)) as f: assert f.read() == "Overwritten" if os.name != "nt": mode = stat.S_IMODE(os.stat(str(f1)).st_mode) assert mode == orig_mode if have_symlink: # Check that writing over a file preserves a symlink with atomic_writing(str(f2)) as f: f.write("written from symlink") with open(str(f1)) as f: assert f.read() == "written from symlink" @pytest.fixture def handle_umask(): global umask umask = os.umask(0) os.umask(umask) yield os.umask(umask) @pytest.mark.skipif(sys.platform.startswith("win"), reason="Windows") def test_atomic_writing_umask(handle_umask, tmp_path): os.umask(0o022) f1 = str(tmp_path / "1") with atomic_writing(f1) as f: f.write("1") mode = stat.S_IMODE(os.stat(f1).st_mode) assert mode == 0o644 os.umask(0o057) f2 = str(tmp_path / "2") with atomic_writing(f2) as f: f.write("2") mode = stat.S_IMODE(os.stat(f2).st_mode) assert mode == 0o620 def test_atomic_writing_newlines(tmp_path): path = str(tmp_path / "testfile") lf = "a\nb\nc\n" plat = lf.replace("\n", os.linesep) crlf = lf.replace("\n", "\r\n") # test default with open(path, "w") as f: f.write(lf) with open(path, newline="") as f: read = f.read() assert read == plat # test newline=LF with open(path, "w", newline="\n") as f: f.write(lf) with open(path, newline="") as f: read = f.read() assert read == lf # test newline=CRLF with atomic_writing(str(path), newline="\r\n") as f: f.write(lf) with open(path, newline="") as f: read = f.read() assert read == crlf # test newline=no convert text = "crlf\r\ncr\rlf\n" with atomic_writing(str(path), newline="") as f: f.write(text) with open(path, newline="") as f: read = f.read() assert read == text def test_path_to_invalid(tmpdir): assert path_to_invalid(tmpdir) == str(tmpdir) + ".invalid" @pytest.mark.skipif(os.name == "nt", reason="test fails on Windows") def test_file_manager_mixin(tmp_path): mixin = FileManagerMixin() mixin.log = logging.getLogger() bad_content = tmp_path / "bad_content.ipynb" bad_content.write_text("{}", "utf8") # Same as `echo -n {} | sha256sum` assert mixin._get_hash(bad_content.read_bytes()) == { "hash": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", "hash_algorithm": "sha256", } with pytest.raises(HTTPError): mixin._read_notebook(bad_content) other = path_to_intermediate(bad_content) with open(other, "w") as fid: json.dump(new_notebook(), fid) mixin.use_atomic_writing = True nb = mixin._read_notebook(bad_content) validate(nb) with pytest.raises(HTTPError): mixin._read_file(tmp_path, "text") with pytest.raises(HTTPError): mixin._save_file(tmp_path / "foo", "foo", "bar") @pytest.mark.skipif(os.name == "nt", reason="test fails on Windows") async def test_async_file_manager_mixin(tmpdir): mixin = AsyncFileManagerMixin() mixin.log = logging.getLogger() bad_content = tmpdir / "bad_content.ipynb" bad_content.write_text("{}", "utf8") with pytest.raises(HTTPError): await mixin._read_notebook(bad_content) other = path_to_intermediate(bad_content) with open(other, "w") as fid: json.dump(new_notebook(), fid) mixin.use_atomic_writing = True nb, bcontent = await mixin._read_notebook(bad_content, raw=True) # Same as `echo -n {} | sha256sum` assert mixin._get_hash(bcontent) == { "hash": "4747f9680816e352a697d0fb69d82334457cdd1e46f053e800859833d3e6003e", "hash_algorithm": "sha256", } validate(nb) with pytest.raises(HTTPError): await mixin._read_file(tmpdir, "text") with pytest.raises(HTTPError): await mixin._save_file(tmpdir / "foo", "foo", "bar") async def test_AsyncFileManagerMixin_read_notebook_no_raw(tmpdir): mixin = AsyncFileManagerMixin() mixin.log = logging.getLogger() bad_content = tmpdir / "bad_content.ipynb" bad_content.write_text("{}", "utf8") other = path_to_intermediate(bad_content) with open(other, "w") as fid: json.dump(new_notebook(), fid) mixin.use_atomic_writing = True answer = await mixin._read_notebook(bad_content) assert not isinstance(answer, tuple) async def test_AsyncFileManagerMixin_read_file_no_raw(tmpdir): mixin = AsyncFileManagerMixin() mixin.log = logging.getLogger() file_path = tmpdir / "bad_content.text" file_path.write_text("blablabla", "utf8") mixin.use_atomic_writing = True answer = await mixin._read_file(file_path, "text") assert len(answer) == 2 jupyter-server-jupyter_server-e5c7e2b/tests/services/contents/test_largefilemanager.py000066400000000000000000000071071473126534200320400ustar00rootroot00000000000000import pytest import tornado from jupyter_core.utils import ensure_async from jupyter_server.services.contents.largefilemanager import ( AsyncLargeFileManager, LargeFileManager, ) from ...utils import expected_http_error @pytest.fixture(params=[LargeFileManager, AsyncLargeFileManager]) def jp_large_contents_manager(request, tmp_path): """Returns a LargeFileManager instance.""" file_manager = request.param return file_manager(root_dir=str(tmp_path)) async def test_save(jp_large_contents_manager): cm = jp_large_contents_manager model = await ensure_async(cm.new_untitled(type="notebook")) name = model["name"] path = model["path"] # Get the model with 'content' full_model = await ensure_async(cm.get(path)) # Save the notebook model = await ensure_async(cm.save(full_model, path)) assert isinstance(model, dict) assert "name" in model assert "path" in model assert model["name"] == name assert model["path"] == path @pytest.mark.parametrize( "model,err_message", [ ( {"name": "test", "path": "test", "chunk": 1}, "HTTP 400: Bad Request (No file type provided)", ), ( {"name": "test", "path": "test", "chunk": 1, "type": "notebook"}, 'HTTP 400: Bad Request (File type "notebook" is not supported for large file transfer)', ), ( {"name": "test", "path": "test", "chunk": 1, "type": "file"}, "HTTP 400: Bad Request (No file content provided)", ), ( { "name": "test", "path": "test", "chunk": 2, "type": "file", "content": "test", "format": "json", }, "HTTP 400: Bad Request (Must specify format of file contents as 'text' or 'base64')", ), ], ) async def test_bad_save(jp_large_contents_manager, model, err_message): with pytest.raises(tornado.web.HTTPError) as e: await ensure_async(jp_large_contents_manager.save(model, model["path"])) assert expected_http_error(e, 400, expected_message=err_message) async def test_saving_different_chunks(jp_large_contents_manager): cm = jp_large_contents_manager model = { "name": "test", "path": "test", "type": "file", "content": "test==", "format": "text", } name = model["name"] path = model["path"] await ensure_async(cm.save(model, path)) for chunk in (1, 2, -1): for fm in ("text", "base64"): full_model = await ensure_async(cm.get(path)) full_model["chunk"] = chunk full_model["format"] = fm model_res = await ensure_async(cm.save(full_model, path)) assert isinstance(model_res, dict) assert "name" in model_res assert "path" in model_res assert "chunk" not in model_res assert model_res["name"] == name assert model_res["path"] == path async def test_save_in_subdirectory(jp_large_contents_manager, tmp_path): cm = jp_large_contents_manager sub_dir = tmp_path / "foo" sub_dir.mkdir() model = await ensure_async(cm.new_untitled(path="/foo/", type="notebook")) path = model["path"] model = await ensure_async(cm.get(path)) # Change the name in the model for rename model = await ensure_async(cm.save(model, path)) assert isinstance(model, dict) assert "name" in model assert "path" in model assert model["name"] == "Untitled.ipynb" assert model["path"] == "foo/Untitled.ipynb" jupyter-server-jupyter_server-e5c7e2b/tests/services/contents/test_manager.py000066400000000000000000001134521473126534200301660ustar00rootroot00000000000000import os import shutil import sys import time from itertools import combinations from typing import Optional from unittest.mock import patch import pytest from jupyter_core.utils import ensure_async from nbformat import ValidationError from nbformat import v4 as nbformat from tornado.web import HTTPError from traitlets import TraitError from jupyter_server.services.contents.filemanager import ( AsyncFileContentsManager, FileContentsManager, ) from ...utils import expected_http_error @pytest.fixture( params=[ (FileContentsManager, True), (FileContentsManager, False), (AsyncFileContentsManager, True), (AsyncFileContentsManager, False), ] ) def jp_contents_manager(request, tmp_path): contents_manager, use_atomic_writing = request.param return contents_manager(root_dir=str(tmp_path), use_atomic_writing=use_atomic_writing) @pytest.fixture(params=[FileContentsManager, AsyncFileContentsManager]) def jp_file_contents_manager_class(request, tmp_path): return request.param # -------------- Functions ---------------------------- def _make_dir(jp_contents_manager, api_path): """ Make a directory. """ os_path = jp_contents_manager._get_os_path(api_path) try: os.makedirs(os_path) except OSError: print("Directory already exists: %r" % os_path) def _make_big_dir(contents_manager, api_path): # make a directory that is over 100 MB in size os_path = contents_manager._get_os_path(api_path) try: os.makedirs(os_path) with open(f"{os_path}/demofile.txt", "a") as textFile: textFile.write( """ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. """ ) num_sub_folders = contents_manager.max_copy_folder_size_mb * 10 for i in range(num_sub_folders): os.makedirs(f"{os_path}/subfolder-{i}") for j in range(200): shutil.copy( f"{os_path}/demofile.txt", f"{os_path}/subfolder-{i}/testfile{j}.txt", ) except OSError as err: print("Directory already exists", err) def symlink(jp_contents_manager, src, dst): """Make a symlink to src from dst src and dst are api_paths """ src_os_path = jp_contents_manager._get_os_path(src) dst_os_path = jp_contents_manager._get_os_path(dst) print(src_os_path, dst_os_path, os.path.isfile(src_os_path)) os.symlink(src_os_path, dst_os_path) def add_code_cell(notebook): output = nbformat.new_output("display_data", {"application/javascript": "alert('hi');"}) cell = nbformat.new_code_cell("print('hi')", outputs=[output]) notebook.cells.append(cell) def add_invalid_cell(notebook): output = nbformat.new_output("display_data", {"application/javascript": "alert('hi');"}) cell = nbformat.new_code_cell("print('hi')", outputs=[output]) cell.pop("source") # Remove source to invaliate notebook.cells.append(cell) async def prepare_notebook( jp_contents_manager: FileContentsManager, make_invalid: Optional[bool] = False ) -> tuple[dict, str]: cm = jp_contents_manager model = await ensure_async(cm.new_untitled(type="notebook")) name = model["name"] path = model["path"] full_model = await ensure_async(cm.get(path)) nb = full_model["content"] nb["metadata"]["counter"] = int(1e6 * time.time()) if make_invalid: add_invalid_cell(nb) else: add_code_cell(nb) return full_model, path async def new_notebook(jp_contents_manager): full_model, path = await prepare_notebook(jp_contents_manager) cm = jp_contents_manager name = full_model["name"] path = full_model["path"] nb = full_model["content"] await ensure_async(cm.save(full_model, path)) return nb, name, path async def make_populated_dir(jp_contents_manager, api_path): cm = jp_contents_manager _make_dir(cm, api_path) await ensure_async(cm.new(path="/".join([api_path, "nb.ipynb"]))) await ensure_async(cm.new(path="/".join([api_path, "file.txt"]))) async def check_populated_dir_files(jp_contents_manager, api_path): dir_model = await ensure_async(jp_contents_manager.get(api_path)) assert dir_model["path"] == api_path assert dir_model["type"] == "directory" for entry in dir_model["content"]: if entry["type"] == "directory": continue elif entry["type"] == "file": assert entry["name"] == "file.txt" complete_path = "/".join([api_path, "file.txt"]) assert entry["path"] == complete_path elif entry["type"] == "notebook": assert entry["name"] == "nb.ipynb" complete_path = "/".join([api_path, "nb.ipynb"]) assert entry["path"] == complete_path # ----------------- Tests ---------------------------------- def test_root_dir(jp_file_contents_manager_class, tmp_path): fm = jp_file_contents_manager_class(root_dir=str(tmp_path)) assert fm.root_dir == str(tmp_path) def test_missing_root_dir(jp_file_contents_manager_class, tmp_path): root = tmp_path / "notebook" / "dir" / "is" / "missing" with pytest.raises(TraitError): jp_file_contents_manager_class(root_dir=str(root)) def test_invalid_root_dir(jp_file_contents_manager_class, tmp_path): temp_file = tmp_path / "file.txt" temp_file.write_text("") with pytest.raises(TraitError): jp_file_contents_manager_class(root_dir=str(temp_file)) def test_get_os_path(jp_file_contents_manager_class, tmp_path): fm = jp_file_contents_manager_class(root_dir=str(tmp_path)) path = fm._get_os_path("/path/to/notebook/test.ipynb") rel_path_list = "/path/to/notebook/test.ipynb".split("/") fs_path = os.path.join(fm.root_dir, *rel_path_list) assert path == fs_path fm = jp_file_contents_manager_class(root_dir=str(tmp_path)) path = fm._get_os_path("test.ipynb") fs_path = os.path.join(fm.root_dir, "test.ipynb") assert path == fs_path @pytest.mark.skipif(os.name == "nt", reason="Posix only") def test_get_os_path_posix(jp_file_contents_manager_class, tmp_path): fm = jp_file_contents_manager_class(root_dir=str(tmp_path)) path = fm._get_os_path("////test.ipynb") fs_path = os.path.join(fm.root_dir, "test.ipynb") assert path == fs_path def test_checkpoint_subdir(jp_file_contents_manager_class, tmp_path): subd = "sub ∂ir" cp_name = "test-cp.ipynb" fm = jp_file_contents_manager_class(root_dir=str(tmp_path)) tmp_path.joinpath(subd).mkdir() cpm = fm.checkpoints cp_dir = cpm.checkpoint_path("cp", "test.ipynb") cp_subdir = cpm.checkpoint_path("cp", "/%s/test.ipynb" % subd) assert cp_dir != cp_subdir assert cp_dir == os.path.join(str(tmp_path), cpm.checkpoint_dir, cp_name) async def test_bad_symlink(jp_file_contents_manager_class, tmp_path): td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) path = "test bad symlink" _make_dir(cm, path) file_model = await ensure_async(cm.new_untitled(path=path, ext=".txt")) # create a broken symlink symlink(cm, "target", "{}/{}".format(path, "bad symlink")) model = await ensure_async(cm.get(path)) contents = {content["name"]: content for content in model["content"]} assert "untitled.txt" in contents assert contents["untitled.txt"] == file_model assert "bad symlink" in contents @pytest.mark.skipif(sys.platform.startswith("win"), reason="Windows doesn't detect symlink loops") async def test_recursive_symlink(jp_file_contents_manager_class, tmp_path): td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) path = "test recursive symlink" _make_dir(cm, path) file_model = await ensure_async(cm.new_untitled(path=path, ext=".txt")) # create recursive symlink symlink(cm, "{}/{}".format(path, "recursive"), "{}/{}".format(path, "recursive")) model = await ensure_async(cm.get(path)) contents = {content["name"]: content for content in model["content"]} assert "untitled.txt" in contents assert contents["untitled.txt"] == file_model # recursive symlinks should not be shown in the contents manager assert "recursive" not in contents async def test_good_symlink(jp_file_contents_manager_class, tmp_path): td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) parent = "test good symlink" name = "good symlink" path = f"{parent}/{name}" _make_dir(cm, parent) file_model = await ensure_async(cm.new(path=parent + "/zfoo.txt")) # create a good symlink symlink(cm, file_model["path"], path) symlink_model = await ensure_async(cm.get(path, content=False)) dir_model = await ensure_async(cm.get(parent)) assert sorted(dir_model["content"], key=lambda x: x["name"]) == [ symlink_model, file_model, ] @pytest.mark.skipif(sys.platform.startswith("win"), reason="Can't test permissions on Windows") async def test_403(jp_file_contents_manager_class, tmp_path): if hasattr(os, "getuid") and os.getuid() == 0: raise pytest.skip("Can't test permissions as root") td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) model = await ensure_async(cm.new_untitled(type="file")) os_path = cm._get_os_path(model["path"]) os.chmod(os_path, 0o400) try: with cm.open(os_path, "w") as f: f.write("don't care") except HTTPError as e: assert e.status_code == 403 async def test_400(jp_file_contents_manager_class, tmp_path): # Test Delete behavior # Test delete of file in hidden directory td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) hidden_dir = ".hidden" file_in_hidden_path = os.path.join(hidden_dir, "visible.txt") _make_dir(cm, hidden_dir) with pytest.raises(HTTPError) as excinfo: await ensure_async(cm.delete_file(file_in_hidden_path)) assert excinfo.value.status_code == 400 # Test delete hidden file in visible directory td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) hidden_dir = "visible" file_in_hidden_path = os.path.join(hidden_dir, ".hidden.txt") _make_dir(cm, hidden_dir) with pytest.raises(HTTPError) as excinfo: await ensure_async(cm.delete_file(file_in_hidden_path)) assert excinfo.value.status_code == 400 # Test Save behavior # Test save of file in hidden directory with pytest.raises(HTTPError) as excinfo: td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) hidden_dir = ".hidden" file_in_hidden_path = os.path.join(hidden_dir, "visible.txt") _make_dir(cm, hidden_dir) model = await ensure_async(cm.new(path=file_in_hidden_path)) os_path = cm._get_os_path(model["path"]) try: result = await ensure_async(cm.save(model, path=os_path)) except HTTPError as e: assert e.status_code == 400 # Test save hidden file in visible directory with pytest.raises(HTTPError) as excinfo: td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) hidden_dir = "visible" file_in_hidden_path = os.path.join(hidden_dir, ".hidden.txt") _make_dir(cm, hidden_dir) model = await ensure_async(cm.new(path=file_in_hidden_path)) os_path = cm._get_os_path(model["path"]) try: result = await ensure_async(cm.save(model, path=os_path)) except HTTPError as e: assert e.status_code == 400 # Test rename behavior # Test rename with source file in hidden directory td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) hidden_dir = ".hidden" file_in_hidden_path = os.path.join(hidden_dir, "visible.txt") _make_dir(cm, hidden_dir) old_path = file_in_hidden_path new_path = "new.txt" with pytest.raises(HTTPError) as excinfo: await ensure_async(cm.rename_file(old_path, new_path)) assert excinfo.value.status_code == 400 # Test rename of dest file in hidden directory td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) hidden_dir = ".hidden" file_in_hidden_path = os.path.join(hidden_dir, "visible.txt") _make_dir(cm, hidden_dir) new_path = file_in_hidden_path old_path = "old.txt" with pytest.raises(HTTPError) as excinfo: await ensure_async(cm.rename_file(old_path, new_path)) assert excinfo.value.status_code == 400 # Test rename with hidden source file in visible directory td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) hidden_dir = "visible" file_in_hidden_path = os.path.join(hidden_dir, ".hidden.txt") _make_dir(cm, hidden_dir) old_path = file_in_hidden_path new_path = "new.txt" with pytest.raises(HTTPError) as excinfo: await ensure_async(cm.rename_file(old_path, new_path)) assert excinfo.value.status_code == 400 # Test rename with hidden dest file in visible directory td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) hidden_dir = "visible" file_in_hidden_path = os.path.join(hidden_dir, ".hidden.txt") _make_dir(cm, hidden_dir) new_path = file_in_hidden_path old_path = "old.txt" with pytest.raises(HTTPError) as excinfo: await ensure_async(cm.rename_file(old_path, new_path)) assert excinfo.value.status_code == 400 async def test_404(jp_file_contents_manager_class, tmp_path): # setup td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) # Test visible file in hidden folder cm.allow_hidden = True hidden_dir = ".hidden" file_in_hidden_path = os.path.join(hidden_dir, "visible.txt") _make_dir(cm, hidden_dir) model = await ensure_async(cm.new(path=file_in_hidden_path)) os_path = cm._get_os_path(model["path"]) cm.allow_hidden = False with pytest.raises(HTTPError) as excinfo: await ensure_async(cm.get(os_path)) assert excinfo.value.status_code == 404 # Test hidden file in visible folder cm.allow_hidden = True hidden_dir = "visible" file_in_hidden_path = os.path.join(hidden_dir, ".hidden.txt") _make_dir(cm, hidden_dir) model = await ensure_async(cm.new(path=file_in_hidden_path)) os_path = cm._get_os_path(model["path"]) cm.allow_hidden = False with pytest.raises(HTTPError) as excinfo: await ensure_async(cm.get(os_path)) assert excinfo.value.status_code == 404 # Test file not found td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) not_a_file = "foo.bar" with pytest.raises(HTTPError) as excinfo: await ensure_async(cm.get(not_a_file)) assert excinfo.value.status_code == 404 async def test_escape_root(jp_file_contents_manager_class, tmp_path): td = str(tmp_path) cm = jp_file_contents_manager_class(root_dir=td) # make foo, bar next to root with open(os.path.join(cm.root_dir, "..", "foo"), "w") as f: f.write("foo") with open(os.path.join(cm.root_dir, "..", "bar"), "w") as f: f.write("bar") with pytest.raises(HTTPError) as e: await ensure_async(cm.get("..")) assert expected_http_error(e, 404) with pytest.raises(HTTPError) as e: await ensure_async(cm.get("foo/../../../bar")) assert expected_http_error(e, 404) with pytest.raises(HTTPError) as e: await ensure_async(cm.delete("../foo")) assert expected_http_error(e, 404) with pytest.raises(HTTPError) as e: await ensure_async(cm.rename("../foo", "../bar")) assert expected_http_error(e, 404) with pytest.raises(HTTPError) as e: await ensure_async( cm.save( model={ "type": "file", "content": "", "format": "text", }, path="../foo", ) ) assert expected_http_error(e, 404) async def test_new_untitled(jp_contents_manager): cm = jp_contents_manager # Test in root directory model = await ensure_async(cm.new_untitled(type="notebook")) assert isinstance(model, dict) assert "name" in model assert "path" in model assert "type" in model assert model["type"] == "notebook" assert model["name"] == "Untitled.ipynb" assert model["path"] == "Untitled.ipynb" # Test in sub-directory model = await ensure_async(cm.new_untitled(type="directory")) assert isinstance(model, dict) assert "name" in model assert "path" in model assert "type" in model assert model["type"] == "directory" assert model["name"] == "Untitled Folder" assert model["path"] == "Untitled Folder" sub_dir = model["path"] model = await ensure_async(cm.new_untitled(path=sub_dir)) assert isinstance(model, dict) assert "name" in model assert "path" in model assert "type" in model assert model["type"] == "file" assert model["name"] == "untitled" assert model["path"] == "%s/untitled" % sub_dir # Test with a compound extension model = await ensure_async(cm.new_untitled(path=sub_dir, ext=".foo.bar")) assert model["name"] == "untitled.foo.bar" model = await ensure_async(cm.new_untitled(path=sub_dir, ext=".foo.bar")) assert model["name"] == "untitled1.foo.bar" async def test_modified_date(jp_contents_manager): cm = jp_contents_manager # Create a new notebook. nb, name, path = await new_notebook(cm) model = await ensure_async(cm.get(path)) # Add a cell and save. add_code_cell(model["content"]) await ensure_async(cm.save(model, path)) # Reload notebook and verify that last_modified incremented. saved = await ensure_async(cm.get(path)) assert saved["last_modified"] >= model["last_modified"] # Move the notebook and verify that last_modified stayed the same. # (The frontend fires a warning if last_modified increases on the # renamed file.) new_path = "renamed.ipynb" await ensure_async(cm.rename(path, new_path)) renamed = await ensure_async(cm.get(new_path)) assert renamed["last_modified"] >= saved["last_modified"] async def test_get(jp_contents_manager): cm = jp_contents_manager # Create a notebook model = await ensure_async(cm.new_untitled(type="notebook")) name = model["name"] path = model["path"] # Check that we 'get' on the notebook we just created model2 = await ensure_async(cm.get(path)) assert isinstance(model2, dict) assert "name" in model2 assert "path" in model2 assert model["name"] == name assert model["path"] == path nb_as_file = await ensure_async(cm.get(path, content=True, type="file")) assert nb_as_file["path"] == path assert nb_as_file["type"] == "file" assert nb_as_file["format"] == "text" assert not isinstance(nb_as_file["content"], dict) nb_as_bin_file = await ensure_async(cm.get(path, content=True, type="file", format="base64")) assert nb_as_bin_file["format"] == "base64" nb_with_hash = await ensure_async(cm.get(path, require_hash=True)) assert nb_with_hash["hash"] assert nb_with_hash["hash_algorithm"] # Get the hash without the content nb_with_hash = await ensure_async(cm.get(path, content=False, require_hash=True)) assert nb_with_hash["content"] is None assert nb_with_hash["format"] is None assert nb_with_hash["hash"] assert nb_with_hash["hash_algorithm"] # Test in sub-directory sub_dir = "/foo/" _make_dir(cm, "foo") await ensure_async(cm.new_untitled(path=sub_dir, ext=".ipynb")) model2 = await ensure_async(cm.get(sub_dir + name)) assert isinstance(model2, dict) assert "name" in model2 assert "path" in model2 assert "content" in model2 assert model2["name"] == "Untitled.ipynb" assert model2["path"] == "{}/{}".format(sub_dir.strip("/"), name) # Test with a regular file. file_model_path = (await ensure_async(cm.new_untitled(path=sub_dir, ext=".txt")))["path"] file_model = await ensure_async(cm.get(file_model_path, require_hash=True)) expected_model = { "content": "", "format": "text", "mimetype": "text/plain", "name": "untitled.txt", "path": "foo/untitled.txt", "type": "file", "writable": True, "hash_algorithm": cm.hash_algorithm, } # Assert expected model is in file_model for key, value in expected_model.items(): assert file_model[key] == value assert "created" in file_model assert "last_modified" in file_model assert file_model["hash"] # Get hash without content file_model = await ensure_async(cm.get(file_model_path, content=False, require_hash=True)) expected_model = { "content": None, "format": None, "mimetype": "text/plain", "name": "untitled.txt", "path": "foo/untitled.txt", "type": "file", "writable": True, "hash_algorithm": cm.hash_algorithm, } # Assert expected model is in file_model for key, value in expected_model.items(): assert file_model[key] == value assert "created" in file_model assert "last_modified" in file_model assert file_model["hash"] # Create a sub-sub directory to test getting directory contents with a # subdir. _make_dir(cm, "foo/bar") dirmodel = await ensure_async(cm.get("foo")) assert dirmodel["type"] == "directory" assert isinstance(dirmodel["content"], list) assert len(dirmodel["content"]) == 3 assert dirmodel["path"] == "foo" assert dirmodel["name"] == "foo" # Directory contents should match the contents of each individual entry # when requested with content=False. model2_no_content = await ensure_async(cm.get(sub_dir + name, content=False)) file_model_no_content = await ensure_async(cm.get("foo/untitled.txt", content=False)) sub_sub_dir_no_content = await ensure_async(cm.get("foo/bar", content=False)) assert sub_sub_dir_no_content["path"] == "foo/bar" assert sub_sub_dir_no_content["name"] == "bar" for entry in dirmodel["content"]: # Order isn't guaranteed by the spec, so this is a hacky way of # verifying that all entries are matched. if entry["path"] == sub_sub_dir_no_content["path"]: assert entry == sub_sub_dir_no_content elif entry["path"] == model2_no_content["path"]: assert entry == model2_no_content elif entry["path"] == file_model_no_content["path"]: assert entry == file_model_no_content else: raise AssertionError("Unexpected directory entry: %s" % entry()) with pytest.raises(HTTPError): await ensure_async(cm.get("foo", type="file")) async def test_update(jp_contents_manager): cm = jp_contents_manager # Create a notebook. model = await ensure_async(cm.new_untitled(type="notebook")) name = model["name"] path = model["path"] # Change the name in the model for rename model["path"] = "test.ipynb" model = await ensure_async(cm.update(model, path)) assert isinstance(model, dict) assert "name" in model assert "path" in model assert model["name"] == "test.ipynb" # Make sure the old name is gone with pytest.raises(HTTPError): await ensure_async(cm.get(path)) # Test in sub-directory # Create a directory and notebook in that directory sub_dir = "/foo/" _make_dir(cm, "foo") model = await ensure_async(cm.new_untitled(path=sub_dir, type="notebook")) path = model["path"] # Change the name in the model for rename d = path.rsplit("/", 1)[0] new_path = model["path"] = d + "/test_in_sub.ipynb" model = await ensure_async(cm.update(model, path)) assert isinstance(model, dict) assert "name" in model assert "path" in model assert model["name"] == "test_in_sub.ipynb" assert model["path"] == new_path # Make sure the old name is gone with pytest.raises(HTTPError): await ensure_async(cm.get(path)) async def test_save(jp_contents_manager): cm = jp_contents_manager # Create a notebook model = await ensure_async(cm.new_untitled(type="notebook")) name = model["name"] path = model["path"] # Get the model with 'content' full_model = await ensure_async(cm.get(path)) # Save the notebook model = await ensure_async(cm.save(full_model, path)) assert isinstance(model, dict) assert "name" in model assert "path" in model assert model["name"] == name assert model["path"] == path # Test in sub-directory # Create a directory and notebook in that directory sub_dir = "/foo/" _make_dir(cm, "foo") model = await ensure_async(cm.new_untitled(path=sub_dir, type="notebook")) path = model["path"] model = await ensure_async(cm.get(path)) # Change the name in the model for rename model = await ensure_async(cm.save(model, path)) assert isinstance(model, dict) assert "name" in model assert "path" in model assert model["name"] == "Untitled.ipynb" assert model["path"] == "foo/Untitled.ipynb" async def test_delete(jp_contents_manager): cm = jp_contents_manager # Create a notebook nb, name, path = await new_notebook(cm) # Delete the notebook await ensure_async(cm.delete(path)) # Check that deleting a non-existent path raises an error. with pytest.raises(HTTPError): await ensure_async(cm.delete(path)) # Check that a 'get' on the deleted notebook raises and error with pytest.raises(HTTPError): await ensure_async(cm.get(path)) @pytest.mark.parametrize( "delete_to_trash, always_delete, error", ( [True, True, False], # on linux test folder may not be on home folder drive # => if this is the case, _check_trash will be False [True, False, None], [False, True, False], [False, False, True], ), ) async def test_delete_non_empty_folder(delete_to_trash, always_delete, error, jp_contents_manager): cm = jp_contents_manager cm.delete_to_trash = delete_to_trash cm.always_delete_dir = always_delete dir = "to_delete" await make_populated_dir(cm, dir) await check_populated_dir_files(cm, dir) if error is None: error = False if sys.platform == "win32": error = True elif sys.platform == "linux": file_dev = os.stat(cm.root_dir).st_dev home_dev = os.stat(os.path.expanduser("~")).st_dev error = file_dev != home_dev if error: with pytest.raises( HTTPError, match=r"HTTP 400: Bad Request \(Directory .*?to_delete not empty\)", ): await ensure_async(cm.delete_file(dir)) else: await ensure_async(cm.delete_file(dir)) assert await ensure_async(cm.dir_exists(dir)) is False async def test_rename(jp_contents_manager): cm = jp_contents_manager # Create a new notebook nb, name, path = await new_notebook(cm) # Rename the notebook await ensure_async(cm.rename(path, "changed_path")) # Attempting to get the notebook under the old name raises an error with pytest.raises(HTTPError): await ensure_async(cm.get(path)) # Fetching the notebook under the new name is successful assert isinstance(await ensure_async(cm.get("changed_path")), dict) # Ported tests on nested directory renaming from pgcontents all_dirs = ["foo", "bar", "foo/bar", "foo/bar/foo", "foo/bar/foo/bar"] unchanged_dirs = all_dirs[:2] changed_dirs = all_dirs[2:] for _dir in all_dirs: await make_populated_dir(cm, _dir) await check_populated_dir_files(cm, _dir) # Renaming to an existing directory should fail for src, dest in combinations(all_dirs, 2): with pytest.raises(HTTPError) as e: await ensure_async(cm.rename(src, dest)) assert expected_http_error(e, 409) # Creating a notebook in a non_existant directory should fail with pytest.raises(HTTPError) as e: await ensure_async(cm.new_untitled("foo/bar_diff", ext=".ipynb")) assert expected_http_error(e, 404) await ensure_async(cm.rename("foo/bar", "foo/bar_diff")) # Assert that unchanged directories remain so for unchanged in unchanged_dirs: await check_populated_dir_files(cm, unchanged) # Assert changed directories can no longer be accessed under old names for changed_dirname in changed_dirs: with pytest.raises(HTTPError) as e: await ensure_async(cm.get(changed_dirname)) assert expected_http_error(e, 404) new_dirname = changed_dirname.replace("foo/bar", "foo/bar_diff", 1) await check_populated_dir_files(cm, new_dirname) # Created a notebook in the renamed directory should work await ensure_async(cm.new_untitled("foo/bar_diff", ext=".ipynb")) async def test_delete_root(jp_contents_manager): cm = jp_contents_manager with pytest.raises(HTTPError) as e: await ensure_async(cm.delete("")) assert expected_http_error(e, 400) async def test_copy(jp_contents_manager): cm = jp_contents_manager parent = "å b" name = "nb √.ipynb" path = f"{parent}/{name}" _make_dir(cm, parent) orig = await ensure_async(cm.new(path=path)) # copy with unspecified name copy = await ensure_async(cm.copy(path)) assert copy["name"] == orig["name"].replace(".ipynb", "-Copy1.ipynb") # copy with specified name copy2 = await ensure_async(cm.copy(path, "å b/copy 2.ipynb")) assert copy2["name"] == "copy 2.ipynb" assert copy2["path"] == "å b/copy 2.ipynb" # copy with specified path copy2 = await ensure_async(cm.copy(path, "/")) assert copy2["name"] == name assert copy2["path"] == name # copy to destination whose parent dir does not exist with pytest.raises(HTTPError) as e: await ensure_async(cm.copy(path, "å x/copy 2.ipynb")) copy3 = await ensure_async(cm.copy(path, "/copy 3.ipynb")) assert copy3["name"] == "copy 3.ipynb" assert copy3["path"] == "copy 3.ipynb" async def test_copy_dir(jp_contents_manager): cm = jp_contents_manager destDir = "Untitled Folder 1" sourceDir = "Morningstar Notebooks" nonExistantDir = "FolderDoesNotExist" _make_dir(cm, destDir) _make_dir(cm, sourceDir) nestedDir = f"{destDir}/{sourceDir}" # copy one folder insider another folder copy = await ensure_async(cm.copy(from_path=sourceDir, to_path=destDir)) assert copy["path"] == nestedDir # need to test when copying in a directory where the another folder with the same name exists _make_dir(cm, nestedDir) copy = await ensure_async(cm.copy(from_path=sourceDir, to_path=destDir)) assert copy["path"] == f"{nestedDir}-Copy1" # need to test for when copying in the same path as the sourceDir copy = await ensure_async(cm.copy(from_path=sourceDir, to_path="")) assert copy["path"] == f"{sourceDir}-Copy1" # ensure its still possible to copy a folder to another folder that doesn't exist copy = await ensure_async( cm.copy( from_path=sourceDir, to_path=nonExistantDir, ) ) assert copy["path"] == f"{nonExistantDir}/{sourceDir}" @pytest.mark.skipif(os.name == "nt", reason="Copying big dirs on Window") async def test_copy_big_dir(jp_contents_manager): # this tests how the Content API limits preventing copying folders that are more than # the size limit specified in max_copy_folder_size_mb trait cm = jp_contents_manager destDir = "Untitled Folder 1" sourceDir = "Morningstar Notebooks" cm.max_copy_folder_size_mb = 5 _make_dir(cm, destDir) _make_big_dir(contents_manager=cm, api_path=sourceDir) with pytest.raises(HTTPError) as exc_info: await ensure_async(cm.copy(from_path=sourceDir, to_path=destDir)) assert exc_info.type is HTTPError async def test_mark_trusted_cells(jp_contents_manager): cm = jp_contents_manager nb, name, path = await new_notebook(cm) cm.mark_trusted_cells(nb, path) for cell in nb.cells: if cell.cell_type == "code": assert not cell.metadata.trusted await ensure_async(cm.trust_notebook(path)) nb = (await ensure_async(cm.get(path)))["content"] for cell in nb.cells: if cell.cell_type == "code": assert cell.metadata.trusted async def test_check_and_sign(jp_contents_manager): cm = jp_contents_manager nb, name, path = await new_notebook(cm) cm.mark_trusted_cells(nb, path) cm.check_and_sign(nb, path) assert not cm.notary.check_signature(nb) await ensure_async(cm.trust_notebook(path)) nb = (await ensure_async(cm.get(path)))["content"] cm.mark_trusted_cells(nb, path) cm.check_and_sign(nb, path) assert cm.notary.check_signature(nb) async def test_nb_validation(jp_contents_manager): # Test that validation is performed once when a notebook is read or written model, path = await prepare_notebook(jp_contents_manager, make_invalid=False) cm = jp_contents_manager # We'll use a patch to capture the call count on "nbformat.validate" for the # successful methods and ensure that calls to the aliased "validate_nb" are # zero. Note that since patching side-effects the validation error case, we'll # skip call-count assertions for that portion of the test. with ( patch("nbformat.validate") as mock_validate, patch("jupyter_server.services.contents.manager.validate_nb") as mock_validate_nb, ): # Valid notebook, save, then get model = await ensure_async(cm.save(model, path)) assert "message" not in model assert mock_validate.call_count == 1 assert mock_validate_nb.call_count == 0 mock_validate.reset_mock() mock_validate_nb.reset_mock() # Get the notebook and ensure there are no messages model = await ensure_async(cm.get(path)) assert "message" not in model assert mock_validate.call_count == 1 assert mock_validate_nb.call_count == 0 mock_validate.reset_mock() mock_validate_nb.reset_mock() # Add invalid cell, save, then get add_invalid_cell(model["content"]) model = await ensure_async(cm.save(model, path)) assert "message" in model assert "Notebook validation failed:" in model["message"] model = await ensure_async(cm.get(path)) assert "message" in model assert "Notebook validation failed:" in model["message"] async def test_validate_notebook_model(jp_contents_manager): # Test the validation_notebook_model method to ensure that validation is not # performed when a validation_error dictionary is provided and is performed # when that parameter is None. model, path = await prepare_notebook(jp_contents_manager, make_invalid=False) cm = jp_contents_manager with patch("jupyter_server.services.contents.manager.validate_nb") as mock_validate_nb: # Valid notebook and a non-None dictionary, no validate call expected validation_error: dict = {} cm.validate_notebook_model(model, validation_error) assert mock_validate_nb.call_count == 0 mock_validate_nb.reset_mock() # And without the extra parameter, validate call expected cm.validate_notebook_model(model) assert mock_validate_nb.call_count == 1 mock_validate_nb.reset_mock() # Now do the same with an invalid model # invalidate the model... add_invalid_cell(model["content"]) validation_error["ValidationError"] = ValidationError("not a real validation error") cm.validate_notebook_model(model, validation_error) assert "Notebook validation failed" in model["message"] assert mock_validate_nb.call_count == 0 mock_validate_nb.reset_mock() model.pop("message") # And without the extra parameter, validate call expected. Since patch side-effects # the patched method, we won't attempt to access the message field. cm.validate_notebook_model(model) assert mock_validate_nb.call_count == 1 mock_validate_nb.reset_mock() @patch( "jupyter_core.paths.is_hidden", side_effect=AssertionError("Should not call is_hidden if not important"), ) @patch( "jupyter_server.services.contents.filemanager.is_hidden", side_effect=AssertionError("Should not call is_hidden if not important"), ) async def test_regression_is_hidden(m1, m2, jp_contents_manager): cm = jp_contents_manager cm.allow_hidden = True # Our role here is to check that the side-effect never triggers dirname = "foo/.hidden_dir" await make_populated_dir(cm, dirname) await ensure_async(cm.get(dirname)) await check_populated_dir_files(cm, dirname) await ensure_async(cm.get(path="/".join([dirname, "nb.ipynb"]))) await ensure_async(cm.get(path="/".join([dirname, "file.txt"]))) await ensure_async(cm.new(path="/".join([dirname, "nb2.ipynb"]))) await ensure_async(cm.new(path="/".join([dirname, "file2.txt"]))) await ensure_async(cm.new(path="/".join([dirname, "subdir"]), model={"type": "directory"})) await ensure_async( cm.copy( from_path="/".join([dirname, "file.txt"]), to_path="/".join([dirname, "file-copy.txt"]) ) ) await ensure_async( cm.rename_file( old_path="/".join([dirname, "file-copy.txt"]), new_path="/".join([dirname, "file-renamed.txt"]), ) ) await ensure_async(cm.delete_file(path="/".join([dirname, "file-renamed.txt"]))) # sanity check that is actually triggers when flag set to false cm.allow_hidden = False with pytest.raises(AssertionError): await ensure_async(cm.get(dirname)) jupyter-server-jupyter_server-e5c7e2b/tests/services/contents/test_manager_no_hash.py000066400000000000000000000023521473126534200316610ustar00rootroot00000000000000import json import pytest from jupyter_server.services.contents.filemanager import ( AsyncFileContentsManager, ) class NoHashFileManager(AsyncFileContentsManager): """FileManager prior to 2.11 that introduce the ability to request file hash.""" def _base_model(self, path): """Drop new attributes from model.""" model = super()._base_model(path) del model["hash"] del model["hash_algorithm"] return model async def get(self, path, content=True, type=None, format=None): """Get without the new `require_hash` argument""" model = await super().get(path, content=content, type=type, format=format) return model @pytest.fixture def jp_server_config(jp_server_config): jp_server_config["ServerApp"]["contents_manager_class"] = NoHashFileManager return jp_server_config async def test_manager_no_hash_support(tmp_path, jp_root_dir, jp_fetch): # Create some content path = "dummy.txt" (jp_root_dir / path).write_text("blablabla", encoding="utf-8") response = await jp_fetch("api", "contents", path, method="GET", params=dict(hash="1")) model = json.loads(response.body) assert "hash" not in model assert "hash_algorithm" not in model jupyter-server-jupyter_server-e5c7e2b/tests/services/events/000077500000000000000000000000001473126534200246045ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/events/__init__.py000066400000000000000000000000001473126534200267030ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/events/mock_event.yaml000066400000000000000000000004411473126534200276210ustar00rootroot00000000000000$id: http://event.mock.jupyter.org/message version: "1" title: Message description: | Emit a message type: object properties: event_message: title: Event Messages categories: - unrestricted description: | Mock event message to read. required: - event_message jupyter-server-jupyter_server-e5c7e2b/tests/services/events/mockextension/000077500000000000000000000000001473126534200274725ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/events/mockextension/__init__.py000066400000000000000000000004011473126534200315760ustar00rootroot00000000000000from .mock_extension import _load_jupyter_server_extension # Function that makes these extensions discoverable # by the test functions. def _jupyter_server_extension_points(): return [ {"module": "tests.services.events.mockextension"}, ] jupyter-server-jupyter_server-e5c7e2b/tests/services/events/mockextension/mock_extension.py000066400000000000000000000013571473126534200330770ustar00rootroot00000000000000import pathlib from jupyter_server.base.handlers import JupyterHandler from jupyter_server.utils import url_path_join class MockEventHandler(JupyterHandler): def get(self): # Emit an event. self.event_logger.emit( schema_id="http://event.mockextension.jupyter.org/message", data={"event_message": "Hello world, from mock extension!"}, ) def _load_jupyter_server_extension(serverapp): # Register a schema with the EventBus schema_file = pathlib.Path(__file__).parent / "mock_extension_event.yaml" serverapp.event_logger.register_event_schema(schema_file) serverapp.web_app.add_handlers( ".*$", [(url_path_join(serverapp.base_url, "/mock/event"), MockEventHandler)] ) jupyter-server-jupyter_server-e5c7e2b/tests/services/events/mockextension/mock_extension_event.yaml000066400000000000000000000004511473126534200346040ustar00rootroot00000000000000$id: http://event.mockextension.jupyter.org/message version: "1" title: Message description: | Emit a message type: object properties: event_message: title: Event Message categories: - unrestricted description: | Mock event message to read. required: - event_message jupyter-server-jupyter_server-e5c7e2b/tests/services/events/test_api.py000066400000000000000000000066301473126534200267730ustar00rootroot00000000000000import io import json import logging import pathlib import pytest import tornado from tests.utils import expected_http_error @pytest.fixture def event_logger_sink(jp_serverapp): event_logger = jp_serverapp.event_logger # Register the event schema defined in this directory. schema_file = pathlib.Path(__file__).parent / "mock_event.yaml" event_logger.register_event_schema(schema_file) sink = io.StringIO() handler = logging.StreamHandler(sink) event_logger.register_handler(handler) return event_logger, sink @pytest.fixture def event_logger(event_logger_sink): event_logger, sink = event_logger_sink return event_logger async def test_subscribe_websocket(event_logger, jp_ws_fetch): ws = await jp_ws_fetch("/api/events/subscribe") event_logger.emit( schema_id="http://event.mock.jupyter.org/message", data={"event_message": "Hello, world!"}, ) # await event_logger.gather_listeners() message = await ws.read_message() event_data = json.loads(message) ws.close() assert event_data.get("event_message") == "Hello, world!" payload_1 = """\ { "schema_id": "http://event.mock.jupyter.org/message", "version": "1", "data": { "event_message": "Hello, world!" }, "timestamp": "2022-05-26T12:50:00+06:00Z" } """ payload_2 = """\ { "schema_id": "http://event.mock.jupyter.org/message", "version": "1", "data": { "event_message": "Hello, world!" } } """ @pytest.mark.parametrize("payload", [payload_1, payload_2]) async def test_post_event(jp_fetch, event_logger_sink, payload): event_logger, sink = event_logger_sink r = await jp_fetch("api", "events", method="POST", body=payload) assert r.code == 204 output = sink.getvalue() assert output input = json.loads(payload) data = json.loads(output) assert input["data"]["event_message"] == data["event_message"] assert data["__timestamp__"] if "timestamp" in input: assert input["timestamp"] == data["__timestamp__"] payload_3 = """\ { "schema_id": "http://event.mock.jupyter.org/message", "data": { "event_message": "Hello, world!" } } """ payload_4 = """\ { "version": "1", "data": { "event_message": "Hello, world!" } } """ payload_5 = """\ { "schema_id": "http://event.mock.jupyter.org/message", "version": "1" } """ payload_6 = """\ { "schema_id": "event.mock.jupyter.org/message", "version": "1", "data": { "event_message": "Hello, world!" }, "timestamp": "2022-05-26 12:50:00" } """ payload_7 = """\ { "schema_id": "http://event.mock.jupyter.org/UNREGISTERED-SCHEMA", "version": "1", "data": { "event_message": "Hello, world!" } } """ payload_8 = """\ { "schema_id": "http://event.mock.jupyter.org/message", "version": "1", "data": { "message": "Hello, world!" } } """ payload_9 = """\ { "schema_id": "http://event.mock.jupyter.org/message", "version": 2, "data": { "event_message": "Hello, world!" } } """ @pytest.mark.parametrize( "payload", [payload_3, payload_4, payload_5, payload_6, payload_7, payload_8, payload_9], ) async def test_post_event_400(jp_fetch, event_logger, payload): with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch("api", "events", method="POST", body=payload) assert expected_http_error(e, 400) jupyter-server-jupyter_server-e5c7e2b/tests/services/events/test_extension.py000066400000000000000000000015201473126534200302270ustar00rootroot00000000000000import json import pytest @pytest.fixture def jp_server_config(): config = { "ServerApp": { "jpserver_extensions": {"tests.services.events.mockextension": True}, }, "EventBus": {"allowed_schemas": ["http://event.mockextension.jupyter.org/message"]}, } return config async def test_subscribe_websocket(jp_ws_fetch, jp_fetch): # Open an event listener websocket ws = await jp_ws_fetch("/api/events/subscribe") # Hit the extension endpoint that emits an event await jp_fetch("/mock/event") # Check the event listener for a message message = await ws.read_message() event_data = json.loads(message) # Close websocket ws.close() # Verify that an event message was received. assert event_data.get("event_message") == "Hello world, from mock extension!" jupyter-server-jupyter_server-e5c7e2b/tests/services/kernels/000077500000000000000000000000001473126534200247435ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/kernels/__init__.py000066400000000000000000000000001473126534200270420ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/kernels/test_api.py000066400000000000000000000225441473126534200271340ustar00rootroot00000000000000import asyncio import json import os import time import warnings import jupyter_client import pytest import tornado from flaky import flaky from jupyter_client.kernelspec import NATIVE_KERNEL_NAME from tornado.httpclient import HTTPClientError from jupyter_server.utils import url_path_join from ...utils import expected_http_error TEST_TIMEOUT = 60 @pytest.fixture(autouse=True) def suppress_deprecation_warnings(): with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message="The synchronous MappingKernelManager", category=DeprecationWarning, ) yield @pytest.fixture def pending_kernel_is_ready(jp_serverapp): async def _(kernel_id, ready=None): km = jp_serverapp.kernel_manager if getattr(km, "use_pending_kernels", False): kernel = km.get_kernel(kernel_id) if getattr(kernel, "ready", None): new_ready = kernel.ready # Make sure we get a new ready promise (for a restart) while new_ready == ready: await asyncio.sleep(0.1) if not isinstance(new_ready, asyncio.Future): new_ready = asyncio.wrap_future(new_ready) await new_ready return new_ready return _ configs: list = [ { "ServerApp": { "kernel_manager_class": "jupyter_server.services.kernels.kernelmanager.MappingKernelManager" } }, { "ServerApp": { "kernel_manager_class": "jupyter_server.services.kernels.kernelmanager.AsyncMappingKernelManager" } }, ] # Pending kernels was released in Jupyter Client 7.1 # It is currently broken on Windows (Jan 2022). When fixed, we can remove the Windows check. # See https://github.com/jupyter-server/jupyter_server/issues/672 if os.name != "nt" and jupyter_client._version.version_info >= (7, 1): # Add a pending kernels condition c = { "ServerApp": { "kernel_manager_class": "jupyter_server.services.kernels.kernelmanager.AsyncMappingKernelManager" }, "AsyncMappingKernelManager": {"use_pending_kernels": True}, } configs.append(c) @pytest.fixture(params=configs) def jp_server_config(request): return request.param async def test_no_kernels(jp_fetch): r = await jp_fetch("api", "kernels", method="GET") kernels = json.loads(r.body.decode()) assert kernels == [] @pytest.mark.timeout(TEST_TIMEOUT) async def test_default_kernels(jp_fetch, jp_base_url): r = await jp_fetch("api", "kernels", method="POST", allow_nonstandard_methods=True) kernel = json.loads(r.body.decode()) assert r.headers["location"] == url_path_join(jp_base_url, "/api/kernels/", kernel["id"]) assert r.code == 201 assert isinstance(kernel, dict) report_uri = url_path_join(jp_base_url, "/api/security/csp-report") expected_csp = "; ".join( ["frame-ancestors 'self'", "report-uri " + report_uri, "default-src 'none'"] ) assert r.headers["Content-Security-Policy"] == expected_csp @pytest.mark.timeout(TEST_TIMEOUT) async def test_main_kernel_handler(jp_fetch, jp_base_url, jp_serverapp, pending_kernel_is_ready): # Start the first kernel r = await jp_fetch( "api", "kernels", method="POST", body=json.dumps({"name": NATIVE_KERNEL_NAME}) ) kernel1 = json.loads(r.body.decode()) assert r.headers["location"] == url_path_join(jp_base_url, "/api/kernels/", kernel1["id"]) assert r.code == 201 assert isinstance(kernel1, dict) report_uri = url_path_join(jp_base_url, "/api/security/csp-report") expected_csp = "; ".join( ["frame-ancestors 'self'", "report-uri " + report_uri, "default-src 'none'"] ) assert r.headers["Content-Security-Policy"] == expected_csp # Check that the kernel is found in the kernel list r = await jp_fetch("api", "kernels", method="GET") kernel_list = json.loads(r.body.decode()) assert r.code == 200 assert isinstance(kernel_list, list) assert kernel_list[0]["id"] == kernel1["id"] assert kernel_list[0]["name"] == kernel1["name"] await pending_kernel_is_ready(kernel1["id"]) # Start a second kernel r = await jp_fetch( "api", "kernels", method="POST", body=json.dumps({"name": NATIVE_KERNEL_NAME}) ) kernel2 = json.loads(r.body.decode()) assert isinstance(kernel2, dict) await pending_kernel_is_ready(kernel1["id"]) # Get kernel list again r = await jp_fetch("api", "kernels", method="GET") kernel_list = json.loads(r.body.decode()) assert r.code == 200 assert isinstance(kernel_list, list) assert len(kernel_list) == 2 # Interrupt a kernel await pending_kernel_is_ready(kernel2["id"]) r = await jp_fetch( "api", "kernels", kernel2["id"], "interrupt", method="POST", allow_nonstandard_methods=True, ) assert r.code == 204 # Restart a kernel ready = await pending_kernel_is_ready(kernel2["id"]) r = await jp_fetch( "api", "kernels", kernel2["id"], "restart", method="POST", allow_nonstandard_methods=True, ) restarted_kernel = json.loads(r.body.decode()) assert restarted_kernel["id"] == kernel2["id"] assert restarted_kernel["name"] == kernel2["name"] # Make sure we get a new ready promise if ready: await pending_kernel_is_ready(kernel2["id"], ready) # Start a kernel with a path r = await jp_fetch( "api", "kernels", method="POST", body=json.dumps({"name": NATIVE_KERNEL_NAME, "path": "/foo"}), ) kernel3 = json.loads(r.body.decode()) assert isinstance(kernel3, dict) await pending_kernel_is_ready(kernel3["id"]) @pytest.mark.timeout(TEST_TIMEOUT) async def test_kernel_handler(jp_fetch, jp_serverapp, pending_kernel_is_ready): # Create a kernel r = await jp_fetch( "api", "kernels", method="POST", body=json.dumps({"name": NATIVE_KERNEL_NAME}) ) kernel_id = json.loads(r.body.decode())["id"] r = await jp_fetch("api", "kernels", kernel_id, method="GET") kernel = json.loads(r.body.decode()) assert r.code == 200 assert isinstance(kernel, dict) assert "id" in kernel assert kernel["id"] == kernel_id # Requests a bad kernel id. bad_id = "111-111-111-111-111" with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch("api", "kernels", bad_id, method="GET") assert expected_http_error(e, 404) # Delete kernel with id. ready = await pending_kernel_is_ready(kernel_id) r = await jp_fetch( "api", "kernels", kernel_id, method="DELETE", ) assert r.code == 204 # Get list of kernels try: await pending_kernel_is_ready(kernel_id, ready) # If the kernel is already deleted, no need to await. except tornado.web.HTTPError: pass r = await jp_fetch("api", "kernels", method="GET") kernel_list = json.loads(r.body.decode()) assert kernel_list == [] # Request to delete a non-existent kernel id bad_id = "111-111-111-111-111" with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch("api", "kernels", bad_id, method="DELETE") assert expected_http_error(e, 404, "Kernel does not exist: " + bad_id) @pytest.mark.timeout(TEST_TIMEOUT) async def test_kernel_handler_startup_error(jp_fetch, jp_serverapp, jp_kernelspecs): if getattr(jp_serverapp.kernel_manager, "use_pending_kernels", False): return # Create a kernel with pytest.raises(HTTPClientError): await jp_fetch("api", "kernels", method="POST", body=json.dumps({"name": "bad"})) @pytest.mark.timeout(TEST_TIMEOUT) async def test_kernel_handler_startup_error_pending( jp_fetch, jp_ws_fetch, jp_serverapp, jp_kernelspecs ): if not getattr(jp_serverapp.kernel_manager, "use_pending_kernels", False): return jp_serverapp.kernel_manager.use_pending_kernels = True # Create a kernel r = await jp_fetch("api", "kernels", method="POST", body=json.dumps({"name": "bad"})) kid = json.loads(r.body.decode())["id"] with pytest.raises(HTTPClientError): await jp_ws_fetch("api", "kernels", kid, "channels") @flaky @pytest.mark.timeout(TEST_TIMEOUT) async def test_connection(jp_fetch, jp_ws_fetch, jp_http_port, jp_auth_header): # Create kernel r = await jp_fetch( "api", "kernels", method="POST", body=json.dumps({"name": NATIVE_KERNEL_NAME}) ) kid = json.loads(r.body.decode())["id"] # Get kernel info r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) assert model["connections"] == 0 # Open a websocket connection. ws = await jp_ws_fetch("api", "kernels", kid, "channels") # Test that it was opened. r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) assert model["connections"] == 1 # Close websocket ws.close() # give it some time to close on the other side: for _ in range(10): r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) if model["connections"] > 0: time.sleep(0.1) else: break r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) assert model["connections"] == 0 jupyter-server-jupyter_server-e5c7e2b/tests/services/kernels/test_config.py000066400000000000000000000020521473126534200276200ustar00rootroot00000000000000import pytest from traitlets.config import Config from jupyter_server.services.kernels.kernelmanager import AsyncMappingKernelManager @pytest.fixture def jp_server_config(): return Config( {"ServerApp": {"MappingKernelManager": {"allowed_message_types": ["kernel_info_request"]}}} ) def test_config(jp_serverapp): assert jp_serverapp.kernel_manager.allowed_message_types == ["kernel_info_request"] def test_async_kernel_manager(jp_configurable_serverapp): argv = [ "--ServerApp.kernel_manager_class=jupyter_server.services.kernels.kernelmanager.AsyncMappingKernelManager" ] app = jp_configurable_serverapp(argv=argv) assert isinstance(app.kernel_manager, AsyncMappingKernelManager) def test_not_server_kernel_manager(jp_configurable_serverapp): argv = [ "--AsyncMappingKernelManager.kernel_manager_class=jupyter_client.ioloop.manager.AsyncIOLoopKernelManager" ] with pytest.warns(FutureWarning, match="is not a subclass of 'ServerKernelManager'"): jp_configurable_serverapp(argv=argv) jupyter-server-jupyter_server-e5c7e2b/tests/services/kernels/test_connection.py000066400000000000000000000033001473126534200305070ustar00rootroot00000000000000import asyncio import json from unittest.mock import MagicMock from jupyter_client.jsonutil import json_clean, json_default from jupyter_client.session import Session from tornado.httpserver import HTTPRequest from jupyter_server.serverapp import ServerApp from jupyter_server.services.kernels.connection.channels import ZMQChannelsWebsocketConnection from jupyter_server.services.kernels.websocket import KernelWebsocketHandler async def test_websocket_connection(jp_serverapp: ServerApp) -> None: app = jp_serverapp kernel_id = await app.kernel_manager.start_kernel() # type:ignore[has-type] kernel = app.kernel_manager.get_kernel(kernel_id) request = HTTPRequest("foo", "GET") request.connection = MagicMock() handler = KernelWebsocketHandler(app.web_app, request) handler.ws_connection = MagicMock() handler.ws_connection.is_closing = lambda: False conn = ZMQChannelsWebsocketConnection(parent=kernel, websocket_handler=handler) handler.connection = conn await conn.prepare() conn.connect() await asyncio.wrap_future(conn.nudge()) session: Session = kernel.session msg = session.msg("data_pub", content={"a": "b"}) data = json.dumps( json_clean(msg), default=json_default, ensure_ascii=False, allow_nan=False, ) conn.handle_incoming_message(data) conn.handle_outgoing_message("iopub", session.serialize(msg)) assert ( conn.websocket_handler.select_subprotocol(["v1.kernel.websocket.jupyter.org"]) == "v1.kernel.websocket.jupyter.org" ) conn.write_stderr("test", {}) conn.on_kernel_restarted() conn.on_restart_failed() conn._on_error("shell", msg, session.serialize(msg)) jupyter-server-jupyter_server-e5c7e2b/tests/services/kernels/test_cull.py000066400000000000000000000207141473126534200273170ustar00rootroot00000000000000import asyncio import datetime import json import os import platform import uuid import warnings import jupyter_client import pytest from tornado.httpclient import HTTPClientError from traitlets.config import Config CULL_TIMEOUT = 30 if platform.python_implementation() == "PyPy" else 5 CULL_INTERVAL = 1 sample_kernel_json_with_metadata = { "argv": ["cat", "{connection_file}"], "display_name": "Test kernel", "metadata": {"cull_idle_timeout": 0}, } @pytest.fixture(autouse=True) def suppress_deprecation_warnings(): with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message="The synchronous MappingKernelManager", category=DeprecationWarning, ) yield @pytest.fixture def jp_kernelspec_with_metadata(jp_data_dir): """Configures some sample kernelspecs in the Jupyter data directory.""" kenrel_spec_name = "sample_with_metadata" sample_kernel_dir = jp_data_dir.joinpath("kernels", kenrel_spec_name) sample_kernel_dir.mkdir(parents=True) # Create kernel json file sample_kernel_file = sample_kernel_dir.joinpath("kernel.json") kernel_json = sample_kernel_json_with_metadata.copy() sample_kernel_file.write_text(json.dumps(kernel_json)) # Create resources text sample_kernel_resources = sample_kernel_dir.joinpath("resource.txt") sample_kernel_resources.write_text("resource") @pytest.mark.parametrize( "jp_server_config", [ # Test the synchronous case Config( { "ServerApp": { "kernel_manager_class": "jupyter_server.services.kernels.kernelmanager.MappingKernelManager", "MappingKernelManager": { "cull_idle_timeout": CULL_TIMEOUT, "cull_interval": CULL_INTERVAL, "cull_connected": False, }, } } ), # Test the async case Config( { "ServerApp": { "kernel_manager_class": "jupyter_server.services.kernels.kernelmanager.AsyncMappingKernelManager", "AsyncMappingKernelManager": { "cull_idle_timeout": CULL_TIMEOUT, "cull_interval": CULL_INTERVAL, "cull_connected": False, }, } } ), ], ) async def test_cull_idle(jp_fetch, jp_ws_fetch): r = await jp_fetch("api", "kernels", method="POST", allow_nonstandard_methods=True) kernel = json.loads(r.body.decode()) kid = kernel["id"] # Open a websocket connection. ws = await jp_ws_fetch("api", "kernels", kid, "channels") r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) assert model["connections"] == 1 culled = await get_cull_status(kid, jp_fetch) # connected, should not be culled assert not culled ws.close() culled = await get_cull_status(kid, jp_fetch) # not connected, should be culled assert culled @pytest.mark.parametrize( "jp_server_config", [ # Test the synchronous case Config( { "ServerApp": { "kernel_manager_class": "jupyter_server.services.kernels.kernelmanager.MappingKernelManager", "MappingKernelManager": { "cull_idle_timeout": CULL_TIMEOUT, "cull_interval": CULL_INTERVAL, "cull_connected": True, }, } } ), # Test the async case Config( { "ServerApp": { "kernel_manager_class": "jupyter_server.services.kernels.kernelmanager.AsyncMappingKernelManager", "AsyncMappingKernelManager": { "cull_idle_timeout": CULL_TIMEOUT, "cull_interval": CULL_INTERVAL, "cull_connected": True, }, } } ), ], ) async def test_cull_connected(jp_fetch, jp_ws_fetch): r = await jp_fetch("api", "kernels", method="POST", allow_nonstandard_methods=True) kernel = json.loads(r.body.decode()) kid = kernel["id"] # Open a websocket connection. ws = await jp_ws_fetch("api", "kernels", kid, "channels") session_id = uuid.uuid1().hex message_id = uuid.uuid1().hex await ws.write_message( json.dumps( { "channel": "shell", "header": { "date": datetime.datetime.now(tz=datetime.timezone.utc).isoformat(), "session": session_id, "msg_id": message_id, "msg_type": "execute_request", "username": "", "version": "5.2", }, "parent_header": {}, "metadata": {}, "content": { "code": f"import time\ntime.sleep({CULL_TIMEOUT-1})", "silent": False, "allow_stdin": False, "stop_on_error": True, }, "buffers": [], } ) ) r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) assert model["connections"] == 1 culled = await get_cull_status( kid, jp_fetch ) # connected, but code cell still running. Should not be culled assert not culled culled = await get_cull_status(kid, jp_fetch) # still connected, but idle... should be culled assert culled ws.close() async def test_cull_idle_disable(jp_fetch, jp_ws_fetch, jp_kernelspec_with_metadata): r = await jp_fetch("api", "kernels", method="POST", allow_nonstandard_methods=True) kernel = json.loads(r.body.decode()) kid = kernel["id"] # Open a websocket connection. ws = await jp_ws_fetch("api", "kernels", kid, "channels") r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) assert model["connections"] == 1 culled = await get_cull_status(kid, jp_fetch) # connected, should not be culled assert not culled ws.close() culled = await get_cull_status(kid, jp_fetch) # not connected, should not be culled assert not culled # Pending kernels was released in Jupyter Client 7.1 # It is currently broken on Windows (Jan 2022). When fixed, we can remove the Windows check. # See https://github.com/jupyter-server/jupyter_server/issues/672 @pytest.mark.skipif( os.name == "nt" or jupyter_client._version.version_info < (7, 1), reason="Pending kernels require jupyter_client >= 7.1 on non-Windows", ) @pytest.mark.parametrize( "jp_server_config", [ Config( { "ServerApp": { "kernel_manager_class": "jupyter_server.services.kernels.kernelmanager.AsyncMappingKernelManager", "AsyncMappingKernelManager": { "cull_idle_timeout": CULL_TIMEOUT, "cull_interval": CULL_INTERVAL, "cull_connected": False, "default_kernel_name": "bad", "use_pending_kernels": True, }, } } ) ], ) @pytest.mark.timeout(30) async def test_cull_dead(jp_fetch, jp_ws_fetch, jp_serverapp, jp_kernelspecs): r = await jp_fetch("api", "kernels", method="POST", allow_nonstandard_methods=True) kernel = json.loads(r.body.decode()) kid = kernel["id"] # Open a websocket connection. with pytest.raises(HTTPClientError): await jp_ws_fetch("api", "kernels", kid, "channels") r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) assert model["connections"] == 0 culled = await get_cull_status(kid, jp_fetch) # connected, should not be culled assert culled async def get_cull_status(kid, jp_fetch): frequency = 0.5 culled = False for _ in range( int((CULL_TIMEOUT + CULL_INTERVAL) / frequency) ): # Timeout + Interval will ensure cull try: r = await jp_fetch("api", "kernels", kid, method="GET") json.loads(r.body.decode()) except HTTPClientError as e: assert e.code == 404 culled = True break else: await asyncio.sleep(frequency) return culled jupyter-server-jupyter_server-e5c7e2b/tests/services/kernels/test_events.py000066400000000000000000000053161473126534200276650ustar00rootroot00000000000000import pytest from jupyter_client.manager import AsyncKernelManager from tornado import web from jupyter_server.services.kernels.kernelmanager import ServerKernelManager pytest_plugins = ["jupyter_events.pytest_plugin"] @pytest.mark.parametrize("action", ["start", "restart", "interrupt", "shutdown"]) async def test_kernel_action_success_event( monkeypatch, action, jp_read_emitted_events, jp_event_handler ): manager = ServerKernelManager() manager.event_logger.register_handler(jp_event_handler) async def mock_method(self, *args, **kwargs): self.kernel_id = "x-x-x-x-x" monkeypatch.setattr(AsyncKernelManager, f"{action}_kernel", mock_method) await getattr(manager, f"{action}_kernel")() output = jp_read_emitted_events()[0] assert "action" in output and output["action"] == action assert "msg" in output assert "kernel_id" in output assert "status" in output and output["status"] == "success" @pytest.mark.parametrize("action", ["start", "restart", "interrupt", "shutdown"]) async def test_kernel_action_failed_event( monkeypatch, action, jp_read_emitted_events, jp_event_handler ): manager = ServerKernelManager() manager.event_logger.register_handler(jp_event_handler) async def mock_method(self, *args, **kwargs): self.kernel_id = "x-x-x-x-x" raise Exception monkeypatch.setattr(AsyncKernelManager, f"{action}_kernel", mock_method) with pytest.raises(Exception): # noqa: B017 await getattr(manager, f"{action}_kernel")() output = jp_read_emitted_events()[0] assert "action" in output and output["action"] == action assert "msg" in output assert "kernel_id" in output assert "status" in output and output["status"] == "error" @pytest.mark.parametrize("action", ["start", "restart", "interrupt", "shutdown"]) async def test_kernel_action_http_error_event( monkeypatch, action, jp_read_emitted_events, jp_event_handler ): manager = ServerKernelManager() manager.event_logger.register_handler(jp_event_handler) log_message = "This http request failed." async def mock_method(self, *args, **kwargs): self.kernel_id = "x-x-x-x-x" raise web.HTTPError(status_code=500, log_message=log_message) monkeypatch.setattr(AsyncKernelManager, f"{action}_kernel", mock_method) with pytest.raises(web.HTTPError): await getattr(manager, f"{action}_kernel")() output = jp_read_emitted_events()[0] assert "action" in output and output["action"] == action assert "msg" in output and output["msg"] == log_message assert "kernel_id" in output assert "status" in output and output["status"] == "error" assert "status_code" in output and output["status_code"] == 500 jupyter-server-jupyter_server-e5c7e2b/tests/services/kernels/test_execution_state.py000066400000000000000000000112041473126534200315550ustar00rootroot00000000000000import asyncio import datetime import json import os import platform import time import uuid import warnings import jupyter_client import pytest from flaky import flaky from tornado.httpclient import HTTPClientError from traitlets.config import Config MAX_POLL_ATTEMPTS = 10 POLL_INTERVAL = 1 MINIMUM_CONSISTENT_COUNT = 4 @flaky async def test_execution_state(jp_fetch, jp_ws_fetch): r = await jp_fetch("api", "kernels", method="POST", allow_nonstandard_methods=True) kernel = json.loads(r.body.decode()) kid = kernel["id"] # Open a websocket connection. ws = await jp_ws_fetch("api", "kernels", kid, "channels") session_id = uuid.uuid1().hex message_id = uuid.uuid1().hex await ws.write_message( json.dumps( { "channel": "shell", "header": { "date": datetime.datetime.now(tz=datetime.timezone.utc).isoformat(), "session": session_id, "msg_id": message_id, "msg_type": "execute_request", "username": "", "version": "5.2", }, "parent_header": {}, "metadata": {}, "content": { "code": "while True:\n\tpass", "silent": False, "allow_stdin": False, "stop_on_error": True, }, "buffers": [], } ) ) await poll_for_parent_message_status(kid, message_id, "busy", ws) es = await get_execution_state(kid, jp_fetch) assert es == "busy" message_id_2 = uuid.uuid1().hex await ws.write_message( json.dumps( { "channel": "control", "header": { "date": datetime.datetime.now(tz=datetime.timezone.utc).isoformat(), "session": session_id, "msg_id": message_id_2, "msg_type": "debug_request", "username": "", "version": "5.2", }, "parent_header": {}, "metadata": {}, "content": { "type": "request", "command": "debugInfo", }, "buffers": [], } ) ) await poll_for_parent_message_status(kid, message_id_2, "idle", ws) es = await get_execution_state(kid, jp_fetch) # Verify that the overall kernel status is still "busy" even though one # "idle" response was already seen for the second execute request. assert es == "busy" await jp_fetch( "api", "kernels", kid, "interrupt", method="POST", allow_nonstandard_methods=True, ) await poll_for_parent_message_status(kid, message_id, "idle", ws) es = await get_execution_state(kid, jp_fetch) assert es == "idle" ws.close() async def get_execution_state(kid, jp_fetch): # There is an inherent race condition when getting the kernel execution status # where we might fetch the status right before an expected state change occurs. # # To work-around this, we don't return the status until we've been able to fetch # it twice in a row and get the same result both times. last_execution_states = [] for _ in range(MAX_POLL_ATTEMPTS): r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) execution_state = model["execution_state"] last_execution_states.append(execution_state) consistent_count = 0 last_execution_state = None for es in last_execution_states: if es != last_execution_state: consistent_count = 0 last_execution_state = es consistent_count += 1 if consistent_count >= MINIMUM_CONSISTENT_COUNT: return es time.sleep(POLL_INTERVAL) raise AssertionError("failed to get a consistent execution state") async def poll_for_parent_message_status(kid, parent_message_id, target_status, ws): while True: resp = await ws.read_message() resp_json = json.loads(resp) print(resp_json) parent_message = resp_json.get("parent_header", {}).get("msg_id", None) if parent_message != parent_message_id: continue response_type = resp_json.get("header", {}).get("msg_type", None) if response_type != "status": continue execution_state = resp_json.get("content", {}).get("execution_state", "") if execution_state == target_status: return jupyter-server-jupyter_server-e5c7e2b/tests/services/kernelspecs/000077500000000000000000000000001473126534200256165ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/kernelspecs/__init__.py000066400000000000000000000000001473126534200277150ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/kernelspecs/test_api.py000066400000000000000000000053751473126534200300120ustar00rootroot00000000000000import json import pytest from tornado.httpclient import HTTPClientError from jupyter_server.serverapp import ServerApp from ...utils import expected_http_error, some_resource async def test_list_kernelspecs_bad(jp_fetch, jp_kernelspecs, jp_data_dir, jp_serverapp): app: ServerApp = jp_serverapp default = app.kernel_manager.default_kernel_name bad_kernel_dir = jp_data_dir.joinpath(jp_data_dir, "kernels", "bad2") bad_kernel_dir.mkdir(parents=True) bad_kernel_json = bad_kernel_dir.joinpath("kernel.json") bad_kernel_json.write_text("garbage") r = await jp_fetch("api", "kernelspecs", method="GET") model = json.loads(r.body.decode()) assert isinstance(model, dict) assert model["default"] == default specs = model["kernelspecs"] assert isinstance(specs, dict) assert len(specs) > 2 async def test_list_kernelspecs(jp_fetch, jp_kernelspecs, jp_serverapp): app: ServerApp = jp_serverapp default = app.kernel_manager.default_kernel_name r = await jp_fetch("api", "kernelspecs", method="GET") model = json.loads(r.body.decode()) assert isinstance(model, dict) assert model["default"] == default specs = model["kernelspecs"] assert isinstance(specs, dict) assert len(specs) > 2 def is_sample_kernelspec(s): return s["name"] == "sample" and s["spec"]["display_name"] == "Test kernel" def is_default_kernelspec(s): return s["name"] == default assert any(is_sample_kernelspec(s) for s in specs.values()), specs assert any(is_default_kernelspec(s) for s in specs.values()), specs async def test_get_kernelspecs(jp_fetch, jp_kernelspecs): r = await jp_fetch("api", "kernelspecs", "Sample", method="GET") model = json.loads(r.body.decode()) assert model["name"].lower() == "sample" assert isinstance(model["spec"], dict) assert model["spec"]["display_name"] == "Test kernel" assert isinstance(model["resources"], dict) async def test_get_nonexistant_kernelspec(jp_fetch, jp_kernelspecs): with pytest.raises(HTTPClientError) as e: await jp_fetch("api", "kernelspecs", "nonexistent", method="GET") assert expected_http_error(e, 404) async def test_get_kernel_resource_file(jp_fetch, jp_kernelspecs): r = await jp_fetch("kernelspecs", "sAmple", "resource.txt", method="GET") res = r.body.decode("utf-8") assert res == some_resource async def test_get_nonexistant_resource(jp_fetch, jp_kernelspecs): with pytest.raises(HTTPClientError) as e: await jp_fetch("kernelspecs", "nonexistent", "resource.txt", method="GET") assert expected_http_error(e, 404) with pytest.raises(HTTPClientError) as e: await jp_fetch("kernelspecs", "sample", "nonexistent.txt", method="GET") assert expected_http_error(e, 404) jupyter-server-jupyter_server-e5c7e2b/tests/services/nbconvert/000077500000000000000000000000001473126534200253005ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/nbconvert/__init__.py000066400000000000000000000000001473126534200273770ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/nbconvert/test_api.py000066400000000000000000000010261473126534200274610ustar00rootroot00000000000000import json async def test_list_formats(jp_fetch): r = await jp_fetch("api", "nbconvert", method="GET") formats = json.loads(r.body.decode()) # Verify the type of the response. assert isinstance(formats, dict) # Verify that all returned formats have an # output mimetype defined. required_keys_present = [] for _, data in formats.items(): required_keys_present.append("output_mimetype" in data) assert all(required_keys_present), "All returned formats must have a `output_mimetype` key." jupyter-server-jupyter_server-e5c7e2b/tests/services/sessions/000077500000000000000000000000001473126534200251465ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/sessions/__init__.py000066400000000000000000000000001473126534200272450ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/services/sessions/test_api.py000066400000000000000000000440571473126534200273420ustar00rootroot00000000000000import asyncio import json import os import shutil import time import warnings from typing import Any import jupyter_client import pytest import tornado from flaky import flaky from jupyter_client.ioloop import AsyncIOLoopKernelManager from nbformat import writes from nbformat.v4 import new_notebook from tornado.httpclient import HTTPClientError from traitlets import default from jupyter_server.services.kernels.kernelmanager import AsyncMappingKernelManager from jupyter_server.utils import url_path_join from ...utils import expected_http_error TEST_TIMEOUT = 10 @pytest.fixture(autouse=True) def suppress_deprecation_warnings(): with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message="The synchronous MappingKernelManager", category=DeprecationWarning, ) yield def j(r): return json.loads(r.body.decode()) class NewPortsKernelManager(AsyncIOLoopKernelManager): @default("cache_ports") def _default_cache_ports(self) -> bool: return False async def restart_kernel( # type:ignore[override] self, now: bool = False, newports: bool = True, **kw: Any ) -> None: self.log.debug(f"DEBUG**** calling super().restart_kernel with newports={newports}") return await super().restart_kernel(now=now, newports=newports, **kw) class NewPortsMappingKernelManager(AsyncMappingKernelManager): @default("kernel_manager_class") def _default_kernel_manager_class(self): self.log.debug("NewPortsMappingKernelManager in _default_kernel_manager_class!") return "tests.services.sessions.test_api.NewPortsKernelManager" configs: list = [ { "ServerApp": { "kernel_manager_class": "jupyter_server.services.kernels.kernelmanager.MappingKernelManager" } }, { "ServerApp": { "kernel_manager_class": "jupyter_server.services.kernels.kernelmanager.AsyncMappingKernelManager" } }, { "ServerApp": { "kernel_manager_class": "tests.services.sessions.test_api.NewPortsMappingKernelManager" } }, ] # Pending kernels was released in Jupyter Client 7.1 # It is currently broken on Windows (Jan 2022). When fixed, we can remove the Windows check. # See https://github.com/jupyter-server/jupyter_server/issues/672 if os.name != "nt" and jupyter_client._version.version_info >= (7, 1): # Add a pending kernels condition c: dict = { "ServerApp": { "kernel_manager_class": "tests.services.sessions.test_api.NewPortsMappingKernelManager" }, "AsyncMappingKernelManager": {"use_pending_kernels": True}, } configs.append(c) @pytest.fixture(params=configs) def jp_server_config(request): return request.param class SessionClient: def __init__(self, fetch_callable): self.jp_fetch = fetch_callable async def _req(self, *args, method, body=None): if body is not None: body = json.dumps(body) r = await self.jp_fetch( "api", "sessions", *args, method=method, body=body, allow_nonstandard_methods=True, ) return r async def list(self): return await self._req(method="GET") async def get(self, id): return await self._req(id, method="GET") async def create(self, path, type="notebook", kernel_name=None, kernel_id=None): body = { "path": path, "type": type, "kernel": {"name": kernel_name, "id": kernel_id}, } return await self._req(method="POST", body=body) def create_deprecated(self, path): body = {"notebook": {"path": path}, "kernel": {"name": "python", "id": "foo"}} return self._req(method="POST", body=body) def modify_path(self, id, path): body = {"path": path} return self._req(id, method="PATCH", body=body) def modify_path_deprecated(self, id, path): body = {"notebook": {"path": path}} return self._req(id, method="PATCH", body=body) def modify_type(self, id, type): body = {"type": type} return self._req(id, method="PATCH", body=body) def modify_kernel_name(self, id, kernel_name): body = {"kernel": {"name": kernel_name}} return self._req(id, method="PATCH", body=body) def modify_kernel_id(self, id, kernel_id): # Also send a dummy name to show that id takes precedence. body = {"kernel": {"id": kernel_id, "name": "foo"}} return self._req(id, method="PATCH", body=body) async def delete(self, id): return await self._req(id, method="DELETE") async def cleanup(self): resp = await self.list() sessions = j(resp) for session in sessions: await self.delete(session["id"]) time.sleep(0.1) @pytest.fixture def session_is_ready(jp_serverapp): """Wait for the kernel started by a session to be ready. This is useful when working with pending kernels. """ async def _(session_id): mkm = jp_serverapp.kernel_manager if getattr(mkm, "use_pending_kernels", False): sm = jp_serverapp.session_manager session = await sm.get_session(session_id=session_id) kernel_id = session["kernel"]["id"] kernel = mkm.get_kernel(kernel_id) if getattr(kernel, "ready", None): ready = kernel.ready if not isinstance(ready, asyncio.Future): ready = asyncio.wrap_future(ready) await ready return _ @pytest.fixture def session_client(jp_root_dir, jp_fetch): subdir = jp_root_dir.joinpath("foo") subdir.mkdir() # Write a notebook to subdir. nb = new_notebook() nb_str = writes(nb, version=4) nbpath = subdir.joinpath("nb1.ipynb") nbpath.write_text(nb_str, encoding="utf-8") # Yield a session client client = SessionClient(jp_fetch) yield client # Remove subdir shutil.rmtree(str(subdir), ignore_errors=True) def assert_kernel_equality(actual, expected): """Compares kernel models after taking into account that execution_states may differ from 'starting' to 'idle'. The 'actual' argument is the current state (which may have an 'idle' status) while the 'expected' argument is the previous state (which may have a 'starting' status). """ actual.pop("execution_state", None) actual.pop("last_activity", None) expected.pop("execution_state", None) expected.pop("last_activity", None) assert actual == expected def assert_session_equality(actual, expected): """Compares session models. `actual` is the most current session, while `expected` is the target of the comparison. This order matters when comparing the kernel sub-models. """ assert actual["id"] == expected["id"] assert actual["path"] == expected["path"] assert actual["type"] == expected["type"] assert_kernel_equality(actual["kernel"], expected["kernel"]) @pytest.mark.timeout(TEST_TIMEOUT) async def test_create(session_client, jp_base_url, jp_serverapp): # Make sure no sessions exist. resp = await session_client.list() sessions = j(resp) assert len(sessions) == 0 # Create a session. resp = await session_client.create("foo/nb1.ipynb") assert resp.code == 201 new_session = j(resp) assert "id" in new_session assert new_session["path"] == "foo/nb1.ipynb" assert new_session["type"] == "notebook" assert resp.headers["Location"] == url_path_join( jp_base_url, "/api/sessions/", new_session["id"] ) # Make sure kernel is in expected state kid = new_session["kernel"]["id"] kernel = jp_serverapp.kernel_manager.get_kernel(kid) if hasattr(kernel, "ready") and os.name != "nt": km = jp_serverapp.kernel_manager if isinstance(km, AsyncMappingKernelManager): assert kernel.ready.done() == (not km.use_pending_kernels) else: assert kernel.ready.done() # Check that the new session appears in list. resp = await session_client.list() sessions = j(resp) assert len(sessions) == 1 assert_session_equality(sessions[0], new_session) # Retrieve that session. sid = new_session["id"] resp = await session_client.get(sid) got = j(resp) assert_session_equality(got, new_session) @pytest.mark.timeout(TEST_TIMEOUT) async def test_create_bad(session_client, jp_base_url, jp_serverapp, jp_kernelspecs): if getattr(jp_serverapp.kernel_manager, "use_pending_kernels", False): return # Make sure no sessions exist. jp_serverapp.kernel_manager.default_kernel_name = "bad" resp = await session_client.list() sessions = j(resp) assert len(sessions) == 0 # Create a session. with pytest.raises(HTTPClientError): await session_client.create("foo/nb1.ipynb") @pytest.mark.timeout(TEST_TIMEOUT) async def test_create_bad_pending( session_client, jp_base_url, jp_ws_fetch, jp_serverapp, jp_kernelspecs, ): if not getattr(jp_serverapp.kernel_manager, "use_pending_kernels", False): return # Make sure no sessions exist. jp_serverapp.kernel_manager.default_kernel_name = "bad" resp = await session_client.list() sessions = j(resp) assert len(sessions) == 0 # Create a session. resp = await session_client.create("foo/nb1.ipynb") assert resp.code == 201 # Open a websocket connection. kid = j(resp)["kernel"]["id"] with pytest.raises(HTTPClientError): await jp_ws_fetch("api", "kernels", kid, "channels") # Get the updated kernel state resp = await session_client.list() session = j(resp)[0] assert session["kernel"]["execution_state"] == "dead" if os.name != "nt": assert "non_existent_path" in session["kernel"]["reason"] @pytest.mark.timeout(TEST_TIMEOUT) async def test_create_file_session(session_client, jp_serverapp, session_is_ready): resp = await session_client.create("foo/nb1.py", type="file") assert resp.code == 201 newsession = j(resp) assert newsession["path"] == "foo/nb1.py" assert newsession["type"] == "file" sid = newsession["id"] await session_is_ready(sid) @pytest.mark.timeout(TEST_TIMEOUT) async def test_create_console_session(session_client, jp_serverapp, session_is_ready): resp = await session_client.create("foo/abc123", type="console") assert resp.code == 201 newsession = j(resp) assert newsession["path"] == "foo/abc123" assert newsession["type"] == "console" sid = newsession["id"] await session_is_ready(sid) @pytest.mark.timeout(TEST_TIMEOUT) async def test_create_deprecated(session_client, jp_serverapp): resp = await session_client.create_deprecated("foo/nb1.ipynb") assert resp.code == 201 newsession = j(resp) assert newsession["path"] == "foo/nb1.ipynb" assert newsession["type"] == "notebook" assert newsession["notebook"]["path"] == "foo/nb1.ipynb" @pytest.mark.timeout(TEST_TIMEOUT) async def test_create_with_kernel_id(session_client, jp_fetch, jp_base_url, jp_serverapp): # create a new kernel resp = await jp_fetch("api/kernels", method="POST", allow_nonstandard_methods=True) kernel = j(resp) resp = await session_client.create("foo/nb1.ipynb", kernel_id=kernel["id"]) assert resp.code == 201 new_session = j(resp) assert "id" in new_session assert new_session["path"] == "foo/nb1.ipynb" assert new_session["kernel"]["id"] == kernel["id"] assert resp.headers["Location"] == url_path_join( jp_base_url, "/api/sessions/{}".format(new_session["id"]) ) resp = await session_client.list() sessions = j(resp) assert len(sessions) == 1 assert_session_equality(sessions[0], new_session) # Retrieve it sid = new_session["id"] resp = await session_client.get(sid) got = j(resp) assert_session_equality(got, new_session) @pytest.mark.timeout(TEST_TIMEOUT) async def test_create_with_bad_kernel_id(session_client, jp_serverapp, session_is_ready): resp = await session_client.create("foo/nb1.py", type="file") assert resp.code == 201 newsession = j(resp) sid = newsession["id"] await session_is_ready(sid) assert newsession["path"] == "foo/nb1.py" assert newsession["type"] == "file" @pytest.mark.timeout(TEST_TIMEOUT) async def test_delete(session_client, jp_serverapp, session_is_ready): resp = await session_client.create("foo/nb1.ipynb") newsession = j(resp) sid = newsession["id"] await session_is_ready(sid) resp = await session_client.delete(sid) assert resp.code == 204 resp = await session_client.list() sessions = j(resp) assert sessions == [] with pytest.raises(tornado.httpclient.HTTPClientError) as e: await session_client.get(sid) assert expected_http_error(e, 404) @pytest.mark.timeout(TEST_TIMEOUT) async def test_modify_path(session_client, jp_serverapp, session_is_ready): resp = await session_client.create("foo/nb1.ipynb") newsession = j(resp) sid = newsession["id"] await session_is_ready(sid) resp = await session_client.modify_path(sid, "nb2.ipynb") changed = j(resp) assert changed["id"] == sid assert changed["path"] == "nb2.ipynb" @pytest.mark.timeout(TEST_TIMEOUT) async def test_modify_path_deprecated(session_client, jp_serverapp, session_is_ready): resp = await session_client.create("foo/nb1.ipynb") newsession = j(resp) sid = newsession["id"] await session_is_ready(sid) resp = await session_client.modify_path_deprecated(sid, "nb2.ipynb") changed = j(resp) assert changed["id"] == sid assert changed["notebook"]["path"] == "nb2.ipynb" @pytest.mark.timeout(TEST_TIMEOUT) async def test_modify_type(session_client, jp_serverapp, session_is_ready): resp = await session_client.create("foo/nb1.ipynb") newsession = j(resp) sid = newsession["id"] await session_is_ready(sid) resp = await session_client.modify_type(sid, "console") changed = j(resp) assert changed["id"] == sid assert changed["type"] == "console" @pytest.mark.timeout(TEST_TIMEOUT) async def test_modify_kernel_name(session_client, jp_fetch, jp_serverapp, session_is_ready): resp = await session_client.create("foo/nb1.ipynb") before = j(resp) sid = before["id"] await session_is_ready(sid) resp = await session_client.modify_kernel_name(sid, before["kernel"]["name"]) after = j(resp) assert after["id"] == sid assert after["path"] == before["path"] assert after["type"] == before["type"] assert after["kernel"]["id"] != before["kernel"]["id"] # check kernel list, to be sure previous kernel was cleaned up resp = await jp_fetch("api/kernels", method="GET") kernel_list = j(resp) after["kernel"].pop("last_activity") [k.pop("last_activity") for k in kernel_list] if not getattr(jp_serverapp.kernel_manager, "use_pending_kernels", False): assert kernel_list == [after["kernel"]] @pytest.mark.timeout(TEST_TIMEOUT) async def test_modify_kernel_id(session_client, jp_fetch, jp_serverapp, session_is_ready): resp = await session_client.create("foo/nb1.ipynb") before = j(resp) sid = before["id"] await session_is_ready(sid) # create a new kernel resp = await jp_fetch("api/kernels", method="POST", allow_nonstandard_methods=True) kernel = j(resp) # Attach our session to the existing kernel resp = await session_client.modify_kernel_id(sid, kernel["id"]) after = j(resp) assert after["id"] == sid assert after["path"] == before["path"] assert after["type"] == before["type"] assert after["kernel"]["id"] != before["kernel"]["id"] assert after["kernel"]["id"] == kernel["id"] # check kernel list, to be sure previous kernel was cleaned up resp = await jp_fetch("api/kernels", method="GET") kernel_list = j(resp) kernel.pop("last_activity") [k.pop("last_activity") for k in kernel_list] if not getattr(jp_serverapp.kernel_manager, "use_pending_kernels", False): assert kernel_list == [kernel] @flaky @pytest.mark.timeout(TEST_TIMEOUT) async def test_restart_kernel(session_client, jp_base_url, jp_fetch, jp_ws_fetch, session_is_ready): # Create a session. resp = await session_client.create("foo/nb1.ipynb") assert resp.code == 201 new_session = j(resp) assert "id" in new_session assert new_session["path"] == "foo/nb1.ipynb" assert new_session["type"] == "notebook" assert resp.headers["Location"] == url_path_join( jp_base_url, "/api/sessions/", new_session["id"] ) sid = new_session["id"] await session_is_ready(sid) kid = new_session["kernel"]["id"] # Get kernel info r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) assert model["connections"] == 0 # Open a websocket connection. ws = await jp_ws_fetch("api", "kernels", kid, "channels") # Test that it was opened. r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) assert model["connections"] == 1 # Restart kernel r = await jp_fetch( "api", "kernels", kid, "restart", method="POST", allow_nonstandard_methods=True ) restarted_kernel = json.loads(r.body.decode()) assert restarted_kernel["id"] == kid # Close/open websocket ws.close() # give it some time to close on the other side: for _ in range(10): r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) if model["connections"] > 0: time.sleep(0.1) else: break r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) assert model["connections"] == 0 # Open a new websocket connection. ws2 = await jp_ws_fetch("api", "kernels", kid, "channels") # give it some time to close on the other side: for _ in range(10): r = await jp_fetch("api", "kernels", kid, method="GET") model = json.loads(r.body.decode()) if model["connections"] == 0: time.sleep(0.1) else: break ws2.close() jupyter-server-jupyter_server-e5c7e2b/tests/services/sessions/test_manager.py000066400000000000000000000424011473126534200301720ustar00rootroot00000000000000import asyncio from datetime import datetime import pytest from tornado import web from traitlets import TraitError from jupyter_server._tz import isoformat, utcnow from jupyter_server.services.contents.manager import ContentsManager from jupyter_server.services.kernels.kernelmanager import MappingKernelManager from jupyter_server.services.sessions.sessionmanager import ( KernelName, KernelSessionRecord, KernelSessionRecordConflict, KernelSessionRecordList, SessionManager, ) class DummyKernel: execution_state: str last_activity: datetime def __init__(self, kernel_name="python"): self.kernel_name = kernel_name def update_env(self, *args, **kwargs): pass dummy_date = utcnow() dummy_date_s = isoformat(dummy_date) class MockMKM(MappingKernelManager): """MappingKernelManager interface that doesn't start kernels, for testing""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.id_letters = iter("ABCDEFGHIJK") def _new_id(self): return next(self.id_letters) async def start_kernel(self, *, kernel_id=None, path=None, kernel_name="python", **kwargs): kernel_id = kernel_id or self._new_id() k = self._kernels[kernel_id] = DummyKernel(kernel_name=kernel_name) self._kernel_connections[kernel_id] = 0 k.last_activity = dummy_date k.execution_state = "idle" return kernel_id async def shutdown_kernel(self, kernel_id, now=False): del self._kernels[kernel_id] class SlowStartingKernelsMKM(MockMKM): async def start_kernel(self, *, kernel_id=None, path=None, kernel_name="python", **kwargs): await asyncio.sleep(1.0) return await super().start_kernel( kernel_id=kernel_id, path=path, kernel_name=kernel_name, **kwargs ) async def shutdown_kernel(self, kernel_id, now=False): await asyncio.sleep(1.0) await super().shutdown_kernel(kernel_id, now=now) @pytest.fixture def session_manager(): return SessionManager(kernel_manager=MockMKM(), contents_manager=ContentsManager()) def test_kernel_record_equals(): record1 = KernelSessionRecord(session_id="session1") record2 = KernelSessionRecord(session_id="session1", kernel_id="kernel1") record3 = KernelSessionRecord(session_id="session2", kernel_id="kernel1") record4 = KernelSessionRecord(session_id="session1", kernel_id="kernel2") assert record1 == record2 assert record2 == record3 assert record3 != record4 assert record1 != record3 assert record3 != record4 with pytest.raises(KernelSessionRecordConflict): assert record2 == record4 def test_kernel_record_update(): record1 = KernelSessionRecord(session_id="session1") record2 = KernelSessionRecord(session_id="session1", kernel_id="kernel1") record1.update(record2) assert record1.kernel_id == "kernel1" record1 = KernelSessionRecord(session_id="session1") record2 = KernelSessionRecord(kernel_id="kernel1") record1.update(record2) assert record1.kernel_id == "kernel1" record1 = KernelSessionRecord(kernel_id="kernel1") record2 = KernelSessionRecord(session_id="session1") record1.update(record2) assert record1.session_id == "session1" record1 = KernelSessionRecord(kernel_id="kernel1") record2 = KernelSessionRecord(session_id="session1", kernel_id="kernel1") record1.update(record2) assert record1.session_id == "session1" record1 = KernelSessionRecord(kernel_id="kernel1") record2 = KernelSessionRecord(session_id="session1", kernel_id="kernel2") with pytest.raises(KernelSessionRecordConflict): record1.update(record2) record1 = KernelSessionRecord(kernel_id="kernel1", session_id="session1") record2 = KernelSessionRecord(kernel_id="kernel2") with pytest.raises(KernelSessionRecordConflict): record1.update(record2) record1 = KernelSessionRecord(kernel_id="kernel1", session_id="session1") record2 = KernelSessionRecord(kernel_id="kernel2", session_id="session1") with pytest.raises(KernelSessionRecordConflict): record1.update(record2) record1 = KernelSessionRecord(session_id="session1", kernel_id="kernel1") record2 = KernelSessionRecord(session_id="session2", kernel_id="kernel1") record1.update(record2) assert record1.session_id == "session2" def test_kernel_record_list(): records = KernelSessionRecordList() r = KernelSessionRecord(kernel_id="kernel1") records.update(r) assert r in records assert "kernel1" in records assert len(records) == 1 # Test .get() r_ = records.get(r) assert r == r_ r_ = records.get(r.kernel_id or "") assert r == r_ with pytest.raises(ValueError): records.get("badkernel") r_update = KernelSessionRecord(kernel_id="kernel1", session_id="session1") records.update(r_update) assert len(records) == 1 assert "session1" in records r2 = KernelSessionRecord(kernel_id="kernel2") records.update(r2) assert r2 in records assert len(records) == 2 records.remove(r2) assert r2 not in records assert len(records) == 1 async def create_multiple_sessions(session_manager, *kwargs_list): sessions = [] for kwargs in kwargs_list: kwargs.setdefault("type", "notebook") session = await session_manager.create_session(**kwargs) sessions.append(session) return sessions async def test_get_session(session_manager): session = await session_manager.create_session( path="/path/to/test.ipynb", kernel_name="bar", type="notebook" ) session_id = session["id"] model = await session_manager.get_session(session_id=session_id) expected = { "id": session_id, "path": "/path/to/test.ipynb", "notebook": {"path": "/path/to/test.ipynb", "name": None}, "type": "notebook", "name": None, "kernel": { "id": "A", "name": "bar", "connections": 0, "last_activity": dummy_date_s, "execution_state": "idle", }, } assert model == expected async def test_bad_get_session(session_manager): session = await session_manager.create_session( path="/path/to/test.ipynb", kernel_name="foo", type="notebook" ) with pytest.raises(TypeError): await session_manager.get_session(bad_id=session["id"]) async def test_get_session_dead_kernel(session_manager): session = await session_manager.create_session( path="/path/to/1/test1.ipynb", kernel_name="python", type="notebook" ) # Kill the kernel await session_manager.kernel_manager.shutdown_kernel(session["kernel"]["id"]) with pytest.raises(web.HTTPError): await session_manager.get_session(session_id=session["id"]) # no session left listed = await session_manager.list_sessions() assert listed == [] async def test_list_session(session_manager): sessions = await create_multiple_sessions( session_manager, dict(path="/path/to/1/test1.ipynb", kernel_name="python"), dict(path="/path/to/2/test2.py", type="file", kernel_name="python"), dict(path="/path/to/3", name="foo", type="console", kernel_name="python"), ) sessions = await session_manager.list_sessions() expected = [ { "id": sessions[0]["id"], "path": "/path/to/1/test1.ipynb", "type": "notebook", "notebook": {"path": "/path/to/1/test1.ipynb", "name": None}, "name": None, "kernel": { "id": "A", "name": "python", "connections": 0, "last_activity": dummy_date_s, "execution_state": "idle", }, }, { "id": sessions[1]["id"], "path": "/path/to/2/test2.py", "type": "file", "name": None, "kernel": { "id": "B", "name": "python", "connections": 0, "last_activity": dummy_date_s, "execution_state": "idle", }, }, { "id": sessions[2]["id"], "path": "/path/to/3", "type": "console", "name": "foo", "kernel": { "id": "C", "name": "python", "connections": 0, "last_activity": dummy_date_s, "execution_state": "idle", }, }, ] assert sessions == expected async def test_list_sessions_dead_kernel(session_manager): sessions = await create_multiple_sessions( session_manager, dict(path="/path/to/1/test1.ipynb", kernel_name="python"), dict(path="/path/to/2/test2.ipynb", kernel_name="python"), ) # kill one of the kernels await session_manager.kernel_manager.shutdown_kernel(sessions[0]["kernel"]["id"]) listed = await session_manager.list_sessions() expected = [ { "id": sessions[1]["id"], "path": "/path/to/2/test2.ipynb", "type": "notebook", "name": None, "notebook": {"path": "/path/to/2/test2.ipynb", "name": None}, "kernel": { "id": "B", "name": "python", "connections": 0, "last_activity": dummy_date_s, "execution_state": "idle", }, } ] assert listed == expected async def test_update_session(session_manager): session = await session_manager.create_session( path="/path/to/test.ipynb", kernel_name="julia", type="notebook" ) session_id = session["id"] await session_manager.update_session(session_id, path="/path/to/new_name.ipynb") model = await session_manager.get_session(session_id=session_id) expected = { "id": session_id, "path": "/path/to/new_name.ipynb", "type": "notebook", "name": None, "notebook": {"path": "/path/to/new_name.ipynb", "name": None}, "kernel": { "id": "A", "name": "julia", "connections": 0, "last_activity": dummy_date_s, "execution_state": "idle", }, } assert model == expected async def test_bad_update_session(session_manager): # try to update a session with a bad keyword ~ raise error session = await session_manager.create_session( path="/path/to/test.ipynb", kernel_name="ir", type="notegbook" ) session_id = session["id"] with pytest.raises(TypeError): await session_manager.update_session( session_id=session_id, bad_kw="test.ipynb" ) # Bad keyword async def test_delete_session(session_manager): sessions = await create_multiple_sessions( session_manager, dict(path="/path/to/1/test1.ipynb", kernel_name="python"), dict(path="/path/to/2/test2.ipynb", kernel_name="python"), dict(path="/path/to/3", name="foo", type="console", kernel_name="python"), ) await session_manager.delete_session(sessions[1]["id"]) new_sessions = await session_manager.list_sessions() expected = [ { "id": sessions[0]["id"], "path": "/path/to/1/test1.ipynb", "type": "notebook", "name": None, "notebook": {"path": "/path/to/1/test1.ipynb", "name": None}, "kernel": { "id": "A", "name": "python", "connections": 0, "last_activity": dummy_date_s, "execution_state": "idle", }, }, { "id": sessions[2]["id"], "type": "console", "path": "/path/to/3", "name": "foo", "kernel": { "id": "C", "name": "python", "connections": 0, "last_activity": dummy_date_s, "execution_state": "idle", }, }, ] assert new_sessions == expected async def test_bad_delete_session(session_manager): # try to delete a session that doesn't exist ~ raise error await session_manager.create_session( path="/path/to/test.ipynb", kernel_name="python", type="notebook" ) with pytest.raises(TypeError): await session_manager.delete_session(bad_kwarg="23424") # Bad keyword with pytest.raises(web.HTTPError): await session_manager.delete_session(session_id="23424") # nonexistent async def test_bad_database_filepath(jp_runtime_dir): kernel_manager = MockMKM() # Try to write to a path that's a directory, not a file. path_id_directory = str(jp_runtime_dir) # Should raise an error because the path is a directory. with pytest.raises(TraitError) as err: SessionManager( kernel_manager=kernel_manager, contents_manager=ContentsManager(), database_filepath=str(path_id_directory), ) # Try writing to file that's not a valid SQLite 3 database file. non_db_file = jp_runtime_dir.joinpath("non_db_file.db") non_db_file.write_bytes(b"this is a bad file") # Should raise an error because the file doesn't # start with an SQLite database file header. with pytest.raises(TraitError) as err: SessionManager( kernel_manager=kernel_manager, contents_manager=ContentsManager(), database_filepath=str(non_db_file), ) async def test_good_database_filepath(jp_runtime_dir): kernel_manager = MockMKM() # Try writing to an empty file. empty_file = jp_runtime_dir.joinpath("empty.db") empty_file.write_bytes(b"") session_manager = SessionManager( kernel_manager=kernel_manager, contents_manager=ContentsManager(), database_filepath=str(empty_file), ) await session_manager.create_session( path="/path/to/test.ipynb", kernel_name=KernelName("python"), type="notebook" ) # Assert that the database file exists assert empty_file.exists() # Close the current session manager del session_manager # Try writing to a file that already exists. session_manager = SessionManager( kernel_manager=kernel_manager, contents_manager=ContentsManager(), database_filepath=str(empty_file), ) assert session_manager.database_filepath == str(empty_file) async def test_session_persistence(jp_runtime_dir): session_db_path = jp_runtime_dir.joinpath("test-session.db") # Kernel manager needs to persist. kernel_manager = MockMKM() # Initialize a session and start a connection. # This should create the session database the first time. session_manager = SessionManager( kernel_manager=kernel_manager, contents_manager=ContentsManager(), database_filepath=str(session_db_path), ) session = await session_manager.create_session( path="/path/to/test.ipynb", kernel_name=KernelName("python"), type="notebook" ) # Assert that the database file exists assert session_db_path.exists() with open(session_db_path, "rb") as f: header = f.read(100) assert header.startswith(b"SQLite format 3") # Close the current session manager del session_manager # Get a new session_manager session_manager = SessionManager( kernel_manager=kernel_manager, contents_manager=ContentsManager(), database_filepath=str(session_db_path), ) # Assert that the session database persists. session = await session_manager.get_session(session_id=session["id"]) async def test_pending_kernel(): session_manager = SessionManager( kernel_manager=SlowStartingKernelsMKM(), contents_manager=ContentsManager() ) # Create a session with a slow starting kernel fut = session_manager.create_session( path="/path/to/test.ipynb", kernel_name=KernelName("python"), type="notebook" ) task = asyncio.create_task(fut) await asyncio.sleep(0.1) assert len(session_manager._pending_sessions) == 1 # Get a handle on the record record = session_manager._pending_sessions._records[0] session = await task # Check that record is cleared after the task has completed. assert record not in session_manager._pending_sessions # Check pending kernel list when sessions are fut = session_manager.delete_session(session_id=session["id"]) task = asyncio.create_task(fut) await asyncio.sleep(0.1) assert len(session_manager._pending_sessions) == 1 # Get a handle on the record record = session_manager._pending_sessions._records[0] session = await task # Check that record is cleared after the task has completed. assert record not in session_manager._pending_sessions # Test multiple, parallel pending kernels fut1 = session_manager.create_session( path="/path/to/test.ipynb", kernel_name=KernelName("python"), type="notebook" ) fut2 = session_manager.create_session( path="/path/to/test.ipynb", kernel_name=KernelName("python"), type="notebook" ) task1 = asyncio.create_task(fut1) await asyncio.sleep(0.1) task2 = asyncio.create_task(fut2) await asyncio.sleep(0.1) assert len(session_manager._pending_sessions) == 2 await task1 await task2 session1, session2 = await asyncio.gather(task1, task2) assert len(session_manager._pending_sessions) == 0 jupyter-server-jupyter_server-e5c7e2b/tests/test_config_manager.py000066400000000000000000000034321473126534200260270ustar00rootroot00000000000000import json import os from jupyter_server.config_manager import BaseJSONConfigManager def test_json(tmp_path): tmpdir = str(tmp_path) root_data = dict(a=1, x=2, nest={"a": 1, "x": 2}) with open(os.path.join(tmpdir, "foo.json"), "w") as f: json.dump(root_data, f) # also make a foo.d/ directory with multiple json files os.makedirs(os.path.join(tmpdir, "foo.d")) with open(os.path.join(tmpdir, "foo.d", "a.json"), "w") as f: json.dump(dict(a=2, b=1, nest={"a": 2, "b": 1}), f) with open(os.path.join(tmpdir, "foo.d", "b.json"), "w") as f: json.dump(dict(a=3, b=2, c=3, nest={"a": 3, "b": 2, "c": 3}, only_in_b={"x": 1}), f) manager = BaseJSONConfigManager(config_dir=tmpdir, read_directory=False) data = manager.get("foo") assert "a" in data assert "x" in data assert "b" not in data assert "c" not in data assert data["a"] == 1 assert "x" in data["nest"] # if we write it out, it also shouldn't pick up the subdirectoy manager.set("foo", data) data = manager.get("foo") assert data == root_data manager = BaseJSONConfigManager(config_dir=tmpdir, read_directory=True) data = manager.get("foo") assert "a" in data assert "b" in data assert "c" in data # files should be read in order foo.d/a.json foo.d/b.json foo.json assert data["a"] == 1 assert data["b"] == 2 assert data["c"] == 3 assert data["nest"]["a"] == 1 assert data["nest"]["b"] == 2 assert data["nest"]["c"] == 3 assert data["nest"]["x"] == 2 # when writing out, we don't want foo.d/*.json data to be included in the root foo.json manager.set("foo", data) manager = BaseJSONConfigManager(config_dir=tmpdir, read_directory=False) data = manager.get("foo") assert data == root_data jupyter-server-jupyter_server-e5c7e2b/tests/test_files.py000066400000000000000000000175701473126534200242020ustar00rootroot00000000000000import json import os from pathlib import Path from unittest.mock import patch import pytest from nbformat import writes from nbformat.v4 import new_code_cell, new_markdown_cell, new_notebook, new_output from tornado.httpclient import HTTPClientError from .utils import expected_http_error @pytest.fixture( params=[ "jupyter_server.files.handlers.FilesHandler", "jupyter_server.base.handlers.AuthenticatedFileHandler", ] ) def jp_argv(request): return ["--ContentsManager.files_handler_class=" + request.param] @pytest.fixture( params=[ [False, ["å b"]], [False, ["å b", "ç. d"]], [True, [".å b"]], [True, ["å b", ".ç d"]], ] ) def maybe_hidden(request): return request.param async def fetch_expect_200(jp_fetch, *path_parts): r = await jp_fetch("files", *path_parts, method="GET") assert r.body.decode() == path_parts[-1], (path_parts, r.body) async def fetch_expect_error(jp_fetch, code, *path_parts): with pytest.raises(HTTPClientError) as e: await jp_fetch("files", *path_parts, method="GET") assert expected_http_error(e, code), [path_parts, e] async def fetch_expect_404(jp_fetch, *path_parts): return await fetch_expect_error(jp_fetch, 404, *path_parts) async def test_file_types(jp_fetch, jp_root_dir): path = Path(jp_root_dir, "test") path.mkdir(parents=True, exist_ok=True) foos = ["foo.tar.gz", "foo.bz", "foo.foo"] for foo in foos: (path / foo).write_text(foo) await fetch_expect_200(jp_fetch, "test", foo) async def test_hidden_files(jp_fetch, jp_serverapp, jp_root_dir, maybe_hidden): is_hidden, path_parts = maybe_hidden path = Path(jp_root_dir, *path_parts) path.mkdir(parents=True, exist_ok=True) foos = ["foo", ".foo"] for foo in foos: (path / foo).write_text(foo) if is_hidden: for foo in foos: await fetch_expect_404(jp_fetch, *path_parts, foo) else: await fetch_expect_404(jp_fetch, *path_parts, ".foo") await fetch_expect_200(jp_fetch, *path_parts, "foo") jp_serverapp.contents_manager.allow_hidden = True for foo in foos: await fetch_expect_200(jp_fetch, *path_parts, foo) @patch( "jupyter_core.paths.is_hidden", side_effect=AssertionError("Should not call is_hidden if not important"), ) @patch( "jupyter_server.services.contents.filemanager.is_hidden", side_effect=AssertionError("Should not call is_hidden if not important"), ) @patch( "jupyter_server.base.handlers.is_hidden", side_effect=AssertionError("Should not call is_hidden if not important"), ) async def test_regression_is_hidden(m1, m2, m3, jp_fetch, jp_serverapp, jp_root_dir): path_parts = [".hidden", "foo"] path = Path(jp_root_dir, *path_parts) path.mkdir(parents=True, exist_ok=True) foos = ["foo", ".foo"] for foo in foos: (path / foo).write_text(foo) jp_serverapp.contents_manager.allow_hidden = True for foo in foos: await fetch_expect_200(jp_fetch, *path_parts, foo) jp_serverapp.contents_manager.allow_hidden = False for foo in foos: await fetch_expect_error(jp_fetch, 500, *path_parts, foo) async def test_contents_manager(jp_fetch, jp_serverapp, jp_root_dir): """make sure ContentsManager returns right files (ipynb, bin, txt). Also test save file hooks.""" nb = new_notebook( cells=[ new_markdown_cell("Created by test ³"), new_code_cell( "print(2*6)", outputs=[ new_output("stream", text="12"), ], ), ] ) jp_root_dir.joinpath("testnb.ipynb").write_text(writes(nb, version=4), encoding="utf-8") jp_root_dir.joinpath("test.bin").write_bytes(b"\xff" + os.urandom(5)) jp_root_dir.joinpath("test.txt").write_text("foobar") r = await jp_fetch("files/testnb.ipynb", method="GET") assert r.code == 200 assert "print(2*6)" in r.body.decode("utf-8") r = await jp_fetch("files/test.bin", method="GET") assert r.code == 200 assert r.headers["content-type"] == "application/octet-stream" assert r.body[:1] == b"\xff" assert len(r.body) == 6 r = await jp_fetch("files/test.txt", method="GET") assert r.code == 200 assert "text/plain" in r.headers["content-type"] assert r.body.decode() == "foobar" async def test_save_hooks(jp_fetch, jp_serverapp): # define a first pre-save hook that will change the content of the file before saving def pre_save_hook1(model, **kwargs): model["content"] += " was modified" # define a second pre-save hook that will change the content of the file before saving def pre_save_hook2(model, **kwargs): model["content"] += " twice!" # define a first post-save hook that will change the 'last_modified' date def post_save_hook1(model, **kwargs): model["last_modified"] = "yesterday" # define a second post-save hook that will change the 'last_modified' date def post_save_hook2(model, **kwargs): model["last_modified"] += " or tomorrow!" # register the pre-save hooks jp_serverapp.contents_manager.register_pre_save_hook(pre_save_hook1) jp_serverapp.contents_manager.register_pre_save_hook(pre_save_hook2) # register the post-save hooks jp_serverapp.contents_manager.register_post_save_hook(post_save_hook1) jp_serverapp.contents_manager.register_post_save_hook(post_save_hook2) # send a request to save a file, with an original content # the 'last_modified' returned model field should have been modified by post_save_hook1 then post_save_hook2 r = await jp_fetch( "api/contents/test.txt", method="PUT", body=json.dumps( {"format": "text", "path": "test.txt", "type": "file", "content": "original content"} ), ) assert json.loads(r.body.decode())["last_modified"] == "yesterday or tomorrow!" # read the file back # the original content should have been modified by pre_save_hook1 then pre_save_hook2 r = await jp_fetch("files/test.txt", method="GET") assert r.body.decode() == "original content was modified twice!" async def test_download(jp_fetch, jp_serverapp, jp_root_dir): text = "hello" jp_root_dir.joinpath("test.txt").write_text(text) r = await jp_fetch("files", "test.txt", method="GET") disposition = r.headers.get("Content-Disposition", "") assert "attachment" not in disposition r = await jp_fetch("files", "test.txt", method="GET", params={"download": True}) disposition = r.headers.get("Content-Disposition", "") assert "attachment" in disposition assert "filename*=utf-8''test.txt" in disposition async def test_old_files_redirect(jp_fetch, jp_serverapp, jp_root_dir): """pre-2.0 'files/' prefixed links are properly redirected""" jp_root_dir.joinpath("files").mkdir(parents=True, exist_ok=True) jp_root_dir.joinpath("sub", "files").mkdir(parents=True, exist_ok=True) for prefix in ("", "sub"): jp_root_dir.joinpath(prefix, "files", "f1.txt").write_text(prefix + "/files/f1") jp_root_dir.joinpath(prefix, "files", "f2.txt").write_text(prefix + "/files/f2") jp_root_dir.joinpath(prefix, "f2.txt").write_text(prefix + "/f2") jp_root_dir.joinpath(prefix, "f3.txt").write_text(prefix + "/f3") # These depend on the tree handlers # # def test_download(self): # rootdir = self.root_dir # text = 'hello' # with open(pjoin(rootdir, 'test.txt'), 'w') as f: # f.write(text) # r = self.request('GET', 'files/test.txt') # disposition = r.headers.get('Content-Disposition', '') # self.assertNotIn('attachment', disposition) # r = self.request('GET', 'files/test.txt?download=1') # disposition = r.headers.get('Content-Disposition', '') # assert 'attachment' in disposition # assert "filename*=utf-8''test.txt" in disposition jupyter-server-jupyter_server-e5c7e2b/tests/test_gateway.py000066400000000000000000001016201473126534200245270ustar00rootroot00000000000000"""Test GatewayClient""" import asyncio import json import logging import os import uuid from datetime import datetime, timedelta, timezone from email.utils import format_datetime from http.cookies import SimpleCookie from io import BytesIO from queue import Empty from typing import Any, Union from unittest.mock import MagicMock, patch import pytest import tornado from jupyter_core.utils import ensure_async from tornado.concurrent import Future from tornado.httpclient import HTTPRequest, HTTPResponse from tornado.httputil import HTTPServerRequest from tornado.queues import Queue from tornado.web import HTTPError from traitlets import Int, Unicode from traitlets.config import Config from jupyter_server.gateway.connections import GatewayWebSocketConnection from jupyter_server.gateway.gateway_client import GatewayTokenRenewerBase, NoOpTokenRenewer from jupyter_server.gateway.managers import ChannelQueue, GatewayClient, GatewayKernelManager from jupyter_server.services.kernels.websocket import KernelWebsocketHandler from .utils import expected_http_error pytest_plugins = ["jupyter_events.pytest_plugin"] def generate_kernelspec(name): argv_stanza = ["python", "-m", "ipykernel_launcher", "-f", "{connection_file}"] spec_stanza = { "spec": { "argv": argv_stanza, "env": {}, "display_name": name, "language": "python", "interrupt_mode": "signal", "metadata": {}, } } kernelspec_stanza = { "name": name, "spec": spec_stanza, "resources": { "logo-64x64": f"f/kernelspecs/{name}/logo-64x64.png", "url": "https://example.com/example-url", }, } return kernelspec_stanza # We'll mock up two kernelspecs - kspec_foo and kspec_bar kernelspecs: dict = { "default": "kspec_foo", "kernelspecs": { "kspec_foo": generate_kernelspec("kspec_foo"), "kspec_bar": generate_kernelspec("kspec_bar"), }, } # maintain a dictionary of expected running kernels. Key = kernel_id, Value = model. running_kernels = {} # Dictionary of kernels to transiently omit from list results. # # This is used to simulate inconsistency in list results from the Gateway server # due to issues like race conditions, bugs, etc. omitted_kernels: dict[str, bool] = {} def generate_model(name): """Generate a mocked kernel model. Caller is responsible for adding model to running_kernels dictionary.""" dt = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") kernel_id = str(uuid.uuid4()) model = { "id": kernel_id, "name": name, "last_activity": str(dt), "execution_state": "idle", "connections": 1, } return model async def mock_gateway_request(url, **kwargs): method = "GET" if kwargs["method"]: method = kwargs["method"] request = HTTPRequest(url=url, **kwargs) endpoint = str(url) # Fetch all kernelspecs if endpoint.endswith("/api/kernelspecs") and method == "GET": response_buf = BytesIO(json.dumps(kernelspecs).encode("utf-8")) response = await ensure_async(HTTPResponse(request, 200, buffer=response_buf)) return response # Fetch named kernelspec if endpoint.rfind("/api/kernelspecs/") >= 0 and method == "GET": requested_kernelspec = endpoint.rpartition("/")[2] kspecs: dict = kernelspecs["kernelspecs"] if requested_kernelspec in kspecs: response_str = json.dumps(kspecs.get(requested_kernelspec)) response_buf = BytesIO(response_str.encode("utf-8")) response = await ensure_async(HTTPResponse(request, 200, buffer=response_buf)) return response else: raise HTTPError(404, message="Kernelspec does not exist: %s" % requested_kernelspec) # Fetch kernelspec asset if endpoint.rfind("/kernelspecs/") >= 0 and method == "GET": response_buf = BytesIO(b"foo") response = await ensure_async(HTTPResponse(request, 200, buffer=response_buf)) return response # Create kernel if endpoint.endswith("/api/kernels") and method == "POST": json_body = json.loads(kwargs["body"]) name = json_body.get("name") env = json_body.get("env") kspec_name = env.get("KERNEL_KSPEC_NAME") assert name == kspec_name # Ensure that KERNEL_ env values get propagated # Verify env propagation is well-behaved... assert "FOO" in env assert "BAR" in env assert "BAZ" not in env model = generate_model(name) running_kernels[model.get("id")] = model # Register model as a running kernel response_buf = BytesIO(json.dumps(model).encode("utf-8")) response = await ensure_async(HTTPResponse(request, 201, buffer=response_buf)) return response # Fetch list of running kernels if endpoint.endswith("/api/kernels") and method == "GET": kernels = [] for kernel_id in running_kernels: if kernel_id in omitted_kernels: omitted_kernels.pop(kernel_id) else: model = running_kernels.get(kernel_id) kernels.append(model) response_buf = BytesIO(json.dumps(kernels).encode("utf-8")) response = await ensure_async(HTTPResponse(request, 200, buffer=response_buf)) return response # Interrupt or restart existing kernel if endpoint.rfind("/api/kernels/") >= 0 and method == "POST": requested_kernel_id, sep, action = endpoint.rpartition("/api/kernels/")[2].rpartition("/") if action == "interrupt": if requested_kernel_id in running_kernels: response = await ensure_async(HTTPResponse(request, 204)) return response else: raise HTTPError(404, message="Kernel does not exist: %s" % requested_kernel_id) elif action == "restart": if requested_kernel_id in running_kernels: response_str = json.dumps(running_kernels.get(requested_kernel_id)) response_buf = BytesIO(response_str.encode("utf-8")) response = await ensure_async(HTTPResponse(request, 204, buffer=response_buf)) return response else: raise HTTPError(404, message="Kernel does not exist: %s" % requested_kernel_id) else: raise HTTPError(404, message="Bad action detected: %s" % action) # Shutdown existing kernel if endpoint.rfind("/api/kernels/") >= 0 and method == "DELETE": requested_kernel_id = endpoint.rpartition("/")[2] if requested_kernel_id not in running_kernels: raise HTTPError(404, message="Kernel does not exist: %s" % requested_kernel_id) running_kernels.pop( requested_kernel_id ) # Simulate shutdown by removing kernel from running set response = await ensure_async(HTTPResponse(request, 204)) return response # Fetch existing kernel if endpoint.rfind("/api/kernels/") >= 0 and method == "GET": requested_kernel_id = endpoint.rpartition("/")[2] if requested_kernel_id in running_kernels: response_str = json.dumps(running_kernels.get(requested_kernel_id)) response_buf = BytesIO(response_str.encode("utf-8")) response = await ensure_async(HTTPResponse(request, 200, buffer=response_buf)) return response else: raise HTTPError(404, message="Kernel does not exist: %s" % requested_kernel_id) mocked_gateway = patch("jupyter_server.gateway.managers.gateway_request", mock_gateway_request) mock_gateway_ws_url = "ws://mock-gateway-server:8889" mock_gateway_url = "http://mock-gateway-server:8889" mock_http_user = "alice" def mock_websocket_create_connection(recv_side_effect=None): def helper(*args, **kwargs): mock = MagicMock() mock.recv = MagicMock(side_effect=recv_side_effect) return mock return helper class CustomTestTokenRenewer(GatewayTokenRenewerBase): # type:ignore[misc] TEST_EXPECTED_TOKEN_VALUE = "Use this token value: 42" # The following are configured by the config test to ensure they flow # configured to: 42 config_var_1: int = Int(config=True) # type:ignore[assignment] # configured to: "Use this token value: " config_var_2: str = Unicode(config=True) # type:ignore[assignment] def get_token( self, auth_header_key: str, auth_scheme: Union[str, None], auth_token: str, **kwargs: Any ) -> str: return f"{self.config_var_2}{self.config_var_1}" @pytest.fixture def jp_server_config(): return Config( {"CustomTestTokenRenewer": {"config_var_1": 42, "config_var_2": "Use this token value: "}} ) @pytest.fixture def init_gateway(monkeypatch): """Initializes the server for use as a gateway client.""" # Clear the singleton first since previous tests may not have used a gateway. GatewayClient.clear_instance() monkeypatch.setenv("JUPYTER_GATEWAY_URL", mock_gateway_url) monkeypatch.setenv("JUPYTER_GATEWAY_HTTP_USER", mock_http_user) monkeypatch.setenv("JUPYTER_GATEWAY_REQUEST_TIMEOUT", "44.4") monkeypatch.setenv("JUPYTER_GATEWAY_CONNECT_TIMEOUT", "44.4") monkeypatch.setenv("JUPYTER_GATEWAY_LAUNCH_TIMEOUT_PAD", "1.1") monkeypatch.setenv("JUPYTER_GATEWAY_ACCEPT_COOKIES", "false") monkeypatch.setenv("JUPYTER_GATEWAY_ENV_WHITELIST", "FOO,BAR") monkeypatch.setenv("FOO", "foo") monkeypatch.setenv("BAR", "bar") monkeypatch.setenv("BAZ", "baz") yield GatewayClient.clear_instance() async def test_gateway_env_options(init_gateway, jp_serverapp): assert jp_serverapp.gateway_config.gateway_enabled is True assert jp_serverapp.gateway_config.url == mock_gateway_url assert jp_serverapp.gateway_config.http_user == mock_http_user assert ( jp_serverapp.gateway_config.connect_timeout == jp_serverapp.gateway_config.request_timeout ) assert jp_serverapp.gateway_config.connect_timeout == 44.4 assert jp_serverapp.gateway_config.launch_timeout_pad == 1.1 assert jp_serverapp.gateway_config.accept_cookies is False assert jp_serverapp.gateway_config.allowed_envs == "FOO,BAR" GatewayClient.instance().init_connection_args() assert GatewayClient.instance().KERNEL_LAUNCH_TIMEOUT == 43 def test_gateway_cli_options(jp_configurable_serverapp, capsys): argv = [ "--gateway-url=" + mock_gateway_url, "--GatewayClient.http_user=" + mock_http_user, "--GatewayClient.connect_timeout=44.4", "--GatewayClient.request_timeout=96.0", "--GatewayClient.launch_timeout_pad=5.1", "--GatewayClient.env_whitelist=FOO,BAR", ] GatewayClient.clear_instance() app = jp_configurable_serverapp(argv=argv) assert app.gateway_config.gateway_enabled is True assert app.gateway_config.url == mock_gateway_url assert app.gateway_config.http_user == mock_http_user assert app.gateway_config.connect_timeout == 44.4 assert app.gateway_config.request_timeout == 96.0 assert app.gateway_config.launch_timeout_pad == 5.1 assert app.gateway_config.gateway_token_renewer_class == NoOpTokenRenewer assert app.gateway_config.allowed_envs == "FOO,BAR" captured = capsys.readouterr() assert ( "env_whitelist is deprecated in jupyter_server 2.0, use GatewayClient.allowed_envs" in captured.err ) gw_client = GatewayClient.instance() gw_client.init_connection_args() assert ( gw_client.KERNEL_LAUNCH_TIMEOUT == 90 ) # Ensure KLT gets set from request-timeout - launch_timeout_pad GatewayClient.clear_instance() @pytest.mark.parametrize( "renewer_type,initial_auth_token", [("default", ""), ("custom", None), ("custom", "")] ) def test_token_renewer_config( jp_server_config, jp_configurable_serverapp, renewer_type, initial_auth_token ): argv = ["--gateway-url=" + mock_gateway_url] if renewer_type == "custom": argv.append( "--GatewayClient.gateway_token_renewer_class=tests.test_gateway.CustomTestTokenRenewer" ) if initial_auth_token is None: argv.append("--GatewayClient.auth_token=None") GatewayClient.clear_instance() app = jp_configurable_serverapp(argv=argv) assert app.gateway_config.gateway_enabled is True assert app.gateway_config.url == mock_gateway_url gw_client = GatewayClient.instance() gw_client.init_connection_args() assert isinstance(gw_client.gateway_token_renewer, GatewayTokenRenewerBase) if renewer_type == "default": assert isinstance(gw_client.gateway_token_renewer, NoOpTokenRenewer) token = gw_client.gateway_token_renewer.get_token( gw_client.auth_header_key, gw_client.auth_scheme, gw_client.auth_token or "" ) assert token == gw_client.auth_token else: assert isinstance(gw_client.gateway_token_renewer, CustomTestTokenRenewer) token = gw_client.gateway_token_renewer.get_token( gw_client.auth_header_key, gw_client.auth_scheme, gw_client.auth_token or "" ) assert token == CustomTestTokenRenewer.TEST_EXPECTED_TOKEN_VALUE gw_client.load_connection_args() if renewer_type == "default" or initial_auth_token is None: assert gw_client.auth_token == initial_auth_token else: assert gw_client.auth_token == CustomTestTokenRenewer.TEST_EXPECTED_TOKEN_VALUE @pytest.mark.parametrize( "request_timeout,kernel_launch_timeout,expected_request_timeout,expected_kernel_launch_timeout", [(50, 10, 50, 45), (10, 50, 55, 50)], ) def test_gateway_request_timeout_pad_option( jp_configurable_serverapp, monkeypatch, request_timeout, kernel_launch_timeout, expected_request_timeout, expected_kernel_launch_timeout, ): argv = [ f"--GatewayClient.request_timeout={request_timeout}", "--GatewayClient.launch_timeout_pad=5", ] GatewayClient.clear_instance() app = jp_configurable_serverapp(argv=argv) monkeypatch.setattr(GatewayClient, "KERNEL_LAUNCH_TIMEOUT", kernel_launch_timeout) GatewayClient.instance().init_connection_args() assert app.gateway_config.request_timeout == expected_request_timeout assert expected_kernel_launch_timeout == GatewayClient.instance().KERNEL_LAUNCH_TIMEOUT GatewayClient.clear_instance() @pytest.mark.parametrize( "accept_cookies,expire_arg,expire_param,existing_cookies,cookie_exists", [ (False, None, None, "EXISTING=1", False), (True, None, None, "EXISTING=1", True), (True, "Expires", 180, None, True), (True, "Max-Age", "-360", "EXISTING=1", False), ], ) def test_gateway_request_with_expiring_cookies( jp_configurable_serverapp, accept_cookies, expire_arg, expire_param, existing_cookies, cookie_exists, ): argv = [f"--GatewayClient.accept_cookies={accept_cookies}"] GatewayClient.clear_instance() _ = jp_configurable_serverapp(argv=argv) cookie: SimpleCookie = SimpleCookie() cookie.load("SERVERID=1234567; Path=/") if expire_arg == "Expires": expire_param = format_datetime( datetime.now(tz=timezone.utc) + timedelta(seconds=expire_param) ) if expire_arg: cookie["SERVERID"][expire_arg] = expire_param GatewayClient.instance().update_cookies(cookie) args = {} if existing_cookies: args["headers"] = {"Cookie": existing_cookies} connection_args = GatewayClient.instance().load_connection_args(**args) if not cookie_exists: assert "SERVERID" not in (connection_args["headers"].get("Cookie") or "") else: assert "SERVERID" in connection_args["headers"].get("Cookie") if existing_cookies: assert "EXISTING" in connection_args["headers"].get("Cookie") GatewayClient.clear_instance() async def test_gateway_class_mappings(init_gateway, jp_serverapp): # Ensure appropriate class mappings are in place. assert jp_serverapp.kernel_manager_class.__name__ == "GatewayMappingKernelManager" assert jp_serverapp.session_manager_class.__name__ == "GatewaySessionManager" assert jp_serverapp.kernel_spec_manager_class.__name__ == "GatewayKernelSpecManager" async def test_gateway_get_kernelspecs(init_gateway, jp_fetch, jp_serverapp): # Validate that kernelspecs come from gateway. with mocked_gateway: r = await jp_fetch("api", "kernelspecs", method="GET") assert r.code == 200 content = json.loads(r.body.decode("utf-8")) kspecs = content.get("kernelspecs") assert len(kspecs) == 2 assert kspecs.get("kspec_bar").get("name") == "kspec_bar" assert ( kspecs.get("kspec_bar").get("resources")["logo-64x64"].startswith(jp_serverapp.base_url) ) async def test_gateway_get_named_kernelspec(init_gateway, jp_fetch): # Validate that a specific kernelspec can be retrieved from gateway (and an invalid spec can't) with mocked_gateway: r = await jp_fetch("api", "kernelspecs", "kspec_foo", method="GET") assert r.code == 200 kspec_foo = json.loads(r.body.decode("utf-8")) assert kspec_foo.get("name") == "kspec_foo" r = await jp_fetch("kernelspecs", "kspec_foo", "logo-64x64.png", method="GET") assert r.code == 200 assert r.body == b"foo" assert r.headers["content-type"] == "image/png" with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch("api", "kernelspecs", "no_such_spec", method="GET") assert expected_http_error(e, 404) @pytest.mark.parametrize("cull_kernel", [False, True]) async def test_gateway_session_lifecycle(init_gateway, jp_root_dir, jp_fetch, cull_kernel): # Validate session lifecycle functions; create and delete. # create session_id, kernel_id = await create_session(jp_fetch, "kspec_foo") # ensure kernel still considered running assert await is_session_active(jp_fetch, session_id) is True # interrupt await interrupt_kernel(jp_fetch, kernel_id) # ensure kernel still considered running assert await is_session_active(jp_fetch, session_id) is True # restart await restart_kernel(jp_fetch, kernel_id) assert await is_session_active(jp_fetch, session_id) is True omitted_kernels[kernel_id] = True if cull_kernel: running_kernels.pop(kernel_id) # fetch kernel and session and ensure not considered running assert await is_kernel_running(jp_fetch, kernel_id) is not cull_kernel assert await is_session_active(jp_fetch, session_id) is not cull_kernel # delete session. If culled, ensure 404 is raised if cull_kernel: with pytest.raises(tornado.httpclient.HTTPClientError) as e: await delete_session(jp_fetch, session_id) assert expected_http_error(e, 404) else: await delete_session(jp_fetch, session_id) assert await is_session_active(jp_fetch, session_id) is False @pytest.mark.parametrize("cull_kernel", [False, True]) async def test_gateway_kernel_lifecycle( init_gateway, jp_configurable_serverapp, jp_read_emitted_events, jp_event_handler, jp_ws_fetch, jp_fetch, cull_kernel, ): # Validate kernel lifecycle functions; create, interrupt, restart and delete. app = jp_configurable_serverapp() app.event_logger.register_handler(jp_event_handler) # create kernel_id = await create_kernel(jp_fetch, "kspec_bar") output = jp_read_emitted_events()[0] assert "action" in output and output["action"] == "start" assert "msg" in output assert "kernel_id" in output and kernel_id == output["kernel_id"] assert "status" in output and output["status"] == "success" # ensure kernel still considered running assert await is_kernel_running(jp_fetch, kernel_id) is True ws = await jp_ws_fetch("api", "kernels", kernel_id, "channels") ws.ping() ws.write_message(b"hi") ws.on_message(b"hi") ws.close() # interrupt await interrupt_kernel(jp_fetch, kernel_id) output = jp_read_emitted_events()[0] assert "action" in output and output["action"] == "interrupt" assert "msg" in output assert "kernel_id" in output and kernel_id == output["kernel_id"] assert "status" in output and output["status"] == "success" # ensure kernel still considered running assert await is_kernel_running(jp_fetch, kernel_id) is True # restart await restart_kernel(jp_fetch, kernel_id) output = jp_read_emitted_events()[0] assert "action" in output and output["action"] == "restart" assert "msg" in output assert "kernel_id" in output and kernel_id == output["kernel_id"] assert "status" in output and output["status"] == "success" # ensure kernel still considered running assert await is_kernel_running(jp_fetch, kernel_id) is True omitted_kernels[kernel_id] = True if cull_kernel: running_kernels.pop(kernel_id) # fetch kernel and session and ensure not considered running assert await is_kernel_running(jp_fetch, kernel_id) is not cull_kernel # delete kernel. If culled, ensure 404 is raised if cull_kernel: with pytest.raises(tornado.httpclient.HTTPClientError) as e: await delete_kernel(jp_fetch, kernel_id) assert expected_http_error(e, 404) else: await delete_kernel(jp_fetch, kernel_id) output = jp_read_emitted_events()[0] assert "action" in output and output["action"] == "shutdown" assert "msg" in output assert "kernel_id" in output and kernel_id == output["kernel_id"] assert "status" in output and output["status"] == "success" assert await is_kernel_running(jp_fetch, kernel_id) is False @pytest.mark.parametrize("missing_kernel", [True, False]) async def test_gateway_shutdown(init_gateway, jp_serverapp, jp_fetch, missing_kernel): # Validate server shutdown when multiple gateway kernels are present or # we've lost track of at least one (missing) kernel # create two kernels k1 = await create_kernel(jp_fetch, "kspec_bar") k2 = await create_kernel(jp_fetch, "kspec_bar") # ensure they're considered running assert await is_kernel_running(jp_fetch, k1) is True assert await is_kernel_running(jp_fetch, k2) is True if missing_kernel: running_kernels.pop(k1) # "terminate" kernel w/o our knowledge with mocked_gateway: await jp_serverapp.kernel_manager.shutdown_all() assert await is_kernel_running(jp_fetch, k1) is False assert await is_kernel_running(jp_fetch, k2) is False @patch("websocket.create_connection", mock_websocket_create_connection(recv_side_effect=Exception)) async def test_kernel_client_response_router_notifies_channel_queue_when_finished( init_gateway, jp_serverapp, jp_fetch ): # create kernel_id = await create_kernel(jp_fetch, "kspec_bar") # get kernel manager km: GatewayKernelManager = jp_serverapp.kernel_manager.get_kernel(kernel_id) # create kernel client kc = km.client() await ensure_async(kc.start_channels()) with pytest.raises(RuntimeError): await kc.iopub_channel.get_msg(timeout=10) all_channels = [ kc.shell_channel, kc.iopub_channel, kc.stdin_channel, kc.hb_channel, kc.control_channel, ] assert all(channel.response_router_finished for channel in all_channels) await ensure_async(kc.stop_channels()) # delete await delete_kernel(jp_fetch, kernel_id) async def test_channel_queue_get_msg_with_invalid_timeout(): queue = ChannelQueue("iopub", MagicMock(), logging.getLogger()) with pytest.raises(ValueError): await queue.get_msg(timeout=-1) async def test_channel_queue_get_msg_raises_empty_after_timeout(): queue = ChannelQueue("iopub", MagicMock(), logging.getLogger()) with pytest.raises(Empty): await asyncio.wait_for(queue.get_msg(timeout=0.1), 2) async def test_channel_queue_get_msg_without_timeout(): queue = ChannelQueue("iopub", MagicMock(), logging.getLogger()) with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(queue.get_msg(timeout=None), 1) async def test_channel_queue_get_msg_with_existing_item(): sent_message = {"msg_id": 1, "msg_type": 2} queue = ChannelQueue("iopub", MagicMock(), logging.getLogger()) queue.put_nowait(sent_message) received_message = await asyncio.wait_for(queue.get_msg(timeout=None), 1) assert received_message == sent_message async def test_channel_queue_get_msg_when_response_router_had_finished(): queue = ChannelQueue("iopub", MagicMock(), logging.getLogger()) queue.response_router_finished = True with pytest.raises(RuntimeError): await queue.get_msg() class MockWebSocketClientConnection(tornado.websocket.WebSocketClientConnection): def __init__(self, *args, **kwargs): self._msgs: Queue = Queue(2) self._msgs.put_nowait('{"msg_type": "status", "content": {"execution_state": "starting"}}') def write_message(self, message, *args, **kwargs): return self._msgs.put(message) def read_message(self, *args, **kwargs): return self._msgs.get() def mock_websocket_connect(): def helper(request): fut: Future = Future() mock_client = MockWebSocketClientConnection() fut.set_result(mock_client) return fut return helper @patch("tornado.websocket.websocket_connect", mock_websocket_connect()) async def test_websocket_connection_closed(init_gateway, jp_serverapp, jp_fetch, caplog): # Create the kernel and get the kernel manager... kernel_id = await create_kernel(jp_fetch, "kspec_foo") km: GatewayKernelManager = jp_serverapp.kernel_manager.get_kernel(kernel_id) # Create the KernelWebsocketHandler... request = HTTPServerRequest("foo", "GET") request.connection = MagicMock() handler = KernelWebsocketHandler(jp_serverapp.web_app, request) # Force the websocket handler to raise a closed error if we try to write a message # to the client. handler.ws_connection = MagicMock() handler.ws_connection.is_closing = lambda: True # Create the GatewayWebSocketConnection and attach it to the handler... with mocked_gateway: conn = GatewayWebSocketConnection(parent=km, websocket_handler=handler) handler.connection = conn await conn.connect() # Processing websocket messages happens in separate coroutines and any # errors in that process will show up in logs, but not bubble up to the # caller. # # To check for these, we wait for the server to stop and then check the # logs for errors. await jp_serverapp._cleanup() for _, level, message in caplog.record_tuples: if level >= logging.ERROR: pytest.fail(f"Logs contain an error: {message}") @patch("tornado.websocket.websocket_connect", mock_websocket_connect()) async def test_websocket_connection_with_session_id(init_gateway, jp_serverapp, jp_fetch, caplog): # Create the session and kernel and get the kernel manager... kernel_id = await create_kernel(jp_fetch, "kspec_foo") km: GatewayKernelManager = jp_serverapp.kernel_manager.get_kernel(kernel_id) # Create the KernelWebsocketHandler... request = HTTPServerRequest("foo", "GET") request.connection = MagicMock() handler = KernelWebsocketHandler(jp_serverapp.web_app, request) # Create the GatewayWebSocketConnection and attach it to the handler... with mocked_gateway: conn = GatewayWebSocketConnection(parent=km, websocket_handler=handler) handler.connection = conn await conn.connect() assert conn.session_id != None expected_ws_url = ( f"{mock_gateway_ws_url}/api/kernels/{kernel_id}/channels?session_id={conn.session_id}" ) assert ( expected_ws_url in caplog.text ), "WebSocket URL does not contain the expected session_id." # Processing websocket messages happens in separate coroutines and any # errors in that process will show up in logs, but not bubble up to the # caller. # # To check for these, we wait for the server to stop and then check the # logs for errors. await jp_serverapp._cleanup() for _, level, message in caplog.record_tuples: if level >= logging.ERROR: pytest.fail(f"Logs contain an error: {message}") # # Test methods below... # async def is_session_active(jp_fetch, session_id): """Issues request to get the set of running kernels""" with mocked_gateway: # Get list of running kernels r = await jp_fetch("api", "sessions", method="GET") assert r.code == 200 sessions = json.loads(r.body.decode("utf-8")) assert len(sessions) == len(running_kernels) # Use running_kernels as truth return any(model.get("id") == session_id for model in sessions) async def create_session(jp_fetch, kernel_name): """Creates a session for a kernel. The session is created against the server which then uses the gateway for kernel management. """ with mocked_gateway: nb_path = "/testgw.ipynb" body = json.dumps( {"path": str(nb_path), "type": "notebook", "kernel": {"name": kernel_name}} ) # add a KERNEL_ value to the current env and we'll ensure that that value exists in the mocked method os.environ["KERNEL_KSPEC_NAME"] = kernel_name # Create the kernel... (also tests get_kernel) r = await jp_fetch("api", "sessions", method="POST", body=body) assert r.code == 201 model = json.loads(r.body.decode("utf-8")) assert model.get("path") == str(nb_path) kernel_id = model.get("kernel").get("id") # ensure its in the running_kernels and name matches. running_kernel = running_kernels.get(kernel_id) assert running_kernel is not None assert kernel_id == running_kernel.get("id") assert model.get("kernel").get("name") == running_kernel.get("name") session_id = model.get("id") # restore env os.environ.pop("KERNEL_KSPEC_NAME") return session_id, kernel_id async def delete_session(jp_fetch, session_id): """Deletes a session corresponding to the given session id.""" with mocked_gateway: # Delete the session (and kernel) r = await jp_fetch("api", "sessions", session_id, method="DELETE") assert r.code == 204 assert r.reason == "No Content" async def is_kernel_running(jp_fetch, kernel_id): """Issues request to get the set of running kernels""" with mocked_gateway: # Get list of running kernels r = await jp_fetch("api", "kernels", method="GET") assert r.code == 200 kernels = json.loads(r.body.decode("utf-8")) assert len(kernels) == len(running_kernels) return any(model.get("id") == kernel_id for model in kernels) async def create_kernel(jp_fetch, kernel_name): """Issues request to restart the given kernel""" with mocked_gateway: body = json.dumps({"name": kernel_name}) # add a KERNEL_ value to the current env and we'll ensure that that value exists in the mocked method os.environ["KERNEL_KSPEC_NAME"] = kernel_name r = await jp_fetch("api", "kernels", method="POST", body=body) assert r.code == 201 model = json.loads(r.body.decode("utf-8")) kernel_id = model.get("id") # ensure its in the running_kernels and name matches. running_kernel = running_kernels.get(kernel_id) assert running_kernel is not None assert kernel_id == running_kernel.get("id") assert model.get("name") == kernel_name # restore env os.environ.pop("KERNEL_KSPEC_NAME") return kernel_id async def interrupt_kernel(jp_fetch, kernel_id): """Issues request to interrupt the given kernel""" with mocked_gateway: r = await jp_fetch( "api", "kernels", kernel_id, "interrupt", method="POST", allow_nonstandard_methods=True, ) assert r.code == 204 assert r.reason == "No Content" async def restart_kernel(jp_fetch, kernel_id): """Issues request to restart the given kernel""" with mocked_gateway: r = await jp_fetch( "api", "kernels", kernel_id, "restart", method="POST", allow_nonstandard_methods=True, ) assert r.code == 200 model = json.loads(r.body.decode("utf-8")) restarted_kernel_id = model.get("id") # ensure its in the running_kernels and name matches. running_kernel = running_kernels.get(restarted_kernel_id) assert running_kernel is not None assert restarted_kernel_id == running_kernel.get("id") assert model.get("name") == running_kernel.get("name") async def delete_kernel(jp_fetch, kernel_id): """Deletes kernel corresponding to the given kernel id.""" with mocked_gateway: # Delete the session (and kernel) r = await jp_fetch("api", "kernels", kernel_id, method="DELETE") assert r.code == 204 assert r.reason == "No Content" jupyter-server-jupyter_server-e5c7e2b/tests/test_paths.py000066400000000000000000000032441473126534200242100ustar00rootroot00000000000000import re import pytest import tornado from jupyter_server.base.handlers import path_regex from jupyter_server.utils import url_path_join # build regexps that tornado uses: path_pat = re.compile("^" + "/x%s" % path_regex + "$") def test_path_regex(): for path in ( "/x", "/x/", "/x/foo", "/x/foo.ipynb", "/x/foo/bar", "/x/foo/bar.txt", ): assert re.match(path_pat, path) def test_path_regex_bad(): for path in ( "/xfoo", "/xfoo/", "/xfoo/bar", "/xfoo/bar/", "/x/foo/bar/", "/x//foo", "/y", "/y/x/foo", ): assert re.match(path_pat, path) is None @pytest.mark.parametrize( "uri,expected", [ ("/notebooks/mynotebook/", "/notebooks/mynotebook"), ("////foo///", "/foo"), ("//example.com/", "/example.com"), ("/has/param/?hasparam=true", "/has/param?hasparam=true"), ], ) async def test_trailing_slash( uri, expected, http_server_client, jp_auth_header, jp_base_url, ): # http_server_client raises an exception when follow_redirects=False with pytest.raises(tornado.httpclient.HTTPClientError) as err: await http_server_client.fetch( url_path_join(jp_base_url, uri), headers=jp_auth_header, request_timeout=20, follow_redirects=False, ) # Capture the response from the raised exception value. response = err.value.response assert response is not None assert response.code == 302 assert "Location" in response.headers assert response.headers["Location"] == url_path_join(jp_base_url, expected) jupyter-server-jupyter_server-e5c7e2b/tests/test_serialize.py000066400000000000000000000013571473126534200250630ustar00rootroot00000000000000"""Test serialize/deserialize messages with buffers""" import os from jupyter_client.session import Session from jupyter_server.services.kernels.connection.base import ( deserialize_binary_message, serialize_binary_message, ) def test_serialize_binary(): s = Session() msg = s.msg("data_pub", content={"a": "b"}) msg["buffers"] = [memoryview(os.urandom(3)) for i in range(3)] bmsg = serialize_binary_message(msg) assert isinstance(bmsg, bytes) def test_deserialize_binary(): s = Session() msg = s.msg("data_pub", content={"a": "b"}) msg["buffers"] = [memoryview(os.urandom(2)) for i in range(3)] bmsg = serialize_binary_message(msg) msg2 = deserialize_binary_message(bmsg) assert msg2 == msg jupyter-server-jupyter_server-e5c7e2b/tests/test_serverapp.py000066400000000000000000000536571473126534200251150ustar00rootroot00000000000000import getpass import json import logging import os import pathlib import sys import warnings from unittest.mock import patch import pytest from jupyter_core.application import NoStart from tornado import web from traitlets import TraitError from traitlets.config import Config from traitlets.tests.utils import check_help_all_output from jupyter_server.auth.decorator import allow_unauthenticated, authorized from jupyter_server.auth.security import passwd_check from jupyter_server.serverapp import ( JupyterPasswordApp, JupyterServerListApp, ServerApp, ServerWebApplication, _has_tornado_web_authenticated, list_running_servers, random_ports, ) from jupyter_server.services.contents.filemanager import ( AsyncFileContentsManager, FileContentsManager, ) from jupyter_server.utils import pathname2url, urljoin @pytest.fixture(params=[FileContentsManager, AsyncFileContentsManager]) def jp_file_contents_manager_class(request, tmp_path): return request.param def test_help_output(): """jupyter server --help-all works""" check_help_all_output("jupyter_server") @pytest.mark.parametrize( "format", [ "json", "jsonlist", "", ], ) def test_server_list(jp_configurable_serverapp, capsys, format): app = jp_configurable_serverapp(log=logging.getLogger()) app.write_server_info_file() capsys.readouterr() listapp = JupyterServerListApp( parent=app, ) if format: setattr(listapp, format, True) listapp.start() captured = capsys.readouterr() sys.stdout.write(captured.out) sys.stderr.write(captured.err) out = captured.out.strip() if not format: assert "Currently running servers:" in out assert app.connection_url in out assert len(out.splitlines()) == 2 return if format == "jsonlist": servers = json.loads(out) elif format == "json": servers = [json.loads(line) for line in out.splitlines()] assert len(servers) == 1 sinfo = servers[0] assert sinfo["port"] == app.port assert sinfo["url"] == app.connection_url assert sinfo["version"] == app.version def test_server_info_file(tmp_path, jp_configurable_serverapp): app = jp_configurable_serverapp(log=logging.getLogger()) app.write_server_info_file() servers = list(list_running_servers(app.runtime_dir)) assert len(servers) == 1 sinfo = servers[0] assert sinfo["port"] == app.port assert sinfo["url"] == app.connection_url assert sinfo["version"] == app.version app.remove_server_info_file() assert list(list_running_servers(app.runtime_dir)) == [] app.remove_server_info_file() def test_root_dir(tmp_path, jp_configurable_serverapp): app = jp_configurable_serverapp(root_dir=str(tmp_path)) assert app.root_dir == str(tmp_path) # Build a list of invalid paths @pytest.fixture(params=[("notebooks",), ("root", "dir", "is", "missing"), ("test.txt",)]) def invalid_root_dir(tmp_path, request): path = tmp_path.joinpath(*request.param) # If the path is a file, create it. if os.path.splitext(str(path))[1] != "": path.write_text("") return str(path) def test_invalid_root_dir(invalid_root_dir, jp_configurable_serverapp): app = jp_configurable_serverapp() with pytest.raises(TraitError): app.root_dir = invalid_root_dir @pytest.fixture(params=[("/",), ("first-level",), ("first-level", "second-level")]) def valid_root_dir(tmp_path, request): path = tmp_path.joinpath(*request.param) if not path.exists(): # Create path in temporary directory path.mkdir(parents=True) return str(path) def test_valid_root_dir(valid_root_dir, jp_configurable_serverapp): app = jp_configurable_serverapp(root_dir=valid_root_dir) root_dir = valid_root_dir # If nested path, the last slash should # be stripped by the root_dir trait. if root_dir != "/": root_dir = valid_root_dir.rstrip("/") assert app.root_dir == root_dir async def test_generate_config(tmp_path, jp_configurable_serverapp): app = jp_configurable_serverapp(config_dir=str(tmp_path)) app.initialize(["--generate-config", "--allow-root"]) with pytest.raises(NoStart): app.start() assert tmp_path.joinpath("jupyter_server_config.py").exists() def test_server_password(tmp_path, jp_configurable_serverapp): password = "secret" with ( patch.dict("os.environ", {"JUPYTER_CONFIG_DIR": str(tmp_path)}), patch.object(getpass, "getpass", return_value=password), ): app = JupyterPasswordApp(log_level=logging.ERROR) app.initialize([]) app.start() sv = jp_configurable_serverapp() sv.load_config_file() assert sv.identity_provider.hashed_password != "" passwd_check(sv.identity_provider.hashed_password, password) @pytest.mark.parametrize( "env,expected", [ ["yes", True], ["Yes", True], ["True", True], ["true", True], ["TRUE", True], ["no", False], ["nooo", False], ["FALSE", False], ["false", False], ], ) def test_allow_unauthenticated_env_var(jp_configurable_serverapp, env, expected): with patch.dict("os.environ", {"JUPYTER_SERVER_ALLOW_UNAUTHENTICATED_ACCESS": env}): app = jp_configurable_serverapp() assert app.allow_unauthenticated_access == expected def test_list_running_servers(jp_serverapp, jp_web_app): servers = list(list_running_servers(jp_serverapp.runtime_dir)) assert len(servers) >= 1 @pytest.fixture def prefix_path(jp_root_dir, tmp_path): """If a given path is prefixed with the literal strings `/jp_root_dir` or `/tmp_path`, replace those strings with these fixtures. Returns a pathlib Path object. """ def _inner(rawpath): path = pathlib.PurePosixPath(rawpath) if rawpath.startswith("/jp_root_dir"): path = jp_root_dir.joinpath(*path.parts[2:]) elif rawpath.startswith("/tmp_path"): path = tmp_path.joinpath(*path.parts[2:]) return pathlib.Path(path) return _inner @pytest.mark.parametrize( "root_dir,file_to_run,expected_output", [ (None, "notebook.ipynb", "notebook.ipynb"), (None, "/tmp_path/path/to/notebook.ipynb", "notebook.ipynb"), ("/jp_root_dir", "/tmp_path/path/to/notebook.ipynb", SystemExit), ("/tmp_path", "/tmp_path/path/to/notebook.ipynb", "path/to/notebook.ipynb"), ("/jp_root_dir", "notebook.ipynb", "notebook.ipynb"), ("/jp_root_dir", "path/to/notebook.ipynb", "path/to/notebook.ipynb"), ], ) def test_resolve_file_to_run_and_root_dir(prefix_path, root_dir, file_to_run, expected_output): # Verify that the Singleton instance is cleared before the test runs. ServerApp.clear_instance() # Setup the file_to_run path, in case the server checks # if the directory exists before initializing the server. file_to_run = prefix_path(file_to_run) if file_to_run.is_absolute(): file_to_run.parent.mkdir(parents=True, exist_ok=True) kwargs = {"file_to_run": str(file_to_run)} # Setup the root_dir path, in case the server checks # if the directory exists before initializing the server. if root_dir: root_dir = prefix_path(root_dir) if root_dir.is_absolute(): root_dir.parent.mkdir(parents=True, exist_ok=True) kwargs["root_dir"] = str(root_dir) # Create the notebook in the given location serverapp = ServerApp.instance(**kwargs) if expected_output is SystemExit: with pytest.raises(SystemExit): serverapp._resolve_file_to_run_and_root_dir() else: relpath = serverapp._resolve_file_to_run_and_root_dir() assert relpath == str(pathlib.Path(expected_output)) # Clear the singleton instance after each run. ServerApp.clear_instance() # Test the URLs returned by ServerApp. The `` piece # in urls shown below will be replaced with the token # generated by the ServerApp on instance creation. @pytest.mark.parametrize( "config,public_url,local_url,connection_url", [ # Token is hidden when configured. ( {"token": "test"}, "http://localhost:8888/?token=...", "http://127.0.0.1:8888/?token=...", "http://localhost:8888/", ), # Verify port number has changed ( {"port": 9999}, "http://localhost:9999/?token=", "http://127.0.0.1:9999/?token=", "http://localhost:9999/", ), ( {"ip": "1.1.1.1"}, "http://1.1.1.1:8888/?token=", "http://127.0.0.1:8888/?token=", "http://1.1.1.1:8888/", ), # Verify that HTTPS is returned when certfile is given ( {"certfile": "/path/to/dummy/file"}, "https://localhost:8888/?token=", "https://127.0.0.1:8888/?token=", "https://localhost:8888/", ), # Verify changed port and a custom display URL ( {"port": 9999, "custom_display_url": "http://test.org"}, "http://test.org/?token=", "http://127.0.0.1:9999/?token=", "http://localhost:9999/", ), ( {"base_url": "/", "default_url": "/test/"}, "http://localhost:8888/test/?token=", "http://127.0.0.1:8888/test/?token=", "http://localhost:8888/", ), # Verify unix socket URLs are handled properly ( {"sock": "/tmp/jp-test.sock"}, "http+unix://%2Ftmp%2Fjp-test.sock/?token=", "http+unix://%2Ftmp%2Fjp-test.sock/?token=", "http+unix://%2Ftmp%2Fjp-test.sock/", ), ( {"base_url": "/", "default_url": "/test/", "sock": "/tmp/jp-test.sock"}, "http+unix://%2Ftmp%2Fjp-test.sock/test/?token=", "http+unix://%2Ftmp%2Fjp-test.sock/test/?token=", "http+unix://%2Ftmp%2Fjp-test.sock/", ), ( {"ip": ""}, "http://localhost:8888/?token=", "http://127.0.0.1:8888/?token=", "http://localhost:8888/", ), ], ) def test_urls(config, public_url, local_url, connection_url): # Verify we're working with a clean instance. ServerApp.clear_instance() serverapp = ServerApp.instance(**config) serverapp.init_configurables() token = serverapp.identity_provider.token # If a token is generated (not set by config), update # expected_url with token. if serverapp.identity_provider.token_generated: public_url = public_url.replace("", token) local_url = local_url.replace("", token) connection_url = connection_url.replace("", token) assert serverapp.public_url == public_url assert serverapp.local_url == local_url assert serverapp.connection_url == connection_url # Cleanup singleton after test. ServerApp.clear_instance() # Preferred dir tests # ---------------------------------------------------------------------------- @pytest.mark.filterwarnings("ignore::FutureWarning") def test_valid_preferred_dir(tmp_path, jp_configurable_serverapp): path = str(tmp_path) app = jp_configurable_serverapp(root_dir=path, preferred_dir=path) assert app.root_dir == path assert app.preferred_dir == path assert app.root_dir == app.preferred_dir assert app.contents_manager.root_dir == path assert app.contents_manager.preferred_dir == "" @pytest.mark.filterwarnings("ignore::FutureWarning") def test_valid_preferred_dir_is_root_subdir(tmp_path, jp_configurable_serverapp): path = str(tmp_path) path_subdir = str(tmp_path / "subdir") os.makedirs(path_subdir, exist_ok=True) app = jp_configurable_serverapp(root_dir=path, preferred_dir=path_subdir) assert app.root_dir == path assert app.preferred_dir == path_subdir assert app.preferred_dir.startswith(app.root_dir) assert app.contents_manager.preferred_dir == "subdir" def test_valid_preferred_dir_does_not_exist(tmp_path, jp_configurable_serverapp): path = str(tmp_path) path_subdir = str(tmp_path / "subdir") with pytest.raises(TraitError) as error: jp_configurable_serverapp(root_dir=path, preferred_dir=path_subdir) assert "No such preferred dir:" in str(error) @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_preferred_dir_validation_sync_regression( tmp_path, jp_configurable_serverapp, jp_file_contents_manager_class ): path = str(tmp_path) path_subdir = str(tmp_path / "subdir") os.makedirs(path_subdir, exist_ok=True) app = jp_configurable_serverapp( root_dir=path, contents_manager_class=jp_file_contents_manager_class, ) app.contents_manager.preferred_dir = path_subdir assert app.preferred_dir == path_subdir assert app.preferred_dir.startswith(app.root_dir) assert app.contents_manager.preferred_dir == "subdir" # This tests some deprecated behavior as well @pytest.mark.filterwarnings("ignore::FutureWarning") @pytest.mark.parametrize( "root_dir_loc,preferred_dir_loc,config_target", [ ("cli", "cli", "ServerApp"), ("cli", "cli", "FileContentsManager"), ("cli", "config", "ServerApp"), ("cli", "config", "FileContentsManager"), ("cli", "default", "ServerApp"), ("cli", "default", "FileContentsManager"), ("config", "cli", "ServerApp"), ("config", "cli", "FileContentsManager"), ("config", "config", "ServerApp"), ("config", "config", "FileContentsManager"), ("config", "default", "ServerApp"), ("config", "default", "FileContentsManager"), ("default", "cli", "ServerApp"), ("default", "cli", "FileContentsManager"), ("default", "config", "ServerApp"), ("default", "config", "FileContentsManager"), ("default", "default", "ServerApp"), ("default", "default", "FileContentsManager"), ], ) def test_preferred_dir_validation( root_dir_loc, preferred_dir_loc, config_target, tmp_path, jp_config_dir, jp_configurable_serverapp, ): expected_root_dir = str(tmp_path) os_preferred_dir = str(tmp_path / "subdir") os.makedirs(os_preferred_dir, exist_ok=True) config_preferred_dir = os_preferred_dir if config_target == "ServerApp" else "subdir" config_preferred_dir = config_preferred_dir + "/" # add trailing slash to ensure it is removed expected_preferred_dir = "subdir" argv = [] kwargs = {"root_dir": None} config_lines = [] config_file = None if root_dir_loc == "config" or preferred_dir_loc == "config": config_file = jp_config_dir.joinpath("jupyter_server_config.py") if root_dir_loc == "cli": argv.append(f"--{config_target}.root_dir={expected_root_dir}") if root_dir_loc == "config": config_lines.append(f'c.{config_target}.root_dir = r"{expected_root_dir}"') if root_dir_loc == "default": expected_root_dir = os.getcwd() if preferred_dir_loc == "cli": argv.append(f"--{config_target}.preferred_dir={config_preferred_dir}") if preferred_dir_loc == "config": config_lines.append(f'c.{config_target}.preferred_dir = r"{config_preferred_dir}"') if preferred_dir_loc == "default": expected_preferred_dir = "" if config_file is not None: config_file.write_text("\n".join(config_lines)) if argv: kwargs["argv"] = argv # type:ignore[assignment] if root_dir_loc == "default" and preferred_dir_loc != "default": # error expected with pytest.raises(SystemExit): jp_configurable_serverapp(**kwargs) else: app = jp_configurable_serverapp(**kwargs) assert app.contents_manager.root_dir == expected_root_dir assert app.contents_manager.preferred_dir == expected_preferred_dir assert ".." not in expected_preferred_dir def test_invalid_preferred_dir_does_not_exist(tmp_path, jp_configurable_serverapp): path = str(tmp_path) path_subdir = str(tmp_path / "subdir") with pytest.raises(TraitError) as error: app = jp_configurable_serverapp(root_dir=path, preferred_dir=path_subdir) assert "No such preferred dir:" in str(error) def test_invalid_preferred_dir_does_not_exist_set(tmp_path, jp_configurable_serverapp): path = str(tmp_path) path_subdir = str(tmp_path / "subdir") app = jp_configurable_serverapp(root_dir=path) with pytest.raises(TraitError) as error: app.preferred_dir = path_subdir assert "No such preferred dir:" in str(error) @pytest.mark.filterwarnings("ignore::FutureWarning") def test_invalid_preferred_dir_not_root_subdir(tmp_path, jp_configurable_serverapp): path = str(tmp_path / "subdir") os.makedirs(path, exist_ok=True) not_subdir_path = str(tmp_path) with pytest.raises(SystemExit): jp_configurable_serverapp(root_dir=path, preferred_dir=not_subdir_path) async def test_invalid_preferred_dir_not_root_subdir_set(tmp_path, jp_configurable_serverapp): path = str(tmp_path / "subdir") os.makedirs(path, exist_ok=True) not_subdir_path = os.path.relpath(tmp_path, path) app = jp_configurable_serverapp(root_dir=path) with pytest.raises(TraitError) as error: app.contents_manager.preferred_dir = not_subdir_path assert "is outside root contents directory" in str(error.value) async def test_absolute_preferred_dir_not_root_subdir_set(tmp_path, jp_configurable_serverapp): path = str(tmp_path / "subdir") os.makedirs(path, exist_ok=True) not_subdir_path = str(tmp_path) app = jp_configurable_serverapp(root_dir=path) with pytest.raises(TraitError) as error: app.contents_manager.preferred_dir = not_subdir_path if os.name == "nt": assert "is not a relative API path" in str(error.value) else: assert "Preferred directory not found" in str(error.value) def test_random_ports(): ports = list(random_ports(500, 50)) assert len(ports) == 50 def test_server_web_application(jp_serverapp): server: ServerApp = jp_serverapp server.default_url = "/foo" with warnings.catch_warnings(): warnings.simplefilter("ignore") app = ServerWebApplication( server, [], server.kernel_manager, server.contents_manager, server.session_manager, server.kernel_manager, server.config_manager, server.event_logger, [], server.log, server.base_url, server.default_url, {}, {}, ) app.init_handlers([], app.settings) def test_misc(jp_serverapp, tmp_path): app: ServerApp = jp_serverapp assert app.terminals_enabled is True app.extra_args = [str(tmp_path)] app.parse_command_line([]) def test_deprecated_props(jp_serverapp, tmp_path): app: ServerApp = jp_serverapp with warnings.catch_warnings(): warnings.simplefilter("ignore") app.cookie_options = dict(foo=1) app.get_secure_cookie_kwargs = dict(bar=1) app.notebook_dir = str(tmp_path) app.server_extensions = dict(foo=True) app.kernel_ws_protocol = "foo" app.limit_rate = True app.iopub_msg_rate_limit = 10 app.iopub_data_rate_limit = 10 app.rate_limit_window = 10 with pytest.raises(SystemExit): app.pylab = "foo" def test_signals(jp_serverapp): app: ServerApp = jp_serverapp app.answer_yes = True app._restore_sigint_handler() app._handle_sigint(None, None) app._confirm_exit() app._signal_info(None, None) async def test_shutdown_no_activity(jp_serverapp): app: ServerApp = jp_serverapp app.extension_manager.extensions = {} app.exit = lambda _: None # type:ignore[assignment,misc] app.shutdown_no_activity() app.shutdown_no_activity_timeout = 1 app.init_shutdown_no_activity() def test_running_server_info(jp_serverapp): app: ServerApp = jp_serverapp app.running_server_info(True) @pytest.mark.parametrize("should_exist", [True, False]) def test_browser_open_files(jp_configurable_serverapp, should_exist, caplog): app = jp_configurable_serverapp(no_browser_open_file=not should_exist) assert os.path.exists(app.browser_open_file) == should_exist url = urljoin("file:", pathname2url(app.browser_open_file)) url_messages = [rec.message for rec in caplog.records if url in rec.message] assert url_messages if should_exist else not url_messages def test_deprecated_notebook_dir_priority(jp_configurable_serverapp, tmp_path): notebook_dir = tmp_path / "notebook" notebook_dir.mkdir() cli_dir = tmp_path / "cli" cli_dir.mkdir() app = jp_configurable_serverapp(argv=[str(cli_dir)], root_dir=None) assert app._root_dir_set # simulate delayed loading of notebook_dir config # this should _not_ take priority over an explicitly set root_dir # as done by notebook_shim cfg = Config() cfg.ServerApp.notebook_dir = str(notebook_dir) app.update_config(cfg) assert app.root_dir == str(cli_dir) def test_immutable_cache_trait(): # Verify we're working with a clean instance. ServerApp.clear_instance() kwargs = {"static_immutable_cache": "/test/immutable"} serverapp = ServerApp.instance(**kwargs) serverapp.init_configurables() serverapp.init_webapp() assert serverapp.web_app.settings["static_immutable_cache"] == ["/test/immutable"] def test(): pass @pytest.mark.parametrize( "method, expected", [ [test, False], [allow_unauthenticated(test), False], [authorized(test), False], [web.authenticated(test), True], [web.authenticated(authorized(test)), True], [authorized(web.authenticated(test)), False], # wrong order! ], ) def test_tornado_authentication_detection(method, expected): assert _has_tornado_web_authenticated(method) == expected jupyter-server-jupyter_server-e5c7e2b/tests/test_terminal.py000066400000000000000000000176331473126534200247130ustar00rootroot00000000000000import asyncio import json import os import shlex import shutil import sys import warnings import pytest from flaky import flaky # type:ignore[import-untyped] from tornado.httpclient import HTTPClientError from traitlets.config import Config from jupyter_server._tz import isoformat @pytest.fixture def terminal_path(tmp_path): subdir = tmp_path.joinpath("terminal_path") subdir.mkdir() yield subdir shutil.rmtree(str(subdir), ignore_errors=True) @pytest.fixture def terminal_root_dir(jp_root_dir): subdir = jp_root_dir.joinpath("terminal_path") subdir.mkdir() yield subdir shutil.rmtree(str(subdir), ignore_errors=True) CULL_TIMEOUT = 10 CULL_INTERVAL = 3 @pytest.fixture def jp_server_config(): return Config( { "ServerApp": { "TerminalManager": { "cull_inactive_timeout": CULL_TIMEOUT, "cull_interval": CULL_INTERVAL, } } } ) @pytest.fixture def jp_argv(): """Allows tests to setup specific argv values.""" return ["--ServerApp.jpserver_extensions", "jupyter_server_terminals=True"] async def test_no_terminals(jp_fetch): resp_list = await jp_fetch( "api", "terminals", method="GET", allow_nonstandard_methods=True, ) data = json.loads(resp_list.body.decode()) assert len(data) == 0 async def test_terminal_create(jp_fetch, jp_serverapp): resp = await jp_fetch( "api", "terminals", method="POST", allow_nonstandard_methods=True, ) term = json.loads(resp.body.decode()) assert term["name"] == "1" resp_list = await jp_fetch( "api", "terminals", method="GET", allow_nonstandard_methods=True, ) data = json.loads(resp_list.body.decode()) assert len(data) == 1 assert data[0]["name"] == term["name"] async def test_terminal_create_with_kwargs(jp_fetch, jp_ws_fetch, terminal_path): resp_create = await jp_fetch( "api", "terminals", method="POST", body=json.dumps({"cwd": str(terminal_path)}), allow_nonstandard_methods=True, ) data = json.loads(resp_create.body.decode()) term_name = data["name"] resp_get = await jp_fetch( "api", "terminals", term_name, method="GET", allow_nonstandard_methods=True, ) data = json.loads(resp_get.body.decode()) assert data["name"] == term_name async def test_terminal_create_with_cwd(jp_fetch, jp_ws_fetch, terminal_path, jp_serverapp): resp = await jp_fetch( "api", "terminals", method="POST", body=json.dumps({"cwd": str(terminal_path)}), allow_nonstandard_methods=True, ) data = json.loads(resp.body.decode()) term_name = data["name"] ws = await jp_ws_fetch("terminals", "websocket", term_name) ws.write_message(json.dumps(["stdin", "pwd\r\n"])) message_stdout = "" while True: try: message = await asyncio.wait_for(ws.read_message(), timeout=5.0) except asyncio.TimeoutError: break message = json.loads(message) if message[0] == "stdout": message_stdout += message[1] ws.close() assert os.path.basename(terminal_path) in message_stdout resp = await jp_fetch("api", "status") data = json.loads(resp.body.decode()) assert data["last_activity"] == isoformat( jp_serverapp.web_app.settings["terminal_last_activity"] ) @pytest.mark.skip(reason="Not yet working") async def test_terminal_create_with_relative_cwd( jp_fetch, jp_ws_fetch, jp_root_dir, terminal_root_dir ): resp = await jp_fetch( "api", "terminals", method="POST", body=json.dumps({"cwd": str(terminal_root_dir.relative_to(jp_root_dir))}), allow_nonstandard_methods=True, ) data = json.loads(resp.body.decode()) term_name = data["name"] ws = await jp_ws_fetch("terminals", "websocket", term_name) ws.write_message(json.dumps(["stdin", "pwd\r\n"])) message_stdout = "" while True: try: message = await asyncio.wait_for(ws.read_message(), timeout=5.0) except asyncio.TimeoutError: break message = json.loads(message) if message[0] == "stdout": message_stdout += message[1] ws.close() expected = terminal_root_dir.name if sys.platform == "win32" else str(terminal_root_dir) assert expected in message_stdout @pytest.mark.skip(reason="Not yet working") async def test_terminal_create_with_bad_cwd(jp_fetch, jp_ws_fetch): non_existing_path = "/tmp/path/to/nowhere" resp = await jp_fetch( "api", "terminals", method="POST", body=json.dumps({"cwd": non_existing_path}), allow_nonstandard_methods=True, ) data = json.loads(resp.body.decode()) term_name = data["name"] ws = await jp_ws_fetch("terminals", "websocket", term_name) ws.write_message(json.dumps(["stdin", "pwd\r\n"])) message_stdout = "" while True: try: message = await asyncio.wait_for(ws.read_message(), timeout=5.0) except asyncio.TimeoutError: break message = json.loads(message) if message[0] == "stdout": message_stdout += message[1] ws.close() assert non_existing_path not in message_stdout @flaky def test_culling_config(jp_server_config, jp_configurable_serverapp): app = jp_configurable_serverapp() terminal_mgr_config = app.config.ServerApp.TerminalManager assert terminal_mgr_config.cull_inactive_timeout == CULL_TIMEOUT assert terminal_mgr_config.cull_interval == CULL_INTERVAL app = jp_configurable_serverapp() terminal_mgr_settings = app.web_app.settings["terminal_manager"] assert terminal_mgr_settings.cull_inactive_timeout == CULL_TIMEOUT assert terminal_mgr_settings.cull_interval == CULL_INTERVAL @flaky async def test_culling(jp_server_config, jp_fetch): # POST request resp = await jp_fetch( "api", "terminals", method="POST", allow_nonstandard_methods=True, ) term = json.loads(resp.body.decode()) term_1 = term["name"] last_activity = term["last_activity"] culled = False for _ in range(CULL_TIMEOUT + CULL_INTERVAL): try: resp = await jp_fetch( "api", "terminals", term_1, method="GET", allow_nonstandard_methods=True, ) except HTTPClientError as e: assert e.code == 404 culled = True break else: await asyncio.sleep(1) assert culled @pytest.mark.parametrize( "terminado_settings,expected_shell,min_traitlets", [ ("shell_command=\"['/path/to/shell', '-l']\"", ["/path/to/shell", "-l"], "5.4"), ('shell_command="/string/path/to/shell -l"', ["/string/path/to/shell", "-l"], "5.1"), ], ) def test_shell_command_override( terminado_settings, expected_shell, min_traitlets, jp_configurable_serverapp ): pytest.importorskip("traitlets", minversion=min_traitlets) argv = shlex.split(f"--ServerApp.terminado_settings={terminado_settings}") app = jp_configurable_serverapp(argv=argv) if os.name == "nt": assert app.web_app.settings["terminal_manager"].shell_command in ( expected_shell, " ".join(expected_shell), ) else: assert app.web_app.settings["terminal_manager"].shell_command == expected_shell def test_importing_shims(): with warnings.catch_warnings(): warnings.simplefilter("ignore") from jupyter_server.terminal import initialize from jupyter_server.terminal.api_handlers import TerminalRootHandler from jupyter_server.terminal.handlers import TermSocket from jupyter_server.terminal.terminalmanager import TerminalManager jupyter-server-jupyter_server-e5c7e2b/tests/test_traittypes.py000066400000000000000000000033501473126534200252770ustar00rootroot00000000000000import pytest from traitlets import HasTraits, TraitError from traitlets.utils.importstring import import_item from jupyter_server.services.contents.largefilemanager import LargeFileManager from jupyter_server.traittypes import InstanceFromClasses, TypeFromClasses class DummyClass: """Dummy class for testing Instance""" class DummyInt(int): """Dummy class for testing types.""" class Thing(HasTraits): a = InstanceFromClasses( default_value=2, klasses=[ int, str, DummyClass, ], ) b = TypeFromClasses( default_value=None, allow_none=True, klasses=[ DummyClass, int, "jupyter_server.services.contents.manager.ContentsManager", ], ) class TestInstanceFromClasses: @pytest.mark.parametrize("value", [1, "test", DummyClass()]) def test_good_values(self, value): thing = Thing(a=value) assert thing.a == value @pytest.mark.parametrize("value", [2.4, object()]) def test_bad_values(self, value): with pytest.raises(TraitError) as e: thing = Thing(a=value) class TestTypeFromClasses: @pytest.mark.parametrize( "value", [ DummyClass, DummyInt, LargeFileManager, "jupyter_server.services.contents.manager.ContentsManager", ], ) def test_good_values(self, value): thing = Thing(b=value) if isinstance(value, str): value = import_item(value) assert thing.b == value @pytest.mark.parametrize("value", [float, object]) def test_bad_values(self, value): with pytest.raises(TraitError) as e: thing = Thing(b=value) jupyter-server-jupyter_server-e5c7e2b/tests/test_utils.py000066400000000000000000000105131473126534200242260ustar00rootroot00000000000000import os import socket import subprocess import sys import uuid import warnings from pathlib import Path from unittest.mock import patch import pytest from traitlets.tests.utils import check_help_all_output from jupyter_server.utils import ( check_pid, check_version, filefind, is_namespace_package, path2url, run_sync_in_loop, samefile_simple, to_api_path, unix_socket_in_use, url2path, url_escape, url_unescape, ) def test_help_output(): check_help_all_output("jupyter_server") @pytest.mark.parametrize( "unescaped,escaped", [ ("/this is a test/for spaces/", "/this%20is%20a%20test/for%20spaces/"), ("notebook with space.ipynb", "notebook%20with%20space.ipynb"), ( "/path with a/notebook and space.ipynb", "/path%20with%20a/notebook%20and%20space.ipynb", ), ( "/ !@$#%^&* / test %^ notebook @#$ name.ipynb", "/%20%21%40%24%23%25%5E%26%2A%20/%20test%20%25%5E%20notebook%20%40%23%24%20name.ipynb", ), ], ) def test_url_escaping(unescaped, escaped): # Test escaping. path = url_escape(unescaped) assert path == escaped # Test unescaping. path = url_unescape(escaped) assert path == unescaped @pytest.mark.parametrize( "name, expected", [ # returns True if it is a namespace package ("test_namespace", True), # returns False if it isn't a namespace package ("sys", False), ("jupyter_server", False), # returns None if it isn't importable ("not_a_python_namespace", None), ], ) def test_is_namespace_package(monkeypatch, name, expected): monkeypatch.syspath_prepend(Path(__file__).parent / "namespace-package-test") assert is_namespace_package(name) is expected def test_is_namespace_package_no_spec(): with patch("importlib.util.find_spec") as mocked_spec: mocked_spec.side_effect = ValueError() assert is_namespace_package("dummy") is None mocked_spec.assert_called_once_with("dummy") @pytest.mark.skipif(os.name == "nt", reason="Paths are annoying on Windows") def test_path_utils(tmp_path): path = str(tmp_path) assert os.path.basename(path2url(path)) == os.path.basename(path) url = path2url(path) assert path.endswith(url2path(url)) assert samefile_simple(path, path) assert to_api_path(path, os.path.dirname(path)) == os.path.basename(path) def test_check_version(): assert check_version("1.0.2", "1.0.1") assert not check_version("1.0.0", "1.0.1") assert check_version(1.0, "1.0.1") # type:ignore[arg-type] def test_check_pid(): proc = subprocess.Popen([sys.executable]) proc.kill() proc.wait() check_pid(proc.pid) async def test_run_sync_in_loop(): async def foo(): pass with warnings.catch_warnings(): warnings.simplefilter("ignore") await run_sync_in_loop(foo()) @pytest.mark.skipif(os.name != "posix", reason="Requires unix sockets") def test_unix_socket_in_use(tmp_path): root_tmp_dir = Path("/tmp").resolve() server_address = os.path.join(root_tmp_dir, uuid.uuid4().hex) if os.path.exists(server_address): os.remove(server_address) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.bind(server_address) sock.listen(0) assert unix_socket_in_use(server_address) sock.close() @pytest.mark.parametrize( "filename, result", [ ("/foo", OSError), ("../c/in-c", OSError), ("in-a", "a/in-a"), ("in-b", "b/in-b"), ("in-both", "a/in-both"), (r"\in-a", OSError), ("not-found", OSError), ], ) def test_filefind(tmp_path, filename, result): a = tmp_path / "a" a.mkdir() b = tmp_path / "b" b.mkdir() c = tmp_path / "c" c.mkdir() for parent in (a, b): with parent.joinpath("in-both").open("w"): pass with a.joinpath("in-a").open("w"): pass with b.joinpath("in-b").open("w"): pass with c.joinpath("in-c").open("w"): pass if isinstance(result, str): found = filefind(filename, [str(a), str(b)]) found_relative = Path(found).relative_to(tmp_path) assert str(found_relative).replace(os.sep, "/") == result else: with pytest.raises(result): filefind(filename, [str(a), str(b)]) jupyter-server-jupyter_server-e5c7e2b/tests/test_version.py000066400000000000000000000021501473126534200245510ustar00rootroot00000000000000import re import pytest from jupyter_server import __version__ pep440re = re.compile(r"^(\d+)\.(\d+)\.(\d+((a|b|rc)\d+)?)(\.post\d+)?(\.dev\d*)?$") def raise_on_bad_version(version): if not pep440re.match(version): raise ValueError( "Versions String does apparently not match Pep 440 specification, " "which might lead to sdist and wheel being seen as 2 different release. " "E.g: do not use dots for beta/alpha/rc markers." ) # --------- Meta test to test the versioning tests ------------- @pytest.mark.parametrize( "version", [ "4.1.0.b1", "4.1.b1", "4.2", "X.y.z", "1.2.3.dev1.post2", ], ) def test_invalid_pep440_versions(version): with pytest.raises(ValueError): raise_on_bad_version(version) @pytest.mark.parametrize( "version", [ "4.1.1", "4.2.1b3", ], ) def test_valid_pep440_versions(version): assert raise_on_bad_version(version) is None # --------- Test current version -------------- def test_current_version(): raise_on_bad_version(__version__) jupyter-server-jupyter_server-e5c7e2b/tests/test_view.py000066400000000000000000000032021473126534200240350ustar00rootroot00000000000000"""test view handler""" from html.parser import HTMLParser import pytest import tornado from jupyter_server.utils import url_path_join from .utils import expected_http_error class IFrameSrcFinder(HTMLParser): """Minimal HTML parser to find iframe.src attr""" def __init__(self): super().__init__() self.iframe_src = None def handle_starttag(self, tag, attrs): if tag.lower() == "iframe": for attr, value in attrs: if attr.lower() == "src": self.iframe_src = value return def find_iframe_src(html): """Find the src= attr of an iframe on the page Assumes only one iframe """ finder = IFrameSrcFinder() finder.feed(html) return finder.iframe_src @pytest.mark.parametrize( "exists, name", [ (False, "nosuchfile.html"), (False, "nosuchfile.bin"), (True, "exists.html"), (True, "exists.bin"), ], ) async def test_view(jp_fetch, jp_serverapp, jp_root_dir, exists, name): """Test /view/$path for a few cases""" if exists: jp_root_dir.joinpath(name).write_text(name) if not exists: with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch("view", name, method="GET") assert expected_http_error(e, 404), [name, e] else: r = await jp_fetch("view", name, method="GET") assert r.code == 200 assert r.headers["content-type"] == "text/html; charset=UTF-8" html = r.body.decode() src = find_iframe_src(html) assert src == url_path_join(jp_serverapp.base_url, f"/files/{name}") jupyter-server-jupyter_server-e5c7e2b/tests/unix_sockets/000077500000000000000000000000001473126534200241735ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/unix_sockets/__init__.py000066400000000000000000000000001473126534200262720ustar00rootroot00000000000000jupyter-server-jupyter_server-e5c7e2b/tests/unix_sockets/conftest.py000066400000000000000000000016311473126534200263730ustar00rootroot00000000000000import os import pathlib import pytest from jupyter_server import DEFAULT_JUPYTER_SERVER_PORT @pytest.fixture def jp_process_id(): """Choose a random unused process ID.""" return os.getpid() @pytest.fixture def jp_unix_socket_file(jp_process_id): """Define a temporary socket connection""" # Rely on `/tmp` to avoid any Linux socket length max buffer # issues. Key on PID for process-wise concurrency. tmp_path = pathlib.Path("/tmp") filename = f"jupyter_server.{jp_process_id}.sock" jp_unix_socket_file = tmp_path.joinpath(filename) yield str(jp_unix_socket_file) # Clean up the file after the test runs. if jp_unix_socket_file.exists(): jp_unix_socket_file.unlink() @pytest.fixture def jp_http_port(): """Set the port to the default value, since sock and port cannot both be configured at the same time. """ return DEFAULT_JUPYTER_SERVER_PORT jupyter-server-jupyter_server-e5c7e2b/tests/unix_sockets/test_api.py000066400000000000000000000043201473126534200263540ustar00rootroot00000000000000import sys import pytest # Skip this module if on Windows. Unix sockets are not available on Windows. pytestmark = pytest.mark.skipif( sys.platform.startswith("win"), reason="Unix sockets are not available on Windows." ) import urllib if not sys.platform.startswith("win"): from tornado.netutil import bind_unix_socket import jupyter_server.serverapp from jupyter_server.utils import async_fetch, url_path_join, urlencode_unix_socket @pytest.fixture def jp_server_config(jp_unix_socket_file): """Configure the serverapp fixture with the unix socket.""" return {"ServerApp": {"sock": jp_unix_socket_file, "allow_remote_access": True}} @pytest.fixture def http_server_port(jp_unix_socket_file, jp_process_id): """Unix socket and process ID used by tornado's HTTP Server. Overrides the http_server_port fixture from pytest-tornasync and replaces it with a tuple: (unix socket, process id) """ return (bind_unix_socket(jp_unix_socket_file), jp_process_id) @pytest.fixture def jp_unix_socket_fetch(jp_unix_socket_file, jp_auth_header, jp_base_url, http_server, io_loop): """A fetch fixture for Jupyter Server tests that use the unix_serverapp fixture""" async def client(*parts, headers=None, params=None, **kwargs): # Handle URL strings host_url = urlencode_unix_socket(jp_unix_socket_file) path_url = url_path_join(jp_base_url, *parts) params_url = urllib.parse.urlencode(params or {}) url = url_path_join(host_url, path_url + "?" + params_url) r = await async_fetch(url, headers=headers or {}, io_loop=io_loop, **kwargs) return r return client async def test_get_spec(jp_unix_socket_fetch): # Handle URL strings parts = ["api", "spec.yaml"] # Make request and verify it succeeds.' response = await jp_unix_socket_fetch(*parts) assert response.code == 200 assert response.body is not None async def test_list_running_servers(jp_unix_socket_file, http_server): """Test that a server running on unix sockets is discovered by the server list""" servers = list(jupyter_server.serverapp.list_running_servers()) assert len(servers) >= 1 assert jp_unix_socket_file in {info["sock"] for info in servers} jupyter-server-jupyter_server-e5c7e2b/tests/unix_sockets/test_serverapp_integration.py000066400000000000000000000166001473126534200322210ustar00rootroot00000000000000import os import platform import shlex import stat import subprocess import sys import time import pytest from jupyter_server.serverapp import ( JupyterServerListApp, JupyterServerStopApp, list_running_servers, shutdown_server, ) from jupyter_server.utils import urlencode_unix_socket, urlencode_unix_socket_path # Skip this module if on Windows. Unix sockets are not available on Windows. pytestmark = pytest.mark.skipif( sys.platform.startswith("win") or platform.python_implementation() == "PyPy", reason="Unix sockets are not supported.", ) def _check_output(cmd, *args, **kwargs): if isinstance(cmd, str): cmd = shlex.split(cmd) kwargs.setdefault("stderr", subprocess.STDOUT) output = subprocess.check_output(cmd, *args, **kwargs) if not isinstance(output, str): output = output.decode("utf-8") return output def _cleanup_process(proc): proc.wait() # Make sure all the fds get closed. for attr in ["stdout", "stderr", "stdin"]: fid = getattr(proc, attr) if fid: fid.close() @pytest.mark.integration_test def test_shutdown_sock_server_integration(jp_unix_socket_file): url = urlencode_unix_socket(jp_unix_socket_file).encode() encoded_sock_path = urlencode_unix_socket_path(jp_unix_socket_file) p = subprocess.Popen( ["jupyter-server", "--sock=%s" % jp_unix_socket_file, "--sock-mode=0700"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) complete = False assert p.stderr is not None for line in iter(p.stderr.readline, b""): if url in line: complete = True break assert complete, "did not find socket URL in stdout when launching notebook" assert encoded_sock_path in _check_output("jupyter-server list") # Ensure umask is properly applied. assert stat.S_IMODE(os.lstat(jp_unix_socket_file).st_mode) == 0o700 try: _check_output("jupyter-server stop") except subprocess.CalledProcessError as e: assert "There is currently no server running on" in e.output.decode() else: raise AssertionError("expected stop command to fail due to target mismatch") assert encoded_sock_path in _check_output("jupyter-server list") # Fake out stopping the server. app = JupyterServerStopApp(sock=str(jp_unix_socket_file)) app.initialize([]) app.shutdown_server = lambda _: True # type:ignore[method-assign] app._maybe_remove_unix_socket = lambda _: _ # type: ignore[method-assign] app.start() _check_output(["jupyter-server", "stop", jp_unix_socket_file]) assert encoded_sock_path not in _check_output(["jupyter-server", "list"]) _cleanup_process(p) @pytest.mark.integration_test def test_sock_server_validate_sockmode_type(): try: _check_output(["jupyter-server", "--sock=/tmp/nonexistent", "--sock-mode=badbadbad"]) except subprocess.CalledProcessError as e: assert "badbadbad" in e.output.decode() else: raise AssertionError("expected execution to fail due to validation of --sock-mode param") @pytest.mark.integration_test def test_sock_server_validate_sockmode_accessible(): try: _check_output( ["jupyter-server", "--sock=/tmp/nonexistent", "--sock-mode=0444"], ) except subprocess.CalledProcessError as e: assert "0444" in e.output.decode() else: raise AssertionError("expected execution to fail due to validation of --sock-mode param") def _ensure_stopped(check_msg="There are no running servers"): try: _check_output(["jupyter-server", "stop"]) except subprocess.CalledProcessError as e: assert check_msg in e.output.decode() else: raise AssertionError("expected all servers to be stopped") @pytest.mark.integration_test def test_stop_multi_integration(jp_unix_socket_file, jp_http_port): """Tests lifecycle behavior for mixed-mode server types w/ default ports. Mostly suitable for local dev testing due to reliance on default port binding. """ TEST_PORT = "9797" MSG_TMPL = "Shutting down server on {}..." _ensure_stopped() # Default port. p1 = subprocess.Popen(["jupyter-server", "--no-browser"]) # Unix socket. p2 = subprocess.Popen(["jupyter-server", "--sock=%s" % jp_unix_socket_file]) # Specified port p3 = subprocess.Popen(["jupyter-server", "--no-browser", "--port=%s" % TEST_PORT]) time.sleep(3) shutdown_msg = MSG_TMPL.format(jp_http_port) assert shutdown_msg in _check_output(["jupyter-server", "stop"]) _ensure_stopped("There is currently no server running on 8888") assert MSG_TMPL.format(jp_unix_socket_file) in _check_output( ["jupyter-server", "stop", jp_unix_socket_file] ) assert MSG_TMPL.format(TEST_PORT) in _check_output(["jupyter-server", "stop", TEST_PORT]) _ensure_stopped() [_cleanup_process(p) for p in [p1, p2, p3]] @pytest.mark.integration_test def test_launch_socket_collision(jp_unix_socket_file): """Tests UNIX socket in-use detection for lifecycle correctness.""" sock = jp_unix_socket_file check_msg = "socket %s is already in use" % sock _ensure_stopped() # Start a server. cmd = ["jupyter-server", "--sock=%s" % sock] p1 = subprocess.Popen(cmd) time.sleep(3) # Try to start a server bound to the same UNIX socket. try: _check_output(cmd) except subprocess.CalledProcessError as cpe: assert check_msg in cpe.output.decode() except Exception as ex: raise AssertionError(f"expected 'already in use' error, got '{ex}'!") from ex else: raise AssertionError("expected 'already in use' error, got success instead!") # Stop the background server, ensure it's stopped and wait on the process to exit. subprocess.check_call(["jupyter-server", "stop", sock]) _ensure_stopped() _cleanup_process(p1) @pytest.mark.integration_test def test_shutdown_server(jp_environ): # Start a server in another process # Stop that server import subprocess from jupyter_client.connect import LocalPortCache port = LocalPortCache().find_available_port("localhost") p = subprocess.Popen(["jupyter-server", f"--port={port}"]) servers = [] while 1: servers = list(list_running_servers()) if len(servers): break time.sleep(0.1) while 1: try: shutdown_server(servers[0]) break except ConnectionRefusedError: time.sleep(0.1) _cleanup_process(p) @pytest.mark.integration_test def test_jupyter_server_apps(jp_environ): # Start a server in another process # Stop that server import subprocess from jupyter_client.connect import LocalPortCache port = LocalPortCache().find_available_port("localhost") p = subprocess.Popen(["jupyter-server", f"--port={port}"]) servers = [] while 1: servers = list(list_running_servers()) if len(servers): break time.sleep(0.1) app = JupyterServerListApp() app.initialize([]) app.jsonlist = True app.start() app.jsonlist = False app.json = True app.start() app.json = False app.start() stop_app = JupyterServerStopApp() stop_app.initialize([]) stop_app.port = port while 1: try: stop_app.start() break except ConnectionRefusedError: time.sleep(0.1) _cleanup_process(p) jupyter-server-jupyter_server-e5c7e2b/tests/utils.py000066400000000000000000000022761473126534200231760ustar00rootroot00000000000000import json from typing import NewType from tornado.httpclient import HTTPClientError from tornado.web import HTTPError some_resource = "The very model of a modern major general" sample_kernel_json = { "argv": ["cat", "{connection_file}"], "display_name": "Test kernel", } ApiPath = NewType("ApiPath", str) def mkdir(tmp_path, *parts): path = tmp_path.joinpath(*parts) if not path.exists(): path.mkdir(parents=True) return path def expected_http_error(error, expected_code, expected_message=None): """Check that the error matches the expected output error.""" e = error.value if isinstance(e, HTTPError): if expected_code != e.status_code: return False if expected_message is not None and expected_message != str(e): return False return True elif any( [ isinstance(e, HTTPClientError), isinstance(e, HTTPError), ] ): if expected_code != e.code: return False if expected_message: message = json.loads(e.response.body.decode())["message"] if expected_message != message: return False return True