Repository: jupyter/jupyter_client
Pull request #965 (2023-08-31 19:50:56)

Clean changelog heading
I think this may not be required, but apologies if it is. This currently renders as-is in the docs:

![Screenshot from 2023-08-31 20-50-21](https://github.com/jupyter/jupyter_client/assets/5832902/e43cf5c9-e1fa-47dd-a302-f2c6698e8a3d)

I believe jupyter-releaser uses `<!-- <START NEW CHANGELOG ENTRY> -->` markers, so I am not sure why this was included/retained.

lumberbot-app[bot]: Awww, sorry krassowski you do not seem to be allowed to do that, please ask a repository maintainer.
krassowski: @blink1073 I assume you are the maintainer in question ;) Would it help if the bot was allowed to label?

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index cbd920f..fcdd3e8 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,7 +4,15 @@ updates:
     directory: "/"
     schedule:
       interval: "weekly"
+    groups:
+      actions:
+        patterns:
+          - "*"
   - package-ecosystem: "pip"
     directory: "/"
     schedule:
       interval: "weekly"
+    groups:
+      actions:
+        patterns:
+          - "*"
diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml
index 4aaa232..8f2aa22 100644
--- a/.github/workflows/downstream.yml
+++ b/.github/workflows/downstream.yml
@@ -50,7 +50,7 @@ jobs:
       - uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1
         with:
           package_name: nbconvert
-          package_spec: pip install -e ".[test]"
+          package_spec: -e ".[test]"
 
   jupyter_server:
     runs-on: ubuntu-latest
@@ -73,7 +73,7 @@ jobs:
         uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
 
       - name: Setup conda ${{ matrix.python-version }}
-        uses: conda-incubator/setup-miniconda@v2
+        uses: conda-incubator/setup-miniconda@v3
         with:
           auto-update-conda: true
           activate-environment: jupyter_kernel_test
@@ -97,7 +97,7 @@ jobs:
         uses: actions/checkout@v4
 
       - name: Setup Python
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: "3.9"
           architecture: "x64"
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 9fe57ae..28bf263 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -38,7 +38,10 @@ jobs:
       fail-fast: false
       matrix:
         os: [ubuntu-latest, windows-latest, macos-latest]
-        python-version: ["3.8", "3.12"]
+        python-version:
+          - "3.8"
+          - "3.12"
+          - "3.13-dev"
         include:
           - os: windows-latest
             python-version: "3.11"
@@ -76,7 +79,7 @@ jobs:
           fail_under: 78
 
   docs:
-    runs-on: windows-latest
+    runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
       - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
@@ -86,8 +89,9 @@ jobs:
           # If this fails run `hatch run docs:api` locally
           # and commit.
           git status --porcelain
-          git status -s | grep "A" && exit 1
-          git status -s | grep "M" && exit 1
+          if git status -s | grep "^\s*[AM]"; then
+            exit 1
+          fi
           echo "API docs done"
       - run: hatch run docs:build
 
diff --git a/.github/workflows/prep-release.yml b/.github/workflows/prep-release.yml
index 7a2a18d..396330b 100644
--- a/.github/workflows/prep-release.yml
+++ b/.github/workflows/prep-release.yml
@@ -12,6 +12,10 @@ on:
       post_version_spec:
         description: "Post Version Specifier"
         required: false
+      silent:
+        description: "Set a placeholder in the changelog and don't publish the release."
+        required: false
+        type: boolean
       since:
         description: "Use PRs with activity since this date or git reference"
         required: false
@@ -22,6 +26,8 @@ on:
 jobs:
   prep_release:
     runs-on: ubuntu-latest
+    permissions:
+      contents: write
     steps:
       - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
 
@@ -29,8 +35,9 @@ jobs:
         id: prep-release
         uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2
         with:
-          token: ${{ secrets.ADMIN_GITHUB_TOKEN }}
+          token: ${{ secrets.GITHUB_TOKEN }}
           version_spec: ${{ github.event.inputs.version_spec }}
+          silent: ${{ github.event.inputs.silent }}
           post_version_spec: ${{ github.event.inputs.post_version_spec }}
           target: ${{ github.event.inputs.target }}
           branch: ${{ github.event.inputs.branch }}
