diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index f20f0d9ef3..4c999c5cdd 100644 --- a/.github/workflows/downstream.yml +++ b/.github/workflows/downstream.yml @@ -122,7 +122,7 @@ jobs: 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 --color=yes + 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() diff --git a/.github/workflows/prep-release.yml b/.github/workflows/prep-release.yml index 7a2a18de75..396330bb97 100644 --- a/.github/workflows/prep-release.yml +++ b/.github/workflows/prep-release.yml @@ -12,6 +12,10 @@ on: 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 @@ -22,6 +26,8 @@ on: jobs: prep_release: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 @@ -29,8 +35,9 @@ jobs: id: prep-release uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 with: - token: ${{ secrets.ADMIN_GITHUB_TOKEN }} + 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 }} diff --git a/.github/workflows/publish-changelog.yml b/.github/workflows/publish-changelog.yml new file mode 100644 index 0000000000..60af4c5f16 --- /dev/null +++ b/.github/workflows/publish-changelog.yml @@ -0,0 +1,34 @@ +name: "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 }}" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index dbaaeaad24..5295e776b6 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -15,30 +15,32 @@ on: 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: ${{ secrets.ADMIN_GITHUB_TOKEN }} - target: ${{ github.event.inputs.target }} + token: ${{ steps.app-token.outputs.token }} branch: ${{ github.event.inputs.branch }} release_url: ${{ github.event.inputs.release_url }} steps_to_skip: ${{ github.event.inputs.steps_to_skip }} - name: Finalize Release id: finalize-release - env: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - PYPI_TOKEN_MAP: ${{ secrets.PYPI_TOKEN_MAP }} - TWINE_USERNAME: __token__ - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - uses: jupyter-server/jupyter-releaser/.github/actions/finalize-release@v2 + uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 with: - token: ${{ secrets.ADMIN_GITHUB_TOKEN }} - target: ${{ github.event.inputs.target }} + token: ${{ steps.app-token.outputs.token }} release_url: ${{ steps.populate-release.outputs.release_url }} - name: "** Next Step **" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 7e821d5649..83c949e6ca 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -43,13 +43,13 @@ jobs: 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 run test:test --lf + 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 run test:nowarn --lf + 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 run cov:nowarn --lf + run: hatch run cov:nowarn -s || hatch -v run cov:nowarn --lf - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 test_docs: @@ -64,14 +64,14 @@ jobs: sudo apt-get install enchant-2 # for spell checking - name: Build API docs run: | - hatch run docs:api + 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 run docs:build + - run: hatch -v run docs:build test_lint: name: Test Lint @@ -81,8 +81,8 @@ jobs: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Run Linters run: | - hatch run typing:test - hatch run lint:build + 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 @@ -114,7 +114,7 @@ jobs: dependency_type: minimum - name: Run the unit tests run: | - hatch -vv run test:nowarn || hatch run test:nowarn --lf + hatch -vv run test:nowarn || hatch -v run test:nowarn --lf test_prereleases: name: Test Prereleases @@ -127,7 +127,7 @@ jobs: dependency_type: pre - name: Run the tests run: | - hatch run test:nowarn || hatch run test:nowarn --lf + hatch run test:nowarn || hatch -v run test:nowarn --lf make_sdist: name: Make SDist @@ -148,7 +148,7 @@ jobs: - uses: jupyterlab/maintainer-tools/.github/actions/test-sdist@v1 with: package_spec: -vv . - test_command: hatch run test:test || hatch run test:test --lf + test_command: hatch run test:test || hatch -v run test:test --lf check_release: runs-on: ubuntu-latest @@ -185,7 +185,7 @@ jobs: - uses: actions/checkout@v4 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Run the tests - run: hatch run cov:integration + run: hatch -v run cov:integration - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 integration_check_pypy: @@ -196,7 +196,7 @@ jobs: with: python_version: "pypy-3.8" - name: Run the tests - run: hatch run test:nowarn --integration_tests=true + run: hatch -v run test:nowarn --integration_tests=true coverage: runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb949b907d..0d72a2d239 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-case-conflict - id: check-ast @@ -21,7 +21,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.27.3 + rev: 0.28.6 hooks: - id: check-github-workflows @@ -39,7 +39,7 @@ repos: types_or: [yaml, html, json] - repo: https://github.com/codespell-project/codespell - rev: "v2.2.6" + rev: "v2.3.0" hooks: - id: codespell args: ["-L", "sur,nd"] @@ -52,7 +52,7 @@ repos: - id: rst-inline-touching-normal - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.8.0" + rev: "v1.10.1" hooks: - id: mypy files: jupyter_server @@ -61,7 +61,7 @@ repos: ["traitlets>=5.13", "jupyter_core>=5.5", "jupyter_client>=8.5"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.9 + rev: v0.5.0 hooks: - id: ruff types_or: [python, jupyter] @@ -70,7 +70,7 @@ repos: types_or: [python, jupyter] - repo: https://github.com/scientific-python/cookie - rev: "2023.12.21" + rev: "2024.04.23" hooks: - id: sp-repo-review additional_dependencies: ["repo-review[cli]"] diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd23942fa..cadf2e8a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,120 @@ All notable changes to this project will be documented in this file. +## 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)) @@ -18,8 +132,6 @@ All notable changes to this project will be documented in this file. [@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)) diff --git a/docs/source/conf.py b/docs/source/conf.py index 7f59cb956b..bf355f366c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -66,11 +66,11 @@ # General information about the project. project = "Jupyter Server" -copyright = "2020, Jupyter Team, https://jupyter.org" +copyright = "2020, Jupyter Team, https://jupyter.org" # noqa: A001 author = "The Jupyter Team" # ghissue config -github_project_url = "https://github.com/jupyter/jupyter_server" +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 @@ -150,7 +150,25 @@ # 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 = {"navigation_with_keys": False} +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 = [] diff --git a/docs/source/contributors/team-meetings.rst b/docs/source/contributors/team-meetings.rst index 75d69d232c..05ba92732f 100644 --- a/docs/source/contributors/team-meetings.rst +++ b/docs/source/contributors/team-meetings.rst @@ -23,7 +23,7 @@ Also check out Jupyter Server's roadmap where we track future plans for Jupyter `Jupyter Server 2.0 Discussion `_ -`Archived roadmap `_ +`Archived roadmap `_ Jupyter Calendar ---------------- diff --git a/docs/source/developers/extensions.rst b/docs/source/developers/extensions.rst index 5c27d25747..be454b26e6 100644 --- a/docs/source/developers/extensions.rst +++ b/docs/source/developers/extensions.rst @@ -6,8 +6,12 @@ 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. -You can check some simple examples on the `examples folder -`_ in the GitHub jupyter_server repository. +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 ================================== diff --git a/docs/source/index.rst b/docs/source/index.rst index a3f6abb517..c95abf94a7 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,7 +3,7 @@ Welcome! You've landed on the documentation pages for the **Jupyter Server** Project. Some other pages you may have been looking for: -* `Jupyter Server Github Repo `_, the source code we describe in this code. +* `Jupyter Server Github Repo `_, 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. @@ -11,7 +11,8 @@ You've landed on the documentation pages for the **Jupyter Server** Project. Som Introduction ------------ -Jupyter Server is the backend—the core services, APIs, and `REST endpoints`_—to Jupyter web applications. +Jupyter Server is the backend that provides the core services, APIs, and +`REST endpoints`_ for Jupyter web applications. .. note:: @@ -21,6 +22,34 @@ Jupyter Server is the backend—the core services, APIs, and `REST endpoints`_ .. _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? --------------- @@ -31,7 +60,8 @@ The Jupyter Server is a highly technical piece of the Jupyter Stack, so we've se 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 `_. +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 ----------------- diff --git a/docs/source/other/links.rst b/docs/source/other/links.rst index 935ddc53cb..da951129a0 100644 --- a/docs/source/other/links.rst +++ b/docs/source/other/links.rst @@ -2,7 +2,7 @@ List of helpful links ===================== * :ref:`Frequently Asked Questions ` -* `Jupyter Server Github Repo `_ +* `Jupyter Server Github Repo `_ * `JupyterLab Github Repo `_ * `Jupyter Notebook Github Repo `_ * `Jupyterhub Github Repo `_ diff --git a/docs/source/users/help.rst b/docs/source/users/help.rst index b290e1bb07..3f97e84f40 100644 --- a/docs/source/users/help.rst +++ b/docs/source/users/help.rst @@ -3,6 +3,6 @@ Getting Help ============ -If you run into any issues or bugs, please open an `issue on Github `_. +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 `. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000..27616bf67f --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,24 @@ +export 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*" + ] + } +]; diff --git a/examples/authorization/jupyter_nbclassic_readonly_config.py b/examples/authorization/jupyter_nbclassic_readonly_config.py index 18c1b83bfd..95b095fd26 100644 --- a/examples/authorization/jupyter_nbclassic_readonly_config.py +++ b/examples/authorization/jupyter_nbclassic_readonly_config.py @@ -1,4 +1,5 @@ """Nbclassic read only auth example.""" + from jupyter_server.auth import Authorizer diff --git a/examples/authorization/jupyter_nbclassic_rw_config.py b/examples/authorization/jupyter_nbclassic_rw_config.py index 5dce9a8444..751cef64a8 100644 --- a/examples/authorization/jupyter_nbclassic_rw_config.py +++ b/examples/authorization/jupyter_nbclassic_rw_config.py @@ -1,4 +1,5 @@ """Nbclassic read/write auth example.""" + from jupyter_server.auth import Authorizer diff --git a/examples/authorization/jupyter_temporary_config.py b/examples/authorization/jupyter_temporary_config.py index dd93948c4c..9756cafe7d 100644 --- a/examples/authorization/jupyter_temporary_config.py +++ b/examples/authorization/jupyter_temporary_config.py @@ -1,4 +1,5 @@ """Nbclassic temporary server auth example.""" + from jupyter_server.auth import Authorizer diff --git a/examples/identity/system_password/jupyter_server_config.py b/examples/identity/system_password/jupyter_server_config.py index 11364c22e8..51fe76f1ba 100644 --- a/examples/identity/system_password/jupyter_server_config.py +++ b/examples/identity/system_password/jupyter_server_config.py @@ -1,4 +1,5 @@ """Jupyter server system password identity provider example.""" + import pwd from getpass import getuser diff --git a/examples/simple/README.md b/examples/simple/README.md index dd76af4ded..77f10be9d2 100644 --- a/examples/simple/README.md +++ b/examples/simple/README.md @@ -9,7 +9,7 @@ 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 examples/simple && \ + cd jupyter_server/examples/simple && \ conda create -y -n jupyter-server-example python=3.9 && \ conda activate jupyter-server-example && \ pip install -e .[test] @@ -187,8 +187,6 @@ 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 -# HTML static page. -open http://localhost:8888/static/simple_ext11/test.html # Content from Handlers. open http://localhost:8888/simple_ext11/params/test?var1=foo # Content from Template. diff --git a/examples/simple/conftest.py b/examples/simple/conftest.py index df81e57d34..91f7da3340 100644 --- a/examples/simple/conftest.py +++ b/examples/simple/conftest.py @@ -1,2 +1,3 @@ """Pytest configuration.""" + pytest_plugins = ["jupyter_server.pytest_plugin"] diff --git a/examples/simple/jupyter_server_config.py b/examples/simple/jupyter_server_config.py index d850655359..1077c5f4d3 100644 --- a/examples/simple/jupyter_server_config.py +++ b/examples/simple/jupyter_server_config.py @@ -1,4 +1,5 @@ """Configuration file for jupyter-server extensions.""" + # ------------------------------------------------------------------------------ # Application(SingletonConfigurable) configuration # ------------------------------------------------------------------------------ diff --git a/examples/simple/jupyter_simple_ext11_config.py b/examples/simple/jupyter_simple_ext11_config.py index 976e3df66a..d4ee872a6a 100644 --- a/examples/simple/jupyter_simple_ext11_config.py +++ b/examples/simple/jupyter_simple_ext11_config.py @@ -1,2 +1,3 @@ """Jupyter server config.""" + c.SimpleApp11.ignore_js = True # type:ignore[name-defined] diff --git a/examples/simple/jupyter_simple_ext1_config.py b/examples/simple/jupyter_simple_ext1_config.py index 6069883494..605b94e158 100644 --- a/examples/simple/jupyter_simple_ext1_config.py +++ b/examples/simple/jupyter_simple_ext1_config.py @@ -1,4 +1,5 @@ """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] diff --git a/examples/simple/jupyter_simple_ext2_config.py b/examples/simple/jupyter_simple_ext2_config.py index 7b61087e1d..81c8aed5b9 100644 --- a/examples/simple/jupyter_simple_ext2_config.py +++ b/examples/simple/jupyter_simple_ext2_config.py @@ -1,2 +1,3 @@ """Jupyter server config.""" + c.SimpleApp2.configD = "ConfigD from file" # type:ignore[name-defined] diff --git a/examples/simple/simple_ext1/__init__.py b/examples/simple/simple_ext1/__init__.py index 7b0c65c96f..f1b750f080 100644 --- a/examples/simple/simple_ext1/__init__.py +++ b/examples/simple/simple_ext1/__init__.py @@ -1,5 +1,5 @@ from .application import SimpleApp1 -def _jupyter_server_extension_paths(): +def _jupyter_server_extension_points(): return [{"module": "simple_ext1.application", "app": SimpleApp1}] diff --git a/examples/simple/simple_ext1/__main__.py b/examples/simple/simple_ext1/__main__.py index 90b15cbc92..0203d1896a 100644 --- a/examples/simple/simple_ext1/__main__.py +++ b/examples/simple/simple_ext1/__main__.py @@ -1,4 +1,5 @@ """Application cli main.""" + from .application import main if __name__ == "__main__": diff --git a/examples/simple/simple_ext1/application.py b/examples/simple/simple_ext1/application.py index b77e57e4a8..f62a7952a2 100644 --- a/examples/simple/simple_ext1/application.py +++ b/examples/simple/simple_ext1/application.py @@ -1,4 +1,5 @@ """Jupyter server example application.""" + import os from traitlets import Unicode diff --git a/examples/simple/simple_ext1/handlers.py b/examples/simple/simple_ext1/handlers.py index 9d25057bc3..72f54a8bfd 100644 --- a/examples/simple/simple_ext1/handlers.py +++ b/examples/simple/simple_ext1/handlers.py @@ -1,4 +1,5 @@ """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 diff --git a/examples/simple/simple_ext11/__init__.py b/examples/simple/simple_ext11/__init__.py index d7c3e4341b..2da9cc1925 100644 --- a/examples/simple/simple_ext11/__init__.py +++ b/examples/simple/simple_ext11/__init__.py @@ -1,6 +1,7 @@ """Extension entry point.""" + from .application import SimpleApp11 -def _jupyter_server_extension_paths(): +def _jupyter_server_extension_points(): return [{"module": "simple_ext11.application", "app": SimpleApp11}] diff --git a/examples/simple/simple_ext11/__main__.py b/examples/simple/simple_ext11/__main__.py index 90b15cbc92..0203d1896a 100644 --- a/examples/simple/simple_ext11/__main__.py +++ b/examples/simple/simple_ext11/__main__.py @@ -1,4 +1,5 @@ """Application cli main.""" + from .application import main if __name__ == "__main__": diff --git a/examples/simple/simple_ext11/application.py b/examples/simple/simple_ext11/application.py index 398716f213..5710534f51 100644 --- a/examples/simple/simple_ext11/application.py +++ b/examples/simple/simple_ext11/application.py @@ -1,4 +1,5 @@ """A Jupyter Server example application.""" + import os from simple_ext1.application import SimpleApp1 # type:ignore[import-not-found] diff --git a/examples/simple/simple_ext2/__init__.py b/examples/simple/simple_ext2/__init__.py index 3059dbda49..d56053a66a 100644 --- a/examples/simple/simple_ext2/__init__.py +++ b/examples/simple/simple_ext2/__init__.py @@ -1,8 +1,9 @@ """The extension entry point.""" + from .application import SimpleApp2 -def _jupyter_server_extension_paths(): +def _jupyter_server_extension_points(): return [ {"module": "simple_ext2.application", "app": SimpleApp2}, ] diff --git a/examples/simple/simple_ext2/__main__.py b/examples/simple/simple_ext2/__main__.py index 465db9c1c2..df0df26c1e 100644 --- a/examples/simple/simple_ext2/__main__.py +++ b/examples/simple/simple_ext2/__main__.py @@ -1,4 +1,5 @@ """The application cli main.""" + from .application import main if __name__ == "__main__": diff --git a/examples/simple/simple_ext2/application.py b/examples/simple/simple_ext2/application.py index b9da358131..d608e0c9a4 100644 --- a/examples/simple/simple_ext2/application.py +++ b/examples/simple/simple_ext2/application.py @@ -1,4 +1,5 @@ """A simple Jupyter Server extension example.""" + import os from traitlets import Unicode diff --git a/examples/simple/simple_ext2/handlers.py b/examples/simple/simple_ext2/handlers.py index 4f52e6f061..d5954f2192 100644 --- a/examples/simple/simple_ext2/handlers.py +++ b/examples/simple/simple_ext2/handlers.py @@ -1,4 +1,5 @@ """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 diff --git a/examples/simple/tests/test_handlers.py b/examples/simple/tests/test_handlers.py index 59b9d045ae..360789ed9a 100644 --- a/examples/simple/tests/test_handlers.py +++ b/examples/simple/tests/test_handlers.py @@ -1,4 +1,5 @@ """Tests for the simple handler.""" + import pytest diff --git a/jupyter_server/__init__.py b/jupyter_server/__init__.py index 3d85bbd2c8..9b4cf72ea8 100644 --- a/jupyter_server/__init__.py +++ b/jupyter_server/__init__.py @@ -1,4 +1,5 @@ """The Jupyter Server""" + import os import pathlib diff --git a/jupyter_server/_sysinfo.py b/jupyter_server/_sysinfo.py index f167c4e92a..bf38e78748 100644 --- a/jupyter_server/_sysinfo.py +++ b/jupyter_server/_sysinfo.py @@ -1,6 +1,7 @@ """ 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 @@ -41,7 +42,7 @@ def pkg_commit_hash(pkg_path): if p.exists(p.join(cur_path, ".git")): try: proc = subprocess.Popen( - ["git", "rev-parse", "--short", "HEAD"], + ["git", "rev-parse", "--short", "HEAD"], # noqa: S607 stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=pkg_path, diff --git a/jupyter_server/_tz.py b/jupyter_server/_tz.py index a7a495de85..ec0b1109e0 100644 --- a/jupyter_server/_tz.py +++ b/jupyter_server/_tz.py @@ -3,6 +3,7 @@ Just UTC-awareness right now """ + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations @@ -13,7 +14,7 @@ ZERO = timedelta(0) -class tzUTC(tzinfo): +class tzUTC(tzinfo): # noqa: N801 """tzinfo object for UTC (zero offset)""" def utcoffset(self, d: datetime | None) -> timedelta: diff --git a/jupyter_server/_version.py b/jupyter_server/_version.py index 5f42e23595..141e68282f 100644 --- a/jupyter_server/_version.py +++ b/jupyter_server/_version.py @@ -2,11 +2,12 @@ store the current version info of the server. """ + import re from typing import List # Version string must appear intact for automatic versioning -__version__ = "2.12.5" +__version__ = "2.14.2" # Build up version_info tuple for backwards compatibility pattern = r"(?P\d+).(?P\d+).(?P\d+)(?P.*)" diff --git a/jupyter_server/auth/__main__.py b/jupyter_server/auth/__main__.py index d1573f11a1..5e194547c9 100644 --- a/jupyter_server/auth/__main__.py +++ b/jupyter_server/auth/__main__.py @@ -1,4 +1,5 @@ """The cli for auth.""" + import argparse import sys import warnings diff --git a/jupyter_server/auth/authorizer.py b/jupyter_server/auth/authorizer.py index aaeb3a6eea..10414e2c39 100644 --- a/jupyter_server/auth/authorizer.py +++ b/jupyter_server/auth/authorizer.py @@ -5,6 +5,7 @@ .. versionadded:: 2.0 """ + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations @@ -65,7 +66,7 @@ def is_authorized( bool True if user authorized to make request; False, otherwise """ - raise NotImplementedError() + raise NotImplementedError class AllowAllAuthorizer(Authorizer): diff --git a/jupyter_server/auth/decorator.py b/jupyter_server/auth/decorator.py index a92866b4e8..4128c39086 100644 --- a/jupyter_server/auth/decorator.py +++ b/jupyter_server/auth/decorator.py @@ -1,5 +1,5 @@ -"""Decorator for layering authorization into JupyterHandlers. -""" +"""Decorator for layering authorization into JupyterHandlers.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import asyncio @@ -85,3 +85,58 @@ async def inner(self, *args, **kwargs): 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) diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py index adeb567b5b..fd2b3db85d 100644 --- a/jupyter_server/auth/identity.py +++ b/jupyter_server/auth/identity.py @@ -5,6 +5,7 @@ .. versionadded:: 2.0 """ + from __future__ import annotations import binascii diff --git a/jupyter_server/auth/login.py b/jupyter_server/auth/login.py index 22832df341..4512127534 100644 --- a/jupyter_server/auth/login.py +++ b/jupyter_server/auth/login.py @@ -1,4 +1,5 @@ """Tornado handlers for logging into the Jupyter Server.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import os @@ -9,6 +10,7 @@ from tornado.escape import url_escape from ..base.handlers import JupyterHandler +from .decorator import allow_unauthenticated from .security import passwd_check, set_password @@ -73,6 +75,7 @@ def _redirect_safe(self, url, default=None): url = default self.redirect(url) + @allow_unauthenticated def get(self): """Get the login form.""" if self.current_user: @@ -81,6 +84,7 @@ def get(self): else: self._render() + @allow_unauthenticated def post(self): """Post a login.""" user = self.current_user = self.identity_provider.process_login_form(self) @@ -110,6 +114,7 @@ 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="") @@ -124,9 +129,9 @@ def post(self): 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.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) diff --git a/jupyter_server/auth/logout.py b/jupyter_server/auth/logout.py index 3db7f796ba..7c13c7cd36 100644 --- a/jupyter_server/auth/logout.py +++ b/jupyter_server/auth/logout.py @@ -1,13 +1,15 @@ -"""Tornado handlers for logging out of the Jupyter Server. -""" +"""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) diff --git a/jupyter_server/auth/security.py b/jupyter_server/auth/security.py index ede64db522..d46587ffcc 100644 --- a/jupyter_server/auth/security.py +++ b/jupyter_server/auth/security.py @@ -1,6 +1,7 @@ """ Password generation for the Jupyter Server. """ + import getpass import hashlib import json @@ -67,13 +68,13 @@ def passwd(passphrase=None, algorithm="argon2"): ) h_ph = ph.hash(passphrase) - return ":".join((algorithm, h_ph)) + 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 ":".join((algorithm, salt, h.hexdigest())) + return f"{algorithm}:{salt}:{h.hexdigest()}" def passwd_check(hashed_passphrase, passphrase): diff --git a/jupyter_server/auth/utils.py b/jupyter_server/auth/utils.py index b0f790be1f..bb77d9db32 100644 --- a/jupyter_server/auth/utils.py +++ b/jupyter_server/auth/utils.py @@ -1,5 +1,5 @@ -"""A module with various utility methods for authorization in Jupyter Server. -""" +"""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 @@ -166,4 +166,4 @@ 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)] + return moons_of_jupyter[random.randint(0, len(moons_of_jupyter) - 1)] # noqa: S311 diff --git a/jupyter_server/base/call_context.py b/jupyter_server/base/call_context.py index 3d989121c2..cf71256235 100644 --- a/jupyter_server/base/call_context.py +++ b/jupyter_server/base/call_context.py @@ -44,7 +44,7 @@ def get(cls, name: str) -> Any: 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) + return None # TODO: should this raise `LookupError` (or a custom error derived from said) @classmethod def set(cls, name: str, value: Any) -> None: diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py index f35166b09e..770fff1866 100644 --- a/jupyter_server/base/handlers.py +++ b/jupyter_server/base/handlers.py @@ -1,4 +1,5 @@ """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 @@ -10,20 +11,16 @@ import mimetypes import os import re -import sys import types -import typing as ty -import logging import warnings from http.client import responses from logging import Logger -from typing import TYPE_CHECKING, Any, Awaitable, Sequence, cast +from typing import TYPE_CHECKING, Any, Awaitable, Coroutine, Sequence, cast from urllib.parse import urlparse import prometheus_client from jinja2 import TemplateNotFound from jupyter_core.paths import is_hidden -from jupyter_events import EventLogger from tornado import web from tornado.log import app_log from traitlets.config import Application @@ -32,7 +29,7 @@ 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 authorized +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 @@ -47,6 +44,7 @@ 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 @@ -63,7 +61,6 @@ # ----------------------------------------------------------------------------- _sys_info_cache = None -_my_globals = "myglobals" def json_sys_info(): @@ -82,36 +79,9 @@ def log() -> Logger: return app_log -def get_token_value(request: ty.Any, prev: str) -> str: - header = "Authorization" - if header not in request.headers: - logging.error(f'Header "{header}" is missing') - return prev - logging.debug(f'Getting value from header "{header}"') - auth_header_value: str = request.headers[header] - if len(auth_header_value) == 0: - logging.error(f'Header "{header}" is empty') - return prev - - try: - logging.info(f"Auth header value: {auth_header_value}") - # We expect the header value to be of the form "Bearer: XXX" - return auth_header_value.split(" ", maxsplit=1)[1] - except Exception as e: - logging.error(f"Could not read token from auth header: {str(e)}") - - return prev - - class AuthenticatedHandler(web.RequestHandler): """A RequestHandler with an authenticated user.""" - def prepare(self): - if _my_globals not in sys.modules: - sys.modules[_my_globals] = types.ModuleType(_my_globals) - prevtoken = sys.modules[_my_globals].token if hasattr(sys.modules[_my_globals], "token") else "" - sys.modules[_my_globals].token = get_token_value(self.request, prevtoken) - @property def base_url(self) -> str: return cast(str, self.settings.get("base_url", "/")) @@ -620,7 +590,7 @@ def check_host(self) -> bool: ) return allow - async def prepare(self): + 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) @@ -661,6 +631,25 @@ async def prepare(self): 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() # --------------------------------------------------------------- @@ -757,7 +746,7 @@ def write_error(self, status_code: int, **kwargs: Any) -> None: class APIHandler(JupyterHandler): """Base class for API handlers""" - async def prepare(self) -> None: + async def prepare(self) -> None: # type:ignore[override] """Prepare an API response.""" await super().prepare() if not self.check_origin(): @@ -796,7 +785,7 @@ def get_login_url(self) -> str: @property def content_security_policy(self) -> str: - csp = "; ".join( + csp = "; ".join( # noqa: FLY002 [ super().content_security_policy, "default-src 'none'", @@ -825,6 +814,7 @@ def finish(self, *args: Any, **kwargs: Any) -> Future[Any]: 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", {}): @@ -868,7 +858,7 @@ def options(self, *args: Any, **kwargs: Any) -> None: class Template404(JupyterHandler): """Render our 404 template""" - async def prepare(self) -> None: + async def prepare(self) -> None: # type:ignore[override] """Prepare a 404 response.""" await super().prepare() raise web.HTTPError(404) @@ -1033,6 +1023,18 @@ 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""" @@ -1067,6 +1069,7 @@ class APIVersionHandler(APIHandler): _track_activity = False + @allow_unauthenticated def get(self) -> None: """Get the server version info.""" # not authenticated, so give as few info as possible @@ -1079,6 +1082,7 @@ class TrailingSlashHandler(web.RequestHandler): 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 @@ -1095,6 +1099,7 @@ def get(self) -> None: class MainHandler(JupyterHandler): """Simple handler for base_url.""" + @allow_unauthenticated def get(self) -> None: """Get the main template.""" html = self.render_template("main.html") @@ -1135,18 +1140,20 @@ async def redirect_to_files(self: Any, path: str) -> None: 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): - """Sam as web.RedirectHandler, but preserves URL parameters""" + """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 "?" @@ -1159,6 +1166,7 @@ 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: @@ -1167,13 +1175,18 @@ def get(self) -> None: self.set_header("Content-Type", prometheus_client.CONTENT_TYPE_LATEST) self.write(prometheus_client.generate_latest(prometheus_client.REGISTRY)) -def get_current_token(): - """ - Get :class:`tornado.httputil.HTTPServerRequest` that is currently being processed. - """ - if _my_globals in sys.modules and hasattr(sys.modules[_my_globals], "token"): - return sys.modules[_my_globals].token - return "" + +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 @@ -1190,6 +1203,6 @@ def get_current_token(): default_handlers = [ (r".*/", TrailingSlashHandler), (r"api", APIVersionHandler), - (r"/(robots\.txt|favicon\.ico)", web.StaticFileHandler), + (r"/(robots\.txt|favicon\.ico)", PublicStaticFileHandler), (r"/metrics", PrometheusMetricsHandler), ] diff --git a/jupyter_server/base/websocket.py b/jupyter_server/base/websocket.py index a27b7a72a7..ded74ed7a0 100644 --- a/jupyter_server/base/websocket.py +++ b/jupyter_server/base/websocket.py @@ -1,11 +1,16 @@ """Base websocket classes.""" + import re +import warnings from typing import Optional, no_type_check from urllib.parse import urlparse -from tornado import ioloop +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 @@ -82,6 +87,40 @@ def check_origin(self, origin: Optional[str] = None) -> bool: 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.""" diff --git a/jupyter_server/base/zmqhandlers.py b/jupyter_server/base/zmqhandlers.py index 4490380a34..07de3d9782 100644 --- a/jupyter_server/base/zmqhandlers.py +++ b/jupyter_server/base/zmqhandlers.py @@ -1,4 +1,5 @@ """This module is deprecated in Jupyter Server 2.0""" + # Raise a warning that this module is deprecated. import warnings diff --git a/jupyter_server/config_manager.py b/jupyter_server/config_manager.py index 87480d7609..4a0bff4015 100644 --- a/jupyter_server/config_manager.py +++ b/jupyter_server/config_manager.py @@ -1,4 +1,5 @@ """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 @@ -101,9 +102,12 @@ def get(self, section_name: str, include_root: bool = True) -> dict[str, t.Any]: ) data: dict[str, t.Any] = {} for path in paths: - if os.path.isfile(path): + if os.path.isfile(path) and os.path.getsize(path): with open(path, encoding="utf-8") as f: - recursive_update(data, json.load(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: diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index aeeab5a94d..32ac94cfcb 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -1,4 +1,5 @@ """An extension application.""" + from __future__ import annotations import logging @@ -358,7 +359,7 @@ def _prepare_handlers(self): ) new_handlers.append(handler) - webapp.add_handlers(".*$", new_handlers) # type:ignore[arg-type] + webapp.add_handlers(".*$", new_handlers) def _prepare_templates(self): """Add templates to web app settings if extension has templates.""" diff --git a/jupyter_server/extension/config.py b/jupyter_server/extension/config.py index 47b4f6cce1..e9bb4a7c78 100644 --- a/jupyter_server/extension/config.py +++ b/jupyter_server/extension/config.py @@ -1,4 +1,5 @@ """Extension config.""" + from jupyter_server.services.config.manager import ConfigManager DEFAULT_SECTION_NAME = "jupyter_server_config" diff --git a/jupyter_server/extension/handler.py b/jupyter_server/extension/handler.py index 55f5aff2c3..31377cc367 100644 --- a/jupyter_server/extension/handler.py +++ b/jupyter_server/extension/handler.py @@ -1,4 +1,5 @@ """An extension handler.""" + from __future__ import annotations from logging import Logger diff --git a/jupyter_server/extension/manager.py b/jupyter_server/extension/manager.py index 3509e2e9f6..b8c52ca9e5 100644 --- a/jupyter_server/extension/manager.py +++ b/jupyter_server/extension/manager.py @@ -1,4 +1,5 @@ """The extension manager.""" + from __future__ import annotations import importlib diff --git a/jupyter_server/extension/serverextension.py b/jupyter_server/extension/serverextension.py index 19f3a30709..b4c9dacbc2 100644 --- a/jupyter_server/extension/serverextension.py +++ b/jupyter_server/extension/serverextension.py @@ -1,4 +1,5 @@ """Utilities for installing extensions""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations diff --git a/jupyter_server/extension/utils.py b/jupyter_server/extension/utils.py index 1ba44ee0d2..f250191b25 100644 --- a/jupyter_server/extension/utils.py +++ b/jupyter_server/extension/utils.py @@ -1,4 +1,5 @@ """Extension utilities.""" + import importlib import time import warnings @@ -110,7 +111,7 @@ def validate_extension(name): hook or metadata field. An extension is valid if: 1) name is an importable Python package. - 1) the package has a _jupyter_server_extension_paths function + 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. diff --git a/jupyter_server/files/handlers.py b/jupyter_server/files/handlers.py index 043c581034..2c1dc5adf6 100644 --- a/jupyter_server/files/handlers.py +++ b/jupyter_server/files/handlers.py @@ -1,4 +1,5 @@ """Serve files directly from the ContentsManager.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations diff --git a/jupyter_server/gateway/connections.py b/jupyter_server/gateway/connections.py index ab794bf7c7..d4dde730fa 100644 --- a/jupyter_server/gateway/connections.py +++ b/jupyter_server/gateway/connections.py @@ -1,4 +1,5 @@ """Gateway connection classes.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations @@ -46,6 +47,8 @@ async def connect(self): 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) @@ -68,9 +71,7 @@ def _connection_done(self, fut): else: self.log.warning( "Websocket connection has been closed via client disconnect or due to error. " - "Kernel with ID '{}' may not be terminated on GatewayClient: {}".format( - self.kernel_id, GatewayClient.instance().url - ) + f"Kernel with ID '{self.kernel_id}' may not be terminated on GatewayClient: {GatewayClient.instance().url}" ) def disconnect(self): @@ -109,7 +110,7 @@ async def _read_messages(self): # 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 + jitter = random.randint(10, 100) * 0.01 # noqa: S311 retry_interval = ( min( GatewayClient.instance().gateway_retry_interval * (2**self.retry), diff --git a/jupyter_server/gateway/gateway_client.py b/jupyter_server/gateway/gateway_client.py index a1ca0057fe..966cf03f35 100644 --- a/jupyter_server/gateway/gateway_client.py +++ b/jupyter_server/gateway/gateway_client.py @@ -1,4 +1,5 @@ """A kernel gateway client.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations @@ -295,7 +296,7 @@ def _http_user_default(self): help="""The password for HTTP authentication. (JUPYTER_GATEWAY_HTTP_PWD env var) """, ) - http_pwd_env = "JUPYTER_GATEWAY_HTTP_PWD" + http_pwd_env = "JUPYTER_GATEWAY_HTTP_PWD" # noqa: S105 @default("http_pwd") def _http_pwd_default(self): @@ -346,7 +347,7 @@ def _auth_header_key_default(self): (JUPYTER_GATEWAY_AUTH_TOKEN env var)""", ) - auth_token_env = "JUPYTER_GATEWAY_AUTH_TOKEN" + auth_token_env = "JUPYTER_GATEWAY_AUTH_TOKEN" # noqa: S105 @default("auth_token") def _auth_token_default(self): @@ -457,9 +458,9 @@ 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" + "jupyter_server.gateway.gateway_client.NoOpTokenRenewer" # noqa: S105 ) - gateway_token_renewer_class_env = "JUPYTER_GATEWAY_TOKEN_RENEWER_CLASS" + gateway_token_renewer_class_env = "JUPYTER_GATEWAY_TOKEN_RENEWER_CLASS" # noqa: S105 gateway_token_renewer_class = Type( klass=GatewayTokenRenewerBase, config=True, diff --git a/jupyter_server/gateway/handlers.py b/jupyter_server/gateway/handlers.py index dcde4cd5ca..ba0758ea06 100644 --- a/jupyter_server/gateway/handlers.py +++ b/jupyter_server/gateway/handlers.py @@ -1,4 +1,5 @@ """Gateway API handlers.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations @@ -200,9 +201,7 @@ def _connection_done(self, fut): else: self.log.warning( "Websocket connection has been closed via client disconnect or due to error. " - "Kernel with ID '{}' may not be terminated on GatewayClient: {}".format( - self.kernel_id, GatewayClient.instance().url - ) + f"Kernel with ID '{self.kernel_id}' may not be terminated on GatewayClient: {GatewayClient.instance().url}" ) def _disconnect(self): @@ -239,7 +238,7 @@ async def _read_messages(self, callback): # 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 + jitter = random.randint(10, 100) * 0.01 # noqa: S311 retry_interval = ( min( GatewayClient.instance().gateway_retry_interval * (2**self.retry), @@ -298,8 +297,8 @@ async def get(self, kernel_name, path, include_body=True): ) if kernel_spec_res is None: self.log.warning( - "Kernelspec resource '{}' for '{}' not found. Gateway may not support" - " resource serving.".format(path, kernel_name) + 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" diff --git a/jupyter_server/gateway/managers.py b/jupyter_server/gateway/managers.py index 2d87b0a62b..0ac47f8f57 100644 --- a/jupyter_server/gateway/managers.py +++ b/jupyter_server/gateway/managers.py @@ -1,4 +1,5 @@ """Kernel gateway managers.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations @@ -7,11 +8,10 @@ import datetime import json import os -from logging import Logger from queue import Empty, Queue from threading import Thread from time import monotonic -from typing import Any, Optional, cast +from typing import TYPE_CHECKING, Any, Optional, cast import websocket from jupyter_client.asynchronous.client import AsyncKernelClient @@ -33,7 +33,9 @@ from ..utils import url_path_join from .gateway_client import GatewayClient, gateway_request -_local_kernels: dict[str, ServerKernelManager] = {} +if TYPE_CHECKING: + from logging import Logger + class GatewayMappingKernelManager(AsyncMappingKernelManager): """Kernel manager that supports remote kernels hosted by Jupyter Kernel or Enterprise Gateway.""" @@ -76,44 +78,20 @@ async def start_kernel(self, *, kernel_id=None, path=None, **kwargs): The API path (unicode, '/' delimited) for the cwd. Will be transformed to an OS path relative to root_dir. """ - kernel_name = kwargs.get("kernel_name") - if kernel_name == 'python3' or kernel_name.startswith('sc-'): - kwargs["kernel_name"] = "python3" - kwargs["local"] = True - env = kwargs["env"] - if kernel_name.startswith('sc-'): - app = kernel_name - startup_file = f"/tmp/{app}.py" - if os.getenv("CHOWN_HOME", "no") == "yes": - startup_content = f"""from pyspark.sql import SparkSession -spark = SparkSession.builder.appName('{kernel_name}').remote('sc://{app}-driver-svc.spark-apps.svc.cluster.local').getOrCreate() -""" - else: - app_short = app[0:-17] - startup_content = f"""from ocean_spark_connect.ocean_spark_session import OceanSparkSession -spark = OceanSparkSession.Builder().cluster_id("osc-739db584").appid("{app_short}").profile("default").getOrCreate() -""" - env["PYTHONSTARTUP"] = startup_file - with open(startup_file, "w") as f: - f.write(startup_content) - kernel_id = await super().start_kernel(kernel_id=kernel_id, path=path, **kwargs) - _local_kernels[kernel_id] = self._kernels[kernel_id] - return kernel_id - else: - 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 + 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 @@ -124,17 +102,11 @@ async def kernel_model(self, kernel_id): kernel_id : uuid The uuid of the kernel. """ - if kernel_id in _local_kernels: - str_kernel_id = str(kernel_id) - model = super().kernel_model(kernel_id) - _local_kernels[str_kernel_id] = model - return model - else: - model = None - km = self.get_kernel(str(kernel_id)) - if km: # type:ignore[truthy-bool] - model = km.kernel # type:ignore[attr-defined] - return model + 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. @@ -156,7 +128,7 @@ async def list_kernels(self, **kwargs): # 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.items(): + 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( @@ -261,7 +233,7 @@ 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 "?user=".join([default_endpoint, kernel_user]) + return f"{default_endpoint}?user={kernel_user}" return default_endpoint def _replace_path_kernelspec_resources(self, kernel_specs): @@ -465,22 +437,19 @@ async def refresh_model(self, model=None): model is fetched from the Gateway server. """ if model is None: - if self.kernel_id in _local_kernels: - model = _local_kernels[self.kernel_id] - else: - 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 + 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: - model = json_decode(response.body) - self.log.debug("Kernel retrieved: %s" % model) + 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( @@ -515,13 +484,6 @@ async def start_kernel(self, **kwargs): """ kernel_id = kwargs.get("kernel_id") - if "local" in kwargs: - kwargs.pop("local") - 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() - await super().start_kernel(**kwargs) - if kernel_id is None: kernel_name = kwargs.get("kernel_name", "python3") self.log.debug("Request new kernel at: %s" % self.kernels_url) @@ -712,9 +674,7 @@ def stop(self) -> None: return if len(msgs): self.log.warning( - "Stopping channel '{}' with {} unprocessed non-status messages: {}.".format( - self.channel_name, len(msgs), msgs - ) + f"Stopping channel '{self.channel_name}' with {len(msgs)} unprocessed non-status messages: {msgs}." ) def is_alive(self) -> bool: diff --git a/jupyter_server/i18n/__init__.py b/jupyter_server/i18n/__init__.py index 896f41c57c..5f41dbc410 100644 --- a/jupyter_server/i18n/__init__.py +++ b/jupyter_server/i18n/__init__.py @@ -1,5 +1,5 @@ -"""Server functions for loading translations -""" +"""Server functions for loading translations""" + from __future__ import annotations import errno diff --git a/jupyter_server/kernelspecs/handlers.py b/jupyter_server/kernelspecs/handlers.py index c7cb141459..c1d50660d2 100644 --- a/jupyter_server/kernelspecs/handlers.py +++ b/jupyter_server/kernelspecs/handlers.py @@ -1,4 +1,5 @@ """Kernelspecs API Handlers.""" + import mimetypes from jupyter_core.utils import ensure_async @@ -44,10 +45,8 @@ async def get(self, kernel_name, path, include_body=True): return None else: self.log.warning( - "Kernelspec resource '{}' for '{}' not found. Kernel spec manager may" - " not support resource serving. Falling back to reading from disk".format( - path, kernel_name - ) + 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)) diff --git a/jupyter_server/log.py b/jupyter_server/log.py index 705eaaf44c..aed024bb32 100644 --- a/jupyter_server/log.py +++ b/jupyter_server/log.py @@ -1,4 +1,5 @@ """Log utilities.""" + # ----------------------------------------------------------------------------- # Copyright (c) Jupyter Development Team # diff --git a/jupyter_server/nbconvert/handlers.py b/jupyter_server/nbconvert/handlers.py index b7a39d0c8b..65d502f61a 100644 --- a/jupyter_server/nbconvert/handlers.py +++ b/jupyter_server/nbconvert/handlers.py @@ -1,4 +1,5 @@ """Tornado handlers for nbconvert.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import io @@ -73,15 +74,15 @@ def get_exporter(format, **kwargs): raise web.HTTPError(500, "Could not import nbconvert: %s" % e) from e try: - Exporter = get_exporter(format) + 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) + return exporter(**kwargs) except Exception as e: - app_log.exception("Could not construct Exporter: %s", Exporter) + app_log.exception("Could not construct Exporter: %s", exporter) raise web.HTTPError(500, "Could not construct Exporter: %s" % e) from e diff --git a/jupyter_server/prometheus/log_functions.py b/jupyter_server/prometheus/log_functions.py index ac4bd620c1..39bb60ea59 100644 --- a/jupyter_server/prometheus/log_functions.py +++ b/jupyter_server/prometheus/log_functions.py @@ -1,4 +1,5 @@ """Log functions for prometheus""" + from .metrics import HTTP_REQUEST_DURATION_SECONDS # type:ignore[unused-ignore] diff --git a/jupyter_server/pytest_plugin.py b/jupyter_server/pytest_plugin.py index f77448f866..3d397e65ef 100644 --- a/jupyter_server/pytest_plugin.py +++ b/jupyter_server/pytest_plugin.py @@ -1,4 +1,5 @@ """Pytest Fixtures exported by Jupyter Server.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json @@ -18,8 +19,8 @@ } -@pytest.fixture() # type:ignore[misc] -def jp_kernelspecs(jp_data_dir: Path) -> None: # noqa: PT004 +@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: @@ -42,7 +43,7 @@ def jp_contents_manager(request, tmp_path): return AsyncFileContentsManager(root_dir=str(tmp_path), use_atomic_writing=request.param) -@pytest.fixture() +@pytest.fixture def jp_large_contents_manager(tmp_path): """Returns an AsyncLargeFileManager instance.""" return AsyncLargeFileManager(root_dir=str(tmp_path)) diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index d0f54ba858..3dedd5634f 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1,4 +1,5 @@ """A tornado based Jupyter server.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations @@ -30,7 +31,6 @@ from pathlib import Path import jupyter_client -import pydevd_pycharm from jupyter_client.kernelspec import KernelSpecManager from jupyter_client.manager import KernelManager from jupyter_client.session import Session @@ -42,10 +42,19 @@ 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, @@ -116,6 +125,7 @@ ) from jupyter_server.services.sessions.sessionmanager import SessionManager from jupyter_server.utils import ( + JupyterServerAuthWarning, check_pid, fetch, unix_socket_in_use, @@ -204,7 +214,7 @@ def random_ports(port: int, n: int) -> t.Generator[int, None, None]: 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)) + yield max(1, port + random.randint(-2 * n, 2 * n)) # noqa: S311 def load_handlers(name: str) -> t.Any: @@ -241,16 +251,9 @@ def __init__( authorizer=None, identity_provider=None, kernel_websocket_connection_class=None, - local_kernel_websocket_connection_class=None, + websocket_ping_interval=None, + websocket_ping_timeout=None, ): - if os.getenv("PYDEVD_PYCHARM", "False") == "True": - warnings.warn( - "PYDEVD_PYCHARM is set to True. This is not recommended for production", - UserWarning, - stacklevel=2, - ) - pydevd_pycharm.settrace('localhost', port=4321, stdoutToServer=True, stderrToServer=True) - """Initialize a server web application.""" if identity_provider is None: warnings.warn( @@ -265,7 +268,7 @@ def __init__( warnings.warn( "authorizer unspecified. Using permissive AllowAllAuthorizer." " Specify an authorizer to avoid this message.", - RuntimeWarning, + JupyterServerAuthWarning, stacklevel=2, ) authorizer = AllowAllAuthorizer(parent=jupyter_app, identity_provider=identity_provider) @@ -287,12 +290,54 @@ def __init__( authorizer=authorizer, identity_provider=identity_provider, kernel_websocket_connection_class=kernel_websocket_connection_class, - local_kernel_websocket_connection_class=local_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, @@ -312,7 +357,8 @@ def init_settings( authorizer=None, identity_provider=None, kernel_websocket_connection_class=None, - local_kernel_websocket_connection_class=None, + websocket_ping_interval=None, + websocket_ping_timeout=None, ): """Initialize settings for the web application.""" _template_path = settings_overrides.get( @@ -326,7 +372,7 @@ def init_settings( jenv_opt: dict[str, t.Any] = {"autoescape": True} jenv_opt.update(jinja_env_options if jinja_env_options else {}) - env = Environment( + env = Environment( # noqa: S701 loader=FileSystemLoader(template_path), extensions=["jinja2.ext.i18n"], **jenv_opt ) sys_info = get_sys_info() @@ -382,6 +428,7 @@ def init_settings( "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, @@ -395,7 +442,8 @@ def init_settings( "identity_provider": identity_provider, "event_logger": event_logger, "kernel_websocket_connection_class": kernel_websocket_connection_class, - "local_kernel_websocket_connection_class": local_kernel_websocket_connection_class, + "websocket_ping_interval": websocket_ping_interval, + "websocket_ping_timeout": websocket_ping_timeout, # handlers "extra_services": extra_services, # Jupyter stuff @@ -499,6 +547,40 @@ def last_activity(self): 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. @@ -1057,8 +1139,9 @@ def _default_cookie_secret_file(self) -> str: b"", config=True, help="""The random bytes used to secure cookies. - By default this is a new random number every time you start the server. - Set it to a value in a config file to enable logins to persist across server sessions. + 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). @@ -1128,9 +1211,9 @@ def _default_min_open_files_limit(self) -> t.Optional[int]: soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) - DEFAULT_SOFT = 4096 - if hard >= DEFAULT_SOFT: - return DEFAULT_SOFT + 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)", @@ -1227,6 +1310,33 @@ def _deprecated_password_config(self, change: t.Any) -> None: """, ) + _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 @@ -1520,12 +1630,6 @@ def _default_session_manager_class(self) -> t.Union[str, type[SessionManager]]: help=_i18n("The kernel websocket connection class to use."), ) - local_kernel_websocket_connection_class = Type( - klass=BaseKernelWebsocketConnection, - config=True, - help=_i18n("The local kernel websocket connection class to use."), - ) - @default("kernel_websocket_connection_class") def _default_kernel_websocket_connection_class( self, @@ -1534,11 +1638,31 @@ def _default_kernel_websocket_connection_class( return "jupyter_server.gateway.connections.GatewayWebSocketConnection" return ZMQChannelsWebsocketConnection - @default("local_kernel_websocket_connection_class") - def _default_local_kernel_websocket_connection_class( - self, - ) -> t.Union[str, type[ZMQChannelsWebsocketConnection]]: - 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, @@ -1731,7 +1855,9 @@ def _root_dir_changed(self, change: t.Any) -> None: preferred_dir = Unicode( config=True, - help=trans.gettext("Preferred starting directory to use for notebooks and kernels."), + 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") @@ -2064,9 +2190,9 @@ def init_webapp(self) -> None: # 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["get_secure_cookie_kwargs"] = ( + self.identity_provider.get_secure_cookie_kwargs + ) self.tornado_settings["token"] = self.identity_provider.token if self.static_immutable_cache: @@ -2126,7 +2252,8 @@ def init_webapp(self) -> None: authorizer=self.authorizer, identity_provider=self.identity_provider, kernel_websocket_connection_class=self.kernel_websocket_connection_class, - local_kernel_websocket_connection_class=self.local_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 @@ -2189,7 +2316,7 @@ def _get_urlparts( if not self.ip: ip = "localhost" # Handle nonexplicit hostname. - elif self.ip in ("0.0.0.0", "::"): + 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 @@ -2294,7 +2421,7 @@ def _confirm_exit(self) -> None: info(self.running_server_info()) yes = _i18n("y") no = _i18n("n") - sys.stdout.write(_i18n("Shutdown this Jupyter server (%s/[%s])? ") % (yes, no)) + 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: @@ -2912,9 +3039,9 @@ def start_app(self) -> None: "", ( "UNIX sockets are not browser-connectable, but you can tunnel to " - "the instance via e.g.`ssh -L 8888:{} -N user@this_host` and then " - "open e.g. {} in a browser." - ).format(self.sock, self.connection_url), + 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." + ), ] ) ) diff --git a/jupyter_server/services/api/handlers.py b/jupyter_server/services/api/handlers.py index 8b9e44f9cf..22904fdb07 100644 --- a/jupyter_server/services/api/handlers.py +++ b/jupyter_server/services/api/handlers.py @@ -1,4 +1,5 @@ """Tornado handlers for api specifications.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json @@ -25,6 +26,11 @@ 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): diff --git a/jupyter_server/services/config/handlers.py b/jupyter_server/services/config/handlers.py index 743c98ef0b..05621e5506 100644 --- a/jupyter_server/services/config/handlers.py +++ b/jupyter_server/services/config/handlers.py @@ -1,4 +1,5 @@ """Tornado handlers for frontend config storage.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json diff --git a/jupyter_server/services/config/manager.py b/jupyter_server/services/config/manager.py index 720c8e7bd7..d4e207e247 100644 --- a/jupyter_server/services/config/manager.py +++ b/jupyter_server/services/config/manager.py @@ -1,5 +1,5 @@ -"""Manager to read and modify frontend config data in JSON files. -""" +"""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 diff --git a/jupyter_server/services/contents/checkpoints.py b/jupyter_server/services/contents/checkpoints.py index e251f7b232..9f69958f4b 100644 --- a/jupyter_server/services/contents/checkpoints.py +++ b/jupyter_server/services/contents/checkpoints.py @@ -1,6 +1,7 @@ """ Classes for managing Checkpoints. """ + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from tornado.web import HTTPError diff --git a/jupyter_server/services/contents/filecheckpoints.py b/jupyter_server/services/contents/filecheckpoints.py index 522b3bbd01..7af1235066 100644 --- a/jupyter_server/services/contents/filecheckpoints.py +++ b/jupyter_server/services/contents/filecheckpoints.py @@ -1,6 +1,7 @@ """ File-based Checkpoints implementations. """ + import os import shutil diff --git a/jupyter_server/services/contents/fileio.py b/jupyter_server/services/contents/fileio.py index 19f84f4653..5799b57497 100644 --- a/jupyter_server/services/contents/fileio.py +++ b/jupyter_server/services/contents/fileio.py @@ -191,7 +191,7 @@ class FileManagerMixin(LoggingConfigurable, Configurable): use_atomic_writing = Bool( True, config=True, - help="""By default notebooks are saved on disk on a temporary file and then if succefully written, it replaces the old ones. + 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 )""", ) @@ -264,7 +264,8 @@ def _get_os_path(self, path): ------ 404: if path is outside root """ - self.log.debug("Reading path from disk: %s", path) + # 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]: diff --git a/jupyter_server/services/contents/filemanager.py b/jupyter_server/services/contents/filemanager.py index 09cbeec13a..96029d96d6 100644 --- a/jupyter_server/services/contents/filemanager.py +++ b/jupyter_server/services/contents/filemanager.py @@ -1,8 +1,10 @@ """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 @@ -704,11 +706,13 @@ def _get_dir_size(self, path="."): if platform.system() == "Darwin": # returns the size of the folder in KB result = subprocess.run( - ["du", "-sk", path], capture_output=True, check=True + ["du", "-sk", path], # noqa: S607 + capture_output=True, + check=True, ).stdout.split() else: result = subprocess.run( - ["du", "-s", "--block-size=1", path], + ["du", "-s", "--block-size=1", path], # noqa: S607 capture_output=True, check=True, ).stdout.split() @@ -1184,18 +1188,18 @@ async def _get_dir_size(self, path: str = ".") -> str: try: if platform.system() == "Darwin": # returns the size of the folder in KB - result = subprocess.run( - ["du", "-sk", path], capture_output=True, check=True - ).stdout.split() + args = ["-sk", path] else: - result = subprocess.run( - ["du", "-s", "--block-size=1", path], - capture_output=True, - check=True, - ).stdout.split() + 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}") - size = result[0].decode("utf-8") + 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!", diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index a7c7ffff17..ad25a2d3f7 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -2,6 +2,7 @@ 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 @@ -16,7 +17,7 @@ from jupyter_core.utils import ensure_async from tornado import web -from jupyter_server.auth.decorator import authorized +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 @@ -400,6 +401,7 @@ class NotebooksRedirectHandler(JupyterHandler): "DELETE", ) # type:ignore[assignment] + @allow_unauthenticated def get(self, path): """Handle a notebooks redirect.""" self.log.warning("/api/notebooks is deprecated, use /api/contents") diff --git a/jupyter_server/services/contents/largefilemanager.py b/jupyter_server/services/contents/largefilemanager.py index 938206100f..78f0d55629 100644 --- a/jupyter_server/services/contents/largefilemanager.py +++ b/jupyter_server/services/contents/largefilemanager.py @@ -151,5 +151,5 @@ async def _save_large_file(self, os_path, content, format): 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: + with open(os_path, "ab") as f: # noqa: ASYNC101 await run_sync(f.write, bcontent) diff --git a/jupyter_server/services/contents/manager.py b/jupyter_server/services/contents/manager.py index b12a2055ec..71e998992a 100644 --- a/jupyter_server/services/contents/manager.py +++ b/jupyter_server/services/contents/manager.py @@ -1,4 +1,5 @@ """A base class for contents managers.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import annotations diff --git a/jupyter_server/services/events/handlers.py b/jupyter_server/services/events/handlers.py index 1ca28b948c..82265ae0e4 100644 --- a/jupyter_server/services/events/handlers.py +++ b/jupyter_server/services/events/handlers.py @@ -2,17 +2,17 @@ .. versionadded:: 2.0 """ + from __future__ import annotations import json from datetime import datetime -from typing import Any, Dict, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, Optional, cast -import jupyter_events.logger from jupyter_core.utils import ensure_async from tornado import web, websocket -from jupyter_server.auth.decorator import authorized +from jupyter_server.auth.decorator import authorized, ws_authenticated from jupyter_server.base.handlers import JupyterHandler from ...base.handlers import APIHandler @@ -20,6 +20,10 @@ AUTH_RESOURCE = "events" +if TYPE_CHECKING: + import jupyter_events.logger + + class SubscribeWebsocket( JupyterHandler, websocket.WebSocketHandler, @@ -29,16 +33,11 @@ class SubscribeWebsocket( auth_resource = AUTH_RESOURCE async def pre_get(self): - """Handles authentication/authorization when + """Handles authorization when attempting to subscribe to events emitted by Jupyter Server's eventbus. """ - # authenticate the request before opening the websocket user = self.current_user - if user is None: - self.log.warning("Couldn't authenticate WebSocket connection") - raise web.HTTPError(403) - # authorize the user. authorized = await ensure_async( self.authorizer.is_authorized(self, user, "execute", "events") @@ -46,6 +45,7 @@ async def pre_get(self): 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()) diff --git a/jupyter_server/services/kernels/connection/base.py b/jupyter_server/services/kernels/connection/base.py index 1f6b2fdcf4..a0e0bae8b8 100644 --- a/jupyter_server/services/kernels/connection/base.py +++ b/jupyter_server/services/kernels/connection/base.py @@ -1,4 +1,5 @@ """Kernel connection helpers.""" + import json import struct from typing import Any, List @@ -162,19 +163,19 @@ def _default_session(self): async def connect(self): """Handle a connect.""" - raise NotImplementedError() + raise NotImplementedError async def disconnect(self): """Handle a disconnect.""" - raise NotImplementedError() + raise NotImplementedError def handle_incoming_message(self, incoming_msg: str) -> None: """Handle an incoming message.""" - raise NotImplementedError() + raise NotImplementedError def handle_outgoing_message(self, stream: str, outgoing_msg: List[Any]) -> None: """Handle an outgoing message.""" - raise NotImplementedError() + raise NotImplementedError KernelWebsocketConnectionABC.register(BaseKernelWebsocketConnection) diff --git a/jupyter_server/services/kernels/connection/channels.py b/jupyter_server/services/kernels/connection/channels.py index 05b9f6954e..78f2dc126e 100644 --- a/jupyter_server/services/kernels/connection/channels.py +++ b/jupyter_server/services/kernels/connection/channels.py @@ -1,4 +1,5 @@ """An implementation of a kernel connection.""" + from __future__ import annotations import asyncio @@ -372,7 +373,7 @@ def replay(value): pass # WebSockets don't respond to traditional error codes so we # close the connection. - for _, stream in self.channels.items(): + for stream in self.channels.values(): if not stream.closed(): stream.close() self.disconnect() @@ -384,7 +385,7 @@ def replay(value): ) def subscribe(value): - for _, stream in self.channels.items(): + for stream in self.channels.values(): stream.on_recv_stream(self.handle_outgoing_message) connected.add_done_callback(subscribe) @@ -429,7 +430,7 @@ def disconnect(self): # 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.items(): + for stream in self.channels.values(): if stream is not None and not stream.closed(): stream.on_recv(None) stream.close() diff --git a/jupyter_server/services/kernels/handlers.py b/jupyter_server/services/kernels/handlers.py index 217f0c9cc2..71a728d68a 100644 --- a/jupyter_server/services/kernels/handlers.py +++ b/jupyter_server/services/kernels/handlers.py @@ -2,6 +2,7 @@ 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 @@ -98,7 +99,7 @@ async def post(self, kernel_id, action): if action == "restart": try: await km.restart_kernel(kernel_id) - except Exception as e: + except Exception: message = "Exception restarting kernel" self.log.error(message, exc_info=True) self.write(json.dumps({"message": message, "traceback": ""})) diff --git a/jupyter_server/services/kernels/kernelmanager.py b/jupyter_server/services/kernels/kernelmanager.py index 451a279a4e..cc6ec8edc6 100644 --- a/jupyter_server/services/kernels/kernelmanager.py +++ b/jupyter_server/services/kernels/kernelmanager.py @@ -3,13 +3,14 @@ - 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 +import pathlib # noqa: TCH003 import typing as t import warnings from collections import defaultdict @@ -203,7 +204,7 @@ async def _remove_kernel_when_ready(self, 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 + # 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 @@ -242,7 +243,12 @@ async def _async_start_kernel( # type:ignore[override] 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: %r", kwargs) + 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] # Increase the metric of number of kernels running # for the relevant kernel type by 1 @@ -336,7 +342,7 @@ def start_buffering(self, kernel_id, session_key, channels): """ if not self.buffer_offline_messages: - for _, stream in channels.items(): + for stream in channels.values(): stream.close() return diff --git a/jupyter_server/services/kernels/websocket.py b/jupyter_server/services/kernels/websocket.py index dbe21aec63..86ff772a62 100644 --- a/jupyter_server/services/kernels/websocket.py +++ b/jupyter_server/services/kernels/websocket.py @@ -6,6 +6,7 @@ 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 @@ -39,11 +40,7 @@ def get_compression_options(self): async def pre_get(self): """Handle a pre_get.""" - # authenticate first user = self.current_user - if user is None: - self.log.warning("Couldn't authenticate WebSocket connection") - raise web.HTTPError(403) # authorize the user. authorized = await ensure_async( @@ -75,6 +72,7 @@ async def pre_get(self): 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 diff --git a/jupyter_server/services/kernelspecs/handlers.py b/jupyter_server/services/kernelspecs/handlers.py index 049b58fa83..15a7ac4b1a 100644 --- a/jupyter_server/services/kernelspecs/handlers.py +++ b/jupyter_server/services/kernelspecs/handlers.py @@ -2,6 +2,7 @@ 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 diff --git a/jupyter_server/services/nbconvert/handlers.py b/jupyter_server/services/nbconvert/handlers.py index bc0d38b9f9..cb4fe1b2ed 100644 --- a/jupyter_server/services/nbconvert/handlers.py +++ b/jupyter_server/services/nbconvert/handlers.py @@ -1,4 +1,5 @@ """API Handlers for nbconvert.""" + import asyncio import json diff --git a/jupyter_server/services/security/handlers.py b/jupyter_server/services/security/handlers.py index 2e248c3e70..329f8b77ea 100644 --- a/jupyter_server/services/security/handlers.py +++ b/jupyter_server/services/security/handlers.py @@ -1,4 +1,5 @@ """Tornado handlers for security logging.""" + # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from tornado import web diff --git a/jupyter_server/services/sessions/handlers.py b/jupyter_server/services/sessions/handlers.py index 53093a328d..5f2429a0ce 100644 --- a/jupyter_server/services/sessions/handlers.py +++ b/jupyter_server/services/sessions/handlers.py @@ -2,6 +2,7 @@ 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 diff --git a/jupyter_server/services/sessions/sessionmanager.py b/jupyter_server/services/sessions/sessionmanager.py index ddf785f849..8b392b4e1b 100644 --- a/jupyter_server/services/sessions/sessionmanager.py +++ b/jupyter_server/services/sessions/sessionmanager.py @@ -5,7 +5,6 @@ import os import pathlib import uuid -from asyncio import Task from typing import Any, Dict, List, NewType, Optional, Union, cast KernelName = NewType("KernelName", str) @@ -17,7 +16,6 @@ # fallback on pysqlite2 if Python was build without sqlite from pysqlite2 import dbapi2 as sqlite3 # type:ignore[no-redef] -import asyncio from dataclasses import dataclass, fields from jupyter_core.utils import ensure_async @@ -212,8 +210,6 @@ def __init__(self, *args, **kwargs): _connection = None _columns = {"session_id", "path", "name", "type", "kernel_id"} - fut_kernel_id_dict: Optional[Dict[str, Task[str]]] = None - @property def cursor(self): """Start a cursor and create a database called 'session'""" @@ -271,7 +267,6 @@ async def create_session( type: Optional[str] = None, kernel_name: Optional[KernelName] = None, kernel_id: Optional[str] = None, - session_id: Optional[str] = None, ) -> Dict[str, Any]: """Creates a session and returns its model @@ -281,13 +276,7 @@ async def create_session( Usually the model name, like the filename associated with current kernel. """ - - if session_id is not None and self.fut_kernel_id_dict is None: - self.fut_kernel_id_dict = {} - - if session_id is None or session_id == "": - session_id = self.new_session_id() - + 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: @@ -348,31 +337,14 @@ async def start_kernel_for_session( 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 - if self.fut_kernel_id_dict is not None: - if session_id in self.fut_kernel_id_dict: - fut_kernel_id = self.fut_kernel_id_dict[session_id] - if fut_kernel_id.done(): - kernel_id = await fut_kernel_id - self.fut_kernel_id_dict.pop(session_id) - return kernel_id - else: - kernel_path = await ensure_async(self.contents_manager.get_kernel_path(path=path)) - kernel_env = self.get_kernel_env(path) - self.fut_kernel_id_dict[session_id] = asyncio.create_task(self.kernel_manager.start_kernel( - path=kernel_path, - kernel_name=kernel_name, - env=kernel_env, - )) - kernel_id = "waiting" - else: - kernel_path = await ensure_async(self.contents_manager.get_kernel_path(path=path)) + 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, - ) + 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): @@ -425,48 +397,37 @@ async def get_session(self, **kwargs): returns a dictionary that includes all the information from the session described by the kwarg. """ - session_id = kwargs["session_id"] - if self.fut_kernel_id_dict is not None and session_id in self.fut_kernel_id_dict: - model = { - "id": session_id, - "name": "Waiting for kernel to start", - "last_activity": None, - "execution_state": "waiting", - "connections": 0, - } - else: - if not kwargs: - msg = "must specify a column to query" + 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 = [] - 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)) - - 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 - + 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): @@ -495,7 +456,7 @@ async def update_session(self, session_id, **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)) + 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"): diff --git a/jupyter_server/services/shutdown.py b/jupyter_server/services/shutdown.py index a8c6787f0e..24486a8432 100644 --- a/jupyter_server/services/shutdown.py +++ b/jupyter_server/services/shutdown.py @@ -1,5 +1,5 @@ -"""HTTP handler to shut down the Jupyter server. -""" +"""HTTP handler to shut down the Jupyter server.""" + from tornado import ioloop, web from jupyter_server.auth.decorator import authorized diff --git a/jupyter_server/templates/page.html b/jupyter_server/templates/page.html index d2c70d0187..a3e75216d2 100644 --- a/jupyter_server/templates/page.html +++ b/jupyter_server/templates/page.html @@ -34,8 +34,7 @@