jupyterhub-exec 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Quantiota
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,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: jupyterhub-exec
3
+ Version: 0.1.0
4
+ Summary: Execute code on a remote JupyterHub kernel from any terminal — zero dependencies.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/quantiota/jupyterhub-exec
7
+ Project-URL: Repository, https://github.com/quantiota/jupyterhub-exec
8
+ Project-URL: Issues, https://github.com/quantiota/jupyterhub-exec/issues
9
+ Keywords: jupyterhub,jupyter,kernel,gpu,offload,agent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Classifier: Topic :: System :: Distributed Computing
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # jupyterhub-exec
21
+
22
+ Execute code on a remote JupyterHub kernel from any terminal — zero external dependencies.
23
+
24
+ ```
25
+ pip install jupyterhub-exec
26
+ ```
27
+
28
+ ## Why
29
+
30
+ JupyterHub provides GPU compute. Your agent terminal does not.
31
+ `jh-exec` bridges the two using the Jupyter kernel protocol over a raw WebSocket —
32
+ no browser, no notebook UI, no library dependencies beyond the Python standard library.
33
+
34
+ ```
35
+ ┌─────────────────────────┐ WebSocket ┌──────────────────────────┐
36
+ │ Agent Terminal (CPU) │ ───────────────────────► │ JupyterHub Kernel (GPU) │
37
+ │ Claude Code / CLI │ ◄─────────────────────── │ PyTorch / CUDA │
38
+ └─────────────────────────┘ stdout stream └──────────────────────────┘
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```bash
44
+ # Execute a script on the remote GPU kernel
45
+ jh-exec run train.py
46
+
47
+ # Execute inline code
48
+ jh-exec exec "import torch; print(torch.cuda.is_available())"
49
+
50
+ # List running kernels
51
+ jh-exec kernels
52
+
53
+ # Start a new kernel
54
+ jh-exec new-kernel
55
+ ```
56
+
57
+ ## Configuration
58
+
59
+ Set via environment variables or a `.env` file in the working or home directory:
60
+
61
+ ```bash
62
+ JH_HOST=192.168.1.100
63
+ JH_PORT=8000
64
+ JH_USER=agent-01
65
+ JH_TOKEN=your_token_here
66
+ JH_TIMEOUT=600
67
+ ```
68
+
69
+ Or pass directly:
70
+
71
+ ```bash
72
+ jh-exec --host 192.168.1.100 --user agent-01 --token your_token run script.py
73
+ ```
74
+
75
+ ## Python API
76
+
77
+ ```python
78
+ from jh_exec import execute, list_kernels, new_kernel
79
+
80
+ # Execute code, stream output to stdout
81
+ execute("import torch; print(torch.cuda.get_device_name(0))")
82
+
83
+ # List running kernels
84
+ kernels = list_kernels()
85
+
86
+ # Start a new kernel, get its ID
87
+ kid = new_kernel()
88
+ ```
89
+
90
+ ## Dedicated GPU per agent
91
+
92
+ In `jupyterhub_config.py`:
93
+
94
+ ```python
95
+ def assign_gpu(spawner):
96
+ gpu_map = {
97
+ "agent-01": "0",
98
+ "agent-02": "1",
99
+ "agent-03": "2",
100
+ }
101
+ spawner.environment["CUDA_VISIBLE_DEVICES"] = gpu_map.get(spawner.user.name, "")
102
+
103
+ c.Spawner.pre_spawn_hook = assign_gpu
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,89 @@
1
+ # jupyterhub-exec
2
+
3
+ Execute code on a remote JupyterHub kernel from any terminal — zero external dependencies.
4
+
5
+ ```
6
+ pip install jupyterhub-exec
7
+ ```
8
+
9
+ ## Why
10
+
11
+ JupyterHub provides GPU compute. Your agent terminal does not.
12
+ `jh-exec` bridges the two using the Jupyter kernel protocol over a raw WebSocket —
13
+ no browser, no notebook UI, no library dependencies beyond the Python standard library.
14
+
15
+ ```
16
+ ┌─────────────────────────┐ WebSocket ┌──────────────────────────┐
17
+ │ Agent Terminal (CPU) │ ───────────────────────► │ JupyterHub Kernel (GPU) │
18
+ │ Claude Code / CLI │ ◄─────────────────────── │ PyTorch / CUDA │
19
+ └─────────────────────────┘ stdout stream └──────────────────────────┘
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ # Execute a script on the remote GPU kernel
26
+ jh-exec run train.py
27
+
28
+ # Execute inline code
29
+ jh-exec exec "import torch; print(torch.cuda.is_available())"
30
+
31
+ # List running kernels
32
+ jh-exec kernels
33
+
34
+ # Start a new kernel
35
+ jh-exec new-kernel
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ Set via environment variables or a `.env` file in the working or home directory:
41
+
42
+ ```bash
43
+ JH_HOST=192.168.1.100
44
+ JH_PORT=8000
45
+ JH_USER=agent-01
46
+ JH_TOKEN=your_token_here
47
+ JH_TIMEOUT=600
48
+ ```
49
+
50
+ Or pass directly:
51
+
52
+ ```bash
53
+ jh-exec --host 192.168.1.100 --user agent-01 --token your_token run script.py
54
+ ```
55
+
56
+ ## Python API
57
+
58
+ ```python
59
+ from jh_exec import execute, list_kernels, new_kernel
60
+
61
+ # Execute code, stream output to stdout
62
+ execute("import torch; print(torch.cuda.get_device_name(0))")
63
+
64
+ # List running kernels
65
+ kernels = list_kernels()
66
+
67
+ # Start a new kernel, get its ID
68
+ kid = new_kernel()
69
+ ```
70
+
71
+ ## Dedicated GPU per agent
72
+
73
+ In `jupyterhub_config.py`:
74
+
75
+ ```python
76
+ def assign_gpu(spawner):
77
+ gpu_map = {
78
+ "agent-01": "0",
79
+ "agent-02": "1",
80
+ "agent-03": "2",
81
+ }
82
+ spawner.environment["CUDA_VISIBLE_DEVICES"] = gpu_map.get(spawner.user.name, "")
83
+
84
+ c.Spawner.pre_spawn_hook = assign_gpu
85
+ ```
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "jupyterhub-exec"
7
+ version = "0.1.0"
8
+ description = "Execute code on a remote JupyterHub kernel from any terminal — zero dependencies."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ keywords = ["jupyterhub", "jupyter", "kernel", "gpu", "offload", "agent"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ "Topic :: System :: Distributed Computing",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/quantiota/jupyterhub-exec"
23
+ Repository = "https://github.com/quantiota/jupyterhub-exec"
24
+ Issues = "https://github.com/quantiota/jupyterhub-exec/issues"
25
+
26
+ [project.scripts]
27
+ jh-exec = "jh_exec.cli:main"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """
2
+ jh_exec — Execute code on a remote JupyterHub kernel from any terminal.
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ from .client import execute, list_kernels, new_kernel, get_or_create_kernel
8
+
9
+ __all__ = ["execute", "list_kernels", "new_kernel", "get_or_create_kernel"]
@@ -0,0 +1,99 @@
1
+ """
2
+ Command-line interface for jh_exec.
3
+ """
4
+
5
+ import argparse, sys, os, pathlib
6
+ from .client import JupyterHubClient
7
+ from . import __version__
8
+
9
+
10
+ def load_env(env_file=None):
11
+ candidates = []
12
+ if env_file:
13
+ candidates.append(pathlib.Path(env_file))
14
+ candidates += [pathlib.Path.cwd() / ".env", pathlib.Path.home() / ".env"]
15
+ for candidate in candidates:
16
+ if candidate.exists():
17
+ with open(candidate) as f:
18
+ for line in f:
19
+ line = line.strip()
20
+ if line and not line.startswith("#") and "=" in line:
21
+ k, v = line.split("=", 1)
22
+ os.environ.setdefault(k.strip(), v.strip())
23
+ break
24
+
25
+
26
+ def build_client(args):
27
+ return JupyterHubClient(
28
+ host = args.host or os.getenv("JH_HOST", "localhost"),
29
+ port = args.port or os.getenv("JH_PORT", "8000"),
30
+ user = args.user or os.getenv("JH_USER", ""),
31
+ token = args.token or os.getenv("JH_TOKEN", ""),
32
+ timeout = args.timeout or int(os.getenv("JH_TIMEOUT", "600")),
33
+ )
34
+
35
+
36
+ def main():
37
+ parser = argparse.ArgumentParser(
38
+ prog="jh-exec",
39
+ description="Execute code on a remote JupyterHub kernel from any terminal."
40
+ )
41
+ parser.add_argument("--version", action="version", version=f"jh-exec {__version__}")
42
+ parser.add_argument("--host", help="JupyterHub host")
43
+ parser.add_argument("--port", help="JupyterHub port (default: 8000)")
44
+ parser.add_argument("--user", help="JupyterHub username")
45
+ parser.add_argument("--token", help="JupyterHub API token")
46
+ parser.add_argument("--timeout", type=int, help="Execution timeout in seconds (default: 600)")
47
+ parser.add_argument("--kernel", help="Kernel ID (auto-discovered if omitted)")
48
+ parser.add_argument("--env", help="Path to .env file")
49
+
50
+ sub = parser.add_subparsers(dest="command")
51
+
52
+ # run
53
+ run_p = sub.add_parser("run", help="Execute a Python script file")
54
+ run_p.add_argument("script", help="Path to the Python script")
55
+
56
+ # exec
57
+ exec_p = sub.add_parser("exec", help="Execute inline Python code")
58
+ exec_p.add_argument("code", help="Python code to execute")
59
+
60
+ # kernels
61
+ sub.add_parser("kernels", help="List running kernels")
62
+
63
+ # new-kernel
64
+ sub.add_parser("new-kernel", help="Start a new kernel and print its ID")
65
+
66
+ args = parser.parse_args()
67
+
68
+ if args.command is None:
69
+ parser.print_help()
70
+ sys.exit(0)
71
+
72
+ load_env(args.env)
73
+ client = build_client(args)
74
+
75
+ if args.command == "kernels":
76
+ kernels = client.list_kernels()
77
+ if not kernels:
78
+ print("No running kernels.")
79
+ for k in kernels:
80
+ print(f"{k['id']} name={k['name']} state={k['execution_state']} last_activity={k['last_activity']}")
81
+
82
+ elif args.command == "new-kernel":
83
+ kid = client.new_kernel()
84
+ print(kid)
85
+
86
+ elif args.command in ("run", "exec"):
87
+ if args.command == "run":
88
+ with open(args.script) as f:
89
+ code = f.read()
90
+ else:
91
+ code = args.code
92
+
93
+ kernel_id = args.kernel or client.get_or_create_kernel()
94
+ status = client.execute(code, kernel_id=kernel_id)
95
+ sys.exit(0 if status == "ok" else 1)
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
@@ -0,0 +1,250 @@
1
+ """
2
+ Core WebSocket client for JupyterHub kernel execution.
3
+ Zero external dependencies — uses only Python built-ins.
4
+ """
5
+
6
+ import socket, base64, struct, json, uuid, time, os, sys
7
+ import urllib.request, urllib.error
8
+
9
+
10
+ class JupyterHubClient:
11
+ def __init__(self, host, port, user, token, timeout=600):
12
+ self.host = host
13
+ self.port = int(port)
14
+ self.user = user
15
+ self.token = token
16
+ self.timeout = timeout
17
+
18
+ # ── Kernel management ────────────────────────────────────────────────────
19
+
20
+ def list_kernels(self):
21
+ url = f"http://{self.host}:{self.port}/user/{self.user}/api/kernels?token={self.token}"
22
+ with urllib.request.urlopen(url, timeout=10) as resp:
23
+ data = resp.read()
24
+ try:
25
+ result = json.loads(data)
26
+ return result if isinstance(result, list) else []
27
+ except json.JSONDecodeError:
28
+ return []
29
+
30
+ def start_server(self):
31
+ """Start the JupyterHub single-user server if not running."""
32
+ url = f"http://{self.host}:{self.port}/hub/api/users/{self.user}/server"
33
+ req = urllib.request.Request(
34
+ url, data=b"",
35
+ headers={"Authorization": f"token {self.token}",
36
+ "Content-Type": "application/json"},
37
+ method="POST"
38
+ )
39
+ try:
40
+ with urllib.request.urlopen(req, timeout=30) as resp:
41
+ return resp.status in (201, 202)
42
+ except urllib.error.HTTPError as e:
43
+ if e.code == 400: # already running
44
+ return True
45
+ raise
46
+
47
+ def new_kernel(self, kernel_name="python3"):
48
+ url = f"http://{self.host}:{self.port}/user/{self.user}/api/kernels?token={self.token}"
49
+ req = urllib.request.Request(
50
+ url, data=json.dumps({"name": kernel_name}).encode(),
51
+ headers={"Content-Type": "application/json"}, method="POST"
52
+ )
53
+ try:
54
+ with urllib.request.urlopen(req, timeout=10) as resp:
55
+ return json.loads(resp.read())["id"]
56
+ except urllib.error.HTTPError as e:
57
+ if e.code == 405:
58
+ # Single-user server not running — start it and retry
59
+ self.start_server()
60
+ time.sleep(3)
61
+ with urllib.request.urlopen(req, timeout=10) as resp:
62
+ return json.loads(resp.read())["id"]
63
+ raise
64
+
65
+ def get_or_create_kernel(self, kernel_name="python3"):
66
+ for k in self.list_kernels():
67
+ if k["name"] == kernel_name:
68
+ return k["id"]
69
+ return self.new_kernel(kernel_name)
70
+
71
+ # ── WebSocket ─────────────────────────────────────────────────────────────
72
+
73
+ def _handshake(self, sock, kernel_id):
74
+ path = f"/user/{self.user}/api/kernels/{kernel_id}/channels?token={self.token}"
75
+ key = base64.b64encode(os.urandom(16)).decode()
76
+ req = (
77
+ f"GET {path} HTTP/1.1\r\n"
78
+ f"Host: {self.host}:{self.port}\r\n"
79
+ f"Upgrade: websocket\r\n"
80
+ f"Connection: Upgrade\r\n"
81
+ f"Sec-WebSocket-Key: {key}\r\n"
82
+ f"Sec-WebSocket-Version: 13\r\n"
83
+ f"\r\n"
84
+ )
85
+ sock.sendall(req.encode())
86
+ resp = b""
87
+ while b"\r\n\r\n" not in resp:
88
+ resp += sock.recv(4096)
89
+ if b"101" not in resp:
90
+ raise ConnectionError(f"WebSocket handshake failed: {resp[:200]}")
91
+
92
+ def _ws_send(self, sock, data):
93
+ if isinstance(data, str):
94
+ data = data.encode()
95
+ mask = os.urandom(4)
96
+ masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
97
+ length = len(data)
98
+ if length < 126:
99
+ header = bytes([0x81, 0x80 | length]) + mask
100
+ elif length < 65536:
101
+ header = bytes([0x81, 0xFE]) + struct.pack(">H", length) + mask
102
+ else:
103
+ header = bytes([0x81, 0xFF]) + struct.pack(">Q", length) + mask
104
+ sock.sendall(header + masked)
105
+
106
+ def _ws_recv(self, sock):
107
+ def recv_exact(n):
108
+ buf = b""
109
+ while len(buf) < n:
110
+ chunk = sock.recv(n - len(buf))
111
+ if not chunk:
112
+ raise ConnectionError("Socket closed")
113
+ buf += chunk
114
+ return buf
115
+
116
+ b1, b2 = recv_exact(2)
117
+ opcode = b1 & 0x0F
118
+ masked = (b2 & 0x80) != 0
119
+ length = b2 & 0x7F
120
+ if length == 126:
121
+ length = struct.unpack(">H", recv_exact(2))[0]
122
+ elif length == 127:
123
+ length = struct.unpack(">Q", recv_exact(8))[0]
124
+ mask_key = recv_exact(4) if masked else b""
125
+ payload = recv_exact(length)
126
+ if masked:
127
+ payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))
128
+ if opcode == 8:
129
+ return None
130
+ if opcode == 9:
131
+ self._ws_send(sock, b"")
132
+ return ""
133
+ return payload.decode("utf-8", errors="replace")
134
+
135
+ # ── Execute ───────────────────────────────────────────────────────────────
136
+
137
+ def execute(self, code, kernel_id=None, stdout=None):
138
+ """
139
+ Execute code on the kernel. Streams output to stdout (default: sys.stdout).
140
+ Returns the kernel execution status ('ok' or 'error').
141
+ """
142
+ if stdout is None:
143
+ stdout = sys.stdout
144
+ if kernel_id is None:
145
+ kernel_id = self.get_or_create_kernel()
146
+
147
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
148
+ sock.connect((self.host, self.port))
149
+ sock.settimeout(self.timeout)
150
+
151
+ self._handshake(sock, kernel_id)
152
+
153
+ msg_id = str(uuid.uuid4())
154
+ execute_msg = {
155
+ "header": {
156
+ "msg_id": msg_id, "username": self.user,
157
+ "session": str(uuid.uuid4()),
158
+ "msg_type": "execute_request",
159
+ "version": "5.3", "date": ""
160
+ },
161
+ "parent_header": {},
162
+ "metadata": {},
163
+ "content": {
164
+ "code": code, "silent": False,
165
+ "store_history": False,
166
+ "user_expressions": {}, "allow_stdin": False
167
+ },
168
+ "channel": "shell",
169
+ "buffers": []
170
+ }
171
+ self._ws_send(sock, json.dumps(execute_msg))
172
+
173
+ status = "unknown"
174
+ deadline = time.time() + self.timeout
175
+ while time.time() < deadline:
176
+ try:
177
+ frame = self._ws_recv(sock)
178
+ except socket.timeout:
179
+ continue
180
+ if frame is None:
181
+ break
182
+ if not frame:
183
+ continue
184
+ try:
185
+ msg = json.loads(frame)
186
+ except json.JSONDecodeError:
187
+ continue
188
+
189
+ mt = msg.get("msg_type", "")
190
+ if mt == "stream":
191
+ stdout.write(msg["content"]["text"])
192
+ stdout.flush()
193
+ elif mt in ("execute_result", "display_data"):
194
+ stdout.write(msg["content"]["data"].get("text/plain", "") + "\n")
195
+ stdout.flush()
196
+ elif mt == "error":
197
+ sys.stderr.write("KERNEL ERROR: " + msg["content"]["evalue"] + "\n")
198
+ for line in msg["content"]["traceback"]:
199
+ sys.stderr.write(line + "\n")
200
+ status = "error"
201
+ break
202
+ elif mt == "execute_reply":
203
+ status = msg["content"]["status"]
204
+ break
205
+
206
+ sock.close()
207
+ return status
208
+
209
+
210
+ # ── Module-level convenience functions ────────────────────────────────────────
211
+
212
+ def _client_from_env():
213
+ """Build a JupyterHubClient from environment variables or .env file."""
214
+ import pathlib
215
+
216
+ def load_env():
217
+ for candidate in [pathlib.Path.cwd() / ".env", pathlib.Path.home() / ".env"]:
218
+ if candidate.exists():
219
+ with open(candidate) as f:
220
+ for line in f:
221
+ line = line.strip()
222
+ if line and not line.startswith("#") and "=" in line:
223
+ k, v = line.split("=", 1)
224
+ os.environ.setdefault(k.strip(), v.strip())
225
+ break
226
+
227
+ load_env()
228
+ return JupyterHubClient(
229
+ host = os.getenv("JH_HOST", "localhost"),
230
+ port = os.getenv("JH_PORT", "8000"),
231
+ user = os.getenv("JH_USER", ""),
232
+ token = os.getenv("JH_TOKEN", ""),
233
+ timeout = int(os.getenv("JH_TIMEOUT", "600")),
234
+ )
235
+
236
+
237
+ def execute(code, kernel_id=None):
238
+ return _client_from_env().execute(code, kernel_id=kernel_id)
239
+
240
+
241
+ def list_kernels():
242
+ return _client_from_env().list_kernels()
243
+
244
+
245
+ def new_kernel(kernel_name="python3"):
246
+ return _client_from_env().new_kernel(kernel_name)
247
+
248
+
249
+ def get_or_create_kernel(kernel_name="python3"):
250
+ return _client_from_env().get_or_create_kernel(kernel_name)
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: jupyterhub-exec
3
+ Version: 0.1.0
4
+ Summary: Execute code on a remote JupyterHub kernel from any terminal — zero dependencies.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/quantiota/jupyterhub-exec
7
+ Project-URL: Repository, https://github.com/quantiota/jupyterhub-exec
8
+ Project-URL: Issues, https://github.com/quantiota/jupyterhub-exec/issues
9
+ Keywords: jupyterhub,jupyter,kernel,gpu,offload,agent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Classifier: Topic :: System :: Distributed Computing
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # jupyterhub-exec
21
+
22
+ Execute code on a remote JupyterHub kernel from any terminal — zero external dependencies.
23
+
24
+ ```
25
+ pip install jupyterhub-exec
26
+ ```
27
+
28
+ ## Why
29
+
30
+ JupyterHub provides GPU compute. Your agent terminal does not.
31
+ `jh-exec` bridges the two using the Jupyter kernel protocol over a raw WebSocket —
32
+ no browser, no notebook UI, no library dependencies beyond the Python standard library.
33
+
34
+ ```
35
+ ┌─────────────────────────┐ WebSocket ┌──────────────────────────┐
36
+ │ Agent Terminal (CPU) │ ───────────────────────► │ JupyterHub Kernel (GPU) │
37
+ │ Claude Code / CLI │ ◄─────────────────────── │ PyTorch / CUDA │
38
+ └─────────────────────────┘ stdout stream └──────────────────────────┘
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```bash
44
+ # Execute a script on the remote GPU kernel
45
+ jh-exec run train.py
46
+
47
+ # Execute inline code
48
+ jh-exec exec "import torch; print(torch.cuda.is_available())"
49
+
50
+ # List running kernels
51
+ jh-exec kernels
52
+
53
+ # Start a new kernel
54
+ jh-exec new-kernel
55
+ ```
56
+
57
+ ## Configuration
58
+
59
+ Set via environment variables or a `.env` file in the working or home directory:
60
+
61
+ ```bash
62
+ JH_HOST=192.168.1.100
63
+ JH_PORT=8000
64
+ JH_USER=agent-01
65
+ JH_TOKEN=your_token_here
66
+ JH_TIMEOUT=600
67
+ ```
68
+
69
+ Or pass directly:
70
+
71
+ ```bash
72
+ jh-exec --host 192.168.1.100 --user agent-01 --token your_token run script.py
73
+ ```
74
+
75
+ ## Python API
76
+
77
+ ```python
78
+ from jh_exec import execute, list_kernels, new_kernel
79
+
80
+ # Execute code, stream output to stdout
81
+ execute("import torch; print(torch.cuda.get_device_name(0))")
82
+
83
+ # List running kernels
84
+ kernels = list_kernels()
85
+
86
+ # Start a new kernel, get its ID
87
+ kid = new_kernel()
88
+ ```
89
+
90
+ ## Dedicated GPU per agent
91
+
92
+ In `jupyterhub_config.py`:
93
+
94
+ ```python
95
+ def assign_gpu(spawner):
96
+ gpu_map = {
97
+ "agent-01": "0",
98
+ "agent-02": "1",
99
+ "agent-03": "2",
100
+ }
101
+ spawner.environment["CUDA_VISIBLE_DEVICES"] = gpu_map.get(spawner.user.name, "")
102
+
103
+ c.Spawner.pre_spawn_hook = assign_gpu
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/jh_exec/__init__.py
5
+ src/jh_exec/cli.py
6
+ src/jh_exec/client.py
7
+ src/jupyterhub_exec.egg-info/PKG-INFO
8
+ src/jupyterhub_exec.egg-info/SOURCES.txt
9
+ src/jupyterhub_exec.egg-info/dependency_links.txt
10
+ src/jupyterhub_exec.egg-info/entry_points.txt
11
+ src/jupyterhub_exec.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jh-exec = jh_exec.cli:main