diff --git a/gui/icons/grass/jupyter-inactive.png b/gui/icons/grass/jupyter-inactive.png
new file mode 100644
index 00000000000..1f61bf5bd68
Binary files /dev/null and b/gui/icons/grass/jupyter-inactive.png differ
diff --git a/gui/icons/grass/jupyter-inactive.svg b/gui/icons/grass/jupyter-inactive.svg
new file mode 100644
index 00000000000..341f838fed1
--- /dev/null
+++ b/gui/icons/grass/jupyter-inactive.svg
@@ -0,0 +1,101 @@
+
+
+
+
diff --git a/gui/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/Makefile b/gui/wxpython/Makefile
index a661e593bfc..1764e35cfa7 100644
--- a/gui/wxpython/Makefile
+++ b/gui/wxpython/Makefile
@@ -9,7 +9,7 @@ include $(MODULE_TOPDIR)/include/Make/Python.make
DSTDIR = $(GUIDIR)/wxpython
SRCFILES := $(wildcard icons/*.py xml/*) \
- $(wildcard animation/*.py core/*.py datacatalog/*.py history/*.py dbmgr/*.py gcp/*.py gmodeler/*.py \
+ $(wildcard animation/*.py core/*.py datacatalog/*.py jupyter_notebook/*.py history/*.py dbmgr/*.py gcp/*.py gmodeler/*.py \
gui_core/*.py iclass/*.py lmgr/*.py location_wizard/*.py main_window/*.py mapwin/*.py mapdisp/*.py \
mapswipe/*.py modules/*.py nviz/*.py psmap/*.py rdigit/*.py \
rlisetup/*.py startup/*.py timeline/*.py vdigit/*.py \
@@ -19,7 +19,7 @@ SRCFILES := $(wildcard icons/*.py xml/*) \
DSTFILES := $(patsubst %,$(DSTDIR)/%,$(SRCFILES)) \
$(patsubst %.py,$(DSTDIR)/%.pyc,$(filter %.py,$(SRCFILES)))
-PYDSTDIRS := $(patsubst %,$(DSTDIR)/%,animation core datacatalog history dbmgr gcp gmodeler \
+PYDSTDIRS := $(patsubst %,$(DSTDIR)/%,animation core datacatalog jupyter_notebook history dbmgr gcp gmodeler \
gui_core iclass lmgr location_wizard main_window mapwin mapdisp modules nviz psmap \
mapswipe vdigit wxplot web_services rdigit rlisetup startup \
vnet timeline iscatt tplot photo2image image2target)
diff --git a/gui/wxpython/jupyter_notebook/dialogs.py b/gui/wxpython/jupyter_notebook/dialogs.py
new file mode 100644
index 00000000000..a95f8dc2112
--- /dev/null
+++ b/gui/wxpython/jupyter_notebook/dialogs.py
@@ -0,0 +1,122 @@
+"""
+@package jupyter_notebook.dialogs
+
+@brief Integration of Jupyter Notebook to GUI.
+
+Classes:
+ - dialog::JupyterStartDialog
+
+(C) 2025 by the GRASS Development Team
+
+This program is free software under the GNU General Public License
+(>=v2). Read the file COPYING that comes with GRASS for details.
+
+@author Linda Karlovska
+"""
+
+import os
+from pathlib import Path
+
+import wx
+
+from grass.workflows.directory import get_default_jupyter_workdir
+
+
+class JupyterStartDialog(wx.Dialog):
+ """Dialog for selecting Jupyter startup options."""
+
+ def __init__(self, parent):
+ super().__init__(parent, title=_("Start Jupyter Notebook"), size=(500, 300))
+
+ self.default_dir = get_default_jupyter_workdir()
+
+ self.selected_dir = self.default_dir
+ self.create_template = True
+
+ sizer = wx.BoxSizer(wx.VERTICAL)
+
+ # Working directory section
+ dir_box = wx.StaticBox(self, label=_("Notebook working directory"))
+ dir_sizer = wx.StaticBoxSizer(dir_box, wx.VERTICAL)
+
+ self.radio_default = wx.RadioButton(
+ self,
+ label=_("Use default: {}").format(self.default_dir),
+ style=wx.RB_GROUP,
+ )
+ self.radio_custom = wx.RadioButton(self, label=_("Select another directory:"))
+
+ self.dir_picker = wx.DirPickerCtrl(
+ self,
+ message=_("Choose a working directory"),
+ style=wx.DIRP_USE_TEXTCTRL | wx.DIRP_DIR_MUST_EXIST,
+ )
+ self.dir_picker.Enable(False)
+
+ dir_sizer.Add(self.radio_default, 0, wx.ALL, 5)
+ dir_sizer.Add(self.radio_custom, 0, wx.ALL, 5)
+ dir_sizer.Add(self.dir_picker, 0, wx.EXPAND | wx.ALL, 5)
+ sizer.Add(dir_sizer, 0, wx.EXPAND | wx.ALL, 10)
+
+ # 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 (welcome.ipynb) will be created,\n"
+ "but only if the selected directory contains no .ipynb files."
+ )
+ )
+ options_sizer.Add(self.checkbox_template, 0, wx.ALL, 5)
+
+ info = wx.StaticText(
+ self,
+ label=_(
+ "Note: 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)
+
+ btns = self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL)
+ sizer.Add(btns, 0, wx.EXPAND | wx.ALL, 10)
+
+ self.SetSizer(sizer)
+
+ self.radio_default.Bind(wx.EVT_RADIOBUTTON, self.OnRadioToggle)
+ self.radio_custom.Bind(wx.EVT_RADIOBUTTON, self.OnRadioToggle)
+
+ self.Fit()
+ self.Layout()
+ self.SetMinSize(self.GetSize())
+ self.CentreOnParent()
+
+ def OnRadioToggle(self, event):
+ """Enable/disable directory picker based on user choice."""
+ self.dir_picker.Enable(self.radio_custom.GetValue())
+
+ def GetValues(self):
+ """Return selected working directory and template preference."""
+ if self.radio_custom.GetValue():
+ path = Path(self.dir_picker.GetPath())
+
+ if not os.access(path, os.W_OK) or not os.access(path, os.X_OK):
+ wx.MessageBox(
+ _("You do not have permission to write to the selected directory."),
+ _("Error"),
+ wx.ICON_ERROR,
+ )
+ return None
+ self.selected_dir = path
+ else:
+ self.selected_dir = self.default_dir
+
+ return {
+ "directory": self.selected_dir,
+ "create_template": self.checkbox_template.GetValue(),
+ }
diff --git a/gui/wxpython/jupyter_notebook/notebook.py b/gui/wxpython/jupyter_notebook/notebook.py
new file mode 100644
index 00000000000..4e46707cb1e
--- /dev/null
+++ b/gui/wxpython/jupyter_notebook/notebook.py
@@ -0,0 +1,109 @@
+"""
+@package jupyter_notebook.notebook
+
+@brief Manages the jupyter notebook widget.
+
+Classes:
+ - page::JupyterAuiNotebook
+
+(C) 2025 by the GRASS Development Team
+
+This program is free software under the GNU General Public License
+(>=v2). Read the file COPYING that comes with GRASS for details.
+
+@author Linda Karlovska
+"""
+
+import wx
+from wx.lib.agw import aui
+
+try:
+ import wx.html2 as html # wx.html2 is available in wxPython 4.0 and later
+except ImportError as e:
+ raise RuntimeError(_("wx.html2 is required for Jupyter integration.")) from e
+
+from gui_core.wrap import SimpleTabArt
+
+
+class JupyterAuiNotebook(aui.AuiNotebook):
+ def __init__(
+ self,
+ parent,
+ agwStyle=aui.AUI_NB_DEFAULT_STYLE
+ | aui.AUI_NB_CLOSE_ON_ACTIVE_TAB
+ | aui.AUI_NB_TAB_EXTERNAL_MOVE
+ | aui.AUI_NB_BOTTOM
+ | wx.NO_BORDER,
+ ):
+ """
+ Wrapper for the notebook widget that manages notebook pages.
+ """
+ self.parent = parent
+ self.webview = None
+
+ super().__init__(parent=self.parent, id=wx.ID_ANY, agwStyle=agwStyle)
+
+ self.SetArtProvider(SimpleTabArt())
+
+ self.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self.OnPageClose)
+
+ def _inject_javascript(self, event):
+ """
+ Inject JavaScript into the Jupyter notebook page to hide top UI bars.
+
+ Works for:
+ - Jupyter Notebook 6 and older (classic interface)
+ - Jupyter Notebook 7+ (Jupyter Lab interface)
+
+ This is called once the WebView has fully loaded the Jupyter page.
+ """
+ webview = event.GetEventObject()
+ js = """
+ var interval = setInterval(function() {
+ // --- Jupyter Notebook 7+ (new UI) ---
+ var topPanel = document.getElementById('top-panel-wrapper');
+ var menuPanel = document.getElementById('menu-panel-wrapper');
+ if (topPanel) topPanel.style.display = 'none';
+ if (menuPanel) menuPanel.style.display = 'none';
+
+ // --- Jupyter Notebook 6 and older (classic UI) ---
+ var headerContainer = document.getElementById('header-container');
+ var menubar = document.getElementById('menubar');
+ if (headerContainer) headerContainer.style.display = 'none';
+ if (menubar) menubar.style.display = 'none';
+
+ // --- Stop once everything is hidden ---
+ if ((topPanel || headerContainer) && (menuPanel || menubar)) {
+ clearInterval(interval);
+ }
+ }, 500);
+ """
+ webview.RunScript(js)
+
+ def AddPage(self, url, title):
+ """
+ Add a new aui notebook page with a Jupyter WebView.
+ :param url: URL of the Jupyter file (str).
+ :param title: Tab title (str).
+ """
+ browser = html.WebView.New(self)
+ wx.CallAfter(browser.LoadURL, url)
+ wx.CallAfter(browser.Bind, html.EVT_WEBVIEW_LOADED, self._inject_javascript)
+ super().AddPage(browser, title)
+
+ def OnPageClose(self, event):
+ """Close the aui notebook page with confirmation dialog."""
+ index = event.GetSelection()
+ title = self.GetPageText(index)
+
+ dlg = wx.MessageDialog(
+ self,
+ message=_("Really close page '{}'?").format(title),
+ caption=_("Close page"),
+ style=wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION,
+ )
+
+ if dlg.ShowModal() != wx.ID_YES:
+ event.Veto()
+
+ dlg.Destroy()
diff --git a/gui/wxpython/jupyter_notebook/panel.py b/gui/wxpython/jupyter_notebook/panel.py
new file mode 100644
index 00000000000..2f5f287a3e9
--- /dev/null
+++ b/gui/wxpython/jupyter_notebook/panel.py
@@ -0,0 +1,320 @@
+"""
+@package jupyter_notebook.panel
+
+@brief Integration of Jupyter Notebook to GUI.
+
+Classes:
+ - panel::JupyterPanel
+
+(C) 2025 by the GRASS Development Team
+
+This program is free software under the GNU General Public License
+(>=v2). Read the file COPYING that comes with GRASS for details.
+
+@author Linda Karlovska
+"""
+
+from pathlib import Path
+
+import wx
+
+from main_window.page import MainPageBase
+from grass.workflows.environment import JupyterEnvironment
+
+from .notebook import JupyterAuiNotebook
+from .toolbars import JupyterToolbar
+
+
+class JupyterPanel(wx.Panel, MainPageBase):
+ def __init__(
+ self,
+ parent,
+ giface,
+ id=wx.ID_ANY,
+ title=_("Jupyter Notebook"),
+ statusbar=None,
+ dockable=False,
+ workdir=None,
+ create_template=False,
+ **kwargs,
+ ):
+ """Jupyter main panel."""
+ super().__init__(parent=parent, id=id, **kwargs)
+ MainPageBase.__init__(self, dockable)
+
+ self.parent = parent
+ self._giface = giface
+ self.statusbar = statusbar
+ self.workdir = workdir
+ self.SetName("Jupyter")
+
+ self.env = JupyterEnvironment(self.workdir, create_template)
+
+ self.toolbar = JupyterToolbar(parent=self)
+ self.aui_notebook = JupyterAuiNotebook(parent=self)
+
+ self._layout()
+
+ def _layout(self):
+ """Do layout"""
+ sizer = wx.BoxSizer(wx.VERTICAL)
+ sizer.Add(self.toolbar, proportion=0, flag=wx.EXPAND)
+ sizer.Add(self.aui_notebook, proportion=1, flag=wx.EXPAND)
+
+ self.SetAutoLayout(True)
+ self.SetSizer(sizer)
+ sizer.Fit(self)
+ self.Layout()
+
+ def SetUpNotebookInterface(self):
+ """Setup Jupyter notebook environment and load initial notebooks."""
+ try:
+ self.env.setup()
+ except Exception as e:
+ wx.MessageBox(
+ _("Failed to start Jupyter environment:\n{}").format(str(e)),
+ _("Startup Error"),
+ wx.ICON_ERROR,
+ )
+ return
+
+ # Load notebook tabs
+ for fname in self.env.directory.files:
+ try:
+ url = self.env.server.get_url(fname.name)
+ except RuntimeError as e:
+ wx.MessageBox(
+ _("Failed to get Jupyter server URLt:\n{}").format(str(e)),
+ _("Startup Error"),
+ wx.ICON_ERROR,
+ )
+ return
+ self.aui_notebook.AddPage(url=url, title=fname.name)
+
+ self.SetStatusText(
+ _("Jupyter server started at {url} (PID: {pid}), directory: {dir}").format(
+ url=self.env.server.server_url,
+ pid=self.env.server.pid,
+ dir=str(self.workdir),
+ )
+ )
+
+ def Switch(self, file_name):
+ """
+ Switch to existing notebook tab.
+ :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') (str).
+ :return: True if the notebook was found and switched to, False otherwise.
+ """
+ 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):
+ """
+ Open a Jupyter notebook to a new tab and switch to it.
+ :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') (str).
+ """
+ try:
+ url = self.env.server.get_url(file_name)
+ self.aui_notebook.AddPage(url=url, title=file_name)
+ self.aui_notebook.SetSelection(self.aui_notebook.GetPageCount() - 1)
+ except RuntimeError as e:
+ wx.MessageBox(
+ _("Failed to get Jupyter server URL:\n{}").format(str(e)),
+ _("URL Error"),
+ wx.ICON_ERROR,
+ )
+
+ def OpenOrSwitch(self, file_name):
+ """
+ Switch to .ipynb file if open, otherwise open it.
+ :param file_name: Name of the .ipynb file (e.g., 'example.ipynb') (str).
+ """
+ if self.Switch(file_name):
+ self.SetStatusText(_("File '{}' is already opened.").format(file_name), 0)
+ else:
+ self.Open(file_name)
+ self.SetStatusText(_("File '{}' opened.").format(file_name), 0)
+
+ def Import(self, source_path, new_name=None):
+ """
+ Import a .ipynb file into a working directory and open it to a new tab.
+ :param source_path: Path to the source .ipynb file to be imported (Path).
+ :param new_name: Optional new name for the imported file (str).
+ """
+ try:
+ path = self.env.directory.import_file(source_path, new_name=new_name)
+ self.Open(path.name)
+ self.SetStatusText(_("File '{}' imported and opened.").format(path.name), 0)
+ except Exception as e:
+ wx.MessageBox(
+ _("Failed to import file:\n{}").format(str(e)),
+ _("Notebook Import Error"),
+ wx.ICON_ERROR | wx.OK,
+ )
+
+ def OnImport(self, event=None):
+ """
+ Import an existing Jupyter notebook file into the working directory
+ and open it in the GUI.
+ - Prompts user to select a .ipynb file.
+ - If the selected file is already in the notebook directory:
+ - Switch to it or open it.
+ - If the file is from elsewhere:
+ - Import the notebook and open it (if needed, prompt for a new name).
+ """
+ # Open file dialog to select an existing Jupyter notebook file
+ with wx.FileDialog(
+ parent=wx.GetActiveWindow() or self.GetTopLevelParent(),
+ message=_("Import file"),
+ defaultDir=str(Path.cwd()),
+ wildcard="Jupyter notebooks (*.ipynb)|*.ipynb",
+ style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
+ ) as dlg:
+ if dlg.ShowModal() == wx.ID_CANCEL:
+ return
+
+ source_path = Path(dlg.GetPath())
+ file_name = source_path.name
+ target_path = self.workdir / file_name
+
+ # File is already in the working directory
+ if source_path.resolve() == target_path.resolve():
+ self.OpenOrSwitch(file_name)
+ return
+
+ # File is from outside the working directory
+ new_name = None
+ if target_path.exists():
+ # Prompt user for a new name if the notebook already exists
+ with wx.TextEntryDialog(
+ self,
+ message=_(
+ "File '{}' already exists in working directory.\nPlease enter a new name:"
+ ).format(file_name),
+ caption=_("Rename File"),
+ value="{}_copy".format(file_name.removesuffix(".ipynb")),
+ ) as name_dlg:
+ if name_dlg.ShowModal() == wx.ID_CANCEL:
+ return
+ new_name = name_dlg.GetValue().strip()
+ if not new_name.endswith(".ipynb"):
+ new_name += ".ipynb"
+
+ # Perform the import and open the notebook
+ self.Import(source_path, new_name=new_name)
+
+ def OnExport(self, event=None):
+ """Export the currently opened Jupyter notebook to a user-selected location."""
+ current_page = self.aui_notebook.GetSelection()
+ if current_page == wx.NOT_FOUND:
+ wx.MessageBox(
+ _("No page for export is currently selected."),
+ caption=_("Notebook Export Error"),
+ style=wx.ICON_WARNING | wx.OK,
+ )
+ return
+ file_name = self.aui_notebook.GetPageText(current_page)
+
+ with wx.FileDialog(
+ parent=wx.GetActiveWindow() or self.GetTopLevelParent(),
+ message=_("Export file as..."),
+ defaultFile=file_name,
+ wildcard="Jupyter notebooks (*.ipynb)|*.ipynb",
+ style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT,
+ ) as dlg:
+ if dlg.ShowModal() == wx.ID_CANCEL:
+ return
+
+ destination_path = Path(dlg.GetPath())
+
+ try:
+ self.env.directory.export_file(
+ file_name, destination_path, overwrite=True
+ )
+ self.SetStatusText(
+ _("File {} exported to {}.").format(file_name, destination_path), 0
+ )
+ except Exception as e:
+ wx.MessageBox(
+ _("Failed to export file:\n{}").format(str(e)),
+ caption=_("Notebook Export Error"),
+ style=wx.ICON_ERROR | wx.OK,
+ )
+
+ def OnCreate(self, event=None):
+ """
+ Prompt the user to create a new empty Jupyter notebook in the working directory,
+ and open it in the GUI.
+ """
+ with wx.TextEntryDialog(
+ self,
+ message=_("Enter a name for the new notebook:"),
+ caption=_("New Notebook"),
+ value="untitled",
+ ) as dlg:
+ if dlg.ShowModal() == wx.ID_CANCEL:
+ return
+
+ name = dlg.GetValue().strip()
+ if not name:
+ return
+
+ try:
+ path = self.env.directory.create_new_notebook(new_name=name)
+ except Exception as e:
+ wx.MessageBox(
+ _("Failed to create notebook:\n{}").format(str(e)),
+ caption=_("Notebook Creation Error"),
+ style=wx.ICON_ERROR | wx.OK,
+ )
+ return
+
+ # Open the newly created notebook in the GUI
+ self.Open(path.name)
+
+ def SetStatusText(self, *args):
+ """Set text in the status bar."""
+ self.statusbar.SetStatusText(*args)
+
+ def GetStatusBar(self):
+ """Get statusbar"""
+ return self.statusbar
+
+ def OnCloseWindow(self, event):
+ """Prompt user, then stop server and close panel."""
+ confirm = wx.MessageBox(
+ _("Do you really want to close this window and stop the Jupyter server?"),
+ _("Confirm Close"),
+ wx.ICON_QUESTION | wx.YES_NO | wx.NO_DEFAULT,
+ )
+
+ if confirm != wx.YES:
+ if event and hasattr(event, "Veto"):
+ event.Veto()
+ return
+
+ # Get server info
+ url = self.env.server.server_url
+ pid = self.env.server.pid
+
+ # Stop server and close panel
+ try:
+ self.env.stop()
+ except RuntimeError as e:
+ wx.MessageBox(
+ _("Failed to stop Jupyter server at {url} (PID: {pid}):\n{err}").format(
+ url=url, pid=pid, err=str(e)
+ ),
+ caption=_("Error"),
+ style=wx.ICON_ERROR | wx.OK,
+ )
+ return
+ self.SetStatusText(
+ _("Jupyter server at {url} (PID: {pid}) has been stopped").format(
+ url=url, pid=pid
+ )
+ )
+ self._onCloseWindow(event)
diff --git a/gui/wxpython/jupyter_notebook/toolbars.py b/gui/wxpython/jupyter_notebook/toolbars.py
new file mode 100644
index 00000000000..0e9e5300b47
--- /dev/null
+++ b/gui/wxpython/jupyter_notebook/toolbars.py
@@ -0,0 +1,95 @@
+"""
+@package jupyter_notebook.toolbars
+
+@brief wxGUI Jupyter toolbars classes
+
+Classes:
+ - toolbars::JupyterToolbar
+
+(C) 2025 by the GRASS Development Team
+
+This program is free software under the GNU General Public License
+(>=v2). Read the file COPYING that comes with GRASS for details.
+
+@author Linda Karlovska
+"""
+
+import sys
+
+import wx
+
+from core.globalvar import CheckWxVersion
+from gui_core.toolbars import BaseToolbar, BaseIcons
+
+from icons.icon import MetaIcon
+
+
+class JupyterToolbar(BaseToolbar):
+ """Jupyter toolbar"""
+
+ def __init__(self, parent):
+ BaseToolbar.__init__(self, parent)
+
+ # workaround for http://trac.wxwidgets.org/ticket/13888
+ if sys.platform == "darwin" and not CheckWxVersion([4, 2, 1]):
+ parent.SetToolBar(self)
+
+ self.InitToolbar(self._toolbarData())
+
+ # realize the toolbar
+ self.Realize()
+
+ def _toolbarData(self):
+ """Toolbar data"""
+ icons = {
+ "create": MetaIcon(
+ img="create",
+ label=_("Create new notebook"),
+ ),
+ "open": MetaIcon(
+ img="open",
+ label=_("Import notebook"),
+ ),
+ "save": MetaIcon(
+ img="save",
+ label=_("Export notebook"),
+ ),
+ "docking": BaseIcons["docking"],
+ "quit": BaseIcons["quit"],
+ }
+ data = (
+ (
+ ("create", icons["create"].label.rsplit(" ", 1)[0]),
+ icons["create"],
+ self.parent.OnCreate,
+ ),
+ (
+ ("open", icons["open"].label.rsplit(" ", 1)[0]),
+ icons["open"],
+ self.parent.OnImport,
+ ),
+ (
+ ("save", icons["save"].label.rsplit(" ", 1)[0]),
+ icons["save"],
+ self.parent.OnExport,
+ ),
+ (None,),
+ )
+ if self.parent.IsDockable():
+ data += (
+ (
+ ("docking", icons["docking"].label),
+ icons["docking"],
+ self.parent.OnDockUndock,
+ wx.ITEM_CHECK,
+ ),
+ )
+ data += (
+ (
+ ("quit", icons["quit"].label),
+ icons["quit"],
+ self.parent.OnCloseWindow,
+ ),
+ )
+
+ return self._getToolbarData(data)
diff --git a/gui/wxpython/lmgr/toolbars.py b/gui/wxpython/lmgr/toolbars.py
index cdcc1a6ac91..903540e5e0e 100644
--- a/gui/wxpython/lmgr/toolbars.py
+++ b/gui/wxpython/lmgr/toolbars.py
@@ -24,6 +24,7 @@
"""
from core.gcmd import RunCommand
+from grass.workflows.server import is_jupyter_installed
from gui_core.toolbars import BaseToolbar, AuiToolbar, BaseIcons
from icons.icon import MetaIcon
@@ -205,21 +206,36 @@ 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")
),
+ "jupyter": MetaIcon(img="jupyter", label=_("Start Jupyter Notebook")),
+ "jupyter-inactive": MetaIcon(
+ img="jupyter-inactive",
+ label=_(
+ "Start Jupyter Notebook - requires Jupyter Notebook, click for more info"
+ ),
+ ),
+ "script-load": MetaIcon(
+ img="script-load", label=_("Launch user-defined script")
+ ),
}
+ # Decide if Jupyter is available
+ if is_jupyter_installed():
+ jupyter_icon = icons["jupyter"]
+ jupyter_handler = self.parent.OnJupyterNotebook
+ else:
+ jupyter_icon = icons["jupyter-inactive"]
+ jupyter_handler = self.parent.OnShowJupyterInfo
+
return self._getToolbarData(
(
(
- ("newdisplay", _("New display")),
+ ("newdisplay", icons["newdisplay"].label),
icons["newdisplay"],
self.parent.OnNewDisplay,
),
@@ -234,11 +250,6 @@ def _toolbarData(self):
icons["georectify"],
self.parent.OnGCPManager,
),
- (
- ("modeler", icons["modeler"].label),
- icons["modeler"],
- self.parent.OnGModeler,
- ),
(
("mapOutput", icons["composer"].label),
icons["composer"],
@@ -246,15 +257,25 @@ def _toolbarData(self):
),
(None,),
(
- ("script-load", icons["script-load"].label),
- icons["script-load"],
- self.parent.OnRunScript,
+ ("modeler", icons["modeler"].label),
+ icons["modeler"],
+ self.parent.OnGModeler,
),
(
("python", _("Python code editor")),
icons["python"],
self.parent.OnSimpleEditor,
),
+ (
+ ("jupyter", jupyter_icon.label),
+ jupyter_icon,
+ jupyter_handler,
+ ),
+ (
+ ("script-load", icons["script-load"].label),
+ icons["script-load"],
+ self.parent.OnRunScript,
+ ),
)
)
diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py
index 84ad713ffa5..b679bfe65f8 100644
--- a/gui/wxpython/main_window/frame.py
+++ b/gui/wxpython/main_window/frame.py
@@ -906,6 +906,62 @@ 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 OnJupyterNotebook(self, event=None, cmd=None):
+ """Launch Jupyter Notebook interface."""
+ from jupyter_notebook.panel import JupyterPanel
+ from jupyter_notebook.dialogs import JupyterStartDialog
+
+ dlg = JupyterStartDialog(parent=self)
+ result = dlg.ShowModal()
+
+ if result != wx.ID_OK:
+ dlg.Destroy()
+ return
+
+ values = dlg.GetValues()
+ dlg.Destroy()
+
+ if not values:
+ return
+
+ workdir = values["directory"]
+ create_template = values["create_template"]
+
+ jupyter_panel = JupyterPanel(
+ parent=self,
+ giface=self._giface,
+ statusbar=self.statusbar,
+ dockable=True,
+ workdir=workdir,
+ create_template=create_template,
+ )
+ jupyter_panel.SetUpPage(self, self.mainnotebook)
+ jupyter_panel.SetUpNotebookInterface()
+
+ # add map display panel to notebook and make it current
+ self.mainnotebook.AddPage(jupyter_panel, _("Jupyter Notebook"))
+
+ def OnShowJupyterInfo(self, event=None):
+ """Show information dialog when Jupyter Notebook is not available."""
+ if sys.platform.startswith("win"):
+ message = _(
+ "Jupyter Notebook is currently not included in the Windows GRASS build process.\n"
+ "This feature will be available in a future release."
+ )
+ else:
+ message = _(
+ "To use notebooks in GRASS, you need to have the Jupyter Notebook package installed. "
+ "For full functionality, we also recommend installing the visualization libraries "
+ "Folium and ipyleaflet. After installing these packages, please restart GRASS to enable this feature."
+ )
+
+ wx.MessageBox(
+ message=message,
+ caption=_("Jupyter Notebook not available"),
+ style=wx.OK | wx.ICON_INFORMATION,
+ parent=self,
+ )
+
def OnPsMap(self, event=None, cmd=None):
"""Launch Cartographic Composer. See OnIClass documentation"""
from psmap.frame import PsMapFrame
@@ -2405,6 +2461,17 @@ def _closeWindow(self, event):
event.Veto()
return
+ # Stop all running Jupyter servers before destroying the GUI
+ from grass.workflows import JupyterEnvironment
+
+ try:
+ JupyterEnvironment.stop_all()
+ except RuntimeError as e:
+ wx.MessageBox(
+ _("Failed to stop Jupyter servers:\n{}").format(str(e)),
+ caption=_("Error"),
+ style=wx.ICON_ERROR | wx.OK,
+ )
self.DisplayCloseAll()
self._auimgr.UnInit()
diff --git a/python/grass/CMakeLists.txt b/python/grass/CMakeLists.txt
index 528e2b12541..45ef9c60f84 100644
--- a/python/grass/CMakeLists.txt
+++ b/python/grass/CMakeLists.txt
@@ -2,6 +2,7 @@ set(PYDIRS
benchmark
exceptions
grassdb
+ workflows
gunittest
imaging
jupyter
diff --git a/python/grass/Makefile b/python/grass/Makefile
index cc04b04b496..37dd1234c27 100644
--- a/python/grass/Makefile
+++ b/python/grass/Makefile
@@ -14,6 +14,7 @@ SUBDIRS = \
gunittest \
imaging \
jupyter \
+ workflows \
pydispatch \
pygrass \
script \
diff --git a/python/grass/workflows/Makefile b/python/grass/workflows/Makefile
new file mode 100644
index 00000000000..e2eaaf8f8ed
--- /dev/null
+++ b/python/grass/workflows/Makefile
@@ -0,0 +1,20 @@
+MODULE_TOPDIR = ../../..
+
+include $(MODULE_TOPDIR)/include/Make/Other.make
+include $(MODULE_TOPDIR)/include/Make/Python.make
+
+DSTDIR = $(ETC)/python/grass/workflows
+TEMPLATE_FILES = template_notebooks/welcome.ipynb template_notebooks/new.ipynb
+MODULES = server directory environment
+
+PYFILES = $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__)
+PYCFILES = $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__)
+TEMPLATE_DST = $(patsubst %,$(DSTDIR)/%,$(TEMPLATE_FILES))
+
+default: $(PYFILES) $(PYCFILES) $(TEMPLATE_DST)
+
+$(DSTDIR):
+ $(MKDIR) $@
+
+$(DSTDIR)/%: % | $(DSTDIR)
+ $(INSTALL_DATA) $< $@
diff --git a/python/grass/workflows/__init__.py b/python/grass/workflows/__init__.py
new file mode 100644
index 00000000000..06af96a1490
--- /dev/null
+++ b/python/grass/workflows/__init__.py
@@ -0,0 +1,45 @@
+# MODULE: grass.workflows
+#
+# AUTHOR(S): Linda Karlovska
+#
+# PURPOSE: Tools for managing Jupyter Notebook within GRASS
+#
+# COPYRIGHT: (C) 2025 Linda Karlovska, and by the GRASS Development Team
+#
+# This program is free software under the GNU General Public
+# License (>=v2). Read the file COPYING that comes with GRASS
+# for details.
+
+"""
+Tools for managing Jupyter Notebook within GRASS
+
+This module provides functionality for:
+- Starting and stopping local Jupyter Notebook servers inside a GRASS session
+- Managing notebook working directories
+- Creating default notebook templates for users
+- Supporting integration with the GUI (e.g., wxGUI) and other tools
+
+Unlike `grass.jupyter`, which allows Jupyter to access GRASS environments,
+this module is focused on running Jupyter from within GRASS.
+
+Example use case:
+ - A user opens a panel in the GRASS that launches a Jupyter server
+ and opens the associated notebook working directory.
+
+.. versionadded:: 8.5
+
+"""
+
+from .server import JupyterServerInstance, JupyterServerRegistry
+from .directory import JupyterDirectoryManager
+from .environment import JupyterEnvironment
+
+__all__ = [
+ "Directory",
+ "Environment",
+ "JupyterDirectoryManager",
+ "JupyterEnvironment",
+ "JupyterServerInstance",
+ "JupyterServerRegistry",
+ "Server",
+]
diff --git a/python/grass/workflows/directory.py b/python/grass/workflows/directory.py
new file mode 100644
index 00000000000..5936d9334e1
--- /dev/null
+++ b/python/grass/workflows/directory.py
@@ -0,0 +1,223 @@
+#
+# AUTHOR(S): Linda Karlovska
+#
+# PURPOSE: Provides an interface for managing notebook working directory.
+#
+# COPYRIGHT: (C) 2025 by Linda Karlovska and the GRASS Development Team
+#
+# This program is free software under the GNU General Public
+# License (>=v2). Read the file COPYING that comes with GRASS
+# for details.
+
+"""
+This module defines a class `JupyterDirectoryManager` that provides functionality
+for working with Jupyter Notebook files stored within the current working directory.
+
+Features:
+- Creates a working directory if it does not exist
+- Generates default template files
+- Lists existing files in a working directory
+- Imports files from external locations
+- Exports files to external locations
+
+Designed for use within GRASS GUI tools or scripting environments.
+"""
+
+import os
+import json
+import shutil
+from pathlib import Path
+
+import grass.script as gs
+
+
+def get_default_jupyter_workdir():
+ """
+ Return the default working directory for Jupyter notebooks associated
+ with the current GRASS mapset.
+ :return: Path to the default notebook working directory (Path)
+ """
+ env = gs.gisenv()
+ mapset_path = Path(env["GISDBASE"]) / env["LOCATION_NAME"] / env["MAPSET"]
+ return mapset_path / "notebooks"
+
+
+class JupyterDirectoryManager:
+ """Manage a Jupyter notebook working directory."""
+
+ def __init__(self, workdir=None, create_template=False):
+ """Initialize the Jupyter notebook directory.
+
+ :param workdir: Optional custom working directory (Path). If not provided,
+ the default working directory is used.
+ :param create_template: If a welcome notebook should be created or not (bool).
+ """
+ self._workdir = workdir or get_default_jupyter_workdir()
+ self._workdir.mkdir(parents=True, exist_ok=True)
+
+ if not os.access(self._workdir, os.W_OK):
+ raise PermissionError(
+ _("Cannot write to the working directory: {}").format(self._workdir)
+ )
+
+ self._files = []
+ self._create_template = create_template
+
+ @property
+ def workdir(self):
+ """
+ :return: path to the working directory (Path).
+ """
+ return self._workdir
+
+ @property
+ def files(self):
+ """
+ :return: List of file paths (list[Path])
+ """
+ return self._files
+
+ def prepare_files(self):
+ """
+ Populate the list of files in the working directory.
+ """
+ # Find all .ipynb files in the notebooks directory
+ self._files = [f for f in self._workdir.iterdir() if f.suffix == ".ipynb"]
+
+ if self._create_template and not self._files:
+ self.create_welcome_notebook()
+
+ def import_file(self, source_path, new_name=None, overwrite=False):
+ """Import an existing notebook file to the working directory.
+
+ :param source_path: Path to the source .ipynb file to import (Path).
+ :param new_name: New name for the imported file (with .ipynb extension),
+ if not provided, original filename is used ((Optional[str]))
+ :param overwrite: Whether to overwrite an existing file with the same name (bool)
+ :return: Path to the copied file in the working directory (Path)
+ :raises FileNotFoundError: If the source_path does not exist
+ :raises FileExistsError: If the target already exists and overwrite=False
+ """
+ # Validate the source path and ensure it has .ipynb extension
+ source = Path(source_path)
+ if not source.exists():
+ raise FileNotFoundError(_("File not found: {}").format(source))
+ if source.suffix != ".ipynb":
+ raise ValueError(
+ _("Source file must have .ipynb extension: {}").format(source)
+ )
+
+ # Ensure the working directory exists
+ target_name = new_name or source.name
+ if not target_name.endswith(".ipynb"):
+ target_name += ".ipynb"
+
+ # Create the target path in the working directory
+ target_path = self._workdir / target_name
+
+ # Check if the target file already exists
+ if target_path.exists() and not overwrite:
+ raise FileExistsError(
+ _("Target file already exists: {}").format(target_path)
+ )
+
+ # Copy the source file to the target path
+ shutil.copyfile(source, target_path)
+
+ # Add the new target file to the list of files
+ self._files.append(target_path)
+
+ return target_path
+
+ def export_file(self, file_name, destination_path, overwrite=False):
+ """Export a file from the working directory to an external location.
+
+ :param file_name: Name of the file (e.g., "example.ipynb") (str)
+ :param destination_path: Full file path or target directory to export the file to (Path)
+ :param overwrite: If True, allows overwriting an existing file at the destination (bool)
+ :raises FileNotFoundError: If the source file does not exist or is not a .ipynb file
+ :raises FileExistsError: If the destination file exists and overwrite is False
+ """
+ # Validate the file name and ensure it has .ipynb extension
+ source_path = self._workdir / file_name
+ if not source_path.exists() or source_path.suffix != ".ipynb":
+ raise FileNotFoundError(_("File not found: {}").format(source_path))
+
+ # Determine the destination path
+ dest_path = Path(destination_path)
+ if dest_path.is_dir() or dest_path.suffix != ".ipynb":
+ dest_path /= file_name
+
+ # Check if the destination file already exists
+ if dest_path.exists() and not overwrite:
+ raise FileExistsError(_("Target file already exists: {}").format(dest_path))
+
+ # Create parent directories if they do not exist
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Copy the file to the destination
+ shutil.copyfile(source_path, dest_path)
+
+ def create_welcome_notebook(self, file_name="welcome.ipynb"):
+ """
+ Create a welcome Jupyter notebook in the working directory with
+ the placeholder '${NOTEBOOK_DIR}' replaced by the actual path.
+
+ :param filename: Name of the template file to copy (str)
+ :return: Path to the created template file (Path)
+ """
+ # Copy template file to the working directory
+ template_path = Path(__file__).parent / "template_notebooks" / file_name
+ template_copy = self.import_file(template_path)
+
+ # Load the template file
+ content = template_copy.read_text(encoding="utf-8")
+
+ # Replace the placeholder '${NOTEBOOK_DIR}' with actual working directory path
+ content = content.replace(
+ "${NOTEBOOK_DIR}", str(self._workdir).replace("\\", "/")
+ )
+
+ # Save the modified content back to the template file
+ template_copy.write_text(content, encoding="utf-8")
+ return template_copy
+
+ def create_new_notebook(self, new_name, template_name="new.ipynb"):
+ """
+ Create a new Jupyter notebook in the working directory using a specified template.
+
+ This method copies the content of a template notebook (default: 'new.ipynb')
+ and saves it as a new file with the user-defined name in the current working directory.
+
+ :param new_name: Desired filename of the new notebook (must end with '.ipynb',
+ or it will be automatically appended) (str).
+ :param template_name: Name of the template file to use (default: 'new.ipynb') (str).
+ :return: Path to the newly created notebook (Path).
+ :raises ValueError: If the provided name is empty.
+ :raises FileExistsError: If a notebook with the same name already exists.
+ :raises FileNotFoundError: If the specified template file does not exist.
+ """
+ if not new_name:
+ raise ValueError(_("Notebook name must not be empty"))
+
+ if not new_name.endswith(".ipynb"):
+ new_name += ".ipynb"
+
+ target_path = self.workdir / new_name
+
+ if target_path.exists():
+ raise FileExistsError(_("File '{}' already exists").format(new_name))
+
+ # Load the template notebook content
+ template_path = Path(__file__).parent / "template_notebooks" / template_name
+ with open(template_path, encoding="utf-8") as f:
+ content = json.load(f)
+
+ # Save the content to the new notebook file
+ with open(target_path, "w", encoding="utf-8") as f:
+ json.dump(content, f, indent=2)
+
+ # Register the new file internally
+ self._files.append(target_path)
+
+ return target_path
diff --git a/python/grass/workflows/environment.py b/python/grass/workflows/environment.py
new file mode 100644
index 00000000000..339e5d5b963
--- /dev/null
+++ b/python/grass/workflows/environment.py
@@ -0,0 +1,56 @@
+#
+# AUTHOR(S): Linda Karlovska
+#
+# PURPOSE: Provides an orchestration layer for Jupyter Notebook environment.
+#
+# COPYRIGHT: (C) 2025 by Linda Karlovska and the GRASS Development Team
+#
+# This program is free software under the GNU General Public
+# License (>=v2). Read the file COPYING that comes with GRASS
+# for details.
+
+"""
+This module defines the `JupyterEnvironment` class, which coordinates
+the setup and teardown of a Jupyter Notebook environment.
+
+It acts as a high-level orchestrator that integrates:
+- a working directory manager (template creation and file discovery)
+- a Jupyter server instance (start, stop, URL management)
+- registration of running servers in a global server registry
+
+Designed for use within GRASS GUI tools or scripting environments.
+"""
+
+from grass.workflows.directory import JupyterDirectoryManager
+from grass.workflows.server import JupyterServerInstance, JupyterServerRegistry
+
+
+class JupyterEnvironment:
+ """Orchestrates directory manager and server startup/shutdown."""
+
+ def __init__(self, workdir, create_template):
+ self.directory = JupyterDirectoryManager(workdir, create_template)
+ self.server = JupyterServerInstance(workdir)
+
+ def setup(self):
+ """Prepare files and start server."""
+ # Prepare files
+ self.directory.prepare_files()
+
+ # Start server
+ self.server.start_server()
+
+ # Register server in global registry
+ JupyterServerRegistry.get().register(self.server)
+
+ def stop(self):
+ """Stop server and unregister it."""
+ try:
+ self.server.stop_server()
+ finally:
+ JupyterServerRegistry.get().unregister(self.server)
+
+ @classmethod
+ def stop_all(cls):
+ """Stop all running Jupyter servers and unregister them."""
+ JupyterServerRegistry.get().stop_all_servers()
diff --git a/python/grass/workflows/server.py b/python/grass/workflows/server.py
new file mode 100644
index 00000000000..13ddcc16aba
--- /dev/null
+++ b/python/grass/workflows/server.py
@@ -0,0 +1,287 @@
+#
+# AUTHOR(S): Linda Karlovska
+#
+# PURPOSE: Provides a simple interface for launching and managing
+# a local Jupyter server.
+#
+# COPYRIGHT: (C) 2025 by Linda Karlovska and the GRASS Development Team
+#
+# This program is free software under the GNU General Public
+# License (>=v2). Read the file COPYING that comes with GRASS
+# for details.
+
+"""
+This module provides a simple interface for launching and managing
+a local Jupyter server.
+
+Functions:
+- `is_jupyter_installed()`: Check if Jupyter Notebook is installed on the system.
+
+Classes:
+- `JupyterServerInstance`: Manages a single Jupyter Notebook server instance.
+- `JupyterServerRegistry`: Manages multiple `JupyterServerInstance` objects
+ and provides methods to start, track, and stop all active servers.
+
+Features of `JupyterServerInstance`:
+- Checks if Jupyter Notebook is installed.
+- Finds an available local port.
+- Starts the server in a background thread.
+- Verifies that the server is running and accessible.
+- Provides the URL to access served files.
+- Tracks and manages the server PID.
+- Stops the server cleanly on request.
+- Registers cleanup routines to stop the server on:
+ - Normal interpreter exit
+ - SIGINT (e.g., Ctrl+C)
+ - SIGTERM (e.g., kill from shell)
+
+Features of `JupyterServerRegistry`:
+- Register and unregister server instances
+- Keeps track of all active server instances.
+- Stops all servers on global cleanup (e.g., GRASS shutdown).
+
+Designed for use within GRASS GUI tools or scripting environments.
+"""
+
+import socket
+import time
+import subprocess
+import threading
+import http.client
+import atexit
+import signal
+import sys
+import shutil
+
+
+def is_jupyter_installed():
+ """Check if Jupyter Notebook is installed.
+
+ - On Linux/macOS: returns True if the presence command succeeds, False otherwise.
+ - On Windows: currently always returns False because Jupyter is
+ not bundled.
+ TODO: Once Jupyter becomes part of the Windows build
+ process, this method should simply return True without additional checks.
+
+ :return: True if Jupyter Notebook is installed and available, False otherwise.
+ """
+ if sys.platform.startswith("win"):
+ # For now, always disabled on Windows
+ return False
+
+ try:
+ result = subprocess.run(
+ ["jupyter", "notebook", "--version"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ check=True,
+ )
+ return result.returncode == 0
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return False
+
+
+class JupyterServerInstance:
+ """Manage the lifecycle of a Jupyter server instance."""
+
+ def __init__(self, workdir):
+ self.workdir = workdir
+ self._reset_state()
+ self._setup_cleanup_handlers()
+
+ def _reset_state(self):
+ """Reset internal state related to the server."""
+ self.pid = None
+ self.port = None
+ self.server_url = ""
+
+ def _setup_cleanup_handlers(self):
+ """Set up handlers to ensure the server is stopped on process exit or signals."""
+ # Stop the server when the program exits normally (e.g., via sys.exit() or interpreter exit)
+ atexit.register(self._safe_stop_server)
+
+ # Stop the server when SIGINT is received (e.g., user presses Ctrl+C)
+ signal.signal(signal.SIGINT, self._handle_exit_signal)
+
+ # Stop the server when SIGTERM is received (e.g., 'kill PID')
+ signal.signal(signal.SIGTERM, self._handle_exit_signal)
+
+ def _safe_stop_server(self):
+ """
+ Quietly stop the server without raising exceptions.
+
+ Used for cleanup via atexit or signal handlers.
+ """
+ try:
+ self.stop_server()
+ except Exception:
+ pass
+
+ def _handle_exit_signal(self, signum, frame):
+ """Handle termination signals and ensure the server is stopped."""
+ try:
+ threading.Thread(target=self._safe_stop_server, daemon=True).start()
+ except Exception:
+ pass
+ finally:
+ sys.exit(0)
+
+ @staticmethod
+ def find_free_port():
+ """Find a free port on the local machine.
+ :return: A free port number (int).
+ """
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+ def is_server_running(self, retries=10, delay=0.2):
+ """Check if the server in responding on the given port.
+ :param retries: Number of retries before giving up (int).
+ :param delay: Delay between retries in seconds (float).
+ :return: True if the server is up, False otherwise (bool).
+ """
+ for _ in range(retries):
+ try:
+ conn = http.client.HTTPConnection("localhost", self.port, timeout=0.5)
+ conn.request("GET", "/")
+ if conn.getresponse().status in {200, 302, 403}:
+ conn.close()
+ return True
+ conn.close()
+ except Exception:
+ time.sleep(delay)
+ return False
+
+ def start_server(self):
+ """Run Jupyter server in the given directory on a free port."""
+ # Check if Jupyter Notebook is installed
+ if not is_jupyter_installed():
+ raise RuntimeError(_("Jupyter Notebook is not installed"))
+
+ # Find free port and build server url
+ self.port = JupyterServerInstance.find_free_port()
+ self.server_url = "http://localhost:{}".format(self.port)
+
+ # Create container for PIDs
+ pid_container = []
+
+ # Run Jupyter notebook server
+ def run_server(pid_container):
+ proc = subprocess.Popen(
+ [
+ "jupyter",
+ "notebook",
+ "--no-browser",
+ "--NotebookApp.token=''",
+ "--NotebookApp.password=''",
+ "--port",
+ str(self.port),
+ "--notebook-dir",
+ self.workdir,
+ ],
+ )
+ pid_container.append(proc.pid)
+
+ # Start the server in a separate thread
+ thread = threading.Thread(target=run_server, args=(pid_container,), daemon=True)
+ thread.start()
+
+ # Check if the server is up
+ if not self.is_server_running(self.port):
+ raise RuntimeError(_("Jupyter server is not running"))
+
+ # Save the PID of the Jupyter server
+ self.pid = pid_container[0] if pid_container else None
+
+ def stop_server(self):
+ """Stop the Jupyter server.
+ :raises RuntimeError: If the server is not running or cannot be stopped.
+ """
+ if not self.pid or self.pid <= 0:
+ raise RuntimeError(
+ _("Jupyter server is not running or PID {} is invalid.").format(
+ self.pid
+ )
+ )
+
+ # Check if the process with the given PID is a Jupyter server
+ try:
+ ps_cmd = shutil.which("ps")
+ if not ps_cmd:
+ raise RuntimeError(_("Unable to find 'ps' command in PATH."))
+ proc_name = (
+ subprocess.check_output(["ps", "-p", str(self.pid), "-o", "args="])
+ .decode()
+ .strip()
+ )
+ if "jupyter-notebook" not in proc_name:
+ raise RuntimeError(
+ _(
+ "Process with PID {} is not a Jupyter server: found '{}'."
+ ).format(self.pid, proc_name)
+ )
+ except subprocess.CalledProcessError as e:
+ raise RuntimeError(
+ _("No process found with PID {}.").format(self.pid)
+ ) from e
+
+ # Attempt to terminate the server process
+ if self.is_server_running(self.port):
+ try:
+ kill_cmd = shutil.which("kill")
+ if not kill_cmd:
+ raise RuntimeError(_("Unable to find 'kill' command in PATH."))
+ subprocess.check_call(["kill", str(self.pid)])
+ except subprocess.CalledProcessError as e:
+ raise RuntimeError(
+ _("Could not terminate Jupyter server with PID {}.").format(
+ self.pid
+ )
+ ) from e
+
+ # Clean up internal state
+ self._reset_state()
+
+ def get_url(self, file_name):
+ """Return full URL to a file served by this server.
+
+ :param file_name: Name of the file (e.g. 'example.ipynb') (str).
+ :return: Full URL to access the file (str).
+ """
+ if not self.server_url:
+ raise RuntimeError(_("Server URL is not set. Start the server first."))
+
+ return "{base}/notebooks/{file}".format(
+ base=self.server_url.rstrip("/"), file=file_name
+ )
+
+
+class JupyterServerRegistry:
+ """Registry of running JupyterServerInstance objects."""
+
+ _instance = None
+
+ @classmethod
+ def get(cls):
+ if cls._instance is None:
+ cls._instance = cls()
+ return cls._instance
+
+ def __init__(self):
+ self.servers = []
+
+ def register(self, server):
+ if server not in self.servers:
+ self.servers.append(server)
+
+ def unregister(self, server):
+ if server in self.servers:
+ self.servers.remove(server)
+
+ def stop_all_servers(self):
+ for server in self.servers[:]:
+ try:
+ server.stop_server()
+ finally:
+ self.unregister(server)
diff --git a/python/grass/workflows/template_notebooks/new.ipynb b/python/grass/workflows/template_notebooks/new.ipynb
new file mode 100644
index 00000000000..75dbc90a80b
--- /dev/null
+++ b/python/grass/workflows/template_notebooks/new.ipynb
@@ -0,0 +1,40 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Import GRASS scripting and Jupyter modules\n",
+ "import grass.script as gs\n",
+ "import grass.jupyter as gj"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Initialize Jupyter environment for GRASS\n",
+ "gisenv = gs.gisenv()\n",
+ "mapset_path = f\"{gisenv['GISDBASE']}/{gisenv['LOCATION_NAME']}/{gisenv['MAPSET']}\"\n",
+ "gj.init(mapset_path)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python [GRASS]",
+ "language": "python",
+ "name": "grass"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.x"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/python/grass/workflows/template_notebooks/welcome.ipynb b/python/grass/workflows/template_notebooks/welcome.ipynb
new file mode 100644
index 00000000000..d4bce52e2ce
--- /dev/null
+++ b/python/grass/workflows/template_notebooks/welcome.ipynb
@@ -0,0 +1,56 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Welcome to GRASS Jupyter environment 👋\n",
+ "\n",
+ "Jupyter server for this environment was started in the directory `${NOTEBOOK_DIR}`.\n",
+ "\n",
+ "---\n",
+ "This notebook is ready to use with GRASS.\n",
+ "You can run Python code using GRASS modules and data.\n",
+ "\n",
+ "---\n",
+ "**Tip:** Start by running a cell below, or create a new notebook by clicking the *Create new notebook* button."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Import GRASS scripting and Jupyter modules\n",
+ "import grass.script as gs\n",
+ "import grass.jupyter as gj"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Initialize Jupyter environment for GRASS\n",
+ "gisenv = gs.gisenv()\n",
+ "mapset_path = f\"{gisenv['GISDBASE']}/{gisenv['LOCATION_NAME']}/{gisenv['MAPSET']}\"\n",
+ "gj.init(mapset_path)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python [GRASS]",
+ "language": "python",
+ "name": "grass"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.x"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}