From b3116a22304e6160110c4a2d54a6b14d21d27d91 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Wed, 21 May 2025 16:25:41 +0200 Subject: [PATCH 01/85] 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 abf8ac88d7ee64c3ca18ef00c91b7e28c2efa2bc Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 23 May 2025 12:09:37 +0200 Subject: [PATCH 02/85] 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 706edef9ca33caa5c96e2f6c6a2f71c705051241 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 27 May 2025 17:56:13 +0200 Subject: [PATCH 03/85] 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 b089cb76160..a88e1af2872 100644 --- a/python/grass/CMakeLists.txt +++ b/python/grass/CMakeLists.txt @@ -3,6 +3,7 @@ set(PYDIRS exceptions experimental 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 215c9ee21a68ec97a3c6de3fb7b53bff03102380 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 17 Jun 2025 18:57:29 +0200 Subject: [PATCH 04/85] 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 f998d79d5edc8d5cbc09f0979c52730df0d12c05 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Wed, 1 Oct 2025 14:36:32 +0200 Subject: [PATCH 05/85] 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 ecdf907a2d74ed9c260fb320f14c20a657589682 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 17 Oct 2025 13:44:58 +0200 Subject: [PATCH 06/85] 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 1aa15dbd9685b545942bfbb4bb5db1ffb7364862 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 20 Oct 2025 13:58:03 +0200 Subject: [PATCH 07/85] 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 3c4e33cc7af14fc89f1e3e6e03cb5650504d2813 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 21 Oct 2025 14:36:55 +0200 Subject: [PATCH 08/85] 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 e07f03c0b4c1d2ca8044ae9b7460cacb52d5c0f8 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 3 Nov 2025 13:16:56 +0100 Subject: [PATCH 09/85] 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 1f31eb2866ff5d1ac2b290849e89ba23430b70ed Mon Sep 17 00:00:00 2001 From: Tomas Zigo Date: Sun, 16 Nov 2025 09:10:43 +0100 Subject: [PATCH 10/85] 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 d62606ee3e5fad85f2fc7bcbacf22c7127276f34 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 2 Jan 2026 13:20:57 +0100 Subject: [PATCH 11/85] 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 146dfa5b4b84104bcb0a817f0da7f061787d021a Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 2 Jan 2026 19:48:08 +0100 Subject: [PATCH 12/85] 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 7bf116fdbdc026a59609042c3e3719ecdde5addc 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/85] 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 0691a2766e2daecee34f51a20eea3358d7e01dbf Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sun, 4 Jan 2026 15:28:08 +0100 Subject: [PATCH 14/85] 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 dcf1b8e934bc3a9a7ab0e206f8436143393ea43a Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 5 Jan 2026 16:53:42 +0100 Subject: [PATCH 15/85] 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 a24456d113f2d434a13c92a8c159685c09f522b0 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/85] 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 0d8481294687a5d4e87f1d6ac7ec3b700a69f154 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/85] 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 15ffbf7674fd1339715c387c4af01452ab45e46b 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/85] 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 287a4278b52f0152a8baa502934ed90bf5a72633 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/85] 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 f75ef871cf74fe624c56aed9004dfbb73a397b40 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/85] 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 fdd4cea2a937f7efe2be2fe882f0d54ee678e9fe 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/85] 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 e5860786f5b689f921fd5f50812a2f438f9e5bb5 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/85] 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 7ea43b67064e671aa4fc70a9edafa476f484f4ae Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 5 Feb 2026 22:22:42 +0100 Subject: [PATCH 23/85] 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 12b54f0c4d8601d5845ca359e384da021a78a032 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 5 Feb 2026 23:49:26 +0100 Subject: [PATCH 24/85] 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 55b5f52582f09cb0dd3d2abd1c531884803ffb29 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 6 Feb 2026 13:06:16 +0100 Subject: [PATCH 25/85] 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 06131f58032a051d9d2e53aa36267d3952ae67e6 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 6 Feb 2026 13:38:26 +0100 Subject: [PATCH 26/85] 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 509453e8e0bc4f817a2142dd7a2fcdaa0ccf4678 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 6 Feb 2026 13:51:45 +0100 Subject: [PATCH 27/85] 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 3ee6ae4bf47e6c730566fc8c08f91c77427a1598 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 6 Feb 2026 14:01:22 +0100 Subject: [PATCH 28/85] 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=''", From b2d15f74f7f693177a33375135344ae1f8afe126 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 9 Feb 2026 00:18:44 +0100 Subject: [PATCH 29/85] Anna's and Vashek suggestions + adding Open in browser button --- gui/wxpython/jupyter_notebook/dialogs.py | 55 +++++- gui/wxpython/jupyter_notebook/notebook.py | 65 ++++--- gui/wxpython/jupyter_notebook/panel.py | 6 +- gui/wxpython/main_window/frame.py | 63 +++++-- python/grass/workflows/Makefile | 2 +- python/grass/workflows/directory.py | 18 +- python/grass/workflows/environment.py | 75 +++++++- python/grass/workflows/server.py | 175 +++++------------- .../template_notebooks/welcome.ipynb | 7 +- python/grass/workflows/utils.py | 51 +++++ 10 files changed, 319 insertions(+), 198 deletions(-) create mode 100644 python/grass/workflows/utils.py diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index a95f8dc2112..18e0b04bd81 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -16,7 +16,6 @@ import os from pathlib import Path - import wx from grass.workflows.directory import get_default_jupyter_workdir @@ -47,9 +46,7 @@ def __init__(self, parent): 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, message=_("Choose a working directory"), style=wx.DIRP_USE_TEXTCTRL ) self.dir_picker.Enable(False) @@ -83,14 +80,27 @@ def __init__(self, parent): sizer.Add(options_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 10) - btns = self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL) - sizer.Add(btns, 0, wx.EXPAND | wx.ALL, 10) + # Buttons section + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) - self.SetSizer(sizer) + btn_cancel = wx.Button(self, wx.ID_CANCEL, label=_("Cancel")) + btn_sizer.Add(btn_cancel, 0, wx.ALL, 5) + btn_browser = wx.Button(self, label=_("Open Notebook in Browser")) + btn_sizer.Add(btn_browser, 0, wx.ALL, 5) + btn_integrated = wx.Button(self, label=_("Open Integrated Notebook")) + btn_sizer.Add(btn_integrated, 0, wx.ALL, 5) + sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 10) + + # Bind events self.radio_default.Bind(wx.EVT_RADIOBUTTON, self.OnRadioToggle) self.radio_custom.Bind(wx.EVT_RADIOBUTTON, self.OnRadioToggle) + btn_cancel.Bind(wx.EVT_BUTTON, self.OnCancel) + btn_browser.Bind(wx.EVT_BUTTON, self.OnOpenInBrowser) + btn_integrated.Bind(wx.EVT_BUTTON, self.OnOpenIntegrated) + + self.SetSizer(sizer) self.Fit() self.Layout() self.SetMinSize(self.GetSize()) @@ -105,13 +115,27 @@ def GetValues(self): if self.radio_custom.GetValue(): path = Path(self.dir_picker.GetPath()) - if not os.access(path, os.W_OK) or not os.access(path, os.X_OK): + # create directory if it does not exist + if not path.exists(): + try: + path.mkdir(parents=True, exist_ok=True) + except OSError: + wx.MessageBox( + _("Cannot create the selected directory."), + _("Error"), + wx.ICON_ERROR, + ) + return None + + # permission check + if not os.access(path, os.W_OK | 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 @@ -120,3 +144,18 @@ def GetValues(self): "directory": self.selected_dir, "create_template": self.checkbox_template.GetValue(), } + + def OnCancel(self, event): + self.EndModal(wx.ID_CANCEL) + + def OnOpenIntegrated(self, event): + if not self.GetValues(): + return + self.action = "integrated" + self.EndModal(wx.ID_OK) + + def OnOpenInBrowser(self, event): + if not self.GetValues(): + return + self.action = "browser" + self.EndModal(wx.ID_OK) diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py index 4e46707cb1e..c1580ffdc5d 100644 --- a/gui/wxpython/jupyter_notebook/notebook.py +++ b/gui/wxpython/jupyter_notebook/notebook.py @@ -47,37 +47,52 @@ def __init__( self.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self.OnPageClose) - def _inject_javascript(self, event): + def _hide_top_ui(self, event): """ - Inject JavaScript into the Jupyter notebook page to hide top UI bars. + Inject CSS via JS into the Jupyter page to hide top UI elements. - Works for: + Works for both: - Jupyter Notebook 6 and older (classic interface) - - Jupyter Notebook 7+ (Jupyter Lab interface) + - Jupyter Notebook 7+ (Lab interface) - This is called once the WebView has fully loaded the Jupyter page. + This method is called once the WebView has fully loaded the page. + Some UI elements may be created dynamically after page load, + so the script ensures the CSS/JS is applied once elements exist. + Duplicate injection is prevented by checking for a unique style ID. """ webview = event.GetEventObject() - js = """ - var interval = setInterval(function() { - // --- 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); + + # CSS rules to hide panels and interface switcher + css = """ + /* Notebook 7+ (Lab UI) */ + #top-panel-wrapper { display: none !important; } + #menu-panel-wrapper { display: none !important; } + + /* Interface switcher ("Open in...") */ + .jp-InterfaceSwitcher { display: none !important; } + + /* remove top spacing left by hidden panels */ + .lm-Panel { top: 0 !important; } + + /* Notebook 6 and older (classic UI) */ + #header-container { display: none !important; } + #menubar { display: none !important; } """ + + # JavaScript that injects the CSS once + js = f""" + (function() {{ + if (document.getElementById('grass-hide-ui')) {{ + return; + }} + + var style = document.createElement('style'); + style.id = 'grass-hide-ui'; + style.innerHTML = `{css}`; + document.head.appendChild(style); + }})(); + """ + webview.RunScript(js) def AddPage(self, url, title): @@ -88,7 +103,7 @@ def AddPage(self, url, title): """ browser = html.WebView.New(self) wx.CallAfter(browser.LoadURL, url) - wx.CallAfter(browser.Bind, html.EVT_WEBVIEW_LOADED, self._inject_javascript) + wx.CallAfter(browser.Bind, html.EVT_WEBVIEW_LOADED, self._hide_top_ui) super().AddPage(browser, title) def OnPageClose(self, event): diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 2f5f287a3e9..70256e7d965 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -48,7 +48,9 @@ def __init__( self.workdir = workdir self.SetName("Jupyter") - self.env = JupyterEnvironment(self.workdir, create_template) + self.env = JupyterEnvironment( + workdir=self.workdir, create_template=create_template, integrated=True + ) self.toolbar = JupyterToolbar(parent=self) self.aui_notebook = JupyterAuiNotebook(parent=self) @@ -78,7 +80,7 @@ def SetUpNotebookInterface(self): ) return - # Load notebook tabs + # Load notebook tabs in embedded AUI notebook for fname in self.env.directory.files: try: url = self.env.server.get_url(fname.name) diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 5411579c103..8ce959c2e5c 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -907,18 +907,16 @@ def OnGModeler(self, event=None, cmd=None): self.mainnotebook.AddPage(gmodeler_panel, _("Graphical Modeler")) def OnJupyterNotebook(self, event=None, cmd=None): - """Launch Jupyter Notebook interface.""" - from jupyter_notebook.panel import JupyterPanel from jupyter_notebook.dialogs import JupyterStartDialog dlg = JupyterStartDialog(parent=self) - result = dlg.ShowModal() - if result != wx.ID_OK: + if dlg.ShowModal() != wx.ID_OK: dlg.Destroy() return values = dlg.GetValues() + action = dlg.action dlg.Destroy() if not values: @@ -927,19 +925,49 @@ def OnJupyterNotebook(self, event=None, cmd=None): workdir = values["directory"] create_template = values["create_template"] - jupyter_panel = JupyterPanel( - 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() + if action == "integrated": + # Embedded notebook mode: create JupyterPanel and start server within it + from jupyter_notebook.panel import JupyterPanel - # add map display panel to notebook and make it current - self.mainnotebook.AddPage(jupyter_panel, _("Jupyter Notebook")) + panel = JupyterPanel( + parent=self, + giface=self._giface, + statusbar=self.statusbar, + dockable=True, + workdir=workdir, + create_template=create_template, + ) + panel.SetUpPage(self, self.mainnotebook) + panel.SetUpNotebookInterface() + + self.mainnotebook.AddPage(panel, _("Jupyter Notebook")) + + elif action == "browser": + # External browser mode: set up environment, open URL and update status + from grass.workflows.environment import JupyterEnvironment + + jupyter_env = JupyterEnvironment( + workdir=workdir, create_template=create_template, integrated=False + ) + try: + jupyter_env.setup() + except Exception as e: + wx.MessageBox( + _("Failed to start Jupyter environment:\n{}").format(str(e)), + _("Startup Error"), + wx.ICON_ERROR, + ) + return + + self.SetStatusText( + _( + "Jupyter server started in browser at {url} (PID: {pid}), directory: {dir}" + ).format( + url=jupyter_env.server.server_url, + pid=jupyter_env.server.pid, + dir=str(workdir), + ) + ) def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" @@ -2442,7 +2470,7 @@ def _closeWindow(self, event): return # Stop all running Jupyter servers before destroying the GUI - from grass.workflows import JupyterEnvironment + from grass.workflows.environment import JupyterEnvironment try: JupyterEnvironment.stop_all() @@ -2452,6 +2480,7 @@ def _closeWindow(self, event): 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 2a15eae0c4c..56b4db08fac 100644 --- a/python/grass/workflows/Makefile +++ b/python/grass/workflows/Makefile @@ -7,7 +7,7 @@ DSTDIR = $(ETC)/python/grass/workflows 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 +MODULES = server directory environment utils PYFILES := $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__) PYCFILES := $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__) diff --git a/python/grass/workflows/directory.py b/python/grass/workflows/directory.py index 7297f8f36a6..7190768404f 100644 --- a/python/grass/workflows/directory.py +++ b/python/grass/workflows/directory.py @@ -159,9 +159,10 @@ def export_file(self, file_name, destination_path, overwrite=False): shutil.copyfile(source_path, dest_path) def create_welcome_notebook(self, file_name="welcome.ipynb"): - """ + r""" Create a welcome Jupyter notebook in the working directory with - the placeholder '${NOTEBOOK_DIR}' replaced by the actual path. + the placeholders '${NOTEBOOK_DIR}' replaced by the actual working dir + and \${NOTEBOOK_MAPSET}'replaced by actual mapset path :param file_name: Name of the template file to copy (str) :return: Path to the created template file (Path) @@ -178,6 +179,19 @@ def create_welcome_notebook(self, file_name="welcome.ipynb"): "${NOTEBOOK_DIR}", str(self._workdir).replace("\\", "/") ) + # Replace the placeholder \${NOTEBOOK_MAPSET}' by actual mapset path + env = gs.gisenv() + mapset_path = Path( + env["GISDBASE"], + env["LOCATION_NAME"], + env["MAPSET"], + ) + + content = content.replace( + "${NOTEBOOK_MAPSET}", + str(mapset_path).replace("\\", "/"), + ) + # Save the modified content back to the template file template_copy.write_text(content, encoding="utf-8") return template_copy diff --git a/python/grass/workflows/environment.py b/python/grass/workflows/environment.py index 339e5d5b963..a71c0532ee7 100644 --- a/python/grass/workflows/environment.py +++ b/python/grass/workflows/environment.py @@ -17,37 +17,98 @@ - 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 +- registration of cleanup routines to stop servers on: + - Normal interpreter exit + - SIGINT (e.g., Ctrl+C) + - SIGTERM (e.g., kill from shell) +- stopping all servers on global cleanup (e.g., GRASS shutdown). Designed for use within GRASS GUI tools or scripting environments. """ +import atexit +import signal +import sys + from grass.workflows.directory import JupyterDirectoryManager from grass.workflows.server import JupyterServerInstance, JupyterServerRegistry +from grass.workflows.utils import is_jupyter_installed, is_wx_html2_available + + +_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 class JupyterEnvironment: - """Orchestrates directory manager and server startup/shutdown.""" + """Orchestrates directory manager and Jupyter server lifecycle. + + :param workdir: Directory for notebooks + :param create_template: Whether to create template notebooks + :param integrated: If False, server is intended to be opened in external browser. + If True, server is integrated into GRASS GUI and will be + automatically stopped on GUI exit. + """ - def __init__(self, workdir, create_template): + def __init__(self, workdir, create_template, integrated): self.directory = JupyterDirectoryManager(workdir, create_template) self.server = JupyterServerInstance(workdir) + self.integrated = integrated def setup(self): """Prepare files and start server.""" + if not is_jupyter_installed(): + raise RuntimeError(_("Jupyter Notebook is not installed")) + + if self.integrated and not is_wx_html2_available(): + raise RuntimeError(_("wx.html2 (WebView) support is not available")) + # Prepare files self.directory.prepare_files() # Start server - self.server.start_server() + self.server.start_server(self.integrated) # Register server in global registry - JupyterServerRegistry.get().register(self.server) + if self.integrated: + _register_global_cleanup() + JupyterServerRegistry.get().register(self.server) def stop(self): - """Stop server and unregister it.""" - try: + """Stop server only if integrated.""" + if self.integrated: self.server.stop_server() - finally: JupyterServerRegistry.get().unregister(self.server) @classmethod diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py index dc7481d4c34..8fce4fa40bf 100644 --- a/python/grass/workflows/server.py +++ b/python/grass/workflows/server.py @@ -14,14 +14,10 @@ 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. -- `is_wx_html2_available()`: Check if wx.html2 module is available. - 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. + and provides methods to start, track, and stop active servers. Features of `JupyterServerInstance`: - Checks if Jupyter Notebook is installed. @@ -31,15 +27,10 @@ - Provides the URL to access served files. - 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`: - 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). Designed for use within GRASS GUI tools or scripting environments. """ @@ -48,96 +39,24 @@ import time import subprocess import threading -import http.client -import atexit -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. - - 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. - """ - return shutil.which("jupyter") is not None - - -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. - - :return: True if wxPython/wxWidgets html2 module is available, False otherwise. - """ - try: - __import__("wx.html2") - return True - except (ImportError, ModuleNotFoundError): - return False - - class JupyterServerInstance: """Manage the lifecycle of a Jupyter server instance.""" - def __init__(self, workdir): + def __init__(self, workdir, integrated=True): """Initialize Jupyter server instance. :param workdir: Working directory for the Jupyter server (str). """ self.workdir = workdir + self.proc = None self._reset_state() - # 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 @@ -164,7 +83,7 @@ def is_alive(self): return False return self.proc.poll() is None - def is_server_running(self, retries=10, delay=0.2): + def is_server_running(self, retries=50, delay=0.2): """Check if the server is responding on the given port. :param retries: Number of retries before giving up (int). @@ -176,26 +95,24 @@ def is_server_running(self, retries=10, delay=0.2): for _ in range(retries): try: - conn = http.client.HTTPConnection("localhost", self.port, timeout=0.5) - conn.request("GET", "/") - response = conn.getresponse() - conn.close() - if response.status in {200, 302, 403}: + with socket.create_connection(("127.0.0.1", self.port), timeout=0.5): return True - except Exception: + except OSError: time.sleep(delay) + return False - def start_server(self): - """Start Jupyter server in the given directory on a free port. + def start_server(self, integrated=True): + """ + Start a Jupyter server in the given directory on a free port. - :raises RuntimeError: If Jupyter is not installed, directory invalid, - or server fails to start. + :param integrated: + - If False, the notebook is launched in the default web browser (external, no GUI integration). + - If True (default), the server runs headless and is intended for integration into the GRASS GUI (suitable for embedded WebView). + :raises RuntimeError: If Jupyter is not installed, the directory is invalid, + or the 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) @@ -220,32 +137,38 @@ def start_server(self): if not jupyter: raise RuntimeError(_("Jupyter executable not found in PATH")) - # Start Jupyter notebook server + # Build command to start Jupyter Notebook server + cmd = [ + jupyter, + "notebook", + "--NotebookApp.token=''", + "--NotebookApp.password=''", + "--port", + str(self.port), + "--notebook-dir", + self.workdir, + ] + + if integrated: + cmd.insert(2, "--no-browser") + + # Start server try: self.proc = subprocess.Popen( - [ - jupyter, - "notebook", - "--no-browser", - "--NotebookApp.token=''", - "--NotebookApp.password=''", - "--port", - str(self.port), - "--notebook-dir", - self.workdir, - ], + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - start_new_session=True, # Detach from terminal + start_new_session=True, ) 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(retries=10, delay=0.5): + if not self.is_server_running(): # Server failed to start try: self.proc.kill() @@ -290,12 +213,6 @@ def stop_server(self): # 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. @@ -313,7 +230,7 @@ def get_url(self, file_name): class JupyterServerRegistry: - """Thread-safe registry of running JupyterServerInstance objects.""" + """Thread-safe registry of running JupyterServerInstance objects. Track integrated servers only.""" _instance = None _lock = threading.Lock() @@ -346,26 +263,18 @@ def register(self, server): self.servers.append(server) def unregister(self, server): - """Unregister a server instance. - - :param server: JupyterServerInstance to unregister. - """ + """Unregister a server instance.""" with self._servers_lock: - if server in self.servers: - self.servers.remove(server) + self.servers = [s for s in self.servers if s != server] def stop_all_servers(self): - """Stop all registered servers. - - Continues attempting to stop all servers even if some fail. - """ + """Stop all registered servers.""" with self._servers_lock: - # Copy list to avoid modification during iteration - servers_to_stop = self.servers[:] + servers = list(self.servers) + self.servers.clear() - for server in servers_to_stop: + for server in servers: try: server.stop_server() except Exception: - # Continue stopping other servers even if one fails pass diff --git a/python/grass/workflows/template_notebooks/welcome.ipynb b/python/grass/workflows/template_notebooks/welcome.ipynb index 8c17095dcaf..0b4176e56b3 100644 --- a/python/grass/workflows/template_notebooks/welcome.ipynb +++ b/python/grass/workflows/template_notebooks/welcome.ipynb @@ -2,6 +2,7 @@ "cells": [ { "cell_type": "markdown", + "id": "7fb27b941602401d91542211134fc71a", "metadata": {}, "source": [ "# Welcome to GRASS Jupyter environment\n", @@ -19,23 +20,23 @@ { "cell_type": "code", "execution_count": null, + "id": "acae54e37e7d407bbb7b55eff062a284", "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, + "id": "9a63283cbaf04dbcab1f6479b197f3a8", "metadata": {}, "outputs": [], "source": [ "# Initialize Jupyter environment for GRASS\n", - "gisenv = gs.gisenv()\n", - "gj.init(gisenv[\"GISDBASE\"], gisenv[\"LOCATION_NAME\"], gisenv[\"MAPSET\"])" + "gj.init(\"${NOTEBOOK_MAPSET}\")" ] } ], diff --git a/python/grass/workflows/utils.py b/python/grass/workflows/utils.py new file mode 100644 index 00000000000..b7e12748599 --- /dev/null +++ b/python/grass/workflows/utils.py @@ -0,0 +1,51 @@ +# +# AUTHOR(S): Linda Karlovska +# +# PURPOSE: Provides utils related to 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 utils for launching and managing +a local Jupyter server. + +Functions: +- `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system. +- `is_wx_html2_available()`: Check if wx.html2 module is available. + +Designed for use within GRASS GUI tools or scripting environments. +""" + +import shutil + + +def is_jupyter_installed(): + """Check if Jupyter Notebook is installed. + + 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. + """ + return shutil.which("jupyter") is not None + + +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. + + :return: True if wxPython/wxWidgets html2 module is available, False otherwise. + """ + try: + __import__("wx.html2") + return True + except (ImportError, ModuleNotFoundError): + return False From b0008db0039d4120cf35217a6c6c4ce7b3e64b39 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 10 Feb 2026 22:20:39 +0100 Subject: [PATCH 30/85] multi-window works again as well as requirement checks (jupyter notebook and wx.html2) --- gui/wxpython/lmgr/frame.py | 101 +++++++++++++++++++++++++----- gui/wxpython/main_window/frame.py | 39 ++++++++++++ python/grass/workflows/utils.py | 20 +++++- 3 files changed, 143 insertions(+), 17 deletions(-) diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index f5eb007f884..ec2c24c11dd 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -778,31 +778,102 @@ def OnGModeler(self, event=None, cmd=None): win.CentreOnScreen() win.Show() + def _show_jupyter_missing_message(self): + wx.MessageBox( + _( + "To use notebooks in GRASS, you need to have the Jupyter Notebook " + "package installed. Please install it and restart GRASS." + ), + _("Jupyter Notebook not available"), + wx.OK | wx.ICON_INFORMATION, + parent=self, + ) + + def _show_html2_missing_message(self): + wx.MessageBox( + _( + "Jupyter Notebook integration requires wxPython with wx.html2 " + "(WebView) support enabled.\n\n" + "Install wxPython/wxWidgets with HTML2/WebView support or use " + "Jupyter externally in a browser." + ), + _("Jupyter Notebook not available"), + wx.OK | wx.ICON_INFORMATION, + parent=self, + ) + def OnJupyterNotebook(self, event=None): """Launch Jupyter Notebook interface.""" - from jupyter_notebook.frame import JupyterFrame + from grass.workflows.utils import ( + is_jupyter_installed, + is_wx_html2_available, + ) from jupyter_notebook.dialogs import JupyterStartDialog + # global requirement (always needed) + if not is_jupyter_installed(): + self._show_jupyter_missing_message() + return + dlg = JupyterStartDialog(parent=self) - try: - if dlg.ShowModal() != wx.ID_OK: - return - values = dlg.GetValues() - finally: + if dlg.ShowModal() != wx.ID_OK: dlg.Destroy() + return + + values = dlg.GetValues() + action = dlg.action + 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() + workdir = values["directory"] + create_template = values["create_template"] + + if action == "integrated": + # Embedded notebook mode: create JupyterFrame and start server within it + if not is_wx_html2_available(): + self._show_html2_missing_message() + return + + from jupyter_notebook.frame import JupyterFrame + + frame = JupyterFrame( + parent=self, + giface=self._giface, + workdir=workdir, + create_template=create_template, + ) + frame.CentreOnParent() + frame.Show() + + elif action == "browser": + # External browser mode: set up environment, open URL and update status + from grass.workflows.environment import JupyterEnvironment + + jupyter_env = JupyterEnvironment( + workdir=workdir, create_template=create_template, integrated=False + ) + try: + jupyter_env.setup() + except Exception as e: + wx.MessageBox( + _("Failed to start Jupyter environment:\n{}").format(str(e)), + _("Startup Error"), + wx.ICON_ERROR, + ) + return + + self.SetStatusText( + _( + "Jupyter server started in browser at {url} (PID: {pid}), directory: {dir}" + ).format( + url=jupyter_env.server.server_url, + pid=jupyter_env.server.pid, + dir=str(workdir), + ) + ) def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" @@ -2324,7 +2395,7 @@ def _closeWindow(self, event): return # Stop all running Jupyter servers before destroying the GUI - from grass.workflows import JupyterEnvironment + from grass.workflows.environment import JupyterEnvironment try: JupyterEnvironment.stop_all() diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 8ce959c2e5c..fdfc2aecb18 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -906,9 +906,43 @@ 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 _show_jupyter_missing_message(self): + wx.MessageBox( + _( + "To use notebooks in GRASS, you need to have the Jupyter Notebook " + "package installed. Please install it and restart GRASS." + ), + _("Jupyter Notebook not available"), + wx.OK | wx.ICON_INFORMATION, + parent=self, + ) + + def _show_html2_missing_message(self): + wx.MessageBox( + _( + "Jupyter Notebook integration requires wxPython with wx.html2 " + "(WebView) support enabled.\n\n" + "Install wxPython/wxWidgets with HTML2/WebView support or use " + "Jupyter externally in a browser." + ), + _("Jupyter Notebook not available"), + wx.OK | wx.ICON_INFORMATION, + parent=self, + ) + def OnJupyterNotebook(self, event=None, cmd=None): + """Launch Jupyter Notebook interface.""" + from grass.workflows.utils import ( + is_jupyter_installed, + is_wx_html2_available, + ) from jupyter_notebook.dialogs import JupyterStartDialog + # global requirement (always needed) + if not is_jupyter_installed(): + self._show_jupyter_missing_message() + return + dlg = JupyterStartDialog(parent=self) if dlg.ShowModal() != wx.ID_OK: @@ -926,6 +960,11 @@ def OnJupyterNotebook(self, event=None, cmd=None): create_template = values["create_template"] if action == "integrated": + # Embedded notebook mode: create JupyterFrame and start server within it + if not is_wx_html2_available(): + self._show_html2_missing_message() + return + # Embedded notebook mode: create JupyterPanel and start server within it from jupyter_notebook.panel import JupyterPanel diff --git a/python/grass/workflows/utils.py b/python/grass/workflows/utils.py index b7e12748599..3d73f4c7bdc 100644 --- a/python/grass/workflows/utils.py +++ b/python/grass/workflows/utils.py @@ -22,17 +22,33 @@ """ import shutil +import subprocess def is_jupyter_installed(): - """Check if Jupyter Notebook is installed. + """Check if Jupyter Notebook is installed and functional. 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. """ - return shutil.which("jupyter") is not None + # Check if 'jupyter' CLI exists + jupyter_cmd = shutil.which("jupyter") + if not jupyter_cmd: + return False + + # Check if 'jupyter notebook' subcommand works + try: + subprocess.run( + [jupyter_cmd, "notebook", "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return True + except Exception: + return False def is_wx_html2_available(): From 0bee46f8e18dc3948d0081dc968871d4166ee440 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 10 Feb 2026 23:24:08 +0100 Subject: [PATCH 31/85] code completely moved from python/grass/workflows to wxpython/jupyter_notebook --- gui/wxpython/Makefile | 16 ++++--- gui/wxpython/jupyter_notebook/dialogs.py | 33 ++++++-------- .../wxpython/jupyter_notebook}/directory.py | 32 ++++++-------- .../wxpython/jupyter_notebook}/environment.py | 41 +++++++----------- gui/wxpython/jupyter_notebook/frame.py | 9 ++-- gui/wxpython/jupyter_notebook/notebook.py | 2 +- gui/wxpython/jupyter_notebook/panel.py | 2 +- .../wxpython/jupyter_notebook}/server.py | 43 ++++++------------- .../template_notebooks/new.ipynb | 0 .../template_notebooks/welcome.ipynb | 0 .../wxpython/jupyter_notebook}/utils.py | 26 +++++------ gui/wxpython/lmgr/frame.py | 6 +-- gui/wxpython/main_window/frame.py | 6 +-- python/grass/CMakeLists.txt | 1 - python/grass/Makefile | 1 - python/grass/workflows/Makefile | 22 ---------- python/grass/workflows/__init__.py | 42 ------------------ 17 files changed, 85 insertions(+), 197 deletions(-) rename {python/grass/workflows => gui/wxpython/jupyter_notebook}/directory.py (90%) rename {python/grass/workflows => gui/wxpython/jupyter_notebook}/environment.py (69%) rename {python/grass/workflows => gui/wxpython/jupyter_notebook}/server.py (85%) rename {python/grass/workflows => gui/wxpython/jupyter_notebook}/template_notebooks/new.ipynb (100%) rename {python/grass/workflows => gui/wxpython/jupyter_notebook}/template_notebooks/welcome.ipynb (100%) rename {python/grass/workflows => gui/wxpython/jupyter_notebook}/utils.py (71%) delete mode 100644 python/grass/workflows/Makefile delete mode 100644 python/grass/workflows/__init__.py diff --git a/gui/wxpython/Makefile b/gui/wxpython/Makefile index 1764e35cfa7..dd4a28307cc 100644 --- a/gui/wxpython/Makefile +++ b/gui/wxpython/Makefile @@ -9,12 +9,14 @@ include $(MODULE_TOPDIR)/include/Make/Python.make DSTDIR = $(GUIDIR)/wxpython SRCFILES := $(wildcard icons/*.py xml/*) \ - $(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 \ - vnet/*.py web_services/*.py wxplot/*.py iscatt/*.py tplot/*.py photo2image/*.py image2target/*.py) \ - wxgui.py README.md + $(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 \ + vnet/*.py web_services/*.py wxplot/*.py iscatt/*.py tplot/*.py photo2image/*.py image2target/*.py) \ + $(wildcard jupyter_notebook/template_notebooks/*.ipynb) \ + wxgui.py README.md + DSTFILES := $(patsubst %,$(DSTDIR)/%,$(SRCFILES)) \ $(patsubst %.py,$(DSTDIR)/%.pyc,$(filter %.py,$(SRCFILES))) @@ -25,7 +27,7 @@ PYDSTDIRS := $(patsubst %,$(DSTDIR)/%,animation core datacatalog jupyter_noteboo vnet timeline iscatt tplot photo2image image2target) -DSTDIRS := $(patsubst %,$(DSTDIR)/%,icons xml) +DSTDIRS := $(patsubst %,$(DSTDIR)/%,icons xml jupyter_notebook/template_notebooks) default: $(DSTFILES) ifndef CROSS_COMPILING diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index 18e0b04bd81..1a30799118b 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -4,7 +4,7 @@ @brief Integration of Jupyter Notebook to GUI. Classes: - - dialog::JupyterStartDialog + - dialogs::JupyterStartDialog (C) 2025 by the GRASS Development Team @@ -14,11 +14,10 @@ @author Linda Karlovska """ -import os from pathlib import Path import wx -from grass.workflows.directory import get_default_jupyter_workdir +from .directory import get_default_jupyter_workdir class JupyterStartDialog(wx.Dialog): @@ -115,22 +114,18 @@ def GetValues(self): if self.radio_custom.GetValue(): path = Path(self.dir_picker.GetPath()) - # create directory if it does not exist - if not path.exists(): - try: - path.mkdir(parents=True, exist_ok=True) - except OSError: - wx.MessageBox( - _("Cannot create the selected directory."), - _("Error"), - wx.ICON_ERROR, - ) - return None - - # permission check - if not os.access(path, os.W_OK | os.X_OK): + try: + # create directory if missing + path.mkdir(parents=True, exist_ok=True) + + # write permission test + test_file = path / ".grass_write_test" + test_file.touch() + test_file.unlink() + + except OSError: wx.MessageBox( - _("You do not have permission to write to the selected directory."), + _("Cannot create or write to the selected directory."), _("Error"), wx.ICON_ERROR, ) @@ -138,7 +133,7 @@ def GetValues(self): self.selected_dir = path else: - self.selected_dir = self.default_dir + self.selected_dir = Path(self.default_dir) return { "directory": self.selected_dir, diff --git a/python/grass/workflows/directory.py b/gui/wxpython/jupyter_notebook/directory.py similarity index 90% rename from python/grass/workflows/directory.py rename to gui/wxpython/jupyter_notebook/directory.py index 7190768404f..f49f262dc75 100644 --- a/python/grass/workflows/directory.py +++ b/gui/wxpython/jupyter_notebook/directory.py @@ -1,26 +1,18 @@ -# -# 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 working directory. +@package jupyter_notebook.directory + +@brief Simple interface for working with Jupyter Notebook files stored within +the current working directory. + +Classes: + - directory::JupyterDirectoryManager + +(C) 2025 by the GRASS Development Team -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 +This program is free software under the GNU General Public License +(>=v2). Read the file COPYING that comes with GRASS for details. -Designed for use within GRASS GUI tools or scripting environments. +@author Linda Karlovska """ import os diff --git a/python/grass/workflows/environment.py b/gui/wxpython/jupyter_notebook/environment.py similarity index 69% rename from python/grass/workflows/environment.py rename to gui/wxpython/jupyter_notebook/environment.py index a71c0532ee7..ea7bf0ff6ab 100644 --- a/python/grass/workflows/environment.py +++ b/gui/wxpython/jupyter_notebook/environment.py @@ -1,38 +1,27 @@ -# -# 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 +@package jupyter_notebook.environment + +@brief Defines the high-level orchestrator 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 -- registration of cleanup routines to stop servers on: - - Normal interpreter exit - - SIGINT (e.g., Ctrl+C) - - SIGTERM (e.g., kill from shell) -- stopping all servers on global cleanup (e.g., GRASS shutdown). - -Designed for use within GRASS GUI tools or scripting environments. +Classes: + - environment::JupyterEnvironment + +(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 atexit import signal import sys -from grass.workflows.directory import JupyterDirectoryManager -from grass.workflows.server import JupyterServerInstance, JupyterServerRegistry -from grass.workflows.utils import is_jupyter_installed, is_wx_html2_available +from .directory import JupyterDirectoryManager +from .server import JupyterServerInstance, JupyterServerRegistry +from .utils import is_jupyter_installed, is_wx_html2_available _cleanup_registered = False diff --git a/gui/wxpython/jupyter_notebook/frame.py b/gui/wxpython/jupyter_notebook/frame.py index 47b3f1bcd5f..40eca399c38 100644 --- a/gui/wxpython/jupyter_notebook/frame.py +++ b/gui/wxpython/jupyter_notebook/frame.py @@ -14,11 +14,12 @@ @author Linda Karlovska """ -import os +from pathlib import Path import wx from core import globalvar -from jupyter_notebook.panel import JupyterPanel + +from .panel import JupyterPanel class JupyterFrame(wx.Frame): @@ -38,8 +39,8 @@ def __init__( self.SetName("JupyterFrame") - icon_path = os.path.join(globalvar.ICONDIR, "grass.ico") - self.SetIcon(wx.Icon(icon_path, wx.BITMAP_TYPE_ICO)) + icon_path = Path(globalvar.ICONDIR) / "grass.ico" + self.SetIcon(wx.Icon(str(icon_path), wx.BITMAP_TYPE_ICO)) self.statusbar = self.CreateStatusBar(number=1) diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py index c1580ffdc5d..a29ca54c8a1 100644 --- a/gui/wxpython/jupyter_notebook/notebook.py +++ b/gui/wxpython/jupyter_notebook/notebook.py @@ -4,7 +4,7 @@ @brief Manages the jupyter notebook widget. Classes: - - page::JupyterAuiNotebook + - notebook::JupyterAuiNotebook (C) 2025 by the GRASS Development Team diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 70256e7d965..4a4739b8ce8 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -19,8 +19,8 @@ import wx from main_window.page import MainPageBase -from grass.workflows.environment import JupyterEnvironment +from .environment import JupyterEnvironment from .notebook import JupyterAuiNotebook from .toolbars import JupyterToolbar diff --git a/python/grass/workflows/server.py b/gui/wxpython/jupyter_notebook/server.py similarity index 85% rename from python/grass/workflows/server.py rename to gui/wxpython/jupyter_notebook/server.py index 8fce4fa40bf..e55d198259e 100644 --- a/python/grass/workflows/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -1,38 +1,19 @@ -# -# 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 a simple interface for launching and managing +@package jupyter_notebook.server + +@brief Simple interface for launching and managing a local Jupyter server. Classes: -- `JupyterServerInstance`: Manages a single Jupyter Notebook server instance. -- `JupyterServerRegistry`: Manages multiple `JupyterServerInstance` objects - and provides methods to start, track, and stop active servers. - -Features of `JupyterServerInstance`: -- Checks if Jupyter Notebook is installed. -- Finds an available local port. -- 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 and process object. -- Stops the server cleanly, preventing zombie processes. - -Features of `JupyterServerRegistry`: -- Thread-safe registration and unregistration of server instances -- Keeps track of all active server instances. - -Designed for use within GRASS GUI tools or scripting environments. + - server::JupyterServerInstance + - server:: JupyterServerRegistry + +(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 socket diff --git a/python/grass/workflows/template_notebooks/new.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb similarity index 100% rename from python/grass/workflows/template_notebooks/new.ipynb rename to gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb diff --git a/python/grass/workflows/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb similarity index 100% rename from python/grass/workflows/template_notebooks/welcome.ipynb rename to gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb diff --git a/python/grass/workflows/utils.py b/gui/wxpython/jupyter_notebook/utils.py similarity index 71% rename from python/grass/workflows/utils.py rename to gui/wxpython/jupyter_notebook/utils.py index 3d73f4c7bdc..7b626d316a2 100644 --- a/python/grass/workflows/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -1,24 +1,18 @@ -# -# AUTHOR(S): Linda Karlovska -# -# PURPOSE: Provides utils related to 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 utils for launching and managing -a local Jupyter server. +@package jupyter_notebook.utils + +@brief wxGUI Jupyter toolbars classes Functions: -- `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system. +- `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system and functional. - `is_wx_html2_available()`: Check if wx.html2 module is available. -Designed for use within GRASS GUI tools or scripting environments. +(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 shutil diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index ec2c24c11dd..f65b48a3bdc 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -804,7 +804,7 @@ def _show_html2_missing_message(self): def OnJupyterNotebook(self, event=None): """Launch Jupyter Notebook interface.""" - from grass.workflows.utils import ( + from jupyter_notebook.utils import ( is_jupyter_installed, is_wx_html2_available, ) @@ -850,7 +850,7 @@ def OnJupyterNotebook(self, event=None): elif action == "browser": # External browser mode: set up environment, open URL and update status - from grass.workflows.environment import JupyterEnvironment + from jupyter_notebook.environment import JupyterEnvironment jupyter_env = JupyterEnvironment( workdir=workdir, create_template=create_template, integrated=False @@ -2395,7 +2395,7 @@ def _closeWindow(self, event): return # Stop all running Jupyter servers before destroying the GUI - from grass.workflows.environment import JupyterEnvironment + from jupyter_notebook.environment import JupyterEnvironment try: JupyterEnvironment.stop_all() diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index fdfc2aecb18..ca75455299b 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -932,7 +932,7 @@ def _show_html2_missing_message(self): def OnJupyterNotebook(self, event=None, cmd=None): """Launch Jupyter Notebook interface.""" - from grass.workflows.utils import ( + from jupyter_notebook.utils import ( is_jupyter_installed, is_wx_html2_available, ) @@ -983,7 +983,7 @@ def OnJupyterNotebook(self, event=None, cmd=None): elif action == "browser": # External browser mode: set up environment, open URL and update status - from grass.workflows.environment import JupyterEnvironment + from jupyter_notebook.environment import JupyterEnvironment jupyter_env = JupyterEnvironment( workdir=workdir, create_template=create_template, integrated=False @@ -2509,7 +2509,7 @@ def _closeWindow(self, event): return # Stop all running Jupyter servers before destroying the GUI - from grass.workflows.environment import JupyterEnvironment + from jupyter_notebook.environment import JupyterEnvironment try: JupyterEnvironment.stop_all() diff --git a/python/grass/CMakeLists.txt b/python/grass/CMakeLists.txt index a88e1af2872..b089cb76160 100644 --- a/python/grass/CMakeLists.txt +++ b/python/grass/CMakeLists.txt @@ -3,7 +3,6 @@ set(PYDIRS exceptions experimental grassdb - workflows gunittest imaging jupyter diff --git a/python/grass/Makefile b/python/grass/Makefile index 37dd1234c27..cc04b04b496 100644 --- a/python/grass/Makefile +++ b/python/grass/Makefile @@ -14,7 +14,6 @@ SUBDIRS = \ gunittest \ imaging \ jupyter \ - workflows \ pydispatch \ pygrass \ script \ diff --git a/python/grass/workflows/Makefile b/python/grass/workflows/Makefile deleted file mode 100644 index 56b4db08fac..00000000000 --- a/python/grass/workflows/Makefile +++ /dev/null @@ -1,22 +0,0 @@ -MODULE_TOPDIR = ../../.. - -include $(MODULE_TOPDIR)/include/Make/Other.make -include $(MODULE_TOPDIR)/include/Make/Python.make - -DSTDIR = $(ETC)/python/grass/workflows -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 utils - -PYFILES := $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__) -PYCFILES := $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__) -TEMPLATE_DST := $(patsubst %,$(DSTDIR)/%,$(TEMPLATE_FILES)) - -default: $(PYFILES) $(PYCFILES) $(TEMPLATE_DST) - -$(TEMPLATE_DIR): - $(MKDIR) $@ - -$(DSTDIR)/%: % | $(TEMPLATE_DIR) - $(INSTALL_DATA) $< $@ diff --git a/python/grass/workflows/__init__.py b/python/grass/workflows/__init__.py deleted file mode 100644 index 4869cc53c6c..00000000000 --- a/python/grass/workflows/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -# MODULE: grass.workflows -# -# AUTHOR(S): Linda Karlovska -# -# PURPOSE: Tools for managing Jupyter Notebook 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 Notebook within GRASS - -This module provides functionality for: -- Starting and stopping local Jupyter Notebook servers inside a GRASS session -- Managing notebook working directories -- 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 working directory. - -.. versionadded:: 8.5 - -""" - -from .server import JupyterServerInstance, JupyterServerRegistry -from .directory import JupyterDirectoryManager -from .environment import JupyterEnvironment - -__all__ = [ - "JupyterDirectoryManager", - "JupyterEnvironment", - "JupyterServerInstance", - "JupyterServerRegistry", -] From aff0cca0e481e619d634dc456a6aa893b406160c Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 12 Feb 2026 12:27:12 +0100 Subject: [PATCH 32/85] fix formatting and add jupyter_notebook also to CMakeLists --- gui/wxpython/CMakeLists.txt | 1 + gui/wxpython/Makefile | 15 +++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gui/wxpython/CMakeLists.txt b/gui/wxpython/CMakeLists.txt index d0a8e91fc3c..152a1bed303 100644 --- a/gui/wxpython/CMakeLists.txt +++ b/gui/wxpython/CMakeLists.txt @@ -8,6 +8,7 @@ set(gui_lib_DIRS lmgr location_wizard main_window + jupyter_notebook mapdisp mapwin modules diff --git a/gui/wxpython/Makefile b/gui/wxpython/Makefile index dd4a28307cc..343599fe01a 100644 --- a/gui/wxpython/Makefile +++ b/gui/wxpython/Makefile @@ -9,14 +9,13 @@ include $(MODULE_TOPDIR)/include/Make/Python.make DSTDIR = $(GUIDIR)/wxpython SRCFILES := $(wildcard icons/*.py xml/*) \ - $(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 \ - vnet/*.py web_services/*.py wxplot/*.py iscatt/*.py tplot/*.py photo2image/*.py image2target/*.py) \ - $(wildcard jupyter_notebook/template_notebooks/*.ipynb) \ - wxgui.py README.md - + $(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 \ + vnet/*.py web_services/*.py wxplot/*.py iscatt/*.py tplot/*.py photo2image/*.py image2target/*.py) \ + $(wildcard jupyter_notebook/template_notebooks/*.ipynb) \ + wxgui.py README.md DSTFILES := $(patsubst %,$(DSTDIR)/%,$(SRCFILES)) \ $(patsubst %.py,$(DSTDIR)/%.pyc,$(filter %.py,$(SRCFILES))) From 4502a503a24f9c393a596255eb374edfaa80e554 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 12 Feb 2026 12:42:36 +0100 Subject: [PATCH 33/85] let the order as it was --- gui/wxpython/xml/wxgui_items.xml | 46 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/gui/wxpython/xml/wxgui_items.xml b/gui/wxpython/xml/wxgui_items.xml index 946fe14d52a..b94eb310862 100644 --- a/gui/wxpython/xml/wxgui_items.xml +++ b/gui/wxpython/xml/wxgui_items.xml @@ -1,22 +1,12 @@ - - - OnSimpleEditor - Launches Simple Python Editor. - - - - OnRunScript - Launches script file. - - - - OnJupyterNotebook - Launch Jupyter Notebook interface. - general,gui,notebook,python,jupyter - jupyter + + + OnGCPManager + g.gui.gcp + Manage Ground Control Points for Georectification + georectify @@ -31,13 +21,6 @@ OnRunModel Run model prepared by Graphical modeler - - - OnGCPManager - g.gui.gcp - Manage Ground Control Points for Georectification - georectify - OnAnimationTool @@ -74,6 +57,23 @@ Launch Map Swipe general,gui,display + + + OnRunScript + Launches script file. + + + + OnJupyterNotebook + Launch Jupyter Notebook interface. + general,gui,notebook,python,jupyter + jupyter + + + + OnSimpleEditor + Launches Simple Python Editor. + OnCloseWindow From 92766162fd7b4a309f7498c19e37e82523116973 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 13 Feb 2026 13:45:41 +0100 Subject: [PATCH 34/85] fix in new.ipynb file and refactoring of create_new_notebook and create_welcome_notebook methods --- gui/wxpython/jupyter_notebook/directory.py | 134 ++++++++++-------- .../template_notebooks/new.ipynb | 62 ++++---- 2 files changed, 100 insertions(+), 96 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/directory.py b/gui/wxpython/jupyter_notebook/directory.py index f49f262dc75..2b7c27947e9 100644 --- a/gui/wxpython/jupyter_notebook/directory.py +++ b/gui/wxpython/jupyter_notebook/directory.py @@ -16,7 +16,6 @@ """ import os -import json import shutil from pathlib import Path @@ -150,80 +149,95 @@ def export_file(self, file_name, destination_path, overwrite=False): # Copy the file to the destination shutil.copyfile(source_path, dest_path) - def create_welcome_notebook(self, file_name="welcome.ipynb"): - r""" - Create a welcome Jupyter notebook in the working directory with - the placeholders '${NOTEBOOK_DIR}' replaced by the actual working dir - and \${NOTEBOOK_MAPSET}'replaced by actual mapset path + def _create_from_template( + self, + template_name: str, + target_name: str | None = None, + replacements: dict[str, str] | None = None, + ): + """ + Create a notebook from a template and optionally replace placeholders. + + The template file is treated as plain text. If ``replacements`` is provided, + each ``key -> value`` pair is replaced in the file content. + + If ``target_name`` is None, the template is copied using ``self.import_file()``. + Otherwise, a new file with the given name is created in the working directory. - :param file_name: Name of the template file to copy (str) - :return: Path to the created template file (Path) + :param template_name: Template filename located in ``template_notebooks``. + :param target_name: Optional target filename for the new notebook. + :param replacements: Optional mapping of placeholder strings to replacement values. + :return: Path to the created notebook file (Path). + :raises FileExistsError: If target file already exists. """ - # Copy template file to the working directory - template_path = Path(__file__).parent / "template_notebooks" / file_name - template_copy = self.import_file(template_path) + # Locate the template file inside the package + template_path = Path(__file__).parent / "template_notebooks" / template_name - # Load the template file - content = template_copy.read_text(encoding="utf-8") + # Determine target path (copy vs. create new file) + if target_name is None: + target_path = self.import_file(template_path) + else: + target_path = self.workdir / target_name - # Replace the placeholder '${NOTEBOOK_DIR}' with actual working directory path - content = content.replace( - "${NOTEBOOK_DIR}", str(self._workdir).replace("\\", "/") - ) + # Prevent accidental overwrite + if target_path.exists(): + raise FileExistsError(_("File '{}' already exists").format(target_name)) - # Replace the placeholder \${NOTEBOOK_MAPSET}' by actual mapset path - env = gs.gisenv() - mapset_path = Path( - env["GISDBASE"], - env["LOCATION_NAME"], - env["MAPSET"], - ) + # Load template content as plain text + content = template_path.read_text(encoding="utf-8") + + # Replace placeholders if provided + if replacements: + for key, value in replacements.items(): + content = content.replace(key, value) + + # Write the processed content to the target file + target_path.write_text(content, encoding="utf-8") + + return target_path + + def create_welcome_notebook(self, template_name="welcome.ipynb"): + """ + Create a welcome notebook with working directory and mapset placeholders replaced. - content = content.replace( - "${NOTEBOOK_MAPSET}", - str(mapset_path).replace("\\", "/"), - ) + :param template_name: Template filename (default: ``welcome.ipynb``). + :return: Path to the created notebook (Path). + """ + # Prepare placeholder replacements + env = gs.gisenv() + mapset_path = Path(env["GISDBASE"], env["LOCATION_NAME"], env["MAPSET"]) + replacements = { + "${NOTEBOOK_DIR}": str(self._workdir).replace("\\", "/"), + "${NOTEBOOK_MAPSET}": str(mapset_path).replace("\\", "/"), + } - # Save the modified content back to the template file - template_copy.write_text(content, encoding="utf-8") - return template_copy + # Create notebook from template + return self._create_from_template(template_name, replacements=replacements) 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. + Create a new notebook from a template with only mapset placeholder replaced. + + :param new_name: Desired notebook filename. + :param template_name: Template filename (default: ``new.ipynb``). + :return: Path to the created notebook (Path). + :raises ValueError: If name is empty. + :raises FileExistsError: If file already exists. """ + # Validate notebook name if not new_name: raise ValueError(_("Notebook name must not be empty")) + # Ensure .ipynb extension 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) + # Replace only mapset placeholder + env = gs.gisenv() + mapset_path = Path(env["GISDBASE"], env["LOCATION_NAME"], env["MAPSET"]) + replacements = { + "${NOTEBOOK_MAPSET}": str(mapset_path).replace("\\", "/"), + } - return target_path + # Create notebook from template under the new name + return self._create_from_template(template_name, new_name, replacements) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb index 99df72a2b6f..f9ddab1931f 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb @@ -1,39 +1,29 @@ { - "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", - "gj.init(gisenv[\"GISDBASE\"], gisenv[\"LOCATION_NAME\"], gisenv[\"MAPSET\"])" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python [GRASS]", - "language": "python", - "name": "grass" - }, - "language_info": { - "name": "python", - "version": "3.x" - } + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "7fb27b941602401d91542211134fc71a", + "metadata": {}, + "outputs": [], + "source": [ + "# Import GRASS scripting and Jupyter modules\n", + "import grass.jupyter as gj" + ] }, - "nbformat": 4, - "nbformat_minor": 2 + { + "cell_type": "code", + "execution_count": null, + "id": "acae54e37e7d407bbb7b55eff062a284", + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize Jupyter environment for GRASS\n", + "gj.init(\"${NOTEBOOK_MAPSET}\")" + ] + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 } From 1ad736b15fc7b05a48e7e869b4ac680d81d07f69 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 13 Feb 2026 16:41:06 +0100 Subject: [PATCH 35/85] renames: NOTEBOOK_MAPSET to MAPSET_PATH, NOTEBOOK_DIR to WORKING_DIR --- gui/wxpython/jupyter_notebook/directory.py | 10 +++++----- .../jupyter_notebook/template_notebooks/new.ipynb | 2 +- .../jupyter_notebook/template_notebooks/welcome.ipynb | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/directory.py b/gui/wxpython/jupyter_notebook/directory.py index 2b7c27947e9..abf21fe929b 100644 --- a/gui/wxpython/jupyter_notebook/directory.py +++ b/gui/wxpython/jupyter_notebook/directory.py @@ -198,7 +198,7 @@ def _create_from_template( def create_welcome_notebook(self, template_name="welcome.ipynb"): """ - Create a welcome notebook with working directory and mapset placeholders replaced. + Create a welcome notebook with working directory and mapset path placeholders replaced. :param template_name: Template filename (default: ``welcome.ipynb``). :return: Path to the created notebook (Path). @@ -207,8 +207,8 @@ def create_welcome_notebook(self, template_name="welcome.ipynb"): env = gs.gisenv() mapset_path = Path(env["GISDBASE"], env["LOCATION_NAME"], env["MAPSET"]) replacements = { - "${NOTEBOOK_DIR}": str(self._workdir).replace("\\", "/"), - "${NOTEBOOK_MAPSET}": str(mapset_path).replace("\\", "/"), + "${WORKING_DIR}": str(self._workdir).replace("\\", "/"), + "${MAPSET_PATH}": str(mapset_path).replace("\\", "/"), } # Create notebook from template @@ -216,7 +216,7 @@ def create_welcome_notebook(self, template_name="welcome.ipynb"): def create_new_notebook(self, new_name, template_name="new.ipynb"): """ - Create a new notebook from a template with only mapset placeholder replaced. + Create a new notebook from a template with only mapset path placeholder replaced. :param new_name: Desired notebook filename. :param template_name: Template filename (default: ``new.ipynb``). @@ -236,7 +236,7 @@ def create_new_notebook(self, new_name, template_name="new.ipynb"): env = gs.gisenv() mapset_path = Path(env["GISDBASE"], env["LOCATION_NAME"], env["MAPSET"]) replacements = { - "${NOTEBOOK_MAPSET}": str(mapset_path).replace("\\", "/"), + "${MAPSET_PATH}": str(mapset_path).replace("\\", "/"), } # Create notebook from template under the new name diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb index f9ddab1931f..2a4078254a5 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb @@ -19,7 +19,7 @@ "outputs": [], "source": [ "# Initialize Jupyter environment for GRASS\n", - "gj.init(\"${NOTEBOOK_MAPSET}\")" + "gj.init(\"${MAPSET_PATH}\")" ] } ], diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index 0b4176e56b3..4ae97a6dea3 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -7,7 +7,7 @@ "source": [ "# Welcome to GRASS Jupyter environment\n", "\n", - "Jupyter server for this environment was started in the directory `${NOTEBOOK_DIR}`.\n", + "Jupyter server for this environment was started in the directory `${WORKING_DIR}`.\n", "\n", "---\n", "This notebook is ready to use with GRASS.\n", @@ -36,7 +36,7 @@ "outputs": [], "source": [ "# Initialize Jupyter environment for GRASS\n", - "gj.init(\"${NOTEBOOK_MAPSET}\")" + "gj.init(\"${MAPSET_PATH}\")" ] } ], From e42f2941813a52d6e556846d07355641f79e1adc Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 13 Feb 2026 16:47:11 +0100 Subject: [PATCH 36/85] 2025 -> 2026 and some small edits in file headers --- gui/wxpython/jupyter_notebook/dialogs.py | 4 ++-- gui/wxpython/jupyter_notebook/directory.py | 2 +- gui/wxpython/jupyter_notebook/environment.py | 4 ++-- gui/wxpython/jupyter_notebook/frame.py | 4 ++-- gui/wxpython/jupyter_notebook/notebook.py | 4 ++-- gui/wxpython/jupyter_notebook/panel.py | 4 ++-- gui/wxpython/jupyter_notebook/server.py | 2 +- gui/wxpython/jupyter_notebook/toolbars.py | 2 +- gui/wxpython/jupyter_notebook/utils.py | 4 ++-- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index 1a30799118b..255408320f4 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -1,12 +1,12 @@ """ @package jupyter_notebook.dialogs -@brief Integration of Jupyter Notebook to GUI. +@brief Startup dialog for integration of Jupyter Notebook to GUI. Classes: - dialogs::JupyterStartDialog -(C) 2025 by the GRASS Development Team +(C) 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. diff --git a/gui/wxpython/jupyter_notebook/directory.py b/gui/wxpython/jupyter_notebook/directory.py index abf21fe929b..b99520f60b4 100644 --- a/gui/wxpython/jupyter_notebook/directory.py +++ b/gui/wxpython/jupyter_notebook/directory.py @@ -7,7 +7,7 @@ Classes: - directory::JupyterDirectoryManager -(C) 2025 by the GRASS Development Team +(C) 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. diff --git a/gui/wxpython/jupyter_notebook/environment.py b/gui/wxpython/jupyter_notebook/environment.py index ea7bf0ff6ab..dcd7637d3e8 100644 --- a/gui/wxpython/jupyter_notebook/environment.py +++ b/gui/wxpython/jupyter_notebook/environment.py @@ -1,13 +1,13 @@ """ @package jupyter_notebook.environment -@brief Defines the high-level orchestrator which coordinates +@brief High-level orchestrator which coordinates the setup and teardown of a Jupyter Notebook environment. Classes: - environment::JupyterEnvironment -(C) 2025 by the GRASS Development Team +(C) 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. diff --git a/gui/wxpython/jupyter_notebook/frame.py b/gui/wxpython/jupyter_notebook/frame.py index 40eca399c38..4bfaeb3a4fb 100644 --- a/gui/wxpython/jupyter_notebook/frame.py +++ b/gui/wxpython/jupyter_notebook/frame.py @@ -1,12 +1,12 @@ """ @package jupyter_notebook.frame -@brief Manages the Jupyter frame widget for multi-window GUI +@brief Frame for integration of Jupyter Notebook to multi-window GUI. Classes: - frame::JupyterFrame -(C) 2025-2026 by the GRASS Development Team +(C) 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. diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py index a29ca54c8a1..073808056f4 100644 --- a/gui/wxpython/jupyter_notebook/notebook.py +++ b/gui/wxpython/jupyter_notebook/notebook.py @@ -1,12 +1,12 @@ """ @package jupyter_notebook.notebook -@brief Manages the jupyter notebook widget. +@brief Notebook for integration of Jupyter Notebook to GUI. Classes: - notebook::JupyterAuiNotebook -(C) 2025 by the GRASS Development Team +(C) 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. diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 4a4739b8ce8..d37534cb624 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -1,12 +1,12 @@ """ @package jupyter_notebook.panel -@brief Integration of Jupyter Notebook to GUI. +@brief Panel for integration of Jupyter Notebook to GUI. Classes: - panel::JupyterPanel -(C) 2025 by the GRASS Development Team +(C) 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. diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index e55d198259e..6fd5291a902 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -8,7 +8,7 @@ - server::JupyterServerInstance - server:: JupyterServerRegistry -(C) 2025 by the GRASS Development Team +(C) 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. diff --git a/gui/wxpython/jupyter_notebook/toolbars.py b/gui/wxpython/jupyter_notebook/toolbars.py index 0e9e5300b47..4bb5bd62de4 100644 --- a/gui/wxpython/jupyter_notebook/toolbars.py +++ b/gui/wxpython/jupyter_notebook/toolbars.py @@ -6,7 +6,7 @@ Classes: - toolbars::JupyterToolbar -(C) 2025 by the GRASS Development Team +(C) 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. diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index 7b626d316a2..ec4e49afe6a 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -1,13 +1,13 @@ """ @package jupyter_notebook.utils -@brief wxGUI Jupyter toolbars classes +@brief wxGUI Jupyter utils Functions: - `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system and functional. - `is_wx_html2_available()`: Check if wx.html2 module is available. -(C) 2025 by the GRASS Development Team +(C) 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. From 11912832c319cdee1e8bc13dffb4422eaba44298 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 13 Feb 2026 16:48:34 +0100 Subject: [PATCH 37/85] docstring edit --- gui/wxpython/jupyter_notebook/utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index ec4e49afe6a..ca1693661c3 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -22,9 +22,6 @@ def is_jupyter_installed(): """Check if Jupyter Notebook is installed and functional. - 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. """ # Check if 'jupyter' CLI exists From 40f3328f08ab242e3891e2f6c7f11fa591540d5b Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sat, 14 Feb 2026 13:46:11 +0100 Subject: [PATCH 38/85] more specific exceptions and some other small refinements --- gui/wxpython/jupyter_notebook/panel.py | 22 ++++---- gui/wxpython/jupyter_notebook/server.py | 69 +++++++++++++------------ gui/wxpython/jupyter_notebook/utils.py | 2 +- 3 files changed, 47 insertions(+), 46 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index d37534cb624..36e2fe77ad9 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -72,9 +72,9 @@ def SetUpNotebookInterface(self): """Setup Jupyter notebook environment and load initial notebooks.""" try: self.env.setup() - except Exception as e: + except RuntimeError as e: wx.MessageBox( - _("Failed to start Jupyter environment:\n{}").format(str(e)), + _("Failed to start Jupyter environment:\n{}").format(e), _("Startup Error"), wx.ICON_ERROR, ) @@ -86,7 +86,7 @@ def SetUpNotebookInterface(self): url = self.env.server.get_url(fname.name) except RuntimeError as e: wx.MessageBox( - _("Failed to get Jupyter server URLt:\n{}").format(str(e)), + _("Failed to get Jupyter server URL:\n{}").format(e), _("Startup Error"), wx.ICON_ERROR, ) @@ -124,7 +124,7 @@ def Open(self, 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)), + _("Failed to get Jupyter server URL:\n{}").format(e), _("URL Error"), wx.ICON_ERROR, ) @@ -150,9 +150,9 @@ def Import(self, source_path, new_name=None): 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: + except (FileNotFoundError, ValueError, FileExistsError) as e: wx.MessageBox( - _("Failed to import file:\n{}").format(str(e)), + _("Failed to import file:\n{}").format(e), _("Notebook Import Error"), wx.ICON_ERROR | wx.OK, ) @@ -239,9 +239,9 @@ def OnExport(self, event=None): self.SetStatusText( _("File {} exported to {}.").format(file_name, destination_path), 0 ) - except Exception as e: + except (FileNotFoundError, FileExistsError) as e: wx.MessageBox( - _("Failed to export file:\n{}").format(str(e)), + _("Failed to export file:\n{}").format(e), caption=_("Notebook Export Error"), style=wx.ICON_ERROR | wx.OK, ) @@ -266,9 +266,9 @@ def OnCreate(self, event=None): try: path = self.env.directory.create_new_notebook(new_name=name) - except Exception as e: + except (FileExistsError, ValueError) as e: wx.MessageBox( - _("Failed to create notebook:\n{}").format(str(e)), + _("Failed to create notebook:\n{}").format(e), caption=_("Notebook Creation Error"), style=wx.ICON_ERROR | wx.OK, ) @@ -308,7 +308,7 @@ def OnCloseWindow(self, event): 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) + url=url, pid=pid, err=e ), caption=_("Error"), style=wx.ICON_ERROR | wx.OK, diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index 6fd5291a902..5584f47059c 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -104,14 +104,14 @@ def start_server(self, integrated=True): _("Working directory is not writable: {}").format(self.workdir) ) - if self.proc and self.is_alive(): + if 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) + self.server_url = "http://127.0.0.1:{}".format(self.port) # Check if Jupyter is available in PATH jupyter = shutil.which("jupyter") @@ -143,10 +143,8 @@ def start_server(self, integrated=True): ) self.pid = self.proc.pid - except Exception as e: - raise RuntimeError( - _("Failed to start Jupyter server: {}").format(str(e)) - ) from e + except (OSError, ValueError, subprocess.SubprocessError) as e: + raise RuntimeError(_("Failed to start Jupyter server: {}").format(e)) from e # Check if the server is up if not self.is_server_running(): @@ -154,7 +152,8 @@ def start_server(self, integrated=True): try: self.proc.kill() self.proc.wait() - except Exception: + self.proc.wait(timeout=3) + except (OSError, subprocess.SubprocessError): pass self._reset_state() @@ -166,33 +165,28 @@ def stop_server(self): :raises RuntimeError: If the server cannot be stopped. """ if not self.proc or not self.pid: - return # Already stopped, nothing to do + return - if self.proc.poll() is None: # Still running - try: - 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: + if self.proc.poll() is None: # Still running try: - self.proc.wait(timeout=1) - except Exception: - pass - raise RuntimeError( - _("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() + 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 + else: + # already finished, just reap + self.proc.wait() + except (OSError, subprocess.SubprocessError) as e: + raise RuntimeError( + _("Error stopping Jupyter server (PID {}): {}").format(self.pid, e) + ) from e + + finally: + # Clean up internal state + self._reset_state() def get_url(self, file_name): """Return full URL to a file served by this server. @@ -250,6 +244,8 @@ def unregister(self, server): def stop_all_servers(self): """Stop all registered servers.""" + errors = [] + with self._servers_lock: servers = list(self.servers) self.servers.clear() @@ -257,5 +253,10 @@ def stop_all_servers(self): for server in servers: try: server.stop_server() - except Exception: - pass + except Exception as e: + errors.append(str(e)) + + if errors: + raise RuntimeError( + _("Some Jupyter servers failed to stop:\n{}").format("\n".join(errors)) + ) diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index ca1693661c3..bd2bacb9829 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -38,7 +38,7 @@ def is_jupyter_installed(): check=True, ) return True - except Exception: + except (FileNotFoundError, subprocess.CalledProcessError, OSError): return False From d9d15e066a41aaf59342c52411e42f6abcea5d26 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sat, 14 Feb 2026 14:02:59 +0100 Subject: [PATCH 39/85] better exception desc in start_server method --- gui/wxpython/jupyter_notebook/server.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index 5584f47059c..1ef24a3b658 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -85,12 +85,12 @@ def is_server_running(self, retries=50, delay=0.2): def start_server(self, integrated=True): """ - Start a Jupyter server in the given directory on a free port. + Start a Jupyter server in the working directory on a free port. :param integrated: - If False, the notebook is launched in the default web browser (external, no GUI integration). - If True (default), the server runs headless and is intended for integration into the GRASS GUI (suitable for embedded WebView). - :raises RuntimeError: If Jupyter is not installed, the directory is invalid, + :raises RuntimeError: If Jupyter is not installed, the working directory is invalid, or the server fails to start. """ # Validation checks @@ -116,7 +116,12 @@ def start_server(self, integrated=True): # Check if Jupyter is available in PATH jupyter = shutil.which("jupyter") if not jupyter: - raise RuntimeError(_("Jupyter executable not found in PATH")) + raise RuntimeError( + _( + "Jupyter executable not found in PATH. " + "Please install Jupyter Notebook and ensure it is available in your system PATH." + ) + ) # Build command to start Jupyter Notebook server cmd = [ @@ -144,7 +149,11 @@ def start_server(self, integrated=True): self.pid = self.proc.pid except (OSError, ValueError, subprocess.SubprocessError) as e: - raise RuntimeError(_("Failed to start Jupyter server: {}").format(e)) from e + raise RuntimeError( + _( + "Failed to start Jupyter server. Ensure all dependencies are installed and accessible: {}" + ).format(e) + ) from e # Check if the server is up if not self.is_server_running(): @@ -157,7 +166,12 @@ def start_server(self, integrated=True): pass self._reset_state() - raise RuntimeError(_("Jupyter server failed to start")) + raise RuntimeError( + _( + "Failed to start Jupyter server. " + "Check for port conflicts, missing dependencies, or insufficient permissions." + ) + ) def stop_server(self): """Stop the Jupyter server, ensuring no zombie processes. From 2db820d6397608d90ebf04c02d8afadee59501c3 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 16 Feb 2026 13:20:10 +0100 Subject: [PATCH 40/85] moving get_default_jupyter_workdir to the utils.py --- gui/wxpython/jupyter_notebook/dialogs.py | 2 +- gui/wxpython/jupyter_notebook/directory.py | 20 ++------------------ gui/wxpython/jupyter_notebook/utils.py | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index 255408320f4..3f85200585e 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -17,7 +17,7 @@ from pathlib import Path import wx -from .directory import get_default_jupyter_workdir +from .utils import get_default_jupyter_workdir class JupyterStartDialog(wx.Dialog): diff --git a/gui/wxpython/jupyter_notebook/directory.py b/gui/wxpython/jupyter_notebook/directory.py index b99520f60b4..5d606fbafbc 100644 --- a/gui/wxpython/jupyter_notebook/directory.py +++ b/gui/wxpython/jupyter_notebook/directory.py @@ -20,17 +20,7 @@ from pathlib import Path 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" +from .utils import get_default_jupyter_workdir class JupyterDirectoryManager: @@ -72,7 +62,7 @@ def prepare_files(self): """ Populate the list of files in the working directory. """ - # Find all .ipynb files in the notebooks directory + # Find all .ipynb files in the working directory self._files = [f for f in self._workdir.iterdir() if f.suffix == ".ipynb"] if self._create_template and not self._files: @@ -158,12 +148,6 @@ def _create_from_template( """ Create a notebook from a template and optionally replace placeholders. - The template file is treated as plain text. If ``replacements`` is provided, - each ``key -> value`` pair is replaced in the file content. - - If ``target_name`` is None, the template is copied using ``self.import_file()``. - Otherwise, a new file with the given name is created in the working directory. - :param template_name: Template filename located in ``template_notebooks``. :param target_name: Optional target filename for the new notebook. :param replacements: Optional mapping of placeholder strings to replacement values. diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index bd2bacb9829..440b80d4c86 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -6,6 +6,7 @@ Functions: - `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system and functional. - `is_wx_html2_available()`: Check if wx.html2 module is available. +- `get_default_jupyter_workdir()`: Return the default working directory for Jupyter notebooks. (C) 2026 by the GRASS Development Team @@ -17,6 +18,9 @@ import shutil import subprocess +from pathlib import Path + +import grass.script as gs def is_jupyter_installed(): @@ -56,3 +60,14 @@ def is_wx_html2_available(): return True except (ImportError, ModuleNotFoundError): return False + + +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" From d03572ec720705818465f5e9b0f914d06a86c88b Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 16 Feb 2026 20:05:54 +0100 Subject: [PATCH 41/85] all server instances are registered, regarding the structure - go back to the Claude suggestion from Anna --- gui/wxpython/jupyter_notebook/environment.py | 50 +------------ gui/wxpython/jupyter_notebook/server.py | 75 +++++++++++++++++--- 2 files changed, 68 insertions(+), 57 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/environment.py b/gui/wxpython/jupyter_notebook/environment.py index dcd7637d3e8..e572705127f 100644 --- a/gui/wxpython/jupyter_notebook/environment.py +++ b/gui/wxpython/jupyter_notebook/environment.py @@ -15,51 +15,11 @@ @author Linda Karlovska """ -import atexit -import signal -import sys - from .directory import JupyterDirectoryManager from .server import JupyterServerInstance, JupyterServerRegistry from .utils import is_jupyter_installed, is_wx_html2_available -_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 - - class JupyterEnvironment: """Orchestrates directory manager and Jupyter server lifecycle. @@ -89,16 +49,10 @@ def setup(self): # Start server self.server.start_server(self.integrated) - # Register server in global registry - if self.integrated: - _register_global_cleanup() - JupyterServerRegistry.get().register(self.server) - def stop(self): """Stop server only if integrated.""" - if self.integrated: - self.server.stop_server() - JupyterServerRegistry.get().unregister(self.server) + self.server.stop_server() + JupyterServerRegistry.get().unregister(self.server) @classmethod def stop_all(cls): diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index 1ef24a3b658..b5eae13f2ec 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -16,6 +16,9 @@ @author Linda Karlovska """ +import atexit +import signal +import sys import socket import time import subprocess @@ -25,10 +28,46 @@ 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 + + class JupyterServerInstance: """Manage the lifecycle of a Jupyter server instance.""" - def __init__(self, workdir, integrated=True): + def __init__(self, workdir): """Initialize Jupyter server instance. :param workdir: Working directory for the Jupyter server (str). @@ -38,6 +77,12 @@ def __init__(self, workdir, integrated=True): self.proc = None self._reset_state() + # 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 @@ -127,8 +172,8 @@ def start_server(self, integrated=True): cmd = [ jupyter, "notebook", - "--NotebookApp.token=''", - "--NotebookApp.password=''", + "--NotebookApp.token=", + "--NotebookApp.password=", "--port", str(self.port), "--notebook-dir", @@ -202,6 +247,12 @@ def stop_server(self): # 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. @@ -219,7 +270,7 @@ def get_url(self, file_name): class JupyterServerRegistry: - """Thread-safe registry of running JupyterServerInstance objects. Track integrated servers only.""" + """Thread-safe registry of running JupyterServerInstance objects.""" _instance = None _lock = threading.Lock() @@ -252,19 +303,25 @@ def register(self, server): self.servers.append(server) def unregister(self, server): - """Unregister a server instance.""" + """Unregister a server instance. + + :param server: JupyterServerInstance to unregister. + """ with self._servers_lock: self.servers = [s for s in self.servers if s != server] def stop_all_servers(self): - """Stop all registered servers.""" + """Stop all registered servers. + + Continues attempting to stop all servers even if some fail. + """ errors = [] with self._servers_lock: - servers = list(self.servers) - self.servers.clear() + # Copy list to avoid modification during iteration + servers_to_stop = self.servers[:] - for server in servers: + for server in servers_to_stop: try: server.stop_server() except Exception as e: From 22f08695d28294c4161b5d3ecffa5a9c5235dbb6 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 16 Feb 2026 23:39:04 +0100 Subject: [PATCH 42/85] better error handling and new lightweight panel for Jupyter running in external browser --- gui/wxpython/jupyter_notebook/environment.py | 32 +- gui/wxpython/jupyter_notebook/notebook.py | 27 +- gui/wxpython/jupyter_notebook/panel.py | 347 +++++++++++++++---- gui/wxpython/jupyter_notebook/server.py | 9 +- gui/wxpython/jupyter_notebook/toolbars.py | 5 +- gui/wxpython/main_window/frame.py | 116 ++++--- 6 files changed, 405 insertions(+), 131 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/environment.py b/gui/wxpython/jupyter_notebook/environment.py index e572705127f..80aa022e5f2 100644 --- a/gui/wxpython/jupyter_notebook/environment.py +++ b/gui/wxpython/jupyter_notebook/environment.py @@ -17,7 +17,6 @@ from .directory import JupyterDirectoryManager from .server import JupyterServerInstance, JupyterServerRegistry -from .utils import is_jupyter_installed, is_wx_html2_available class JupyterEnvironment: @@ -25,32 +24,22 @@ class JupyterEnvironment: :param workdir: Directory for notebooks :param create_template: Whether to create template notebooks - :param integrated: If False, server is intended to be opened in external browser. - If True, server is integrated into GRASS GUI and will be - automatically stopped on GUI exit. """ - def __init__(self, workdir, create_template, integrated): + def __init__(self, workdir, create_template): self.directory = JupyterDirectoryManager(workdir, create_template) self.server = JupyterServerInstance(workdir) - self.integrated = integrated def setup(self): """Prepare files and start server.""" - if not is_jupyter_installed(): - raise RuntimeError(_("Jupyter Notebook is not installed")) - - if self.integrated and not is_wx_html2_available(): - raise RuntimeError(_("wx.html2 (WebView) support is not available")) - # Prepare files self.directory.prepare_files() # Start server - self.server.start_server(self.integrated) + self.server.start_server() def stop(self): - """Stop server only if integrated.""" + """Stop server and unregister it.""" self.server.stop_server() JupyterServerRegistry.get().unregister(self.server) @@ -58,3 +47,18 @@ def stop(self): def stop_all(cls): """Stop all running Jupyter servers and unregister them.""" JupyterServerRegistry.get().stop_all_servers() + + @property + def server_url(self): + """Get server URL.""" + return self.server.server_url if self.server else None + + @property + def pid(self): + """Get server process ID.""" + return self.server.pid if self.server else None + + @property + def workdir(self): + """Get working directory.""" + return self.directory.workdir if self.directory else None diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py index 073808056f4..66a5355fb0e 100644 --- a/gui/wxpython/jupyter_notebook/notebook.py +++ b/gui/wxpython/jupyter_notebook/notebook.py @@ -17,15 +17,26 @@ import wx from wx.lib.agw import aui +# Try to import html2, but it might not be available 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 wx.html2 as html + + WX_HTML2_AVAILABLE = True +except ImportError: + WX_HTML2_AVAILABLE = False + html = None from gui_core.wrap import SimpleTabArt class JupyterAuiNotebook(aui.AuiNotebook): + """AUI Notebook for managing Jupyter notebook tabs with embedded WebView. + + Note: This class requires wx.html2.WebView to be available and functional. + If wx.html2 is not available or WebView.New() raises NotImplementedError, + the AddPage method will raise an exception. + """ + def __init__( self, parent, @@ -37,9 +48,15 @@ def __init__( ): """ Wrapper for the notebook widget that manages notebook pages. + + :raises ImportError: If wx.html2 is not available """ + + if not WX_HTML2_AVAILABLE: + msg = "wx.html2 is not available" + raise ImportError(msg) + self.parent = parent - self.webview = None super().__init__(parent=self.parent, id=wx.ID_ANY, agwStyle=agwStyle) @@ -100,6 +117,8 @@ 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). + + :raises NotImplementedError: If wx.html2.WebView is not functional on this system """ browser = html.WebView.New(self) wx.CallAfter(browser.LoadURL, url) diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 36e2fe77ad9..697f9d06496 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -5,6 +5,7 @@ Classes: - panel::JupyterPanel + - panel::JupyterBrowserPanel (C) 2026 by the GRASS Development Team @@ -17,6 +18,7 @@ from pathlib import Path import wx +import webbrowser from main_window.page import MainPageBase @@ -26,6 +28,18 @@ class JupyterPanel(wx.Panel, MainPageBase): + """Integrated Jupyter Notebook panel with embedded browser (requires wx.html2). + + This panel provides a full-featured Jupyter Notebook interface embedded + directly in the GRASS GUI using wx.html2.WebView. Notebooks are displayed + in tabs within the GUI itself. + + For a lightweight alternative that opens notebooks in an external browser + without requiring wx.html2, see JupyterBrowserPanel. + + The Jupyter server is automatically stopped when this panel is closed. + """ + def __init__( self, parent, @@ -38,18 +52,18 @@ def __init__( create_template=False, **kwargs, ): - """Jupyter main panel.""" super().__init__(parent=parent, id=id, **kwargs) MainPageBase.__init__(self, dockable) self.parent = parent self._giface = giface self.statusbar = statusbar - self.workdir = workdir - self.SetName("Jupyter") + self.SetName("JupyterIntegrated") + # Create environment in integrated mode (requires wx.html2) self.env = JupyterEnvironment( - workdir=self.workdir, create_template=create_template, integrated=True + workdir=workdir, + create_template=create_template, ) self.toolbar = JupyterToolbar(parent=self) @@ -58,7 +72,6 @@ def __init__( 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) @@ -68,9 +81,17 @@ def _layout(self): sizer.Fit(self) self.Layout() - def SetUpNotebookInterface(self): - """Setup Jupyter notebook environment and load initial notebooks.""" + def SetUpEnvironment(self): + """Setup integrated Jupyter notebook environment and load initial notebooks. + + - Prepares notebook files in the working directory + - Starts the Jupyter server + - Loads all existing notebooks as tabs in the embedded browser + + :return: bool: True if setup was successful, False otherwise + """ try: + # Prepare files and start server self.env.setup() except RuntimeError as e: wx.MessageBox( @@ -78,7 +99,7 @@ def SetUpNotebookInterface(self): _("Startup Error"), wx.ICON_ERROR, ) - return + return False # Load notebook tabs in embedded AUI notebook for fname in self.env.directory.files: @@ -90,22 +111,24 @@ def SetUpNotebookInterface(self): _("Startup Error"), wx.ICON_ERROR, ) - return + return False + 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), + url=self.env.server_url, + pid=self.env.pid, + dir=self.env.workdir, ) ) + return True def Switch(self, file_name): - """ - 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. + """Switch to existing notebook tab. + + :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') + :return: True if the notebook was found and switched to, False otherwise """ for i in range(self.aui_notebook.GetPageCount()): if self.aui_notebook.GetPageText(i) == file_name: @@ -114,9 +137,9 @@ def Switch(self, file_name): return False 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). + """Open a Jupyter notebook in a new tab and switch to it. + + :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') """ try: url = self.env.server.get_url(file_name) @@ -130,9 +153,9 @@ def Open(self, file_name): ) 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). + """Switch to .ipynb file if open, otherwise open it. + + :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') """ if self.Switch(file_name): self.SetStatusText(_("File '{}' is already opened.").format(file_name), 0) @@ -141,10 +164,10 @@ def OpenOrSwitch(self, file_name): self.SetStatusText(_("File '{}' opened.").format(file_name), 0) 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). + """Import a .ipynb file into working directory and open it in a new tab. + + :param source_path: Path to the source .ipynb file to be imported + :param new_name: Optional new name for the imported file """ try: path = self.env.directory.import_file(source_path, new_name=new_name) @@ -158,14 +181,11 @@ def Import(self, source_path, new_name=None): ) 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). + """Import an existing Jupyter notebook file into the working directory. + + Prompts user to select a .ipynb file: + - If the file is already in the notebook directory: switch to it or open it + - If the file is from elsewhere: import and open it (prompt for new name if needed) """ # Open file dialog to select an existing Jupyter notebook file with wx.FileDialog( @@ -180,7 +200,7 @@ def OnImport(self, event=None): source_path = Path(dlg.GetPath()) file_name = source_path.name - target_path = self.workdir / file_name + target_path = self.env.workdir / file_name # File is already in the working directory if source_path.resolve() == target_path.resolve(): @@ -247,10 +267,7 @@ def OnExport(self, event=None): ) 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. - """ + """Create a new empty Jupyter notebook in the working directory and open it.""" with wx.TextEntryDialog( self, message=_("Enter a name for the new notebook:"), @@ -276,19 +293,212 @@ def OnCreate(self, event=None): # Open the newly created notebook in the GUI self.Open(path.name) + self.SetStatusText(_("New file '{}' created.").format(path.name), 0) + + def OnCloseWindow(self, event=None): + """Cleanup when panel is being closed (called by parent notebook). + + This method is called by mainnotebook when the tab is being closed. + """ + confirm = wx.MessageBox( + _("Do you really want to close this tab 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.env: + # Get server info before stopping + url = self.env.server_url + pid = self.env.pid + + try: + self.env.stop() + if self.statusbar: + self.statusbar.SetStatusText( + _( + "Jupyter server at {url} (PID: {pid}) has been stopped" + ).format(url=url, pid=pid) + ) + except Exception as e: + wx.MessageBox( + _("Failed to stop Jupyter server:\n{}").format(str(e)), + _("Error"), + wx.ICON_ERROR, + ) + if event and hasattr(event, "Veto"): + event.Veto() + return + + self._onCloseWindow(event) def SetStatusText(self, *args): """Set text in the status bar.""" self.statusbar.SetStatusText(*args) def GetStatusBar(self): - """Get statusbar""" + """Get statusbar.""" return self.statusbar - def OnCloseWindow(self, event): - """Prompt user, then stop server and close panel.""" + +class JupyterBrowserPanel(wx.Panel, MainPageBase): + """Lightweight panel for Jupyter running in external browser. + + This panel doesn't require wx.html2 and just shows info about + the running Jupyter server with a button to stop it. + """ + + def __init__( + self, + parent, + giface, + id=wx.ID_ANY, + statusbar=None, + dockable=False, + workdir=None, + create_template=False, + **kwargs, + ): + super().__init__(parent=parent, id=id, **kwargs) + MainPageBase.__init__(self, dockable) + + self.parent = parent + self._giface = giface + self.statusbar = statusbar + self.SetName("JupyterBrowser") + + self.env = JupyterEnvironment(workdir=workdir, create_template=create_template) + + self._layout() + + def _layout(self): + """Create simple layout with message and controls.""" + main_sizer = wx.BoxSizer(wx.VERTICAL) + main_sizer.AddStretchSpacer(1) + + # Info icon and message + info_sizer = wx.BoxSizer(wx.HORIZONTAL) + info_icon = wx.StaticBitmap( + self, + bitmap=wx.ArtProvider.GetBitmap(wx.ART_INFORMATION, wx.ART_MESSAGE_BOX), + ) + info_sizer.Add(info_icon, flag=wx.ALL, border=5) + + info_text = wx.StaticText( + self, + label=_("Jupyter Notebook has been opened in your default browser."), + ) + info_text.SetFont(info_text.GetFont().Bold()) + info_sizer.Add(info_text, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=5) + + main_sizer.Add(info_sizer, flag=wx.ALL | wx.ALIGN_CENTER, border=10) + + # Server details (will be populated after setup) + details_sizer = wx.BoxSizer(wx.VERTICAL) + + self.url_text = wx.StaticText(self, label="") + details_sizer.Add(self.url_text, flag=wx.ALL | wx.ALIGN_CENTER, border=3) + + self.pid_text = wx.StaticText(self, label="") + details_sizer.Add(self.pid_text, flag=wx.ALL | wx.ALIGN_CENTER, border=3) + + self.dir_text = wx.StaticText(self, label="") + details_sizer.Add(self.dir_text, flag=wx.ALL | wx.ALIGN_CENTER, border=3) + + main_sizer.Add(details_sizer, flag=wx.ALL | wx.ALIGN_CENTER, border=10) + + # Buttons + button_sizer = wx.BoxSizer(wx.HORIZONTAL) + + open_browser_btn = wx.Button(self, label=_("Open in Browser Again")) + open_browser_btn.Bind(wx.EVT_BUTTON, self.OnOpenBrowser) + button_sizer.Add(open_browser_btn, flag=wx.ALL, border=5) + + stop_btn = wx.Button(self, label=_("Stop Jupyter Server")) + stop_btn.Bind(wx.EVT_BUTTON, self.OnStop) + button_sizer.Add(stop_btn, flag=wx.ALL, border=5) + + main_sizer.Add(button_sizer, flag=wx.ALL | wx.ALIGN_CENTER, border=10) + main_sizer.AddStretchSpacer(2) + + self.SetSizer(main_sizer) + self.Layout() + + def SetUpEnvironment(self): + """Setup Jupyter environment and open in browser. + + :return: bool: True if setup was successful, False otherwis + """ + + try: + # Prepare files and start server + self.env.setup() + except RuntimeError as e: + wx.MessageBox( + _("Failed to start Jupyter environment:\n{}").format(e), + _("Startup Error"), + wx.ICON_ERROR, + ) + return False + + # Update UI with server info + self.url_text.SetLabel(_("Server URL: {}").format(self.env.server_url)) + self.pid_text.SetLabel(_("Process ID: {}").format(self.env.pid)) + self.dir_text.SetLabel(_("Working directory: {}").format(self.env.workdir)) + + self.Layout() + + # Open in default browser + webbrowser.open(self.env.server_url) + + return True + + def OnOpenBrowser(self, event): + """Re-open the Jupyter server URL in browser.""" + if self.env and self.env.server and self.env.server_url: + webbrowser.open(self.env.server_url) + + def OnStop(self, event): + """Stop the Jupyter server and close the tab.""" + if self.env: + # Get server info before stopping + url = self.env.server_url + pid = self.env.pid + + try: + # Stop server and unregister it + self.env.stop() + self.statusbar.SetStatusText( + _("Jupyter server at {url} (PID: {pid}) has been stopped").format( + url=url, pid=pid + ) + ) + # Close this tab/panel + parent_notebook = self.GetParent() + if hasattr(parent_notebook, "DeletePage"): + # Find our page index and delete it + for i in range(parent_notebook.GetPageCount()): + if parent_notebook.GetPage(i) == self: + parent_notebook.DeletePage(i) + break + except Exception as e: + wx.MessageBox( + _("Failed to stop server:\n{}").format(str(e)), + _("Error"), + wx.ICON_ERROR, + ) + + def OnCloseWindow(self, event=None): + """Cleanup when panel is being closed (called by parent notebook). + + This method is called by mainnotebook when the tab is being closed. + """ confirm = wx.MessageBox( - _("Do you really want to close this window and stop the Jupyter server?"), + _("Do you really want to close this tab and stop the Jupyter server?"), _("Confirm Close"), wx.ICON_QUESTION | wx.YES_NO | wx.NO_DEFAULT, ) @@ -298,25 +508,36 @@ def OnCloseWindow(self, event): event.Veto() return - # Get server info - url = self.env.server.server_url - pid = self.env.server.pid + if self.env: + # Get server info before stopping + url = self.env.server_url + pid = self.env.pid + + try: + self.env.stop() + if self.statusbar: + self.statusbar.SetStatusText( + _( + "Jupyter server at {url} (PID: {pid}) has been stopped" + ).format(url=url, pid=pid) + ) + except Exception as e: + wx.MessageBox( + _("Failed to stop Jupyter server:\n{}").format(str(e)), + _("Error"), + wx.ICON_ERROR, + ) + if event and hasattr(event, "Veto"): + event.Veto() + return - # 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=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) + + def SetStatusText(self, *args): + """Set text in the status bar.""" + if self.statusbar: + self.statusbar.SetStatusText(*args) + + def GetStatusBar(self): + """Get status bar.""" + return self.statusbar diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index b5eae13f2ec..955ce4b5ec4 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -128,13 +128,10 @@ def is_server_running(self, retries=50, delay=0.2): return False - def start_server(self, integrated=True): + def start_server(self): """ Start a Jupyter server in the working directory on a free port. - :param integrated: - - If False, the notebook is launched in the default web browser (external, no GUI integration). - - If True (default), the server runs headless and is intended for integration into the GRASS GUI (suitable for embedded WebView). :raises RuntimeError: If Jupyter is not installed, the working directory is invalid, or the server fails to start. """ @@ -172,6 +169,7 @@ def start_server(self, integrated=True): cmd = [ jupyter, "notebook", + "--no-browser", "--NotebookApp.token=", "--NotebookApp.password=", "--port", @@ -180,9 +178,6 @@ def start_server(self, integrated=True): self.workdir, ] - if integrated: - cmd.insert(2, "--no-browser") - # Start server try: self.proc = subprocess.Popen( diff --git a/gui/wxpython/jupyter_notebook/toolbars.py b/gui/wxpython/jupyter_notebook/toolbars.py index 4bb5bd62de4..d8b9f643b78 100644 --- a/gui/wxpython/jupyter_notebook/toolbars.py +++ b/gui/wxpython/jupyter_notebook/toolbars.py @@ -55,7 +55,10 @@ def _toolbarData(self): label=_("Export notebook"), ), "docking": BaseIcons["docking"], - "quit": BaseIcons["quit"], + "quit": MetaIcon( + img="quit", + label=_("Stop server"), + ), } data = ( ( diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index ca75455299b..5fb133254fa 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -917,19 +917,6 @@ def _show_jupyter_missing_message(self): parent=self, ) - def _show_html2_missing_message(self): - wx.MessageBox( - _( - "Jupyter Notebook integration requires wxPython with wx.html2 " - "(WebView) support enabled.\n\n" - "Install wxPython/wxWidgets with HTML2/WebView support or use " - "Jupyter externally in a browser." - ), - _("Jupyter Notebook not available"), - wx.OK | wx.ICON_INFORMATION, - parent=self, - ) - def OnJupyterNotebook(self, event=None, cmd=None): """Launch Jupyter Notebook interface.""" from jupyter_notebook.utils import ( @@ -960,16 +947,28 @@ def OnJupyterNotebook(self, event=None, cmd=None): create_template = values["create_template"] if action == "integrated": - # Embedded notebook mode: create JupyterFrame and start server within it + # Embedded notebook mode: requires wx.html2 for WebView if not is_wx_html2_available(): - self._show_html2_missing_message() - return + # Offer fallback to browser mode + response = wx.MessageBox( + _( + "Integrated mode requires wx.html2.WebView which is not available on this system.\n\n" + "Would you like to open Jupyter Notebook in your external browser instead?" + ), + _("Integrated Mode Not Available"), + wx.ICON_WARNING | wx.YES_NO, + ) + + if response == wx.YES: + action = "browser" + else: + return - # Embedded notebook mode: create JupyterPanel and start server within it + if action == "integrated": from jupyter_notebook.panel import JupyterPanel panel = JupyterPanel( - parent=self, + parent=self.mainnotebook, giface=self._giface, statusbar=self.statusbar, dockable=True, @@ -977,36 +976,69 @@ def OnJupyterNotebook(self, event=None, cmd=None): create_template=create_template, ) panel.SetUpPage(self, self.mainnotebook) - panel.SetUpNotebookInterface() - - self.mainnotebook.AddPage(panel, _("Jupyter Notebook")) - elif action == "browser": - # External browser mode: set up environment, open URL and update status - from jupyter_notebook.environment import JupyterEnvironment - - jupyter_env = JupyterEnvironment( - workdir=workdir, create_template=create_template, integrated=False - ) + # Setup environment and load notebooks try: - jupyter_env.setup() - except Exception as e: - wx.MessageBox( - _("Failed to start Jupyter environment:\n{}").format(str(e)), - _("Startup Error"), - wx.ICON_ERROR, + if not panel.SetUpEnvironment(): + # Setup failed for other reasons + panel.Destroy() + return + + except NotImplementedError as e: + # WebView.New() raised NotImplementedError - not functional on this system + panel.Destroy() + + response = wx.MessageBox( + _( + "Integrated mode failed: wx.html2.WebView is not functional on this system.\n" + "Error: {}\n\n" + "Would you like to open Jupyter Notebook in your external browser instead?" + ).format(str(e)), + _("WebView Not Supported"), + wx.ICON_ERROR | wx.YES_NO, ) + + if response == wx.YES: + action = "browser" + # Fall through to browser setup below + else: + return + else: + # Success! + self.mainnotebook.AddPage(panel, _("Jupyter Notebook (Integrated)")) return - self.SetStatusText( - _( - "Jupyter server started in browser at {url} (PID: {pid}), directory: {dir}" - ).format( - url=jupyter_env.server.server_url, - pid=jupyter_env.server.pid, - dir=str(workdir), - ) + if action == "browser": + # External browser mode: lightweight panel without wx.html2 requirement + from jupyter_notebook.panel import JupyterBrowserPanel + + panel = JupyterBrowserPanel( + parent=self.mainnotebook, + giface=self._giface, + statusbar=self.statusbar, + dockable=True, + workdir=workdir, + create_template=create_template, + ) + panel.SetUpPage(self, self.mainnotebook) + + # Add panel as tab first (so user sees something is happening) + self.mainnotebook.AddPage( + panel, + _("Jupyter Browser - {}").format( + workdir.name if workdir else "default" + ), ) + self.mainnotebook.SetSelection(self.mainnotebook.GetPageCount() - 1) + + # Setup environment and open in browser + if not panel.SetUpEnvironment(): + # Setup failed, remove the panel + for i in range(self.mainnotebook.GetPageCount()): + if self.mainnotebook.GetPage(i) == panel: + self.mainnotebook.DeletePage(i) + break + return def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" From bdd13e58eb7a51b227d29c33756129931664271f Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 17 Feb 2026 11:32:15 +0100 Subject: [PATCH 43/85] fix jupyter notebook modes in multi-window GUI --- gui/wxpython/jupyter_notebook/frame.py | 107 ++++++++++++++++++++----- gui/wxpython/lmgr/frame.py | 76 ++++++------------ 2 files changed, 112 insertions(+), 71 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/frame.py b/gui/wxpython/jupyter_notebook/frame.py index 4bfaeb3a4fb..70e625aba14 100644 --- a/gui/wxpython/jupyter_notebook/frame.py +++ b/gui/wxpython/jupyter_notebook/frame.py @@ -15,20 +15,24 @@ """ from pathlib import Path + import wx from core import globalvar - -from .panel import JupyterPanel +from jupyter_notebook.panel import JupyterPanel, JupyterBrowserPanel class JupyterFrame(wx.Frame): - """Main window for the Jupyter Notebook interface.""" + """Main window for the Jupyter Notebook interface in multi-window GUI. + + Supports both integrated (embedded WebView) and browser (external) modes. + """ def __init__( self, parent, giface, + action="integrated", workdir=None, create_template=False, id=wx.ID_ANY, @@ -43,20 +47,83 @@ def __init__( self.SetIcon(wx.Icon(str(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) + self.panel = None + + # Try integrated mode first if requested + if action == "integrated": + try: + self.panel = JupyterPanel( + parent=self, + giface=giface, + workdir=workdir, + create_template=create_template, + statusbar=self.statusbar, + dockable=False, + ) + + # Setup environment and load notebooks + if not self.panel.SetUpEnvironment(): + self.panel.Destroy() + self.panel = None + self.Close() + return + + except NotImplementedError as e: + # WebView.New() raised NotImplementedError - not functional + if self.panel: + self.panel.Destroy() + self.panel = None + + response = wx.MessageBox( + _( + "Integrated mode failed: wx.html2.WebView is not functional on this system.\n" + "Error: {}\n\n" + "Would you like to open Jupyter Notebook in your external browser instead?" + ).format(str(e)), + _("WebView Not Supported"), + wx.ICON_ERROR | wx.YES_NO, + ) + + if response == wx.YES: + action = "browser" + else: + self.Close() + return + + # Browser mode + if action == "browser": + self.panel = JupyterBrowserPanel( + parent=self, + giface=giface, + workdir=workdir, + create_template=create_template, + statusbar=self.statusbar, + dockable=False, + ) + + # Setup environment and open in browser + if not self.panel.SetUpEnvironment(): + self.panel.Destroy() + self.panel = None + self.Close() + return + + self._layout() + + def _layout(self): + if self.panel: + 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.OnCloseWindow) + + def OnCloseWindow(self, event): + if self.panel and hasattr(self.panel, "OnCloseWindow"): + self.panel.OnCloseWindow(event) + + if event.GetVeto(): + return + self.Destroy() diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index f65b48a3bdc..cf8ea1dce2c 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -789,19 +789,6 @@ def _show_jupyter_missing_message(self): parent=self, ) - def _show_html2_missing_message(self): - wx.MessageBox( - _( - "Jupyter Notebook integration requires wxPython with wx.html2 " - "(WebView) support enabled.\n\n" - "Install wxPython/wxWidgets with HTML2/WebView support or use " - "Jupyter externally in a browser." - ), - _("Jupyter Notebook not available"), - wx.OK | wx.ICON_INFORMATION, - parent=self, - ) - def OnJupyterNotebook(self, event=None): """Launch Jupyter Notebook interface.""" from jupyter_notebook.utils import ( @@ -832,48 +819,35 @@ def OnJupyterNotebook(self, event=None): create_template = values["create_template"] if action == "integrated": - # Embedded notebook mode: create JupyterFrame and start server within it + # Embedded notebook mode: requires wx.html2 for WebView if not is_wx_html2_available(): - self._show_html2_missing_message() - return - - from jupyter_notebook.frame import JupyterFrame - - frame = JupyterFrame( - parent=self, - giface=self._giface, - workdir=workdir, - create_template=create_template, - ) - frame.CentreOnParent() - frame.Show() + # Offer fallback to browser mode + response = wx.MessageBox( + _( + "Integrated mode requires wx.html2.WebView which is not available on this system.\n\n" + "Would you like to open Jupyter Notebook in your external browser instead?" + ), + _("Integrated Mode Not Available"), + wx.ICON_WARNING | wx.YES_NO, + ) - elif action == "browser": - # External browser mode: set up environment, open URL and update status - from jupyter_notebook.environment import JupyterEnvironment + if response == wx.YES: + action = "browser" + else: + return - jupyter_env = JupyterEnvironment( - workdir=workdir, create_template=create_template, integrated=False - ) - try: - jupyter_env.setup() - except Exception as e: - wx.MessageBox( - _("Failed to start Jupyter environment:\n{}").format(str(e)), - _("Startup Error"), - wx.ICON_ERROR, - ) - return + from jupyter_notebook.frame import JupyterFrame - self.SetStatusText( - _( - "Jupyter server started in browser at {url} (PID: {pid}), directory: {dir}" - ).format( - url=jupyter_env.server.server_url, - pid=jupyter_env.server.pid, - dir=str(workdir), - ) - ) + # Create and show the Jupyter frame + frame = JupyterFrame( + parent=self, + giface=self._giface, + action=action, + workdir=workdir, + create_template=create_template, + ) + frame.CentreOnParent() + frame.Show() def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" From 8a955dc042e3a929c19ed9c43fe1c64e0a5c3eb7 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 17 Feb 2026 11:44:47 +0100 Subject: [PATCH 44/85] change the order of working directory pickers in startup dialog --- gui/wxpython/jupyter_notebook/dialogs.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index 3f85200585e..c554c691705 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -37,21 +37,26 @@ def __init__(self, parent): dir_box = wx.StaticBox(self, label=_("Notebook working directory")) dir_sizer = wx.StaticBoxSizer(dir_box, wx.VERTICAL) - self.radio_default = wx.RadioButton( + self.radio_custom = wx.RadioButton( self, - label=_("Use default: {}").format(self.default_dir), + label=_("Select directory:"), style=wx.RB_GROUP, ) - self.radio_custom = wx.RadioButton(self, label=_("Select another directory:")) + self.radio_custom.SetValue(True) # Default selection self.dir_picker = wx.DirPickerCtrl( self, message=_("Choose a working directory"), style=wx.DIRP_USE_TEXTCTRL ) - self.dir_picker.Enable(False) + self.dir_picker.Enable(True) # Enabled by default + + self.radio_default = wx.RadioButton( + self, + label=_("Use default: {}").format(self.default_dir), + ) - 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) + dir_sizer.Add(self.radio_default, 0, wx.ALL, 5) sizer.Add(dir_sizer, 0, wx.EXPAND | wx.ALL, 10) # Template preference section From 60d206c72685d6f7cc9aa9fe926bebcb19b16473 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 20 Feb 2026 20:15:56 +0100 Subject: [PATCH 45/85] notebook names as variables --- gui/wxpython/jupyter_notebook/dialogs.py | 5 +++-- gui/wxpython/jupyter_notebook/directory.py | 13 +++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index c554c691705..c13b39e41ce 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -18,6 +18,7 @@ import wx from .utils import get_default_jupyter_workdir +from .directory import WELCOME_NOTEBOOK_NAME class JupyterStartDialog(wx.Dialog): @@ -67,9 +68,9 @@ def __init__(self, parent): self.checkbox_template.SetValue(True) self.checkbox_template.SetToolTip( _( - "If selected, a welcome notebook (welcome.ipynb) will be created,\n" + "If selected, a welcome notebook ({}) will be created,\n" "but only if the selected directory contains no .ipynb files." - ) + ).format(WELCOME_NOTEBOOK_NAME) ) options_sizer.Add(self.checkbox_template, 0, wx.ALL, 5) diff --git a/gui/wxpython/jupyter_notebook/directory.py b/gui/wxpython/jupyter_notebook/directory.py index 5d606fbafbc..0d270802eef 100644 --- a/gui/wxpython/jupyter_notebook/directory.py +++ b/gui/wxpython/jupyter_notebook/directory.py @@ -23,6 +23,11 @@ from .utils import get_default_jupyter_workdir +# Template notebook filenames +WELCOME_NOTEBOOK_NAME = "welcome.ipynb" +NEW_NOTEBOOK_TEMPLATE_NAME = "new.ipynb" + + class JupyterDirectoryManager: """Manage a Jupyter notebook working directory.""" @@ -180,11 +185,11 @@ def _create_from_template( return target_path - def create_welcome_notebook(self, template_name="welcome.ipynb"): + def create_welcome_notebook(self, template_name=WELCOME_NOTEBOOK_NAME): """ Create a welcome notebook with working directory and mapset path placeholders replaced. - :param template_name: Template filename (default: ``welcome.ipynb``). + :param template_name: Template filename (default: WELCOME_NOTEBOOK_NAME). :return: Path to the created notebook (Path). """ # Prepare placeholder replacements @@ -198,12 +203,12 @@ def create_welcome_notebook(self, template_name="welcome.ipynb"): # Create notebook from template return self._create_from_template(template_name, replacements=replacements) - def create_new_notebook(self, new_name, template_name="new.ipynb"): + def create_new_notebook(self, new_name, template_name=NEW_NOTEBOOK_TEMPLATE_NAME): """ Create a new notebook from a template with only mapset path placeholder replaced. :param new_name: Desired notebook filename. - :param template_name: Template filename (default: ``new.ipynb``). + :param template_name: Template filename (default: NEW_NOTEBOOK_TEMPLATE_NAME). :return: Path to the created notebook (Path). :raises ValueError: If name is empty. :raises FileExistsError: If file already exists. From 4b98186e547f38748abc04c5527a012e203ccb98 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 20 Feb 2026 20:30:34 +0100 Subject: [PATCH 46/85] add type annotations to directory.py and edit docstrings --- gui/wxpython/jupyter_notebook/directory.py | 100 +++++++++++---------- 1 file changed, 52 insertions(+), 48 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/directory.py b/gui/wxpython/jupyter_notebook/directory.py index 0d270802eef..43ac0b5ee08 100644 --- a/gui/wxpython/jupyter_notebook/directory.py +++ b/gui/wxpython/jupyter_notebook/directory.py @@ -31,12 +31,15 @@ class JupyterDirectoryManager: """Manage a Jupyter notebook working directory.""" - def __init__(self, workdir=None, create_template=False): + def __init__( + self, workdir: Path | None = None, create_template: bool = False + ) -> None: """Initialize the Jupyter notebook directory. - :param workdir: Optional custom working directory (Path). If not provided, - the default working directory is used. - :param create_template: If a welcome notebook should be created or not (bool). + :param workdir: Optional custom working directory. If not provided, + the default working directory is used + :param create_template: If a welcome notebook should be created or not + :raises PermissionError: If the working directory is not writable """ self._workdir = workdir or get_default_jupyter_workdir() self._workdir.mkdir(parents=True, exist_ok=True) @@ -50,39 +53,36 @@ def __init__(self, workdir=None, create_template=False): self._create_template = create_template @property - def workdir(self): - """ - :return: path to the working directory (Path). - """ + def workdir(self) -> Path: + """Return path to the working directory.""" return self._workdir @property - def files(self): - """ - :return: List of file paths (list[Path]) - """ + def files(self) -> list[Path]: + """Return list of file paths.""" return self._files - def prepare_files(self): - """ - Populate the list of files in the working directory. - """ + def prepare_files(self) -> None: + """Populate the list of files in the working directory.""" # Find all .ipynb files in the working directory 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() - def import_file(self, source_path, new_name=None, overwrite=False): + def import_file( + self, source_path: Path, new_name: str | None = None, overwrite: bool = False + ) -> Path: """Import an existing notebook file to the working directory. - :param source_path: Path to the source .ipynb file to import (Path). + :param source_path: Path to the source .ipynb file to import :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) + if not provided, original filename is used + :param overwrite: Whether to overwrite an existing file with the same name + :return: Path to the copied file in the working directory :raises FileNotFoundError: If the source_path does not exist :raises FileExistsError: If the target already exists and overwrite=False + :raises ValueError: If source file doesn't have .ipynb extension """ # Validate the source path and ensure it has .ipynb extension source = Path(source_path) @@ -115,12 +115,14 @@ def import_file(self, source_path, new_name=None, overwrite=False): return target_path - def export_file(self, file_name, destination_path, overwrite=False): + def export_file( + self, file_name: str, destination_path: Path, overwrite: bool = False + ) -> None: """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) + :param file_name: Name of the file (e.g., "example.ipynb") + :param destination_path: Full file path or target directory to export the file to + :param overwrite: If True, allows overwriting an existing file at the destination :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 """ @@ -149,15 +151,14 @@ def _create_from_template( template_name: str, target_name: str | None = None, replacements: dict[str, str] | None = None, - ): - """ - Create a notebook from a template and optionally replace placeholders. - - :param template_name: Template filename located in ``template_notebooks``. - :param target_name: Optional target filename for the new notebook. - :param replacements: Optional mapping of placeholder strings to replacement values. - :return: Path to the created notebook file (Path). - :raises FileExistsError: If target file already exists. + ) -> Path: + """Create a notebook from a template and optionally replace placeholders. + + :param template_name: Template filename located in ``template_notebooks`` + :param target_name: Optional target filename for the new notebook + :param replacements: Optional mapping of placeholder strings to replacement values + :return: Path to the created notebook file + :raises FileExistsError: If target file already exists """ # Locate the template file inside the package template_path = Path(__file__).parent / "template_notebooks" / template_name @@ -185,12 +186,13 @@ def _create_from_template( return target_path - def create_welcome_notebook(self, template_name=WELCOME_NOTEBOOK_NAME): - """ - Create a welcome notebook with working directory and mapset path placeholders replaced. + def create_welcome_notebook( + self, template_name: str = WELCOME_NOTEBOOK_NAME + ) -> Path: + """Create a welcome notebook with working directory and mapset path placeholders replaced. - :param template_name: Template filename (default: WELCOME_NOTEBOOK_NAME). - :return: Path to the created notebook (Path). + :param template_name: Template filename + :return: Path to the created notebook """ # Prepare placeholder replacements env = gs.gisenv() @@ -203,19 +205,21 @@ def create_welcome_notebook(self, template_name=WELCOME_NOTEBOOK_NAME): # Create notebook from template return self._create_from_template(template_name, replacements=replacements) - def create_new_notebook(self, new_name, template_name=NEW_NOTEBOOK_TEMPLATE_NAME): - """ - Create a new notebook from a template with only mapset path placeholder replaced. + def create_new_notebook( + self, new_name: str, template_name: str = NEW_NOTEBOOK_TEMPLATE_NAME + ) -> Path: + """Create a new notebook from a template with only mapset path placeholder replaced. - :param new_name: Desired notebook filename. - :param template_name: Template filename (default: NEW_NOTEBOOK_TEMPLATE_NAME). - :return: Path to the created notebook (Path). - :raises ValueError: If name is empty. - :raises FileExistsError: If file already exists. + :param new_name: Desired notebook filename + :param template_name: Template filename + :return: Path to the created notebook + :raises ValueError: If name is empty + :raises FileExistsError: If file already exists """ # Validate notebook name if not new_name: - raise ValueError(_("Notebook name must not be empty")) + msg = "Notebook name must not be empty" + raise ValueError(_(msg)) # Ensure .ipynb extension if not new_name.endswith(".ipynb"): From aae3cea555c1d730c9dab4595d55c3780fb77f64 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 20 Feb 2026 21:12:07 +0100 Subject: [PATCH 47/85] better desc in startup dialog, directory -> storage, changing default storage to project, adding type notations to utils.py and dialogs.py --- gui/wxpython/jupyter_notebook/dialogs.py | 78 ++++++++++++++-------- gui/wxpython/jupyter_notebook/directory.py | 4 +- gui/wxpython/jupyter_notebook/panel.py | 2 +- gui/wxpython/jupyter_notebook/utils.py | 24 +++---- gui/wxpython/lmgr/frame.py | 4 +- gui/wxpython/main_window/frame.py | 8 +-- 6 files changed, 71 insertions(+), 49 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index c13b39e41ce..08380faeb8c 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -17,48 +17,52 @@ from pathlib import Path import wx -from .utils import get_default_jupyter_workdir +from .utils import get_default_jupyter_storage from .directory import WELCOME_NOTEBOOK_NAME class JupyterStartDialog(wx.Dialog): """Dialog for selecting Jupyter startup options.""" - def __init__(self, parent): + def __init__(self, parent: wx.Window) -> None: + """Initialize the Jupyter startup dialog. + + :param parent: Parent window + """ super().__init__(parent, title=_("Start Jupyter Notebook"), size=(500, 300)) - self.default_dir = get_default_jupyter_workdir() + self.default_storage = get_default_jupyter_storage() - self.selected_dir = self.default_dir + self.selected_storage = self.default_storage 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) + # Jupyter storage section + storage_box = wx.StaticBox(self, label=_("Where to Save Notebooks")) + storage_sizer = wx.StaticBoxSizer(storage_box, wx.VERTICAL) self.radio_custom = wx.RadioButton( self, - label=_("Select directory:"), + label=_("Choose custom directory:"), style=wx.RB_GROUP, ) self.radio_custom.SetValue(True) # Default selection - self.dir_picker = wx.DirPickerCtrl( - self, message=_("Choose a working directory"), style=wx.DIRP_USE_TEXTCTRL + self.storage_picker = wx.DirPickerCtrl( + self, message=_("Select notebook directory"), style=wx.DIRP_USE_TEXTCTRL ) - self.dir_picker.Enable(True) # Enabled by default + self.storage_picker.Enable(True) # Enabled by default self.radio_default = wx.RadioButton( self, - label=_("Use default: {}").format(self.default_dir), + label=_("Save in project: {}").format(self.default_storage), ) - dir_sizer.Add(self.radio_custom, 0, wx.ALL, 5) - dir_sizer.Add(self.dir_picker, 0, wx.EXPAND | wx.ALL, 5) - dir_sizer.Add(self.radio_default, 0, wx.ALL, 5) - sizer.Add(dir_sizer, 0, wx.EXPAND | wx.ALL, 10) + storage_sizer.Add(self.radio_custom, 0, wx.ALL, 5) + storage_sizer.Add(self.storage_picker, 0, wx.EXPAND | wx.ALL, 5) + storage_sizer.Add(self.radio_default, 0, wx.ALL, 5) + sizer.Add(storage_sizer, 0, wx.EXPAND | wx.ALL, 10) # Template preference section options_box = wx.StaticBox(self, label=_("Options")) @@ -111,14 +115,20 @@ def __init__(self, parent): 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 OnRadioToggle(self, event: wx.Event) -> None: + """Enable/disable storage picker based on user choice. + + :param event: Radio button event + """ + self.storage_picker.Enable(self.radio_custom.GetValue()) + + def GetValues(self) -> dict[str, Path | bool] | None: + """Return selected storage and template preference. - def GetValues(self): - """Return selected working directory and template preference.""" + :return: Dictionary with 'storage' and 'create_template' keys, or None on error + """ if self.radio_custom.GetValue(): - path = Path(self.dir_picker.GetPath()) + path = Path(self.storage_picker.GetPath()) try: # create directory if missing @@ -137,25 +147,37 @@ def GetValues(self): ) return None - self.selected_dir = path + self.selected_storage = path else: - self.selected_dir = Path(self.default_dir) + self.selected_storage = Path(self.default_storage) return { - "directory": self.selected_dir, + "storage": self.selected_storage, "create_template": self.checkbox_template.GetValue(), } - def OnCancel(self, event): + def OnCancel(self, event: wx.Event) -> None: + """Handle cancel button click. + + :param event: Button click event + """ self.EndModal(wx.ID_CANCEL) - def OnOpenIntegrated(self, event): + def OnOpenIntegrated(self, event: wx.Event) -> None: + """Handle integrated mode button click. + + :param event: Button click event + """ if not self.GetValues(): return self.action = "integrated" self.EndModal(wx.ID_OK) - def OnOpenInBrowser(self, event): + def OnOpenInBrowser(self, event: wx.Event) -> None: + """Handle browser mode button click. + + :param event: Button click event + """ if not self.GetValues(): return self.action = "browser" diff --git a/gui/wxpython/jupyter_notebook/directory.py b/gui/wxpython/jupyter_notebook/directory.py index 43ac0b5ee08..88b544346ae 100644 --- a/gui/wxpython/jupyter_notebook/directory.py +++ b/gui/wxpython/jupyter_notebook/directory.py @@ -20,7 +20,7 @@ from pathlib import Path import grass.script as gs -from .utils import get_default_jupyter_workdir +from .utils import get_default_jupyter_storage # Template notebook filenames @@ -41,7 +41,7 @@ def __init__( :param create_template: If a welcome notebook should be created or not :raises PermissionError: If the working directory is not writable """ - self._workdir = workdir or get_default_jupyter_workdir() + self._workdir = workdir or get_default_jupyter_storage() self._workdir.mkdir(parents=True, exist_ok=True) if not os.access(self._workdir, os.W_OK): diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 697f9d06496..5e0e4b40fc7 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -448,7 +448,7 @@ def SetUpEnvironment(self): # Update UI with server info self.url_text.SetLabel(_("Server URL: {}").format(self.env.server_url)) self.pid_text.SetLabel(_("Process ID: {}").format(self.env.pid)) - self.dir_text.SetLabel(_("Working directory: {}").format(self.env.workdir)) + self.dir_text.SetLabel(_("Notebook storage {}").format(self.env.workdir)) self.Layout() diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index 440b80d4c86..769e718dd15 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -6,7 +6,7 @@ Functions: - `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system and functional. - `is_wx_html2_available()`: Check if wx.html2 module is available. -- `get_default_jupyter_workdir()`: Return the default working directory for Jupyter notebooks. +- `get_default_jupyter_storage()`: Return the default storage for Jupyter notebooks. (C) 2026 by the GRASS Development Team @@ -23,10 +23,10 @@ import grass.script as gs -def is_jupyter_installed(): +def is_jupyter_installed() -> bool: """Check if Jupyter Notebook is installed and functional. - :return: True if Jupyter Notebook is installed and available, False otherwise. + :return: True if Jupyter Notebook is installed and available, False otherwise """ # Check if 'jupyter' CLI exists jupyter_cmd = shutil.which("jupyter") @@ -46,14 +46,14 @@ def is_jupyter_installed(): return False -def is_wx_html2_available(): +def is_wx_html2_available() -> bool: """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. - :return: True if wxPython/wxWidgets html2 module is available, False otherwise. + :return: True if wxPython/wxWidgets html2 module is available, False otherwise """ try: __import__("wx.html2") @@ -62,12 +62,12 @@ def is_wx_html2_available(): return False -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) +def get_default_jupyter_storage() -> Path: + """Return the default jupyter storage for Jupyter notebooks associated + with the current GRASS project. + + :return: Path to the default jupyter storage """ env = gs.gisenv() - mapset_path = Path(env["GISDBASE"]) / env["LOCATION_NAME"] / env["MAPSET"] - return mapset_path / "notebooks" + project_path = Path(env["GISDBASE"]) / env["LOCATION_NAME"] + return project_path / "notebooks" diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index cf8ea1dce2c..6ae08ead4f4 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -815,7 +815,7 @@ def OnJupyterNotebook(self, event=None): if not values: return - workdir = values["directory"] + storage = values["storage"] create_template = values["create_template"] if action == "integrated": @@ -843,7 +843,7 @@ def OnJupyterNotebook(self, event=None): parent=self, giface=self._giface, action=action, - workdir=workdir, + workdir=storage, create_template=create_template, ) frame.CentreOnParent() diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 5fb133254fa..a1f53f76fce 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -943,7 +943,7 @@ def OnJupyterNotebook(self, event=None, cmd=None): if not values: return - workdir = values["directory"] + storage = values["storage"] create_template = values["create_template"] if action == "integrated": @@ -972,7 +972,7 @@ def OnJupyterNotebook(self, event=None, cmd=None): giface=self._giface, statusbar=self.statusbar, dockable=True, - workdir=workdir, + workdir=storage, create_template=create_template, ) panel.SetUpPage(self, self.mainnotebook) @@ -1017,7 +1017,7 @@ def OnJupyterNotebook(self, event=None, cmd=None): giface=self._giface, statusbar=self.statusbar, dockable=True, - workdir=workdir, + workdir=storage, create_template=create_template, ) panel.SetUpPage(self, self.mainnotebook) @@ -1026,7 +1026,7 @@ def OnJupyterNotebook(self, event=None, cmd=None): self.mainnotebook.AddPage( panel, _("Jupyter Browser - {}").format( - workdir.name if workdir else "default" + storage.name if storage else "default" ), ) self.mainnotebook.SetSelection(self.mainnotebook.GetPageCount() - 1) From 8a7a560268af53661a3cf38a0cd51a9110d41cdd Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 20 Feb 2026 21:22:41 +0100 Subject: [PATCH 48/85] get rid of the word default and replace it by the word project --- gui/wxpython/jupyter_notebook/dialogs.py | 16 +++++++--------- gui/wxpython/jupyter_notebook/directory.py | 4 ++-- gui/wxpython/jupyter_notebook/utils.py | 8 ++++---- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index 08380faeb8c..dcd18a6ce69 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -17,7 +17,7 @@ from pathlib import Path import wx -from .utils import get_default_jupyter_storage +from .utils import get_project_jupyter_storage from .directory import WELCOME_NOTEBOOK_NAME @@ -31,9 +31,7 @@ def __init__(self, parent: wx.Window) -> None: """ super().__init__(parent, title=_("Start Jupyter Notebook"), size=(500, 300)) - self.default_storage = get_default_jupyter_storage() - - self.selected_storage = self.default_storage + self.project_storage = get_project_jupyter_storage() self.create_template = True sizer = wx.BoxSizer(wx.VERTICAL) @@ -54,14 +52,14 @@ def __init__(self, parent: wx.Window) -> None: ) self.storage_picker.Enable(True) # Enabled by default - self.radio_default = wx.RadioButton( + self.radio_project = wx.RadioButton( self, - label=_("Save in project: {}").format(self.default_storage), + label=_("Save in project: {}").format(self.project_storage), ) storage_sizer.Add(self.radio_custom, 0, wx.ALL, 5) storage_sizer.Add(self.storage_picker, 0, wx.EXPAND | wx.ALL, 5) - storage_sizer.Add(self.radio_default, 0, wx.ALL, 5) + storage_sizer.Add(self.radio_project, 0, wx.ALL, 5) sizer.Add(storage_sizer, 0, wx.EXPAND | wx.ALL, 10) # Template preference section @@ -102,7 +100,7 @@ def __init__(self, parent: wx.Window) -> None: sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 10) # Bind events - self.radio_default.Bind(wx.EVT_RADIOBUTTON, self.OnRadioToggle) + self.radio_project.Bind(wx.EVT_RADIOBUTTON, self.OnRadioToggle) self.radio_custom.Bind(wx.EVT_RADIOBUTTON, self.OnRadioToggle) btn_cancel.Bind(wx.EVT_BUTTON, self.OnCancel) @@ -149,7 +147,7 @@ def GetValues(self) -> dict[str, Path | bool] | None: self.selected_storage = path else: - self.selected_storage = Path(self.default_storage) + self.selected_storage = Path(self.project_storage) return { "storage": self.selected_storage, diff --git a/gui/wxpython/jupyter_notebook/directory.py b/gui/wxpython/jupyter_notebook/directory.py index 88b544346ae..5212d4b7030 100644 --- a/gui/wxpython/jupyter_notebook/directory.py +++ b/gui/wxpython/jupyter_notebook/directory.py @@ -20,7 +20,7 @@ from pathlib import Path import grass.script as gs -from .utils import get_default_jupyter_storage +from .utils import get_project_jupyter_storage # Template notebook filenames @@ -41,7 +41,7 @@ def __init__( :param create_template: If a welcome notebook should be created or not :raises PermissionError: If the working directory is not writable """ - self._workdir = workdir or get_default_jupyter_storage() + self._workdir = workdir or get_project_jupyter_storage() self._workdir.mkdir(parents=True, exist_ok=True) if not os.access(self._workdir, os.W_OK): diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index 769e718dd15..31af848122b 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -6,7 +6,7 @@ Functions: - `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system and functional. - `is_wx_html2_available()`: Check if wx.html2 module is available. -- `get_default_jupyter_storage()`: Return the default storage for Jupyter notebooks. +- `get_project_jupyter_storage()`: Return the storage for Jupyter notebooks associated with the current GRASS project. (C) 2026 by the GRASS Development Team @@ -62,11 +62,11 @@ def is_wx_html2_available() -> bool: return False -def get_default_jupyter_storage() -> Path: - """Return the default jupyter storage for Jupyter notebooks associated +def get_project_jupyter_storage() -> Path: + """Return the storage for Jupyter notebooks associated with the current GRASS project. - :return: Path to the default jupyter storage + :return: Path to the project jupyter storage """ env = gs.gisenv() project_path = Path(env["GISDBASE"]) / env["LOCATION_NAME"] From 2cff2e66dd88a0a242d728f0d9080ec1c5482fca Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 20 Feb 2026 21:49:53 +0100 Subject: [PATCH 49/85] workdir -> storage --- gui/wxpython/jupyter_notebook/directory.py | 63 +++++++++---------- gui/wxpython/jupyter_notebook/environment.py | 16 ++--- gui/wxpython/jupyter_notebook/frame.py | 6 +- gui/wxpython/jupyter_notebook/panel.py | 28 ++++++--- gui/wxpython/jupyter_notebook/server.py | 20 +++--- .../template_notebooks/welcome.ipynb | 2 +- gui/wxpython/lmgr/frame.py | 2 +- gui/wxpython/main_window/frame.py | 4 +- 8 files changed, 76 insertions(+), 65 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/directory.py b/gui/wxpython/jupyter_notebook/directory.py index 5212d4b7030..9b3fd80d053 100644 --- a/gui/wxpython/jupyter_notebook/directory.py +++ b/gui/wxpython/jupyter_notebook/directory.py @@ -5,7 +5,7 @@ the current working directory. Classes: - - directory::JupyterDirectoryManager + - directory::JupyterStorageManager (C) 2026 by the GRASS Development Team @@ -28,44 +28,43 @@ NEW_NOTEBOOK_TEMPLATE_NAME = "new.ipynb" -class JupyterDirectoryManager: - """Manage a Jupyter notebook working directory.""" +class JupyterStorageManager: + """Manage Jupyter notebook storage.""" def __init__( - self, workdir: Path | None = None, create_template: bool = False + self, storage: Path | None = None, create_template: bool = False ) -> None: - """Initialize the Jupyter notebook directory. + """Initialize the Jupyter notebook storage. - :param workdir: Optional custom working directory. If not provided, - the default working directory is used + :param storage: Optional custom storage path. If not provided, + the default project storage is used :param create_template: If a welcome notebook should be created or not - :raises PermissionError: If the working directory is not writable + :raises PermissionError: If the storage is not writable """ - self._workdir = workdir or get_project_jupyter_storage() - self._workdir.mkdir(parents=True, exist_ok=True) + self._storage = storage or get_project_jupyter_storage() + self._storage.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) - ) + if not os.access(self._storage, os.W_OK): + msg = "Cannot write to the storage: {}" + raise PermissionError(_(msg).format(self._storage)) self._files = [] self._create_template = create_template @property - def workdir(self) -> Path: - """Return path to the working directory.""" - return self._workdir + def storage(self) -> Path: + """Return path to the storage.""" + return self._storage @property def files(self) -> list[Path]: - """Return list of file paths.""" + """Return list of notebook files.""" return self._files def prepare_files(self) -> None: - """Populate the list of files in the working directory.""" - # Find all .ipynb files in the working directory - self._files = [f for f in self._workdir.iterdir() if f.suffix == ".ipynb"] + """Populate the list of files in the storage.""" + # Find all .ipynb files in the storage + self._files = [f for f in self._storage.iterdir() if f.suffix == ".ipynb"] if self._create_template and not self._files: self.create_welcome_notebook() @@ -73,13 +72,13 @@ def prepare_files(self) -> None: def import_file( self, source_path: Path, new_name: str | None = None, overwrite: bool = False ) -> Path: - """Import an existing notebook file to the working directory. + """Import an existing notebook file to the storage. :param source_path: Path to the source .ipynb file to import :param new_name: New name for the imported file (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 file in the working directory + :return: Path to the copied file in the storage :raises FileNotFoundError: If the source_path does not exist :raises FileExistsError: If the target already exists and overwrite=False :raises ValueError: If source file doesn't have .ipynb extension @@ -93,13 +92,13 @@ def import_file( _("Source file must have .ipynb extension: {}").format(source) ) - # Ensure the working directory exists + # Determine target name 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 + # Create the target path in the storage + target_path = self._storage / target_name # Check if the target file already exists if target_path.exists() and not overwrite: @@ -118,16 +117,16 @@ def import_file( def export_file( self, file_name: str, destination_path: Path, overwrite: bool = False ) -> None: - """Export a file from the working directory to an external location. + """Export a file from the storage to an external location. :param file_name: Name of the file (e.g., "example.ipynb") - :param destination_path: Full file path or target directory to export the file to + :param destination_path: Full file path or target location to export the file to :param overwrite: If True, allows overwriting an existing file at the destination :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 + source_path = self._storage / file_name if not source_path.exists() or source_path.suffix != ".ipynb": raise FileNotFoundError(_("File not found: {}").format(source_path)) @@ -167,7 +166,7 @@ def _create_from_template( if target_name is None: target_path = self.import_file(template_path) else: - target_path = self.workdir / target_name + target_path = self.storage / target_name # Prevent accidental overwrite if target_path.exists(): @@ -189,7 +188,7 @@ def _create_from_template( def create_welcome_notebook( self, template_name: str = WELCOME_NOTEBOOK_NAME ) -> Path: - """Create a welcome notebook with working directory and mapset path placeholders replaced. + """Create a welcome notebook with storage and mapset path placeholders replaced. :param template_name: Template filename :return: Path to the created notebook @@ -198,7 +197,7 @@ def create_welcome_notebook( env = gs.gisenv() mapset_path = Path(env["GISDBASE"], env["LOCATION_NAME"], env["MAPSET"]) replacements = { - "${WORKING_DIR}": str(self._workdir).replace("\\", "/"), + "${STORAGE_PATH}": str(self._storage).replace("\\", "/"), "${MAPSET_PATH}": str(mapset_path).replace("\\", "/"), } diff --git a/gui/wxpython/jupyter_notebook/environment.py b/gui/wxpython/jupyter_notebook/environment.py index 80aa022e5f2..f35a94a99a1 100644 --- a/gui/wxpython/jupyter_notebook/environment.py +++ b/gui/wxpython/jupyter_notebook/environment.py @@ -15,20 +15,20 @@ @author Linda Karlovska """ -from .directory import JupyterDirectoryManager +from .directory import JupyterStorageManager from .server import JupyterServerInstance, JupyterServerRegistry class JupyterEnvironment: """Orchestrates directory manager and Jupyter server lifecycle. - :param workdir: Directory for notebooks + :param storage: Directory for notebooks :param create_template: Whether to create template notebooks """ - def __init__(self, workdir, create_template): - self.directory = JupyterDirectoryManager(workdir, create_template) - self.server = JupyterServerInstance(workdir) + def __init__(self, storage, create_template): + self.directory = JupyterStorageManager(storage, create_template) + self.server = JupyterServerInstance(storage) def setup(self): """Prepare files and start server.""" @@ -59,6 +59,6 @@ def pid(self): return self.server.pid if self.server else None @property - def workdir(self): - """Get working directory.""" - return self.directory.workdir if self.directory else None + def storage(self): + """Get Jupyter notebook storage.""" + return self.directory.storage if self.directory else None diff --git a/gui/wxpython/jupyter_notebook/frame.py b/gui/wxpython/jupyter_notebook/frame.py index 70e625aba14..61d47a51753 100644 --- a/gui/wxpython/jupyter_notebook/frame.py +++ b/gui/wxpython/jupyter_notebook/frame.py @@ -33,7 +33,7 @@ def __init__( parent, giface, action="integrated", - workdir=None, + storage=None, create_template=False, id=wx.ID_ANY, title=_("Jupyter Notebook"), @@ -55,7 +55,7 @@ def __init__( self.panel = JupyterPanel( parent=self, giface=giface, - workdir=workdir, + storage=storage, create_template=create_template, statusbar=self.statusbar, dockable=False, @@ -95,7 +95,7 @@ def __init__( self.panel = JupyterBrowserPanel( parent=self, giface=giface, - workdir=workdir, + storage=storage, create_template=create_template, statusbar=self.statusbar, dockable=False, diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 5e0e4b40fc7..3b9ce9680d9 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -48,7 +48,7 @@ def __init__( title=_("Jupyter Notebook"), statusbar=None, dockable=False, - workdir=None, + storage=None, create_template=False, **kwargs, ): @@ -62,7 +62,7 @@ def __init__( # Create environment in integrated mode (requires wx.html2) self.env = JupyterEnvironment( - workdir=workdir, + storage=storage, create_template=create_template, ) @@ -116,10 +116,12 @@ def SetUpEnvironment(self): self.aui_notebook.AddPage(url=url, title=fname.name) self.SetStatusText( - _("Jupyter server started at {url} (PID: {pid}), directory: {dir}").format( + _( + "Jupyter server running at {url} (PID: {pid}) | Storage: {storage}" + ).format( url=self.env.server_url, pid=self.env.pid, - dir=self.env.workdir, + storage=self.env.storage, ) ) return True @@ -200,7 +202,7 @@ def OnImport(self, event=None): source_path = Path(dlg.GetPath()) file_name = source_path.name - target_path = self.env.workdir / file_name + target_path = self.env.storage / file_name # File is already in the working directory if source_path.resolve() == target_path.resolve(): @@ -359,7 +361,7 @@ def __init__( id=wx.ID_ANY, statusbar=None, dockable=False, - workdir=None, + storage=None, create_template=False, **kwargs, ): @@ -371,7 +373,7 @@ def __init__( self.statusbar = statusbar self.SetName("JupyterBrowser") - self.env = JupyterEnvironment(workdir=workdir, create_template=create_template) + self.env = JupyterEnvironment(storage=storage, create_template=create_template) self._layout() @@ -448,10 +450,20 @@ def SetUpEnvironment(self): # Update UI with server info self.url_text.SetLabel(_("Server URL: {}").format(self.env.server_url)) self.pid_text.SetLabel(_("Process ID: {}").format(self.env.pid)) - self.dir_text.SetLabel(_("Notebook storage {}").format(self.env.workdir)) + self.dir_text.SetLabel(_("Notebook Storage: {}").format(self.env.storage)) self.Layout() + self.SetStatusText( + _( + "Jupyter server running at {url} (PID: {pid}) | Storage: {storage}" + ).format( + url=self.env.server_url, + pid=self.env.pid, + storage=self.env.storage, + ) + ) + # Open in default browser webbrowser.open(self.env.server_url) diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index 955ce4b5ec4..94b1b858af7 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -67,12 +67,12 @@ def handle_signal(signum, frame): class JupyterServerInstance: """Manage the lifecycle of a Jupyter server instance.""" - def __init__(self, workdir): + def __init__(self, storage): """Initialize Jupyter server instance. - :param workdir: Working directory for the Jupyter server (str). + :param storage: Storage of notebooks for the Jupyter server (str). """ - self.workdir = workdir + self.storage = storage self.proc = None self._reset_state() @@ -130,20 +130,20 @@ def is_server_running(self, retries=50, delay=0.2): def start_server(self): """ - Start a Jupyter server in the working directory on a free port. + Start a Jupyter server in the notebook storage on a free port. - :raises RuntimeError: If Jupyter is not installed, the working directory is invalid, + :raises RuntimeError: If Jupyter is not installed, the notebook storage is invalid, or the server fails to start. """ # Validation checks - if not pathlib.Path(self.workdir).is_dir(): + if not pathlib.Path(self.storage).is_dir(): raise RuntimeError( - _("Working directory does not exist: {}").format(self.workdir) + _("Notebook storage does not exist: {}").format(self.storage) ) - if not os.access(self.workdir, os.W_OK): + if not os.access(self.storage, os.W_OK): raise RuntimeError( - _("Working directory is not writable: {}").format(self.workdir) + _("Notebook storage is not writable: {}").format(self.storage) ) if self.is_alive(): @@ -175,7 +175,7 @@ def start_server(self): "--port", str(self.port), "--notebook-dir", - self.workdir, + self.storage, ] # Start server diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index 4ae97a6dea3..475bd0e6a92 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -7,7 +7,7 @@ "source": [ "# Welcome to GRASS Jupyter environment\n", "\n", - "Jupyter server for this environment was started in the directory `${WORKING_DIR}`.\n", + "Jupyter server for this environment was started in the directory `${STORAGE_PATH}`.\n", "\n", "---\n", "This notebook is ready to use with GRASS.\n", diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index 6ae08ead4f4..3fd5c76fc07 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -843,7 +843,7 @@ def OnJupyterNotebook(self, event=None): parent=self, giface=self._giface, action=action, - workdir=storage, + storage=storage, create_template=create_template, ) frame.CentreOnParent() diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index a1f53f76fce..ac85c33865a 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -972,7 +972,7 @@ def OnJupyterNotebook(self, event=None, cmd=None): giface=self._giface, statusbar=self.statusbar, dockable=True, - workdir=storage, + storage=storage, create_template=create_template, ) panel.SetUpPage(self, self.mainnotebook) @@ -1017,7 +1017,7 @@ def OnJupyterNotebook(self, event=None, cmd=None): giface=self._giface, statusbar=self.statusbar, dockable=True, - workdir=storage, + storage=storage, create_template=create_template, ) panel.SetUpPage(self, self.mainnotebook) From 8778b3baa59c2cc4f34e535496f2669a4d5ce9c1 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 20 Feb 2026 22:06:33 +0100 Subject: [PATCH 50/85] directory.py -> storage.py --- gui/wxpython/jupyter_notebook/dialogs.py | 2 +- gui/wxpython/jupyter_notebook/environment.py | 19 ++++++++++----- gui/wxpython/jupyter_notebook/panel.py | 24 +++++++++---------- .../{directory.py => storage.py} | 8 +++---- 4 files changed, 30 insertions(+), 23 deletions(-) rename gui/wxpython/jupyter_notebook/{directory.py => storage.py} (98%) diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index dcd18a6ce69..b7f87dc97af 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -18,7 +18,7 @@ import wx from .utils import get_project_jupyter_storage -from .directory import WELCOME_NOTEBOOK_NAME +from .storage import WELCOME_NOTEBOOK_NAME class JupyterStartDialog(wx.Dialog): diff --git a/gui/wxpython/jupyter_notebook/environment.py b/gui/wxpython/jupyter_notebook/environment.py index f35a94a99a1..2fa4ee24b9c 100644 --- a/gui/wxpython/jupyter_notebook/environment.py +++ b/gui/wxpython/jupyter_notebook/environment.py @@ -15,25 +15,27 @@ @author Linda Karlovska """ -from .directory import JupyterStorageManager +from pathlib import Path + +from .storage import JupyterStorageManager from .server import JupyterServerInstance, JupyterServerRegistry class JupyterEnvironment: - """Orchestrates directory manager and Jupyter server lifecycle. + """Orchestrates storage manager and Jupyter server lifecycle. - :param storage: Directory for notebooks + :param storage: Storage for notebooks :param create_template: Whether to create template notebooks """ def __init__(self, storage, create_template): - self.directory = JupyterStorageManager(storage, create_template) + self.storage_manager = JupyterStorageManager(storage, create_template) self.server = JupyterServerInstance(storage) def setup(self): """Prepare files and start server.""" # Prepare files - self.directory.prepare_files() + self.storage_manager.prepare_files() # Start server self.server.start_server() @@ -61,4 +63,9 @@ def pid(self): @property def storage(self): """Get Jupyter notebook storage.""" - return self.directory.storage if self.directory else None + return self.storage_manager.storage if self.storage_manager else None + + @property + def files(self) -> list[Path]: + """Return list of notebook files.""" + return self.storage_manager.files if self.storage_manager else [] diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 3b9ce9680d9..97d6dbe98b0 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -84,7 +84,7 @@ def _layout(self): def SetUpEnvironment(self): """Setup integrated Jupyter notebook environment and load initial notebooks. - - Prepares notebook files in the working directory + - Prepares notebook files in the notebook storage - Starts the Jupyter server - Loads all existing notebooks as tabs in the embedded browser @@ -102,7 +102,7 @@ def SetUpEnvironment(self): return False # Load notebook tabs in embedded AUI notebook - for fname in self.env.directory.files: + for fname in self.env.files: try: url = self.env.server.get_url(fname.name) except RuntimeError as e: @@ -166,13 +166,13 @@ def OpenOrSwitch(self, file_name): self.SetStatusText(_("File '{}' opened.").format(file_name), 0) def Import(self, source_path, new_name=None): - """Import a .ipynb file into working directory and open it in a new tab. + """Import a .ipynb file into notebook storage and open it in a new tab. :param source_path: Path to the source .ipynb file to be imported :param new_name: Optional new name for the imported file """ try: - path = self.env.directory.import_file(source_path, new_name=new_name) + path = self.env.storage_manager.import_file(source_path, new_name=new_name) self.Open(path.name) self.SetStatusText(_("File '{}' imported and opened.").format(path.name), 0) except (FileNotFoundError, ValueError, FileExistsError) as e: @@ -183,10 +183,10 @@ def Import(self, source_path, new_name=None): ) def OnImport(self, event=None): - """Import an existing Jupyter notebook file into the working directory. + """Import an existing Jupyter notebook file into notebook storage. Prompts user to select a .ipynb file: - - If the file is already in the notebook directory: switch to it or open it + - If the file is already in the notebook storage: switch to it or open it - If the file is from elsewhere: import and open it (prompt for new name if needed) """ # Open file dialog to select an existing Jupyter notebook file @@ -204,19 +204,19 @@ def OnImport(self, event=None): file_name = source_path.name target_path = self.env.storage / file_name - # File is already in the working directory + # File is already in the storage if source_path.resolve() == target_path.resolve(): self.OpenOrSwitch(file_name) return - # File is from outside the working directory + # File is from outside the storage 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:" + "File '{}' already exists in notebook storage.\nPlease enter a new name:" ).format(file_name), caption=_("Rename File"), value="{}_copy".format(file_name.removesuffix(".ipynb")), @@ -255,7 +255,7 @@ def OnExport(self, event=None): destination_path = Path(dlg.GetPath()) try: - self.env.directory.export_file( + self.env.storage_manager.export_file( file_name, destination_path, overwrite=True ) self.SetStatusText( @@ -269,7 +269,7 @@ def OnExport(self, event=None): ) def OnCreate(self, event=None): - """Create a new empty Jupyter notebook in the working directory and open it.""" + """Create a new empty Jupyter notebook in the notebook storage and open it.""" with wx.TextEntryDialog( self, message=_("Enter a name for the new notebook:"), @@ -284,7 +284,7 @@ def OnCreate(self, event=None): return try: - path = self.env.directory.create_new_notebook(new_name=name) + path = self.env.storage_manager.create_new_notebook(new_name=name) except (FileExistsError, ValueError) as e: wx.MessageBox( _("Failed to create notebook:\n{}").format(e), diff --git a/gui/wxpython/jupyter_notebook/directory.py b/gui/wxpython/jupyter_notebook/storage.py similarity index 98% rename from gui/wxpython/jupyter_notebook/directory.py rename to gui/wxpython/jupyter_notebook/storage.py index 9b3fd80d053..dc68072a36e 100644 --- a/gui/wxpython/jupyter_notebook/directory.py +++ b/gui/wxpython/jupyter_notebook/storage.py @@ -1,11 +1,11 @@ """ -@package jupyter_notebook.directory +@package jupyter_notebook.storage @brief Simple interface for working with Jupyter Notebook files stored within -the current working directory. +the current storage. Classes: - - directory::JupyterStorageManager + - storage::JupyterStorageManager (C) 2026 by the GRASS Development Team @@ -37,7 +37,7 @@ def __init__( """Initialize the Jupyter notebook storage. :param storage: Optional custom storage path. If not provided, - the default project storage is used + the project storage is used :param create_template: If a welcome notebook should be created or not :raises PermissionError: If the storage is not writable """ From d7ae46391172769933167f0496e44fece047309d Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sun, 22 Feb 2026 21:12:47 +0100 Subject: [PATCH 51/85] type notations and smaller refactoring, creating some global variables --- gui/wxpython/jupyter_notebook/dialogs.py | 3 +- gui/wxpython/jupyter_notebook/environment.py | 43 ++++-- gui/wxpython/jupyter_notebook/frame.py | 34 +++-- gui/wxpython/jupyter_notebook/notebook.py | 35 +++-- gui/wxpython/jupyter_notebook/panel.py | 149 +++++++++++++------ gui/wxpython/jupyter_notebook/server.py | 86 ++++++----- gui/wxpython/jupyter_notebook/storage.py | 27 +++- gui/wxpython/jupyter_notebook/toolbars.py | 16 +- gui/wxpython/jupyter_notebook/utils.py | 15 -- gui/wxpython/main_window/frame.py | 1 - 10 files changed, 256 insertions(+), 153 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index b7f87dc97af..3c903ec487b 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -17,8 +17,7 @@ from pathlib import Path import wx -from .utils import get_project_jupyter_storage -from .storage import WELCOME_NOTEBOOK_NAME +from .storage import WELCOME_NOTEBOOK_NAME, get_project_jupyter_storage class JupyterStartDialog(wx.Dialog): diff --git a/gui/wxpython/jupyter_notebook/environment.py b/gui/wxpython/jupyter_notebook/environment.py index 2fa4ee24b9c..6f24c710915 100644 --- a/gui/wxpython/jupyter_notebook/environment.py +++ b/gui/wxpython/jupyter_notebook/environment.py @@ -22,17 +22,18 @@ class JupyterEnvironment: - """Orchestrates storage manager and Jupyter server lifecycle. + """Orchestrates storage manager and Jupyter server lifecycle.""" - :param storage: Storage for notebooks - :param create_template: Whether to create template notebooks - """ + def __init__(self, storage: Path | None, create_template: bool) -> None: + """Initialize Jupyter environment. - def __init__(self, storage, create_template): + :param storage: Storage path for notebooks + :param create_template: Whether to create template notebooks + """ self.storage_manager = JupyterStorageManager(storage, create_template) self.server = JupyterServerInstance(storage) - def setup(self): + def setup(self) -> None: """Prepare files and start server.""" # Prepare files self.storage_manager.prepare_files() @@ -40,32 +41,44 @@ def setup(self): # Start server self.server.start_server() - def stop(self): + def stop(self) -> None: """Stop server and unregister it.""" self.server.stop_server() JupyterServerRegistry.get().unregister(self.server) @classmethod - def stop_all(cls): + def stop_all(cls) -> None: """Stop all running Jupyter servers and unregister them.""" JupyterServerRegistry.get().stop_all_servers() @property - def server_url(self): - """Get server URL.""" + def server_url(self) -> str | None: + """Get server URL. + + :return: Server URL or None if server is not available + """ return self.server.server_url if self.server else None @property - def pid(self): - """Get server process ID.""" + def pid(self) -> int | None: + """Get server process ID. + + :return: Process ID or None if server is not running + """ return self.server.pid if self.server else None @property - def storage(self): - """Get Jupyter notebook storage.""" + def storage(self) -> Path | None: + """Get Jupyter notebook storage path. + + :return: Storage path or None if storage manager is not available + """ return self.storage_manager.storage if self.storage_manager else None @property def files(self) -> list[Path]: - """Return list of notebook files.""" + """Return list of notebook files. + + :return: List of notebook file paths + """ return self.storage_manager.files if self.storage_manager else [] diff --git a/gui/wxpython/jupyter_notebook/frame.py b/gui/wxpython/jupyter_notebook/frame.py index 61d47a51753..9235a4a6af7 100644 --- a/gui/wxpython/jupyter_notebook/frame.py +++ b/gui/wxpython/jupyter_notebook/frame.py @@ -30,15 +30,26 @@ class JupyterFrame(wx.Frame): def __init__( self, - parent, + parent: wx.Window, giface, - action="integrated", - storage=None, - create_template=False, - id=wx.ID_ANY, - title=_("Jupyter Notebook"), + action: str = "integrated", + storage: Path | None = None, + create_template: bool = False, + id: int = wx.ID_ANY, + title: str = _("Jupyter Notebook"), **kwargs, - ): + ) -> None: + """Initialize Jupyter frame. + + :param parent: Parent window + :param giface: GRASS interface + :param action: Mode - "integrated" or "browser" + :param storage: Storage path for notebooks + :param create_template: Whether to create template notebooks + :param id: Window ID + :param title: Frame title + :param kwargs: Additional arguments passed to wx.Frame + """ super().__init__(parent=parent, id=id, title=title, **kwargs) self.SetName("JupyterFrame") @@ -110,7 +121,8 @@ def __init__( self._layout() - def _layout(self): + def _layout(self) -> None: + """Setup frame layout and size.""" if self.panel: sizer = wx.BoxSizer(wx.VERTICAL) sizer.Add(self.panel, 1, wx.EXPAND) @@ -120,7 +132,11 @@ def _layout(self): self.Bind(wx.EVT_CLOSE, self.OnCloseWindow) - def OnCloseWindow(self, event): + def OnCloseWindow(self, event: wx.CloseEvent) -> None: + """Handle window close event. + + :param event: Close event + """ if self.panel and hasattr(self.panel, "OnCloseWindow"): self.panel.OnCloseWindow(event) diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py index 66a5355fb0e..c44f6fb472b 100644 --- a/gui/wxpython/jupyter_notebook/notebook.py +++ b/gui/wxpython/jupyter_notebook/notebook.py @@ -39,19 +39,19 @@ class JupyterAuiNotebook(aui.AuiNotebook): def __init__( self, - parent, - agwStyle=aui.AUI_NB_DEFAULT_STYLE + parent: wx.Window, + agwStyle: int = 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. + ) -> None: + """Initialize AUI notebook for Jupyter with WebView tabs. + :param parent: Parent window + :param agwStyle: AUI notebook style flags :raises ImportError: If wx.html2 is not available """ - if not WX_HTML2_AVAILABLE: msg = "wx.html2 is not available" raise ImportError(msg) @@ -64,9 +64,8 @@ def __init__( self.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self.OnPageClose) - def _hide_top_ui(self, event): - """ - Inject CSS via JS into the Jupyter page to hide top UI elements. + def _hide_top_ui(self, event: html.WebViewEvent) -> None: + """Inject CSS via JS into the Jupyter page to hide top UI elements. Works for both: - Jupyter Notebook 6 and older (classic interface) @@ -76,6 +75,8 @@ def _hide_top_ui(self, event): Some UI elements may be created dynamically after page load, so the script ensures the CSS/JS is applied once elements exist. Duplicate injection is prevented by checking for a unique style ID. + + :param event: WebView loaded event """ webview = event.GetEventObject() @@ -112,12 +113,11 @@ def _hide_top_ui(self, event): 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). + def AddPage(self, url: str, title: str) -> None: + """Add a new AUI notebook page with a Jupyter WebView. + :param url: URL of the Jupyter file + :param title: Tab title :raises NotImplementedError: If wx.html2.WebView is not functional on this system """ browser = html.WebView.New(self) @@ -125,8 +125,11 @@ def AddPage(self, url, title): wx.CallAfter(browser.Bind, html.EVT_WEBVIEW_LOADED, self._hide_top_ui) super().AddPage(browser, title) - def OnPageClose(self, event): - """Close the aui notebook page with confirmation dialog.""" + def OnPageClose(self, event: aui.AuiNotebookEvent) -> None: + """Close the AUI notebook page with confirmation dialog. + + :param event: Notebook page close event + """ index = event.GetSelection() title = self.GetPageText(index) diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 97d6dbe98b0..129f8672b74 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -42,16 +42,28 @@ class JupyterPanel(wx.Panel, MainPageBase): def __init__( self, - parent, + parent: wx.Window, giface, - id=wx.ID_ANY, - title=_("Jupyter Notebook"), - statusbar=None, - dockable=False, - storage=None, - create_template=False, + id: int = wx.ID_ANY, + title: str = _("Jupyter Notebook"), + statusbar: wx.StatusBar | None = None, + dockable: bool = False, + storage: Path | None = None, + create_template: bool = False, **kwargs, - ): + ) -> None: + """Initialize Jupyter panel with integrated notebook interface. + + :param parent: Parent window + :param giface: GRASS interface + :param id: Window ID + :param title: Panel title + :param statusbar: Optional status bar for messages + :param dockable: Whether the panel can be docked + :param storage: Storage path for notebooks + :param create_template: Whether to create template notebooks + :param kwargs: Additional arguments passed to wx.Panel + """ super().__init__(parent=parent, id=id, **kwargs) MainPageBase.__init__(self, dockable) @@ -71,7 +83,8 @@ def __init__( self._layout() - def _layout(self): + def _layout(self) -> None: + """Setup panel 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) @@ -81,14 +94,14 @@ def _layout(self): sizer.Fit(self) self.Layout() - def SetUpEnvironment(self): + def SetUpEnvironment(self) -> bool: """Setup integrated Jupyter notebook environment and load initial notebooks. - Prepares notebook files in the notebook storage - Starts the Jupyter server - Loads all existing notebooks as tabs in the embedded browser - :return: bool: True if setup was successful, False otherwise + :return: True if setup was successful, False otherwise """ try: # Prepare files and start server @@ -126,7 +139,7 @@ def SetUpEnvironment(self): ) return True - def Switch(self, file_name): + def Switch(self, file_name: str) -> bool: """Switch to existing notebook tab. :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') @@ -138,7 +151,7 @@ def Switch(self, file_name): return True return False - def Open(self, file_name): + def Open(self, file_name: str) -> None: """Open a Jupyter notebook in a new tab and switch to it. :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') @@ -154,7 +167,7 @@ def Open(self, file_name): wx.ICON_ERROR, ) - def OpenOrSwitch(self, file_name): + def OpenOrSwitch(self, file_name: str) -> None: """Switch to .ipynb file if open, otherwise open it. :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') @@ -165,7 +178,7 @@ def OpenOrSwitch(self, file_name): self.Open(file_name) self.SetStatusText(_("File '{}' opened.").format(file_name), 0) - def Import(self, source_path, new_name=None): + def Import(self, source_path: Path, new_name: str | None = None) -> None: """Import a .ipynb file into notebook storage and open it in a new tab. :param source_path: Path to the source .ipynb file to be imported @@ -182,12 +195,14 @@ def Import(self, source_path, new_name=None): wx.ICON_ERROR | wx.OK, ) - def OnImport(self, event=None): + def OnImport(self, event: wx.Event | None = None) -> None: """Import an existing Jupyter notebook file into notebook storage. Prompts user to select a .ipynb file: - If the file is already in the notebook storage: switch to it or open it - If the file is from elsewhere: import and open it (prompt for new name if needed) + + :param event: Toolbar event """ # Open file dialog to select an existing Jupyter notebook file with wx.FileDialog( @@ -230,8 +245,11 @@ def OnImport(self, event=None): # 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.""" + def OnExport(self, event: wx.Event | None = None) -> None: + """Export the currently opened Jupyter notebook to a user-selected location. + + :param event: Toolbar event + """ current_page = self.aui_notebook.GetSelection() if current_page == wx.NOT_FOUND: wx.MessageBox( @@ -268,8 +286,11 @@ def OnExport(self, event=None): style=wx.ICON_ERROR | wx.OK, ) - def OnCreate(self, event=None): - """Create a new empty Jupyter notebook in the notebook storage and open it.""" + def OnCreate(self, event: wx.Event | None = None) -> None: + """Create a new empty Jupyter notebook in the notebook storage and open it. + + :param event: Toolbar event + """ with wx.TextEntryDialog( self, message=_("Enter a name for the new notebook:"), @@ -297,10 +318,12 @@ def OnCreate(self, event=None): self.Open(path.name) self.SetStatusText(_("New file '{}' created.").format(path.name), 0) - def OnCloseWindow(self, event=None): - """Cleanup when panel is being closed (called by parent notebook). + def OnCloseWindow(self, event: wx.Event | None = None) -> None: + """Cleanup when panel is being closed. This method is called by mainnotebook when the tab is being closed. + + :param event: Close event """ confirm = wx.MessageBox( _("Do you really want to close this tab and stop the Jupyter server?"), @@ -338,12 +361,18 @@ def OnCloseWindow(self, event=None): self._onCloseWindow(event) - def SetStatusText(self, *args): - """Set text in the status bar.""" + def SetStatusText(self, *args) -> None: + """Set text in the status bar. + + :param args: Arguments passed to statusbar.SetStatusText + """ self.statusbar.SetStatusText(*args) - def GetStatusBar(self): - """Get statusbar.""" + def GetStatusBar(self) -> wx.StatusBar | None: + """Get statusbar. + + :return: Status bar or None if not available + """ return self.statusbar @@ -356,15 +385,26 @@ class JupyterBrowserPanel(wx.Panel, MainPageBase): def __init__( self, - parent, - giface, - id=wx.ID_ANY, - statusbar=None, - dockable=False, - storage=None, - create_template=False, + parent: wx.Window, + giface, # GrassInterface type + id: int = wx.ID_ANY, + statusbar: wx.StatusBar | None = None, + dockable: bool = False, + storage: Path | None = None, + create_template: bool = False, **kwargs, - ): + ) -> None: + """Initialize Jupyter browser panel. + + :param parent: Parent window + :param giface: GRASS interface + :param id: Window ID + :param statusbar: Optional status bar for messages + :param dockable: Whether the panel can be docked + :param storage: Storage path for notebooks + :param create_template: Whether to create template notebooks + :param kwargs: Additional arguments passed to wx.Panel + """ super().__init__(parent=parent, id=id, **kwargs) MainPageBase.__init__(self, dockable) @@ -377,7 +417,7 @@ def __init__( self._layout() - def _layout(self): + def _layout(self) -> None: """Create simple layout with message and controls.""" main_sizer = wx.BoxSizer(wx.VERTICAL) main_sizer.AddStretchSpacer(1) @@ -430,12 +470,11 @@ def _layout(self): self.SetSizer(main_sizer) self.Layout() - def SetUpEnvironment(self): + def SetUpEnvironment(self) -> bool: """Setup Jupyter environment and open in browser. - :return: bool: True if setup was successful, False otherwis + :return: True if setup was successful, False otherwise """ - try: # Prepare files and start server self.env.setup() @@ -469,13 +508,19 @@ def SetUpEnvironment(self): return True - def OnOpenBrowser(self, event): - """Re-open the Jupyter server URL in browser.""" + def OnOpenBrowser(self, event: wx.Event) -> None: + """Re-open the Jupyter server URL in browser. + + :param event: Button click event + """ if self.env and self.env.server and self.env.server_url: webbrowser.open(self.env.server_url) - def OnStop(self, event): - """Stop the Jupyter server and close the tab.""" + def OnStop(self, event: wx.Event) -> None: + """Stop the Jupyter server and close the tab. + + :param event: Button click event + """ if self.env: # Get server info before stopping url = self.env.server_url @@ -504,10 +549,12 @@ def OnStop(self, event): wx.ICON_ERROR, ) - def OnCloseWindow(self, event=None): - """Cleanup when panel is being closed (called by parent notebook). + def OnCloseWindow(self, event: wx.Event | None = None) -> None: + """Cleanup when panel is being closed. This method is called by mainnotebook when the tab is being closed. + + :param event: Close event """ confirm = wx.MessageBox( _("Do you really want to close this tab and stop the Jupyter server?"), @@ -545,11 +592,17 @@ def OnCloseWindow(self, event=None): self._onCloseWindow(event) - def SetStatusText(self, *args): - """Set text in the status bar.""" + def SetStatusText(self, *args) -> None: + """Set text in the status bar. + + :param args: Arguments passed to statusbar.SetStatusText + """ if self.statusbar: self.statusbar.SetStatusText(*args) - def GetStatusBar(self): - """Get status bar.""" + def GetStatusBar(self) -> wx.StatusBar | None: + """Get status bar. + + :return: Status bar or None if not available + """ return self.statusbar diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index 94b1b858af7..005b83a091e 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -25,13 +25,16 @@ import threading import os import shutil -import pathlib +from pathlib import Path + + +from types import FrameType _cleanup_registered = False -def _register_global_cleanup(): +def _register_global_cleanup() -> None: """Register cleanup handlers once at module level. This ensures that all Jupyter servers are properly stopped when: @@ -46,15 +49,19 @@ def _register_global_cleanup(): if _cleanup_registered: return - def cleanup_all(): + def cleanup_all() -> None: """Stop all registered servers.""" try: JupyterServerRegistry.get().stop_all_servers() except Exception: pass - def handle_signal(signum, frame): - """Handle termination signals.""" + def handle_signal(signum: int, frame: FrameType | None) -> None: + """Handle termination signals. + + :param signum: Signal number + :param frame: Current stack frame + """ cleanup_all() sys.exit(0) @@ -67,10 +74,10 @@ def handle_signal(signum, frame): class JupyterServerInstance: """Manage the lifecycle of a Jupyter server instance.""" - def __init__(self, storage): + def __init__(self, storage: Path) -> None: """Initialize Jupyter server instance. - :param storage: Storage of notebooks for the Jupyter server (str). + :param storage: Storage path for notebooks """ self.storage = storage @@ -83,7 +90,7 @@ def __init__(self, storage): # Set up global cleanup handlers (only once) _register_global_cleanup() - def _reset_state(self): + def _reset_state(self) -> None: """Reset internal state related to the server.""" self.pid = None self.port = None @@ -91,30 +98,30 @@ def _reset_state(self): self.proc = None @staticmethod - def find_free_port(): + def find_free_port() -> int: """Find a free port on the local machine. - :return: A free port number (int). + :return: A free port number """ with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] - def is_alive(self): + def is_alive(self) -> bool: """Check if the server process is still running. - :return: True if process is running, False otherwise (bool). + :return: True if process is running, False otherwise """ if not self.proc: return False return self.proc.poll() is None - def is_server_running(self, retries=50, delay=0.2): + def is_server_running(self, retries: int = 50, delay: float = 0.2) -> bool: """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). + :param retries: Number of retries before giving up + :param delay: Delay between retries in seconds + :return: True if the server is up, False otherwise """ if not self.port: return False @@ -128,15 +135,14 @@ def is_server_running(self, retries=50, delay=0.2): return False - def start_server(self): - """ - Start a Jupyter server in the notebook storage on a free port. + def start_server(self) -> None: + """Start a Jupyter server in the notebook storage on a free port. :raises RuntimeError: If Jupyter is not installed, the notebook storage is invalid, - or the server fails to start. + or the server fails to start """ # Validation checks - if not pathlib.Path(self.storage).is_dir(): + if not Path(self.storage).is_dir(): raise RuntimeError( _("Notebook storage does not exist: {}").format(self.storage) ) @@ -213,10 +219,10 @@ def start_server(self): ) ) - def stop_server(self): + def stop_server(self) -> None: """Stop the Jupyter server, ensuring no zombie processes. - :raises RuntimeError: If the server cannot be stopped. + :raises RuntimeError: If the server cannot be stopped """ if not self.proc or not self.pid: return @@ -248,12 +254,12 @@ def stop_server(self): except Exception: pass - def get_url(self, file_name): + def get_url(self, file_name: str) -> str: """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. + :param file_name: Name of the file (e.g. 'example.ipynb') + :return: Full URL to access the file + :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.")) @@ -267,14 +273,14 @@ def get_url(self, file_name): class JupyterServerRegistry: """Thread-safe registry of running JupyterServerInstance objects.""" - _instance = None - _lock = threading.Lock() + _instance: "JupyterServerRegistry | None" = None + _lock: threading.Lock = threading.Lock() @classmethod - def get(cls): + def get(cls) -> "JupyterServerRegistry": """Get the singleton registry instance (thread-safe). - :return: The JupyterServerRegistry singleton instance. + :return: The JupyterServerRegistry singleton instance """ if cls._instance is None: with cls._lock: @@ -283,32 +289,34 @@ def get(cls): cls._instance = cls() return cls._instance - def __init__(self): + def __init__(self) -> None: """Initialize the registry.""" - self.servers = [] - self._servers_lock = threading.Lock() + self.servers: list[JupyterServerInstance] = [] + self._servers_lock: threading.Lock = threading.Lock() - def register(self, server): + def register(self, server: JupyterServerInstance) -> None: """Register a server instance. - :param server: JupyterServerInstance to register. + :param server: JupyterServerInstance to register """ with self._servers_lock: if server not in self.servers: self.servers.append(server) - def unregister(self, server): + def unregister(self, server: JupyterServerInstance) -> None: """Unregister a server instance. - :param server: JupyterServerInstance to unregister. + :param server: JupyterServerInstance to unregister """ with self._servers_lock: self.servers = [s for s in self.servers if s != server] - def stop_all_servers(self): + def stop_all_servers(self) -> None: """Stop all registered servers. Continues attempting to stop all servers even if some fail. + + :raises RuntimeError: If any servers failed to stop """ errors = [] diff --git a/gui/wxpython/jupyter_notebook/storage.py b/gui/wxpython/jupyter_notebook/storage.py index dc68072a36e..5bb3a0becf9 100644 --- a/gui/wxpython/jupyter_notebook/storage.py +++ b/gui/wxpython/jupyter_notebook/storage.py @@ -4,6 +4,9 @@ @brief Simple interface for working with Jupyter Notebook files stored within the current storage. +Functions: +- `get_project_jupyter_storage()`: Return the storage for Jupyter notebooks associated with the current GRASS project. + Classes: - storage::JupyterStorageManager @@ -20,13 +23,29 @@ from pathlib import Path import grass.script as gs -from .utils import get_project_jupyter_storage +# Template directory +TEMPLATE_DIR = Path(__file__).parent / "template_notebooks" + # Template notebook filenames WELCOME_NOTEBOOK_NAME = "welcome.ipynb" NEW_NOTEBOOK_TEMPLATE_NAME = "new.ipynb" +# Jupyter storage directory name within the project structure +JUPYTER_STORAGE_DIR_NAME = "notebooks" + + +def get_project_jupyter_storage() -> Path: + """Return the storage for Jupyter notebooks associated + with the current GRASS project. + + :return: Path to the project jupyter storage + """ + env = gs.gisenv() + project_path = Path(env["GISDBASE"]) / env["LOCATION_NAME"] + return project_path / JUPYTER_STORAGE_DIR_NAME + class JupyterStorageManager: """Manage Jupyter notebook storage.""" @@ -37,7 +56,7 @@ def __init__( """Initialize the Jupyter notebook storage. :param storage: Optional custom storage path. If not provided, - the project storage is used + the default project storage is used :param create_template: If a welcome notebook should be created or not :raises PermissionError: If the storage is not writable """ @@ -153,14 +172,14 @@ def _create_from_template( ) -> Path: """Create a notebook from a template and optionally replace placeholders. - :param template_name: Template filename located in ``template_notebooks`` + :param template_name: Template filename located in TEMPLATE_DIR :param target_name: Optional target filename for the new notebook :param replacements: Optional mapping of placeholder strings to replacement values :return: Path to the created notebook file :raises FileExistsError: If target file already exists """ # Locate the template file inside the package - template_path = Path(__file__).parent / "template_notebooks" / template_name + template_path = TEMPLATE_DIR / template_name # Determine target path (copy vs. create new file) if target_name is None: diff --git a/gui/wxpython/jupyter_notebook/toolbars.py b/gui/wxpython/jupyter_notebook/toolbars.py index d8b9f643b78..8077b5a825c 100644 --- a/gui/wxpython/jupyter_notebook/toolbars.py +++ b/gui/wxpython/jupyter_notebook/toolbars.py @@ -15,6 +15,7 @@ """ import sys +from typing import Any import wx @@ -25,9 +26,13 @@ class JupyterToolbar(BaseToolbar): - """Jupyter toolbar""" + """Toolbar for integrated Jupyter notebook interface.""" - def __init__(self, parent): + def __init__(self, parent: wx.Window) -> None: + """Initialize Jupyter toolbar. + + :param parent: Parent window + """ BaseToolbar.__init__(self, parent) # workaround for http://trac.wxwidgets.org/ticket/13888 @@ -39,8 +44,11 @@ def __init__(self, parent): # realize the toolbar self.Realize() - def _toolbarData(self): - """Toolbar data""" + def _toolbarData(self) -> tuple[Any, ...]: + """Build toolbar data structure. + + :return: Toolbar data tuple containing tool definitions + """ icons = { "create": MetaIcon( img="create", diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index 31af848122b..e0c9ba9db1b 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -6,7 +6,6 @@ Functions: - `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system and functional. - `is_wx_html2_available()`: Check if wx.html2 module is available. -- `get_project_jupyter_storage()`: Return the storage for Jupyter notebooks associated with the current GRASS project. (C) 2026 by the GRASS Development Team @@ -18,9 +17,6 @@ import shutil import subprocess -from pathlib import Path - -import grass.script as gs def is_jupyter_installed() -> bool: @@ -60,14 +56,3 @@ def is_wx_html2_available() -> bool: return True except (ImportError, ModuleNotFoundError): return False - - -def get_project_jupyter_storage() -> Path: - """Return the storage for Jupyter notebooks associated - with the current GRASS project. - - :return: Path to the project jupyter storage - """ - env = gs.gisenv() - project_path = Path(env["GISDBASE"]) / env["LOCATION_NAME"] - return project_path / "notebooks" diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index ac85c33865a..6e503db0ed9 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -1004,7 +1004,6 @@ def OnJupyterNotebook(self, event=None, cmd=None): else: return else: - # Success! self.mainnotebook.AddPage(panel, _("Jupyter Notebook (Integrated)")) return From cf53855a1bb7118f4aafad8ea11d24a3db79d1a5 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sun, 22 Feb 2026 21:18:47 +0100 Subject: [PATCH 52/85] get rid of rsplit in toolbars.py --- gui/wxpython/jupyter_notebook/toolbars.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/toolbars.py b/gui/wxpython/jupyter_notebook/toolbars.py index 8077b5a825c..e9685f03059 100644 --- a/gui/wxpython/jupyter_notebook/toolbars.py +++ b/gui/wxpython/jupyter_notebook/toolbars.py @@ -70,17 +70,17 @@ def _toolbarData(self) -> tuple[Any, ...]: } data = ( ( - ("create", icons["create"].label.rsplit(" ", 1)[0]), + ("create", icons["create"].label), icons["create"], self.parent.OnCreate, ), ( - ("open", icons["open"].label.rsplit(" ", 1)[0]), + ("open", icons["open"].label), icons["open"], self.parent.OnImport, ), ( - ("save", icons["save"].label.rsplit(" ", 1)[0]), + ("save", icons["save"].label), icons["save"], self.parent.OnExport, ), From 672fe20b68bd5451ded1200f0a1c042dc4f33774 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sun, 22 Feb 2026 23:19:34 +0100 Subject: [PATCH 53/85] making templates more user friendly + adding Tools API examples --- .../template_notebooks/new.ipynb | 17 ++- .../template_notebooks/welcome.ipynb | 143 ++++++++++++++++-- 2 files changed, 142 insertions(+), 18 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb index 2a4078254a5..c6e73a4b3a6 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb @@ -3,23 +3,28 @@ { "cell_type": "code", "execution_count": null, - "id": "7fb27b941602401d91542211134fc71a", + "id": "setup-jupyter", "metadata": {}, "outputs": [], "source": [ - "# Import GRASS scripting and Jupyter modules\n", - "import grass.jupyter as gj" + "# Import GRASS Jupyter modules\n", + "import grass.jupyter as gj\n", + "\n", + "# Initialize GRASS session\n", + "gj.init(\"${MAPSET_PATH}\")" ] }, { "cell_type": "code", "execution_count": null, - "id": "acae54e37e7d407bbb7b55eff062a284", + "id": "setup-tools", "metadata": {}, "outputs": [], "source": [ - "# Initialize Jupyter environment for GRASS\n", - "gj.init(\"${MAPSET_PATH}\")" + "# Import the GRASS Tools API (recommended)\n", + "from grass.tools import Tools\n", + "\n", + "tools = Tools()" ] } ], diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index 475bd0e6a92..a8d78363f39 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -2,41 +2,160 @@ "cells": [ { "cell_type": "markdown", - "id": "7fb27b941602401d91542211134fc71a", + "id": "welcome-user", "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 `${STORAGE_PATH}`.\n", + "This notebook is connected to your GRASS session and ready to use!\n", + "\n", + "**Notebook Storage:** `${STORAGE_PATH}` \n", + "**Active Mapset:** `${MAPSET_PATH}`\n", "\n", "---\n", - "This notebook is ready to use with GRASS.\n", - "You can run Python code using GRASS tools and data.\n", "\n", + "## Quick Start\n", + "\n", + "Run the cells below in order to set up your GRASS Jupyter environment:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "setup-jupyter", + "metadata": {}, + "outputs": [], + "source": [ + "# Import GRASS Jupyter modules\n", + "import grass.jupyter as gj\n", + "\n", + "# Initialize GRASS session\n", + "gj.init(\"${MAPSET_PATH}\")\n", + "\n", + "print(\"GRASS Jupyter environment initialized!\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "setup-tools", + "metadata": {}, + "outputs": [], + "source": [ + "# Import the GRASS Tools API (recommended)\n", + "from grass.tools import Tools\n", + "\n", + "tools = Tools()\n", + "tools.g_region(flags=\"p\").text" + ] + }, + { + "cell_type": "markdown", + "id": "examples-header", + "metadata": {}, + "source": [ "---\n", - "**Tip:** Start by running a cell below, or create a new notebook by clicking the *Create new notebook* button." + "\n", + "## Examples - Try These!\n", + "\n", + "### Example 1: List Available Data" ] }, { "cell_type": "code", "execution_count": null, - "id": "acae54e37e7d407bbb7b55eff062a284", + "id": "example-list", "metadata": {}, "outputs": [], "source": [ - "# Import GRASS scripting and Jupyter modules\n", - "import grass.jupyter as gj" + "# List raster maps in current mapset\n", + "tools.g_list(type=\"raster\", mapset=\".\").text.splitlines()" + ] + }, + { + "cell_type": "markdown", + "id": "examples-viz", + "metadata": {}, + "source": [ + "### Example 2: Visualize a Map\n", + "\n", + "*Replace `'elevation'` with a raster from your mapset*" ] }, { "cell_type": "code", "execution_count": null, - "id": "9a63283cbaf04dbcab1f6479b197f3a8", + "id": "example-display", "metadata": {}, "outputs": [], "source": [ - "# Initialize Jupyter environment for GRASS\n", - "gj.init(\"${MAPSET_PATH}\")" + "# Create a map display\n", + "m = gj.Map()\n", + "\n", + "# Add a raster layer (change 'elevation' to your raster name)\n", + "m.d_rast(map=\"elevation\")\n", + "\n", + "# Display the map\n", + "m.show()" + ] + }, + { + "cell_type": "markdown", + "id": "example-analysis", + "metadata": {}, + "source": [ + "### Example 3: Run Analysis with Tools API" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "example-stats", + "metadata": {}, + "outputs": [], + "source": [ + "# Get statistics for a raster map\n", + "# Replace 'elevation' with your raster name\n", + "tools.r_univar(map=\"elevation\", format=\"json\")" + ] + }, + { + "cell_type": "markdown", + "id": "whats-next", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## What's Next?\n", + "\n", + "1. **Run GRASS modules** - Use `tools.()` syntax (e.g., `tools.r_slope_aspect()`)\n", + "3. **Visualize results** - Create maps with `gj.Map()` and display with `.show()`\n", + "4. **Create new notebooks** - Click *\"Create new notebook\"* button in the toolbar\n", + "\n", + "---\n", + "\n", + "## Resources\n", + "\n", + "- **[GRASS Tutorials](https://grass-tutorials.osgeo.org/)**\n", + "- **[GRASS Jupyter Documentation](https://grass.osgeo.org/grass85/manuals/jupyter_intro.html)**\n", + "- **[Tools API Guide](https://grass.osgeo.org/grass85/manuals/libpython/grass.tools.html)**\n", + "\n", + "---\n", + "\n", + "### Tip: Tools API vs. Traditional API\n", + "\n", + "**New Tools API (recommended):**\n", + "```python\n", + "tools.r_slope_aspect(elevation=\"elevation\", slope=\"slope\")\n", + "```\n", + "\n", + "**Traditional API:**\n", + "```python\n", + "import grass.script as gs\n", + "gs.run_command(\"r.slope.aspect\", elevation=\"elevation\", slope=\"slope\")\n", + "```\n", + "\n", + "The Tools API provides better autocomplete, type hints, and cleaner syntax!" ] } ], From 4acb6f53ee34e017c341d76ac7b6a7d924f446c9 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 23 Feb 2026 17:49:15 +0100 Subject: [PATCH 54/85] Error is empty - delete it --- gui/wxpython/jupyter_notebook/frame.py | 7 +++---- gui/wxpython/main_window/frame.py | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/frame.py b/gui/wxpython/jupyter_notebook/frame.py index 9235a4a6af7..9a69adf1273 100644 --- a/gui/wxpython/jupyter_notebook/frame.py +++ b/gui/wxpython/jupyter_notebook/frame.py @@ -79,7 +79,7 @@ def __init__( self.Close() return - except NotImplementedError as e: + except NotImplementedError: # WebView.New() raised NotImplementedError - not functional if self.panel: self.panel.Destroy() @@ -87,10 +87,9 @@ def __init__( response = wx.MessageBox( _( - "Integrated mode failed: wx.html2.WebView is not functional on this system.\n" - "Error: {}\n\n" + "Integrated mode failed: wx.html2.WebView is not functional on this system.\n\n" "Would you like to open Jupyter Notebook in your external browser instead?" - ).format(str(e)), + ), _("WebView Not Supported"), wx.ICON_ERROR | wx.YES_NO, ) diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 6e503db0ed9..e5ed4f299f8 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -984,16 +984,15 @@ def OnJupyterNotebook(self, event=None, cmd=None): panel.Destroy() return - except NotImplementedError as e: + except NotImplementedError: # WebView.New() raised NotImplementedError - not functional on this system panel.Destroy() response = wx.MessageBox( _( - "Integrated mode failed: wx.html2.WebView is not functional on this system.\n" - "Error: {}\n\n" + "Integrated mode failed: wx.html2.WebView is not functional on this system.\n\n" "Would you like to open Jupyter Notebook in your external browser instead?" - ).format(str(e)), + ), _("WebView Not Supported"), wx.ICON_ERROR | wx.YES_NO, ) From 193d6fbdb866ae13ed03d224493c4494ddbe20d9 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 24 Feb 2026 21:41:05 +0100 Subject: [PATCH 55/85] fixed . directory, unified panel and frame naming + refactoring --- gui/wxpython/jupyter_notebook/dialogs.py | 12 +- gui/wxpython/jupyter_notebook/frame.py | 141 ++++++++++++++-------- gui/wxpython/jupyter_notebook/panel.py | 2 +- gui/wxpython/main_window/frame.py | 143 ++++++++++++----------- 4 files changed, 180 insertions(+), 118 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py index 3c903ec487b..d736ffb1a14 100644 --- a/gui/wxpython/jupyter_notebook/dialogs.py +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -41,7 +41,7 @@ def __init__(self, parent: wx.Window) -> None: self.radio_custom = wx.RadioButton( self, - label=_("Choose custom directory:"), + label=_("Choose custom directory (leave empty for current directory):"), style=wx.RB_GROUP, ) self.radio_custom.SetValue(True) # Default selection @@ -125,7 +125,13 @@ def GetValues(self) -> dict[str, Path | bool] | None: :return: Dictionary with 'storage' and 'create_template' keys, or None on error """ if self.radio_custom.GetValue(): - path = Path(self.storage_picker.GetPath()) + path_str = self.storage_picker.GetPath() + + # Convert empty or "." to absolute current directory + if not path_str or path_str == ".": + path = Path.cwd() + else: + path = Path(path_str).resolve() try: # create directory if missing @@ -146,7 +152,7 @@ def GetValues(self) -> dict[str, Path | bool] | None: self.selected_storage = path else: - self.selected_storage = Path(self.project_storage) + self.selected_storage = Path(self.project_storage).resolve() return { "storage": self.selected_storage, diff --git a/gui/wxpython/jupyter_notebook/frame.py b/gui/wxpython/jupyter_notebook/frame.py index 9a69adf1273..42c813bef93 100644 --- a/gui/wxpython/jupyter_notebook/frame.py +++ b/gui/wxpython/jupyter_notebook/frame.py @@ -36,7 +36,6 @@ def __init__( storage: Path | None = None, create_template: bool = False, id: int = wx.ID_ANY, - title: str = _("Jupyter Notebook"), **kwargs, ) -> None: """Initialize Jupyter frame. @@ -47,9 +46,11 @@ def __init__( :param storage: Storage path for notebooks :param create_template: Whether to create template notebooks :param id: Window ID - :param title: Frame title :param kwargs: Additional arguments passed to wx.Frame """ + # Set title based on mode and storage + title = self._format_title(action, storage) + super().__init__(parent=parent, id=id, title=title, **kwargs) self.SetName("JupyterFrame") @@ -59,66 +60,112 @@ def __init__( self.statusbar = self.CreateStatusBar(number=1) self.panel = None + self.storage = storage + self.giface = giface # Try integrated mode first if requested if action == "integrated": - try: - self.panel = JupyterPanel( - parent=self, - giface=giface, - storage=storage, - create_template=create_template, - statusbar=self.statusbar, - dockable=False, - ) - - # Setup environment and load notebooks - if not self.panel.SetUpEnvironment(): - self.panel.Destroy() - self.panel = None - self.Close() - return - - except NotImplementedError: - # WebView.New() raised NotImplementedError - not functional - if self.panel: - self.panel.Destroy() - self.panel = None - - response = wx.MessageBox( - _( - "Integrated mode failed: wx.html2.WebView is not functional on this system.\n\n" - "Would you like to open Jupyter Notebook in your external browser instead?" - ), - _("WebView Not Supported"), - wx.ICON_ERROR | wx.YES_NO, - ) - - if response == wx.YES: - action = "browser" - else: - self.Close() - return - - # Browser mode + success = self._setup_integrated_mode(storage, create_template) + if success: + self._layout() + return + + # Integrated mode failed, offer browser fallback + response = wx.MessageBox( + _( + "Integrated mode failed: wx.html2.WebView is not functional on this system.\n\n" + "Would you like to open Jupyter Notebook in your external browser instead?" + ), + _("WebView Not Supported"), + wx.ICON_ERROR | wx.YES_NO, + ) + + if response == wx.YES: + action = "browser" + # Update title for browser mode + self.SetTitle(self._format_title("browser", storage)) + else: + self.Close() + return + + # Set up browser mode if action == "browser": - self.panel = JupyterBrowserPanel( + success = self._setup_browser_mode(storage, create_template) + if success: + self._layout() + else: + self.Close() + + def _format_title(self, mode: str, storage: Path | None) -> str: + """Format frame title with mode and storage path. + + :param mode: Mode name - "integrated" or "browser" + :param storage: Storage path for notebooks + :return: Formatted title string + """ + if storage: + mode_name = _("Integrated") if mode == "integrated" else _("Browser") + # Show full absolute path + return _("Jupyter Notebook ({}) - {}").format(mode_name, storage.resolve()) + return _("Jupyter Notebook") + + def _setup_integrated_mode( + self, storage: Path | None, create_template: bool + ) -> bool: + """Setup integrated Jupyter panel. Returns True on success. + + :param storage: Storage path for notebooks + :param create_template: Whether to create template notebooks + :return: True if setup succeeded, False otherwise + """ + try: + self.panel = JupyterPanel( parent=self, - giface=giface, + giface=self.giface, storage=storage, create_template=create_template, statusbar=self.statusbar, dockable=False, ) - # Setup environment and open in browser + # Setup environment and load notebooks if not self.panel.SetUpEnvironment(): self.panel.Destroy() self.panel = None - self.Close() - return + return False + + return True - self._layout() + except NotImplementedError: + # WebView.New() raised NotImplementedError - not functional + if self.panel: + self.panel.Destroy() + self.panel = None + return False + + def _setup_browser_mode(self, storage: Path | None, create_template: bool) -> bool: + """Setup browser-based Jupyter panel. Returns True on success. + + :param storage: Storage path for notebooks + :param create_template: Whether to create template notebooks + :return: True if setup succeeded, False otherwise + """ + self.panel = JupyterBrowserPanel( + parent=self, + giface=self.giface, + storage=storage, + create_template=create_template, + statusbar=self.statusbar, + dockable=False, + ) + + # Setup environment and open in browser + if not self.panel.SetUpEnvironment(): + self.panel.Destroy() + self.panel = None + return False + + return True def _layout(self) -> None: """Setup frame layout and size.""" diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 129f8672b74..2cc82437b85 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -386,7 +386,7 @@ class JupyterBrowserPanel(wx.Panel, MainPageBase): def __init__( self, parent: wx.Window, - giface, # GrassInterface type + giface, id: int = wx.ID_ANY, statusbar: wx.StatusBar | None = None, dockable: bool = False, diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index e5ed4f299f8..f844dbfc782 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -917,6 +917,65 @@ def _show_jupyter_missing_message(self): parent=self, ) + def _add_jupyter_panel_to_notebook(self, panel, mode_name, storage): + """Add Jupyter panel to notebook with tooltip.""" + self.mainnotebook.AddPage( + panel, + _("Jupyter Notebook ({}) - {}").format(mode_name, storage.resolve().name), + ) + + page_idx = self.mainnotebook.GetPageCount() - 1 + self.mainnotebook.SetPageTooltip(page_idx, str(storage.resolve())) + + def _setup_jupyter_integrated_mode(self, storage, create_template): + """Setup integrated Jupyter panel. Returns True on success.""" + from jupyter_notebook.panel import JupyterPanel + + panel = JupyterPanel( + parent=self.mainnotebook, + giface=self._giface, + statusbar=self.statusbar, + dockable=True, + storage=storage, + create_template=create_template, + ) + panel.SetUpPage(self, self.mainnotebook) + + try: + if not panel.SetUpEnvironment(): + panel.Destroy() + return False + + except NotImplementedError: + panel.Destroy() + return False + + # Success - add panel to notebook + self._add_jupyter_panel_to_notebook(panel, _("Integrated"), storage) + return True + + def _setup_jupyter_browser_mode(self, storage, create_template): + """Setup browser-based Jupyter panel.""" + from jupyter_notebook.panel import JupyterBrowserPanel + + panel = JupyterBrowserPanel( + parent=self.mainnotebook, + giface=self._giface, + statusbar=self.statusbar, + dockable=True, + storage=storage, + create_template=create_template, + ) + panel.SetUpPage(self, self.mainnotebook) + + # Setup environment FIRST (before adding to UI) + if not panel.SetUpEnvironment(): + panel.Destroy() + return + + # Success - add panel to notebook + self._add_jupyter_panel_to_notebook(panel, _("Browser"), storage) + def OnJupyterNotebook(self, event=None, cmd=None): """Launch Jupyter Notebook interface.""" from jupyter_notebook.utils import ( @@ -946,10 +1005,9 @@ def OnJupyterNotebook(self, event=None, cmd=None): storage = values["storage"] create_template = values["create_template"] + # Check integrated mode requirements and offer fallback if action == "integrated": - # Embedded notebook mode: requires wx.html2 for WebView if not is_wx_html2_available(): - # Offer fallback to browser mode response = wx.MessageBox( _( "Integrated mode requires wx.html2.WebView which is not available on this system.\n\n" @@ -964,79 +1022,30 @@ def OnJupyterNotebook(self, event=None, cmd=None): else: return + # Try integrated mode if action == "integrated": - from jupyter_notebook.panel import JupyterPanel + success = self._setup_jupyter_integrated_mode(storage, create_template) + if success: + return # Successfully set up integrated mode - panel = JupyterPanel( - parent=self.mainnotebook, - giface=self._giface, - statusbar=self.statusbar, - dockable=True, - storage=storage, - create_template=create_template, + # Integrated mode failed, offer browser fallback + response = wx.MessageBox( + _( + "Integrated mode failed: wx.html2.WebView is not functional on this system.\n\n" + "Would you like to open Jupyter Notebook in your external browser instead?" + ), + _("WebView Not Supported"), + wx.ICON_ERROR | wx.YES_NO, ) - panel.SetUpPage(self, self.mainnotebook) - # Setup environment and load notebooks - try: - if not panel.SetUpEnvironment(): - # Setup failed for other reasons - panel.Destroy() - return - - except NotImplementedError: - # WebView.New() raised NotImplementedError - not functional on this system - panel.Destroy() - - response = wx.MessageBox( - _( - "Integrated mode failed: wx.html2.WebView is not functional on this system.\n\n" - "Would you like to open Jupyter Notebook in your external browser instead?" - ), - _("WebView Not Supported"), - wx.ICON_ERROR | wx.YES_NO, - ) - - if response == wx.YES: - action = "browser" - # Fall through to browser setup below - else: - return + if response == wx.YES: + action = "browser" else: - self.mainnotebook.AddPage(panel, _("Jupyter Notebook (Integrated)")) return + # Set up browser mode if action == "browser": - # External browser mode: lightweight panel without wx.html2 requirement - from jupyter_notebook.panel import JupyterBrowserPanel - - panel = JupyterBrowserPanel( - parent=self.mainnotebook, - giface=self._giface, - statusbar=self.statusbar, - dockable=True, - storage=storage, - create_template=create_template, - ) - panel.SetUpPage(self, self.mainnotebook) - - # Add panel as tab first (so user sees something is happening) - self.mainnotebook.AddPage( - panel, - _("Jupyter Browser - {}").format( - storage.name if storage else "default" - ), - ) - self.mainnotebook.SetSelection(self.mainnotebook.GetPageCount() - 1) - - # Setup environment and open in browser - if not panel.SetUpEnvironment(): - # Setup failed, remove the panel - for i in range(self.mainnotebook.GetPageCount()): - if self.mainnotebook.GetPage(i) == panel: - self.mainnotebook.DeletePage(i) - break - return + self._setup_jupyter_browser_mode(storage, create_template) def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" From 896841b3ed9c4427099236799796cfca64a2d31d Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:44:45 +0100 Subject: [PATCH 56/85] Update gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index a8d78363f39..3fc30db98d2 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -46,7 +46,7 @@ "from grass.tools import Tools\n", "\n", "tools = Tools()\n", - "tools.g_region(flags=\"p\").text" + "print(tools.g_region(flags=\"p\").text)" ] }, { From 57412869bb52a25d8c26841d036f31d51bf6671e Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:45:04 +0100 Subject: [PATCH 57/85] Update gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index 3fc30db98d2..b9c0ad81ea7 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -69,7 +69,7 @@ "outputs": [], "source": [ "# List raster maps in current mapset\n", - "tools.g_list(type=\"raster\", mapset=\".\").text.splitlines()" + "print(tools.g_list(type=\"raster\", mapset=\".\").text)" ] }, { From 00b26a2761cf7901ba9ca0a3b330c2a2f60a6c3a Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 24 Feb 2026 21:55:37 +0100 Subject: [PATCH 58/85] better wording in welcome.ipynb --- .../jupyter_notebook/template_notebooks/welcome.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index b9c0ad81ea7..2c7802684e7 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -9,8 +9,8 @@ "\n", "This notebook is connected to your GRASS session and ready to use!\n", "\n", - "**Notebook Storage:** `${STORAGE_PATH}` \n", - "**Active Mapset:** `${MAPSET_PATH}`\n", + "**Notebook saved in:** `${STORAGE_PATH}` \n", + "**Started in mapset:** `${MAPSET_PATH}`\n", "\n", "---\n", "\n", From cf8dc1692943a800a424814ba9ab9fb91d04e510 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 24 Feb 2026 22:23:47 +0100 Subject: [PATCH 59/85] small refinements in welcome.ipynb based on reviews --- .../template_notebooks/welcome.ipynb | 31 +++++-------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index 2c7802684e7..9901519500a 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -116,7 +116,8 @@ "source": [ "# Get statistics for a raster map\n", "# Replace 'elevation' with your raster name\n", - "tools.r_univar(map=\"elevation\", format=\"json\")" + "stats = tools.r_univar(map=\"elevation\", format=\"json\")\n", + "stats[\"mean\"]" ] }, { @@ -128,34 +129,18 @@ "\n", "## What's Next?\n", "\n", - "1. **Run GRASS modules** - Use `tools.()` syntax (e.g., `tools.r_slope_aspect()`)\n", - "3. **Visualize results** - Create maps with `gj.Map()` and display with `.show()`\n", - "4. **Create new notebooks** - Click *\"Create new notebook\"* button in the toolbar\n", + "1. **Run GRASS tools** - Use `tools.()` syntax (e.g., `tools.r_slope_aspect()`) - [Learn more](https://grass.osgeo.org/grass-devel/manuals/python_intro.html#running-tools)\n", + "2. **Visualize results** - Create maps with `gj.Map()` and display with `.show()` - [Learn more](https://grass.osgeo.org/grass-devel/manuals/jupyter_intro.html#map)\n", + "3. **Create new notebooks** - Click *\"Create new notebook\"* button in the toolbar\n", "\n", "---\n", "\n", "## Resources\n", "\n", "- **[GRASS Tutorials](https://grass-tutorials.osgeo.org/)**\n", - "- **[GRASS Jupyter Documentation](https://grass.osgeo.org/grass85/manuals/jupyter_intro.html)**\n", - "- **[Tools API Guide](https://grass.osgeo.org/grass85/manuals/libpython/grass.tools.html)**\n", - "\n", - "---\n", - "\n", - "### Tip: Tools API vs. Traditional API\n", - "\n", - "**New Tools API (recommended):**\n", - "```python\n", - "tools.r_slope_aspect(elevation=\"elevation\", slope=\"slope\")\n", - "```\n", - "\n", - "**Traditional API:**\n", - "```python\n", - "import grass.script as gs\n", - "gs.run_command(\"r.slope.aspect\", elevation=\"elevation\", slope=\"slope\")\n", - "```\n", - "\n", - "The Tools API provides better autocomplete, type hints, and cleaner syntax!" + "- **[GRASS Jupyter Documentation](https://grass.osgeo.org/grass-devel/manuals/jupyter_intro.html)**\n", + "- **[Tools API Guide](https://grass.osgeo.org/grass-devel/manuals/libpython/grass.tools.html)**\n", + "- **[Additional Ways to Access Tools](https://grass.osgeo.org/grass-devel/manuals/python_intro.html#additional-ways-to-access-tools)**" ] } ], From a870195101241c1a3b13f1562b2ff10cce480e22 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 24 Feb 2026 23:13:58 +0100 Subject: [PATCH 60/85] Fix hyperlinks not working in integrated Jupyter WebView + always open a new window --- gui/wxpython/jupyter_notebook/notebook.py | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py index c44f6fb472b..733b4edfb59 100644 --- a/gui/wxpython/jupyter_notebook/notebook.py +++ b/gui/wxpython/jupyter_notebook/notebook.py @@ -113,6 +113,37 @@ def _hide_top_ui(self, event: html.WebViewEvent) -> None: webview.RunScript(js) + def _on_navigating(self, event): + """Handle navigation events - open external links in system browser. + + :param event: WebView navigating event + """ + import webbrowser + from urllib.parse import urlparse + + url = event.GetURL() + parsed = urlparse(url) + + # Allow navigation within Jupyter (localhost) + if parsed.hostname in {"localhost", "127.0.0.1", None}: + event.Skip() + return + + # Open external links in system browser (new window) + event.Veto() + webbrowser.open_new(url) + + def _on_new_window(self, event): + """Handle links that try to open in new window. + + :param event: WebView new window event + """ + import webbrowser + + url = event.GetURL() + event.Veto() # Don't open new WebView window + webbrowser.open_new(url) + def AddPage(self, url: str, title: str) -> None: """Add a new AUI notebook page with a Jupyter WebView. @@ -123,6 +154,8 @@ def AddPage(self, url: str, title: str) -> None: browser = html.WebView.New(self) wx.CallAfter(browser.LoadURL, url) wx.CallAfter(browser.Bind, html.EVT_WEBVIEW_LOADED, self._hide_top_ui) + wx.CallAfter(browser.Bind, html.EVT_WEBVIEW_NAVIGATING, self._on_navigating) + wx.CallAfter(browser.Bind, html.EVT_WEBVIEW_NEWWINDOW, self._on_new_window) super().AddPage(browser, title) def OnPageClose(self, event: aui.AuiNotebookEvent) -> None: From 5ca492bf043f890a271bc4430c4b0d6137dd5bdc Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sat, 28 Feb 2026 20:40:54 +0100 Subject: [PATCH 61/85] JupyterBrowserPanel - open directly welcome notebook (welcome.ipynb) if it exists --- gui/wxpython/jupyter_notebook/environment.py | 18 +++++++++++++++++- gui/wxpython/jupyter_notebook/panel.py | 7 +++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/environment.py b/gui/wxpython/jupyter_notebook/environment.py index 6f24c710915..ed15e516026 100644 --- a/gui/wxpython/jupyter_notebook/environment.py +++ b/gui/wxpython/jupyter_notebook/environment.py @@ -17,7 +17,7 @@ from pathlib import Path -from .storage import JupyterStorageManager +from .storage import JupyterStorageManager, WELCOME_NOTEBOOK_NAME from .server import JupyterServerInstance, JupyterServerRegistry @@ -82,3 +82,19 @@ def files(self) -> list[Path]: :return: List of notebook file paths """ return self.storage_manager.files if self.storage_manager else [] + + @property + def template_url(self) -> str | None: + """Get URL to welcome notebook if it exists. + + :return: URL to welcome.ipynb if it exists, None otherwise + """ + if not self.server_url: + return None + + # Check if welcome notebook exists + template_path = Path(self.storage_manager.storage) / WELCOME_NOTEBOOK_NAME + if template_path.exists(): + return "{}/notebooks/{}".format(self.server_url, WELCOME_NOTEBOOK_NAME) + + return None diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 2cc82437b85..48ed1b2a3ee 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -503,8 +503,11 @@ def SetUpEnvironment(self) -> bool: ) ) - # Open in default browser - webbrowser.open(self.env.server_url) + # Open template notebook if available, otherwise open directory + if self.env.template_url: + webbrowser.open_new(self.env.template_url) + else: + webbrowser.open_new(self.env.server_url) return True From c135597dbfe668bcc95c4c9a8550679f0260cf67 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sat, 28 Feb 2026 22:21:25 +0100 Subject: [PATCH 62/85] fix panel tooltip to persist after undock/dock --- gui/wxpython/main_window/frame.py | 7 ++++++- gui/wxpython/main_window/notebook.py | 13 +++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index f844dbfc782..1eb6489df52 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -919,13 +919,18 @@ def _show_jupyter_missing_message(self): def _add_jupyter_panel_to_notebook(self, panel, mode_name, storage): """Add Jupyter panel to notebook with tooltip.""" + tooltip = str(storage.resolve()) + + # Store tooltip as panel attribute (persists through undock/dock) + panel.page_tooltip = tooltip + self.mainnotebook.AddPage( panel, _("Jupyter Notebook ({}) - {}").format(mode_name, storage.resolve().name), ) page_idx = self.mainnotebook.GetPageCount() - 1 - self.mainnotebook.SetPageTooltip(page_idx, str(storage.resolve())) + self.mainnotebook.SetPageTooltip(page_idx, tooltip) def _setup_jupyter_integrated_mode(self, storage, create_template): """Setup integrated Jupyter panel. Returns True on success.""" diff --git a/gui/wxpython/main_window/notebook.py b/gui/wxpython/main_window/notebook.py index 7d57917e23a..08581126826 100644 --- a/gui/wxpython/main_window/notebook.py +++ b/gui/wxpython/main_window/notebook.py @@ -146,11 +146,16 @@ def DockPage(self, page): frame.SetMenuBar(None) frame.Destroy() - def AddPage(self, *args, **kwargs): + def AddPage(self, page, *args, **kwargs): """Overrides Aui.Notebook AddPage method. - Adds page to notebook and makes it current""" - super().AddPage(*args, **kwargs) - self.SetSelection(self.GetPageCount() - 1) + Adds page to notebook, makes it current, and restores tooltip if available.""" + super().AddPage(page, *args, **kwargs) + page_idx = self.GetPageCount() - 1 + self.SetSelection(page_idx) + + # Restore tooltip if page has one stored + if hasattr(page, "page_tooltip"): + self.SetPageTooltip(page_idx, page.page_tooltip) def SetSelectionToMainPage(self, page): """Decides whether to set selection to a MainNotebook page From 68b1ca2cbfe2320372aac109f1056737aef35ced Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sat, 28 Feb 2026 22:31:01 +0100 Subject: [PATCH 63/85] add minimal toolbar to JupyterBrowserPanel --- gui/wxpython/jupyter_notebook/panel.py | 5 ++ gui/wxpython/jupyter_notebook/toolbars.py | 56 +++++++++++++++++------ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py index 48ed1b2a3ee..64532e66d2d 100644 --- a/gui/wxpython/jupyter_notebook/panel.py +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -415,11 +415,16 @@ def __init__( self.env = JupyterEnvironment(storage=storage, create_template=create_template) + self.toolbar = JupyterToolbar(parent=self, minimal=True) + self._layout() def _layout(self) -> None: """Create simple layout with message and controls.""" main_sizer = wx.BoxSizer(wx.VERTICAL) + + # Toolbar at the top + main_sizer.Add(self.toolbar, flag=wx.EXPAND) main_sizer.AddStretchSpacer(1) # Info icon and message diff --git a/gui/wxpython/jupyter_notebook/toolbars.py b/gui/wxpython/jupyter_notebook/toolbars.py index e9685f03059..5efa0b6ffe0 100644 --- a/gui/wxpython/jupyter_notebook/toolbars.py +++ b/gui/wxpython/jupyter_notebook/toolbars.py @@ -28,12 +28,14 @@ class JupyterToolbar(BaseToolbar): """Toolbar for integrated Jupyter notebook interface.""" - def __init__(self, parent: wx.Window) -> None: + def __init__(self, parent: wx.Window, minimal: bool = False) -> None: """Initialize Jupyter toolbar. :param parent: Parent window + :param minimal: If True, show only Undock and Stop buttons """ BaseToolbar.__init__(self, parent) + self.minimal = minimal # workaround for http://trac.wxwidgets.org/ticket/13888 if sys.platform == "darwin" and not CheckWxVersion([4, 2, 1]): @@ -50,24 +52,52 @@ def _toolbarData(self) -> tuple[Any, ...]: :return: Toolbar data tuple containing tool definitions """ 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": MetaIcon( img="quit", label=_("Stop server"), ), } + + # Minimal mode: only Undock and Stop + if self.minimal: + data = () + 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) + + # Full mode: all buttons + icons.update( + { + "create": MetaIcon( + img="create", + label=_("Create new notebook"), + ), + "open": MetaIcon( + img="open", + label=_("Import notebook"), + ), + "save": MetaIcon( + img="save", + label=_("Export notebook"), + ), + } + ) + data = ( ( ("create", icons["create"].label), From f1f41284de5b6b8c0bde345f5b37b37b8673e4f4 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 3 Mar 2026 12:52:37 +0100 Subject: [PATCH 64/85] documentation for Using Jupyter Notebooks from GRASS GUI --- doc/jupyter_browser_mode.png | Bin 0 -> 161654 bytes doc/jupyter_integrated_mode.png | Bin 0 -> 111839 bytes doc/jupyter_intro.md | 93 ++++++++++++++++++++++++++++++++ doc/jupyter_startup_dialog.png | Bin 0 -> 36945 bytes 4 files changed, 93 insertions(+) create mode 100644 doc/jupyter_browser_mode.png create mode 100644 doc/jupyter_integrated_mode.png create mode 100644 doc/jupyter_startup_dialog.png diff --git a/doc/jupyter_browser_mode.png b/doc/jupyter_browser_mode.png new file mode 100644 index 0000000000000000000000000000000000000000..989cc6696cf914e11c91e215bcef60bfd6a93925 GIT binary patch literal 161654 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

5RsYjarmK`9B$Eg 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%0A)YQzH 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 bhj;{dIA`zo)^S^cfeuMAInh#KeV_jU8Lzki literal 0 HcmV?d00001 diff --git a/doc/jupyter_integrated_mode.png b/doc/jupyter_integrated_mode.png new file mode 100644 index 0000000000000000000000000000000000000000..9d4147f57ee079d67e3b0ac8ed6f999f55b7e35e GIT binary patch literal 111839 zcmb5WWmHvB7dA>sBNEc3lqlVegd#1{-QC?GEsY=@Dj?l)XrvpYySqCMc^9v~@Autb zcicV3mcwz!+H1{to@dU@myfdI7^p<3FfcF}5+B4q!N4HAg@J)rMMebQw9hAUfgcDC zA`*(oVDm&a3<19rI=)kNRIo92bkVanf-$kSu`*(CFt9f=vUV`FaXf@?76b>enklL} zirO3LIhxs6Qz)8Q8G$chU_RQkerIKD{jT4UWaMtG2Yz8@<)nZ7!OOwU%fWI=eeVtf zLjfZpCamb1x;O9Qs%Uh7e|(vgbS4`UK=lC!Rv300RWj}y%`4~9ww>4d?Wjh!~L7A<#4EdQO@-|e<-e(uTJ)8MBA^G*LwW=}Il6HTV{{kZkw<=;()D2V+X z!EiJuZv4MvQu&Fn&DqTt)%{qgh)lJEh^bBYQ`@*sGszcj2C=BT>v@9-I-BFT_xRr zZ`hXb_Gfi;+y1Jcli4WkISQtEO77U?@3zrJ4y}cR>VK1%WjDs@mRIs4IH%d7u7SyF zGQW6P*sB%OrbS$4Q-0mWX8+%;eHbk;o07EB6CaO-cv+a|{IVib^PG>V3nZ_4Le`YZ zPg+pz{%?F)cH``e;^0u37a=*$;i#L$WEba^1DSOQ6dZXLOM3Z68si2^mYF+;99B60 zo;)b8?+?D*i;%FL&%rrX>2NXmFFrIviG0U!xv#Lj6Zvls%u<*HM9lLg{x=C$%U~>D zdOrt;jSQVBLB_|@KVLGp=lEtd3B2{h5XuiC{YB(m!hn1{R-BXk@2qwo^Bvev8Zav- zM-}>s%lY=`Ho_mQUVJF>M0wjV&?uj9l)2M0dXen+jQ>_!BkkW@j@VW`p-2~bCf6da zb|uf!{R&N(`+ijHP9$1gG$v>e_LI{G$&RBog+F?o4iwm!XYlTD48HMiqZ6Dx^Zt*q zCM@7RzodgL_ci#9r%P&^xF{MOsuu!Tt>L${_@hbTgn?(ibGK|&6IL6v$OnrfA}Ts$ zIi9bisH&>^vqK++C1n6M=m-BlTU|3;b*{JLO>+)zlh;&Lb$!Lg#sVu3t_PNxevqCnYZ5cMzppP0Jsw&|M~65jDQ{&?O^qwo5t(kL{g(WBP7X!; zs3glm{8~|`rp7#(cL|Z-82e;!QtxswVF<#nuFlT%gv7+D#eHXSJbe6BTW(y>|2iRD zFE1B2$x};CV!SZwLZ{sc6q;DN!tdXg&?yK@Rcb<*+TP|5LYH^feUssz>3H~zO`K+~ z&>_2)YSla6>Fetc(lIh}rpo4&RE7S%WVb~3w&RK|cX)1YZgopb%jeqK+I?S+G5%#U zFINn)9biinH*Fe6dcPc7$5ZqT43Nac#V;8d8H;bm|LcFT@$=TS^z?h|71MjKU%f(x zt)8%tN#3~49bNm;gh){}aJOtWB{hDO>G0u0H^cGqF??uP7)M*)zY&l}w=Gk!va%L` z|4#9H+13)){-|lJm?<{(I*uiAN7=Xvksl*4k0?AYkYbXWhQ_&6y$Dt4pJm?vE>teS zA}3!YtBLPD4R-Iv%iq8}*UxKfYC1zco*x}Bi$#Ko$Jd~>8(UXMeCnGe_r)uzB#9m*w*sKUd7iqGv>&;ruC4Do`GFu5X-yZDf%~%+Ye^4eFurTJSy`{rPXzuH@go zh0)W~LqI@qt&)p}f4j&=T@Dhk){R8-eww zMlXK1cj^f?p*yog{fK4`m$|h6 zeZDk30>KR5NoSR6;3PGvC%^jcE2 zs%&NhE-!foVO%}6hvNQvQ0&}Vsc9Qhg88+1RTa7@->4pA>o4*QD<(FLjytHm*aRDY zWbWHl@d5|Z+4@C-_S%-AgCh+EjPk+fsXxxO_Ere7X$hb7-y1Rh%1h6FlN4)4Cf zHl0tNZHYDN+^MPj`JUHsa%&)+F_jSi{!ORgDHf9eROkM%7olV|G(|~4@IJZw%QZJn zOsobNdP|Goz|_agrKKgrU__tayy%i_{`ow9kfSyYGWmj_CTw>!p?t&_?`7t-(ZhsW zBTSw-$@WRkrYR%U?y|kH3fr3g8jv#BUiu0F3oB0uZBzOth=Gods@eNFL&}j(J{C_r zzB2vhY!tbXqb8-7A-l_RLin8zm%o4gx>B~ALieu{MrKw4h}5T z_T%tew#MJn(sVp~gTAgpw0eyNlgC;%G0$mttj21bjLOzl4<%g0SzUYQ(F;Vk6xCq- z?`s2!=;$n=J7jf}ioa_4EyuP-i*4yR&4KB8eP1FMX<# ztmrhZuA9gPh|W@Tqu8)`pn-vbY0tWNhj*Ei8pM$o1`CQJSJi~GZ+&;BMf?+f2VP@- zJ{~*I`b9>ufQhI3^1hQ%EPWn3ymzvLnGpA&w?k=#esROO9+{sZ@o`#kZOuJnU%WgC z)=w;DSCSJ{KrmUn=a14MS}eu1l2LQOLECb}m^?@6NNbLR2rY;<;t%u5+ho2k>XW(O zxx8}BAq z>1Mg;4r78Lex8=ZX=T7dtc!5I6x4(NOx`<|u#(JnHi&ht5-@ z$D_x4z<@70JT|YJlM}jAT+O4~VNR0fzuBt@Cljcfamy5s+L$)UI4AHM`w=1E5_OP`KO z`o7~4jZsgUNq(MSIedD3X!5z`Ol9P{PIFIl$&PZKi;TZDr4;?9jJux;UZL*YFPTD} zQ=CQ5dptIqE%|Hi6Zgen)}sSR62$cRE~gcZ+dg)a=e|w20Oc5y6CsvB&Bd_GmGk&} z&Z37eQ3pE;ZisH1-c9Kc6u!@?Y@P&GyXV#IGA-2uB({Ef+aeG)$vFBU;}) zxyQG4ox%$=Bzab>W9HJr#w#|w{zt_uFu40Yl!X?K*+NV0m?ep>h zU!!~BjkckO=niX@-X2cL@nx1Nx(U{_8f-fxtQGyadv8s*YIEdWvA5&)L@?Ql!`tjIShjRVDx7(Z&q@t1jhE{-?cuJDN%vfo5$kM z7}SFdmZ)|imOnKvSL${Lmsd@`qVPYs7xTA?wb+QWK&)jxI!gV-_+atplq0U}tYc-i%OWtpWKs}) z=dDd>TG9GbxJrIFjhUP%`12MbD=Z%1EMOC4c=%Q}*5P1vwPE_Mh(4Jr50!eb z{Wd{9N0S&GaI{$Rl~}XmX`y&%Zxn&IF}vNQWBw*Uw=wQ^*~iCe_-uO(RMx2#Y1ov0 z4z!E?-=Eb~;gaM)yO@ap333@298>Po;I2(_3I!^9j0iLB+`5EaY$Po&c}i1zW>? z)Y8UZONFLzQvhvl#>{d0rXr}2A_eUmCq;40J{>s2LMP%5u#+pKm?V{qN}+KO77I)x z{#0?wOfF5<`$tcd@TQy9e%=_Hu`%?t&8y&ttP{4_=B~-fT!5@9Yr@G|oVeOqO0b;U zFK+DFHIzMm;DJP(PS&x6)@6Gh!DY9PF;w;onLg(-TdqSwXjeX=cK%f{ zDRy9wn{U*;J&R~?T30Edko>1?s?8sFSu^tlpO2|JCmc$@dGQf*;;(j!BvjO6k&K1? z@c(JMui28ns2On982x$qv*Z5o^RQ(qi&aE#tM!a7@?WOv?TO5`<#%lmH6_!Rc5`Bk z;gJSo0aB*xg53`b&~#~h0tWpMmd|-IcNrx}8j;4m5%VZcVzWRO@+@qd zT*}nPlQ+T8LWG#VSO{uUjLonw9QhM}WX+Q0bNt2{5_U_Sd^F225<1 z{~)wpr;BIk!fy_eL${+Z<%r^~H*J+mKVvyQy&RbRAlZwDcjtk;9Q2uo5q9W}YhBr_ zMN(C3Om70)eamL>Qe}+K=3%D6o5m}(y0if`VZ`Fpbx&v;7D=3~?mZmm)O=!Vy@M=9 zU(ZC5G40cO9NXw>SCm;2-2mtB4t~=)L_3N(Q1-$4EGGH2drb*OnUuHD8nUU)vdJY*0E;~v+oa>9#;JiqeR96=WHM5r3s#i^lU^yU7hd( zt(irBenB>!IvpQrF-WXCJq()@b=ejh%b2(s5Pdg@ST&(%hvYCLmyWWWA39a1UOpfq<3l}l6%A3|S zvD7ALr1>Pvgqw$f@oU(jPomUU@|9Uf%;nhG(5iNf46z?Z;$U7;}eQqibfme~na}$Qj#Y}z+ z-tMP76-ddww_@%`td@Qg zI9p+7IE&09kh*lST(d7bvT-kRA8#P2`2-7#r2|vnuxnAZ$($)Lrjc;skX^dwRX4Bt zHAdk%fm$GAh{W1ng@(a`nnI0bC$=seeWeW1drY(%G|W=2qBDg*!-qm9+g|>J!>7sz z`y`=yv)z7&-V9o0KdCa{Qiw%Xxibw2Iv4oS!=7w1@j*LSg0x{7koiv-Y`n`;;P4ST z+i|S;!gH`loaAe~@uC`p0=R4r6t~+74K|HGNirJgo;_nEC+nICRHM@5O>TlkJn$p6 zoIPx}IyD%0k04M&CN76o6KO?DJ^qEV`GSa~dAv!E$Axq8;f6eJ?&fuDeZfz9EYUQ625oCq~2{0hN0G#L6FdeF4THMawon5;&Ys;`NYM%NGAW7NVK6x&`hbX-)CYHD} z={$WtS0C0G$&!lW1-mLvQ+i*ZD2Yt*@@47$We(<$0Tz}G5O0Kqg~^6at6L=rZDON& zNt_P#lod$N*AIE$M-Z*s4Mvso2sZ>$2u{c=3}Nc92B~ktA3ME|yACfo-e!f)M%o<> zXi+VABL2|&iENG|az9~EV}~02#EDp z`IF}lYdyp`EIe8@=s3o)u!EGI&9C;eI=%CZfqMBXG~8Lf2zCPV^5NF z1xzNe@XtyZ?!NNFKx+0r$Lc$k8gkxbSY0i_Jk8m~2|;#HsPq(E%qD#hGhFPPSrd7W z@kMW~VsVtKA|U!Fbk4hjWFgUqbVw)t?MV2Lnzzl4~0%$ z?K~XYjgZi!-+Q>Ys&v8``#1}1%I^K{Hfb{25|Ts?N2c`EckcHJ%Q%6JB{%1+THjIe z)(znEQ z+-L65scHr^m7$5Vtx^BqCCbVvi>)RYH9BcX%^UOYxQ@=K?ZUmUHC{6otDMeK(-QJI zM{n~7bHbD99*8H{QZRjGd-AN;!3yvbgr0$Vrh!)VJLk)Eto1xJW%C^KEaW$}%aUmaVv&Vky=c}A9 zsWd?M&u>kn=_%7yyPsgLf3!rUb#0>z_ik>w+_Z3V_YMhBG_06LMmr^Gn5^!Vl<7J% zA^qq@EYUq|^=NBTn%v5M5684j&1MP{hg zaWtL;#2x65V3JZ&u%e$lE#0{bPGmjZL@fwWoxWP`%9@&A>NP&jS+uL%qwYB;J|(*& zwqaP5s-_HxXYlXtd-LK?>cCfV>y5$9X8pU*BqT57#h%P`Pc=qRR)4=9a8y!ScGq@0 zuz2kyaX4r)z!sZ$vLeDs&PYQsCO!P)+!K0cHFwd=S%^_I?G`Z5H&MaHfN%0aNACwv(N!clr^#|_M4#~`KjDtuL=}_m;^+e<&$w`y z^&KJmcgh#FND^xGk&U$nLF4O#M$-mdZ0p%2)2_MCq#P~u={rc~nbiQP?)89-OCFOIEI%-_HOhhUe|M zJ%e3H4P$+x($pQ-17TS`z00=Zu~OZ)W^P46V}njqa~;ro-%bw9-RwXtWe0*wOGj=_EChnjwUViFawjC?T3E%?a^@0FXgVx|Ib`1wZ;O-%>CL=zk;FWInQD#4| zE!U$KB!M7ur`)z#svPeW((39jV)?QnpQ`C2pjU^+k{&T}X>hMo;ojVO)*5t0ZjLES zn+79e)4Hrbil&Xc{#V39OOPi>U$_gN1lBun-$fo|HPr|IY;-$sYyueg+2=M_`)R3g z44L&b;;y6qUpq|3G_fcNX!=0{yme!_7Pt8c;i>0Agln30SoylE{ zo7GVx=IA6u!NOvi{J;7Y?f2j6dKCMt8;}vN=+u`zkErX2`T|UfBw9+FHB7(*}KSJl~od#x!9kD5Z0iT!d{>X^~pw}($Z#+ z)_xnv+W6@?KXsyzB-to(I}P6+%S+Ai?&g$-+k=*Y(K3u@%Me#WyQF2VTc}J_ty~-$ex@28s^jHKjJ624Dy=tFxRiO%C@cwqCfTZ@vj62hYHe zG7vOAb9Ao{s!iulNfnWnx)#XNuXvF@h>mTe;*xjM?$6J+qnnE!|A4a<6$2Fa=Hgig z5}LUXs2;PEJ6Pz8kRWtccE(kN9WHvt`FJ^061wAt9L}v3CHAxpkx<*+c@#332VqO= zsYY4q`8dq7eJ|^2I_Qn!-W~X3E_MW#dkP-adzliV7r2 z1jGrO5sLbGSv@^b8vpCI$>H4PUg>PIal{=GouB!?=u}H!20o-H?;Rv3(o)!IU1a^xxt&2T%cArK_h`eB2QdN}z78aJ{;LsK*IIn+Z zWi9OH=0(;OxZn-rit6sjEd!wcxUsWE!;euWxJ7ts_E1LzDCA{uLl&B$Mf3%s~B9 zfrI*A8J70T+1VM5nYgI#zna-vTn@~?k7BZJsBi!G^`xPLZ+G6%vaN2})kXAcM#f)R zSqM3!UuO5M$Li4Aep_>V6H{dFDogMwZC{JPb~+8VoB{oj$=>wkoKDf6qQ zrl!X~D!+FIqFywh?EiN{*ag6i3?(HclR0i*ff_Cy9i7<$>)$J9q0qs^(2U~Zq+hr0 zJA-m*ay|Or=`a(a?U0U+rIDj~#audZ$7wnBd#1`F<@?LLD4ZAIIz#G%g3jm(dZi3B zoi<&#e@}YgrGtz9{`;+U{c0r9rQ6-O%JyQjP+3{oqq6knb-^I+vv57uWqLaEYN9MN zr@nzFVA%iJ?BV$6;GXG35c0*_;4`kpYgp?Y%+*d=R?}E5l3>fu@rb^9&G^D*2$AGn zt;~$K5{p343a3(27)FBso>gCR-pj-5%R@O^+ftpVZTni)GW|fED7DTN=T+u#-VEBm z_ZV1>Lw);_lCt(T^v#v?vWRsW`xiOE`$Q0TIV5J|qmg+ri3Xh#qV)DB1PWp8FAKZW z;wGu~Jytl?u5KD3I3yq4x$wVDxt?@P4=BsY`1$*Tb*MBQ_Df780L#_q^ZwmCEK*V# zLqn>Vm>5v8ymIW%Q^>h)l!f0)j%|w$N7oiQm5#cZtMlOMo2}F{ab%|*{7(L#sf!V+ zR)dOB%bl+F3$$B%%ZAZ%>xPy4teB+?%~gbXc<8!*?-hYPqy59j5P zprM}BEtY+!c%So&^S^20H3rpg>br88=f6I|=wW*)t-)S~>>P4V{F*)Cox6&|pLJ?p3 zYGqI`*C*Z265xZjEdMpozjdHK(%drV` zms|BDzFk1gdh{)w#RK~ssSXn&WYjmm2p)>RQwna1!|*X1Jc9qO;4K*A%(qBIH3lsg zf>;l5++2>*31!U3#6)~d`|H?OBCsC>zC9r{y5spdB%5CyHE=*-(zilWKv#ZRoA_F~ zj)>sn-2n!Asbc=PQk>KR6LE4@(~RpbET}e56*^T_tJ@JE=IQA^hyN8QZ(JluKSolmZhbGJk*LagE)fdn3xg6B|PA;rE z(=*1moW_*25*kT)>$ZZ}eFh%{B)KOiIi`BjtX@05y^C1c0Zd2H}^T0flffJtfX z&-fB9)oO|2k1G0Hx~uIOI=q4|6{+QYQZzdiRX~b$6Jg50`;k(;fZ$*cjJNN@351)y z?_5_SxTfk|n2<2Z+Sl!&*^&961ke@|L-{2vjFO!y;3&R~|7I}3QK$e3Ppedv{g&V7F zf5Y#GC}>-PxwRTm;^)~^nEOqt5@Gmus2C>I>tfpTq=&Xpwaia6`027Ow>h`_Xzp$~ z@8Lhsj9n)&|F&+*M^c#8=C<|&G%=JsJgoR$^Yw_(lk?>OfO(OS?0c1K4&6XE6 zWS4~$()V6=1|n9;lTkVW-!pZL?Y1p-H=zugR*tP^;dDtd=1v~&3?#F?=E!IU8m?Bt zpy!i2Zz`@t!6MjB?T0s>jYwL#>_`7@K1_V8C(j};r%4HWB6D8_85+Wr{!n^TA|Q+|*%tMJ zkZ>;~bmRo`chI!H3N$dVTyEP!!@qX5)=Ua13^wUuUK#|)B;3d9Xh=?1M_6msE6*r@ z{P-3$+hiZLFr$MepODDNPD9q1l$2c-f@7rMHsRe>0i5vHZz3H-Y*x?h@pU{|W+65< zamhRS>%HHq4oXdn ze0R1#n^053jowB9jC*B8Uz1VAt^2X@Xm+1Oz#TVbpfgJ7{)4r3@#0Rd>1451s`vFa zIuU#KDpWj*?8zexBOnL?Ej;>VN4q7>>C6tksauxDY-rdx(>TvTM_q8O`-At~TiK7# zV1}uw3Eq}gHt%WVuRBwyP?4Y#FTT=H5@-M|ICVV4-Z@ z`ul!YCYhFpyue$)#m9Hp`tu^)^F+e^e5b<~7Cr=c*X!4zKYzXiaIAE89~TerYiw*A zQ}b=>!F;`hj7)Y-%@h-d;@e0gsD9XYc79HqbW*$ci38)~{dXdTod& zCnrSNzk&#O9Zek)mc|ddKU@R{kAgTcwW2>m(k#c-)gDcTE%N?D$j0s8W;33aR94sz zod=WL<)_rP_1%K(QSqe3MnmPhFx|3gQ9Za@lNW$>OjVf3LFFK`GIn+>#uc@mFNZ*H z8GxnYktE9?Zi2R}tE!+-F#fCaNgcQSs!0H9b^m-9Ztv{GCL{#Sk_enB5#R{{w(bVG z0<1w+PEG~DSntt{Zmqx)4rqz+)c1&il#}PAiKp^6TPAl&VEM*PSvK776lf0}lFX%l zeCjIXI~&+%X7qsd!1!**zV?HZ6eR;gk*5Y=AVGSH#7{?Y3zgs0yBuz84QCpfnBWi* zg$@{JW@QEB=Dzat^8@_paS4`w!;XxM5Lrnq1gTeBszq#Kl5m%PdHolG#3;0Y8~5iK zRYeV=&=l8D1U$8CzBE)lm0kbx_L3ZB`83-u;*-uh&?ytK@ZJjad-V(sS|70H=kfAb zh;kSP;`>bgwc(ev)v+&O@&=gle((!G6dV#>R57OvaHq*OpM^dqn?@!J0#K%)JN|?J z_txZQzuud%0x^_UYzVE58JV}qKJ0H~zh!v^P&p1Zwx1qBVzONzZfsA09svRp5|;+X zKZiNP_5BbV8X3umDV$u+^`V0^Gcz-#Q0woNd00$eBA9&w#7ntIU9UZIw3NJt8qMgA>Cc+l^^;|TeGQ>OomNtN_$zyK~HGamQv z$Pd&qyeK<>{il@k{2c@q&u6E@g~pzlk!J+|{brpG=HJH{h4laSAY<|T|Nc6L=G)h= zt#Nb;iL6Gbd@cu;2ljsxcFerTVWZ>TRM{=N^&Gdh>+%!eCp(U*pxg8KYBh@M)3;_R z35n&Vn}gMrFs6Z6HY&#? zba;C@wApYvcxrKDA6mv}{2ub9ABAk7klKb>u)5W4*4S=1JF2X4(}4co9D9Dpaft+P zAw%qBoEUoZd*))krLZgt>vTnvrn1D~?$KO)K9)4!^TH#=Hx2jp_O5`&ruBWu;>JP! zVezp&x#tlqDW5Zav!%hCSFd!h&$e&(ZG2W^#L4uwN3!Q0(aXiO2>=f&vy0|c<(AW| zWFC7gFbB;K-s%7=Q&n+RhcPuJPHKBZG~eypIMvMsVGv&d=rRSd(aGKi9jjE-)GU@$ z%s%mIw6vNqC!NGLir2sQBywca5;?7ifc?uTe)A#aw)M?PcMk=zea@OoP7q2JC3yV` zoc-;hLcNIpa?W9I782FskHqG7vgQt{wOf4;+LbMCkwyvR#x55;qDE!;FnrsBF%xWD z>gU01JMYiD1FHk;`fzvo+?V~Wx3?fz3K%LHnn&i~oetV=DJZ_zN}iS+4NH?WWpwP? z6Zn`pLN`eJ@JE*OzkH8;_c(Wgbgy5ptS9JfT*sKtD9X=(Iy zg~?+y0~oGFhHG9-L<9~dFE8&Fm`ekg0?XOz2)@IHQ6`RwaTI*tLiMUpyVXv+jU*Fy z{)Z%!bUomWk6j*fE{k3f7eA~Q8r)#QiMa+3yR>WVslim=u982%EIqC~$mW_}=i}=) zAlfs7GCuHY@p|X|nuEGsqOo5<2?C9{#ai`W)6)lmC}1(?v-RgkdwV;3t_%$54)-Q* zy51>(8J{V3ZxZslJ?_$U8dpk`&N(nG`T7+bk6!gR7(wv7H4+8DIr5qBS@heH@fg&_ z02tn$EYV?8*3CsHOo#yeiLAZ?#HMDKh^`Iz_ zYSTscHcjQ(if_4z_+T|=;`xduUZJ3f^r3usvjb5qqB?Y?no3!#DrPeXNpuHEdXLM$ z#)a!-?#T4Roo^2%7pva6Lwh4fGS9HW)=?WenTCFLTlBHCha=)D9}?Gk#Op_=duwzU zli{|Jbb@*5ak2M8P_XG1-pk-M9UZLh&RCvwI)9SGAPjp8Yze3&%Y_w#a7)GY z2P^pn#Vf$AX&4wHUWf+S9yVTLV`0hX{bC#9kM+6R)mis3F*g4C?b|y`OZs$A=z2nb zDo@4jVTyI#mo9Rj)WSk~JVwnArlzkJJx?+AXR20bEJ~xt5!s}>Z4d&nVXE8+&8GQ|6X_|*k{=r55;!?qCWRBQ9@`;-%g6oE zj?%p@)Y4s7P)a5z_nj+12U64RiPWAmUt17{L2CdC0wSVvz4vCi2cuLx{V^Sk+inqr z+i~j|n{og5$7neyB`r6r}gT}wa$5e=UCBr zb1<#p_M|UDSyWwJJ(~>e-jD#1Cicr?>pDC<;0N3!kte58l9H|(=zvSbKra1$CdW$k_&(?Ay%nW?E`6KkRM1mIo;hKnyZwQl$G%?g>IRN zIW0{qIbYYEPiR>1Lp6Z^gAwa?gg&2h+e~`|{h<9Nxc;CntRoEnbjcqR(2$I){U?^E z8w2a25YOoaRX-1!m`Vqh0@f%S?$Xe};Y+TK+6{nV1APYKaiu?$<7 zHx4>#(*11(-1erF4ng}Soo~W=%*Hf6f;_u+sXYyv)Gen+JA7DXaG0ddS4NHMaJ{x5J79MX9nV$%V-(xj32;XsuVUli zw1L3Yw$>Ay#A1Lbn-km6zz-s9Z+8qO6r|~ziy4a~UZ>Z<{?@zhPWqUjLq7Lyv>Bcv zVE)7KnTnN7K7M=#BsXAf%wE@y#kCtmAoT~D3!}s#;(J^koX^C1U+hg+TF)ngcHcMv z5fUE204*1r(qQ2cOHGG=p4?3oYxP|nucAD?ziZr!B;|7ji{!SGmjlo(1#qdG!{&^h zSlUy77)G^x)z;2gQY?_vb#mU#R6SQA4F-3u( z?a}ZQXrP6fwIi!;&x^DhlOu?^P7<1Lv(>NU<@NPny3E-j0bPY&tvqXD^?DX%5*|S^ zBcy4n&dGvOWrREEDQPcYHQmkk=S(0&fRGPRB_M_<=;-w17b9ReAe9R?qo3!wrfjUQ zvpDZfo%nDoF=*CAXGwis6?au(T`@}kYp_(gs$(jm7&iJ5? zJ9>r$Qr~9t;iwa~gAg(eCociXGi)*po@v~KG@JdltKF|#AGPzp?KxG{ z?4BpJ^H&_ZVum{^59svg<`oc(^ALp?kC7@QUL9z{*ef#`E`O&hB%1zFiPQsCsM#H9 zX=pGM!nvl#I^BMM!RKx;U+JG!PpN@l;e zII5=kUrzt$6)~d6hyXO`sJS`Kk<$g(j=L{sFm+k zI@H_y)8%kM+ao0dh!mO*100D1#&s@-gArT{-C%iZ_p4{S2M0I){1653r5v;nTh}ZH zo%CgAWgSgu*svIMqJu1q^!xEpka)Kj8a<#+NnAE$0FC|vFE2eJr#`0UhLIavn@N`z zzbBe5^G@r+Js4bbzrPFnC$gJ;s@k=kG0Q3QzH?8NHHi>a_P$z1`cK<(TR^^~w6x*b z)-b(xLru>nJkJaGXcZl=D)}JM)K?(zyKemmRxLL~>FCHN@A{;m@aQu&hfO!V85AJP zL%OU#d=h%2QcT6elT=V#7mCJM^S!putQ3?CIjv^etswKtbhj{xd@k16iqnhL*7LpL zglu3$*pIx|ss7=~sMQDT9vw?66IUeV2N;Q{P7&ciEfR564iCJH5<$3OJI9bJIMUZ7 zFC!B}SGBv12WfBI4koCK8_#+8g5-XtP^VCCgJTLx4ID{Gv7>Zeo+1x_v3njggn%>q z1AaS+=T9xB#Dp4E&eLD6T1be^+=(@ECgqe)%+tHP%v<@3_41vaozT2aE}-i6`qO;-d1-R6BH$#&H~NK^?v&q}U7^Yii5 zdp3Px1t`nV$mrX{e3RGB$zr2dBGaxs7EtLg59YzL@o7M;slh}ow*{-rOl~_W?^MYv z0n2?;HCk<@QEfFFG;L|T)*bU0c|oNBgf!78GJ)R98;}fZjys8=Yq4?!tcJ)yvLg}n zOkD3vP}z-;=L~bsJ#g4vkm~nYWv)spIE^oq91WjN&>ZtGX*2KZTXzj zft!Sc*D>TxNyEslmAn!lJ8Gr6!m%Y!9Yhc*2J`@C{1$J-Kz#qH8sXVn08H;VX+Hof z-Wth9`^c--Uoj*o1%h&g$q?hiTZ^)-gJTGAp9Qx~(#O^QrlPH8wK5&s^BEoqW^1D8 z+MO1MR@QK({Y%7*x3)If1Bj{;l9E4xSU?8R0e%p{DJN%JLiLqQ`*(iYyxQJ}mBand zR?Qh1{5Omn+++p^wEiL0FwOnTp}}uRzR!%l38IS zGHu95^8{a_<{Hr@w_xU=fntAK{>&a$LH5ouZ zOzy7F_ZFL*)&G2qfh^#$u&{vl9r8x1K^;-@P{E6E;uK)CuOMvz^4bgO=I-~r#@R)HWV&))5$;g~@$8Cb3l8oqva^tVT7%Fk8Hc=<^R z&U^y`5d5>@S99cE-XtZ_OGgK~tTc2meyJt7kdios24Jx(@yJMr8zqLCMW;=k~c0fB~u1J2QZI)DE~T;E)?b z8ACvq+ffuR(Qdo~9B;bTffndjMH&!RpLbBX#{Wf*nqe- zRb_z>;+!?~S(^2HU75usqu}k4C?Y1A=C-}jsn~+kq}Joq85$eg-$OSafkOp22Ap^W zvMFG`>Wv%bge>|9U?ph?2?+sqIRe!95f8b~aR~^d0T4e{8-X*9@_+}RER#upA`1-A z5Af+#OTWv{5OP^-W-pMIG@PIY>BZ6hxUcrOv<6xyEh-uskMkY@;JS|cGtVogrNIkA zL;R=cqd9Vy+|!X`I;N&ubZiK}==DGh@~HAjHsZ9N>jq^$0GRM^1)0Qw@j*W(gffLc zhj0~-`090q^+-cas;>MOxcUvpGT8{`h9rX*B%~Y8VSg3Xhe1$OupV^acbV0tA>aQUu&v-~xrUU@S54FrKFz~_xuQii@8kJAQN<))E}v-5 zf!WJmkfKK1j45r)+J8H~HuS(h|8mAG7pNA=Sy@zo=f8po5q8}nZj6S5eU za-UlkKrVBg8y_`ZV0l1=j#_MsN*C~OPu&}vb#6wHT`4P)Xnx??W2sizNO)llwB_nhfr+?fpKI~P+4vC?w5>H9kq%4QpsUzh%9s!q2h3lRR!ISsNKughKU zk{5N82xJ}(ew*$Z5i9GWH{K8RcL*C~c?~v@ys$(KJJBdQde_m@P_e-(C=;tPN!l-W+dWFLvPgQ%R&^e~ z;PRQ{hjIO8gDH(7C6$8W*0@jKdfSUroYN$eruy~#cZr@OrzG;$DC9^fz2}T!Tb7FJ zy^N~N78)Tph3dHqkw*#U_Vn8x0`axG!9Hanq;oSf=^in*eRwEvF=bG@68feF?C$$-I5?_KS9ZV}UiInRw}M1W zSaGLy=>i;~0z@xiB#K9g5Ga?tK!n@*UQPa5{1!J6^s#+Ey%ZG?+R}xNX56=f*d!}2 zj|I>peomS*kx7~p9&M^ETojwS#U3C#RM2_`wT$i zW03wFgUBD{54WYyNUmV#75CYjuZPU~kV^wu22fIrN7;+O5$XEFG5NzWd8%C}VFQ44 z{@(q7VYUL^Hw1V9*V0yKTV7dN%$wqxpNl(8q~M)<)n;!2o5u4A-`OCM7mrdiXes>( z3n4WZpF@rY7k1L%TPo`!qokzt2duMFMJddGT0$r>E%1>C0~>Hub_G7URh>9w*G1Y@ z1X<`E^1fj6Ygvkx4(3^pQ{V=>Z%fgDZO!C2g4a2z_NYSF!%^3o^k%t5s&>8!2hHDs z9-TC6T_0i87k@4}K`Wcu4YCeb*6wC|@(uVK2=-g4?mKz(fCr9@e0t=F015x!{&;4n}oY&>NCy?vr4*g-?{lL*s69)MKr7(cJp~ zVec)2;)wotPn-}e5Zv7zf@^{$XmEFTch}(V7CgASLvVNZ;O;WW_WbVu{_pOKt=ju) ztG0Hk=0!;NOi%YYea?BF&-ZB?qG}Ofd2a&9B!Lz$EeF}7<6N8kf2?CDDk>_#$G2p~ z3I0XEdVWygVQp63qx2gE`Zl-)frT)j$!ngSGLs1jIy zAYg%?cC|Z5d`=bi>(>z4DPT_*S5`&?a|Q?Cva-0GssT0;>eC#|(=`Y%9n{<1STEXd zP*SuU5H{cLoA}B)>Khxcfyu^AFCsH=I$&(tuLdUK0I`j>9gvWGA#I=pAqCH_E;jBr zAE2zYeFRUg>#7FjLTyy2KZ<{=B1On}wt4sjpAj0!vl&geeS+``;95vVh}foUYFeFSjF1zr?7;2w0S zH8^Y0aL-eEC=W-X+lipUMga`Cj?EbW{SSWG9|I>+V5dld@JdmcPqQ zwwt;9|M_je&m#~4EQtZ2E6zynOu>^1A5?lbR z+qMY+UxZv#VGI}WpTLMJnFR6cyDqS;2QLA+DC#N(jyEnK?DFuMeT)P) zymjJc^Y0Uc5*7K+e!!m7dw*~sXV@?QL)M{h-oAUFp~dAd#&MwER!2e{J=vC91D{4A zNbp_P3w#Ju$A`Pb2%GRI?hxqoFaL=#LhrhLcL(paTCLv~{fp#ZH4I50F2c+mD{ws5 zd(ISESHKC0>nqy@bpu=QLdSi3CESjMBLVaaT)^`Ocz2Hgm#iMu=L;ov?2Muy7wQv2ueZfGP|AsXGFBklOt9AVU8~>mC%KwXJ zqr(u@75X2^sz!f#CYcwNnrX#Wq34>~^BQ-g>9+A$8S=M)tS7mOqHg2cj*A7}>$;ng zRmcTgsYmT)r*hrumfRJ0Y0X1OSJno4hZ^6W*Woz&nd8CLL(^S+d;}OxbL({!>QzbJ zzUaPfy&Vd!N(4>@gx3q|$6G5CEDu(A?rh^k!d0$#`%Lz@ISwwH6z^_tS7Xj(mb1{L zpcOwD`j(hZ(W}JH$s!#x{eTm1H*b9Rn+ga?(j*D=iD;~k!aM6`@Fn>@O}M=$&@kjSncjEGV-PY?XPc#~0dDUEB$Imu zljEE22{KfEu%YaGh@r#|pTwkl`N;hI`z?C7{i5GvSJ8f-*RYLbUyAnyRW=4Ad;wim z`2XlmPnPN#cmJ39(7&m@-$>$bEdnKo`XwesKSxFQNZZjanqV&fs_O34)>bq1xk2!{ z$Oz@w+t#Ksx4b@*PIpqrvbj#incn}O^hYdAHq-EmiyNB{50vX-EQgt>+g7LUSZe5=Rs3i$6~aByg3#32YaHhG;zA8jK-Yc(~N-@4hti|+-CLmYHx6mc#BQ&=yKSfa(s z)V(`)?YPV!;3iHkibHY_`%zsRDdJ|6AjXKy^?At3mKn?{BJ!7s(T}~+ zu@&Drb~loIi5R`Kg5J&4I`{n(d&D|{X{tfR!(Laypd&n-4xwDQSHLUP7E1rEg*glw zP6Y&`Wv<(4cT%SNO6M;O;kO&jch1rTh9{$`G+^#%^qjd@tHMLCU{}6HWUmA~;s=UW zZ*`K!WU@od%FW{Es%Zu;_lo;E&BK;ilDR}hC%x4N|8c*ZFneU3kr5Gt?N{ypD8!4S zrrm-eL@n7!4rnlhx%0pJO;+}kkV zH#$1Xffqk8yz#8iK=9yE@T78cuBflP9q3&+V?zj<718ST+QA>0s}~fhLw?>o8Lz=k zu|n8Fg;uj8syOi#C1b%Lyw3M~8lU_ry_ex9 z-s9Mpe??N^@$GPZj_=xkYkC)Zo=UGNZ>zt2h1y2;CWTMes_32Tmy?WpYy?s z<+=kzfj9YIqGVu>BeV?{7{WuNinQY;Qsq6zoVg z8e}9!I~yZ!4@AL?Noxt15Cv&$M8eVbok>fFeSzWo>Qt2 z#ms`D!h}5+gQ&Z$Mu?>4SsM32v8%fIe>gg&M4Gb7Suyf|1FSOx6A?uyC-8qU0apoC-b}ACvRw(lUJAkkuezj;avi0r^4L{r(K-6 zfLwv#t4bZQYUD_SMZ0k&5G3P>d5%VR`1Il@NCs*NYzIPKxDcTD_G{GS?kc2Orct5T z2|5qcSg#XM4tId~zJ>6$4c(yb*6g82`ay z)(Va(-}#b85@n%G@t^s@nT1{XP8JfsKlgNT2d2fGQr!5@D@pSaZ90y+A9IymT75K( ztlj0Nhdl_!j>}F9vvf*XWZEg_H>EPpU~Il9G43z#)Jgd8CSk(nXb&N!!P?MeH|ur6 zajklBp#}c~NdLmXNg55u4!4gveS4g|S;o@Inxt%+)7R1+r;pIW;e&F^uG~p`hJ)HY z-**jEk1+Yuq1ktOD(1#`Q({fiedQmMPMxf%ctEKaKsoGh*dB2kQ8zSkc>UD8DzUs* zN3;1cI>Vwwd+NbpIc_{@>{$fiZM_iZ7b1Z;a`K)VHFMBsH+JHUE_SM%UO7|>RVXF%F(cUOR@#}6db zfe9JlW(^bC*8^T$0+i205W;5&fGLItv?Kngs3@!T)+9hqj0a#dOjWI(MZkd-)Xnn- z-CqHM-`1T60t^&jA`=2+ETFOiB5o$Yd$1Be0G{=Kl#b44i*rCB2G`C2qr!0d7fXo8 z`=+jO;2XfMBk$^mb^2SUX27$992y$h0!-txx(c9w>cV{#j{Yd_L>L=HqVM?G7EY!p z=J+{y0w~Luz^N|evB%Mila43AYel8t#dc@t&s{#Y`J2p?j8+p|vTb`@9l_jJCE_XtSq~sfwZC z;VFAT%MN#3&)hQ)(V}k869&1i^BTGu#OW)-bjv$n64WtU)L8ZRRh1B zfhzO73L@54BWN@2At1j(;gB<`t&pXfP&{v9A;O8r{9r)~m`tiZ+$c5sv_LCuRz`er zOgBR0sAxC&Iws^NhMnJb*`$iiW@prDPju)+8$noXX@*Fa;fpxajwmVlm!~M_3&u)- zn=i3D$ee}H`cMB%;jPyy~!$%>&x=bf6#Y(vvM}w6XDt@Qw zWm}Og-{T5((A9;D(`KAo*Bp>5h0dD2CtD48Y!VZapd)sS_w+n^yXmppYF+ZL;qmjW z$k|e^(f#ZKGIt||P_vV9Hk9+*H&sJEI^mJ^nOjweFkoWCO9;2I*BsUy}ZFO-4pW%XSF98-V+GtUmx;$>kXPq`Z>S+}ZuZrf&`a8YpOM zj|2P(VB)BmnC{CCHU=i9JwQ{q zY`Bil7EdiK3`h`r!*qYA&H@0~^h#hNWiJ9b^s|@EhKxxH)0wYv*A_)YBgN(?h2(Em z7&Uu22hpyZoaC>^O$=`Bq+V|%nTv*?yEEN2=FDIDC)k^fEE_ZU!@9U6@p{xZ&st0! zQ?d$qu!gRw$#-}BcSgu>Uns}e$;%&~UPO{fgQpx_9d-$wgIMgwh78X+u!qCo;UF^~8QImX%O-hGNI+Zd1qW^{^V+92g#%%^H4r^iKM>`4KZISfKLJ zjRqI4b_X}u4o6n4t4^_`+RKWY~miJbvwFeXnkA!p=}i^c+D z@o6qEls@xl&TXtVXUFRNhNL5<(9=(ZY!c zjS*#bh0e(J^)@y%gZPS~<)`;h2&_!nZ}>)DOhq~A$9P|@^~T32+a_D5y0Mt=sw9fL zn%T6R#3{u=9I4}wV~ZzO*2DcomZbC3>SI~TfmOY7Mv9nEDT_CAb5^x$CESE4r^5?9 zF+t!V?@Ag<4r|*M8SVRo(VO!oxw95Jmb69_$JI+hl3U|*Nvk2`zz`-zYlWXbIU^#- z5EAOmIpxn_iGWG-J(whC? z_5%p`hcO_rfcweJck1bd1#x$G9zZ$t0;GJm4jr&(189yV<>mkU;fW3#Hvx{q4zQ)J z07Sq6*nt&L;DMS`a`8w#fR1ekG(n)-*1BC82);cU+O(ZDrk)|f1%lx@fWrvUv}p+j zqd>bcLfP-Ho9~6dp@DBHT3TaZ&!O3Vw_1&%T(>J*lA;&DijlkXV+2d-SjfoGxiI_) z7-gmO^ZLGO=YPM9f*>UT@i{X3;A!~BF;PWZzT;)<4Z7zN_MOBe_jOFvyjxgU#zw7@ zw7y9=G%=s=6Q?L#k3yhDL_A5Q+|o@Sa!*Ei)&}w&VEB&2xIB_wJ*M%j zj+W1WU2OlDymlPUWUxGOnRAe$J!!74=)Wvm5G$g_`PGH0P0+bbIiDD;gio|j1Xl? zcn)?#+FT24<3CW%sfF=^fsp=Y28^rR;L4SEJEP?IWbzI|ptlL_==m{g0A+s|J$uAl z=sqjZ8^#0o3R-`p2`VWIxVV*Q5g-d$v4^X>$k{x|pAcZ%Jcy2b-2ZugQ}Yt1Y{WDf z7mT9=>@BQ`jNG+tzA^z2 zokZ0%G9x2n*7hJ^6pXdI-?YjYOUcQZ&J=tHQ&oVOPIAdI-D$Jph~tdd6w}??m^V7k~dn#_6hDlz`4Zn1Kh0$ z``u{2TuES-2%m-}BA`LBWY?Uh+bQkqm&`Ov5MaxJwuYQ+TNpo6_)X8X$WRJ`eJZHS z`TcDBca9$dl56bo;R-F%c7?WB^?0|tHl(|^bW(M}zj&^}sNfHsrucb;V~{WclUwU> zk>jKnA?Mo?iT;e_DVYdxrzkTRhiZ4de;>Xgzpd*vU(+-%;NJWw+iTbQXk(}UiS*J+ zpi^*R#Zk){nTNKct!3egFv*3XxoYAJ-RIOcXO~|UR6{P>I_418-Wgshl&+U0yD`pM zbOAx3X1^ILVZ&Bkk&TTHU#i9CmOF5uJ`WW+21pbFy)h{HYhzhl@Sb0tSQRGm1CeSw zCH^N1pj*#6b2Uh0nY+2i3C#9<*do;Hp5KOjXEq-UvWrq`+kJ?HJc*wA_vWmlw=%Uw z&Ss$2UV|m$AavxMp2f;193)Kf$sTnt(N0fjS*@B6l&han%hZ3WjJ>F*aq~sDjmH3U z-@Jh3v9VS^#7@49yd<3f@7K6*Emh>&0a!Z2a=KvUbbww@s>ivOFK8HYE7Udb4Ki zrw7@4r;gXQ=_&H+##~UnCC1xuIW0UM0ZUd!eNf3I%l^x86xNvX5;9vBG~NyiIugw znCH-=V-Dx!Zx+nyVBx+8gbe^tou1lx<0Wj zToxlNRlZS<8QKwZ#}&ED8V!q%p2p2|&w;x8xXU9rfcdm#LY&K4U7$Vpj%gkd7?zMX z@BZRzjhk0WM8)VsOr__n`{Vhrv#DPC2Q?qA8R9gD-qF%4FCTTA29UqzLgkGzFsQ=9~rIL`cKyyy56#?~q zO&JXSIU?w-E%GxX>xE-MgD;Wo)y&k*tL-z;5+g%o6t&@B7PakqV7+$nH-EWr0{Ain zP^Y&INO%@GpvQC9E<{o25CBK(Uo*M>H=JqLi@sq&CCK*@wq2YU2^(X zn^|SR6MY930}$N63gVgH1&H`RXr&{H2?bWj{cAP=*XMnnU)3N3wt|uX$w`c1muiQ% zC0?HU?*Jods$MAJxRsXe*8sxBM1x`lO2xb2Z}b%;SL>EV62IJ_wbRe9*1G z-LEYsKa$Y{Q~BA=Hp>gyuV^GQy?#l^i(( zUm08wVvPp%AvYV(f&^Lgzebj|L(9;zz~SsoMSSPRAcGQJcuP#%q&;yx5is%m)X-;S z)>^;1ys^O}OOUemj%nW);@ytf7=jSeD~hvbP@bId`Mb2A-Ky-@i_q;%!RYucqc?h6 zcA)|7p5x`Q_P*73|8gFsziOKWikdJtfe;(I%zMQ#o>%;dsN|L<&2%bym_3_Yq*HK_ zP6kb<6ic;riz)-=C{_rNU)!MPJfX+ZSbiO8K_Fn28)h{R^4d=h8NZPw{N9Wx_Mnwo zgkj}UayXDXqhKD#bt|2gKR@|kKF=vJ zY0jeWWr_cx$=}<;qiRIu6>nscQ;O{SH@4@#Zi^g0dTYPQh8}L#u)lvNe`?b%i!D$f zb~bbr_oTkT@mF-?E*qiNo;s`6hzmfRZggY-Ge4-<#JP8ZqM!Nqs8`Q}LT~=-8z3!W zGh*nYvT&tEG)nJajvDbSh;=OQR{*fzyto2bNmx;A-}MGd)hhYDf&|%K3jsRBd7#ko zZvo(B8_4t=)ywa)BD7C`eWc?{?iO#?-{Ro?X4+%liD>DpT~Bas!&Rxx2-BGA zhnX@v?alk0A^;b1rF#tv@AgBmfF~S{H6APL0~Agqj^HI`%X!TD@I;4j?^PtXy&v;q zNY4jolHQ4-?6)RDwz$`PvN4ui5kd2~A)=6iU~9B7RB}JqQQ=8cIPpQmPWs;8h%;1t zb{eGBj07DDIm^DkFe9GyEu9iaAO$NkV4$Vq6ONj&>WZE5B2+TmR3Co!^xukA=|G=e z%Cn7r8-{2@b;G;d-1!|J9Y$zb zZ8vKS-(LXs6{ITyveX4>99($IT;cpdy5GJ8Rx~be8x5^v!h!(WuG5qS6v>xv0Mm<^ zV5r#qaO6P>zK}sVw^v}M+s4W%R;;TwOSSXsigKP;5VN=tWs*(LAC!SQeXfI|M&565+i#+7~u-{ciJ4)Z&jA zu~uDAU2DGDr0kxA2xR^bwrrJuX`~c&Z*yuwh(j z3eCnqu)yQ(VUX6A354Sa=8b};yU#~@S=VBGy#?MD2u|}!Z;vC~T>b2IIL~p6|A3sU z1y^_c&U&Tnqi(m3G?1xI^L?ywLfVG1XWkQYJ8!@6=2;$2e?bZ>JZU9_4yKfd|NB>z zq!s4`j9ec$f*&FTFKg*XV0>!YR0VzQpQ9)B>Ag&(JHS!l$7$KgcUc8OxO!Og6%2~z z`N?}uy<1TN0SOe%&o!D5b-{LS9~KE%$|+VDg;TrRa((#3wq=O;E&1KN&Q_!94e${@ zkv`r-oTzwxqG-mK#PR=fC0`*AT&{@Z`t@ZzlMiiAfr*kkhh`5(pycE?`fAayFq7JZLX`2?{c+=9sdsfH3O|mk7)~BAn$5w5^EFx z<;|j$vAqGpHs0IsRs#-|FB(aRY*WNEkP*X*ae;$Ne%owXCs-|{7h1!4_D0qS8rl#% zUzsKHr|7;y$jSF?;~Kkv0KL=q`oBlXQXWZZ{?c=F(wO;0K_8}l&ITxXg*emBG+v}; zh&wlrEHzs%HolgSciVd2js1ZX*eR`yQ&?GWpCNls@XbC1_9sPou1$%z0_4Yq)@DricG=t|&SI+h+vFROYWStPA||V=rAgRNNt^hXnD> z&}S>|+0*G`ny50hy%;7=Ge;iJ6jZ{rUQtu0pS{l^KBdmUEclUOf`~oDv8v=)yCgT| z71Juf_p<*F=Ts%5YW)7Q6c!?ss7eIBclBAS?m71VDWdJwGZBc_^Ro6GsYssUPu&% zYkeJZkk6)H*KVFu0HLXd)3|@PZMMODTi_A5#E0horup0cJvbMh~5_KucCo+=NJ9wLh0_j1o z^Lkzs7e*1S_1atQFVHh5so0NB^tW)TnuGkF|wfH^3jSB93lgGF--Ks4th zPe|jY+_2^0lcN{+?Vb2Lz>j=MtAkj`fy=dOyYMrr{iuL;yw2pn$ml; zWA>$ApEl&bO~+i*ZW{H(vB4k;49qOBo|$*WUi6z4#=_t)2ht{4aNGGdb+li@-*Gra ztFYSXWRKqd>~7@!;iL6c+tIg2ImphCl5(_tI$VOi^6$L64$5BVvuTZ-mG66KNmbc} zz(>Q-80ATEdU)JuB?42Yu2t@7JmfhqnMVW64VG6Wfkv+_`7%by`P#zi>qUWJZzU!j zJVw>5Oc|HR+WKh3eB~@@T>+^awct${sg(yiLbWdoF7PLHjDl*^g$Xv4yvXI1+1MR` zA=qE|twz`qNmbh!6GF4mN*}0`1P8NpH2#M%tu-En7@C~CXwn#qGx_@qZOvhn5h5`; zd1h`7!?6ue)fU0x6h!m-dEi3!B*5bTKGOs!Ciz)B5(z?lT3Pty`OXQFZkOEr2eW7< zq(RZOVv%Y?8JGy@Vse+JspIT#+n9p;0))Shlgkh)a{7`K2`DHvx~&S0y#?e1)B}Z( zuw04U72%&?-K|hyxwy8vCLSKW(xytGF_2j zRY9o_&BT*|bqHMvgI^R^iQDLzirRMHw*7z8*>k-v!sM@ja<6_ zNWFW@__ybSy4Xy?fIzh^?-#NHc*by~J+kLAiv()0h)Z2&`;{cNooKyB1paY)dNnvK zGvk?>j+g5i-!5Ck#y-Liwb3CKuZPzx7Bnmm-pDZlE)5hGmphmDgauiCvEt*U?EOpg zuht5m((_cY@dTz?_8I$ZV{-cf%GRy3MiPAd6ZN}Lh@x$hgKUM5;*0Dj=?)%?`cGbw z2@KQmf7>*cIt8Gz$bG&(E{K1FjxT=D3~8YE)ZmnjK&`?p^2v&69~(2XW8)1C$b+bW zRet1Bipx3MOT(T0F7z&K1volL(D&&OZ)e;g516E{E;N$Xri`WNvuI(GbA%ia} z1eI#rci2YGaY^R+CdRR)Gi=hIPwVO)3Ho*6_5hcK0su=XrIM?ShS4{CK-RUjwcr3* zfUkXg_3;6eU+8J{YM>67n;V(um&sUa4;WVmDkx1W0b9_fWe*DwfPm^rynev_{580n z+uDlf7mSEKP;Pf4=A`ZV4@g%z0E)ZwDk@}*9mPcdNy!1m#}KC=re*-M#twT=xaAB) z#GE>Tv~-=qlatd}Yh31v=W7fK0w|?@kxwYP0)} z4+8F?PPY>9qd`v$>K|PKXdqU>N1=yle4@?aOLIWV zY+6*{wKEKEi>X12oxf}~O`PiC^hOzqdJXbVO{GefF?v-sqpKD@o3sO9 z50qgsn1=dsca4cgyzj;XEPYt}`LKT8W^B9F#v5Iitz;ugt)Y{u)$q$vIm29 zYa&pm2o%9ilL`S3svOD%)?+kA6nyqsaQ-S=|C|&i@eVCZlM=r(F~}|ILxo}UJ`+Q- ztWDf{mZrs?^~_F*&gMYZ!!!XjLc)$TrLzN-`n&OnOXP}LC3Rp@H`JnyJLR_>ng*j-l+msNv5*{ZrYV**z4?zZ|{Dn*-e!FzRy&i zbH_8&xS*awAFv{SUS{cv4Y1U%Pfii>oi{>90xHndwYy z8vX3w4*t|T`T^BNKyZUGpqIPx69L$Qu#^sj(TTQ!Fv1w>Kbf#-?-b9m;lCe+b>*P#FU< zhqr#*F)xZB=lhR3t?RG-P*n@O`^E>mtY>4srO5%%*?d&MERYOd6F63IS%$7(hM%0D zoaakh)9XWrux3imWb4c6$<=J`$ps!Hc*`{Xo^+?fzEHj0n@1N2+T{XYssCix4o%B~ z5p{|;Q7AW633@)B;x^QtbOP|FI~4o}O~1-z{7L~O+WVNJBRhYBR^2?5=0I}N0!FYU z+h;IohIrrssRwns!}!3m=)9_;L+#NU$onAobr+ zAvj%cC&>THvj3eO*MFYW87-*+qYV5N{Q7@Dr2lW>>;GTfILs2ws}P$77^RnlOl9*O z*n=z%SkJD6i9sI}Rbxe-LcXnBLeK6O^V8?FTEiP2pn0L1RGZ1T$!o%wnX+3NU+QsC zywyL^b}{6%F7&O(2L2wWXl{(pYgacakTqHPa-(*uhq>WOuFV;~-<9Kbw)NDj` z^TYCT&|~!M{-?_@&cs9s#ni&h#0Py{WMkO6)cY*N)J%bhml-;*inw#lj+4UU4KEX8 z?QYnVF{ArlHVaOq^H=XbUhWh5A3A4{!rS+^dAta+zX4VET!7ikw>AzmvpDWT2#QKv zZrc|^aPbTHeL}adCzU4Wta#g{YpEe3J0c9Kj%CY-P@Ty&v&9Cq5+h9e=4Z?t)tw;A zR6g1(JFeX+?mtyOaVC4o`Q1*WG;uDx>sl&3_@Z%P8Gu9zbr+DmRD0dc)!l{e) zCA2WHJxTUx=BZiSWzC|GbHP7pnZa#F%HzAqfx9_UlR<;V!$r3RC6_Dj7p|`F-I>v= zGxicNzWMNm9*~YrDVJkqkoyJPycdWZbDy4i*!Ja)n#Tj~R-mq-*xfr>gFHi`s=J7) z7*lk0PI?zQqYOv9KKA0boWlyW9JJKfJ@)UpW9)W-?M&JN*Uj`TF^~&InyF9 zCiAVFV-KB=iBvz1LpaAU7`V8=;cn*0On)TK)%kV>Dc$ZmRscM0^g0=7;&tDsXq-OR z1fZ?P^o70@-lV4aK;rT|CC)!1G@%jhzXt+{R{0L~W{#LLyKs=aAtohkU7E`+iRdmEwJK>XO~Gag9w6o=t5c z6D~j5>nUzSS|`4B#C!z>SdP$+L^KM$_uxVCwXPk|nogf0QTdwU)giXMSwjm<2D%Zt zh_3Q5QwK(7x+f}uKDa*Zj*mMhVosRsQ{s-%nfY!AObzwzKA3apFkJQ5F+_&S3a zGw9RYLqoSh=)z4V^Y^o|;Ze6Wcl&whi=`f?d%Ahb5~<(#jJsodSj6>k;KUbyc{Jq| zHNdGe0fAc)TN=6#<}B8qmd)?obT#ZZpJ8Cl!?IRSS!Kr7*JDg`JcQ)54kFhp8(H2~ z*siP62L6?&jkPtEuHI%!Mud*+0Nv7<#Zd<>X@jXs%J)E6=)svUrgI9^)g&2_GQwxV z&iD$&)+v-BhDj7aI4R*sS@-eevVvA$(cIjb&c}q?{skG z-@F_xR5CmGpyiz>nV3P{`{ z2U2)}qQ(FK?E(hhy3TA-@E}DB3@i{!I(3Q4MvlJY7bTt6WIgaHJ4aut_yc_R$g)?G zWVa0)l!Q1^L^sJs1iUMVKkb+Fm$5jMx$oU+{GiTIN?s7E%qf4;x`asK1SqSNiP45|A`@0dp# z-)4l>`G}HAy1kKv3>v!fNEbH{A67Rfz{K-^`>Q?lxy*vLV@6!@ima6L;SqobdvCVL zj;({&1Xfvvsqee9%Pv>Yv6L<-?gq?2%*L3AMAztWV*_kh42aw9R|poC$*hZL1*Z#z zkGvp^SN=)pP88DPbM3zogC;*O?Y_*TPV^Jekot;wN*u5Ap1)k(5C|2i+PWX*2@}nm2`4>B_%)q@PFB9yK9Y zkw0Cm77o!W5^9Iunj`Uj>wjHqFuzsklUe)qhYQf&^R3)68OI&CRRTv@R`%m5`={J= zLZ{gj`n`g~3Hxa#N6Jj81(vQ*+}WsY!I|VpVp@Tyg#<3xI0uYV-Ggvv5wZEufbpVb68`5+F8e zY&QkZJ05hvC4fL|lq2}*o1C^AW6Zu!qtHUpz>IC^;5US5-hl4+b0qkMZMcOSw(qX> zwcZkwQPRD{sT4d4)iJetgl7g50&*;a%dQxDlM|E={PfA&Dgh5_QuF$WX*_hqkccFG6|Q+DljGq5{+ z6A2&gVE@&I!}%I@8^U%>ZsqW&v;&@?3cZUfle>^#x)4mOR@!>>#kN?Y*uH{SeuQ9a zQ_fAx$8FDvP)8f0G}`HV(>GTl9_ox!E5zNhn^^v0zswseHHl?A?Jqek#q&RbVt3_v z!JGsC^Ua;@H0$Mm+Qi;A<@s6v)-KAKo!l*mv0o&u>%F22t@* zhWo-RV_97xNf)mm=vYXR-whQFlPBEVL1`zv#8s9klarYT?v%M}jWH`XdX)_O|EiK} zm;PAWK0Nj_L=l9al!@F=hj}L|pQt~ZK|LeTDRG6=Jmyk3L2IG2{1$AL3=^Cat|)2r zj@>ZnQ92^wR|++dU(~+PqL%pF$qn}kgBhN^=gMRE+vtyHP~T6neWj)-S=;kY^FrF) zXdC=o+0G{vHo3w0nkjGbkcbu32S0ndXZ>#Hw;+)ElJ)Tljgq?*9dn8mi8_3PZ9yH$ zi;4+@T2B$72|<>#VsOoI1EJAhv&Uy)l$eQ*Bu#ScxK4M}WI-w!FYBgW7{XQrx(q_YqO71`KutY&lr6(MgpF12E9JZo_fC@29sLe+K?o=Xd>p^1k&Sv zD1E*SIvTXUF4x>+4vK5%hB}jk9kJIJ4JoN!nH0`Q6QCUYrCytuN{gqo>minSN`yxtn29PXWkD27+X6IxK)=B@5LR-~dQ#apI2amA zewfxx(2QmgM4 zpsMpxn&%_(l=Tt)ifoQ13mU59DeapF!IdgaX#FlL;-jM{!X`F7y5|E_1y9ZZmbNhO z_sAG#ev7(2(|KoGAXM-8vvm(#5@R%qxYsAbD zw{`A0XD5hgY*fnF? zMU|S@Aj}g}6BL!XUT>ShM>G2zr2NZ6nuNaS`AWlMiYBfk4If;V%s_7=_`Ye`!}Kf1 zr+cU~QRvps4NA(He?6`h8Jv|9z4ECEp1VT%op4Nq_el{WUL_PYwSzZO+5Gg*wl1nh zTUjFuPKXV@&lLX^m)_f_^|+I6CEW`dv-`v7Em8Umn`k*e^$;8Hc#M*(d z5evU??o@2M4Sb2BhkI8=W6WpFP4C&B?1LkPA&u=A+Ox%ffF>n}e=+E{TDrUXl>08K zdAB&>-BBe^gJYxBk3Xr+dkM?u`5PpN;eAvbqvgBmeN>Bz%G8EoMB;w`y^xxpBqPuS zepmLi?+ZP0R$kL@9bYZGTo8S@eo9Mah=;Iy$3hQ!&xQ=TQm+)g@TKC&v(U)Je=EJ=H11#mm-g?xWODZ}k!nTFR(M2>+W{Obq3r%-Y3K$_D!7_JCJDI;qL?#uL>5JOa}3m8kp=j%x?{ z4K-Gi`K8Le5yOeGt>5TFoWU5fbp6~+1s*j+Y8S3NE&P;(K*^Nc&7hKUZ}tyW&^0!k z+j>LG8mFm1-|!?{-3Q@dQr`H4V9E|HawY~$<5MDG3~^RzglJp7?5TgfUcWyszA$<@ zy>Jim`3kQ0&}F50hcawInwH+sR9%o?0Z%sm*esizpu4jN- zK^hPQQmxUSoi%$nEm}aq(YHf|?*m@anfiyt(&wOoZc<^_FNz=*uTqV$gPkl>dq>${V^Q7ivzug|haK-0EdS?wIyhLHaqBi&s? zo6t^&cwgr-#^*4!kuU1$J@~6){KqgokCl+sp#93%`DB8R7zjZ!fKQOZ$$|3vrsY64 zwluj?qjV5=!%HE0%=|Q<>@Ec*6)5u$WB#7c49`u|93f^=v4JN=AoPs;6suHcvhYXY zw=yODU(s7jmOP1b>NTqsb6D174@O9FA@rAwM?rQwQ1`JNK79KLE(Fxl|2k3w?^?sm zAhK8?=Nq*?@~*5a{1iT?=K7s6wR{eJnr{!c^VM?LyC!HQ!Q~XZy~4z>C6jJDyKWQe zFx;#}mXZj2S~GcqmMVA1cS7cS*YzkR*2;T0gjGml=-}g;53|`@4hbH~mqwkS_OlB;r9+CAf?b0$5@%Eli8B~_` z&lFKQ`1`MvZziak4|uFZzZPIRDiirG8By&_uqe??Nl>%eXi^>7D(MUA5BjWx@9hd$ zd61&2ex*p!QTV7uJtFB_12c=jhZ0IycSM4v?Aa}8{EKqOjC0v`&Y*#>dLgOW+?z4K z;jAZYbR8PHO$ca+RXBnK^R$Sea;SG#AIr+d7K!=FqH5@Xz2Oa#T_R=$)e4ugx_o%(tYv0p<;PbqjNFT`z6eXH^= zTzruwoC!}?HG}Wk5eL72hOJ80J4zicdK{2`OP5JpRJZ3QP{Ws=)1YPpLgoizh$n&g zG$5dJz@ou`Gvf&eL-{2>i#hfTgrGkjmx*wcf`_HQBtz&ptRLveh{m_Zq@MWkKorp@ zz$i$W(9k_~VJN_yI~AYUNp$U?9(PIMl9c@`=#GEArQT?X+a_C|nq41K_n<>$CXu2x z`=`8o`b3@(w>+#w{Po(-y+l+aWMBM{o8;)`A~V@)DFqEje7R}-6fMN_u%#AK*@B3T z4i(};rYIVY_*V}s1Vs$H;c7{-rJa5Z>OBSY8vg=(5P=VBX@$~sncBrjq24QcZI}F( ztu)2SboD)?v>M*YW!QN6o1d9)e>fyxTle=k&_hD4tcGBWyS zU0tQ8b5gr!ElHbL62yF~o0>qg5$2m^4xhY(!ha1e?*1Rlon=&9O}C&SSO^x}AvnR^ z9Rk7K-QC^Yf(3W?;0}!i5ANWi2{%r@OjN_ccM7kdP|?PoLVhEMe0z`4sDvhWsY0H9ctZICFVbIJy-7qncS6ST2>VQ_ zg!6o|n}SG@>wJ=bCGdZ8eZ2#aGFn^G@Q(n)W-oY=%0I;B5>I@D$*nMScWc;9&xxv+k9V?qv%H);G@bFs zxvxRrC~BRoaTcjne;&L+W4}mIP4@DSKEk!^n9>qoQKKW@71mIFjrWEa)pc*VRnT+$ z0&x|wIFi0?pk>DqerIxi9E=WZ7BMu~+yY+_{^ZmYl-n{UcyG*B{JMWiPZr{-iu`Io z*NLbb)JGCIlW0!L+8`?}4@nkV!;zfPa2MyT5YDzBVB`V4JVgY!JBJ_W9#=O6!w9iG`#xdd{n)9vuI_>< zf*B1So_*t^1TK)E&&k_oXXCLXVu${rKn9YK_qBcE;t6W0^Y!%kq0@%{ZF##m{^_h1 zQuL$!jeTZ=K_zAG3O7nm?^#M%U%5G(f0dz|Hsol z3u)buf)W4gHJ^dnWI+o2`_4!E*?6$9e@c75sq}cS!GDy}t1SEB%fIjZe|@udB7P_E zpwDj_rsG*Jxe;H2KCh<(U)AfxxA#i-#w3jVpK85h;kqWWr=;uU>rSyq9=eRH()hMV z?&aI!7a-eF+$L*dIyl;~@~DI!{TlN+hL?D-a_EUFxmN2F)8ZwIr)(s1vs*+)cCF$e z39yeZ+Y?7D<~!TWcv-EodR~I1`JEUaXRD3r0F?wFH?bd|>mSBDC}46##6D}(iuJ;6 zTAP;jStlE98CjjO`!?O?!6JW~&S}Lp^C*nD^TY9U++gg?#ooA5>Ys0GQvAage)RoO zG^?)zm*bK7_{s&cz}putd*YWzP}?>iniydA=DqZFa_y_&G_H)lXyQK9Akk+MmTza( zGo>vpy&C-g}JpCCU*NWQ^!esOwKtS>NLhB=YY<>HeAK z1VIY;NZ={EqbV)={xt{_l*3jWbTdJRef{BU-A@ZsvcUS|e$nT-qzA&9jcT|IHp8-a zz_6OabBmojwnSWKU2M=N<5m0dW?hsz>MY5Dhbqf_-eZe*E_7}hF(Vnzw0IZoJ{sRS z`0|ES%`zXs8eAI!IFntv~ z@w@&U$MvAj(I4iPTz$dhcp4C~7ZKu27Ju=kT4mBG-P==E6Uk*f%wU=N<5JKf^ds}$ z9Bx!14y`bFQ$neIAm}Ti1*RSKj%y95sB)U8L{>3BkwgE5jgciLs&AL7j3<}S87*!D zVH^cd{)LYvT}u|}?PX5j;fO&;h_#{|CpLkfFd?VcCrG`8Y&;(MV)dQ>WYVPgc2w)K zN!i}5#J}JhyJL$uXHwp`5lv%qqBn1qU@8XNj(F~D*Ppk#M*yi=PR<#{SO7A-R5H9} zlJ+~W@0;t(MBxFw@rEb|dgpRV)4#%V2;q?+ zo=s4mHEbo{i34#VCC5HlFrIlIiVJ8lO?NnVvGPuxFa6o`^x~2!FLM9`U;r`}+|C-M%1Tq_Pc?YK`nBEwQ7S$h`I>n5Z-^zN&bn9HvQpBk0ryk~-4f5twD4~jgr z&K6|*Sf4fDT9$9U2xl13fH#T7XP||ygH?9Ufq+^hgjSHW0#Yt8pPMq>8k5nqh`y52 z(p*3g)88*zB$e9TezztmAn?2I?(HJR_cIDgcM`282f%{`*2+fWamG<(xqa~UeOvPo zeeY)SRGA=liVae-c3|Qr@W&{c@|s%K>1e*m)|$DqJ&hfOwNQlE)9xD4y|8xDHv|ZY zDcn*Shz2mRL?M)sOn59T*6k4`wHfJOS1E``&Fax=_2*H1VrY#zJh25f5ZzWyhG5GP z(0;jMM~YQkt)c$2NL8zd8TFqrE-9OpAMTZaKyv{GwekLp=! z3C_sw!PS0Dsn-_aJq4*@tm8)a>&QF?Qd4pq2e84BVM~6eZ^)05Au_H+R5fNBR!P$9 z$oXbFlmBc%_-1`d`6kZ%<~XUkYTBL~_xCRelCN~B3m?dYrK36w=A7M+eZuJ{ zuYK&)(Z4xy`NWnqxxo36cU|C>`G_M=dNY_^V$ho-XfJ?;?ZkC?@b_M~>#4<5zLwRJ z-1veqXL-j7N8c0o?O1hE&Fz5%T3vFyqLPG-24y9?YW&k?;d-es9E^*~1}<*YdQK6u z8**A>G6;0%zImGKO)%SBhO>X2Xf;A_u&qVuQe`R0nm#3Io|!o|_v}m>Q&DWDd8PLW zoHlWXPjR%QmHDez-Or5KuOr9UcAyfH`tF%Pl7 z7QEH`oNAN8h_)rAuDjl}NF#Q^BX(~cV?J_Ta*F{ZgZW7ng9u0J( zhc2#={LpctNYZDC3ci}peH)qkSgd@FCdU?cxsaF<}Mc4V7idn02?D+nPK9ww+DWr?v(KQ%g_V#C!eMt5<==7l|Q@RBq-Ej zMl(kz$8Ywr6NbisimrX{8yF~VZ$CN&LGi!Ke1JW@GFn>iY+S&fbp=pr4VrWxyHc{V z$FC2jfj#^BfM$m>u|4?rKRx;*IkSKd6B;GLV1bCOsk#ohGIJcL)4L<-QuR(RyE%oH zv+N@-?8=K|2susePQwYQSaIIGId&yj0?u=>nVgq`fKM>gNjLkS=5@ah6P{`*P2@3` z!Zm*@&%SG-4^R7!5S!pN6N}bSeoc=%A&(O#xo@mZiHkuO;i=0)fUBpp({HvxhO3pJ zT+fc|GCOF}S{mc>GA5=ciFepF(2>!Ntek(tUFLL|iQLAaRU|PGXDIn16*}e=ve~?W zi`j!9sg7>@<*P<$-e|z(xs6lE?hbwd`p=xB;Bz0QX@@I&Qcf1&QmJ^8H<4`5QAc$eke7gn6LjLxqJL?9EVP zXQYKTv8K0&J2OXGFZKyDS6hA_EL~~;9K6rGkY$e!RYw%XRVCO=*6%QmxKpm*5Kk6n zsmQah(r<43&;6eHeEY+c{JPKm9{CwNl2=4974=iiWmx+eQgJqCf4qWXA8KX`j;kmh z8YgTZw&wED6TB@*$tvy^5#MNbuPO5yP9OxG?HGt~TMw5MG{tZ3-E_jQdvMRArHo!Z z(RYx@Awp3#)6?YmS#@;$k_p$^p#B)7*J%2$iYPrf#7!1$3Ta5Nbs=`?*qtpFU( z#WH#QE=amJOI6kbrF3=34K(SWqbN3{pS33wUPk-LmhkRH=}3n<^VH4vcSPOC4T?O_XAwOzWwtS8-~{B7^2rU!HEsjatupB0Sqv(k0u zFl+x`*iPwh;=9Ee^?@B?0$uh)0e#@NQE(}3lP~yWN%5u|kI|h0#V9P5FRtJG{7big zJN*f0poLE^#28?}R}5^xU)Q0UfRX*St>1l}>MXEpK}gTBaJBX6HUp+mzK7$EjDvUS zx2~r zM*h5>S-tMcAEa7Uz4Gg_p72d_Pf%v3Y&l2w{pzSZ=soljB_OW>=Y7TuU72GnXSYa` zYb-Lg6$iNCjj91==y-$VD}>{vIjLvEl(|Q^^+dLU#Obo z^CY(6$VL~If8)xzQT>MC38fU+9F!B-+=Q)E(OTfNsgZv<)e6Wu%Bq?_{*1SwLQcWT zO?@6YOqw<M%4aldq%=p@RgKb?VGJ`*B`u65UoIf9{qyy+Cz4FubE8rF^$61smtj zPIx@*M?^EibC9_Yul(p?1-DF(Y2R_0&I-vl)`-lCiIzWo15KJwB?C_r8rD7H^Lrg@k8)^mOx3HM=!8hB zs$jkD8AV0VKjLkT6`Oc?VBPWt8i2FCLH$U7NXqN!2LZzMV{0U^L!ZfhtJkK=yg{P_ zfF(&r0{{wqJJ3Iq|Mpn0KeCbC{Qe0KyEGoGFav*d_VD2?);~h!{60U2o-&C#Vtc_! zgr>tg-OliVH|HO6_ODDuhWI+qs^1&52q+C=+J?T@=6Pl#=BD1!*g}ySFyZfP=r$_8 zDxj0mn}@ldIwL4RwZzjc4KRH0BXlE8W-!!}B9*XsLJRi-*&z}A8M&Crk}icmHFE?v50+M)GiJkRb8?>uLDU&s;|n&>#k zJwG|q%zJDffHz*U5McENszPUoT9Tme4up638pph7i000*C{C-zTxsa$RVh3EstEBY zL+xAmS?Q-f;qb{Z{``sJ_5l1hchHV0ll|%jgp)61-LNBh>Y39C`*c&sV}mb5`)g%F zuCT8ZfTjY*-1n}eo_^0el#DC!LXajS+09S_mD4$j{_T%n8cex$xV48nkWTDY5h5L9 z$m+e9v*JV}WDdH01jAi^Ag-d}Nfk3lc^0a(;sXF4axF54`$(IRvwu;%?VG%$vO;AA-pxiJnS@jGD=y$mS7!f~;n(_3<7ZWy6<^gwYdo zLfVDSW1mbnsy4eu(CSASiqYij2#U2OFd-6D^$riH$?UUTxtI!TQ7C8>1~$Lm&~1C0 zH4$}1(@YIBrfgy(6Q_X?5Ys-8+#&5%CYq4ABi)vlhmhPwE`jWa^eh(9;W% z_pO;3Wh{`i1gIOw)BjH5WHtoBC3kR5uw)c5NwjZ;fBK8fc`n53gBJBvW`29owJaP} z)fhYUGJsuXlRdgsdP}x3Ov0jFiz(*1&W1nW(oxn16m*VPb;`&*441tzaf=|{+I}3d zo{~S~yY#e122nT%**zfLeoY2{{|F(_)wF)QhHBA}R#bF~OdiqKox*7NT3Y*0eCApd z=nB7~5`XU)pLkh**7qA zcI5zbHIB{4Xi6#H6GpnQ5;=4_MK)@mc$wQPYPDM>|EI%5>aA9rfI}W7iB#e&vlmxS znsU?MG+QAFac?dVjgB%Jh-Y~lC*(q=A=ej5@sbK(zEH*1v^xG-T=c8x6CW-m$GOE+ ztX{V+c-jS*aDBLaqEu~={qlSQHmgD*V|vuLORXBvwD!Hkm>xZJ875^c-?P-M>Pb)0 z+xttUaHts%Qaz`+TT?*Vl?{wq*;mgd|04QKj!aq>d@hmv0VA)<6JA-_HxUKYjGf&C z7f8xBxtP1X@7uArWEAyN^>ryz1a;_i%T)v&L87IL%1TmEv*B06&=TvF$GeP`$&6A_ z9$u(XQM4!iWh%Idw#f?oB_J;Ih4zTUd^SMQJ*J2-GsDI>XR0*dw;rEvBC`En6~`0j zhW4fi?P3u0Y<~aahB@cZMRYeeKV8xNst#L`a*147dWo%B_o76T-lpG_nPr}XCvR?z z=uA80t*G|V@C8!Z`Q+n=tP<}TyoVg9^&0$aR72tLi2B&p8b1q3xG_iCh3L6zOG|8o zQ|r05h+1pbg)z>W*+hl<;fVVQaK4U(o>28G;nKyn=vrYmjWJGhbBh8HPmFfp{qGA9 zZJNlZg=-DgC|xf5EX&-3K<$}~0Dm1{Ft-?hquH?mkeB**9qc!SRIkmK>do5#cLgBW zA_Q!OfPwbi?%M)zK4Em^-h;Pc;BjOO{NkUkuj0fh8RhlPJfykb)Eg;%#RU&+E_Iz5 zxV|JQbBdFSI32SyO3CgB!_IVoVxFhc!^32V4f-V8ytD}(&UR|V8IgrYpc_|kr(aW6 zqf-%v{wht8YbanIKP|M2H|Tc6z4TR?+LbDifqqbQnAN`4=>leUF~>8!($v(cEm{ zP2s}>Txe8T?WgidZ5CVY!YT%*GxhX=sky@H#zM$A(XU%HXtTS^5D1La%U5fv+I?9u z@T9!)!MCO83A!y~FE&e7O3A9ISn&blu9->vG=4jgyJ($B!-iMOgn^cA6!AN$feQsQ z6w$Nep7L68!-nZG8`o)4eteMI8Dj`$8s=V2Dcg0)wY4z#oKh#tw{=_nGct*tjEmnj*aa4(*@PQo`|K*wD zOy+O2ek3N)EwQCLYJA|kmy4I*ZwrduNewMI<0)MCx#``nL@zfRC81}{+PkRx-oYKc zu~c+AEKN7i6MAp_b;m|Wd?@{sXwH;YCK;bTcZ^LF8#a87sk zg#LYsH}h~tzZhqo+M2z=TJbNK)X)?xrLG(oqxX!G&&NUjy>oGr`^lD&-%E@opI{an zbWXhEFQozm32(JNmGQ&qYJc%>`i8uLF@f*liPEn>YgAaCH%J}SY{Q-lw8mO8xn zS6)hRlIn7V*6l%xn86Ka()%=KCPE*av|_NgtU1hCvW{bS@&Zc>-_P`IUbom%cyMH7 z2XKcMbaZ3^R=Vx2cs)R3{<}X4_bUyJ(P$bgBqU@tpgI!;RDodupWZJM_>u8P_Y+fI zpVye>n9$|%&l~&rZyqksr>|%IfaDa0fNy0v3%Htm3jx}c&ZWlDfO~ePOE~VzKFs3GP(N2(na-f%iAf!mxl8|JFfGznt(oL z())jsI2UK%-xFtT#aN856Tw(uveF+#VSV5i{2m)d`*Zm*>pC_$T!QJR^H9_|`Sq&D z>mSU|FNvIPk?wbh{#Af%?0Zt~IU$NQ4%Gks5J?;HpT~(hOo9B|7KP~Cz(1WVa8y5b z7@PkYroZ3(pA&+fejn}8xd87Fk~Snz96xdGTm1Vc18mQD|L6VY0`<%PkT$$jw3{2r z8a<#KZ!aMc78t|#o&Kw#ZUIc_6NO5-nd07yU?#dKj{U~~R zR~o;wk$)6@1r9;WniR~T!BL%3y&;TjJlWAIC2QgS`!lCh*VVAv&!o@?uP6Pa3b>SG z?!e>Dmw3V8PHTgZGowB0170_KGI-Thsp9~(`p)~c+WXj=+81{AO>WlAU(_89V$=wl zr~9uJQCf>cFNyoldiZw*iIX$X8^Q)-PcyUDB;Jn`K4VHbh%&@RZTp=$3H%sud@qmf zoVq=r6qccKRq~d~qpHM^QE#YnjnYNC!Vh8if*pToevP*jcG}YkHjNOSZBzg3OLXCg zX&HoKX?(G)eF6k%uyAlaHiTX*z5tm5xR|_<3Q>SBt2ZXqD!`+GTjkpzIQOqs-My%h zk4j+HE_o{SdgAint(NfXad6uumukuFs@_3fFe6t^JU5-?NfSBQ@+5~Y@pYdTmM_!fEP&S}}?U$wI z$IJtSu^0{~CFyEWlc<0AOb=K4`3j}b-ZpY_6L)gN@2AuAf;iZY#lFMl-;9|_Xg4qd zs&0k(E0uD%zUEspvMVO3W=p19fzP zr{RRN&G$2gx|c+)AJj;~0w2EWuj@2Q{I}V&tX!kP%<>Z1ja_N>t2I83lK*UZ0UB9LLhvxE>AD<{I zS>*461H!>Qy0_!Mf3yZ1f5%_m zj$*@Q4emp;D(h#XO6Uz$5YL!mow4LplCqJ9n%%=_gUAb$l_0O&eo0y_`~Dyob>W3D zYdx)oxBcy=3<~c~dt``W>l}A3E9C!@!aJTFZuPcT$iyG-scHgS>*ZCIdbB#T5o58+ z5+3_0CSH(M`^&>>KIpo{5(sJR#c5VYgfxnakbTQ{dUI*52;U+@+SaO#@5|gSjk!u` z)e$tYLGd9-ZcLuP7l#a?C*92g110YnSeq}kB+Z`v5+7ER`u)+4VD+2*A`lyic39b! z)-z)Xzw1S1YP>rat$JCq$OkdRJl6_y42+X9sz|?thzh+WMAy`P5rb2^=f*6x^fIHQ zDcb5$q;#_90eLVI=~S?s&=*~=;vC{ePcRuU<@Srjog#kYEvHWRIXbj5P!*&re6ry1 z5ggt5{do(cNbmaMm}>vXedmlr+dl~ zvd$IsS;bZ?5I=;|Q(DVIdTBMqr?_goPoq2{=lei2|5GZ@ZoJ<;Lz{kAuKtPg{u|@l z{Gf>RVcTm63deNVNnngws(J6U*677@WY9BN)$)zOvaVEDF!(M6z6w$ zXLA$)y>T1hMwkNoy1<^#-d^beSNIB`N0Z9ry4kXS_uTe7-1far$a+S*Ft^X3{I9UaNyO@Dd1%U7am&2)UcHa9RZSm^ZS z7Y;=<2HvmjYK+?p5S-pWJTSVRY5}rN;918DjDYAAU;_i2G^_14g?M;dPY-}H03ejD z<5n;1d(;+y>Q7iSV6LyXd$I%4*>{K77f#NHIFI*!a7&>Ua?irG9kJ&|k6xp@?M+MY zb`Padb=IIIN6O(*}w*lo}*dse*?>f_P@(s`ZeB?aSgMjfB6EB|McCL z80ko_SZ;ZE(CZi069=zge#P-(oXq13F40LW``S8&+Hz_0xyo>3Lty} z1rUP$FEWhvhMaOp?YBTAYp{ALu%yp;_6R|H*a}*1zW=geCp*3WGDAV)tv-6!70QL$ zbbDFdDAoJD;6^?+)csZ{CE&sT$2=w2824}X9|^?{UulMnck#$ze9~7>a84Z#ZuU7v zqhczsJ*gMUqujKS2Ka5kO_@jObo-+}*eP5|K)Ywln&|hdNL(#HS)bGyuRd1P6hv-= z3d@JrhlZ9jcla|UM>(60S^cFf%l7i=GiQ9wNJtkj?>Iea&nmWStPukU-Tb@rv{VMD z7N4%Us5%Q;`JT`<(udSu+!&*C&~)wvAWOPTwG~D23Ms@$6GvYqJw_`RhFj< z-*u%6&-M@OKjI#A7o>~@%#W&__=QGnh7^|nVE>6$Av|d!=Bs z5DLFk!EqpY-l>i3h~t#ebL9#Yq1z|g*D;gufRaq#RYX$3bEU>08aYP?B5Us z%!G*R8Y>92pb;q=*;sRZEZW+@==s|`;rz7jllp;0Nv*QdON!cENML6h9C>uFyWf6S z<$a{3%)b~G@$x8`wd`!fF9wZ>&eU27v1IwNU$}l#yk*BtHd91N`}ti!479d1)+ek3 zDbnA9|5r@un&pz6pzHR?@3v6Qz#K+xL~{ zU3LKI3=VG}X`5EMf$)R-c^6E}UYezuxw$?tP~ZXNst}Zht*&eCp@7KRrt`%GtgXGh z1#s=U15rG{TE7J>m@g_Vh0T<4wQqbE;z}qgqAC_if#(z!LN#cN_J*VWV^QiG->= z3uT1ynz#aJ8cV|vW}E2I z1;hDR;Aiz9zVtYmQ8QREnuVY37hc1eSPz*%3)P#TafJkF+iqG_-Zea(O zSR%{Z*E4iKCvyCvTNj=1>=njN>e7udg|E!y%Cyy+Z#3B=Y<0SY@$)DL9XZQF>2R1? zL)aPAHEiT7wj!{!%g3lr8r4lNF(xR(oBOKf%H{pNPg-;g`;dI3xuF@YX{FT3ot(1| z+CO6DhJ-nNqT&D|FaD-lJKGI?>HxP1{7%oxw;ej=WzOV{ zODJ`1-DgCr+cz>HKrWjhC15AeWK04!dS()9--taQndo+RobW#{~62-B9i~blkgxn%w^T6mT}dRy#xKozPv;tmw^hU&3iw z(b|g0usfLTmdzO_Sy9Z9ZOW&HrT$V3T$TsM0|I_4+!#YkoBkr@?Gx~d#%&fam66JW zKHe#%x`oBW7?02Ep&}bo zHiGoryxdD_|;LmTz ztKMGmvzu>k$r!MK$RR&}O@!6U=U}@E_A=mAtkg4^eQVuPfzyO)lF01y3DU?!cTSAb ztw8;huGC>+EM#mD@xfVmtN&vF9E4Ph`e(*(xyaapDS|&b`8B@Kw!;Q&bw540?M%jL zvG0Q_0fV>uU54XinJWFtDM-A7nu_WjDn9&^NO+;ekpfV^emLC2e0L%Pej@&PU0>dI zj~fF(YA+(ff>(v5RSEd1vkq4;fUWx%fU??QUfVTOz9XJj2RJBU1B0#7JNnM&OXwa$ zU}{ld{z;%)OD`F99|jzLexZ5*i&59K$15|6gTcnt@!x9!%D`YZ>+l?s$+Nb!v*LpZR=_ZRSL(oAZw;FnhN#nil5Xd3gsc;*D=Whu+!%%XA-BC( zx|bO*N-!tf@xacr@x1}ULN{anPyJa|6{p6=`UGX3{6en*Vh3wAw0Nrm7x;yyE8G>@ z^FOiRrFAqdE-=*0`IV5p10zFd`*C2&e#2;w1{4gqKI^=XG$c-` zi#*2yrmyNmSnS6F@s{iT+V&BdFJgvJ2JT%%N2P4iSCzp~M9uy*-rCsddwdjqBV;2QdwKN;o{=MhxuDb97M;D_3fzv?4Mc+7xk5I(poEtK0bIgY{f~^!J4t1#~uE7HSfJ+5UY_dpJ{y&^XKS`2mRh;XXe#%2D18**w!KB z8UNGtn}A|B{eznAF!6pgt^Btgd2xXZ$0GDs}iW^Sehzy+jf^&HupM?!}U-QkkOgj+v({>dFd^* zo`G*TzB2<9U76&>O9p000LLk2=e$_@2FY9!Q<6V~OlIR%%n|!)Q+IK}49CCntzfa~*dOk(sZ5G;mlGTFJ>Smj-8w#N;XO}rwxCHP3S z;4iy?dp+Ksb%NJ%VIq3{3yyyS%aeaadRmc!Fpzo3)o=AQrg?LZ@g~YWXKp~O71uVm z@Nqu&RMpk^Y1#6jz*H6_;zY4jn>lOSDcf}F+^)Xl5>vGPYJF_OgDFPh$^RUr$h3^`;N}jTbl;14~0}BRMoY zmVmSKO=%M%A>Rnl%KcrsInGL{^Lg>016njiVIv$|7PE=cK{3@zE%!ND&+zc@Twv(3 zI6i8^4ESbt-(x>O(5Kg=?qW{U_G0zSCmFB?Dh^}ALqFc049ECB8@^*khv_>M}6&g~7LC1lyuOp=!HV0);8 z`+Dz8Zm!));T;DU%1?IzUnZ$WMV@j{i>qPcc&SlUi%8&zk+2!AJur>YW!hUWz_ieNOQq+ zg8_zbX*8)RS? z_FCRNB;G1Bld2lj6hIRk*D`$g;m7UjfzORiqb9ky{EeK()2XC!2*w|NRRGl@w)v5T-x3T7>=h3mv7h+iF#k0q{-lIHpvDSjj()D;3 z7%t-h!BbqV)`Ph^AFS^pEIl>{Lx=2dWBwXBw_s(em3p3XVUrD-vP&K==RPmdNVeMD zah)v4T-Z`hfT43#)5Y-~OHf~545YgnY`xqxtIZr;^K?T-W4RJokoY)%^TTZIZNSDu zBhSCl>1;96`i(=tsp;{Ra5codS@Sreeg6epYP23k@;QgVpw$q`=JO7ViZY(e`v#BAw4;>zZuta;V1eWN+kotI zGt+gAh>k8=k{;ouQ!AcadJD<-9^z=eYX7%C#JfzRP%^pJabE_^rsKgxrBW*#co}fz z8UY?fFTe_A>Rb-s;V?-M{9#?8sSi4}MRqorRJbxYFu3oZsir3D0$!7XINos~z`?(j{*iGj5jGqIxRcWGojxuXYf5}R9R}G37IY;V2?icP9K9W?- zS9RYz*FIv2d${ps>niqBiNQj7@=?@gXI@iWrKBjil56sarBtu81(V8twbaq+o0zm$ z8}p(RGPAg=ZSY9W!?N zC$1LidA)9#W2FY^#fqK5;CK*%yZ^S6X7fc}oo7KxPEHr+v2f4sD5GU1Jz-$&RxpNZ zReq=-vRHX7vjCc*SHIPD$0kf6S93FhC zNqI}Ji~5cJg3V^}A)Ywrl)&=DYqr$@3zfO4W`mtS*KI&&DVp~MYo)oF0|){3p84Ey z-=3|H1J{`_5C!aF4zaH{0s;Z=$q9G@p60$?Af@331OX{&Xspg(2M3O!JvW0f{ib`Q zD?x`;d!y+CfP$kN@S6TzX<-JM4bzoo`{KD8jS4_UUa^J~xZqp2KKk%=+{JN1|G#+y!DxeR05^tb@bl@fJ$E zO{_dRC0pTsVNk@7E?RGDq0{>K%Xghi*XM!k9T3gF?&Vl_<@kM=%at!eNebY};-cZ= z4g-hC263mrF295sQ_QK)pEhXC_1KHkTb4rtuN#menB{UZdbZ17yX zFD65?b|=gxN=vs%{T=9f4QIc#6cKT_-Xsg8%&-C6dN5tcd!aG^6e{IH&ac-A?A$h2 z2d*V=14u&t=z1^n|9#0&N1KORN7 zMf3ZQN53!p!vOy0KmM~kxoR|+&d;m;6C5196Y~230$-*kF$KlG5awopJQ&Iz-QLi@ z4wcv;F68%r;v@Ob{pl}h{r&I<%5Eaiu_DlU%y;WnsjiI6ZxBUz#(CZJuO9bpx>d?@ z_Bg%=<>+^!^~^ud^QN(Sj~1Ht^io8-u=EFTZ$dg57d(lyepN$*X|qRaO?`cIG0AC5 zGAW*Y26YI=f6v{HYslgE&29g6;yLJ3g0+;~+ta55JN96s4|AGGez%SXT3VB#!*wmN zhnJgk77V2Jv~KDi^)T8(Ka6vn&wV(NR`XD2AiUdjR@%_e793PG+45nHUtdEF{Ynf( z2To^Ye#N9$N7b`F9$RkRZ8)8F+eVU&5$w&2{_D))Q3P|CJm1V<4m>u+yRZKFlQT7( zzt*t5KeC1w@|mxVTbn~i`*(mU73H5_-B*8kOp4UTgoU|LG$jb?`bAKtH+k@e+>w9R z-6Pj#+kgI>D6;lu9Bb92$K_rv=G~mROG8Oju$5|Mf`!TG8+UWvQgXAEkqoSZIiLXz zh8(orrkM8&V$$#JZs9Oy&!3od7>MBjP*MO(8WH#5XGD;zJJI`brlS1$>J?df8!~Dd zI2pA22uU+PfNVFZ7x|5N-B#<`d3?`PukqdMC}_ z6%LyKfAsG|WsWOvf;6_k^-nD-Z_{#rTSX``S)=$Qo{Y_Wg+G73<`@Oz>Xvj#Rv?+m z<3LnKnOt8p@rVhl?YsGb`E^3C$0lL07-oZh7SdzJ;wj!mTaMY~RpEP`d8cX-4F=PWo1((qw7Gm7y)P&=W9r<8I@nBpTV8Izf(cjO{_v4UF)|CcU30 zVTZ$L98P6^PZ7X-$ER@7hJLj-;R?1Z`Pb=?$0^XQTY-5-PFUY`@uIU=r#9(FHWv+x zq$A79SHAB(CB1$qbeUaVP^8OMM=(mHiVY;hOUGCTI!?q`DVMDCKU%7wy zU#NS_pg6cDN*D+d9D+Lp3+}D~0>Rzg-Q6JscL?qhB)Gdf3=-TO26rE1Cr_UD-P*6d zs{OM+cIO8L1sl<-AfF!LE01^Hp)``G*Tj6e4lVnQ z_Mf#mVb8v!ScQ3>^+CXu5zF4wDGHGjU;ZMznJ7xgyRrAnweBd`X%&`_2Nf8vMdAm6 zn|~tXXFv;|^}^jLVs{?wM+t@yxOAJ-^-LLCAX2Wah%F#;u9@YO(#h1#&g=s?_C=Tkhm&-@ia} zmvHVg5ivfn8RP{-xoSWirg=i&c{;QZmTR>KOgErJoM*sJGEo3+h~Wt@c2bw1QMWAnIc=vD707zxHK22C0zs#~M+;flgpG^A(Tn{CgTq&bR*V=-8N!TaGRBX;Y zO4)LC-HJG-OnlrsWSp`*2SrA5oHRm&-Ej|&g@vYZj|OC>1lD2gEO_3Ccv5Y3f+T;B z#|H!$Qp3rwCkA}t&)n-S^S9|1;@v<#0p`AQBiWw>AL;w=>%MokF>PL>VYAhrLPKA` zrTafgu~{2+>@;BtWPZ2R;MGjD!Tm*-3K|-n>V=2!QIR7NxoIe%Szlg015o@dGO|VO zWLU?ZB!PfL{ppEkBD52L?#bU_!DC+q!dc<$>bnY-PrL`W98g-GuIR zr7W9o)-;qcW=7w9lQ3oE$m)gzxzM^hTp)k)b#t?3eGkmLmn>NV+M2mVhD5CaQddOB z@2t|#QnveAs^h*bQF7mNXkdU2xamWlHvE&n9Jy|4a;CsoixTY^_q(W4msG%)s>^J8 z1PDVQspDn%vI0s?o065E)JcjiV_DuVM<|vmQmfdFxLfqBA^$|Z;D-+bW`GV7?@+v- zJgwR455dd#l;@o2m@YA<2i2ceuUVVf$b(jk2_N|S<6Yf9CbQwc`1x?WvIWIxp-#-9 zh(wSX+4SGDZu)~b2vudpsOIF7pKDSRxKKHS3}FLkVn&peS>RTGOfU_&vj4~;s9sGC zpzM`5m4wIg){3z4;okT(q-@>I3lO(P{cuNe;aGzGk%1*O{k`;a+iLTq6C?It+v0IY z5N7aSTteOc2l|UXB(oe&2%8*3G zy9#jeSJCIXC@V+{ZVs?i1@w-hY2TmPQmmx~Jcb4JZeS`Yx_WhV3*!9z60GuowCJB1 zR*^(TzR!}FmTvHIQ>S}%;B3k(ca=Flldl5TEt%E$2q{9n`QqW*JkrDEsf!XPT?QVL z$7ZWa?Vxr>YEGa36Cu=~U2^NSvSv&H??I53&>7YMz@r*%LGe8y0@_1F$7d)B)L?eN z*MEW>F}WFK14ewRlbLL@JWp@|@`r-r57{s1Xpn;_u+N_>JVc|Aj~#I;aS%Q=n_=_p zFck9{_ERyllp-x`OCczgX>d4^Cg1t3S`#%3aiF_P&<5_Al8p};%6Wxpl01xLtBbff z(FE6U<|7HNX7E2)_psVgdAf6A@v?s~Bzw%JSF0Ewx*Tfxftn+4C1ZV@`gNFL+foz~ zW>QrlRrNEO@OSi?y-m=pzg2UDqXGCrnIvcQpjv!S9_zL6%{Xr->t7H zbLMMDzkpPJX}kTZ*BMxO zFr;Fs)$$pW(*$Fs8Z{xV8NV)0*oxN*gC=_Y zTUtRoY~+`>Lq9GqM6mDtkadIuVJ|2La{ zx%qE4P4nP>3lWcvTR~x}p{)y;TEesbN_V}|2L+b|5qkzhD-s@cMjmf zi+oq*XD$zu9 zc-rm+<3LTZp}?*BKDha$4IBmwib^CVD}Vix)D?~lDgh9P+~mrP-eo>o8pH-oQ`bsL z6ZZajV=_#IC%PM&Ofw%Q5iOB&PIr6#K|XvQ*rsuJ-@s}6PCK{bHZ-_Yv-fXB162{i zDZW~uIt)ypDL!jBv!`d6Fq|41Fqx>_v>p*|#AZ7a;p}BHQ*?9kBb43^W*qX|*7PyN zx52UM+Gy4yiJ@j|wpK)*zvRK}c>ZuroGtR~{wBFKX{OLvXXrA;Xh=1-(BXUp+O;?R zA`|6wodE)Fb1zz~2;x{1W>u%^lrJqFtaJl&4XnuQuA$8Y-*8^9ia$DiSgzUcv5DBY z4ebOp^}k8^h7)qf(**VHtQdk%N;{a{gVTx&K2ldgV&J>DcWV9nh|7~{=Y*V>*N3e6 z&QQ?GTuhv^X>B|+;M>;m`sBjzKG6>GPCeO7{SAGfQZ+-iX0T^rOts;u;UE~<{|?w- zWt)?@yp9PWvs{tGtwMSd46jYNs0*if0()YMdfNrw%#N9n$28;=W91;>S)i6-F4E3_ zxB$U@nG>^;ZuIK-Ea?CZ2^mTt<2zIP=8Mo~8I1?n;|>o}9bLbAHQnAK~MvQWoy1Zt5rsFHc#(MoE!-$Go#wei>~_ zj=L*^X;yy=;j?GjpS6f3C#H>Ll?*4DXa@L#DOC@LR|N|m zzgA#@fw4GND|Sns0hKfUj*9L|DJe6v#ANRD9{?XI!u9#Ku7xLXU*7&P5gYMu>#Q`sR4aJQv_;lYa{;E0K8581OO-5h9P&WYOHLXwvu z_m=yjOX@l+X=ns%+9sDx0{18i7gzIOf-xPtv(Pu9GcQJxvANC#NgZsASmM(ZwYm#= zgU1Vgq7Rv%bRl+sRhVENS5Y!0)@vGF@26+a6tjem*=TP~$TSRThXoM>c}mKz2?|pi zgtRWkyy=+>4efSfZRxL%u9VsC(j$|@U~*uH=_TX%e)dP|N>6tdJ2PL=Rbal28Wqi@ zC~j7|?3NNv=RUsOYpYjMJb(J{CiA$isc^3E$FsYB1U5~3pn)d-jHpK}6~HJ3gCqM+ z5qQ~vHJ6~Z^IQWJPOP2<6LT88k%8uzX&)l^Ji>}BeQ^4*)t#Ii(c!!^iL^LvcDXuf zWLeZgSYHAuN9i8kw-pWNiN8L8^I@e&5zYd*8^hs683ZCdA63&ojD89#DpvCeLMv zcJUa(ktjU2#Rltz(n(^k12RGsIB*de2xisogPAmD*ZboT{LdGCe>!Ma{oh`mA1D1` z4ZwiYpX&IW)t^cy4igM1PHFdN)TF&K=AuDkTas;FYedaW;(h>E<7Lwu7tHmbkJLQp z@4{ogv@eq~J-pjwdQaCXC$IscDuUWlva81QKE^b4A#k74RP`28OKz1e#Te;UC=#v# zAF;=?zYXXL--)Qt^bBMp5PNQNEJqI;zvf`abCrj{pwgFELq8E>u6BJF4Ju|Xb3y;& zj+$@QBY?@P^{QR7!IH8%>L%miLpCOD?pS7s^*G6w@q*|)J{H{maHw!4h0StKOz=8w ziCJEPNdA1D90!UDJ>TF-4EERhIuSOhc~u5Q`-{MyGT=B^=ZY&M?P^vjIeFLNx|oU8 z){V0BP7M~hat!u%AoJWE;uYxdb z9^!xt3OPxn0Gg;<3J2S?g+VV`g9Y&CQ5<4R_G6P=yBdZ`p&nlpp zSrUNSJ}NNji!%?0sMMzgM3k&fnMT#0rV!zDlSzI)*j=z22njGYKkXWv1w9kwAt`&zp!6Z@UX)le`s5efS z_d9Bj*LkcX97=E%YmXb;YdQek;XgIf+EfrmV$^k0N7o_M1~+1$_}Di63eg8Yj1h{vmWhFnSk2HZS)a$k8mw-BONP zTz~*_An?UsNO3N|N9%r>M!Mn>OUUHQ`7$|aB9hWHJWe;@e5Gb{))`ZaZtqyjSYn-H zKclTOKxt%u#LD$a;{9z!(s^Qc#0NFj_%Ht6p+9E;+;z&sK6qEx*J&Kq*kHbRJ6O;g z;!leVnEe)?ojtKV5Q)cOg^9|40Rg%{7gB9ssxwbaPamEBDg6hZ@dRT+4`8y-pEep? z0WWrWfm_U`4L?2zFui^Ma)?A|%$o7f*}wpw4R|2nH!?LNA$$5a-w{Bj3%36k0pg+l z5qJ$=0%q}ncwF<|<1%>1JvTARpNWD~-)lPHkHb7{I5=E8_}x@$P4rnc-G>ZYroWel zM$hUO8vD4)uIYCZI9BP2+{V_X^7nolG3n*?W7P~leeZ*?-juS5&lz0Kq6VPC*bkI6 zk*!yJEUFuT-66}t@zSWdV>V>h{0f#%iLkRa#0{rRqe(m(weA#5D)&C&rpxrW+@WV0 za#BZ;d^hg8M-V^o1D#Q=(th@<(2>y`9qe?m+#s=WFQzH^VK&X;LRU!L^4BN^?35Kz zh?1pSViuu{FyAq6SL9qPt~Yk1fo1X^&Ku$1>qi#7aamj%z)b9`VNKZo({3O=mpu3#0u;LR((oCxOaPC~3{LF-0*F{>01CPFL zQ&Srr8}_389?etX`2^xlDsMZQ^tFM~R8A<-Qws-0>5=;RkbuH#2aQ^$!%9Q5Mq-7A zOft|X_IZ6(UuH%|OPGM&Dz#^gTN-CJMtX$gjfQSI6HjDB4aAvcFa4->Miwf`A%ahz3g$jW$Tor zfp~kr*@|JJ07ty%Lyyl@`;qOR+B!e;K;_LgsddU%N}6sHamt93?;Bn&@@ap4mZa5f zb2IG@fTWJPHI#_O?BbSIVIhe}hCX7+uYXeV4_+#NMV8v`n zIXN@1EZ`qBR93GoTfjRZHFXh?^ruh~$cfVmaW(!YwtS>XR(^YZ&drF(_d!Tr(o8k8 zG#n=tV1R!6J%{GVSto;Aee?dPVrtOgNa*D?w!@>5FEv@mkJo|wZ0NYjxs1eON_0TJ zVJOtp!-3n7iqV-Cg3%sp-Sn}rok*oo7&#Q6hSH$GdtD8`sGs&(%j{!#AFLsP#Spf& z6(_{u?80(xjd|^2*ri9QpqA)3h@)I9C&XG#0bTG`q?N?N{Yf?l2UYDukSLNe(!90X z<+(3p!V~gGt5O2_3ap%!$$NY0?gQHH4mbcYS0_PYO^6dbQjLzaU`Os&t=l*~Zuk9X z4wm`U+>ivwF;_JW9a-eYR^!+jG)@20cJDP@xFRDItM0Ul4Qi)>5mB2*uT%&xt#Ejj z;A>BT2V;yH=sEbHJ&=a={MPk5S)RBfkXHBEg4e33)1o++r@k&g=5=2-mEsN zz(T1QE7(ygG0gVb?sG*P8sAHL!~dPDJoD9X5y0_))1PMN|)Q(QdV) z8-@tN6&X1GcUnQJ%ai~UPrvO_-tOP{I;Jp~{K`Guy%a}hPC|@Bv{84(EO1cJ5Y8lg zr`i6IvU8HD1xE{~He`$gB08fgSI>H@jMRIGBwLlJ29}>ibYI?^rd=0daF$UX)Z^D^ zRH*RzKGYbsvBZ2E$hasEVx#P9J8KWgH8O z`xP7W5%kl49*T?Ybd?YU%)g3kDB&%$uW51NBDvtl59MzJPp87q+LxupuHe?fWMz+KtskTl@ZxH%+lx&BD|ApH zM7{fX=E{;&L-wjKqD@vSX>}@9F|o0X{0zX*SA$zt=eK#PIGcANJM02C!zSdN`eete ze))IhWX!C^!473fW$Pt`4A~oZ*p&^Qp0<&pBQ?}j@sK+s=Lq@``Vf`Kdca+Zl43-8 z5i8cJmlFZE$3$j=z#zu?^1H3|l)(U=9=Z$<>%`c?PvmE&KTnR5;KC=2K;7ig;aT+Z(GBvLQ{W&M+3{R1O6m1swLd{z% zhyTXsN1qK@(NRZ;?y|e8f`ZCYzXK$O;sUPYIT9mSt|)EkhaIv(OruhVsd`XqPg`r; zH&X_IY*uhC0}~?vOMeHgbUv|jxII*@QfcM(aaL4Y0GV2J^(>$FPD=)sZC?;_eFa^| zNod0Snr3bGzy_S(Iqa-s+^qNLHVcLI*suole6zh^eAPVM?Xd2Q{n-I2O+s)so>abq z-PxVJuM;1OkBOyHH8?o(db5{mVP?LO6?zqxX|UV3~tS1EBVW5zZ} zdw=MO7SNr2@8Hg9%)8_A;J{GU9|W@!MnNUS92pGbrUSlzc_NQzGu6&gM70T$XPVYz zRqfdnW%qHYY`6xtU4AIG@G5XU|2zxOt|TY>QD91SV*H`huLWrc&s8A6e89RK;iqU+ z4MzL}{4A>Gb=&-Avk$HGhIh{WhS1~kU_L;Oo5;0K7;0{>y?J>}O!FO@B3GW(r!n^3 zA&?o&xvz_rIKp$z6LUQx!k*Q;Ok!b0(OWp=N_;!_i$_E-@-k==^9zwTN-YG%xuz&T zp^4uS$@Uir4w$!|kzeiNwzH0<(pX?On*FI~=|QniHXM8*8XAm?<5b?-ZHFw0AWDmt z+5_VUj10lVi{jT8Fx~D7wDFcLo57iN?;8%zp98?<#1&KF=q@{@9u%sND{UIrjd1>? z0rtu?Kbd$ey$W5!P)I(aNlBMW@HgF+6;_u(xi_G`5mQr60tUQQkz{2rEs5eI4(DKg z@bW*PJYAH3GVPhL21h*xwbY5#1#!J!7~2Jf)@4mGaazLa>)AC+0=u&phW(3>fW#zz zU8qgYM8wL<(MPa>#F0=KeBOh1i=lJADtvq|i*Ex)DQ}I75dE zd&7eIxRjqh8)mntnE6zbD|uD3@ZPos(f}_QENkd{ z6((+i1?Q?Wm}hCgk5}ejcB4v;Bkd0|Cd!9GmFoC&qGpj0va(XI+RBSa*wR@d`O89K zx}*$MyddpeQ@<*n9p(uS>ILC`<+JfUnjBDN@ex7*a4lE+5DD}ZBn*37L4}M$P5b@m zt^cAIvft$J8s%2&m(tgejgxja!(P;}OD)I+=be!|+0}dd-mGyDLp4qF-aY4bEXbejW^0;yRHpULpqj_0v%XgjRckb4H|$&;z%MSgdx#{D=---W6JZn8=L=V_ zFpM5kcPB|PF&G4aanJf?d{e|Dx-FFjvloc6KWOepqZKak(&n_mbSydl< zLs+@tt)!&PM;>Kz&HDrHkQ)+_%9+vr(#G6dzW8juU~4X{1V?Rr7&<5l!CTzY&+<(y z%OOTwnqQksw_XWmQ?3=x8FoktKEcmpn#NsS`{?oc(bW-iNAk#fErrKkpy67gK1%B6 z`A}!c|LJKuTWz7&Zj8FQarx7b%r?p2ci(y65jRi5<3!Q*_Hyp|a=QSz*y+m$=Hg8) zC@B50QJj?et98m~y%gAvkKgocOOEBIU@f%Yye}qse8J^ZeO1-lTf*Q>{RT)VTrDsM zHjLe~>mk`Z@vcW~y?tT+*((}G2eWu`;Gth+x{6O|FAf_Tik(n&XXsK4!8(M&)JzAl zZ_F(zo@Gs9KBtHiBkm8tFue5tk5jO%XZd8k_wrpja8OqQZtf%?^0O zzQ<)NBBFKQKC>gZFAcYHT^{2tQ;;zm9AyPGNVAQISA^E$J(fqxQ2}wxQO>~|f*x-# zgdF(i8}I{+h=>~e1@FMrN%H?!xOx94fRcZBXOA`DMkYjE=)b$s^8WVV_l^h-83mYW z6E|>%$45wWy=tJ)ORw#cIQM8;=w2)mjxU$MX!ku3&@ruLC>eS8$N< zN3oID<=}!Nxqdx7*5l=^W1{AJTMNSlZ&m8W8xj3_b)x?>@Vx{h2b!-Z zyZb6@a*u8xM(E-#DqLjKsp0h&gwA~6q8%rzeOo-8$VrZ?&2dhP^Bjn<^v!+s!rNmr z#6DLL66m(xB^y)yCLEBON++T*FZ@>6_ShGGuXz=^0I%~{(G2;I?G|Ezw@49mcZ$d3 zw2OECcC7!l4ekl>xmz*^Ba5NOmDUgHd*A^H$p;4(dj2oB_PO$lM{k#~l>k}s}rvpj61K6a2V`*>STh2yv9%p28wCl<+aap=n#aU>=? zgCqB>Rd?ql*_hm48?yAKQMg8L>pT!KTNWGCgO|ALkRB;R$zDMlFW_=5-RLEoe=#PI z;C0!oofO;|hCiBziLqe-_B08ZpM+sz>v{7KmTtY+dbgbLL4YkoOXT&nti}FBmmke? zGg4m2oozaqBkvLWuFbVu4|0#3mcyQTU5^mPyZmfF784ZQHHzEaIt#-w`%D*Z z_CosjH2rdknxZp?Z3R}k!$ocUy|I{Dg?g`Hbk=XRSpC_PU#a=535B}%yDGO^vN%rK z>M`Jb$uIqW2b1V(x@YS#X!iPnI!x{==uRpZfGipD&xR+5q}PiQ-j^=T-`diLGh$9x z`fE7|N>|;(CK-=KdXd8mH>N5&1xbLL$z4jdhSV9cvrZ=;-qj>~5aZ5J-w6N1{uY}^ zhl;C`oXi%0Y`&SU5}dSARmBp_i3LlSHzoM%qgWY-4S~3qNx@t$K>}qS7Y$ukMU+7` z>htHuZ;+kbPGPz!lnGpqR?8nYH$QJGy)*XrLp65qnqb-pfP3}7JLB&xrL}*47-FYj zAp7o8Opsdp6N=#zrh?Rq=J&wXK!jnofb9Tow(jdsfSu-T?j7=|m`#5AnEV4$W^$~o z?Y@15l5a53zozSj-?`KsVvD3VO3plh$PYys&q24XRfR40+1_U@)HoAdz)U{s zq($kHxnO;tk-PwIH6%Mw%3JTW)KO3fdIhwD5ziCNp?OgA3#a~%9%aeFP7E}@ctu6Z z69=u5U!fWt0qNZkLV!omNEq%&btk%o*1l}s=PIY#or~iBvQUGl->z4@_2WuP;p7g? zEYaaVFr8UR!~gYMPKJKWJ|Gk4Dy0kux=q|M~W)HJ(`kTZBn@Ot?<6trbJMJ0SD z2Qhw8bWKZ^vs`_seOJ4DzTHlzhC;6MX)qu}F{^U=`QYCJ1@q{FSqkgGg(I_3g3y~tmyL<7S z`rYq(!%!?pH_lGK#J??lt|b?*^ZIV@TOatqNQFSRhE@q}gQ><1A1S+)LXPhpT=Ki= zVBL_RqELzixYl0BZ)$?G75d@s>EbM;QXO}kKo@a+JM$|kixYtB?w_{}? zjJ248=PpOaGwMy6LK76N8VE>TL{$wy7C@orB*hdS34mAjJga*tG`BN)^X(Ofl#%)y8*e9i@$S+-p{8|1%JudGNKShgK z7pK2j7=r7kY0`mrG$qHpH@m6@^Y3idaui``ebd1IZ~@-^&ikxlu3hc*l;@ry`L-(Z ze36D^!E!I@y=V7gL~TDibm?@2rhidFGSb1esHY`jEIgp36)wsZ=D8D!w!fl}u^3MF zX+vZ9{6pWjPAi=pp5H@AlzPYeafrPxyw$`;#+3=B%7bjesAyt7^(YH9Qsu<&>xc}E ziDg=uf;EVQkuJBaZYsap#}+CfKI)3J6oj68RI*;#;v4m8>tVqKW1&2WeW^##;^A)g z7JBR$sxvCAsvJbxGJ8QsN#>P|RtV}ypSJJWEAI=2O``e%x!~@z{CA3I*!iE?--dtM z*}+)AhP+XOS*@)hg~YepfZ(U>0w%B!kb)XOQl8YkKJ&WUIB)7#4{l!2FY?_o5lIy2 zp>U~Rw>1Y?1~rQZ*uNLiU{^WIUHzb~<~C`z6yg&N3!XT*C(2~ezJ`WTb*v!6^u!DUO)&!G`)XFRaFP(&74X*`~NhI+@b$r*32n&5SeWY#Y- z7moDWJQ+we<&G&2&nL)L5YZ8-*b~r35Ex zE|QD&03%XDNL&pFi`=nwRl}05DZ2cfUg|SLj7{l^kbdhzMiO-%7O)xj<-n{rU{E$miD$}6|InE1Spkyfo1EG9jsZQ5l@%9<(FB+tlOU& z6iQIYTTL@2)j~#2v)p@c8-k&Hg~00oU=!2`kSjd;(Ja8ZTQ3i|(&hw-u`-Fn6T_)C z_*t;~jUUI7P`61(xi>>}Ya+JXeqNnfH%aUa=Z^N}y@rlOY+1DvMwa48PnSZ#`b=>T zJxagJ4(%jyPRh4L+bHLD#V;(bdmz` z>z+))$}u2+ipAhYm!Ah;hcb=+%^db{0k!oeH|1Yvo}lvzjkOK@jNVtc6kAK7HeRBL zdU>xOy}u9fZc>0XrvcTS^VmyiJ>67`LNI?nKn-KsY{Wvr3mBrNF~`U}ttT&9>7 zL1BtbSpxi=^e93qy3fl?xkRJ5uo9n^?)U6!=`uHK<8#zS44asZT0KI~Fx}ukwmN=- z{@hRCvb&qpeMC$8q5v7lC9@wig#DNts)6~da8?h#d1Rw28c7*`UW=>Q29tPskdG37 zaLoWh9vCX8`U|^jDV|tAp+8hpT-G zA!DJ#3DT#BD})?61{d85HVnxdYboRia)`&d+4z+?pbD!)|dalfMsndABhG2hPwqT~76_Wa9p zq_a0^*vmfit74WsjZvGDZy~wG2`5fmHGS`T9{GHBU#~8_(|GG2fzHyFl+c450xbJbqqufw8__@c z^mgjxpWmPRbNS;F%!E77Fxxzfnc0JW?44J3Z!3Nv!0kKH4nZplc%GIz$FSET(pT7*R&4CR-O^t+o8#!kz==DAF}@j2uUqM;8d?}T!|G5vtGlYPG(+qQ7A zxJA01?_z<)YvhZO+bYGN0TnG%F}B$>*0hcAelJFwnvJs2bO0>Fv@Nj(j~tl*tIOS9 zvgV}DMsG?Vyu#{fv{$S`_xyeqnuXSoz7PMNp&_mc#rRjFYd(=fN}TXNvYYQG&9KIiSG<@_~e|IAO#N$qv7(Z-(+CTno?J5Q* zj3bNo8y|x55mucAw3eI{}rj)QendtT2QTMlA+SygV6`k`$*B>vb z&xnZU&)$fr#C8xuLcOl>GWatKZ8;qYqM>(nHYpe>y|EX>@rAG3b_qdDHH6@)W`8Uy zrMzPP!L?$&Ne7bwy9+z=!@;(<>f?HokMFnz`yg<4_nFz;G5AMv@)dYgY+^!!Q%@_H z>0;ENH3_nr%KeOs6jIbhfRF!GUY?qg5+-`TSM3~}7=h*L$T&C}`r0#joI~Q`;>Nfm zNm36*iJxTpB2jz*z14tQnG&)ZL)_WjudkXXpnv@P&KAv~WM_*YRr5Yn|sh z+~rK}$xzgWj>&=afkn19^V87SKV$E~i+_G^V({(nrvrW?{~L+tU9Fw+f(b?UpU!_@ z=g>3C z+L1CB)e$C27B*T;niy!1`1fzZtV5xNE73gp46mq~9};!OnV+}kcvNsj z&*XGXMD!6X>1j(at?2^rTjD_WQ=X`2BgW%L+K;|3I(m5T@rGV|vb~6QLOX{>(akk# zouH%zI-|02gU!;oVo4@F<5`wtCdaeelIG|(jO-oVLu4|ew)(9m*6szbL!bN>m=*!w zhUikCH+oE7oSD7=Pnux?rib7#CVFN0l^BkZM>u^VZm)j-i?zBgiuMmnDOP7C* z_TSWxrR2O9=dKA1)e9+BlkbC=a`?gF-XSrY{!=CjI;La%oj(omPKP>zsU}32i{My-ZD5MI?5D|AyB-L9NDRF>-48m)+F4_KRcP5=FyiXY|Oy zFP>cP*fs7uD>m!d!Ap3(ahEfxY{vA8(IXtG9^Uf^v(80Z02$!dQld82_(vwd$Gqc# z$+ok8A&L%W>_Cs>E z!rM=Gf8J0NLlAgJ+%XGYy0>fGa?0Ju$_N_#l0 zSWa~gys(T*lsZ=!j31h7avM?B@ng%;1u(+PbetL|&^6WUULZ__RPHhbc?EsUgoMoE zX7R?h7s!#j4=#ILxGtvV2E`f7L^uy{n{n~ui0Nn=lXT%8vwgKev5o}xi3GK>}^7S~eYLZ1nz~VEn zzixfOeyz_Bcg6^~&Vy&aXdV3whgUbA$u}v)ecac*LkP$x+UMFQ+o3@+!l!a%x3 zP(xOs34>z6;D;tVCJA%Ogc6wRPTfW2{luxXN|ZbDLVrTQF4y3PIV{rDe)v)wy&u7z zMd-%LaT4QUZAvVh+4X|W%D^guJNomrZB*j!tgo@PvhCR^U+nPK^@PrNb5XQUHK(Es zWL9wpU9ars4tf9e&CNw$A;df$i~3aGO&oV`EOb{mbJ^tLE<9##S$>G8{uE<-ks#KLN!chQ%t2(#)1gr&_2Q*FJLiIHwUwA>}VjCirpyi29@4VBf zwTz9QB_tpI`3E~3H&-Y*>M8F20j7wrFF$im4sFikN*V5K=w)-Lwr}Q|Ub(u4BO6mR z67M07uY`+RBZIl#f(Np2p=SQq9dSvB=C`Kdd@3jt=sVORXl3N}`gEI#d;tftlqw5Q zC@JF~$9q1+n%Zf{d}d029aw0Tm=nMq9lPXs40O&cvpCKxvgMkN6x186-6?H<61wgX zAi|HS-GAi(o|=En9(>-l1h}g?xV+5pck~Q{5PkckW)&&c`BM75f#2r(LxdPMZvpon zf|L)n6F?}tT*!-enG=Y_3rDmSxxL{`n?}G)tX6k>n5lZK2|?_GVRrNx-V} zUB?Ep?-q%v&qH4*bnB%CqF4Ob@80n{1=sUvhvl^=PAb7tW7FNHrx+TL6OGGB?= zbUEd6h7H!vJlpH-F?K%eV{G*3mR-C44LaUCGQ@*#S417`TGVsH$vmVhtK)9xkoKy( zYTy*{6)kU$jOECO?8Vv`tn;pZ{3fAyAH}Dn^NjnE*(7glj7B7+HSb68{knjvwV3X# z)(&qm7`BCnpPQlNQf#(Ja==fy#ZP}0->Awpct#Fo6WiMszxT#NX^YR3^btNoE?SY?>AEhKz$q&G>ah)`C`iQ)_aGGZ zNo389EB3no_p7sT89y^YV{JDA^$~4*s_L72+Z(BHne9MH&GN_FckOGcp@!3m z{2XKmQwiKa&&>0#@!A$9gT^v=7K%9TEl6ITup-}9768%9$HsT|*PBnMcvHr-Bg>Ev zO{p2;Rdb5zO(6_LI&$7#ak&{|bmYa;U2N{<=Ni=i#_Hf zRkRzR_wJNT??AyD!q7gvT{ao(9p$Kk2CS7wZawBTgqThJRzOKj}`#@_jy*7YDg+ z*ZwCe{ax7Jo~hU2rF-}4=;)X#Q)IK*6o6P_e|vP-VV_Bw>v&nO(P`VjZuO`KM!Ivl zA~2|Eefro>TGCEFk%;#Gyb)~KKH2s01+&S?HvGxk2gRNwsPXs&FVn$=yh^#}fIY^) zi+vc0Cu80=KVJf^s3oJd$%HuV40lX9F({3B%b|JaLN?(jB5 zKhgGY^8Hm|OzDx=0q zG5)SJ%sTc;%3Sz4jGwZwD=Ef$m5_>O#&h@DKrdSmO4j_2VSMxc7&YP4@-pq?4;a*m z-Men-;h;twhZ{ih?#+CKxr=)lla7gkUi0Ob^QnV?U+L3T^5Hj1~$@a)f49ZZu(g|eJDpbKB zu-Q!M!kw(%Oop-8*(_?{SI;%PtLKr4u!?sZK^Gk6?>(EAqPL?oY*n!9dbFd$;|L%) zu0i4?=AJM&C*sDmLJEW}di|JW*dt^H@57a%{GZdhKn#w}rL7`a=0Y4LK55*geX2ita=@UD^xJRx^z~R~S)$+}DFqmaY$HGtvlW0a zyInGLOs_j8mb1>r9M@i!VBY0ar}*yLE=i?S>waZ0t}YB<=_Uh$m)HgrWje{@9)>x8`K)siuh&Oj$y4HGa9+y&tilN?hAhKK7!nx$)??KjJ@=!B59IW4V)Kqx7n;pZdhhO- z4_)i(N3}5sT?!dPlch4}JQd%_9^tC8DcSJX95c5F9D>53QP}@r?=7R^+`2ANoB#oW zySsaE2_d)!cMld^3fJHkf(Lhp6z=ZsP9cT6yL6p%-gCdc<9?&Z?f%_At7?p@r=Dl+ zz1Ny^tvS&blns|BDdS{seXP6Zc!Nvo^}WA2LxHO6emn6MlMU-qp|%@^$LxQ~3%|vE zw`7Qy&7nv!w~vB+K#9-3g?ad6K=L%xw$-%SVS345wgNnl0pRc|W_zoHKVE4{i!Lgo zf7Vd&K9xy`U0uU`HvW#wt7PeT+GjFa^aVI#iRw1U+%u zn^>!6I2;=e1n~=}U}#9WS<%oYhsb~nIT>y#r+ELxNPa_519+U&=Zm(_W(zHjM!Q`* zPbGk+oNXSoU7DIl5Jd*Dh_Z}vQ~Z9hMvt@*E`)AWLp+dfX9{K*#++WTqg*qi0^#k4 zwY$o7K@V zr7Fn!F#uO1i^^&@G{^S?v`J@Ne?Hb3UP%_vq|APjKb-2fx*?kwc_BGoITtuEHr`7P zrVQJ*iv9I|qhxy{Nuk{7y~(uyt^xMMX$kn&aV8aS1@$P0Md-q>B|IZu_B-M_>q2-? zeg=x!q5jJ zY+gVq@kg?EW~ zI9XvcDkS&{3_fXwEJWcj%Z5~iGkfzLilDp4<}vS|?PN!jCFP`~5yL0h=V~+o(eP7K zkCF38ybprB7veJSl@zi@p(>(%$jU92Iyy{F+Jclf3nxwT+S?JQc$H|?(H{<~u6j4Y z`Tc0Wa=8E5NA(K+T72(F$KtOL<1bDk~1x5f|Xfx+0k9 z)G71$gmRQ#pu58LJD@V|L2O248B6BTDVp1^ZnXHUZm8TW1+jj+e{bKAB9=QPQ>aCD zAhB@%#sO>?rLC*trN&75AoXm4I3$V!BpJ<9NimG78z4XPCbHv7n=h>Wu)&)_WMhUdpGv)LESNHO&4vCKpN_uMST1pEJHO0^(hvqhdq5f-C7nt%a^te8&Gc z4e?Df?@PaiP;U}t)~Y(V-syje5U?Bmjn-} zZprAqsb%?(FQzmj*a52o*BZY3ln6KE4MuuFTw?B-;8zAl#n`gn0KA#_xex)era0cN7FLs|gd8C>zF6D9vcpo;R6 z*l~7Jv>Ikm zuPFf7QD^L*x3Ocb9@&PG8*)-dRz-^Ufh31^AR|Qof63PgL6_oL@pi)xU_!yCM!CU; z(gQ4z?-|v<$IChm?@I4P6lI7^4Vk%2`EQjx(yg-&!m%-EgiJ3p5gW2ov-2J>LPLD_ zFWGmN(}V`YaS5ufKc!QV1Oj=sBbO{9%=5J8R|x5y%Pu zM^&DQ zVUjO6gc7xF{!!j(cd*H*kq7UZ)K5qY;dy*KL$?@(#2%4|SR1fxK#tW!f%{lFKT1tT zZy+rnX;iFJFDCmMp6Tg24#Z>BV;EhzOOf{*9P7W9WB=$Ns@plFa>@w05J!$7+0o5E z%Y*pzf`NX-Q^>pzPYrR#j9ofM2+7gipI6+ABzhW&-e(L6CH&?~F$^DpZOl+xDs{B# zd8Y{*L7sc#Yqm$ywM7)M{SQ6L3iW;4&#K$xPLcV@NzKId5rb%#XnAJSGwb6K-2RP# z+B}z;W>c3f6~IEkY8}m_U)kQj#~7$wigJZI2F=L4?ML(@XALCvpVYf7;2Nq4w-M#O zT)Jm0&!th3cmNx7pjA=yIQPG$D%+8ib#E&jRJC+uj0UCc*@9GC?qA+WijaBL>+Fj4 zCa9#yJtx{PAwi6!o?0*>N;+Y?}J6J%ko4 z+7O!tN$5y=LWmx|-{F!nO=HZ|5lvas=zBtBl?wV8=uuZm$%d;(7n&?$fBkvI(iYAk zYm{%jy0yD)>e_MHAdi1wy30c*=>PQ;lEWt04H=y*A0~di{}#i`^5p~9X~yZv8Yai@ zV5)|T-SN8Bc(mWF){j5>IO?3Kq$lYLFSna)Td(|VJQ_&3m&NQzKK^z`Z5%b zXMzd)$Ubb(6nc5dPR2;}?Hv1;uA+ACT+ZJ6B

!qR{3ITNUY5 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

bXu!>FY?yY|~#2tXWSF-&)BV$x;o{z1UB}0+) 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 J Date: Mon, 16 Mar 2026 12:15:16 +0100 Subject: [PATCH 65/85] Update gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- .../jupyter_notebook/template_notebooks/welcome.ipynb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index 9901519500a..66907a2cc87 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -77,9 +77,7 @@ "id": "examples-viz", "metadata": {}, "source": [ - "### Example 2: Visualize a Map\n", - "\n", - "*Replace `'elevation'` with a raster from your mapset*" + "### Example 2: Visualize a Map\n" ] }, { From 7e6014b97d851768f948f52f83cc1cd8c97c4a9a Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:16:14 +0100 Subject: [PATCH 66/85] Update gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- .../jupyter_notebook/template_notebooks/welcome.ipynb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index 66907a2cc87..be7641eb823 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -69,7 +69,15 @@ "outputs": [], "source": [ "# List raster maps in current mapset\n", - "print(tools.g_list(type=\"raster\", mapset=\".\").text)" + "maps = tools.g_list(type=\"raster\", mapset=\".\", format=\"json\")\n", + "\n", + "# Use first raster or generate if mapset empty\n", + "if len(maps):\n", + " name = maps[0]\n", + "else:\n", + " name = \"fractal\"\n", + " tools.g_region(rows=100, cols=100)\n", + " tools.r_surf_fractal(output=name)" ] }, { From 06efbc14f0cfdc22bce041c26e951d3f29a448f5 Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:16:40 +0100 Subject: [PATCH 67/85] Update gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- .../jupyter_notebook/template_notebooks/welcome.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index be7641eb823..e59fde008c0 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -98,8 +98,8 @@ "# Create a map display\n", "m = gj.Map()\n", "\n", - "# Add a raster layer (change 'elevation' to your raster name)\n", - "m.d_rast(map=\"elevation\")\n", + "# Add a raster layer\n", + "m.d_rast(map=name)\n", "\n", "# Display the map\n", "m.show()" From 59558f74f52e531ae9b49cdf3b032f6052072780 Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:16:58 +0100 Subject: [PATCH 68/85] Update gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index e59fde008c0..9410afaee29 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -121,8 +121,7 @@ "outputs": [], "source": [ "# Get statistics for a raster map\n", - "# Replace 'elevation' with your raster name\n", - "stats = tools.r_univar(map=\"elevation\", format=\"json\")\n", + "stats = tools.r_univar(map=name, format=\"json\")\n", "stats[\"mean\"]" ] }, From 45d4ff556bc7b081621fc96bf5604710bb9b1fbc Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:17:23 +0100 Subject: [PATCH 69/85] Update gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb Co-authored-by: Anna Petrasova --- .../jupyter_notebook/template_notebooks/welcome.ipynb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index 9410afaee29..c34cbeaf42a 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -134,8 +134,8 @@ "\n", "## What's Next?\n", "\n", - "1. **Run GRASS tools** - Use `tools.()` syntax (e.g., `tools.r_slope_aspect()`) - [Learn more](https://grass.osgeo.org/grass-devel/manuals/python_intro.html#running-tools)\n", - "2. **Visualize results** - Create maps with `gj.Map()` and display with `.show()` - [Learn more](https://grass.osgeo.org/grass-devel/manuals/jupyter_intro.html#map)\n", + "1. **Run GRASS tools** - Use `tools.()` syntax (e.g., `tools.r_slope_aspect()`) - [Learn more](https://grass.osgeo.org/grass-stable/manuals/python_intro.html#running-tools)\n", + "2. **Visualize results** - Create maps with `gj.Map()` and display with `.show()` - [Learn more](https://grass.osgeo.org/grass-stable/manuals/jupyter_intro.html#map)\n", "3. **Create new notebooks** - Click *\"Create new notebook\"* button in the toolbar\n", "\n", "---\n", @@ -143,9 +143,9 @@ "## Resources\n", "\n", "- **[GRASS Tutorials](https://grass-tutorials.osgeo.org/)**\n", - "- **[GRASS Jupyter Documentation](https://grass.osgeo.org/grass-devel/manuals/jupyter_intro.html)**\n", - "- **[Tools API Guide](https://grass.osgeo.org/grass-devel/manuals/libpython/grass.tools.html)**\n", - "- **[Additional Ways to Access Tools](https://grass.osgeo.org/grass-devel/manuals/python_intro.html#additional-ways-to-access-tools)**" + "- **[GRASS Jupyter Documentation](https://grass.osgeo.org/grass-stable/manuals/jupyter_intro.html)**\n", + "- **[Tools API Guide](https://grass.osgeo.org/grass-stable/manuals/libpython/grass.tools.html)**\n", + "- **[Additional Ways to Access Tools](https://grass.osgeo.org/grass-stable/manuals/python_intro.html#additional-ways-to-access-tools)**" ] } ], From 9100576ff6da0f0dd73abd73b19428dfd033d891 Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:17:52 +0100 Subject: [PATCH 70/85] Update doc/jupyter_intro.md Co-authored-by: Anna Petrasova --- doc/jupyter_intro.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/jupyter_intro.md b/doc/jupyter_intro.md index 352301deb65..2a7280bda9c 100644 --- a/doc/jupyter_intro.md +++ b/doc/jupyter_intro.md @@ -269,7 +269,7 @@ For complete documentation on the `grass.script` package, see the [grass.script](https://grass.osgeo.org/grass-stable/manuals/libpython/grass.script.html) library documentation page. -## Using Jupyter Notebooks from GRASS GUI +## Using Jupyter Notebooks from GUI Starting with GRASS version 8.5, the GUI provides integrated support for launching and managing Jupyter Notebooks directly from the interface. From a16c128b8bba6da7dbe9850f75f911b3b84a23e6 Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:18:11 +0100 Subject: [PATCH 71/85] Update doc/jupyter_intro.md Co-authored-by: Anna Petrasova --- doc/jupyter_intro.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/jupyter_intro.md b/doc/jupyter_intro.md index 2a7280bda9c..423b397b023 100644 --- a/doc/jupyter_intro.md +++ b/doc/jupyter_intro.md @@ -274,7 +274,7 @@ library documentation page. Starting with GRASS version 8.5, the GUI provides integrated support for launching and managing Jupyter Notebooks directly from the interface. This allows you to seamlessly combine interactive Python notebooks -with your GRASS workflow. +with your GUI workflow. ### Getting Started From da8d07cbc56782ad65e42ddc9824184cc540270c Mon Sep 17 00:00:00 2001 From: Linda Karlovska <49241681+lindakarlovska@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:22:31 +0100 Subject: [PATCH 72/85] Update doc/jupyter_intro.md Co-authored-by: Vaclav Petras --- doc/jupyter_intro.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/jupyter_intro.md b/doc/jupyter_intro.md index 423b397b023..837194f868f 100644 --- a/doc/jupyter_intro.md +++ b/doc/jupyter_intro.md @@ -316,7 +316,7 @@ In Browser Mode, Jupyter opens in your system's default web browser. This mode p ![Integrated Mode - Jupyter embedded directly in GRASS GUI](jupyter_integrated_mode.png) In Integrated Mode, Jupyter notebooks are embedded directly in the GRASS GUI window -using the wx.html2 library. This mode offers: +(if the wx.html2 library is available). This mode offers: - Jupyter interface embedded as a native GRASS GUI tab - Seamless integration with other GRASS tools and panels From 8f5f2a1a5e91ef7901905dfbb4acef5d9c17cf6a Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 16 Mar 2026 13:26:50 +0100 Subject: [PATCH 73/85] jupyter GUI docs moved to the GUI section + new Ways to Use Jupyter with GRASS section in jupyter_intro.md --- doc/jupyter_intro.md | 149 ++++++------------ .../wxpython/docs}/jupyter_browser_mode.png | Bin .../docs}/jupyter_integrated_mode.png | Bin .../wxpython/docs}/jupyter_startup_dialog.png | Bin gui/wxpython/docs/wxGUI.jupyter.md | 101 ++++++++++++ 5 files changed, 150 insertions(+), 100 deletions(-) rename {doc => gui/wxpython/docs}/jupyter_browser_mode.png (100%) rename {doc => gui/wxpython/docs}/jupyter_integrated_mode.png (100%) rename {doc => gui/wxpython/docs}/jupyter_startup_dialog.png (100%) create mode 100644 gui/wxpython/docs/wxGUI.jupyter.md diff --git a/doc/jupyter_intro.md b/doc/jupyter_intro.md index 837194f868f..39e5d151252 100644 --- a/doc/jupyter_intro.md +++ b/doc/jupyter_intro.md @@ -11,6 +11,55 @@ notebook interface to GRASS. It includes modules for creating map figures, interactive web maps, visualizing data series and time series, and generating 3D visualizations. +## Ways to Use Jupyter with GRASS + +There are several ways to work with GRASS in Jupyter notebooks, +each suited to different workflows and requirements: + +### 1. GUI Integration (GRASS 8.5+) + +Launch Jupyter directly from the GUI with pre-configured sessions and templates. +This is the easiest way to get started—no manual setup required. + +- **Best for:** Users who prefer GUI workflows and want quick access to notebooks +- **Setup:** No configuration needed; launches from **File → Jupyter Notebook** +- **Learn more:** [Using Jupyter Notebooks from GRASS GUI](https://grass.osgeo.org/grass-stable/manuals/wxGUI.jupyter.html) + +### 2. Local Jupyter Server + +Run Jupyter on your own machine with a local GRASS installation. +This gives you full control over your environment and packages. + +- **Best for:** Regular GRASS users who want to integrate notebooks +into their existing workflow +- **Requirements:** GRASS 8.4+ and Jupyter installed locally +- **Tutorial:** [Get started with GRASS & Python in Jupyter (Unix/Linux)](https://grass-tutorials.osgeo.org/content/tutorials/get_started/fast_track_grass_and_python.html) + | [Get started on Windows](https://grass-tutorials.osgeo.org/content/tutorials/get_started/JupyterOnWindows_OSGeo4W_Tutorial.html) + +### 3. Google Colab + +Run GRASS notebooks in the cloud using Google Colaboratory—no installation required. +Free access to computing resources including GPUs. +Ideal for sharing and reproducibility. + +- **Best for:** Quick experiments, teaching, sharing workflows, +or users without local GRASS installation +- **Requirements:** Google account only +- **Tutorial:** [Get started with GRASS in Google Colab](https://grass-tutorials.osgeo.org/content/tutorials/get_started/grass_gis_in_google_colab.html) + +### 4. Binder + +Launch interactive GRASS notebooks directly from your browser using Binder. +No installation or account needed. + +- **Best for:** Trying GRASS without installation, workshops, demonstrations +- **Requirements:** None—runs entirely in your browser +- **Try it now:** [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/OSGeo/grass/main?labpath=doc%2Fexamples%2Fnotebooks%2Fjupyter_tutorial.ipynb) + +--- + +## Getting Started with grass.jupyter + If you don't have a project yet, create a new one first: ```python @@ -268,103 +317,3 @@ library documentation page. For complete documentation on the `grass.script` package, see the [grass.script](https://grass.osgeo.org/grass-stable/manuals/libpython/grass.script.html) library documentation page. - -## Using Jupyter Notebooks from GUI - -Starting with GRASS version 8.5, the GUI provides integrated support for launching -and managing Jupyter Notebooks directly from the interface. -This allows you to seamlessly combine interactive Python notebooks -with your GUI workflow. - -### Getting Started - -To launch Jupyter from GRASS GUI, go to **File → Jupyter Notebook** or -click the Jupyter button in the Tools toolbar at the top of the GRASS window. -A startup dialog will appear where you can configure your notebook environment: - -![Jupyter Startup Dialog](jupyter_startup_dialog.png) - -#### Configuration Options - -- **Where to Save Notebooks:** Select where your Jupyter notebooks will be stored. - You can choose an existing directory, create a new one, or leave the field empty - to use your current directory. Notebooks can be saved anywhere, - including inside your current GRASS project. - -- **Create Welcome Notebook:** Check this option to automatically create - a `welcome.ipynb` template notebook with GRASS-specific examples and - quick-start code. Recommended for new users. - -### Display Modes - -After configuring the storage, choose how to interact with Jupyter notebooks: - -#### Browser Mode - -![Browser Mode - Jupyter opened in your default web browser](jupyter_browser_mode.png) - -In Browser Mode, Jupyter opens in your system's default web browser. This mode provides: - -- Full Jupyter Lab/Notebook interface with all features -- File browser and notebook management -- Terminal access and extensions support -- A control panel in GRASS GUI showing server URL, PID, and storage location -- Quick access to reopen the browser or stop the server - -#### Integrated Mode - -![Integrated Mode - Jupyter embedded directly in GRASS GUI](jupyter_integrated_mode.png) - -In Integrated Mode, Jupyter notebooks are embedded directly in the GRASS GUI window -(if the wx.html2 library is available). This mode offers: - -- Jupyter interface embedded as a native GRASS GUI tab -- Seamless integration with other GRASS tools and panels -- Import/export notebooks, and create new notebooks from the toolbar -- External links (documentation, tutorials) open in your system browser - -### Toolbar Actions - -The integrated mode toolbar provides quick access to common operations: - -- **Create:** Create a new notebook with prepared GRASS module imports and session -initialization -- **Import:** Import existing .ipynb files into your notebook storage -- **Export:** Export the current notebook to a different location -- **Undock:** Open the notebook in a separate window (also available in -the browser mode) -- **Stop:** Stop the Jupyter server and close the notebook (also available in -the browser mode) - -### Multiple Notebook Sessions - -You can launch multiple Jupyter sessions with different storage locations. -Each session appears as a separate tab in the GRASS GUI, with the storage -location shown in the tab name. Hover over a tab to see the full storage path. - -### Server Management - -GRASS automatically manages Jupyter server instances: - -- Each notebook storage location runs on a separate Jupyter server -- Servers are automatically stopped when GRASS exits -- Multiple notebooks from the same storage share one server instance -- Server information (URL, PID) is displayed in the interface - -### Tips - -- The `welcome.ipynb` template includes examples of using - the GRASS Tools API, importing data, creating maps, and visualization - -- You can switch between browser and integrated modes by closing one and - relaunching Jupyter with the same storage location - -- Tab tooltips show the full storage path—useful when working with - multiple storage locations - -## Tutorials - -- [Get started with GRASS in Jupyter Notebooks on Windows](https://grass-tutorials.osgeo.org/content/tutorials/get_started/JupyterOnWindows_OSGeo4W_Tutorial.html) -- [Get started with GRASS & Python in Jupyter Notebooks (Unix/Linux)](https://grass-tutorials.osgeo.org/content/tutorials/get_started/fast_track_grass_and_python.html) -- [Get started with GRASS in Google Colab](https://grass-tutorials.osgeo.org/content/tutorials/get_started/grass_gis_in_google_colab.html) -- Run `grass.jupyter` tutorial on Binder: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/OSGeo/grass/main?labpath=doc%2Fexamples%2Fnotebooks%2Fjupyter_tutorial.ipynb) diff --git a/doc/jupyter_browser_mode.png b/gui/wxpython/docs/jupyter_browser_mode.png similarity index 100% rename from doc/jupyter_browser_mode.png rename to gui/wxpython/docs/jupyter_browser_mode.png diff --git a/doc/jupyter_integrated_mode.png b/gui/wxpython/docs/jupyter_integrated_mode.png similarity index 100% rename from doc/jupyter_integrated_mode.png rename to gui/wxpython/docs/jupyter_integrated_mode.png diff --git a/doc/jupyter_startup_dialog.png b/gui/wxpython/docs/jupyter_startup_dialog.png similarity index 100% rename from doc/jupyter_startup_dialog.png rename to gui/wxpython/docs/jupyter_startup_dialog.png diff --git a/gui/wxpython/docs/wxGUI.jupyter.md b/gui/wxpython/docs/wxGUI.jupyter.md new file mode 100644 index 00000000000..b53b9151dea --- /dev/null +++ b/gui/wxpython/docs/wxGUI.jupyter.md @@ -0,0 +1,101 @@ +--- +authors: + - Linda Karlovska + - GRASS Development Team +--- + +# Using Jupyter Notebooks from GUI + +Starting with GRASS version 8.5, the GUI provides integrated support for launching +and managing Jupyter Notebooks directly from the interface. +This allows you to seamlessly combine interactive Python notebooks +with your GUI workflow. + +## Getting Started + +To launch Jupyter from GUI, go to **File → Jupyter Notebook** or +click the Jupyter button in the Tools toolbar at the top of the GRASS window. +A startup dialog will appear where you can configure your notebook environment: + +![Jupyter Startup Dialog](jupyter_startup_dialog.png) + +### Configuration Options + +- **Where to Save Notebooks:** Select where your Jupyter notebooks will be stored. + You can choose an existing directory, create a new one, or leave the field empty + to use your current directory. Notebooks can be saved anywhere, + including inside your current GRASS project. + +- **Create Welcome Notebook:** Check this option to automatically create + a `welcome.ipynb` template notebook with GRASS-specific examples and + quick-start code. Recommended for new users. + +### Display Modes + +After configuring the storage, choose how to interact with Jupyter notebooks: + +#### Browser Mode + +![Browser Mode - Jupyter opened in your default web browser](jupyter_browser_mode.png) + +In Browser Mode, Jupyter opens in your system's default web browser. + +This mode provides: + +- Full Jupyter Lab/Notebook interface with all features +- File browser and notebook management +- Terminal access and extensions support +- A control panel in GRASS GUI showing server URL, PID, and storage location +- Quick access to reopen the browser or stop the server + +#### Integrated Mode + +![Integrated Mode - Jupyter embedded directly in GRASS GUI](jupyter_integrated_mode.png) + +In Integrated Mode, Jupyter notebooks are embedded directly in the GRASS GUI window +(if the wx.html2 library is available). This mode offers: + +- Jupyter interface embedded as a native GRASS GUI tab +- Seamless integration with other GRASS tools and panels +- Import/export notebooks, and create new notebooks from the toolbar +- External links (documentation, tutorials) open in your system browser + +### Toolbar Actions + +The integrated mode toolbar provides quick access to common operations: + +- **Create:** Create a new notebook with prepared GRASS module imports and session +initialization +- **Import:** Import existing .ipynb files into your notebook storage +- **Export:** Export the current notebook to a different location +- **Undock:** Open the notebook in a separate window (also available in +the browser mode) +- **Stop:** Stop the Jupyter server and close the notebook (also available in +the browser mode) + +### Multiple Notebook Sessions + +You can launch multiple Jupyter sessions with different storage locations. +Each session appears as a separate tab in the GRASS GUI, with the storage +location shown in the tab name. Hover over a tab to see the full storage path. + +### Server Management + +GRASS automatically manages Jupyter server instances: + +- Each notebook storage location runs on a separate Jupyter server +- Servers are automatically stopped when GRASS exits +- Multiple notebooks from the same storage share one server instance +- Server information (URL, PID) is displayed in the interface + +### Tips + +- The `welcome.ipynb` template includes GRASS session initialization + and practical examples of listing data, visualizing maps, + and running analyses with the Tools API + +- You can switch between browser and integrated modes by closing one and + relaunching Jupyter with the same storage location + +- Tab tooltips show the full storage path—useful when working with + multiple storage locations From 590df4f0d02bc6621c6f15713f25ae478cf01312 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Tue, 17 Mar 2026 13:25:06 +0100 Subject: [PATCH 74/85] better jupyter_intro docs --- doc/jupyter_intro.md | 85 +++++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/doc/jupyter_intro.md b/doc/jupyter_intro.md index 39e5d151252..b32fa28a594 100644 --- a/doc/jupyter_intro.md +++ b/doc/jupyter_intro.md @@ -6,59 +6,70 @@ authors: # Jupyter notebooks introduction -The `grass.jupyter` Python package provides a [Jupyter](https://jupyter.org/) -notebook interface to GRASS. It includes modules for creating map figures, -interactive web maps, visualizing data series and time series, and generating -3D visualizations. +GRASS can be used directly in Jupyter notebooks, allowing you to combine +interactive Python code, visualizations, and documentation in one place. +This is ideal for geospatial analysis, teaching, and reproducible research. -## Ways to Use Jupyter with GRASS +With GRASS in [Jupyter](https://jupyter.org/), you can: -There are several ways to work with GRASS in Jupyter notebooks, -each suited to different workflows and requirements: +- Run GRASS tools and analyses interactively + +- Create maps and 3D visualizations -### 1. GUI Integration (GRASS 8.5+) +- Document your workflow step-by-step -Launch Jupyter directly from the GUI with pre-configured sessions and templates. -This is the easiest way to get started—no manual setup required. +- Share reproducible analyses -- **Best for:** Users who prefer GUI workflows and want quick access to notebooks -- **Setup:** No configuration needed; launches from **File → Jupyter Notebook** -- **Learn more:** [Using Jupyter Notebooks from GRASS GUI](https://grass.osgeo.org/grass-stable/manuals/wxGUI.jupyter.html) +## How to Get Started -### 2. Local Jupyter Server +There are several ways to work with GRASS in Jupyter notebooks, +each suited to different workflows and requirements: -Run Jupyter on your own machine with a local GRASS installation. -This gives you full control over your environment and packages. +### GUI Integration (GRASS 8.5+) -- **Best for:** Regular GRASS users who want to integrate notebooks -into their existing workflow -- **Requirements:** GRASS 8.4+ and Jupyter installed locally -- **Tutorial:** [Get started with GRASS & Python in Jupyter (Unix/Linux)](https://grass-tutorials.osgeo.org/content/tutorials/get_started/fast_track_grass_and_python.html) - | [Get started on Windows](https://grass-tutorials.osgeo.org/content/tutorials/get_started/JupyterOnWindows_OSGeo4W_Tutorial.html) +If you are a user who prefers working through a GUI environment, +you may launch Jupyter directly from the GUI with pre-configured +sessions and templates. This is the easiest way to get started—no manual +setup required, you only need to have Jupyter installed locally. +There are two available launching modes: integrated mode and +browser mode. See [Using Jupyter Notebooks from GRASS GUI](https://grass.osgeo.org/grass-stable/manuals/wxGUI.jupyter.html) +for details. -### 3. Google Colab +### Local Jupyter Server -Run GRASS notebooks in the cloud using Google Colaboratory—no installation required. -Free access to computing resources including GPUs. -Ideal for sharing and reproducibility. +If you are a regular Jupyter notebook user and want full control +over your environment and packages, you can use your GRASS +installation (8.4+) and run a local Jupyter server instance. +Then you can run your cells either in the browser or in an +IDE with Jupyter support (e.g., VS Code, PyCharm). +Tutorials: +[Get started with GRASS & Python in Jupyter (Unix/Linux)](https://grass-tutorials.osgeo.org/content/tutorials/get_started/fast_track_grass_and_python.html) +and [Get started on Windows](https://grass-tutorials.osgeo.org/content/tutorials/get_started/JupyterOnWindows_OSGeo4W_Tutorial.html). -- **Best for:** Quick experiments, teaching, sharing workflows, -or users without local GRASS installation -- **Requirements:** Google account only -- **Tutorial:** [Get started with GRASS in Google Colab](https://grass-tutorials.osgeo.org/content/tutorials/get_started/grass_gis_in_google_colab.html) +### Google Colab -### 4. Binder +If you want to make quick experiments and do not have a local GRASS +installation, you can run GRASS notebooks in the cloud using +Google Colaboratory. It is ideal for notebook sharing and +reproducibility, with free access to computing resources including GPUs. +The only requirement is a Google account. +Tutorial: [Get started with GRASS in Google Colab](https://grass-tutorials.osgeo.org/content/tutorials/get_started/grass_gis_in_google_colab.html). -Launch interactive GRASS notebooks directly from your browser using Binder. -No installation or account needed. +### Binder -- **Best for:** Trying GRASS without installation, workshops, demonstrations -- **Requirements:** None—runs entirely in your browser -- **Try it now:** [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/OSGeo/grass/main?labpath=doc%2Fexamples%2Fnotebooks%2Fjupyter_tutorial.ipynb) +If you want to make demonstrations, run examples at workshops, etc., +and do not have a local GRASS installation, you can launch +GRASS notebooks directly from your browser using Binder. +No account needed. +Try it now: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/OSGeo/grass/main?labpath=doc%2Fexamples%2Fnotebooks%2Fjupyter_tutorial.ipynb) ---- +## Working with grass.jupyter -## Getting Started with grass.jupyter +Once you have Jupyter running (using any method above), use +the `grass.jupyter` package providing a Jupyter +notebook interface to GRASS. It includes modules for creating map figures, +interactive web maps, visualizing data series and time series, and generating +3D visualizations. If you don't have a project yet, create a new one first: From ff48065c6eff62dc03243f69b4ec1d0254a12a71 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 20 Mar 2026 17:38:17 +0100 Subject: [PATCH 75/85] Anna's suggestion - bullets with links to tutorials --- doc/jupyter_intro.md | 72 +++++++++----------------------------------- 1 file changed, 15 insertions(+), 57 deletions(-) diff --git a/doc/jupyter_intro.md b/doc/jupyter_intro.md index b32fa28a594..72ff9c06649 100644 --- a/doc/jupyter_intro.md +++ b/doc/jupyter_intro.md @@ -6,70 +6,28 @@ authors: # Jupyter notebooks introduction -GRASS can be used directly in Jupyter notebooks, allowing you to combine -interactive Python code, visualizations, and documentation in one place. -This is ideal for geospatial analysis, teaching, and reproducible research. - -With GRASS in [Jupyter](https://jupyter.org/), you can: - -- Run GRASS tools and analyses interactively - -- Create maps and 3D visualizations - -- Document your workflow step-by-step - -- Share reproducible analyses +GRASS can be used directly in [Jupyter](https://jupyter.org/) +notebooks, allowing you to combine interactive Python code, +visualizations, and documentation in one place. ## How to Get Started -There are several ways to work with GRASS in Jupyter notebooks, -each suited to different workflows and requirements: - -### GUI Integration (GRASS 8.5+) - -If you are a user who prefers working through a GUI environment, -you may launch Jupyter directly from the GUI with pre-configured -sessions and templates. This is the easiest way to get started—no manual -setup required, you only need to have Jupyter installed locally. -There are two available launching modes: integrated mode and -browser mode. See [Using Jupyter Notebooks from GRASS GUI](https://grass.osgeo.org/grass-stable/manuals/wxGUI.jupyter.html) -for details. - -### Local Jupyter Server - -If you are a regular Jupyter notebook user and want full control -over your environment and packages, you can use your GRASS -installation (8.4+) and run a local Jupyter server instance. -Then you can run your cells either in the browser or in an -IDE with Jupyter support (e.g., VS Code, PyCharm). -Tutorials: -[Get started with GRASS & Python in Jupyter (Unix/Linux)](https://grass-tutorials.osgeo.org/content/tutorials/get_started/fast_track_grass_and_python.html) -and [Get started on Windows](https://grass-tutorials.osgeo.org/content/tutorials/get_started/JupyterOnWindows_OSGeo4W_Tutorial.html). - -### Google Colab - -If you want to make quick experiments and do not have a local GRASS -installation, you can run GRASS notebooks in the cloud using -Google Colaboratory. It is ideal for notebook sharing and -reproducibility, with free access to computing resources including GPUs. -The only requirement is a Google account. -Tutorial: [Get started with GRASS in Google Colab](https://grass-tutorials.osgeo.org/content/tutorials/get_started/grass_gis_in_google_colab.html). - -### Binder +You can work with GRASS in Jupyter notebooks in several ways. +See these tutorials for setup instructions: -If you want to make demonstrations, run examples at workshops, etc., -and do not have a local GRASS installation, you can launch -GRASS notebooks directly from your browser using Binder. -No account needed. -Try it now: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/OSGeo/grass/main?labpath=doc%2Fexamples%2Fnotebooks%2Fjupyter_tutorial.ipynb) +- [Using Jupyter Notebooks from GRASS GUI](https://grass.osgeo.org/grass-stable/manuals/wxGUI.jupyter.html) +(GRASS 8.5+) +- [Get started with GRASS & Python in Jupyter (Unix/Linux)](https://grass-tutorials.osgeo.org/content/tutorials/get_started/fast_track_grass_and_python.html) +- [Get started on Windows](https://grass-tutorials.osgeo.org/content/tutorials/get_started/JupyterOnWindows_OSGeo4W_Tutorial.html) +- [Get started with GRASS in Google Colab](https://grass-tutorials.osgeo.org/content/tutorials/get_started/grass_gis_in_google_colab.html) +- Try it now: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/OSGeo/grass/main?labpath=doc%2Fexamples%2Fnotebooks%2Fjupyter_tutorial.ipynb) ## Working with grass.jupyter -Once you have Jupyter running (using any method above), use -the `grass.jupyter` package providing a Jupyter -notebook interface to GRASS. It includes modules for creating map figures, -interactive web maps, visualizing data series and time series, and generating -3D visualizations. +Once you have Jupyter running (using any method above), +use the `grass.jupyter` package to create map figures, +interactive web maps, visualize data series and time series, +and generate 3D visualizations. If you don't have a project yet, create a new one first: From fe37d763b13c3c201c480129ade1967958928d4f Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 20 Mar 2026 23:07:13 +0100 Subject: [PATCH 76/85] fix in welcome.ipynb from testing --- gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb index c34cbeaf42a..044191178fb 100644 --- a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -73,7 +73,7 @@ "\n", "# Use first raster or generate if mapset empty\n", "if len(maps):\n", - " name = maps[0]\n", + " name = maps[0][\"name\"]\n", "else:\n", " name = \"fractal\"\n", " tools.g_region(rows=100, cols=100)\n", From 245440e9528c1404bd5550e8fd3c505073bc9a58 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 23 Mar 2026 13:46:41 +0100 Subject: [PATCH 77/85] make hiding an interface switcher more general --- gui/wxpython/jupyter_notebook/notebook.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py index 733b4edfb59..85e313ce5fb 100644 --- a/gui/wxpython/jupyter_notebook/notebook.py +++ b/gui/wxpython/jupyter_notebook/notebook.py @@ -86,8 +86,11 @@ def _hide_top_ui(self, event: html.WebViewEvent) -> None: #top-panel-wrapper { display: none !important; } #menu-panel-wrapper { display: none !important; } - /* Interface switcher ("Open in...") */ - .jp-InterfaceSwitcher { display: none !important; } + /* Interface switcher - hides any interface switching UI elements + (e.g., dropdown menu "Open In..." when nbclassic is installed, + JupyterLab button when only jupyter lab is installed, or other + interface options that may be added in future Jupyter versions) */ + [data-jp-item-name="interfaceSwitcher"] { display: none !important; } /* remove top spacing left by hidden panels */ .lm-Panel { top: 0 !important; } From b4a6611dfa577bf734cd743589109575b2fd6dec Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Wed, 8 Apr 2026 13:45:03 +0200 Subject: [PATCH 78/85] check for MS Edge WebView2 runtime on Windows --- gui/wxpython/jupyter_notebook/server.py | 32 ++++++++++++++++--------- gui/wxpython/jupyter_notebook/utils.py | 24 +++++++++++++++++++ gui/wxpython/main_window/frame.py | 21 ++++++++++++---- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index 005b83a091e..5c4d02b4c60 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -161,20 +161,30 @@ def start_server(self) -> None: self.port = JupyterServerInstance.find_free_port() self.server_url = "http://127.0.0.1:{}".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. " - "Please install Jupyter Notebook and ensure it is available in your system PATH." + # Resolve Jupyter executable based on platform + if sys.platform.startswith("win"): + python = os.environ.get("GRASS_PYTHON") + if not python: + raise RuntimeError( + _( + "GRASS_PYTHON environment variable is not set. " + "Cannot locate Python executable." + ) ) - ) + executable = [python, "-m", "notebook"] + else: + jupyter = shutil.which("jupyter") + if not jupyter: + raise RuntimeError( + _( + "Jupyter executable not found in PATH. " + "Please install Jupyter Notebook and ensure it is available in your system PATH." + ) + ) + executable = [jupyter, "notebook"] - # Build command to start Jupyter Notebook server cmd = [ - jupyter, - "notebook", + *executable, "--no-browser", "--NotebookApp.token=", "--NotebookApp.password=", diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index e0c9ba9db1b..97378fe2127 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -6,6 +6,7 @@ Functions: - `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system and functional. - `is_wx_html2_available()`: Check if wx.html2 module is available. +- `is_webview2_available()`: Check if wx.html2.WebView uses Microsoft Edge WebView2 on Windows. (C) 2026 by the GRASS Development Team @@ -15,6 +16,7 @@ @author Linda Karlovska """ +import sys import shutil import subprocess @@ -56,3 +58,25 @@ def is_wx_html2_available() -> bool: return True except (ImportError, ModuleNotFoundError): return False + + +def is_webview2_available() -> bool: + """Check whether wx.html2.WebView uses Microsoft Edge WebView2 on Windows. + + On Windows, wx.html2.WebView can fall back to the Internet Explorer (MSHTML) + engine if WebView2 runtime is not installed, which is not compatible with + modern Jupyter Notebook/Lab interfaces. On Linux/macOS, the backend is + assumed to be usable (WebKit-based). + + :return: True if WebView2 (Edge) is available or non-Windows platform, + False otherwise + """ + if sys.platform.startswith("win"): + try: + import wx.html2 as html + + info = html.WebView.GetBackendVersionInfo() + return info.GetName().lower() == "edge" + except Exception: + return False + return True diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 1eb6489df52..a31d743dab7 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -986,6 +986,7 @@ def OnJupyterNotebook(self, event=None, cmd=None): from jupyter_notebook.utils import ( is_jupyter_installed, is_wx_html2_available, + is_webview2_available, ) from jupyter_notebook.dialogs import JupyterStartDialog @@ -1012,12 +1013,24 @@ def OnJupyterNotebook(self, event=None, cmd=None): # Check integrated mode requirements and offer fallback if action == "integrated": + message = None + if not is_wx_html2_available(): + message = _( + "Integrated mode requires wx.html2.WebView, which is not available on this system.\n\n" + "This can happen if wxPython or wxWidgets were built without HTML2/WebView support." + ) + elif not is_webview2_available(): + message = _( + "Integrated mode requires Microsoft Edge WebView2 runtime on Windows.\n\n" + "It is missing or not properly configured on this system." + ) + + if message is not None: response = wx.MessageBox( _( - "Integrated mode requires wx.html2.WebView which is not available on this system.\n\n" - "Would you like to open Jupyter Notebook in your external browser instead?" - ), + "{message}\n\nWould you like to open Jupyter Notebook in your external browser instead?" + ).format(message=message), _("Integrated Mode Not Available"), wx.ICON_WARNING | wx.YES_NO, ) @@ -1036,7 +1049,7 @@ def OnJupyterNotebook(self, event=None, cmd=None): # Integrated mode failed, offer browser fallback response = wx.MessageBox( _( - "Integrated mode failed: wx.html2.WebView is not functional on this system.\n\n" + "Integrated mode is not supported on this system.\n\n" "Would you like to open Jupyter Notebook in your external browser instead?" ), _("WebView Not Supported"), From 1072460b0eec24102e4a05a56cb440f4cc93a1f6 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 10 Apr 2026 13:52:41 +0200 Subject: [PATCH 79/85] return always the notebook running under GRASS environment - GRASS_PYTHON variable together with sys.executable used for all platforms --- gui/wxpython/jupyter_notebook/server.py | 29 +++++-------------------- gui/wxpython/jupyter_notebook/utils.py | 11 +++------- 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index 5c4d02b4c60..3ba241581b7 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -24,7 +24,6 @@ import subprocess import threading import os -import shutil from pathlib import Path @@ -161,31 +160,13 @@ def start_server(self) -> None: self.port = JupyterServerInstance.find_free_port() self.server_url = "http://127.0.0.1:{}".format(self.port) - # Resolve Jupyter executable based on platform - if sys.platform.startswith("win"): - python = os.environ.get("GRASS_PYTHON") - if not python: - raise RuntimeError( - _( - "GRASS_PYTHON environment variable is not set. " - "Cannot locate Python executable." - ) - ) - executable = [python, "-m", "notebook"] - else: - jupyter = shutil.which("jupyter") - if not jupyter: - raise RuntimeError( - _( - "Jupyter executable not found in PATH. " - "Please install Jupyter Notebook and ensure it is available in your system PATH." - ) - ) - executable = [jupyter, "notebook"] + # Resolve Jupyter executable + python = os.environ.get("GRASS_PYTHON") or sys.executable cmd = [ - *executable, - "--no-browser", + python, + "-m", + "notebook", "--NotebookApp.token=", "--NotebookApp.password=", "--port", diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index 97378fe2127..2f684f7bed9 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -16,8 +16,8 @@ @author Linda Karlovska """ +import os import sys -import shutil import subprocess @@ -26,15 +26,10 @@ def is_jupyter_installed() -> bool: :return: True if Jupyter Notebook is installed and available, False otherwise """ - # Check if 'jupyter' CLI exists - jupyter_cmd = shutil.which("jupyter") - if not jupyter_cmd: - return False - - # Check if 'jupyter notebook' subcommand works + python = os.environ.get("GRASS_PYTHON") or sys.executable try: subprocess.run( - [jupyter_cmd, "notebook", "--version"], + [python, "-m", "notebook", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, From 87bde7627efcc2fa3c315744d79377e398ba399d Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sun, 12 Apr 2026 19:49:48 +0200 Subject: [PATCH 80/85] small refactoring and extending WebView2 catch to multi-window GUI --- gui/wxpython/jupyter_notebook/utils.py | 7 +++--- gui/wxpython/lmgr/frame.py | 30 ++++++++++++++++++-------- gui/wxpython/main_window/frame.py | 8 +++---- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index 2f684f7bed9..bc49f5f896b 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -4,7 +4,7 @@ @brief wxGUI Jupyter utils Functions: -- `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system and functional. +- `is_jupyter_notebook_installed()`: Check if Jupyter Notebook is installed on the system and functional. - `is_wx_html2_available()`: Check if wx.html2 module is available. - `is_webview2_available()`: Check if wx.html2.WebView uses Microsoft Edge WebView2 on Windows. @@ -21,7 +21,7 @@ import subprocess -def is_jupyter_installed() -> bool: +def is_jupyter_notebook_installed() -> bool: """Check if Jupyter Notebook is installed and functional. :return: True if Jupyter Notebook is installed and available, False otherwise @@ -70,8 +70,7 @@ def is_webview2_available() -> bool: try: import wx.html2 as html - info = html.WebView.GetBackendVersionInfo() - return info.GetName().lower() == "edge" + return html.WebView.IsBackendAvailable(html.WebViewBackendEdge) except Exception: return False return True diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index 3fd5c76fc07..b6056b49bb5 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -778,7 +778,7 @@ def OnGModeler(self, event=None, cmd=None): win.CentreOnScreen() win.Show() - def _show_jupyter_missing_message(self): + def _show_jupyter_notebook_missing_message(self): wx.MessageBox( _( "To use notebooks in GRASS, you need to have the Jupyter Notebook " @@ -792,14 +792,15 @@ def _show_jupyter_missing_message(self): def OnJupyterNotebook(self, event=None): """Launch Jupyter Notebook interface.""" from jupyter_notebook.utils import ( - is_jupyter_installed, + is_jupyter_notebook_installed, is_wx_html2_available, + is_webview2_available, ) from jupyter_notebook.dialogs import JupyterStartDialog # global requirement (always needed) - if not is_jupyter_installed(): - self._show_jupyter_missing_message() + if not is_jupyter_notebook_installed(): + self._show_jupyter_notebook_missing_message() return dlg = JupyterStartDialog(parent=self) @@ -818,15 +819,26 @@ def OnJupyterNotebook(self, event=None): storage = values["storage"] create_template = values["create_template"] + # Check integrated mode requirements and offer fallback if action == "integrated": - # Embedded notebook mode: requires wx.html2 for WebView + message = None + if not is_wx_html2_available(): - # Offer fallback to browser mode + message = _( + "Integrated mode requires wx.html2.WebView, which is not available on this system.\n\n" + "This can happen if wxPython or wxWidgets were built without HTML2/WebView support." + ) + elif not is_webview2_available(): + message = _( + "Integrated mode requires Microsoft Edge WebView2 runtime on Windows.\n\n" + "It is missing or not properly configured on this system." + ) + + if message is not None: response = wx.MessageBox( _( - "Integrated mode requires wx.html2.WebView which is not available on this system.\n\n" - "Would you like to open Jupyter Notebook in your external browser instead?" - ), + "{message}\n\nWould you like to open Jupyter Notebook in your external browser instead?" + ).format(message=message), _("Integrated Mode Not Available"), wx.ICON_WARNING | wx.YES_NO, ) diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index a31d743dab7..640d6bdaacc 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -906,7 +906,7 @@ 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 _show_jupyter_missing_message(self): + def _show_jupyter_notebook_missing_message(self): wx.MessageBox( _( "To use notebooks in GRASS, you need to have the Jupyter Notebook " @@ -984,15 +984,15 @@ def _setup_jupyter_browser_mode(self, storage, create_template): def OnJupyterNotebook(self, event=None, cmd=None): """Launch Jupyter Notebook interface.""" from jupyter_notebook.utils import ( - is_jupyter_installed, + is_jupyter_notebook_installed, is_wx_html2_available, is_webview2_available, ) from jupyter_notebook.dialogs import JupyterStartDialog # global requirement (always needed) - if not is_jupyter_installed(): - self._show_jupyter_missing_message() + if not is_jupyter_notebook_installed(): + self._show_jupyter_notebook_missing_message() return dlg = JupyterStartDialog(parent=self) From d319e0b672e7d48ba59206501e181edaa2dcb06f Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sun, 12 Apr 2026 20:07:15 +0200 Subject: [PATCH 81/85] Vasheks doc rewrite - better way of communicating what the Jupyter NB integration to GUI means --- doc/jupyter_intro.md | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/doc/jupyter_intro.md b/doc/jupyter_intro.md index 72ff9c06649..11da2e95fa5 100644 --- a/doc/jupyter_intro.md +++ b/doc/jupyter_intro.md @@ -6,28 +6,10 @@ authors: # Jupyter notebooks introduction -GRASS can be used directly in [Jupyter](https://jupyter.org/) -notebooks, allowing you to combine interactive Python code, -visualizations, and documentation in one place. - -## How to Get Started - -You can work with GRASS in Jupyter notebooks in several ways. -See these tutorials for setup instructions: - -- [Using Jupyter Notebooks from GRASS GUI](https://grass.osgeo.org/grass-stable/manuals/wxGUI.jupyter.html) -(GRASS 8.5+) -- [Get started with GRASS & Python in Jupyter (Unix/Linux)](https://grass-tutorials.osgeo.org/content/tutorials/get_started/fast_track_grass_and_python.html) -- [Get started on Windows](https://grass-tutorials.osgeo.org/content/tutorials/get_started/JupyterOnWindows_OSGeo4W_Tutorial.html) -- [Get started with GRASS in Google Colab](https://grass-tutorials.osgeo.org/content/tutorials/get_started/grass_gis_in_google_colab.html) -- Try it now: [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/OSGeo/grass/main?labpath=doc%2Fexamples%2Fnotebooks%2Fjupyter_tutorial.ipynb) - -## Working with grass.jupyter - -Once you have Jupyter running (using any method above), -use the `grass.jupyter` package to create map figures, -interactive web maps, visualize data series and time series, -and generate 3D visualizations. +The `grass.jupyter` Python package provides a [Jupyter](https://jupyter.org/) +notebook interface to GRASS. It includes modules for creating map figures, +interactive web maps, visualizing data series and time series, and generating +3D visualizations. If you don't have a project yet, create a new one first: @@ -286,3 +268,16 @@ library documentation page. For complete documentation on the `grass.script` package, see the [grass.script](https://grass.osgeo.org/grass-stable/manuals/libpython/grass.script.html) library documentation page. + +## Tutorials + +For local installation, see the tutorials for [Unix/Linux] +() +or [Windows](https://grass-tutorials.osgeo.org/content/tutorials/get_started/JupyterOnWindows_OSGeo4W_Tutorial.html). +If you want to combine the notebook workflow with the desktop GUI experience, +the GUI includes a built-in [Jupyter integration](wxGUI.jupyter.md) (since v8.5+). +For cloud setup, see the [Google Colab tutorial](https://grass-tutorials.osgeo.org/content/tutorials/get_started/grass_gis_in_google_colab.html). + +To try GRASS in a notebook without any setup, +run the `grass.jupyter` tutorial on Binder: +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/OSGeo/grass/main?labpath=doc%2Fexamples%2Fnotebooks%2Fjupyter_tutorial.ipynb) From 5b2fa8c3e7a91ca1177d94e5c5d8274728f84c4d Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Wed, 15 Apr 2026 06:42:08 +0200 Subject: [PATCH 82/85] keep only sys.executable as executable, get rid of os import in server.py (replaced by Path writability check) --- gui/wxpython/jupyter_notebook/server.py | 19 +++++++++++-------- gui/wxpython/jupyter_notebook/utils.py | 4 +--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index 3ba241581b7..d14c4f5c079 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -23,7 +23,6 @@ import time import subprocess import threading -import os from pathlib import Path @@ -141,15 +140,21 @@ def start_server(self) -> None: or the server fails to start """ # Validation checks - if not Path(self.storage).is_dir(): + if not self.storage.is_dir(): raise RuntimeError( _("Notebook storage does not exist: {}").format(self.storage) ) - if not os.access(self.storage, os.W_OK): + write_check = self.storage / ".jupyter_notebook_write_check_{}".format( + time.time_ns() + ) + try: + write_check.touch(exist_ok=False) + write_check.unlink() + except OSError as error: raise RuntimeError( _("Notebook storage is not writable: {}").format(self.storage) - ) + ) from error if self.is_alive(): raise RuntimeError( @@ -160,11 +165,9 @@ def start_server(self) -> None: self.port = JupyterServerInstance.find_free_port() self.server_url = "http://127.0.0.1:{}".format(self.port) - # Resolve Jupyter executable - python = os.environ.get("GRASS_PYTHON") or sys.executable - + # Build command to start Jupyter server cmd = [ - python, + sys.executable, "-m", "notebook", "--NotebookApp.token=", diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index bc49f5f896b..f4f54fa2988 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -16,7 +16,6 @@ @author Linda Karlovska """ -import os import sys import subprocess @@ -26,10 +25,9 @@ def is_jupyter_notebook_installed() -> bool: :return: True if Jupyter Notebook is installed and available, False otherwise """ - python = os.environ.get("GRASS_PYTHON") or sys.executable try: subprocess.run( - [python, "-m", "notebook", "--version"], + [sys.executable, "-m", "notebook", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, From 87534c33bed59b98c0592fe10ae81360ee89dbc4 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 16 Apr 2026 22:54:09 +0200 Subject: [PATCH 83/85] dialog for easy notebook installation, dialog for easy wxpython reinstall from pip (to make integrated mode available) --- gui/wxpython/jupyter_notebook/server.py | 3 + gui/wxpython/jupyter_notebook/utils.py | 203 +++++++++++++++++++++++- gui/wxpython/lmgr/frame.py | 42 ++--- gui/wxpython/main_window/frame.py | 42 ++--- 4 files changed, 247 insertions(+), 43 deletions(-) diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index d14c4f5c079..9a6dca57cd8 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -166,10 +166,13 @@ def start_server(self) -> None: self.server_url = "http://127.0.0.1:{}".format(self.port) # Build command to start Jupyter server + # Use the current Python interpreter to avoid PATH/env mismatches + # (especially on Windows) between GRASS and a separate jupyter executable. cmd = [ sys.executable, "-m", "notebook", + "--no-browser", "--NotebookApp.token=", "--NotebookApp.password=", "--port", diff --git a/gui/wxpython/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py index f4f54fa2988..fa631a2cc3f 100644 --- a/gui/wxpython/jupyter_notebook/utils.py +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -4,9 +4,13 @@ @brief wxGUI Jupyter utils Functions: -- `is_jupyter_notebook_installed()`: Check if Jupyter Notebook is installed on the system and functional. +- `is_notebook_module_available()`: Check if the current Python can run the notebook module. - `is_wx_html2_available()`: Check if wx.html2 module is available. - `is_webview2_available()`: Check if wx.html2.WebView uses Microsoft Edge WebView2 on Windows. +- `get_wxpython_version()`: Return current wxPython version. +- `force_reinstall_wxpython()`: Force reinstall current wxPython version via pip. +- `ensure_notebook_module_available()`: Show install dialog and ensure notebook module. +- `ensure_webview2_backend_available()`: Show reinstall dialog and ensure WebView2 backend. (C) 2026 by the GRASS Development Team @@ -20,8 +24,8 @@ import subprocess -def is_jupyter_notebook_installed() -> bool: - """Check if Jupyter Notebook is installed and functional. +def is_notebook_module_available() -> bool: + """Check if the current Python can run the notebook module. :return: True if Jupyter Notebook is installed and available, False otherwise """ @@ -37,6 +41,44 @@ def is_jupyter_notebook_installed() -> bool: return False +def install_notebook_package() -> None: + """Install notebook package in the current Python environment. + + Uses the same interpreter as GRASS to avoid environment mismatch issues. + """ + subprocess.check_call([sys.executable, "-m", "pip", "install", "notebook"]) + + +def get_wxpython_version() -> str | None: + """Get current wxPython version string. + + :return: Installed wxPython version, or None if wx is not importable + """ + try: + import wx + + return str(wx.__version__) + except Exception: + return None + + +def force_reinstall_wxpython(version: str) -> None: + """Force reinstall wxPython with a specific version in current Python. + + :param version: wxPython version (e.g. "4.2.2") + """ + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "--force-reinstall", + "wxpython=={}".format(version), + ] + ) + + def is_wx_html2_available() -> bool: """Check whether wx.html2 (WebView) support is available. @@ -72,3 +114,158 @@ def is_webview2_available() -> bool: except Exception: return False return True + + +def ensure_notebook_module_available(parent, report_error, report_info) -> bool: + """Ensure notebook module is available, offering interactive installation. + + :param parent: Parent wx window for dialogs + :param report_error: Error reporting callback (e.g. GError) + :param report_info: Info reporting callback (e.g. GMessage) + :return: True when notebook module is available, False otherwise + """ + import wx + + if is_notebook_module_available(): + return True + + dlg = wx.MessageDialog( + parent=parent, + message=( + "To use notebooks in GRASS, you need to have the Jupyter Notebook " + "package installed.\n\n" + "Would you like to install it now?" + ), + caption="Jupyter Notebook not available", + style=wx.YES_NO | wx.ICON_INFORMATION, + ) + if hasattr(dlg, "SetYesNoLabels"): + dlg.SetYesNoLabels("Install", "Cancel") + + response = dlg.ShowModal() + dlg.Destroy() + + if response != wx.ID_YES: + return False + + busy = wx.BusyInfo("Installing Jupyter Notebook package...", parent=parent) + try: + install_notebook_package() + except Exception as error: + report_error( + parent=parent, + message=( + "Automatic installation failed.\n\n" + "Please run this command manually in the same Python environment:\n" + "{command}\n\n" + "Details: {error}" + ).format( + command="{} -m pip install notebook".format(sys.executable), + error=error, + ), + ) + return False + finally: + del busy + + if not is_notebook_module_available(): + report_error( + parent=parent, + message=( + "Installation finished, but notebook module is still not available " + "in the current Python environment. Please restart GRASS and try again." + ), + ) + return False + + report_info( + parent=parent, + message="Jupyter Notebook dependency was installed successfully.", + ) + return True + + +def ensure_webview2_backend_available(parent, report_error, report_info) -> str: + """Ensure WebView2 backend is available on Windows. + + Offers wxPython force-reinstall with currently installed wxPython version. + + :param parent: Parent wx window for dialogs + :param report_error: Error reporting callback (e.g. GError) + :param report_info: Info reporting callback (e.g. GMessage) + :return: "available" when backend is ready now, + "restart-required" when reinstall succeeded but GRASS restart is needed, + "unavailable" otherwise + """ + import wx + + # WebView2 backend is Windows-specific; non-Windows platforms are unaffected. + if not sys.platform.startswith("win"): + return "available" + + if is_webview2_available(): + return "available" + + version = get_wxpython_version() + if not version: + report_error( + parent=parent, + message=( + "Failed to detect wxPython version. " + "Cannot run automatic reinstall for WebView2 support." + ), + ) + return "unavailable" + + command = "{} -m pip install --force-reinstall wxpython=={}".format( + sys.executable, version + ) + dlg = wx.MessageDialog( + parent=parent, + message=( + "Integrated mode requires Microsoft Edge WebView2 backend in wxPython.\n\n" + "Your current wxPython build may come without this support " + "(common in some OSGeo4W builds).\n\n" + "Would you like to run this command now in the current Python environment?\n" + "{command}" + ).format(command=command), + caption="WebView2 Backend Not Available", + style=wx.YES_NO | wx.ICON_WARNING, + ) + if hasattr(dlg, "SetYesNoLabels"): + dlg.SetYesNoLabels("Reinstall", "Skip") + + response = dlg.ShowModal() + dlg.Destroy() + + if response != wx.ID_YES: + return "unavailable" + + busy = wx.BusyInfo( + "Reinstalling wxPython (this may take a while)...", parent=parent + ) + try: + force_reinstall_wxpython(version) + except Exception as error: + report_error( + parent=parent, + message=( + "Automatic wxPython reinstall failed.\n\n" + "Please run this command manually in the same Python environment:\n" + "{command}\n\n" + "Details: {error}" + ).format(command=command, error=error), + ) + return "unavailable" + finally: + del busy + + report_info( + parent=parent, + message=( + "wxPython reinstall finished successfully, but the current GRASS session " + "is still using the old wxPython build.\n\n" + "Please restart GRASS and try integrated mode again." + ), + ) + return "restart-required" diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index b6056b49bb5..89ccc6aa253 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -778,30 +778,24 @@ def OnGModeler(self, event=None, cmd=None): win.CentreOnScreen() win.Show() - def _show_jupyter_notebook_missing_message(self): - wx.MessageBox( - _( - "To use notebooks in GRASS, you need to have the Jupyter Notebook " - "package installed. Please install it and restart GRASS." - ), - _("Jupyter Notebook not available"), - wx.OK | wx.ICON_INFORMATION, - parent=self, - ) - def OnJupyterNotebook(self, event=None): """Launch Jupyter Notebook interface.""" from jupyter_notebook.utils import ( - is_jupyter_notebook_installed, + ensure_notebook_module_available, + ensure_webview2_backend_available, + is_notebook_module_available, is_wx_html2_available, - is_webview2_available, ) from jupyter_notebook.dialogs import JupyterStartDialog # global requirement (always needed) - if not is_jupyter_notebook_installed(): - self._show_jupyter_notebook_missing_message() - return + if not is_notebook_module_available(): + if not ensure_notebook_module_available( + parent=self, + report_error=GError, + report_info=GMessage, + ): + return dlg = JupyterStartDialog(parent=self) @@ -828,11 +822,19 @@ def OnJupyterNotebook(self, event=None): "Integrated mode requires wx.html2.WebView, which is not available on this system.\n\n" "This can happen if wxPython or wxWidgets were built without HTML2/WebView support." ) - elif not is_webview2_available(): - message = _( - "Integrated mode requires Microsoft Edge WebView2 runtime on Windows.\n\n" - "It is missing or not properly configured on this system." + elif sys.platform.startswith("win"): + webview2_status = ensure_webview2_backend_available( + parent=self, + report_error=GError, + report_info=GMessage, ) + if webview2_status == "restart-required": + return + if webview2_status == "unavailable": + message = _( + "Integrated mode requires Microsoft Edge WebView2 runtime on Windows.\n\n" + "It is missing or not properly configured on this system." + ) if message is not None: response = wx.MessageBox( diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index 640d6bdaacc..34799f499c8 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -906,17 +906,6 @@ 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 _show_jupyter_notebook_missing_message(self): - wx.MessageBox( - _( - "To use notebooks in GRASS, you need to have the Jupyter Notebook " - "package installed. Please install it and restart GRASS." - ), - _("Jupyter Notebook not available"), - wx.OK | wx.ICON_INFORMATION, - parent=self, - ) - def _add_jupyter_panel_to_notebook(self, panel, mode_name, storage): """Add Jupyter panel to notebook with tooltip.""" tooltip = str(storage.resolve()) @@ -984,16 +973,21 @@ def _setup_jupyter_browser_mode(self, storage, create_template): def OnJupyterNotebook(self, event=None, cmd=None): """Launch Jupyter Notebook interface.""" from jupyter_notebook.utils import ( - is_jupyter_notebook_installed, + ensure_notebook_module_available, + ensure_webview2_backend_available, + is_notebook_module_available, is_wx_html2_available, - is_webview2_available, ) from jupyter_notebook.dialogs import JupyterStartDialog # global requirement (always needed) - if not is_jupyter_notebook_installed(): - self._show_jupyter_notebook_missing_message() - return + if not is_notebook_module_available(): + if not ensure_notebook_module_available( + parent=self, + report_error=GError, + report_info=GMessage, + ): + return dlg = JupyterStartDialog(parent=self) @@ -1020,11 +1014,19 @@ def OnJupyterNotebook(self, event=None, cmd=None): "Integrated mode requires wx.html2.WebView, which is not available on this system.\n\n" "This can happen if wxPython or wxWidgets were built without HTML2/WebView support." ) - elif not is_webview2_available(): - message = _( - "Integrated mode requires Microsoft Edge WebView2 runtime on Windows.\n\n" - "It is missing or not properly configured on this system." + elif sys.platform.startswith("win"): + webview2_status = ensure_webview2_backend_available( + parent=self, + report_error=GError, + report_info=GMessage, ) + if webview2_status == "restart-required": + return + if webview2_status == "unavailable": + message = _( + "Integrated mode requires Microsoft Edge WebView2 runtime on Windows.\n\n" + "It is missing or not properly configured on this system." + ) if message is not None: response = wx.MessageBox( From c5ddd100d59f52fbbbdc92af82d4028974a51657 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sun, 19 Apr 2026 07:57:04 +0200 Subject: [PATCH 84/85] small edit in start_server command + desc extension --- gui/wxpython/jupyter_notebook/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gui/wxpython/jupyter_notebook/server.py b/gui/wxpython/jupyter_notebook/server.py index 9a6dca57cd8..406219057f6 100644 --- a/gui/wxpython/jupyter_notebook/server.py +++ b/gui/wxpython/jupyter_notebook/server.py @@ -168,6 +168,8 @@ def start_server(self) -> None: # Build command to start Jupyter server # Use the current Python interpreter to avoid PATH/env mismatches # (especially on Windows) between GRASS and a separate jupyter executable. + # We intentionally run `-m notebook` instead of `-m jupyter notebook` + # to avoid an extra launcher layer which can fail for Standalone Windows installer cmd = [ sys.executable, "-m", @@ -178,7 +180,7 @@ def start_server(self) -> None: "--port", str(self.port), "--notebook-dir", - self.storage, + str(self.storage), ] # Start server From 66abadf475ed9267ef0d8cd127c491cf7a7138e1 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Sun, 19 Apr 2026 08:04:56 +0200 Subject: [PATCH 85/85] extending GUI docs - adding Windows Setup section and Command Used to Start Jupyter section --- gui/wxpython/docs/wxGUI.jupyter.md | 83 ++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/gui/wxpython/docs/wxGUI.jupyter.md b/gui/wxpython/docs/wxGUI.jupyter.md index b53b9151dea..3f922d9c585 100644 --- a/gui/wxpython/docs/wxGUI.jupyter.md +++ b/gui/wxpython/docs/wxGUI.jupyter.md @@ -6,16 +6,21 @@ authors: # Using Jupyter Notebooks from GUI -Starting with GRASS version 8.5, the GUI provides integrated support for launching +Starting with GRASS 8.5, the GUI provides integrated support for launching and managing Jupyter Notebooks directly from the interface. -This allows you to seamlessly combine interactive Python notebooks -with your GUI workflow. +This lets you combine interactive Python notebooks with your GUI workflow. + +## Before You Start + +- Linux: no additional setup is currently required. +- Windows: setup and dependency fixes are guided automatically by GRASS dialogs. + For details, see [Windows Setup Details](#windows-setup-details). ## Getting Started To launch Jupyter from GUI, go to **File → Jupyter Notebook** or click the Jupyter button in the Tools toolbar at the top of the GRASS window. -A startup dialog will appear where you can configure your notebook environment: +The startup dialog lets you configure your notebook environment: ![Jupyter Startup Dialog](jupyter_startup_dialog.png) @@ -23,8 +28,8 @@ A startup dialog will appear where you can configure your notebook environment: - **Where to Save Notebooks:** Select where your Jupyter notebooks will be stored. You can choose an existing directory, create a new one, or leave the field empty - to use your current directory. Notebooks can be saved anywhere, - including inside your current GRASS project. + to use your current directory. Notebooks can be saved anywhere, including + inside your current GRASS project. - **Create Welcome Notebook:** Check this option to automatically create a `welcome.ipynb` template notebook with GRASS-specific examples and @@ -38,7 +43,7 @@ After configuring the storage, choose how to interact with Jupyter notebooks: ![Browser Mode - Jupyter opened in your default web browser](jupyter_browser_mode.png) -In Browser Mode, Jupyter opens in your system's default web browser. +In Browser Mode, Jupyter opens in your default system web browser. This mode provides: @@ -62,7 +67,7 @@ In Integrated Mode, Jupyter notebooks are embedded directly in the GRASS GUI win ### Toolbar Actions -The integrated mode toolbar provides quick access to common operations: +The Integrated mode toolbar provides quick access to common operations: - **Create:** Create a new notebook with prepared GRASS module imports and session initialization @@ -76,7 +81,7 @@ the browser mode) ### Multiple Notebook Sessions You can launch multiple Jupyter sessions with different storage locations. -Each session appears as a separate tab in the GRASS GUI, with the storage +Each session appears as a separate tab in the GRASS GUI, with its storage location shown in the tab name. Hover over a tab to see the full storage path. ### Server Management @@ -88,6 +93,24 @@ GRASS automatically manages Jupyter server instances: - Multiple notebooks from the same storage share one server instance - Server information (URL, PID) is displayed in the interface +### Command Used to Start Jupyter + +GRASS starts Jupyter with the same command on Windows, Linux, and macOS: + +```bash + -m notebook ... +``` + +The main reason for this choice is Windows standalone reliability. On +Windows standalone installations, running `python -m jupyter notebook` can fail +even when Notebook is installed and available. A typical symptom is an +import/bootstrap error (for example `Could not import runpy._run_module_as_main` +or `AssertionError: SRE module mismatch`). + +This usually indicates a Python environment mismatch in the `jupyter` launcher +path, where imported modules do not fully match the active GRASS Python runtime. +Using `python -m notebook` avoids that extra launcher layer. + ### Tips - The `welcome.ipynb` template includes GRASS session initialization @@ -97,5 +120,45 @@ GRASS automatically manages Jupyter server instances: - You can switch between browser and integrated modes by closing one and relaunching Jupyter with the same storage location -- Tab tooltips show the full storage path—useful when working with +- Tab tooltips show the full storage path - useful when working with multiple storage locations + +## Windows Setup Details + +This section describes the Windows-specific setup. +Users on Linux and macOS can skip this section. + +### Install Missing Notebook Package + +If the `notebook` package is missing when you click **Launch Jupyter +Notebook**, GRASS detects this automatically and opens a dialog that offers to +install it. Clicking **Install** runs: + +```bash + -m pip install notebook +``` + +in the current GRASS Python environment. After installation, the +Jupyter Startup dialog opens normally. + +If the automatic installation fails, an error dialog displays the exact command +you can run manually in the GRASS Python console. + +### Microsoft Edge WebView2 Runtime Support for Integrated Mode + +Integrated mode requires `wx.html2.WebView` with Microsoft Edge WebView2 +backend support. Some wxPython builds (including current OSGeo4W-based +builds) are compiled without WebView2 support, so Integrated mode cannot start. + +When you choose Integrated mode and WebView2 is not available, GRASS detects +this and opens a dialog offering to reinstall wxPython using pip. The +reinstall keeps the currently installed wxPython version, but fetches the pip +wheel that includes WebView2 support. Clicking **Reinstall** runs: + +```bash + -m pip install --force-reinstall wxpython== +``` + +Because the current GRASS session still uses the previously loaded wxPython +build, a restart is required after reinstalling. After restart, Integrated mode +should start successfully.