Skip to content

Instantly share code, notes, and snippets.

@charbonnierg
Last active April 23, 2023 18:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save charbonnierg/b693316276e67fb02189548841830a34 to your computer and use it in GitHub Desktop.
Save charbonnierg/b693316276e67fb02189548841830a34 to your computer and use it in GitHub Desktop.
An Empty class to use with Pydantic
"""
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>
"""
from __future__ import annotations
from typing import Any, Dict, Iterator, Literal, Type, TypeVar, Union
from pydantic import BaseModel
T = TypeVar("T", bound=Any)
class Singleton(type):
_instances: Dict[Type[Any], Any] = {}
def __call__(cls: Type[T], *args: Any, **kwargs: Any) -> T:
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs) # type: ignore[misc]
return cls._instances[cls] # type: ignore[no-any-return]
class Empty(metaclass=Singleton):
"""A special type which denote an empty JSON value.
It emulates the container interface and can be iterated upon (an empty generator is returned)
"""
def __bool__(self) -> Literal[False]:
"""Empty instances always return False when evaluated as a boolean."""
return False
def __str__(self) -> Literal[""]:
"""If str function is explicitely used, empty returns an empty string."""
return ""
def __repr__(self) -> Literal["{}"]:
"""Empty instances are represented as an empty dictionary."""
return "{}"
def __len__(self) -> Literal[0]:
"""Always return an empty length."""
return 0
def __contains__(self, _: Any) -> Literal[False]:
"""Ensure that testing if an item is IN an Empty instance always return False."""
return False
def __iter__(self) -> Iterator[Any]:
"""Used to allow giving Empty instances as an argument to dict, list, or any function accepting an iterable."""
yield from ()
@classmethod
def __get_validators__(cls): # type: ignore[no-untyped-def]
"""Used to indicate validators to pydantic."""
# There is a single validator
yield cls.validate
@classmethod
def validate(cls, v): # type: ignore[no-untyped-def]
"""Used to perform validation with pydantic."""
# If value does evaluate to True an error is raised
if v:
raise ValueError("Not empty")
# Else we return the Empty() instance
return cls()
# Create a new type
Maybe = Union[Empty, T]
# NOTE: This constant is true if Python was not started with an -O option
if __debug__: # pragma: no cover
###################################################################
# TESTING WITH PYDANTIC #
###################################################################
import pytest
from pydantic import ValidationError
# Declare some pydantic model
class Bar(BaseModel):
tutu: int
toto: str
# Use Maybe to denote a field which might be empty JSON
class Foo(BaseModel):
# It can also be used to provide a default value
titi: Maybe[Bar]
def test_when_non_empty() -> None:
"""Test that using Empty does not perturb validation when data is provided."""
# A field is missing
with pytest.raises(ValidationError):
Foo(titi={"toto": "A"})
# "tutu" field should be of type int
with pytest.raises(ValidationError):
Foo(titi={"tutu": "A", "toto": "A"})
# This should work
f = Foo(titi={"toto": "A", "tutu": 1})
# mypy will known that titi is of type Bar under the "assert" statement
# At first it indicates: "(variable) titi: Maybe[Bar]"
assert f.titi
# And then: "(variable) titi: Bar"
# NOTE: This also works with "if" statements
assert f.titi.toto == "A"
assert f.titi.tutu == 1
def test_empty_dict_allowed() -> None:
"""Test that pydantic accept models created using empty dict."""
import pytest
from pydantic import ValidationError
# Dict keys are unknown, validation will fail
with pytest.raises(ValidationError):
f1 = Foo(titi={"some": "data"})
# Using an empty dictionary is allowed
f1 = Foo(titi={})
f2 = Foo(titi={})
# Make sure that all attributes are equal to the Empty() singleton
assert f1.titi is f2.titi is Empty()
def test_strict_empty() -> None:
"""Test that a model with a non optional Empty field does not allow non empty dict"""
class ReallyEmpty(BaseModel):
something: Empty
with pytest.raises(ValidationError):
ReallyEmpty(something={"a": "b"})
ReallyEmpty(something={})
###################################################################
# UNIT TESTING #
###################################################################
def test_empty_as_bool() -> None:
"""Test that Empty() evaluates to False."""
assert not Empty()
def test_empty_is_singleton() -> None:
"""Test that Empty() always returns the same object."""
assert Empty() == Empty() == Empty()
def test_empty_is_len_zero() -> None:
"""Test that length of Empty() is 0."""
assert len(Empty()) == 0
def test_empty_as_dict() -> None:
"""Test that an empty dictionary can be created from Empty()."""
assert dict(Empty()) == {}
def test_empty_as_list() -> None:
"""Test that an empty list can be created from Empty()."""
assert list(Empty()) == []
def test_empty_as_string() -> None:
"""Test that an empty string can be created from Empty()"""
assert str(Empty()) == ""
def test_empty_as_iterable() -> None:
"""Test that Empty() can be iterated upon. Iterator is always empty."""
empty = True
for _ in Empty():
empty = False
assert empty
def test_empty_repr() -> None:
"""Test the representation of Empty()"""
assert repr(Empty()) == "{}"
def test_empty_does_not_contain() -> None:
"""Test that 'in' operator can be used with Empty. False is always returned."""
assert "a" not in Empty()
assert 1 not in Empty()
assert None not in Empty()
@gpkc
Copy link

gpkc commented Apr 23, 2023

Unfortunately, this will not work with e.g. FastAPI or Starlette and will result in: TypeError: Object of type Empty is not JSON serializable

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment