Tkinter: wm_attributes() needs breaking changes

Tkinter is a relatively thin wrapper around the Tcl/Tk library. It automatically translates Python objects to corresponding Tcl values and back, although in some cases it needs a code to ensure the structure of the returned values, because in Tcl "foo bar" , ("foo", "bar") and {"foo": "bar"} can be the same. Tcl as the programming language does not have keyword arguments, but there is a convention that options are passed as intermixed minus-prefixed names and values. Tkinter usually translates keyword arguments to Tcl options.

For example, Tcl/Tk command .b configure -padding {2 0 0 2} corresponds to Python call b.configure(padding=(2, 0, 0, 2)). And while Tcl/Tk command .b configure returns

{-command command Command {} {}} {-default default Default normal normal} {-takefocus takeFocus TakeFocus ttk::takefocus ttk::takefocus} {-text text Text {} {}} {-textvariable textVariable Variable {} {}} {-underline underline Underline -1 -1} {-width width Width {} {}} {-image image Image {} {}} {-compound compound Compound {} {}} {-padding padding Pad {} {}} {-state state State normal normal} {-cursor cursor Cursor {} {}} {-style style Style {} {}} {-class {} {} {} {}}

which can be automatically translated in Python as

(('-command', 'command', 'Command', '', ''), ('-default', 'default', 'Default', <index object: 'normal'>, <index object: 'normal'>), ('-takefocus', 'takeFocus', 'TakeFocus', 'ttk::takefocus', 'ttk::takefocus'), ('-text', 'text', 'Text', '', ''), ('-textvariable', 'textVariable', 'Variable', '', ''), ('-underline', 'underline', 'Underline', -1, -1), ('-width', 'width', 'Width', '', ''), ('-image', 'image', 'Image', '', ''), ('-compound', 'compound', 'Compound', '', ''), ('-padding', 'padding', 'Pad', '', ''), ('-state', 'state', 'State', <index object: 'normal'>, <index object: 'normal'>), ('-cursor', 'cursor', 'Cursor', '', ''), ('-style', 'style', 'Style', '', ''), ('-class', '', '', '', ''))

the wrapper makes Python call b.configure() returning a dict

{'command': ('command', 'command', 'Command', '', ''), 'default': ('default', 'default', 'Default', <index object: 'normal'>, <index object: 'normal'>), 'takefocus': ('takefocus', 'takeFocus', 'TakeFocus', 'ttk::takefocus', 'ttk::takefocus'), 'text': ('text', 'text', 'Text', '', ''), 'textvariable': ('textvariable', 'textVariable', 'Variable', '', ''), 'underline': ('underline', 'underline', 'Underline', -1, -1), 'width': ('width', 'width', 'Width', '', ''), 'image': ('image', 'image', 'Image', '', ''), 'compound': ('compound', 'compound', 'Compound', '', ''), 'padding': ('padding', 'padding', 'Pad', '', ''), 'state': ('state', 'state', 'State', <index object: 'normal'>, <index object: 'normal'>), 'cursor': ('cursor', 'cursor', 'Cursor', '', ''), 'style': ('style', 'style', 'Style', '', ''), 'class': ('class', '', '', '', '')}

But it all was not done in the wm_attributes() method. To pass options you need to call w.wm_attributes('-alpha', 0.5, '-type', 'dialog') instead of more Pythonic w.wm_attributes(alpha= 0.5, type='dialog'). Well, it is easy part, we can do both of them working. The hard part is that currently w.wm_attributes() without arguments returns a tuple (or even a str when wantobjects was set to 0), e.g. ('-alpha', 1.0, '-topmost', 0, '-zoomed', 0, '-fullscreen', 0, '-type', ''). It is difficult to use and differs from virtually all other methods. It would be better to return a dict {'alpha': 1.0, 'topmost': 0, 'zoomed': 0, 'fullscreen': 0, 'type': ''}.

But it is a breaking change! For @tjreedy’s suggestion I ask the Steering Сouncil permission to make such change. Deprecation period is not possible, because the result should be a dict or a tuple, not both. Fortunately, None passed as argument is ignored in the current implementation, and it can be used as a sign to return the old value, so if you do not have time to rewrite the code that works with the returned value, you can simply pass None as argument: w.wm_attributes(None).

