Repository: jupyter/jupyter_client
Pull request #221 (2016-10-31 10:14:48)

validate kernelspec names
allow only: ascii letters, numbers, period, hyphen, underscore.

cf https://github.com/jupyterlab/jupyterlab/issues/1018

takluyver: +1

If we'd specified this sooner, I would have disallowed `.` too, as it's confusing in file names. But I guess Julia's already using it.
blink1073: cc @damianavila.  It looks like the names used by the `nb_conda_kernels` exactly match the name of the environment.  All of the ones on my machine would pass the above criteria.
Carreau: > installing new specs (I'd like to raise here)
> listing existing specs (we can reduce this to a warning, to avoid disabling already-installed specs)

Seem like a good compromise. 

Patch:
diff --git a/docs/kernels.rst b/docs/kernels.rst
index 471b858..42101c3 100644
--- a/docs/kernels.rst
+++ b/docs/kernels.rst
@@ -108,6 +108,8 @@ locations:
 The user location takes priority over the system locations, and the case of the
 names is ignored, so selecting kernels works the same way whether or not the
 filesystem is case sensitive.
+Since kernelspecs show up in URLs and other places,
+a kernelspec is required to have a simple name, only containing ASCII letters, ASCII numbers, and the simple separators: ``-`` hyphen, ``.`` period, ``_`` underscore.
 
 Other locations may also be searched if the :envvar:`JUPYTER_PATH` environment
 variable is set.
diff --git a/jupyter_client/kernelspec.py b/jupyter_client/kernelspec.py
index 6ee0715..80e5044 100644
--- a/jupyter_client/kernelspec.py
+++ b/jupyter_client/kernelspec.py
@@ -6,6 +6,7 @@
 import io
 import json
 import os
+import re
 import shutil
 import warnings
 
@@ -20,6 +21,7 @@ from jupyter_core.paths import jupyter_data_dir, jupyter_path, SYSTEM_JUPYTER_PA
 
 NATIVE_KERNEL_NAME = 'python3' if PY3 else 'python2'
 
+
 class KernelSpec(HasTraits):
     argv = List()
     display_name = Unicode()
@@ -54,10 +56,24 @@ class KernelSpec(HasTraits):
         """
         return json.dumps(self.to_dict())
 
+
+_kernel_name_pat = re.compile(r'^[a-z0-9._\-]+$', re.IGNORECASE)
+
+def _is_valid_kernel_name(name):
+    """Check that a kernel name is valid."""
+    # quote is not unicode-safe on Python 2
+    return _kernel_name_pat.match(name)
+
+
+_kernel_name_description = "Kernel names can only contain ASCII letters and numbers and these separators:" \
+ " - . _ (hyphen, period, and underscore)."
+
+
 def _is_kernel_dir(path):
     """Is ``path`` a kernel directory?"""
     return os.path.isdir(path) and os.path.isfile(pjoin(path, 'kernel.json'))
 
+
 def _list_kernels_in(dir):
     """Return a mapping of kernel names to resource directories from dir.
 
@@ -65,8 +81,19 @@ def _list_kernels_in(dir):
     """
     if dir is None or not os.path.isdir(dir):
         return {}
-    return {f.lower(): pjoin(dir, f) for f in os.listdir(dir)
-                        if _is_kernel_dir(pjoin(dir, f))}
+    kernels = {}
+    for f in os.listdir(dir):
+        path = pjoin(dir, f)
+        if not _is_kernel_dir(path):
+            continue
+        key = f.lower()
+        if not _is_valid_kernel_name(key):
+            warnings.warn("Invalid kernelspec directory name (%s): %s",
+                _kernel_name_description, path, stacklevel=3,
+            )
+        kernels[key] = path
+    return kernels
+
 
 class NoSuchKernel(KeyError):
     def __init__(self, name):
@@ -75,6 +102,7 @@ class NoSuchKernel(KeyError):
     def __str__(self):
         return "No such kernel named {}".format(self.name)
 
+
 class KernelSpecManager(LoggingConfigurable):
 
     kernel_spec_class = Type(KernelSpec, config=True,
@@ -242,10 +270,12 @@ class KernelSpecManager(LoggingConfigurable):
         if not kernel_name:
             kernel_name = os.path.basename(source_dir)
         kernel_name = kernel_name.lower()
-        
+        if not _is_valid_kernel_name(kernel_name):
+            raise ValueError("Invalid kernel name %r.  %s" % (kernel_name, _kernel_name_description))
+
         if user and prefix:
             raise ValueError("Can't specify both user and prefix. Please choose one or the other.")
-        
+
         if replace is not None:
             warnings.warn(
                 "replace is ignored. Installing a kernelspec always replaces an existing installation",


Test patch:
diff --git a/jupyter_client/tests/test_kernelspec.py b/jupyter_client/tests/test_kernelspec.py
index 2da064d..5d6d4d1 100644
--- a/jupyter_client/tests/test_kernelspec.py
+++ b/jupyter_client/tests/test_kernelspec.py
@@ -1,3 +1,4 @@
+# coding: utf-8
 """Tests for the KernelSpecManager"""
 
 # Copyright (c) Jupyter Development Team.
@@ -141,3 +142,23 @@ class KernelSpecTests(unittest.TestCase):
         )
         out, _ = p.communicate()
         self.assertEqual(p.returncode, 0, out.decode('utf8', 'replace'))
+
+    def test_validate_kernel_name(self):
+        for good in [
+            'julia-0.4',
+            'ipython',
+            'R',
+            'python_3',
+            'Haskell-1-2-3',
+        ]:
+            assert kernelspec._is_valid_kernel_name(good)
+        
+        for bad in [
+            'has space',
+            u'ünicode',
+            '%percent',
+            'question?',
+        ]:
+            assert not kernelspec._is_valid_kernel_name(bad)
+
+