diff --git a/src/graphql/type/scalars.py b/src/graphql/type/scalars.py index db61272d..ff2d49ae 100644 --- a/src/graphql/type/scalars.py +++ b/src/graphql/type/scalars.py @@ -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) @@ -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: @@ -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) @@ -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: @@ -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) ) @@ -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: @@ -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: @@ -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 ( diff --git a/tests/type/test_scalars.py b/tests/type/test_scalars.py index 058d04a4..5bae1b0b 100644 --- a/tests/type/test_scalars.py +++ b/tests/type/test_scalars.py @@ -1,4 +1,6 @@ import pickle +from decimal import Decimal +from fractions import Fraction from math import inf, nan, pi from typing import Any @@ -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(): @@ -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:" + " ", + ) + _coerce_output_value_raises( + NumPyLikeScalar(-9876504321), + "Int cannot represent non 32-bit signed integer value:" + " ", + ) + # 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: ", + ) + # 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") @@ -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'" @@ -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'"