From ebea865dd5bda00cefef6d0947c57558f62e7b4d Mon Sep 17 00:00:00 2001 From: Tim Saucer Date: Wed, 9 Sep 2026 09:27:52 -0400 Subject: [PATCH] docs: quiet the kernel noise in the docs build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nb_execution_mode = "force"` starts a Jupyter kernel per executed page, and each start printed two messages that have nothing to do with this build: [IPKernelApp] WARNING | Kernel is running over TCP without encryption... UserWarning: IPython parent '' is not a writable location... Together they accounted for every line of the build's stderr, which makes a real Sphinx warning easy to miss. The build log is now empty on success. The encryption notice is not describing a risk here. The kernel is a short-lived child process on the same machine, executing pages from this repository, reachable only over loopback. Neither remedy the message suggests works. `transport="ipc"` silences it and executes fine in isolation, but the real build then fails with "Kernel didn't respond in 60 seconds". `KernelManager.transport_encryption = "auto"` fails at kernel start on jupyter_client 8.9.1, which hands the provisioned CurveZMQ key to the client as `str` where a `bytes` trait is expected. So the message is filtered instead, by a logging filter matched to that one string rather than by `log_level = "ERROR"`, so any other kernel warning still reaches the log. If ipykernel rewords the message the filter stops matching and the warning returns, which is the right way for it to fail. The filter lives in `docs/ipython_kernel_config.py`, which `build.sh` copies into a throwaway `IPYTHONDIR` profile. It has to be an IPython profile rather than a `JUPYTER_CONFIG_PATH` entry, because `IPKernelApp` derives from `BaseIPythonApplication` and reads the IPython profile directory — `IPKernelApp().config_file_paths` is empty. Pointing `IPYTHONDIR` at a writable directory is also what removes the second message. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + docs/build.sh | 17 ++++++++++ docs/ipython_kernel_config.py | 62 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 ++ 4 files changed, 82 insertions(+) create mode 100644 docs/ipython_kernel_config.py diff --git a/.gitignore b/.gitignore index 614d82327..0558192c8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target .idea /docs/temp /docs/build +/docs/ipython .DS_Store .vscode diff --git a/docs/build.sh b/docs/build.sh index e8409ee72..b837ab054 100755 --- a/docs/build.sh +++ b/docs/build.sh @@ -34,9 +34,26 @@ fi rm -rf build 2> /dev/null rm -rf temp 2> /dev/null +rm -rf ipython 2> /dev/null mkdir temp cp -rf source/* temp/ +# Give the notebook kernels a writable IPython profile of our own, and put +# `ipython_kernel_config.py` in it. Two reasons, both about build output: +# +# * Without a writable IPYTHONDIR, IPython warns "IPython parent '' is +# not a writable location, using a temp directory" once per kernel start. +# * The config file filters ipykernel's unencrypted-TCP notice, which +# otherwise repeats once per executed page. See the comments in it. +# +# `IPKernelApp` reads the IPython profile directory rather than the Jupyter +# config path, so this has to be an IPYTHONDIR profile and not a +# `JUPYTER_CONFIG_PATH` entry. Kept out of temp/, which is the Sphinx source +# directory for this build. +export IPYTHONDIR="$script_dir/ipython" +mkdir -p "$IPYTHONDIR/profile_default" +cp ipython_kernel_config.py "$IPYTHONDIR/profile_default/" + # myst-nb executes each page as a notebook from the directory that page # lives in, so the example data files must sit alongside every page that # loads them by relative name (e.g. `ctx.read_csv("pokemon.csv")`). Symlink diff --git a/docs/ipython_kernel_config.py b/docs/ipython_kernel_config.py new file mode 100644 index 000000000..8813eee62 --- /dev/null +++ b/docs/ipython_kernel_config.py @@ -0,0 +1,62 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Config for the Jupyter kernel that executes the docs pages. + +``build.sh`` copies this into a throwaway ``IPYTHONDIR`` profile, which is +where ``IPKernelApp`` looks for it. It is not on the Jupyter config path: +``IPKernelApp`` derives from ``BaseIPythonApplication`` and reads the IPython +profile directory, so ``JUPYTER_CONFIG_PATH`` has no effect on it. +""" + +import logging + +# ipykernel 7 warns at every kernel start that the ZeroMQ channels are +# unencrypted TCP. With `nb_execution_mode = "force"` the docs build starts a +# kernel per executed page, so the message repeats a dozen times per build and +# buries the output that matters. +# +# It does not describe a risk this build has. The kernel is a short-lived child +# process on the same machine, executing pages from this repository, reachable +# only over loopback. There is no second party to the conversation. +# +# Neither remedy the message itself suggests is usable here: +# +# - `transport="ipc"` does silence it, and it did work in isolation, but the +# real build then fails with "Kernel didn't respond in 60 seconds". +# - `KernelManager.transport_encryption = "auto"` fails at kernel start on +# jupyter_client 8.9.1, which hands the provisioned CurveZMQ key to the +# client as `str` where a `bytes` trait is expected. +# +# So the message is filtered rather than the condition removed. A filter is +# used in preference to `c.Application.log_level = "ERROR"` so that any *other* +# kernel warning still reaches the build log. If ipykernel rewords the message +# this filter stops matching and the warning comes back, which is the right way +# for it to fail. +_SUPPRESSED = "Kernel is running over TCP without encryption" + + +class _DropTransportEncryptionNotice(logging.Filter): + """Drop ipykernel's unencrypted-TCP notice, and nothing else.""" + + def filter(self, record: logging.LogRecord) -> bool: + """Return False only for the one message this build does not need.""" + return _SUPPRESSED not in record.getMessage() + + +# Traitlets names an Application's logger after its class. +logging.getLogger("IPKernelApp").addFilter(_DropTransportEncryptionNotice()) diff --git a/pyproject.toml b/pyproject.toml index e71098799..e293fd14c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -178,6 +178,8 @@ extend-allowed-calls = ["datafusion.lit", "lit"] ] "docs/*" = ["D"] "docs/source/conf.py" = ["ANN001", "ERA001", "INP001"] +# A Jupyter kernel config file, executed by traitlets rather than imported. +"docs/ipython_kernel_config.py" = ["INP001"] # CI and pre-commit invoke codespell with different paths, so we have a little # redundancy here, and we intentionally drop python in the path.