jupyterlab-kernel-terminal-workspace-culler-extension 1.0.21__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.
- jupyterlab_kernel_terminal_workspace_culler_extension/__init__.py +54 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/_version.py +4 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/cli.py +430 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/culler.py +499 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/routes.py +176 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/tests/__init__.py +1 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/tests/test_culler.py +269 -0
- jupyterlab_kernel_terminal_workspace_culler_extension/tests/test_routes.py +17 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/etc/jupyter/jupyter_server_config.d/jupyterlab_kernel_terminal_workspace_culler_extension.json +7 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/install.json +5 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/package.json +219 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/schemas/jupyterlab_kernel_terminal_workspace_culler_extension/package.json.orig +214 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/schemas/jupyterlab_kernel_terminal_workspace_culler_extension/plugin.json +64 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/static/728.b056947597422f9e496c.js +1 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/static/750.b2aa372edac477cffcb9.js +1 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/static/remoteEntry.61977aac2cfde9b88947.js +1 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/static/style.js +4 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/static/third-party-licenses.json +16 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/METADATA +208 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/RECORD +23 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/WHEEL +4 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/entry_points.txt +2 -0
- jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
except ImportError:
|
|
4
|
+
# Fallback when using the package in dev mode without installing
|
|
5
|
+
# in editable mode with pip. It is highly recommended to install
|
|
6
|
+
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
warnings.warn(
|
|
10
|
+
"Importing 'jupyterlab_kernel_terminal_workspace_culler_extension' outside a proper installation."
|
|
11
|
+
)
|
|
12
|
+
__version__ = "dev"
|
|
13
|
+
|
|
14
|
+
from .culler import ResourceCuller
|
|
15
|
+
from .routes import set_culler, setup_route_handlers
|
|
16
|
+
|
|
17
|
+
# Global culler instance
|
|
18
|
+
_culler_instance: ResourceCuller | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_culler() -> ResourceCuller | None:
|
|
22
|
+
"""Return the global culler instance."""
|
|
23
|
+
return _culler_instance
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _jupyter_labextension_paths():
|
|
27
|
+
return [{"src": "labextension", "dest": "jupyterlab_kernel_terminal_workspace_culler_extension"}]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _jupyter_server_extension_points():
|
|
31
|
+
return [{"module": "jupyterlab_kernel_terminal_workspace_culler_extension"}]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_jupyter_server_extension(server_app):
|
|
35
|
+
"""Registers the API handler and starts the resource culler.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
server_app: jupyterlab.labapp.LabApp
|
|
40
|
+
JupyterLab application instance
|
|
41
|
+
"""
|
|
42
|
+
global _culler_instance
|
|
43
|
+
|
|
44
|
+
# Set up route handlers
|
|
45
|
+
setup_route_handlers(server_app.web_app)
|
|
46
|
+
|
|
47
|
+
# Create and start the culler
|
|
48
|
+
_culler_instance = ResourceCuller(server_app)
|
|
49
|
+
set_culler(_culler_instance)
|
|
50
|
+
_culler_instance.start()
|
|
51
|
+
|
|
52
|
+
name = "jupyterlab_kernel_terminal_workspace_culler_extension"
|
|
53
|
+
server_app.log.info(f"Registered {name} server extension")
|
|
54
|
+
server_app.log.info("[Culler] Resource culler initialized and started")
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""CLI for jupyterlab_kernel_terminal_workspace_culler_extension."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from urllib.parse import urljoin
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_jupyter_server_info() -> tuple[str, str | None]:
|
|
15
|
+
"""
|
|
16
|
+
Auto-detect JupyterLab base URL and token.
|
|
17
|
+
|
|
18
|
+
Checks in order:
|
|
19
|
+
1. jupyter server list --json - query running servers (uses localhost)
|
|
20
|
+
2. JUPYTERHUB_SERVICE_PREFIX - JupyterHub environment variable
|
|
21
|
+
3. Default: http://localhost:8888
|
|
22
|
+
|
|
23
|
+
Token priority (JupyterHub API token takes precedence for API access):
|
|
24
|
+
1. JUPYTERHUB_API_TOKEN - required for JupyterHub API endpoints
|
|
25
|
+
2. JPY_API_TOKEN - legacy JupyterHub token
|
|
26
|
+
3. JUPYTER_TOKEN - standalone Jupyter server token
|
|
27
|
+
4. Token from jupyter server list (fallback)
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Tuple of (base_url, token) where token may be None
|
|
31
|
+
"""
|
|
32
|
+
# Get token - prioritize JupyterHub API token for proper API access
|
|
33
|
+
token = (
|
|
34
|
+
os.environ.get("JUPYTERHUB_API_TOKEN")
|
|
35
|
+
or os.environ.get("JPY_API_TOKEN")
|
|
36
|
+
or os.environ.get("JUPYTER_TOKEN")
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Try to detect from running Jupyter servers (preferred - always uses localhost)
|
|
40
|
+
try:
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["jupyter", "server", "list", "--json"],
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=5,
|
|
46
|
+
)
|
|
47
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
48
|
+
# Parse first server (one JSON object per line)
|
|
49
|
+
first_line = result.stdout.strip().split("\n")[0]
|
|
50
|
+
server_info = json.loads(first_line)
|
|
51
|
+
port = server_info.get("port", 8888)
|
|
52
|
+
base_url = server_info.get("base_url", "/").rstrip("/")
|
|
53
|
+
# Use server token as fallback if no env token
|
|
54
|
+
if not token:
|
|
55
|
+
token = server_info.get("token")
|
|
56
|
+
return f"http://127.0.0.1:{port}{base_url}", token
|
|
57
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
|
|
58
|
+
pass # Fall through to other methods
|
|
59
|
+
|
|
60
|
+
# Check for JupyterHub environment
|
|
61
|
+
service_prefix = os.environ.get("JUPYTERHUB_SERVICE_PREFIX")
|
|
62
|
+
if service_prefix:
|
|
63
|
+
port = os.environ.get("JUPYTER_PORT", "8888")
|
|
64
|
+
return f"http://127.0.0.1:{port}{service_prefix.rstrip('/')}", token
|
|
65
|
+
|
|
66
|
+
# Default
|
|
67
|
+
port = os.environ.get("JUPYTER_PORT", "8888")
|
|
68
|
+
return f"http://localhost:{port}", token
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_idle_time(last_activity: str | datetime | None) -> str:
|
|
72
|
+
"""Format idle time as human-readable string."""
|
|
73
|
+
if last_activity is None:
|
|
74
|
+
return "unknown"
|
|
75
|
+
|
|
76
|
+
if isinstance(last_activity, str):
|
|
77
|
+
last_activity = datetime.fromisoformat(last_activity.replace("Z", "+00:00"))
|
|
78
|
+
|
|
79
|
+
if last_activity.tzinfo is None:
|
|
80
|
+
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
|
81
|
+
|
|
82
|
+
now = datetime.now(timezone.utc)
|
|
83
|
+
idle_seconds = (now - last_activity).total_seconds()
|
|
84
|
+
|
|
85
|
+
if idle_seconds < 60:
|
|
86
|
+
return f"{idle_seconds:.0f}s"
|
|
87
|
+
elif idle_seconds < 3600:
|
|
88
|
+
return f"{idle_seconds / 60:.1f}m"
|
|
89
|
+
elif idle_seconds < 86400:
|
|
90
|
+
return f"{idle_seconds / 3600:.1f}h"
|
|
91
|
+
else:
|
|
92
|
+
return f"{idle_seconds / 86400:.1f}d"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_idle_seconds(last_activity: str | datetime | None) -> float:
|
|
96
|
+
"""Get idle time in seconds."""
|
|
97
|
+
if last_activity is None:
|
|
98
|
+
return -1
|
|
99
|
+
|
|
100
|
+
if isinstance(last_activity, str):
|
|
101
|
+
last_activity = datetime.fromisoformat(last_activity.replace("Z", "+00:00"))
|
|
102
|
+
|
|
103
|
+
if last_activity.tzinfo is None:
|
|
104
|
+
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
|
105
|
+
|
|
106
|
+
now = datetime.now(timezone.utc)
|
|
107
|
+
return (now - last_activity).total_seconds()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class JupyterClient:
|
|
111
|
+
"""Client for Jupyter server REST API."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, server_url: str, token: str | None = None):
|
|
114
|
+
self.server_url = server_url.rstrip("/") + "/"
|
|
115
|
+
self.token = token
|
|
116
|
+
self.headers = {"Authorization": f"token {token}"} if token else {}
|
|
117
|
+
self.is_localhost = (
|
|
118
|
+
server_url.startswith("http://localhost")
|
|
119
|
+
or server_url.startswith("http://127.0.0.1")
|
|
120
|
+
or server_url.startswith("http://[::1]")
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _get(self, endpoint: str) -> dict | list:
|
|
124
|
+
url = urljoin(self.server_url, endpoint)
|
|
125
|
+
response = requests.get(url, headers=self.headers, timeout=10)
|
|
126
|
+
response.raise_for_status()
|
|
127
|
+
return response.json()
|
|
128
|
+
|
|
129
|
+
def _delete(self, endpoint: str) -> bool:
|
|
130
|
+
url = urljoin(self.server_url, endpoint)
|
|
131
|
+
response = requests.delete(url, headers=self.headers, timeout=10)
|
|
132
|
+
return response.status_code in (200, 204)
|
|
133
|
+
|
|
134
|
+
def list_kernels(self) -> list[dict]:
|
|
135
|
+
"""List all kernels with their status."""
|
|
136
|
+
kernels = self._get("api/kernels")
|
|
137
|
+
result = []
|
|
138
|
+
for k in kernels:
|
|
139
|
+
result.append({
|
|
140
|
+
"id": k.get("id"),
|
|
141
|
+
"name": k.get("name"),
|
|
142
|
+
"execution_state": k.get("execution_state"),
|
|
143
|
+
"last_activity": k.get("last_activity"),
|
|
144
|
+
"idle_seconds": format_idle_seconds(k.get("last_activity")),
|
|
145
|
+
"idle_time": format_idle_time(k.get("last_activity")),
|
|
146
|
+
})
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
def list_terminals(self) -> list[dict]:
|
|
150
|
+
"""List all terminals with their status."""
|
|
151
|
+
terminals = self._get("api/terminals")
|
|
152
|
+
result = []
|
|
153
|
+
for t in terminals:
|
|
154
|
+
result.append({
|
|
155
|
+
"name": t.get("name"),
|
|
156
|
+
"last_activity": t.get("last_activity"),
|
|
157
|
+
"idle_seconds": format_idle_seconds(t.get("last_activity")),
|
|
158
|
+
"idle_time": format_idle_time(t.get("last_activity")),
|
|
159
|
+
})
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
def list_workspaces(self) -> list[dict]:
|
|
163
|
+
"""List all workspaces with their status."""
|
|
164
|
+
try:
|
|
165
|
+
workspaces = self._get(
|
|
166
|
+
"jupyterlab-kernel-terminal-workspace-culler-extension/workspaces"
|
|
167
|
+
)
|
|
168
|
+
result = []
|
|
169
|
+
for w in workspaces:
|
|
170
|
+
result.append({
|
|
171
|
+
"id": w.get("id"),
|
|
172
|
+
"last_modified": w.get("last_modified"),
|
|
173
|
+
"created": w.get("created"),
|
|
174
|
+
"idle_seconds": format_idle_seconds(w.get("last_modified")),
|
|
175
|
+
"idle_time": format_idle_time(w.get("last_modified")),
|
|
176
|
+
})
|
|
177
|
+
return result
|
|
178
|
+
except Exception:
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
def shutdown_kernel(self, kernel_id: str) -> bool:
|
|
182
|
+
"""Shutdown a kernel."""
|
|
183
|
+
return self._delete(f"api/kernels/{kernel_id}")
|
|
184
|
+
|
|
185
|
+
def terminate_terminal(self, name: str) -> bool:
|
|
186
|
+
"""Terminate a terminal."""
|
|
187
|
+
return self._delete(f"api/terminals/{name}")
|
|
188
|
+
|
|
189
|
+
def get_culler_status(self) -> dict | None:
|
|
190
|
+
"""Get culler status and settings from the extension."""
|
|
191
|
+
try:
|
|
192
|
+
return self._get("jupyterlab-kernel-terminal-workspace-culler-extension/status")
|
|
193
|
+
except Exception:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def get_terminals_connection(self) -> dict[str, bool]:
|
|
197
|
+
"""Get terminal connection status from the extension."""
|
|
198
|
+
try:
|
|
199
|
+
return self._get("jupyterlab-kernel-terminal-workspace-culler-extension/terminals-connection")
|
|
200
|
+
except Exception:
|
|
201
|
+
return {}
|
|
202
|
+
|
|
203
|
+
def cull_workspaces(self, timeout_minutes: int, dry_run: bool = False) -> list[dict]:
|
|
204
|
+
"""Cull workspaces via the backend extension."""
|
|
205
|
+
try:
|
|
206
|
+
url = urljoin(
|
|
207
|
+
self.server_url,
|
|
208
|
+
"jupyterlab-kernel-terminal-workspace-culler-extension/cull-workspaces",
|
|
209
|
+
)
|
|
210
|
+
response = requests.post(
|
|
211
|
+
url,
|
|
212
|
+
headers=self.headers,
|
|
213
|
+
json={"timeout": timeout_minutes, "dry_run": dry_run},
|
|
214
|
+
timeout=30,
|
|
215
|
+
)
|
|
216
|
+
response.raise_for_status()
|
|
217
|
+
return response.json().get("workspaces_culled", [])
|
|
218
|
+
except Exception:
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_list(client: JupyterClient, args: argparse.Namespace) -> int:
|
|
223
|
+
"""List all resources and their idle times."""
|
|
224
|
+
kernels = client.list_kernels()
|
|
225
|
+
terminals = client.list_terminals()
|
|
226
|
+
workspaces = client.list_workspaces()
|
|
227
|
+
culler_status = client.get_culler_status()
|
|
228
|
+
terminals_connection = client.get_terminals_connection()
|
|
229
|
+
|
|
230
|
+
# Add connection status to terminals
|
|
231
|
+
for t in terminals:
|
|
232
|
+
name = t.get("name")
|
|
233
|
+
t["connected"] = terminals_connection.get(name, False)
|
|
234
|
+
|
|
235
|
+
if args.json:
|
|
236
|
+
output = {
|
|
237
|
+
"kernels": kernels,
|
|
238
|
+
"terminals": terminals,
|
|
239
|
+
"workspaces": workspaces,
|
|
240
|
+
"culler": culler_status,
|
|
241
|
+
}
|
|
242
|
+
print(json.dumps(output, indent=2, default=str))
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
# Human-readable output - Settings first
|
|
246
|
+
print("CULLER SETTINGS")
|
|
247
|
+
print("-" * 60)
|
|
248
|
+
if culler_status:
|
|
249
|
+
settings = culler_status.get("settings", {})
|
|
250
|
+
running = culler_status.get("running", False)
|
|
251
|
+
print(f" Status: {'running' if running else 'stopped'}")
|
|
252
|
+
print(f" Check interval: {settings.get('cullCheckInterval', '?')} min")
|
|
253
|
+
print(f" Kernel culling: {'enabled' if settings.get('kernelCullEnabled') else 'disabled'}, timeout: {settings.get('kernelCullIdleTimeout', '?')} min")
|
|
254
|
+
print(f" Terminal culling: {'enabled' if settings.get('terminalCullEnabled') else 'disabled'}, timeout: {settings.get('terminalCullIdleTimeout', '?')} min, disconnected-only: {settings.get('terminalCullDisconnectedOnly', '?')}")
|
|
255
|
+
print(f" Workspace culling: {'enabled' if settings.get('workspaceCullEnabled') else 'disabled'}, timeout: {settings.get('workspaceCullIdleTimeout', '?')} min")
|
|
256
|
+
else:
|
|
257
|
+
print(" (culler extension not available)")
|
|
258
|
+
|
|
259
|
+
print("\nKERNELS")
|
|
260
|
+
print("-" * 60)
|
|
261
|
+
if kernels:
|
|
262
|
+
for k in kernels:
|
|
263
|
+
state = k["execution_state"] or "unknown"
|
|
264
|
+
print(f" {k['id'][:8]} {state:8} idle: {k['idle_time']:>8} ({k['name']})")
|
|
265
|
+
else:
|
|
266
|
+
print(" (none)")
|
|
267
|
+
|
|
268
|
+
print("\nTERMINALS")
|
|
269
|
+
print("-" * 60)
|
|
270
|
+
if terminals:
|
|
271
|
+
for t in terminals:
|
|
272
|
+
conn_status = "connected" if t.get("connected") else "disconnected"
|
|
273
|
+
print(f" {t['name']:8} {conn_status:12} idle: {t['idle_time']:>8}")
|
|
274
|
+
else:
|
|
275
|
+
print(" (none)")
|
|
276
|
+
|
|
277
|
+
print("\nWORKSPACES")
|
|
278
|
+
print("-" * 60)
|
|
279
|
+
if workspaces:
|
|
280
|
+
for w in workspaces:
|
|
281
|
+
ws_id = w["id"] or "unknown"
|
|
282
|
+
protected = " (protected)" if ws_id == "default" else ""
|
|
283
|
+
print(f" {ws_id:12} idle: {w['idle_time']:>8}{protected}")
|
|
284
|
+
else:
|
|
285
|
+
print(" (none)")
|
|
286
|
+
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def cmd_cull(client: JupyterClient, args: argparse.Namespace) -> int:
|
|
291
|
+
"""Cull idle resources."""
|
|
292
|
+
kernels = client.list_kernels()
|
|
293
|
+
terminals = client.list_terminals()
|
|
294
|
+
|
|
295
|
+
# Default timeouts in seconds
|
|
296
|
+
kernel_timeout = args.kernel_timeout * 60
|
|
297
|
+
terminal_timeout = args.terminal_timeout * 60
|
|
298
|
+
|
|
299
|
+
results = {
|
|
300
|
+
"kernels_culled": [],
|
|
301
|
+
"terminals_culled": [],
|
|
302
|
+
"workspaces_culled": [],
|
|
303
|
+
"dry_run": args.dry_run,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# Cull idle kernels (skip busy ones)
|
|
307
|
+
for k in kernels:
|
|
308
|
+
if k["execution_state"] == "busy":
|
|
309
|
+
continue
|
|
310
|
+
if k["idle_seconds"] > 0 and k["idle_seconds"] > kernel_timeout:
|
|
311
|
+
if args.dry_run:
|
|
312
|
+
results["kernels_culled"].append({"id": k["id"], "idle_time": k["idle_time"], "action": "would_cull"})
|
|
313
|
+
else:
|
|
314
|
+
success = client.shutdown_kernel(k["id"])
|
|
315
|
+
results["kernels_culled"].append({"id": k["id"], "idle_time": k["idle_time"], "action": "culled" if success else "failed"})
|
|
316
|
+
|
|
317
|
+
# Cull idle terminals
|
|
318
|
+
for t in terminals:
|
|
319
|
+
if t["idle_seconds"] > 0 and t["idle_seconds"] > terminal_timeout:
|
|
320
|
+
if args.dry_run:
|
|
321
|
+
results["terminals_culled"].append({"name": t["name"], "idle_time": t["idle_time"], "action": "would_cull"})
|
|
322
|
+
else:
|
|
323
|
+
success = client.terminate_terminal(t["name"])
|
|
324
|
+
results["terminals_culled"].append({"name": t["name"], "idle_time": t["idle_time"], "action": "culled" if success else "failed"})
|
|
325
|
+
|
|
326
|
+
# Cull idle workspaces via backend
|
|
327
|
+
workspaces_culled = client.cull_workspaces(args.workspace_timeout, args.dry_run)
|
|
328
|
+
results["workspaces_culled"] = workspaces_culled
|
|
329
|
+
|
|
330
|
+
if args.json:
|
|
331
|
+
print(json.dumps(results, indent=2))
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
# Human-readable output
|
|
335
|
+
prefix = "[DRY RUN] " if args.dry_run else ""
|
|
336
|
+
|
|
337
|
+
if results["kernels_culled"]:
|
|
338
|
+
print(f"{prefix}Kernels culled:")
|
|
339
|
+
for k in results["kernels_culled"]:
|
|
340
|
+
print(f" {k['id'][:8]} idle: {k['idle_time']} ({k['action']})")
|
|
341
|
+
else:
|
|
342
|
+
print(f"{prefix}No kernels to cull")
|
|
343
|
+
|
|
344
|
+
if results["terminals_culled"]:
|
|
345
|
+
print(f"{prefix}Terminals culled:")
|
|
346
|
+
for t in results["terminals_culled"]:
|
|
347
|
+
print(f" {t['name']} idle: {t['idle_time']} ({t['action']})")
|
|
348
|
+
else:
|
|
349
|
+
print(f"{prefix}No terminals to cull")
|
|
350
|
+
|
|
351
|
+
if results["workspaces_culled"]:
|
|
352
|
+
print(f"{prefix}Workspaces culled:")
|
|
353
|
+
for w in results["workspaces_culled"]:
|
|
354
|
+
print(f" {w['id']} idle: {w['idle_time']} ({w['action']})")
|
|
355
|
+
else:
|
|
356
|
+
print(f"{prefix}No workspaces to cull")
|
|
357
|
+
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def main(argv: list[str] | None = None) -> int:
|
|
362
|
+
"""Main entry point for CLI."""
|
|
363
|
+
parser = argparse.ArgumentParser(
|
|
364
|
+
prog="jupyterlab_kernel_terminal_workspace_culler",
|
|
365
|
+
description="List and cull idle Jupyter kernels, terminals, and workspaces.",
|
|
366
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
367
|
+
epilog="""
|
|
368
|
+
Environment variables:
|
|
369
|
+
JUPYTER_SERVER_URL Jupyter server URL (e.g., http://localhost:8888/)
|
|
370
|
+
JUPYTER_TOKEN Jupyter server authentication token
|
|
371
|
+
|
|
372
|
+
Examples:
|
|
373
|
+
%(prog)s list List all resources and idle times
|
|
374
|
+
%(prog)s list --json List as JSON
|
|
375
|
+
%(prog)s cull --dry-run Show what would be culled
|
|
376
|
+
%(prog)s cull Cull idle resources
|
|
377
|
+
%(prog)s cull --json Cull and output as JSON
|
|
378
|
+
%(prog)s cull --kernel-timeout 30 Cull kernels idle > 30 minutes
|
|
379
|
+
%(prog)s cull --workspace-timeout 1 Cull workspaces idle > 1 minute
|
|
380
|
+
""",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
parser.add_argument("--server-url", help="Jupyter server URL (overrides JUPYTER_SERVER_URL)")
|
|
384
|
+
parser.add_argument("--token", help="Jupyter server token (overrides JUPYTER_TOKEN)")
|
|
385
|
+
|
|
386
|
+
subparsers = parser.add_subparsers(dest="command", title="commands")
|
|
387
|
+
|
|
388
|
+
# list command
|
|
389
|
+
list_parser = subparsers.add_parser("list", help="List all resources and their idle times")
|
|
390
|
+
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
391
|
+
list_parser.set_defaults(func=cmd_list)
|
|
392
|
+
|
|
393
|
+
# cull command
|
|
394
|
+
cull_parser = subparsers.add_parser("cull", help="Cull idle resources")
|
|
395
|
+
cull_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
396
|
+
cull_parser.add_argument("--dry-run", action="store_true", help="Simulate culling without actually terminating")
|
|
397
|
+
cull_parser.add_argument("--kernel-timeout", type=int, default=60, metavar="MIN", help="Kernel idle timeout in minutes (default: 60)")
|
|
398
|
+
cull_parser.add_argument("--terminal-timeout", type=int, default=60, metavar="MIN", help="Terminal idle timeout in minutes (default: 60)")
|
|
399
|
+
cull_parser.add_argument("--workspace-timeout", type=int, default=10080, metavar="MIN", help="Workspace idle timeout in minutes (default: 10080 = 7 days)")
|
|
400
|
+
cull_parser.set_defaults(func=cmd_cull)
|
|
401
|
+
|
|
402
|
+
args = parser.parse_args(argv)
|
|
403
|
+
|
|
404
|
+
# Show help if no command
|
|
405
|
+
if not args.command:
|
|
406
|
+
parser.print_help()
|
|
407
|
+
return 0
|
|
408
|
+
|
|
409
|
+
# Get server URL and token (auto-detect or from args)
|
|
410
|
+
if args.server_url:
|
|
411
|
+
server_url = args.server_url
|
|
412
|
+
token = args.token # Use provided token or None
|
|
413
|
+
else:
|
|
414
|
+
server_url, auto_token = get_jupyter_server_info()
|
|
415
|
+
token = args.token if args.token else auto_token
|
|
416
|
+
|
|
417
|
+
client = JupyterClient(server_url, token)
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
return args.func(client, args)
|
|
421
|
+
except requests.exceptions.ConnectionError:
|
|
422
|
+
print(f"Error: Cannot connect to Jupyter server at {server_url}", file=sys.stderr)
|
|
423
|
+
return 1
|
|
424
|
+
except requests.exceptions.HTTPError as e:
|
|
425
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
426
|
+
return 1
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
if __name__ == "__main__":
|
|
430
|
+
sys.exit(main())
|