For details see the issue:

And the PR:

(For the record, posting as myself, not for the SC.)

How reasonable is it to call wm_attributes() with no arguments and use the result? As far as I can tell it’s not unreasonable at all. Is it really a good idea to break them all?

How about instead of breaking backward compatibility, the old behaviour is preserved but a special (keyword) argument is added to make the function return a dict, instead of requiring code to be changed to get the current behaviour.

(As I understand it, passing None is already possible, so that’s not desirable as the indicator: it would silently do the wrong thing on older Pythons. If I misunderstood and passing None is not currently possible, that could also serve as the indicator, although it’s not as obvious as return_python_dict=True.)

Calls that pass no arguments can then get their usual delegation period with DeprecationWarning, and code that would otherwise break straight away would get a migration path instead.

2 Likes

Or just have a new method, wm_attributes_dict()?

Disclaimer: I’ve almost never used Tkinter, and never used this method, so this is a purely theoretical suggestion.

3 Likes

I have found 4 projects (ignoring clones) on GitHub that use it:

GitHub - thonny/thonny: Python IDE for beginners (most of other search results are clones of this code)
GitHub - aggerdom/tkinter-widgets: Collection of tkinter widgets and helper functions for things such as setting a window to always on top
GitHub - mymadhavyadav07/Library-Management-System: A Client-Server Architecture Based Library Management System.
GitHub - bopopescu/NovalIDE: NovalIDE是一款开源,跨平台,而且免费的国产Python IDE。

They will be broken, but they can be easily fixed.

The alternative is:

  1. Add new option _return_python_dict.
  2. Few years later start to emit a deprecation warning for wm_attributes() without _return_python_dict=True.
  3. Few years later convert a warning into error. _return_python_dict=True is now required for wm_attributes().
  4. Few years later allow wm_attributes() without _return_python_dict=True to return a dict.
  5. Few years later start to emit a deprecation warning for wm_attributes() with _return_python_dict=True.
  6. Few years later remove the _return_python_dict option.

Few years should be passed between every changes, so users can add or remove _return_python_dict=True without fear to break their code in any supported Python version. It takes 13-19 years for the whole plan to complete. Either way, any existing code will need to be rewritten twice. The “What’s News” document is the only place where users can find information about this, because most of Tkinter is not documented.

I think it’s worse than breaking the code just once.

Why bother removing the existing form at all? What harm is it doing?

3 Likes

Do you ask why returning a tuple or a str is worse than returning a dict? Just look how it is used:

        atts = self.winfo_toplevel().wm_attributes()
        if "-modified" in atts:
            i = atts.index("-modified")
            mod = atts[i : i + 2]
            rest = atts[:i] + atts[i + 2 :]
        else:
            mod = ()
            rest = atts

Note that it does not even work when wm_attributes() returns a str.

With new interface it can be something like this (and the rest of the code can be simplified):

        atts = self.winfo_toplevel().wm_attributes()
        mod = atts.pop("modified")

There is also learning problem or “less surprise” problem. Many Tkinter methods have the following template:
w.something(option=value) – set a new value of option.
w.something('option') – get the current value of option.
w.something() – return a dict with option names as keys.

Or do you ask why not keep w.wm_attributes(_return_python_dict=True) or w.wm_attributes_v2() indefinitely? The problem is with recognizability. How new users (or even old users) can know about new shiny way and that it is more preferable than the simpler looking old method?

No, I’m asking why you can’t create a new function that returns a dict (or a “return a dict” argument) and simply leave the existing version there as well, so that people using it don’t need to change unless they want to.

4 Likes

Because there is a relation between most of Tk commands and Tkinter methods. If the method implements the wm attributes command, it should be named wm_attributes(). It helps Tk users that came to Python and Python users that search additional documentation for Tk commands. If we start to introduce unnecessary deviations in naming, it will make them worse.

2 Likes

That’s a good point and argues in favor of just adding the keyword only argument to choose the dict return type.

Deprecation period is not possible, because the result should be a dict or a tuple, not both.

