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
69 changes: 65 additions & 4 deletions src/graphql/type/scalars.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ def serialize_int(output_value: Any) -> int:
return coerce_int_from_number(output_value)
if isinstance(output_value, str):
return coerce_int_from_string(output_value)
if is_numeric_object(output_value):
return coerce_int_from_numeric_object(output_value)
msg = "Int cannot represent non-integer value: " + inspect(output_value)
raise GraphQLError(msg)

Expand Down Expand Up @@ -106,7 +108,11 @@ def int_value_to_literal(value: Any) -> ConstValueNode | None:
coerce_input_literal=parse_int_literal,
value_to_literal=int_value_to_literal,
)
"""The built-in ``Int`` scalar type."""
"""The built-in ``Int`` scalar type.

Besides Python ints, other numeric types like ``Decimal``, ``Fraction`` or NumPy
scalars can also be serialized as Int, as long as they represent 32-bit integers.
"""


def serialize_float(output_value: Any) -> float:
Expand All @@ -118,6 +124,10 @@ def serialize_float(output_value: Any) -> float:
return coerce_float_from_int(output_value)
if isinstance(output_value, str):
return coerce_float_from_string(output_value)
if is_numeric_object(output_value):
num = finite_float_from_numeric_object(output_value)
if num is not None:
return num
msg = "Float cannot represent non numeric value: " + inspect(output_value)
raise GraphQLError(msg)

Expand Down Expand Up @@ -160,7 +170,13 @@ def float_value_to_literal(value: Any) -> ConstValueNode | None:
coerce_input_literal=parse_float_literal,
value_to_literal=float_value_to_literal,
)
"""The built-in ``Float`` scalar type."""
"""The built-in ``Float`` scalar type.

Besides Python floats and ints, other numeric types like ``Decimal``, ``Fraction``
or NumPy scalars can also be serialized as Float. Note that they are converted to
Python floats and may lose precision. Use a custom scalar type if you need to
preserve the precision.
"""


def serialize_string(output_value: Any) -> str:
Expand Down Expand Up @@ -230,6 +246,10 @@ def serialize_boolean(output_value: Any) -> bool:
# always finite.
if isinstance(output_value, int):
return output_value != 0
if is_numeric_object(output_value):
num = finite_float_from_numeric_object(output_value)
if num is not None:
return num != 0
raise GraphQLError(
"Boolean cannot represent a non boolean value: " + inspect(output_value)
)
Expand Down Expand Up @@ -269,7 +289,12 @@ def boolean_value_to_literal(value: Any) -> ConstValueNode | None:
coerce_input_literal=parse_boolean_literal,
value_to_literal=boolean_value_to_literal,
)
"""The built-in ``Boolean`` scalar type."""
"""The built-in ``Boolean`` scalar type.

Besides Python bools and other numbers, numeric types like ``Decimal``,
``Fraction`` or NumPy scalars (including NumPy booleans) can also be serialized
as Boolean.
"""


def serialize_id(output_value: Any) -> str:
Expand Down Expand Up @@ -412,7 +437,8 @@ def coerce_boolean_from_number(value: float) -> bool:
if not isfinite(value):
msg = "Boolean cannot represent a non boolean value: " + inspect(value)
raise GraphQLError(msg)
return value != 0
# use bool() since subclasses like NumPy's float64 return NumPy's bool here
return bool(value != 0)


def coerce_id_from_number(value: float) -> str:
Expand All @@ -422,6 +448,41 @@ def coerce_id_from_number(value: float) -> str:
return str(int(value))


def is_numeric_object(value: Any) -> bool:
"""Check whether the value is a number of a non-builtin type.

Numbers of types such as ``Decimal``, ``Fraction`` or NumPy scalars do not
subclass Python's ``int`` or ``float``, but can be converted to them.
"""
type_ = type(value)
return type_.__module__ != "builtins" and hasattr(type_, "__float__")


