diff --git a/src/validataclass/helpers/unset_value.py b/src/validataclass/helpers/unset_value.py index 3e52265..afa3dbc 100644 --- a/src/validataclass/helpers/unset_value.py +++ b/src/validataclass/helpers/unset_value.py @@ -4,7 +4,8 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import TypeAlias, TypeVar +from enum import Enum +from typing import Final, Literal, TypeAlias, TypeVar, final from typing_extensions import Self, override @@ -19,8 +20,16 @@ T = TypeVar('T') -# Class to create the UnsetValue sentinel object -class UnsetValueType: +# Create type for UnsetValue sentinel object as a single-value enum. +# +# Using a single-value enum has the advantage that mypy will recognize it as a singleton object and correctly narrow +# down the type in conditions like `if x is not UnsetValue`. +# +# Please also note that while Python 3.15 will finally get a sentinel class (PEP 661), we won't be using that for +# UnsetValue in the foreseeable future, unless they decide to allow custom boolean evaluation (PEP 661 sentinels are +# always truthy, but UnsetValue needs to be falsy). +@final +class UnsetValueType(Enum): """ Class to represent an unset value (e.g. a field in a dataclass that has no value at all because it did not exist in the input data). @@ -30,6 +39,9 @@ class UnsetValueType: or to create a copy of `UnsetValue` will always result in the same instance. """ + # Sentinel value + UnsetValue = 'UnsetValue' + def __call__(self) -> Self: return self @@ -41,16 +53,15 @@ def __repr__(self) -> str: def __str__(self) -> str: return '' - def __bool__(self) -> bool: + def __bool__(self) -> Literal[False]: return False # Don't define __eq__ because the default implementation is fine (identity check), and because we would then have to # implement __hash__ as well, otherwise UnsetValue would be considered mutable by @dataclass. -# Create sentinel object and redefine __new__ so that the object cannot be cloned -UnsetValue = UnsetValueType() -UnsetValueType.__new__ = lambda cls: UnsetValue # type: ignore[assignment, method-assign, return-value] +# Create actual sentinel object +UnsetValue: Final = UnsetValueType.UnsetValue # Type alias OptionalUnset[T] for fields with DefaultUnset: Allows either the type T or UnsetValue OptionalUnset: TypeAlias = T | UnsetValueType @@ -60,7 +71,7 @@ def __bool__(self) -> bool: # Small helper function for easier conversion of UnsetValue to None -def unset_to_none(value: OptionalUnset[T]) -> T | None: +def unset_to_none(value: T | UnsetValueType) -> T | None: """ Converts `UnsetValue` to `None`. diff --git a/tests/mypy/helpers/test_unset_value.yml b/tests/mypy/helpers/test_unset_value.yml new file mode 100644 index 0000000..184af19 --- /dev/null +++ b/tests/mypy/helpers/test_unset_value.yml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/typeddjango/pytest-mypy-plugins/master/pytest_mypy_plugins/schema.json + +# Test that a type containing UnsetValueType is narrowed down correctly with `is`, `==` and boolean evaluation. +- case: unset_value_type_narrowing + main: | + from validataclass.helpers import UnsetValue, UnsetValueType + + var: str | UnsetValueType + reveal_type(var) # N: Revealed type is "str | validataclass.helpers.unset_value.UnsetValueType" + + # Type narrowing with is + if var is not UnsetValue: + reveal_type(var) # N: Revealed type is "str" + else: + reveal_type(var) # N: Revealed type is "Literal[validataclass.helpers.unset_value.UnsetValueType.UnsetValue]" + + # Type narrowing with equality + if var == UnsetValue: + reveal_type(var) # N: Revealed type is "Literal[validataclass.helpers.unset_value.UnsetValueType.UnsetValue]" + else: + reveal_type(var) # N: Revealed type is "str" + + # Type narrowing by checking falsiness + if var: + reveal_type(var) # N: Revealed type is "str" + else: + reveal_type(var) # N: Revealed type is "Literal[''] | validataclass.helpers.unset_value.UnsetValueType" diff --git a/tests/unit/helpers/unset_value_test.py b/tests/unit/helpers/unset_value_test.py index 2b77bd9..0115f2f 100644 --- a/tests/unit/helpers/unset_value_test.py +++ b/tests/unit/helpers/unset_value_test.py @@ -27,8 +27,8 @@ def test_unset_value_unique(): """ Test that UnsetValue is a unique sentinel object, i.e. all UnsetValue values are the same. """ unset_value1 = UnsetValue unset_value2 = copy(unset_value1) - unset_value3 = UnsetValueType() - assert unset_value1 is unset_value2 is unset_value3 is UnsetValue + assert unset_value1 is UnsetValue + assert unset_value2 is UnsetValue # Test that calling the UnsetValue returns the UnsetValue itself assert UnsetValue() is UnsetValue