Implicit tuple return type

On second thought, __class_getitem__ isn’t the problem, but the current parser rule that forces parentheses enclosed in square brackets to be redundant is.

Perhaps introduce a new syntax of Class{*types} (a la Julia) that translates into a call to Class.__class_getitem__(tuple(types)) so that for example,

  • Class{(int, str)} would call Class.__class_getitem__(((int, str),))
  • Class{int, str} would call Class.__class_getitem__((int, str))

This makes the Class{...} syntax fully compatible with the existing Class[...] syntax except for the very case of Class{(A, B)} versus Class[(A, B)], in which parentheses form a tuple in the former but are redundant in the latter.

Note that I had thought about using angle brackets from popular languages like C++, Java, Rust, TypeScript, etc., but found @Jelle’s old post explaining why angle brackets were rejected. It’s also why Julia chose to use the Class{...} syntax for generics because it’s the only popular language besides Python to support chained comparisons.

You could also try to fix __class_getitem__ in a way that’s backwards compatible:

class Object:
    @classmethod
    def __new_class_getitem__(cls, *items):
        if len(items) == 1:
            return cls.__class_getitem__(*items)
        return cls.__class_getitem__(items)

class Old(Object):
    @classmethod
    def __class_getitem__(cls, item):
        print(item)

class New(Object):
    @classmethod
    def __new_class_getitem__(cls, *items):
        print(items)

Old.__new_class_getitem__(int, str)  # (<class 'int'>, <class 'str'>)
Old.__new_class_getitem__((int, str))  # (<class 'int'>, <class 'str'>)
New.__new_class_getitem__(int, str)  # (<class 'int'>, <class 'str'>)
New.__new_class_getitem__((int, str))  # ((<class 'int'>, <class 'str'>),)

But:

Old[int,]  # (<class 'int'>,)
Old[(int,)]  # (<class 'int'>,)
Old.__new_class_getitem__(int,)  # <class 'int'>
Old.__new_class_getitem__((int,))  # (<class 'int'>,)

Or we just deal with it.

1 Like

That’s exactly why a new syntax is needed. The current syntax rules force parentheses enclosed by square brackets to be redundant so there has to be a new syntax that preserves parentheses.

2 Likes

I don’t think the issue with redundant parentheses is significant enough to justify the braces notation (which could be used for something else). And modifying the bracket notation would break backwards compatibility, no matter what you try.

A linter could help out with the ambiguous syntax by suggesting adding a comma:

-tuple[(int, int)]
+tuple[(int, int),]

Or to remove the redundant parentheses:

-tuple[(int, int)]
+tuple[int, int]
1 Like

This has nothing to do with linting. His point is that you would have to change the entire mechanism of what the square brackets do. Currently they call __getitem__ or __class_getitem__ with a tuple. You would have to make them call a different method with multiple positional parameters instead. That’s a big change.

Yes, unfortunately the ship has sailed already for Python’s typing system and the status quo is square brackets. Adding curly brackets now to do what square brackets can already do 99% of the time does seem like a huge waste.

I don’t think anyone here is talking about modifying the bracket notation.

Class[(A, B),] would indeed be a reasonable workaround for the new syntax of a tuple literal to be used as an argument to a generic type. Not as clean-looking as a hypothetical Class{(A, B)} in my opinion, but certainly the best we can do without introducing a largely redundant new grammar.

So as long as it is clearly documented that Class[tuple[A, B]] can be replaced with Class[(A, B),] rather than Class[(A, B)] under the new syntax, I think this proposal can reasonably move forward.

1 Like

Oh I thought this discussion had become fully academic. :sweat_smile: I don’t think there’s any chance of this proposal moving forward.

It was fully academic until a reasonable solution materialized from the academic discussion.

The only real objection to this proposal so far has been about the ambiguity this syntax can cause when used as an argument to a generic type, and I believe the solution above is reasonable enough to move the proposal beyond this particular objection.

Would certainly love your constructive input if you have any.

Is there a single concrete proposal at this point? It started as “an implicit tuple return type” but the latest posts are about something different.

In any case, it comes back to the same questions that any idea has to address:

  • What is the benefit? Is there a benefit to this that goes beyond “some people think it looks nicer”? Does it add any capabilities to Python, or make something possible that wasn’t possible before?
  • What is the cost? How does this affect existing code? If the proposed change is going to swap __class_getitem__ for another method this is a serious concern, particularly for libraries that rely on metaprogramming.
2 Likes

The OP’s proposal aimed specifically at a tuple return type, and the discussion has been to generalize the idea to find the most use for it. It’s what a discussion is for. Things get clearer and more concrete the more we discuss.

At this point I believe the proposal has evolved to be about replacing type annotations of all built-in container types with their literal representations.

If most people think it looks nicer that would be enough benefit on its own. It doesn’t need to bring anything functionally new, just like how replacing typing.Tuple with tuple and replacing class Class(Generic[T]) with class Class[T] and replacing TypeAlias with the type statement didn’t bring anything functionally new.

The latest iteration of the discussed proposal so far involves adding a condition in the implementation of __class_getitem__ to recognize instances of tuple, list, dict or set as valid type annotations, and normalize them to the equivalent generic aliases for the original logics.

1 Like
  1. Type relation between key and value of a mapping (needs support of the dict generic alias):
    dic: {str: str, int: int} = {
        "abc": "xyz",
        123: 789
    }
    reveal_type(dic["abc"])  # Revealed type is "str"
    reveal_type(dic[123])  # Revealed type is "int"
    
  2. Every (user defined) class can define its own behaviour, and type checkers should be able to understand it:
    • ...EllipsisType
    • 'Tree'ForwardRef('Tree') (instead of Literal['Tree'])
    • NoneNoneType
    • 123Literal[123]
    • TrueLiteral[True] (I like this one a lot)
    • Color.REDLiteral[Color.RED] (Color.RED being a Enum)
    • to_stringCallable[[], str] (to_string being a function)

There shouldn’t be any cost for the current annotations, as we already have special handling for None and str instances. This suggestion is just a generalisation of that.

That would mean that built-in collection literals cannot stand on their own which is a fairly significant shortcoming and a likely source of confusion for typing novices. A nested tuple literal in square brackets is also hardly an improvement over the status quo. Not only are you confusing people with literals which only work in specific contexts but you gotta explain to them that comma-separated values in square brackets make a non-typing tuple.

To clarify: they can stand on their own (we got a little sidetracked with __class_getitem__):

Whenever we have an obj that isn’t a type or _GenericAlias, we call obj.__alias__() (probably followed by another check):

Which can recursively resolve itself:

By “cost” I wasn’t talking about performance. The cost of changing syntax is that existing working code might cease working, or start doing something different*. Another cost, particularly for a change that is syntactic sugar, is the prospect of “churn” for maintainers–there could be a bunch of drive-by PRs to perfectly-fine codebases because people want them to change their library to a newer syntax [1]. edited to add: Yet another cost is whether the new syntax makes code any easier to read or write. Some of the proposals here are much less clear than the existing typing syntax, IMO.

These are real costs and have to be weighed against the benefit. If the benefit is primarily aesthetic, then these costs are a significant barrier to adoption.

  • From this post, it seems like you’re proposing major syntax changes that are distinct from what @blhsing is discussing, it’s difficult to keep track of it all

  1. and people want to contribute to popular projects, and go for low-hanging fruit ↩︎

Hmm, maybe we shouldn’t convert None, ... & 'Class', or should these be accepted by MyPy?

from types import NoneType, EllipsisType
foo1: None
foo2: NoneType  # NoneType should not be used as a type, please use None instead
bar1: tuple[str, ...]
bar2: tuple[str, EllipsisType]
reveal_type(bar2)  # Revealed type is "tuple[builtins.str, types.EllipsisType]
baz1: 'Baz'
baz2: ForwardRef('Baz')  # Invalid type comment or annotation
class Baz: ...

For the rest there wouldn’t be any differences to current annotations.


The same thing applies to previous additions to typing. And if people would be willing to do that, that means that they like the new syntax.


So use ((int, int),) or tuple[tuple[int, int]] and not tuple[((int, int),)].


He was just focussing on the problem with __class_getitem__. Example implementation for tuple:

class Tuple(tuple):
    def __class_getitem__(cls, item):
        if isinstance(item, tuple):
            return super().__class_getitem__(cls, tuple(map(alias, item)))
        return super().__class_getitem__(cls, alias(item))

I did not notice this generalization of the idea from you until now.

I don’t think there’s ever a good real-world use case for a dict with mixed-type keys, or else people would’ve asked for dict to support usage like dict[[(str, str), (int, int)]] already.

Let’s keep this proposal simply a prettier alternative to an existing syntax without complicating it with additional functionalities.

The cost to this proposal as summarized in my last reply to you is extremely minimal, especially compared to the other aethetic changes already implemented. There is no API change to a type, unlike making tuple able to perform double duty as typing.Tuple. There is no syntax change to the Python grammar unlike making the class Class[T] syntax and the type statement work. Existing code will continue to work since the new syntax is not required. And it’s trivial to automate migrations to this new syntax with both a simple AST transformer and a backport added to typing_extensions.

The new syntax should be easier to both read and write for most because people are already extremely familiar with and used to reading and writing the literal forms of the built-in container types such as (1, 2, 3) and {'a': 1, 'b': 2, 'c': 3} versus their explicitly named alternatives such as tuple([1, 2, 3]) and dict(a=1, b=2, c=3). And since we’re talking about using type objects as keys in a dict here, there isn’t even a downside of having to quote the keys as in {'a': 1, 'b': 2, 'c': 3} versus dict(a=1, b=2, c=3).

So as long as the new syntax is perceived by most as a better looking alternative to the existing one I believe the benefit-to-cost ratio here will be good as the cost is extremely low.

cf:

This IS a real cost. You say that it’s “trivial” but that simply isn’t true when you need to support multiple versions. Drive-by PRs are a hassle to deal with, and this should not be ignored.

I wonder how the past aethetic changes got greenlighted when drive-by PRs are a concern.

Do we setup a poll to gauge whether the level of favorability can overcome the cost of drive-by PRs? What exactly is the process?

A core dev has to believe that the benefits outweigh the costs. The best way to achieve that is not to downplay the costs, but to honestly evaluate them.

Can you name a purely aesthetic change that has been implemented?