PEP 661: Sentinel Values

7 Likes

I’m confused about the type checking motivation – isn’t the natural solution to do typing.Literal[MySentinel]? Doesn’t your example code end up giving the type an obscure name that would look weird in type signatures?

BTW, here’s a kind of “stupid python trick”, but it is convenient in some ways. You can make a sentinel whose type is itself:

class _SentinelBase(type):
    def __new__(self):
        raise TypeError(f"{self!r} is not callable")

    def __repr__(self):
        return self.__name__

def sentinel(name):
    cls = type.__new__(_SentinelBase, name, (_SentinelBase,), {})
    cls.__class__ = cls
    return cls

Example:

>>> MISSING = sentinel("MISSING")

>>> MISSING                                                                           
MISSING

# You can dispatch by type, e.g. using functools.singledispatch:
>>> isinstance(MISSING, MISSING)                                                      
True

And you get to use MISSING and Literal[MISSING] interchangeably in type annotations!

I blame Dave for inspiring this idea. This code is provided AS IS, WITHOUT WARRANTY OF ANY KIND, INCLUDING THE IMPLIED WARRANTY OF NOT GIVING YOU A HEADACHE WHILE READING IT.

9 Likes

It would be good to see an example in the PEP where a sentinel type is included in a type hint.

6 Likes

It is, except it does not work. According to PEP 586:

Literal may be parameterized with literal ints, byte and unicode strings, bools, Enum values and None.

And “any other types” are invalid.

1 Like

Well, we can change that :slight_smile: A PEP such as this can suggest expanding that list to include sentinel values, especially considering that it already supports Enum values (which I’d previously missed!).

1 Like

Yes! I missed that it is already made to support Enum values. I’ll take a look and see if it would be reasonable to expand that to sentinel values.

Yes, that’s the major thing I still would like to improve in the implementation. (There are other ways to achieve this, e.g. a meta-class with a custom repr.)

Oh no!

:rofl:

1 Like

Actually, it seems that was only mentioned in the PEP, but I can’t see any mention of that anywhere in the codebase. The docs say “At runtime, an arbitrary value is allowed as type argument to Literal[...] , but type checkers may impose restrictions.” So I guess this could be the suggested approach, depending on whether this could be made to work with type checkers…

1 Like

Thanks for writing the PEP! I’ve really wanted something like this for bidict. Validating to see a lot of the patterns here that I reinvented independently while improving my sentinel implementation of the years (e.g. better reprs and static type-check’ability):

Looks like what’s currently proposed in the PEP would cover this use case perfectly.

Thanks again!

1 Like

Just a small observation, but no real opinion one way or the other. In my quick grep, probably using an inadequate regex pattern, I find only typing.py previously uses type annotations within the standard library in 3.10. The reference implementation of sentinel.py has full type annotations (in the repo, not in this thread).

Is this good? Bad? Neutral?

Later: Looking more closely, typing.py does have some type annotations, but even there only occasionally. Most of what I found in my first search were false positives against doctstrings.

1 Like

In case the PEP is accepted, I expect that whether to include typing annotations would be sorted out when finalizing the implementation.

3 Likes

Just a quick sidenote: Maybe its already in work given the timing on this, but it would be helpful to link this discussion in the PEP, preferably in the Discussions-To field set aside for that purpose. I had to go looking for it to find it. Thanks!

I’ve searched through the PEP, the previous discussion and this one, and I found it interesting that, as far as I can tell, nobody mentioned the already existing sentinel mechanism in the stdlib: unittest.mock.sentinel

Usage semantics:

>>> from unittest.mock import sentinel
>>> sentinel.thing
sentinel.thing
>>> str(sentinel.thing)
'sentinel.thing'
>>> repr(sentinel.thing)
'sentinel.thing'
>>> type(sentinel.thing)
<class 'unittest.mock._SentinelObject'>
>>> sentinel.thing is sentinel.thing
True

Now I’m not saying that this fulfills the aim of the PEP, eg. the mock.sentinel object probably isn’t used in type annotation very often - but it should probably be mentioned in the PEP.

5 Likes

Indeed, and it’s also missing from @vstinner’s list of sentinels in the stdlib (referenced in the PEP). Thanks for bringing this up!

I find this note from the docs to be important in this context:

Changed in version 3.7: The sentinel attributes now preserve their identity when they are copied or pickled.

This supports the notion that supporting copying and pickling is significant, and that it is often initially overlooked in many re-implementations of sentinel objects.

I don’t think type annotations has to be tied to the implementation. FWIW, last I checked (and this may be horribly outdated information), mypy doesn’t derive type information from the standard library’s source code directly, but looks at stubs in typeshed. typeshed[1] is universally supported by other type checkers, so type information can always be added there instead of tying them to the stdlib’s source. There’s also some previous discussions (with no consensus) on whether to add type hints to the stdlib[2].

Anyways, I hope I didn’t come off as bike shedding, just wanted to say that I think you don’t have to decide this when finalizing the implementation either - there are other ways :). And once again thanks for bringing this PEP to life.

[1] typeshed/stdlib at master · python/typeshed · GitHub
[2] Type annotations in the standard library

I’ve added a “Discussions-To” header to the PEP as suggested.

1 Like

It was a good comment, and indeed the implementation going into the stdlib won’t include the type annotations.

One minor thought: Would it make sense to have a common base class for on-the-fly sentinel classes? This would make it possible to detect arbitrary sentinels at runtime, but it could also possibly aid type checking.

Let’s assume that type annotations will be taken care of nicely (I’m still researching the possibilities for that). Are there any other concrete use-cases for having a common base class, that would make having one a significant advantage?

Are there any other concrete use-cases for having a common base class, that would make having one a significant advantage?

I have to admit that I don’t have concrete use cases in mind, only vague ideas about testing tools that would detect sentinels passed to functions. Or a generic function like this:

def assert_arg_type(argname, value, expected_type):
    if not isinstance(value, expected_type) and not isinstance(value, Sentinel):
        raise TypeError(f"argument '{argname}' has wrong type '{type(value)}'")

For the typing case, having a Sentinel base class could be used to mark the return type of sentinel() or custom sentinel factory functions:

def my_lib_sentinel(name: str) -> Sentinel:
    return sentinel(f"My Lib: {name}")

Given that these are all rather vague ideas, and I’m not sure about their usefulness, I don’t feel strongly about it. That said, considering the dynamic nature of Python, I think it would be shame if experimenting with this wasn’t possible.

1 Like

Isn’t the whole point of using a sentinel that nobody should be using it for anything outside of the specific use as a marker that it was created for?