def coerce_int_from_numeric_object(value: Any) -> int:
try:
num = int(value)
except (OverflowError, TypeError, ValueError) as error:
msg = "Int cannot represent non-integer value: " + inspect(value)
raise GraphQLError(msg) from error
# do not truncate non-integer values (the comparison is exact for decimals)
if num != value:
msg = "Int cannot represent non-integer value: " + inspect(value)
raise GraphQLError(msg)
if not GRAPHQL_MIN_INT <= num <= GRAPHQL_MAX_INT:
msg = "Int cannot represent non 32-bit signed integer value: " + inspect(value)
raise GraphQLError(msg)
return num


def finite_float_from_numeric_object(value: Any) -> float | None:
"""Convert a number of a non-builtin type to a finite float if possible."""
try:
num = float(value)
except (OverflowError, ValueError):
return None
return num if isfinite(num) else None


specified_scalar_types: Mapping[str, GraphQLScalarType] = {
type_.name: type_
for type_ in (
Expand Down
173 changes: 173 additions & 0 deletions tests/type/test_scalars.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import pickle
from decimal import Decimal
from fractions import Fraction
from math import inf, nan, pi
from typing import Any

Expand All @@ -17,6 +19,36 @@
)


class NumPyLikeScalar: # noqa: PLW1641
"""Mimic a NumPy scalar such as numpy.int64, numpy.float32 or numpy.bool.

Like these, it does not subclass a builtin numeric type,
but can be converted to and compared with one.
"""

def __init__(self, value: float) -> None:
self.value = value

def __int__(self) -> int:
return int(self.value)

def __float__(self) -> float:
return float(self.value)

def __bool__(self) -> bool:
return bool(self.value)

def __eq__(self, other: object) -> bool:
return self.value == other


class NumPyLikeFloat64(float):
"""Mimic numpy.float64, which subclasses float but compares to numpy.bool."""

def __ne__(self, other: object) -> Any:
return NumPyLikeScalar(super().__ne__(other))


def describe_type_system_specified_scalar_types():
def describe_graphql_int():
def coerce_input_value():
Expand Down Expand Up @@ -200,6 +232,65 @@ def coerce_output_value():
coerce_output_value([5])
assert str(exc_info.value) == "Int cannot represent non-integer value: [5]"

def coerce_output_value_from_other_numeric_types():
coerce_output_value = GraphQLInt.coerce_output_value

def _coerce_output_value_raises(value: Any, message: str):
with pytest.raises(GraphQLError) as exc_info:
coerce_output_value(value)
assert str(exc_info.value) == message

result = coerce_output_value(Decimal(123))
assert result == 123
assert type(result) is int
assert coerce_output_value(Decimal("-1.0")) == -1
assert coerce_output_value(Fraction(4, 2)) == 2
result = coerce_output_value(NumPyLikeScalar(5))
assert result == 5
assert type(result) is int
assert coerce_output_value(NumPyLikeScalar(1e5)) == 100000
assert coerce_output_value(NumPyLikeScalar(True)) == 1

# Non-integer values are not truncated, even if the loss is not
# visible after conversion to float.
for value in (
Decimal("0.1"),
Decimal("1.0000000000000000000001"),
Fraction(1, 2),
NumPyLikeScalar(1.1),
Decimal("NaN"),
Decimal("sNaN"),
Decimal("Infinity"),
NumPyLikeScalar(nan),
NumPyLikeScalar(inf),
):
_coerce_output_value_raises(
value, "Int cannot represent non-integer value: " + inspect(value)
)
_coerce_output_value_raises(
Decimal(9876504321),
"Int cannot represent non 32-bit signed integer value:"
" <Decimal instance>",
)
_coerce_output_value_raises(
NumPyLikeScalar(-9876504321),
"Int cannot represent non 32-bit signed integer value:"
" <NumPyLikeScalar instance>",
)
# Numbers that cannot be converted to int are not accepted.
float_only = type("FloatOnly", (), {"__float__": lambda _self: 1.0})()
_coerce_output_value_raises(
float_only,
"Int cannot represent non-integer value: <FloatOnly instance>",
)
# Builtin types are not converted.
_coerce_output_value_raises(
b"5", "Int cannot represent non-integer value: b'5'"
)
_coerce_output_value_raises(
5 + 0j, "Int cannot represent non-integer value: (5+0j)"
)

def cannot_be_redefined():
with pytest.raises(TypeError, match="Redefinition of reserved type 'Int'"):
GraphQLScalarType(name="Int")
Expand Down Expand Up @@ -378,6 +469,51 @@ def coerce_output_value():
str(exc_info.value) == "Float cannot represent non numeric value: [5]"
)

