From 30622c871b50e87a8a467fa4ed5f1846e12920f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sofus=20Albert=20H=C3=B8gsbro=20Rose?= Date: Wed, 15 Jan 2025 12:04:12 +0100 Subject: [PATCH] feat: New "Copy URL" button in revamped UI --- bpy_jupyter/contracts/operator_types.py | 1 + bpy_jupyter/operators/__init__.py | 5 +- .../operators/copy_jupy_url_to_clip.py | 155 ++++++++++++++++++ bpy_jupyter/operators/start_jupyter_kernel.py | 2 +- bpy_jupyter/operators/stop_jupyter_kernel.py | 2 +- bpy_jupyter/panels/jupyter_panel.py | 102 ++++++++---- bpy_jupyter/services/jupyter_kernel.py | 56 ++++++- pyproject.toml | 1 + uv.lock | 11 ++ 9 files changed, 298 insertions(+), 37 deletions(-) create mode 100644 bpy_jupyter/operators/copy_jupy_url_to_clip.py diff --git a/bpy_jupyter/contracts/operator_types.py b/bpy_jupyter/contracts/operator_types.py index 2d9878e..9a39694 100644 --- a/bpy_jupyter/contracts/operator_types.py +++ b/bpy_jupyter/contracts/operator_types.py @@ -26,3 +26,4 @@ class OperatorType(enum.StrEnum): StartJupyterKernel = f'{ADDON_NAME}.start_jupyter_kernel' StopJupyterKernel = f'{ADDON_NAME}.stop_jupyter_kernel' + CopyJupyURLToClip = f'{ADDON_NAME}.copy_jupy_url_to_clip' diff --git a/bpy_jupyter/operators/__init__.py b/bpy_jupyter/operators/__init__.py index ca9201f..a109bd7 100644 --- a/bpy_jupyter/operators/__init__.py +++ b/bpy_jupyter/operators/__init__.py @@ -19,21 +19,24 @@ from functools import reduce from .. import contracts as ct -from . import start_jupyter_kernel, stop_jupyter_kernel +from . import copy_jupy_url_to_clip, start_jupyter_kernel, stop_jupyter_kernel BL_REGISTER: list[ct.BLClass] = [ *start_jupyter_kernel.BL_REGISTER, *stop_jupyter_kernel.BL_REGISTER, + *copy_jupy_url_to_clip.BL_REGISTER, ] BL_HANDLERS: ct.BLHandlers = reduce( lambda a, b: a + b, [ start_jupyter_kernel.BL_HANDLERS, stop_jupyter_kernel.BL_HANDLERS, + copy_jupy_url_to_clip.BL_HANDLERS, ], ct.BLHandlers(), ) BL_KEYMAP_ITEMS: list[ct.BLKeymapItem] = [ *start_jupyter_kernel.BL_KEYMAP_ITEMS, *stop_jupyter_kernel.BL_KEYMAP_ITEMS, + *copy_jupy_url_to_clip.BL_KEYMAP_ITEMS, ] diff --git a/bpy_jupyter/operators/copy_jupy_url_to_clip.py b/bpy_jupyter/operators/copy_jupy_url_to_clip.py new file mode 100644 index 0000000..bdbb71d --- /dev/null +++ b/bpy_jupyter/operators/copy_jupy_url_to_clip.py @@ -0,0 +1,155 @@ +# bpy_jupyter +# Copyright (C) 2025 bpy_jupyter Project Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Defines the `CopyJupyURLToClip` operator. + +Inspired by +""" + +import datetime +import ipaddress +import secrets + +import bpy +import pyperclipfix + +from .. import contracts as ct +from ..services import jupyter_kernel as jkern + +#################### +# - Constants +#################### +CLIPBOARD_CLEAR_DELAY = 45 + + +#################### +# - Constants +#################### +class CopyJupyURLToClip(bpy.types.Operator): + """Copy a Jupyter Server URL to the system clipboard. The system clipboard will be cleared after a timeout, unless otherwise altered.""" + + bl_idname = ct.OperatorType.CopyJupyURLToClip + bl_label = 'Copy Jupyter Server URL' + + _timer = None + _start_time = None + url_type: bpy.props.EnumProperty( + name='Jupyter URL Type', + description='The type of Jupyter URL to copy to the clipboard.', + items=[ + ( + 'API', + 'API Server', + 'The API server, which allows connections from any Jupyter client.', + ), + ( + 'LAB', + 'Lab Server', + 'The Lab server, which provides a browser-based IDE.', + ), + ], + default='API', + ) + + def jupyter_url( + self, + jupyter_ip: ipaddress.IPv4Address | ipaddress.IPv6Address, + jupyter_port: int, + ) -> str: + match self.url_type: + case 'API': + return jkern.jupyter_api_url(jupyter_ip, jupyter_port) + case 'LAB': + return jkern.jupyter_lab_url(jupyter_ip, jupyter_port) + + msg = 'URL Type not valid' + raise ValueError(msg) + + @classmethod + def poll(cls, _: bpy.types.Context) -> bool: + return jkern.is_kernel_running() + + def modal( + self, context: bpy.types.Context, event: bpy.types.Event + ) -> set[ct.BLOperatorStatus]: + if ( + event.type == 'TIMER' + and (datetime.datetime.now() - self._start_time).seconds + > CLIPBOARD_CLEAR_DELAY // 2 + ): + # Stop the Timer + wm = context.window_manager + wm.event_timer_remove(self._timer) + self._timer = None + self.start_time = None + + # Clear Clipboard + ## - ONLY if the clipboard still contains our token. + ## - This helps prevent interfering with the user's life. + clipboard: str = pyperclipfix.paste() + if secrets.compare_digest( + clipboard, + self.jupyter_url( + ipaddress.IPv4Address(context.scene.jupyter_ip), + context.scene.jupyter_port, + ), + ): + pyperclipfix.copy(' ') + self.report( + {'INFO'}, + 'Cleared System Clipboard', + ) + + return {'FINISHED'} + return {'PASS_THROUGH'} + + def execute(self, context: bpy.types.Context) -> set[ct.BLOperatorStatus]: + # Setup Timer + wm = context.window_manager + self._timer = wm.event_timer_add(CLIPBOARD_CLEAR_DELAY, window=context.window) + wm.modal_handler_add(self) + + self._start_time = datetime.datetime.now() + + # Set System Clipboard to Jupyter Server URL + pyperclipfix.copy( + self.jupyter_url( + ipaddress.IPv4Address(context.scene.jupyter_ip), + context.scene.jupyter_port, + ) + ) + self.report( + {'INFO'}, + f'Copied URL to System Clipboard. Will clear in {CLIPBOARD_CLEAR_DELAY} seconds.', + ) + + return {'RUNNING_MODAL'} + + def cancel(self, context): + jkern.stop_kernel() + + wm = context.window_manager + wm.event_timer_remove(self._timer) + + self._timer = None + + +#################### +# - Blender Registration +#################### +BL_REGISTER = [CopyJupyURLToClip] +BL_HANDLERS: ct.BLHandlers = ct.BLHandlers() +BL_KEYMAP_ITEMS: list[ct.BLKeymapItem] = [] diff --git a/bpy_jupyter/operators/start_jupyter_kernel.py b/bpy_jupyter/operators/start_jupyter_kernel.py index 9b97cf7..855d5cd 100644 --- a/bpy_jupyter/operators/start_jupyter_kernel.py +++ b/bpy_jupyter/operators/start_jupyter_kernel.py @@ -30,7 +30,7 @@ class StartJupyterKernel(bpy.types.Operator): - """Operator that starts a Jupyter kernel within Blender.""" + """Start a notebook kernel, and Jupyter Lab server, from within Blender.""" bl_idname = ct.OperatorType.StartJupyterKernel bl_label = 'Start Jupyter Kernel' diff --git a/bpy_jupyter/operators/stop_jupyter_kernel.py b/bpy_jupyter/operators/stop_jupyter_kernel.py index c0fcb2f..01ade91 100644 --- a/bpy_jupyter/operators/stop_jupyter_kernel.py +++ b/bpy_jupyter/operators/stop_jupyter_kernel.py @@ -29,7 +29,7 @@ class StopJupyterKernel(bpy.types.Operator): - """Operator that starts a Jupyter kernel within Blender.""" + """Stop a notebook kernel and Jupyter Lab server running within Blender.""" bl_idname = ct.OperatorType.StopJupyterKernel bl_label = 'Stop Jupyter Kernel' diff --git a/bpy_jupyter/panels/jupyter_panel.py b/bpy_jupyter/panels/jupyter_panel.py index 3218785..e19b25d 100644 --- a/bpy_jupyter/panels/jupyter_panel.py +++ b/bpy_jupyter/panels/jupyter_panel.py @@ -31,7 +31,7 @@ ( 'IPYKERNEL', 'IPyKernel', - 'A traditional, well-tested Python notebook kernel', + 'IPyKernel is the standard Python notebook kernel', ), # ( # 'MARIMO', @@ -45,13 +45,13 @@ # Behavior bpy.types.Scene.jupyter_notebook_dir = bpy.props.StringProperty( name='Notebook Root Folder', - description='The default notebook folder.', + description='The top-level folder in which the Jupyter server can find and store notebooks.', subtype='DIR_PATH', default=platformdirs.user_desktop_dir(), ) bpy.types.Scene.jupyter_launch_browser = bpy.props.BoolProperty( name='Auto-Launch Browser?', - description='Whether to launch a browser automatically when starting the kernel', + description='Whether to launch the default browser automatically, pointing to Jupyter Lab, when starting Jupyter.', default=True, ) @@ -59,13 +59,13 @@ ## - See https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers bpy.types.Scene.jupyter_ip = bpy.props.StringProperty( name='Jupyter Network IP', - description='IP address of the Jupyter server', + description='IPv4 address of the launched Jupyter server', default='127.0.0.1', ) bpy.types.Scene.jupyter_port = bpy.props.IntProperty( name='Jupyter Network Port', - description='Network port of the Jupyter server', + description='Network port of the launched Jupyter server', min=1024, max=49151, default=10462, @@ -111,32 +111,65 @@ def draw(self, context: bpy.types.Context) -> None: split_fac = 0.25 layout = self.layout - # Kernel Panel + #################### + # - Section: Stop/Start Kernel + #################### + # Operator: Start Kernel row = layout.row(align=True) row.enabled = not jkern.is_kernel_running() - split = row.split(factor=split_fac, align=True) + split = row.split(factor=0.85, align=True) split.alignment = 'RIGHT' - split.label(text='Kernel') - split.prop(context.scene, 'jupyter_kernel_type', text='') + split.operator(ct.OperatorType.StartJupyterKernel) + split.prop( + context.scene, + 'jupyter_launch_browser', + icon=ct.Icon.LaunchBrowser, + toggle=True, + icon_only=True, + ) + # Operator: Stop Kernel row = layout.row(align=True) - row.enabled = not jkern.is_kernel_running() - split = row.split(factor=split_fac, align=True) - split.alignment = 'RIGHT' - split.label(text='Root Dir') - split.prop(context.scene, 'jupyter_notebook_dir', text='') + row.operator(ct.OperatorType.StopJupyterKernel) + #################### + # - Section: Basic Options + #################### header, body = layout.panel( - 'jupyter_network_subpanel', + 'jupyter_basic_options_subpanel', + default_closed=False, + ) + header.label(text='Basic Options') + if body is not None: + # Kernel Panel + row = body.row(align=True) + row.enabled = not jkern.is_kernel_running() + split = row.split(factor=split_fac, align=True) + split.alignment = 'RIGHT' + split.label(text='Kernel') + split.prop(context.scene, 'jupyter_kernel_type', text='') + + row = body.row(align=True) + row.enabled = not jkern.is_kernel_running() + split = row.split(factor=split_fac, align=True) + split.alignment = 'RIGHT' + split.label(text='Root Dir') + split.prop(context.scene, 'jupyter_notebook_dir', text='') + + #################### + # - Section: Network Options + #################### + header, body = layout.panel( + 'jupyter_network_options_subpanel', default_closed=True, ) - header.label(text='Network') + header.label(text='Network Options') if body is not None: body_row = body.row(align=True) body_row.enabled = not jkern.is_kernel_running() body_split = body_row.split(factor=split_fac, align=True) body_split.alignment = 'RIGHT' - body_split.label(text='IP') + body_split.label(text='IPv4') body_split.prop(context.scene, 'jupyter_ip', text='') body_row = body.row(align=True) @@ -146,23 +179,28 @@ def draw(self, context: bpy.types.Context) -> None: body_split.label(text='Port') body_split.prop(context.scene, 'jupyter_port', text='') - # Operator: Start Kernel - row = layout.row(align=True) - row.enabled = not jkern.is_kernel_running() - split = row.split(factor=0.85, align=True) - split.alignment = 'RIGHT' - split.operator(ct.OperatorType.StartJupyterKernel) - split.prop( - context.scene, - 'jupyter_launch_browser', - icon=ct.Icon.LaunchBrowser, - toggle=True, - icon_only=True, + #################### + # - Section: Copyable URLs + #################### + header, body = layout.panel( + 'jupyter_copy_urls_subpanel', + default_closed=False, ) + header.label(text='URLs') + if body is not None: + # Label + row = body.row(align=False) + row.alignment = 'CENTER' + row.label(text='Copy URLs to Clipboard:') - # Operator: Stop Kernel - row = layout.row(align=True) - row.operator(ct.OperatorType.StopJupyterKernel) + # Operators + row = body.row(align=False) + + op = row.operator(ct.OperatorType.CopyJupyURLToClip, text='Lab URL') + op.url_type = 'LAB' + + op = row.operator(ct.OperatorType.CopyJupyURLToClip, text='API URL') + op.url_type = 'API' #################### diff --git a/bpy_jupyter/services/jupyter_kernel.py b/bpy_jupyter/services/jupyter_kernel.py index 4271102..522bd7f 100644 --- a/bpy_jupyter/services/jupyter_kernel.py +++ b/bpy_jupyter/services/jupyter_kernel.py @@ -13,7 +13,18 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +""" +References: + +- IPython Kernel Options: +- Wrapper Kernels: +- Jupyter Lab Connect to Existing Kernel: +- `pyxll-jupyter` Custom Kernel Provisioner: +- `marimo-blender`: https://github.com/iplai/marimo-blender/blob/main/marimo_blender/addon_setup.py +""" + +import secrets import asyncio import ipaddress import multiprocessing @@ -96,6 +107,8 @@ def shutdown_kernel(path_connection_file: Path) -> None: _PATH_JUPYTER_CONNECTION_FILE: Path | None = None +_SECRET_TOKEN: str | None = None + #################### # - Actions @@ -110,7 +123,7 @@ def start_kernel( jupyter_port: int, ) -> None: """Start the jupyter kernel in Blender, and expose it by starting the Jupyter notebook server in a subprocess.""" - global _JUPYTER, _KERNEL, _PATH_JUPYTER_CONNECTION_FILE, _RUNNING # noqa: PLW0603 + global _JUPYTER, _KERNEL, _PATH_JUPYTER_CONNECTION_FILE, _RUNNING, _SECRET_TOKEN # noqa: PLW0603 if kernel_type != 'IPYKERNEL': raise NotImplementedError @@ -129,6 +142,7 @@ def start_kernel( _KERNEL.initialize([sys.executable]) # type: ignore[no-untyped-call] _KERNEL.kernel.start() + _SECRET_TOKEN = secrets.token_urlsafe(32) _JUPYTER = subprocess.Popen( [ sys.executable, @@ -138,6 +152,7 @@ def start_kernel( f'--ip={jupyter_ip!s}', f'--port={jupyter_port!s}', f'--notebook-dir={notebook_dir!s}', + f'--IdentityProvider.token={_SECRET_TOKEN!s}', *(['--no-browser'] if not launch_browser else []), '--KernelProvisionerFactory.default_provisioner_name=pyxll-provisioner', ], @@ -157,12 +172,14 @@ def start_kernel( def stop_kernel() -> None: """Stop a running the jupyter kernel in Blender, and stop a running Jupyter notebook server as well.""" - global _JUPYTER, _KERNEL, _PATH_JUPYTER_CONNECTION_FILE, _WAITING_TO_STOP, _RUNNING # noqa: PLW0603 + global _JUPYTER, _KERNEL, _PATH_JUPYTER_CONNECTION_FILE, _WAITING_TO_STOP, _RUNNING, _SECRET_TOKEN # noqa: PLW0603 # Start Kernel Shutdown shutdown_kernel(_PATH_JUPYTER_CONNECTION_FILE) with _LOCK: + _SECRET_TOKEN = None + # Stop the Jupyter Notebook Server if _JUPYTER is not None: proc = _JUPYTER @@ -228,6 +245,41 @@ def queue_kernel_stop() -> None: _WAITING_TO_STOP = True +#################### +# - Information +#################### +def jupyter_api_url( + jupyter_ip: ipaddress.IPv4Address | ipaddress.IPv6Address, + jupyter_port: int, +) -> str: + """The URL of the Jupyter notebook server. + + Used to connect via external IDEs like VSCodium. + """ + with _LOCK: + if _SECRET_TOKEN is not None: + return f'http://{jupyter_ip!s}:{jupyter_port!s}/?token={_SECRET_TOKEN}' + + msg = 'No jupyter kernel is running; cannot get token.' + raise ValueError(msg) + + +def jupyter_lab_url( + jupyter_ip: ipaddress.IPv4Address | ipaddress.IPv6Address, + jupyter_port: int, +) -> str: + """The URL of the Jupyter lab server. + + Used to access the browser-based IDE. + """ + with _LOCK: + if _SECRET_TOKEN is not None: + return f'http://{jupyter_ip!s}:{jupyter_port!s}/lab?token={_SECRET_TOKEN}' + + msg = 'No jupyter kernel is running; cannot get token.' + raise ValueError(msg) + + #################### # - Status #################### diff --git a/pyproject.toml b/pyproject.toml index c480ddb..da9761d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "ipykernel==6.29.5", "jupyter-client>=8.6.3", "platformdirs>=4.3.6", + "pyperclipfix>=1.9.4", ] [project.urls] diff --git a/uv.lock b/uv.lock index 07a2273..dc8e1e1 100644 --- a/uv.lock +++ b/uv.lock @@ -279,6 +279,7 @@ dependencies = [ { name = "jupyterlab" }, { name = "platformdirs" }, { name = "pydantic" }, + { name = "pyperclipfix" }, { name = "pyxll-jupyter" }, { name = "rich" }, ] @@ -305,6 +306,7 @@ requires-dist = [ { name = "jupyterlab", specifier = ">=4.2.5" }, { name = "platformdirs", specifier = ">=4.3.6" }, { name = "pydantic", specifier = ">=2.9.2" }, + { name = "pyperclipfix", specifier = ">=1.9.4" }, { name = "pyxll-jupyter", specifier = ">=0.5.3" }, { name = "rich", specifier = ">=13.9.3" }, ] @@ -1579,6 +1581,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/5e/ac68c41650d7b29f1c52e4fc0923f6a098a4070d44e340c3c570fb15005d/pypdl-1.5.1-py3-none-any.whl", hash = "sha256:e95d5ea48a56bf807b73b96ebd48e918648f7cb432dbbe8b8968f93d9ad47e76", size = 20712 }, ] +[[package]] +name = "pyperclipfix" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/cd/f4e48c462a8b686cf24c1ff20f8ab9b0a390c4517ef520f01b1f181e79ad/pyperclipfix-1.9.4.tar.gz", hash = "sha256:a6c1bbe582190f0e141d338cd33bebf707aa782cdc5b93df140b142b52747da1", size = 21112 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/73/8a8a044b4d5ede957d7edc1f2706f0e566ea7474bf66883f798b97d5f6ce/pyperclipfix-1.9.4-py3-none-any.whl", hash = "sha256:345260636d601f291278c91728f52a87cc5eeb9f26a432d858abebac41f56dbf", size = 11181 }, +] + [[package]] name = "pyqt5" version = "5.15.11"