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.
Files changed (23) hide show
  1. jupyterlab_kernel_terminal_workspace_culler_extension/__init__.py +54 -0
  2. jupyterlab_kernel_terminal_workspace_culler_extension/_version.py +4 -0
  3. jupyterlab_kernel_terminal_workspace_culler_extension/cli.py +430 -0
  4. jupyterlab_kernel_terminal_workspace_culler_extension/culler.py +499 -0
  5. jupyterlab_kernel_terminal_workspace_culler_extension/routes.py +176 -0
  6. jupyterlab_kernel_terminal_workspace_culler_extension/tests/__init__.py +1 -0
  7. jupyterlab_kernel_terminal_workspace_culler_extension/tests/test_culler.py +269 -0
  8. jupyterlab_kernel_terminal_workspace_culler_extension/tests/test_routes.py +17 -0
  9. 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
  10. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/install.json +5 -0
  11. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.data/data/share/jupyter/labextensions/jupyterlab_kernel_terminal_workspace_culler_extension/package.json +219 -0
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. 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
  19. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/METADATA +208 -0
  20. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/RECORD +23 -0
  21. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/WHEEL +4 -0
  22. jupyterlab_kernel_terminal_workspace_culler_extension-1.0.21.dist-info/entry_points.txt +2 -0
  23. 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,4 @@
1
+ # This file is auto-generated by Hatchling. As such, do not:
2
+ # - modify
3
+ # - track in version control e.g. be sure to add to .gitignore
4
+ __version__ = VERSION = '1.0.21'
@@ -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())