def coerce_output_value_from_other_numeric_types():
coerce_output_value = GraphQLFloat.coerce_output_value

def _coerce_output_value_raises(value: Any, message: str):
with pytest.raises(GraphQLError) as exc_info:
coerce_output_value(value)
assert str(exc_info.value) == message

result = coerce_output_value(Decimal("123.45"))
assert result == 123.45
assert type(result) is float
assert coerce_output_value(Decimal(-1)) == -1.0
assert coerce_output_value(Fraction(1, 4)) == 0.25
result = coerce_output_value(NumPyLikeScalar(1.5))
assert result == 1.5
assert type(result) is float
assert coerce_output_value(NumPyLikeScalar(3)) == 3.0
assert coerce_output_value(NumPyLikeScalar(True)) == 1.0

# These values may lose precision when converted to float.
assert coerce_output_value(Decimal("0.1000000000000000000001")) == 0.1
assert coerce_output_value(Fraction(1, 3)) == 1 / 3
assert coerce_output_value(Decimal(9007199254740993)) == 9007199254740992

for value in (
Decimal("NaN"),
Decimal("sNaN"),
Decimal("Infinity"),
Decimal("-Infinity"),
Decimal("1e400"),
Fraction(2**1024),
NumPyLikeScalar(nan),
NumPyLikeScalar(inf),
):
_coerce_output_value_raises(
value, "Float cannot represent non numeric value: " + inspect(value)
)
# Builtin types are not converted.
_coerce_output_value_raises(
b"1.5", "Float cannot represent non numeric value: b'1.5'"
)
_coerce_output_value_raises(
1.5 + 0j, "Float cannot represent non numeric value: (1.5+0j)"
)

def cannot_be_redefined():
with pytest.raises(
TypeError, match="Redefinition of reserved type 'Float'"
Expand Down Expand Up @@ -656,6 +792,43 @@ def coerce_output_value():
"Boolean cannot represent a non boolean value: {}"
)

def coerce_output_value_from_other_numeric_types():
coerce_output_value = GraphQLBoolean.coerce_output_value

def _coerce_output_value_raises(value: Any, message: str):
with pytest.raises(GraphQLError) as exc_info:
coerce_output_value(value)
assert str(exc_info.value) == message

assert coerce_output_value(NumPyLikeFloat64(2.0)) is True
assert coerce_output_value(NumPyLikeFloat64(0.0)) is False
assert coerce_output_value(NumPyLikeScalar(True)) is True
assert coerce_output_value(NumPyLikeScalar(False)) is False
assert coerce_output_value(NumPyLikeScalar(5)) is True
assert coerce_output_value(NumPyLikeScalar(0.0)) is False
assert coerce_output_value(Decimal("0.5")) is True
assert coerce_output_value(Decimal(0)) is False
assert coerce_output_value(Fraction(1, 2)) is True

for value in (
Decimal("NaN"),
Decimal("sNaN"),
Decimal("Infinity"),
NumPyLikeScalar(nan),
NumPyLikeScalar(inf),
):
_coerce_output_value_raises(
value,
"Boolean cannot represent a non boolean value: " + inspect(value),
)
# Builtin types are not converted.
_coerce_output_value_raises(
b"1", "Boolean cannot represent a non boolean value: b'1'"
)
_coerce_output_value_raises(
1 + 0j, "Boolean cannot represent a non boolean value: (1+0j)"
)

def cannot_be_redefined():
with pytest.raises(
TypeError, match="Redefinition of reserved type 'Boolean'"
Expand Down
Loading