diff --git a/.github/workflows/publish-changelog.yml b/.github/workflows/publish-changelog.yml
new file mode 100644
index 0000000..60af4c5
--- /dev/null
+++ b/.github/workflows/publish-changelog.yml
@@ -0,0 +1,34 @@
+name: "Publish Changelog"
+on:
+  release:
+    types: [published]
+
+  workflow_dispatch:
+    inputs:
+      branch:
+        description: "The branch to target"
+        required: false
+
+jobs:
+  publish_changelog:
+    runs-on: ubuntu-latest
+    environment: release
+    steps:
+      - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
+
+      - uses: actions/create-github-app-token@v1
+        id: app-token
+        with:
+          app-id: ${{ vars.APP_ID }}
+          private-key: ${{ secrets.APP_PRIVATE_KEY }}
+
+      - name: Publish changelog
+        id: publish-changelog
+        uses: jupyter-server/jupyter_releaser/.github/actions/publish-changelog@v2
+        with:
+          token: ${{ steps.app-token.outputs.token }}
+          branch: ${{ github.event.inputs.branch }}
+
+      - name: "** Next Step **"
+        run: |
+          echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}"
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index dbaaeaa..5295e77 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -15,30 +15,32 @@ on:
 jobs:
   publish_release:
     runs-on: ubuntu-latest
+    environment: release
+    permissions:
+      id-token: write
     steps:
       - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
 
+      - uses: actions/create-github-app-token@v1
+        id: app-token
+        with:
+          app-id: ${{ vars.APP_ID }}
+          private-key: ${{ secrets.APP_PRIVATE_KEY }}
+
       - name: Populate Release
         id: populate-release
         uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2
         with:
-          token: ${{ secrets.ADMIN_GITHUB_TOKEN }}
-          target: ${{ github.event.inputs.target }}
+          token: ${{ steps.app-token.outputs.token }}
           branch: ${{ github.event.inputs.branch }}
           release_url: ${{ github.event.inputs.release_url }}
           steps_to_skip: ${{ github.event.inputs.steps_to_skip }}
 
       - name: Finalize Release
         id: finalize-release
-        env:
-          PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
-          PYPI_TOKEN_MAP: ${{ secrets.PYPI_TOKEN_MAP }}
-          TWINE_USERNAME: __token__
-          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
-        uses: jupyter-server/jupyter-releaser/.github/actions/finalize-release@v2
+        uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2
         with:
-          token: ${{ secrets.ADMIN_GITHUB_TOKEN }}
-          target: ${{ github.event.inputs.target }}
+          token: ${{ steps.app-token.outputs.token }}
           release_url: ${{ steps.populate-release.outputs.release_url }}
 
       - name: "** Next Step **"
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9d3580e..3720f04 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -21,7 +21,7 @@ repos:
       - id: trailing-whitespace
 
   - repo: https://github.com/python-jsonschema/check-jsonschema
-    rev: 0.27.1
+    rev: 0.27.4
     hooks:
       - id: check-github-workflows
 
@@ -31,13 +31,13 @@ repos:
       - id: mdformat
 
   - repo: https://github.com/pre-commit/mirrors-prettier
-    rev: "v3.0.3"
+    rev: "v4.0.0-alpha.8"
     hooks:
       - id: prettier
         types_or: [yaml, html, json]
 
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: "v1.6.1"
+    rev: "v1.8.0"
     hooks:
       - id: mypy
         files: jupyter_client
@@ -66,7 +66,7 @@ repos:
       - id: rst-inline-touching-normal
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.1.5
+    rev: v0.2.0
     hooks:
       - id: ruff
         types_or: [python, jupyter]
@@ -75,7 +75,7 @@ repos:
         types_or: [python, jupyter]
 
   - repo: https://github.com/scientific-python/cookie
-    rev: "2023.10.27"
+    rev: "2024.01.24"
     hooks:
       - id: sp-repo-review
         additional_dependencies: ["repo-review[cli]"]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58920aa..12caeba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,79 @@
-# Changes in Jupyter Client {#changelog}
+# Changes in Jupyter Client
 
 <!-- <START NEW CHANGELOG ENTRY> -->
 
