Repository: jupyter/jupyter_client
Pull request #692 (2021-09-16 18:08:10)

Address missing local-provisioner scenario
There have been a few instances of users reporting that they can't run kernels due to the `local-provisioner` not being available.  However, this provisioner is registered in the current jupyter_client package, yet when the package searches for the default provisioner (i.e., `local-provisioner`) it is not found.

- https://discourse.jupyter.org/t/kernel-python-3-is-referencing-a-kernel-provisioner-local-provisioner-that-is-not-available-ensure-the-appropriate-package-has-been-installed-and-retry/10436
- https://github.com/Anaconda-Platform/nb_conda_kernels/issues/209

It turns out that in certain circumstances, which remain a mystery to me, there can be multiple _dist-info_ directories present related to `jupyter_client` - one representing the previous release (6.x - which does not include kernel provisioning and `local-provisioner`) and the current release, which is obviously running since its looking for the `local-provisioner`.  Since [entrypoints](https://github.com/takluyver/entrypoints) uses the first distribution it finds, it locates the 6.x _dist-info_ directory and deems `local-provisioner` as not available.

This pull request will detect this condition (i.e., `local-provisioner` is not available), issue a warning message alerting users to the situation instructing them what to do, but then constructs a local-provisioner entrypoint instance so that default behavior is experienced and kernels can be started.

Here's an example of the logged warning:
`[W 10:08:17.848 NotebookApp] Kernel Provisioning: The 'local-provisioner' is not found.  This is likely due to the presence of multiple jupyter_client distributions and a previous distribution is being used as the source for entrypoints - which does not include 'local-provisioner'.  That distribution should be removed such that only the version-appropriate distribution remains (version >= 7).  Until then, a 'local-provisioner' entrypoint will be automatically constructed and used.  `
`The candidate distribution locations are: ['/opt/anaconda3/envs/elyra-dev/lib/python3.8/site-packages/jupyter_client-6.1.12.dist-info', '/opt/anaconda3/envs/elyra-dev/lib/python3.8/site-packages/jupyter_client-7.0.2.dist-info']`

I think we should treat this as a HOTFIX since it appears to be occurring at a high enough frequency.

If anyone has ideas for what causes a previous distribution to remain, that would be helpful.


Patch:
diff --git a/jupyter_client/provisioning/factory.py b/jupyter_client/provisioning/factory.py
index 212e036..c85009c 100644
--- a/jupyter_client/provisioning/factory.py
+++ b/jupyter_client/provisioning/factory.py
@@ -1,7 +1,9 @@
 """Kernel Provisioner Classes"""
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
-import os
+import glob
+from os import getenv
+from os import path
 from typing import Any
 from typing import Dict
 from typing import List
@@ -44,7 +46,7 @@ class KernelProvisionerFactory(SingletonConfigurable):
 
     @default('default_provisioner_name')
     def default_provisioner_name_default(self):
-        return os.getenv(self.default_provisioner_name_env, "local-provisioner")
+        return getenv(self.default_provisioner_name_env, "local-provisioner")
 
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
@@ -115,7 +117,7 @@ class KernelProvisionerFactory(SingletonConfigurable):
         is_available = True
         if provisioner_name not in self.provisioners:
             try:
-                ep = KernelProvisionerFactory._get_provisioner(provisioner_name)
+                ep = self._get_provisioner(provisioner_name)
                 self.provisioners[provisioner_name] = ep  # Update cache
             except NoSuchEntryPoint:
                 is_available = False
@@ -167,7 +169,33 @@ class KernelProvisionerFactory(SingletonConfigurable):
         """Wrapper around entrypoints.get_group_all() - primarily to facilitate testing."""
         return get_group_all(KernelProvisionerFactory.GROUP_NAME)
 
-    @staticmethod
-    def _get_provisioner(name: str) -> EntryPoint:
+    def _get_provisioner(self, name: str) -> EntryPoint:
         """Wrapper around entrypoints.get_single() - primarily to facilitate testing."""
-        return get_single(KernelProvisionerFactory.GROUP_NAME, name)
+        try:
+            ep = get_single(KernelProvisionerFactory.GROUP_NAME, name)
+        except NoSuchEntryPoint:
+            # Check if the entrypoint name is 'local-provisioner'.  Although this should never
+            # happen, we have seen cases where the previous distribution of jupyter_client has
+            # remained which doesn't include kernel-provisioner entrypoints (so 'local-provisioner'
+            # is deemed not found even though its definition is in THIS package).  In such cass,
+            # the entrypoints package uses what it first finds - which is the older distribution
+            # resulting in a violation of a supposed invariant condition.  To address this scenario,
+            # we will log a warning message indicating this situation, then build the entrypoint
+            # instance ourselves - since we have that information.
+            if name == 'local-provisioner':
+                distros = glob.glob(f"{path.dirname(path.dirname(__file__))}-*")
+                self.log.warn(
+                    f"Kernel Provisioning: The 'local-provisioner' is not found.  This is likely "
+                    f"due to the presence of multiple jupyter_client distributions and a previous "
+                    f"distribution is being used as the source for entrypoints - which does not "
+                    f"include 'local-provisioner'.  That distribution should be removed such that "
+                    f"only the version-appropriate distribution remains (version >= 7).  Until "
+                    f"then, a 'local-provisioner' entrypoint will be automatically constructed "
+                    f"and used.\nThe candidate distribution locations are: {distros}"
+                )
+                ep = EntryPoint(
+                    'local-provisioner', 'jupyter_client.provisioning', 'LocalProvisioner'
+                )
+            else:
+                raise
+        return ep


Test patch:
diff --git a/jupyter_client/tests/test_provisioning.py b/jupyter_client/tests/test_provisioning.py
index 3aa3db0..f6db54c 100644
--- a/jupyter_client/tests/test_provisioning.py
+++ b/jupyter_client/tests/test_provisioning.py
@@ -210,7 +210,7 @@ def mock_get_all_provisioners() -> List[EntryPoint]:
     return result
 
 
-def mock_get_provisioner(name) -> EntryPoint:
+def mock_get_provisioner(factory, name) -> EntryPoint:
     if name == 'new-test-provisioner':
         return EntryPoint(
             'new-test-provisioner', 'jupyter_client.tests.test_provisioning', 'NewTestProvisioner'