Repository: psychopy/psychopy

Issue:
Builder: variable naming collisions not apparent to novice user
Hello!

I tried building a test experiment through the builder, and imported a conditions file that has a column named "image". When I added an Image stimulus by clicking on one in the Components, and left the default name as "image" and set the image as $image, this generated a python error: 

`Traceback (most recent call last):
  File "/home/daniel/ownCloud/phdExperiments/maskedVocabTraining/vocabTraining-builder_lastrun.py", line 109, in <module>
    image.setImage(image)
AttributeError: 'unicode' object has no attribute 'setImage'`

The problem was that the stimulus's variable name is "image", and also the variable used in the conditions spreadsheet is "image", so I inadvertently created a naming collision. I was able to quickly figure this out because I program, but I'm posting this here as a bug because to someone who has no programming background this would be a meaningless and intimidating error message, which could give them a negative impression of your wonderful software. I've included a snapshot of the dialogue to hopefully make what I'm saying clearer.
![screenshot from 2016-03-07 14 52 20](https://cloud.githubusercontent.com/assets/10103957/13581338/5b615b5c-e474-11e5-9f3a-3aa118b4b84a.png)

I suggest that a simple way to avoid this could be that when adding a stimulus from the builder's  dialogues, that if the user tries to click OK and the name of the stimulus and the image (or the Text, for a text stimulus) will be the same, that a clear error or warning be generated, saying something like "The fields for Name and Image cannot have the same value". Just a thought.

Thank you for the great software. I do hope you consider this, it seems like an easily implemented improvement to the user experience.  Take care,



Discussion:
Good catch. I agree this should be fixed. The Builder does try to keep track of what the namespace will be when the program is converted into python code, including some inspection of the headers in conditions files when they are linked. But there are some holes in this process, as you discovered. If you wouldn't mind poking around a bit more to try to find other holes, that would be useful. Thanks for reporting this.

Thanks Jeremy, I'll certainly keep my eyes open, and if I find anything that I think will help, I'll try to respectfully point it out. Thanks again.

Sorry, one more note, I made the exact same mistake with the Text component, so the same issue applies there. 


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
@@ -18,7 +18,8 @@
 from wx.lib import flatnotebook
 
 from ... import dialogs
-from .. import validators, experiment
+from .. import experiment
+from .. validators import NameValidator, CodeSnippetValidator
 from .dlgsConditions import DlgConditions
 from .dlgsCode import DlgCodeComponentProperties, CodeBox
 from psychopy import data, logging
@@ -29,67 +30,7 @@
 codeSyntaxOkay = wx.Colour(220, 250, 220, 255)  # light green
 _unescapedDollarSign_re = re.compile(r"^\$|[^\\]\$")
 
-_localized = {
-    # strings for all allowedVals (from all components) go here:
-    # interpolation
-    'linear': _translate('linear'),
-    'nearest': _translate('nearest'),
-    # color spaces not translated:
-    'rgb': 'rgb', 'dkl': 'dkl', 'lms': 'lms', 'hsv': 'hsv',
-    'last key': _translate('last key'),
-    'first key': _translate('first key'),
-    'all keys': _translate('all keys'),
-    'nothing': _translate('nothing'),
-    'last button': _translate('last button'),
-    'first button': _translate('first button'),
-    'all buttons': _translate('all buttons'),
-    'final': _translate('final'),
-    'on click': _translate('on click'),
-    'every frame': _translate('every frame'),
-    'never': _translate('never'),
-    'from exp settings': _translate('from exp settings'),
-    'from prefs': _translate('from preferences'),
-    'circle': _translate('circle'),
-    'square': _translate('square'),  # dots
-    # dots
-    'direction': _translate('direction'),
-    'position': _translate('position'),
-    'walk': _translate('walk'),
-    # dots
-    'same': _translate('same'),
-    'different': _translate('different'),
-    'experiment': _translate('Experiment'),
-    # startType & stopType:
-    'time (s)': _translate('time (s)'),
-    'frame N': _translate('frame N'),
-    'condition': _translate('condition'),
-    'duration (s)': _translate('duration (s)'),
-    'duration (frames)': _translate('duration (frames)'),
-    # units not translated:
-    'pix': 'pix', 'deg': 'deg', 'cm': 'cm',
-    'norm': 'norm', 'height': 'height',
-    # tex resolution:
-    '32': '32', '64': '64', '128': '128', '256': '256', '512': '512',
-    'routine': 'Routine',
-    # strings for allowedUpdates:
-    'constant': _translate('constant'),
-    'set every repeat': _translate('set every repeat'),
-    'set every frame': _translate('set every frame'),
-    # strings for allowedVals in settings:
-    'add': _translate('add'),
-    'avg': _translate('average'),  # blend mode
-    'use prefs': _translate('use preferences'),
-    # logging level:
-    'debug': _translate('debug'),
-    'info': _translate('info'),
-    'exp': _translate('exp'),
-    'data': _translate('data'),
-    'warning': _translate('warning'),
-    'error': _translate('error'),
-    # Experiment info dialog:
-    'Field': _translate('Field'),
-    'Default': _translate('Default'),
-}
+from ..localizedStrings import _localizedDialogs as _localized
 
 
 class ParamCtrls(object):
@@ -235,9 +176,12 @@ def __init__(self, dlg, label, param, parent, fieldName,
         if len(param.allowedVals) == 1 or param.readOnly:
             self.valueCtrl.Disable()  # visible but can't be changed
 
-        # add a NameValidator to name valueCtrl
+        # add a Validator to the valueCtrl
         if fieldName == "name":
-            self.valueCtrl.SetValidator(validators.NameValidator())
+            self.valueCtrl.SetValidator(NameValidator())
+        elif isinstance(self.valueCtrl, (wx.TextCtrl, CodeBox)):
+            # only want anything that is valType code, or can be with $
+            self.valueCtrl.SetValidator(CodeSnippetValidator(fieldName))
 
         # create the type control
         if len(param.allowedTypes):
@@ -422,6 +366,7 @@ def __init__(self, frame, title, params, order,
             makeValid = self.frame.exp.namespace.makeValid
             self.params['name'].val = makeValid(params['name'].val)
         self.paramCtrls = {}
+        CodeSnippetValidator.clsWarnings = {}
         self.suppressTitles = suppressTitles
         self.showAdvanced = showAdvanced
         self.order = order
@@ -663,8 +608,12 @@ def addStartStopCtrls(self, remaining, parent, sizer, currRow):
         # use monospace font to signal code:
         self.checkCodeWanted(self.startValCtrl)
         self.startValCtrl.Bind(wx.EVT_KEY_UP, self.checkCodeWanted)
+        self.startValCtrl.SetValidator(CodeSnippetValidator('startVal'))
+        self.startValCtrl.Bind(wx.EVT_KEY_UP, self.doValidate)
         self.checkCodeWanted(self.stopValCtrl)
         self.stopValCtrl.Bind(wx.EVT_KEY_UP, self.checkCodeWanted)
+        self.stopValCtrl.SetValidator(CodeSnippetValidator('stopVal'))
+        self.stopValCtrl.Bind(wx.EVT_KEY_UP, self.doValidate)
 
         return remaining, currRow
 
@@ -682,8 +631,11 @@ def addParam(self, fieldName, parent, sizer, currRow, advanced=False,
                            advanced=advanced, appPrefs=self.app.prefs)
         self.paramCtrls[fieldName] = ctrls
         if fieldName == 'name':
-            ctrls.valueCtrl.Bind(wx.EVT_TEXT, self.checkName)
+            ctrls.valueCtrl.Bind(wx.EVT_KEY_UP, self.doValidate)
             ctrls.valueCtrl.SetFocus()
+        elif isinstance(ctrls.valueCtrl, (wx.TextCtrl, CodeBox)):
+            ctrls.valueCtrl.Bind(wx.EVT_KEY_UP, self.doValidate)
+
         # self.valueCtrl = self.typeCtrl = self.updateCtrl
         _flag = wx.ALIGN_RIGHT | wx.ALIGN_CENTRE_VERTICAL | wx.LEFT | wx.RIGHT
         sizer.Add(ctrls.nameCtrl, (currRow, 0), border=5, flag=_flag)
@@ -697,6 +649,7 @@ def addParam(self, fieldName, parent, sizer, currRow, advanced=False,
             sizer.AddGrowableRow(currRow)  # doesn't seem to work though
             # self.Bind(EVT_ETC_LAYOUT_NEEDED, self.onNewTextSize,
             #    ctrls.valueCtrl)
+            ctrls.valueCtrl.Bind(wx.EVT_KEY_UP, self.doValidate)
         elif fieldName in ('color', 'fillColor', 'lineColor'):
             ctrls.valueCtrl.Bind(wx.EVT_RIGHT_DOWN, self.launchColorPicker)
         elif valType == 'extendedCode':
@@ -775,7 +728,7 @@ def show(self):
             self.OKbtn.Bind(wx.EVT_BUTTON, self.onOK)
         self.OKbtn.SetDefault()
 
-        self.checkName()  # disables OKbtn if bad name
+        self.doValidate()  # disables OKbtn if bad name, syntax error, etc
         buttons.Add(self.OKbtn, 0, wx.ALL, border=3)
         CANCEL = wx.Button(self, wx.ID_CANCEL, _translate(" Cancel "))
         buttons.Add(CANCEL, 0, wx.ALL, border=3)
@@ -1019,8 +972,8 @@ def _checkName(self, event=None, name=None):
             else:
                 return "", True
 
-    def checkName(self, event=None):
-        """Issue a form validation on name change.
+    def doValidate(self, event=None):
+        """Issue a form validation on event, e.g., name or text change.
         """
         self.Validate()
 
@@ -1168,7 +1121,7 @@ def makeGlobalCtrls(self):
                            flag=wx.EXPAND | wx.ALIGN_CENTRE_VERTICAL | wx.ALL)
             row += 1
 
-        self.globalCtrls['name'].valueCtrl.Bind(wx.EVT_TEXT, self.checkName)
+        self.globalCtrls['name'].valueCtrl.Bind(wx.EVT_TEXT, self.doValidate)
         self.Bind(wx.EVT_CHOICE, self.onTypeChanged,
                   self.globalCtrls['loopType'].valueCtrl)
         return panel
diff --git a/psychopy/app/builder/localizedStrings.py b/psychopy/app/builder/localizedStrings.py
new file mode 100644
--- /dev/null
+++ b/psychopy/app/builder/localizedStrings.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python2
+# -*- coding: utf-8 -*-
+
+# Part of the PsychoPy library
+# Copyright (C) 2015 Jonathan Peirce
+# Distributed under the terms of the GNU General Public License (GPL).
+
+"""
+Discover all _localized strings from all Builder components, etc.
+
+Mainly used by validators.py -- need access to _translate()d field names.
+"""
+
+import copy
+import os, glob
+
+from ..localization import _translate
+from psychopy.app.builder.components._base import _localized as _localizedBase
+
+_localizedDialogs = {
+    # strings for all allowedVals (from all components) go here:
+    # interpolation
+    'linear': _translate('linear'),
+    'nearest': _translate('nearest'),
+    # color spaces not translated:
+    'rgb': 'rgb', 'dkl': 'dkl', 'lms': 'lms', 'hsv': 'hsv',
+    'last key': _translate('last key'),
+    'first key': _translate('first key'),
+    'all keys': _translate('all keys'),
+    'nothing': _translate('nothing'),
+    'last button': _translate('last button'),
+    'first button': _translate('first button'),
+    'all buttons': _translate('all buttons'),
+    'final': _translate('final'),
+    'on click': _translate('on click'),
+    'every frame': _translate('every frame'),
+    'never': _translate('never'),
+    'from exp settings': _translate('from exp settings'),
+    'from prefs': _translate('from preferences'),
+    'circle': _translate('circle'),
+    'square': _translate('square'),  # dots
+    # dots
+    'direction': _translate('direction'),
+    'position': _translate('position'),
+    'walk': _translate('walk'),
+    # dots
+    'same': _translate('same'),
+    'different': _translate('different'),
+    'experiment': _translate('Experiment'),
+    # startType & stopType:
+    'time (s)': _translate('time (s)'),
+    'frame N': _translate('frame N'),
+    'condition': _translate('condition'),
+    'duration (s)': _translate('duration (s)'),
+    'duration (frames)': _translate('duration (frames)'),
+    # units not translated:
+    'pix': 'pix', 'deg': 'deg', 'cm': 'cm',
+    'norm': 'norm', 'height': 'height',
+    # tex resolution:
+    '32': '32', '64': '64', '128': '128', '256': '256', '512': '512',
+    'routine': 'Routine',
+    # strings for allowedUpdates:
+    'constant': _translate('constant'),
+    'set every repeat': _translate('set every repeat'),
+    'set every frame': _translate('set every frame'),
+    # strings for allowedVals in settings:
+    'add': _translate('add'),
+    'avg': _translate('average'),  # blend mode
+    'use prefs': _translate('use preferences'),
+    # logging level:
+    'debug': _translate('debug'),
+    'info': _translate('info'),
+    'exp': _translate('exp'),
+    'data': _translate('data'),
+    'warning': _translate('warning'),
+    'error': _translate('error'),
+    # Experiment info dialog:
+    'Field': _translate('Field'),
+    'Default': _translate('Default'),
+}
+
+_localized = copy.copy(_localizedBase)
+_localized.update(_localizedDialogs)
+
+thisDir = os.path.dirname(os.path.abspath(__file__))
+modules = glob.glob(os.path.join(thisDir, 'components', '*.py'))
+components = [os.path.basename(m).replace('.py', '') for m in modules
+              if not m.endswith('patch.py')]
+
+for comp in components:
+    try:
+        exec('from psychopy.app.builder.components.' + comp + ' import _localized as _loc')
+        _localized.update(_loc)
+    except ImportError:
+        pass
+
+if __name__ == '__main__':
+    for key, val in _localized.iteritems():
+        print key, val
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
@@ -1,25 +1,37 @@
-'''
+#!/usr/bin/env python2
+# -*- coding: utf-8 -*-
+
+# Part of the PsychoPy library
+# Copyright (C) 2015 Jonathan Peirce
+# Distributed under the terms of the GNU General Public License (GPL).
+
+"""
 Module containing validators for various parameters.
-'''
+"""
+
 import wx
 from ..localization import _translate
+from . import experiment
+from .localizedStrings import _localized
 
 
-class NameValidator(wx.PyValidator):
+class BaseValidator(wx.PyValidator):
     """
-    Component name validator for _BaseParamsDlg class. It depends on accesss
-    to an experiment namespace. Validation checks if it is a valid Python
-    identifier and if it does not clash with existing names.
+    Component name validator for _BaseParamsDlg class. It depends on access
+    to an experiment namespace.
+
+    Validate calls check, which needs to be implemented per class.
+
+    Messages are passed to user as text in nameOklabel.
 
     @see: _BaseParamsDlg
     """
 
     def __init__(self):
-        super(NameValidator, self).__init__()
-        self.message = ""
+        super(BaseValidator, self).__init__()
 
     def Clone(self):
-        return NameValidator()
+        return self.__class__()
 
     def Validate(self, parent):
         """
@@ -31,8 +43,8 @@ def Validate(self, parent):
                 parent = parent.GetParent()
             except Exception:
                 print("Couldn't find the root dialog for this event")
-        message, valid = self.checkName(parent)
-        parent.nameOKlabel.SetLabel(message)
+        message, valid = self.check(parent)
+        self.setMessage(parent, message)
         return valid
 
     def TransferFromWindow(self):
@@ -41,7 +53,22 @@ def TransferFromWindow(self):
     def TransferToWindow(self):
         return True
 
-    def checkName(self, parent):
+    def check(self, parent):
+        raise NotImplementedError
+
+    def setMessage(self, parent, message):
+        raise NotImplementedError
+
+
+class NameValidator(BaseValidator):
+    """Validation checks if the value in Name field is a valid Python
+    identifier and if it does not clash with existing names.
+    """
+
+    def __init__(self):
+        super(NameValidator, self).__init__()
+
+    def check(self, parent):
         """checks namespace, return error-msg (str), enable (bool)
         """
         control = self.GetWindow()
@@ -65,3 +92,90 @@ def checkName(self, parent):
                 return msg, True
             else:
                 return "", True
+
+    def setMessage(self, parent, message):
+        parent.nameOKlabel.SetLabel(message)
+
+
+class CodeSnippetValidator(BaseValidator):
+    """Validation checks if field value is intended to be Python code.
+    If so, check that it is valid Python, and if valid, check whether
+    it contains bad identifiers (currently only self-reference).
+
+    @see: _BaseParamsDlg
+    """
+    # class attribute: dict of {fieldName: message} to handle all messages
+    clsWarnings = {}
+
+    def __init__(self, fieldName):
+        super(CodeSnippetValidator, self).__init__()
+        self.fieldName = fieldName
+        try:
+            self.displayName = _localized[fieldName]
+        except KeyError:
+            # should have all _localized[fieldName] from .localizedStrings
+            # might as well try to do something useful if fail:
+            self.displayName = _translate(fieldName)
+        self.clsWarnings[fieldName] = ''
+
+    def Clone(self):
+        return self.__class__(self.fieldName)
+
+    def check(self, parent):
+        """Checks python syntax of code snippets, and for self-reference.
+
+        Note: code snippets simply use existing names in the namespace,
+        like from condition-file headers. They do not add to the
+        namespace (unlike Name fields).
+
+        Code snippets in param fields will often be user-defined
+        vars, especially condition names. Can also be expressions like
+        random(1,100). Hard to know what will be problematic.
+        But its always the case that self-reference is wrong.
+        """
+        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
+        codeWanted = experiment._unescapedDollarSign_re.search(val)
+        isCodeField = bool(parent.params[self.fieldName].valType == 'code')
+        if codeWanted or isCodeField:
+            # get var names from val, check against namespace:
+            code = experiment.getCodeFromParamStr(val)
+            try:
+                names = compile(code, '', 'eval').co_names
+            except SyntaxError:
+                # empty '' compiles to a syntax error, ignore
+                if not code.strip() == '':
+                    msg = _translate('Python syntax error in field `{}`:  {}')
+                    return msg.format(self.displayName, code), False
+            else:
+                # namespace = parent.frame.exp.namespace
+                # parent.params['name'].val is not in namespace for new params
+                # and is not fixed as .val until dialog closes. Use getvalue()
+                # to handle dynamic changes to Name field:
+                parentName = parent.paramCtrls['name'].getValue()
+                for name in names:
+                    # `name` means a var name within a compiled code snippet
+                    if name == parentName:
+                        msg = _translate(
+                            'Python var `{}` in `{}` is same as Name')
+                        return msg.format(name, self.displayName), False
+        return '', True
+
+    def setMessage(self, parent, message):
+        """Set nameOklabel to the first warning (if any).
+
+        Complexity: Use a single nameOklabel for warnings for all fields.
+        And we want to reset a given warning to 'all clear' when fixed by
+        user, but not reset all warnings.
+        Solution: use a class attribute to collect warnings for all params.
+        should only be one param dialog open at a time, making this possible:
+        Reset clsWarnings to {} when setting up the dialog.
+        """
+        self.clsWarnings[self.fieldName] = message
+        warnings = [w for w in self.clsWarnings.values() if w] or ['']
+        parent.nameOKlabel.SetLabel(warnings[0])
+


Test patch: