From f1e200e654ff7c5876298a2bf56b0a2f553fbb02 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Fri, 27 Feb 2026 17:13:51 +0000 Subject: [PATCH] Add explicit Absent utility type --- synapse/types/__init__.py | 65 +++++++++++++++++++++++++++++++ synapse/util/sentinel.py | 11 ++++++ tests/test_types.py | 81 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/synapse/types/__init__.py b/synapse/types/__init__.py index 02889795bb..6621145f25 100644 --- a/synapse/types/__init__.py +++ b/synapse/types/__init__.py @@ -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: 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( diff --git a/synapse/util/sentinel.py b/synapse/util/sentinel.py index c8434fc97a..e885f81879 100644 --- a/synapse/util/sentinel.py +++ b/synapse/util/sentinel.py @@ -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() diff --git a/tests/test_types.py b/tests/test_types.py index 1802f0fae3..fb8735d8a4 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -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)