Refactor Sentinel to conform to PEP 661
My attempt at implementing PEP 661 since I was unhappy with #594. I'm hoping that this is a PR of decent quality and not just me desperately wanting pickle support.
Changes made to Sentinel:
- ~~
reprparameter removed, explicit repr tests removed,reprwas rejected by the PEP~~, reverted,repris kept __repr__modified to match PEP implementation (removed angle brackets)- Added
module_nameparameter following PEP implementation and tweaking that to use the local_callerhelper function. namerequired support for qualified names to follow PEP and ensure forward compatibility, this is tested. Unless sentinels in class scopes are forbidden then qualified names are not optional.- Added
__reduce__to track Sentinel by name and module_name. - Added a Sentinel registry to preserve Sentinel identity across multiple calls to the class. Added tests for this.
- ~~Added an import step to allow forward compatibility with other sentinel libraries. Import step is tested. This is not from the PEP but it is required for typing_extensions to be forward compatible with any official sentinel type.~~, reverted, not necessary when pickle handles singletons.
- Added copy and pickle tests. There were several other use-cases for pickle from the PEP 661 discussion. The PEP requires that the sentinel identity is preserved during these operations.
- Updated documentation for Sentinel.
For a while I still supported the repr parameter and after following the PEP 661 discussion I ended up implementing the truthiness tweaks mentioned there, but then I ended up scrapping all of that so that I could follow the PEP more closely, but they would be easy to reintroduce later if desired.
I'm unsure of how to mention version changes in the docs. Replacing repr with module_name is technically a breaking change.
_sentinel_registry.setdefault is potentially supposed to be protected with a lock but I'm unsure of if I can import the threading module without causing platform issues.
_sentinel_registry could be replaced with a weak values dictionary but I'm unsure if that's necessary due to how permanent most sentinels are.
Replacing
reprwithmodule_nameis technically a breaking change.
I think breaking changes are expected for draft PEPs, so imo no deprecation process should be used. Users should be aware that changes are expected. However, I think we can:
- Improve the documentation about draft PEPs, stating that implementation can change.
- Explicitly state that Sentinels are from a draft PEP in the
Sentinelclass documentation, and isn't stable yet.
cc @taleinat
I'm not willing to remove the repr parameter from typing_extensions.Sentinel without a deprecation. However, I'm OK to re-export the builtin Sentinel class in 3.15 (if it makes it in), even if it doesn't have this parameter.
I'm not willing to remove the
reprparameter fromtyping_extensions.Sentinelwithout a deprecation. However, I'm OK to re-export the builtin Sentinel class in 3.15 (if it makes it in), even if it doesn't have this parameter.
Then I will change repr into a keyword parameter.
If compatibility is important enough then I can also have the code assume module_name is actually repr if it does not refer to a existing module. Would this be necessary?
Configuration like repr would be defined only with the initial Sentinel object. It wouldn't get stored in the reduce function. This allows changes in repr to be reflected in unpickled sentinels. This means that removing repr later will not break serialization.
I've replaced the __reduce__ method with a version using pickle's singleton support. This is the most conservative and inoffensive option for a new reduce function since it doesn't add a custom unpickle function, is forward compatible with any future method of pickling, is the standard method of handling singletons via pickle, and is generally strict. This does not support pickling anonymous sentinels which will now raise pickle.PicklingError instead of TypeError. I can revert this if the previous behavior was more desired, but I personally only need to pickle sentinels which have a top-level definition, and the more I work with them the more that anonymous sentinels feel counter intuitive.
I've attempted to add to the discussion of PEP 661. Anonymous sentinels bring a lot of issues which need to be addressed. https://discuss.python.org/t/pep-661-sentinel-values/9126/251
~~Before this is merged I'd like to add a strict keyword parameter to Sentinel which prevents sentinels from being accidentally named after existing top-level objects which are not Sentinel as well as ensuring that repr is never given conflicting values. I'll need feedback on if this is appropriate.~~
Thanks @HexDecimal. I just tested your implementation in Pydantic to implement an UNSET sentinel, and pickling seems to work as expected.
Edit: all of this is no longer relevant for this PR
Note on behavior regarding the sentinel registry and imported sentinels:
class MISSING:
pass
assert Sentinel("MISSING") is MISSING # Sentinel named after an existing non-Sentinel object
Which of these makes the most sense for the above code?
- Register and return the existing class as the sentinel identity, the assert will pass (current PR implementation)
- Return a new Sentinel identity, the assert will fail (old implementation)
- Raise a warning, then do 1
- Raise
TypeErrorbecauseMISSINGis not aSentinel(what I recommend, the most strict option) - Add a
strictparameter toSentinelto decide between 1 and 4
Edit: currently going with 2 to simplify the implementation.
2nd behavior involving registered sentinel parameters:
MISSING = Sentinel("MISSING", repr="foo")
assert MISSING is Sentinel("MISSING") # Implicit 'repr' conflicts with previous 'repr'
assert MISSING is Sentinel("MISSING", repr="bar") # Explicit 'repr' conflicts with previous 'repr'
Which of these makes the most sense for the above code?
repr="foo"is locked in for all subsequent sentinels, subsequentrepris always ignored, asserts pass (current implementation)- Both asserts raise
ValueErroras long asrepr="foo"is not given again (my recommendation, the most strict option) repronly checked if explicitly given, 1st assert passes, 2nd assert raisesValueError- Check
reprlike 2 but raise warnings instead of errors - Add
strictparameter to decide between 1 and 2
Edit: currently going with 4 to not break anything.
The truthiness of sentinels is still being discussed. One thing which is unclear is if conversions to bool are common enough to be an issue in the first place (because sentinels are normally checked using identity comparison and little else). It'd be interesting to experiment with the most strict option and disable conversions to bool entirely:
def __bool__(self) -> Never:
raise TypeError(...)
Which of these makes the most sense for the above code?
I feel strongly that 2 is the right behavior. Constructing a class shouldn't look around in the globals for other stuff that might be using the same name.
Constructing a class shouldn't look around in the globals for other stuff that might be using the same name.
That was necessary to handle unpickling until I finally implemented the correct reduce function, but at this point I can remove that code now and revert to the old behavior. I'd accept that the other options are unnecessarily handholdy.
~~Unless my removal of sentinel truthiness causes new issues then this PR is finished. I can rebase this if needed.~~
Nevermind, anything related to truthiness can be a separate PR. It's outside the scope of this one.
Codecov Report
:white_check_mark: All modified and coverable lines are covered by tests.
:white_check_mark: Project coverage is 97.38%. Comparing base (19cc3bc) to head (678b73b).
@@ Coverage Diff @@
## main #617 +/- ##
=======================================
Coverage 97.38% 97.38%
=======================================
Files 3 3
Lines 7689 7701 +12
=======================================
+ Hits 7488 7500 +12
Misses 201 201
| Flag | Coverage Δ | |
|---|---|---|
| 3.10 | 89.00% <100.00%> (+0.01%) |
:arrow_up: |
| 3.10.4 | 89.00% <100.00%> (+0.01%) |
:arrow_up: |
| 3.11 | 88.23% <100.00%> (+0.01%) |
:arrow_up: |
| 3.11.0 | 87.46% <100.00%> (+0.01%) |
:arrow_up: |
| 3.12 | 88.18% <100.00%> (+0.01%) |
:arrow_up: |
| 3.12.0 | 88.16% <100.00%> (+0.01%) |
:arrow_up: |
| 3.13 | 81.68% <100.00%> (+0.02%) |
:arrow_up: |
| 3.13.0 | 82.41% <100.00%> (+0.02%) |
:arrow_up: |
| 3.14 | 78.86% <100.00%> (+0.03%) |
:arrow_up: |
| 3.9 | 89.71% <100.00%> (+0.01%) |
:arrow_up: |
| 3.9.12 | 89.71% <100.00%> (+0.01%) |
:arrow_up: |
| pypy3.10 | 88.83% <100.00%> (+0.01%) |
:arrow_up: |
| pypy3.11 | 88.09% <100.00%> (+0.01%) |
:arrow_up: |
| pypy3.9 | 89.54% <100.00%> (+0.01%) |
:arrow_up: |
Flags with carried forward coverage won't be shown. Click here to find out more.
| Files with missing lines | Coverage Δ | |
|---|---|---|
| src/test_typing_extensions.py | 98.39% <100.00%> (+<0.01%) |
:arrow_up: |
| src/typing_extensions.py | 93.95% <100.00%> (+<0.01%) |
:arrow_up: |
:rocket: New features to boost your workflow:
- :snowflake: Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
I've narrowed the scope of this PR down to only what's needed to support pickling sentinels. Unfortunately this still means that repr needs to be touched to allow for the module_name parameter. Thankfully a lot of other changes were no longer needed after this latest rebase.
Has anyone mentioned that _marker is a terribly obfuscated name for a sentinel?