From c705df104449f3153247ef97b9f613d140a0dfe3 Mon Sep 17 00:00:00 2001 From: polucifier Date: Sun, 17 May 2026 23:52:26 +0200 Subject: [PATCH 1/4] gui: Refactor Copy button to use Tools API and remember user preference - Limit Python API options in the copy menu strictly to the recommended Tools API - Persist the user's last chosen copy format (Shell, Python, or JSON) across sessions using wx.Config - Support io.StringIO for inline file inputs (e.g., r.colors rules typed directly into the field) to make the copied Python code fully portable --- gui/wxpython/gui_core/forms.py | 133 ++++++++++++++++++++++----------- python/grass/script/task.py | 4 +- 2 files changed, 91 insertions(+), 46 deletions(-) diff --git a/gui/wxpython/gui_core/forms.py b/gui/wxpython/gui_core/forms.py index 7303f989031..da270f06acf 100644 --- a/gui/wxpython/gui_core/forms.py +++ b/gui/wxpython/gui_core/forms.py @@ -615,25 +615,21 @@ def __init__( self.copyMenu = wx.Menu() # Note: We include Shell command in the menu too, for clarity item_shell = self.copyMenu.Append(wx.ID_ANY, _("Copy as Shell command")) - item_script = self.copyMenu.Append(wx.ID_ANY, _("Copy as Python (Script API)")) - item_tools = self.copyMenu.Append(wx.ID_ANY, _("Copy as Python (Tools API)")) - item_pygrass = self.copyMenu.Append(wx.ID_ANY, _("Copy as Python (PyGRASS)")) + item_python = self.copyMenu.Append(wx.ID_ANY, _("Copy as Python (Tools API)")) item_json = self.copyMenu.Append(wx.ID_ANY, _("Copy as JSON settings")) # Bind menu items - self.Bind(wx.EVT_MENU, self.OnCopyCommand, item_shell) - self.Bind(wx.EVT_MENU, self.OnCopyPython, item_script) - self.Bind(wx.EVT_MENU, self.OnCopyPythonTools, item_tools) - self.Bind(wx.EVT_MENU, self.OnCopyPygrass, item_pygrass) + self.Bind(wx.EVT_MENU, self.OnCopyShellCommand, item_shell) + self.Bind(wx.EVT_MENU, self.OnCopyPythonTools, item_python) self.Bind(wx.EVT_MENU, self.OnCopyJSON, item_json) # A horizontal sizer to combine the main button and the arrow button copy_sizer = wx.BoxSizer(wx.HORIZONTAL) - # 1. Main Copy button (triggers Shell command copy by default) + # Main Copy button (triggers Shell command copy by default) self.btn_copy = Button(parent=self.panel, id=wx.ID_ANY, label=_("Copy")) - self.btn_copy.SetToolTip(_("Copy as Shell command")) - self.btn_copy.Bind(wx.EVT_BUTTON, self.OnCopyCommand) + self.btn_copy.Bind(wx.EVT_BUTTON, self.OnCopyMain) + self._updateCopyButtonUI() # Get standard button dimensions to ensure the arrow segment matches standard_size = self.btn_copy.GetBestSize() @@ -652,7 +648,7 @@ def __init__( mdc.SelectObject(wx.NullBitmap) - # 3. The Arrow Button segment + # The Arrow Button segment # Using a standard Button class to inherit native hover and click effects self.btn_copy_menu = Button( parent=self.panel, id=wx.ID_ANY, size=(arrow_btn_w, btn_h) @@ -661,10 +657,10 @@ def __init__( self.btn_copy_menu.SetToolTip(_("More copy formats")) self.btn_copy_menu.Bind(wx.EVT_BUTTON, self.OnShowCopyMenu) - # 4. Using a small negative border to pull the buttons closer on Windows. + # Using a small negative border to pull the buttons closer on Windows. btn_glue = -1 if sys.platform.startswith("win") else 0 - # 5. Assembly + # Assembly copy_sizer.Add(self.btn_copy, 0, wx.EXPAND) copy_sizer.Add(self.btn_copy_menu, 0, wx.EXPAND | wx.LEFT, border=btn_glue) @@ -946,8 +942,40 @@ def OnShowCopyMenu(self, event): size = button.GetSize() self.panel.PopupMenu(self.copyMenu, wx.Point(pos.x, pos.y + size.height)) - def OnCopyCommand(self, event): - """Copy the command""" + def _setCopyMode(self, mode): + """Save the last used copy format and update the button tooltip""" + config = wx.Config.Get() + config.Write("CmdPanel/CopyMode", mode) + config.Flush() + self._updateCopyButtonUI() + + def _updateCopyButtonUI(self): + """Update the tooltip of the main Copy button based on current mode""" + config = wx.Config.Get() + mode = config.Read("CmdPanel/CopyMode", "shell") + + if mode == "shell": + self.btn_copy.SetToolTip(_("Copy as Shell command")) + elif mode == "python": + self.btn_copy.SetToolTip(_("Copy as Python (Tools API)")) + else: + self.btn_copy.SetToolTip(_("Copy as JSON settings")) + + def OnCopyMain(self, event): + """Route the main Copy button click to the last used method""" + config = wx.Config.Get() + mode = config.Read("CmdPanel/CopyMode", "shell") + + if mode == "shell": + self.OnCopyShellCommand(event) + elif mode == "python": + self.OnCopyPythonTools(event) + else: + self.OnCopyJSON(event) + + def OnCopyShellCommand(self, event): + """Copy the command in shell syntax""" + self._setCopyMode("shell") cmddata = wx.TextDataObject() # list -> string cmdlist = self.createCmd(ignoreErrors=True) @@ -964,23 +992,9 @@ def OnCopyCommand(self, event): wx.TheClipboard.Close() self.SetStatusText(_("'%s' copied to clipboard") % (cmdstring)) - def OnCopyPython(self, event): - """Copy the command in Python syntax with keyword handling""" - - cmd = self.createCmd(ignoreErrors=True) - if not cmd or len(cmd) < 1: - return - - module_name = cmd[0] - - # Build the command string - py_cmd = f"gs.run_command('{module_name}', " + gtask.cmd_to_python_args(cmd) - - # Copy to clipboard - self._toClipboard(py_cmd) - def OnCopyPythonTools(self, event): - """Copy command in Tools API syntax""" + """Copy the command in Python Tools API syntax""" + self._setCopyMode("python") cmd = self.createCmd(ignoreErrors=True) if not cmd or len(cmd) < 1: return @@ -989,24 +1003,55 @@ def OnCopyPythonTools(self, event): # e.g., v.distance -> v_distance, v.in.ascii -> v_in_ascii module_name = cmd[0].replace(".", "_") - py_cmd = f"Tools().{module_name}(" + gtask.cmd_to_python_args(cmd) - - self._toClipboard(py_cmd) - - def OnCopyPygrass(self, event): - """Copy command in PyGRASS syntax""" - cmd = self.createCmd(ignoreErrors=True) - if not cmd or len(cmd) < 1: - return - - module_name = cmd[0] - - py_cmd = f"Module('{module_name}', " + gtask.cmd_to_python_args(cmd) + # Find any inline file parameters + inline_files = {} + for p in self.task.params: + if p.get("prompt") == "file" and "wxId" in p and len(p["wxId"]) > 1: + ifbb = self.FindWindowById(p["wxId"][1]) + if ifbb and hasattr(ifbb, "GetValue"): + text = ifbb.GetValue().strip() + if text: + param_name = p["name"] + inline_files[param_name] = text + + # Mark the parameter in the command list for replacement + for i, arg in enumerate(cmd): + if arg.startswith(param_name + "="): + cmd[i] = f"{param_name}=___INLINE_{param_name}___" + break + + args_str = gtask.cmd_to_python_args(cmd) + + # Construct the final Python code + if inline_files: + script_lines = ["import io", ""] + + # Generate inline file variables + for param_name, text in inline_files.items(): + var_name = f"{param_name}_txt" + script_lines.append(f"{var_name} = {text!r}") + + script_lines.append("") + + # Change the command arguments to use StringIO for inline file parameters + for param_name in inline_files: + var_name = f"{param_name}_txt" + replace_str = f"io.StringIO({var_name})" + + args_str = args_str.replace(f'"___INLINE_{param_name}___"', replace_str) + args_str = args_str.replace(f"'___INLINE_{param_name}___'", replace_str) + + script_lines.append(f"Tools().{module_name}({args_str})") + py_cmd = "\n".join(script_lines) + else: + py_cmd = f"Tools().{module_name}({args_str})" + # Copy to clipboard self._toClipboard(py_cmd) def OnCopyJSON(self, event): """Copy parameters as JSON string""" + self._setCopyMode("json") cmd = self.createCmd(ignoreErrors=True) if not cmd or len(cmd) < 1: return diff --git a/python/grass/script/task.py b/python/grass/script/task.py index 61726a26ff7..ab94ef0e00b 100644 --- a/python/grass/script/task.py +++ b/python/grass/script/task.py @@ -680,7 +680,7 @@ def cmd_to_python_args(cmd): """Format parameters for Python calls, handling illegal keywords. :param cmd: list of command arguments (e.g. ['v.distance', 'from=map1', 'to=map2']) - :return: string of formatted Python arguments ending with a closing parenthesis ')' + :return: string of formatted Python arguments """ flags = "" python_params = [] @@ -721,7 +721,7 @@ def cmd_to_python_args(cmd): # Safe unpacking: **{'from': 'val', 'to': 'val'} args.append(f"**{illegal_keys!r}") - return ", ".join(args) + ")" + return ", ".join(args) def cmd_to_dict(cmd): From 6dcd30ef7c363d43c1413d06526d4ec6f08dd57d Mon Sep 17 00:00:00 2001 From: polucifier Date: Tue, 19 May 2026 00:10:56 +0200 Subject: [PATCH 2/4] gui: Support multiple Python APIs in Copy button and add global preference to settings - Integrate a default Python API flavor selector (Tools, Script, or PyGRASS) into GUI Preferences -> Tools tab via UserSettings - Update module dialog Copy button tooltips and code generation dynamically based on the globally chosen API style - Implement robust multi-line script formatting for interactive file fields using io.StringIO for Tools API and tempfile for external process APIs - Enhance JSON Copy/Paste mechanism to seamlessly embed and extract raw inline text inputs instead of volatile temporary file paths --- gui/wxpython/core/settings.py | 9 ++ gui/wxpython/gui_core/forms.py | 144 ++++++++++++++++++++------- gui/wxpython/gui_core/preferences.py | 28 ++++++ 3 files changed, 146 insertions(+), 35 deletions(-) diff --git a/gui/wxpython/core/settings.py b/gui/wxpython/core/settings.py index 22502b1c4f9..de5d2a3fde3 100644 --- a/gui/wxpython/core/settings.py +++ b/gui/wxpython/core/settings.py @@ -272,6 +272,9 @@ def _defaultSettings(self): "interactiveInput": { "enabled": True, }, + "pythonAPI": { + "selection": 0, + }, }, # # d.rast @@ -802,6 +805,12 @@ def _internalSettings(self): "quiet", ) + self.internalSettings["cmd"]["pythonAPI"]["choices"] = ( + _("Tools API"), + _("Script API"), + _("PyGRASS API"), + ) + self.internalSettings["appearance"]["iconTheme"]["choices"] = ("grass",) self.internalSettings["appearance"]["menustyle"]["choices"] = ( _("Classic (labels only)"), diff --git a/gui/wxpython/gui_core/forms.py b/gui/wxpython/gui_core/forms.py index da270f06acf..87dbaf717ba 100644 --- a/gui/wxpython/gui_core/forms.py +++ b/gui/wxpython/gui_core/forms.py @@ -615,12 +615,12 @@ def __init__( self.copyMenu = wx.Menu() # Note: We include Shell command in the menu too, for clarity item_shell = self.copyMenu.Append(wx.ID_ANY, _("Copy as Shell command")) - item_python = self.copyMenu.Append(wx.ID_ANY, _("Copy as Python (Tools API)")) + item_python = self.copyMenu.Append(wx.ID_ANY, _("Copy as Python")) item_json = self.copyMenu.Append(wx.ID_ANY, _("Copy as JSON settings")) # Bind menu items self.Bind(wx.EVT_MENU, self.OnCopyShellCommand, item_shell) - self.Bind(wx.EVT_MENU, self.OnCopyPythonTools, item_python) + self.Bind(wx.EVT_MENU, self.OnCopyPython, item_python) self.Bind(wx.EVT_MENU, self.OnCopyJSON, item_json) # A horizontal sizer to combine the main button and the arrow button @@ -954,13 +954,46 @@ def _updateCopyButtonUI(self): config = wx.Config.Get() mode = config.Read("CmdPanel/CopyMode", "shell") + api_index = UserSettings.Get(group="cmd", key="pythonAPI", subkey="selection") + api_map = {0: "tools", 1: "script", 2: "pygrass"} + api_flavor = api_map.get(api_index, "tools") + if mode == "shell": self.btn_copy.SetToolTip(_("Copy as Shell command")) elif mode == "python": - self.btn_copy.SetToolTip(_("Copy as Python (Tools API)")) + if api_flavor == "tools": + self.btn_copy.SetToolTip(_("Copy as Python (Tools API)")) + elif api_flavor == "script": + self.btn_copy.SetToolTip(_("Copy as Python (Script API)")) + else: + self.btn_copy.SetToolTip(_("Copy as Python (PyGRASS API)")) else: self.btn_copy.SetToolTip(_("Copy as JSON settings")) + def _getInlineFiles(self, cmd): + """Find inline file parameters and mark them in the command list. + + :param cmd: list of command arguments + :return: dict mapping parameter name to its raw text content + """ + inline_files = {} + for p in self.task.params: + if p.get("prompt") == "file" and "wxId" in p and len(p["wxId"]) > 1: + ifbb = self.FindWindowById(p["wxId"][1]) + if ifbb and hasattr(ifbb, "GetValue"): + text = ifbb.GetValue().strip() + if text: + param_name = p["name"] + inline_files[param_name] = text + + # Mark the parameter in the command list for replacement + for i, arg in enumerate(cmd): + if arg.startswith(param_name + "="): + cmd[i] = f"{param_name}=___INLINE_{param_name}___" + break + + return inline_files + def OnCopyMain(self, event): """Route the main Copy button click to the last used method""" config = wx.Config.Get() @@ -969,7 +1002,7 @@ def OnCopyMain(self, event): if mode == "shell": self.OnCopyShellCommand(event) elif mode == "python": - self.OnCopyPythonTools(event) + self.OnCopyPython(event) else: self.OnCopyJSON(event) @@ -992,71 +1025,93 @@ def OnCopyShellCommand(self, event): wx.TheClipboard.Close() self.SetStatusText(_("'%s' copied to clipboard") % (cmdstring)) - def OnCopyPythonTools(self, event): - """Copy the command in Python Tools API syntax""" + def OnCopyPython(self, event): + """Copy the command in the selected Python API syntax""" self._setCopyMode("python") cmd = self.createCmd(ignoreErrors=True) if not cmd or len(cmd) < 1: return - # Replace dots with underscores to match the Tools API method names - # e.g., v.distance -> v_distance, v.in.ascii -> v_in_ascii - module_name = cmd[0].replace(".", "_") + api_index = UserSettings.Get(group="cmd", key="pythonAPI", subkey="selection") + api_map = {0: "tools", 1: "script", 2: "pygrass"} + api_flavor = api_map.get(api_index, "tools") + module_name = cmd[0] # Find any inline file parameters - inline_files = {} - for p in self.task.params: - if p.get("prompt") == "file" and "wxId" in p and len(p["wxId"]) > 1: - ifbb = self.FindWindowById(p["wxId"][1]) - if ifbb and hasattr(ifbb, "GetValue"): - text = ifbb.GetValue().strip() - if text: - param_name = p["name"] - inline_files[param_name] = text - - # Mark the parameter in the command list for replacement - for i, arg in enumerate(cmd): - if arg.startswith(param_name + "="): - cmd[i] = f"{param_name}=___INLINE_{param_name}___" - break - + inline_files = self._getInlineFiles(cmd) args_str = gtask.cmd_to_python_args(cmd) + script_lines = [] + # Construct the final Python code if inline_files: - script_lines = ["import io", ""] + if api_flavor == "tools": + script_lines.append("import io") + else: + script_lines.append("import tempfile") + + script_lines.append("") # Generate inline file variables for param_name, text in inline_files.items(): - var_name = f"{param_name}_txt" - script_lines.append(f"{var_name} = {text!r}") + script_lines.append(f"{param_name}_txt = {text!r}") script_lines.append("") + if api_flavor != "tools": + for param_name in inline_files: + script_lines.extend( + [ + "with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:", + f" f.write({param_name}_txt)", + f" {param_name}_file = f.name", + "", + ] + ) + # Change the command arguments to use StringIO for inline file parameters for param_name in inline_files: - var_name = f"{param_name}_txt" - replace_str = f"io.StringIO({var_name})" + if api_flavor == "tools": + replace_str = f"io.StringIO({param_name}_txt)" + else: + replace_str = f"{param_name}_file" args_str = args_str.replace(f'"___INLINE_{param_name}___"', replace_str) args_str = args_str.replace(f"'___INLINE_{param_name}___'", replace_str) - script_lines.append(f"Tools().{module_name}({args_str})") - py_cmd = "\n".join(script_lines) + # Route to the appropriate API flavor + if api_flavor == "tools": + method_name = module_name.replace(".", "_") + call_str = f"Tools().{method_name}({args_str})" + elif api_flavor == "script": + call_str = f"gs.run_command('{module_name}', {args_str})" else: - py_cmd = f"Tools().{module_name}({args_str})" + call_str = f"Module('{module_name}', {args_str})" + + script_lines.append(call_str) + py_cmd = "\n".join(script_lines) # Copy to clipboard self._toClipboard(py_cmd) + if inline_files: + self.SetStatusText(_("Python code copied to clipboard")) def OnCopyJSON(self, event): - """Copy parameters as JSON string""" + """Copy parameters as JSON string with inline text support""" self._setCopyMode("json") cmd = self.createCmd(ignoreErrors=True) if not cmd or len(cmd) < 1: return - data = {"module": cmd[0], "params": gtask.cmd_to_dict(cmd)} + inline_files = self._getInlineFiles(cmd) + params_dict = gtask.cmd_to_dict(cmd) + + # Replace inline file parameter values with their raw text content + for param_name, text in inline_files.items(): + if param_name in params_dict: + params_dict[param_name] = text + + data = {"module": cmd[0], "params": params_dict} self._toClipboard(json.dumps(data, indent=4)) self.SetStatusText(_("JSON copied to clipboard")) @@ -1129,6 +1184,25 @@ def OnPaste(self, event): if not win or win.GetName() == "ModelParam": continue + # Catch inline file parameters and set their content directly to the second widget + if ( + not is_flag + and porf.get("prompt") == "file" + and len(porf.get("wxId", [])) > 1 + ): + idx = porf["wxId"].index(w_id) + if idx == 0: + # First widget is the file path + if hasattr(win, "SetValue"): + win.SetValue("") + continue + if idx == 1: + # Second widget is the file content + if hasattr(win, "SetValue"): + win.SetValue(str(clean_val)) + found_any = True + continue + # Specialized Widget Handling if isinstance(win, wx.CheckBox): if not is_flag and porf.get("prompt") == "color": diff --git a/gui/wxpython/gui_core/preferences.py b/gui/wxpython/gui_core/preferences.py index 73ad6f3d3bd..3bee2dbeb39 100644 --- a/gui/wxpython/gui_core/preferences.py +++ b/gui/wxpython/gui_core/preferences.py @@ -1286,6 +1286,34 @@ def _createCmdPage(self, notebook): gridSizer.Add(verbosity, pos=(row, 1), flag=wx.ALIGN_RIGHT) + row += 1 + gridSizer.Add( + StaticText( + parent=panel, + id=wx.ID_ANY, + label=_("Default Python API for copied commands:"), + ), + flag=wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL, + pos=(row, 0), + ) + pythonAPI = wx.Choice( + parent=panel, + id=wx.ID_ANY, + size=(200, -1), + choices=self.settings.Get( + group="cmd", + key="pythonAPI", + subkey="choices", + settings_type="internal", + ), + name="GetSelection", + ) + pythonAPI.SetSelection( + self.settings.Get(group="cmd", key="pythonAPI", subkey="selection") + ) + self.winId["cmd:pythonAPI:selection"] = pythonAPI.GetId() + gridSizer.Add(pythonAPI, pos=(row, 1), flag=wx.ALIGN_RIGHT) + row += 1 # nprocs gridSizer.Add( From 1b94185cd40d4048a2f4499647d47156e52939c4 Mon Sep 17 00:00:00 2001 From: Mykhailo Radchenko <91974475+polucifier@users.noreply.github.com> Date: Wed, 20 May 2026 16:05:08 +0200 Subject: [PATCH 3/4] Output double quotes instead of single quotes --- gui/wxpython/gui_core/forms.py | 4 ++-- python/grass/script/task.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/gui/wxpython/gui_core/forms.py b/gui/wxpython/gui_core/forms.py index 87dbaf717ba..5100200e614 100644 --- a/gui/wxpython/gui_core/forms.py +++ b/gui/wxpython/gui_core/forms.py @@ -1084,9 +1084,9 @@ def OnCopyPython(self, event): method_name = module_name.replace(".", "_") call_str = f"Tools().{method_name}({args_str})" elif api_flavor == "script": - call_str = f"gs.run_command('{module_name}', {args_str})" + call_str = f'gs.run_command("{module_name}", {args_str})' else: - call_str = f"Module('{module_name}', {args_str})" + call_str = f'Module("{module_name}", {args_str})' script_lines.append(call_str) py_cmd = "\n".join(script_lines) diff --git a/python/grass/script/task.py b/python/grass/script/task.py index ab94ef0e00b..2508954d9d6 100644 --- a/python/grass/script/task.py +++ b/python/grass/script/task.py @@ -23,6 +23,7 @@ import re import sys import keyword +import json import xml.etree.ElementTree as ET from xml.parsers import expat @@ -679,7 +680,7 @@ def cmdstring_to_tuple(cmd): def cmd_to_python_args(cmd): """Format parameters for Python calls, handling illegal keywords. - :param cmd: list of command arguments (e.g. ['v.distance', 'from=map1', 'to=map2']) + :param cmd: list of command arguments (e.g. ["v.distance", "from=map1", "to=map2"]) :return: string of formatted Python arguments """ flags = "" @@ -707,19 +708,21 @@ def cmd_to_python_args(cmd): if keyword.iskeyword(k) or not k.isidentifier(): illegal_keys[k] = v else: - python_params.append(f"{k}={v!r}") + # json.dumps() automatically handles quotes, backslashes, and newlines using double quotes. + # ensure_ascii=False keeps international characters human-readable + python_params.append(f"{k}={json.dumps(v, ensure_ascii=False)}") # Build the command string args = [] if flags: - args.append(f"flags='{flags}'") + args.append(f'flags="{flags}"') args.extend(python_params) if illegal_keys: - # Safe unpacking: **{'from': 'val', 'to': 'val'} - args.append(f"**{illegal_keys!r}") + # Safe unpacking: **{"from": "val", "to": "val"} + args.append(f"**{json.dumps(illegal_keys, ensure_ascii=False)}") return ", ".join(args) From ce29a25ba198928634e99464fbce7e82c9214b1e Mon Sep 17 00:00:00 2001 From: polucifier Date: Fri, 22 May 2026 20:52:26 +0200 Subject: [PATCH 4/4] Fix remaining single quotes --- gui/wxpython/gui_core/forms.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gui/wxpython/gui_core/forms.py b/gui/wxpython/gui_core/forms.py index 5100200e614..b88b9fb04f0 100644 --- a/gui/wxpython/gui_core/forms.py +++ b/gui/wxpython/gui_core/forms.py @@ -1054,7 +1054,9 @@ def OnCopyPython(self, event): # Generate inline file variables for param_name, text in inline_files.items(): - script_lines.append(f"{param_name}_txt = {text!r}") + script_lines.append( + f"{param_name}_txt = {json.dumps(text, ensure_ascii=False)}" + ) script_lines.append("") @@ -1062,7 +1064,7 @@ def OnCopyPython(self, event): for param_name in inline_files: script_lines.extend( [ - "with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:", + 'with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:', f" f.write({param_name}_txt)", f" {param_name}_file = f.name", "",