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 7303f989031..b88b9fb04f0 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")) 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.OnCopyPython, 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,73 @@ 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") + + 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": + 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() + mode = config.Read("CmdPanel/CopyMode", "shell") + + if mode == "shell": + self.OnCopyShellCommand(event) + elif mode == "python": + self.OnCopyPython(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) @@ -965,53 +1026,94 @@ def OnCopyCommand(self, event): self.SetStatusText(_("'%s' copied to clipboard") % (cmdstring)) def OnCopyPython(self, event): - """Copy the command in Python syntax with keyword handling""" - + """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 + 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 = self._getInlineFiles(cmd) + args_str = gtask.cmd_to_python_args(cmd) - # Build the command string - py_cmd = f"gs.run_command('{module_name}', " + gtask.cmd_to_python_args(cmd) + script_lines = [] - # Copy to clipboard - self._toClipboard(py_cmd) + # Construct the final Python code + if inline_files: + if api_flavor == "tools": + script_lines.append("import io") + else: + script_lines.append("import tempfile") - def OnCopyPythonTools(self, event): - """Copy command in Tools API syntax""" - cmd = self.createCmd(ignoreErrors=True) - if not cmd or len(cmd) < 1: - return + script_lines.append("") - # 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(".", "_") + # Generate inline file variables + for param_name, text in inline_files.items(): + script_lines.append( + f"{param_name}_txt = {json.dumps(text, ensure_ascii=False)}" + ) - py_cmd = f"Tools().{module_name}(" + gtask.cmd_to_python_args(cmd) + script_lines.append("") - self._toClipboard(py_cmd) + 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", + "", + ] + ) - def OnCopyPygrass(self, event): - """Copy command in PyGRASS syntax""" - cmd = self.createCmd(ignoreErrors=True) - if not cmd or len(cmd) < 1: - return + # Change the command arguments to use StringIO for inline file parameters + for param_name in inline_files: + if api_flavor == "tools": + replace_str = f"io.StringIO({param_name}_txt)" + else: + replace_str = f"{param_name}_file" - module_name = cmd[0] + args_str = args_str.replace(f'"___INLINE_{param_name}___"', replace_str) + args_str = args_str.replace(f"'___INLINE_{param_name}___'", replace_str) + + # 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: + call_str = f'Module("{module_name}", {args_str})' - py_cmd = f"Module('{module_name}', " + gtask.cmd_to_python_args(cmd) + 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")) @@ -1084,6 +1186,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( diff --git a/python/grass/script/task.py b/python/grass/script/task.py index 61726a26ff7..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,8 +680,8 @@ 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']) - :return: string of formatted Python arguments ending with a closing parenthesis ')' + :param cmd: list of command arguments (e.g. ["v.distance", "from=map1", "to=map2"]) + :return: string of formatted Python arguments """ flags = "" python_params = [] @@ -707,21 +708,23 @@ 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) + ")" + return ", ".join(args) def cmd_to_dict(cmd):