From 8cb8b8226a8bc5386b1ebfed4d374f2d8602f34b Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Wed, 21 May 2025 16:25:41 +0200 Subject: [PATCH 01/28] basic functionality --- gui/wxpython/Makefile | 4 +- gui/wxpython/jupyter_notebook/panel.py | 236 +++++++++++++++++++++++++ gui/wxpython/lmgr/toolbars.py | 8 + gui/wxpython/main_window/frame.py | 12 ++ 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 gui/wxpython/jupyter_notebook/panel.py diff --git a/gui/wxpython/Makefile b/gui/wxpython/Makefile index a661e593bfc..1764e35cfa7 100644 --- a/gui/wxpython/Makefile +++ b/gui/wxpython/Makefile @@ -9,7 +9,7 @@ include $(MODULE_TOPDIR)/include/Make/Python.make DSTDIR = $(GUIDIR)/wxpython SRCFILES := $(wildcard icons/*.py xml/*) \ - $(wildcard animation/*.py core/*.py datacatalog/*.py history/*.py dbmgr/*.py gcp/*.py gmodeler/*.py \ + $(wildcard animation/*.py core/*.py datacatalog/*.py jupyter_notebook/*.py history/*.py dbmgr/*.py gcp/*.py gmodeler/*.py \ gui_core/*.py iclass/*.py lmgr/*.py location_wizard/*.py main_window/*.py mapwin/*.py mapdisp/*.py \ mapswipe/*.py modules/*.py nviz/*.py psmap/*.py rdigit/*.py \ rlisetup/*.py startup/*.py timeline/*.py vdigit/*.py \ @@ -19,7 +19,7 @@ SRCFILES := $(wildcard icons/*.py xml/*) \ DSTFILES := $(patsubst %,$(DSTDIR)/%,$(SRCFILES)) \ $(patsubst %.py,$(DSTDIR)/%.pyc,$(filter %.py,$(SRCFILES))) -PYDSTDIRS := $(patsubst %,$(DSTDIR)/%,animation core datacatalog history dbmgr gcp gmodeler \ +PYDSTDIRS := $(patsubst %,$(DSTDIR)/%,animation core datacatalog jupyter_notebook history dbmgr gcp gmodeler \ gui_core iclass lmgr location_wizard main_window mapwin mapdisp modules nviz psmap \ mapswipe vdigit wxplot web_services rdigit rlisetup startup \ vnet timeline iscatt tplot photo2image image2target) diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py new file mode 100644 index 00000000000..3e9d9cb3341 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -0,0 +1,236 @@ +""" +@package jupyter_notebook::panel + +@brief Integration of Jupyter notebook to GUI. + +Classes: + - panel::JupyterPanel + +(C) 2025 by the GRASS Development Team + +This program is free software under the GNU General Public License +(>=v2). Read the file COPYING that comes with GRASS for details. + +@author Linda Karlovska +""" + +import json +import subprocess +import threading +from pathlib import Path + +import wx + +try: + import wx.html2 as html # wx.html2 is available in wxPython 4.0 and later +except ImportError as e: + raise RuntimeError(_("wx.html2 is required for Jupyter integration.")) from e + +import grass.script as gs +import grass.jupyter as gj + +from main_window.page import MainPageBase + + +class JupyterPanel(wx.Panel, MainPageBase): + def __init__( + self, + parent, + giface, + id=wx.ID_ANY, + title=_("Jupyter Notebook"), + statusbar=None, + dockable=False, + **kwargs, + ): + """Jupyter Notebook main panel + :param parent: parent window + :param giface: GRASS interface + :param id: window id + :param title: window title + + :param kwargs: wx.Panel' arguments + """ + self.parent = parent + self._giface = giface + self.statusbar = statusbar + + wx.Panel.__init__(self, parent=parent, id=id, **kwargs) + MainPageBase.__init__(self, dockable) + + self.SetName("Jupyter") + + def start_jupyter_server(self, notebooks_dir): + """Spustí Jupyter notebook server v daném adresáři na volném portu.""" + import socket + import time + + # Najdi volný port + sock = socket.socket() + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + + # Spusť server v samostatném vlákně + def run_server(): + subprocess.Popen( + [ + "jupyter", + "notebook", + "--no-browser", + "--NotebookApp.token=''", + "--NotebookApp.password=''", + "--port", + str(port), + "--notebook-dir", + notebooks_dir, + ] + ) + + threading.Thread(target=run_server, daemon=True).start() + + output = subprocess.check_output(["jupyter", "notebook", "list"]).decode() + print(output) + + # Počkej, až server naběhne (lepší by bylo kontrolovat výstup, zde jen krátké čekání) + time.sleep(3) + print(port) + return f"http://localhost:{port}" + + def SetUpPage(self): + """Set up the Jupyter Notebook interface.""" + gisenv = gs.gisenv() + gisdbase = gisenv["GISDBASE"] + location = gisenv["LOCATION_NAME"] + mapset = gisenv["MAPSET"] + mapset_path = f"{gisdbase}/{location}/{mapset}" + notebooks_dir = Path(mapset_path) / "notebooks" + notebooks_dir.mkdir(parents=True, exist_ok=True) + self.session = gj.init(mapset_path) + + # Spusť Jupyter server v adresáři notebooks + url_base = self.start_jupyter_server(notebooks_dir) + + # Najdi všechny .ipynb soubory v notebooks/ + ipynb_files = [f for f in Path.iterdir(notebooks_dir) if f.endswith(".ipynb")] + print(ipynb_files) + + if not ipynb_files: + print("No notebooks found in the directory.") + # Pokud nejsou žádné soubory, vytvoř template + new_notebook_name = "template.ipynb" + print(new_notebook_name) + new_notebook_path = Path(notebooks_dir) / (new_notebook_name) + template = { + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Template file\n", + ], + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can add your own code here\n", + "or create new notebooks in the GRASS GUI\n", + "and they will be automatically saved in the directory: `{}`\n".format( + notebooks_dir.replace("\\", "/") + ), + "and opened in the Jupyter Notebook interface.\n", + "\n", + ], + }, + { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": [ + "import grass.script as gs", + ], + }, + { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": [ + "print('Raster maps in the current mapset:')\n", + "for rast in gs.list_strings(type='raster'):\n", + " print(' ', rast)\n", + ], + }, + { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": [ + "print('\\nVector maps in the current mapset:')\n", + "for vect in gs.list_strings(type='vector'):\n", + " print(' ', vect)\n", + ], + }, + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3", + }, + "language_info": {"name": "python", "version": "3.x"}, + }, + "nbformat": 4, + "nbformat_minor": 2, + } + print(new_notebook_path) + print("template") + with open(new_notebook_path, "w", encoding="utf-8") as f: + json.dump(template, f, ensure_ascii=False, indent=2) + ipynb_files.append(new_notebook_name) + + notebook = wx.Notebook(self) + + # Po načtení stránky injektujte JS pro skrytí File menu + def hide_file_menu(event): + browser = event.GetEventObject() + print(browser) + js = """ + var interval = setInterval(function() { + // Skrytí File menu + var fileMenu = document.querySelector('li#file_menu, a#filelink, a[aria-controls="file_menu"]'); + if (fileMenu) { + if (fileMenu.tagName === "LI") { + fileMenu.style.display = 'none'; + } else if (fileMenu.parentElement && fileMenu.parentElement.tagName === "LI") { + fileMenu.parentElement.style.display = 'none'; + } + } + // Skrytí horního panelu + var header = document.getElementById('header-container'); + if (header) { + header.style.display = 'none'; + } + // Ukonči interval, pokud jsou oba prvky nalezeny + if (fileMenu && header) { + clearInterval(interval); + } + }, 500); + """ + + browser.RunScript(js) + + for fname in ipynb_files: + url_base = url_base.rstrip("/") + url = f"{url_base}/notebooks/{fname}" + browser = html.WebView.New(notebook) + wx.CallAfter(browser.LoadURL, url) + wx.CallAfter(browser.Bind, html.EVT_WEBVIEW_LOADED, hide_file_menu) + notebook.AddPage(browser, fname) + + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(notebook, 1, wx.EXPAND) + self.SetSizer(sizer) diff --git a/gui/wxpython/lmgr/toolbars.py b/gui/wxpython/lmgr/toolbars.py index cdcc1a6ac91..b4312d06ac8 100644 --- a/gui/wxpython/lmgr/toolbars.py +++ b/gui/wxpython/lmgr/toolbars.py @@ -202,6 +202,9 @@ def _toolbarData(self): "newdisplay": MetaIcon( img="monitor-create", label=_("Start new map display") ), + "newjupyter": MetaIcon( + img="monitor-create", label=_("Jupyter Notebook Console") + ), "mapcalc": MetaIcon( img="raster-calculator", label=_("Raster Map Calculator") ), @@ -223,6 +226,11 @@ def _toolbarData(self): icons["newdisplay"], self.parent.OnNewDisplay, ), + ( + ("newjupyter", _("New jupyter notebook")), + icons["newjupyter"], + self.parent.OnNewJupyterNotebook, + ), (None,), ( ("mapCalc", icons["mapcalc"].label), diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index ebcb420dbd5..3ad2865826b 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -906,6 +906,18 @@ def OnGModeler(self, event=None, cmd=None): # add map display panel to notebook and make it current self.mainnotebook.AddPage(gmodeler_panel, _("Graphical Modeler")) + def OnNewJupyterNotebook(self, event=None, cmd=None): + """Launch Jupyter Notebook page. See OnIClass documentation""" + from jupyter_notebook.panel import JupyterPanel + + jupyter_panel = JupyterPanel( + parent=self, giface=self._giface, statusbar=self.statusbar, dockable=True + ) + jupyter_panel.SetUpPage() + + # add map display panel to notebook and make it current + self.mainnotebook.AddPage(jupyter_panel, _("Jupyter Notebook")) + def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" from psmap.frame import PsMapFrame From 1a718f39fae1987e868f371e7ac2d3526118f787 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 23 May 2025 12:09:37 +0200 Subject: [PATCH 02/28] not working version - but already refactored hugely --- gui/wxpython/jupyter_notebook/panel.py | 236 +++++------------- python/grass/Makefile | 1 + python/grass/notebooks/Makefile | 19 ++ python/grass/notebooks/__init__.py | 41 +++ python/grass/notebooks/directory.py | 136 ++++++++++ python/grass/notebooks/launcher.py | 167 +++++++++++++ .../template_notebooks/template.ipynb | 77 ++++++ 7 files changed, 503 insertions(+), 174 deletions(-) create mode 100644 python/grass/notebooks/Makefile create mode 100644 python/grass/notebooks/__init__.py create mode 100644 python/grass/notebooks/directory.py create mode 100644 python/grass/notebooks/launcher.py create mode 100644 python/grass/notebooks/template_notebooks/template.ipynb diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 3e9d9cb3341..618a81c391b 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -14,11 +14,6 @@ @author Linda Karlovska """ -import json -import subprocess -import threading -from pathlib import Path - import wx try: @@ -26,11 +21,11 @@ except ImportError as e: raise RuntimeError(_("wx.html2 is required for Jupyter integration.")) from e -import grass.script as gs -import grass.jupyter as gj - from main_window.page import MainPageBase +from grass.notebooks.launcher import NotebookServerManager +from grass.notebooks.directory import NotebookDirectoryManager + class JupyterPanel(wx.Panel, MainPageBase): def __init__( @@ -60,177 +55,70 @@ def __init__( self.SetName("Jupyter") - def start_jupyter_server(self, notebooks_dir): - """Spustí Jupyter notebook server v daném adresáři na volném portu.""" - import socket - import time - - # Najdi volný port - sock = socket.socket() - sock.bind(("", 0)) - port = sock.getsockname()[1] - sock.close() - - # Spusť server v samostatném vlákně - def run_server(): - subprocess.Popen( - [ - "jupyter", - "notebook", - "--no-browser", - "--NotebookApp.token=''", - "--NotebookApp.password=''", - "--port", - str(port), - "--notebook-dir", - notebooks_dir, - ] - ) - - threading.Thread(target=run_server, daemon=True).start() - - output = subprocess.check_output(["jupyter", "notebook", "list"]).decode() - print(output) - - # Počkej, až server naběhne (lepší by bylo kontrolovat výstup, zde jen krátké čekání) - time.sleep(3) - print(port) - return f"http://localhost:{port}" + def _hide_file_menu(self, event): + """Inject JavaScript to hide Jupyter's File menu + and header after load. + :param event: wx.EVT_WEBVIEW_LOADED event + """ + # Hide File menu and header + webview = event.GetEventObject() + js = """ + var interval = setInterval(function() { + // Hide File menu + var fileMenu = document.querySelector('li#file_menu, a#filelink, a[aria-controls="file_menu"]'); + if (fileMenu) { + if (fileMenu.tagName === "LI") { + fileMenu.style.display = 'none'; + } else if (fileMenu.parentElement && fileMenu.parentElement.tagName === "LI") { + fileMenu.parentElement.style.display = 'none'; + } + } + // Hide top header + var header = document.getElementById('header-container'); + if (header) { + header.style.display = 'none'; + } + // Stop checking once both are hidden + if (fileMenu && header) { + clearInterval(interval); + } + }, 500); + """ + webview.RunScript(js) + + def _add_notebook_tab(self, url, title): + """Add a new tab with a Jupyter notebook loaded in a WebView. + + This method creates a new browser tab inside the notebook panel + and loads the given URL. After the page is loaded, it injects + JavaScript to hide certain UI elements from the Jupyter interface. + + :param url: URL to the Jupyter notebook + :param title: Title of the new tab + """ + webview = html.WebView.New(self.notebook) + wx.CallAfter(webview.LoadURL, url) + wx.CallAfter(webview.Bind, html.EVT_WEBVIEW_LOADED, self._hide_file_menu) + self.notebook.AddPage(webview, title) def SetUpPage(self): """Set up the Jupyter Notebook interface.""" - gisenv = gs.gisenv() - gisdbase = gisenv["GISDBASE"] - location = gisenv["LOCATION_NAME"] - mapset = gisenv["MAPSET"] - mapset_path = f"{gisdbase}/{location}/{mapset}" - notebooks_dir = Path(mapset_path) / "notebooks" - notebooks_dir.mkdir(parents=True, exist_ok=True) - self.session = gj.init(mapset_path) - - # Spusť Jupyter server v adresáři notebooks - url_base = self.start_jupyter_server(notebooks_dir) - - # Najdi všechny .ipynb soubory v notebooks/ - ipynb_files = [f for f in Path.iterdir(notebooks_dir) if f.endswith(".ipynb")] - print(ipynb_files) - - if not ipynb_files: - print("No notebooks found in the directory.") - # Pokud nejsou žádné soubory, vytvoř template - new_notebook_name = "template.ipynb" - print(new_notebook_name) - new_notebook_path = Path(notebooks_dir) / (new_notebook_name) - template = { - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Template file\n", - ], - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "You can add your own code here\n", - "or create new notebooks in the GRASS GUI\n", - "and they will be automatically saved in the directory: `{}`\n".format( - notebooks_dir.replace("\\", "/") - ), - "and opened in the Jupyter Notebook interface.\n", - "\n", - ], - }, - { - "cell_type": "code", - "execution_count": None, - "metadata": {}, - "outputs": [], - "source": [ - "import grass.script as gs", - ], - }, - { - "cell_type": "code", - "execution_count": None, - "metadata": {}, - "outputs": [], - "source": [ - "print('Raster maps in the current mapset:')\n", - "for rast in gs.list_strings(type='raster'):\n", - " print(' ', rast)\n", - ], - }, - { - "cell_type": "code", - "execution_count": None, - "metadata": {}, - "outputs": [], - "source": [ - "print('\\nVector maps in the current mapset:')\n", - "for vect in gs.list_strings(type='vector'):\n", - " print(' ', vect)\n", - ], - }, - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3", - }, - "language_info": {"name": "python", "version": "3.x"}, - }, - "nbformat": 4, - "nbformat_minor": 2, - } - print(new_notebook_path) - print("template") - with open(new_notebook_path, "w", encoding="utf-8") as f: - json.dump(template, f, ensure_ascii=False, indent=2) - ipynb_files.append(new_notebook_name) - - notebook = wx.Notebook(self) - - # Po načtení stránky injektujte JS pro skrytí File menu - def hide_file_menu(event): - browser = event.GetEventObject() - print(browser) - js = """ - var interval = setInterval(function() { - // Skrytí File menu - var fileMenu = document.querySelector('li#file_menu, a#filelink, a[aria-controls="file_menu"]'); - if (fileMenu) { - if (fileMenu.tagName === "LI") { - fileMenu.style.display = 'none'; - } else if (fileMenu.parentElement && fileMenu.parentElement.tagName === "LI") { - fileMenu.parentElement.style.display = 'none'; - } - } - // Skrytí horního panelu - var header = document.getElementById('header-container'); - if (header) { - header.style.display = 'none'; - } - // Ukonči interval, pokud jsou oba prvky nalezeny - if (fileMenu && header) { - clearInterval(interval); - } - }, 500); - """ + # Create a directory manager to handle notebook files + # and a server manager to run the Jupyter server + dir_manager = NotebookDirectoryManager() + dir_manager.prepare_notebook_files() + + server_manager = NotebookServerManager(dir_manager.notebook_workdir) + server_manager.start_server() - browser.RunScript(js) + self.notebook = wx.Notebook(self) - for fname in ipynb_files: - url_base = url_base.rstrip("/") - url = f"{url_base}/notebooks/{fname}" - browser = html.WebView.New(notebook) - wx.CallAfter(browser.LoadURL, url) - wx.CallAfter(browser.Bind, html.EVT_WEBVIEW_LOADED, hide_file_menu) - notebook.AddPage(browser, fname) + # Create a new tab for each notebook file + for fname in dir_manager.notebook_files: + print(fname) + url = server_manager.get_notebook_url(fname) + self._add_notebook_tab(url, fname) sizer = wx.BoxSizer(wx.VERTICAL) - sizer.Add(notebook, 1, wx.EXPAND) + sizer.Add(self.notebook, 1, wx.EXPAND) self.SetSizer(sizer) diff --git a/python/grass/Makefile b/python/grass/Makefile index cc04b04b496..6e729728be2 100644 --- a/python/grass/Makefile +++ b/python/grass/Makefile @@ -14,6 +14,7 @@ SUBDIRS = \ gunittest \ imaging \ jupyter \ + notebooks \ pydispatch \ pygrass \ script \ diff --git a/python/grass/notebooks/Makefile b/python/grass/notebooks/Makefile new file mode 100644 index 00000000000..9961f91da55 --- /dev/null +++ b/python/grass/notebooks/Makefile @@ -0,0 +1,19 @@ +MODULE_TOPDIR = ../../.. + +include $(MODULE_TOPDIR)/include/Make/Other.make +include $(MODULE_TOPDIR)/include/Make/Python.make + +DSTDIR = $(ETC)/python/grass/notebooks + +MODULES = launcher directory + +PYFILES := $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__) +PYCFILES := $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__) + +default: $(PYFILES) $(PYCFILES) + +$(DSTDIR): + $(MKDIR) $@ + +$(DSTDIR)/%: % | $(DSTDIR) + $(INSTALL_DATA) $< $@ diff --git a/python/grass/notebooks/__init__.py b/python/grass/notebooks/__init__.py new file mode 100644 index 00000000000..c5d50064505 --- /dev/null +++ b/python/grass/notebooks/__init__.py @@ -0,0 +1,41 @@ +# MODULE: grass.notebooks +# +# AUTHOR(S): Linda Karlovska +# +# PURPOSE: Tools for managing Jupyter Notebooks within GRASS +# +# COPYRIGHT: (C) 2025 Linda Karlovska, and by the GRASS Development Team +# +# This program is free software under the GNU General Public +# License (>=v2). Read the file COPYING that comes with GRASS +# for details. + +""" +Tools for managing Jupyter Notebooks within GRASS + +This module provides functionality for: +- Starting and stopping local Jupyter Notebook servers inside a GRASS GIS session +- Managing notebook directories linked to specific GRASS mapsets +- Creating default notebook templates for users +- Supporting integration with the GUI (e.g., wxGUI) and other tools + +Unlike `grass.jupyter`, which allows Jupyter to access GRASS environments, +this module is focused on running Jupyter from within GRASS. + +Example use case: + - A user opens a panel in the GRASS that launches a Jupyter server + and opens the associated notebook directory for the current mapset. + +.. versionadded:: 8.5 + +""" + +from .launcher import NotebookServerManager +from .directory import NotebookDirectoryManager + +__all__ = [ + "Directory", + "Launcher", + "NotebookDirectoryManager", + "NotebookServerManager", +] diff --git a/python/grass/notebooks/directory.py b/python/grass/notebooks/directory.py new file mode 100644 index 00000000000..ca6ae84e0a0 --- /dev/null +++ b/python/grass/notebooks/directory.py @@ -0,0 +1,136 @@ +# +# AUTHOR(S): Linda Karlovska +# +# PURPOSE: Provides a class for managing notebook files within the current +# GRASS mapset. +# +# COPYRIGHT: (C) 2025 by Linda Karlovska and the GRASS Development Team +# +# This program is free software under the GNU General Public +# License (>=v2). Read the file COPYING that comes with GRASS +# for details. + +""" +This module defines a class `NotebookDirectoryManager` that provides functionality +for working with Jupyter Notebook files stored within the current GRASS mapset. +It handles: + +- Creating a notebooks directory if it does not exist +- Generating a default template notebook +- Listing existing `.ipynb` files +- Optionally importing notebooks from external locations + +Designed for use within GRASS GUI tools or scripting environments. +""" + +import shutil +from pathlib import Path + +import grass.script as gs + + +class NotebookDirectoryManager: + """Manage a directory of Jupyter notebooks tied to the current GRASS mapset. + + Handles locating the notebook directory, listing existing notebooks, + and creating a default template notebook if none exist. + """ + + def __init__(self): + """Initialize the notebook directory and load existing notebooks.""" + self._notebook_workdir = self._get_notebook_workdir() + self._notebook_files = None + + @property + def notebook_workdir(self): + """Path to the notebook working directory.""" + return self._notebook_workdir + + @property + def notebook_files(self): + """list of all .ipynb files in the current mapset notebooks dir.""" + return self._notebook_files + + def _get_notebook_workdir(self): + """Return path to the current mapset notebook directory. + It is created if it does not exist. + """ + env = gs.gisenv() + mapset_path = "{gisdbase}/{location}/{mapset}".format( + gisdbase=env["GISDBASE"], + location=env["LOCATION_NAME"], + mapset=env["MAPSET"], + ) + notebook_workdir = Path(mapset_path) / "notebooks" + notebook_workdir.mkdir(parents=True, exist_ok=True) + return notebook_workdir + + def prepare_notebook_files(self): + """Return list of all .ipynb files in the current mapset notebooks dir. + The template file is created if no ipynb files are found. + """ + # Find all .ipynb files in the notebooks directory + self._notebook_files = [ + f for f in self._notebook_workdir.iterdir() if f.suffix == ".ipynb" + ] + print(self._notebook_files) + + if not self._notebook_files: + # If no .ipynb files are found, create a template ipynb file + self._notebook_files.append(self.create_template()) + print(self._notebook_files) + + def copy_notebook(self, source_path, new_name=None, overwrite=False): + """Copy an existing Jupyter notebook file into the notebook directory. + + :param source_path: Path to the source .ipynb notebook + :param new_name: Optional new name for the copied notebook (with .ipynb extension), + if not provided, original filename is used + :param overwrite: Whether to overwrite an existing file with the same name + :return: Path to the copied notebook + :raises FileNotFoundError: If the source_path does not exist + :raises FileExistsError: If the target already exists and overwrite=False + """ + source = Path(source_path) + if not source.exists() or not source.suffix == ".ipynb": + raise FileExistsError(_("Notebook file not found:: {}").format(source)) + + target_name = new_name or source.name + target_path = self._notebook_workdir / target_name + + if target_path.exists() and not overwrite: + raise FileExistsError( + _("Target notebook already exists: {}").format(target_path) + ) + + shutil.copyfile(source, target_path) + return target_path + + def create_template(self, filename="template.ipynb"): + """ + Create a template Jupyter notebook by copying an existing template + file and replacing workdir placeholder. + :param filename: Name of the template file to copy + :return: Path to the created template notebook + """ + # Copy template file to the notebook directory + notebook_template_path = self.copy_notebook( + Path(__file__).parent / "template_notebooks" / filename + ) + print(notebook_template_path) + + # Load the template file + with open(notebook_template_path, encoding="utf-8"): + content = Path(notebook_template_path).read_text(encoding="utf-8") + + # Replace the placeholder with the actual notebook workdir + content = content.replace("XXX", str(self._notebook_workdir).replace("\\", "/")) + + # Save the modified content back to the template file + with open(notebook_template_path, "w", encoding="utf-8"): + Path(notebook_template_path).write_text(content, encoding="utf-8") + + # Add the new template file to the list of notebook files + self._notebook_files.append(notebook_template_path) + + return notebook_template_path diff --git a/python/grass/notebooks/launcher.py b/python/grass/notebooks/launcher.py new file mode 100644 index 00000000000..18c876e6e61 --- /dev/null +++ b/python/grass/notebooks/launcher.py @@ -0,0 +1,167 @@ +# +# AUTHOR(S): Linda Karlovska +# +# PURPOSE: Provides a simple interface for launching and managing a local Jupyter Notebook +# server within the current GRASS mapset. Includes utility methods for +# detecting Jupyter installation, managing server lifecycle, and retrieving +# process details. +# +# COPYRIGHT: (C) 2025 by Linda Karlovska and the GRASS Development Team +# +# This program is free software under the GNU General Public +# License (>=v2). Read the file COPYING that comes with GRASS +# for details. + +""" +This module provides a class `NotebookServerManager` for starting and stopping a +Jupyter Notebook server inside the current GRASS session. It also handles: + +- Checking if Jupyter Notebook is installed +- Finding an available port +- Verifying server startup +- Returning the server URL + +Intended for internal use within GRASS tools or scripts. +""" + +import socket +import time +import subprocess +import threading +import http.client + +from grass.jupyter import init + + +class NotebookServerManager: + """Manage the lifecycle of a Jupyter Notebook server. + + Handles launching, stopping, and tracking a local Jupyter server + within a specified working directory. + """ + + def __init__(self, notebook_workdir): + self.notebook_workdir = notebook_workdir + self.port = None + self.server_url = None + self.pid = None + + def _find_free_port(self): + """Find a free port on the local machine. + :return: A free port number. + """ + sock = socket.socket() + sock.bind(("", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + @staticmethod + def is_jupyter_notebook_installed(): + """Check if Jupyter notebook is installed. + :return: True if Jupyter notebook is installed, False otherwise. + """ + try: + subprocess.check_output(["jupyter", "notebook", "--version"]) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + def is_server_running(self, port, retries=10, delay=0.2): + """Wait until an HTTP server responds on the given port. + :param port: Port number to check. + :param retries: Number of retries before giving up. + :param delay: Delay between retries in seconds. + :return: True if the server is up, False otherwise. + """ + for _ in range(retries): + try: + conn = http.client.HTTPConnection("localhost", port, timeout=0.5) + conn.request("GET", "/") + resp = conn.getresponse() + if resp.status in (200, 302, 403): + conn.close() + return True + conn.close() + except Exception: + time.sleep(delay) + return False + + def start_server(self): + """Run Jupyter notebook server in the given directory on a free port. + :param notebooks_dir: Directory where the Jupyter notebook server will be started + :return server_url str: URL of the Jupyter notebook server. + """ + # Check if Jupyter notebook is installed + if not NotebookServerManager.is_jupyter_notebook_installed(): + raise RuntimeError(_("Jupyter notebook is not installed")) + + # Find free port and build server url + self.port = self._find_free_port() + self.server_url = "http://localhost:{}".format(self.port) + + # Create container for PIDs + pid_container = [] + + # Run Jupyter notebook server + def run_server(pid_container): + proc = subprocess.Popen( + [ + "jupyter", + "notebook", + "--no-browser", + "--NotebookApp.token=''", + "--NotebookApp.password=''", + "--port", + str(self.port), + "--notebook-dir", + self.notebook_workdir, + ], + ) + pid_container.append(proc.pid) + + # Save the PID of the Jupyter notebook server + self.pid = pid_container[0] if pid_container else None + + # Start the server in a separate thread + thread = threading.Thread(target=run_server, args=(pid_container,), daemon=True) + thread.start() + + # Initialize the grass.jupyter session for the current mapset + self.initialize_session() + + # Check if the server is up + if not self.is_server_running(self.port): + raise RuntimeError(_("Jupyter server is not running")) + + def initialize_session(self): + """Initialize the Jupyter notebook session. + + This method is called to set up the Jupyter notebook . + """ + # Derive mapset path and initialize GRASS backend + mapset_path = self.notebook_workdir.parent + self.session = init(mapset_path) + + def get_notebook_url(self, notebook_name): + """Return full URL to a notebook served by this server. + + :param notebook_name: Name of the notebook file (e.g. 'example.ipynb') + :return: Full URL to access the notebook + """ + if not self.server_url: + raise RuntimeError(_("Server URL is not set. Start the server first.")) + + return "{base}/notebooks/{file}".format( + base=self.server_url.rstrip("/"), file=notebook_name + ) + + def stop_server(self): + """Stop the Jupyter notebook server. + :return: None + """ + # Find the PID of the Jupyter notebook server + try: + subprocess.check_call(["kill", str(self.pid)]) + except subprocess.CalledProcessError: + pass # No Jupyter server running diff --git a/python/grass/notebooks/template_notebooks/template.ipynb b/python/grass/notebooks/template_notebooks/template.ipynb new file mode 100644 index 00000000000..ae4618eaa80 --- /dev/null +++ b/python/grass/notebooks/template_notebooks/template.ipynb @@ -0,0 +1,77 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Template file\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can add your own code here\n", + "or create new notebooks in the GRASS GUI\n", + "and they will be automatically saved in the directory: XXX\n", + "and opened in the Jupyter Notebook interface.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import grass.script as gs" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Raster maps in the current mapset:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for rast in gs.list_strings(type=\"raster\"):\n", + " print(\" \", rast)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Vector maps in the current mapset:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for vect in gs.list_strings(type=\"vector\"):\n", + " print(\" \", vect)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.x" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From 6f2280fcfba4f06c324e4e89cc1c124aa3881aed Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 27 May 2025 17:56:13 +0200 Subject: [PATCH 03/28] fully working version after refactoring --- gui/icons/grass/jupyter.png | Bin 0 -> 786 bytes gui/icons/grass/jupyter.svg | 85 +++++ gui/wxpython/jupyter_notebook/notebook.py | 108 ++++++ gui/wxpython/jupyter_notebook/panel.py | 329 ++++++++++++++---- gui/wxpython/jupyter_notebook/toolbars.py | 95 +++++ gui/wxpython/lmgr/toolbars.py | 40 +-- gui/wxpython/main_window/frame.py | 11 +- python/grass/CMakeLists.txt | 1 + python/grass/Makefile | 2 +- python/grass/notebooks/Makefile | 19 - python/grass/notebooks/directory.py | 136 -------- python/grass/notebooks/launcher.py | 167 --------- .../template_notebooks/template.ipynb | 77 ---- python/grass/workflows/Makefile | 20 ++ .../{notebooks => workflows}/__init__.py | 21 +- python/grass/workflows/directory.py | 216 ++++++++++++ python/grass/workflows/server.py | 260 ++++++++++++++ .../workflows/template_notebooks/new.ipynb | 40 +++ .../template_notebooks/welcome.ipynb | 56 +++ 19 files changed, 1173 insertions(+), 510 deletions(-) create mode 100644 gui/icons/grass/jupyter.png create mode 100644 gui/icons/grass/jupyter.svg create mode 100644 gui/wxpython/jupyter_notebook/notebook.py create mode 100644 gui/wxpython/jupyter_notebook/toolbars.py delete mode 100644 python/grass/notebooks/Makefile delete mode 100644 python/grass/notebooks/directory.py delete mode 100644 python/grass/notebooks/launcher.py delete mode 100644 python/grass/notebooks/template_notebooks/template.ipynb create mode 100644 python/grass/workflows/Makefile rename python/grass/{notebooks => workflows}/__init__.py (67%) create mode 100644 python/grass/workflows/directory.py create mode 100644 python/grass/workflows/server.py create mode 100644 python/grass/workflows/template_notebooks/new.ipynb create mode 100644 python/grass/workflows/template_notebooks/welcome.ipynb diff --git a/gui/icons/grass/jupyter.png b/gui/icons/grass/jupyter.png new file mode 100644 index 0000000000000000000000000000000000000000..dc4c010eda200eb629d19edc36d1d33e39669c94 GIT binary patch literal 786 zcmV+t1MU2YP)nK9|*A}j}KG>TK_W6|1k-VbdZa*Os{;nWsYRaV{>*dwaPViA8JZ8=^6dvo+7 z0-pqu(>RZ)wk1~kdPl;w-rRs0b4x`0^@{xjaG=~(L;H#}{m@mqa#+j(E2C4hV&3>< zSzX$;w+rk-*bF@IlveDRuFDEgb8T^aBJ~vIu{i9D-q!acGd5=y#3KHHV4p$dk@wQz z*}S57v}Ru{;tT$%Y5pMJ!Ps%%!n{R2QeE;l=7sk8kAD$rR=Eo8lhsY*lf^sZQUBIC z8-v86HO=wwE_a5@zR8Ey7bVN%PZVAnDi_?B28aLDJl$VE?B+{+}OFP~qbNKv7w$v@Nt@IUy{lC)z-$Xd=b`5okiJe=_ z`mw94tHk5+90is-ozAPFQ0Q~)gwJm%Aypdq^`g0eufSW--XEoUV}bcTHe0%L`iI-? zJ_WP@SeCU32$Z!By~}WOLDn%f3}eaE(nPA}YC3CxWm%VjL10u>!-WX=3D?u`!;M-4 QbpQYW07*qoM6N<$g8GMXIRF3v literal 0 HcmV?d00001 diff --git a/gui/icons/grass/jupyter.svg b/gui/icons/grass/jupyter.svg new file mode 100644 index 00000000000..8a11dcc7b7d --- /dev/null +++ b/gui/icons/grass/jupyter.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py new file mode 100644 index 00000000000..d5e608d7e65 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/notebook.py @@ -0,0 +1,108 @@ +""" +@package jupyter_notebook.notebook + +@brief Manages the jupyter notebook widget. + +Classes: + - page::JupyterAuiNotebook + +(C) 2025 by the GRASS Development Team + +This program is free software under the GNU General Public License +(>=v2). Read the file COPYING that comes with GRASS for details. + +@author Linda Karlovska +""" + +import wx +from wx.lib.agw import aui + +try: + import wx.html2 as html # wx.html2 is available in wxPython 4.0 and later +except ImportError as e: + raise RuntimeError(_("wx.html2 is required for Jupyter integration.")) from e + +from gui_core.wrap import SimpleTabArt + + +class JupyterAuiNotebook(aui.AuiNotebook): + def __init__( + self, + parent, + agwStyle=aui.AUI_NB_DEFAULT_STYLE + | aui.AUI_NB_CLOSE_ON_ACTIVE_TAB + | aui.AUI_NB_TAB_EXTERNAL_MOVE + | aui.AUI_NB_BOTTOM + | wx.NO_BORDER, + ): + """ + Wrapper for the notebook widget that manages notebook pages. + """ + self.parent = parent + self.webview = None + + super().__init__(parent=self.parent, id=wx.ID_ANY, agwStyle=agwStyle) + + self.SetArtProvider(SimpleTabArt()) + + self.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self.OnPageClose) + + def _inject_javascript(self, event): + """ + Inject JavaScript into the Jupyter notebook page to hide UI elements. + + Specifically hides: + - The File menu + - The top header bar + + This is called once the WebView has fully loaded the Jupyter page. + """ + webview = event.GetEventObject() + js = """ + var interval = setInterval(function() { + var fileMenu = document.querySelector('li#file_menu, a#filelink, a[aria-controls="file_menu"]'); + if (fileMenu) { + if (fileMenu.tagName === "LI") { + fileMenu.style.display = 'none'; + } else if (fileMenu.parentElement && fileMenu.parentElement.tagName === "LI") { + fileMenu.parentElement.style.display = 'none'; + } + } + var header = document.getElementById('header-container'); + if (header) { + header.style.display = 'none'; + } + if (fileMenu && header) { + clearInterval(interval); + } + }, 500); + """ + webview.RunScript(js) + + def AddPage(self, url, title): + """ + Add a new aui notebook page with a Jupyter WebView. + :param url: URL of the Jupyter file (str). + :param title: Tab title (str). + """ + browser = html.WebView.New(self) + wx.CallAfter(browser.LoadURL, url) + wx.CallAfter(browser.Bind, html.EVT_WEBVIEW_LOADED, self._inject_javascript) + super().AddPage(browser, title) + + def OnPageClose(self, event): + """Close the aui notebook page with confirmation dialog.""" + index = event.GetSelection() + title = self.GetPageText(index) + + dlg = wx.MessageDialog( + self, + message=_("Really close page '{}'?").format(title), + caption=_("Close page"), + style=wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION, + ) + + if dlg.ShowModal() != wx.ID_YES: + event.Veto() + + dlg.Destroy() diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 618a81c391b..251ac1733ba 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -1,7 +1,7 @@ """ -@package jupyter_notebook::panel +@package jupyter_notebook.panel -@brief Integration of Jupyter notebook to GUI. +@brief Integration of Jupyter Notebook to GUI. Classes: - panel::JupyterPanel @@ -15,16 +15,13 @@ """ import wx +from pathlib import Path -try: - import wx.html2 as html # wx.html2 is available in wxPython 4.0 and later -except ImportError as e: - raise RuntimeError(_("wx.html2 is required for Jupyter integration.")) from e - +from .notebook import JupyterAuiNotebook +from .toolbars import JupyterToolbar from main_window.page import MainPageBase - -from grass.notebooks.launcher import NotebookServerManager -from grass.notebooks.directory import NotebookDirectoryManager +from grass.workflows.directory import JupyterDirectoryManager +from grass.workflows.server import JupyterServerInstance, JupyterServerRegistry class JupyterPanel(wx.Panel, MainPageBase): @@ -38,87 +35,267 @@ def __init__( dockable=False, **kwargs, ): - """Jupyter Notebook main panel - :param parent: parent window - :param giface: GRASS interface - :param id: window id - :param title: window title + """Jupyter main panel.""" + super().__init__(parent=parent, id=id, **kwargs) + MainPageBase.__init__(self, dockable) - :param kwargs: wx.Panel' arguments - """ self.parent = parent self._giface = giface self.statusbar = statusbar - wx.Panel.__init__(self, parent=parent, id=id, **kwargs) - MainPageBase.__init__(self, dockable) - self.SetName("Jupyter") - def _hide_file_menu(self, event): - """Inject JavaScript to hide Jupyter's File menu - and header after load. - :param event: wx.EVT_WEBVIEW_LOADED event + self.directory_manager = JupyterDirectoryManager() + self.workdir = self.directory_manager.workdir + self.server_manager = JupyterServerInstance(workdir=self.workdir) + + self.toolbar = JupyterToolbar(parent=self) + self.aui_notebook = JupyterAuiNotebook(parent=self) + + self._layout() + + def _layout(self): + """Do layout""" + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(self.toolbar, proportion=0, flag=wx.EXPAND) + sizer.Add(self.aui_notebook, proportion=1, flag=wx.EXPAND) + + self.SetAutoLayout(True) + self.SetSizer(sizer) + sizer.Fit(self) + self.Layout() + + def SetUpNotebookInterface(self): + """Start server and load files available in a working directory.""" + # Prepare the working directory (find all existing files, copy a template file if needed) + self.directory_manager.prepare_files() + + # Start the Jupyter server in the specified working directory + self.server_manager.start_server() + + # Register server to server registry + JupyterServerRegistry.get().register(self.server_manager) + + # Update the status bar with server info + status_msg = _("Jupyter server has started at {url} (PID: {pid})").format( + url=self.server_manager.server_url, pid=self.server_manager.pid + ) + self.SetStatusText(status_msg, 0) + + # Load all existing files found in the working directory as separate tabs + for fname in self.directory_manager.files: + url = self.server_manager.get_url(fname.name) + self.aui_notebook.AddPage(url=url, title=fname.name) + + def Switch(self, file_name): """ - # Hide File menu and header - webview = event.GetEventObject() - js = """ - var interval = setInterval(function() { - // Hide File menu - var fileMenu = document.querySelector('li#file_menu, a#filelink, a[aria-controls="file_menu"]'); - if (fileMenu) { - if (fileMenu.tagName === "LI") { - fileMenu.style.display = 'none'; - } else if (fileMenu.parentElement && fileMenu.parentElement.tagName === "LI") { - fileMenu.parentElement.style.display = 'none'; - } - } - // Hide top header - var header = document.getElementById('header-container'); - if (header) { - header.style.display = 'none'; - } - // Stop checking once both are hidden - if (fileMenu && header) { - clearInterval(interval); - } - }, 500); + Switch to existing notebook tab. + :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') (str). + :return: True if the notebook was found and switched to, False otherwise. """ - webview.RunScript(js) + for i in range(self.aui_notebook.GetPageCount()): + if self.aui_notebook.GetPageText(i) == file_name: + self.aui_notebook.SetSelection(i) + return True + return False - def _add_notebook_tab(self, url, title): - """Add a new tab with a Jupyter notebook loaded in a WebView. + def Open(self, file_name): + """ + Open a Jupyter notebook to a new tab and switch to it. + :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') (str). + """ + url = self.server_manager.get_url(file_name) + self.aui_notebook.AddPage(url=url, title=file_name) + self.aui_notebook.SetSelection(self.aui_notebook.GetPageCount() - 1) - This method creates a new browser tab inside the notebook panel - and loads the given URL. After the page is loaded, it injects - JavaScript to hide certain UI elements from the Jupyter interface. + def OpenOrSwitch(self, file_name): + """ + Switch to .ipynb file if open, otherwise open it. + :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') (str). + """ + if self.Switch(file_name): + self.SetStatusText(_("File '{}' is already opened.").format(file_name), 0) + else: + self.Open(file_name) + self.SetStatusText(_("File '{}' opened.").format(file_name), 0) - :param url: URL to the Jupyter notebook - :param title: Title of the new tab + def Import(self, source_path, new_name=None): + """ + Import a .ipynb file into a working directory and open it to a new tab. + :param source_path: Path to the source .ipynb file to be imported (Path). + :param new_name: Optional new name for the imported file (str). """ - webview = html.WebView.New(self.notebook) - wx.CallAfter(webview.LoadURL, url) - wx.CallAfter(webview.Bind, html.EVT_WEBVIEW_LOADED, self._hide_file_menu) - self.notebook.AddPage(webview, title) + try: + path = self.directory_manager.import_file(source_path, new_name=new_name) + self.Open(path.name) + self.SetStatusText(_("File '{}' imported and opened.").format(path.name), 0) + except Exception as e: + wx.MessageBox( + _("Failed to import file:\n{}").format(str(e)), + _("Notebook Import Error"), + wx.ICON_ERROR | wx.OK, + ) - def SetUpPage(self): - """Set up the Jupyter Notebook interface.""" - # Create a directory manager to handle notebook files - # and a server manager to run the Jupyter server - dir_manager = NotebookDirectoryManager() - dir_manager.prepare_notebook_files() + def OnImport(self, event=None): + """ + Import an existing Jupyter notebook file into the working directory + and open it in the GUI. + - Prompts user to select a .ipynb file. + - If the selected file is already in the notebook directory: + - Switch to it or open it. + - If the file is from elsewhere: + - Import the notebook and open it (if needed, prompt for a new name). + """ + # Open file dialog to select an existing Jupyter notebook file + with wx.FileDialog( + parent=wx.GetActiveWindow() or self.GetTopLevelParent(), + message=_("Import file"), + defaultDir=str(Path.cwd()), + wildcard="Jupyter notebooks (*.ipynb)|*.ipynb", + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST, + ) as dlg: + if dlg.ShowModal() == wx.ID_CANCEL: + return - server_manager = NotebookServerManager(dir_manager.notebook_workdir) - server_manager.start_server() + source_path = Path(dlg.GetPath()) + file_name = source_path.name + target_path = self.directory_manager.workdir / file_name - self.notebook = wx.Notebook(self) + # File is already in the working directory + if source_path.resolve() == target_path.resolve(): + self.OpenOrSwitch(file_name) + return - # Create a new tab for each notebook file - for fname in dir_manager.notebook_files: - print(fname) - url = server_manager.get_notebook_url(fname) - self._add_notebook_tab(url, fname) + # File is from outside the working directory + new_name = None + if target_path.exists(): + # Prompt user for a new name if the notebook already exists + with wx.TextEntryDialog( + self, + message=_( + "File '{}' already exists in working directory.\nPlease enter a new name:" + ).format(file_name), + caption=_("Rename File"), + value="{}_copy".format(file_name.removesuffix(".ipynb")), + ) as name_dlg: + if name_dlg.ShowModal() == wx.ID_CANCEL: + return + new_name = name_dlg.GetValue().strip() + if not new_name.endswith(".ipynb"): + new_name += ".ipynb" - sizer = wx.BoxSizer(wx.VERTICAL) - sizer.Add(self.notebook, 1, wx.EXPAND) - self.SetSizer(sizer) + # Perform the import and open the notebook + self.Import(source_path, new_name=new_name) + + def OnExport(self, event=None): + """Export the currently opened Jupyter notebook to a user-selected location.""" + current_page = self.aui_notebook.GetSelection() + if current_page == wx.NOT_FOUND: + wx.MessageBox( + _("No page for export is currently selected."), + caption=_("Notebook Export Error"), + style=wx.ICON_WARNING | wx.OK, + ) + return + file_name = self.aui_notebook.GetPageText(current_page) + + with wx.FileDialog( + parent=wx.GetActiveWindow() or self.GetTopLevelParent(), + message=_("Export file as..."), + defaultFile=file_name, + wildcard="Jupyter notebooks (*.ipynb)|*.ipynb", + style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, + ) as dlg: + if dlg.ShowModal() == wx.ID_CANCEL: + return + + destination_path = Path(dlg.GetPath()) + + try: + self.directory_manager.export_file( + file_name, destination_path, overwrite=True + ) + self.SetStatusText( + _("File {} exported to {}.").format(file_name, destination_path), 0 + ) + except Exception as e: + wx.MessageBox( + _("Failed to export file:\n{}").format(str(e)), + caption=_("Notebook Export Error"), + style=wx.ICON_ERROR | wx.OK, + ) + + def OnCreate(self, event=None): + """ + Prompt the user to create a new empty Jupyter notebook in the working directory, + and open it in the GUI. + """ + with wx.TextEntryDialog( + self, + message=_("Enter a name for the new notebook:"), + caption=_("New Notebook"), + value="untitled", + ) as dlg: + if dlg.ShowModal() == wx.ID_CANCEL: + return + + name = dlg.GetValue().strip() + if not name: + return + + try: + path = self.directory_manager.create_new_notebook(new_name=name) + except Exception as e: + wx.MessageBox( + _("Failed to create notebook:\n{}").format(str(e)), + caption=_("Notebook Creation Error"), + style=wx.ICON_ERROR | wx.OK, + ) + return + + # Open the newly created notebook in the GUI + self.Open(path.name) + + def SetStatusText(self, *args): + """Set text in the status bar.""" + self.statusbar.SetStatusText(*args) + + def GetStatusBar(self): + """Get statusbar""" + return self.statusbar + + def OnCloseWindow(self, event): + """Prompt user, then stop server and close panel.""" + confirm = wx.MessageBox( + _("Do you really want to close this window and stop the Jupyter server?"), + _("Confirm Close"), + wx.ICON_QUESTION | wx.YES_NO | wx.NO_DEFAULT, + ) + + if confirm != wx.YES: + if event and hasattr(event, "Veto"): + event.Veto() + return + + if self.server_manager: + try: + # Stop the Jupyter server + self.server_manager.stop_server() + + # Unregister server from server registry + JupyterServerRegistry.get().unregister(self.server_manager) + self.SetStatusText(_("Jupyter server has been stopped."), 0) + except RuntimeError as e: + wx.MessageBox( + _("Failed to stop the Jupyter server:\n{}").format(str(e)), + _("Error"), + wx.ICON_ERROR | wx.OK, + ) + self.SetStatusText(_("Failed to stop Jupyter server."), 0) + + # Clean up the server manager + if hasattr(self.GetParent(), "jupyter_server_manager"): + self.GetParent().jupyter_server_manager = None + + # Close the notebook panel + self._onCloseWindow(event) diff --git a/gui/wxpython/jupyter_notebook/toolbars.py b/gui/wxpython/jupyter_notebook/toolbars.py new file mode 100644 index 00000000000..0e9e5300b47 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/toolbars.py @@ -0,0 +1,95 @@ +""" +@package jupyter_notebook.toolbars + +@brief wxGUI Jupyter toolbars classes + +Classes: + - toolbars::JupyterToolbar + +(C) 2025 by the GRASS Development Team + +This program is free software under the GNU General Public License +(>=v2). Read the file COPYING that comes with GRASS for details. + +@author Linda Karlovska +""" + +import sys + +import wx + +from core.globalvar import CheckWxVersion +from gui_core.toolbars import BaseToolbar, BaseIcons + +from icons.icon import MetaIcon + + +class JupyterToolbar(BaseToolbar): + """Jupyter toolbar""" + + def __init__(self, parent): + BaseToolbar.__init__(self, parent) + + # workaround for http://trac.wxwidgets.org/ticket/13888 + if sys.platform == "darwin" and not CheckWxVersion([4, 2, 1]): + parent.SetToolBar(self) + + self.InitToolbar(self._toolbarData()) + + # realize the toolbar + self.Realize() + + def _toolbarData(self): + """Toolbar data""" + icons = { + "create": MetaIcon( + img="create", + label=_("Create new notebook"), + ), + "open": MetaIcon( + img="open", + label=_("Import notebook"), + ), + "save": MetaIcon( + img="save", + label=_("Export notebook"), + ), + "docking": BaseIcons["docking"], + "quit": BaseIcons["quit"], + } + data = ( + ( + ("create", icons["create"].label.rsplit(" ", 1)[0]), + icons["create"], + self.parent.OnCreate, + ), + ( + ("open", icons["open"].label.rsplit(" ", 1)[0]), + icons["open"], + self.parent.OnImport, + ), + ( + ("save", icons["save"].label.rsplit(" ", 1)[0]), + icons["save"], + self.parent.OnExport, + ), + (None,), + ) + if self.parent.IsDockable(): + data += ( + ( + ("docking", icons["docking"].label), + icons["docking"], + self.parent.OnDockUndock, + wx.ITEM_CHECK, + ), + ) + data += ( + ( + ("quit", icons["quit"].label), + icons["quit"], + self.parent.OnCloseWindow, + ), + ) + + return self._getToolbarData(data) diff --git a/gui/wxpython/lmgr/toolbars.py b/gui/wxpython/lmgr/toolbars.py index b4312d06ac8..66a77254323 100644 --- a/gui/wxpython/lmgr/toolbars.py +++ b/gui/wxpython/lmgr/toolbars.py @@ -202,35 +202,28 @@ def _toolbarData(self): "newdisplay": MetaIcon( img="monitor-create", label=_("Start new map display") ), - "newjupyter": MetaIcon( - img="monitor-create", label=_("Jupyter Notebook Console") - ), "mapcalc": MetaIcon( img="raster-calculator", label=_("Raster Map Calculator") ), - "modeler": MetaIcon(img="modeler-main", label=_("Graphical Modeler")), "georectify": MetaIcon(img="georectify", label=_("Georectifier")), "composer": MetaIcon(img="print-compose", label=_("Cartographic Composer")), - "script-load": MetaIcon( - img="script-load", label=_("Launch user-defined script") - ), + "modeler": MetaIcon(img="modeler-main", label=_("Open Graphical Modeler")), "python": MetaIcon( img="python", label=_("Open a simple Python code editor") ), + "jupyter": MetaIcon(img="jupyter", label=_("Start Jupyter Notebook")), + "script-load": MetaIcon( + img="script-load", label=_("Launch user-defined script") + ), } return self._getToolbarData( ( ( - ("newdisplay", _("New display")), + ("newdisplay", icons["newdisplay"].label), icons["newdisplay"], self.parent.OnNewDisplay, ), - ( - ("newjupyter", _("New jupyter notebook")), - icons["newjupyter"], - self.parent.OnNewJupyterNotebook, - ), (None,), ( ("mapCalc", icons["mapcalc"].label), @@ -242,11 +235,6 @@ def _toolbarData(self): icons["georectify"], self.parent.OnGCPManager, ), - ( - ("modeler", icons["modeler"].label), - icons["modeler"], - self.parent.OnGModeler, - ), ( ("mapOutput", icons["composer"].label), icons["composer"], @@ -254,15 +242,25 @@ def _toolbarData(self): ), (None,), ( - ("script-load", icons["script-load"].label), - icons["script-load"], - self.parent.OnRunScript, + ("modeler", icons["modeler"].label), + icons["modeler"], + self.parent.OnGModeler, ), ( ("python", _("Python code editor")), icons["python"], self.parent.OnSimpleEditor, ), + ( + ("jupyter", icons["jupyter"].label), + icons["jupyter"], + self.parent.OnJupyterNotebook, + ), + ( + ("script-load", icons["script-load"].label), + icons["script-load"], + self.parent.OnRunScript, + ), ) ) diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 3ad2865826b..92cb137347d 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -906,14 +906,15 @@ def OnGModeler(self, event=None, cmd=None): # add map display panel to notebook and make it current self.mainnotebook.AddPage(gmodeler_panel, _("Graphical Modeler")) - def OnNewJupyterNotebook(self, event=None, cmd=None): - """Launch Jupyter Notebook page. See OnIClass documentation""" + def OnJupyterNotebook(self, event=None, cmd=None): + """Launch Jupyter Notebook page. See OnJupyterNotebook documentation""" from jupyter_notebook.panel import JupyterPanel jupyter_panel = JupyterPanel( parent=self, giface=self._giface, statusbar=self.statusbar, dockable=True ) - jupyter_panel.SetUpPage() + jupyter_panel.SetUpPage(self, self.mainnotebook) + jupyter_panel.SetUpNotebookInterface() # add map display panel to notebook and make it current self.mainnotebook.AddPage(jupyter_panel, _("Jupyter Notebook")) @@ -2418,6 +2419,10 @@ def _closeWindow(self, event): event.Veto() return + from grass.workflows import JupyterServerRegistry + + JupyterServerRegistry.get().stop_all_servers() + self.DisplayCloseAll() self._auimgr.UnInit() diff --git a/python/grass/CMakeLists.txt b/python/grass/CMakeLists.txt index 528e2b12541..45ef9c60f84 100644 --- a/python/grass/CMakeLists.txt +++ b/python/grass/CMakeLists.txt @@ -2,6 +2,7 @@ set(PYDIRS benchmark exceptions grassdb + workflows gunittest imaging jupyter diff --git a/python/grass/Makefile b/python/grass/Makefile index 6e729728be2..37dd1234c27 100644 --- a/python/grass/Makefile +++ b/python/grass/Makefile @@ -14,7 +14,7 @@ SUBDIRS = \ gunittest \ imaging \ jupyter \ - notebooks \ + workflows \ pydispatch \ pygrass \ script \ diff --git a/python/grass/notebooks/Makefile b/python/grass/notebooks/Makefile deleted file mode 100644 index 9961f91da55..00000000000 --- a/python/grass/notebooks/Makefile +++ /dev/null @@ -1,19 +0,0 @@ -MODULE_TOPDIR = ../../.. - -include $(MODULE_TOPDIR)/include/Make/Other.make -include $(MODULE_TOPDIR)/include/Make/Python.make - -DSTDIR = $(ETC)/python/grass/notebooks - -MODULES = launcher directory - -PYFILES := $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__) -PYCFILES := $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__) - -default: $(PYFILES) $(PYCFILES) - -$(DSTDIR): - $(MKDIR) $@ - -$(DSTDIR)/%: % | $(DSTDIR) - $(INSTALL_DATA) $< $@ diff --git a/python/grass/notebooks/directory.py b/python/grass/notebooks/directory.py deleted file mode 100644 index ca6ae84e0a0..00000000000 --- a/python/grass/notebooks/directory.py +++ /dev/null @@ -1,136 +0,0 @@ -# -# AUTHOR(S): Linda Karlovska -# -# PURPOSE: Provides a class for managing notebook files within the current -# GRASS mapset. -# -# COPYRIGHT: (C) 2025 by Linda Karlovska and the GRASS Development Team -# -# This program is free software under the GNU General Public -# License (>=v2). Read the file COPYING that comes with GRASS -# for details. - -""" -This module defines a class `NotebookDirectoryManager` that provides functionality -for working with Jupyter Notebook files stored within the current GRASS mapset. -It handles: - -- Creating a notebooks directory if it does not exist -- Generating a default template notebook -- Listing existing `.ipynb` files -- Optionally importing notebooks from external locations - -Designed for use within GRASS GUI tools or scripting environments. -""" - -import shutil -from pathlib import Path - -import grass.script as gs - - -class NotebookDirectoryManager: - """Manage a directory of Jupyter notebooks tied to the current GRASS mapset. - - Handles locating the notebook directory, listing existing notebooks, - and creating a default template notebook if none exist. - """ - - def __init__(self): - """Initialize the notebook directory and load existing notebooks.""" - self._notebook_workdir = self._get_notebook_workdir() - self._notebook_files = None - - @property - def notebook_workdir(self): - """Path to the notebook working directory.""" - return self._notebook_workdir - - @property - def notebook_files(self): - """list of all .ipynb files in the current mapset notebooks dir.""" - return self._notebook_files - - def _get_notebook_workdir(self): - """Return path to the current mapset notebook directory. - It is created if it does not exist. - """ - env = gs.gisenv() - mapset_path = "{gisdbase}/{location}/{mapset}".format( - gisdbase=env["GISDBASE"], - location=env["LOCATION_NAME"], - mapset=env["MAPSET"], - ) - notebook_workdir = Path(mapset_path) / "notebooks" - notebook_workdir.mkdir(parents=True, exist_ok=True) - return notebook_workdir - - def prepare_notebook_files(self): - """Return list of all .ipynb files in the current mapset notebooks dir. - The template file is created if no ipynb files are found. - """ - # Find all .ipynb files in the notebooks directory - self._notebook_files = [ - f for f in self._notebook_workdir.iterdir() if f.suffix == ".ipynb" - ] - print(self._notebook_files) - - if not self._notebook_files: - # If no .ipynb files are found, create a template ipynb file - self._notebook_files.append(self.create_template()) - print(self._notebook_files) - - def copy_notebook(self, source_path, new_name=None, overwrite=False): - """Copy an existing Jupyter notebook file into the notebook directory. - - :param source_path: Path to the source .ipynb notebook - :param new_name: Optional new name for the copied notebook (with .ipynb extension), - if not provided, original filename is used - :param overwrite: Whether to overwrite an existing file with the same name - :return: Path to the copied notebook - :raises FileNotFoundError: If the source_path does not exist - :raises FileExistsError: If the target already exists and overwrite=False - """ - source = Path(source_path) - if not source.exists() or not source.suffix == ".ipynb": - raise FileExistsError(_("Notebook file not found:: {}").format(source)) - - target_name = new_name or source.name - target_path = self._notebook_workdir / target_name - - if target_path.exists() and not overwrite: - raise FileExistsError( - _("Target notebook already exists: {}").format(target_path) - ) - - shutil.copyfile(source, target_path) - return target_path - - def create_template(self, filename="template.ipynb"): - """ - Create a template Jupyter notebook by copying an existing template - file and replacing workdir placeholder. - :param filename: Name of the template file to copy - :return: Path to the created template notebook - """ - # Copy template file to the notebook directory - notebook_template_path = self.copy_notebook( - Path(__file__).parent / "template_notebooks" / filename - ) - print(notebook_template_path) - - # Load the template file - with open(notebook_template_path, encoding="utf-8"): - content = Path(notebook_template_path).read_text(encoding="utf-8") - - # Replace the placeholder with the actual notebook workdir - content = content.replace("XXX", str(self._notebook_workdir).replace("\\", "/")) - - # Save the modified content back to the template file - with open(notebook_template_path, "w", encoding="utf-8"): - Path(notebook_template_path).write_text(content, encoding="utf-8") - - # Add the new template file to the list of notebook files - self._notebook_files.append(notebook_template_path) - - return notebook_template_path diff --git a/python/grass/notebooks/launcher.py b/python/grass/notebooks/launcher.py deleted file mode 100644 index 18c876e6e61..00000000000 --- a/python/grass/notebooks/launcher.py +++ /dev/null @@ -1,167 +0,0 @@ -# -# AUTHOR(S): Linda Karlovska -# -# PURPOSE: Provides a simple interface for launching and managing a local Jupyter Notebook -# server within the current GRASS mapset. Includes utility methods for -# detecting Jupyter installation, managing server lifecycle, and retrieving -# process details. -# -# COPYRIGHT: (C) 2025 by Linda Karlovska and the GRASS Development Team -# -# This program is free software under the GNU General Public -# License (>=v2). Read the file COPYING that comes with GRASS -# for details. - -""" -This module provides a class `NotebookServerManager` for starting and stopping a -Jupyter Notebook server inside the current GRASS session. It also handles: - -- Checking if Jupyter Notebook is installed -- Finding an available port -- Verifying server startup -- Returning the server URL - -Intended for internal use within GRASS tools or scripts. -""" - -import socket -import time -import subprocess -import threading -import http.client - -from grass.jupyter import init - - -class NotebookServerManager: - """Manage the lifecycle of a Jupyter Notebook server. - - Handles launching, stopping, and tracking a local Jupyter server - within a specified working directory. - """ - - def __init__(self, notebook_workdir): - self.notebook_workdir = notebook_workdir - self.port = None - self.server_url = None - self.pid = None - - def _find_free_port(self): - """Find a free port on the local machine. - :return: A free port number. - """ - sock = socket.socket() - sock.bind(("", 0)) - port = sock.getsockname()[1] - sock.close() - return port - - @staticmethod - def is_jupyter_notebook_installed(): - """Check if Jupyter notebook is installed. - :return: True if Jupyter notebook is installed, False otherwise. - """ - try: - subprocess.check_output(["jupyter", "notebook", "--version"]) - return True - except (subprocess.CalledProcessError, FileNotFoundError): - return False - - def is_server_running(self, port, retries=10, delay=0.2): - """Wait until an HTTP server responds on the given port. - :param port: Port number to check. - :param retries: Number of retries before giving up. - :param delay: Delay between retries in seconds. - :return: True if the server is up, False otherwise. - """ - for _ in range(retries): - try: - conn = http.client.HTTPConnection("localhost", port, timeout=0.5) - conn.request("GET", "/") - resp = conn.getresponse() - if resp.status in (200, 302, 403): - conn.close() - return True - conn.close() - except Exception: - time.sleep(delay) - return False - - def start_server(self): - """Run Jupyter notebook server in the given directory on a free port. - :param notebooks_dir: Directory where the Jupyter notebook server will be started - :return server_url str: URL of the Jupyter notebook server. - """ - # Check if Jupyter notebook is installed - if not NotebookServerManager.is_jupyter_notebook_installed(): - raise RuntimeError(_("Jupyter notebook is not installed")) - - # Find free port and build server url - self.port = self._find_free_port() - self.server_url = "http://localhost:{}".format(self.port) - - # Create container for PIDs - pid_container = [] - - # Run Jupyter notebook server - def run_server(pid_container): - proc = subprocess.Popen( - [ - "jupyter", - "notebook", - "--no-browser", - "--NotebookApp.token=''", - "--NotebookApp.password=''", - "--port", - str(self.port), - "--notebook-dir", - self.notebook_workdir, - ], - ) - pid_container.append(proc.pid) - - # Save the PID of the Jupyter notebook server - self.pid = pid_container[0] if pid_container else None - - # Start the server in a separate thread - thread = threading.Thread(target=run_server, args=(pid_container,), daemon=True) - thread.start() - - # Initialize the grass.jupyter session for the current mapset - self.initialize_session() - - # Check if the server is up - if not self.is_server_running(self.port): - raise RuntimeError(_("Jupyter server is not running")) - - def initialize_session(self): - """Initialize the Jupyter notebook session. - - This method is called to set up the Jupyter notebook . - """ - # Derive mapset path and initialize GRASS backend - mapset_path = self.notebook_workdir.parent - self.session = init(mapset_path) - - def get_notebook_url(self, notebook_name): - """Return full URL to a notebook served by this server. - - :param notebook_name: Name of the notebook file (e.g. 'example.ipynb') - :return: Full URL to access the notebook - """ - if not self.server_url: - raise RuntimeError(_("Server URL is not set. Start the server first.")) - - return "{base}/notebooks/{file}".format( - base=self.server_url.rstrip("/"), file=notebook_name - ) - - def stop_server(self): - """Stop the Jupyter notebook server. - :return: None - """ - # Find the PID of the Jupyter notebook server - try: - subprocess.check_call(["kill", str(self.pid)]) - except subprocess.CalledProcessError: - pass # No Jupyter server running diff --git a/python/grass/notebooks/template_notebooks/template.ipynb b/python/grass/notebooks/template_notebooks/template.ipynb deleted file mode 100644 index ae4618eaa80..00000000000 --- a/python/grass/notebooks/template_notebooks/template.ipynb +++ /dev/null @@ -1,77 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Template file\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "You can add your own code here\n", - "or create new notebooks in the GRASS GUI\n", - "and they will be automatically saved in the directory: XXX\n", - "and opened in the Jupyter Notebook interface.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import grass.script as gs" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Raster maps in the current mapset:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for rast in gs.list_strings(type=\"raster\"):\n", - " print(\" \", rast)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Vector maps in the current mapset:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for vect in gs.list_strings(type=\"vector\"):\n", - " print(\" \", vect)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.x" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/python/grass/workflows/Makefile b/python/grass/workflows/Makefile new file mode 100644 index 00000000000..5986af77991 --- /dev/null +++ b/python/grass/workflows/Makefile @@ -0,0 +1,20 @@ +MODULE_TOPDIR = ../../.. + +include $(MODULE_TOPDIR)/include/Make/Other.make +include $(MODULE_TOPDIR)/include/Make/Python.make + +DSTDIR = $(ETC)/python/grass/workflows +TEMPLATE_FILES = template_notebooks/welcome.ipynb template_notebooks/new.ipynb +MODULES = server directory + +PYFILES = $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__) +PYCFILES = $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__) +TEMPLATE_DST = $(patsubst %,$(DSTDIR)/%,$(TEMPLATE_FILES)) + +default: $(PYFILES) $(PYCFILES) $(TEMPLATE_DST) + +$(DSTDIR): + $(MKDIR) $@ + +$(DSTDIR)/%: % | $(DSTDIR) + $(INSTALL_DATA) $< $@ diff --git a/python/grass/notebooks/__init__.py b/python/grass/workflows/__init__.py similarity index 67% rename from python/grass/notebooks/__init__.py rename to python/grass/workflows/__init__.py index c5d50064505..5f9cbbc4d9b 100644 --- a/python/grass/notebooks/__init__.py +++ b/python/grass/workflows/__init__.py @@ -1,8 +1,8 @@ -# MODULE: grass.notebooks +# MODULE: grass.workflows # # AUTHOR(S): Linda Karlovska # -# PURPOSE: Tools for managing Jupyter Notebooks within GRASS +# PURPOSE: Tools for managing Jupyter Notebook within GRASS # # COPYRIGHT: (C) 2025 Linda Karlovska, and by the GRASS Development Team # @@ -11,10 +11,10 @@ # for details. """ -Tools for managing Jupyter Notebooks within GRASS +Tools for managing Jupyter Notebook within GRASS This module provides functionality for: -- Starting and stopping local Jupyter Notebook servers inside a GRASS GIS session +- Starting and stopping local Jupyter Notebook servers inside a GRASS session - Managing notebook directories linked to specific GRASS mapsets - Creating default notebook templates for users - Supporting integration with the GUI (e.g., wxGUI) and other tools @@ -24,18 +24,19 @@ Example use case: - A user opens a panel in the GRASS that launches a Jupyter server - and opens the associated notebook directory for the current mapset. + and opens the associated notebook working directory. .. versionadded:: 8.5 """ -from .launcher import NotebookServerManager -from .directory import NotebookDirectoryManager +from .server import JupyterServerInstance, JupyterServerRegistry +from .directory import JupyterDirectoryManager __all__ = [ "Directory", - "Launcher", - "NotebookDirectoryManager", - "NotebookServerManager", + "JupyterDirectoryManager", + "JupyterServerInstance", + "JupyterServerRegistry", + "Server", ] diff --git a/python/grass/workflows/directory.py b/python/grass/workflows/directory.py new file mode 100644 index 00000000000..60a100a5b3a --- /dev/null +++ b/python/grass/workflows/directory.py @@ -0,0 +1,216 @@ +# +# AUTHOR(S): Linda Karlovska +# +# PURPOSE: Provides an interface for managing notebook working directory. +# +# COPYRIGHT: (C) 2025 by Linda Karlovska and the GRASS Development Team +# +# This program is free software under the GNU General Public +# License (>=v2). Read the file COPYING that comes with GRASS +# for details. + +""" +This module defines a class `JupyterDirectoryManager` that provides functionality +for working with Jupyter Notebook files stored within the current GRASS mapset. + +Features: +- Creates a working directory if it does not exist +- Generates default template files +- Lists existing files in a working directory +- Imports files from external locations +- Exports files to external locations + +Designed for use within GRASS GUI tools or scripting environments. +""" + +import json +import shutil +from pathlib import Path + +import grass.script as gs + + +class JupyterDirectoryManager: + """Manage a directory of Jupyter notebooks tied to the current GRASS mapset.""" + + def __init__(self): + """Initialize the Jupyter notebook directory and load existing files.""" + self._workdir = self._get_workdir() + self._files = None + + @property + def workdir(self): + """ + :return: path to the working directory (Path). + """ + return self._workdir + + @property + def files(self): + """ + :return: List of file paths (list[Path]) + """ + return self._files + + def _get_workdir(self): + """ + :return: Path to working directory, it is created if it does not exist (Path). + """ + env = gs.gisenv() + mapset_path = "{gisdbase}/{location}/{mapset}".format( + gisdbase=env["GISDBASE"], + location=env["LOCATION_NAME"], + mapset=env["MAPSET"], + ) + # Create the working directory within the mapset path + workdir = Path(mapset_path) / "notebooks" + workdir.mkdir(parents=True, exist_ok=True) + return workdir + + def import_file(self, source_path, new_name=None, overwrite=False): + """Import an existing notebook file to the working directory. + + :param source_path: Path to the source .ipynb file to import (Path). + :param new_name: New name for the imported file (with .ipynb extension), + if not provided, original filename is used ((Optional[str])) + :param overwrite: Whether to overwrite an existing file with the same name (bool) + :return: Path to the copied file in the working directory (Path) + :raises FileNotFoundError: If the source_path does not exist + :raises FileExistsError: If the target already exists and overwrite=False + """ + # Validate the source path and ensure it has .ipynb extension + source = Path(source_path) + if not source.exists() or source.suffix != ".ipynb": + raise FileNotFoundError(_("File not found: {}").format(source)) + + # Ensure the working directory exists + target_name = new_name or source.name + if not target_name.endswith(".ipynb"): + target_name += ".ipynb" + + # Create the target path in the working directory + target_path = self._workdir / target_name + + # Check if the target file already exists + if target_path.exists() and not overwrite: + raise FileExistsError( + _("Target file already exists: {}").format(target_path) + ) + + # Copy the source file to the target path + shutil.copyfile(source, target_path) + + # Add the new target file to the list of files + self._files.append(target_path) + + return target_path + + def export_file(self, file_name, destination_path, overwrite=False): + """Export a file from the working directory to an external location. + + :param file_name: Name of the file (e.g., "example.ipynb") (str) + :param destination_path: Full file path or target directory to export the file to (Path) + :param overwrite: If True, allows overwriting an existing file at the destination (bool) + :raises FileNotFoundError: If the source file does not exist or is not a .ipynb file + :raises FileExistsError: If the destination file exists and overwrite is False + """ + # Validate the file name and ensure it has .ipynb extension + source_path = self._workdir / file_name + if not source_path.exists() or source_path.suffix != ".ipynb": + raise FileNotFoundError(_("File not found: {}").format(source_path)) + + # Determine the destination path + dest_path = Path(destination_path) + if dest_path.is_dir() or dest_path.suffix != ".ipynb": + dest_path /= file_name + + # Check if the destination file already exists + if dest_path.exists() and not overwrite: + raise FileExistsError(_("Target file already exists: {}").format(dest_path)) + + # Create parent directories if they do not exist + dest_path.parent.mkdir(parents=True, exist_ok=True) + + # Copy the file to the destination + shutil.copyfile(source_path, dest_path) + + def create_welcome_notebook(self, file_name="welcome.ipynb"): + """ + Create a welcome Jupyter notebook in the working directory with + the placeholder '${NOTEBOOK_DIR}' replaced by the actual path. + + :param filename: Name of the template file to copy (str) + :return: Path to the created template file (Path) + """ + # Copy template file to the working directory + template_path = self.import_file( + Path(__file__).parent / "template_notebooks" / file_name + ) + + # Load the template file + with open(template_path, encoding="utf-8"): + content = Path(template_path).read_text(encoding="utf-8") + + # Replace the placeholder '${NOTEBOOK_DIR}' with actual working directory path + content = content.replace( + "${NOTEBOOK_DIR}", str(self._workdir).replace("\\", "/") + ) + + # Save the modified content back to the template file + with open(template_path, "w", encoding="utf-8"): + Path(template_path).write_text(content, encoding="utf-8") + + return template_path + + def create_new_notebook(self, new_name, template_name="new.ipynb"): + """ + Create a new Jupyter notebook in the working directory using a specified template. + + This method copies the content of a template notebook (default: 'new.ipynb') + and saves it as a new file with the user-defined name in the current working directory. + + :param new_name: Desired filename of the new notebook (must end with '.ipynb', + or it will be automatically appended) (str). + :param template_name: Name of the template file to use (default: 'new.ipynb') (str). + :return: Path to the newly created notebook (Path). + :raises ValueError: If the provided name is empty. + :raises FileExistsError: If a notebook with the same name already exists. + :raises FileNotFoundError: If the specified template file does not exist. + """ + if not new_name: + raise ValueError(_("Notebook name must not be empty")) + + if not new_name.endswith(".ipynb"): + new_name += ".ipynb" + + target_path = self.workdir / new_name + + if target_path.exists(): + raise FileExistsError(_("File '{}' already exists").format(new_name)) + + # Load the template notebook content + template_path = Path(__file__).parent / "template_notebooks" / template_name + with open(template_path, encoding="utf-8") as f: + content = json.load(f) + + # Save the content to the new notebook file + with open(target_path, "w", encoding="utf-8") as f: + json.dump(content, f, indent=2) + + # Register the new file internally + self._files.append(target_path) + + return target_path + + def prepare_files(self): + """ + Populate the list of files in the working directory. + Creates a welcome template if does not exist. + """ + # Find all .ipynb files in the notebooks directory + self._files = [ + f for f in Path(self._workdir).iterdir() if str(f).endswith(".ipynb") + ] + if not self._files: + # If no .ipynb files are found, create a welcome ipynb file + self.create_welcome_notebook() diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py new file mode 100644 index 00000000000..28d22843137 --- /dev/null +++ b/python/grass/workflows/server.py @@ -0,0 +1,260 @@ +# +# AUTHOR(S): Linda Karlovska +# +# PURPOSE: Provides a simple interface for launching and managing +# a local Jupyter server. +# +# COPYRIGHT: (C) 2025 by Linda Karlovska and the GRASS Development Team +# +# This program is free software under the GNU General Public +# License (>=v2). Read the file COPYING that comes with GRASS +# for details. + +""" +This module provides two classes for managing Jupyter Notebook servers +programmatically within GRASS GIS tools or scripting environments: + +Classes: +- `JupyterServerInstance`: Manages a single Jupyter Notebook server instance. +- `JupyterServerRegistry`: Manages multiple `JupyterServerInstance` objects + and provides methods to start, track, and stop all active servers. + +Features of `JupyterServerInstance`: +- Checks if Jupyter Notebook is installed. +- Finds an available local port. +- Starts the server in a background thread. +- Verifies that the server is running and accessible. +- Provides the URL to access served files. +- Tracks and manages the server PID. +- Stops the server cleanly on request. +- Registers cleanup routines to stop the server on: + - Normal interpreter exit + - SIGINT (e.g., Ctrl+C) + - SIGTERM (e.g., kill from shell) + +Features of `JupyterServerRegistry`: +- Register and unregister server instances +- Keeps track of all active server instances. +- Stops all servers on global cleanup (e.g., GRASS shutdown). + +Designed for use within GRASS GUI tools or scripting environments. +""" + +import socket +import time +import subprocess +import threading +import http.client +import atexit +import signal +import sys + + +class JupyterServerInstance: + """Manage the lifecycle of a Jupyter server instance.""" + + def __init__(self, workdir): + self.workdir = workdir + self._reset_state() + self._setup_cleanup_handlers() + + def _reset_state(self): + """Reset internal state related to the server.""" + self.pid = None + self.port = None + self.server_url = "" + + def _setup_cleanup_handlers(self): + """Set up handlers to ensure the server is stopped on process exit or signals.""" + # Stop the server when the program exits normally (e.g., via sys.exit() or interpreter exit) + atexit.register(self._safe_stop_server) + + # Stop the server when SIGINT is received (e.g., user presses Ctrl+C) + signal.signal(signal.SIGINT, self._handle_exit_signal) + + # Stop the server when SIGTERM is received (e.g., 'kill PID') + signal.signal(signal.SIGTERM, self._handle_exit_signal) + + def _safe_stop_server(self): + """ + Quietly stop the server without raising exceptions. + + Used for cleanup via atexit or signal handlers. + """ + try: + self.stop_server() + except Exception: + pass + + def _handle_exit_signal(self, signum, frame): + """Handle termination signals and ensure the server is stopped.""" + try: + threading.Thread(target=self._safe_stop_server, daemon=True).start() + except Exception: + pass + finally: + sys.exit(0) + + @staticmethod + def is_jupyter_notebook_installed(): + """Check if Jupyter Notebook is installed. + :return: True if Jupyter Notebook is installed, False otherwise (bool). + """ + try: + subprocess.check_output(["jupyter", "notebook", "--version"]) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + @staticmethod + def find_free_port(): + """Find a free port on the local machine. + :return: A free port number (int). + """ + with socket.socket() as sock: + sock.bind(("", 0)) + return sock.getsockname()[1] + + def is_server_running(self, retries=10, delay=0.2): + """Check if the server in responding on the given port. + :param retries: Number of retries before giving up (int). + :param delay: Delay between retries in seconds (float). + :return: True if the server is up, False otherwise (bool). + """ + for _ in range(retries): + try: + conn = http.client.HTTPConnection("localhost", self.port, timeout=0.5) + conn.request("GET", "/") + if conn.getresponse().status in (200, 302, 403): + conn.close() + return True + conn.close() + except Exception: + time.sleep(delay) + return False + + def start_server(self): + """Run Jupyter server in the given directory on a free port.""" + # Check if Jupyter Notebook is installed + if not JupyterServerInstance.is_jupyter_notebook_installed(): + raise RuntimeError(_("Jupyter Notebook is not installed")) + + # Find free port and build server url + self.port = JupyterServerInstance.find_free_port() + self.server_url = "http://localhost:{}".format(self.port) + + # Create container for PIDs + pid_container = [] + + # Run Jupyter notebook server + def run_server(pid_container): + proc = subprocess.Popen( + [ + "jupyter", + "notebook", + "--no-browser", + "--NotebookApp.token=''", + "--NotebookApp.password=''", + "--port", + str(self.port), + "--notebook-dir", + self.workdir, + ], + ) + pid_container.append(proc.pid) + + # Start the server in a separate thread + thread = threading.Thread(target=run_server, args=(pid_container,), daemon=True) + thread.start() + + # Check if the server is up + if not self.is_server_running(self.port): + raise RuntimeError(_("Jupyter server is not running")) + + # Save the PID of the Jupyter server + self.pid = pid_container[0] if pid_container else None + + def stop_server(self): + """Stop the Jupyter server. + :raises RuntimeError: If the server is not running or cannot be stopped. + """ + if not self.pid or self.pid <= 0: + raise RuntimeError(_("Jupyter server is not running or PID is invalid.")) + + # Check if the process with the given PID is a Jupyter server + try: + proc_name = ( + subprocess.check_output(["ps", "-p", str(self.pid), "-o", "args="]) + .decode() + .strip() + ) + if "jupyter-notebook" not in proc_name: + raise RuntimeError( + _( + "Process with PID {} is not a Jupyter server: found '{}'." + ).format(self.pid, proc_name) + ) + except subprocess.CalledProcessError: + raise RuntimeError(_("No process found with PID {}.").format(self.pid)) + + # Attempt to terminate the server process + if self.is_server_running(self.port): + try: + subprocess.check_call(["kill", str(self.pid)]) + except subprocess.CalledProcessError as e: + raise RuntimeError( + _("Could not terminate Jupyter server with PID {}.").format( + self.pid + ) + ) from e + + # Clean up internal state + self._reset_state() + + def get_url(self, file_name): + """Return full URL to a file served by this server. + + :param file_name: Name of the file (e.g. 'example.ipynb') (str). + :return: Full URL to access the file (str). + """ + if not self.server_url: + raise RuntimeError(_("Server URL is not set. Start the server first.")) + + return "{base}/notebooks/{file}".format( + base=self.server_url.rstrip("/"), file=file_name + ) + + +class JupyterServerRegistry: + """Registry of running JupyterServerInstance objects.""" + + _instance = None + + @classmethod + def get(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + self.servers = [] + + def register(self, server): + if server not in self.servers: + self.servers.append(server) + + def unregister(self, server): + if server in self.servers: + self.servers.remove(server) + + def stop_all_servers(self): + for server in self.servers[:]: + try: + server.stop_server() + except Exception as e: + print(f"Failed to stop Jupyter server: {e}") + finally: + self.unregister(server) + + def get_servers(self): + return list(self.servers) diff --git a/python/grass/workflows/template_notebooks/new.ipynb b/python/grass/workflows/template_notebooks/new.ipynb new file mode 100644 index 00000000000..75dbc90a80b --- /dev/null +++ b/python/grass/workflows/template_notebooks/new.ipynb @@ -0,0 +1,40 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Import GRASS scripting and Jupyter modules\n", + "import grass.script as gs\n", + "import grass.jupyter as gj" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize Jupyter environment for GRASS\n", + "gisenv = gs.gisenv()\n", + "mapset_path = f\"{gisenv['GISDBASE']}/{gisenv['LOCATION_NAME']}/{gisenv['MAPSET']}\"\n", + "gj.init(mapset_path)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python [GRASS]", + "language": "python", + "name": "grass" + }, + "language_info": { + "name": "python", + "version": "3.x" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/python/grass/workflows/template_notebooks/welcome.ipynb b/python/grass/workflows/template_notebooks/welcome.ipynb new file mode 100644 index 00000000000..5407781245d --- /dev/null +++ b/python/grass/workflows/template_notebooks/welcome.ipynb @@ -0,0 +1,56 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Welcome to GRASS Jupyter environment 👋\n", + "\n", + "Jupyter server for this environment was started in the directory `${NOTEBOOK_DIR}`.\n", + "\n", + "---\n", + "This notebook is ready to use with GRASS.\n", + "You can run Python code using GRASS modules and data.\n", + "\n", + "---\n", + "**Tip:** Start by running a cell below, or create a new empty notebook by clicking the *Create new notebook* button." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Import GRASS scripting and Jupyter modules\n", + "import grass.script as gs\n", + "import grass.jupyter as gj" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize Jupyter environment for GRASS\n", + "gisenv = gs.gisenv()\n", + "mapset_path = f\"{gisenv['GISDBASE']}/{gisenv['LOCATION_NAME']}/{gisenv['MAPSET']}\"\n", + "gj.init(mapset_path)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python [GRASS]", + "language": "python", + "name": "grass" + }, + "language_info": { + "name": "python", + "version": "3.x" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From 4816bd048824fe43e70741e741f474bc22dadb35 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 17 Jun 2025 18:57:29 +0200 Subject: [PATCH 04/28] applying relevant github-advanced-security suggestions --- gui/wxpython/jupyter_notebook/panel.py | 8 +++++--- python/grass/workflows/server.py | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 251ac1733ba..ba78b524f11 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -14,15 +14,17 @@ @author Linda Karlovska """ -import wx from pathlib import Path -from .notebook import JupyterAuiNotebook -from .toolbars import JupyterToolbar +import wx + from main_window.page import MainPageBase from grass.workflows.directory import JupyterDirectoryManager from grass.workflows.server import JupyterServerInstance, JupyterServerRegistry +from .notebook import JupyterAuiNotebook +from .toolbars import JupyterToolbar + class JupyterPanel(wx.Panel, MainPageBase): def __init__( diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index 28d22843137..af84ff7c956 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -48,6 +48,7 @@ import atexit import signal import sys +import shutil class JupyterServerInstance: @@ -112,7 +113,7 @@ def find_free_port(): :return: A free port number (int). """ with socket.socket() as sock: - sock.bind(("", 0)) + sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] def is_server_running(self, retries=10, delay=0.2): @@ -183,6 +184,9 @@ def stop_server(self): # Check if the process with the given PID is a Jupyter server try: + ps_cmd = shutil.which("ps") + if not ps_cmd: + raise RuntimeError(_("Unable to find 'ps' command in PATH.")) proc_name = ( subprocess.check_output(["ps", "-p", str(self.pid), "-o", "args="]) .decode() @@ -194,12 +198,17 @@ def stop_server(self): "Process with PID {} is not a Jupyter server: found '{}'." ).format(self.pid, proc_name) ) - except subprocess.CalledProcessError: - raise RuntimeError(_("No process found with PID {}.").format(self.pid)) + except subprocess.CalledProcessError as e: + raise RuntimeError( + _("No process found with PID {}.").format(self.pid) + ) from e # Attempt to terminate the server process if self.is_server_running(self.port): try: + kill_cmd = shutil.which("kill") + if not kill_cmd: + raise RuntimeError(_("Unable to find 'kill' command in PATH.")) subprocess.check_call(["kill", str(self.pid)]) except subprocess.CalledProcessError as e: raise RuntimeError( From d744c63234c7267b65e6c65eadf24c4a68f15d44 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Wed, 1 Oct 2025 14:36:32 +0200 Subject: [PATCH 05/28] handling better when jupyter is not installed or is launched on Windows where jupyter is not part of the build process yet --- gui/icons/grass/jupyter-inactive.png | Bin 0 -> 925 bytes gui/icons/grass/jupyter-inactive.svg | 101 +++++++++++++++++++++++++++ gui/wxpython/lmgr/toolbars.py | 21 +++++- gui/wxpython/main_window/frame.py | 20 ++++++ python/grass/workflows/server.py | 42 +++++++---- 5 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 gui/icons/grass/jupyter-inactive.png create mode 100644 gui/icons/grass/jupyter-inactive.svg diff --git a/gui/icons/grass/jupyter-inactive.png b/gui/icons/grass/jupyter-inactive.png new file mode 100644 index 0000000000000000000000000000000000000000..1f61bf5bd68d2023293ef2c11a161feaaedf3ecd GIT binary patch literal 925 zcmV;O17iG%P)G zK~zYI?UqYu6j>C8|8uKio3Yi2P82mGG6=pJ@09sX5-5D&Z!JxgpeMuw| zSwAo^aJjOJ<2Vt|^L|$pWgCFy0HOf?W9Iuph%4!I`X&ILvX27TJXOClV>iJd9*>U_ z(FOn=uIruwfZpET231vcB02=%ry3`_OGHkgP&n;*-aKaB>HGc-%d#GZo{)$h09XUy z2>=*|u@As$B5JGwn$}uoJ{gThJL~J~59qpnp_+**6NyA(zU#UL%d)zeIalF%0^m7- zX8&U($8`QON1YFpP}= zT7?jMnYm>Ox`8XO#)Yno!E69@pgTyBRDVgx`vfcwlm0$`-BuI}%yuCAv+Z(CcN z(%jsLi=rrMM@Pp$0LZee z{mk46pas=mdPO3-P%fAM(slh>tsQ*;92y$RGjrF}c5{hDLZ7bn^z{4~jYhW;(F!T$ z0wKh>5MoSGlF4P!HvBi5^vu5J00000NkvXXu0mjf+e?^{ literal 0 HcmV?d00001 diff --git a/gui/icons/grass/jupyter-inactive.svg b/gui/icons/grass/jupyter-inactive.svg new file mode 100644 index 00000000000..341f838fed1 --- /dev/null +++ b/gui/icons/grass/jupyter-inactive.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/wxpython/lmgr/toolbars.py b/gui/wxpython/lmgr/toolbars.py index 66a77254323..903540e5e0e 100644 --- a/gui/wxpython/lmgr/toolbars.py +++ b/gui/wxpython/lmgr/toolbars.py @@ -24,6 +24,7 @@ """ from core.gcmd import RunCommand +from grass.workflows.server import is_jupyter_installed from gui_core.toolbars import BaseToolbar, AuiToolbar, BaseIcons from icons.icon import MetaIcon @@ -212,11 +213,25 @@ def _toolbarData(self): img="python", label=_("Open a simple Python code editor") ), "jupyter": MetaIcon(img="jupyter", label=_("Start Jupyter Notebook")), + "jupyter-inactive": MetaIcon( + img="jupyter-inactive", + label=_( + "Start Jupyter Notebook - requires Jupyter Notebook, click for more info" + ), + ), "script-load": MetaIcon( img="script-load", label=_("Launch user-defined script") ), } + # Decide if Jupyter is available + if is_jupyter_installed(): + jupyter_icon = icons["jupyter"] + jupyter_handler = self.parent.OnJupyterNotebook + else: + jupyter_icon = icons["jupyter-inactive"] + jupyter_handler = self.parent.OnShowJupyterInfo + return self._getToolbarData( ( ( @@ -252,9 +267,9 @@ def _toolbarData(self): self.parent.OnSimpleEditor, ), ( - ("jupyter", icons["jupyter"].label), - icons["jupyter"], - self.parent.OnJupyterNotebook, + ("jupyter", jupyter_icon.label), + jupyter_icon, + jupyter_handler, ), ( ("script-load", icons["script-load"].label), diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 92cb137347d..3b6440a4cce 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -919,6 +919,26 @@ def OnJupyterNotebook(self, event=None, cmd=None): # add map display panel to notebook and make it current self.mainnotebook.AddPage(jupyter_panel, _("Jupyter Notebook")) + def OnShowJupyterInfo(self, event=None): + """Show information dialog when Jupyter Notebook is not installed.""" + if sys.platform.startswith("win"): + message = _( + "Jupyter Notebook is currently not included in the Windows GRASS build process.\n" + "This feature will be available in a future release." + ) + else: + message = _( + "To use notebooks in GRASS, you need to have the Jupyter Notebook " + "package installed. After the installation, please restart GRASS to enable this feature." + ) + + wx.MessageBox( + message=message, + caption=_("Jupyter Notebook not available"), + style=wx.OK | wx.ICON_INFORMATION, + parent=self, + ) + def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" from psmap.frame import PsMapFrame diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index af84ff7c956..cd5bab1da67 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -14,6 +14,8 @@ This module provides two classes for managing Jupyter Notebook servers programmatically within GRASS GIS tools or scripting environments: +Functions: +- `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system. Classes: - `JupyterServerInstance`: Manages a single Jupyter Notebook server instance. - `JupyterServerRegistry`: Manages multiple `JupyterServerInstance` objects @@ -51,6 +53,33 @@ import shutil +def is_jupyter_installed(): + """Check if Jupyter Notebook is installed. + + - On Linux/macOS: returns True if the presence command succeeds, False otherwise. + - On Windows: currently always returns False because Jupyter is + not bundled. + TODO: Once Jupyter becomes part of the Windows build + process, this method should simply return True without additional checks. + + :return: True if Jupyter Notebook is installed and available, False otherwise. + """ + if sys.platform.startswith("win"): + # For now, always disabled on Windows + return False + + try: + result = subprocess.run( + ["jupyter", "notebook", "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return result.returncode == 0 + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + class JupyterServerInstance: """Manage the lifecycle of a Jupyter server instance.""" @@ -96,17 +125,6 @@ def _handle_exit_signal(self, signum, frame): finally: sys.exit(0) - @staticmethod - def is_jupyter_notebook_installed(): - """Check if Jupyter Notebook is installed. - :return: True if Jupyter Notebook is installed, False otherwise (bool). - """ - try: - subprocess.check_output(["jupyter", "notebook", "--version"]) - return True - except (subprocess.CalledProcessError, FileNotFoundError): - return False - @staticmethod def find_free_port(): """Find a free port on the local machine. @@ -137,7 +155,7 @@ def is_server_running(self, retries=10, delay=0.2): def start_server(self): """Run Jupyter server in the given directory on a free port.""" # Check if Jupyter Notebook is installed - if not JupyterServerInstance.is_jupyter_notebook_installed(): + if not is_jupyter_installed(): raise RuntimeError(_("Jupyter Notebook is not installed")) # Find free port and build server url From 0b77318ffea8e9289f23eef88ee61d11bc54a62b Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 17 Oct 2025 13:44:58 +0200 Subject: [PATCH 06/28] add info about working dir to status msg --- gui/wxpython/jupyter_notebook/panel.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index ba78b524f11..ca9f0ab0b72 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -79,8 +79,12 @@ def SetUpNotebookInterface(self): JupyterServerRegistry.get().register(self.server_manager) # Update the status bar with server info - status_msg = _("Jupyter server has started at {url} (PID: {pid})").format( - url=self.server_manager.server_url, pid=self.server_manager.pid + status_msg = _( + "Jupyter server has started at {url} (PID: {pid}) in working directory {dir}" + ).format( + url=self.server_manager.server_url, + pid=self.server_manager.pid, + dir=self.workdir, ) self.SetStatusText(status_msg, 0) From c00740d3f4452ab532355faa0e526dd47b199908 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 20 Oct 2025 13:58:03 +0200 Subject: [PATCH 07/28] new start dialog for working dir and template settings --- gui/wxpython/jupyter_notebook/dialogs.py | 115 +++++++++++++++++++++++ gui/wxpython/jupyter_notebook/panel.py | 8 +- gui/wxpython/main_window/frame.py | 21 ++++- python/grass/workflows/directory.py | 43 +++++---- 4 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 gui/wxpython/jupyter_notebook/dialogs.py diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py new file mode 100644 index 00000000000..02b0b6a0915 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -0,0 +1,115 @@ +""" +@package jupyter_notebook.dialog + +@brief Integration of Jupyter Notebook to GUI. + +Classes: + - dialog::JupyterStartDialog + +(C) 2025 by the GRASS Development Team + +This program is free software under the GNU General Public License +(>=v2). Read the file COPYING that comes with GRASS for details. + +@author Linda Karlovska +""" + +from pathlib import Path +import grass.script as gs + +import wx + + +class JupyterStartDialog(wx.Dialog): + """Dialog for selecting working directory and Jupyter startup options.""" + + def __init__(self, parent): + wx.Dialog.__init__( + self, parent, title=_("Start Jupyter Notebook"), size=(500, 300) + ) + env = gs.gisenv() + mapset_path = Path(env["GISDBASE"]) / env["LOCATION_NAME"] / env["MAPSET"] + self.default_dir = mapset_path / "notebooks" + + self.selected_dir = self.default_dir + self.create_template = True + + sizer = wx.BoxSizer(wx.VERTICAL) + + # Working directory section + dir_box = wx.StaticBox(self, label=_("Notebook working directory")) + dir_sizer = wx.StaticBoxSizer(dir_box, wx.VERTICAL) + + self.radio_default = wx.RadioButton( + self, + label=_("Use default: {}").format(self.default_dir), + style=wx.RB_GROUP, + ) + self.radio_custom = wx.RadioButton(self, label=_("Select another directory:")) + + self.dir_picker = wx.DirPickerCtrl( + self, + message=_("Choose a working directory"), + style=wx.DIRP_USE_TEXTCTRL | wx.DIRP_DIR_MUST_EXIST, + ) + self.dir_picker.Enable(False) + + dir_sizer.Add(self.radio_default, 0, wx.ALL, 5) + dir_sizer.Add(self.radio_custom, 0, wx.ALL, 5) + dir_sizer.Add(self.dir_picker, 0, wx.EXPAND | wx.ALL, 5) + sizer.Add(dir_sizer, 0, wx.EXPAND | wx.ALL, 10) + + # Jupyter startup section + options_box = wx.StaticBox(self, label=_("Options")) + options_sizer = wx.StaticBoxSizer(options_box, wx.VERTICAL) + + self.checkbox_template = wx.CheckBox(self, label=_("Create example notebook")) + self.checkbox_template.SetValue(True) + self.checkbox_template.SetToolTip( + _( + "If selected, a welcome notebook (welcome.ipynb) will be created,\n" + "but only if the selected directory contains no .ipynb files." + ) + ) + options_sizer.Add(self.checkbox_template, 0, wx.ALL, 5) + + info = wx.StaticText( + self, + label=_( + "Note: Template will be created only if the directory contains no .ipynb files." + ), + ) + + options_sizer.Add(info, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 8) + + sizer.Add(options_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 10) + + # OK / Cancel buttons + btns = self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL) + sizer.Add(btns, 0, wx.EXPAND | wx.ALL, 10) + + self.SetSizer(sizer) + + self.radio_default.Bind(wx.EVT_RADIOBUTTON, self.OnRadioToggle) + self.radio_custom.Bind(wx.EVT_RADIOBUTTON, self.OnRadioToggle) + + self.Fit() + self.Layout() + self.SetMinSize(self.GetSize()) + self.CentreOnParent() + + def OnRadioToggle(self, event): + """Enable/disable directory picker based on user choice.""" + self.dir_picker.Enable(self.radio_custom.GetValue()) + + def GetValues(self): + """Return selected working directory and template preference.""" + if self.radio_custom.GetValue(): + self.selected_dir = Path(self.dir_picker.GetPath()) + else: + self.selected_dir = self.default_dir + + return { + "directory": self.selected_dir, + "create_template": self.checkbox_template.GetValue(), + } diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index ca9f0ab0b72..0c5082ddcf0 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -35,6 +35,8 @@ def __init__( title=_("Jupyter Notebook"), statusbar=None, dockable=False, + workdir=None, + create_template=False, **kwargs, ): """Jupyter main panel.""" @@ -44,11 +46,13 @@ def __init__( self.parent = parent self._giface = giface self.statusbar = statusbar + self.workdir = workdir self.SetName("Jupyter") - self.directory_manager = JupyterDirectoryManager() - self.workdir = self.directory_manager.workdir + self.directory_manager = JupyterDirectoryManager( + workdir=self.workdir, create_template=create_template + ) self.server_manager = JupyterServerInstance(workdir=self.workdir) self.toolbar = JupyterToolbar(parent=self) diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 3b6440a4cce..d454b9825a6 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -909,9 +909,28 @@ def OnGModeler(self, event=None, cmd=None): def OnJupyterNotebook(self, event=None, cmd=None): """Launch Jupyter Notebook page. See OnJupyterNotebook documentation""" from jupyter_notebook.panel import JupyterPanel + from jupyter_notebook.dialogs import JupyterStartDialog + + dlg = JupyterStartDialog(parent=self) + result = dlg.ShowModal() + + if result != wx.ID_OK: + dlg.Destroy() + return + + values = dlg.GetValues() + dlg.Destroy() + + workdir = values["directory"] + create_template = values["create_template"] jupyter_panel = JupyterPanel( - parent=self, giface=self._giface, statusbar=self.statusbar, dockable=True + parent=self, + giface=self._giface, + statusbar=self.statusbar, + dockable=True, + workdir=workdir, + create_template=create_template, ) jupyter_panel.SetUpPage(self, self.mainnotebook) jupyter_panel.SetUpNotebookInterface() diff --git a/python/grass/workflows/directory.py b/python/grass/workflows/directory.py index 60a100a5b3a..a14d4df138d 100644 --- a/python/grass/workflows/directory.py +++ b/python/grass/workflows/directory.py @@ -33,10 +33,17 @@ class JupyterDirectoryManager: """Manage a directory of Jupyter notebooks tied to the current GRASS mapset.""" - def __init__(self): - """Initialize the Jupyter notebook directory and load existing files.""" - self._workdir = self._get_workdir() - self._files = None + def __init__(self, workdir=None, create_template=False): + """Initialize the Jupyter notebook directory and load existing files. + + :param workdir: Optional custom working directory (Path). If not provided, + the default MAPSET notebooks directory is used. + :param create_template: If True, create a welcome notebook if the directory is empty. + """ + self._workdir = workdir or self._get_workdir() + self._workdir.mkdir(parents=True, exist_ok=True) + self._files = [] + self._create_template = create_template @property def workdir(self): @@ -54,7 +61,7 @@ def files(self): def _get_workdir(self): """ - :return: Path to working directory, it is created if it does not exist (Path). + :return: Path to default working directory, it is created if it does not exist (Path). """ env = gs.gisenv() mapset_path = "{gisdbase}/{location}/{mapset}".format( @@ -67,6 +74,19 @@ def _get_workdir(self): workdir.mkdir(parents=True, exist_ok=True) return workdir + def prepare_files(self): + """ + Populate the list of files in the working directory. + """ + # Find all .ipynb files in the notebooks directory + + self._files = [ + f for f in Path(self._workdir).iterdir() if str(f).endswith(".ipynb") + ] + + if self._create_template and not self._files: + self.create_welcome_notebook() + def import_file(self, source_path, new_name=None, overwrite=False): """Import an existing notebook file to the working directory. @@ -201,16 +221,3 @@ def create_new_notebook(self, new_name, template_name="new.ipynb"): self._files.append(target_path) return target_path - - def prepare_files(self): - """ - Populate the list of files in the working directory. - Creates a welcome template if does not exist. - """ - # Find all .ipynb files in the notebooks directory - self._files = [ - f for f in Path(self._workdir).iterdir() if str(f).endswith(".ipynb") - ] - if not self._files: - # If no .ipynb files are found, create a welcome ipynb file - self.create_welcome_notebook() From 9fa46b49f79eb0343dd7c871f0472a3980bd5945 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 21 Oct 2025 14:36:55 +0200 Subject: [PATCH 08/28] refactoring and other refinement edits --- gui/wxpython/jupyter_notebook/dialogs.py | 35 +++-- gui/wxpython/jupyter_notebook/panel.py | 121 ++++++++++-------- gui/wxpython/main_window/frame.py | 25 +++- python/grass/workflows/Makefile | 2 +- python/grass/workflows/__init__.py | 5 +- python/grass/workflows/directory.py | 70 +++++----- python/grass/workflows/environment.py | 56 ++++++++ python/grass/workflows/server.py | 18 +-- .../template_notebooks/welcome.ipynb | 2 +- 9 files changed, 210 insertions(+), 124 deletions(-) create mode 100644 python/grass/workflows/environment.py diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index 02b0b6a0915..a95f8dc2112 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -1,5 +1,5 @@ """ -@package jupyter_notebook.dialog +@package jupyter_notebook.dialogs @brief Integration of Jupyter Notebook to GUI. @@ -14,22 +14,21 @@ @author Linda Karlovska """ +import os from pathlib import Path -import grass.script as gs import wx +from grass.workflows.directory import get_default_jupyter_workdir + class JupyterStartDialog(wx.Dialog): - """Dialog for selecting working directory and Jupyter startup options.""" + """Dialog for selecting Jupyter startup options.""" def __init__(self, parent): - wx.Dialog.__init__( - self, parent, title=_("Start Jupyter Notebook"), size=(500, 300) - ) - env = gs.gisenv() - mapset_path = Path(env["GISDBASE"]) / env["LOCATION_NAME"] / env["MAPSET"] - self.default_dir = mapset_path / "notebooks" + super().__init__(parent, title=_("Start Jupyter Notebook"), size=(500, 300)) + + self.default_dir = get_default_jupyter_workdir() self.selected_dir = self.default_dir self.create_template = True @@ -59,11 +58,11 @@ def __init__(self, parent): dir_sizer.Add(self.dir_picker, 0, wx.EXPAND | wx.ALL, 5) sizer.Add(dir_sizer, 0, wx.EXPAND | wx.ALL, 10) - # Jupyter startup section + # Template preference section options_box = wx.StaticBox(self, label=_("Options")) options_sizer = wx.StaticBoxSizer(options_box, wx.VERTICAL) - self.checkbox_template = wx.CheckBox(self, label=_("Create example notebook")) + self.checkbox_template = wx.CheckBox(self, label=_("Create welcome notebook")) self.checkbox_template.SetValue(True) self.checkbox_template.SetToolTip( _( @@ -76,7 +75,7 @@ def __init__(self, parent): info = wx.StaticText( self, label=_( - "Note: Template will be created only if the directory contains no .ipynb files." + "Note: The welcome notebook will be created only if the directory contains no .ipynb files." ), ) @@ -84,7 +83,6 @@ def __init__(self, parent): sizer.Add(options_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 10) - # OK / Cancel buttons btns = self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL) sizer.Add(btns, 0, wx.EXPAND | wx.ALL, 10) @@ -105,7 +103,16 @@ def OnRadioToggle(self, event): def GetValues(self): """Return selected working directory and template preference.""" if self.radio_custom.GetValue(): - self.selected_dir = Path(self.dir_picker.GetPath()) + path = Path(self.dir_picker.GetPath()) + + if not os.access(path, os.W_OK) or not os.access(path, os.X_OK): + wx.MessageBox( + _("You do not have permission to write to the selected directory."), + _("Error"), + wx.ICON_ERROR, + ) + return None + self.selected_dir = path else: self.selected_dir = self.default_dir diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 0c5082ddcf0..2f5f287a3e9 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -19,8 +19,7 @@ import wx from main_window.page import MainPageBase -from grass.workflows.directory import JupyterDirectoryManager -from grass.workflows.server import JupyterServerInstance, JupyterServerRegistry +from grass.workflows.environment import JupyterEnvironment from .notebook import JupyterAuiNotebook from .toolbars import JupyterToolbar @@ -47,13 +46,9 @@ def __init__( self._giface = giface self.statusbar = statusbar self.workdir = workdir - self.SetName("Jupyter") - self.directory_manager = JupyterDirectoryManager( - workdir=self.workdir, create_template=create_template - ) - self.server_manager = JupyterServerInstance(workdir=self.workdir) + self.env = JupyterEnvironment(self.workdir, create_template) self.toolbar = JupyterToolbar(parent=self) self.aui_notebook = JupyterAuiNotebook(parent=self) @@ -72,31 +67,38 @@ def _layout(self): self.Layout() def SetUpNotebookInterface(self): - """Start server and load files available in a working directory.""" - # Prepare the working directory (find all existing files, copy a template file if needed) - self.directory_manager.prepare_files() - - # Start the Jupyter server in the specified working directory - self.server_manager.start_server() - - # Register server to server registry - JupyterServerRegistry.get().register(self.server_manager) - - # Update the status bar with server info - status_msg = _( - "Jupyter server has started at {url} (PID: {pid}) in working directory {dir}" - ).format( - url=self.server_manager.server_url, - pid=self.server_manager.pid, - dir=self.workdir, - ) - self.SetStatusText(status_msg, 0) + """Setup Jupyter notebook environment and load initial notebooks.""" + try: + self.env.setup() + except Exception as e: + wx.MessageBox( + _("Failed to start Jupyter environment:\n{}").format(str(e)), + _("Startup Error"), + wx.ICON_ERROR, + ) + return - # Load all existing files found in the working directory as separate tabs - for fname in self.directory_manager.files: - url = self.server_manager.get_url(fname.name) + # Load notebook tabs + for fname in self.env.directory.files: + try: + url = self.env.server.get_url(fname.name) + except RuntimeError as e: + wx.MessageBox( + _("Failed to get Jupyter server URLt:\n{}").format(str(e)), + _("Startup Error"), + wx.ICON_ERROR, + ) + return self.aui_notebook.AddPage(url=url, title=fname.name) + self.SetStatusText( + _("Jupyter server started at {url} (PID: {pid}), directory: {dir}").format( + url=self.env.server.server_url, + pid=self.env.server.pid, + dir=str(self.workdir), + ) + ) + def Switch(self, file_name): """ Switch to existing notebook tab. @@ -114,9 +116,16 @@ def Open(self, file_name): Open a Jupyter notebook to a new tab and switch to it. :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') (str). """ - url = self.server_manager.get_url(file_name) - self.aui_notebook.AddPage(url=url, title=file_name) - self.aui_notebook.SetSelection(self.aui_notebook.GetPageCount() - 1) + try: + url = self.env.server.get_url(file_name) + self.aui_notebook.AddPage(url=url, title=file_name) + self.aui_notebook.SetSelection(self.aui_notebook.GetPageCount() - 1) + except RuntimeError as e: + wx.MessageBox( + _("Failed to get Jupyter server URL:\n{}").format(str(e)), + _("URL Error"), + wx.ICON_ERROR, + ) def OpenOrSwitch(self, file_name): """ @@ -136,7 +145,7 @@ def Import(self, source_path, new_name=None): :param new_name: Optional new name for the imported file (str). """ try: - path = self.directory_manager.import_file(source_path, new_name=new_name) + path = self.env.directory.import_file(source_path, new_name=new_name) self.Open(path.name) self.SetStatusText(_("File '{}' imported and opened.").format(path.name), 0) except Exception as e: @@ -169,7 +178,7 @@ def OnImport(self, event=None): source_path = Path(dlg.GetPath()) file_name = source_path.name - target_path = self.directory_manager.workdir / file_name + target_path = self.workdir / file_name # File is already in the working directory if source_path.resolve() == target_path.resolve(): @@ -222,7 +231,7 @@ def OnExport(self, event=None): destination_path = Path(dlg.GetPath()) try: - self.directory_manager.export_file( + self.env.directory.export_file( file_name, destination_path, overwrite=True ) self.SetStatusText( @@ -254,7 +263,7 @@ def OnCreate(self, event=None): return try: - path = self.directory_manager.create_new_notebook(new_name=name) + path = self.env.directory.create_new_notebook(new_name=name) except Exception as e: wx.MessageBox( _("Failed to create notebook:\n{}").format(str(e)), @@ -287,25 +296,25 @@ def OnCloseWindow(self, event): event.Veto() return - if self.server_manager: - try: - # Stop the Jupyter server - self.server_manager.stop_server() - - # Unregister server from server registry - JupyterServerRegistry.get().unregister(self.server_manager) - self.SetStatusText(_("Jupyter server has been stopped."), 0) - except RuntimeError as e: - wx.MessageBox( - _("Failed to stop the Jupyter server:\n{}").format(str(e)), - _("Error"), - wx.ICON_ERROR | wx.OK, - ) - self.SetStatusText(_("Failed to stop Jupyter server."), 0) + # Get server info + url = self.env.server.server_url + pid = self.env.server.pid - # Clean up the server manager - if hasattr(self.GetParent(), "jupyter_server_manager"): - self.GetParent().jupyter_server_manager = None - - # Close the notebook panel + # Stop server and close panel + try: + self.env.stop() + except RuntimeError as e: + wx.MessageBox( + _("Failed to stop Jupyter server at {url} (PID: {pid}):\n{err}").format( + url=url, pid=pid, err=str(e) + ), + caption=_("Error"), + style=wx.ICON_ERROR | wx.OK, + ) + return + self.SetStatusText( + _("Jupyter server at {url} (PID: {pid}) has been stopped").format( + url=url, pid=pid + ) + ) self._onCloseWindow(event) diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index d454b9825a6..662c89ac270 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -907,7 +907,7 @@ def OnGModeler(self, event=None, cmd=None): self.mainnotebook.AddPage(gmodeler_panel, _("Graphical Modeler")) def OnJupyterNotebook(self, event=None, cmd=None): - """Launch Jupyter Notebook page. See OnJupyterNotebook documentation""" + """Launch Jupyter Notebook interface.""" from jupyter_notebook.panel import JupyterPanel from jupyter_notebook.dialogs import JupyterStartDialog @@ -921,6 +921,9 @@ def OnJupyterNotebook(self, event=None, cmd=None): values = dlg.GetValues() dlg.Destroy() + if not values: + return + workdir = values["directory"] create_template = values["create_template"] @@ -939,7 +942,7 @@ def OnJupyterNotebook(self, event=None, cmd=None): self.mainnotebook.AddPage(jupyter_panel, _("Jupyter Notebook")) def OnShowJupyterInfo(self, event=None): - """Show information dialog when Jupyter Notebook is not installed.""" + """Show information dialog when Jupyter Notebook is not available.""" if sys.platform.startswith("win"): message = _( "Jupyter Notebook is currently not included in the Windows GRASS build process.\n" @@ -947,8 +950,9 @@ def OnShowJupyterInfo(self, event=None): ) else: message = _( - "To use notebooks in GRASS, you need to have the Jupyter Notebook " - "package installed. After the installation, please restart GRASS to enable this feature." + "To use notebooks in GRASS, you need to have the Jupyter Notebook package installed. " + "For full functionality, we also recommend installing the visualization libraries " + "Folium and ipyleaflet. After installing these packages, please restart GRASS to enable this feature." ) wx.MessageBox( @@ -2458,10 +2462,17 @@ def _closeWindow(self, event): event.Veto() return - from grass.workflows import JupyterServerRegistry - - JupyterServerRegistry.get().stop_all_servers() + # Stop all running Jupyter servers before destroying the GUI + from grass.workflows import JupyterEnvironment + try: + JupyterEnvironment.stop_all() + except RuntimeError as e: + wx.MessageBox( + _("Failed to stop Jupyter servers:\n{}").format(str(e)), + caption=_("Error"), + style=wx.ICON_ERROR | wx.OK, + ) self.DisplayCloseAll() self._auimgr.UnInit() diff --git a/python/grass/workflows/Makefile b/python/grass/workflows/Makefile index 5986af77991..e2eaaf8f8ed 100644 --- a/python/grass/workflows/Makefile +++ b/python/grass/workflows/Makefile @@ -5,7 +5,7 @@ include $(MODULE_TOPDIR)/include/Make/Python.make DSTDIR = $(ETC)/python/grass/workflows TEMPLATE_FILES = template_notebooks/welcome.ipynb template_notebooks/new.ipynb -MODULES = server directory +MODULES = server directory environment PYFILES = $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__) PYCFILES = $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__) diff --git a/python/grass/workflows/__init__.py b/python/grass/workflows/__init__.py index 5f9cbbc4d9b..06af96a1490 100644 --- a/python/grass/workflows/__init__.py +++ b/python/grass/workflows/__init__.py @@ -15,7 +15,7 @@ This module provides functionality for: - Starting and stopping local Jupyter Notebook servers inside a GRASS session -- Managing notebook directories linked to specific GRASS mapsets +- Managing notebook working directories - Creating default notebook templates for users - Supporting integration with the GUI (e.g., wxGUI) and other tools @@ -32,10 +32,13 @@ from .server import JupyterServerInstance, JupyterServerRegistry from .directory import JupyterDirectoryManager +from .environment import JupyterEnvironment __all__ = [ "Directory", + "Environment", "JupyterDirectoryManager", + "JupyterEnvironment", "JupyterServerInstance", "JupyterServerRegistry", "Server", diff --git a/python/grass/workflows/directory.py b/python/grass/workflows/directory.py index a14d4df138d..5936d9334e1 100644 --- a/python/grass/workflows/directory.py +++ b/python/grass/workflows/directory.py @@ -11,7 +11,7 @@ """ This module defines a class `JupyterDirectoryManager` that provides functionality -for working with Jupyter Notebook files stored within the current GRASS mapset. +for working with Jupyter Notebook files stored within the current working directory. Features: - Creates a working directory if it does not exist @@ -23,6 +23,7 @@ Designed for use within GRASS GUI tools or scripting environments. """ +import os import json import shutil from pathlib import Path @@ -30,18 +31,35 @@ import grass.script as gs +def get_default_jupyter_workdir(): + """ + Return the default working directory for Jupyter notebooks associated + with the current GRASS mapset. + :return: Path to the default notebook working directory (Path) + """ + env = gs.gisenv() + mapset_path = Path(env["GISDBASE"]) / env["LOCATION_NAME"] / env["MAPSET"] + return mapset_path / "notebooks" + + class JupyterDirectoryManager: - """Manage a directory of Jupyter notebooks tied to the current GRASS mapset.""" + """Manage a Jupyter notebook working directory.""" def __init__(self, workdir=None, create_template=False): - """Initialize the Jupyter notebook directory and load existing files. + """Initialize the Jupyter notebook directory. :param workdir: Optional custom working directory (Path). If not provided, - the default MAPSET notebooks directory is used. - :param create_template: If True, create a welcome notebook if the directory is empty. + the default working directory is used. + :param create_template: If a welcome notebook should be created or not (bool). """ - self._workdir = workdir or self._get_workdir() + self._workdir = workdir or get_default_jupyter_workdir() self._workdir.mkdir(parents=True, exist_ok=True) + + if not os.access(self._workdir, os.W_OK): + raise PermissionError( + _("Cannot write to the working directory: {}").format(self._workdir) + ) + self._files = [] self._create_template = create_template @@ -59,30 +77,12 @@ def files(self): """ return self._files - def _get_workdir(self): - """ - :return: Path to default working directory, it is created if it does not exist (Path). - """ - env = gs.gisenv() - mapset_path = "{gisdbase}/{location}/{mapset}".format( - gisdbase=env["GISDBASE"], - location=env["LOCATION_NAME"], - mapset=env["MAPSET"], - ) - # Create the working directory within the mapset path - workdir = Path(mapset_path) / "notebooks" - workdir.mkdir(parents=True, exist_ok=True) - return workdir - def prepare_files(self): """ Populate the list of files in the working directory. """ # Find all .ipynb files in the notebooks directory - - self._files = [ - f for f in Path(self._workdir).iterdir() if str(f).endswith(".ipynb") - ] + self._files = [f for f in self._workdir.iterdir() if f.suffix == ".ipynb"] if self._create_template and not self._files: self.create_welcome_notebook() @@ -100,8 +100,12 @@ def import_file(self, source_path, new_name=None, overwrite=False): """ # Validate the source path and ensure it has .ipynb extension source = Path(source_path) - if not source.exists() or source.suffix != ".ipynb": + if not source.exists(): raise FileNotFoundError(_("File not found: {}").format(source)) + if source.suffix != ".ipynb": + raise ValueError( + _("Source file must have .ipynb extension: {}").format(source) + ) # Ensure the working directory exists target_name = new_name or source.name @@ -163,13 +167,11 @@ def create_welcome_notebook(self, file_name="welcome.ipynb"): :return: Path to the created template file (Path) """ # Copy template file to the working directory - template_path = self.import_file( - Path(__file__).parent / "template_notebooks" / file_name - ) + template_path = Path(__file__).parent / "template_notebooks" / file_name + template_copy = self.import_file(template_path) # Load the template file - with open(template_path, encoding="utf-8"): - content = Path(template_path).read_text(encoding="utf-8") + content = template_copy.read_text(encoding="utf-8") # Replace the placeholder '${NOTEBOOK_DIR}' with actual working directory path content = content.replace( @@ -177,10 +179,8 @@ def create_welcome_notebook(self, file_name="welcome.ipynb"): ) # Save the modified content back to the template file - with open(template_path, "w", encoding="utf-8"): - Path(template_path).write_text(content, encoding="utf-8") - - return template_path + template_copy.write_text(content, encoding="utf-8") + return template_copy def create_new_notebook(self, new_name, template_name="new.ipynb"): """ diff --git a/python/grass/workflows/environment.py b/python/grass/workflows/environment.py new file mode 100644 index 00000000000..339e5d5b963 --- /dev/null +++ b/python/grass/workflows/environment.py @@ -0,0 +1,56 @@ +# +# AUTHOR(S): Linda Karlovska +# +# PURPOSE: Provides an orchestration layer for Jupyter Notebook environment. +# +# COPYRIGHT: (C) 2025 by Linda Karlovska and the GRASS Development Team +# +# This program is free software under the GNU General Public +# License (>=v2). Read the file COPYING that comes with GRASS +# for details. + +""" +This module defines the `JupyterEnvironment` class, which coordinates +the setup and teardown of a Jupyter Notebook environment. + +It acts as a high-level orchestrator that integrates: +- a working directory manager (template creation and file discovery) +- a Jupyter server instance (start, stop, URL management) +- registration of running servers in a global server registry + +Designed for use within GRASS GUI tools or scripting environments. +""" + +from grass.workflows.directory import JupyterDirectoryManager +from grass.workflows.server import JupyterServerInstance, JupyterServerRegistry + + +class JupyterEnvironment: + """Orchestrates directory manager and server startup/shutdown.""" + + def __init__(self, workdir, create_template): + self.directory = JupyterDirectoryManager(workdir, create_template) + self.server = JupyterServerInstance(workdir) + + def setup(self): + """Prepare files and start server.""" + # Prepare files + self.directory.prepare_files() + + # Start server + self.server.start_server() + + # Register server in global registry + JupyterServerRegistry.get().register(self.server) + + def stop(self): + """Stop server and unregister it.""" + try: + self.server.stop_server() + finally: + JupyterServerRegistry.get().unregister(self.server) + + @classmethod + def stop_all(cls): + """Stop all running Jupyter servers and unregister them.""" + JupyterServerRegistry.get().stop_all_servers() diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index cd5bab1da67..13ddcc16aba 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -11,11 +11,12 @@ # for details. """ -This module provides two classes for managing Jupyter Notebook servers -programmatically within GRASS GIS tools or scripting environments: +This module provides a simple interface for launching and managing +a local Jupyter server. Functions: - `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system. + Classes: - `JupyterServerInstance`: Manages a single Jupyter Notebook server instance. - `JupyterServerRegistry`: Manages multiple `JupyterServerInstance` objects @@ -144,7 +145,7 @@ def is_server_running(self, retries=10, delay=0.2): try: conn = http.client.HTTPConnection("localhost", self.port, timeout=0.5) conn.request("GET", "/") - if conn.getresponse().status in (200, 302, 403): + if conn.getresponse().status in {200, 302, 403}: conn.close() return True conn.close() @@ -198,7 +199,11 @@ def stop_server(self): :raises RuntimeError: If the server is not running or cannot be stopped. """ if not self.pid or self.pid <= 0: - raise RuntimeError(_("Jupyter server is not running or PID is invalid.")) + raise RuntimeError( + _("Jupyter server is not running or PID {} is invalid.").format( + self.pid + ) + ) # Check if the process with the given PID is a Jupyter server try: @@ -278,10 +283,5 @@ def stop_all_servers(self): for server in self.servers[:]: try: server.stop_server() - except Exception as e: - print(f"Failed to stop Jupyter server: {e}") finally: self.unregister(server) - - def get_servers(self): - return list(self.servers) diff --git a/python/grass/workflows/template_notebooks/welcome.ipynb b/python/grass/workflows/template_notebooks/welcome.ipynb index 5407781245d..d4bce52e2ce 100644 --- a/python/grass/workflows/template_notebooks/welcome.ipynb +++ b/python/grass/workflows/template_notebooks/welcome.ipynb @@ -13,7 +13,7 @@ "You can run Python code using GRASS modules and data.\n", "\n", "---\n", - "**Tip:** Start by running a cell below, or create a new empty notebook by clicking the *Create new notebook* button." + "**Tip:** Start by running a cell below, or create a new notebook by clicking the *Create new notebook* button." ] }, { From 09a6d9f1dabe6d48a182b96a4298227aa997c6a5 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 3 Nov 2025 13:16:56 +0100 Subject: [PATCH 09/28] hide Jupyter UI elements --- gui/wxpython/jupyter_notebook/notebook.py | 35 ++++++++++++----------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py index d5e608d7e65..4e46707cb1e 100644 --- a/gui/wxpython/jupyter_notebook/notebook.py +++ b/gui/wxpython/jupyter_notebook/notebook.py @@ -49,30 +49,31 @@ def __init__( def _inject_javascript(self, event): """ - Inject JavaScript into the Jupyter notebook page to hide UI elements. + Inject JavaScript into the Jupyter notebook page to hide top UI bars. - Specifically hides: - - The File menu - - The top header bar + Works for: + - Jupyter Notebook 6 and older (classic interface) + - Jupyter Notebook 7+ (Jupyter Lab interface) This is called once the WebView has fully loaded the Jupyter page. """ webview = event.GetEventObject() js = """ var interval = setInterval(function() { - var fileMenu = document.querySelector('li#file_menu, a#filelink, a[aria-controls="file_menu"]'); - if (fileMenu) { - if (fileMenu.tagName === "LI") { - fileMenu.style.display = 'none'; - } else if (fileMenu.parentElement && fileMenu.parentElement.tagName === "LI") { - fileMenu.parentElement.style.display = 'none'; - } - } - var header = document.getElementById('header-container'); - if (header) { - header.style.display = 'none'; - } - if (fileMenu && header) { + // --- Jupyter Notebook 7+ (new UI) --- + var topPanel = document.getElementById('top-panel-wrapper'); + var menuPanel = document.getElementById('menu-panel-wrapper'); + if (topPanel) topPanel.style.display = 'none'; + if (menuPanel) menuPanel.style.display = 'none'; + + // --- Jupyter Notebook 6 and older (classic UI) --- + var headerContainer = document.getElementById('header-container'); + var menubar = document.getElementById('menubar'); + if (headerContainer) headerContainer.style.display = 'none'; + if (menubar) menubar.style.display = 'none'; + + // --- Stop once everything is hidden --- + if ((topPanel || headerContainer) && (menuPanel || menubar)) { clearInterval(interval); } }, 500); From 0169fa09e71134a4b398633a18891b364e99988a Mon Sep 17 00:00:00 2001 From: Tomas Zigo Date: Sun, 16 Nov 2025 09:10:43 +0100 Subject: [PATCH 10/28] Fix making template_notebooks/ sub diretory --- python/grass/workflows/Makefile | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/python/grass/workflows/Makefile b/python/grass/workflows/Makefile index e2eaaf8f8ed..2a15eae0c4c 100644 --- a/python/grass/workflows/Makefile +++ b/python/grass/workflows/Makefile @@ -4,17 +4,19 @@ include $(MODULE_TOPDIR)/include/Make/Other.make include $(MODULE_TOPDIR)/include/Make/Python.make DSTDIR = $(ETC)/python/grass/workflows -TEMPLATE_FILES = template_notebooks/welcome.ipynb template_notebooks/new.ipynb +TEMPLATE_DIR_NAME = template_notebooks +TEMPLATE_DIR = $(DSTDIR)/$(TEMPLATE_DIR_NAME) +TEMPLATE_FILES = $(TEMPLATE_DIR_NAME)/welcome.ipynb $(TEMPLATE_DIR_NAME)/new.ipynb MODULES = server directory environment -PYFILES = $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__) -PYCFILES = $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__) -TEMPLATE_DST = $(patsubst %,$(DSTDIR)/%,$(TEMPLATE_FILES)) +PYFILES := $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__) +PYCFILES := $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__) +TEMPLATE_DST := $(patsubst %,$(DSTDIR)/%,$(TEMPLATE_FILES)) default: $(PYFILES) $(PYCFILES) $(TEMPLATE_DST) -$(DSTDIR): +$(TEMPLATE_DIR): $(MKDIR) $@ -$(DSTDIR)/%: % | $(DSTDIR) +$(DSTDIR)/%: % | $(TEMPLATE_DIR) $(INSTALL_DATA) $< $@ From 8d5acae3132e8fdf55582e930ecf68101adb6cef Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 2 Jan 2026 13:20:57 +0100 Subject: [PATCH 11/28] adding the check for wx.html2 presence and making Jupyter button inactive if fails --- gui/wxpython/lmgr/toolbars.py | 4 ++-- gui/wxpython/main_window/frame.py | 12 +++++++++++- python/grass/workflows/server.py | 13 +++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/gui/wxpython/lmgr/toolbars.py b/gui/wxpython/lmgr/toolbars.py index 903540e5e0e..a62d34436d3 100644 --- a/gui/wxpython/lmgr/toolbars.py +++ b/gui/wxpython/lmgr/toolbars.py @@ -24,7 +24,7 @@ """ from core.gcmd import RunCommand -from grass.workflows.server import is_jupyter_installed +from grass.workflows.server import is_jupyter_installed, is_wx_html2_available from gui_core.toolbars import BaseToolbar, AuiToolbar, BaseIcons from icons.icon import MetaIcon @@ -225,7 +225,7 @@ def _toolbarData(self): } # Decide if Jupyter is available - if is_jupyter_installed(): + if is_jupyter_installed() and is_wx_html2_available(): jupyter_icon = icons["jupyter"] jupyter_handler = self.parent.OnJupyterNotebook else: diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 662c89ac270..d5e68ed57b1 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -93,6 +93,7 @@ ) from grass.grassdb.checks import is_first_time_user from grass.grassdb.history import Status +from grass.workflows.server import is_wx_html2_available class SingleWindowAuiManager(aui.AuiManager): @@ -946,7 +947,16 @@ def OnShowJupyterInfo(self, event=None): if sys.platform.startswith("win"): message = _( "Jupyter Notebook is currently not included in the Windows GRASS build process.\n" - "This feature will be available in a future release." + "This feature will be available in a future release. " + "You can use Jupyter Notebook externally." + ) + elif not is_wx_html2_available(): + message = _( + "Jupyter Notebook integration requires wxPython with the wx.html2 module enabled.\n\n" + "Your current wxPython / wxWidgets build does not provide wx.html2 support " + "(typically due to missing WebView / WebKit support).\n\n" + "Please install wxPython and wxWidgets with HTML2/WebView support enabled, " + "or use Jupyter Notebook externally." ) else: message = _( diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index 13ddcc16aba..f0b9f8b16fe 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -81,6 +81,19 @@ def is_jupyter_installed(): return False +def is_wx_html2_available(): + """Check whether wx.html2 (WebView) support is available. + + This can be missing on some platforms or distributions (e.g. Gentoo) + when wxPython or the underlying wxWidgets library is built without + HTML2/WebView support. + """ + try: + return True + except Exception: + return False + + class JupyterServerInstance: """Manage the lifecycle of a Jupyter server instance.""" From d2db9823fcf9f47f253edfd8b669f3a9640cff04 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 2 Jan 2026 19:48:08 +0100 Subject: [PATCH 12/28] incorporating notes from Tomas in stop_server method --- python/grass/workflows/server.py | 30 +++--------------------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index f0b9f8b16fe..645cf103707 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -51,7 +51,7 @@ import atexit import signal import sys -import shutil +import os def is_jupyter_installed(): @@ -218,35 +218,11 @@ def stop_server(self): ) ) - # Check if the process with the given PID is a Jupyter server - try: - ps_cmd = shutil.which("ps") - if not ps_cmd: - raise RuntimeError(_("Unable to find 'ps' command in PATH.")) - proc_name = ( - subprocess.check_output(["ps", "-p", str(self.pid), "-o", "args="]) - .decode() - .strip() - ) - if "jupyter-notebook" not in proc_name: - raise RuntimeError( - _( - "Process with PID {} is not a Jupyter server: found '{}'." - ).format(self.pid, proc_name) - ) - except subprocess.CalledProcessError as e: - raise RuntimeError( - _("No process found with PID {}.").format(self.pid) - ) from e - # Attempt to terminate the server process if self.is_server_running(self.port): try: - kill_cmd = shutil.which("kill") - if not kill_cmd: - raise RuntimeError(_("Unable to find 'kill' command in PATH.")) - subprocess.check_call(["kill", str(self.pid)]) - except subprocess.CalledProcessError as e: + os.kill(self.pid, signal.SIGTERM) + except Exception as e: raise RuntimeError( _("Could not terminate Jupyter server with PID {}.").format( self.pid From 2668c3f634f1b87024e738f5b5440e51a3a2e836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edouard=20Choini=C3=A8re?= <27212526+echoix@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:36:47 -0500 Subject: [PATCH 13/28] Update python/grass/workflows/__init__.py --- python/grass/workflows/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/python/grass/workflows/__init__.py b/python/grass/workflows/__init__.py index 06af96a1490..4869cc53c6c 100644 --- a/python/grass/workflows/__init__.py +++ b/python/grass/workflows/__init__.py @@ -35,11 +35,8 @@ from .environment import JupyterEnvironment __all__ = [ - "Directory", - "Environment", "JupyterDirectoryManager", "JupyterEnvironment", "JupyterServerInstance", "JupyterServerRegistry", - "Server", ] From a997e5acb0c81955826f915f3662b7af21a66af2 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sun, 4 Jan 2026 15:28:08 +0100 Subject: [PATCH 14/28] fixes from Tomas Z. --- python/grass/workflows/server.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index 645cf103707..f495343d37d 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -82,15 +82,18 @@ def is_jupyter_installed(): def is_wx_html2_available(): - """Check whether wx.html2 (WebView) support is available. + """Check whether wx.html2 (WebView) support is available and does not trigger Pylance import warnings. This can be missing on some platforms or distributions (e.g. Gentoo) when wxPython or the underlying wxWidgets library is built without HTML2/WebView support. + + :return: True if wxPython/wxWidgets html2 module is available, False otherwise. """ try: + __import__("wx.html2") return True - except Exception: + except ImportError: return False From 039dd3bd52bc076355fc5ce721880d4b34283bb0 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 5 Jan 2026 16:53:42 +0100 Subject: [PATCH 15/28] edit in except handling --- python/grass/workflows/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index f495343d37d..93c01e8e3fc 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -93,7 +93,7 @@ def is_wx_html2_available(): try: __import__("wx.html2") return True - except ImportError: + except (ImportError, ModuleNotFoundError): return False From e062d6875ebcb5e7487f49e3b9c51f0bd4f200fd Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:26:24 +0100 Subject: [PATCH 16/28] Update python/grass/workflows/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- python/grass/workflows/template_notebooks/welcome.ipynb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/grass/workflows/template_notebooks/welcome.ipynb b/python/grass/workflows/template_notebooks/welcome.ipynb index d4bce52e2ce..ac27c392ab7 100644 --- a/python/grass/workflows/template_notebooks/welcome.ipynb +++ b/python/grass/workflows/template_notebooks/welcome.ipynb @@ -35,8 +35,7 @@ "source": [ "# Initialize Jupyter environment for GRASS\n", "gisenv = gs.gisenv()\n", - "mapset_path = f\"{gisenv['GISDBASE']}/{gisenv['LOCATION_NAME']}/{gisenv['MAPSET']}\"\n", - "gj.init(mapset_path)" + "gj.init(gisenv[\"GISDBASE\"], gisenv[\"LOCATION_NAME\"], gisenv[\"MAPSET\"])" ] } ], From 90610fa1a95110776d627ea87ee2e31dc0da8472 Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:27:22 +0100 Subject: [PATCH 17/28] Update python/grass/workflows/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- python/grass/workflows/template_notebooks/welcome.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/grass/workflows/template_notebooks/welcome.ipynb b/python/grass/workflows/template_notebooks/welcome.ipynb index ac27c392ab7..5600a4e9ecf 100644 --- a/python/grass/workflows/template_notebooks/welcome.ipynb +++ b/python/grass/workflows/template_notebooks/welcome.ipynb @@ -10,7 +10,7 @@ "\n", "---\n", "This notebook is ready to use with GRASS.\n", - "You can run Python code using GRASS modules and data.\n", + "You can run Python code using GRASS tools and data.\n", "\n", "---\n", "**Tip:** Start by running a cell below, or create a new notebook by clicking the *Create new notebook* button." From b02f201769e0fbea679eb0109be3a0ae7c7e3786 Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:27:47 +0100 Subject: [PATCH 18/28] Update python/grass/workflows/template_notebooks/new.ipynb Co-authored-by: Anna Petrasova --- python/grass/workflows/template_notebooks/new.ipynb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/grass/workflows/template_notebooks/new.ipynb b/python/grass/workflows/template_notebooks/new.ipynb index 75dbc90a80b..99df72a2b6f 100644 --- a/python/grass/workflows/template_notebooks/new.ipynb +++ b/python/grass/workflows/template_notebooks/new.ipynb @@ -19,8 +19,7 @@ "source": [ "# Initialize Jupyter environment for GRASS\n", "gisenv = gs.gisenv()\n", - "mapset_path = f\"{gisenv['GISDBASE']}/{gisenv['LOCATION_NAME']}/{gisenv['MAPSET']}\"\n", - "gj.init(mapset_path)" + "gj.init(gisenv[\"GISDBASE\"], gisenv[\"LOCATION_NAME\"], gisenv[\"MAPSET\"])" ] } ], From b932a13d4803428e8ae342bc817c4942e848862d Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:35:13 +0100 Subject: [PATCH 19/28] Update python/grass/workflows/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- .../workflows/template_notebooks/welcome.ipynb | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/python/grass/workflows/template_notebooks/welcome.ipynb b/python/grass/workflows/template_notebooks/welcome.ipynb index 5600a4e9ecf..f1e8cddca8e 100644 --- a/python/grass/workflows/template_notebooks/welcome.ipynb +++ b/python/grass/workflows/template_notebooks/welcome.ipynb @@ -39,17 +39,7 @@ ] } ], - "metadata": { - "kernelspec": { - "display_name": "Python [GRASS]", - "language": "python", - "name": "grass" - }, - "language_info": { - "name": "python", - "version": "3.x" - } - }, + "metadata": {}, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 5 } From c35b1be275895680f11a3d1341e9825a2701b8af Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:36:46 +0100 Subject: [PATCH 20/28] Update python/grass/workflows/server.py Co-authored-by: Anna Petrasova --- python/grass/workflows/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index 93c01e8e3fc..8bd5a13482c 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -152,7 +152,7 @@ def find_free_port(): return sock.getsockname()[1] def is_server_running(self, retries=10, delay=0.2): - """Check if the server in responding on the given port. + """Check if the server is responding on the given port. :param retries: Number of retries before giving up (int). :param delay: Delay between retries in seconds (float). :return: True if the server is up, False otherwise (bool). From a0f51e13e5ee91899f2f947be3f0b3d2182446c1 Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:37:11 +0100 Subject: [PATCH 21/28] Update python/grass/workflows/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- python/grass/workflows/template_notebooks/welcome.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/grass/workflows/template_notebooks/welcome.ipynb b/python/grass/workflows/template_notebooks/welcome.ipynb index f1e8cddca8e..8c17095dcaf 100644 --- a/python/grass/workflows/template_notebooks/welcome.ipynb +++ b/python/grass/workflows/template_notebooks/welcome.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Welcome to GRASS Jupyter environment 👋\n", + "# Welcome to GRASS Jupyter environment\n", "\n", "Jupyter server for this environment was started in the directory `${NOTEBOOK_DIR}`.\n", "\n", From f5693029f153f56ede8e9ba9ea3a798c96f4da7d Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:38:49 +0100 Subject: [PATCH 22/28] Update python/grass/workflows/directory.py Co-authored-by: Anna Petrasova --- python/grass/workflows/directory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/grass/workflows/directory.py b/python/grass/workflows/directory.py index 5936d9334e1..7297f8f36a6 100644 --- a/python/grass/workflows/directory.py +++ b/python/grass/workflows/directory.py @@ -163,7 +163,7 @@ def create_welcome_notebook(self, file_name="welcome.ipynb"): Create a welcome Jupyter notebook in the working directory with the placeholder '${NOTEBOOK_DIR}' replaced by the actual path. - :param filename: Name of the template file to copy (str) + :param file_name: Name of the template file to copy (str) :return: Path to the created template file (Path) """ # Copy template file to the working directory From 94619097b481960fad65807c4592cd5b1b99ad85 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 5 Feb 2026 22:22:42 +0100 Subject: [PATCH 23/28] works also for multi-window GUI --- gui/wxpython/jupyter_notebook/frame.py | 61 +++++++++++++++++++++++ gui/wxpython/lmgr/frame.py | 69 ++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 gui/wxpython/jupyter_notebook/frame.py diff --git a/gui/wxpython/jupyter_notebook/frame.py b/gui/wxpython/jupyter_notebook/frame.py new file mode 100644 index 00000000000..47b3f1bcd5f --- /dev/null +++ b/gui/wxpython/jupyter_notebook/frame.py @@ -0,0 +1,61 @@ +""" +@package jupyter_notebook.frame + +@brief Manages the Jupyter frame widget for multi-window GUI + +Classes: + - frame::JupyterFrame + +(C) 2025-2026 by the GRASS Development Team + +This program is free software under the GNU General Public License +(>=v2). Read the file COPYING that comes with GRASS for details. + +@author Linda Karlovska +""" + +import os +import wx + +from core import globalvar +from jupyter_notebook.panel import JupyterPanel + + +class JupyterFrame(wx.Frame): + """Main window for the Jupyter Notebook interface.""" + + def __init__( + self, + parent, + giface, + workdir=None, + create_template=False, + id=wx.ID_ANY, + title=_("Jupyter Notebook"), + **kwargs, + ): + super().__init__(parent=parent, id=id, title=title, **kwargs) + + self.SetName("JupyterFrame") + + icon_path = os.path.join(globalvar.ICONDIR, "grass.ico") + self.SetIcon(wx.Icon(icon_path, wx.BITMAP_TYPE_ICO)) + + self.statusbar = self.CreateStatusBar(number=1) + + self.panel = JupyterPanel( + parent=self, + giface=giface, + workdir=workdir, + create_template=create_template, + statusbar=self.statusbar, + ) + self.panel.SetUpNotebookInterface() + + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(self.panel, 1, wx.EXPAND) + self.SetSizer(sizer) + + self.SetSize((800, 600)) + + self.Bind(wx.EVT_CLOSE, self.panel.OnCloseWindow) diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index 1ab5692d980..d26e546212d 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -86,6 +86,7 @@ ) from grass.grassdb.checks import is_first_time_user from grass.grassdb.history import Status +from grass.workflows.server import is_wx_html2_available class GMFrame(wx.Frame): @@ -778,6 +779,62 @@ def OnGModeler(self, event=None, cmd=None): win.CentreOnScreen() win.Show() + def OnJupyterNotebook(self, event=None): + """Launch Jupyter Notebook interface.""" + from jupyter_notebook.frame import JupyterFrame + from jupyter_notebook.dialogs import JupyterStartDialog + + dlg = JupyterStartDialog(parent=self) + try: + if dlg.ShowModal() != wx.ID_OK: + return + + values = dlg.GetValues() + finally: + dlg.Destroy() + + if not values: + return + + frame = JupyterFrame( + parent=self, + giface=self._giface, + workdir=values["directory"], + create_template=values["create_template"], + ) + frame.CentreOnParent() + frame.Show() + + def OnShowJupyterInfo(self, event=None): + """Show information dialog when Jupyter Notebook is not available.""" + if sys.platform.startswith("win"): + message = _( + "Jupyter Notebook is currently not included in the Windows GRASS build process.\n" + "This feature will be available in a future release. " + "You can use Jupyter Notebook externally." + ) + elif not is_wx_html2_available(): + message = _( + "Jupyter Notebook integration requires wxPython with the wx.html2 module enabled.\n\n" + "Your current wxPython / wxWidgets build does not provide wx.html2 support " + "(typically due to missing WebView / WebKit support).\n\n" + "Please install wxPython and wxWidgets with HTML2/WebView support enabled, " + "or use Jupyter Notebook externally." + ) + else: + message = _( + "To use notebooks in GRASS, you need to have the Jupyter Notebook package installed. " + "For full functionality, we also recommend installing the visualization libraries " + "Folium and ipyleaflet. After installing these packages, please restart GRASS to enable this feature." + ) + + wx.MessageBox( + message=message, + caption=_("Jupyter Notebook not available"), + style=wx.OK | wx.ICON_INFORMATION, + parent=self, + ) + def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" from psmap.frame import PsMapFrame @@ -2297,6 +2354,18 @@ def _closeWindow(self, event): event.Veto() return + # Stop all running Jupyter servers before destroying the GUI + from grass.workflows import JupyterEnvironment + + try: + JupyterEnvironment.stop_all() + except RuntimeError as e: + wx.MessageBox( + _("Failed to stop Jupyter servers:\n{}").format(str(e)), + caption=_("Error"), + style=wx.ICON_ERROR | wx.OK, + ) + self.DisplayCloseAll() self._auimgr.UnInit() From bce91a2c0eaaf3bb6915a2afbd3f7bbb0717742c Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 5 Feb 2026 23:49:26 +0100 Subject: [PATCH 24/28] changes in icons order in toolbar, button for Jupyter start always active, deletation of availability check - will be done after clicking on new buttons in Start dialog --- gui/icons/grass/jupyter-inactive.png | Bin 925 -> 0 bytes gui/icons/grass/jupyter-inactive.svg | 101 --------------------------- gui/wxpython/lmgr/frame.py | 31 -------- gui/wxpython/lmgr/toolbars.py | 27 ++----- gui/wxpython/main_window/frame.py | 31 -------- 5 files changed, 6 insertions(+), 184 deletions(-) delete mode 100644 gui/icons/grass/jupyter-inactive.png delete mode 100644 gui/icons/grass/jupyter-inactive.svg diff --git a/gui/icons/grass/jupyter-inactive.png b/gui/icons/grass/jupyter-inactive.png deleted file mode 100644 index 1f61bf5bd68d2023293ef2c11a161feaaedf3ecd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 925 zcmV;O17iG%P)G zK~zYI?UqYu6j>C8|8uKio3Yi2P82mGG6=pJ@09sX5-5D&Z!JxgpeMuw| zSwAo^aJjOJ<2Vt|^L|$pWgCFy0HOf?W9Iuph%4!I`X&ILvX27TJXOClV>iJd9*>U_ z(FOn=uIruwfZpET231vcB02=%ry3`_OGHkgP&n;*-aKaB>HGc-%d#GZo{)$h09XUy z2>=*|u@As$B5JGwn$}uoJ{gThJL~J~59qpnp_+**6NyA(zU#UL%d)zeIalF%0^m7- zX8&U($8`QON1YFpP}= zT7?jMnYm>Ox`8XO#)Yno!E69@pgTyBRDVgx`vfcwlm0$`-BuI}%yuCAv+Z(CcN z(%jsLi=rrMM@Pp$0LZee z{mk46pas=mdPO3-P%fAM(slh>tsQ*;92y$RGjrF}c5{hDLZ7bn^z{4~jYhW;(F!T$ z0wKh>5MoSGlF4P!HvBi5^vu5J00000NkvXXu0mjf+e?^{ diff --git a/gui/icons/grass/jupyter-inactive.svg b/gui/icons/grass/jupyter-inactive.svg deleted file mode 100644 index 341f838fed1..00000000000 --- a/gui/icons/grass/jupyter-inactive.svg +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index d26e546212d..f5eb007f884 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -86,7 +86,6 @@ ) from grass.grassdb.checks import is_first_time_user from grass.grassdb.history import Status -from grass.workflows.server import is_wx_html2_available class GMFrame(wx.Frame): @@ -805,36 +804,6 @@ def OnJupyterNotebook(self, event=None): frame.CentreOnParent() frame.Show() - def OnShowJupyterInfo(self, event=None): - """Show information dialog when Jupyter Notebook is not available.""" - if sys.platform.startswith("win"): - message = _( - "Jupyter Notebook is currently not included in the Windows GRASS build process.\n" - "This feature will be available in a future release. " - "You can use Jupyter Notebook externally." - ) - elif not is_wx_html2_available(): - message = _( - "Jupyter Notebook integration requires wxPython with the wx.html2 module enabled.\n\n" - "Your current wxPython / wxWidgets build does not provide wx.html2 support " - "(typically due to missing WebView / WebKit support).\n\n" - "Please install wxPython and wxWidgets with HTML2/WebView support enabled, " - "or use Jupyter Notebook externally." - ) - else: - message = _( - "To use notebooks in GRASS, you need to have the Jupyter Notebook package installed. " - "For full functionality, we also recommend installing the visualization libraries " - "Folium and ipyleaflet. After installing these packages, please restart GRASS to enable this feature." - ) - - wx.MessageBox( - message=message, - caption=_("Jupyter Notebook not available"), - style=wx.OK | wx.ICON_INFORMATION, - parent=self, - ) - def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" from psmap.frame import PsMapFrame diff --git a/gui/wxpython/lmgr/toolbars.py b/gui/wxpython/lmgr/toolbars.py index a62d34436d3..6966a6c8e7b 100644 --- a/gui/wxpython/lmgr/toolbars.py +++ b/gui/wxpython/lmgr/toolbars.py @@ -24,7 +24,6 @@ """ from core.gcmd import RunCommand -from grass.workflows.server import is_jupyter_installed, is_wx_html2_available from gui_core.toolbars import BaseToolbar, AuiToolbar, BaseIcons from icons.icon import MetaIcon @@ -212,26 +211,12 @@ def _toolbarData(self): "python": MetaIcon( img="python", label=_("Open a simple Python code editor") ), - "jupyter": MetaIcon(img="jupyter", label=_("Start Jupyter Notebook")), - "jupyter-inactive": MetaIcon( - img="jupyter-inactive", - label=_( - "Start Jupyter Notebook - requires Jupyter Notebook, click for more info" - ), - ), "script-load": MetaIcon( img="script-load", label=_("Launch user-defined script") ), + "jupyter": MetaIcon(img="jupyter", label=_("Start Jupyter Notebook")), } - # Decide if Jupyter is available - if is_jupyter_installed() and is_wx_html2_available(): - jupyter_icon = icons["jupyter"] - jupyter_handler = self.parent.OnJupyterNotebook - else: - jupyter_icon = icons["jupyter-inactive"] - jupyter_handler = self.parent.OnShowJupyterInfo - return self._getToolbarData( ( ( @@ -266,16 +251,16 @@ def _toolbarData(self): icons["python"], self.parent.OnSimpleEditor, ), - ( - ("jupyter", jupyter_icon.label), - jupyter_icon, - jupyter_handler, - ), ( ("script-load", icons["script-load"].label), icons["script-load"], self.parent.OnRunScript, ), + ( + ("jupyter", _("Jupyter Notebook")), + icons["jupyter"], + self.parent.OnJupyterNotebook, + ), ) ) diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index d5e68ed57b1..5411579c103 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -93,7 +93,6 @@ ) from grass.grassdb.checks import is_first_time_user from grass.grassdb.history import Status -from grass.workflows.server import is_wx_html2_available class SingleWindowAuiManager(aui.AuiManager): @@ -942,36 +941,6 @@ def OnJupyterNotebook(self, event=None, cmd=None): # add map display panel to notebook and make it current self.mainnotebook.AddPage(jupyter_panel, _("Jupyter Notebook")) - def OnShowJupyterInfo(self, event=None): - """Show information dialog when Jupyter Notebook is not available.""" - if sys.platform.startswith("win"): - message = _( - "Jupyter Notebook is currently not included in the Windows GRASS build process.\n" - "This feature will be available in a future release. " - "You can use Jupyter Notebook externally." - ) - elif not is_wx_html2_available(): - message = _( - "Jupyter Notebook integration requires wxPython with the wx.html2 module enabled.\n\n" - "Your current wxPython / wxWidgets build does not provide wx.html2 support " - "(typically due to missing WebView / WebKit support).\n\n" - "Please install wxPython and wxWidgets with HTML2/WebView support enabled, " - "or use Jupyter Notebook externally." - ) - else: - message = _( - "To use notebooks in GRASS, you need to have the Jupyter Notebook package installed. " - "For full functionality, we also recommend installing the visualization libraries " - "Folium and ipyleaflet. After installing these packages, please restart GRASS to enable this feature." - ) - - wx.MessageBox( - message=message, - caption=_("Jupyter Notebook not available"), - style=wx.OK | wx.ICON_INFORMATION, - parent=self, - ) - def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" from psmap.frame import PsMapFrame From 3a8f76609eeaeedd053d01eca24cbc17bb4ab180 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 6 Feb 2026 13:06:16 +0100 Subject: [PATCH 25/28] add Jupyter Notebook to file menu and change the order of items to have workflows together --- gui/wxpython/xml/toolboxes.xml | 9 ++++---- gui/wxpython/xml/wxgui_items.xml | 39 +++++++++++++++++++------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/gui/wxpython/xml/toolboxes.xml b/gui/wxpython/xml/toolboxes.xml index 34bcbb0de95..a61e7d28f56 100644 --- a/gui/wxpython/xml/toolboxes.xml +++ b/gui/wxpython/xml/toolboxes.xml @@ -22,11 +22,15 @@ - + + + + + @@ -39,9 +43,6 @@ - - - diff --git a/gui/wxpython/xml/wxgui_items.xml b/gui/wxpython/xml/wxgui_items.xml index bd6f24c00e0..946fe14d52a 100644 --- a/gui/wxpython/xml/wxgui_items.xml +++ b/gui/wxpython/xml/wxgui_items.xml @@ -1,12 +1,22 @@ - - - OnGCPManager - g.gui.gcp - Manage Ground Control Points for Georectification - georectify + + + OnSimpleEditor + Launches Simple Python Editor. + + + + OnRunScript + Launches script file. + + + + OnJupyterNotebook + Launch Jupyter Notebook interface. + general,gui,notebook,python,jupyter + jupyter @@ -21,6 +31,13 @@ OnRunModel Run model prepared by Graphical modeler + + + OnGCPManager + g.gui.gcp + Manage Ground Control Points for Georectification + georectify + OnAnimationTool @@ -57,16 +74,6 @@ Launch Map Swipe general,gui,display - - - OnRunScript - Launches script file. - - - - OnSimpleEditor - Launches Simple Python Editor. - OnCloseWindow From 6825d39d87d12e659185d3777c80aac1b920f65c Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 6 Feb 2026 13:38:26 +0100 Subject: [PATCH 26/28] fix in the parameter calling --- python/grass/workflows/server.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index 8bd5a13482c..048d1b35d91 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -152,11 +152,15 @@ def find_free_port(): return sock.getsockname()[1] def is_server_running(self, retries=10, delay=0.2): - """Check if the server is responding on the given port. + """Check if the server is responding. + :param retries: Number of retries before giving up (int). :param delay: Delay between retries in seconds (float). :return: True if the server is up, False otherwise (bool). """ + if not self.port: + return False + for _ in range(retries): try: conn = http.client.HTTPConnection("localhost", self.port, timeout=0.5) @@ -204,7 +208,7 @@ def run_server(pid_container): thread.start() # Check if the server is up - if not self.is_server_running(self.port): + if not self.is_server_running(): raise RuntimeError(_("Jupyter server is not running")) # Save the PID of the Jupyter server @@ -222,7 +226,7 @@ def stop_server(self): ) # Attempt to terminate the server process - if self.is_server_running(self.port): + if self.is_server_running(): try: os.kill(self.pid, signal.SIGTERM) except Exception as e: From 1148d735dd8049a9a956ccad709be01ef3113426 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 6 Feb 2026 13:51:45 +0100 Subject: [PATCH 27/28] applying suggestions from Claude regarding mainly zombie processes and some other refinements --- python/grass/workflows/server.py | 289 ++++++++++++++++++++----------- 1 file changed, 186 insertions(+), 103 deletions(-) diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index 048d1b35d91..9ff6ff3a7a5 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -16,6 +16,7 @@ Functions: - `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system. +- `is_wx_html2_available()`: Check if wx.html2 module is available. Classes: - `JupyterServerInstance`: Manages a single Jupyter Notebook server instance. @@ -25,18 +26,18 @@ Features of `JupyterServerInstance`: - Checks if Jupyter Notebook is installed. - Finds an available local port. -- Starts the server in a background thread. +- Starts the server with proper subprocess management. - Verifies that the server is running and accessible. - Provides the URL to access served files. -- Tracks and manages the server PID. -- Stops the server cleanly on request. -- Registers cleanup routines to stop the server on: +- Tracks and manages the server PID and process object. +- Stops the server cleanly, preventing zombie processes. +- Registers cleanup routines to stop servers on: - Normal interpreter exit - SIGINT (e.g., Ctrl+C) - SIGTERM (e.g., kill from shell) Features of `JupyterServerRegistry`: -- Register and unregister server instances +- Thread-safe registration and unregistration of server instances - Keeps track of all active server instances. - Stops all servers on global cleanup (e.g., GRASS shutdown). @@ -52,37 +53,59 @@ import signal import sys import os +import shutil +import pathlib + + +_cleanup_registered = False + + +def _register_global_cleanup(): + """Register cleanup handlers once at module level. + + This ensures that all Jupyter servers are properly stopped when: + - The program exits normally (atexit) + - SIGINT is received (Ctrl+C) + - SIGTERM is received (kill command) + + Signal handlers are process-global, so we register them only once + and have them clean up all servers via the registry. + """ + global _cleanup_registered + if _cleanup_registered: + return + + def cleanup_all(): + """Stop all registered servers.""" + try: + JupyterServerRegistry.get().stop_all_servers() + except Exception: + pass + + def handle_signal(signum, frame): + """Handle termination signals.""" + cleanup_all() + sys.exit(0) + + atexit.register(cleanup_all) + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + _cleanup_registered = True def is_jupyter_installed(): """Check if Jupyter Notebook is installed. - - On Linux/macOS: returns True if the presence command succeeds, False otherwise. - - On Windows: currently always returns False because Jupyter is - not bundled. - TODO: Once Jupyter becomes part of the Windows build - process, this method should simply return True without additional checks. + Uses shutil.which() to check if 'jupyter' command is available in PATH. + Works on all platforms (Windows, Linux, macOS). :return: True if Jupyter Notebook is installed and available, False otherwise. """ - if sys.platform.startswith("win"): - # For now, always disabled on Windows - return False - - try: - result = subprocess.run( - ["jupyter", "notebook", "--version"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True, - ) - return result.returncode == 0 - except (subprocess.CalledProcessError, FileNotFoundError): - return False + return shutil.which("jupyter") is not None def is_wx_html2_available(): - """Check whether wx.html2 (WebView) support is available and does not trigger Pylance import warnings. + """Check whether wx.html2 (WebView) support is available. This can be missing on some platforms or distributions (e.g. Gentoo) when wxPython or the underlying wxWidgets library is built without @@ -101,58 +124,48 @@ class JupyterServerInstance: """Manage the lifecycle of a Jupyter server instance.""" def __init__(self, workdir): + """Initialize Jupyter server instance. + + :param workdir: Working directory for the Jupyter server (str). + """ self.workdir = workdir + self.proc = None self._reset_state() - self._setup_cleanup_handlers() + + # Register this instance in the global registry + JupyterServerRegistry.get().register(self) + + # Set up global cleanup handlers (only once) + _register_global_cleanup() def _reset_state(self): """Reset internal state related to the server.""" self.pid = None self.port = None self.server_url = "" - - def _setup_cleanup_handlers(self): - """Set up handlers to ensure the server is stopped on process exit or signals.""" - # Stop the server when the program exits normally (e.g., via sys.exit() or interpreter exit) - atexit.register(self._safe_stop_server) - - # Stop the server when SIGINT is received (e.g., user presses Ctrl+C) - signal.signal(signal.SIGINT, self._handle_exit_signal) - - # Stop the server when SIGTERM is received (e.g., 'kill PID') - signal.signal(signal.SIGTERM, self._handle_exit_signal) - - def _safe_stop_server(self): - """ - Quietly stop the server without raising exceptions. - - Used for cleanup via atexit or signal handlers. - """ - try: - self.stop_server() - except Exception: - pass - - def _handle_exit_signal(self, signum, frame): - """Handle termination signals and ensure the server is stopped.""" - try: - threading.Thread(target=self._safe_stop_server, daemon=True).start() - except Exception: - pass - finally: - sys.exit(0) + self.proc = None @staticmethod def find_free_port(): """Find a free port on the local machine. + :return: A free port number (int). """ with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] + def is_alive(self): + """Check if the server process is still running. + + :return: True if process is running, False otherwise (bool). + """ + if not self.proc: + return False + return self.proc.poll() is None + def is_server_running(self, retries=10, delay=0.2): - """Check if the server is responding. + """Check if the server is responding on the given port. :param retries: Number of retries before giving up (int). :param delay: Delay between retries in seconds (float). @@ -165,30 +178,46 @@ def is_server_running(self, retries=10, delay=0.2): try: conn = http.client.HTTPConnection("localhost", self.port, timeout=0.5) conn.request("GET", "/") - if conn.getresponse().status in {200, 302, 403}: - conn.close() - return True + response = conn.getresponse() conn.close() + if response.status in {200, 302, 403}: + return True except Exception: time.sleep(delay) return False def start_server(self): - """Run Jupyter server in the given directory on a free port.""" - # Check if Jupyter Notebook is installed + """Start Jupyter server in the given directory on a free port. + + :raises RuntimeError: If Jupyter is not installed, directory invalid, + or server fails to start. + """ + # Validation checks if not is_jupyter_installed(): raise RuntimeError(_("Jupyter Notebook is not installed")) + if not pathlib.Path(self.workdir).is_dir(): + raise RuntimeError( + _("Working directory does not exist: {}").format(self.workdir) + ) + + if not os.access(self.workdir, os.W_OK): + raise RuntimeError( + _("Working directory is not writable: {}").format(self.workdir) + ) + + if self.proc and self.is_alive(): + raise RuntimeError( + _("Server is already running on port {}").format(self.port) + ) + # Find free port and build server url self.port = JupyterServerInstance.find_free_port() self.server_url = "http://localhost:{}".format(self.port) - # Create container for PIDs - pid_container = [] - - # Run Jupyter notebook server - def run_server(pid_container): - proc = subprocess.Popen( + # Start Jupyter notebook server + try: + self.proc = subprocess.Popen( [ "jupyter", "notebook", @@ -200,84 +229,138 @@ def run_server(pid_container): "--notebook-dir", self.workdir, ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, # Detach from terminal ) - pid_container.append(proc.pid) - - # Start the server in a separate thread - thread = threading.Thread(target=run_server, args=(pid_container,), daemon=True) - thread.start() + self.pid = self.proc.pid + except Exception as e: + raise RuntimeError( + _("Failed to start Jupyter server: {}").format(str(e)) + ) from e # Check if the server is up - if not self.is_server_running(): - raise RuntimeError(_("Jupyter server is not running")) + if not self.is_server_running(retries=10, delay=0.5): + # Server failed to start + try: + self.proc.kill() + self.proc.wait() + except Exception: + pass - # Save the PID of the Jupyter server - self.pid = pid_container[0] if pid_container else None + self._reset_state() + raise RuntimeError(_("Jupyter server failed to start")) def stop_server(self): - """Stop the Jupyter server. - :raises RuntimeError: If the server is not running or cannot be stopped. + """Stop the Jupyter server, ensuring no zombie processes. + + :raises RuntimeError: If the server cannot be stopped. """ - if not self.pid or self.pid <= 0: - raise RuntimeError( - _("Jupyter server is not running or PID {} is invalid.").format( - self.pid - ) - ) + if not self.proc or not self.pid: + return # Already stopped, nothing to do - # Attempt to terminate the server process - if self.is_server_running(): + if self.proc.poll() is None: # Still running try: - os.kill(self.pid, signal.SIGTERM) + self.proc.terminate() # Send SIGTERM + self.proc.wait(timeout=5) # Wait up to 5 seconds, reap zombie + except subprocess.TimeoutExpired: + # Force kill if terminate doesn't work + self.proc.kill() # Send SIGKILL + self.proc.wait() # Still need to reap after kill except Exception as e: + # Even if there's an error, try to reap the zombie + try: + self.proc.wait(timeout=1) + except Exception: + pass raise RuntimeError( - _("Could not terminate Jupyter server with PID {}.").format( - self.pid + _("Error stopping Jupyter server (PID {}): {}").format( + self.pid, str(e) ) ) from e + else: + # Process already terminated, just reap it + self.proc.wait() # Clean up internal state self._reset_state() + # Unregister from global registry + try: + JupyterServerRegistry.get().unregister(self) + except Exception: + pass + def get_url(self, file_name): """Return full URL to a file served by this server. :param file_name: Name of the file (e.g. 'example.ipynb') (str). :return: Full URL to access the file (str). + :raises RuntimeError: If server is not running or URL not set. """ if not self.server_url: raise RuntimeError(_("Server URL is not set. Start the server first.")) - return "{base}/notebooks/{file}".format( - base=self.server_url.rstrip("/"), file=file_name - ) + if not self.is_alive(): + raise RuntimeError(_("Jupyter server has stopped unexpectedly.")) + + return "{}/notebooks/{}".format(self.server_url.rstrip("/"), file_name) class JupyterServerRegistry: - """Registry of running JupyterServerInstance objects.""" + """Thread-safe registry of running JupyterServerInstance objects.""" _instance = None + _lock = threading.Lock() @classmethod def get(cls): + """Get the singleton registry instance (thread-safe). + + :return: The JupyterServerRegistry singleton instance. + """ if cls._instance is None: - cls._instance = cls() + with cls._lock: + # Double-check after acquiring lock + if cls._instance is None: + cls._instance = cls() return cls._instance def __init__(self): + """Initialize the registry.""" self.servers = [] + self._servers_lock = threading.Lock() def register(self, server): - if server not in self.servers: - self.servers.append(server) + """Register a server instance. + + :param server: JupyterServerInstance to register. + """ + with self._servers_lock: + if server not in self.servers: + self.servers.append(server) def unregister(self, server): - if server in self.servers: - self.servers.remove(server) + """Unregister a server instance. + + :param server: JupyterServerInstance to unregister. + """ + with self._servers_lock: + if server in self.servers: + self.servers.remove(server) def stop_all_servers(self): - for server in self.servers[:]: + """Stop all registered servers. + + Continues attempting to stop all servers even if some fail. + """ + with self._servers_lock: + # Copy list to avoid modification during iteration + servers_to_stop = self.servers[:] + + for server in servers_to_stop: try: server.stop_server() - finally: - self.unregister(server) + except Exception: + # Continue stopping other servers even if one fails + pass From a6a9bf5ddb341543eaa128fa93e59c7bfc17b52f Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 6 Feb 2026 14:01:22 +0100 Subject: [PATCH 28/28] shutil used to find in jupyter is in PATH - needs to be tested also on Windows - not tested yet --- python/grass/workflows/server.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index 9ff6ff3a7a5..dc7481d4c34 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -215,11 +215,16 @@ def start_server(self): self.port = JupyterServerInstance.find_free_port() self.server_url = "http://localhost:{}".format(self.port) + # Check if Jupyter is available in PATH + jupyter = shutil.which("jupyter") + if not jupyter: + raise RuntimeError(_("Jupyter executable not found in PATH")) + # Start Jupyter notebook server try: self.proc = subprocess.Popen( [ - "jupyter", + jupyter, "notebook", "--no-browser", "--NotebookApp.token=''",