DetachHead / basedpyright

pyright fork with various type checking improvements, improved vscode support and pylance features built into the language server
https://docs.basedpyright.com
Other
1.26k stars 26 forks source link

allow `Final` values as arguments to `Literal` #787

Open karolyi opened 1 month ago

karolyi commented 1 month ago

Hey,

what's your stance on this issue?

KotlinIsland commented 1 month ago

Hey @erictraut,

this bug is not fixed. See:

Code sample in pyright playground

from typing import Literal
x = '1'
y = '2'
xy = Literal[(x, y)]

reveal_type(xy)   # should be Literal['1', '2'], it isn't

z = 1
xyz = Literal[(x, y, z)]

reveal_type(xyz)   # should be Literal['1', '2', 1], it isn't

foo = Literal[*(x, y, z)]
reveal_type(foo)  # Should be Literal['1', '2', 1] from 3.11 on, it isn't

what's your usecase? using a value as a type isn't valid (maybe something that's Final could be considered valid 🤔)

why not use types here?

from typing import Literal

type x = Literal['1']
type y = Literal['2']
type xy = Literal[x, y]

if we allow Final:

x: Final = 1
y: Final = 2
type XY = Literal[x, y]  # i think this could be fine

values = (1, 2, 3, 4)
type Value = Literal[*values]  # i think this could be fine

this actually look extremely useful imo

@DetachHead what's your opinion?

KotlinIsland commented 1 month ago

Expanding on why this is important, with a more precise example:

Python 3.11.9 (main, Aug  1 2024, 12:59:41) [GCC 14.1.1 20240522] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from typing import Literal
>>> from inspect import signature, Parameter
>>> 
>>> valid_values = list(signature(Parameter).parameters)
>>> 
>>> ParamAttribute = Literal[*valid_values]
>>> assert ParamAttribute == Literal['name', 'kind', 'default', 'annotation']
>>> ParamAttribute
typing.Literal['name', 'kind', 'default', 'annotation']

this wouldn't work, because statically all type information of the signature is lost in signature, to achieve this we would need some mechanism to catpure the names/types of the parameters within the type system

karolyi commented 1 month ago

My usecase is django choices, e.g.:

from typing import Literal

from ktools.django.utils.translation import gettext_safelazy as _

BILLING_BY_WIRE_TRANSFER = 'wire-transfer'
BILLING_BY_COLLECTION = 'sepa-collection'

BILLING_TYPES = (
    (BILLING_BY_WIRE_TRANSFER, _('Wire transfer')),
    (BILLING_BY_COLLECTION, _('SEPA collection')),
)

BILLING_TYPES_DICT = dict(BILLING_TYPES)
BillingTypesType = Literal['wire-transfer', 'sepa-collection']

It would be great to have BillingTypesType deducted from either BILLING_TYPES_DICT.keys(), or BILLING_TYPES. But I'd also be fine with

BillingTypesType = Literal[(BILLING_BY_WIRE_TRANSFER, BILLING_BY_COLLECTION)]

None of these are available now, and handling form/model choices would be better supported with types that support this.

KotlinIsland commented 1 month ago

because those are uppercase, they would be pseudo Final, i can't imagine why this couldn't be supported. well have to get signoff from the design committee before we can start work on it though

karolyi commented 1 month ago

Do what you have to do, I'm just spinning ideas here :)

If it gets rejected, it's fine either way. I'll be only more happy when it somehow goes through.

There is merit to what traut says too. I'm just thinking, why not if python already depicts it that way?

KotlinIsland commented 1 month ago

Traut is wrong here, when he says:

You are conflating values and types. At runtime, the interpreter evaluates the value of an expression. A static type checker evaluates the type of an expression. ... Remember, static type checkers don't actually run your code.

he is correct that a type checker does not execute your code, but incorrect that it means that it rules out as being usable in a type position. for example, here, we use the value cls in a type position, because the semantics determine that we know what the value of cls will always be (it will always be an instance of type[Self], so using it in a type position would be the same as writing Self)

class A:
    @classmethod
    def f(cls):
        a: cls = cls()

as long as the type checker has the necessary static information about a value, it should be able to use the value in a type position

DetachHead commented 1 month ago

the fact that you can spread a tuple into Literal at runtime is a consequence of the terrible design decision to not develop any typing syntax, which meant we have all these stupid classes that exist at runtime that don't make any sense being a class (eg. Generic, ABC, Union, Literal, etc). so im a bit skeptical of supporting stuff like this because it seems that the runtime machinery for this stuff is so poorly thought out that it changes all the time (for example i think the runtime representation of the new union syntax (|) is completely different to the old Union type for some reason)

lets look at how typescript supports the same use case:

const x = 1
const y = 2
type XY = typeof x | typeof y

or if you have a tuple of values, you can get a union of all of its values using [number] index access:

const values = [1, 2, 3] as const
type Value = (typeof values)[number] // 1 | 2 | 3

ideally Literal shouldn't even exist at all and you should instead be able to just use a separate "type-realm" syntax for stuff like this like you can in typescript.

the other concern i have (which i guess isn't entirely related to your idea) is how confusing tuples are inside square brackets:

>>> Literal[*(1,2)].__args__ 
(1, 2)
>>> Literal[(1,2)].__args__  
(1, 2)
>>> Literal[1,2].__args__  
(1, 2)

you'd intuitively think that because Literal[*(1,2)] means the same as Literal[1, 2] (as in the value could either be 1 or 2), then Literal[(1, 2)] means the value can only be the tuple (1, 2). but at runtime as far as the Literal class is concerned they are all the same thing. (related: #5)