Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions gui/wxpython/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ def _defaultSettings(self):
"interactiveInput": {
"enabled": True,
},
"pythonAPI": {
"selection": 0,
},
},
#
# d.rast
Expand Down Expand Up @@ -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)"),
Expand Down
201 changes: 161 additions & 40 deletions gui/wxpython/gui_core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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})"
Comment thread
petrasovaa marked this conversation as resolved.
Resolved
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"))

Expand Down Expand Up @@ -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":
Expand Down
28 changes: 28 additions & 0 deletions gui/wxpython/gui_core/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 10 additions & 7 deletions python/grass/script/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import re
import sys
import keyword
import json
import xml.etree.ElementTree as ET
from xml.parsers import expat

Expand Down Expand Up @@ -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 = []
Expand All @@ -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):
Expand Down
Loading