typing icon indicating copy to clipboard operation
typing copied to clipboard

Literal for sentinel values

Open srittau opened this issue 5 years ago • 20 comments

This came up python/typeshed#3521: Currently I can't think of a way to type sentinel values that are often constructed by allowing a certain instance of object as the argument. For the example above it would be useful to be able to do something like this:

class PSS(AsymmetricPadding):
    MAX_LENGTH: ClassVar[object]
    def __init__(self, mgf: MGF1, salt_length: Union[int, Literal[MAX_LENGTH]]) -> None: ...

Alternatively we could add a type like Singleton type to typing:

class PSS(AsymmetricPadding):
    MAX_LENGTH: Singleton
    def __init__(self, mgf: MGF1, salt_length: Union[int, MAX_LENGTH]) -> None: ...

srittau avatar Dec 03 '19 12:12 srittau

It would be nice if this could be written using Final and Literal:

# Does not work (yet)
A: Final = object()
def f(a: Literal[A]): ...
f(A)

If you're willing to rewrite the code some more (beyond adding type annotations), you can usually solve this with an enum, since enums are allowed in Literal:

# This does work

class AA(Enum):
    A = 0

A: Final = AA.A

def f(a: Literal[AA.A]):
    ...

f(A)

(Alas, I haven't found a way to alias A = AA.A and be able to write Literal[A].)

gvanrossum avatar Dec 04 '19 00:12 gvanrossum

You can use AA as the type instead of Listeral[AA.A]:

...

A: Final = AA.A

def f(a: AA):
    ...

f(A)

This mypy issue has related discussion: python/mypy#7642

JukkaL avatar Dec 04 '19 10:12 JukkaL

Yeah it would be good to have some special-casing for ad-hoc sentinels. One of the main downsides I see for enum solution is that error messages if making a stub for existing library (as in original message) can be cryptic.

ilevkivskyi avatar Dec 04 '19 12:12 ilevkivskyi

What about having a naming convention for sentinel enums in stubs? Maybe something like this:

class _Sentinel(Enum):
    _SENTINEL = 0

This way even if the type leaks out of the stub, at least there is a hint about what it means, and the underscore prefix suggests that this is something internal to the module/stub.

JukkaL avatar Dec 04 '19 14:12 JukkaL

What about having a naming convention for sentinel enums in stubs?

Yes, I was also thinking about this as a "quick fix".

ilevkivskyi avatar Dec 04 '19 14:12 ilevkivskyi

Good idea for a quick fix.

srittau avatar Dec 04 '19 15:12 srittau

Speaking about adding embedded annotations (not type stubs) I can say that I like @gvanrossum proposal (a combination of Final and Literal) very much. It naturally reflects what is going on in the Python runtime.

asvetlov avatar Nov 24 '20 12:11 asvetlov

Here's another possible solution using NewType.

_MaxLengthSentinel = NewType("_MaxLengthSentinel", object)

class PSS(AsymmetricPadding):
    MAX_LENGTH: _MaxLengthSentinel

    def __init__(self, mgf: MGF1, salt_length: Union[int, _MaxLengthSentinel]) -> None:
        ...

By naming the sentinel with an underscore, it will be considered private, so the only way to get to its instance is through the MAX_LENGTH class variable.

This solution appears to work fine with existing standards and type checker implementations.

erictraut avatar Nov 24 '20 17:11 erictraut

The recipe with NewType has flaws:

def f(arg: Union[str, SENTINEL_TYPE) -> None:
    if arg is SENTINEL:
      arg = "default"
    reveal_type(arg)  # the type is still union

is doesn't remove SENTINEL type as arg is None does in mypy.

asvetlov avatar Nov 25 '20 15:11 asvetlov

Yeah, the implementation would need to include an additional check, something like this:

    def f(arg: Union[str, SENTINEL_TYPE]) -> None:
        if arg is SENTINEL:
            arg = "default"
        else:
            assert isinstance(arg, str)
        reveal_type(arg)

erictraut avatar Nov 25 '20 15:11 erictraut

Related: PEP 661 -- Sentinel Values

srittau avatar Nov 04 '21 12:11 srittau

Related: PEP 661 -- Sentinel Values

If I read this pep correctly it would not cover the classic usage of sentinel flags, that are checked with is Implementing the example given using the classic is check

def foo(value: int | Sentinel = MISSING) -> int:
  if value is MISSING:
    return 0
  else:
    return value + 1

would fail type checkers without special support Sentinels them since value would still have type int | Sentinel

CaselIT avatar Sep 14 '22 08:09 CaselIT

In the Discourse thread the author said that they've "decided to forego type signatures specific to each sentinel". Could you please comment there if you believe is comparisons are vital?

layday avatar Sep 14 '22 09:09 layday

Thanks for pointing to the discussion

As per https://discuss.python.org/t/pep-661-sentinel-values/9126/44 the proposed solution seem to implement Literal[MISSING]. So the Pep text seems outdated. That's unless I haven't missed anything in the discussion

The above function would be typed as def foo(value: int | Literal[MISSING]= MISSING) -> int: making is work with type checkers

CaselIT avatar Sep 14 '22 11:09 CaselIT

No, the latest version of the PEP does not support sentinel literals. See https://github.com/taleinat/python-stdlib-sentinels/blob/main/pep-0661.rst#specific-type-signatures-for-each-sentinel-value.

layday avatar Sep 14 '22 11:09 layday

Understood. That's a shame, since it makes Sentinel use very cumbersome when using type checkers

CaselIT avatar Sep 14 '22 11:09 CaselIT

Understood. That's a shame, since it makes Sentinel use very cumbersome when using type checkers

I think it was probably the correct decision for the PEP, since the PEP wasn't primarily typing-focused. I don't think this rules out the possibility of adding special-casing to type checkers in the future to better handle PEP 661 sentinel values. That could always be proposed in a future PEP.

AlexWaygood avatar Sep 14 '22 11:09 AlexWaygood

Sure, but at that point I don't understand what's the point of adding Sentinel. If we don't care about type checking support I think the classic flag = object() (or the flag = type('flag', (), {})() works fine enough

CaselIT avatar Sep 14 '22 12:09 CaselIT

Sure, but at that point I don't understand what's the point of adding Signature. If we don't care about type checking support I think the classic flag = object() (or the flag = type('flag', (), {})() works fine enough

(The discuss.python.org thread is the better place to discuss that, and I see you've already posted there :)

AlexWaygood avatar Sep 14 '22 12:09 AlexWaygood

I needed to add type hints to a function with a default sentinel object idiom, i.e.

RAISE_ERROR = object()
def get_something(name, default=RAISE_ERROR):
    ...

I solved it by using @overload:

RAISE_ERROR = object()

import typing

@typing.overload
def get_something(name: str) -> str: ...

@typing.overload
def get_something(name: str, default: str) -> str: ...

def get_something(name, default=RAISE_ERROR):
    ...

It doesn't type check the function body but this was fine in my case since the function itself was trivial and I was mostly interested in adding type hints to the signature!

pelme avatar Nov 17 '22 11:11 pelme