Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Temporary moratorium on literal constants (until the end of 2022) #7258

Closed
JukkaL opened this issue Feb 18, 2022 · 26 comments
Closed

Temporary moratorium on literal constants (until the end of 2022) #7258

JukkaL opened this issue Feb 18, 2022 · 26 comments
Labels
project: policy Organization of the typeshed project

Comments

@JukkaL
Copy link
Contributor

JukkaL commented Feb 18, 2022

I've encountered cases where the changes from int to a literal type in stubs causes regressions for mypy users. Here's one (simplified) example:

import logging

class C:
    def __init__(self) -> None:
        self.level = logging.INFO

    def enable_debug(self) -> None:
        # Error: expression has type "Literal[10]", variable has type "Literal[20]"
        self.level = logging.DEBUG

Here's another example:

import logging

class Base:
    level = logging.INFO

class Derived(Base):
    level = logging.DEBUG  # Error -- incompatible with base class

Previously the types were int, so no errors were generated. The code in the above examples doesn't look buggy to me, and the behavior of mypy also seems as expected.

There could well be other problematic cases like this. Having more precise types is generally useful, but in the case of literal types, the regressions such as the above may make them not worth the potential false positives (which are hard to predict, since in most use cases literal types are fine). I wonder if there are concrete examples where the literal types bring concrete benefits that could offset the false positives.

There were several false positives like the above in a big internal codebase when using a recent typeshed. Working around the issues is simple by annotating the attributes as int, but this could be quite non-obvious for non-expert users who aren't familiar with literal types (a somewhat advanced typing feature).

Thoughts? I'm leaning towards preferring to only use literal types when there is a concrete use cases where they help, since the fallout from using them seems difficult to predict.

@srittau
Copy link
Collaborator

srittau commented Feb 18, 2022

At least in the sort term we should revert in cases where literals cause problems and in the future only use literals where it's unlikely that the literal gets assigned to a mutable variable.

In the long term we should rethink how type checkers handle literals. For example,

FOO: Literal[1] = 1
BAR: Literal[2] = 2

x = FOO  # `x` should probably have the type `int`, not `Literal[1]`

@srittau srittau added the project: policy Organization of the typeshed project label Feb 18, 2022
@srittau
Copy link
Collaborator

srittau commented Feb 18, 2022

Actually, at least in mypy the following already works:

FOO: Final = 1
BAR: Final = 2

x = FOO

reveal_type(FOO)  # type: Literal[1]
reveal_type(x)  # type: int

So maybe we should allow X: Final = 1 in stubs?

@srittau
Copy link
Collaborator

srittau commented Feb 18, 2022

Cc @rchen152 @erictraut @pradeep90 for feedback on using X: Final = 3 in stubs.

@srittau
Copy link
Collaborator

srittau commented Feb 18, 2022

Also Cc @sobolevn as "Literal master".

@srittau
Copy link
Collaborator

srittau commented Feb 18, 2022

In #7259 pytype currently fails with "ParseError: Default value for CRITICAL: $external$typing_extensions.Final can only be '...', got LITERAL(50)", so we'd at least need to fix that and of course flake8-pyi.

@AlexWaygood
Copy link
Member

AlexWaygood commented Feb 18, 2022

So maybe we should allow X: Final = 1 in stubs?

This to me feels much more expressive than writing X: Literal[1], so I'd be in favour of allowing this in stubs whatever the case.

I worry, however, that there's not much point in annotating a variable as Literal or Final if it's automatically inferred as something less precise as soon as it's assigned to a different variable.

@JukkaL
Copy link
Contributor Author

JukkaL commented Feb 18, 2022

I worry, however, that there's not much point in annotating a variable as Literal or Final if it's automatically inferred as something less precise as soon as it's assigned to a different variable.

Using Final in the assignment will propagate the literal value:

Y: Final = X

Since a variable with a literal type is effectively a constant, using Final seems logical to me. In Python all variables are mutable by default, so making something effectively final (i.e. literal) but not quite seems a bit unexpected to me. Using literal types is fairly rare, in my experience, so requiring an explicit annotation when something has a literal type seems more usable than requiring an int annotation when something is not a literal (int seems about 100x as common as all literal types, in one big codebase at least). "Make the common case easy and the uncommon case possible" seems to apply here, and it was an important principle when literal types were designed.

PEP 586 has some relevant discussion:

A common use case for explicit literal types is as function arguments, and using a Final variable works for this purpose:

X: Final = 5
f(X)  # X treated as Literal[5] as needed

Similarly, this is fine:

X: Final = 5
a: Literal[5, 6] = X

@sobolevn
Copy link
Member

Thanks for letting me know.

I think this is a perfect example of "soft literal" type that I've mentioned several times in different discussions.

So, basically we have two cases:

  1. When we have a "strict literal", like x: Literal[1] = 1. You can't reassign x to anything other than 1
  2. When we have a "soft literal", when value can be changed. Like x: Literal[1]; self.value = x; self.value = 2. I think, that this should not be an error at all

I know that @Akuli was interested in this as well.

@rchen152
Copy link
Collaborator

Just double-checking that I understand correctly: x: Final = 3 would mean that the type of x is Literal[3], but if we write y = x, the type of y will be int. This is to prevent false positives in mypy caused by assigning a name to multiple Literal values. Is that right?

If so:

  • No objections to allowing x: Final = 3 in stubs; I think it reads nicely. Changing pytype to not complain about this should be pretty straightforward, too.
  • However, I don't love the fact that it means something slightly different from x: Literal[3] = 3, and it's not obvious from the annotations which one is stricter than the other, so to speak. If anything, mypy's behavior with the Final annotation makes more sense to me, and I would want it to behave that way for Literal, too.

rchen152 added a commit to google/pytype that referenced this issue Feb 18, 2022
See python/typeshed#7258. While that issue proposes
that Final constants be handled differently from Literal ones, we generally
allow type mutations (unless a name is annotated), so it should be fine to just
convert Final to Literal.

PiperOrigin-RevId: 429642600
@erictraut
Copy link
Contributor

erictraut commented Feb 19, 2022

I've encountered cases where the changes from int to a literal type in stubs causes regressions for mypy users.

I'm not seeing any errors when I run mypy on these code samples. Are you saying that mypy infers that the type of self.level is Literal[20] in this case? That's not what I'm seeing.

Type stub files should be as unambiguous as possible. The statement x: Final = 3 tells a type checker that x is read-only, but it doesn't specify its type. A type checker must infer its type in this case. Since type inference rules are not specified in any PEP, this is ambiguous. It's probably safe to say that most (all?) Python type checkers will infer a type of Literal[3] in this case, but why take a chance? Explicit is better than implicit.

So I would recommend against using the x: Final = 3 form in any typeshed stubs.

x: Literal[3] # x is a read/write variable that can be assigned only a value of 3

x: Final[Literal[3]] # x is a read-only variable that has a value of 3

x: Final = 3 # x is a read-only variable that most (all?) type checkers will infer to have the type Final[Literal[3]]

Edit: reading this section of PEP 586 makes it pretty clear that x: Final = 3 should be interpreted the same as x: Final[Literal[3]], so I guess it is safe to assume that all type checkers will infer the same type in this case.

@erictraut
Copy link
Contributor

erictraut commented Feb 19, 2022

Pyright follows these rules consistently:

  1. If a symbol has a declared (annotated) type, its type is dictated by the annotation, and all assignments to that symbol will be validated against the declared type
  2. If a symbol doesn't have a declared type, its type is inferred based on the union of all assignments, but literals are widened to their corresponding type; (note: for class and instance variables, only assignments within the class implementation are considered)
  3. Within a local execution scope, the type of a symbol can be further narrowed based on assignments or type guards

Here's how these rules apply to the examples above:

import logging
class C:
    def __init__(self) -> None:
        reveal_type(self.level) # int
        self.level = logging.INFO
        reveal_type(self.level) # Literal[20] (narrowed via assignment)

    def enable_debug(self) -> None:
        reveal_type(self.level) # int
        self.level = logging.DEBUG
        reveal_type(self.level) # Literal[10] (narrowed via assignment)

c = C()
reveal_type(c.level) # int
c.level = 1
reveal_type(c.level) # Literal[1] (narrowed via assignment)
c.level = "hi" # Error because inferred type of `C.level` is `int`
FOO: Literal[1] = 1
x = FOO
reveal_type(x) # Literal[1] (narrowed via assignment)
x = 3 # This is not an error because the type of x was not declared
reveal_type(x) # Literal[3] (narrowed via assignment)
x = "hi" # This is not an error because the type of x was not declared
reveal_type(x) # Literal["hi"] (narrowed via assignment)

@sobolevn, I don't understand what you mean by a "soft literal". If you declare a variable with the type Literal[1], that means it can accept only values of Literal[1]. If it is meant to accept any int value, then it should be annotation as int.

@JukkaL
Copy link
Contributor Author

JukkaL commented Feb 21, 2022

The statement x: Final = 3 tells a type checker that x is read-only, but it doesn't specify its type. A type checker must infer its type in this case. Since type inference rules are not specified in any PEP, this is ambiguous.

Literal types are only supported for simple types such as int, bool, str and a few others. I assume that all type checkers can infer these types without problems. We could restrict the use of x: Final = y in stubs to cases where the right hand side is one of the literals supported by Literal.

I agree that this would a little inconsistent with how we define other things in stubs, but it would be consistent with how you'd define the variable in non-stub code. With Final, this looks nice to me:

A: Final = 'some-value
B: Final = A

If we'd use explicit literal types in non-stub code, this would look something like the following:

A: Final[Literal['some-value']] = 'some-value
B: Final[Literal['some-value']] = A

We had to duplicate the literal value three times. Even though this is more explicit, I much prefer the shorter version. If we'd support Final with an initializer in stubs, the stub and non-stub use cases would be consistent.

It's probably safe to say that most (all?) Python type checkers will infer a type of Literal[3] in this case, but why take a chance? Explicit is better than implicit.

Mypy doesn't infer Literal[3] as the type, as discussed above, but something a little different, as implied in PEP 586 (but the relevant section in the text is not super clear, I have to admit). PEP 586 was based on the implementation in mypy, so if something can be interpreted in multiple ways, the mypy interpretation seems implicitly like a valid one (though not necessarily the only one!). Most PEPs can't explain every detail -- that's one reason why it's good to have a prototype/reference implementation, as it can "fill in the gaps".

Edit: reading this section of PEP 586 makes it pretty clear that x: Final = 3 should be interpreted the same as x: Final[Literal[3]], so I guess it is safe to assume that all type checkers will infer the same type in this case.

Okay, that section apparently can be understood in multiple ways. Here the intention was that 'effectively literal' is different from just plain 'literal'.

The key part is this: "Specifically, given a variable or attribute assignment of the form var: Final = value where value is a valid parameter for Literal[...], type checkers should understand that var may be used in any context that expects a Literal[value].".

If this would be exactly the same as a literal type, the above passage would feel badly/confusingly written to me. Instead, it would be better written as e.g. "var: Final = value causes var to have the type Literal[value]". So it means that var can be used as a literal type if the context expects a literal type [but not necessarily in other contexts]. Actually, this is how mypy behaves. If you have var: Final = 1, var is treated as having the type int in non-literal contexts.

For something with a "real" literal type (i.e. not "effectively literal"), mypy treats literal types like any other type. It is propagated via type inference, etc. Mypy treats explicit literal types similarly to TypedDict types. The same constructor (e.g. string literal or dict expression) can generate values of multiple types, depending on the type context. Here's an example:

def f(a: Literal['x', 'y']) -> None:
    b = a  # b has type Literal['x', 'y'], propagated from rhs which has an explicit literal type
    c = 'x'  # c has type str, since no explicit annotation
    d: Literal['x', 'y'] = 'x'  # d has type Literal['x', 'y']

def g(a: SomeTypedDict) -> None:
    b = a  # b has type SomeTypedDict
    c = {'k': 5, ...}  # c has type dict[str, ...], since no explicit annotation
    d: SomeTypedDict = {'k': 5, ...}  # d has type SomeTypedDict

A type like Literal['x'] behaves consistently with a union of literal types in the above example, but I decided to use a union since the non-union case is used pretty rarely.

Anyway, if different type checkers have interpreted PEP 586 differently, it seems to me that we should avoid using literal types in stubs in contexts where their semantics aren't consistent, at least until we can figure out a convention that works for all major tools. Clearly part of the reason for the confusion is that PEP 586 was kind of vague and open to multiple interpretations, some of which were even explicitly allowed in the PEP. Perhaps we could narrow the semantics down bit, at least for stubs.

@JukkaL
Copy link
Contributor Author

JukkaL commented Feb 21, 2022

Another way to explain the mypy behavior is that explicit literal types are "sticky"/"strict" and don't get implicitly "weakened" into the underlying runtime type such as str. This allows precise type checking of some common use cases, such as using a union of literal types to represent Python 2 style enums:

# can achieve many things that real enums can do, but require some extra type annotations
EnumLike = Literal['x', 'y', 'z', ...]

In contrast, x: Final = y are "weak"/"leaky"/"soft" literals, since Final is used for many other things beyond literal types, and inferring a proper literal type for x would be too restrictive/inconvenient, if we also want to support "strict" literal types use cases (which we want).

The type Literal['x'] without a union is treated kind of like an enum with only one item -- i.e. it follows the same rules but is rarely useful, similarly how enums typically have multiple values.

@erictraut
Copy link
Contributor

Thanks for the additional details and background information, @JukkaL.

I think I understand what you mean by "soft" literals versus "real" literals, but that's an internal implementation detail of mypy that is not documented in any specification. Pyright doesn't use such a distinction, but it uses a variety of other heuristics to determine when a value should be interpreted as literal or widened.

Incidentally, I find some of mypy's behaviors regarding literals very non-intuitive. Thanks to your explanation above, I now better understand the reason for these behaviors, but I still find them confusing as a user. For example, the following code sample type checks without errors in pyright. And indeed, there are no type safety issues here, but mypy emits three (what I would consider false positive) errors.

from typing import Final, Literal

def requires_x(val: Literal["x"]): ...

a: Literal["x", "y"]
a = "x"
requires_x(a)  # Mypy: OK

b: Literal["x", "y"] = "x"
requires_x(b)  # Mypy: incompatible type

c: str
c = "x"
requires_x(c)  # Mypy: incompatible type

d = "x"
requires_x(d)  # Mypy: incompatible type

e: Final = "x"
requires_x(e)  # Mypy: OK

Anyway, if different type checkers have interpreted PEP 586 differently, it seems to me that we should avoid using literal types in stubs in contexts where their semantics aren't consistent, at least until we can figure out a convention that works for all major tools.

Yeah, that makes sense.

@JukkaL
Copy link
Contributor Author

JukkaL commented Feb 22, 2022

And indeed, there are no type safety issues here, but mypy emits three (what I would consider false positive) errors.

These are working as designed. The relevant rules are these:

