factory.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. """Kernel Provisioner Classes"""
  2. # Copyright (c) Jupyter Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. import glob
  5. # See compatibility note on `group` keyword in https://docs.python.org/3/library/importlib.metadata.html#entry-points
  6. from importlib.metadata import EntryPoint, entry_points
  7. from os import getenv, path
  8. from typing import Any
  9. from traitlets.config import SingletonConfigurable, Unicode, default
  10. from .provisioner_base import KernelProvisionerBase
  11. class KernelProvisionerFactory(SingletonConfigurable):
  12. """
  13. :class:`KernelProvisionerFactory` is responsible for creating provisioner instances.
  14. A singleton instance, `KernelProvisionerFactory` is also used by the :class:`KernelSpecManager`
  15. to validate `kernel_provisioner` references found in kernel specifications to confirm their
  16. availability (in cases where the kernel specification references a kernel provisioner that has
  17. not been installed into the current Python environment).
  18. It's ``default_provisioner_name`` attribute can be used to specify the default provisioner
  19. to use when a kernel_spec is found to not reference a provisioner. It's value defaults to
  20. `"local-provisioner"` which identifies the local provisioner implemented by
  21. :class:`LocalProvisioner`.
  22. """
  23. GROUP_NAME = "jupyter_client.kernel_provisioners"
  24. provisioners: dict[str, EntryPoint] = {}
  25. default_provisioner_name_env = "JUPYTER_DEFAULT_PROVISIONER_NAME"
  26. default_provisioner_name = Unicode(
  27. config=True,
  28. help="""Indicates the name of the provisioner to use when no kernel_provisioner
  29. entry is present in the kernelspec.""",
  30. )
  31. @default("default_provisioner_name")
  32. def _default_provisioner_name_default(self) -> str:
  33. """The default provisioner name."""
  34. return getenv(self.default_provisioner_name_env, "local-provisioner")
  35. def __init__(self, **kwargs: Any) -> None:
  36. """Initialize a kernel provisioner factory."""
  37. super().__init__(**kwargs)
  38. for ep in KernelProvisionerFactory._get_all_provisioners():
  39. self.provisioners[ep.name] = ep
  40. def is_provisioner_available(self, kernel_spec: Any) -> bool:
  41. """
  42. Reads the associated ``kernel_spec`` to determine the provisioner and returns whether it
  43. exists as an entry_point (True) or not (False). If the referenced provisioner is not
  44. in the current cache or cannot be loaded via entry_points, a warning message is issued
  45. indicating it is not available.
  46. """
  47. is_available: bool = True
  48. provisioner_cfg = self._get_provisioner_config(kernel_spec)
  49. provisioner_name = str(provisioner_cfg.get("provisioner_name"))
  50. if not self._check_availability(provisioner_name):
  51. is_available = False
  52. self.log.warning(
  53. f"Kernel '{kernel_spec.display_name}' is referencing a kernel "
  54. f"provisioner ('{provisioner_name}') that is not available. "
  55. f"Ensure the appropriate package has been installed and retry."
  56. )
  57. return is_available
  58. def create_provisioner_instance(
  59. self, kernel_id: str, kernel_spec: Any, parent: Any
  60. ) -> KernelProvisionerBase:
  61. """
  62. Reads the associated ``kernel_spec`` to see if it has a `kernel_provisioner` stanza.
  63. If one exists, it instantiates an instance. If a kernel provisioner is not
  64. specified in the kernel specification, a default provisioner stanza is fabricated
  65. and instantiated corresponding to the current value of ``default_provisioner_name`` trait.
  66. The instantiated instance is returned.
  67. If the provisioner is found to not exist (not registered via entry_points),
  68. `ModuleNotFoundError` is raised.
  69. """
  70. provisioner_cfg = self._get_provisioner_config(kernel_spec)
  71. provisioner_name = str(provisioner_cfg.get("provisioner_name"))
  72. if not self._check_availability(provisioner_name):
  73. msg = f"Kernel provisioner '{provisioner_name}' has not been registered."
  74. raise ModuleNotFoundError(msg)
  75. self.log.debug(
  76. f"Instantiating kernel '{kernel_spec.display_name}' with "
  77. f"kernel provisioner: {provisioner_name}"
  78. )
  79. provisioner_class = self.provisioners[provisioner_name].load()
  80. provisioner_config = provisioner_cfg.get("config")
  81. provisioner: KernelProvisionerBase = provisioner_class(
  82. kernel_id=kernel_id, kernel_spec=kernel_spec, parent=parent, **provisioner_config
  83. )
  84. return provisioner
  85. def _check_availability(self, provisioner_name: str) -> bool:
  86. """
  87. Checks that the given provisioner is available.
  88. If the given provisioner is not in the current set of loaded provisioners an attempt
  89. is made to fetch the named entry point and, if successful, loads it into the cache.
  90. :param provisioner_name:
  91. :return:
  92. """
  93. is_available = True
  94. if provisioner_name not in self.provisioners:
  95. try:
  96. ep = self._get_provisioner(provisioner_name)
  97. self.provisioners[provisioner_name] = ep # Update cache
  98. except Exception:
  99. is_available = False
  100. return is_available
  101. def _get_provisioner_config(self, kernel_spec: Any) -> dict[str, Any]:
  102. """
  103. Return the kernel_provisioner stanza from the kernel_spec.
  104. Checks the kernel_spec's metadata dictionary for a kernel_provisioner entry.
  105. If found, it is returned, else one is created relative to the DEFAULT_PROVISIONER
  106. and returned.
  107. Parameters
  108. ----------
  109. kernel_spec : Any - this is a KernelSpec type but listed as Any to avoid circular import
  110. The kernel specification object from which the provisioner dictionary is derived.
  111. Returns
  112. -------
  113. dict
  114. The provisioner portion of the kernel_spec. If one does not exist, it will contain
  115. the default information. If no `config` sub-dictionary exists, an empty `config`
  116. dictionary will be added.
  117. """
  118. env_provisioner = kernel_spec.metadata.get("kernel_provisioner", {})
  119. if "provisioner_name" in env_provisioner: # If no provisioner_name, return default
  120. if (
  121. "config" not in env_provisioner
  122. ): # if provisioner_name, but no config stanza, add one
  123. env_provisioner.update({"config": {}})
  124. return env_provisioner # Return what we found (plus config stanza if necessary)
  125. return {"provisioner_name": self.default_provisioner_name, "config": {}}
  126. def get_provisioner_entries(self) -> dict[str, str]:
  127. """
  128. Returns a dictionary of provisioner entries.
  129. The key is the provisioner name for its entry point. The value is the colon-separated
  130. string of the entry point's module name and object name.
  131. """
  132. entries = {}
  133. for name, ep in self.provisioners.items():
  134. entries[name] = ep.value
  135. return entries
  136. @staticmethod
  137. def _get_all_provisioners() -> list[EntryPoint]:
  138. """Wrapper around entry_points (to fetch the set of provisioners) - primarily to facilitate testing."""
  139. return entry_points(group=KernelProvisionerFactory.GROUP_NAME)
  140. def _get_provisioner(self, name: str) -> EntryPoint:
  141. """Wrapper around entry_points (to fetch a single provisioner) - primarily to facilitate testing."""
  142. eps = entry_points(group=KernelProvisionerFactory.GROUP_NAME, name=name)
  143. if eps:
  144. return eps[0]
  145. # Check if the entrypoint name is 'local-provisioner'. Although this should never
  146. # happen, we have seen cases where the previous distribution of jupyter_client has
  147. # remained which doesn't include kernel-provisioner entrypoints (so 'local-provisioner'
  148. # is deemed not found even though its definition is in THIS package). In such cases,
  149. # the entrypoints package uses what it first finds - which is the older distribution
  150. # resulting in a violation of a supposed invariant condition. To address this scenario,
  151. # we will log a warning message indicating this situation, then build the entrypoint
  152. # instance ourselves - since we have that information.
  153. if name == "local-provisioner":
  154. distros = glob.glob(f"{path.dirname(path.dirname(__file__))}-*")
  155. self.log.warning(
  156. f"Kernel Provisioning: The 'local-provisioner' is not found. This is likely "
  157. f"due to the presence of multiple jupyter_client distributions and a previous "
  158. f"distribution is being used as the source for entrypoints - which does not "
  159. f"include 'local-provisioner'. That distribution should be removed such that "
  160. f"only the version-appropriate distribution remains (version >= 7). Until "
  161. f"then, a 'local-provisioner' entrypoint will be automatically constructed "
  162. f"and used.\nThe candidate distribution locations are: {distros}"
  163. )
  164. return EntryPoint(
  165. "local-provisioner", "jupyter_client.provisioning", "LocalProvisioner"
  166. )
  167. err_message = "Was unable to find a provisioner"
  168. raise RuntimeError(err_message)