jupyter-kernel-cli 0.1.0__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.
- jupyter_kernel_cli-0.1.0.dist-info/METADATA +198 -0
- jupyter_kernel_cli-0.1.0.dist-info/RECORD +9 -0
- jupyter_kernel_cli-0.1.0.dist-info/WHEEL +5 -0
- jupyter_kernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
- jupyter_kernel_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- jupyter_kernel_cli-0.1.0.dist-info/top_level.txt +1 -0
- jupyter_kernel_client/__init__.py +21 -0
- jupyter_kernel_client/cli.py +276 -0
- jupyter_kernel_client/client.py +239 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jupyter-kernel-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-friendly CLI and Python API for talking to existing Jupyter kernels.
|
|
5
|
+
Home-page: https://github.com/hruskamiro/jupyter-kernel-client
|
|
6
|
+
Author: Miroslav Hruska
|
|
7
|
+
Author-email: hruska.miro@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/hruskamiro/jupyter-kernel-client
|
|
10
|
+
Project-URL: Repository, https://github.com/hruskamiro/jupyter-kernel-client
|
|
11
|
+
Project-URL: Issues, https://github.com/hruskamiro/jupyter-kernel-client/issues
|
|
12
|
+
Keywords: jupyter,kernel,cli,automation,agent
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
25
|
+
Classifier: Topic :: Software Development
|
|
26
|
+
Classifier: Topic :: Utilities
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: jupyter-client>=7.0
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
33
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# jupyter-kernel-client
|
|
37
|
+
|
|
38
|
+
`jk` is a small command-line tool and Python library for **executing code in an existing Jupyter kernel**.
|
|
39
|
+
|
|
40
|
+
It is designed for scripts and AI agents that need a **stable, machine-readable way to inspect or modify a live Python session**.
|
|
41
|
+
|
|
42
|
+
## Intended Use Case
|
|
43
|
+
|
|
44
|
+
You are working in an IPython console, Spyder console, notebook kernel, or other Jupyter-backed Python session. **The session already has important state loaded:** imports, data frames, models, helper functions, configuration, intermediate results, and whatever else you have built up interactively.
|
|
45
|
+
|
|
46
|
+
**Instead of asking an AI agent to recreate that state from scratch, give it access to the existing kernel.** Export or copy the kernel connection information, tell the agent to use `jk`, and let it inspect variables, run experiments, evaluate expressions, and return structured output from the same live Python process you are using.
|
|
47
|
+
|
|
48
|
+
The workflow is:
|
|
49
|
+
|
|
50
|
+
1. **Work normally** in an IPython, Spyder, notebook, or other Jupyter-backed console.
|
|
51
|
+
2. **Load the state you care about:** data, objects, functions, imports, models, and intermediate results.
|
|
52
|
+
3. **Copy the active kernel connection info**, for example with `%connect_info`.
|
|
53
|
+
4. **Give that connection info to Codex or another agent.**
|
|
54
|
+
5. **Tell the agent to use `jk`** to connect to that exact kernel.
|
|
55
|
+
6. **Let the agent inspect and experiment** with `jk exec`, `jk eval`, `jk get`, and `jk vars`.
|
|
56
|
+
|
|
57
|
+
This is useful when the hard part is **not writing code from a blank environment**, but **exploring and manipulating the state that already exists in a live session**.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
Recommended:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pipx install jupyter-kernel-cli
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Other install paths:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pipx install git+https://github.com/hruskamiro/jupyter-kernel-client.git
|
|
71
|
+
pipx upgrade jupyter-kernel-cli
|
|
72
|
+
pipx install --force .
|
|
73
|
+
python -m pip install -e ".[dev]"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Check:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
jk --help
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Usage
|
|
83
|
+
|
|
84
|
+
Common commands:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
jk kernels
|
|
88
|
+
jk kernels --probe
|
|
89
|
+
jk exec -f /path/to/kernel.json "x = 41"
|
|
90
|
+
jk eval -f /path/to/kernel.json "x + 1"
|
|
91
|
+
jk get -f /path/to/kernel.json x
|
|
92
|
+
jk vars -f /path/to/kernel.json --json
|
|
93
|
+
jk demo -f /path/to/kernel.json --json
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Use **JSON for agents** and stdin for larger generated code:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
cat <<'PY' | jk exec -f /path/to/kernel.json --json --stdin
|
|
100
|
+
import pandas as pd
|
|
101
|
+
|
|
102
|
+
summary = {
|
|
103
|
+
"variables": sorted(name for name in globals() if not name.startswith("_")),
|
|
104
|
+
"answer": 6 * 7,
|
|
105
|
+
}
|
|
106
|
+
summary
|
|
107
|
+
PY
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Other supported forms:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
jk exec -f /path/to/kernel.json --file script.py
|
|
114
|
+
jk -f /path/to/kernel.json --json eval "x + 1"
|
|
115
|
+
export JK_CONNECTION_FILE=/path/to/kernel.json
|
|
116
|
+
jk eval "df.shape"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The JSON response includes status, stdout, stderr, rich display outputs, final `text/plain` result, parsed Python literal when possible, traceback details, elapsed time, timeout state, and message id.
|
|
120
|
+
|
|
121
|
+
## Using `jk` from Codex
|
|
122
|
+
|
|
123
|
+
One working pattern is to start Codex with workspace sandboxing and on-request approvals:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
codex -s workspace-write -a on-request
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The exact command and permission flow may change across Codex versions, approval policies, sandbox settings, and local configuration. The important point is that Codex may need permission to run `jk` outside its command sandbox so it can open the local Jupyter kernel connection.
|
|
130
|
+
|
|
131
|
+
Give Codex the **active kernel connection information**. You can copy it from
|
|
132
|
+
`%connect_info`, or use
|
|
133
|
+
[`spyder-copy-current`](https://github.com/hruskamiro/spyder-copy-current) and
|
|
134
|
+
press `Ctrl+Alt+K` in Spyder to copy the current console's connection
|
|
135
|
+
information.
|
|
136
|
+
|
|
137
|
+
Ask Codex explicitly to **run `jk` outside its command sandbox**. For example:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
Connect to this exact Jupyter kernel using jk. Run jk with an elevated request
|
|
141
|
+
and ask for reusable approval of the jk executable prefix.
|
|
142
|
+
|
|
143
|
+
<paste the kernel connection information here>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Approve the permission request shown by Codex. If the interface offers reusable command-prefix approval, scope it to the resolved `jk` executable, which can be found with `command -v jk`.
|
|
147
|
+
|
|
148
|
+
**Approving `jk` allows arbitrary code execution in the connected Jupyter
|
|
149
|
+
kernel.** Treat this as execution access to that live Python session, even when
|
|
150
|
+
the rest of the Codex session remains workspace-sandboxed.
|
|
151
|
+
|
|
152
|
+
## JSON Contract
|
|
153
|
+
|
|
154
|
+
Successful JSON responses include:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"status": "ok",
|
|
159
|
+
"ok": true,
|
|
160
|
+
"execution_count": 12,
|
|
161
|
+
"stdout": "",
|
|
162
|
+
"stderr": "",
|
|
163
|
+
"outputs": [],
|
|
164
|
+
"result_text": "42",
|
|
165
|
+
"result_python": 42,
|
|
166
|
+
"ename": null,
|
|
167
|
+
"evalue": null,
|
|
168
|
+
"traceback": [],
|
|
169
|
+
"elapsed_seconds": 0.01,
|
|
170
|
+
"timed_out": false,
|
|
171
|
+
"msg_id": "..."
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Exit codes:
|
|
176
|
+
|
|
177
|
+
```text
|
|
178
|
+
0 kernel execution succeeded
|
|
179
|
+
1 kernel execution raised an error
|
|
180
|
+
2 client or argument error
|
|
181
|
+
124 client timed out waiting for the kernel
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Timeouts only stop the client wait. With only a connection file, `jk` cannot reliably kill or interrupt an arbitrary kernel process, so timed-out execution may continue in the kernel.
|
|
185
|
+
|
|
186
|
+
## Python API
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from jupyter_kernel_client import execute, eval_expression, get_variable
|
|
190
|
+
|
|
191
|
+
response = eval_expression("/path/to/kernel.json", "x + 1")
|
|
192
|
+
if response.ok:
|
|
193
|
+
print(response.result_python)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
jupyter_kernel_cli-0.1.0.dist-info/licenses/LICENSE,sha256=d_PcsgZbFuE93GSCHhXlJzl3kG__dhFQAUSsE0MSwnA,1072
|
|
2
|
+
jupyter_kernel_client/__init__.py,sha256=WRUdSC17b9Ol_dO33yJayHcBOPvMcrvW5nzoEr8PL6E,387
|
|
3
|
+
jupyter_kernel_client/cli.py,sha256=mpnS43xK7USguocwl83MFcfopPzwBJLVld4tSYkUL3w,10779
|
|
4
|
+
jupyter_kernel_client/client.py,sha256=02_QMLedtbN0FPiH8Vw6jifYW--s_04HefYgGgzcOtw,7678
|
|
5
|
+
jupyter_kernel_cli-0.1.0.dist-info/METADATA,sha256=pRCowl_TG21n1WW4z94ecO4lBe_ZtJyyy9gGXF78DoY,6633
|
|
6
|
+
jupyter_kernel_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
jupyter_kernel_cli-0.1.0.dist-info/entry_points.txt,sha256=DfFgYmRuw95UCD8nZVMS01_2QSnNbCFZrfBAvUlIVqc,54
|
|
8
|
+
jupyter_kernel_cli-0.1.0.dist-info/top_level.txt,sha256=rd_e7DJIJyUsPY3s7sVcqqmBzO3XqyA7RiUoRARzxRM,22
|
|
9
|
+
jupyter_kernel_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miroslav Hruska
|
|
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
|
+
jupyter_kernel_client
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Small client for executing code in an existing Jupyter kernel."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
KernelClientError,
|
|
5
|
+
KernelResponse,
|
|
6
|
+
RichOutput,
|
|
7
|
+
execute,
|
|
8
|
+
eval_expression,
|
|
9
|
+
get_variable,
|
|
10
|
+
kernel_is_ready,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"KernelClientError",
|
|
15
|
+
"KernelResponse",
|
|
16
|
+
"RichOutput",
|
|
17
|
+
"execute",
|
|
18
|
+
"eval_expression",
|
|
19
|
+
"get_variable",
|
|
20
|
+
"kernel_is_ready",
|
|
21
|
+
]
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, List, Optional
|
|
9
|
+
|
|
10
|
+
from jupyter_core.paths import jupyter_runtime_dir
|
|
11
|
+
|
|
12
|
+
from .client import KernelClientError, KernelResponse, execute, eval_expression, get_variable, kernel_is_ready
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _default_connection_file() -> Optional[str]:
|
|
16
|
+
return os.environ.get("JK_CONNECTION_FILE") or os.environ.get("JUPYTER_CONNECTION_FILE")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _runtime_connection_files(limit: Optional[int] = None) -> List[Path]:
|
|
20
|
+
runtime = Path(jupyter_runtime_dir()).expanduser()
|
|
21
|
+
if not runtime.exists():
|
|
22
|
+
return []
|
|
23
|
+
files = sorted(runtime.glob("kernel-*.json"), key=lambda path: path.stat().st_mtime, reverse=True)
|
|
24
|
+
if limit is not None:
|
|
25
|
+
return files[:limit]
|
|
26
|
+
return files
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _kernel_record(path: Path, *, probe: bool, timeout: float) -> dict:
|
|
30
|
+
record = {
|
|
31
|
+
"path": str(path),
|
|
32
|
+
"modified": path.stat().st_mtime,
|
|
33
|
+
"alive": None,
|
|
34
|
+
"error": None,
|
|
35
|
+
}
|
|
36
|
+
if not probe:
|
|
37
|
+
return record
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
kernel_is_ready(str(path), timeout=timeout)
|
|
41
|
+
record["alive"] = True
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
record["alive"] = False
|
|
44
|
+
record["error"] = str(exc)
|
|
45
|
+
return record
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_code(args: argparse.Namespace) -> str:
|
|
49
|
+
sources = [bool(args.code), bool(args.file), bool(args.stdin)]
|
|
50
|
+
if sum(sources) != 1:
|
|
51
|
+
raise KernelClientError("Provide exactly one code source: argument, --file, or --stdin.")
|
|
52
|
+
if args.stdin:
|
|
53
|
+
return sys.stdin.read()
|
|
54
|
+
if args.file:
|
|
55
|
+
return Path(args.file).expanduser().read_text()
|
|
56
|
+
return args.code
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _print_human(response: KernelResponse) -> int:
|
|
60
|
+
if response.stdout:
|
|
61
|
+
sys.stdout.write(response.stdout)
|
|
62
|
+
if response.stderr:
|
|
63
|
+
sys.stderr.write(response.stderr)
|
|
64
|
+
|
|
65
|
+
if response.status == "timeout":
|
|
66
|
+
sys.stderr.write(f"Timed out after {response.elapsed_seconds:.2f}s. Kernel execution may still be running.\n")
|
|
67
|
+
return 124
|
|
68
|
+
|
|
69
|
+
if response.status == "error":
|
|
70
|
+
if response.traceback:
|
|
71
|
+
sys.stderr.write("\n".join(response.traceback) + "\n")
|
|
72
|
+
elif response.ename or response.evalue:
|
|
73
|
+
sys.stderr.write(f"{response.ename}: {response.evalue}\n")
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
if response.result_text is not None:
|
|
77
|
+
sys.stdout.write(f"{response.result_text}\n")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _emit_response(response: KernelResponse, *, json_output: bool) -> int:
|
|
82
|
+
if json_output:
|
|
83
|
+
print(json.dumps(response.to_dict(), ensure_ascii=True))
|
|
84
|
+
if response.status == "timeout":
|
|
85
|
+
return 124
|
|
86
|
+
return 0 if response.ok else 1
|
|
87
|
+
return _print_human(response)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_connection_file(args: argparse.Namespace) -> str:
|
|
91
|
+
connection_file = args.connection_file or _default_connection_file()
|
|
92
|
+
if connection_file:
|
|
93
|
+
return str(Path(connection_file).expanduser())
|
|
94
|
+
raise KernelClientError(
|
|
95
|
+
"No connection file provided. Use --connection-file, JK_CONNECTION_FILE, "
|
|
96
|
+
"JUPYTER_CONNECTION_FILE, or run `jk kernels` to list candidates."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _add_common_options(parser: argparse.ArgumentParser) -> None:
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--connection-file",
|
|
103
|
+
"-f",
|
|
104
|
+
help="Path to a Jupyter kernel connection JSON file. Defaults to JK_CONNECTION_FILE or JUPYTER_CONNECTION_FILE.",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument("--timeout", type=float, default=30.0, help="Seconds to wait for execution completion.")
|
|
107
|
+
parser.add_argument("--json", action="store_true", help="Emit stable structured JSON output.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _vars_code(*, include_private: bool, repr_length: int) -> str:
|
|
111
|
+
private_check = "True" if include_private else "not name.startswith('_')"
|
|
112
|
+
return f"""
|
|
113
|
+
[
|
|
114
|
+
{{
|
|
115
|
+
"name": name,
|
|
116
|
+
"type": type(value).__module__ + "." + type(value).__qualname__,
|
|
117
|
+
"repr": repr(value)[:{repr_length}],
|
|
118
|
+
}}
|
|
119
|
+
for name, value in sorted(globals().items())
|
|
120
|
+
if {private_check}
|
|
121
|
+
and name not in {{"In", "Out", "exit", "quit", "get_ipython"}}
|
|
122
|
+
]
|
|
123
|
+
""".strip()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_demo(connection_file: str, *, timeout: float, json_output: bool) -> int:
|
|
127
|
+
snippets = [
|
|
128
|
+
("stdout_and_result", "print('hello from jk')\n21 * 2"),
|
|
129
|
+
("state_write", "jk_demo_value = {'ready': True, 'items': [1, 2, 3]}\njk_demo_value"),
|
|
130
|
+
("state_read", "jk_demo_value"),
|
|
131
|
+
("rich_output", "from IPython.display import display, HTML\ndisplay(HTML('<b>jk html output</b>'))\n'ok'"),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
results = []
|
|
135
|
+
exit_code = 0
|
|
136
|
+
for name, code in snippets:
|
|
137
|
+
response = execute(connection_file, code, timeout=timeout)
|
|
138
|
+
results.append({"name": name, "response": response.to_dict()})
|
|
139
|
+
if not response.ok and exit_code == 0:
|
|
140
|
+
exit_code = 124 if response.status == "timeout" else 1
|
|
141
|
+
|
|
142
|
+
if json_output:
|
|
143
|
+
print(json.dumps({"status": "ok" if exit_code == 0 else "error", "ok": exit_code == 0, "checks": results}, ensure_ascii=True))
|
|
144
|
+
return exit_code
|
|
145
|
+
|
|
146
|
+
for result in results:
|
|
147
|
+
response = result["response"]
|
|
148
|
+
status = "ok" if response["ok"] else response["status"]
|
|
149
|
+
print(f"{result['name']}: {status}")
|
|
150
|
+
if response["stdout"]:
|
|
151
|
+
print(response["stdout"], end="")
|
|
152
|
+
if response["result_text"] is not None:
|
|
153
|
+
print(response["result_text"])
|
|
154
|
+
if response["outputs"]:
|
|
155
|
+
print(f"outputs: {len(response['outputs'])}")
|
|
156
|
+
return exit_code
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
160
|
+
parser = argparse.ArgumentParser(prog="jk", description="Execute code in an existing Jupyter kernel.")
|
|
161
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
162
|
+
|
|
163
|
+
exec_parser = subparsers.add_parser("exec", help="Execute code.")
|
|
164
|
+
_add_common_options(exec_parser)
|
|
165
|
+
exec_parser.add_argument("code", nargs="?", help="Code to execute.")
|
|
166
|
+
exec_parser.add_argument("--file", "-i", help="Read code from a file.")
|
|
167
|
+
exec_parser.add_argument("--stdin", action="store_true", help="Read code from standard input.")
|
|
168
|
+
exec_parser.add_argument("--silent", action="store_true", help="Execute silently.")
|
|
169
|
+
exec_parser.add_argument("--no-history", action="store_true", help="Do not store execution in kernel history.")
|
|
170
|
+
|
|
171
|
+
eval_parser = subparsers.add_parser("eval", help="Evaluate a Python expression.")
|
|
172
|
+
_add_common_options(eval_parser)
|
|
173
|
+
eval_parser.add_argument("expression", help="Python expression to evaluate.")
|
|
174
|
+
|
|
175
|
+
get_parser = subparsers.add_parser("get", help="Get a variable by name.")
|
|
176
|
+
_add_common_options(get_parser)
|
|
177
|
+
get_parser.add_argument("name", help="Variable name to fetch from the kernel.")
|
|
178
|
+
|
|
179
|
+
vars_parser = subparsers.add_parser("vars", help="Inspect user-visible variables in the kernel namespace.")
|
|
180
|
+
_add_common_options(vars_parser)
|
|
181
|
+
vars_parser.add_argument("--include-private", action="store_true", help="Include names beginning with underscore.")
|
|
182
|
+
vars_parser.add_argument("--repr-length", type=int, default=160, help="Maximum repr length per variable.")
|
|
183
|
+
|
|
184
|
+
demo_parser = subparsers.add_parser("demo", help="Run a short capability demo against a kernel.")
|
|
185
|
+
_add_common_options(demo_parser)
|
|
186
|
+
|
|
187
|
+
kernels_parser = subparsers.add_parser("kernels", help="List recent connection files in the Jupyter runtime directory.")
|
|
188
|
+
kernels_parser.add_argument("--json", action="store_true", help="Emit JSON.")
|
|
189
|
+
kernels_parser.add_argument("--limit", type=int, default=20, help="Maximum files to list. Use 0 for no limit.")
|
|
190
|
+
kernels_parser.add_argument("--probe", action="store_true", help="Connect to each listed file and report whether it responds.")
|
|
191
|
+
kernels_parser.add_argument("--timeout", type=float, default=1.0, help="Seconds to wait per probed kernel.")
|
|
192
|
+
|
|
193
|
+
return parser
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _normalize_argv(argv: Optional[Iterable[str]]) -> Optional[List[str]]:
|
|
197
|
+
if argv is None:
|
|
198
|
+
values = sys.argv[1:]
|
|
199
|
+
else:
|
|
200
|
+
values = list(argv)
|
|
201
|
+
|
|
202
|
+
commands = {"exec", "eval", "get", "vars", "demo", "kernels"}
|
|
203
|
+
command_positions = [index for index, value in enumerate(values) if value in commands]
|
|
204
|
+
if not command_positions:
|
|
205
|
+
return values if argv is not None else None
|
|
206
|
+
|
|
207
|
+
command_index = command_positions[0]
|
|
208
|
+
if command_index == 0:
|
|
209
|
+
return values if argv is not None else None
|
|
210
|
+
|
|
211
|
+
normalized = [values[command_index], *values[:command_index], *values[command_index + 1 :]]
|
|
212
|
+
return normalized
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _list_kernels(*, json_output: bool, limit: int, probe: bool, timeout: float) -> int:
|
|
216
|
+
files = _runtime_connection_files(limit=None if limit == 0 else limit)
|
|
217
|
+
records = [_kernel_record(path, probe=probe, timeout=timeout) for path in files]
|
|
218
|
+
if json_output:
|
|
219
|
+
print(json.dumps(records, ensure_ascii=True))
|
|
220
|
+
return 0
|
|
221
|
+
if not records:
|
|
222
|
+
print("No kernel connection files found.")
|
|
223
|
+
return 1
|
|
224
|
+
for record in records:
|
|
225
|
+
if probe:
|
|
226
|
+
status = "alive" if record["alive"] else "dead"
|
|
227
|
+
print(f"{status:5} {record['path']}")
|
|
228
|
+
else:
|
|
229
|
+
print(record["path"])
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main(argv: Optional[Iterable[str]] = None) -> int:
|
|
234
|
+
parser = _build_parser()
|
|
235
|
+
args = parser.parse_args(_normalize_argv(argv))
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
if args.command == "kernels":
|
|
239
|
+
return _list_kernels(json_output=args.json, limit=args.limit, probe=args.probe, timeout=args.timeout)
|
|
240
|
+
|
|
241
|
+
connection_file = _resolve_connection_file(args)
|
|
242
|
+
if args.command == "exec":
|
|
243
|
+
code = _read_code(args)
|
|
244
|
+
response = execute(
|
|
245
|
+
connection_file,
|
|
246
|
+
code,
|
|
247
|
+
timeout=args.timeout,
|
|
248
|
+
silent=args.silent,
|
|
249
|
+
store_history=not args.no_history,
|
|
250
|
+
)
|
|
251
|
+
elif args.command == "eval":
|
|
252
|
+
response = eval_expression(connection_file, args.expression, timeout=args.timeout)
|
|
253
|
+
elif args.command == "get":
|
|
254
|
+
response = get_variable(connection_file, args.name, timeout=args.timeout)
|
|
255
|
+
elif args.command == "vars":
|
|
256
|
+
response = execute(
|
|
257
|
+
connection_file,
|
|
258
|
+
_vars_code(include_private=args.include_private, repr_length=args.repr_length),
|
|
259
|
+
timeout=args.timeout,
|
|
260
|
+
)
|
|
261
|
+
elif args.command == "demo":
|
|
262
|
+
return _run_demo(connection_file, timeout=args.timeout, json_output=args.json)
|
|
263
|
+
else:
|
|
264
|
+
parser.error(f"Unknown command: {args.command}")
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
if getattr(args, "json", False):
|
|
267
|
+
print(json.dumps({"status": "client_error", "ok": False, "error": str(exc)}, ensure_ascii=True))
|
|
268
|
+
else:
|
|
269
|
+
print(f"Client error: {exc}", file=sys.stderr)
|
|
270
|
+
return 2
|
|
271
|
+
|
|
272
|
+
return _emit_response(response, json_output=args.json)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from queue import Empty
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from jupyter_client import BlockingKernelClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class KernelClientError(RuntimeError):
|
|
16
|
+
"""Raised when the client cannot complete a request."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RichOutput:
|
|
21
|
+
output_type: str
|
|
22
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
transient: Dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
execution_count: Optional[int] = None
|
|
26
|
+
text: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
29
|
+
return {
|
|
30
|
+
"output_type": self.output_type,
|
|
31
|
+
"data": self.data,
|
|
32
|
+
"metadata": self.metadata,
|
|
33
|
+
"transient": self.transient,
|
|
34
|
+
"execution_count": self.execution_count,
|
|
35
|
+
"text": self.text,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class KernelResponse:
|
|
41
|
+
status: str
|
|
42
|
+
execution_count: Optional[int]
|
|
43
|
+
stdout: str
|
|
44
|
+
stderr: str
|
|
45
|
+
outputs: List[RichOutput]
|
|
46
|
+
result_text: Optional[str]
|
|
47
|
+
result_python: Any
|
|
48
|
+
ename: Optional[str]
|
|
49
|
+
evalue: Optional[str]
|
|
50
|
+
traceback: List[str]
|
|
51
|
+
elapsed_seconds: float
|
|
52
|
+
timed_out: bool
|
|
53
|
+
msg_id: str
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def ok(self) -> bool:
|
|
57
|
+
return self.status == "ok" and not self.timed_out
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
60
|
+
return {
|
|
61
|
+
"status": self.status,
|
|
62
|
+
"ok": self.ok,
|
|
63
|
+
"execution_count": self.execution_count,
|
|
64
|
+
"stdout": self.stdout,
|
|
65
|
+
"stderr": self.stderr,
|
|
66
|
+
"outputs": [output.to_dict() for output in self.outputs],
|
|
67
|
+
"result_text": self.result_text,
|
|
68
|
+
"result_python": self.result_python,
|
|
69
|
+
"ename": self.ename,
|
|
70
|
+
"evalue": self.evalue,
|
|
71
|
+
"traceback": self.traceback,
|
|
72
|
+
"elapsed_seconds": self.elapsed_seconds,
|
|
73
|
+
"timed_out": self.timed_out,
|
|
74
|
+
"msg_id": self.msg_id,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_python_literal(text: Optional[str]) -> Any:
|
|
79
|
+
if text is None:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
return ast.literal_eval(text)
|
|
83
|
+
except Exception:
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _make_client(connection_file: str, *, ready_timeout: float = 5.0) -> BlockingKernelClient:
|
|
88
|
+
path = Path(connection_file).expanduser()
|
|
89
|
+
if not path.exists():
|
|
90
|
+
raise KernelClientError(f"Connection file does not exist: {path}")
|
|
91
|
+
|
|
92
|
+
client = BlockingKernelClient(connection_file=str(path))
|
|
93
|
+
client.load_connection_file()
|
|
94
|
+
client.start_channels()
|
|
95
|
+
try:
|
|
96
|
+
client.wait_for_ready(timeout=ready_timeout)
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
client.stop_channels()
|
|
99
|
+
raise KernelClientError(f"Kernel did not become ready within {ready_timeout:.1f}s: {exc}") from exc
|
|
100
|
+
return client
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def kernel_is_ready(connection_file: str, *, timeout: float = 1.0) -> None:
|
|
104
|
+
"""Raise KernelClientError if the kernel connection file does not respond."""
|
|
105
|
+
client = _make_client(connection_file, ready_timeout=timeout)
|
|
106
|
+
client.stop_channels()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _json_safe(value: Any) -> Any:
|
|
110
|
+
try:
|
|
111
|
+
json.dumps(value)
|
|
112
|
+
return value
|
|
113
|
+
except TypeError:
|
|
114
|
+
return repr(value)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _message_output(msg_type: str, content: Dict[str, Any]) -> Optional[RichOutput]:
|
|
118
|
+
if msg_type not in {"execute_result", "display_data", "update_display_data"}:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
data = {key: _json_safe(value) for key, value in content.get("data", {}).items()}
|
|
122
|
+
text = data.get("text/plain")
|
|
123
|
+
return RichOutput(
|
|
124
|
+
output_type=msg_type,
|
|
125
|
+
data=data,
|
|
126
|
+
metadata=content.get("metadata", {}),
|
|
127
|
+
transient=content.get("transient", {}),
|
|
128
|
+
execution_count=content.get("execution_count"),
|
|
129
|
+
text=text if isinstance(text, str) else None,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def execute(
|
|
134
|
+
connection_file: str,
|
|
135
|
+
code: str,
|
|
136
|
+
*,
|
|
137
|
+
timeout: float = 30.0,
|
|
138
|
+
ready_timeout: float = 5.0,
|
|
139
|
+
silent: bool = False,
|
|
140
|
+
store_history: bool = True,
|
|
141
|
+
allow_stdin: bool = False,
|
|
142
|
+
) -> KernelResponse:
|
|
143
|
+
"""Execute code in an existing Jupyter kernel and collect its IOPub output."""
|
|
144
|
+
if timeout <= 0:
|
|
145
|
+
raise ValueError("timeout must be greater than zero")
|
|
146
|
+
|
|
147
|
+
client = _make_client(connection_file, ready_timeout=ready_timeout)
|
|
148
|
+
stdout_chunks: List[str] = []
|
|
149
|
+
stderr_chunks: List[str] = []
|
|
150
|
+
outputs: List[RichOutput] = []
|
|
151
|
+
result_text: Optional[str] = None
|
|
152
|
+
execution_count: Optional[int] = None
|
|
153
|
+
status = "unknown"
|
|
154
|
+
ename: Optional[str] = None
|
|
155
|
+
evalue: Optional[str] = None
|
|
156
|
+
traceback: List[str] = []
|
|
157
|
+
msg_id = ""
|
|
158
|
+
started = time.monotonic()
|
|
159
|
+
timed_out = False
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
msg_id = client.execute(
|
|
163
|
+
code,
|
|
164
|
+
silent=silent,
|
|
165
|
+
store_history=store_history,
|
|
166
|
+
user_expressions={},
|
|
167
|
+
allow_stdin=allow_stdin,
|
|
168
|
+
stop_on_error=True,
|
|
169
|
+
)
|
|
170
|
+
deadline = started + timeout
|
|
171
|
+
|
|
172
|
+
while time.monotonic() < deadline:
|
|
173
|
+
remaining = max(0.05, deadline - time.monotonic())
|
|
174
|
+
try:
|
|
175
|
+
msg = client.get_iopub_msg(timeout=remaining)
|
|
176
|
+
except Empty:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if msg.get("parent_header", {}).get("msg_id") != msg_id:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
msg_type = msg.get("msg_type")
|
|
183
|
+
content = msg.get("content", {})
|
|
184
|
+
|
|
185
|
+
if msg_type == "stream":
|
|
186
|
+
if content.get("name") == "stderr":
|
|
187
|
+
stderr_chunks.append(content.get("text", ""))
|
|
188
|
+
else:
|
|
189
|
+
stdout_chunks.append(content.get("text", ""))
|
|
190
|
+
elif msg_type in {"execute_result", "display_data", "update_display_data"}:
|
|
191
|
+
output = _message_output(msg_type, content)
|
|
192
|
+
if output is not None:
|
|
193
|
+
outputs.append(output)
|
|
194
|
+
if output.text is not None:
|
|
195
|
+
result_text = output.text
|
|
196
|
+
execution_count = output.execution_count or execution_count
|
|
197
|
+
elif msg_type == "error":
|
|
198
|
+
status = "error"
|
|
199
|
+
ename = content.get("ename")
|
|
200
|
+
evalue = content.get("evalue")
|
|
201
|
+
traceback = content.get("traceback", [])
|
|
202
|
+
elif msg_type == "execute_input":
|
|
203
|
+
execution_count = content.get("execution_count", execution_count)
|
|
204
|
+
elif msg_type == "status" and content.get("execution_state") == "idle":
|
|
205
|
+
if status != "error":
|
|
206
|
+
status = "ok"
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
timed_out = status == "unknown"
|
|
210
|
+
if timed_out:
|
|
211
|
+
status = "timeout"
|
|
212
|
+
finally:
|
|
213
|
+
client.stop_channels()
|
|
214
|
+
|
|
215
|
+
return KernelResponse(
|
|
216
|
+
status=status,
|
|
217
|
+
execution_count=execution_count,
|
|
218
|
+
stdout="".join(stdout_chunks),
|
|
219
|
+
stderr="".join(stderr_chunks),
|
|
220
|
+
outputs=outputs,
|
|
221
|
+
result_text=result_text,
|
|
222
|
+
result_python=_parse_python_literal(result_text),
|
|
223
|
+
ename=ename,
|
|
224
|
+
evalue=evalue,
|
|
225
|
+
traceback=traceback,
|
|
226
|
+
elapsed_seconds=round(time.monotonic() - started, 6),
|
|
227
|
+
timed_out=timed_out,
|
|
228
|
+
msg_id=msg_id,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def eval_expression(connection_file: str, expression: str, *, timeout: float = 30.0) -> KernelResponse:
|
|
233
|
+
sentinel = f"__jk_value_{uuid.uuid4().hex}__"
|
|
234
|
+
code = f"globals()[{sentinel!r}] = ({expression})\nglobals().pop({sentinel!r})"
|
|
235
|
+
return execute(connection_file, code, timeout=timeout)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_variable(connection_file: str, variable_name: str, *, timeout: float = 30.0) -> KernelResponse:
|
|
239
|
+
return eval_expression(connection_file, variable_name, timeout=timeout)
|