Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions src/validataclass/helpers/unset_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
Expand All @@ -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

Expand All @@ -41,16 +53,15 @@ def __repr__(self) -> str:
def __str__(self) -> str:
return '<UnsetValue>'

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
Expand All @@ -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`.

Expand Down
27 changes: 27 additions & 0 deletions tests/mypy/helpers/test_unset_value.yml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 2 additions & 2 deletions tests/unit/helpers/unset_value_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down