1"""Tools for managing kernel specs"""
2
3# Copyright (c) Jupyter Development Team.
4# Distributed under the terms of the Modified BSD License.
5from __future__ import annotations
6
7import json
8import os
9import re
10import shutil
11import typing as t
12import warnings
13
14from jupyter_core.paths import SYSTEM_JUPYTER_PATH, jupyter_data_dir, jupyter_path
15from traitlets import Bool, CaselessStrEnum, Dict, HasTraits, List, Set, Type, Unicode, observe
16from traitlets.config import LoggingConfigurable
17
18from .provisioning import KernelProvisionerFactory as KPF # noqa
19
20pjoin = os.path.join
21
22NATIVE_KERNEL_NAME = "python3"
23
24
25class KernelSpec(HasTraits):
26 """A kernel spec model object."""
27
28 argv: List[str] = List()
29 name = Unicode()
30 mimetype = Unicode()
31 display_name = Unicode()
32 language = Unicode()
33 kernel_protocol_version = Unicode()
34 env = Dict()
35 resource_dir = Unicode()
36 interrupt_mode = CaselessStrEnum(["message", "signal"], default_value="signal")
37 metadata = Dict()
38
39 @classmethod
40 def from_resource_dir(cls: type[KernelSpec], resource_dir: str) -> KernelSpec:
41 """Create a KernelSpec object by reading kernel.json
42
43 Pass the path to the *directory* containing kernel.json.
44 """
45 kernel_file = pjoin(resource_dir, "kernel.json")
46 with open(kernel_file, encoding="utf-8") as f:
47 kernel_dict = json.load(f)
48 return cls(resource_dir=resource_dir, **kernel_dict)
49
50 def to_dict(self) -> dict[str, t.Any]:
51 """Convert the kernel spec to a dict."""
52 d = {
53 "argv": self.argv,
54 "env": self.env,
55 "display_name": self.display_name,
56 "language": self.language,
57 "interrupt_mode": self.interrupt_mode,
58 "metadata": self.metadata,
59 "kernel_protocol_version": self.kernel_protocol_version,
60 }
61
62 return d
63
64 def to_json(self) -> str:
65 """Serialise this kernelspec to a JSON object.
66
67 Returns a string.
68 """
69 return json.dumps(self.to_dict())
70
71
72_kernel_name_pat = re.compile(r"^[a-z0-9._\-]+$", re.IGNORECASE)
73
74
75def _is_valid_kernel_name(name: str) -> t.Any:
76 """Check that a kernel name is valid."""
77 # quote is not unicode-safe on Python 2
78 return _kernel_name_pat.match(name)
79
80
81_kernel_name_description = (
82 "Kernel names can only contain ASCII letters and numbers and these separators:"
83 " - . _ (hyphen, period, and underscore)."
84)
85
86
87def _is_kernel_dir(path: str) -> bool:
88 """Is ``path`` a kernel directory?"""
89 return os.path.isdir(path) and os.path.isfile(pjoin(path, "kernel.json"))
90
91
92def _list_kernels_in(dir: str | None) -> dict[str, str]:
93 """Return a mapping of kernel names to resource directories from dir.
94
95 If dir is None or does not exist, returns an empty dict.
96 """
97 if dir is None or not os.path.isdir(dir):
98 return {}
99 kernels = {}
100 for f in os.listdir(dir):
101 path = pjoin(dir, f)
102 if not _is_kernel_dir(path):
103 continue
104 key = f.lower()
105 if not _is_valid_kernel_name(key):
106 warnings.warn(
107 f"Invalid kernelspec directory name ({_kernel_name_description}): {path}",
108 stacklevel=3,
109 )
110 kernels[key] = path
111 return kernels
112
113
114class NoSuchKernel(KeyError): # noqa
115 """An error raised when there is no kernel of a give name."""
116
117 def __init__(self, name: str) -> None:
118 """Initialize the error."""
119 self.name = name
120
121 def __str__(self) -> str:
122 return f"No such kernel named {self.name}"
123
124
125class KernelSpecManager(LoggingConfigurable):
126 """A manager for kernel specs."""
127
128 kernel_spec_class = Type(
129 KernelSpec,
130 config=True,
131 help="""The kernel spec class. This is configurable to allow
132 subclassing of the KernelSpecManager for customized behavior.
133 """,
134 )
135
136 ensure_native_kernel = Bool(
137 True,
138 config=True,
139 help="""If there is no Python kernelspec registered and the IPython
140 kernel is available, ensure it is added to the spec list.
141 """,
142 )
143
144 data_dir = Unicode()
145
146 def _data_dir_default(self) -> str:
147 return jupyter_data_dir()
148
149 user_kernel_dir = Unicode()
150
151 def _user_kernel_dir_default(self) -> str:
152 return pjoin(self.data_dir, "kernels")
153
154 whitelist = Set(
155 config=True,
156 help="""Deprecated, use `KernelSpecManager.allowed_kernelspecs`
157 """,
158 )
159 allowed_kernelspecs = Set(
160 config=True,
161 help="""List of allowed kernel names.
162
163 By default, all installed kernels are allowed.
164 """,
165 )
166 kernel_dirs: List[str] = List(
167 help="""
168 List of kernel directories to search.
169
170 Directories earlier in the list take priority: when two directories
171 contain a kernelspec with the same name, the one from the earlier
172 directory wins. This matches the precedence of ``$JUPYTER_PATH``,
173 where the first entry has the highest priority (see
174 ``jupyter_path("kernels")``).
175 """
176 )
177
178 _deprecated_aliases = {
179 "whitelist": ("allowed_kernelspecs", "7.0"),
180 }
181
182 # Method copied from
183 # https://github.com/jupyterhub/jupyterhub/blob/d1a85e53dccfc7b1dd81b0c1985d158cc6b61820/jupyterhub/auth.py#L143-L161
184 @observe(*list(_deprecated_aliases))
185 def _deprecated_trait(self, change: t.Any) -> None:
186 """observer for deprecated traits"""
187 old_attr = change.name
188 new_attr, version = self._deprecated_aliases[old_attr]
189 new_value = getattr(self, new_attr)
190 if new_value != change.new:
191 # only warn if different
192 # protects backward-compatible config from warnings
193 # if they set the same value under both names
194 self.log.warning(
195 f"{self.__class__.__name__}.{old_attr} is deprecated in jupyter_client "
196 f"{version}, use {self.__class__.__name__}.{new_attr} instead"
197 )
198 setattr(self, new_attr, change.new)
199
200 def _kernel_dirs_default(self) -> list[str]:
201 dirs = jupyter_path("kernels")
202 # At some point, we should stop adding .ipython/kernels to the path,
203 # but the cost to keeping it is very small.
204 try:
205 # this should always be valid on IPython 3+
206 from IPython.paths import get_ipython_dir
207
208 dirs.append(os.path.join(get_ipython_dir(), "kernels"))
209 except ModuleNotFoundError:
210 pass
211 return dirs
212
213 def find_kernel_specs(self) -> dict[str, str]:
214 """Returns a dict mapping kernel names to resource directories."""
215 d = {}
216 for kernel_dir in self.kernel_dirs:
217 kernels = _list_kernels_in(kernel_dir)
218 for kname, spec in kernels.items():
219 if kname not in d:
220 self.log.debug("Found kernel %s in %s", kname, kernel_dir)
221 d[kname] = spec
222
223 if self.ensure_native_kernel and NATIVE_KERNEL_NAME not in d:
224 try:
225 from ipykernel.kernelspec import RESOURCES
226
227 self.log.debug(
228 "Native kernel (%s) available from %s",
229 NATIVE_KERNEL_NAME,
230 RESOURCES,
231 )
232 d[NATIVE_KERNEL_NAME] = RESOURCES
233 except ImportError:
234 self.log.warning("Native kernel (%s) is not available", NATIVE_KERNEL_NAME)
235
236 if self.allowed_kernelspecs:
237 # filter if there's an allow list
238 d = {name: spec for name, spec in d.items() if name in self.allowed_kernelspecs}
239 return d
240 # TODO: Caching?
241
242 def _get_kernel_spec_by_name(self, kernel_name: str, resource_dir: str) -> KernelSpec:
243 """Returns a :class:`KernelSpec` instance for a given kernel_name
244 and resource_dir.
245 """
246 kspec = None
247 if kernel_name == NATIVE_KERNEL_NAME:
248 try:
249 from ipykernel.kernelspec import RESOURCES, get_kernel_dict
250 except ImportError:
251 # It should be impossible to reach this, but let's play it safe
252 pass
253 else:
254 if resource_dir == RESOURCES:
255 kdict = get_kernel_dict()
256 kspec = self.kernel_spec_class(resource_dir=resource_dir, **kdict)
257 if not kspec:
258 kspec = self.kernel_spec_class.from_resource_dir(resource_dir)
259
260 if not KPF.instance(parent=self.parent).is_provisioner_available(kspec):
261 raise NoSuchKernel(kernel_name)
262
263 return kspec
264
265 def _find_spec_directory(self, kernel_name: str) -> str | None:
266 """Find the resource directory of a named kernel spec"""
267 for kernel_dir in [kd for kd in self.kernel_dirs if os.path.isdir(kd)]:
268 files = os.listdir(kernel_dir)
269 for f in files:
270 path = pjoin(kernel_dir, f)
271 if f.lower() == kernel_name and _is_kernel_dir(path):
272 return path
273
274 if kernel_name == NATIVE_KERNEL_NAME:
275 try:
276 from ipykernel.kernelspec import RESOURCES
277 except ImportError:
278 pass
279 else:
280 return RESOURCES
281 return None
282
283 def get_kernel_spec(self, kernel_name: str) -> KernelSpec:
284 """Returns a :class:`KernelSpec` instance for the given kernel_name.
285
286 Raises :exc:`NoSuchKernel` if the given kernel name is not found.
287 """
288 if not _is_valid_kernel_name(kernel_name):
289 self.log.warning(
290 f"Kernelspec name {kernel_name} is invalid: {_kernel_name_description}"
291 )
292
293 resource_dir = self._find_spec_directory(kernel_name.lower())
294 if resource_dir is None:
295 raise NoSuchKernel(kernel_name)
296
297 return self._get_kernel_spec_by_name(kernel_name, resource_dir)
298
299 def get_all_specs(self) -> dict[str, t.Any]:
300 """Returns a dict mapping kernel names to kernelspecs.
301
302 Returns a dict of the form::
303
304 {
305 'kernel_name': {
306 'resource_dir': '/path/to/kernel_name',
307 'spec': {"the spec itself": ...}
308 },
309 ...
310 }
311 """
312 d = self.find_kernel_specs()
313 res = {}
314 for kname, resource_dir in d.items():
315 try:
316 if self.__class__ is KernelSpecManager:
317 spec = self._get_kernel_spec_by_name(kname, resource_dir)
318 else:
319 # avoid calling private methods in subclasses,
320 # which may have overridden find_kernel_specs
321 # and get_kernel_spec, but not the newer get_all_specs
322 spec = self.get_kernel_spec(kname)
323
324 res[kname] = {"resource_dir": resource_dir, "spec": spec.to_dict()}
325 except NoSuchKernel:
326 pass # The appropriate warning has already been logged
327 except Exception:
328 self.log.warning("Error loading kernelspec %r", kname, exc_info=True)
329 return res
330
331 def remove_kernel_spec(self, name: str) -> str:
332 """Remove a kernel spec directory by name.
333
334 Returns the path that was deleted.
335 """
336 save_native = self.ensure_native_kernel
337 try:
338 self.ensure_native_kernel = False
339 specs = self.find_kernel_specs()
340 finally:
341 self.ensure_native_kernel = save_native
342 spec_dir = specs[name]
343 self.log.debug("Removing %s", spec_dir)
344 if os.path.islink(spec_dir):
345 os.remove(spec_dir)
346 else:
347 shutil.rmtree(spec_dir)
348 return spec_dir
349
350 def _get_destination_dir(
351 self, kernel_name: str, user: bool = False, prefix: str | None = None
352 ) -> str:
353 if user:
354 return os.path.join(self.user_kernel_dir, kernel_name)
355 elif prefix:
356 return os.path.join(os.path.abspath(prefix), "share", "jupyter", "kernels", kernel_name)
357 else:
358 return os.path.join(SYSTEM_JUPYTER_PATH[0], "kernels", kernel_name)
359
360 def install_kernel_spec(
361 self,
362 source_dir: str,
363 kernel_name: str | None = None,
364 user: bool = False,
365 replace: bool | None = None,
366 prefix: str | None = None,
367 ) -> str:
368 """Install a kernel spec by copying its directory.
369
370 If ``kernel_name`` is not given, the basename of ``source_dir`` will
371 be used.
372
373 If ``user`` is False, it will attempt to install into the systemwide
374 kernel registry. If the process does not have appropriate permissions,
375 an :exc:`OSError` will be raised.
376
377 If ``prefix`` is given, the kernelspec will be installed to
378 PREFIX/share/jupyter/kernels/KERNEL_NAME. This can be sys.prefix
379 for installation inside virtual or conda envs.
380 """
381 source_dir = source_dir.rstrip("/\\")
382 if not kernel_name:
383 kernel_name = os.path.basename(source_dir)
384 kernel_name = kernel_name.lower()
385 if not _is_valid_kernel_name(kernel_name):
386 msg = f"Invalid kernel name {kernel_name!r}. {_kernel_name_description}"
387 raise ValueError(msg)
388
389 if user and prefix:
390 msg = "Can't specify both user and prefix. Please choose one or the other."
391 raise ValueError(msg)
392
393 if replace is not None:
394 warnings.warn(
395 "replace is ignored. Installing a kernelspec always replaces an existing "
396 "installation",
397 DeprecationWarning,
398 stacklevel=2,
399 )
400
401 destination = self._get_destination_dir(kernel_name, user=user, prefix=prefix)
402 self.log.debug("Installing kernelspec in %s", destination)
403
404 kernel_dir = os.path.dirname(destination)
405 if kernel_dir not in self.kernel_dirs:
406 self.log.warning(
407 "Installing to %s, which is not in %s. The kernelspec may not be found.",
408 kernel_dir,
409 self.kernel_dirs,
410 )
411
412 if os.path.isdir(destination):
413 self.log.info("Removing existing kernelspec in %s", destination)
414 shutil.rmtree(destination)
415
416 shutil.copytree(source_dir, destination)
417 self.log.info("Installed kernelspec %s in %s", kernel_name, destination)
418 return destination
419
420 def install_native_kernel_spec(self, user: bool = False) -> None:
421 """DEPRECATED: Use ipykernel.kernelspec.install"""
422 warnings.warn(
423 "install_native_kernel_spec is deprecated. Use ipykernel.kernelspec import install.",
424 stacklevel=2,
425 )
426 from ipykernel.kernelspec import install
427
428 install(self, user=user)
429
430
431def find_kernel_specs() -> dict[str, str]:
432 """Returns a dict mapping kernel names to resource directories."""
433 return KernelSpecManager().find_kernel_specs()
434
435
436def get_kernel_spec(kernel_name: str) -> KernelSpec:
437 """Returns a :class:`KernelSpec` instance for the given kernel_name.
438
439 Raises KeyError if the given kernel name is not found.
440 """
441 return KernelSpecManager().get_kernel_spec(kernel_name)
442
443
444def install_kernel_spec(
445 source_dir: str,
446 kernel_name: str | None = None,
447 user: bool = False,
448 replace: bool | None = False,
449 prefix: str | None = None,
450) -> str:
451 """Install a kernel spec in a given directory."""
452 return KernelSpecManager().install_kernel_spec(source_dir, kernel_name, user, replace, prefix)
453
454
455install_kernel_spec.__doc__ = KernelSpecManager.install_kernel_spec.__doc__
456
457
458def install_native_kernel_spec(user: bool = False) -> None:
459 """Install the native kernel spec."""
460 KernelSpecManager().install_native_kernel_spec(user=user)
461
462
463install_native_kernel_spec.__doc__ = KernelSpecManager.install_native_kernel_spec.__doc__