This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new e793be502c fix(pyamber): coerce numpy scalar types (np.integer /
np.bool_) in cast_to_schema (#6278)
e793be502c is described below
commit e793be502c050a9be05bc55d67bdad8eaf0ae418
Author: Eugene Gu <[email protected]>
AuthorDate: Tue Jul 14 13:13:44 2026 -0700
fix(pyamber): coerce numpy scalar types (np.integer / np.bool_) in
cast_to_schema (#6278)
### What changes were proposed in this PR?
Pandas-based Python operators naturally produce **numpy scalar types**
at the `Tuple` output boundary, and the Python worker rejects them:
- `df["x"].sum()` / `.max()` / `.count()` return `numpy.int64`
- `df["x"].any()` or any numpy comparison returns `numpy.bool_`
On output, `Tuple.finalize(schema)` runs `cast_to_schema()` then a
strict `isinstance(value, expected_python_type)` check in
`validate_schema()`. Because numpy's integer and bool scalar types are
**not** subclasses of Python `int` / `bool` (`isinstance(np.int64(5),
int)` and `isinstance(np.bool_(True), bool)` are both `False`), the
check fails and the operator crashes with `TypeError: Unmatched type for
field ...`, even though the value is semantically correct (`np.int64(5)`
is exactly the integer 5; the field is declared INTEGER).
This is a direct follow-up to #6053, which added `float → int` coercion
for INT/LONG fields. That fix only handled `np.float64` (which happens
to subclass Python `float`); numpy integer and bool scalars were still
rejected. This PR extends the same mechanism in `cast_to_schema()` to
cover them:
- **INT/LONG fields:** accept `numpy.integer` scalars by normalizing
them (and integral floats) into a candidate `int`, then applying the
existing `INTEGRAL_TYPE_RANGES` bounds check once. `int()` is exact for
numpy integers, so there is no precision loss. Out-of-range values are
left unchanged so `validate_schema()` still fails loudly (no int32
overflow / silent corruption).
- **BOOL fields:** convert `numpy.bool_` to Python `bool` in a separate
branch gated on the BOOL target type, so `bool` and `int` never
cross-coerce (`np.bool_` is not `numpy.integer`, and a plain Python
`bool` — an `int` subclass — is never coerced into an integer field).
`validate_schema()` is unchanged; it remains the strict backstop, so any
type this PR does not explicitly coerce still fails loudly rather than
passing silently.
**Out of scope:** `np.float32` is not a Python `float` subclass, so an
integral `np.float32` into an INT field is still rejected. It is
intentionally left out of this PR to keep the change focused; it can be
folded into the same float-normalization path in a follow-up if desired.
### Any related issues, documentation, discussions?
- Fixes #6277
- Follow-up to #6053 (integral `float → int` coercion for INT/LONG
fields).
### How was this PR tested?
Added unit tests to `amber/src/test/python/core/models/test_tuple.py`.
Results:
```
# Python unit tests
pytest src/test/python/core/models/test_tuple.py -q -> 84 passed
ruff check / ruff format --check -> clean
# Backend suite
sbt scalafmtCheckAll -> exit 0
sbt "scalafixAll --check" -> exit 0
sbt test -> 1717 passed, 0
failed
```
Adversarial check: reverting `cast_to_schema()` to the pre-fix version
turns exactly the coercion cases red while the guard cases stay green;
restoring the fix returns to all-green. Every pass-case asserts the
concrete Python type, so a test cannot pass without the fix.
Also reproduced end-to-end in the platform: `CSV File Scan` → `Python
UDF` (`yield {"total_age": table["age"].sum()}` / `{"has_senior":
(table["age"] > 60).any()}`) crashed with `Unmatched type ...
numpy.int64` / `numpy.bool` before the fix and runs correctly after.
### Was this PR authored or co-authored using generative AI tooling?
Co-authored by: Claude Code (Claude Fable 5)
---------
Signed-off-by: Eugene Gu <[email protected]>
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
.../python/core/models/schema/attribute_type.py | 9 ++
amber/src/main/python/core/models/tuple.py | 46 ++++--
amber/src/test/python/core/models/test_tuple.py | 170 +++++++++++++++++++++
3 files changed, 210 insertions(+), 15 deletions(-)
diff --git a/amber/src/main/python/core/models/schema/attribute_type.py
b/amber/src/main/python/core/models/schema/attribute_type.py
index 666ee69ede..0511937336 100644
--- a/amber/src/main/python/core/models/schema/attribute_type.py
+++ b/amber/src/main/python/core/models/schema/attribute_type.py
@@ -110,3 +110,12 @@ INTEGRAL_TYPE_RANGES = {
AttributeType.INT: (-(2**31), 2**31 - 1),
AttributeType.LONG: (-(2**53) + 1, 2**53 - 1),
}
+
+# numpy integer scalars are exact (unlike integral floats, which lose
+# integer precision above 2**53), so they are bounded only by the target
+# Arrow integer width, not the float64 exact-integer window used by
+# INTEGRAL_TYPE_RANGES: INT -> int32, LONG -> int64.
+NUMPY_INTEGRAL_RANGES = {
+ AttributeType.INT: (-(2**31), 2**31 - 1),
+ AttributeType.LONG: (-(2**63), 2**63 - 1),
+}
diff --git a/amber/src/main/python/core/models/tuple.py
b/amber/src/main/python/core/models/tuple.py
index 4b5a2e0ee9..1f319b6a4f 100644
--- a/amber/src/main/python/core/models/tuple.py
+++ b/amber/src/main/python/core/models/tuple.py
@@ -16,6 +16,7 @@
# under the License.
import ctypes
+import numpy
import pandas
import pickle
import pyarrow
@@ -32,6 +33,7 @@ from typing_extensions import Protocol, runtime_checkable
from core.models.type.large_binary import largebinary
from .schema.attribute_type import (
INTEGRAL_TYPE_RANGES,
+ NUMPY_INTEGRAL_RANGES,
TO_PYOBJECT_MAPPING,
AttributeType,
)
@@ -307,10 +309,12 @@ class Tuple:
"""
Safely cast each field value to match the target schema.
If failed, the value will stay not changed.
- This current conducts three kinds of casts:
+ This method performs the following casts:
1. cast NaN to None;
- 2. cast integral floats to int for INT/LONG fields;
- 3. cast any object to bytes (using pickle).
+ 2. cast integral floats and numpy integer scalars to int for
+ INT/LONG fields;
+ 3. cast numpy bool scalars to bool for BOOL fields;
+ 4. cast any object to bytes (using pickle).
:param schema: The target Schema that describes the target
AttributeType to
cast.
:return:
@@ -324,30 +328,42 @@ class Tuple:
self[field_name] = None
elif field_value is not None:
field_type = schema.get_attr_type(field_name)
- if (
- field_type in INTEGRAL_TYPE_RANGES
- and isinstance(field_value, float)
- and field_value.is_integer()
+ if field_type in INTEGRAL_TYPE_RANGES and (
+ isinstance(field_value, numpy.integer)
+ or (isinstance(field_value, float) and
field_value.is_integer())
):
- # pandas 2.2.3 promotes an int column holding nulls to
- # float64 (119 -> 119.0), so convert integral floats
- # destined for INT/LONG back to int — but only within
- # the safe range above; out-of-range floats are left
- # unchanged so validation still fails. Compare on the
- # int result to avoid float rounding at the endpoints.
- min_value, max_value = INTEGRAL_TYPE_RANGES[field_type]
+ # Coerce numpy integer scalars and integral floats into
+ # int for INT/LONG (pandas 2.2.3 promotes null-holding
+ # int columns to float64, and reductions like .sum()
+ # return numpy ints). Bounds differ by source: numpy
+ # integers are exact, so only the target width applies
+ # (LONG -> int64); integral floats stay within the
+ # float64 exact-integer window (2**53). Out-of-range
+ # values are left unchanged so validation still fails.
+ if isinstance(field_value, numpy.integer):
+ min_value, max_value =
NUMPY_INTEGRAL_RANGES[field_type]
+ else:
+ min_value, max_value =
INTEGRAL_TYPE_RANGES[field_type]
int_value = int(field_value)
if min_value <= int_value <= max_value:
self[field_name] = int_value
else:
logger.warning(
- f"Field '{field_name}': integral float "
+ f"Field '{field_name}': integral value "
f"{field_value} is outside the safely
coercible "
f"range of {field_type}; leaving it unchanged "
f"(schema validation will fail). Consider "
f"casting the column to STRING or DOUBLE (or "
f"LONG for large integers in an INT field)."
)
+ elif field_type == AttributeType.BOOL and isinstance(
+ field_value, numpy.bool_
+ ):
+ # pandas reductions such as .any()/.all() and numpy
+ # comparisons return numpy.bool_, which is not a Python
+ # bool; convert it for BOOL fields. Gated on the BOOL
+ # target type so a numpy integer never lands here.
+ self[field_name] = bool(field_value)
elif field_type == AttributeType.BINARY and not isinstance(
field_value, bytes
):
diff --git a/amber/src/test/python/core/models/test_tuple.py
b/amber/src/test/python/core/models/test_tuple.py
index 3d61fb10f5..b9d4219913 100644
--- a/amber/src/test/python/core/models/test_tuple.py
+++ b/amber/src/test/python/core/models/test_tuple.py
@@ -305,6 +305,176 @@ class TestTuple:
tuple_.finalize(Schema(raw_schema={"payload": "BINARY"}))
assert tuple_["payload"] is None
+ # Pandas-based operators also produce numpy scalar types very naturally:
+ # reductions such as df["x"].sum()/.max()/.count() return numpy.int64, and
+ # df["x"].any() or any numpy comparison returns numpy.bool_. These are NOT
+ # subclasses of Python int/bool, so finalize() must coerce them for
INT/LONG
+ # and BOOL fields the same way it already coerces integral floats — while
+ # still rejecting numpy integers outside the target range and never
crossing
+ # the bool<->int boundary.
+
+ @pytest.mark.parametrize(
+ "raw_value, attr_type, expected",
+ [
+ (np.int64(5), "INTEGER", 5),
+ (np.int32(5), "INTEGER", 5),
+ (np.int64(-7), "INTEGER", -7),
+ # int32 boundaries fit an INT field
+ (np.int64(2**31 - 1), "INTEGER", 2**31 - 1),
+ (np.int64(-(2**31)), "INTEGER", -(2**31)),
+ # an np.int64 that overflows int32 still fits a LONG field
+ (np.int64(3000000000), "LONG", 3000000000),
+ ],
+ )
+ def test_finalize_coerces_numpy_integer_to_int(
+ self, raw_value, attr_type, expected
+ ):
+ tuple_ = Tuple({"count": raw_value})
+ tuple_.finalize(Schema(raw_schema={"count": attr_type}))
+ assert tuple_["count"] == expected
+ assert type(tuple_["count"]) is int
+
+ @pytest.mark.parametrize(
+ "raw_value, expected",
+ [(np.bool_(True), True), (np.bool_(False), False)],
+ )
+ def test_finalize_coerces_numpy_bool_to_bool(self, raw_value, expected):
+ tuple_ = Tuple({"flag": raw_value})
+ tuple_.finalize(Schema(raw_schema={"flag": "BOOLEAN"}))
+ assert tuple_["flag"] == expected
+ assert type(tuple_["flag"]) is bool
+
+ def test_cast_to_schema_coerces_numpy_integer(self):
+ # The coercion must live in cast_to_schema(), mirroring the
+ # integral-float coercion.
+ tuple_ = Tuple({"count": np.int64(5)})
+ tuple_.cast_to_schema(Schema(raw_schema={"count": "INTEGER"}))
+ assert tuple_["count"] == 5
+ assert type(tuple_["count"]) is int
+
+ def test_cast_to_schema_coerces_numpy_bool(self):
+ tuple_ = Tuple({"flag": np.bool_(True)})
+ tuple_.cast_to_schema(Schema(raw_schema={"flag": "BOOLEAN"}))
+ assert tuple_["flag"] is True
+ assert type(tuple_["flag"]) is bool
+
+ @pytest.mark.parametrize(
+ "raw_value",
+ [np.int64(2**31), np.int64(-(2**31) - 1)],
+ )
+ def test_finalize_rejects_out_of_range_numpy_integer(self, raw_value):
+ # An np.int64 outside int32 range must be left unchanged so validation
+ # still fails; it must never silently overflow int32.
+ tuple_ = Tuple({"count": raw_value})
+ with pytest.raises(TypeError, match="Unmatched type"):
+ tuple_.finalize(Schema(raw_schema={"count": "INTEGER"}))
+
+ def test_finalize_rejects_numpy_bool_in_int_field(self):
+ # Coercion must never cross the bool<->int boundary: np.bool_ is not
+ # np.integer, so it must not be coerced into an INT field.
+ tuple_ = Tuple({"count": np.bool_(True)})
+ with pytest.raises(TypeError, match="Unmatched type"):
+ tuple_.finalize(Schema(raw_schema={"count": "INTEGER"}))
+
+ def test_finalize_keeps_plain_bool_in_int_field_unchanged(self):
+ # Pin the pre-existing behavior: a plain Python bool passes INT
+ # validation (bool subclasses int) and is left as a bool.
+ tuple_ = Tuple({"flag": True})
+ tuple_.finalize(Schema(raw_schema={"flag": "INTEGER"}))
+ assert tuple_["flag"] is True
+ assert type(tuple_["flag"]) is bool
+
+ def test_finalize_keeps_plain_bool_unchanged(self):
+ tuple_ = Tuple({"flag": True})
+ tuple_.finalize(Schema(raw_schema={"flag": "BOOLEAN"}))
+ assert tuple_["flag"] is True
+ assert type(tuple_["flag"]) is bool
+
+ def test_finalize_coerces_numpy_scalars_from_pandas_reduction(self):
+ # Mirrors idiomatic pandas UDF output: df["x"].sum() returns
+ # numpy.int64 and (df["x"] > n).any() returns numpy.bool_. Both must be
+ # accepted and stored as Python builtins.
+ df = pandas.DataFrame({"age": [20, 65, 70]})
+ tuple_ = Tuple(
+ {
+ "total_age": df["age"].sum(),
+ "has_senior": (df["age"] > 60).any(),
+ }
+ )
+ assert isinstance(tuple_["total_age"], np.integer)
+ assert isinstance(tuple_["has_senior"], np.bool_)
+ tuple_.finalize(
+ Schema(raw_schema={"total_age": "INTEGER", "has_senior":
"BOOLEAN"})
+ )
+ assert type(tuple_["total_age"]) is int
+ assert tuple_["total_age"] == 155
+ assert type(tuple_["has_senior"]) is bool
+ assert tuple_["has_senior"] is True
+
+ @pytest.mark.parametrize("raw_value", [np.int64(1), np.int64(0)])
+ def test_finalize_rejects_numpy_integer_in_bool_field(self, raw_value):
+ # Symmetric guard to the bool<->int boundary: a numpy integer must
+ # never be coerced into a BOOLEAN field. The BOOL branch is gated on
+ # isinstance(v, numpy.bool_), and numpy.integer is not numpy.bool_.
+ tuple_ = Tuple({"flag": raw_value})
+ with pytest.raises(TypeError, match="Unmatched type"):
+ tuple_.finalize(Schema(raw_schema={"flag": "BOOLEAN"}))
+
+ @pytest.mark.parametrize(
+ "raw_value, expected",
+ [
+ (np.int64(2**53 - 1), 2**53 - 1),
+ (np.int64(-(2**53) + 1), -(2**53) + 1),
+ # beyond the float64 exact-integer window: numpy integers are
+ # exact, so unlike integral floats they are bounded only by the
+ # int64 width of LONG, not by the 2**53 window
+ (np.int64(2**53), 2**53),
+ (np.int64(-(2**53)), -(2**53)),
+ (np.int64(2**62), 2**62),
+ # int64 boundaries
+ (np.int64(2**63 - 1), 2**63 - 1),
+ (np.int64(-(2**63)), -(2**63)),
+ # an in-range uint64 is also a numpy.integer and must be accepted
+ (np.uint64(2**63 - 1), 2**63 - 1),
+ ],
+ )
+ def test_finalize_coerces_large_numpy_integer_to_long(self, raw_value,
expected):
+ tuple_ = Tuple({"count": raw_value})
+ tuple_.finalize(Schema(raw_schema={"count": "LONG"}))
+ assert tuple_["count"] == expected
+ assert type(tuple_["count"]) is int
+
+ def test_finalize_coerces_large_id_numpy_integer_to_long(self):
+ # Real-world regression scenario: database/snowflake IDs (~10**18)
+ # arrive as np.int64 above 2**53 and must coerce to LONG instead of
+ # being rejected by the float64 exact-integer window.
+ tuple_ = Tuple({"id": np.int64(1234567890123456789)})
+ tuple_.finalize(Schema(raw_schema={"id": "LONG"}))
+ assert tuple_["id"] == 1234567890123456789
+ assert type(tuple_["id"]) is int
+
+ def test_finalize_coerces_unsigned_numpy_integer_to_int(self):
+ # Unsigned numpy integers are also numpy.integer, and int() is exact
+ # for them, so an in-range uint must be coerced to a Python int.
+ tuple_ = Tuple({"count": np.uint32(5)})
+ tuple_.finalize(Schema(raw_schema={"count": "INTEGER"}))
+ assert tuple_["count"] == 5
+ assert type(tuple_["count"]) is int
+
+ def test_finalize_rejects_unsigned_numpy_integer_beyond_long_range(self):
+ # A uint64 above int64 max (2**63 - 1) cannot fit a LONG (Arrow int64)
+ # field, so it must be left unchanged and fail validation.
+ tuple_ = Tuple({"count": np.uint64(2**63)})
+ with pytest.raises(TypeError, match="Unmatched type"):
+ tuple_.finalize(Schema(raw_schema={"count": "LONG"}))
+
+ def test_finalize_rejects_numpy_bool_false_in_int_field(self):
+ # Complement to the np.bool_(True) guard: the falsy numpy bool must
+ # also never be coerced into an INT field.
+ tuple_ = Tuple({"count": np.bool_(False)})
+ with pytest.raises(TypeError, match="Unmatched type"):
+ tuple_.finalize(Schema(raw_schema={"count": "INTEGER"}))
+
def test_hash(self):
schema = Schema(
raw_schema={