kernelcontract 0.1.0a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """KernelContract: read-only diagnostics for Jupyter kernels and Python environments."""
2
+
3
+ __version__ = "0.1.0a3"
@@ -0,0 +1,4 @@
1
+ from .cli import entrypoint
2
+
3
+ if __name__ == "__main__":
4
+ entrypoint()
kernelcontract/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ """Command-line interface for KernelContract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Optional, Sequence
9
+
10
+ from . import __version__
11
+ from .diagnostics import run_diagnostics
12
+ from .models import Report
13
+
14
+
15
+ def _parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="kernelcontract",
18
+ description="Read-only diagnostics for Jupyter kernels and Python environments.",
19
+ epilog="No packages are installed, removed, or modified by this tool.",
20
+ )
21
+ parser.add_argument("--version", action="version", version="%(prog)s {}".format(__version__))
22
+ subparsers = parser.add_subparsers(dest="command", required=True)
23
+
24
+ doctor = subparsers.add_parser("doctor", help="check registered Jupyter kernels and environment drift")
25
+ doctor.add_argument("--server-python", help="path to the Python interpreter used by the Jupyter server (optional)")
26
+ doctor.add_argument("--json", action="store_true", dest="json_output", help="print machine-readable JSON")
27
+ doctor.add_argument("--strict", action="store_true", help="exit non-zero for warnings as well as failures")
28
+ doctor.add_argument("--probe-python", action="store_true", help="execute each registered Python kernel's argv[0] for package checks; use only with trusted kernelspecs")
29
+
30
+ notebook = subparsers.add_parser("notebook", help="check a notebook's declared kernel against registered kernels")
31
+ notebook.add_argument("path", help="path to a .ipynb file")
32
+ notebook.add_argument("--server-python", help="path to the Python interpreter used by the Jupyter server (optional)")
33
+ notebook.add_argument("--json", action="store_true", dest="json_output", help="print machine-readable JSON")
34
+ notebook.add_argument("--strict", action="store_true", help="exit non-zero for warnings as well as failures")
35
+ notebook.add_argument("--probe-python", action="store_true", help="execute each registered Python kernel's argv[0] for package checks; use only with trusted kernelspecs")
36
+ return parser
37
+
38
+
39
+ def render_text(report: Report) -> str:
40
+ lines = ["KernelContract {} - {}".format(__version__, report.command), "=" * 44]
41
+ for check in report.checks:
42
+ lines.append("{:<5} {}".format(check.status, check.title))
43
+ lines.append(" {}".format(check.message))
44
+ for suggestion in check.suggestions:
45
+ lines.append(" Next: {}".format(suggestion))
46
+ counts = report.counts
47
+ lines.extend([
48
+ "",
49
+ "Summary: {pass_count} pass, {info_count} info, {warn_count} warning(s), {fail_count} failure(s).".format(
50
+ pass_count=counts["pass"], info_count=counts["info"], warn_count=counts["warn"], fail_count=counts["fail"]
51
+ ),
52
+ "Overall status: {}".format(report.overall_status),
53
+ "KernelContract did not modify packages or environments.",
54
+ ])
55
+ return "\n".join(lines)
56
+
57
+
58
+ def exit_code(report: Report, strict: bool = False) -> int:
59
+ if any(check.status == "FAIL" for check in report.checks):
60
+ return 1
61
+ if strict and any(check.status == "WARN" for check in report.checks):
62
+ return 1
63
+ return 0
64
+
65
+
66
+ def main(argv: Optional[Sequence[str]] = None) -> int:
67
+ args = _parser().parse_args(argv)
68
+ notebook_path = args.path if args.command == "notebook" else None
69
+ report = run_diagnostics(
70
+ command=args.command,
71
+ notebook_path=notebook_path,
72
+ server_python=args.server_python,
73
+ probe_python_kernels=args.probe_python,
74
+ )
75
+ if args.json_output:
76
+ print(json.dumps(report.to_dict(), indent=2, sort_keys=True))
77
+ else:
78
+ print(render_text(report))
79
+ return exit_code(report, strict=args.strict)
80
+
81
+
82
+ def entrypoint() -> None:
83
+ raise SystemExit(main())
84
+
85
+
86
+ if __name__ == "__main__":
87
+ entrypoint()
@@ -0,0 +1,432 @@
1
+ """The core deterministic checks used by both local and CI invocations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from .discovery import discover_kernelspecs, is_python_kernel, probe_python, resolve_executable
12
+ from .models import Check, Report
13
+
14
+
15
+ def _same_path(left: Optional[str], right: Optional[str]) -> bool:
16
+ if not left or not right:
17
+ return False
18
+ try:
19
+ return os.path.normcase(os.path.realpath(left)) == os.path.normcase(os.path.realpath(right))
20
+ except (OSError, TypeError):
21
+ return os.path.normcase(str(left)) == os.path.normcase(str(right))
22
+
23
+
24
+ def _package(packages: Dict[str, Any], name: str) -> Dict[str, Any]:
25
+ value = packages.get(name, {})
26
+ return value if isinstance(value, dict) else {}
27
+
28
+
29
+ def _read_notebook(path: str) -> tuple[Optional[Dict[str, Any]], Optional[str]]:
30
+ target = Path(path).expanduser()
31
+ if not target.exists():
32
+ return None, "Notebook file does not exist: {}".format(target)
33
+ if not target.is_file():
34
+ return None, "Notebook path is not a file: {}".format(target)
35
+ try:
36
+ with target.open("r", encoding="utf-8") as handle:
37
+ data = json.load(handle)
38
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
39
+ return None, "Could not read notebook JSON: {}".format(exc)
40
+ if not isinstance(data, dict):
41
+ return None, "Notebook JSON root must be an object."
42
+ metadata = data.get("metadata", {})
43
+ if not isinstance(metadata, dict):
44
+ return None, "Notebook metadata must be an object."
45
+ return data, None
46
+
47
+
48
+ def run_diagnostics(
49
+ command: str = "doctor",
50
+ notebook_path: Optional[str] = None,
51
+ server_python: Optional[str] = None,
52
+ probe_python_kernels: bool = False,
53
+ ) -> Report:
54
+ """Collect diagnostics. All operations are read-only and use bounded subprocesses."""
55
+ report = Report(command=command)
56
+ if probe_python_kernels:
57
+ report.add(Check(
58
+ id="security.python-probing",
59
+ status="INFO",
60
+ title="Python kernel probing enabled",
61
+ message="The --probe-python option can execute each registered Python kernel executable. Use it only when you trust the kernelspecs on this machine.",
62
+ suggestions=[],
63
+ ))
64
+ provided_server_python = bool(server_python)
65
+ if server_python:
66
+ resolved_server = resolve_executable(server_python)
67
+ control_python = resolved_server if resolved_server else os.path.abspath(os.path.expanduser(server_python))
68
+ else:
69
+ control_python = sys.executable
70
+
71
+ if not os.path.isfile(control_python):
72
+ report.add(Check(
73
+ id="control-python.path",
74
+ status="FAIL",
75
+ title="Control/server Python path",
76
+ message="The configured Python interpreter does not exist or is not a file: {}".format(control_python),
77
+ details={"path": control_python},
78
+ suggestions=["Pass the absolute path to the Python interpreter used by the Jupyter server with --server-python."],
79
+ ))
80
+ control_probe = None
81
+ else:
82
+ control_probe, control_error = probe_python(control_python)
83
+ if control_error:
84
+ report.add(Check(
85
+ id="control-python.probe",
86
+ status="FAIL",
87
+ title="Control/server Python probe",
88
+ message=control_error,
89
+ details={"path": control_python},
90
+ suggestions=["Confirm the interpreter is executable and not blocked by the local environment."],
91
+ ))
92
+ else:
93
+ label = "Server Python (explicitly provided)" if provided_server_python else "Current Python (control environment)"
94
+ report.add(Check(
95
+ id="control-python.probe",
96
+ status="PASS",
97
+ title=label,
98
+ message="Python {} is executable.".format(control_probe.get("version", "unknown")),
99
+ details={"executable": control_probe.get("executable"), "prefix": control_probe.get("prefix"), "version": control_probe.get("version")},
100
+ suggestions=[],
101
+ ))
102
+ if not provided_server_python:
103
+ report.add(Check(
104
+ id="control-python.role",
105
+ status="INFO",
106
+ title="Server interpreter not inferred",
107
+ message="The current Python is used as a control environment; it is not assumed to be the actual Jupyter server Python.",
108
+ details={"current_python": control_python},
109
+ suggestions=["Re-run with --server-python /absolute/path/to/server/python when Jupyter runs from another environment."],
110
+ ))
111
+
112
+ kernels, discover_error = discover_kernelspecs()
113
+ if discover_error:
114
+ report.add(Check(
115
+ id="kernelspec.discovery",
116
+ status="FAIL",
117
+ title="Discover Jupyter kernels",
118
+ message=discover_error,
119
+ details={},
120
+ suggestions=["Install or activate the environment that provides the Jupyter command, then rerun kernelcontract doctor."],
121
+ ))
122
+ kernels = {}
123
+ else:
124
+ report.add(Check(
125
+ id="kernelspec.discovery",
126
+ status="PASS",
127
+ title="Discover Jupyter kernels",
128
+ message="Found {} registered kernelspec(s).".format(len(kernels)),
129
+ details={"count": len(kernels), "names": sorted(kernels.keys())},
130
+ suggestions=["Register a kernel in the environment you intend to use if none of these is expected."] if not kernels else [],
131
+ ))
132
+ if not kernels:
133
+ report.add(Check(
134
+ id="kernelspec.empty",
135
+ status="WARN",
136
+ title="No registered kernels",
137
+ message="Jupyter returned no registered kernelspecs.",
138
+ details={},
139
+ suggestions=["For a Python environment, install ipykernel in the target environment and register it with `python -m ipykernel install --user --name <name>`."],
140
+ ))
141
+
142
+ kernel_probes: Dict[str, Dict[str, Any]] = {}
143
+ for name, entry in sorted(kernels.items()):
144
+ resource_dir = entry.get("resource_dir") if isinstance(entry, dict) else None
145
+ spec = entry.get("spec", {}) if isinstance(entry, dict) else {}
146
+ spec = spec if isinstance(spec, dict) else {}
147
+
148
+ if resource_dir:
149
+ res_path = Path(resource_dir)
150
+ if not res_path.is_dir():
151
+ report.add(Check(
152
+ id="kernel.{}.resource-dir".format(name),
153
+ status="FAIL",
154
+ title="{}: kernelspec directory".format(name),
155
+ message="The kernelspec resource directory does not exist or is not a directory: {}".format(resource_dir),
156
+ details={"kernel": name, "resource_dir": resource_dir},
157
+ suggestions=["Remove the stale kernelspec registration or restore the missing directory."],
158
+ ))
159
+ continue
160
+ kernel_json_path = res_path / "kernel.json"
161
+ if not kernel_json_path.is_file():
162
+ report.add(Check(
163
+ id="kernel.{}.kernel-json".format(name),
164
+ status="FAIL",
165
+ title="{}: kernel.json file".format(name),
166
+ message="The kernel.json file is missing in kernelspec directory: {}".format(kernel_json_path),
167
+ details={"kernel": name, "resource_dir": resource_dir},
168
+ suggestions=["Reinstall or re-register this kernelspec."],
169
+ ))
170
+ continue
171
+ try:
172
+ with kernel_json_path.open("r", encoding="utf-8") as kf:
173
+ disk_spec = json.load(kf)
174
+ if not isinstance(disk_spec, dict):
175
+ report.add(Check(
176
+ id="kernel.{}.kernel-json".format(name),
177
+ status="FAIL",
178
+ title="{}: kernel.json format".format(name),
179
+ message="kernel.json root must be a JSON object: {}".format(kernel_json_path),
180
+ details={"kernel": name, "resource_dir": resource_dir},
181
+ suggestions=["Fix the format of kernel.json or re-register this kernelspec."],
182
+ ))
183
+ continue
184
+ if not spec:
185
+ spec = disk_spec
186
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError) as exc:
187
+ report.add(Check(
188
+ id="kernel.{}.kernel-json".format(name),
189
+ status="FAIL",
190
+ title="{}: kernel.json valid".format(name),
191
+ message="kernel.json contains malformed JSON or cannot be read: {}".format(exc),
192
+ details={"kernel": name, "resource_dir": resource_dir, "error": str(exc)},
193
+ suggestions=["Fix syntax errors in kernel.json or re-register this kernelspec."],
194
+ ))
195
+ continue
196
+
197
+ argv = spec.get("argv", [])
198
+ if not isinstance(argv, list) or not argv or not isinstance(argv[0], str) or not argv[0].strip():
199
+ report.add(Check(
200
+ id="kernel.{}.argv".format(name),
201
+ status="FAIL",
202
+ title="{}: kernelspec executable".format(name),
203
+ message="The kernelspec has no valid argv[0] interpreter/launcher.",
204
+ details={"kernel": name, "argv": argv, "resource_dir": resource_dir},
205
+ suggestions=["Inspect kernel.json and reinstall or re-register this kernelspec if it is stale."],
206
+ ))
207
+ continue
208
+
209
+ spec_env = spec.get("env", {}) if isinstance(spec.get("env", {}), dict) else {}
210
+ executable = resolve_executable(argv[0], spec_env)
211
+ is_python = is_python_kernel(spec, executable)
212
+ if not executable:
213
+ report.add(Check(
214
+ id="kernel.{}.executable".format(name),
215
+ status="FAIL",
216
+ title="{}: executable exists".format(name),
217
+ message="The kernelspec executable could not be resolved: {!r}.".format(argv[0]),
218
+ details={"kernel": name, "argv0": argv[0], "language": spec.get("language"), "resource_dir": resource_dir},
219
+ suggestions=["If the environment was moved or deleted, recreate it and re-register the kernel. Otherwise inspect the argv field in this kernel's kernel.json."],
220
+ ))
221
+ continue
222
+
223
+ report.add(Check(
224
+ id="kernel.{}.executable".format(name),
225
+ status="PASS",
226
+ title="{}: executable exists".format(name),
227
+ message="Resolved kernelspec executable to {}.".format(executable),
228
+ details={"kernel": name, "argv0": argv[0], "resolved_executable": executable, "language": spec.get("language"), "display_name": spec.get("display_name")},
229
+ ))
230
+
231
+ if not is_python:
232
+ report.add(Check(
233
+ id="kernel.{}.runtime".format(name),
234
+ status="INFO",
235
+ title="{}: non-Python runtime".format(name),
236
+ message="Executable exists; Python package checks do not apply to this kernel language.",
237
+ details={"kernel": name, "language": spec.get("language", "unknown")},
238
+ ))
239
+ continue
240
+
241
+ same_as_control = bool(control_probe and _same_path(executable, str(control_probe.get("executable", ""))))
242
+ if not probe_python_kernels and not same_as_control:
243
+ report.add(Check(
244
+ id="kernel.{}.probe-skipped".format(name),
245
+ status="INFO",
246
+ title="{}: Python runtime probe skipped".format(name),
247
+ message="This kernel uses a different executable from the current control interpreter. KernelContract did not execute it because kernelspec executables can run arbitrary code.",
248
+ details={"kernel": name, "executable": executable, "control_executable": control_probe.get("executable") if control_probe else None},
249
+ suggestions=["If you trust this kernelspec and want package/runtime checks, rerun with --probe-python."],
250
+ ))
251
+ continue
252
+
253
+ probe, probe_error = probe_python(executable)
254
+ if probe_error:
255
+ report.add(Check(
256
+ id="kernel.{}.probe".format(name),
257
+ status="FAIL",
258
+ title="{}: Python can start".format(name),
259
+ message=probe_error,
260
+ details={"kernel": name, "executable": executable},
261
+ suggestions=["Run the configured interpreter directly and check whether the environment is damaged or inaccessible."],
262
+ ))
263
+ continue
264
+
265
+ kernel_probes[name] = probe
266
+ packages = probe.get("packages", {})
267
+ ipykernel = _package(packages, "ipykernel")
268
+ if ipykernel.get("installed"):
269
+ report.add(Check(
270
+ id="kernel.{}.ipykernel".format(name),
271
+ status="PASS",
272
+ title="{}: ipykernel importable".format(name),
273
+ message="Python {} starts and ipykernel is installed{}.".format(
274
+ probe.get("version", "unknown"),
275
+ " (version {})".format(ipykernel.get("version")) if ipykernel.get("version") else "",
276
+ ),
277
+ details={"kernel": name, "python": probe.get("executable"), "prefix": probe.get("prefix"), "ipykernel": ipykernel},
278
+ ))
279
+ else:
280
+ report.add(Check(
281
+ id="kernel.{}.ipykernel".format(name),
282
+ status="FAIL",
283
+ title="{}: ipykernel importable".format(name),
284
+ message="The kernel points to a Python interpreter where ipykernel was not found.",
285
+ details={"kernel": name, "python": probe.get("executable"), "prefix": probe.get("prefix")},
286
+ suggestions=["Install ipykernel into this exact environment, for example: {} -m pip install ipykernel".format(executable)],
287
+ ))
288
+
289
+ if control_probe and not _same_path(str(control_probe.get("prefix", "")), str(probe.get("prefix", ""))):
290
+ report.add(Check(
291
+ id="kernel.{}.environment-drift".format(name),
292
+ status="WARN" if provided_server_python else "INFO",
293
+ title="{}: control/kernel environments differ".format(name),
294
+ message=(
295
+ "The configured server Python and this kernel use different prefixes. This may be intentional, but server-side and kernel-side packages must be installed in the correct places."
296
+ if provided_server_python else
297
+ "This kernel uses a different Python prefix from the current CLI environment. The actual server environment was not inferred, so this does not prove a server/kernel mismatch."
298
+ ),
299
+ details={"kernel_prefix": probe.get("prefix"), "control_prefix": control_probe.get("prefix"), "server_python_explicit": provided_server_python},
300
+ suggestions=["Compare package locations in both environments. Keep separate environments if intentional, and install each dependency in the layer that uses it."],
301
+ ))
302
+
303
+ if control_probe:
304
+ control_packages = control_probe.get("packages", {})
305
+ kernel_packages = probe.get("packages", {})
306
+ if _package(control_packages, "jupyterlab").get("installed") and _package(kernel_packages, "ipywidgets").get("installed"):
307
+ frontend_widgets = _package(control_packages, "jupyterlab_widgets")
308
+ if not frontend_widgets.get("installed"):
309
+ report.add(Check(
310
+ id="kernel.{}.widgets-companion".format(name),
311
+ status="WARN",
312
+ title="{}: JupyterLab widgets companion".format(name),
313
+ message="The Python kernel has ipywidgets, but the control/server environment has no jupyterlab_widgets package. Interactive widgets may not render in JupyterLab.",
314
+ details={"kernel": name, "ipywidgets": _package(kernel_packages, "ipywidgets"), "jupyterlab": _package(control_packages, "jupyterlab")},
315
+ suggestions=["If this is the environment that runs JupyterLab, install jupyterlab_widgets there; keep ipywidgets installed in each kernel environment that uses widgets."],
316
+ ))
317
+ else:
318
+ report.add(Check(
319
+ id="kernel.{}.widgets-companion".format(name),
320
+ status="PASS",
321
+ title="{}: JupyterLab widgets companion present".format(name),
322
+ message="Both ipywidgets in the kernel and jupyterlab_widgets in the control environment are present; exact version compatibility is not certified by this check.",
323
+ details={"kernel": name, "ipywidgets": _package(kernel_packages, "ipywidgets"), "jupyterlab_widgets": frontend_widgets},
324
+ ))
325
+
326
+ if notebook_path:
327
+ notebook_data, notebook_error = _read_notebook(notebook_path)
328
+ if notebook_error:
329
+ report.add(Check(
330
+ id="notebook.read",
331
+ status="FAIL",
332
+ title="Read notebook metadata",
333
+ message=notebook_error,
334
+ details={"path": notebook_path},
335
+ suggestions=["Provide a readable .ipynb file containing valid JSON."],
336
+ ))
337
+ else:
338
+ metadata = notebook_data.get("metadata")
339
+ if not isinstance(metadata, dict):
340
+ report.add(Check(
341
+ id="notebook.metadata",
342
+ status="FAIL",
343
+ title="Notebook metadata object",
344
+ message="Notebook metadata is not an object.",
345
+ details={"path": str(Path(notebook_path).expanduser())},
346
+ suggestions=["Ensure the notebook contains a valid metadata object."],
347
+ ))
348
+ else:
349
+ raw_kernelspec = metadata.get("kernelspec")
350
+ if raw_kernelspec is not None and not isinstance(raw_kernelspec, dict):
351
+ report.add(Check(
352
+ id="notebook.kernelspec-metadata",
353
+ status="WARN",
354
+ title="Notebook kernel metadata malformed",
355
+ message="The notebook metadata.kernelspec field is not an object.",
356
+ details={"path": str(Path(notebook_path).expanduser()), "kernelspec": raw_kernelspec},
357
+ suggestions=["Open the notebook in Jupyter, select the intended kernel, and save the notebook."],
358
+ ))
359
+ elif not raw_kernelspec:
360
+ report.add(Check(
361
+ id="notebook.kernelspec-metadata",
362
+ status="WARN",
363
+ title="Notebook kernel metadata missing",
364
+ message="The notebook does not declare metadata.kernelspec; the intended kernel cannot be verified from metadata alone.",
365
+ details={"path": str(Path(notebook_path).expanduser()), "language_info": metadata.get("language_info")},
366
+ suggestions=["Open the notebook in Jupyter, select the intended kernel, save the notebook, and rerun the check."],
367
+ ))
368
+ else:
369
+ declared_name = raw_kernelspec.get("name")
370
+ display_name = raw_kernelspec.get("display_name")
371
+ if not isinstance(declared_name, str) or not declared_name.strip():
372
+ report.add(Check(
373
+ id="notebook.kernelspec-metadata",
374
+ status="WARN",
375
+ title="Notebook kernel metadata invalid name",
376
+ message="The notebook does not declare a valid metadata.kernelspec.name string; the intended kernel cannot be verified from metadata alone.",
377
+ details={"path": str(Path(notebook_path).expanduser()), "kernelspec": raw_kernelspec},
378
+ suggestions=["Open the notebook in Jupyter, select the intended kernel, save the notebook, and rerun the check."],
379
+ ))
380
+ else:
381
+ declared_name = declared_name.strip()
382
+ if declared_name not in kernels:
383
+ report.add(Check(
384
+ id="notebook.kernelspec-registered",
385
+ status="FAIL",
386
+ title="Notebook kernel is registered",
387
+ message="Notebook requests kernel {!r}, but that name is not in the discovered kernelspec list.".format(declared_name),
388
+ details={
389
+ "path": str(Path(notebook_path).expanduser()),
390
+ "requested_kernel": declared_name,
391
+ "requested_display_name": display_name,
392
+ "available_kernels": sorted(kernels.keys()),
393
+ },
394
+ suggestions=["Register the intended kernel or select an installed kernel in Jupyter and save the notebook. Do not assume a fallback kernel is equivalent."],
395
+ ))
396
+ else:
397
+ spec = kernels[declared_name].get("spec", {})
398
+ registered_display = spec.get("display_name") if isinstance(spec, dict) else None
399
+ report.add(Check(
400
+ id="notebook.kernelspec-registered",
401
+ status="PASS",
402
+ title="Notebook kernel is registered",
403
+ message="Notebook kernel {!r} is present in the discovered kernelspec list.".format(declared_name),
404
+ details={
405
+ "path": str(Path(notebook_path).expanduser()),
406
+ "requested_kernel": declared_name,
407
+ "notebook_display_name": display_name,
408
+ "registered_display_name": registered_display,
409
+ },
410
+ ))
411
+ if (
412
+ isinstance(display_name, str)
413
+ and isinstance(registered_display, str)
414
+ and display_name != registered_display
415
+ ):
416
+ report.add(Check(
417
+ id="notebook.display-name-drift",
418
+ status="WARN",
419
+ title="Notebook kernel display name differs",
420
+ message="Notebook metadata says {!r}, while the registered kernelspec displays {!r}. The kernel name is registered, but the display name may have changed.".format(
421
+ display_name, registered_display
422
+ ),
423
+ details={
424
+ "notebook_display_name": display_name,
425
+ "registered_display_name": registered_display,
426
+ },
427
+ suggestions=["Confirm the notebook still uses the intended kernel; update metadata only after verifying the runtime."],
428
+ ))
429
+
430
+ if not report.checks:
431
+ report.add(Check(id="report.empty", status="INFO", title="No checks run", message="No diagnostics were collected."))
432
+ return report
@@ -0,0 +1,249 @@
1
+ """Read-only discovery of Jupyter kernelspecs and safe interpreter probes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from string import Template
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+
14
+
15
+ PYTHON_LANGUAGE_NAMES = {"python", "python2", "python3"}
16
+
17
+
18
+ def _expanded_env(spec_env: Dict[str, Any]) -> Dict[str, str]:
19
+ env = os.environ.copy()
20
+ entries = {
21
+ key: value
22
+ for key, value in spec_env.items()
23
+ if isinstance(key, str) and isinstance(value, str)
24
+ }
25
+ # Kernelspec env entries are added to the parent environment. Expand only
26
+ # values declared by the kernelspec; applying Template to every inherited
27
+ # variable could fail on unrelated values containing a literal '$'.
28
+ env.update(entries)
29
+ for key, value in entries.items():
30
+ try:
31
+ env[key] = Template(value).safe_substitute(env)
32
+ except ValueError:
33
+ # Treat malformed template syntax as a literal environment value.
34
+ env[key] = value
35
+ return env
36
+
37
+
38
+ def resolve_executable(argv0: str, spec_env: Optional[Dict[str, Any]] = None) -> Optional[str]:
39
+ """Resolve the first kernelspec argv item without invoking a shell."""
40
+ if not argv0 or not isinstance(argv0, str):
41
+ return None
42
+ cleaned = argv0.strip()
43
+ if (cleaned.startswith('"') and cleaned.endswith('"')) or (cleaned.startswith("'") and cleaned.endswith("'")):
44
+ cleaned = cleaned[1:-1].strip()
45
+ if not cleaned:
46
+ return None
47
+
48
+ env = _expanded_env(spec_env or {})
49
+ try:
50
+ expanded = Template(os.path.expanduser(cleaned)).safe_substitute(env)
51
+ except ValueError:
52
+ expanded = os.path.expanduser(cleaned)
53
+ # Expand conventional $VAR and %VAR% forms as well. Template above handles
54
+ # variables added by the kernelspec; expandvars handles platform syntax.
55
+ expanded = os.path.expandvars(expanded)
56
+ has_path_component = os.path.isabs(expanded) or os.sep in expanded or (os.altsep and os.altsep in expanded)
57
+ if has_path_component:
58
+ candidate = Path(expanded)
59
+ try:
60
+ if candidate.is_file() and (os.name == "nt" or os.access(str(candidate), os.X_OK)):
61
+ return str(candidate.resolve())
62
+ except OSError:
63
+ pass
64
+ # On Windows, path components may omit .exe; check PATHEXT with normalized slashes
65
+ if os.name == "nt":
66
+ norm_expanded = os.path.normpath(expanded)
67
+ pathext = [ext for ext in os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") if ext]
68
+ for ext in pathext:
69
+ target = Path(norm_expanded + ext if not norm_expanded.lower().endswith(ext.lower()) else norm_expanded)
70
+ try:
71
+ if target.is_file():
72
+ return str(target.resolve())
73
+ except OSError:
74
+ pass
75
+ which_path = shutil.which(norm_expanded)
76
+ if which_path:
77
+ return str(Path(which_path).resolve())
78
+ else:
79
+ which_path = shutil.which(expanded)
80
+ if which_path:
81
+ return str(Path(which_path).resolve())
82
+ return None
83
+ resolved = shutil.which(expanded, path=env.get("PATH"))
84
+ return str(Path(resolved).resolve()) if resolved else None
85
+
86
+
87
+ def discover_kernelspecs(timeout: float = 15.0) -> Tuple[Dict[str, Dict[str, Any]], Optional[str]]:
88
+ """Return {name: {resource_dir, spec}} using Jupyter's supported JSON CLI."""
89
+ jupyter = shutil.which("jupyter")
90
+ if not jupyter:
91
+ return {}, (
92
+ "Could not find the 'jupyter' command on PATH. Run KernelContract in the "
93
+ "environment that provides Jupyter, then try again."
94
+ )
95
+ try:
96
+ proc = subprocess.run(
97
+ [jupyter, "kernelspec", "list", "--json"],
98
+ capture_output=True,
99
+ text=True,
100
+ encoding="utf-8",
101
+ errors="replace",
102
+ timeout=timeout,
103
+ check=False,
104
+ shell=False,
105
+ )
106
+ except subprocess.TimeoutExpired:
107
+ return {}, "'jupyter kernelspec list --json' timed out after {} seconds.".format(timeout)
108
+ except OSError as exc:
109
+ return {}, "Could not run Jupyter's kernelspec command: {}".format(exc)
110
+
111
+ if proc.returncode != 0:
112
+ detail = (proc.stderr or proc.stdout or "no diagnostic text").strip()
113
+ return {}, "Jupyter could not list kernelspecs (exit {}). {}".format(proc.returncode, detail[:800])
114
+ raw_stdout = proc.stdout.strip()
115
+ try:
116
+ payload = json.loads(raw_stdout)
117
+ except json.JSONDecodeError as exc:
118
+ # Some Jupyter plugins or logging hooks print warnings to stdout; attempt to locate JSON boundaries
119
+ start = raw_stdout.find("{")
120
+ end = raw_stdout.rfind("}")
121
+ if start != -1 and end != -1 and end > start:
122
+ try:
123
+ payload = json.loads(raw_stdout[start : end + 1])
124
+ except json.JSONDecodeError:
125
+ return {}, "Jupyter returned invalid JSON for kernelspecs: {}".format(exc)
126
+ else:
127
+ return {}, "Jupyter returned invalid JSON for kernelspecs: {}".format(exc)
128
+
129
+ if not isinstance(payload, dict):
130
+ return {}, "Jupyter returned an unexpected JSON root structure: expected an object, got {}.".format(
131
+ type(payload).__name__
132
+ )
133
+
134
+ if "kernelspecs" not in payload:
135
+ return {}, "Jupyter's kernelspec JSON is missing the required 'kernelspecs' field."
136
+
137
+ raw_specs = payload["kernelspecs"]
138
+ if not isinstance(raw_specs, dict):
139
+ return {}, "Jupyter's kernelspec JSON did not contain a 'kernelspecs' object (got {}).".format(
140
+ type(raw_specs).__name__
141
+ )
142
+
143
+ result: Dict[str, Dict[str, Any]] = {}
144
+ for name, entry in raw_specs.items():
145
+ if not isinstance(name, str) or not isinstance(entry, dict):
146
+ continue
147
+ spec = entry.get("spec")
148
+ if not isinstance(spec, dict):
149
+ spec = {}
150
+ result[name] = {
151
+ "resource_dir": entry.get("resource_dir"),
152
+ "spec": spec,
153
+ }
154
+ return result, None
155
+
156
+
157
+ _PROBE_SENTINEL = "__KERNELCONTRACT_PROBE_JSON__:"
158
+
159
+
160
+ def probe_python(interpreter: str, timeout: float = 8.0) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
161
+ """Run a short, read-only probe in an interpreter; never install or modify packages."""
162
+ code = r'''
163
+ import importlib.metadata as md
164
+ import importlib.util
165
+ import json
166
+ import sys
167
+
168
+ def package(name):
169
+ try:
170
+ version = md.version(name)
171
+ return {"installed": True, "version": version}
172
+ except Exception:
173
+ try:
174
+ installed = importlib.util.find_spec(name.replace("-", "_")) is not None
175
+ except Exception:
176
+ installed = False
177
+ return {"installed": installed, "version": None}
178
+
179
+ payload = {
180
+ "executable": sys.executable,
181
+ "prefix": sys.prefix,
182
+ "base_prefix": sys.base_prefix,
183
+ "version": ".".join(map(str, sys.version_info[:3])),
184
+ "packages": {
185
+ "ipykernel": package("ipykernel"),
186
+ "ipywidgets": package("ipywidgets"),
187
+ "jupyterlab": package("jupyterlab"),
188
+ "jupyterlab_widgets": package("jupyterlab_widgets"),
189
+ "notebook": package("notebook"),
190
+ "widgetsnbextension": package("widgetsnbextension"),
191
+ },
192
+ }
193
+ print("__KERNELCONTRACT_PROBE_JSON__:" + json.dumps(payload))
194
+ '''
195
+ try:
196
+ proc = subprocess.run(
197
+ [interpreter, "-c", code],
198
+ capture_output=True,
199
+ text=True,
200
+ encoding="utf-8",
201
+ errors="replace",
202
+ timeout=timeout,
203
+ check=False,
204
+ shell=False,
205
+ )
206
+ except subprocess.TimeoutExpired:
207
+ return None, "Python probe timed out after {} seconds.".format(timeout)
208
+ except OSError as exc:
209
+ return None, "Could not start interpreter: {}".format(exc)
210
+ if proc.returncode != 0:
211
+ detail = (proc.stderr or proc.stdout or "no diagnostic text").strip()
212
+ return None, "Interpreter probe failed (exit {}). {}".format(proc.returncode, detail[:800])
213
+
214
+ lines = proc.stdout.strip().splitlines()
215
+ json_text: Optional[str] = None
216
+ for line in reversed(lines):
217
+ stripped = line.strip()
218
+ if stripped.startswith(_PROBE_SENTINEL):
219
+ json_text = stripped[len(_PROBE_SENTINEL):]
220
+ break
221
+ if json_text is None and lines:
222
+ json_text = lines[-1].strip()
223
+
224
+ if not json_text:
225
+ return None, "Interpreter returned empty probe output."
226
+
227
+ try:
228
+ result = json.loads(json_text)
229
+ except json.JSONDecodeError as exc:
230
+ return None, "Interpreter returned unexpected probe output: {}".format(exc)
231
+ return result, None
232
+
233
+
234
+ def is_python_kernel(spec: Dict[str, Any], resolved_executable: Optional[str]) -> bool:
235
+ language = str(spec.get("language", "")).strip().lower()
236
+ if language in PYTHON_LANGUAGE_NAMES:
237
+ return True
238
+ if language:
239
+ return False
240
+ # If language was omitted from kernelspec, inspect argv and executable
241
+ argv = spec.get("argv", [])
242
+ if isinstance(argv, list):
243
+ for arg in argv:
244
+ if isinstance(arg, str) and ("ipykernel" in arg.lower() or "ipykernel_launcher" in arg.lower()):
245
+ return True
246
+ if not resolved_executable:
247
+ return False
248
+ name = Path(resolved_executable).name.lower()
249
+ return name.startswith(("python", "pypy"))
@@ -0,0 +1,66 @@
1
+ """Small, dependency-free data models for diagnostic results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any, Dict, List
7
+
8
+
9
+ VALID_STATUSES = {"PASS", "INFO", "WARN", "FAIL"}
10
+
11
+
12
+ @dataclass
13
+ class Check:
14
+ """One diagnostic observation."""
15
+
16
+ id: str
17
+ status: str
18
+ title: str
19
+ message: str
20
+ details: Dict[str, Any] = field(default_factory=dict)
21
+ suggestions: List[str] = field(default_factory=list)
22
+
23
+ def __post_init__(self) -> None:
24
+ self.status = self.status.upper()
25
+ if self.status not in VALID_STATUSES:
26
+ raise ValueError("status must be one of PASS, INFO, WARN, FAIL")
27
+
28
+ def to_dict(self) -> Dict[str, Any]:
29
+ return asdict(self)
30
+
31
+
32
+ @dataclass
33
+ class Report:
34
+ """A complete diagnostic report."""
35
+
36
+ command: str
37
+ checks: List[Check] = field(default_factory=list)
38
+ schema_version: str = "1.0"
39
+
40
+ def add(self, check: Check) -> None:
41
+ self.checks.append(check)
42
+
43
+ @property
44
+ def counts(self) -> Dict[str, int]:
45
+ return {
46
+ status.lower(): sum(1 for check in self.checks if check.status == status)
47
+ for status in ("PASS", "INFO", "WARN", "FAIL")
48
+ }
49
+
50
+ @property
51
+ def overall_status(self) -> str:
52
+ if any(check.status == "FAIL" for check in self.checks):
53
+ return "FAIL"
54
+ if any(check.status == "WARN" for check in self.checks):
55
+ return "WARN"
56
+ return "PASS"
57
+
58
+ def to_dict(self) -> Dict[str, Any]:
59
+ return {
60
+ "schema_version": self.schema_version,
61
+ "tool": "kernelcontract",
62
+ "command": self.command,
63
+ "status": self.overall_status,
64
+ "summary": self.counts,
65
+ "checks": [check.to_dict() for check in self.checks],
66
+ }
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: kernelcontract
3
+ Version: 0.1.0a3
4
+ Summary: Read-only diagnostics for Jupyter kernels and Python environments
5
+ Author: KernelContract contributors
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 KernelContract contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Keywords: jupyter,notebook,kernel,diagnostics,python-environments
29
+ Classifier: Development Status :: 3 - Alpha
30
+ Classifier: Environment :: Console
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Operating System :: OS Independent
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Programming Language :: Python :: 3.13
39
+ Classifier: Topic :: Scientific/Engineering
40
+ Classifier: Topic :: Software Development :: Debuggers
41
+ Classifier: Topic :: Utilities
42
+ Requires-Python: >=3.10
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Provides-Extra: test
46
+ Requires-Dist: pytest>=8.0; extra == "test"
47
+ Dynamic: license-file
48
+
49
+ # KernelContract
50
+
51
+ **Local-first, read-only diagnostics for Jupyter kernels, kernelspecs, and notebook environments.**
52
+
53
+ KernelContract inspects registered Jupyter kernelspecs, checks whether their configured executables resolve, probes Python kernel environments, and compares notebook kernel metadata with the kernels available to the current Jupyter CLI. It reports likely configuration mismatches, missing interpreters, and safe next steps without modifying the machine.
54
+
55
+ > **Alpha Software — Candidate 0.1.0a3 (Latest tagged pre-release: [v0.1.0a2](https://github.com/Ayushxsingh100/kernelcontract/releases/tag/v0.1.0a2)).** KernelContract is an early-stage diagnostic tool under active development. The latest published GitHub pre-release is `v0.1.0a2`, and the current default branch is preparing candidate `0.1.0a3` for PyPI Trusted Publishing. It has **not** yet been published to PyPI. The project name is provisional and has not been trademark- or registry-cleared.
56
+
57
+ ---
58
+
59
+ ## Quick start
60
+
61
+ ### Prerequisites
62
+
63
+ * **Python 3.10 or newer.**
64
+ * **Jupyter CLI on PATH:** KernelContract has zero external runtime dependencies (it uses the Python standard library only), but it requires that the `jupyter` command is available on your `PATH` (such as via `jupyter-core`) to discover registered kernels via `jupyter kernelspec list --json`.
65
+
66
+ ### End-user installation (pre-release)
67
+
68
+ Install the tagged alpha pre-release directly from GitHub using `pip`:
69
+
70
+ ```bash
71
+ python -m pip install "git+https://github.com/Ayushxsingh100/kernelcontract.git@v0.1.0a3"
72
+ ```
73
+
74
+ Or install the latest development snapshot from the default `master` branch:
75
+
76
+ ```bash
77
+ python -m pip install "git+https://github.com/Ayushxsingh100/kernelcontract.git"
78
+ ```
79
+
80
+ > **Note on PyPI:** The command `pip install kernelcontract` is reserved for future distribution once the package is officially published to the Python Package Index.
81
+
82
+ ---
83
+
84
+ ## Core usage
85
+
86
+ ### 1. Diagnose registered Jupyter kernels
87
+
88
+ Run the `doctor` command to inspect all discovered kernelspecs, verify their executables, and check the active Python environment:
89
+
90
+ ```bash
91
+ kernelcontract doctor
92
+ ```
93
+
94
+ ### 2. Validate notebook kernel metadata
95
+
96
+ Inspect whether a `.ipynb` file's declared kernel exists and matches currently registered kernelspecs:
97
+
98
+ ```bash
99
+ kernelcontract notebook path/to/notebook.ipynb
100
+ ```
101
+
102
+ If the notebook requests an unavailable kernel or if the kernel's stored display name has drifted from disk, KernelContract reports the issue with actionable next steps.
103
+
104
+ ### 3. Machine-readable JSON output
105
+
106
+ Produce structured, versioned JSON suitable for automated tooling, pre-commit hooks, or CI pipelines:
107
+
108
+ ```bash
109
+ kernelcontract doctor --json
110
+ kernelcontract notebook path/to/notebook.ipynb --json
111
+ ```
112
+
113
+ The JSON schema output includes `schema_version`, `status` (`PASS`, `WARN`, `FAIL`), check details, and specific remediation suggestions.
114
+
115
+ ### 4. Strict CI mode
116
+
117
+ By default, `FAIL` makes the command exit with status 1, while warnings exit with status 0. To fail CI on warnings as well as failures, pass `--strict`:
118
+
119
+ ```bash
120
+ kernelcontract doctor --strict
121
+ ```
122
+
123
+ ### 5. Probing external Python kernels (opt-in)
124
+
125
+ ```bash
126
+ kernelcontract doctor --probe-python
127
+ ```
128
+
129
+ * **Security Note:** By default, KernelContract only executes package inspection on the current control interpreter and kernels that resolve to the exact same executable. Interpreters outside the current environment are **not** executed unless you pass `--probe-python`. Use `--probe-python` only with kernelspecs and environments you trust.
130
+
131
+ ---
132
+
133
+ ## What it checks
134
+
135
+ * **CLI Discovery:** Whether the Jupyter CLI can enumerate kernelspecs using `jupyter kernelspec list --json`.
136
+ * **JSON Structure Safety:** Whether kernelspec outputs have valid root structures and dictionary objects.
137
+ * **Executable Resolution:** Whether each registered kernelspec has a resolvable `argv[0]` executable (including Windows `PATHEXT` resolution for `.exe`, `.cmd`, `.bat`, quoted paths with spaces, and environment variable expansion).
138
+ * **Environment Integrity:** Whether the control Python (and, when opted in, external Python kernels) can start and whether `ipykernel` is installed in that interpreter.
139
+ * **Environment Drift:** Whether a probed kernel's Python prefix differs from the control interpreter.
140
+ * **Widget Companion Check:** A narrow check for presence of `jupyterlab_widgets` when the control environment contains JupyterLab and a probed kernel contains `ipywidgets`.
141
+ * **Notebook Metadata Check:** Whether a notebook file declares a kernel name that is currently registered and whether the recorded display name matches the kernelspec on disk.
142
+
143
+ ---
144
+
145
+ ## Server vs. kernel environments
146
+
147
+ KernelContract cannot reliably infer which Python interpreter belongs to an already-running Jupyter server across diverse setups (e.g. systemd services, Docker containers, remote SSH tunnels, JupyterHub). By default, it uses the Python interpreter launching KernelContract as its **control environment**.
148
+
149
+ If you know the specific Python interpreter running the Jupyter server, pass it explicitly:
150
+
151
+ ```bash
152
+ kernelcontract doctor --server-python /absolute/path/to/server/python
153
+ ```
154
+
155
+ ---
156
+
157
+ ## Safety and privacy guarantees
158
+
159
+ * **Strictly Read-Only:** KernelContract modifies no packages, rewrites no files, and changes no configuration.
160
+ * **No Telemetry:** Zero analytics, telemetry, or network calls are made by KernelContract.
161
+ * **Bounded Subprocesses:** Subprocess probes use strict execution timeouts to avoid hanging indefinitely.
162
+ * **No Shell Execution:** Commands are executed directly with argument lists (`shell=False`).
163
+
164
+ ---
165
+
166
+ ## Development
167
+
168
+ To contribute or run the test suite locally from source:
169
+
170
+ ```bash
171
+ git clone https://github.com/Ayushxsingh100/kernelcontract.git
172
+ cd kernelcontract
173
+ python -m venv .venv
174
+ # Activate .venv (Windows: .venv\Scripts\activate | Unix: source .venv/bin/activate)
175
+ python -m pip install -e ".[test]"
176
+ python -m unittest discover -s tests -v
177
+ python -m pytest -q
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Status
183
+
184
+ * **Version:** `0.1.0a3` (alpha candidate; latest tagged pre-release: [`v0.1.0a2`](https://github.com/Ayushxsingh100/kernelcontract/releases/tag/v0.1.0a2))
185
+ * **Repository:** [https://github.com/Ayushxsingh100/kernelcontract](https://github.com/Ayushxsingh100/kernelcontract)
186
+ * **Pre-release Notes:** [v0.1.0a2 Release](https://github.com/Ayushxsingh100/kernelcontract/releases/tag/v0.1.0a2)
187
+ * **License:** [MIT](LICENSE)
188
+ * **Stability:** Alpha
@@ -0,0 +1,12 @@
1
+ kernelcontract/__init__.py,sha256=GCWDqMBzU3yXXNJ2PpFH-NDk6uaeBzIqvXIUi2By5x0,114
2
+ kernelcontract/__main__.py,sha256=fpwbU4p9ugfnYvX5e0tIBc20l6JdbVlLs_94qi3K-WE,73
3
+ kernelcontract/cli.py,sha256=OFMzheFbVDazJhhfLE8YMegWwma7EU4dopEbDtwC0qI,3815
4
+ kernelcontract/diagnostics.py,sha256=d_fsfejCNYQzZDaryTLDAt2hKKv2SqtbYXf4jvBMgFI,24118
5
+ kernelcontract/discovery.py,sha256=tprzEdfp1LAZAW4NFTNdYbkViRkGpAm-154E7WJ8IuA,9467
6
+ kernelcontract/models.py,sha256=5fvxxLxMxXqk8W99BlJ3fr_J0Z3wIrjljGeWr67H-Zo,1821
7
+ kernelcontract-0.1.0a3.dist-info/licenses/LICENSE,sha256=Ld4N2ot7ms3UbMdBMw_6R1SVDxps3hcuHxl7RBbaPk8,1084
8
+ kernelcontract-0.1.0a3.dist-info/METADATA,sha256=zPDC4GcxkDbatj3szZe7WLAMXh8UhK8iT3Ou9woyAO0,8720
9
+ kernelcontract-0.1.0a3.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ kernelcontract-0.1.0a3.dist-info/entry_points.txt,sha256=01ZeuxIGq6Zhr60RIEdFORQWZegp0eX4xYUaegU_qhY,65
11
+ kernelcontract-0.1.0a3.dist-info/top_level.txt,sha256=MyCSo3pNdngRsBz-TgDfxSUnR2vJw7aV6HY2eqZB_rs,15
12
+ kernelcontract-0.1.0a3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kernelcontract = kernelcontract.cli:entrypoint
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KernelContract contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ kernelcontract