From b847905285c290bf1352e7709f1389e5c2e6cc5d Mon Sep 17 00:00:00 2001 From: Zsailer Date: Thu, 21 Aug 2025 14:42:39 +0000 Subject: [PATCH 01/48] Bump to 2.18.0.dev0 --- jupyter_server/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyter_server/_version.py b/jupyter_server/_version.py index 70245bfeab..eb2ead9170 100644 --- a/jupyter_server/_version.py +++ b/jupyter_server/_version.py @@ -6,7 +6,7 @@ import re # Version string must appear intact for automatic versioning -__version__ = "2.17.0" +__version__ = "2.18.0.dev0" # Build up version_info tuple for backwards compatibility pattern = r"(?P\d+).(?P\d+).(?P\d+)(?P.*)" From 8f99062f28e34984cfba6fe92e8f34a9f53698fd Mon Sep 17 00:00:00 2001 From: Kevin Bates Date: Thu, 18 Sep 2025 11:19:35 -0700 Subject: [PATCH 02/48] Fix gateway cookie handling (#1558) Co-authored-by: Kevin Bates --- jupyter_server/gateway/gateway_client.py | 49 +++++++++++++-------- pyproject.toml | 1 + tests/test_gateway.py | 55 +++++++++++++++--------- 3 files changed, 67 insertions(+), 38 deletions(-) diff --git a/jupyter_server/gateway/gateway_client.py b/jupyter_server/gateway/gateway_client.py index 966cf03f35..533836730f 100644 --- a/jupyter_server/gateway/gateway_client.py +++ b/jupyter_server/gateway/gateway_client.py @@ -12,12 +12,13 @@ from abc import ABC, ABCMeta, abstractmethod from datetime import datetime, timezone from email.utils import parsedate_to_datetime -from http.cookies import SimpleCookie +from http.cookies import Morsel, SimpleCookie from socket import gaierror from jupyter_events import EventLogger from tornado import web from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse +from tornado.httputil import HTTPHeaders from traitlets import ( Bool, Float, @@ -40,9 +41,6 @@ STATUS_CODE_KEY = "status_code" MESSAGE_KEY = "msg" -if ty.TYPE_CHECKING: - from http.cookies import Morsel - class GatewayTokenRenewerMeta(ABCMeta, type(LoggingConfigurable)): # type: ignore[misc] """The metaclass necessary for proper ABC behavior in a Configurable.""" @@ -630,20 +628,41 @@ def load_connection_args(self, **kwargs): return kwargs - def update_cookies(self, cookie: SimpleCookie) -> None: - """Update cookies from existing requests for load balancers""" + def update_cookies(self, headers: HTTPHeaders) -> None: + """Update cookies from response headers""" + if not self.accept_cookies: return + # Get individual Set-Cookie headers in list form. This handles multiple cookies + # that are otherwise comma-separated in the header and will break the parsing logic + # if only headers.get() is used. + cookie_headers = headers.get_list("Set-Cookie") + if not cookie_headers: + return + store_time = datetime.now(tz=timezone.utc) - for key, item in cookie.items(): + for header in cookie_headers: + cookie = SimpleCookie() + try: + cookie.load(header) + except Exception as e: + self.log.warning("Failed to parse cookie header %s: %s", header, e) + continue + + if not cookie: + self.log.warning("No cookies found in header: %s", header) + continue + name, morsel = next(iter(cookie.items())) + # Convert "expires" arg into "max-age" to facilitate expiration management. # As "max-age" has precedence, ignore "expires" when "max-age" exists. - if item.get("expires") and not item.get("max-age"): - expire_timedelta = parsedate_to_datetime(item["expires"]) - store_time - item["max-age"] = str(expire_timedelta.total_seconds()) + if morsel.get("expires") and not morsel.get("max-age"): + expire_time = parsedate_to_datetime(morsel["expires"]) + expire_timedelta = expire_time - store_time + morsel["max-age"] = str(expire_timedelta.total_seconds()) - self._cookies[key] = (item, store_time) + self._cookies[name] = (morsel, store_time) def _clear_expired_cookies(self) -> None: """Clear expired cookies.""" @@ -821,10 +840,6 @@ async def gateway_request(endpoint: str, **kwargs: ty.Any) -> HTTPResponse: raise e if gateway_client.accept_cookies: - # Update cookies on GatewayClient from server if configured. - cookie_values = response.headers.get("Set-Cookie") - if cookie_values: - cookie: SimpleCookie = SimpleCookie() - cookie.load(cookie_values) - gateway_client.update_cookies(cookie) + gateway_client.update_cookies(response.headers) + return response diff --git a/pyproject.toml b/pyproject.toml index eb2a0ed58c..81fbb5bb7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,6 +156,7 @@ unfixable = [ [tool.ruff.lint.extend-per-file-ignores] "jupyter_server/*" = ["S101", "RET", "S110", "UP031", "FBT", "FA100", "SLF001", "A002", "SIM105", "A001", "UP007", "PLR2004", "T201", "N818", "F403"] +"jupyter_server/gateway/*" = ["TCH" ] "tests/*" = ["UP031", "PT", 'EM', "TRY", "RET", "SLF", "C408", "F841", "FBT", "A002", "FLY", "N", "PERF", "ASYNC", "T201", "FA100", "E711", "INP", "TCH", "SIM105", "A001", "PLW0603"] "examples/*_config.py" = ["F821"] diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 00aa64f111..bf774bb431 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -6,8 +6,6 @@ import os import uuid from datetime import datetime, timedelta, timezone -from email.utils import format_datetime -from http.cookies import SimpleCookie from io import BytesIO from queue import Empty from typing import Any, Union @@ -18,7 +16,7 @@ from jupyter_core.utils import ensure_async from tornado.concurrent import Future from tornado.httpclient import HTTPRequest, HTTPResponse -from tornado.httputil import HTTPServerRequest +from tornado.httputil import HTTPHeaders, HTTPServerRequest from tornado.queues import Queue from tornado.web import HTTPError from traitlets import Int, Unicode @@ -376,12 +374,12 @@ def test_gateway_request_timeout_pad_option( @pytest.mark.parametrize( - "accept_cookies,expire_arg,expire_param,existing_cookies,cookie_exists", + "accept_cookies,expire_arg,expire_param,existing_cookies", [ - (False, None, None, "EXISTING=1", False), - (True, None, None, "EXISTING=1", True), - (True, "Expires", 180, None, True), - (True, "Max-Age", "-360", "EXISTING=1", False), + (False, None, 0, "EXISTING=1"), + (True, None, 0, "EXISTING=1"), + (True, "expires", 180, None), + (True, "Max-Age", -360, "EXISTING=1"), ], ) def test_gateway_request_with_expiring_cookies( @@ -390,35 +388,50 @@ def test_gateway_request_with_expiring_cookies( expire_arg, expire_param, existing_cookies, - cookie_exists, ): argv = [f"--GatewayClient.accept_cookies={accept_cookies}"] GatewayClient.clear_instance() _ = jp_configurable_serverapp(argv=argv) - cookie: SimpleCookie = SimpleCookie() - cookie.load("SERVERID=1234567; Path=/") - if expire_arg == "Expires": - expire_param = format_datetime( - datetime.now(tz=timezone.utc) + timedelta(seconds=expire_param) + test_expiration = bool(expire_param < 0) + # Create mock headers with Set-Cookie values + headers = HTTPHeaders() + cookie_value = "SERVERID=1234567; Path=/; HttpOnly" + if expire_arg == "expires": + # Convert expire_param to a string in the format of "Expires: " (RFC 7231) + expire_param = (datetime.now(tz=timezone.utc) + timedelta(seconds=expire_param)).strftime( + "%a, %d %b %Y %H:%M:%S GMT" ) - if expire_arg: - cookie["SERVERID"][expire_arg] = expire_param + cookie_value = f"SERVERID=1234567; Path=/; expires={expire_param}; HttpOnly" + elif expire_arg == "Max-Age": + cookie_value = f"SERVERID=1234567; Path=/; Max-Age={expire_param}; HttpOnly" - GatewayClient.instance().update_cookies(cookie) + # Add a second cookie to test comma-separated cookies + headers.add("Set-Cookie", cookie_value) + headers.add("Set-Cookie", "ADDITIONAL_COOKIE=8901234; Path=/; HttpOnly") + + headers_list = headers.get_list("Set-Cookie") + print(headers_list) + + GatewayClient.instance().update_cookies(headers) args = {} if existing_cookies: args["headers"] = {"Cookie": existing_cookies} + connection_args = GatewayClient.instance().load_connection_args(**args) - if not cookie_exists: - assert "SERVERID" not in (connection_args["headers"].get("Cookie") or "") + if not accept_cookies or test_expiration: + # The first condition ensure the response cookie is not accepted, + # the second condition ensures that the cookie is not accepted if it is expired. + assert "SERVERID" not in connection_args["headers"].get("Cookie") else: - assert "SERVERID" in connection_args["headers"].get("Cookie") + # The cookie is accepted if it is not expired and accept_cookies is True. + assert "SERVERID" in connection_args["headers"].get("Cookie", "") + if existing_cookies: - assert "EXISTING" in connection_args["headers"].get("Cookie") + assert "EXISTING" in connection_args["headers"].get("Cookie", "") GatewayClient.clear_instance() From 987ebdd5e188cdc49751b01a0d6782d686492a53 Mon Sep 17 00:00:00 2001 From: dualc <294862532@qq.com> Date: Tue, 30 Sep 2025 02:07:38 +0800 Subject: [PATCH 03/48] fix context pollution (#1561) Co-authored-by: chengcong1 --- jupyter_server/base/call_context.py | 4 ++-- tests/base/test_call_context.py | 37 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/jupyter_server/base/call_context.py b/jupyter_server/base/call_context.py index 4e80be8a7d..13f6a762ed 100644 --- a/jupyter_server/base/call_context.py +++ b/jupyter_server/base/call_context.py @@ -41,7 +41,6 @@ def get(cls, name: str) -> Any: The value associated with the named variable for this call context """ name_value_map = CallContext._get_map() - if name in name_value_map: return name_value_map[name] return None # TODO: should this raise `LookupError` (or a custom error derived from said) @@ -61,8 +60,9 @@ def set(cls, name: str, value: Any) -> None: ------- None """ - name_value_map = CallContext._get_map() + name_value_map = CallContext._get_map().copy() name_value_map[name] = value + CallContext._name_value_map.set(name_value_map) @classmethod def context_variable_names(cls) -> list[str]: diff --git a/tests/base/test_call_context.py b/tests/base/test_call_context.py index 1c12338d61..f3e48522f5 100644 --- a/tests/base/test_call_context.py +++ b/tests/base/test_call_context.py @@ -1,4 +1,5 @@ import asyncio +from contextvars import copy_context from jupyter_server import CallContext from jupyter_server.auth.utils import get_anonymous_username @@ -107,3 +108,39 @@ async def context2(): # Assert that THIS context doesn't have any variables defined. names = CallContext.context_variable_names() assert len(names) == 0 + + +async def test_callcontext_with_copy_context_run(): + """ + Test scenario: + - The upper layer uses copy_context().run() + - Multiple contexts concurrently modify CallContext + - Verify that no context pollution occurs + """ + + async def context_task(name, value, delay): + """Coroutine task that modifies CallContext and validates its own values""" + await asyncio.sleep(delay) + CallContext.set(name, value) + # Sleep again to simulate interleaving execution + await asyncio.sleep(0.1) + assert CallContext.get(name) == value, f"{name} was polluted" + # Ensure only the variable written by this context exists + keys = CallContext.context_variable_names() + assert name in keys + assert len(keys) == 1 + + # Initialize a variable in the main context + CallContext.set("foo", "bar3") + + # Create two independent copy_context instances + ctx1 = copy_context() + ctx2 = copy_context() + + # Run coroutines in their respective contexts + fut1 = asyncio.create_task(ctx1.run(lambda: context_task("foo", "bar1", 0.0))) + fut2 = asyncio.create_task(ctx2.run(lambda: context_task("foo", "bar2", 0.05))) + await asyncio.gather(fut1, fut2) + + # The main context should remain unaffected (still is empty) + assert CallContext.get("foo") == "bar3" From 27b8578d6caa8f044d1b9a1121c2190a16ff7db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Thu, 30 Oct 2025 22:24:37 +0100 Subject: [PATCH 04/48] Replace `@flaky.flaky` decorate with pytest marker (#1544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michał Górny --- tests/services/kernels/test_api.py | 3 +-- tests/services/kernels/test_execution_state.py | 3 +-- tests/services/sessions/test_api.py | 3 +-- tests/test_terminal.py | 5 ++--- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/services/kernels/test_api.py b/tests/services/kernels/test_api.py index 60009e8978..8bea6acae1 100644 --- a/tests/services/kernels/test_api.py +++ b/tests/services/kernels/test_api.py @@ -7,7 +7,6 @@ import jupyter_client import pytest import tornado -from flaky import flaky from jupyter_client.kernelspec import NATIVE_KERNEL_NAME from tornado.httpclient import HTTPClientError @@ -257,7 +256,7 @@ async def test_kernel_handler_startup_error_pending( await jp_ws_fetch("api", "kernels", kid, "channels") -@flaky +@pytest.mark.flaky @pytest.mark.timeout(TEST_TIMEOUT) async def test_connection(jp_fetch, jp_ws_fetch, jp_http_port, jp_auth_header): # Create kernel diff --git a/tests/services/kernels/test_execution_state.py b/tests/services/kernels/test_execution_state.py index 50155ec76f..7625a16608 100644 --- a/tests/services/kernels/test_execution_state.py +++ b/tests/services/kernels/test_execution_state.py @@ -9,7 +9,6 @@ import jupyter_client import pytest -from flaky import flaky from tornado.httpclient import HTTPClientError from traitlets.config import Config @@ -18,7 +17,7 @@ MINIMUM_CONSISTENT_COUNT = 4 -@flaky +@pytest.mark.flaky async def test_execution_state(jp_fetch, jp_ws_fetch): r = await jp_fetch("api", "kernels", method="POST", allow_nonstandard_methods=True) kernel = json.loads(r.body.decode()) diff --git a/tests/services/sessions/test_api.py b/tests/services/sessions/test_api.py index a0502b544e..3a8ad5437b 100644 --- a/tests/services/sessions/test_api.py +++ b/tests/services/sessions/test_api.py @@ -9,7 +9,6 @@ import jupyter_client import pytest import tornado -from flaky import flaky from jupyter_client.ioloop import AsyncIOLoopKernelManager from nbformat import writes from nbformat.v4 import new_notebook @@ -505,7 +504,7 @@ async def test_modify_kernel_id(session_client, jp_fetch, jp_serverapp, session_ assert kernel_list == [kernel] -@flaky +@pytest.mark.flaky @pytest.mark.timeout(TEST_TIMEOUT) async def test_restart_kernel(session_client, jp_base_url, jp_fetch, jp_ws_fetch, session_is_ready): # Create a session. diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 8f35c7df60..d4e9464dd6 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -7,7 +7,6 @@ import warnings import pytest -from flaky import flaky # type:ignore[import-untyped] from tornado.httpclient import HTTPClientError from traitlets.config import Config @@ -230,7 +229,7 @@ async def test_terminal_create_with_bad_cwd(jp_fetch, jp_ws_fetch): assert non_existing_path not in message_stdout -@flaky +@pytest.mark.flaky def test_culling_config(jp_server_config, jp_configurable_serverapp): app = jp_configurable_serverapp() terminal_mgr_config = app.config.ServerApp.TerminalManager @@ -242,7 +241,7 @@ def test_culling_config(jp_server_config, jp_configurable_serverapp): assert terminal_mgr_settings.cull_interval == CULL_INTERVAL -@flaky +@pytest.mark.flaky async def test_culling(jp_server_config, jp_fetch): # POST request resp = await jp_fetch( From 028ca23592e53240d9b77d8d5c84d42eb08df8e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 17:23:35 -0800 Subject: [PATCH 05/48] Bump brace-expansion from 1.1.11 to 1.1.12 (#1546) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 546528db85..7660081ef2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,9 +49,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -425,9 +425,9 @@ "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==" }, "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" From dd435c3467f5a7659c02facfc66eb4e7789b59fc Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 13 Nov 2025 16:12:32 -0800 Subject: [PATCH 06/48] don't link check npm (#1568) --- .github/workflows/python-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 0b8ad119fd..fb571e1e13 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -173,6 +173,8 @@ jobs: - uses: actions/checkout@v5 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 + with: + ignore_links: "https://www.npmjs.com" integration_check: runs-on: ${{ matrix.os }} From e9ced8436f6ae93514ac8bb2c376957452baccac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krassowski?= <5832902+krassowski@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:40:20 +0000 Subject: [PATCH 07/48] Fix failing link check due to a blog post referred in docs changing URL format (#1577) --- docs/source/operators/public-server.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/operators/public-server.rst b/docs/source/operators/public-server.rst index dc534ca832..1a45e87fa7 100644 --- a/docs/source/operators/public-server.rst +++ b/docs/source/operators/public-server.rst @@ -415,7 +415,7 @@ Docker CMD Using ``jupyter server`` as a `Docker CMD `_ results in kernels repeatedly crashing, likely due to a lack of `PID reaping -`_. +`_. To avoid this, use the `tini `_ ``init`` as your Dockerfile ``ENTRYPOINT``:: From 10174ea96b727faabb9f24b578bac64ca877b05a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krassowski?= <5832902+krassowski@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:40:59 +0000 Subject: [PATCH 08/48] Fix flaky `test_execution_state` test (#1579) --- .github/workflows/python-tests.yml | 2 +- tests/services/kernels/test_execution_state.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index fb571e1e13..235e03e5bd 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -49,7 +49,7 @@ jobs: run: hatch run test:nowarn || hatch -v run test:nowarn --lf - name: Run the tests on windows if: ${{ startsWith(matrix.os, 'windows') }} - run: hatch run cov:nowarn -s || hatch -v run cov:nowarn --lf + run: hatch run cov:nowarn -s -k test_execution_state || hatch -v run cov:nowarn --lf - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 test_docs: diff --git a/tests/services/kernels/test_execution_state.py b/tests/services/kernels/test_execution_state.py index 7625a16608..ebe3a6c553 100644 --- a/tests/services/kernels/test_execution_state.py +++ b/tests/services/kernels/test_execution_state.py @@ -53,6 +53,17 @@ async def test_execution_state(jp_fetch, jp_ws_fetch): ) await poll_for_parent_message_status(kid, message_id, "busy", ws) es = await get_execution_state(kid, jp_fetch) + + # kernels start slowly on Windows + max_startup_time = 60 + started = time.time() + while es == "starting": + await asyncio.sleep(1) + elapsed = time.time() - started + if elapsed > max_startup_time: + raise ValueError(f"Kernel did not start up in {max_startup_time} seconds") + es = await get_execution_state(kid, jp_fetch) + assert es == "busy" message_id_2 = uuid.uuid1().hex From 5ffa86c6943d7b37df28a0c6bcd81c73fd58b4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krassowski?= <5832902+krassowski@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:52:21 +0000 Subject: [PATCH 09/48] Restore running all tests for Windows in CI workflow (#1581) --- .github/workflows/python-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 235e03e5bd..fb571e1e13 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -49,7 +49,7 @@ jobs: 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 -k test_execution_state || hatch -v 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: From c5c452c3d5f3060557e57caaadce8f2fffc1c417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krassowski?= <5832902+krassowski@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:10:50 +0000 Subject: [PATCH 10/48] Fix lint on CI, pin ruff to the same version in `hatch fmt` and `pre-commit` (#1576) Co-authored-by: Zachary Sailer --- .pre-commit-config.yaml | 3 +- .../jupyter_nbclassic_readonly_config.py | 4 +-- .../jupyter_nbclassic_rw_config.py | 4 +-- .../authorization/jupyter_temporary_config.py | 4 +-- jupyter_server/__init__.py | 6 ++-- jupyter_server/auth/decorator.py | 8 ++--- jupyter_server/auth/identity.py | 6 ++-- jupyter_server/base/handlers.py | 35 ++++++++++--------- jupyter_server/extension/handler.py | 25 ++++++------- jupyter_server/gateway/gateway_client.py | 2 +- jupyter_server/gateway/managers.py | 2 +- jupyter_server/kernelspecs/handlers.py | 2 +- jupyter_server/log.py | 2 +- jupyter_server/nbconvert/handlers.py | 2 +- jupyter_server/prometheus/metrics.py | 2 +- jupyter_server/serverapp.py | 11 +++--- jupyter_server/services/api/handlers.py | 2 +- jupyter_server/services/contents/fileio.py | 4 +-- .../services/contents/filemanager.py | 4 +-- .../services/contents/largefilemanager.py | 2 +- jupyter_server/services/events/handlers.py | 6 ++-- .../services/kernels/connection/channels.py | 4 +-- .../services/kernels/kernelmanager.py | 6 ++-- .../services/sessions/sessionmanager.py | 6 ++-- jupyter_server/traittypes.py | 3 +- jupyter_server/utils.py | 3 +- pyproject.toml | 9 +++-- tests/extension/test_app.py | 6 ++-- tests/services/contents/test_manager.py | 2 +- tests/services/kernels/test_cull.py | 2 +- tests/test_gateway.py | 6 ++-- .../test_serverapp_integration.py | 4 +-- tests/utils.py | 4 +-- 33 files changed, 94 insertions(+), 97 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d72a2d239..153066f34b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,7 +61,8 @@ repos: ["traitlets>=5.13", "jupyter_core>=5.5", "jupyter_client>=8.5"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + # keep the revision in sync with the ruff version in pyproject.toml + rev: v0.14.6 hooks: - id: ruff types_or: [python, jupyter] diff --git a/examples/authorization/jupyter_nbclassic_readonly_config.py b/examples/authorization/jupyter_nbclassic_readonly_config.py index 95b095fd26..2dca22a159 100644 --- a/examples/authorization/jupyter_nbclassic_readonly_config.py +++ b/examples/authorization/jupyter_nbclassic_readonly_config.py @@ -8,9 +8,7 @@ class ReadOnly(Authorizer): def is_authorized(self, handler, user, action, resource): """Only allows `read` operations.""" - if action != "read": - return False - return True + return action == "read" c.ServerApp.authorizer_class = ReadOnly # type:ignore[name-defined] diff --git a/examples/authorization/jupyter_nbclassic_rw_config.py b/examples/authorization/jupyter_nbclassic_rw_config.py index 751cef64a8..65baa81466 100644 --- a/examples/authorization/jupyter_nbclassic_rw_config.py +++ b/examples/authorization/jupyter_nbclassic_rw_config.py @@ -8,9 +8,7 @@ class ReadWriteOnly(Authorizer): def is_authorized(self, handler, user, action, resource): """Only allows `read` and `write` operations.""" - if action not in {"read", "write"}: - return False - return True + return action in {"read", "write"} c.ServerApp.authorizer_class = ReadWriteOnly # type:ignore[name-defined] diff --git a/examples/authorization/jupyter_temporary_config.py b/examples/authorization/jupyter_temporary_config.py index 9756cafe7d..081816a401 100644 --- a/examples/authorization/jupyter_temporary_config.py +++ b/examples/authorization/jupyter_temporary_config.py @@ -8,9 +8,7 @@ class TemporaryServerPersonality(Authorizer): def is_authorized(self, handler, user, action, resource): """Allow everything but write on contents""" - if action == "write" and resource == "contents": - return False - return True + return not (action == "write" and resource == "contents") c.ServerApp.authorizer_class = TemporaryServerPersonality # type:ignore[name-defined] diff --git a/jupyter_server/__init__.py b/jupyter_server/__init__.py index 9b4cf72ea8..ca99f30fe0 100644 --- a/jupyter_server/__init__.py +++ b/jupyter_server/__init__.py @@ -17,12 +17,12 @@ from .base.call_context import CallContext __all__ = [ + "DEFAULT_EVENTS_SCHEMA_PATH", + "DEFAULT_JUPYTER_SERVER_PORT", "DEFAULT_STATIC_FILES_PATH", "DEFAULT_TEMPLATE_PATH_LIST", - "DEFAULT_JUPYTER_SERVER_PORT", "JUPYTER_SERVER_EVENTS_URI", - "DEFAULT_EVENTS_SCHEMA_PATH", + "CallContext", "__version__", "version_info", - "CallContext", ] diff --git a/jupyter_server/auth/decorator.py b/jupyter_server/auth/decorator.py index 4128c39086..daedb9061c 100644 --- a/jupyter_server/auth/decorator.py +++ b/jupyter_server/auth/decorator.py @@ -82,9 +82,9 @@ async def inner(self, *args, **kwargs): method = action action = None # no-arguments `@authorized` decorator called - return cast(FuncT, wrapper(method)) + return cast("FuncT", wrapper(method)) - return cast(FuncT, wrapper) + return cast("FuncT", wrapper) def allow_unauthenticated(method: FuncT) -> FuncT: @@ -111,7 +111,7 @@ def wrapper(self, *args, **kwargs): setattr(wrapper, "__allow_unauthenticated", True) - return cast(FuncT, wrapper) + return cast("FuncT", wrapper) def ws_authenticated(method: FuncT) -> FuncT: @@ -139,4 +139,4 @@ def wrapper(self, *args, **kwargs): setattr(wrapper, "__allow_unauthenticated", False) - return cast(FuncT, wrapper) + return cast("FuncT", wrapper) diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py index fc4b029922..96b9e4cc31 100644 --- a/jupyter_server/auth/identity.py +++ b/jupyter_server/auth/identity.py @@ -252,7 +252,7 @@ async def _get_user(self, handler: web.RequestHandler) -> User | None: """Get the user.""" if getattr(handler, "_jupyter_current_user", None): # already authenticated - return t.cast(User, handler._jupyter_current_user) # type:ignore[attr-defined] + return t.cast("User", handler._jupyter_current_user) # type:ignore[attr-defined] _token_user: User | None | t.Awaitable[User | None] = self.get_user_token(handler) if isinstance(_token_user, t.Awaitable): _token_user = await _token_user @@ -298,7 +298,7 @@ def update_user( ) -> User: """Update user information and persist the user model.""" self.check_update(user_data) - current_user = t.cast(User, handler.current_user) + current_user = t.cast("User", handler.current_user) updated_user = self.update_user_model(current_user, user_data) self.persist_user_model(handler) return updated_user @@ -585,7 +585,7 @@ def process_login_form(self, handler: web.RequestHandler) -> User | None: return self.generate_anonymous_user(handler) if self.token and self.token == typed_password: - return t.cast(User, self.user_for_token(typed_password)) # type:ignore[attr-defined] + return t.cast("User", self.user_for_token(typed_password)) # type:ignore[attr-defined] return user diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py index 3909c70638..3e492a09be 100644 --- a/jupyter_server/base/handlers.py +++ b/jupyter_server/base/handlers.py @@ -15,7 +15,6 @@ import warnings from collections.abc import Awaitable, Coroutine, Sequence from http.client import responses -from logging import Logger from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse @@ -44,6 +43,8 @@ ) if TYPE_CHECKING: + from logging import Logger + from jupyter_client.kernelspec import KernelSpecManager from jupyter_events import EventLogger from jupyter_server_terminals.terminalmanager import TerminalManager @@ -75,7 +76,7 @@ def json_sys_info(): def log() -> Logger: """Get the application log.""" if Application.initialized(): - return cast(Logger, Application.instance().log) + return cast("Logger", Application.instance().log) else: return app_log @@ -85,7 +86,7 @@ class AuthenticatedHandler(web.RequestHandler): @property def base_url(self) -> str: - return cast(str, self.settings.get("base_url", "/")) + return cast("str", self.settings.get("base_url", "/")) @property def content_security_policy(self) -> str: @@ -95,7 +96,7 @@ def content_security_policy(self) -> str: """ if "Content-Security-Policy" in self.settings.get("headers", {}): # user-specified, don't override - return cast(str, self.settings["headers"]["Content-Security-Policy"]) + return cast("str", self.settings["headers"]["Content-Security-Policy"]) return "; ".join( [ @@ -173,7 +174,7 @@ def get_current_user(self) -> str: DeprecationWarning, stacklevel=2, ) - return cast(str, self._jupyter_current_user) + return cast("str", self._jupyter_current_user) # haven't called get_user in prepare, raise raise RuntimeError(msg) @@ -224,7 +225,7 @@ def login_available(self) -> bool: whether the user is already logged in or not. """ - return cast(bool, self.identity_provider.login_available) + return cast("bool", self.identity_provider.login_available) @property def authorizer(self) -> Authorizer: @@ -302,26 +303,26 @@ def serverapp(self) -> ServerApp | None: @property def version_hash(self) -> str: """The version hash to use for cache hints for static files""" - return cast(str, self.settings.get("version_hash", "")) + return cast("str", self.settings.get("version_hash", "")) @property def mathjax_url(self) -> str: - url = cast(str, self.settings.get("mathjax_url", "")) + url = cast("str", self.settings.get("mathjax_url", "")) if not url or url_is_absolute(url): return url return url_path_join(self.base_url, url) @property def mathjax_config(self) -> str: - return cast(str, self.settings.get("mathjax_config", "TeX-AMS-MML_HTMLorMML-full,Safe")) + return cast("str", self.settings.get("mathjax_config", "TeX-AMS-MML_HTMLorMML-full,Safe")) @property def default_url(self) -> str: - return cast(str, self.settings.get("default_url", "")) + return cast("str", self.settings.get("default_url", "")) @property def ws_url(self) -> str: - return cast(str, self.settings.get("websocket_url", "")) + return cast("str", self.settings.get("websocket_url", "")) @property def contents_js_source(self) -> str: @@ -329,7 +330,7 @@ def contents_js_source(self) -> str: "Using contents: %s", self.settings.get("contents_js_source", "services/contents"), ) - return cast(str, self.settings.get("contents_js_source", "services/contents")) + return cast("str", self.settings.get("contents_js_source", "services/contents")) # --------------------------------------------------------------- # Manager objects @@ -370,7 +371,7 @@ def event_logger(self) -> EventLogger: @property def allow_origin(self) -> str: """Normal Access-Control-Allow-Origin""" - return cast(str, self.settings.get("allow_origin", "")) + return cast("str", self.settings.get("allow_origin", "")) @property def allow_origin_pat(self) -> str | None: @@ -380,7 +381,7 @@ def allow_origin_pat(self) -> str | None: @property def allow_credentials(self) -> bool: """Whether to set Access-Control-Allow-Credentials""" - return cast(bool, self.settings.get("allow_credentials", False)) + return cast("bool", self.settings.get("allow_credentials", False)) def set_default_headers(self) -> None: """Add CORS headers, if defined""" @@ -774,7 +775,7 @@ def write_error(self, status_code: int, **kwargs: Any) -> None: # backward-compatibility: traceback field is present, # but always empty reply["traceback"] = "" - self.log.warning("wrote error: %r", reply["message"], exc_info=True) + self.log.warning("wrote error: %r", reply["message"]) self.finish(json.dumps(reply)) def get_login_url(self) -> str: @@ -1060,7 +1061,7 @@ def validate_absolute_path(self, root: str, absolute_path: str) -> str | None: if not absolute_path: raise web.HTTPError(404) - for root in self.root: + for root in self.root: # noqa: PLR1704 if (absolute_path + os.sep).startswith(root): break @@ -1115,7 +1116,7 @@ class FilesRedirectHandler(JupyterHandler): """Handler for redirecting relative URLs to the /files/ handler""" @staticmethod - async def redirect_to_files(self: Any, path: str) -> None: + async def redirect_to_files(self: Any, path: str) -> None: # noqa: PLW0211 """make redirect logic a reusable static method so it can be called from other handlers. diff --git a/jupyter_server/extension/handler.py b/jupyter_server/extension/handler.py index 4285c415b0..a0e6b850e7 100644 --- a/jupyter_server/extension/handler.py +++ b/jupyter_server/extension/handler.py @@ -2,15 +2,16 @@ from __future__ import annotations -from logging import Logger from typing import TYPE_CHECKING, Any, cast -from jinja2 import Template from jinja2.exceptions import TemplateNotFound from jupyter_server.base.handlers import FileFindHandler if TYPE_CHECKING: + from logging import Logger + + from jinja2 import Template from traitlets.config import Config from jupyter_server.extension.application import ExtensionApp @@ -26,10 +27,10 @@ def get_template(self, name: str) -> Template: """Return the jinja template object for a given name""" try: env = f"{self.name}_jinja2_env" # type:ignore[attr-defined] - template = cast(Template, self.settings[env].get_template(name)) # type:ignore[attr-defined] + template = cast("Template", self.settings[env].get_template(name)) # type:ignore[attr-defined] return template except TemplateNotFound: - return cast(Template, super().get_template(name)) # type:ignore[misc] + return cast("Template", super().get_template(name)) # type:ignore[misc] class ExtensionHandlerMixin: @@ -64,12 +65,12 @@ def serverapp(self) -> ServerApp: @property def log(self) -> Logger: if not hasattr(self, "name"): - return cast(Logger, super().log) # type:ignore[misc] + return cast("Logger", super().log) # type:ignore[misc] # Attempt to pull the ExtensionApp's log, otherwise fall back to ServerApp. try: - return cast(Logger, self.extensionapp.log) + return cast("Logger", self.extensionapp.log) except AttributeError: - return cast(Logger, self.serverapp.log) + return cast("Logger", self.serverapp.log) @property def config(self) -> Config: @@ -81,7 +82,7 @@ def server_config(self) -> Config: @property def base_url(self) -> str: - return cast(str, self.settings.get("base_url", "/")) + return cast("str", self.settings.get("base_url", "/")) def render_template(self, name: str, **ns) -> str: """Override render template to handle static_paths @@ -90,12 +91,12 @@ def render_template(self, name: str, **ns) -> str: (e.g. default error pages) make sure our extension-specific static_url is _not_ used. """ - template = cast(Template, self.get_template(name)) # type:ignore[attr-defined] + template = cast("Template", self.get_template(name)) # type:ignore[attr-defined] ns.update(self.template_namespace) # type:ignore[attr-defined] if template.environment is self.settings["jinja2_env"]: # default template environment, use default static_url ns["static_url"] = super().static_url # type:ignore[misc] - return cast(str, template.render(**ns)) + return cast("str", template.render(**ns)) @property def static_url_prefix(self) -> str: @@ -103,7 +104,7 @@ def static_url_prefix(self) -> str: @property def static_path(self) -> str: - return cast(str, self.settings[f"{self.name}_static_paths"]) + return cast("str", self.settings[f"{self.name}_static_paths"]) def static_url(self, path: str, include_host: bool | None = None, **kwargs: Any) -> str: """Returns a static URL for the given relative static file path. @@ -151,4 +152,4 @@ def static_url(self, path: str, include_host: bool | None = None, **kwargs: Any) "static_url_prefix": self.static_url_prefix, } - return base + cast(str, get_url(settings, path, **kwargs)) + return base + cast("str", get_url(settings, path, **kwargs)) diff --git a/jupyter_server/gateway/gateway_client.py b/jupyter_server/gateway/gateway_client.py index 533836730f..3c0bfb7975 100644 --- a/jupyter_server/gateway/gateway_client.py +++ b/jupyter_server/gateway/gateway_client.py @@ -534,7 +534,7 @@ def gateway_enabled(self): return bool(self.url is not None and len(self.url) > 0) # Ensure KERNEL_LAUNCH_TIMEOUT has a default value. - KERNEL_LAUNCH_TIMEOUT = int(os.environ.get("KERNEL_LAUNCH_TIMEOUT", 40)) + KERNEL_LAUNCH_TIMEOUT = int(os.environ.get("KERNEL_LAUNCH_TIMEOUT", "40")) _connection_args: dict[str, ty.Any] # initialized on first use diff --git a/jupyter_server/gateway/managers.py b/jupyter_server/gateway/managers.py index daa6f99213..7845258de6 100644 --- a/jupyter_server/gateway/managers.py +++ b/jupyter_server/gateway/managers.py @@ -674,7 +674,7 @@ def stop(self) -> None: msgs.append(msg["msg_type"]) if self.channel_name == "iopub" and "shutdown_reply" in msgs: return - if len(msgs): + if msgs: self.log.warning( f"Stopping channel '{self.channel_name}' with {len(msgs)} unprocessed non-status messages: {msgs}." ) diff --git a/jupyter_server/kernelspecs/handlers.py b/jupyter_server/kernelspecs/handlers.py index 650982c76d..f273da233a 100644 --- a/jupyter_server/kernelspecs/handlers.py +++ b/jupyter_server/kernelspecs/handlers.py @@ -29,7 +29,7 @@ async def get(self, kernel_name, path, include_body=True): """Get a kernelspec resource.""" ksm = self.kernel_spec_manager if path.lower().endswith(".png"): - self.set_header("Cache-Control", f"max-age={60*60*24*30}") + self.set_header("Cache-Control", f"max-age={60 * 60 * 24 * 30}") ksm = self.kernel_spec_manager if hasattr(ksm, "get_kernel_spec_resource"): # If the kernel spec manager defines a method to get kernelspec resources, diff --git a/jupyter_server/log.py b/jupyter_server/log.py index 14eef42aad..03c928ec17 100644 --- a/jupyter_server/log.py +++ b/jupyter_server/log.py @@ -33,7 +33,7 @@ def _scrub_uri(uri: str, extra_param_keys=None) -> str: parts = parsed.query.split("&") changed = False for i, s in enumerate(parts): - key, sep, value = s.partition("=") + key, sep, _value = s.partition("=") for substring in scrub_param_keys: if substring in key: parts[i] = f"{key}{sep}[secret]" diff --git a/jupyter_server/nbconvert/handlers.py b/jupyter_server/nbconvert/handlers.py index d0a17ba99b..e474ac2785 100644 --- a/jupyter_server/nbconvert/handlers.py +++ b/jupyter_server/nbconvert/handlers.py @@ -104,7 +104,7 @@ async def get(self, format, path): # give its path to nbconvert. if hasattr(self.contents_manager, "_get_os_path"): os_path = self.contents_manager._get_os_path(path) - ext_resources_dir, basename = os.path.split(os_path) + ext_resources_dir, _basename = os.path.split(os_path) else: ext_resources_dir = None diff --git a/jupyter_server/prometheus/metrics.py b/jupyter_server/prometheus/metrics.py index 3340905375..50c7ecde60 100644 --- a/jupyter_server/prometheus/metrics.py +++ b/jupyter_server/prometheus/metrics.py @@ -72,7 +72,7 @@ __all__ = [ "HTTP_REQUEST_DURATION_SECONDS", - "TERMINAL_CURRENTLY_RUNNING_TOTAL", "KERNEL_CURRENTLY_RUNNING_TOTAL", "SERVER_INFO", + "TERMINAL_CURRENTLY_RUNNING_TOTAL", ] diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 1afbef4d0d..748c3e83c4 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1037,7 +1037,7 @@ def _default_ip(self) -> str: @validate("ip") def _validate_ip(self, proposal: t.Any) -> str: - value = t.cast(str, proposal["value"]) + value = t.cast("str", proposal["value"]) if value == "*": value = "" return value @@ -1539,7 +1539,7 @@ def _deprecated_cookie_config(self, change: t.Any) -> None: @validate("base_url") def _update_base_url(self, proposal: t.Any) -> str: - value = t.cast(str, proposal["value"]) + value = t.cast("str", proposal["value"]) if not value.startswith("/"): value = "/" + value if not value.endswith("/"): @@ -2335,8 +2335,7 @@ def init_resources(self) -> None: soft = self.min_open_files_limit hard = old_hard if soft is not None and old_soft < soft: - if hard < soft: - hard = soft + hard = max(hard, soft) self.log.debug( f"Raising open file limit: soft {old_soft}->{soft}; hard {old_hard}->{hard}" ) @@ -2473,7 +2472,7 @@ def _confirm_exit(self) -> None: no = _i18n("n") sys.stdout.write(_i18n("Shut down this Jupyter server (%s/[%s])? ") % (yes, no)) sys.stdout.flush() - r, w, x = select.select([sys.stdin], [], [], 5) + r, _w, _x = select.select([sys.stdin], [], [], 5) if r: line = sys.stdin.readline() if line.lower().startswith(yes) and no not in line.lower(): @@ -2870,7 +2869,7 @@ async def cleanup_extensions(self) -> None: def running_server_info(self, kernel_count: bool = True) -> str: """Return the current working directory and the server url information""" - info = t.cast(str, self.contents_manager.info_string()) + "\n" + info = t.cast("str", self.contents_manager.info_string()) + "\n" if kernel_count: n_kernels = len(self.kernel_manager.list_kernel_ids()) kernel_msg = trans.ngettext("%d active kernel", "%d active kernels", n_kernels) diff --git a/jupyter_server/services/api/handlers.py b/jupyter_server/services/api/handlers.py index 609d68601f..18293839f2 100644 --- a/jupyter_server/services/api/handlers.py +++ b/jupyter_server/services/api/handlers.py @@ -120,7 +120,7 @@ async def get(self): @web.authenticated async def patch(self): """Update user information.""" - user_data = cast(dict[UpdatableField, str], self.get_json_body()) + user_data = cast("dict[UpdatableField, str]", self.get_json_body()) if not user_data: raise web.HTTPError(400, "Invalid or missing JSON body") diff --git a/jupyter_server/services/contents/fileio.py b/jupyter_server/services/contents/fileio.py index 3b5e042812..d0833b7d35 100644 --- a/jupyter_server/services/contents/fileio.py +++ b/jupyter_server/services/contents/fileio.py @@ -42,7 +42,7 @@ def copy2_safe(src, dst, log=None): # if src file is not writable, avoid creating a back-up if not os.access(src, os.W_OK): if log: - log.debug("Source file, %s, is not writable", src, exc_info=True) + log.debug("Source file, %s, is not writable", src) raise PermissionError(errno.EACCES, f"File is not writable: {src}") shutil.copyfile(src, dst) @@ -60,7 +60,7 @@ async def async_copy2_safe(src, dst, log=None): """ if not os.access(src, os.W_OK): if log: - log.debug("Source file, %s, is not writable", src, exc_info=True) + log.debug("Source file, %s, is not writable", src) raise PermissionError(errno.EACCES, f"File is not writable: {src}") await run_sync(shutil.copyfile, src, dst) diff --git a/jupyter_server/services/contents/filemanager.py b/jupyter_server/services/contents/filemanager.py index 3b84446a17..a49b992342 100644 --- a/jupyter_server/services/contents/filemanager.py +++ b/jupyter_server/services/contents/filemanager.py @@ -669,7 +669,7 @@ def _copy_dir(self, from_path, to_path_original, to_name, to_path): """ try: os_from_path = self._get_os_path(from_path.strip("/")) - os_to_path = f'{self._get_os_path(to_path_original.strip("/"))}/{to_name}' + os_to_path = f"{self._get_os_path(to_path_original.strip('/'))}/{to_name}" shutil.copytree(os_from_path, os_to_path) model = self.get(to_path, content=False) except OSError as err: @@ -1154,7 +1154,7 @@ async def _copy_dir( """ try: os_from_path = self._get_os_path(from_path.strip("/")) - os_to_path = f'{self._get_os_path(to_path_original.strip("/"))}/{to_name}' + os_to_path = f"{self._get_os_path(to_path_original.strip('/'))}/{to_name}" shutil.copytree(os_from_path, os_to_path) model = await self.get(to_path, content=False) except OSError as err: diff --git a/jupyter_server/services/contents/largefilemanager.py b/jupyter_server/services/contents/largefilemanager.py index 78f0d55629..1515ef2943 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: # noqa: ASYNC101 + with open(os_path, "ab") as f: # noqa: ASYNC230 await run_sync(f.write, bcontent) diff --git a/jupyter_server/services/events/handlers.py b/jupyter_server/services/events/handlers.py index fbc007341d..1f1aa1cfd5 100644 --- a/jupyter_server/services/events/handlers.py +++ b/jupyter_server/services/events/handlers.py @@ -81,12 +81,12 @@ def validate_model( if key not in data: message = f"Missing `{key}` in the JSON request body." raise Exception(message) - schema_id = cast(str, data.get("schema_id")) + schema_id = cast("str", data.get("schema_id")) # The case where a given schema_id isn't found, # jupyter_events raises a useful error, so there's no need to # handle that case here. schema = registry.get(schema_id) - version = str(cast(str, data.get("version"))) + version = str(cast("str", data.get("version"))) if schema.version != version: message = f"Unregistered version: {version!r}≠{schema.version!r} for `{schema_id}`" raise Exception(message) @@ -126,7 +126,7 @@ async def post(self): try: validate_model(payload, self.event_logger.schemas) self.event_logger.emit( - schema_id=cast(str, payload.get("schema_id")), + schema_id=cast("str", payload.get("schema_id")), data=cast("dict[str, Any]", payload.get("data")), timestamp_override=get_timestamp(payload), ) diff --git a/jupyter_server/services/kernels/connection/channels.py b/jupyter_server/services/kernels/connection/channels.py index 78f2dc126e..bde7f2fc9f 100644 --- a/jupyter_server/services/kernels/connection/channels.py +++ b/jupyter_server/services/kernels/connection/channels.py @@ -288,7 +288,7 @@ async def _register_session(self): self.kernel_id in self.multi_kernel_manager ): # only update open sessions if kernel is actively managed self._open_sessions[self.session_key] = t.cast( - KernelWebsocketHandler, self.websocket_handler + "KernelWebsocketHandler", self.websocket_handler ) async def prepare(self): @@ -599,7 +599,7 @@ def _handle_kernel_info_reply(self, msg): enabling msg spec adaptation, if necessary """ - idents, msg = self.session.feed_identities(msg) + _idents, msg = self.session.feed_identities(msg) try: msg = self.session.deserialize(msg) except BaseException: diff --git a/jupyter_server/services/kernels/kernelmanager.py b/jupyter_server/services/kernels/kernelmanager.py index 8f4e8277f9..6df8ce22bb 100644 --- a/jupyter_server/services/kernels/kernelmanager.py +++ b/jupyter_server/services/kernels/kernelmanager.py @@ -10,7 +10,7 @@ import asyncio import os -import pathlib # noqa: TCH003 +import pathlib # noqa: TC003 import sys import typing as t import warnings @@ -247,7 +247,7 @@ async def _async_start_kernel( # type:ignore[override] self.log.debug( "Kernel args (excluding env): %r", {k: v for k, v in kwargs.items() if k != "env"} ) - env = kwargs.get("env", None) + env = kwargs.get("env") if env and isinstance(env, dict): # type:ignore[unreachable] self.log.debug("Kernel argument 'env' passed with: %r", list(env.keys())) # type:ignore[unreachable] @@ -598,7 +598,7 @@ def start_watching_activity(self, kernel_id): def record_activity(msg_list): """Record an IOPub message arriving from a kernel""" - idents, fed_msg_list = session.feed_identities(msg_list) + _idents, fed_msg_list = session.feed_identities(msg_list) msg = session.deserialize(fed_msg_list, content=False) msg_type = msg["header"]["msg_type"] diff --git a/jupyter_server/services/sessions/sessionmanager.py b/jupyter_server/services/sessions/sessionmanager.py index 3aac78a0a9..f02e04bc4b 100644 --- a/jupyter_server/services/sessions/sessionmanager.py +++ b/jupyter_server/services/sessions/sessionmanager.py @@ -33,7 +33,7 @@ class KernelSessionRecordConflict(Exception): @dataclass -class KernelSessionRecord: +class KernelSessionRecord: # noqa: PLW1641 - TODO: implement __hash__ """A record object for tracking a Jupyter Server Kernel Session. Two records that share a session_id must also share a kernel_id, while @@ -291,7 +291,7 @@ async def create_session( session_id, path=path, name=name, type=type, kernel_id=kernel_id ) self._pending_sessions.remove(record) - return cast(dict[str, Any], result) + return cast("dict[str, Any]", result) def get_kernel_env( self, path: Optional[str], name: Optional[ModelName] = None @@ -345,7 +345,7 @@ async def start_kernel_for_session( kernel_name=kernel_name, env=kernel_env, ) - return cast(str, kernel_id) + return cast("str", kernel_id) async def save_session(self, session_id, path=None, name=None, type=None, kernel_id=None): """Saves the items for the session with the given session_id diff --git a/jupyter_server/traittypes.py b/jupyter_server/traittypes.py index c1537d353f..b2394490fa 100644 --- a/jupyter_server/traittypes.py +++ b/jupyter_server/traittypes.py @@ -164,8 +164,7 @@ class or its subclasses. Our implementation is quite different self.klasses = klasses else: raise TraitError( - "The klasses attribute must be a list of class names or classes" - " not: %r" % klasses + "The klasses attribute must be a list of class names or classes not: %r" % klasses ) if (kw is not None) and not isinstance(kw, dict): diff --git a/jupyter_server/utils.py b/jupyter_server/utils.py index d83e1be880..ea241e4f1f 100644 --- a/jupyter_server/utils.py +++ b/jupyter_server/utils.py @@ -146,8 +146,7 @@ def to_api_path(os_path: str, root: str = "") -> ApiPath: If given, root will be removed from the path. root must be a filesystem path already. """ - if os_path.startswith(root): - os_path = os_path[len(root) :] + os_path = os_path.removeprefix(root) parts = os_path.strip(os.path.sep).split(os.path.sep) parts = [p for p in parts if p != ""] # remove duplicate splits path = "/".join(parts) diff --git a/pyproject.toml b/pyproject.toml index 81fbb5bb7f..0653f67ef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,6 +130,10 @@ skip-if-exists = ["jupyter_server/static/style/bootstrap.min.css"] install-pre-commit-hook = true optional-editable-build = true +[tool.hatch.envs.hatch-static-analysis] +# keep this version in sync with pre-commit version +dependencies = ["ruff==0.14.6"] + [tool.ruff] line-length = 100 @@ -137,7 +141,7 @@ line-length = 100 docstring-code-format = true [tool.ruff.lint] -ignore = ["ARG", "TRY", "RUF012", "TID252", "G", "INP001", "E402", "F401", "BLE001"] +ignore = ["ARG", "TRY", "RUF012", "TID252", "G", "INP001", "E402", "F401", "BLE001", "UP045", "PLC0415"] extend-select = [ "B", # flake8-bugbear "I", # isort @@ -158,7 +162,8 @@ unfixable = [ "SIM105", "A001", "UP007", "PLR2004", "T201", "N818", "F403"] "jupyter_server/gateway/*" = ["TCH" ] "tests/*" = ["UP031", "PT", 'EM', "TRY", "RET", "SLF", "C408", "F841", "FBT", "A002", "FLY", "N", - "PERF", "ASYNC", "T201", "FA100", "E711", "INP", "TCH", "SIM105", "A001", "PLW0603"] + "PERF", "ASYNC", "T201", "FA100", "E711", "INP", "TCH", "SIM105", "A001", "PLW0603", + "RUF059"] "examples/*_config.py" = ["F821"] "examples/*" = ["N815"] diff --git a/tests/extension/test_app.py b/tests/extension/test_app.py index 21275b6d8c..b7844545d6 100644 --- a/tests/extension/test_app.py +++ b/tests/extension/test_app.py @@ -143,9 +143,9 @@ async def test_load_parallel_extensions(monkeypatch, jp_environ): async def test_start_extension(jp_serverapp, mock_extension): await jp_serverapp._post_start() assert mock_extension.started - assert hasattr( - jp_serverapp, "mock1_started" - ), "Failed because the `_start_jupyter_server_extension` function in 'mock1.py' was never called" + assert hasattr(jp_serverapp, "mock1_started"), ( + "Failed because the `_start_jupyter_server_extension` function in 'mock1.py' was never called" + ) assert jp_serverapp.mock1_started diff --git a/tests/services/contents/test_manager.py b/tests/services/contents/test_manager.py index ef8d40a28e..22374d2f1c 100644 --- a/tests/services/contents/test_manager.py +++ b/tests/services/contents/test_manager.py @@ -188,7 +188,7 @@ def test_invalid_root_dir(jp_file_contents_manager_class, tmp_path): def test_get_os_path(jp_file_contents_manager_class, tmp_path): fm = jp_file_contents_manager_class(root_dir=str(tmp_path)) path = fm._get_os_path("/path/to/notebook/test.ipynb") - rel_path_list = "/path/to/notebook/test.ipynb".split("/") + rel_path_list = ["", "path", "to", "notebook", "test.ipynb"] fs_path = os.path.join(fm.root_dir, *rel_path_list) assert path == fs_path diff --git a/tests/services/kernels/test_cull.py b/tests/services/kernels/test_cull.py index 5b0b8fd9a0..482eba0979 100644 --- a/tests/services/kernels/test_cull.py +++ b/tests/services/kernels/test_cull.py @@ -151,7 +151,7 @@ async def test_cull_connected(jp_fetch, jp_ws_fetch): "parent_header": {}, "metadata": {}, "content": { - "code": f"import time\ntime.sleep({CULL_TIMEOUT-1})", + "code": f"import time\ntime.sleep({CULL_TIMEOUT - 1})", "silent": False, "allow_stdin": False, "stop_on_error": True, diff --git a/tests/test_gateway.py b/tests/test_gateway.py index bf774bb431..6f4fcebc1a 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -766,9 +766,9 @@ async def test_websocket_connection_with_session_id(init_gateway, jp_serverapp, expected_ws_url = ( f"{mock_gateway_ws_url}/api/kernels/{kernel_id}/channels?session_id={conn.session_id}" ) - assert ( - expected_ws_url in caplog.text - ), "WebSocket URL does not contain the expected session_id." + assert expected_ws_url in caplog.text, ( + "WebSocket URL does not contain the expected session_id." + ) # Processing websocket messages happens in separate coroutines and any # errors in that process will show up in logs, but not bubble up to the diff --git a/tests/unix_sockets/test_serverapp_integration.py b/tests/unix_sockets/test_serverapp_integration.py index f60c99b1bc..107c1b5bd2 100644 --- a/tests/unix_sockets/test_serverapp_integration.py +++ b/tests/unix_sockets/test_serverapp_integration.py @@ -202,7 +202,7 @@ def test_shutdown_server(jp_environ): servers = [] while 1: servers = list(list_running_servers()) - if len(servers): + if servers: break time.sleep(0.1) while 1: @@ -227,7 +227,7 @@ def test_jupyter_server_apps(jp_environ): servers = [] while 1: servers = list(list_running_servers()) - if len(servers): + if servers: break time.sleep(0.1) diff --git a/tests/utils.py b/tests/utils.py index 0ca9c008f2..276cefc6d3 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -27,9 +27,7 @@ def expected_http_error(error, expected_code, expected_message=None): if isinstance(e, HTTPError): if expected_code != e.status_code: return False - if expected_message is not None and expected_message != str(e): - return False - return True + return not (expected_message is not None and expected_message != str(e)) elif any( [ isinstance(e, HTTPClientError), From df624c0c24f12d7c801eca96c9cf1fc030278a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krassowski?= <5832902+krassowski@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:32:01 +0000 Subject: [PATCH 11/48] Fix writing on remote file systems with attribute cache (#1574) Co-authored-by: Zachary Sailer --- .github/workflows/python-tests.yml | 18 +++++++++ jupyter_server/services/contents/fileio.py | 16 +++++++- tests/services/contents/test_fileio.py | 44 ++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index fb571e1e13..8e2854cd0d 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -129,6 +129,24 @@ jobs: run: | hatch run test:nowarn || hatch -v run test:nowarn --lf + test_filesystems: + name: Test remote file systems + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 + - name: Create NFS file system + run: | + sudo apt-get install -y nfs-kernel-server + mkdir /tmp/nfs_source /tmp/nfs_mount + echo "/tmp/nfs_source localhost(rw)" | sudo bash -c 'cat - > /etc/exports' + sudo exportfs -a + sudo mount -t nfs -o acregmin=60 localhost:/tmp/nfs_source /tmp/nfs_mount + - name: Run the tests + run: | + hatch run test:nowarn -k test_atomic_writing_permission_cache + make_sdist: name: Make SDist runs-on: ubuntu-latest diff --git a/jupyter_server/services/contents/fileio.py b/jupyter_server/services/contents/fileio.py index d0833b7d35..8448b75410 100644 --- a/jupyter_server/services/contents/fileio.py +++ b/jupyter_server/services/contents/fileio.py @@ -39,8 +39,22 @@ def copy2_safe(src, dst, log=None): like shutil.copy2, but log errors in copystat instead of raising """ + is_writable = os.access(src, os.W_OK) + + if not is_writable: + # attempt to refresh the attribute cache (used by remote file systems) + # rather than raising a permission error before any operation that could + # refresh the attribute cache is allowed to take place. + fd = os.open(src, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + # re-try + is_writable = os.access(src, os.W_OK) + # if src file is not writable, avoid creating a back-up - if not os.access(src, os.W_OK): + if not is_writable: if log: log.debug("Source file, %s, is not writable", src) raise PermissionError(errno.EACCES, f"File is not writable: {src}") diff --git a/tests/services/contents/test_fileio.py b/tests/services/contents/test_fileio.py index eeb4fa0832..97a2673343 100644 --- a/tests/services/contents/test_fileio.py +++ b/tests/services/contents/test_fileio.py @@ -1,8 +1,11 @@ +import contextlib import json import logging import os +import pathlib import stat import sys +import tempfile import pytest from nbformat import validate @@ -156,6 +159,47 @@ def test_atomic_writing_in_readonly_dir(tmp_path): assert mode == 0o500 +@contextlib.contextmanager +def tmp_dir(tmp_root: pathlib.Path): + """Thin wrapper around `TemporaryDirectory` adopting it to `pathlib.Path`s""" + # we need to append `/` if we want to get a sub-directory + prefix = str(tmp_root) + "/" + with tempfile.TemporaryDirectory(prefix=prefix) as temp_path: + yield pathlib.Path(temp_path) + + +@pytest.mark.skipif( + not pathlib.Path("/tmp/nfs_mount").exists(), reason="requires a local NFS mount" +) +def test_atomic_writing_permission_cache(): + remote_source = pathlib.Path("/tmp/nfs_source") + local_mount = pathlib.Path("/tmp/nfs_mount") + + with tmp_dir(tmp_root=local_mount) as local_mount_path: + f = local_mount_path / "file.txt" + + # write initial content + f.write_text("original content") + + # make the file non-writable + f.chmod(0o500) + + # attempt write, should fail due to NFS attribute cache + with pytest.raises(PermissionError): + with atomic_writing(str(f)) as ff: + ff.write("new content") + + source_path = remote_source / local_mount_path.name / "file.txt" + + # make it readable by modifying attributes at source + source_path.chmod(0o700) + + with atomic_writing(str(f)) as ff: + ff.write("new content") + + assert f.read_text() == "new content" + + @pytest.mark.skipif(os.name == "nt", reason="test fails on Windows") def test_file_manager_mixin(tmp_path): mixin = FileManagerMixin() From 92bd8a0c740525b62396f751c0edb0aea8357876 Mon Sep 17 00:00:00 2001 From: carlfarrington <33500423+carlfarrington@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:34:19 +0000 Subject: [PATCH 12/48] add missing word 'to' (#1596) --- docs/source/operators/security.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/operators/security.rst b/docs/source/operators/security.rst index 7a304f3923..ecab114bc9 100644 --- a/docs/source/operators/security.rst +++ b/docs/source/operators/security.rst @@ -73,7 +73,7 @@ you can set a password for your server. :command:`jupyter server password` will prompt you for a password, and store the hashed password in your :file:`jupyter_server_config.json`. -It is possible disable authentication altogether by setting the token and password to empty strings, +It is possible to disable authentication altogether by setting the token and password to empty strings, but this is **NOT RECOMMENDED**, unless authentication or access restrictions are handled at a different layer in your web application: .. sourcecode:: python From 5ecf4e90fa6664d3307ff15ab820206dd3977e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krassowski?= <5832902+krassowski@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:34:44 +0000 Subject: [PATCH 13/48] Pin sphinx to an older version (<9) to fix docs (#1597) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0653f67ef3..7c434376f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ docs = [ "prometheus_client", "pydata_sphinx_theme", "Send2Trash", + "sphinx<9.0", "sphinxcontrib-openapi>=0.8.0", "sphinxcontrib_github_alt", "sphinxcontrib-spelling", From 8f13c24d58d04fb17fb7e97a48315d73fe269158 Mon Sep 17 00:00:00 2001 From: Emin Martinian Date: Thu, 12 Feb 2026 17:01:49 -0500 Subject: [PATCH 14/48] Add IdentityProvider.cookie_secret_hook (#1569) Co-authored-by: Min RK --- jupyter_server/auth/identity.py | 24 ++++++++++++++++++++++++ jupyter_server/serverapp.py | 1 + 2 files changed, 25 insertions(+) diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py index 96b9e4cc31..2ffddfda5e 100644 --- a/jupyter_server/auth/identity.py +++ b/jupyter_server/auth/identity.py @@ -10,6 +10,7 @@ import binascii import datetime +import hmac import json import os import re @@ -28,6 +29,9 @@ from .security import passwd_check, set_password from .utils import get_anonymous_username +if t.TYPE_CHECKING: + import hmac + _non_alphanum = re.compile(r"[^A-Za-z0-9]") @@ -610,6 +614,18 @@ def logout_available(self): """Whether a LogoutHandler is needed.""" return True + def cookie_secret_hook(self, h: hmac.HMAC) -> hmac.HMAC: + """Update cookie secret input + + Subclasses may call `h.update()` with any credentials that, + when changed, should invalidate existing cookies, such as a + password. + + The updated hashlib object should be returned. + + """ + return h + class PasswordIdentityProvider(IdentityProvider): """A password identity provider.""" @@ -740,6 +756,14 @@ def validate_security( self.log.critical(_i18n("\t$ python -m jupyter_server.auth password")) sys.exit(1) + def cookie_secret_hook(self, h: hmac.HMAC) -> hmac.HMAC: + """Include password in cookie secret. + + This makes it so changing the password invalidates cookies. + """ + h.update(self.hashed_password.encode()) + return h + class LegacyIdentityProvider(PasswordIdentityProvider): """Legacy IdentityProvider for use with custom LoginHandlers diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 748c3e83c4..6f96989d18 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -1169,6 +1169,7 @@ def _default_cookie_secret(self) -> bytes: self._write_cookie_secret_file(key) h = hmac.new(key, digestmod=hashlib.sha256) h.update(self.password.encode()) + h = self.identity_provider.cookie_secret_hook(h) return h.digest() def _write_cookie_secret_file(self, secret: bytes) -> None: From 633c4cca163bf28e658f3d9ae764a32b92290903 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:06:31 -0800 Subject: [PATCH 15/48] Bump actions/checkout from 5 to 6 in the actions group (#1572) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/downstream.yml | 12 ++++++------ .github/workflows/python-tests.yml | 26 +++++++++++++------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index 1f3e03153a..be985257a4 100644 --- a/.github/workflows/downstream.yml +++ b/.github/workflows/downstream.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 @@ -60,7 +60,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 @@ -79,7 +79,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 @@ -96,7 +96,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 @@ -113,7 +113,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 8e2854cd0d..dbf68f2745 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -30,7 +30,7 @@ jobs: python-version: "3.12" steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Install nbconvert dependencies on Linux @@ -56,7 +56,7 @@ jobs: name: Test Docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Install Dependencies run: | @@ -77,7 +77,7 @@ jobs: name: Test Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Run Linters run: | @@ -93,7 +93,7 @@ jobs: timeout-minutes: 10 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Install the Python dependencies for the examples run: | @@ -108,7 +108,7 @@ jobs: timeout-minutes: 20 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 with: dependency_type: minimum @@ -121,7 +121,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 with: dependency_type: pre @@ -134,7 +134,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Create NFS file system run: | @@ -152,7 +152,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - uses: jupyterlab/maintainer-tools/.github/actions/make-sdist@v1 @@ -172,7 +172,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Install Dependencies @@ -188,7 +188,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 with: @@ -202,7 +202,7 @@ jobs: os: [ubuntu-latest] python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Run the tests run: hatch -v run cov:integration @@ -211,7 +211,7 @@ jobs: integration_check_pypy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 with: python_version: "pypy-3.9" @@ -224,7 +224,7 @@ jobs: - integration_check - build steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: jupyterlab/maintainer-tools/.github/actions/report-coverage@v1 with: fail_under: 80 From ec427f37fa54bd921613d41bc387863e6f4ae680 Mon Sep 17 00:00:00 2001 From: MaryushSoroka <64984526+MaryushSoroka@users.noreply.github.com> Date: Fri, 13 Feb 2026 02:10:03 +0300 Subject: [PATCH 16/48] Close all sockets in _find_http_port explicitly (#1584) Co-authored-by: Min RK --- jupyter_server/serverapp.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 6f96989d18..1580d15270 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -2686,7 +2686,8 @@ def _find_http_port(self) -> None: for port in random_ports(self.port, self.port_retries + 1): try: sockets = bind_sockets(port, self.ip) - sockets[0].close() + for s in sockets: + s.close() except OSError as e: if e.errno == errno.EADDRINUSE: if self.port_retries: From e9a37ad9b50539e329056f6ae29e0b250909c35b Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 12 Feb 2026 15:45:12 -0800 Subject: [PATCH 17/48] run prerelease tests on 3.14 (#1599) --- .github/workflows/python-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index dbf68f2745..91d94384ca 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -125,6 +125,8 @@ jobs: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 with: dependency_type: pre + # pyO3 dependencies can't build on dev python + python_version: "3.14" - name: Run the tests run: | hatch run test:nowarn || hatch -v run test:nowarn --lf From ed5fb81f1fe58b803506f3babbbc4c51053ac226 Mon Sep 17 00:00:00 2001 From: dualc <294862532@qq.com> Date: Fri, 13 Feb 2026 08:24:03 +0800 Subject: [PATCH 18/48] fix connection exception cause high cpu load (#1484) Co-authored-by: chengcong Co-authored-by: Min RK --- jupyter_server/gateway/connections.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/jupyter_server/gateway/connections.py b/jupyter_server/gateway/connections.py index d4dde730fa..1f1ea64ef7 100644 --- a/jupyter_server/gateway/connections.py +++ b/jupyter_server/gateway/connections.py @@ -146,6 +146,11 @@ def handle_outgoing_message(self, incoming_msg: str, *args: Any) -> None: def handle_incoming_message(self, message: str) -> None: """Send message to gateway server.""" if self.ws is None and self.ws_future is not None: + if self.ws_future.done() and self.ws_future.exception() is not None: + self.log.warning( + "Ignoring message on failed connection to kernel %s", self.kernel_id + ) + return loop = IOLoop.current() loop.add_future(self.ws_future, lambda future: self.handle_incoming_message(message)) else: From 0d125da16ac7d8946bec0a94e03cc25b1ebaa2dc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:24:57 -0800 Subject: [PATCH 19/48] chore: update pre-commit hooks (#1449) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Min RK --- .github/workflows/python-tests.yml | 2 +- .pre-commit-config.yaml | 23 ++--- CHANGELOG.md | 92 +++++++++---------- docs/source/other/faq.rst | 2 +- jupyter_server/base/websocket.py | 2 +- jupyter_server/extension/application.py | 6 +- jupyter_server/extension/serverextension.py | 4 +- jupyter_server/files/handlers.py | 2 +- jupyter_server/gateway/gateway_client.py | 4 +- jupyter_server/gateway/handlers.py | 2 +- jupyter_server/gateway/managers.py | 14 +-- jupyter_server/kernelspecs/handlers.py | 6 +- jupyter_server/pytest_plugin.py | 2 +- jupyter_server/serverapp.py | 10 +- jupyter_server/services/api/handlers.py | 6 +- .../services/contents/filemanager.py | 4 +- jupyter_server/services/contents/handlers.py | 2 +- jupyter_server/services/contents/manager.py | 4 +- jupyter_server/services/events/handlers.py | 2 +- .../services/kernels/connection/channels.py | 4 +- jupyter_server/services/kernels/handlers.py | 6 +- .../services/kernels/kernelmanager.py | 2 +- jupyter_server/services/kernels/websocket.py | 4 +- jupyter_server/utils.py | 3 +- pyproject.toml | 12 ++- 25 files changed, 107 insertions(+), 113 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 91d94384ca..1083867e48 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -81,8 +81,8 @@ jobs: - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - name: Run Linters run: | + pipx run pre-commit run --all-files 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 153066f34b..5618313d11 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,11 @@ ci: autoupdate_schedule: monthly autoupdate_commit_msg: "chore: update pre-commit hooks" + autofix_commit_msg: "core: pre-commit fixes" repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v6.0.0 hooks: - id: check-case-conflict - id: check-ast @@ -21,25 +22,25 @@ repos: - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.28.6 + rev: 0.36.1 hooks: - id: check-github-workflows - - repo: https://github.com/executablebooks/mdformat - rev: 0.7.17 + - repo: https://github.com/hukkin/mdformat + rev: 1.0.0 hooks: - id: mdformat additional_dependencies: [mdformat-gfm, mdformat-frontmatter, mdformat-footnote] - - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v4.0.0-alpha.8" + - repo: https://github.com/rbubley/mirrors-prettier + rev: v3.8.1 hooks: - id: prettier types_or: [yaml, html, json] - repo: https://github.com/codespell-project/codespell - rev: "v2.3.0" + rev: "v2.4.1" hooks: - id: codespell args: ["-L", "sur,nd"] @@ -52,7 +53,7 @@ repos: - id: rst-inline-touching-normal - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.10.1" + rev: "v1.19.1" hooks: - id: mypy files: jupyter_server @@ -62,16 +63,16 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # keep the revision in sync with the ruff version in pyproject.toml - rev: v0.14.6 + rev: v0.14.14 hooks: - - id: ruff + - id: ruff-check types_or: [python, jupyter] args: ["--fix", "--show-fixes"] - id: ruff-format types_or: [python, jupyter] - repo: https://github.com/scientific-python/cookie - rev: "2024.04.23" + rev: "2025.11.21" hooks: - id: sp-repo-review additional_dependencies: ["repo-review[cli]"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b880ef345..c01a64eed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ All notable changes to this project will be documented in this file. ### Enhancements made -- If ServerApp.ip is ipv6 use \[::1\] as local_url [#1495](https://github.com/jupyter-server/jupyter_server/pull/1495) ([@manics](https://github.com/manics)) +- If ServerApp.ip is ipv6 use [::1] as local_url [#1495](https://github.com/jupyter-server/jupyter_server/pull/1495) ([@manics](https://github.com/manics)) - Don't hide .so,.dylib files by default [#1457](https://github.com/jupyter-server/jupyter_server/pull/1457) ([@nokados](https://github.com/nokados)) - Add async start hook to ExtensionApp API [#1417](https://github.com/jupyter-server/jupyter_server/pull/1417) ([@Zsailer](https://github.com/Zsailer)) @@ -703,7 +703,7 @@ All notable changes to this project will be documented in this file. ### Bugs fixed - Redact tokens, etc. in url parameters from request logs [#1212](https://github.com/jupyter-server/jupyter_server/pull/1212) ([@minrk](https://github.com/minrk)) -- Fix get_loader returning None when load_jupyter_server_extension is not found (#1193)Co-authored-by: pre-commit-ci\[bot\] \<66853113+pre-commit-ci\[bot\]@users.noreply.github.com> [#1193](https://github.com/jupyter-server/jupyter_server/pull/1193) ([@cmd-ntrf](https://github.com/cmd-ntrf)) +- Fix get_loader returning None when load_jupyter_server_extension is not found (#1193)Co-authored-by: pre-commit-ci[bot] \<66853113+pre-commit-ci[bot]@users.noreply.github.com> [#1193](https://github.com/jupyter-server/jupyter_server/pull/1193) ([@cmd-ntrf](https://github.com/cmd-ntrf)) ### Maintenance and upkeep improvements @@ -909,7 +909,7 @@ All notable changes to this project will be documented in this file. ### Enhancements made -- \[Gateway\] Remove redundant list kernels request during session poll [#1112](https://github.com/jupyter-server/jupyter_server/pull/1112) ([@kevin-bates](https://github.com/kevin-bates)) +- [Gateway] Remove redundant list kernels request during session poll [#1112](https://github.com/jupyter-server/jupyter_server/pull/1112) ([@kevin-bates](https://github.com/kevin-bates)) ### Maintenance and upkeep improvements @@ -934,7 +934,7 @@ All notable changes to this project will be documented in this file. - Pass kernel environment to `cwd_for_path` method [#1046](https://github.com/jupyter-server/jupyter_server/pull/1046) ([@divyansshhh](https://github.com/divyansshhh)) - Better Handling of Asyncio [#1035](https://github.com/jupyter-server/jupyter_server/pull/1035) ([@blink1073](https://github.com/blink1073)) - Add authorization to AuthenticatedFileHandler [#1021](https://github.com/jupyter-server/jupyter_server/pull/1021) ([@jiajunjie](https://github.com/jiajunjie)) -- \[Gateway\] Add support for gateway token renewal [#985](https://github.com/jupyter-server/jupyter_server/pull/985) ([@kevin-bates](https://github.com/kevin-bates)) +- [Gateway] Add support for gateway token renewal [#985](https://github.com/jupyter-server/jupyter_server/pull/985) ([@kevin-bates](https://github.com/kevin-bates)) - Make it easier to pass custom env variables to kernel [#981](https://github.com/jupyter-server/jupyter_server/pull/981) ([@divyansshhh](https://github.com/divyansshhh)) - Accept and manage cookies when requesting gateways [#969](https://github.com/jupyter-server/jupyter_server/pull/969) ([@wjsi](https://github.com/wjsi)) - Emit events from the Contents Service [#954](https://github.com/jupyter-server/jupyter_server/pull/954) ([@Zsailer](https://github.com/Zsailer)) @@ -961,7 +961,7 @@ All notable changes to this project will be documented in this file. - Fallback new file type to file for contents put [#1013](https://github.com/jupyter-server/jupyter_server/pull/1013) ([@a3626a](https://github.com/a3626a)) - Fix some typos in release instructions [#1003](https://github.com/jupyter-server/jupyter_server/pull/1003) ([@kevin-bates](https://github.com/kevin-bates)) - Wrap the concurrent futures in an asyncio future [#1001](https://github.com/jupyter-server/jupyter_server/pull/1001) ([@blink1073](https://github.com/blink1073)) -- \[Gateway\] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) +- [Gateway] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) - fix issues with jupyter_events 0.5.0 [#972](https://github.com/jupyter-server/jupyter_server/pull/972) ([@Zsailer](https://github.com/Zsailer)) - Correct content-type headers [#965](https://github.com/jupyter-server/jupyter_server/pull/965) ([@epignot](https://github.com/epignot)) - Don't validate certs for when stopping server [#959](https://github.com/jupyter-server/jupyter_server/pull/959) ([@Zsailer](https://github.com/Zsailer)) @@ -1047,7 +1047,7 @@ All notable changes to this project will be documented in this file. ### Deprecated features -- \[Gateway\] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) +- [Gateway] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) ### Contributors to this release @@ -1243,13 +1243,13 @@ All notable changes to this project will be documented in this file. ### Enhancements made -- \[Gateway\] Add support for gateway token renewal [#985](https://github.com/jupyter-server/jupyter_server/pull/985) ([@kevin-bates](https://github.com/kevin-bates)) +- [Gateway] Add support for gateway token renewal [#985](https://github.com/jupyter-server/jupyter_server/pull/985) ([@kevin-bates](https://github.com/kevin-bates)) - Make it easier to pass custom env variables to kernel [#981](https://github.com/jupyter-server/jupyter_server/pull/981) ([@divyansshhh](https://github.com/divyansshhh)) ### Bugs fixed - Wrap the concurrent futures in an asyncio future [#1001](https://github.com/jupyter-server/jupyter_server/pull/1001) ([@blink1073](https://github.com/blink1073)) -- \[Gateway\] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) +- [Gateway] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) ### Maintenance and upkeep improvements @@ -1266,7 +1266,7 @@ All notable changes to this project will be documented in this file. ### Deprecated features -- \[Gateway\] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) +- [Gateway] Fix and deprecate env whitelist handling [#979](https://github.com/jupyter-server/jupyter_server/pull/979) ([@kevin-bates](https://github.com/kevin-bates)) ### Contributors to this release @@ -1317,39 +1317,39 @@ All notable changes to this project will be documented in this file. ### Maintenance and upkeep improvements - Run downstream tests in parallel [#973](https://github.com/jupyter-server/jupyter_server/pull/973) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#971](https://github.com/jupyter-server/jupyter_server/pull/971) ([@pre-commit-ci](https://github.com/pre-commit-ci)) -- \[pre-commit.ci\] pre-commit autoupdate [#963](https://github.com/jupyter-server/jupyter_server/pull/963) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#971](https://github.com/jupyter-server/jupyter_server/pull/971) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#963](https://github.com/jupyter-server/jupyter_server/pull/963) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Update pytest_plugin with fixtures to test auth in core and extensions [#956](https://github.com/jupyter-server/jupyter_server/pull/956) ([@akshaychitneni](https://github.com/akshaychitneni)) -- \[pre-commit.ci\] pre-commit autoupdate [#955](https://github.com/jupyter-server/jupyter_server/pull/955) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#955](https://github.com/jupyter-server/jupyter_server/pull/955) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix docs build [#952](https://github.com/jupyter-server/jupyter_server/pull/952) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#945](https://github.com/jupyter-server/jupyter_server/pull/945) ([@pre-commit-ci](https://github.com/pre-commit-ci)) -- \[pre-commit.ci\] pre-commit autoupdate [#942](https://github.com/jupyter-server/jupyter_server/pull/942) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#945](https://github.com/jupyter-server/jupyter_server/pull/945) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#942](https://github.com/jupyter-server/jupyter_server/pull/942) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix flake8 v5 compat [#941](https://github.com/jupyter-server/jupyter_server/pull/941) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#938](https://github.com/jupyter-server/jupyter_server/pull/938) ([@pre-commit-ci](https://github.com/pre-commit-ci)) -- \[pre-commit.ci\] pre-commit autoupdate [#928](https://github.com/jupyter-server/jupyter_server/pull/928) ([@pre-commit-ci](https://github.com/pre-commit-ci)) -- \[pre-commit.ci\] pre-commit autoupdate [#902](https://github.com/jupyter-server/jupyter_server/pull/902) ([@pre-commit-ci](https://github.com/pre-commit-ci)) -- \[pre-commit.ci\] pre-commit autoupdate [#894](https://github.com/jupyter-server/jupyter_server/pull/894) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#938](https://github.com/jupyter-server/jupyter_server/pull/938) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#928](https://github.com/jupyter-server/jupyter_server/pull/928) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#902](https://github.com/jupyter-server/jupyter_server/pull/902) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#894](https://github.com/jupyter-server/jupyter_server/pull/894) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Normalize os_path [#886](https://github.com/jupyter-server/jupyter_server/pull/886) ([@martinRenou](https://github.com/martinRenou)) -- \[pre-commit.ci\] pre-commit autoupdate [#885](https://github.com/jupyter-server/jupyter_server/pull/885) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#885](https://github.com/jupyter-server/jupyter_server/pull/885) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - suppress tornado deprecation warnings [#882](https://github.com/jupyter-server/jupyter_server/pull/882) ([@minrk](https://github.com/minrk)) - Fix lint [#867](https://github.com/jupyter-server/jupyter_server/pull/867) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#866](https://github.com/jupyter-server/jupyter_server/pull/866) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#866](https://github.com/jupyter-server/jupyter_server/pull/866) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix sphinx 5.0 support [#865](https://github.com/jupyter-server/jupyter_server/pull/865) ([@blink1073](https://github.com/blink1073)) - Add license metadata and file [#827](https://github.com/jupyter-server/jupyter_server/pull/827) ([@blink1073](https://github.com/blink1073)) - CI cleanup [#824](https://github.com/jupyter-server/jupyter_server/pull/824) ([@blink1073](https://github.com/blink1073)) - Switch to flit [#823](https://github.com/jupyter-server/jupyter_server/pull/823) ([@blink1073](https://github.com/blink1073)) - Remove unused pytest-mock dependency [#814](https://github.com/jupyter-server/jupyter_server/pull/814) ([@mgorny](https://github.com/mgorny)) - Remove duplicate requests requirement from setup.cfg [#813](https://github.com/jupyter-server/jupyter_server/pull/813) ([@mgorny](https://github.com/mgorny)) -- \[pre-commit.ci\] pre-commit autoupdate [#802](https://github.com/jupyter-server/jupyter_server/pull/802) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#802](https://github.com/jupyter-server/jupyter_server/pull/802) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Add helper jobs for branch protection [#797](https://github.com/jupyter-server/jupyter_server/pull/797) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#793](https://github.com/jupyter-server/jupyter_server/pull/793) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#793](https://github.com/jupyter-server/jupyter_server/pull/793) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Centralize app cleanup [#792](https://github.com/jupyter-server/jupyter_server/pull/792) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#785](https://github.com/jupyter-server/jupyter_server/pull/785) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#785](https://github.com/jupyter-server/jupyter_server/pull/785) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Clean up pre-commit [#782](https://github.com/jupyter-server/jupyter_server/pull/782) ([@blink1073](https://github.com/blink1073)) - Add mypy check [#779](https://github.com/jupyter-server/jupyter_server/pull/779) ([@blink1073](https://github.com/blink1073)) - Use new post-version-spec from jupyter_releaser [#777](https://github.com/jupyter-server/jupyter_server/pull/777) ([@blink1073](https://github.com/blink1073)) - Give write permissions to enforce label workflow [#776](https://github.com/jupyter-server/jupyter_server/pull/776) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#775](https://github.com/jupyter-server/jupyter_server/pull/775) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#775](https://github.com/jupyter-server/jupyter_server/pull/775) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Add explicit handling of warnings [#771](https://github.com/jupyter-server/jupyter_server/pull/771) ([@blink1073](https://github.com/blink1073)) - Use test-sdist from maintainer-tools [#769](https://github.com/jupyter-server/jupyter_server/pull/769) ([@blink1073](https://github.com/blink1073)) - Add pyupgrade and doc8 hooks [#768](https://github.com/jupyter-server/jupyter_server/pull/768) ([@blink1073](https://github.com/blink1073)) @@ -1398,13 +1398,13 @@ All notable changes to this project will be documented in this file. ### Maintenance and upkeep improvements - Update pytest_plugin with fixtures to test auth in core and extensions [#956](https://github.com/jupyter-server/jupyter_server/pull/956) ([@akshaychitneni](https://github.com/akshaychitneni)) -- \[pre-commit.ci\] pre-commit autoupdate [#955](https://github.com/jupyter-server/jupyter_server/pull/955) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#955](https://github.com/jupyter-server/jupyter_server/pull/955) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix docs build [#952](https://github.com/jupyter-server/jupyter_server/pull/952) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#945](https://github.com/jupyter-server/jupyter_server/pull/945) ([@pre-commit-ci](https://github.com/pre-commit-ci)) -- \[pre-commit.ci\] pre-commit autoupdate [#942](https://github.com/jupyter-server/jupyter_server/pull/942) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#945](https://github.com/jupyter-server/jupyter_server/pull/945) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#942](https://github.com/jupyter-server/jupyter_server/pull/942) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix flake8 v5 compat [#941](https://github.com/jupyter-server/jupyter_server/pull/941) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#938](https://github.com/jupyter-server/jupyter_server/pull/938) ([@pre-commit-ci](https://github.com/pre-commit-ci)) -- \[pre-commit.ci\] pre-commit autoupdate [#928](https://github.com/jupyter-server/jupyter_server/pull/928) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#938](https://github.com/jupyter-server/jupyter_server/pull/938) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#928](https://github.com/jupyter-server/jupyter_server/pull/928) ([@pre-commit-ci](https://github.com/pre-commit-ci)) ### Documentation improvements @@ -1434,7 +1434,7 @@ All notable changes to this project will be documented in this file. - Improve logging of bare exceptions and other cleanups. [#922](https://github.com/jupyter-server/jupyter_server/pull/922) ([@thetorpedodog](https://github.com/thetorpedodog)) - Use more explicit version template for pyproject [#919](https://github.com/jupyter-server/jupyter_server/pull/919) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#916](https://github.com/jupyter-server/jupyter_server/pull/916) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#916](https://github.com/jupyter-server/jupyter_server/pull/916) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix handling of dev version [#913](https://github.com/jupyter-server/jupyter_server/pull/913) ([@blink1073](https://github.com/blink1073)) - Fix owasp link [#908](https://github.com/jupyter-server/jupyter_server/pull/908) ([@blink1073](https://github.com/blink1073)) - default to system node version in precommit [#906](https://github.com/jupyter-server/jupyter_server/pull/906) ([@dlqqq](https://github.com/dlqqq)) @@ -1461,13 +1461,13 @@ All notable changes to this project will be documented in this file. ### Maintenance and upkeep improvements -- \[pre-commit.ci\] pre-commit autoupdate [#902](https://github.com/jupyter-server/jupyter_server/pull/902) ([@pre-commit-ci](https://github.com/pre-commit-ci)) -- \[pre-commit.ci\] pre-commit autoupdate [#894](https://github.com/jupyter-server/jupyter_server/pull/894) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#902](https://github.com/jupyter-server/jupyter_server/pull/902) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#894](https://github.com/jupyter-server/jupyter_server/pull/894) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Normalize os_path [#886](https://github.com/jupyter-server/jupyter_server/pull/886) ([@martinRenou](https://github.com/martinRenou)) -- \[pre-commit.ci\] pre-commit autoupdate [#885](https://github.com/jupyter-server/jupyter_server/pull/885) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#885](https://github.com/jupyter-server/jupyter_server/pull/885) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - suppress tornado deprecation warnings [#882](https://github.com/jupyter-server/jupyter_server/pull/882) ([@minrk](https://github.com/minrk)) - Fix lint [#867](https://github.com/jupyter-server/jupyter_server/pull/867) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#866](https://github.com/jupyter-server/jupyter_server/pull/866) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#866](https://github.com/jupyter-server/jupyter_server/pull/866) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Fix sphinx 5.0 support [#865](https://github.com/jupyter-server/jupyter_server/pull/865) ([@blink1073](https://github.com/blink1073)) ### Documentation improvements @@ -1512,16 +1512,16 @@ All notable changes to this project will be documented in this file. - Switch to flit [#823](https://github.com/jupyter-server/jupyter_server/pull/823) ([@blink1073](https://github.com/blink1073)) - Remove unused pytest-mock dependency [#814](https://github.com/jupyter-server/jupyter_server/pull/814) ([@mgorny](https://github.com/mgorny)) - Remove duplicate requests requirement from setup.cfg [#813](https://github.com/jupyter-server/jupyter_server/pull/813) ([@mgorny](https://github.com/mgorny)) -- \[pre-commit.ci\] pre-commit autoupdate [#802](https://github.com/jupyter-server/jupyter_server/pull/802) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#802](https://github.com/jupyter-server/jupyter_server/pull/802) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Add helper jobs for branch protection [#797](https://github.com/jupyter-server/jupyter_server/pull/797) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#793](https://github.com/jupyter-server/jupyter_server/pull/793) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#793](https://github.com/jupyter-server/jupyter_server/pull/793) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Centralize app cleanup [#792](https://github.com/jupyter-server/jupyter_server/pull/792) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#785](https://github.com/jupyter-server/jupyter_server/pull/785) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#785](https://github.com/jupyter-server/jupyter_server/pull/785) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Clean up pre-commit [#782](https://github.com/jupyter-server/jupyter_server/pull/782) ([@blink1073](https://github.com/blink1073)) - Add mypy check [#779](https://github.com/jupyter-server/jupyter_server/pull/779) ([@blink1073](https://github.com/blink1073)) - Use new post-version-spec from jupyter_releaser [#777](https://github.com/jupyter-server/jupyter_server/pull/777) ([@blink1073](https://github.com/blink1073)) - Give write permissions to enforce label workflow [#776](https://github.com/jupyter-server/jupyter_server/pull/776) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#775](https://github.com/jupyter-server/jupyter_server/pull/775) ([@pre-commit-ci](https://github.com/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#775](https://github.com/jupyter-server/jupyter_server/pull/775) ([@pre-commit-ci](https://github.com/pre-commit-ci)) - Add explicit handling of warnings [#771](https://github.com/jupyter-server/jupyter_server/pull/771) ([@blink1073](https://github.com/blink1073)) - Use test-sdist from maintainer-tools [#769](https://github.com/jupyter-server/jupyter_server/pull/769) ([@blink1073](https://github.com/blink1073)) - Add pyupgrade and doc8 hooks [#768](https://github.com/jupyter-server/jupyter_server/pull/768) ([@blink1073](https://github.com/blink1073)) @@ -1557,7 +1557,7 @@ All notable changes to this project will be documented in this file. ### Maintenance and upkeep improvements - Add helper jobs for branch protection [#797](https://github.com/jupyter-server/jupyter_server/pull/797) ([@blink1073](https://github.com/blink1073)) -- \[pre-commit.ci\] pre-commit autoupdate [#793](https://github.com/jupyter-server/jupyter_server/pull/793) ([@pre-commit-ci\[bot\]](https://github.com/apps/pre-commit-ci)) +- [pre-commit.ci] pre-commit autoupdate [#793](https://github.com/jupyter-server/jupyter_server/pull/793) ([@pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - Update branch references and links [#791](https://github.com/jupyter-server/jupyter_server/pull/791) ([@blink1073](https://github.com/blink1073)) ### Contributors to this release @@ -2051,7 +2051,7 @@ All notable changes to this project will be documented in this file. - enable a way to run a task when an io_loop is created [#531](https://github.com/jupyter-server/jupyter_server/pull/531) ([@eastonsuo](https://github.com/eastonsuo)) - adds `GatewayClient.auth_scheme` configurable [#529](https://github.com/jupyter-server/jupyter_server/pull/529) ([@telamonian](https://github.com/telamonian)) -- \[Notebook port 4835\] Add UNIX socket support to notebook server [#525](https://github.com/jupyter-server/jupyter_server/pull/525) ([@jtpio](https://github.com/jtpio)) +- [Notebook port 4835] Add UNIX socket support to notebook server [#525](https://github.com/jupyter-server/jupyter_server/pull/525) ([@jtpio](https://github.com/jtpio)) ### Bugs fixed @@ -2135,7 +2135,7 @@ All notable changes to this project will be documented in this file. ([GitHub contributors page for this release](https://github.com/jupyter-server/jupyter_server/graphs/contributors?from=2021-04-22&to=2021-05-10&type=c)) [@codecov-commenter](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acodecov-commenter+updated%3A2021-05-06..2021-05-10&type=Issues) | [@hMED22](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AhMED22+updated%3A2021-05-06..2021-05-10&type=Issues) | [@jtpio](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ajtpio+updated%3A2021-05-06..2021-05-10&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-05-06..2021-05-10&type=Issues) | [@the-higgs](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Athe-higgs+updated%3A2021-05-06..2021-05-10&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Awelcome+updated%3A2021-05-06..2021-05-10&type=Issues) -[@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-05-01..2021-05-05&type=Issues) | [@candlerb](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acandlerb+updated%3A2021-05-01..2021-05-05&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-05-01..2021-05-05&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2021-05-01..2021-05-05&type=Issues) | [@mwakaba2](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amwakaba2+updated%3A2021-05-01..2021-05-05&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-05-01..2021-05-05&type=Issues) | [@kiendang](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akiendang+updated%3A2021-04-21..2021-05-01&type=Issues) | \[@Carreau\] +[@blink1073](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Ablink1073+updated%3A2021-05-01..2021-05-05&type=Issues) | [@candlerb](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Acandlerb+updated%3A2021-05-01..2021-05-05&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akevin-bates+updated%3A2021-05-01..2021-05-05&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Aminrk+updated%3A2021-05-01..2021-05-05&type=Issues) | [@mwakaba2](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Amwakaba2+updated%3A2021-05-01..2021-05-05&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3AZsailer+updated%3A2021-05-01..2021-05-05&type=Issues) | [@kiendang](https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3Akiendang+updated%3A2021-04-21..2021-05-01&type=Issues) | [@Carreau] (https://github.com/search?q=repo%3Ajupyter-server%2Fjupyter_server+involves%3ACarreau+updated%3A2021-04-21..2021-05-01&type=Issues) ## 1.6.4 @@ -2308,7 +2308,7 @@ All notable changes to this project will be documented in this file. - Emit deprecation warning on old name [#411](https://github.com/jupyter-server/jupyter_server/pull/411) ([fcollonval](https://github.com/fcollonval)) - Correct logging message position [#410](https://github.com/jupyter-server/jupyter_server/pull/410) ([fcollonval](https://github.com/fcollonval)) - Update 1.3.0 Changelog to include broken 1.2.3 PRs [#408](https://github.com/jupyter-server/jupyter_server/pull/408) ([kevin-bates](https://github.com/kevin-bates)) -- \[Gateway\] Track only this server's kernels [#407](https://github.com/jupyter-server/jupyter_server/pull/407) ([kevin-bates](https://github.com/kevin-bates)) +- [Gateway] Track only this server's kernels [#407](https://github.com/jupyter-server/jupyter_server/pull/407) ([kevin-bates](https://github.com/kevin-bates)) - Update manager.py: more descriptive warnings when extensions fail to load [#396](https://github.com/jupyter-server/jupyter_server/pull/396) ([alberti42](https://github.com/alberti42)) ## [1.3.0](https://github.com/jupyter-server/jupyter_server/tree/1.3.0) (2021-02-04) @@ -2462,13 +2462,13 @@ This was a broken release and was yanked from PyPI. - Prevent a re-definition of prometheus metrics if `notebook` package already imports them. ([#210](https://github.com/jupyter/jupyter_server/pull/210)) - Fixed `terminals` REST API unit tests that weren't shutting down properly. ([221](https://github.com/jupyter/jupyter_server/pull/221)) -- Fixed jupyter_server on Windows for Python \< 3.7. Added patch to handle subprocess cleanup. ([240](https://github.com/jupyter/jupyter_server/pull/240)) +- Fixed jupyter_server on Windows for Python < 3.7. Added patch to handle subprocess cleanup. ([240](https://github.com/jupyter/jupyter_server/pull/240)) - `base_url` was being duplicated when getting a url path from the `ServerApp`. ([280](https://github.com/jupyter/jupyter_server/pull/280)) - Extension URLs are now properly prefixed with `base_url`. Previously, all `static` paths were not. ([285](https://github.com/jupyter/jupyter_server/pull/285)) - Changed ExtensionApp mixin to inherit from `HasTraits`. This broke in traitlets 5.0 ([294](https://github.com/jupyter/jupyter_server/pull/294)) - Replaces `urlparse` with `url_path_join` to prevent URL squashing issues. ([304](https://github.com/jupyter/jupyter_server/pull/304)) -## \[0.3\] - 2020-4-22 +## [0.3] - 2020-4-22 ### Added @@ -2484,7 +2484,7 @@ This was a broken release and was yanked from PyPI. - ([#194](https://github.com/jupyter/jupyter_server/pull/194)) The bundlerextension entry point was removed. -## \[0.2.1\] - 2020-1-10 +## [0.2.1] - 2020-1-10 ### Added @@ -2496,7 +2496,7 @@ This was a broken release and was yanked from PyPI. - `fetch`: an awaitable function that tests makes requests to the server API - `create_notebook`: a function that writes a notebook to a given temporary file path. -## \[0.2.0\] - 2019-12-19 +## [0.2.0] - 2019-12-19 ### Added diff --git a/docs/source/other/faq.rst b/docs/source/other/faq.rst index db9e2634df..1c07bec0d3 100644 --- a/docs/source/other/faq.rst +++ b/docs/source/other/faq.rst @@ -4,7 +4,7 @@ Frequently asked questions ========================== -Here is a list of questions we think you might have. This list will always be growing, so please feel free to add your question+anwer to this page! |:rocket:| +Here is a list of questions we think you might have. This list will always be growing, so please feel free to add your question+answer to this page! |:rocket:| Can I configure multiple extensions at once? diff --git a/jupyter_server/base/websocket.py b/jupyter_server/base/websocket.py index ded74ed7a0..ef91b1024c 100644 --- a/jupyter_server/base/websocket.py +++ b/jupyter_server/base/websocket.py @@ -98,7 +98,7 @@ def _maybe_auth(self): 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 + # rather than reusing `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: diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index 698d920801..33683b45e9 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -175,7 +175,7 @@ def config_file_paths(self): # file, jupyter_{name}_config. # This should also match the jupyter subcommand used to launch # this extension from the CLI, e.g. `jupyter {name}`. - name: str | Unicode[str, str] = "ExtensionApp" # type:ignore[assignment] + name: str | Unicode[str, str] = "ExtensionApp" @classmethod def get_extension_package(cls): @@ -413,7 +413,7 @@ def _link_jupyter_server_extension(self, serverapp: ServerApp) -> None: # Acknowledge that this extension has been linked. self._linked = True - def initialize(self): + def initialize(self): # type: ignore[override] """Initialize the extension app. The corresponding server app and webapp should already be initialized by this step. @@ -594,7 +594,7 @@ def initialize_server(cls, argv=None, load_other_extensions=True, **kwargs): cls.serverapp_config["jpserver_extensions"] = jpserver_extensions find_extensions = False serverapp = cls.make_serverapp(jpserver_extensions=jpserver_extensions, **kwargs) - serverapp.aliases.update(cls.aliases) # type:ignore[has-type] + serverapp.aliases.update(cls.aliases) serverapp.initialize( argv=argv or [], starter_extension=cls.name, diff --git a/jupyter_server/extension/serverextension.py b/jupyter_server/extension/serverextension.py index 7b6513b2b8..d53e3a707b 100644 --- a/jupyter_server/extension/serverextension.py +++ b/jupyter_server/extension/serverextension.py @@ -310,7 +310,7 @@ class EnableServerExtensionApp(ToggleServerExtensionApp): Usage jupyter server extension enable [--system|--sys-prefix] """ - _toggle_value = True # type:ignore[assignment] + _toggle_value = True _toggle_pre_message = "enabling" _toggle_post_message = "enabled" @@ -325,7 +325,7 @@ class DisableServerExtensionApp(ToggleServerExtensionApp): Usage jupyter server extension disable [--system|--sys-prefix] """ - _toggle_value = False # type:ignore[assignment] + _toggle_value = False _toggle_pre_message = "disabling" _toggle_post_message = "disabled" diff --git a/jupyter_server/files/handlers.py b/jupyter_server/files/handlers.py index 749328438e..68e9e4f2b9 100644 --- a/jupyter_server/files/handlers.py +++ b/jupyter_server/files/handlers.py @@ -48,7 +48,7 @@ def head(self, path: str) -> Awaitable[None] | None: # type:ignore[override] @web.authenticated @authorized - async def get(self, path, include_body=True): + async def get(self, path, include_body=True): # type: ignore[override] """Get a file by path.""" # /files/ requests must originate from the same site self.check_xsrf_cookie() diff --git a/jupyter_server/gateway/gateway_client.py b/jupyter_server/gateway/gateway_client.py index 3c0bfb7975..88dc79ffa1 100644 --- a/jupyter_server/gateway/gateway_client.py +++ b/jupyter_server/gateway/gateway_client.py @@ -46,7 +46,7 @@ class GatewayTokenRenewerMeta(ABCMeta, type(LoggingConfigurable)): # type: igno """The metaclass necessary for proper ABC behavior in a Configurable.""" -class GatewayTokenRenewerBase( # type:ignore[misc] +class GatewayTokenRenewerBase( # type:ignore[metaclass] ABC, LoggingConfigurable, metaclass=GatewayTokenRenewerMeta ): """ @@ -70,7 +70,7 @@ def get_token( """ -class NoOpTokenRenewer(GatewayTokenRenewerBase): # type:ignore[misc] +class NoOpTokenRenewer(GatewayTokenRenewerBase): """NoOpTokenRenewer is the default value to the GatewayClient trait `gateway_token_renewer` and merely returns the provided token. """ diff --git a/jupyter_server/gateway/handlers.py b/jupyter_server/gateway/handlers.py index ba0758ea06..4742d27bd8 100644 --- a/jupyter_server/gateway/handlers.py +++ b/jupyter_server/gateway/handlers.py @@ -94,7 +94,7 @@ def send_ping(self): self.ping(b"") - def open(self, kernel_id, *args, **kwargs): + def open(self, kernel_id: str, *args, **kwargs) -> None: # type: ignore[override] """Handle web socket connection open to notebook server and delegate to gateway web socket handler""" self.ping_callback = PeriodicCallback(self.send_ping, GATEWAY_WS_PING_INTERVAL_SECS * 1000) self.ping_callback.start() diff --git a/jupyter_server/gateway/managers.py b/jupyter_server/gateway/managers.py index 7845258de6..270001f30c 100644 --- a/jupyter_server/gateway/managers.py +++ b/jupyter_server/gateway/managers.py @@ -41,7 +41,7 @@ class GatewayMappingKernelManager(AsyncMappingKernelManager): """Kernel manager that supports remote kernels hosted by Jupyter Kernel or Enterprise Gateway.""" # We'll maintain our own set of kernel ids - _kernels: dict[str, GatewayKernelManager] = {} # type:ignore[assignment] + _kernels: dict[str, GatewayKernelManager] = {} @default("kernel_manager_class") def _default_kernel_manager_class(self): @@ -380,7 +380,7 @@ async def kernel_culled(self, kernel_id: str) -> bool: # typing: ignore class GatewayKernelManager(ServerKernelManager): """Manages a single kernel remotely via a Gateway Server.""" - kernel_id: Optional[str] = None # type:ignore[assignment] + kernel_id: Optional[str] = None kernel = None @default("cache_ports") @@ -714,11 +714,11 @@ class GatewayKernelClient(AsyncKernelClient): allow_stdin = False _channels_stopped: bool _channel_queues: Optional[dict[str, ChannelQueue]] - _control_channel: Optional[ChannelQueue] # type:ignore[assignment] - _hb_channel: Optional[ChannelQueue] # type:ignore[assignment] - _stdin_channel: Optional[ChannelQueue] # type:ignore[assignment] - _iopub_channel: Optional[ChannelQueue] # type:ignore[assignment] - _shell_channel: Optional[ChannelQueue] # type:ignore[assignment] + _control_channel: Optional[ChannelQueue] + _hb_channel: Optional[ChannelQueue] + _stdin_channel: Optional[ChannelQueue] + _iopub_channel: Optional[ChannelQueue] + _shell_channel: Optional[ChannelQueue] def __init__(self, kernel_id, **kwargs): """Initialize a gateway kernel client.""" diff --git a/jupyter_server/kernelspecs/handlers.py b/jupyter_server/kernelspecs/handlers.py index f273da233a..52ace94df3 100644 --- a/jupyter_server/kernelspecs/handlers.py +++ b/jupyter_server/kernelspecs/handlers.py @@ -19,13 +19,13 @@ class KernelSpecResourceHandler(web.StaticFileHandler, JupyterHandler): SUPPORTED_METHODS = ("GET", "HEAD") auth_resource = AUTH_RESOURCE - def initialize(self): + def initialize(self) -> None: # type: ignore[override] """Initialize a kernelspec resource handler.""" web.StaticFileHandler.initialize(self, path="") @web.authenticated @authorized - async def get(self, kernel_name, path, include_body=True): + async def get(self, kernel_name: str, path: str, include_body: bool = True): # type: ignore[override] """Get a kernelspec resource.""" ksm = self.kernel_spec_manager if path.lower().endswith(".png"): @@ -58,7 +58,7 @@ async def get(self, kernel_name, path, include_body=True): @web.authenticated @authorized - async def head(self, kernel_name, path): + async def head(self, kernel_name: str, path: str) -> None: # type: ignore[override] """Get the head info for a kernel resource.""" return await ensure_async(self.get(kernel_name, path, include_body=False)) diff --git a/jupyter_server/pytest_plugin.py b/jupyter_server/pytest_plugin.py index 3d397e65ef..7496d8fb51 100644 --- a/jupyter_server/pytest_plugin.py +++ b/jupyter_server/pytest_plugin.py @@ -19,7 +19,7 @@ } -@pytest.fixture # type:ignore[misc] +@pytest.fixture # type: ignore[untyped-decorator] def jp_kernelspecs(jp_data_dir: Path) -> None: """Configures some sample kernelspecs in the Jupyter data directory.""" spec_names = ["sample", "sample2", "bad"] diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 1580d15270..ed2bd13615 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -879,8 +879,8 @@ class ServerApp(JupyterApp): ) examples = _examples - flags = Dict(flags) # type:ignore[assignment] - aliases = Dict(aliases) # type:ignore[assignment] + flags = Dict(flags) + aliases = Dict(aliases) classes = [ KernelManager, @@ -2416,11 +2416,7 @@ def connection_url(self) -> str: def init_signal(self) -> None: """Initialize signal handlers.""" - if ( - not sys.platform.startswith("win") - and sys.stdin # type:ignore[truthy-bool] - and sys.stdin.isatty() - ): + if not sys.platform.startswith("win") and sys.stdin and sys.stdin.isatty(): signal.signal(signal.SIGINT, self._handle_sigint) signal.signal(signal.SIGTERM, self._signal_stop) if hasattr(signal, "SIGUSR1"): diff --git a/jupyter_server/services/api/handlers.py b/jupyter_server/services/api/handlers.py index 18293839f2..8f14812439 100644 --- a/jupyter_server/services/api/handlers.py +++ b/jupyter_server/services/api/handlers.py @@ -23,18 +23,18 @@ class APISpecHandler(web.StaticFileHandler, JupyterHandler): auth_resource = AUTH_RESOURCE - def initialize(self): + def initialize(self): # type: ignore[override] """Initialize the API spec handler.""" web.StaticFileHandler.initialize(self, path=os.path.dirname(__file__)) @web.authenticated @authorized - def head(self): + def head(self): # type: ignore[override] return self.get("api.yaml", include_body=False) @web.authenticated @authorized - def get(self): + def get(self): # type: ignore[override] """Get the API spec.""" self.log.warning("Serving api spec (experimental, incomplete)") return web.StaticFileHandler.get(self, "api.yaml") diff --git a/jupyter_server/services/contents/filemanager.py b/jupyter_server/services/contents/filemanager.py index a49b992342..a78acec778 100644 --- a/jupyter_server/services/contents/filemanager.py +++ b/jupyter_server/services/contents/filemanager.py @@ -167,7 +167,7 @@ def is_writable(self, path): self.log.error("Failed to check write permissions on %s", os_path) return False - def file_exists(self, path): + def file_exists(self, path: str) -> bool | t.Awaitable[bool]: """Returns True if the file exists, else returns False. API-style wrapper for os.path.isfile @@ -1082,7 +1082,7 @@ async def dir_exists(self, path): os_path = self._get_os_path(path=path) return os.path.isdir(os_path) - async def file_exists(self, path): + async def file_exists(self, path: str) -> bool: """Does a file exist at the given path""" path = path.strip("/") os_path = self._get_os_path(path) diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index 1eae0c2f9e..dac8183214 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -414,7 +414,7 @@ def get(self, path): class TrustNotebooksHandler(JupyterHandler): """Handles trust/signing of notebooks""" - @web.authenticated # type:ignore[misc] + @web.authenticated @authorized(resource=AUTH_RESOURCE) async def post(self, path=""): """Trust a notebook by path.""" diff --git a/jupyter_server/services/contents/manager.py b/jupyter_server/services/contents/manager.py index 0604b84cc1..25d4d4fd2b 100644 --- a/jupyter_server/services/contents/manager.py +++ b/jupyter_server/services/contents/manager.py @@ -410,7 +410,7 @@ def is_hidden(self, path): """ raise NotImplementedError - def file_exists(self, path=""): + def file_exists(self, path): """Does a file exist at the given path? Like os.path.isfile @@ -818,7 +818,7 @@ async def is_hidden(self, path): """ raise NotImplementedError - async def file_exists(self, path=""): + async def file_exists(self, path): """Does a file exist at the given path? Like os.path.isfile diff --git a/jupyter_server/services/events/handlers.py b/jupyter_server/services/events/handlers.py index 1f1aa1cfd5..6a2737b252 100644 --- a/jupyter_server/services/events/handlers.py +++ b/jupyter_server/services/events/handlers.py @@ -60,7 +60,7 @@ async def event_listener( capsule = dict(schema_id=schema_id, **data) self.write_message(json.dumps(capsule)) - def open(self): + def open(self) -> None: # type: ignore[override] """Routes events that are emitted by Jupyter Server's EventBus to a WebSocket client in the browser. """ diff --git a/jupyter_server/services/kernels/connection/channels.py b/jupyter_server/services/kernels/connection/channels.py index bde7f2fc9f..3b936541d8 100644 --- a/jupyter_server/services/kernels/connection/channels.py +++ b/jupyter_server/services/kernels/connection/channels.py @@ -287,9 +287,7 @@ async def _register_session(self): if ( self.kernel_id in self.multi_kernel_manager ): # only update open sessions if kernel is actively managed - self._open_sessions[self.session_key] = t.cast( - "KernelWebsocketHandler", self.websocket_handler - ) + self._open_sessions[self.session_key] = self.websocket_handler async def prepare(self): """Prepare a kernel connection.""" diff --git a/jupyter_server/services/kernels/handlers.py b/jupyter_server/services/kernels/handlers.py index 71a728d68a..5a81213b11 100644 --- a/jupyter_server/services/kernels/handlers.py +++ b/jupyter_server/services/kernels/handlers.py @@ -52,10 +52,8 @@ async def post(self): else: model.setdefault("name", km.default_kernel_name) - kernel_id = await ensure_async( - km.start_kernel( # type:ignore[has-type] - kernel_name=model["name"], path=model.get("path") - ) + kernel_id: str = await ensure_async( + km.start_kernel(kernel_name=model["name"], path=model.get("path")) ) model = await ensure_async(km.kernel_model(kernel_id)) location = url_path_join(self.base_url, "api", "kernels", url_escape(kernel_id)) diff --git a/jupyter_server/services/kernels/kernelmanager.py b/jupyter_server/services/kernels/kernelmanager.py index 6df8ce22bb..8eb0b947a8 100644 --- a/jupyter_server/services/kernels/kernelmanager.py +++ b/jupyter_server/services/kernels/kernelmanager.py @@ -740,7 +740,7 @@ async def cull_kernel_if_idle(self, kernel_id): # AsyncMappingKernelManager inherits as much as possible from MappingKernelManager, # overriding only what is different. -class AsyncMappingKernelManager(MappingKernelManager, AsyncMultiKernelManager): # type:ignore[misc] +class AsyncMappingKernelManager(MappingKernelManager, AsyncMultiKernelManager): """An asynchronous mapping kernel manager.""" @default("kernel_manager_class") diff --git a/jupyter_server/services/kernels/websocket.py b/jupyter_server/services/kernels/websocket.py index 374df76f3e..a24b0539f4 100644 --- a/jupyter_server/services/kernels/websocket.py +++ b/jupyter_server/services/kernels/websocket.py @@ -13,7 +13,7 @@ AUTH_RESOURCE = "kernels" -class KernelWebsocketHandler(WebSocketMixin, WebSocketHandler, JupyterHandler): # type:ignore[misc] +class KernelWebsocketHandler(WebSocketMixin, WebSocketHandler, JupyterHandler): """The kernels websocket should connect""" auth_resource = AUTH_RESOURCE @@ -65,7 +65,7 @@ async def get(self, kernel_id): await self.pre_get() await super().get(kernel_id=kernel_id) - async def open(self, kernel_id): + async def open(self, kernel_id): # type: ignore[override] """Open a kernel websocket.""" # Need to call super here to make sure we # begin a ping-pong loop with the client. diff --git a/jupyter_server/utils.py b/jupyter_server/utils.py index ea241e4f1f..16698b74b8 100644 --- a/jupyter_server/utils.py +++ b/jupyter_server/utils.py @@ -10,7 +10,6 @@ import socket import sys import warnings -from _frozen_importlib_external import _NamespacePath from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING, Any, NewType @@ -338,7 +337,7 @@ def is_namespace_package(namespace: str) -> bool | None: if not spec: # e.g. module not installed return None - return isinstance(spec.submodule_search_locations, _NamespacePath) + return bool(spec.origin is None and spec.submodule_search_locations) def filefind(filename: str, path_dirs: Sequence[str]) -> str: diff --git a/pyproject.toml b/pyproject.toml index 7c434376f0..6db9159190 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ docs = [ "typing_extensions" ] + [project.scripts] jupyter-server = "jupyter_server.serverapp:main" @@ -131,10 +132,6 @@ skip-if-exists = ["jupyter_server/static/style/bootstrap.min.css"] install-pre-commit-hook = true optional-editable-build = true -[tool.hatch.envs.hatch-static-analysis] -# keep this version in sync with pre-commit version -dependencies = ["ruff==0.14.6"] - [tool.ruff] line-length = 100 @@ -241,4 +238,9 @@ exclude = ["docs", "test"] ignore = ["W002"] [tool.repo-review] -ignore = ["GH102", "PC111"] +ignore = [ + "GH102", # auto-cancel + "PC111", # blacken-docs + "PP006", # dependency-group, not ready yet + "PP304", # pytest log_level (breaks tests) +] From b26faff3d674f66dece52950cee271ddfa7431fd Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 13 Feb 2026 14:11:06 -0800 Subject: [PATCH 20/48] try to fix ci on windows (#1600) --- .../services/kernels/connection/channels.py | 43 +++++++++++++------ .../services/kernels/kernelmanager.py | 6 ++- pyproject.toml | 2 +- .../services/kernels/test_execution_state.py | 6 +-- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/jupyter_server/services/kernels/connection/channels.py b/jupyter_server/services/kernels/connection/channels.py index 3b936541d8..b8e5ed2e61 100644 --- a/jupyter_server/services/kernels/connection/channels.py +++ b/jupyter_server/services/kernels/connection/channels.py @@ -11,7 +11,7 @@ from textwrap import dedent from jupyter_client import protocol_version as client_protocol_version # type:ignore[attr-defined] -from tornado import gen, web +from tornado import web from tornado.ioloop import IOLoop from tornado.websocket import WebSocketClosedError from traitlets import Any, Bool, Dict, Float, Instance, Int, List, Unicode, default @@ -171,9 +171,9 @@ def nudge(self): # establishing its zmq subscriptions before processing the next request. if getattr(self.kernel_manager, "execution_state", None) == "busy": self.log.debug("Nudge: not nudging busy kernel %s", self.kernel_id) - f: Future[t.Any] = Future() + f: asyncio.Future[t.Any] = asyncio.Future() f.set_result(None) - return _ensure_future(f) + return f # Use a transient shell channel to prevent leaking # shell responses to the front-end. shell_channel = self.kernel_manager.connect_shell() @@ -183,17 +183,26 @@ def nudge(self): # The IOPub used by the client, whose subscriptions we are verifying. iopub_channel = self.channels["iopub"] - info_future: Future[t.Any] = Future() - iopub_future: Future[t.Any] = Future() - both_done = gen.multi([info_future, iopub_future]) + async def wait_for_activity(): + execution_state = getattr(self.kernel_manager, "execution_state", None) + while execution_state == "starting": + await asyncio.sleep(0.05) + execution_state = getattr(self.kernel_manager, "execution_state", None) + self.log.debug("Nudge: %s execution_state=%s", self.kernel_id, execution_state) + + info_future: asyncio.Future[t.Any] = asyncio.Future() + iopub_future: asyncio.Future[t.Any] = asyncio.Future() + futures = [info_future, iopub_future] + futures.append(asyncio.ensure_future(wait_for_activity())) + all_done = asyncio.ensure_future(asyncio.gather(*futures)) def finish(_=None): """Ensure all futures are resolved which in turn triggers cleanup """ - for f in (info_future, iopub_future): + for f in futures: if not f.done(): - f.set_result(None) + f.cancel() def cleanup(_=None): """Common cleanup""" @@ -205,7 +214,7 @@ def cleanup(_=None): control_channel.close() # trigger cleanup when both message futures are resolved - both_done.add_done_callback(cleanup) + all_done.add_done_callback(cleanup) def on_shell_reply(msg): """Handle nudge shell replies.""" @@ -256,7 +265,7 @@ def nudge(count): finish() return - if not both_done.done(): + if not all_done.done(): log = self.log.warning if count % 10 == 0 else self.log.debug log(f"Nudge: attempt {count} on kernel {self.kernel_id}") self.session.send(shell_channel, "kernel_info_request") @@ -267,10 +276,16 @@ def nudge(count): nudge_handle = loop.call_later(0, nudge, count=0) # resolve with a timeout if we get no response - future = gen.with_timeout(loop.time() + self.kernel_info_timeout, both_done) - # ensure we have no dangling resources or unresolved Futures in case of timeout - future.add_done_callback(finish) - return _ensure_future(future) + async def finish_nudge(): + try: + await asyncio.wait_for(all_done, timeout=self.kernel_info_timeout) + except asyncio.CancelledError: + pass + finally: + # make sure everybody gets cancelled, just in case + finish() + + return asyncio.ensure_future(finish_nudge()) async def _register_session(self): """Ensure we aren't creating a duplicate session. diff --git a/jupyter_server/services/kernels/kernelmanager.py b/jupyter_server/services/kernels/kernelmanager.py index 8eb0b947a8..5a64917c12 100644 --- a/jupyter_server/services/kernels/kernelmanager.py +++ b/jupyter_server/services/kernels/kernelmanager.py @@ -12,6 +12,7 @@ import os import pathlib # noqa: TC003 import sys +import time import typing as t import warnings from collections import defaultdict @@ -278,6 +279,7 @@ async def _async_start_kernel( # type:ignore[override] async def _finish_kernel_start(self, kernel_id): """Handle a kernel that finishes starting.""" km = self.get_kernel(kernel_id) + self.log.debug("Waiting for kernel %s", kernel_id) if hasattr(km, "ready"): ready = km.ready if not isinstance(ready, asyncio.Future): @@ -287,6 +289,7 @@ async def _finish_kernel_start(self, kernel_id): except Exception: self.log.exception("Error waiting for kernel manager ready") return + self.log.debug("Kernel %s ready", kernel_id) self._kernel_ports[kernel_id] = km.ports self.start_watching_activity(kernel_id) @@ -490,6 +493,7 @@ def on_restart_failed(): # Re-establish activity watching if ports have changed... if self._get_changed_ports(kernel_id) is not None: self.stop_watching_activity(kernel_id) + self.execution_state = "starting" self.start_watching_activity(kernel_id) return future @@ -585,9 +589,9 @@ def start_watching_activity(self, kernel_id): - update last_activity on every message - record execution_state from status messages """ + self.log.debug("Watching kernel activity: %s", kernel_id) kernel = self._kernels[kernel_id] # add busy/activity markers: - kernel.execution_state = "starting" kernel.reason = "" kernel.last_activity = utcnow() kernel._activity_stream = kernel.connect_iopub() diff --git a/pyproject.toml b/pyproject.toml index 6db9159190..c8acfd2147 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,7 +168,7 @@ unfixable = [ [tool.pytest.ini_options] minversion = "6.0" xfail_strict = true -log_cli_level = "info" +log_cli_level = "debug" addopts = [ "-ra", "--durations=10", "--color=yes", "--doctest-modules", "--showlocals", "--strict-markers", "--strict-config" diff --git a/tests/services/kernels/test_execution_state.py b/tests/services/kernels/test_execution_state.py index ebe3a6c553..f0e6ead51f 100644 --- a/tests/services/kernels/test_execution_state.py +++ b/tests/services/kernels/test_execution_state.py @@ -42,7 +42,7 @@ async def test_execution_state(jp_fetch, jp_ws_fetch): "parent_header": {}, "metadata": {}, "content": { - "code": "while True:\n\tpass", + "code": "import time\nwhile True:\n\ttime.sleep(1)", "silent": False, "allow_stdin": False, "stop_on_error": True, @@ -56,10 +56,10 @@ async def test_execution_state(jp_fetch, jp_ws_fetch): # kernels start slowly on Windows max_startup_time = 60 - started = time.time() + started = time.perf_counter() while es == "starting": await asyncio.sleep(1) - elapsed = time.time() - started + elapsed = time.perf_counter() - started if elapsed > max_startup_time: raise ValueError(f"Kernel did not start up in {max_startup_time} seconds") es = await get_execution_state(kid, jp_fetch) From a91b2e7baf1ae0236e458de4df9fb5c6a11eeaa6 Mon Sep 17 00:00:00 2001 From: Konstantin Taletskiy Date: Fri, 13 Feb 2026 14:25:15 -0800 Subject: [PATCH 21/48] Use st_birthtime for file created timestamp on macOS/BSD (#1594) Co-authored-by: Min RK --- .../services/contents/filemanager.py | 34 ++- tests/services/contents/test_manager.py | 276 +++++++++++++++++- 2 files changed, 305 insertions(+), 5 deletions(-) diff --git a/jupyter_server/services/contents/filemanager.py b/jupyter_server/services/contents/filemanager.py index a78acec778..97bf81bd0a 100644 --- a/jupyter_server/services/contents/filemanager.py +++ b/jupyter_server/services/contents/filemanager.py @@ -44,6 +44,31 @@ _script_exporter = None +def _get_created_timestamp(info: os.stat_result) -> float: + """Get best-effort file creation timestamp from stat result. + + Uses st_birthtime (actual creation time) when available (macOS, BSD, + and Linux with kernel 4.11+ via statx on supported filesystems). + On Windows, st_ctime is the creation time. + On Linux/other, falls back to st_ctime (note: this is inode change time, + not creation time, so operations like chmod may update 'created'). + + Falls back to st_ctime if st_birthtime is unavailable, non-numeric, + negative, or non-finite. Returns st_ctime as final fallback, which + is validated in _base_model() during datetime conversion. + """ + birthtime = getattr(info, "st_birthtime", None) + # Validate: must be numeric, non-negative, and finite. + # Some FUSE/network filesystems may return None or non-numeric values. + # Note: birthtime >= 0 rejects pre-1970 dates as these typically indicate + # invalid or uninitialized values rather than legitimate historical dates. + if isinstance(birthtime, (int, float)) and birthtime >= 0 and math.isfinite(birthtime): + return birthtime + # Fallback to st_ctime; validation happens in _base_model() during datetime conversion + # where OverflowError and other conversion errors are caught and handled + return info.st_ctime + + class FileContentsManager(FileManagerMixin, ContentsManager): """A file contents manager.""" @@ -245,7 +270,7 @@ def _base_model(self, path): try: last_modified = tz.utcfromtimestamp(info.st_mtime) - except (ValueError, OSError): + except (ValueError, OverflowError, OSError): # Files can rarely have an invalid timestamp # https://github.com/jupyter/notebook/issues/2539 # https://github.com/jupyter/notebook/issues/2757 @@ -253,10 +278,11 @@ def _base_model(self, path): self.log.warning("Invalid mtime %s for %s", info.st_mtime, os_path) last_modified = datetime(1970, 1, 1, 0, 0, tzinfo=tz.UTC) + raw_created = _get_created_timestamp(info) try: - created = tz.utcfromtimestamp(info.st_ctime) - except (ValueError, OSError): # See above - self.log.warning("Invalid ctime %s for %s", info.st_ctime, os_path) + created = tz.utcfromtimestamp(raw_created) + except (ValueError, OverflowError, OSError): # See above + self.log.warning("Invalid creation time %s for %s", raw_created, os_path) created = datetime(1970, 1, 1, 0, 0, tzinfo=tz.UTC) # Create the base model. diff --git a/tests/services/contents/test_manager.py b/tests/services/contents/test_manager.py index 22374d2f1c..0a4f3895f0 100644 --- a/tests/services/contents/test_manager.py +++ b/tests/services/contents/test_manager.py @@ -1,10 +1,11 @@ +import math import os import shutil import sys import time from itertools import combinations from typing import Optional -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from jupyter_core.utils import ensure_async @@ -16,6 +17,7 @@ from jupyter_server.services.contents.filemanager import ( AsyncFileContentsManager, FileContentsManager, + _get_created_timestamp, ) from ...utils import expected_http_error @@ -1107,3 +1109,275 @@ async def test_regression_is_hidden(m1, m2, jp_contents_manager): cm.allow_hidden = False with pytest.raises(AssertionError): await ensure_async(cm.get(dirname)) + + +async def test_created_timestamp(jp_contents_manager): + """Test basic created timestamp behavior. + + Verifies that created <= last_modified for a newly created file. + Note: On Linux, 'created' is best-effort (uses st_ctime which is inode + change time, not true creation time). This test verifies the basic + invariant holds for the common case of file creation and modification. + """ + cm = jp_contents_manager + + # Create a new file + model = await ensure_async(cm.new_untitled(type="file", ext=".txt")) + path = model["path"] + os_path = cm._get_os_path(path) + + # Get the model with timestamps + model = await ensure_async(cm.get(path)) + + # created should be <= last_modified for a newly created file. + # Note: On Linux, 'created' uses st_ctime (inode change time) which could + # theoretically be slightly newer than st_mtime in edge cases, but for + # normal file creation this invariant should hold. + assert model["created"] <= model["last_modified"] + + # Backdate the file's mtime to ensure save produces a distinct newer timestamp + # This avoids slow asyncio.sleep() and is more reliable than timing-based tests + info = os.stat(os_path) + old_mtime = info.st_mtime - 10 # 10 seconds in the past + os.utime(os_path, (info.st_atime, old_mtime)) + + model["content"] = "modified content" + model["format"] = "text" + await ensure_async(cm.save(model, path)) + + # Get the updated model + updated_model = await ensure_async(cm.get(path)) + + # Verify last_modified advanced (sanity check) + assert updated_model["last_modified"] > model["last_modified"] + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS only - st_birthtime test") +async def test_created_uses_birthtime_on_macos(jp_contents_manager): + """Test that on macOS, created timestamp uses st_birthtime (actual creation time).""" + cm = jp_contents_manager + + # Create a new file + model = await ensure_async(cm.new_untitled(type="file", ext=".txt")) + path = model["path"] + os_path = cm._get_os_path(path) + + # Get stat info to verify st_birthtime is available and being used + # Use lstat for consistency with _base_model() which doesn't follow symlinks + info = os.lstat(os_path) + assert hasattr(info, "st_birthtime"), "st_birthtime should be available on macOS" + + # Skip on FUSE/network filesystems where st_birthtime may be invalid + if info.st_birthtime < 0 or not math.isfinite(info.st_birthtime): + pytest.skip("st_birthtime is invalid (possibly FUSE/network filesystem)") + + # Get the model and verify created matches st_birthtime directly + model = await ensure_async(cm.get(path)) + created_ts = model["created"].timestamp() + # Allow tolerance for datetime conversion round-trip and filesystem precision differences + assert abs(created_ts - info.st_birthtime) < 1.0, ( + f"created ({created_ts}) should match st_birthtime ({info.st_birthtime})" + ) + + # On macOS, created should be <= last_modified (birthtime <= mtime) + assert model["created"] <= model["last_modified"], ( + "created should be <= last_modified for a newly created file" + ) + + +def test_get_created_timestamp_uses_birthtime(): + """Test that _get_created_timestamp uses st_birthtime when available and valid.""" + mock_info = MagicMock() + mock_info.st_birthtime = 1234567890.0 + mock_info.st_ctime = 9999999999.0 + + result = _get_created_timestamp(mock_info) + assert result == mock_info.st_birthtime + + +def test_get_created_timestamp_fallback_no_birthtime(): + """Test fallback to st_ctime when st_birthtime is unavailable.""" + mock_info = MagicMock(spec=["st_ctime"]) # No st_birthtime attribute + mock_info.st_ctime = 1234567890.0 + + result = _get_created_timestamp(mock_info) + assert result == mock_info.st_ctime + + +def test_get_created_timestamp_accepts_zero_birthtime(): + """Test that st_birthtime == 0 (Unix epoch) is accepted as valid.""" + mock_info = MagicMock() + mock_info.st_birthtime = 0 + mock_info.st_ctime = 1234567890.0 + + result = _get_created_timestamp(mock_info) + assert result == 0 # Unix epoch is valid + + +def test_get_created_timestamp_fallback_negative_birthtime(): + """Test fallback to st_ctime when st_birthtime is negative (invalid).""" + mock_info = MagicMock() + mock_info.st_birthtime = -1 + mock_info.st_ctime = 1234567890.0 + + result = _get_created_timestamp(mock_info) + assert result == mock_info.st_ctime + + +def test_get_created_timestamp_fallback_infinite_birthtime(): + """Test fallback to st_ctime when st_birthtime is infinite (invalid).""" + mock_info = MagicMock() + mock_info.st_birthtime = math.inf + mock_info.st_ctime = 1234567890.0 + + result = _get_created_timestamp(mock_info) + assert result == mock_info.st_ctime + + +def test_get_created_timestamp_fallback_nan_birthtime(): + """Test fallback to st_ctime when st_birthtime is NaN (invalid).""" + mock_info = MagicMock() + mock_info.st_birthtime = math.nan + mock_info.st_ctime = 1234567890.0 + + result = _get_created_timestamp(mock_info) + assert result == mock_info.st_ctime + + +def test_get_created_timestamp_fallback_none_birthtime(): + """Test fallback to st_ctime when st_birthtime is None (some FUSE filesystems).""" + mock_info = MagicMock() + mock_info.st_birthtime = None + mock_info.st_ctime = 1234567890.0 + + result = _get_created_timestamp(mock_info) + assert result == mock_info.st_ctime + + +def test_get_created_timestamp_fallback_string_birthtime(): + """Test fallback to st_ctime when st_birthtime is non-numeric (malformed).""" + mock_info = MagicMock() + mock_info.st_birthtime = "invalid" + mock_info.st_ctime = 1234567890.0 + + result = _get_created_timestamp(mock_info) + assert result == mock_info.st_ctime + + +async def test_created_timestamp_fallback_to_epoch(jp_contents_manager, caplog): + """Test end-to-end fallback to Unix epoch when timestamps are invalid. + + Verifies that _base_model() correctly handles invalid timestamps from + _get_created_timestamp() by falling back to Unix epoch and logging a warning. + """ + from datetime import datetime + + from jupyter_server import _tz as tz + + cm = jp_contents_manager + + # Create a file first + model = await ensure_async(cm.new_untitled(type="file", ext=".txt")) + path = model["path"] + os_path = cm._get_os_path(path) + + # Mock os.lstat to return an invalid timestamp (overflow-inducing value) + original_lstat = os.lstat + + def mock_lstat(p): + result = original_lstat(p) + if p == os_path: + # Create a mock that returns invalid timestamps + mock_result = MagicMock(wraps=result) + mock_result.st_mtime = 1e20 # Will cause OverflowError + mock_result.st_ctime = 1e20 + mock_result.st_birthtime = 1e20 + mock_result.st_size = result.st_size + mock_result.st_mode = result.st_mode + return mock_result + return result + + with patch("os.lstat", side_effect=mock_lstat): + import logging + + with caplog.at_level(logging.WARNING): + model = await ensure_async(cm.get(path)) + + # Should fall back to Unix epoch for both timestamps + unix_epoch = datetime(1970, 1, 1, 0, 0, tzinfo=tz.UTC) + assert model["created"] == unix_epoch + assert model["last_modified"] == unix_epoch + + # Should have logged warnings about invalid timestamps + assert "Invalid" in caplog.text or "invalid" in caplog.text.lower() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only - st_ctime is creation time") +async def test_created_uses_ctime_on_windows(jp_contents_manager): + """Test that on Windows, created timestamp uses st_ctime (which is creation time). + + On Windows, st_ctime represents the actual file creation time, unlike Unix + where it represents inode change time. This test verifies that the created + timestamp correctly reflects st_ctime on Windows. + """ + cm = jp_contents_manager + + # Create a new file + model = await ensure_async(cm.new_untitled(type="file", ext=".txt")) + path = model["path"] + os_path = cm._get_os_path(path) + + # Get stat info + info = os.lstat(os_path) + + # Get the model and verify created matches st_ctime + model = await ensure_async(cm.get(path)) + created_ts = model["created"].timestamp() + + # On Windows, created should match st_ctime (creation time) + # Allow tolerance for datetime conversion round-trip + assert abs(created_ts - info.st_ctime) < 1.0, ( + f"created ({created_ts}) should match st_ctime ({info.st_ctime}) on Windows" + ) + + # created should be <= last_modified + assert model["created"] <= model["last_modified"], ( + "created should be <= last_modified for a newly created file" + ) + + +async def test_base_model_invalid_timestamp_fallback(jp_contents_manager): + """Test that _base_model falls back to Unix epoch when timestamps are invalid.""" + from datetime import datetime + + from jupyter_server import _tz as tz + + cm = jp_contents_manager + + # Create a real file first + model = await ensure_async(cm.new_untitled(type="file", ext=".txt")) + path = model["path"] + os_path = cm._get_os_path(path) + + # Mock os.lstat to return invalid timestamps + real_lstat = os.lstat + + def mock_lstat(p): + if p == os_path: + mock_info = MagicMock() + mock_info.st_size = 0 + mock_info.st_mode = real_lstat(p).st_mode + # Invalid timestamps that will cause OverflowError + mock_info.st_mtime = math.inf + mock_info.st_ctime = math.inf + mock_info.st_birthtime = math.inf + return mock_info + return real_lstat(p) + + with patch("os.lstat", side_effect=mock_lstat): + result = await ensure_async(cm.get(path)) + + # Should fall back to Unix epoch for both timestamps + unix_epoch = datetime(1970, 1, 1, 0, 0, tzinfo=tz.UTC) + assert result["created"] == unix_epoch + assert result["last_modified"] == unix_epoch From 33f5e2960c51d291e7e08957da8f7f15327833aa Mon Sep 17 00:00:00 2001 From: Krishna Date: Sat, 14 Feb 2026 03:55:46 +0530 Subject: [PATCH 22/48] Fix double write when refusing hidden files in contents handler (#1585) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Min RK --- jupyter_server/services/contents/handlers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index dac8183214..08ea9a7650 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -149,6 +149,7 @@ async def get(self, path=""): await self._finish_error( HTTPStatus.NOT_FOUND, f"file or directory {path!r} does not exist" ) + return try: expect_hash = require_hash From 8980e5086a0d491a8c006a7cc76c097fdc5859a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krassowski?= <5832902+krassowski@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:54:04 +0100 Subject: [PATCH 23/48] Fix package spec for jupytext (#1614) --- .github/workflows/downstream.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index be985257a4..4fc2efc86d 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 - package_spec: '."[test,test-functional]"' + package_spec: ".[test,test-functional]" 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 From 1b77d4407eedc2ab29657c0665b785e9f50db658 Mon Sep 17 00:00:00 2001 From: Yaniv Schahar <1933810+YDawn@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:06:14 -0400 Subject: [PATCH 24/48] Handle EADDRINUSE and EACCES in _bind_http_server_tcp (#1613) Co-authored-by: Zachary Sailer --- jupyter_server/serverapp.py | 15 ++++++- tests/test_serverapp.py | 87 ++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index ed2bd13615..68127440e8 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -2672,8 +2672,19 @@ def _bind_http_server_unix(self) -> bool: def _bind_http_server_tcp(self) -> bool: """Bind a tcp server.""" - self.http_server.listen(self.port, self.ip) - return True + try: + self.http_server.listen(self.port, self.ip) + except OSError as e: + if e.errno == errno.EADDRINUSE: + self.log.warning(_i18n("The port %i is already in use.") % self.port) + return False + elif e.errno in (errno.EACCES, getattr(errno, "WSAEACCES", errno.EACCES)): + self.log.warning(_i18n("Permission to listen on port %i denied.") % self.port) + return False + else: + raise + else: + return True def _find_http_port(self) -> None: """Find an available http port.""" diff --git a/tests/test_serverapp.py b/tests/test_serverapp.py index eb137b12d4..b84711a0c8 100644 --- a/tests/test_serverapp.py +++ b/tests/test_serverapp.py @@ -1,3 +1,4 @@ +import errno import getpass import json import logging @@ -5,7 +6,7 @@ import pathlib import sys import warnings -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from jupyter_core.application import NoStart @@ -661,3 +662,87 @@ def test(): ) def test_tornado_authentication_detection(method, expected): assert _has_tornado_web_authenticated(method) == expected + + +def test_bind_http_server_tcp_success(jp_configurable_serverapp): + """Normal case: listen succeeds, returns True.""" + app = jp_configurable_serverapp() + mock_server = MagicMock() + with patch.object( + type(app), "http_server", new_callable=lambda: property(lambda self: mock_server) + ): + assert app._bind_http_server_tcp() is True + mock_server.listen.assert_called_once_with(app.port, app.ip) + + +def test_bind_http_server_tcp_eaddrinuse(jp_configurable_serverapp): + """EADDRINUSE: returns False instead of crashing with a traceback.""" + app = jp_configurable_serverapp() + mock_server = MagicMock() + mock_server.listen.side_effect = OSError(errno.EADDRINUSE, "Address already in use") + with patch.object( + type(app), "http_server", new_callable=lambda: property(lambda self: mock_server) + ): + assert app._bind_http_server_tcp() is False + + +def test_bind_http_server_tcp_eacces(jp_configurable_serverapp): + """EACCES: returns False instead of crashing with a traceback.""" + app = jp_configurable_serverapp() + mock_server = MagicMock() + mock_server.listen.side_effect = OSError(errno.EACCES, "Permission denied") + with patch.object( + type(app), "http_server", new_callable=lambda: property(lambda self: mock_server) + ): + assert app._bind_http_server_tcp() is False + + +def test_bind_http_server_tcp_unexpected_oserror(jp_configurable_serverapp): + """Unexpected OSError: re-raised, not silently swallowed.""" + app = jp_configurable_serverapp() + mock_server = MagicMock() + mock_server.listen.side_effect = OSError(errno.ENOENT, "No such file or directory") + with patch.object( + type(app), "http_server", new_callable=lambda: property(lambda self: mock_server) + ): + with pytest.raises(OSError, match="No such file or directory"): + app._bind_http_server_tcp() + + +def test_bind_http_server_tcp_eaddrinuse_logs_warning(jp_configurable_serverapp, caplog): + """EADDRINUSE: logs a warning mentioning the port.""" + app = jp_configurable_serverapp() + mock_server = MagicMock() + mock_server.listen.side_effect = OSError(errno.EADDRINUSE, "Address already in use") + with patch.object( + type(app), "http_server", new_callable=lambda: property(lambda self: mock_server) + ): + with caplog.at_level(logging.WARNING): + app._bind_http_server_tcp() + assert any("already in use" in rec.message for rec in caplog.records) + + +def test_bind_http_server_tcp_eacces_logs_warning(jp_configurable_serverapp, caplog): + """EACCES: logs a warning mentioning permission denied.""" + app = jp_configurable_serverapp() + mock_server = MagicMock() + mock_server.listen.side_effect = OSError(errno.EACCES, "Permission denied") + with patch.object( + type(app), "http_server", new_callable=lambda: property(lambda self: mock_server) + ): + with caplog.at_level(logging.WARNING): + app._bind_http_server_tcp() + assert any("denied" in rec.message.lower() for rec in caplog.records) + + +def test_bind_http_server_eaddrinuse_exits_cleanly(jp_configurable_serverapp): + """Integration: _bind_http_server calls exit(1) when TCP bind returns False.""" + app = jp_configurable_serverapp() + mock_server = MagicMock() + mock_server.listen.side_effect = OSError(errno.EADDRINUSE, "Address already in use") + with patch.object( + type(app), "http_server", new_callable=lambda: property(lambda self: mock_server) + ): + with patch.object(app, "exit") as mock_exit: + app._bind_http_server() + mock_exit.assert_called_once_with(1) From 7bdad130f956d1096965575c78d635a57ddc708b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:06:33 -0700 Subject: [PATCH 25/48] chore: update pre-commit hooks (#1607) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5618313d11..3f63f8736e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.36.1 + rev: 0.37.1 hooks: - id: check-github-workflows @@ -40,7 +40,7 @@ repos: types_or: [yaml, html, json] - repo: https://github.com/codespell-project/codespell - rev: "v2.4.1" + rev: "v2.4.2" hooks: - id: codespell args: ["-L", "sur,nd"] @@ -53,7 +53,7 @@ repos: - id: rst-inline-touching-normal - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.19.1" + rev: "v1.20.0" hooks: - id: mypy files: jupyter_server @@ -63,7 +63,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # keep the revision in sync with the ruff version in pyproject.toml - rev: v0.14.14 + rev: v0.15.9 hooks: - id: ruff-check types_or: [python, jupyter] @@ -72,7 +72,7 @@ repos: types_or: [python, jupyter] - repo: https://github.com/scientific-python/cookie - rev: "2025.11.21" + rev: "2026.04.04" hooks: - id: sp-repo-review additional_dependencies: ["repo-review[cli]"] From 9c075eb6a1b6b33378228986a9d40ef0828fd6a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:19:33 +0000 Subject: [PATCH 26/48] Bump brace-expansion from 1.1.12 to 1.1.13 (#1615) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Min RK --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7660081ef2..b147965bea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,9 +49,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -425,9 +425,9 @@ "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==" }, "brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" From e80b461621bfa6d14113ce865a03e78b7199d547 Mon Sep 17 00:00:00 2001 From: Terminal Chai Date: Thu, 9 Apr 2026 21:51:49 +0530 Subject: [PATCH 27/48] Fix outdated ContentsManager testing guidance (#1611) Co-authored-by: Zachary Sailer --- docs/source/developers/contents.rst | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/source/developers/contents.rst b/docs/source/developers/contents.rst index 6910535f30..35bb60daa1 100644 --- a/docs/source/developers/contents.rst +++ b/docs/source/developers/contents.rst @@ -278,19 +278,21 @@ for a more complete example. Testing ------- -.. currentmodule:: jupyter_server.services.contents.tests -:mod:`jupyter_server.services.contents.tests` includes several test suites written -against the abstract Contents API. This means that an excellent way to test a -new ContentsManager subclass is to subclass our tests to make them use your -ContentsManager. +Jupyter Server's ContentsManager test suites live in the +`tests/services/contents `_ +directory in the source repository rather than in an importable +``jupyter_server.services.contents.tests`` module. This means that an +excellent way to test a new ``ContentsManager`` subclass is to copy or adapt +the relevant tests from the repository so that they use your +``ContentsManager`` implementation. .. note:: - PGContents_ is an example of a complete implementation of a custom - ``ContentsManager``. It stores notebooks and files in PostgreSQL_ and encodes - directories as SQL relations. PGContents also provides an example of how to - reuse the notebook's tests. + PGContents_ is an example of a complete implementation of a custom + ``ContentsManager``. It stores notebooks and files in PostgreSQL_, encodes + directories as SQL relations, and shows how to adapt Jupyter Server's tests + for a custom backend. .. _NBFormat: https://nbformat.readthedocs.io/en/latest/index.html .. _PGContents: https://github.com/quantopian/pgcontents From 1b70bc429443c3691d0f10b259af7268b38a3f0f Mon Sep 17 00:00:00 2001 From: M Bussonnier Date: Wed, 29 Apr 2026 15:46:07 +0200 Subject: [PATCH 28/48] Start to test on Python 3.13 and 3.14 (#1623) Remove duplicate windows + 3.9 I think it would be good to drop 3.9, 3.10 soon (after next release). Drop Pypy3.9 (too old) Remove all_check, this is now a native option of github if we want. it just adds extra noise to CI results Marking tests that rely on warning and free-threading as xfail as capturing warnings on this platform is notoriously finicky --- .github/workflows/python-tests.yml | 43 ++---------------------------- tests/base/test_websocket.py | 4 +++ 2 files changed, 6 insertions(+), 41 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 1083867e48..dbaaa5b4eb 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -18,12 +18,8 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.9", "3.11", "3.12"] + python-version: ["3.9", "3.11", "3.12", "3.13", "3.14", "3.14t"] include: - - os: windows-latest - python-version: "3.9" - - os: ubuntu-latest - python-version: "pypy-3.9" - os: macos-latest python-version: "3.10" - os: ubuntu-latest @@ -39,14 +35,9 @@ jobs: sudo apt-get update sudo apt-get install texlive-plain-generic inkscape texlive-xetex sudo apt-get install xvfb x11-utils libxkbcommon-x11-0 - # pandoc is not up to date in the ubuntu repos, so we install directly - wget https://github.com/jgm/pandoc/releases/download/3.1.2/pandoc-3.1.2-1-amd64.deb && sudo dpkg -i pandoc-3.1.2-1-amd64.deb - name: Run the tests on posix - if: ${{ !startsWith(matrix.python-version, 'pypy') && !startsWith(matrix.os, 'windows') }} + if: ${{!startsWith(matrix.os, 'windows') }} run: hatch run cov:test --cov-fail-under 75 || hatch -v run test:test --lf - - name: Run the tests on pypy - if: ${{ startsWith(matrix.python-version, 'pypy') }} - run: hatch run test:nowarn || hatch -v run test:nowarn --lf - name: Run the tests on windows if: ${{ startsWith(matrix.os, 'windows') }} run: hatch run cov:nowarn -s || hatch -v run cov:nowarn --lf @@ -210,16 +201,6 @@ jobs: run: hatch -v run cov:integration - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 - integration_check_pypy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 - with: - python_version: "pypy-3.9" - - name: Run the tests - run: hatch -v run test:nowarn --integration_tests=true - coverage: runs-on: ubuntu-latest needs: @@ -230,23 +211,3 @@ jobs: - uses: jupyterlab/maintainer-tools/.github/actions/report-coverage@v1 with: fail_under: 80 - - tests_check: # This job does nothing and is only used for the branch protection - if: always() - needs: - - coverage - - integration_check_pypy - - test_docs - - test_lint - - test_examples - - test_minimum_versions - - test_prereleases - - check_links - - check_release - - test_sdist - runs-on: ubuntu-latest - steps: - - name: Decide whether the needed jobs succeeded or failed - uses: re-actors/alls-green@release/v1 - with: - jobs: ${{ toJSON(needs) }} diff --git a/tests/base/test_websocket.py b/tests/base/test_websocket.py index 13dd7cfe4f..08af0fd111 100644 --- a/tests/base/test_websocket.py +++ b/tests/base/test_websocket.py @@ -1,6 +1,7 @@ """Test Base Websocket classes""" import logging +import sysconfig import time from unittest.mock import MagicMock, patch @@ -17,6 +18,8 @@ from jupyter_server.serverapp import ServerApp from jupyter_server.utils import JupyterServerAuthWarning, url_path_join +is_freethreaded = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + class MockHandler(WebSocketMixin, WebSocketHandler): allow_origin = "*" @@ -165,6 +168,7 @@ def get(self, *args, **kwargs) -> None: return super().get(*args, **kwargs) +@pytest.mark.xfail(is_freethreaded, reason="warnings are finicky on free-threaded python") @pytest.mark.parametrize( "jp_server_config", [ From 7aafbf2d69876edaedc798a1e08c259204bffcee Mon Sep 17 00:00:00 2001 From: Terminal Chai Date: Thu, 30 Apr 2026 13:41:58 +0530 Subject: [PATCH 29/48] fix: use %s placeholders in HTTPError to prevent Tornado from doubling % in gateway URLs (#1620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michał Krassowski <5832902+krassowski@users.noreply.github.com> --- jupyter_server/gateway/gateway_client.py | 12 ++++++++---- tests/test_gateway.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/jupyter_server/gateway/gateway_client.py b/jupyter_server/gateway/gateway_client.py index 88dc79ffa1..c1be89a3c9 100644 --- a/jupyter_server/gateway/gateway_client.py +++ b/jupyter_server/gateway/gateway_client.py @@ -807,8 +807,10 @@ async def gateway_request(endpoint: str, **kwargs: ty.Any) -> HTTPResponse: raise web.HTTPError( e.code, - f"Error from Gateway: [{error_message}] {error_reason}. " + "Error from Gateway: [%s] %s. " "Ensure gateway url is valid and the Gateway instance is running.", + error_message, + error_reason, ) from e except ConnectionError as e: gateway_client.emit( @@ -816,8 +818,9 @@ async def gateway_request(endpoint: str, **kwargs: ty.Any) -> HTTPResponse: ) raise web.HTTPError( 503, - f"ConnectionError was received from Gateway server url '{gateway_client.url}'. " + "ConnectionError was received from Gateway server url '%s'. " "Check to be sure the Gateway instance is running.", + gateway_client.url, ) from e except gaierror as e: gateway_client.emit( @@ -825,8 +828,9 @@ async def gateway_request(endpoint: str, **kwargs: ty.Any) -> HTTPResponse: ) raise web.HTTPError( 404, - f"The Gateway server specified in the gateway_url '{gateway_client.url}' doesn't " - f"appear to be valid. Ensure gateway url is valid and the Gateway instance is running.", + "The Gateway server specified in the gateway_url '%s' doesn't " + "appear to be valid. Ensure gateway url is valid and the Gateway instance is running.", + gateway_client.url, ) from e except Exception as e: gateway_client.emit( diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 6f4fcebc1a..0b9b9ad724 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -782,6 +782,26 @@ async def test_websocket_connection_with_session_id(init_gateway, jp_serverapp, pytest.fail(f"Logs contain an error: {message}") +def test_gateway_httperror_percent_not_doubled(): + """Verify that % characters in gateway URLs are not doubled in error messages. + + Tornado's HTTPError escapes '%' in log_message when called without trailing + args (replacing '%' with '%%'). By using '%s' placeholders with separate + args, gateway error messages preserve URLs that contain percent-encoded + characters such as 'http://host/?q=a%3Db'. Regression test for #1503. + """ + url = "http://gateway-host/api?redirect=http%3A%2F%2Fexample.com" + + # Without args: Tornado doubles '%' in log_message. + error_no_args = HTTPError(503, f"Gateway url '{url}' is unreachable.") + assert "%%" in error_no_args.log_message # Tornado's escaping in effect + + # With '%s' placeholder + args: log_message is kept as-is, URL goes to args. + error_with_args = HTTPError(503, "Gateway url '%s' is unreachable.", url) + assert "%%" not in error_with_args.log_message + assert url in error_with_args.args + + # # Test methods below... # From b3890772e5016c7164932ebc058081e469f47364 Mon Sep 17 00:00:00 2001 From: Sam Bloomquist Date: Thu, 30 Apr 2026 07:09:56 -0500 Subject: [PATCH 30/48] Updated dev install instructions (#1622) --- CONTRIBUTING.rst | 86 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index beacc9d8a3..1911bf6b17 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -19,24 +19,69 @@ Setting Up a Development Environment Installing the Jupyter Server ----------------------------- -The development version of the server requires `node `_ and `pip `_. +Developing on Jupyter Server requires Python, pip, and Git to be installed on your system. +The minimum supported Python version for Jupyter Server can be found in the ``pyproject.toml``. -Once you have installed the dependencies mentioned above, use the following -steps:: - pip install --upgrade pip - git clone https://github.com/jupyter/jupyter_server +First clone your fork of the repository:: + + git clone https://github.com//jupyter_server cd jupyter_server - pip install -e ".[test]" -If you are using a system-wide Python installation and you only want to install the server for you, -you can add ``--user`` to the install commands. +Then choose one of the following environment setup options. Any of them will work. Picking one is a matter of +personal preference. + +Option 1: ``pip`` + ``venv`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This is the most direct setup, avoiding any additional tool installations:: + + python -m venv .venv + source .venv/bin/activate + python -m pip install --upgrade pip + python -m pip install -e ".[test]" + +On Windows, activate the environment with:: + + .venv\Scripts\activate + +Option 2: ``conda`` +~~~~~~~~~~~~~~~~~~~ + +Many Jupyter projects and contributors use ``conda`` or ``mamba`` for local +development instead:: + + conda create -n jupyter-server-dev python=3.12 pip + conda activate jupyter-server-dev + python -m pip install -e ".[test]" + -Once you have done this, you can launch the main branch of Jupyter server -from any directory in your system with:: +With your ``venv`` or ``conda`` environment activated, you can run the server with:: jupyter server +Option 3: ``uv`` +~~~~~~~~~~~~~~~~ + +`uv `_ is a more recent option for Python project management. +It can set up your environment with a single command:: + + uv sync --extra test + +This creates a local ``.venv`` automatically if needed. To activate it +yourself, run:: + + source .venv/bin/activate + +On Windows, use:: + + .venv\Scripts\activate + + +When using ``uv`` you can run the server with:: + + uv run jupyter server + Code Styling and Quality Checks ------------------------------- @@ -44,8 +89,8 @@ Code Styling and Quality Checks need to worry too much about your code style. As long as your code is valid, the pre-commit hook should take care of how it should look. -``pre-commit`` and its associated hooks will automatically be installed when -you run ``pip install -e ".[test]"`` +``pre-commit`` and its associated hooks are included in the ``test`` dependency group +and therefore, would be installed in any of the three installation options above. To install ``pre-commit`` hook manually, run the following:: @@ -56,7 +101,7 @@ You can invoke the pre-commit hook by hand at any time with:: pre-commit run -which should run any autoformatting on your code +which will run any autoformatting on your code and tell you about any errors it couldn't fix automatically. You may also install `black integration `_ into your text editor to format code automatically. @@ -76,8 +121,8 @@ run the type checker. Troubleshooting the Installation -------------------------------- -If you do not see that your Jupyter Server is not running on dev mode, it's possible that you are -running other instances of Jupyter Server. You can try the following steps: +If you do not see that your Jupyter Server is running in dev mode, it's possible that you are +running other instances of Jupyter Server elsewhere on your system. You can try the following steps: 1. Uninstall all instances of the jupyter_server package. These include any installations you made using pip or conda @@ -91,10 +136,11 @@ running other instances of Jupyter Server. You can try the following steps: Running Tests ============= -Install dependencies:: +If you used one of the environment setup options above, the test dependencies +are already installed. Otherwise install them with:: - pip install -e ".[test]" - pip install -e examples/simple # to test the examples + python -m pip install -e ".[test]" + python -m pip install -e examples/simple # to test the examples To run the Python tests, use:: @@ -117,9 +163,9 @@ You can also drop into a shell in the test environment by running:: Building the Docs ================= -Install the docs requirements using ``pip``:: +Install the docs requirements into your active environment using ``pip``:: - pip install ".[docs]" + python -m pip install -e ".[docs]" Once you have installed the required packages, you can build the docs with:: From 8ba9f6c1d36b58e0abe5136fab52f51cd189d4d1 Mon Sep 17 00:00:00 2001 From: Yann Pellegrini <3519082+Yann-P@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:19:53 +0200 Subject: [PATCH 31/48] Add query param to sanitize HTML in GET /nbconvert/html (#1618) New optional `sanitize_html` query param that defaults to false (nbconvert's default). To be used by jupyterlab, specifically for this pull request: https://github.com/jupyterlab/jupyterlab/pull/18765 ### Backwards compatibility OK because the flag is optional. To conditionally display the "sanitize" toggle in jupyterlab, it will rely on jupyter server's version (see https://github.com/jupyterlab/jupyterlab/pull/18765). --- jupyter_server/nbconvert/handlers.py | 14 +++++++- tests/nbconvert/test_handlers.py | 49 +++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/jupyter_server/nbconvert/handlers.py b/jupyter_server/nbconvert/handlers.py index e474ac2785..5b12d7020c 100644 --- a/jupyter_server/nbconvert/handlers.py +++ b/jupyter_server/nbconvert/handlers.py @@ -95,9 +95,21 @@ class NbconvertFileHandler(JupyterHandler): @web.authenticated @authorized async def get(self, format, path): - """Get a notebook file in a desired format.""" + """Get a notebook file in a desired format. + + Parameters + ---------- + download: bool, optional + If true, set Content-Disposition: attachment + sanitize_html: bool, optional (html format only) + If true, sanitize HTML (sets sanitize_html flag on nbconvert) + """ self.check_xsrf_cookie() exporter = get_exporter(format, config=self.config, log=self.log) + if format == "html": + sanitize = self.get_argument("sanitize_html", None) + if sanitize is not None: + exporter.sanitize_html = sanitize.lower() == "true" path = path.strip("/") # If the notebook relates to a real file (default contents manager), diff --git a/tests/nbconvert/test_handlers.py b/tests/nbconvert/test_handlers.py index f14fde35a2..b0d7da35f2 100644 --- a/tests/nbconvert/test_handlers.py +++ b/tests/nbconvert/test_handlers.py @@ -35,6 +35,15 @@ def notebook(jp_root_dir): execution_count=1, ) ) + cc1.outputs.append( + new_output( + output_type="display_data", + data={ + "text/html": '', + "text/plain": "Fallback xss test for Non-html backend", + }, + ) + ) nb.cells.append(cc1) # Write file to tmp dir. @@ -101,7 +110,7 @@ async def test_from_file_download(jp_fetch, notebook): assert "testnb.py" in content_disposition -async def test_from_file_zip(jp_fetch, notebook): +async def test_from_file_zip_to_latex(jp_fetch, notebook): r = await jp_fetch( "nbconvert", "latex", @@ -133,6 +142,44 @@ async def test_from_post(jp_fetch, notebook): assert "print(2*6)" in r.body.decode() +async def test_from_file_sanitize_html(jp_fetch, notebook): + # flag explicitly set to true + r = await jp_fetch( + "nbconvert", + "html", + "foo", + "testnb.ipynb", + method="GET", + params={"sanitize_html": "true"}, + ) + assert r.code == 200 + assert "