From ece7f6c33afcabe5f014b0a39c8e29e344f6e0ac Mon Sep 17 00:00:00 2001 From: geoffreyweal Date: Mon, 22 Jun 2026 09:55:29 +1200 Subject: [PATCH] Update bash_kernel to use any bash given to it using the BASH_KERNEL_CMD command. This allows containers that contain bash to be used --- .gitignore | 3 +- CHANGELOG.md | 10 +++++++ README.rst | 39 +++++++++++++++++++++++++ bash_kernel/kernel.py | 68 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index ae00842..a75fcde 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ __pycache__ build/ dist/ MANIFEST -.ipynb_checkpoints \ No newline at end of file +.ipynb_checkpoints +.DS_Store \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5115b..e21704e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# Unreleased + +- Add `BASH_KERNEL_CMD` environment variable to override the command used to + launch bash. Its value allows a wrapper command with arguments, e.g. + `export BASH_KERNEL_CMD="apptainer exec --nv container.sif bash"`. When a + wrapper is used, the `--rcfile` bash startup file is copied into the shared + temp directory (`$TMPDIR`/`/tmp`) so it is readable from inside the wrapper + (e.g. a container), and the kernel banner no longer fails if the wrapper's + `--version` output is unavailable or unexpected. + # Version 0.10.0 (2024-01-05) - Support for Python 3.13, by replacing the removed imghdr standard library module diff --git a/README.rst b/README.rst index 52ce11c..de1d398 100644 --- a/README.rst +++ b/README.rst @@ -57,6 +57,45 @@ Requirements of Bash Bash kernel directly interacts with bash, and therefore requires a functioning interactive build of bash. In nearly all cases this will be the default, however some distributions remove GNU readline or other interactivity features of bash. Almost always, these features are provided in a separate, more complete bash package, which should be installed. See for example https://github.com/takluyver/bash_kernel/issues/142. +Using a custom bash command +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default the kernel launches the ``bash`` found on your ``PATH``. You can +override this by setting the ``BASH_KERNEL_CMD`` environment variable before +starting Jupyter. You can put in whatever you like here, as long as it starts an +interactive ``bash``. + +This is useful for running the kernel inside a container, or via any other +wrapper that eventually launches ``bash``. For example, to run the kernel's +bash inside an `Apptainer `_ (formerly Singularity) +container: + +.. code:: shell + + export BASH_KERNEL_CMD="apptainer exec --nv container.sif bash" + jupyter notebook + +To make the override available as its own entry in the Jupyter kernel menu +(instead of exporting the variable globally), install a dedicated kernelspec +whose ``kernel.json`` sets the variable in its ``env`` block. This also lets a +plain-bash kernel and a wrapped kernel coexist: + +.. code:: json + + { + "argv": ["python", "-m", "bash_kernel", "-f", "{connection_file}"], + "display_name": "Bash (container)", + "language": "bash", + "env": { "BASH_KERNEL_CMD": "apptainer exec --nv container.sif bash" } + } + +**Note:** the bash startup file must be readable from inside the wrapper. The +kernel places it in ``$TMPDIR`` (or ``/tmp``), so this works automatically as +long as that directory is shared with the wrapper at the same path -- Apptainer +does this for ``/tmp`` by default. Other runtimes may need an explicit mount +(e.g. Docker's ``-v /tmp:/tmp``), and pointing ``$TMPDIR`` at a directory the +wrapper can't see will prevent startup. + Displaying Rich Content ----------------------- diff --git a/bash_kernel/kernel.py b/bash_kernel/kernel.py index 3b9cf44..0d13746 100644 --- a/bash_kernel/kernel.py +++ b/bash_kernel/kernel.py @@ -3,7 +3,12 @@ import pexpect from subprocess import check_output +import atexit +import os import os.path +import shlex +import shutil +import tempfile import uuid import random import string @@ -17,6 +22,55 @@ from .display import (extract_contents, build_cmds) +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def bash_command(): + """The command (and any wrapper arguments) used to launch bash. + + Defaults to ``bash``, but can be overridden with the ``BASH_KERNEL_CMD`` + environment variable to launch bash via a wrapper, e.g. inside a + container: ``apptainer exec --nv container.sif bash``. The value is parsed + with shell-like quoting, so paths/arguments containing spaces can be + quoted. Returns a list suitable for ``subprocess``/``pexpect``. + """ + return shlex.split(os.environ.get('BASH_KERNEL_CMD') or 'bash') or ['bash'] + +_bashrc_copy = None + +def _remove_bashrc_copy(): + global _bashrc_copy + if _bashrc_copy and os.path.exists(_bashrc_copy): + try: + os.remove(_bashrc_copy) + except OSError: + pass + _bashrc_copy = None + +def bashrc_path(): + """Path to the bash startup file passed to bash via ``--rcfile``. + + Normally this is pexpect's bundled ``bashrc.sh``. But when + ``BASH_KERNEL_CMD`` launches bash via a wrapper (e.g. a container), that + host path may not be visible inside the wrapper, so we copy the rcfile into + the shared temp dir (``$TMPDIR`` or ``/tmp``) where it can be read. + """ + src = os.path.join(os.path.dirname(pexpect.__file__), 'bashrc.sh') + if 'BASH_KERNEL_CMD' not in os.environ: + return src + + global _bashrc_copy + if _bashrc_copy is None: + tmp_dir = os.environ.get('TMPDIR', '/tmp') + fd, path = tempfile.mkstemp(prefix='bash_kernel_', suffix='.sh', + dir=tmp_dir) + os.close(fd) + shutil.copyfile(src, path) + _bashrc_copy = path + atexit.register(_remove_bashrc_copy) + return _bashrc_copy + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + class IREPLWrapper(replwrap.REPLWrapper): """A subclass of REPLWrapper that gives incremental output specifically for bash_kernel. @@ -81,14 +135,19 @@ class BashKernel(Kernel): @property def language_version(self): m = version_pat.search(self.banner) - return m.group(1) + return m.group(1) if m else '' _banner = None @property def banner(self): if self._banner is None: - self._banner = check_output(['bash', '--version']).decode('utf-8') + try: + self._banner = check_output(bash_command() + ['--version']).decode('utf-8') + except Exception as exc: + # Don't let a missing/failing wrapper command (e.g. a container + # image that isn't available) break the kernel_info reply. + self._banner = 'bash_kernel: unable to determine bash version ({})'.format(exc) return self._banner language_info = {'name': 'bash', @@ -119,8 +178,9 @@ def _start_bash(self): # bash() function of pexpect/replwrap.py. Look at the # source code there for comments and context for # understanding the code here. - bashrc = os.path.join(os.path.dirname(pexpect.__file__), 'bashrc.sh') - child = pexpect.spawn("bash", ['--rcfile', bashrc], echo=False, + bashrc = bashrc_path() + bash_args = bash_command() + ['--rcfile', bashrc] + child = pexpect.spawn(bash_args[0], bash_args[1:], echo=False, encoding='utf-8', codec_errors='replace') # Following comment stolen from upstream's REPLWrap: # If the user runs 'env', the value of PS1 will be in the output. To avoid