I think evaluating as truthy as the default is probably correct given that it’s supposed to be a replacement for existing patterns such as _sentinel = object() where the sentinel currently does evaluate as truthy.
The PEP does note:
Custom boolean behavior may be considered later if the added API and typing complexity is judged worthwhile.
I’m slightly concerned that not being able to customize the __repr__ is more of an issue than initially thought as the ‘picklability’ of sentinels depends on the name meaning potentially choosing between a changed/worse __repr__ or an unpicklable sentinel (or continuing not to use the builtin sentinels). This has already come up in replacing some of the stdlib sentinels with the new builtin.
>> x=sentinel("x")
>>> x.__bool__ = lambda: False
Traceback (most recent call last):
File "<python-input-1>", line 1, in <module>
x.__bool__ = lambda: False
^^^^^^^^^^
AttributeError: 'sentinel' object has no attribute '__bool__' and no __dict__ for setting new attributes. Did you mean '.__copy__' instead of '.__bool__'?
But even I you could, it wouln’t work because dunder functions are checked on the class (type), not on the instance:
Right it’s slotted. Why is it slotted though? As I understand the design tradeoffs, slots offer better memory performance at the cost of worse flexibility. By design, there should only ever be 1 instance of a sentinal. Therefore I’d think the tradeoff ways heavily in favour of using the __dict__ instead of slots.
@tstefan I see I could have checked with a custom class. I know something in this direction is possible because I’ve done it once, but I clearly don’t remember the details of how to implement it.
I tried this with the reference implementation of the PEP, and this two-liner does technically work
X = sentinel("X")
sentinel.__bool__ = lambda o: o is not X
but I can’t think of a situation where I’d actually do this with sentinel. You need sentinels in libraries, and I only use this kind of ‘clever’ hacks in scripts.
If sentinel had a __dict__, I’d probably still hack it together using
import sys
class sentinel:
"""Unique sentinel values."""
# __slots__ = ("__name__", "_module_name")
def __init_subclass__(cls):
raise TypeError("type 'sentinel' is not an acceptable base type")
def __init__(self, name, /):
if not isinstance(name, str):
raise TypeError("sentinel name must be a string")
self.__name__ = name
self._module_name = sys._getframemodulename(1)
@property
def __module__(self):
return self._module_name
def __repr__(self):
return self.__name__
def __reduce__(self):
return self.__name__
def __copy__(self):
return self
def __deepcopy__(self, memo):
return self
def __or__(self, other):
return typing.Union[self, other]
def __ror__(self, other):
return typing.Union[other, self]
sentinel.__bool__ = lambda o: getattr(o, "_bool", True)
X = sentinel("X")
X._bool = False
Y = sentinel("Y")
assert (X or Y) is (Y or X) is Y
but with the current implementation of sentinel I’d need a factory function to make it work well, and at that point it’s just not worth it.
Better to just copy the sentinel class definition into utils.py and modify it there
I did implement this kind of thing in full, but I didn’t think there was enough of a reason to upload it. Lack of type-checking support will mean that bool configuration is unlikely to ever completely work, so it’s only really for compatibility with projects which thought that making sentinels falsy was a good idea.
# Standard syntax
UNKNOWN = sentinel("UNKNOWN", repr="?", bool=NotImplemented)
# The following is an alternative syntax of the above, works better for type-checkers
class MISSING(sentinel):
# Same as parameters to standard syntax
repr = "<MISSING>"
bool = False
Implementation
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Never
if TYPE_CHECKING:
import builtins
from types import NotImplementedType
__all__ = ("sentinel",)
class _SentinelMeta(type):
repr: str
bool: builtins.bool | NotImplementedType
def __instancecheck__(cls, instance: object) -> builtins.bool:
"""Return True when compared with itself.
>>> isinstance(sentinel, sentinel)
True
>>> FOO = sentinel("FOO")
>>> isinstance(FOO, FOO)
True
>>> isinstance(FOO, sentinel)
False
"""
return cls is instance
def __repr__(cls) -> str:
"""Return a declared sentinel repr value.
>>> sentinel
<class 'strict_sentinel.sentinel'>
>>> sentinel("FOO")
FOO
>>> sentinel("FOO", repr="<foo>")
<foo>
"""
if cls is sentinel:
return super().__repr__()
return cls.repr
def __bool__(cls) -> builtins.bool:
"""Return a declared sentinel truthiness.
>>> bool(sentinel)
True
>>> bool(sentinel("FOO", bool=True))
True
>>> bool(sentinel("FOO", bool=False))
False
>>> bool(sentinel("FOO"))
Traceback (most recent call last):
...
TypeError: Sentinel FOO has no truthiness value
Note: sentinel('FOO', bool=True) can be used to define a sentinels truthiness
"""
if cls is sentinel:
return True
if cls.bool is NotImplemented:
exc = TypeError(f"Sentinel {cls!r} has no truthiness value")
exc.add_note(f"Note: sentinel({cls.__name__!r}, bool=True) can be used to define a sentinels truthiness")
raise exc
return cls.bool
def _stubbed_call(cls: type[sentinel], *_args: object, **_kwargs: object) -> Never:
"""Suppress __new__ on defined sentinels.
>>> sentinel("FOO")()
Traceback (most recent call last):
...
TypeError: Sentinel FOO is not callable
"""
msg = f"Sentinel {cls!r} is not callable"
raise TypeError(msg)
class _sentinel:
def __init__(self, *_args: object, **_kwargs: object) -> None:
pass
class sentinel(metaclass=_SentinelMeta): # noqa: N801
"""A sentinel type.
>>> DEFAULT = sentinel("DEFAULT")
>>> UNKNOWN = sentinel("UNKNOWN", repr="?")
>>> MISSING = sentinel("MISSING", bool=False)
>>> FOUND = sentinel("FOUND", bool=True)
>>> class MyClass:
... FOO = sentinel("MyClass.FOO")
"""
def __new__( # type: ignore[misc] # Force abnormal return type
cls,
name: str,
module_name: str | None = None,
*,
repr: str | None = None, # noqa: A002
bool: bool | NotImplementedType = NotImplemented, # noqa: A002
) -> type[_sentinel]:
"""Return a new sentinel type."""
module_name = module_name if module_name is not None else sys._getframemodulename(1) or __name__ # noqa: SLF001
return type( # type: ignore[misc, unused-ignore]
name,
(cls,),
{
"__new__": _stubbed_call,
"__module__": module_name,
"repr": repr if repr is not None else name,
"bool": bool,
},
)
I would be willing to support the UNKNOWN = sentinel("UNKNOWN", repr="?", bool=NotImplemented). The attributes would be read-only though and __bool__ shall be a constant. As for the __repr__ I agree that it could be better just for representation purposes (having <something> may look nicer than mymodule.mysentinel). We do not need to make it subclassable for now IMO and would prefer extending sentinel.__new__ instead.
The current representation defaults to mysentinel instead of mymodule.mysentinel. Might be confusing if multiple projects have sentinels with the same name, but Python enums already don’t include their own module.
The sentinel refactors to the standard library gave the impression that repr= might be revisited, but I assume boolean customization is still settled and won’t be added. As far as I know the stdlib never abused truthiness which in my opinion was always the right call. Boolean customization is mainly relevant for third-party libraries which abused truthiness to categorize their sentinels with other falsy types. My use of bool= was for a speculative compatibility-focused implementation rather than a suggestion for official sentinels.
We clashed over this a little last year as well. I feel the need to say again that __bool__ is part of the language. I find calling use of it “abuse” grating, as someone who has used that feature for many years.
The following is not very fancy Python. I find it difficult to call the use of __bool__ here anything other than “ordinary”.
class MissingType:
def __init__(self) -> None:
if "MISSING" in globals():
raise TypeError("MissingType should not be instantiated")
def __bool__(self) -> bool:
return False
def __copy__(self) -> MissingType:
return self
def __deepcopy__(self, memo: dict[int, t.Any]) -> MissingType:
return self
def __reduce__(self) -> str:
return "MISSING"
def __repr__(self) -> str:
return "<mylib.MISSING>"
MISSING = MissingType()
I’m not asking you to share my taste on this, but please stop insinuating that defining the truthiness in a class I control is somehow a weird thing to do. I find numpy’s decision to raise when objects are bool-ed to be far more surprising, but I’m not heaping abuse on them for the choice – their library, their use-cases, their decision.
As Jelle is the current torch bearer for this PEP, that’s who I would try to convince if there’s to be any further change on this topic. And to quote, at least as of last fall:
I agree, allowing sentinels which raise if they’re bool-checked is starting to feel like complexity creep.
TBH, if I have to choose between
getting bool=False, but it comes with bool=NotImplemented
not getting either
I prefer option (2). I can adapt to truthy-only sentinels. It’s worth that cost for us to have a simpler model for builtin sentinels in the language into the future.
On a totally separate note, huge congrats to @taleinat and @Jelle on getting this landed; it’s been quite a saga! Everyone I’ve shared this with has been very happy and excited.
That use of __bool__ was ordinary in the past, but now it’s wrong because it ignores modern type checking. You must at least narrow the return type for __bool__ to be Literal[False]:
To be clear, whenever I impulsively say “abusing customization of bool” I specifically mean the following:
New types using their own truthiness as a sorting category while also being mixed with other unknown types. There is a reason that if x is not None: is common, thus the example MissingType is typically checked with x is MISSING or isinstance(x, MissingType) rather than if x:which is long known to cause insidious issues with generic unions of types. If you break this rule then it should be for a well-documented reason rather than laziness or inertia. The dangerous allure of if x: is why I’ve previously suggested banning truthiness from sentinels.
Types with constant truthiness which are not detected by a type checker, return type hints must be Literal for these otherwise one has made a runtime-only pattern.
Types which disallow truth tests which are not caught by a type checker. A “not allowed to call this function return type” is still being discussed and I expect Numpy to use them once they’re added. Until then these issues are annoyingly silent until runtime.
Breaking these rules outside of a library might be okay if the only reviewer is oneself but as a library author I always default to the context of writing a library and having high standards for the API. When I see MissingType I think about how an empty string referring to the working directory would have the same truthiness as it as well as other worst case scenarios, but you’re likely to be using MissingType in a less speculative environment.
I was curious about the general approval of these options. Otherwise it has just been the most passionate of us talking back and forth. It might be a bit late but I’d like to setup a poll regardless.
The following are all approval voting polls, so pick every option you can live with.
How should sentinel truthiness be handled?
Only returns True
Only returns False
Always raises an error
Customizable truthiness, default is True
Customizable truthiness, default is False
Customizable truthiness, default raises
0voters
How should sentinel repr be handed?
Default repr should be “name”
Default repr should be “module.name”
Repr should be customizable
0voters
How should sentinel truthiness exceptions be handled?
Never raise errors on __bool__
No customization, only raise with a standard message
Allow custom exceptions derived from TypeError (or whichever exception type is the default)
Add a repr= keyword argument to control the repr(). Many existing sentinels have a custom repr of a form like <missing>, and this allows us to convert these sentinels. For Python code this would look like X = sentinel("X", repr="<X>"); in C I add a new third parameter to PySentinel_New(name, module, repr)
Make the __module__ attribute writable. Most similar __module__ attributes in the stdlib (classes, functions, TypeVars, as of 3.15 type aliases) are already writable, and this helps people who do slightly unusual things like defining sentinels in exec() or private submodules.
I remain uninterested in allowing customization of bool, though I acknowledge that lots of people feel they need it; the fact is that virtually no sentinel in the stdlib needs it (an exception is the private pathlib._STAT_RESULT_ERROR).
Another possible addition would be a doc= kwarg to allow setting a docstring on the sentinel; a few stdlib sentinels have this but it doesn’t seem critical.
Why make __module__ editable instead of adding an optional positional module parameter to Python sentinel definitions? A modifiable __module__ appears to break the general rule of no modification or redefinition of sentinels.
Would you consider changing the default repr to “module.name”? I’ve noticed people assuming that the repr includes the module when that isn’t the case, and “module.name” was slightly more popular on the poll. A customizable repr satisfies those who want the alternative variant.
I think doc= is unnecessary. Most tools recognize docstrings on names so moving docstrings to a keyword could cause more issues than it solves.
Both making repr customizable and setting __module__ were in earlier versions of the PEP and removed in the name of keeping things as simple as possible to begin with. Now that @Jelle has seen concrete cases where they have clear benefit, adding them make sense to me.
Regarding making __module__ writeable, as @HexDecimal above, I find that a bit surprising. Adding a module argument to sentinel() would be more in line with namedtuple and Enum which do this for similar reasons, and be more in line with sentinels being immutable.
I’m currently -1 on adding a doc argument.
Adding f"{module}." to the beginning of the default repr is an interesting thought. It seems that namedtuple does this while Enum does not. What would be most useful in the stdlib?
module= vs. __module__: we could go either way on this but there is lots of precedent in the stdlib for a writable __module__= attribute, especially for builtins. Also, the need for this seems rare enough that it doesn’t need to pollute the main signature. (@devdanzin’s solution is a bit too inventive for me though.)
Including the module in the repr: a good number of stdlib sentinels do this though not all:
We could change this (and of course, the repr= argument means that no matter the default, users can choose to override it). I think the version without a module will often look nicer in signatures. For example, on current main help(functools.reduce) shows reduce(function, iterable, /, initial=_initial_missing), where _initial_missing is a sentinel; adding the module here would be distracting.