Repository: psychopy/psychopy

Issue:
ratingscale customize everything bug
`pos=(0,0)` as the last in the customize everything textbox loses the closing )

issue: strip needs to check before blindly removing all )
workaround: don't have `pos=()` come last, or use `pos=[]` instead



Discussion:

Resolving patch:
diff --git a/psychopy/app/builder/validators.py b/psychopy/app/builder/validators.py
--- a/psychopy/app/builder/validators.py
+++ b/psychopy/app/builder/validators.py
@@ -13,6 +13,7 @@
 from past.builtins import basestring
 import wx
 import psychopy.experiment.utils
+from psychopy.tools import stringtools
 from psychopy.localization import _translate
 from . import experiment
 from .localizedStrings import _localized
@@ -506,7 +507,7 @@ def _highlightParamVal(parent, error=False):
             # get var names from val, check against namespace:
             code = experiment.getCodeFromParamStr(val)
             try:
-                names = compile(code, '', 'exec').co_names
+                names = list(stringtools.getVariables(code))
                 parent.warnings.clearWarning(control)
             except (SyntaxError, TypeError) as e:
                 # empty '' compiles to a syntax error, ignore
diff --git a/psychopy/experiment/components/ratingScale/__init__.py b/psychopy/experiment/components/ratingScale/__init__.py
--- a/psychopy/experiment/components/ratingScale/__init__.py
+++ b/psychopy/experiment/components/ratingScale/__init__.py
@@ -12,7 +12,7 @@
 from builtins import map
 from os import path
 from pathlib import Path
-import re
+from psychopy.tools.stringtools import getArgs
 from psychopy.experiment.components import BaseComponent, Param, _translate
 from psychopy.localization import _localized as __localized
 _localized = __localized.copy()
@@ -215,10 +215,19 @@ def writeInitCode(self, buff):
         _in = "%(name)s = visual.RatingScale(win=win, name='%(name)s'"
         init_str = _in % self.params
         if self.params['customize_everything'].val.strip() != '':
-            # clean it up a little, remove win=*, leading / trailing typos
-            orig = self.params['customize_everything'].val
-            custom = re.sub(r"[\\s,]*win=[^,]*,", '', orig)
-            init_str += ', ' + custom.lstrip('(, ').strip('), ')
+            # Add preamble
+            init_str += ", "
+            # Get arguments
+            args = getArgs(self.params['customize_everything'].val)
+            # Remove anything which would override win or name
+            if 'win' in args:
+                del args['win']
+            if 'name' in args:
+                del args['name']
+            # Convert each argument to a string
+            argList = [f"{key}={val}" for key, val in args.items()]
+            # Recombine these arguments into a (now santized) string
+            init_str += ", ".join(argList)
         else:
             if self.params['marker'].val:
                 init_str += ', marker=%s' % repr(self.params['marker'].val)
diff --git a/psychopy/tools/stringtools.py b/psychopy/tools/stringtools.py
--- a/psychopy/tools/stringtools.py
+++ b/psychopy/tools/stringtools.py
@@ -9,6 +9,7 @@
 # Distributed under the terms of the GNU General Public License (GPL).
 
 import re
+import ast
 
 __all__ = ["prettyname"]
 
@@ -42,4 +43,69 @@ def prettyname(name, wrap=False):
         # Remove spaces after line
         name = re.sub(r" *\n *", "\n", name)
 
