diff --git a/doc/jupyter_intro.md b/doc/jupyter_intro.md index ca581ae2fd4..11da2e95fa5 100644 --- a/doc/jupyter_intro.md +++ b/doc/jupyter_intro.md @@ -271,7 +271,13 @@ library documentation page. ## 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) +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) diff --git a/gui/icons/grass/jupyter.png b/gui/icons/grass/jupyter.png new file mode 100644 index 00000000000..dc4c010eda2 Binary files /dev/null and b/gui/icons/grass/jupyter.png differ 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/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 a661e593bfc..343599fe01a 100644 --- a/gui/wxpython/Makefile +++ b/gui/wxpython/Makefile @@ -9,23 +9,24 @@ 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 \ 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))) -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) -DSTDIRS := $(patsubst %,$(DSTDIR)/%,icons xml) +DSTDIRS := $(patsubst %,$(DSTDIR)/%,icons xml jupyter_notebook/template_notebooks) default: $(DSTFILES) ifndef CROSS_COMPILING diff --git a/gui/wxpython/docs/jupyter_browser_mode.png b/gui/wxpython/docs/jupyter_browser_mode.png new file mode 100644 index 00000000000..989cc6696cf Binary files /dev/null and b/gui/wxpython/docs/jupyter_browser_mode.png differ diff --git a/gui/wxpython/docs/jupyter_integrated_mode.png b/gui/wxpython/docs/jupyter_integrated_mode.png new file mode 100644 index 00000000000..9d4147f57ee Binary files /dev/null and b/gui/wxpython/docs/jupyter_integrated_mode.png differ diff --git a/gui/wxpython/docs/jupyter_startup_dialog.png b/gui/wxpython/docs/jupyter_startup_dialog.png new file mode 100644 index 00000000000..33ecb4bf6d3 Binary files /dev/null and b/gui/wxpython/docs/jupyter_startup_dialog.png differ diff --git a/gui/wxpython/docs/wxGUI.jupyter.md b/gui/wxpython/docs/wxGUI.jupyter.md new file mode 100644 index 00000000000..3f922d9c585 --- /dev/null +++ b/gui/wxpython/docs/wxGUI.jupyter.md @@ -0,0 +1,164 @@ +--- +authors: + - Linda Karlovska + - GRASS Development Team +--- + +# Using Jupyter Notebooks from GUI + +Starting with GRASS 8.5, the GUI provides integrated support for launching +and managing Jupyter Notebooks directly from the interface. +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. +The startup dialog lets you 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 default system 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 its 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 + +### 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 + 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 + +## 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. diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py new file mode 100644 index 00000000000..d736ffb1a14 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/dialogs.py @@ -0,0 +1,187 @@ +""" +@package jupyter_notebook.dialogs + +@brief Startup dialog for integration of Jupyter Notebook to GUI. + +Classes: + - dialogs::JupyterStartDialog + +(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. + +@author Linda Karlovska +""" + +from pathlib import Path +import wx + +from .storage import WELCOME_NOTEBOOK_NAME, get_project_jupyter_storage + + +class JupyterStartDialog(wx.Dialog): + """Dialog for selecting Jupyter startup options.""" + + 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.project_storage = get_project_jupyter_storage() + self.create_template = True + + sizer = wx.BoxSizer(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=_("Choose custom directory (leave empty for current directory):"), + style=wx.RB_GROUP, + ) + self.radio_custom.SetValue(True) # Default selection + + self.storage_picker = wx.DirPickerCtrl( + self, message=_("Select notebook directory"), style=wx.DIRP_USE_TEXTCTRL + ) + self.storage_picker.Enable(True) # Enabled by default + + self.radio_project = wx.RadioButton( + self, + 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_project, 0, wx.ALL, 5) + sizer.Add(storage_sizer, 0, wx.EXPAND | wx.ALL, 10) + + # 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 welcome notebook")) + self.checkbox_template.SetValue(True) + self.checkbox_template.SetToolTip( + _( + "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) + + info = wx.StaticText( + self, + label=_( + "Note: The welcome notebook 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) + + # Buttons section + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) + + 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_project.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()) + self.CentreOnParent() + + 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. + + :return: Dictionary with 'storage' and 'create_template' keys, or None on error + """ + if self.radio_custom.GetValue(): + 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 + 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( + _("Cannot create or write to the selected directory."), + _("Error"), + wx.ICON_ERROR, + ) + return None + + self.selected_storage = path + else: + self.selected_storage = Path(self.project_storage).resolve() + + return { + "storage": self.selected_storage, + "create_template": self.checkbox_template.GetValue(), + } + + 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: 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: wx.Event) -> None: + """Handle browser mode button click. + + :param event: Button click event + """ + if not self.GetValues(): + return + self.action = "browser" + self.EndModal(wx.ID_OK) diff --git a/gui/wxpython/jupyter_notebook/environment.py b/gui/wxpython/jupyter_notebook/environment.py new file mode 100644 index 00000000000..ed15e516026 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/environment.py @@ -0,0 +1,100 @@ +""" +@package jupyter_notebook.environment + +@brief High-level orchestrator which coordinates +the setup and teardown of a Jupyter Notebook environment. + +Classes: + - environment::JupyterEnvironment + +(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. + +@author Linda Karlovska +""" + +from pathlib import Path + +from .storage import JupyterStorageManager, WELCOME_NOTEBOOK_NAME +from .server import JupyterServerInstance, JupyterServerRegistry + + +class JupyterEnvironment: + """Orchestrates storage manager and Jupyter server lifecycle.""" + + def __init__(self, storage: Path | None, create_template: bool) -> None: + """Initialize Jupyter environment. + + :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) -> None: + """Prepare files and start server.""" + # Prepare files + self.storage_manager.prepare_files() + + # Start server + self.server.start_server() + + def stop(self) -> None: + """Stop server and unregister it.""" + self.server.stop_server() + JupyterServerRegistry.get().unregister(self.server) + + @classmethod + def stop_all(cls) -> None: + """Stop all running Jupyter servers and unregister them.""" + JupyterServerRegistry.get().stop_all_servers() + + @property + 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) -> 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) -> 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 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/frame.py b/gui/wxpython/jupyter_notebook/frame.py new file mode 100644 index 00000000000..42c813bef93 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/frame.py @@ -0,0 +1,191 @@ +""" +@package jupyter_notebook.frame + +@brief Frame for integration of Jupyter Notebook to multi-window GUI. + +Classes: + - frame::JupyterFrame + +(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. + +@author Linda Karlovska +""" + +from pathlib import Path + +import wx + +from core import globalvar +from jupyter_notebook.panel import JupyterPanel, JupyterBrowserPanel + + +class JupyterFrame(wx.Frame): + """Main window for the Jupyter Notebook interface in multi-window GUI. + + Supports both integrated (embedded WebView) and browser (external) modes. + """ + + def __init__( + self, + parent: wx.Window, + giface, + action: str = "integrated", + storage: Path | None = None, + create_template: bool = False, + id: int = wx.ID_ANY, + **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 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") + + icon_path = Path(globalvar.ICONDIR) / "grass.ico" + self.SetIcon(wx.Icon(str(icon_path), wx.BITMAP_TYPE_ICO)) + + self.statusbar = self.CreateStatusBar(number=1) + self.panel = None + self.storage = storage + self.giface = giface + + # Try integrated mode first if requested + if action == "integrated": + 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": + 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=self.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 + return False + + return True + + 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.""" + 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: wx.CloseEvent) -> None: + """Handle window close event. + + :param event: Close event + """ + if self.panel and hasattr(self.panel, "OnCloseWindow"): + self.panel.OnCloseWindow(event) + + if event.GetVeto(): + return + self.Destroy() diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py new file mode 100644 index 00000000000..85e313ce5fb --- /dev/null +++ b/gui/wxpython/jupyter_notebook/notebook.py @@ -0,0 +1,182 @@ +""" +@package jupyter_notebook.notebook + +@brief Notebook for integration of Jupyter Notebook to GUI. + +Classes: + - notebook::JupyterAuiNotebook + +(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. + +@author Linda Karlovska +""" + +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_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: 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, + ) -> 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) + + self.parent = parent + + super().__init__(parent=self.parent, id=wx.ID_ANY, agwStyle=agwStyle) + + self.SetArtProvider(SimpleTabArt()) + + self.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self.OnPageClose) + + 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) + - Jupyter Notebook 7+ (Lab interface) + + 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. + + :param event: WebView loaded event + """ + webview = event.GetEventObject() + + # 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 - 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; } + + /* 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 _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. + + :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) + 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: + """Close the AUI notebook page with confirmation dialog. + + :param event: Notebook page close event + """ + 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 new file mode 100644 index 00000000000..64532e66d2d --- /dev/null +++ b/gui/wxpython/jupyter_notebook/panel.py @@ -0,0 +1,616 @@ +""" +@package jupyter_notebook.panel + +@brief Panel for integration of Jupyter Notebook to GUI. + +Classes: + - panel::JupyterPanel + - panel::JupyterBrowserPanel + +(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. + +@author Linda Karlovska +""" + +from pathlib import Path + +import wx +import webbrowser + +from main_window.page import MainPageBase + +from .environment import JupyterEnvironment +from .notebook import JupyterAuiNotebook +from .toolbars import JupyterToolbar + + +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: wx.Window, + giface, + 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) + + self.parent = parent + self._giface = giface + self.statusbar = statusbar + self.SetName("JupyterIntegrated") + + # Create environment in integrated mode (requires wx.html2) + self.env = JupyterEnvironment( + storage=storage, + create_template=create_template, + ) + + self.toolbar = JupyterToolbar(parent=self) + self.aui_notebook = JupyterAuiNotebook(parent=self) + + self._layout() + + 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) + + self.SetAutoLayout(True) + self.SetSizer(sizer) + sizer.Fit(self) + self.Layout() + + 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: True if setup was successful, False otherwise + """ + 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 + + # Load notebook tabs in embedded AUI notebook + for fname in self.env.files: + try: + url = self.env.server.get_url(fname.name) + except RuntimeError as e: + wx.MessageBox( + _("Failed to get Jupyter server URL:\n{}").format(e), + _("Startup Error"), + wx.ICON_ERROR, + ) + return False + + self.aui_notebook.AddPage(url=url, title=fname.name) + + 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, + ) + ) + return True + + def Switch(self, file_name: str) -> bool: + """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: + self.aui_notebook.SetSelection(i) + return True + return False + + 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') + """ + 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(e), + _("URL Error"), + wx.ICON_ERROR, + ) + + 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') + """ + 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) + + 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 + :param new_name: Optional new name for the imported file + """ + try: + 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: + wx.MessageBox( + _("Failed to import file:\n{}").format(e), + _("Notebook Import Error"), + wx.ICON_ERROR | wx.OK, + ) + + 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( + 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 + + source_path = Path(dlg.GetPath()) + file_name = source_path.name + target_path = self.env.storage / file_name + + # File is already in the storage + if source_path.resolve() == target_path.resolve(): + self.OpenOrSwitch(file_name) + return + + # 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 notebook storage.\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" + + # Perform the import and open the notebook + self.Import(source_path, new_name=new_name) + + 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( + _("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.env.storage_manager.export_file( + file_name, destination_path, overwrite=True + ) + self.SetStatusText( + _("File {} exported to {}.").format(file_name, destination_path), 0 + ) + except (FileNotFoundError, FileExistsError) as e: + wx.MessageBox( + _("Failed to export file:\n{}").format(e), + caption=_("Notebook Export Error"), + style=wx.ICON_ERROR | wx.OK, + ) + + 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:"), + 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.env.storage_manager.create_new_notebook(new_name=name) + except (FileExistsError, ValueError) as e: + wx.MessageBox( + _("Failed to create notebook:\n{}").format(e), + caption=_("Notebook Creation Error"), + style=wx.ICON_ERROR | wx.OK, + ) + return + + # 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: 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?"), + _("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) -> None: + """Set text in the status bar. + + :param args: Arguments passed to statusbar.SetStatusText + """ + self.statusbar.SetStatusText(*args) + + def GetStatusBar(self) -> wx.StatusBar | None: + """Get statusbar. + + :return: Status bar or None if not available + """ + return self.statusbar + + +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: wx.Window, + giface, + 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) + + self.parent = parent + self._giface = giface + self.statusbar = statusbar + self.SetName("JupyterBrowser") + + 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 + 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) -> bool: + """Setup Jupyter environment and open in browser. + + :return: True if setup was successful, False otherwise + """ + 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(_("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 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 + + 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: 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 + 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: 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?"), + _("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) -> None: + """Set text in the status bar. + + :param args: Arguments passed to statusbar.SetStatusText + """ + if self.statusbar: + self.statusbar.SetStatusText(*args) + + 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 new file mode 100644 index 00000000000..406219057f6 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/server.py @@ -0,0 +1,335 @@ +""" +@package jupyter_notebook.server + +@brief Simple interface for launching and managing +a local Jupyter server. + +Classes: + - server::JupyterServerInstance + - server:: JupyterServerRegistry + +(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. + +@author Linda Karlovska +""" + +import atexit +import signal +import sys +import socket +import time +import subprocess +import threading +from pathlib import Path + + +from types import FrameType + + +_cleanup_registered = False + + +def _register_global_cleanup() -> None: + """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() -> None: + """Stop all registered servers.""" + try: + JupyterServerRegistry.get().stop_all_servers() + except Exception: + pass + + 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) + + 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, storage: Path) -> None: + """Initialize Jupyter server instance. + + :param storage: Storage path for notebooks + """ + self.storage = storage + + 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) -> None: + """Reset internal state related to the server.""" + self.pid = None + self.port = None + self.server_url = "" + self.proc = None + + @staticmethod + def find_free_port() -> int: + """Find a free port on the local machine. + + :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) -> bool: + """Check if the server process is still running. + + :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: 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 + :param delay: Delay between retries in seconds + :return: True if the server is up, False otherwise + """ + if not self.port: + return False + + for _ in range(retries): + try: + with socket.create_connection(("127.0.0.1", self.port), timeout=0.5): + return True + except OSError: + time.sleep(delay) + + return False + + 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 + """ + # Validation checks + if not self.storage.is_dir(): + raise RuntimeError( + _("Notebook storage does not exist: {}").format(self.storage) + ) + + 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( + _("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://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. + # 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", + "notebook", + "--no-browser", + "--NotebookApp.token=", + "--NotebookApp.password=", + "--port", + str(self.port), + "--notebook-dir", + str(self.storage), + ] + + # Start server + try: + self.proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + self.pid = self.proc.pid + + except (OSError, ValueError, subprocess.SubprocessError) as 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(): + # Server failed to start + try: + self.proc.kill() + self.proc.wait() + self.proc.wait(timeout=3) + except (OSError, subprocess.SubprocessError): + pass + + self._reset_state() + raise RuntimeError( + _( + "Failed to start Jupyter server. " + "Check for port conflicts, missing dependencies, or insufficient permissions." + ) + ) + + def stop_server(self) -> None: + """Stop the Jupyter server, ensuring no zombie processes. + + :raises RuntimeError: If the server cannot be stopped + """ + if not self.proc or not self.pid: + return + + try: + 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 + 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() + + # Unregister from global registry + try: + JupyterServerRegistry.get().unregister(self) + except Exception: + pass + + 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') + :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.")) + + if not self.is_alive(): + raise RuntimeError(_("Jupyter server has stopped unexpectedly.")) + + return "{}/notebooks/{}".format(self.server_url.rstrip("/"), file_name) + + +class JupyterServerRegistry: + """Thread-safe registry of running JupyterServerInstance objects.""" + + _instance: "JupyterServerRegistry | None" = None + _lock: threading.Lock = threading.Lock() + + @classmethod + def get(cls) -> "JupyterServerRegistry": + """Get the singleton registry instance (thread-safe). + + :return: The JupyterServerRegistry singleton instance + """ + if cls._instance is None: + with cls._lock: + # Double-check after acquiring lock + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self) -> None: + """Initialize the registry.""" + self.servers: list[JupyterServerInstance] = [] + self._servers_lock: threading.Lock = threading.Lock() + + def register(self, server: JupyterServerInstance) -> None: + """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: JupyterServerInstance) -> None: + """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) -> None: + """Stop all registered servers. + + Continues attempting to stop all servers even if some fail. + + :raises RuntimeError: If any servers failed to stop + """ + errors = [] + + 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() + 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/storage.py b/gui/wxpython/jupyter_notebook/storage.py new file mode 100644 index 00000000000..5bb3a0becf9 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/storage.py @@ -0,0 +1,254 @@ +""" +@package jupyter_notebook.storage + +@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 + +(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. + +@author Linda Karlovska +""" + +import os +import shutil +from pathlib import Path + +import grass.script as gs + + +# 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.""" + + def __init__( + self, storage: Path | None = None, create_template: bool = False + ) -> None: + """Initialize the Jupyter notebook storage. + + :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 storage is not writable + """ + self._storage = storage or get_project_jupyter_storage() + self._storage.mkdir(parents=True, exist_ok=True) + + 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 storage(self) -> Path: + """Return path to the storage.""" + return self._storage + + @property + def files(self) -> list[Path]: + """Return list of notebook files.""" + return self._files + + def prepare_files(self) -> None: + """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() + + def import_file( + self, source_path: Path, new_name: str | None = None, overwrite: bool = False + ) -> Path: + """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 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 + """ + # Validate the source path and ensure it has .ipynb extension + source = Path(source_path) + 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) + ) + + # 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 storage + target_path = self._storage / 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: str, destination_path: Path, overwrite: bool = False + ) -> None: + """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 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._storage / 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_from_template( + self, + template_name: str, + target_name: str | None = None, + replacements: dict[str, str] | None = None, + ) -> Path: + """Create a notebook from a template and optionally replace placeholders. + + :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 = TEMPLATE_DIR / template_name + + # Determine target path (copy vs. create new file) + if target_name is None: + target_path = self.import_file(template_path) + else: + target_path = self.storage / target_name + + # Prevent accidental overwrite + if target_path.exists(): + raise FileExistsError(_("File '{}' already exists").format(target_name)) + + # 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: str = WELCOME_NOTEBOOK_NAME + ) -> Path: + """Create a welcome notebook with storage and mapset path placeholders replaced. + + :param template_name: Template filename + :return: Path to the created notebook + """ + # Prepare placeholder replacements + env = gs.gisenv() + mapset_path = Path(env["GISDBASE"], env["LOCATION_NAME"], env["MAPSET"]) + replacements = { + "${STORAGE_PATH}": str(self._storage).replace("\\", "/"), + "${MAPSET_PATH}": str(mapset_path).replace("\\", "/"), + } + + # Create notebook from template + return self._create_from_template(template_name, replacements=replacements) + + 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 + :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: + msg = "Notebook name must not be empty" + raise ValueError(_(msg)) + + # Ensure .ipynb extension + if not new_name.endswith(".ipynb"): + new_name += ".ipynb" + + # Replace only mapset placeholder + env = gs.gisenv() + mapset_path = Path(env["GISDBASE"], env["LOCATION_NAME"], env["MAPSET"]) + replacements = { + "${MAPSET_PATH}": str(mapset_path).replace("\\", "/"), + } + + # 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 new file mode 100644 index 00000000000..c6e73a4b3a6 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/template_notebooks/new.ipynb @@ -0,0 +1,34 @@ +{ + "cells": [ + { + "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}\")" + ] + }, + { + "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()" + ] + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb new file mode 100644 index 00000000000..044191178fb --- /dev/null +++ b/gui/wxpython/jupyter_notebook/template_notebooks/welcome.ipynb @@ -0,0 +1,155 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "welcome-user", + "metadata": {}, + "source": [ + "# Welcome to GRASS Jupyter Environment\n", + "\n", + "This notebook is connected to your GRASS session and ready to use!\n", + "\n", + "**Notebook saved in:** `${STORAGE_PATH}` \n", + "**Started in mapset:** `${MAPSET_PATH}`\n", + "\n", + "---\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", + "print(tools.g_region(flags=\"p\").text)" + ] + }, + { + "cell_type": "markdown", + "id": "examples-header", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Examples - Try These!\n", + "\n", + "### Example 1: List Available Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "example-list", + "metadata": {}, + "outputs": [], + "source": [ + "# List raster maps in current mapset\n", + "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][\"name\"]\n", + "else:\n", + " name = \"fractal\"\n", + " tools.g_region(rows=100, cols=100)\n", + " tools.r_surf_fractal(output=name)" + ] + }, + { + "cell_type": "markdown", + "id": "examples-viz", + "metadata": {}, + "source": [ + "### Example 2: Visualize a Map\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "example-display", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a map display\n", + "m = gj.Map()\n", + "\n", + "# Add a raster layer\n", + "m.d_rast(map=name)\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", + "stats = tools.r_univar(map=name, format=\"json\")\n", + "stats[\"mean\"]" + ] + }, + { + "cell_type": "markdown", + "id": "whats-next", + "metadata": {}, + "source": [ + "---\n", + "\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-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", + "\n", + "## Resources\n", + "\n", + "- **[GRASS Tutorials](https://grass-tutorials.osgeo.org/)**\n", + "- **[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)**" + ] + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/gui/wxpython/jupyter_notebook/toolbars.py b/gui/wxpython/jupyter_notebook/toolbars.py new file mode 100644 index 00000000000..5efa0b6ffe0 --- /dev/null +++ b/gui/wxpython/jupyter_notebook/toolbars.py @@ -0,0 +1,136 @@ +""" +@package jupyter_notebook.toolbars + +@brief wxGUI Jupyter toolbars classes + +Classes: + - toolbars::JupyterToolbar + +(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. + +@author Linda Karlovska +""" + +import sys +from typing import Any + +import wx + +from core.globalvar import CheckWxVersion +from gui_core.toolbars import BaseToolbar, BaseIcons + +from icons.icon import MetaIcon + + +class JupyterToolbar(BaseToolbar): + """Toolbar for integrated Jupyter notebook interface.""" + + 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]): + parent.SetToolBar(self) + + self.InitToolbar(self._toolbarData()) + + # realize the toolbar + self.Realize() + + def _toolbarData(self) -> tuple[Any, ...]: + """Build toolbar data structure. + + :return: Toolbar data tuple containing tool definitions + """ + icons = { + "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), + icons["create"], + self.parent.OnCreate, + ), + ( + ("open", icons["open"].label), + icons["open"], + self.parent.OnImport, + ), + ( + ("save", icons["save"].label), + 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/jupyter_notebook/utils.py b/gui/wxpython/jupyter_notebook/utils.py new file mode 100644 index 00000000000..fa631a2cc3f --- /dev/null +++ b/gui/wxpython/jupyter_notebook/utils.py @@ -0,0 +1,271 @@ +""" +@package jupyter_notebook.utils + +@brief wxGUI Jupyter utils + +Functions: +- `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 + +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 subprocess + + +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 + """ + try: + subprocess.run( + [sys.executable, "-m", "notebook", "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return True + except (FileNotFoundError, subprocess.CalledProcessError, OSError): + 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. + + 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 + + +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 + + return html.WebView.IsBackendAvailable(html.WebViewBackendEdge) + 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 1ab5692d980..89ccc6aa253 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -778,6 +778,91 @@ def OnGModeler(self, event=None, cmd=None): win.CentreOnScreen() win.Show() + def OnJupyterNotebook(self, event=None): + """Launch Jupyter Notebook interface.""" + from jupyter_notebook.utils import ( + ensure_notebook_module_available, + ensure_webview2_backend_available, + is_notebook_module_available, + is_wx_html2_available, + ) + from jupyter_notebook.dialogs import JupyterStartDialog + + # global requirement (always needed) + 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) + + if dlg.ShowModal() != wx.ID_OK: + dlg.Destroy() + return + + values = dlg.GetValues() + action = dlg.action + dlg.Destroy() + + if not values: + return + + storage = values["storage"] + create_template = values["create_template"] + + # 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 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( + _( + "{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, + ) + + if response == wx.YES: + action = "browser" + else: + return + + from jupyter_notebook.frame import JupyterFrame + + # Create and show the Jupyter frame + frame = JupyterFrame( + parent=self, + giface=self._giface, + action=action, + storage=storage, + create_template=create_template, + ) + frame.CentreOnParent() + frame.Show() + def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" from psmap.frame import PsMapFrame @@ -2297,6 +2382,18 @@ def _closeWindow(self, event): event.Veto() return + # Stop all running Jupyter servers before destroying the GUI + from jupyter_notebook.environment 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/gui/wxpython/lmgr/toolbars.py b/gui/wxpython/lmgr/toolbars.py index cdcc1a6ac91..6966a6c8e7b 100644 --- a/gui/wxpython/lmgr/toolbars.py +++ b/gui/wxpython/lmgr/toolbars.py @@ -205,21 +205,22 @@ def _toolbarData(self): "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") ), + "script-load": MetaIcon( + img="script-load", label=_("Launch user-defined script") + ), + "jupyter": MetaIcon(img="jupyter", label=_("Start Jupyter Notebook")), } return self._getToolbarData( ( ( - ("newdisplay", _("New display")), + ("newdisplay", icons["newdisplay"].label), icons["newdisplay"], self.parent.OnNewDisplay, ), @@ -234,26 +235,31 @@ def _toolbarData(self): icons["georectify"], self.parent.OnGCPManager, ), + ( + ("mapOutput", icons["composer"].label), + icons["composer"], + self.parent.OnPsMap, + ), + (None,), ( ("modeler", icons["modeler"].label), icons["modeler"], self.parent.OnGModeler, ), ( - ("mapOutput", icons["composer"].label), - icons["composer"], - self.parent.OnPsMap, + ("python", _("Python code editor")), + icons["python"], + self.parent.OnSimpleEditor, ), - (None,), ( ("script-load", icons["script-load"].label), icons["script-load"], self.parent.OnRunScript, ), ( - ("python", _("Python code editor")), - icons["python"], - self.parent.OnSimpleEditor, + ("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 ebcb420dbd5..34799f499c8 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -906,6 +906,167 @@ 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 _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, tooltip) + + 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 ( + ensure_notebook_module_available, + ensure_webview2_backend_available, + is_notebook_module_available, + is_wx_html2_available, + ) + from jupyter_notebook.dialogs import JupyterStartDialog + + # global requirement (always needed) + 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) + + if dlg.ShowModal() != wx.ID_OK: + dlg.Destroy() + return + + values = dlg.GetValues() + action = dlg.action + dlg.Destroy() + + if not values: + return + + storage = values["storage"] + create_template = values["create_template"] + + # 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 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( + _( + "{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, + ) + + if response == wx.YES: + action = "browser" + else: + return + + # Try integrated mode + if action == "integrated": + success = self._setup_jupyter_integrated_mode(storage, create_template) + if success: + return # Successfully set up integrated mode + + # Integrated mode failed, offer browser fallback + response = wx.MessageBox( + _( + "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"), + wx.ICON_ERROR | wx.YES_NO, + ) + + if response == wx.YES: + action = "browser" + else: + return + + # Set up browser mode + if action == "browser": + self._setup_jupyter_browser_mode(storage, create_template) + def OnPsMap(self, event=None, cmd=None): """Launch Cartographic Composer. See OnIClass documentation""" from psmap.frame import PsMapFrame @@ -2406,6 +2567,18 @@ def _closeWindow(self, event): event.Veto() return + # Stop all running Jupyter servers before destroying the GUI + from jupyter_notebook.environment 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/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 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..b94eb310862 100644 --- a/gui/wxpython/xml/wxgui_items.xml +++ b/gui/wxpython/xml/wxgui_items.xml @@ -62,6 +62,13 @@ OnRunScript Launches script file. + + + OnJupyterNotebook + Launch Jupyter Notebook interface. + general,gui,notebook,python,jupyter + jupyter + OnSimpleEditor