Add explicit Absent utility type

This commit is contained in:
Olivier 'reivilibre
2026-02-27 17:13:51 +00:00
parent 7da440cb05
commit f1e200e654
3 changed files with 156 additions and 1 deletions

View File

@@ -29,6 +29,7 @@ from typing import (
AbstractSet,
Any,
ClassVar,
Final,
Literal,
Mapping,
Match,
@@ -42,7 +43,10 @@ from typing import (
)
import attr
import pydantic_core.core_schema
from immutabledict import immutabledict
from pydantic import GetCoreSchemaHandler
from pydantic_core import CoreSchema
from signedjson.key import decode_verify_key_bytes
from signedjson.types import VerifyKey
from typing_extensions import Self
@@ -109,6 +113,67 @@ StrCollection = tuple[str, ...] | list[str] | AbstractSet[str]
StrSequence = tuple[str, ...] | list[str]
class AbsentType(Enum):
"""
Type of a sentinel to use as an alternative to `None`
for when we really mean 'absent' and not JSON null.
For a Sentinel for internal (non-API-facing) use, instead consider
`Sentinel.UNSET_SENTINEL`.
It is falsy (like None is), so shorthand forms like `x or 0` can be used.
"""
# Making this an Enum member makes this compatible with type narrowing,
# meaning `x is not Absent` will narrow `x: int | AbsentType` to `x: int` etc.
_Absent = object()
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: object, handler: GetCoreSchemaHandler
) -> CoreSchema:
return pydantic_core.core_schema.is_instance_schema(cls)
def __copy__(self) -> "AbsentType":
"""
Copy implementation used by `copy.copy()`.
Always use the same instance.
Without this and the deep version `__deepcopy__`,
`copy.copy(Absent)` on Python 3.10 (olddeps)
had a problem where it tried to construct a new Absent
as part of a deepcopy operation and resulted in:
ValueError: <object object at 0x7f64b3b6d930> is not a valid AbsentType
"""
return self
def __deepcopy__(self, memo: object) -> "AbsentType":
"""
Copy implementation used by `copy.deepcopy()`.
Always use the same instance.
"""
return self
def __bool__(self) -> Literal[False]:
return False
def __str__(self) -> str:
return "Absent"
def __repr__(self) -> str:
return "Absent"
Absent: Final = AbsentType._Absent
"""
Sentinel to use as an alternative to `None`
for when we really mean 'absent' and not JSON null.
For a Sentinel for internal (non-API-facing) use, instead consider
`Sentinel.UNSET_SENTINEL`.
"""
# Note that this seems to require inheriting *directly* from Interface in order
# for mypy-zope to realize it is an interface.
class ISynapseThreadlessReactor(

View File

@@ -16,6 +16,17 @@ import enum
class Sentinel(enum.Enum):
"""
Internal marker sentinel for distinguishing a default state from user-suppliable values.
Has no meaning on its own.
Use this when you want to be absolutely sure that the marker came from Synapse code
and not from request body parsing.
If you want a Pydantic-compatible Sentinel that is suitable for expressing
'absent from some parsed JSON payload' or equivalent, see `Absent`.
"""
# defining a sentinel in this way allows mypy to correctly handle the
# type of a dictionary lookup and subsequent type narrowing.
UNSET_SENTINEL = object()

View File

@@ -18,14 +18,17 @@
# [This file includes modifications made by New Vector Limited]
#
#
import copy
from unittest import skipUnless
from immutabledict import immutabledict
from parameterized import parameterized_class
from pydantic import BaseModel, PydanticInvalidForJsonSchema, ValidationError
from synapse.api.errors import SynapseError
from synapse.types import (
Absent,
AbsentType,
AbstractMultiWriterStreamToken,
MultiWriterStreamToken,
RoomAlias,
@@ -199,3 +202,79 @@ class MultiWriterTokenTestCase(unittest.HomeserverTestCase):
parsed_token = self.get_success(self.token_type.parse(store, "m5~"))
self.assertEqual(parsed_token, self.token_type(stream=5))
class AbsentTestCase(unittest.TestCase):
"""
Tests for the `Absent` utility, which is meant to be like `None` except
explicitly signalling absence rather than JSON null.
"""
def test_cant_create_second_absent(self) -> None:
"""
Tests that we aren't allowed to instantiate a second `Absent`.
"""
with self.assertRaises(TypeError):
AbsentType() # type: ignore[call-arg]
def test_is_falsy(self) -> None:
"""
Tests `Absent` is falsy and can therefore be used a bit like `None`.
"""
if Absent:
self.fail("Absent is truthy!")
self.assertEqual(Absent or "something", "something")
def test_pydantic_jsonschema(self) -> None:
"""
Tests that `Absent` can't be used to produce JSONSchema in Pydantic models.
In the future, it may be useful to produce correct JSONSchema, but for now
I was mostly interested in making sure we don't produce weird/invalid JSONSchema.
"""
class MyModel(BaseModel):
absent: AbsentType = Absent
with self.assertRaises(PydanticInvalidForJsonSchema):
MyModel.model_json_schema()
def test_pydantic_reject_null(self) -> None:
"""
Tests that `Absent` rejects `None` (JSON null) when used in Pydantic models.
"""
class MyModel(BaseModel):
absent: AbsentType = Absent
with self.assertRaises(ValidationError):
MyModel.model_validate({"absent": None})
with self.assertRaises(ValidationError):
MyModel.model_validate_json('{"absent": null}')
def test_pydantic_accept_absence(self) -> None:
"""
Tests that `Absent` accepts the absence of a value when used in Pydantic models.
"""
class MyModel(BaseModel):
absent: AbsentType = Absent
self.assertEqual(MyModel.model_validate({}), MyModel(absent=Absent))
self.assertEqual(MyModel.model_validate_json("{}"), MyModel(absent=Absent))
def test_copy(self) -> None:
"""
Tests that the `copy` module always uses the same instance of Absent.
"""
class MyModel(BaseModel):
absent: AbsentType = Absent
a = MyModel.model_validate({})
b = copy.deepcopy(a)
self.assertIs(copy.copy(Absent), Absent)
self.assertIs(a.absent, b.absent)