Repository: psychopy/psychopy

Issue:
confusing warning messages
I'm updating my PsychoPy Builder tutorial and noticed that the latest Builder (2020.2.8) show somewhat confusing warnings.
Firstly, if we use `FINISHED` in **start** and **stop** properties as a part of condition, Builder says "Variable name $FINISHED is in use (by PsychoPy module). Try another name". 
![4](https://user-images.githubusercontent.com/2094930/100316846-ab798a80-2ffe-11eb-967e-7b087d357692.png)

Next, if we have single key name in **Allowed keys$** of Keyboard component, Builder says "Variable name $\_\_doc\_\_ is in use (by Psychopy module). Try another name)".
![5](https://user-images.githubusercontent.com/2094930/100316851-ad434e00-2ffe-11eb-9b50-6d29c85b6a82.png)

Although they settings work as intended when experiment is executed, these warnings are very confusing, I think.


Discussion:
Todd could you look into this one (Maybe with git bisect to track back the change)? Maybe it was the change to use safe eval (from ast lib) rather than literally doing eval.
I run git bisect and found that the phenomenon started with c1305d0430e068e628ee3e4d99d00c69afdedd67.

Resolving patch:
diff --git a/psychopy/app/builder/dialogs/__init__.py b/psychopy/app/builder/dialogs/__init__.py
--- a/psychopy/app/builder/dialogs/__init__.py
+++ b/psychopy/app/builder/dialogs/__init__.py
@@ -840,7 +840,7 @@ def launchColorPicker(self, event):
     def onNewTextSize(self, event):
         self.Fit()  # for ExpandoTextCtrl this is needed
 
-    def show(self):
+    def show(self, testing=False):
         """Adds an OK and cancel button, shows dialogue.
 
         This method returns wx.ID_OK (as from ShowModal), but also
@@ -908,8 +908,11 @@ def show(self):
         if self.timeout is not None:
             timeout = wx.CallLater(self.timeout, self.autoTerminate)
             timeout.Start()
-        retVal = self.ShowModal()
-        self.OK = bool(retVal == wx.ID_OK)
+        if testing:
+            self.Show()
+        else:
+            retVal = self.ShowModal()
+            self.OK = bool(retVal == wx.ID_OK)
         return wx.ID_OK
 
     def autoTerminate(self, event=None, retval=1):
@@ -1788,7 +1791,7 @@ def __init__(self, frame, title, params, order,
                  helpUrl=None, suppressTitles=True, size=wx.DefaultSize,
                  style=wx.DEFAULT_DIALOG_STYLE | wx.DIALOG_NO_PARENT,
                  editing=False, depends=[],
-                 timeout=None, type=None):
+                 timeout=None, testing=False, type=None):
         style = style | wx.RESIZE_BORDER
         _BaseParamsDlg.__init__(self, frame, title, params, order,
                                 helpUrl=helpUrl, size=size, style=style,
@@ -1807,10 +1810,11 @@ def __init__(self, frame, title, params, order,
                       self.paramCtrls['storeCorrect'].valueCtrl)
 
         # for all components
-        self.show()
-        if self.OK:
-            self.params = self.getParams()  # get new vals from dlg
-        self.Destroy()
+        self.show(testing)
+        if not testing:
+            if self.OK:
+                self.params = self.getParams()  # get new vals from dlg
+            self.Destroy()
 
     def onStoreCorrectChange(self, event=None):
         """store correct has been checked/unchecked. Show or hide the
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
@@ -19,6 +19,8 @@
 from .localizedStrings import _localized
 from pkg_resources import parse_version
 
+from ...data.utils import listFromString
+
 if parse_version(wx.__version__) < parse_version('4.0.0a1'):
     _ValidatorBase = wx.PyValidator
 else:
@@ -168,36 +170,36 @@ def _highlightParamVal(parent, error=False):
             except KeyError:
                 pass
 
+        # Get attributes of value control
         control = self.GetWindow()
         if not hasattr(control, 'GetValue'):
             return '', True
         val = control.GetValue()  # same as parent.params[self.fieldName].val
         if not isinstance(val, basestring):
             return '', True
-
         field = self.fieldName
+        allowedUpdates = ['set every repeat', 'set every frame']
+        # Set initials
         msg, OK = '', True  # until we find otherwise
         _highlightParamVal(parent)
+        # What valType should code be treated as?
         codeWanted = psychopy.experiment.utils.unescapedDollarSign_re.search(val)
         isCodeField = bool(parent.params[self.fieldName].valType == 'code')
-        allowedUpdates = ['set every repeat', 'set every frame']
 
-        # Check if variable incorrectly defined in correct answer
+        # Validate as list
         allKeyBoardKeys = list(key._key_names.values()) + [str(num) for num in range(10)]
+        allKeyBoardKeys = [key.lower() for key in allKeyBoardKeys]
         if self.fieldName == 'correctAns' and not val.startswith('$'):
-            if ',' in val:  # comma separated
-                keyList = val.upper().split(',')
-                keyList = [thisKey.replace(' ', '') for thisKey in keyList if len(thisKey) > 0]
-            else:  # whitespace separated
-                keyList = val.upper().split(' ')
-                keyList = [thisKey.replace(' ', '') for thisKey in keyList if len(thisKey) > 0]
-
+            keyList = listFromString(val)
+            if isinstance(keyList, str):
+                keyList = [keyList]
             potentialVars = list(set(keyList) - set(allKeyBoardKeys))  # Elements of keyList not in allKeyBoardKeys
             _highlightParamVal(parent, bool(potentialVars))
             if len(potentialVars):
                 msg = _translate("It looks like your 'Correct answer' contains a variable - prepend variables with '$' e.g. ${val}")
                 msg = msg.format(val=potentialVars[0].lower())
 
+        # Validate as code
         if codeWanted or isCodeField:
             # get var names from val, check against namespace:
             code = experiment.getCodeFromParamStr(val)
@@ -218,7 +220,7 @@ def _highlightParamVal(parent, error=False):
                             eval(code)
                         except NameError as e:
                             _highlightParamVal(parent, True)
-                            msg = _translate("Looks like your variable '{code}' in '{displayName}' should be set to update.")
+                            msg = _translate(f"Looks like your variable '{code}' in '{self.displayName}' should be set to update.")
                             msg = msg.format(code=code, displayName=self.displayName)
                         except SyntaxError as e:
                             msg = ''
@@ -240,7 +242,7 @@ def _highlightParamVal(parent, error=False):
 
                     for newName in names:
                         namespace = parent.frame.exp.namespace
-                        if newName in namespace.user or newName in namespace.builder:
+                        if newName in [*namespace.user, *namespace.builder, *namespace.constants]:
                             continue
                         used = namespace.exists(newName)
                         sameAsOldName = bool(newName == parent.params['name'].val)
diff --git a/psychopy/experiment/exports.py b/psychopy/experiment/exports.py
--- a/psychopy/experiment/exports.py
+++ b/psychopy/experiment/exports.py
@@ -147,7 +147,8 @@ def __init__(self, exp):
         # noinspection PyUnresolvedReferences
         self.keywords = keyword.kwlist + dir(__builtins__)
         # these are based on a partial test, known to be incomplete:
-        self.psychopy = psychopy.__all__ + ['psychopy', 'os'] + dir(constants)
+        self.psychopy = psychopy.__all__ + ['psychopy', 'os']
+        self.constants = dir(constants)
         self.builder = ['KeyResponse', 'keyboard', 'buttons',
                         'continueRoutine', 'expInfo', 'expName', 'thisExp',
                         'filename', 'logFile', 'paramName',


Test patch:
diff --git a/psychopy/tests/test_app/test_builder/test_BuilderFrame.py b/psychopy/tests/test_app/test_builder/test_BuilderFrame.py
--- a/psychopy/tests/test_app/test_builder/test_BuilderFrame.py
+++ b/psychopy/tests/test_app/test_builder/test_BuilderFrame.py
@@ -13,6 +13,9 @@
 import psychopy.experiment
 from psychopy import prefs
 from psychopy.app import psychopyApp
+from psychopy.app.builder.dialogs import DlgComponentProperties
+from psychopy.app.builder.validators import CodeSnippetValidator
+from psychopy.experiment import Param
 
 # Jeremy Gray March 2011
 
@@ -103,3 +106,27 @@ def test_ComponentDialogs(self):
         builderView.isModified = False
         builderView.closeFrame()
         del builderView, componsPanel
+
+    def test_param_validator(self):
+        """Test the code validator for component parameters"""
+
+        # Define 'tykes' - combinations of values likely to cause an error if certain features aren't working
+        tykes = [
+            {'fieldName': "brokenCode", 'param': Param(val="for + :", valType="code"), 'msg': "Python syntax error in field `{fieldName}`:  {param.val}"}, # Make sure it's picking up clearly broken code
+            {'fieldName': "correctAns", 'param': Param(val="'space'", valType="code"), 'msg': ""}, # Single-element lists should not cause warning
+        ]
+        for tyke in tykes:
+            # For each tyke, create a dummy environment
+            parent = DlgComponentProperties(
+                frame=self.builder, title='Param Testing',
+                params={tyke['fieldName']: tyke['param']}, order=[],
+                testing=True)
+            # Set validator and validate
+            parent.SetValidator(CodeSnippetValidator(tyke['fieldName']))
+            parent.Validate()
+            # Does the message delivered by the validator match what is expected?
+            warnings = [w for w in list(parent.warningsDict.values()) if w] or ['']
+            msg = warnings[0]
+            assert msg == tyke['msg'].format(**tyke)
+            # Cleanup
+            parent.Destroy()
\ No newline at end of file