+## 8.6.3
+
+([Full Changelog](https://github.com/jupyter/jupyter_client/compare/v8.6.2...7d7251f24ed42c34b9b6ebdf09a5d89c46450131))
+
+### Enhancements made
+
+- Support psutil for finding network addresses [#1033](https://github.com/jupyter/jupyter_client/pull/1033) ([@juliangilbey](https://github.com/juliangilbey))
+
+### Bugs fixed
+
+- use simpler isoparse from dateutil to parse ISO-8601 timestamps [#1032](https://github.com/jupyter/jupyter_client/pull/1032) ([@minrk](https://github.com/minrk))
+- Avoid a DeprecationWarning on Python 3.13+ [#1027](https://github.com/jupyter/jupyter_client/pull/1027) ([@hroncok](https://github.com/hroncok))
+
+### Maintenance and upkeep improvements
+
+- Fix docs check [#1038](https://github.com/jupyter/jupyter_client/pull/1038) ([@minrk](https://github.com/minrk))
+- fix invocation of pip for nbconvert downstream [#1037](https://github.com/jupyter/jupyter_client/pull/1037) ([@minrk](https://github.com/minrk))
+- test on python 3.13-dev [#1036](https://github.com/jupyter/jupyter_client/pull/1036) ([@andife](https://github.com/andife))
+- Support ipykernel's use of anyio in test_signal_kernel_subprocesses [#1034](https://github.com/jupyter/jupyter_client/pull/1034) ([@ianthomas23](https://github.com/ianthomas23))
+
+### Contributors to this release
+
+([GitHub contributors page for this release](https://github.com/jupyter/jupyter_client/graphs/contributors?from=2024-05-23&to=2024-09-17&type=c))
+
+[@andife](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Aandife+updated%3A2024-05-23..2024-09-17&type=Issues) | [@hroncok](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Ahroncok+updated%3A2024-05-23..2024-09-17&type=Issues) | [@ianthomas23](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Aianthomas23+updated%3A2024-05-23..2024-09-17&type=Issues) | [@juliangilbey](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Ajuliangilbey+updated%3A2024-05-23..2024-09-17&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Aminrk+updated%3A2024-05-23..2024-09-17&type=Issues)
+
+<!-- <END NEW CHANGELOG ENTRY> -->
+
+## 8.6.2
+
+([Full Changelog](https://github.com/jupyter/jupyter_client/compare/v8.6.1...474093f4237744dee2af1c8929e2afe5354ef356))
+
+### Bugs fixed
+
+- Use non-blocking zmq Poller [#1023](https://github.com/jupyter/jupyter_client/pull/1023) ([@fcollonval](https://github.com/fcollonval))
+
+### Documentation improvements
+
+- use https url [#1021](https://github.com/jupyter/jupyter_client/pull/1021) ([@Carreau](https://github.com/Carreau))
+
+### Contributors to this release
+
+([GitHub contributors page for this release](https://github.com/jupyter/jupyter_client/graphs/contributors?from=2024-03-12&to=2024-05-23&type=c))
+
+[@Carreau](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3ACarreau+updated%3A2024-03-12..2024-05-23&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Afcollonval+updated%3A2024-03-12..2024-05-23&type=Issues)
+
+## 8.6.1
+
+([Full Changelog](https://github.com/jupyter/jupyter_client/compare/v8.6.0...8a3327ae58247be734d51f44c629354f0f600660))
+
+### Maintenance and upkeep improvements
+
+- Update Release Scripts [#1016](https://github.com/jupyter/jupyter_client/pull/1016) ([@blink1073](https://github.com/blink1073))
+- chore: update pre-commit hooks [#1008](https://github.com/jupyter/jupyter_client/pull/1008) ([@pre-commit-ci](https://github.com/pre-commit-ci))
+- chore: update pre-commit hooks [#1002](https://github.com/jupyter/jupyter_client/pull/1002) ([@pre-commit-ci](https://github.com/pre-commit-ci))
+- Bump actions/setup-python from 4 to 5 [#1000](https://github.com/jupyter/jupyter_client/pull/1000) ([@dependabot](https://github.com/dependabot))
+- chore: update pre-commit hooks [#999](https://github.com/jupyter/jupyter_client/pull/999) ([@pre-commit-ci](https://github.com/pre-commit-ci))
+- Bump conda-incubator/setup-miniconda from 2 to 3 [#998](https://github.com/jupyter/jupyter_client/pull/998) ([@dependabot](https://github.com/dependabot))
+- chore: update pre-commit hooks [#996](https://github.com/jupyter/jupyter_client/pull/996) ([@pre-commit-ci](https://github.com/pre-commit-ci))
+
+### Documentation improvements
+
+- Fix docs reference [#1017](https://github.com/jupyter/jupyter_client/pull/1017) ([@blink1073](https://github.com/blink1073))
+- Add docs about packaging kernels [#1013](https://github.com/jupyter/jupyter_client/pull/1013) ([@blink1073](https://github.com/blink1073))
+- Clarify what a restart means [#966](https://github.com/jupyter/jupyter_client/pull/966) ([@mlucool](https://github.com/mlucool))
+
+### Contributors to this release
+
+([GitHub contributors page for this release](https://github.com/jupyter/jupyter_client/graphs/contributors?from=2023-11-06&to=2024-03-12&type=c))
+
+[@blink1073](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Ablink1073+updated%3A2023-11-06..2024-03-12&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Adependabot+updated%3A2023-11-06..2024-03-12&type=Issues) | [@mlucool](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Amlucool+updated%3A2023-11-06..2024-03-12&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Apre-commit-ci+updated%3A2023-11-06..2024-03-12&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3AZsailer+updated%3A2023-11-06..2024-03-12&type=Issues)
+
 ## 8.6.0
 
 ([Full Changelog](https://github.com/jupyter/jupyter_client/compare/v8.5.0...2d6f52bdf5266688c51f3270cd7e47bbd17c708c))
@@ -23,8 +95,6 @@
 
 [@blink1073](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Ablink1073+updated%3A2023-10-25..2023-11-06&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3ACarreau+updated%3A2023-10-25..2023-11-06&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_client+involves%3Adavidbrochart+updated%3A2023-10-25..2023-11-06&type=Issues)
 
-<!-- <END NEW CHANGELOG ENTRY> -->
-
 ## 8.5.0
 
 ([Full Changelog](https://github.com/jupyter/jupyter_client/compare/v8.4.0...ff94e310c2af3546f2f9384e6b35fd11c3d09a71))
diff --git a/docs/conf.py b/docs/conf.py
index 198a792..7ebc807 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -326,7 +326,7 @@ texinfo_documents = [
 
 
 # Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {"ipython": ("http://ipython.readthedocs.io/en/stable/", None)}
+intersphinx_mapping = {"ipython": ("https://ipython.readthedocs.io/en/stable/", None)}
 
 
 def setup(app: object) -> None:
diff --git a/docs/kernels.rst b/docs/kernels.rst
index 60b1edd..5f88462 100644
--- a/docs/kernels.rst
+++ b/docs/kernels.rst
@@ -184,3 +184,21 @@ To start the terminal console or the Qt console with a specific kernel::
 
 The notebook offers you the available kernels in a dropdown menu from the 'New'
 button.
+
+
+.. _packaging-kernels:
+
+Packaging
+=========
+
+To release your kernel as a Python package, we recommend following the pattern
+used in the `echo_kernel`_, which uses the `hatch`_  build backend and
+a build file that creates the kernel directory with the ``kernel.json`` and
+kernel icons, which is included as ``shared-data``, ending up in the
+``share/jupyter/kernels/`` folder in the user's installed environment.
+See `pyproject.toml`_ and `hatch_build.py`_ for more details.
+
+.. _hatch: https://hatch.pypa.io/latest/
+.. _pyproject.toml: https://github.com/jupyter/echo_kernel/blob/main/pyproject.toml
+.. _hatch_build.py: https://github.com/jupyter/echo_kernel/blob/main/hatch_build.py
+.. _echo_kernel: https://github.com/jupyter/echo_kernel
diff --git a/docs/messaging.rst b/docs/messaging.rst
index b1a275d..12ae051 100644
--- a/docs/messaging.rst
+++ b/docs/messaging.rst
@@ -1049,6 +1049,15 @@ multiple cases:
   IPythonQt client) to force a kernel restart to get a clean kernel without
   losing client-side state like history or inlined figures.
 
+Implementation recommendation for starting kernels: A restart should optimally
+preserve as many resources outside the kernel as possible (e.g. only restart the
+kernel and its subprocesses and not any parent processes). That is, ideally a
+restart should be "in-place". For local kernels, there is typically no parent
+process so a "hard" restart and an in-place restart are identical whereas for
+remote kernels this is not generally the same. As an example, if a remote kernel
+is run in a container, during an in-place restart the container may be kept
+running and a new kernel process within it would be started.
+
 The client sends a shutdown request to the kernel, and once it receives the
 reply message (which is otherwise empty), it can assume that the kernel has
 completed shutdown safely.  The request is sent on the ``control`` channel.
diff --git a/docs/wrapperkernels.rst b/docs/wrapperkernels.rst
index 1dd9fd4..4693109 100644
--- a/docs/wrapperkernels.rst
+++ b/docs/wrapperkernels.rst
@@ -19,6 +19,7 @@ also has a ``ProcessKernel`` subclass that makes it easy to write kernels that
 use ``pexpect``.
 See `Octave Kernel <https://github.com/Calysto/octave_kernel>`_ as an example.
 
+If releasing a wrapper kernel as a Python package, see the steps in :ref:`packaging-kernels`.
 
 Required steps
 --------------
@@ -86,7 +87,7 @@ Example
 
 .. seealso::
 
-    `echo_kernel <https://github.com/jupyter/echo_kernel>`__
+    `echo_kernel <https://github.com/jupyter/echo_kernel>`_
       A packaged, installable version of the condensed example below.
 
 ``echokernel.py`` will simply echo any input it's given to stdout::
diff --git a/jupyter_client/_version.py b/jupyter_client/_version.py
index a329a5c..8c309c1 100644
--- a/jupyter_client/_version.py
+++ b/jupyter_client/_version.py
@@ -2,7 +2,7 @@
 import re
 from typing import List, Union
 
-__version__ = "8.6.0"
+__version__ = "8.6.3"
 
 # Build up version_info tuple for backwards compatibility
 pattern = r"(?P<major>\d+).(?P<minor>\d+).(?P<patch>\d+)(?P<rest>.*)"
diff --git a/jupyter_client/asynchronous/client.py b/jupyter_client/asynchronous/client.py
index 1187341..cde8eca 100644
--- a/jupyter_client/asynchronous/client.py
+++ b/jupyter_client/asynchronous/client.py
@@ -33,7 +33,7 @@ class AsyncKernelClient(KernelClient):
     raising :exc:`queue.Empty` if no message arrives within ``timeout`` seconds.
     """
 
-    context = Instance(zmq.asyncio.Context)
+    context = Instance(zmq.asyncio.Context)  # type:ignore[arg-type]
 
     def _context_default(self) -> zmq.asyncio.Context:
         self._created_context = True
diff --git a/jupyter_client/channels.py b/jupyter_client/channels.py
index c645b13..465dccd 100644
--- a/jupyter_client/channels.py
+++ b/jupyter_client/channels.py
@@ -223,9 +223,8 @@ class ZMQSocketChannel:
     def get_msg(self, timeout: t.Optional[float] = None) -> t.Dict[str, t.Any]:
         """Gets a message if there is one that is ready."""
         assert self.socket is not None
-        if timeout is not None:
-            timeout *= 1000  # seconds to ms
-        ready = self.socket.poll(timeout)
+        timeout_ms = None if timeout is None else int(timeout * 1000)  # seconds to ms
+        ready = self.socket.poll(timeout_ms)
         if ready:
             res = self._recv()
             return res
@@ -305,9 +304,8 @@ class AsyncZMQSocketChannel(ZMQSocketChannel):
     ) -> t.Dict[str, t.Any]:
         """Gets a message if there is one that is ready."""
         assert self.socket is not None
-        if timeout is not None:
-            timeout *= 1000  # seconds to ms
-        ready = await self.socket.poll(timeout)
+        timeout_ms = None if timeout is None else int(timeout * 1000)  # seconds to ms
+        ready = await self.socket.poll(timeout_ms)
         if ready:
             res = await self._recv()
             return res
diff --git a/jupyter_client/client.py b/jupyter_client/client.py
index aa353ac..851a234 100644
--- a/jupyter_client/client.py
+++ b/jupyter_client/client.py
@@ -530,7 +530,7 @@ class KernelClient(ConnectionFileMixin):
         else:
             timeout_ms = None
 
-        poller = zmq.Poller()
+        poller = zmq.asyncio.Poller()
         iopub_socket = self.iopub_channel.socket
         poller.register(iopub_socket, zmq.POLLIN)
         if allow_stdin:
@@ -544,7 +544,7 @@ class KernelClient(ConnectionFileMixin):
             if timeout is not None:
                 timeout = max(0, deadline - time.monotonic())
                 timeout_ms = int(1000 * timeout)
-            events = dict(poller.poll(timeout_ms))
+            events = dict(await poller.poll(timeout_ms))
             if not events:
                 emsg = "Timeout waiting for output"
                 raise TimeoutError(emsg)
diff --git a/jupyter_client/consoleapp.py b/jupyter_client/consoleapp.py
index 4e9ad35..434fdf0 100644
--- a/jupyter_client/consoleapp.py
+++ b/jupyter_client/consoleapp.py
@@ -306,8 +306,11 @@ class JupyterConsoleApp(ConnectionFileMixin):
                 parent=self,
                 data_dir=self.data_dir,
             )
+            # access kernel_spec to ensure the NoSuchKernel error is raised
+            # if it's going to be
+            kernel_spec = self.kernel_manager.kernel_spec  # noqa: F841
         except NoSuchKernel:
-            self.log.critical("Could not find kernel %s", self.kernel_name)
+            self.log.critical("Could not find kernel %r", self.kernel_name)
             self.exit(1)  # type:ignore[attr-defined]
 
         self.kernel_manager = t.cast(KernelManager, self.kernel_manager)
diff --git a/jupyter_client/jsonutil.py b/jupyter_client/jsonutil.py
index 2ba640f..6e6bac8 100644
--- a/jupyter_client/jsonutil.py
+++ b/jupyter_client/jsonutil.py
@@ -11,7 +11,7 @@ from collections.abc import Iterable
 from datetime import datetime
 from typing import Any, Optional, Union
 
-from dateutil.parser import parse as _dateutil_parse
+from dateutil.parser import isoparse as _dateutil_parse
 from dateutil.tz import tzlocal
 
 next_attr_name = "__next__"  # Not sure what downstream library uses this, but left it to be safe
@@ -28,7 +28,7 @@ ISO8601_PAT = re.compile(
 
 # holy crap, strptime is not threadsafe.
 # Calling it once at import seems to help.
-datetime.strptime("1", "%d")  # noqa
+datetime.strptime("2000-01-01", "%Y-%m-%d")  # noqa
 
 # -----------------------------------------------------------------------------
 # Classes and functions
diff --git a/jupyter_client/kernelspec.py b/jupyter_client/kernelspec.py
index 41ed2ba..b903ee6 100644
--- a/jupyter_client/kernelspec.py
+++ b/jupyter_client/kernelspec.py
@@ -281,7 +281,6 @@ class KernelSpecManager(LoggingConfigurable):
 
         resource_dir = self._find_spec_directory(kernel_name.lower())
         if resource_dir is None:
-            self.log.warning("Kernelspec name %s cannot be found!", kernel_name)
             raise NoSuchKernel(kernel_name)
 
         return self._get_kernel_spec_by_name(kernel_name, resource_dir)
diff --git a/jupyter_client/localinterfaces.py b/jupyter_client/localinterfaces.py
index ca684a6..e257b26 100644
--- a/jupyter_client/localinterfaces.py
+++ b/jupyter_client/localinterfaces.py
@@ -8,11 +8,11 @@ import re
 import socket
 import subprocess
 from subprocess import PIPE, Popen
-from typing import Any, Callable, Iterable, Sequence
+from typing import Any, Callable, Iterable, Mapping, Sequence
 from warnings import warn
 
-LOCAL_IPS: list = []
-PUBLIC_IPS: list = []
+LOCAL_IPS: list[str] = []
+PUBLIC_IPS: list[str] = []
 
 LOCALHOST: str = ""
 
@@ -75,27 +75,34 @@ class NoIPAddresses(Exception):  # noqa
     pass
 
 
-def _populate_from_list(addrs: Sequence[str] | None) -> None:
+def _populate_from_list(addrs: Sequence[str]) -> None:
     """populate local and public IPs from flat list of all IPs"""
+    _populate_from_dict({"all": addrs})
+
+
+def _populate_from_dict(addrs: Mapping[str, Sequence[str]]) -> None:
+    """populate local and public IPs from dict of {'en0': 'ip'}"""
     if not addrs:
-        raise NoIPAddresses
+        raise NoIPAddresses()
 
     global LOCALHOST
     public_ips = []
     local_ips = []
 
-    for ip in addrs:
-        local_ips.append(ip)
-        if not ip.startswith("127."):
-            public_ips.append(ip)
-        elif not LOCALHOST:
-            LOCALHOST = ip
+    for iface, ip_list in addrs.items():
+        for ip in ip_list:
+            local_ips.append(ip)
+            if not LOCALHOST and (iface.startswith("lo") or ip.startswith("127.")):
+                LOCALHOST = ip
+            if not iface.startswith("lo") and not ip.startswith(("127.", "169.254.")):
+                # don't include link-local address in public_ips
+                public_ips.append(ip)
 
     if not LOCALHOST or LOCALHOST == "127.0.0.1":
         LOCALHOST = "127.0.0.1"
         local_ips.insert(0, LOCALHOST)
 
-    local_ips.extend(["0.0.0.0", ""])  # noqa
+    local_ips.extend(["0.0.0.0", ""])  # noqa: S104
 
     LOCAL_IPS[:] = _uniq_stable(local_ips)
     PUBLIC_IPS[:] = _uniq_stable(public_ips)
@@ -151,34 +158,42 @@ def _load_ips_ipconfig() -> None:
     _populate_from_list(addrs)
 
 
+def _load_ips_psutil() -> None:
+    """load ip addresses with netifaces"""
+    import psutil
+
+    addr_dict: dict[str, list[str]] = {}
+
+    # dict of iface_name: address_list, eg
+    # {"lo": [snicaddr(family=<AddressFamily.AF_INET>, address="127.0.0.1",
+    #   ...), snicaddr(family=<AddressFamily.AF_INET6>, ...)]}
+    for iface, ifaddresses in psutil.net_if_addrs().items():
+        addr_dict[iface] = [
+            address_data.address
+            for address_data in ifaddresses
+            if address_data.family == socket.AF_INET
+        ]
+
+    _populate_from_dict(addr_dict)
+
+
 def _load_ips_netifaces() -> None:
     """load ip addresses with netifaces"""
     import netifaces  # type: ignore[import-not-found]
 
-    global LOCALHOST
-    local_ips = []
-    public_ips = []
+    addr_dict: dict[str, list[str]] = {}
 
     # list of iface names, 'lo0', 'eth0', etc.
     for iface in netifaces.interfaces():
         # list of ipv4 addrinfo dicts
+        addr_dict[iface] = []
+
         ipv4s = netifaces.ifaddresses(iface).get(netifaces.AF_INET, [])
         for entry in ipv4s:
             addr = entry.get("addr")
-            if not addr:
-                continue
-            if not (iface.startswith("lo") or addr.startswith("127.")):
-                public_ips.append(addr)
-            elif not LOCALHOST:
-                LOCALHOST = addr
-            local_ips.append(addr)
-    if not LOCALHOST:
-        # we never found a loopback interface (can this ever happen?), assume common default
-        LOCALHOST = "127.0.0.1"
-        local_ips.insert(0, LOCALHOST)
-    local_ips.extend(["0.0.0.0", ""])  # noqa
-    LOCAL_IPS[:] = _uniq_stable(local_ips)
-    PUBLIC_IPS[:] = _uniq_stable(public_ips)
+            if addr:
+                addr_dict[iface].append(addr)
+    _populate_from_dict(addr_dict)
 
 
 def _load_ips_gethostbyname() -> None:
@@ -227,13 +242,20 @@ def _load_ips(suppress_exceptions: bool = True) -> None:
 
     This function will only ever be called once.
 
-    It will use netifaces to do it quickly if available.
+    If will use psutil to do it quickly if available.
+    If not, it will use netifaces to do it quickly if available.
     Then it will fallback on parsing the output of ifconfig / ip addr / ipconfig, as appropriate.
     Finally, it will fallback on socket.gethostbyname_ex, which can be slow.
     """
 
     try:
-        # first priority, use netifaces
+        # first priority, use psutil
+        try:
+            return _load_ips_psutil()
+        except ImportError:
+            pass
+
+        # second priority, use netifaces
         try:
             return _load_ips_netifaces()
         except ImportError:
diff --git a/jupyter_client/runapp.py b/jupyter_client/runapp.py
index 9ed4b15..bea5d5c 100644
--- a/jupyter_client/runapp.py
+++ b/jupyter_client/runapp.py
@@ -3,10 +3,9 @@
 # Distributed under the terms of the Modified BSD License.
 from __future__ import annotations
 
-import queue
+import atexit
 import signal
 import sys
-import time
 import typing as t
 
 from jupyter_core.application import JupyterApp, base_aliases, base_flags
@@ -73,7 +72,8 @@ class RunApp(JupyterApp, JupyterConsoleApp):  # type:ignore[misc]
         super().initialize(argv)
         JupyterConsoleApp.initialize(self)
         signal.signal(signal.SIGINT, self.handle_sigint)
-        self.init_kernel_info()
+        if self.kernel_manager:
+            atexit.register(self.kernel_manager.shutdown_kernel)
 
     def handle_sigint(self, *args: t.Any) -> None:
         """Handle SIGINT."""
@@ -82,28 +82,11 @@ class RunApp(JupyterApp, JupyterConsoleApp):  # type:ignore[misc]
         else:
             self.log.error("Cannot interrupt kernels we didn't start.\n")
 
-    def init_kernel_info(self) -> None:
-        """Wait for a kernel to be ready, and store kernel info"""
-        timeout = self.kernel_timeout
-        tic = time.time()
-        self.kernel_client.hb_channel.unpause()
-        msg_id = self.kernel_client.kernel_info()
-        while True:
-            try:
-                reply = self.kernel_client.get_shell_msg(timeout=1)
-            except queue.Empty as e:
-                if (time.time() - tic) > timeout:
-                    msg = "Kernel didn't respond to kernel_info_request"
-                    raise RuntimeError(msg) from e
-            else:
-                if reply["parent_header"].get("msg_id") == msg_id:
-                    self.kernel_info = reply["content"]
-                    return
-
     def start(self) -> None:
         """Start the application."""
         self.log.debug("jupyter run: starting...")
         super().start()
+        self.kernel_client.wait_for_ready(timeout=self.kernel_timeout)
         if self.filenames_to_run:
             for filename in self.filenames_to_run:
                 self.log.debug("jupyter run: executing `%s`", filename)
@@ -112,8 +95,10 @@ class RunApp(JupyterApp, JupyterConsoleApp):  # type:ignore[misc]
                     reply = self.kernel_client.execute_interactive(code, timeout=OUTPUT_TIMEOUT)
                     return_code = 0 if reply["content"]["status"] == "ok" else 1
                     if return_code:
-                        raise Exception("jupyter-run error running '%s'" % filename)
+                        msg = f"jupyter-run error running '{filename}'"
+                        raise Exception(msg)
         else:
+            self.log.debug("jupyter run: executing from stdin")
             code = sys.stdin.read()
             reply = self.kernel_client.execute_interactive(code, timeout=OUTPUT_TIMEOUT)
             return_code = 0 if reply["content"]["status"] == "ok" else 1
diff --git a/pyproject.toml b/pyproject.toml
index 38ea8b7..3701c4f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,7 +51,7 @@ test = [
     "mypy",
     "paramiko; sys_platform == 'win32'",
     "pre-commit",
-    "pytest",
+    "pytest<8.2.0",
     "pytest-jupyter[client]>=0.4.1",
     "pytest-cov",
     "pytest-timeout",
@@ -133,6 +133,8 @@ filterwarnings= [
   # from python-dateutil
   "ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning",
   "ignore:datetime.datetime.utcnow:DeprecationWarning",
+  # from ipykernel 6 (fixed in 7)
+  "ignore:Parsing dates:DeprecationWarning:ipykernel.jsonutil",
 ]
 
 [tool.coverage.report]
@@ -165,7 +167,6 @@ enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
 no_implicit_reexport = false
 pretty = true
 show_error_context = true
-show_error_codes = true
 warn_return_any = false
 warn_unreachable = true
 
@@ -280,4 +281,4 @@ fail-under=90
 exclude = ["docs", "test"]
 
 [tool.repo-review]
-ignore = ["PY007", "PP308", "GH102"]
+ignore = ["GH102"]


Test patch:
diff --git a/tests/signalkernel.py b/tests/signalkernel.py
index 65fdb68..74e9fa4 100644
--- a/tests/signalkernel.py
+++ b/tests/signalkernel.py
@@ -30,7 +30,7 @@ class SignalTestKernel(Kernel):
         if os.environ.get("NO_SHUTDOWN_REPLY") != "1":
             await super().shutdown_request(stream, ident, parent)
 
-    def do_execute(
+    async def do_execute(
         self, code, silent, store_history=True, user_expressions=None, allow_stdin=False
     ):
         code = code.strip()
@@ -47,12 +47,31 @@ class SignalTestKernel(Kernel):
         elif code == "env":
             reply["user_expressions"]["env"] = os.getenv("TEST_VARS", "")
         elif code == "sleep":
-            try:
-                time.sleep(10)
-            except KeyboardInterrupt:
-                reply["user_expressions"]["interrupted"] = True
+            import ipykernel
+
+            if ipykernel.version_info < (7, 0):
+                # ipykernel before anyio.
+                try:
+                    time.sleep(10)
+                except KeyboardInterrupt:
+                    reply["user_expressions"]["interrupted"] = True
+                else:
+                    reply["user_expressions"]["interrupted"] = False
             else:
+                # ipykernel after anyio.
+                from anyio import create_task_group, open_signal_receiver, sleep
+
+                async def signal_handler(cancel_scope, reply):
+                    with open_signal_receiver(signal.SIGINT) as signals:
+                        async for _ in signals:
+                            reply["user_expressions"]["interrupted"] = True
+                            cancel_scope.cancel()
+                            return
+
                 reply["user_expressions"]["interrupted"] = False
+                async with create_task_group() as tg:
+                    tg.start_soon(signal_handler, tg.cancel_scope, reply)
+                    tg.start_soon(sleep, 10)
         else:
             reply["status"] = "error"
             reply["ename"] = "Error"
diff --git a/tests/test_kernelspecapp.py b/tests/test_kernelspecapp.py
index a8cb971..119b6fb 100644
--- a/tests/test_kernelspecapp.py
+++ b/tests/test_kernelspecapp.py
@@ -18,7 +18,7 @@ def test_kernelspec_sub_apps(jp_kernel_dir):
     prefix = os.path.dirname(os.environ["JUPYTER_DATA_DIR"])
     kernel_dir = os.path.join(prefix, "share/jupyter/kernels")
     app.kernel_spec_manager.kernel_dirs.append(kernel_dir)
-    app.prefix = prefix = prefix
+    app.prefix = prefix
     app.initialize([str(jp_kernel_dir)])
     with warnings.catch_warnings():
         warnings.simplefilter("ignore")
diff --git a/tests/test_runapp.py b/tests/test_runapp.py
new file mode 100644
index 0000000..7f295ba
--- /dev/null
+++ b/tests/test_runapp.py
@@ -0,0 +1,34 @@
+import sys
+from subprocess import run
+
+
+def test_runapp(tmp_path):
+    test_py = tmp_path / "test.py"
+    with test_py.open("w") as f:
+        f.write("print('hello')")
+
+    p = run(
+        [sys.executable, "-m", "jupyter_client.runapp", str(test_py)],
+        capture_output=True,
+        text=True,
+        check=True,
+    )
+    assert p.stdout.strip() == "hello"
+
+
+def test_no_such_kernel(tmp_path):
+    test_py = tmp_path / "test.py"
+    with test_py.open("w") as f:
+        f.write("print('hello')")
+    kernel_name = "nosuchkernel"
+    p = run(
+        [sys.executable, "-m", "jupyter_client.runapp", "--kernel", kernel_name, str(test_py)],
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    assert p.returncode
+    assert "Could not find kernel" in p.stderr
+    assert kernel_name in p.stderr
+    # shouldn't show a traceback
+    assert "Traceback" not in p.stderr