Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/scripts/make_test_kernels.py
14419 views
1
#!/usr/bin/env python3
2
"""
3
Create artificial versioned Jupyter kernels for testing the "versioned
4
kernels / kernel update awareness" feature.
5
6
See src/docs/jupyter.md -> "Versioned Kernels" -> "Local testing plan".
7
8
Run this INSIDE the project/user environment you want to test in (so that
9
$HOME points at that environment). It writes throwaway kernelspecs next to
10
your existing ones under the Jupyter data dir:
11
12
$JUPYTER_DATA_DIR/kernels/ (default: ~/.local/share/jupyter/kernels)
13
14
It reuses the `argv` of the already-installed `python3` kernel, so the test
15
kernels actually launch. They are all the same Python interpreter; only the
16
cocalc metadata and the `language` label differ. Two languages are used so
17
the per-language grouping is exercised: the real "python" and a fake
18
"snake".
19
20
Kernels created:
21
22
language "python":
23
testfam-1.0, testfam-1.1 (prio 0), testfam-2.0 (prio 10)
24
-> select 1.0 -> yellow "Update..." offering 2.0; 1.1 compact.
25
testfam-2.0 has priority 10, so it is also "Suggested"/starred.
26
otherfam-3.4, otherfam-3.5 (family otherfam, prio 0)
27
otherfam-3.7 (family otherfam, prio -1)
28
-> second family group; otherfam-3.4 must offer 3.5 (NOT the
29
negative-priority 3.7), and 3.7 must not be "Suggested".
30
31
language "snake" (fake):
32
snakefam-1.0, snakefam-2.0 (family snakefam, prio 0)
33
cobra-5.1, cobra-5.2 (family cobra, prio 0)
34
-> a separate language group with two families of its own.
35
36
plain kernels (no family/version) -> never show "Update", render in the
37
ungrouped section after the final menu divider:
38
plainkernel-a (no metadata.cocalc at all, like the stock python3)
39
plainkernel-b (metadata.cocalc present but no family/version)
40
plainkernel-c (language "snake")
41
42
The pre-existing `python3` kernel (no family/version) also stays untouched
43
and likewise verifies non-participation + the ungrouped section.
44
45
Usage:
46
src/scripts/make_test_kernels.py # create / refresh them
47
src/scripts/make_test_kernels.py --clean # remove them again
48
src/scripts/make_test_kernels.py --list # show kernels dir contents
49
50
After creating/removing, click "Refresh" in the CoCalc kernel selector
51
(the frontend caches kernelspecs for 5 minutes).
52
"""
53
54
import argparse
55
import json
56
import os
57
import shutil
58
import subprocess
59
import sys
60
61
# (family, version, display_name, language, priority)
62
# priority 10 -> "Suggested" / starred (needs >=10); newest in a family
63
# normally gets this, mirroring the real Sage convention.
64
# priority 0 -> out of "Suggested" but update-eligible (>=0).
65
# priority -1 -> filtered out of "Suggested", update detection and
66
# closest_kernel_match (but still selectable in the full
67
# kernel list / Change Kernel menu).
68
#
69
# All kernels run the same Python under the hood; `language` is just a
70
# label so we can exercise the per-language grouping. We use two languages:
71
# the real "python" and a fake "snake".
72
TEST_KERNELS = [
73
# language: python
74
("testfam", "1.0", "Test Family 1.0", "python", 0),
75
("testfam", "1.1", "Test Family 1.1", "python", 0),
76
# newest in testfam: priority 10 -> also shows in "Suggested"/starred,
77
# while still being the version-based update target.
78
("testfam", "2.0", "Test Family 2.0", "python", 10),
79
("otherfam", "3.4", "Other Family 3.4", "python", 0),
80
("otherfam", "3.5", "Other Family 3.5", "python", 0),
81
# negative priority: must NOT be offered as an update for otherfam-3.4
82
# (so 3.5 stays the latest), and must not appear in "Suggested".
83
("otherfam", "3.7", "Other Family 3.7", "python", -1),
84
# language: snake (fake) -- two families, so the "Snake" group/submenu
85
# also shows family grouping + a divider between groups.
86
("snakefam", "1.0", "Snake Family 1.0", "snake", 0),
87
("snakefam", "2.0", "Snake Family 2.0", "snake", 0),
88
("cobra", "5.1", "Cobra 5.1", "snake", 0),
89
("cobra", "5.2", "Cobra 5.2", "snake", 0),
90
]
91
92
# Plain kernels WITHOUT family/version -> must never show an "Update"
93
# button and must render in the ungrouped section (after the final menu
94
# divider). (name, display_name, language, cocalc_metadata_or_None)
95
# - plainkernel-a: no metadata.cocalc at all (like the stock python3).
96
# - plainkernel-b: has metadata.cocalc but no family/version, to confirm
97
# that presence of cocalc metadata alone does not opt a kernel in.
98
PLAIN_KERNELS = [
99
("plainkernel-a", "Plain Kernel A", "python", None),
100
(
101
"plainkernel-b",
102
"Plain Kernel B",
103
"python",
104
{
105
"priority": 0,
106
"description": "Artificial test kernel without family/version.",
107
"url": "https://doc.cocalc.com/jupyter.html",
108
},
109
),
110
# a plain kernel in the fake "snake" language
111
("plainkernel-c", "Plain Kernel C", "snake", None),
112
]
113
114
# kernel.json prefixes we own and may delete with --clean
115
OWNED_PREFIXES = (
116
"testfam-",
117
"otherfam-",
118
"snakefam-",
119
"cobra-",
120
"plainkernel-",
121
)
122
123
DEFAULT_ARGV = [
124
"python",
125
"-m",
126
"ipykernel_launcher",
127
"-f",
128
"{connection_file}",
129
]
130
131
132
def kernels_dir() -> str:
133
data_dir = os.environ.get("JUPYTER_DATA_DIR") or os.path.join(
134
os.path.expanduser("~"), ".local", "share", "jupyter"
135
)
136
return os.path.join(data_dir, "kernels")
137
138
139
def has_ipykernel(py: str) -> bool:
140
"""True if `py -m ipykernel_launcher` can at least import ipykernel."""
141
try:
142
r = subprocess.run(
143
[py, "-c", "import ipykernel"],
144
stdout=subprocess.DEVNULL,
145
stderr=subprocess.DEVNULL,
146
timeout=30,
147
)
148
return r.returncode == 0
149
except Exception:
150
return False
151
152
153
def resolve_interpreter(argv0: str) -> tuple:
154
"""Pick an absolute interpreter path that can import ipykernel.
155
156
Kernels are spawned by the CoCalc project server, whose PATH may not
157
contain a bare `python` (the stock python3 kernelspec has this same
158
fragility -> 'spawn python ENOENT'); and even when found, the wrong
159
interpreter may lack ipykernel -> the kernel exits and CoCalc reports
160
'timeout'. So we build a candidate list, prefer the first that can
161
`import ipykernel`, and report whether any could.
162
163
Returns (path, has_ipykernel)."""
164
candidates = [sys.executable] # run THIS script with the project python
165
if os.path.isabs(argv0) and os.path.exists(argv0):
166
candidates.append(argv0)
167
for cand in (argv0, "python3", "python"):
168
w = shutil.which(cand)
169
if w:
170
candidates.append(w)
171
# de-duplicate, preserve order
172
seen = set()
173
ordered = []
174
for c in candidates:
175
if c and c not in seen:
176
seen.add(c)
177
ordered.append(c)
178
for c in ordered:
179
if has_ipykernel(c):
180
return c, True
181
return (ordered[0] if ordered else sys.executable), False
182
183
184
def discover_python_argv(kdir: str) -> tuple:
185
"""Reuse the argv of an existing real python kernel so the test
186
kernels actually start, but force argv[0] to an absolute interpreter
187
that has ipykernel. Falls back to a sane default.
188
189
Returns (argv, has_ipykernel)."""
190
argv = list(DEFAULT_ARGV)
191
for name in ("python3", "python"):
192
path = os.path.join(kdir, name, "kernel.json")
193
if os.path.isfile(path):
194
try:
195
with open(path) as f:
196
spec = json.load(f)
197
found = spec.get("argv")
198
if isinstance(found, list) and found:
199
argv = list(found)
200
break
201
except (OSError, ValueError):
202
pass
203
argv[0], ok = resolve_interpreter(argv[0])
204
return argv, ok
205
206
207
def kernel_name(family: str, version: str) -> str:
208
return f"{family}-{version}"
209
210
211
def write_kernel(kdir: str, name: str, argv: list, display_name: str,
212
language: str, cocalc) -> str:
213
"""Write a kernels/<name>/kernel.json. `cocalc` is the
214
metadata.cocalc dict, or None for no cocalc metadata at all."""
215
path = os.path.join(kdir, name)
216
os.makedirs(path, exist_ok=True)
217
metadata = {"cocalc": cocalc} if cocalc is not None else {}
218
spec = {
219
"argv": list(argv),
220
"display_name": display_name,
221
"language": language,
222
"metadata": metadata,
223
}
224
with open(os.path.join(path, "kernel.json"), "w") as f:
225
json.dump(spec, f, indent=1)
226
f.write("\n")
227
return name
228
229
230
def make_kernel(kdir: str, argv: list, family: str, version: str,
231
display_name: str, language: str, priority: int = 0) -> str:
232
return write_kernel(
233
kdir,
234
kernel_name(family, version),
235
argv,
236
display_name,
237
language,
238
{
239
"priority": priority,
240
"description": f"Artificial test kernel ({family} {version}).",
241
"url": "https://doc.cocalc.com/jupyter.html",
242
"family": family,
243
"version": version,
244
},
245
)
246
247
248
def clean(kdir: str) -> None:
249
if not os.path.isdir(kdir):
250
print(f"nothing to clean: {kdir} does not exist")
251
return
252
removed = 0
253
for entry in sorted(os.listdir(kdir)):
254
if entry.startswith(OWNED_PREFIXES):
255
shutil.rmtree(os.path.join(kdir, entry), ignore_errors=True)
256
print(f" removed {entry}")
257
removed += 1
258
print(f"removed {removed} test kernel(s) from {kdir}")
259
260
261
def list_dir(kdir: str) -> None:
262
if not os.path.isdir(kdir):
263
print(f"{kdir} does not exist")
264
return
265
print(f"kernels in {kdir}:")
266
for entry in sorted(os.listdir(kdir)):
267
marker = " (test)" if entry.startswith(OWNED_PREFIXES) else ""
268
print(f" {entry}{marker}")
269
270
271
def main() -> int:
272
parser = argparse.ArgumentParser(
273
description="Create artificial versioned Jupyter test kernels."
274
)
275
parser.add_argument(
276
"--clean", action="store_true",
277
help="remove the test kernels instead of creating them",
278
)
279
parser.add_argument(
280
"--list", action="store_true",
281
help="list the kernels directory and exit",
282
)
283
args = parser.parse_args()
284
285
kdir = kernels_dir()
286
287
if args.list:
288
list_dir(kdir)
289
return 0
290
291
if args.clean:
292
clean(kdir)
293
return 0
294
295
os.makedirs(kdir, exist_ok=True)
296
argv, ipykernel_ok = discover_python_argv(kdir)
297
print(f"kernels dir : {kdir}")
298
print(f"interpreter : {argv[0]}")
299
print(f"full argv : {argv}")
300
if not ipykernel_ok:
301
print()
302
print("WARNING: none of the candidate interpreters could "
303
"'import ipykernel'.")
304
print(" The kernels will spawn but fail with a 3s timeout.")
305
print(" Re-run this script with the project's Python (the one")
306
print(" that has ipykernel installed), e.g.:")
307
print(" /path/to/project/python make_test_kernels.py")
308
print(" or: python -m pip install ipykernel")
309
print()
310
for family, version, display_name, language, priority in TEST_KERNELS:
311
name = make_kernel(
312
kdir, argv, family, version, display_name, language, priority
313
)
314
print(
315
f" created {name} "
316
f"(family={family} version={version} priority={priority})"
317
)
318
for name, display_name, language, cocalc in PLAIN_KERNELS:
319
write_kernel(kdir, name, argv, display_name, language, cocalc)
320
kind = "no metadata.cocalc" if cocalc is None else "no family/version"
321
print(f" created {name} ({kind})")
322
print()
323
print("Done. In CoCalc: open a notebook, click 'Refresh' in the kernel")
324
print("selector, then select 'Test Family 1.0' to see the Update button.")
325
print("Run with --clean to remove these again.")
326
return 0
327
328
329
if __name__ == "__main__":
330
sys.exit(main())
331
332