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.
- jupyterhub_exec-0.1.0/LICENSE +21 -0
- jupyterhub_exec-0.1.0/PKG-INFO +108 -0
- jupyterhub_exec-0.1.0/README.md +89 -0
- jupyterhub_exec-0.1.0/pyproject.toml +30 -0
- jupyterhub_exec-0.1.0/setup.cfg +4 -0
- jupyterhub_exec-0.1.0/src/jh_exec/__init__.py +9 -0
- jupyterhub_exec-0.1.0/src/jh_exec/cli.py +99 -0
- jupyterhub_exec-0.1.0/src/jh_exec/client.py +250 -0
- jupyterhub_exec-0.1.0/src/jupyterhub_exec.egg-info/PKG-INFO +108 -0
- jupyterhub_exec-0.1.0/src/jupyterhub_exec.egg-info/SOURCES.txt +11 -0
- jupyterhub_exec-0.1.0/src/jupyterhub_exec.egg-info/dependency_links.txt +1 -0
- jupyterhub_exec-0.1.0/src/jupyterhub_exec.egg-info/entry_points.txt +2 -0
- jupyterhub_exec-0.1.0/src/jupyterhub_exec.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jh_exec
|