(1) We don't infer literal types for variables if there is no explicit Literal annotation. This is consistent with how we don't infer TypedDict types unless there an explicit TypedDict annotation. We could perhaps infer a "soft" literal types more aggressively, but there hasn't been much demand for this feature. Users generally don't need to know about literal types to use mypy effectively (it's more of a niche/advanced feature).

Mypy type inference is quite conservative by design and doesn't even try to avoid these kinds of "false positives", since my earlier attempts with more powerful type inference (and experience with other languages with more complex type inference) resulted in friction from confusing error messages. I think that there is some similarity with the philosophy of Go, though clearly Go takes this much further.

(2) a: type = value does not narrow down the type of a to the type of value. This is a feature that we maintain to avoid a backward compatibility break. The feature has been supported from a long time (I bet it was available back in 2013), when we didn't have literal types, union types or other relatively recent additions.

The second feature allows type-safe upcasting in assignment to a more general type. This is sometimes a handy feature, and currently Python typing doesn't provide a clean way to do this, so mypy maintains this as an extension. If I want x to have type Sequence so that there some protection against appending to the value, I don't know how to do it in Python typing in a portable way.

x: Sequence[str] = ['x', 'y']  # Use Sequence to discourage mutation
alias = x  # alias also has type Sequence[str] to discourage mutation

It can also be used to infer a more general type for invariant containers (this is a legacy use case but changing this would break existing code):

x: object = 's'
a = [x]  # Inferred type is list[object], not list[str]

(I'm going to stop the discussion about the semantics of literal types here. This is going way off topic, sorry.)

@JukkaL
Copy link
Contributor Author

JukkaL commented Feb 22, 2022

Anyway, if different type checkers have interpreted PEP 586 differently, it seems to me that we should avoid using literal types in stubs in contexts where their semantics aren't consistent, at least until we can figure out a convention that works for all major tools.

Yeah, that makes sense.

Is everybody else on board with this?

To rephrase, if we agree with this, we'll stop adding literal types that don't behave consistently across type checkers (and can revert some existing uses).

We can seek a long-term solution that works for everybody elsewhere (we can refer to this discussion).

As far as I understand, using literal types in overloads poses no compatibility problems. The issue only affects module-level or class-level attributes with a literal type. Example:

FOO: Literal[20]

This would have to written like this:

FOO: int  # or Final[int]

@srittau
Copy link
Collaborator

srittau commented Feb 22, 2022

Anyway, if different type checkers have interpreted PEP 586 differently, it seems to me that we should avoid using literal types in stubs in contexts where their semantics aren't consistent, at least until we can figure out a convention that works for all major tools.

Yeah, that makes sense.

Is everybody else on board with this?

To rephrase, if we agree with this, we'll stop adding literal types that don't behave consistently across type checkers (and can revert some existing uses).

We can seek a long-term solution that works for everybody elsewhere (we can refer to this discussion).

From a typeshed perspective, I am fine with temporarily stopping new literals and temporarily reverting literals that prove problematic for mypy. But in the long term having literals is a clear win for typeshed as it allows type checking "enum-like" arguments and fields more precisely. There could be other uses, like improved documentation, as well. I don't particular care whether we type those as x: Literal[42] or x: Final = 42.

@srittau srittau changed the title Use of literal types in stubs cause new false positives Temporary moratorium on literal constants Feb 22, 2022
@srittau srittau pinned this issue Feb 22, 2022
@srittau
Copy link
Collaborator

srittau commented Feb 22, 2022

I wouldn't want to extend this moratorium past the end of the year (2022). I hope we'll find a solution until then. Otherwise I suggest that typeshed will continue to use the Literal[] syntax.

@srittau srittau changed the title Temporary moratorium on literal constants Temporary moratorium on literal constants (until the end of 2022) Feb 22, 2022
@JukkaL
Copy link
Contributor Author

JukkaL commented Feb 22, 2022

Technically, it would be easy to change mypy to support x: Literal[42] as an alias for x: Final = 42 in stubs, but I prefer the Final approach, since it's consistent with how this would be written outside stubs (and personally using the Literal annotation feels against the spirit of PEP 586).

JukkaL added a commit to JukkaL/typeshed that referenced this issue Feb 22, 2022
See python#7258 for an extended discussion. In summary, for mypy these
would be better written as follows, but this would not work with
other type checkers:
```
CRITICAL: Final = 50
```
srittau pushed a commit that referenced this issue Feb 22, 2022
See #7258 for an extended discussion. In summary, for mypy these
would be better written as follows, but this would not work with
other type checkers:
```
CRITICAL: Final = 50
```
@DevilXD
Copy link
Contributor

DevilXD commented Aug 24, 2022

As the author of #6610 who introduced these literals in question to typeshed, I figured I'd leave my thoughts on the matter here. It's kinda a shame I only stumbled upon this issue and discussion after 6 months since it started, but it's better late than never, especially since it's still open.

Personally, I agree with @srittau about typeshed eventually committing to the Literal syntax instead of int. The reason is quite simple - these values are logging constants that are never supposed to change, so them having an int type makes their type definition unnecessarily broad. Whether or not it's gonna be later done via Literal, or Final that gets translated to a Literal, it doesn't really matter to me personally.

Now, as far as the issue itself goes, the starting sentence of:

I've encountered cases where the changes from int to a literal type in stubs causes regressions for mypy users.

has immediately caught my attention, since I've personally provided a solution to this exact same problem that was described right under it, in the PR of mine. And I disagree with the:

could be quite non-obvious for non-expert users who aren't familiar with literal types

statement, as this really isn't that complicated. In fact, it follows one of the "common issues" MyPy documentation describes. Now I know a Literal isn't really a "supertype", but it can be treated as one in this exact context: https://mypy.readthedocs.io/en/stable/common_issues.html?highlight=invariance#declaring-a-supertype-as-variable-type Note that this paragraph is one of the shortest on the page, so there's shouldn't be much problem with understanding it.

Now, I know that MyPy isn't the only type checker typeshed provides the typing information for, but it'd only be a case of adding a similar explanation of this exact situation in other type checker documentations, in case it wouldn't be there already, possibly also mentioning that the same logic applies to Literals. If the user would like to avoid "weird" errors of having a variable with a literal type overwritten by another literal, they should use either a union of literals, or just int per the documentation (the prerequisites of existence of which I described above).

I think that sufficiently solves the problem already. If desirable, the inference logic could be improved to try constructing a union of literals, if it detects an unannotated variable being assigned a literal, and a different literal later.

@srittau
Copy link
Collaborator

srittau commented Nov 16, 2022

I haven't reread the whole thread, so I might be missing something from the discussion. But from what I understand, we can't really use literals for enum- or flag-like constants, because when assigning a literal to a variable, at least in mypy the variable now has a literal type that can't be changed later:

FLAG1: Literal[1]
FLAG2: Literal[2]
_Flag: TypeAlias = Literal[1, 2]

a = FLAG1  # type is now "Literal[1]"
a = FLAG2  # error

class Foo:
    flag = FLAG1

class Bar:
    flag  = FLAG2  # error

Would typing such constants in typeshed like this work?

_Flag: TypeAlias = Literal[1, 2]
X: Final[_Flag] = 1
Y: Final[_Flag] = 2

As far as I understand, this would automatically get us "enum-like" type checking automatically. What would probably not work at the moment is code like this, although type checkers could probably make it work when they would special case the idiom above:

def foo(x: Literal[1]): ...  # we only accept a specific set of flags
foo(X)

@DevilXD
Copy link
Contributor

DevilXD commented Nov 16, 2022

we can't really use literals for enum- or flag-like constants, because when assigning a literal to a variable, at least in mypy the variable now has a literal type that can't be changed later

@srittau You can though, and I kinda explained this twice already, not sure why it's still unclear. Here's a practical explanation using your example:
obraz

There is something to be said about how MyPy infers the type without an annotation, forcing only the first literal it gets assigned to, and not a Literal[1, 2] - this is mostly what this thread is about. The final type being int from my solution isn't a union of said literals, but it surely does it's job at telling MyPy that this variable is okay to change later on. If you'd want to be really specific about it, you'd have to specify the union of literals yourself, but if you think about it and consider the fact that those literals are currently defined as an int already, you'll see that making such a change has no real impact, other than maybe requiring the user to add some explicit int annotations in affected places - that's it.

This discussion also seems to argue about whether or not one should use x: Literal[42] = 42 or x: Final = 42 for typeshed and stub annotations, in places that have them defined as int right now, such as the logging module:

CRITICAL: int
FATAL: int
ERROR: int
WARNING: int
WARN: int
INFO: int
DEBUG: int
NOTSET: int

As far as the semantics go, either of the two translates to a literal value to me, so it's really whatever as long as it's usable as a literal in the end. The supposed problem arises from the fact that people would often assign those literals to a variable, which would infer the literal type, and then throw an error upon a reassignment attempt, like so:

import logging

verbose = False  # read from somewhere
log_level = logging.ERROR
if verbose:
    log_level = logging.INFO  # error

My PR which added Literals as the logging levels, was reverted because "it was giving false positives", at least according to the very first comment on this issue, despite me clearly providing a solution of "just slapping" an int annotation on the affected variables and calling it a day. Those were, as I've mentioned, already defined as int's before and them being int's now gains and losses exactly nothing, while more diligent people could actually use those literal types being defined properly from them on - that's the main reason I made that PR in the first place. But it's been completely disregarded and I sort of can't understand why it's been reverted and made into such a big deal. Is there a place where adding an int annotation wouldn't solve the false positives, or am I missing something?

If this literal inference issue is really so bad to require the user to add an explicit annotation to, MyPy could change the inference logic to using int or a union of literals automatically when that happens - int would work just fine for beginner typed code IMO.

@Avasam
Copy link
Sponsor Collaborator

Avasam commented Dec 31, 2022

Happy new year! With the end of 2022, what is the status on this? If I can specify the value of constants in the stubs I've contributed, I'd like to do it, and do it right :)

@JukkaL
Copy link
Contributor Author

JukkaL commented Jan 3, 2023

My preference is that literal constants are only/mainly used in stubs when the value of the literal is documented or the value is otherwise semantically significant somehow. #9367 has some context. Otherwise we risk unnecessarily leaking implementation details in the stub, which is intended as a description of the (public) interface of a module.

If we want to expose the value of a particular constant, I propose that we standardize on a Final declaration with an initializer but no explicit type, like this:

# This would work identically in both a stub and the implementation
PY3: Final = True

It should be easy to add support for this, even If some type checkers don't support this currently in stubs. Mypy already supports this, and PY3 above would be treated as "effectively literal" with the value True. I think other type checkers would also have no issues with type inference if they follow this section in PEP 586: https://peps.python.org/pep-0586/#interactions-with-final

My reasoning is that it's better for the stub definition to be similar to the implementation. Writing the definition like this outside a stub would look out of place and redundant to me:

PY3: Literal[True] = True

I think that this is analogous to function definitions. We don't declare a function like this in a stub:

func: Callable[[int], None]

Instead the stub looks similar to the actual definition, even if this is sometimes more verbose than the prior option:

def func(__x: int) -> None: ...

I believe that adding type aliases that map to literal unions and using them as "fake enums" is a clever idea but ultimately too confusing for end users to be adopted as a best practice. I'm thinking of something like this, as suggested by @srittau above:

_MyType = Literal[1, 2, 3]
CONST1: Final[_MyType] = 1
CONST2: Final[_MyType] = 2

I discussed this in #9367, but here are my main arguments:

  • Using the alias requires the use of if TYPE_CHECKING, since it doesn't exist at runtime, and this looks kind of ugly and is confusing for new users.
  • The alias isn't easily discoverable, as it's only defined in the stub and not documented in library documentation.
  • The alias may expose internal details the might change in the future (the values of constants).
  • The alias looks like an internal definition, since the convention is to prefix them with an underscore.

Using a real enum avoids all the issues and I think that we should recommend that libraries switch to enums instead (e.g. IntEnum). The non-enum literal constant use case is mostly important for legacy code, and over time the need should diminish.

@Avasam
Copy link
Sponsor Collaborator

Avasam commented May 2, 2023

Is there anything left to do or discuss here? We seem to have been specifying literal constants for a while now.

@srittau srittau unpinned this issue Jul 13, 2023
@srittau
Copy link
Collaborator

srittau commented Jul 13, 2023

Is there anything left to do or discuss here? We seem to have been specifying literal constants for a while now.

Indeed, the moratorium is over.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
project: policy Organization of the typeshed project
Projects
None yet
Development

No branches or pull requests

8 participants