
Change of subject line as I wish to focus on a single critical point of the PEP: keyword-only subscripts. TL;DR: 1. We have to pass a sentinel to the setitem dunder if there is no positional index passed. What should that sentinel be? * None * the empty tuple () * NotImplemented * something else 2. Even though we don't have to pass the same sentinel to getitem and delitem dunders, we could. Should we? * No, getitem and delitem should use no sentinel. * Yes, all three dunders should use the same rules. * Just prohibit keyword-only subscripts. (Voting is non-binding. It's feedback, not a democracy :-) Please read the details below before voting. Comments welcome. ---------------------------------------------------------------------- For all three dunders, there is no difficulty in retrofitting keyword subscripts to the dunder signature if there is there is a positional index: obj[index, spam=1, eggs=2] # => calls type(obj).__getitem__(index, spam=1, eggs=2) del obj[index, spam=1, eggs=2] # => calls type(obj).__delitem__(index, spam=1, eggs=2) obj[index, spam=1, eggs=2] = value # => calls type(obj).__setitem__(index, value, spam=1, eggs=2) If there is no positional index, the getitem and delitem calls are easy: obj[spam=1, eggs=2] # => calls type(obj).__getitem__(spam=1, eggs=2) del obj[spam=1, eggs=2] # => calls type(obj).__delitem__(spam=1, eggs=2) If the dunders are defined with a default value for the index, the call will succeed; if there is no default, you will get a TypeError. This is what we expect to happen. But setitem is hard: obj[spam=1, eggs=2] = value # => calls type(obj).__setitem__(???, value, spam=1, eggs=2) Python doesn't easily give us a way to call a method and skip over positional arguments. So it seems that setitem needs to fill in a fake placeholder. Three obvious choices are None, the empty tuple () or NotImplemented. All three are hashable, so they could be used as legitimate keys in a mapping; but in practice, I expect that only None and () would be. I can't see very many objects actually using NotImplemented as a key. numpy also uses None to force creation of a new axis. I don't think that *quite* rules out None: numpy could distinguish the meaning of None as a subscript depending on whether or not there are keyword args. But NotImplemented is special: - I don't expect anyone is using NotImplemented as a key or index. - NotImplemented is already used a sentinel for operators to say "I don't know how to handle this"; it's not far from that to interpret it as saying "I don't know what value to put in this positional argument". - Starting in Python 3.9 or 3.10, NotImplemented is even more special: it no longer ducktypes as truthy or falsey. This will encourage people to explicitly check for it: if index is NotImplemented: ... rather than `if index: ...`. So I think that NotImplemented is a better choice than None or an empty tuple. Whatever sentinel we use, that implies that setitem cannot distingish these two cases: obj[SENTINEL, spam=1, eggs=2] = value obj[spam=1, eggs=2] = value Since both None and () are likely to be legitimate indexes, and NotImplemented is less likely to be such, I think this supports using NotImplemented. But whichever sentinel we choose, that brings us to the second part of the problem. What should getitem and delitem do? setitem must provide a sentinel for the first positional argument, but getitem and delitem don't have to. So we could have this: # Option 1: only setitem is passed a sentinel obj[spam=1, eggs=2] # => calls type(obj).__getitem__(spam=1, eggs=2) del obj[spam=1, eggs=2] # => calls type(obj).__delitem__(spam=1, eggs=2) obj[spam=1, eggs=2] = value # => calls type(obj).__setitem__(SENTINEL, value, spam=1, eggs=2) Advantages: - The simple getitem and delitem cases stay simple; it is only the complicated setitem case that is complicated. - getitem and delitem can distinguish the "no positional index at all" case from the case where the caller explicitly passes the sentinel as a positional index; only setitem cannot distinguish them. If your class doesn't support setitem, this might be useful to you. Disadvantages: - Inconsistency: the rules for one dunder are different from the other two dunders. - If your class does distinguish between no positional index, and the sentinel, that means that there is a case that getitem and delitem can handle but setitem cannot. Or we could go with an alternative: # Option 2: all three dunders are passed a sentinel obj[spam=1, eggs=2] # => calls type(obj).__getitem__(SENTINEL, spam=1, eggs=2) del obj[spam=1, eggs=2] # => calls type(obj).__delitem__(SENTINEL, spam=1, eggs=2) obj[spam=1, eggs=2] = value # => calls type(obj).__setitem__(SENTINEL, value, spam=1, eggs=2) Even though the getitem and delitem cases don't need the sentinel, they get them anyway. This has the advantage that all three dunders are treated the same, and that there is no case that two of the dunders will handle but the third does not. But it also means that subscript dunders cannot meaningfully provide their own default for the index in the function signature: def __getitem__(self, index=0, *, spam, eggs) will always receive a value for index, not the default. So we need to check that inside the body: if index is SENTINEL: index = 0 There's a third option: just prohibit keyword-only subscripts. I think that's harsh, throwing the baby out with the bathwater. I personally have use-cases where I would use keyword-only subscripts so I would prefer options 1 or 2. -- Steve