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 +}