It is possible. Our standard API PEP 387 – Backwards Compatibility Policy | peps.python.org deprecation cycle is already reasonable for this:

  1. adding return_python_dict=False kwarg can happen in 3.13, with documentation saying the non-dict behavior will be deprecated and require the flag in the future.
  2. in either 3.13 or 3.14 have calls where that argument is not supplied emit a DeprecationWarning about the upcoming default behavior change.
  3. two releases later (.15 or .16) change the default to True, removing the warning.

We never need to remove the argument. And never need to remove support for the old =False behavior. While those could be done in followup deprecation cycles, it isn’t required, and would just cause more churn in doing so as you’ve noted. So I’d just track them as a followup low priority issue and be surprised if anyone is motivated to bother with those arg removal old behavior removal steps. (the tkinter code does not see much change)

4 Likes

I agree that they never need to be removed, and frankly it’s probably best if they never are removed. Someone will get to removing it eventually, and then users who opted into the correct behaviour are broken.

So I agree with Greg’s plan, but wouldn’t ever remove them. At most, make it an error to explicitly pass False and remove the old code if it’s a burden (but it doesn’t sound like it is? It’s just more complicated to handle for someone who explicitly asked for the more complicated behaviour?)

1 Like

My request was rejected by the SC, I implemented the @gpshead suggestion.

But there was yet one similar breaking change in 3.13. Text.count() now always returns an integer if one or less counting options are specified. Previously it could return a single count as a 1-tuple, an integer (only if option "update" was specified) or None if no items found. And it was more complicated if wantobjects is 0.

  • text.count('1.3', '1.3') – was None or ('0',) (if wantobjects is 0), now 0.
  • text.count('1.3', '1.5') – was (2,) or ('2',), now 2.
  • text.count('1.3', '1.5', 'lines') – was None or ('0',), now 0.
  • text.count('1.3', '3.5', 'lines') – was (2,) or ('2',), now 2.
  • text.count('1.3', '3.5', 'lines', 'update') – was 2 or '2', now 2.
  • text.count('1.3', '1.5', 'chars', 'lines') – was (2, 0) or '2 0', now (2, 0).

Should I revert this change too? What should be the name of the keyword argument to switch the behavior?

1 Like

LOL that was yet another messy tkinter wrapped API.

These four seem like possible behavior change problems given the non-None return values from the past were always subscriptable v = int(x[0]) or multi-assignment single iteration expandable v, = x; v = int(v) to get an int representation of the actual value.

So yes, I’d undo this change and add a keyword only argument to control the API behavior. return_ints=False perhaps with the new behavior when =True is passed?

These last two changes are more likely to be fine as it now returns a consistent type that was one of the two forms it could return in the past. But implementation wise do whichever is simpler internally overall to go along with the above reverts. A truthful return_ints= should guarantee the new behavior. If the minor behavior consistency change is kept in these four arg circumstances, that should be documented with a versionchanged:: 3.13 about the now consistent single-type vs one of two types past behavior.

I will do this, thank you for the suggestion.

Now I have yet one question. Can I at least change the result for the case when wantobject is set to 0?

Semantically, all values in Tcl are strings. But for efficiency many values (integers, floats, lists, and various Tk specific types) are implemented in more efficient form and only converted to string when it is requested. Tkinter by default tries to represent them using corresponding Python types: Tcl integers (all 3 kinds of them) as Python ints, Tcl lists as Python tuples, unknown Tcl types as Tcl_Obj. But it depends on the internal Tcl representation which changes from version to version (even in bugfix releases) and can depend on the history of the value. So an integer or string value can became a Tcl_Obj in other version of Tcl/Tk. For compatibility, the module global variable wantobjects was added. If it is set to 0 before creating the root window, Tkinter always implicitly represents Tcl values as Python strings. This is not what you need in many cases, so many wrappers contain explicit converters to expected values, so you get for example a tuple of ints (instead of a string of space separated decimal representations of integers) independently from the value of wantobjects.

In the above cases, this does not happen. The wrappers were poor and did not contain explicit converters of the result or the postprocessing did not support wantobjects set to 0. I propose to change this, and make the result more usable in this case. In all examples for wm_attributes() the code does not work in this case. There were precendences of fixing the behavior for wantobjects=0 in the past.