views:

292

answers:

2

I have a menubutton, which when clicked should display a menu containing a specific sequence of strings. Exactly what strings are in that sequence, we do not know until runtime, so the menu that pops up must be generated at that moment. Here's what I have:

class para_frame(Frame):
    def __init__(self, para=None, *args, **kwargs):
        # ...

        # menu button for adding tags that already exist in other para's
        self.add_tag_mb = Menubutton(self, text='Add tags...')

        # this menu needs to re-create itself every time it's clicked
        self.add_tag_menu = Menu(self.add_tag_mb,
                                 tearoff=0,
                                 postcommand = self.build_add_tag_menu)

        self.add_tag_mb['menu'] = self.add_tag_menu

    # ...

    def build_add_tag_menu(self):
        self.add_tag_menu.delete(0, END) # clear whatever was in the menu before

        all_tags = self.get_article().all_tags()
        # we don't want the menu to include tags that already in this para
        menu_tags = [tag for tag in all_tags if tag not in self.para.tags]

        if menu_tags:
            for tag in menu_tags:
                def new_command():
                    self.add_tag(tag)

                self.add_tag_menu.add_command(label = tag,
                                              command = new_command)
        else:
            self.add_tag_menu.add_command(label = "<No tags>")

The important part is the stuff under "if menu_tags:" -- Suppose menu_tags is the list ['stack', 'over', 'flow']. Then what I want to do is effectively this:

self.add_tag_menu.add_command(label = 'stack', command = add_tag_stack)
self.add_tag_menu.add_command(label = 'over', command = add_tag_over)
self.add_tag_menu.add_command(label = 'flow', command = add_tag_flow)

where add_tag_stack() is defined as:

def add_tag_stack():
    self.add_tag('stack')

and so on.

The problem is, the variable 'tag' takes on the value 'stack' and then the value 'over' and so on, and it doesn't get evaluated until new_command is called, at which point the variable 'tag' is just 'flow'. So the tag that gets added is always the last one on the menu, no matter what the user clicks on.

I was originally using a lambda, and I thought maybe explicitly defining the function as above might work better. Either way the problem occurs. I've tried using a copy of the variable 'tag' (either with "current_tag = tag" or using the copy module) but that doesn't solve it. I'm not sure why.

My mind is starting to wander towards things like "eval" but I'm hoping someone can think of a clever way that doesn't involve such horrible things.

Much thanks!

(In case it's relevant, Tkinter.__version__ returns '$Revision: 67083 $' and I'm using Python 2.6.1 on Windows XP.)

+1  A: 

That kind of thing is quite a common problem in Tkinter, I think.

Try this (at the appropriate point):

def new_command(tag=tag):
    self.add_tag(tag)
John Fouhy
I don't quite know why this works, but it does! Although I like the detailed explanation of the other answer, I'll use this one since it's a bit shorter. Thanks!
MatrixFrog
I think it works because of the tag=tag default argument in the functions argument list. Doing this creates the name 'tag' within the function scope, pointing to the value tag in the invoker's scope.Please correct me if I am wrong, I am currently grappling with similar scoping questions.
mataap
Basically, the 'for x in ..' construct is one of the few places in python where a name (x) is rebound. So you need to create a new name that has the same value as x at the appropriate point. You do this with 'tag=tag' which is evaluated at function definition, and not later at function execution.
John Fouhy
+4  A: 

First of all, your problem doesn't have anything to do with Tkinter; it's best if you reduce it down to a simple piece of code demonstrating your problem, so you can experiment with it more easily. Here's a simplified version of what you're doing that I experimented with. I'm substituting a dict in place of the menu, to make it easy to write a small test case.

items = ["stack", "over", "flow"]
map = { }

for item in items:
    def new_command():
        print(item)

    map[item] = new_command

map["stack"]()
map["over"]()
map["flow"]()

Now, when we execute this, as you said, we get:

flow
flow
flow

The issue here is Python's notion of scope. In particular, the for statement does not introduce a new level of scope, nor a new binding for item; so it is updating the same item variable each time through the loop, and all of the new_command() functions are referring to that same item.

What you need to do is introduce a new level of scope, with a new binding, for each of the items. The easiest way to do that is to wrap it in a new function definition:

for item in items:
    def item_command(name):
        def new_command():
            print(name)
        return new_command

    map[item] = item_command(item)

Now, if you substitute that into the preceding program, you get the desired result:

stack
over
flow
Brian Campbell
Well I thought there might have been a Tkinter-specific solution. Someone saying "No no no, the way you do this is the special function Tkinter.somethingOrOther()" Thanks for the help!
MatrixFrog
No problem! I just wanted to point out that a good first step is to try to isolate a small example of the problem, to see if it's a language issue or an API issue.
Brian Campbell