-    return name
\ No newline at end of file
+    return name
+
+
+def _actualizeAstValue(item):
+    """
+    Convert an AST value node to a useable Python object
+    """
+    if isinstance(item, ast.Str):
+        # Handle ast string
+        return item.s
+    elif hasattr(ast, 'Bytes') and isinstance(item, ast.Bytes):
+        # Handle ast bytes
+        return item.s
+    elif isinstance(item, ast.Num):
+        # Handle ast numbers
+        return item.n
+    elif isinstance(item, ast.Tuple):
+        # Handle ast array
+        return tuple(_actualizeAstValue(i) for i in item.elts)
+
+
+def getVariables(code):
+    """
+    Use AST tree parsing to convert a string of valid Python code to a dict containing each variable created and its
+    value.
+    """
+    assert isinstance(code, str), "First input to `getArgs()` must be a string"
+    # Make blank output dict
+    vars = {}
+    # Construct tree
+    tree = compile(code, '', 'exec', flags=ast.PyCF_ONLY_AST)
+    # Iterate through each line
+    for line in tree.body:
+        if hasattr(line, "targets") and hasattr(line, "value"):
+            # Append targets and values this line to arguments dict
+            for target in line.targets:
+                vars[target.id] = _actualizeAstValue(line.value)
+
+    return vars
+
+
+def getArgs(code):
+    """
+    Use AST tree parsing to convert a string of valid Python arguments to a dict containing each argument used and its
+    value.
+    """
+    assert isinstance(code, str), "First input to `getArgs()` must be a string"
+    # Make blank output dict
+    args = {}
+    # Add outer brackets if needed
+    if not (code.startswith("(") and code.endswith(")")):
+        code = "(" + code + ")"
+    # Move it all to one line
+    code = code.replace("\n", "")
+    # Append dict constructor
+    code = "dict" + code
+    # Construct tree
+    tree = compile(code, '', 'exec', flags=ast.PyCF_ONLY_AST)
+    # Get keywords
+    keywords = tree.body[0].value.keywords
+    for kw in keywords:
+        if hasattr(kw, "arg") and hasattr(kw, "value"):
+            # Append keyword and value to arguments dict
+            args[kw.arg] = _actualizeAstValue(kw.value)
+
+    return args


Test patch:
diff --git a/psychopy/tests/test_tools/test_stringtools.py b/psychopy/tests/test_tools/test_stringtools.py
--- a/psychopy/tests/test_tools/test_stringtools.py
+++ b/psychopy/tests/test_tools/test_stringtools.py
@@ -2,7 +2,7 @@
 """Tests for psychopy.tools.mathtools
 """
 
-from psychopy.tools.stringtools import *
+from psychopy.tools import stringtools as tools
 import pytest
 
 @pytest.mark.stringtools
@@ -19,4 +19,38 @@ def test_name_wrap():
         {"text": "Hello There", "wrap": 0, "ans": "Hello There"},  # Wrap = 0
     ]
     for case in exemplars + tykes:
-        assert prettyname(case['text'], case['wrap']) == case['ans']
\ No newline at end of file
+        assert tools.prettyname(case['text'], case['wrap']) == case['ans']
+
+
+@pytest.mark.stringtools
+def test_get_variables():
+    exemplars = [
+        {"code": "x=1\ny=2", "ans": {'x': 1, 'y': 2}},  # numbers
+        {"code": "x=\"a\"\ny=\"b\"", "ans": {'x': "a", 'y': "b"}},  # double quotes
+        {"code": "x='a'\ny='b'", "ans": {'x': "a", 'y': "b"}},  # single quotes
+        {"code": "x=(1, 2)\ny=(3, 4)", "ans": {'x': (1, 2), 'y': (3, 4)}},  # arrays
+    ]
+    tykes = [
+        {"code": "x='(1, 2)'\ny='(3, 4)'", "ans": {'x': "(1, 2)", 'y': "(3, 4)"}},  # string representation of array (single)
+        {"code": "x=\"(1, 2)\"\ny=\"(3, 4)\"", "ans": {'x': "(1, 2)", 'y': "(3, 4)"}},  # string representation of array (double)
+    ]
+    for case in exemplars + tykes:
+        assert tools.getVariables(case['code']) == case['ans']
+
+
+@pytest.mark.stringtools
+def test_get_arguments():
+    exemplars = [
+        {"code": "x=1,y=2", "ans": {'x': 1, 'y': 2}},  # numbers
+        {"code": "x=\"a\",y=\"b\"", "ans": {'x': "a", 'y': "b"}},  # double quotes
+        {"code": "x='a',y='b'", "ans": {'x': "a", 'y': "b"}},  # single quotes
+        {"code": "x=(1, 2), y=(3, 4)", "ans": {'x': (1, 2), 'y': (3, 4)}},  # arrays
+
+    ]
+    tykes = [
+        {"code": "(x=1, y=2)", "ans": {'x': 1, 'y': 2}},  # outer brackets
+        {"code": "x='(1, 2)', y='(3, 4)'", "ans": {'x': "(1, 2)", 'y': "(3, 4)"}},  # string representation of array (single)
+        {"code": "x=\"(1, 2)\", y=\"(3, 4)\"", "ans": {'x': "(1, 2)", 'y': "(3, 4)"}},  # string representation of array (double)
+    ]
+    for case in exemplars + tykes:
+        assert tools.getArgs(case['code']) == case['ans']
\ No newline at end of file