PEP 661: Sentinel Values

Perhaps as a loosely related tangent, it would be great if Ellipsis worked in match/case statements. Since it already supports copying and pickling, that seems like a robust implementation for sentinel values.

PS: Never mind, it’s just the literal that doesn’t match. So why not make the sentinel work exactly like the one sentinel value that has been part of the standard for a long time, including MySentinelType = type(MySentinel) for type hints?

I tried, it does work in a match statement. However, if you only use

case Quit:

you get

Syntax error: name capture 'Quit' makes remaining patterns unreachable

This has nothing to do with the specific implementation. If the sentinel is in another module, and you write:

case mymodule.Quit:

and it works fine.

This might be a reason to add another hack, and to make Quit() return Quit (the sentinel itself), instead of forbidding it. Then you would be able to write case Quit(): and get the desired behavior.

I really don’t like it. It’s very verbose, and doesn’t add any information about what a function actually gets or returns. I think that it’s very natural to have other sentinels behave like the one sentinel everyone is always using.

(You may even say that it’s consistent. ABCs allow isinstance(x, T) to return True even if type(x) is not really a subclass of T, and it’s allowed to use T in a type signature and return x. I just used the existing mechanism to make Quit an abstract base class, that happens to be a “fake base class” of its metaclass, So, according to the current rules, there should be no problem in specifying that a function returns an object of type Quit and returning the object Quit, since Quit is an “abstract instance” of the class Quit :thinking:)

Sure! The question was meant as a preface to my (sometimes implied) suggestions :‍)

I agree with that. I was thinking about raising an exception when someone attempts to pickle a non-global sentinel.

This is API intended for library maintainers, who are creating their own APIs and want those to be pretty. IMO, it’s appropriate to lean towards more explicit (but verbose) API than usual.

Also, consider that Flask, a framework that tends to avoid boilerplate code, makes you specify the module name:

from flask import Flask
app = Flask(__name__)

… and I don’t think people find it overly complex to learn.

Yup. But the PEP needs to acknowledge it, and you should probably get the maintainer of sentinels on PyPI to agree with breaking their project.

The first is not surprising to me case int: does the same. The latter however is surprising. I think I still don’t fully grasp the match-case and why types need to be called in the case for it work. It would be nice if not needed, especially for Sentinels

My thoughts-

  1. Literal is better because it makes sense to do Literal[1, 2, MISSING]
  2. Type checkers should treat MISSING as Literal[MISSING] so both forms are valid. (but if this is not accepted, just Literal[MISSING] is more accurate)
3 Likes

Also, similar to a potential new single sentinel value, Ellipsis can’t be as confidently used in all cases, unlike a dedicated, distinct value.

Could you elaborate?

The “confidently” bit specifically trips me up somehow…

Suggestion unrelated to the above.

Please consider typing… would every use have to be annotated as foo: str|Sentinel = Sentinel("foo") ?

I’m currently trying to use ad hoc sentinel values in FastAPI, where the type implies coercion (in the simple syntax like above), so that foo: str = "" not only has the default empty string but will convert the input to a string, foo: int = 10 would try to parse input as int, and foo: str|EllipsisType = ... outright fails, because pydantic [roughly speaking] knows that it can’t coerce input to ellpisis (class) and/or won’t be able to pick the type in this union.

Maybe what I’m thinking is too much for this PEP, or just plain wrong/outdated, but somehow I would imagine being able to create sentinel objects that pass as objects of specific type, like a sentinel int, sentinel str, etc.

You could have an approach similar to Optional and None, where foo: str = None is implicitly equal to foo: str | None = None (or foo: Optional[str] = None on older Pythons). So with a sentinel foo: str = Sentinel("foo") would implicitly mean foo: str | Sentinel = Sentinel("foo").

But as I understand it the explicit form is more widely preferred over the implicit one.

Not anymore.
PEP 484 now states:

A past version of this PEP allowed type checkers to assume an optional type when the default value is None, as in this code:
def handle_employee(e: Employee = None): ...
This would have been treated as equivalent to:
def handle_employee(e: Optional[Employee] = None) -> None: ...
This is no longer the recommended behavior. Type checkers should move towards requiring the optional type to be made explicit.

MyPy implemented that as default, with a command line switch for the old behaviour.

FWIW all of attrs, dataclasses, and Pydantic share the problem of possible incompatibility between the type of the instance attribute, the type of the init parameter, and attribute/field metadata declaration. Pydantic (at least in 1.x, not sure about the new version) very strongly prefers to ensure the correctness of the instance attribute annotation above all else, deliberately allowing much more flexibility in what you can pass to the init method. The other two take the approach that the init parameter and instance attribute types should match, and that conversion should should be the responsibility of a different tool. Maybe that helps guide the reasoning here.