Skip to content

Commit b0037a1

Browse files
authored
feat: added --missing flag to list and remove subcommands (#1056)
* feat: added `--missing` flag to list and remove subcommands * chore: better fallback for missing kernels Now use `shutil.which` to check missing kernels. This catches things like "python" or "bash" * chore: added basic test to _limit_to_missing func
1 parent 5531215 commit b0037a1

File tree

2 files changed

+119
-2
lines changed

2 files changed

+119
-2
lines changed

jupyter_client/kernelspecapp.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import os.path
99
import sys
1010
import typing as t
11+
from pathlib import Path
1112

1213
from jupyter_core.application import JupyterApp, base_aliases, base_flags
1314
from traitlets import Bool, Dict, Instance, List, Unicode
@@ -29,12 +30,20 @@ class ListKernelSpecs(JupyterApp):
2930
help="output spec name and location as machine-readable json.",
3031
config=True,
3132
)
32-
33+
missing_kernels = Bool(
34+
False,
35+
help="List only specs with missing interpreters.",
36+
config=True,
37+
)
3338
flags = {
3439
"json": (
3540
{"ListKernelSpecs": {"json_output": True}},
3641
"output spec name and location as machine-readable json.",
3742
),
43+
"missing": (
44+
{"ListKernelSpecs": {"missing_kernels": True}},
45+
"output only missing kernels",
46+
),
3847
"debug": base_flags["debug"],
3948
}
4049

@@ -45,6 +54,10 @@ def start(self) -> dict[str, t.Any] | None: # type:ignore[override]
4554
"""Start the application."""
4655
paths = self.kernel_spec_manager.find_kernel_specs()
4756
specs = self.kernel_spec_manager.get_all_specs()
57+
58+
if self.missing_kernels:
59+
paths, specs = _limit_to_missing(paths, specs)
60+
4861
if not self.json_output:
4962
if not specs:
5063
print("No kernels available")
@@ -177,6 +190,11 @@ class RemoveKernelSpec(JupyterApp):
177190

178191
force = Bool(False, config=True, help="""Force removal, don't prompt for confirmation.""")
179192
spec_names = List(Unicode())
193+
missing_kernels = Bool(
194+
False,
195+
help="Remove missing specs.",
196+
config=True,
197+
)
180198

181199
kernel_spec_manager = Instance(KernelSpecManager)
182200

@@ -185,6 +203,10 @@ def _kernel_spec_manager_default(self) -> KernelSpecManager:
185203

186204
flags = {
187205
"f": ({"RemoveKernelSpec": {"force": True}}, force.help),
206+
"missing": (
207+
{"RemoveKernelSpec": {"missing_kernels": True}},
208+
"remove missing kernels",
209+
),
188210
}
189211
flags.update(JupyterApp.flags)
190212

@@ -195,12 +217,22 @@ def parse_command_line(self, argv: list[str] | None) -> None: # type:ignore[ove
195217
if self.extra_args:
196218
self.spec_names = sorted(set(self.extra_args)) # remove duplicates
197219
else:
198-
self.exit("No kernelspec specified.")
220+
self.spec_names = []
199221

200222
def start(self) -> None:
201223
"""Start the application."""
202224
self.kernel_spec_manager.ensure_native_kernel = False
203225
spec_paths = self.kernel_spec_manager.find_kernel_specs()
226+
227+
if self.missing_kernels:
228+
_, spec = _limit_to_missing(
229+
spec_paths,
230+
self.kernel_spec_manager.get_all_specs(),
231+
)
232+
233+
# append missing kernels
234+
self.spec_names = sorted(set(self.spec_names + list(spec)))
235+
204236
missing = set(self.spec_names).difference(set(spec_paths))
205237
if missing:
206238
self.exit("Couldn't find kernel spec(s): %s" % ", ".join(missing))
@@ -337,5 +369,22 @@ def start(self) -> None:
337369
return self.subapp.start()
338370

339371

372+
def _limit_to_missing(
373+
paths: dict[str, str], specs: dict[str, t.Any]
374+
) -> tuple[dict[str, str], dict[str, t.Any]]:
375+
from shutil import which
376+
377+
missing: dict[str, t.Any] = {}
378+
for name, data in specs.items():
379+
exe = data["spec"]["argv"][0]
380+
# if exe exists or is on the path, keep it
381+
if Path(exe).exists() or which(exe):
382+
continue
383+
missing[name] = data
384+
385+
paths_: dict[str, str] = {k: v for k, v in paths.items() if k in missing}
386+
return paths_, missing
387+
388+
340389
if __name__ == "__main__":
341390
KernelSpecApp.launch_instance()

tests/test_kernelspecapp.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
# Distributed under the terms of the Modified BSD License.
44
import os
55
import warnings
6+
from pathlib import Path
7+
8+
import pytest
69

710
from jupyter_client.kernelspecapp import (
811
InstallKernelSpec,
@@ -38,6 +41,11 @@ def test_kernelspec_sub_apps(jp_kernel_dir):
3841
specs = app3.start()
3942
assert specs and "echo" not in specs
4043

44+
app4 = ListKernelSpecs(missing_kernels=True)
45+
app4.kernel_spec_manager.kernel_dirs.append(kernel_dir)
46+
specs = app4.start()
47+
assert specs is None
48+
4149

4250
def test_kernelspec_app():
4351
app = KernelSpecApp()
@@ -49,3 +57,63 @@ def test_list_provisioners_app():
4957
app = ListProvisioners()
5058
app.initialize([])
5159
app.start()
60+
61+
62+
@pytest.fixture
63+
def dummy_kernelspecs():
64+
import sys
65+
66+
p = Path.cwd().resolve()
67+
# some missing kernelspecs
68+
out = {
69+
name: {
70+
"resource_dir": str(p / name),
71+
"spec": {
72+
"argv": [
73+
str(p / name / "bin" / "python"),
74+
"-Xfrozen_modules=off",
75+
"-m",
76+
"ipykernel_launcher",
77+
"-f",
78+
"{connection_file}",
79+
],
80+
"env": {},
81+
"display_name": "Python [venv: dummy0]",
82+
"language": "python",
83+
"interrupt_mode": "signal",
84+
"metadata": {"debugger": True},
85+
},
86+
}
87+
for name in ("dummy0", "dummy1")
88+
}
89+
90+
out["good"] = {
91+
"resource_dir": str(p / "good"),
92+
"spec": {
93+
"argv": [
94+
sys.executable,
95+
"-Xfrozen_modules=off",
96+
"-m",
97+
"ipykernel_launcher",
98+
"-f",
99+
"{connection_file}",
100+
],
101+
"env": {},
102+
"display_name": "Python [venv: dummy0]",
103+
"language": "python",
104+
"interrupt_mode": "signal",
105+
"metadata": {"debugger": True},
106+
},
107+
}
108+
return out
109+
110+
111+
def test__limit_to_missing(dummy_kernelspecs) -> None:
112+
from jupyter_client.kernelspecapp import _limit_to_missing
113+
114+
paths = {k: v["resource_dir"] for k, v in dummy_kernelspecs.items()}
115+
116+
paths, specs = _limit_to_missing(paths, dummy_kernelspecs)
117+
118+
assert specs == {k: v for k, v in dummy_kernelspecs.items() if k != "good"}
119+
assert paths == {k: v["resource_dir"] for k, v in dummy_kernelspecs.items() if k != "good"}

0 commit comments

Comments
 (0)