Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a6147ea
Test UDT monoid identities, aggregator lookups and named monoid unpic…
eriknw Oct 21, 2026
deac332
Test IndexBinaryOp thetas, typed op attributes and old SuiteSparse er…
eriknw Oct 21, 2026
9a2bec7
Test the JIT compiler repair without conda, without xcrun and without…
eriknw Oct 21, 2026
5e1a9db
Test that dtypes.ss.register_new checks its name and typedef
eriknw Oct 21, 2026
51c7861
Test the element count check of array settings in gb.ss.config
eriknw Oct 21, 2026
3392969
Test Matrix.ss.serialize of a UDT whose name is too long for the blob
eriknw Oct 21, 2026
3d05e36
Test Matrix Market I/O with fast_matrix_market installed
eriknw Oct 21, 2026
4df4081
Exclude arms no input reaches from coverage
eriknw Oct 21, 2026
7747523
Test an infix ewise_add of two infix expressions
eriknw Oct 21, 2026
683e4d6
Test gb.ss.diag of a Vector without options
eriknw Oct 21, 2026
e3ff940
Exclude test lines that run only on failure or in a child process
eriknw Oct 21, 2026
748aab8
Pack a hyper_hash back in on every SuiteSparse version
eriknw Oct 21, 2026
a9eb4f4
Test that unary identity and one on a UDT need Numba
eriknw Oct 21, 2026
79aea09
Test deserializing a blob whose integer sizes SuiteSparse cannot read
eriknw Oct 21, 2026
82ae7bd
Test the shape of a full array-UDT import without nrows and ncols
eriknw Oct 21, 2026
daebd9e
Test that the reprs of an apply name its UDT scalar operand
eriknw Oct 22, 2026
f8b307b
Fail the JIT tests instead of skipping them under GRAPHBLAS_REQUIRE_JIT
eriknw Nov 8, 2026
bc87dda
Add a CI job that always compiles the JIT, so its coverage stops swin…
eriknw Nov 8, 2026
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
25 changes: 23 additions & 2 deletions .github/workflows/test_and_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ jobs:
slowtask: pytest_normal
- os: ubuntu-24.04-arm # Linux aarch64
slowtask: pytest_normal
# Not drawn at random: the newest Python and psg from conda-forge, with Numba and
# a C compiler, so the JIT tests run and count for coverage on every run. Its
# GRAPHBLAS_REQUIRE_JIT fails the JIT tests instead of skipping them if it cannot
# compile.
- os: ubuntu-latest
slowtask: pytest_jit
# Uncomment these temporarily for stress testing
# - os: macos-15-intel # macOS x86_64 (Intel)
# slowtask: pytest_bizarro
Expand Down Expand Up @@ -128,6 +134,10 @@ jobs:
# 3.9.16 0_73_pypy
run: |
choices=(3.11 3.12 3.13 3.14 3.15)
if [[ ${{ matrix.slowtask == 'pytest_jit' }} == true ]] ; then
# MAINT: 2026-11-07 the newest Python with conda-forge builds of psg and Numba
choices=(3.15)
fi
selected=${choices[$((RANDOM % ${#choices[@]}))]}
echo "selected: $selected (from: ${choices[*]})"
echo "selected=$selected" >> "$GITHUB_OUTPUT"
Expand All @@ -136,6 +146,9 @@ jobs:
# Remove 'upstream' from the list if it is known to not work.
run: |
choices=(conda-forge wheel source upstream)
if [[ ${{ matrix.slowtask == 'pytest_jit' }} == true ]] ; then
choices=(conda-forge) # psg's wheels are built without the JIT
fi
selected=${choices[$((RANDOM % ${#choices[@]}))]}
echo "selected: $selected (from: ${choices[*]})"
echo "selected=$selected" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -172,6 +185,9 @@ jobs:
exit 1
fi
eval "$picks"
if [[ ${{ matrix.slowtask == 'pytest_jit' }} == true ]] ; then
psgver="" # the newest psg, whichever the picker printed above
fi

# Set up psg conda package variable (conda-forge installs via conda, others via pip).
# For conda-forge, psg must always be installed; an empty psgver just means "latest".
Expand Down Expand Up @@ -220,7 +236,7 @@ jobs:
awkward=awkward${akver}
datashade=""
if [[ ${{ contains(steps.pyver.outputs.selected, 'pypy') }} == true ||
( ${{ matrix.slowtask != 'notebooks'}} == true && (
( ${{ matrix.slowtask != 'notebooks' && matrix.slowtask != 'pytest_jit' }} == true && (
( ${{ matrix.os == 'windows-latest' }} == true && $(python -c 'import random ; print(random.random() < .2)') == True ) ||
( ${{ matrix.os == 'windows-latest' }} == false && $(python -c 'import random ; print(random.random() < .4)') == True ))) ]]
then
Expand Down Expand Up @@ -328,6 +344,8 @@ jobs:
# A hang (e.g. two libomp copies deadlocking in an OpenMP barrier on macOS) would
# otherwise hold the runner for GitHub's 6-hour limit.
timeout-minutes: 120
env:
GRAPHBLAS_REQUIRE_JIT: ${{ matrix.slowtask == 'pytest_jit' && '1' || '' }}
run: |
A=${{ needs.rngs.outputs.mapnumpy == 'A' || '' }} ; B=${{ needs.rngs.outputs.mapnumpy == 'B' || '' }}
C=${{ needs.rngs.outputs.mapnumpy == 'C' || '' }} ; D=${{ needs.rngs.outputs.mapnumpy == 'D' || '' }}
Expand Down Expand Up @@ -358,8 +376,11 @@ jobs:
# pytest ${{ matrix.os != 'windows-latest' && '--forked' || '' }} \ # to investigate crashes
# --color=yes --randomly -v -s ${args} \
coverage run -m pytest --color=yes --randomly -v ${args} \
${{ matrix.slowtask == 'pytest_normal' && '--runslow' || '' }}
${{ matrix.slowtask == 'pytest_normal' && '--runslow' || '' }} \
${{ matrix.slowtask == 'pytest_jit' && '--runslow --backend=suitesparse' || '' }}
- name: Unit tests (bizarro scalars)
# The JIT job runs the suite once; the other jobs cover the C scalar defaults
if: matrix.slowtask != 'pytest_jit'
timeout-minutes: 120
run: |
# Run tests again with Scalars being C scalars by default
Expand Down
6 changes: 4 additions & 2 deletions graphblas/core/dtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ def _get_udt_c_type_info(datatype):
"""
from .operator.udt_codegen import _udt_c_typedef

if datatype.np_type is None:
# Every UDT registered from numpy has its np_type; one from C source never comes here.
if datatype.np_type is None: # pragma: no cover (safety)
return None
return _udt_c_typedef(datatype.name, datatype.np_type)

Expand Down Expand Up @@ -829,7 +830,8 @@ def _dtype_to_explicit_dict(np_type):
else:
base_repr = base.str
return (base_repr, shape)
return np_type.str
# Only records and arrays get here: any other dtype round-trips through its ``.str``.
return np_type.str # pragma: no cover (safety)


def _string_to_dtype(s):
Expand Down
4 changes: 2 additions & 2 deletions graphblas/core/operator/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,15 +291,15 @@ def __del__(self):
if getattr(self, "_gb_obj_owner", None) is not None:
return
gb_obj = getattr(self, "gb_obj", None)
if gb_obj is None or lib is None or ffi is None:
if gb_obj is None or lib is None or ffi is None: # pragma: no cover (defensive)
# Interpreter shutdown can clear ``lib`` / ``ffi`` before
# finalizers run; SS will clean up the handles at process exit.
return
c_type_name = _GB_OBJ_C_TYPENAME.get(self.opclass)
if c_type_name is None: # pragma: no cover (defensive)
return
free_fn = getattr(lib, f"{c_type_name}_free", None)
if free_fn is None:
if free_fn is None: # pragma: no cover (defensive)
# ``GxB_IndexBinaryOp_free`` is absent on SS < 9.4; that build
# also can't allocate one in the first place, so this path is
# unreachable in practice but guarded for safety.
Expand Down
6 changes: 4 additions & 2 deletions graphblas/core/operator/indexbinary.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,9 @@ def indexbinary_wrapper(
raise UdfParseError("Unable to parse function using Numba")

def _compile_udt(self, dtype, dtype2, theta_type=None):
if not _has_idxbinop:
# ``_build`` refuses to make an IndexBinaryOp without this, so it guards one
# made some other way.
if not _has_idxbinop: # pragma: no cover (safety)
# KeyError (not RuntimeError) so ``udt in op`` and the resolver
# chain in ``__contains__`` / ``[dtype]`` lookups return cleanly.
raise KeyError(
Expand Down Expand Up @@ -691,7 +693,7 @@ def register_new(

@classmethod
def _initialize(cls):
if cls._initialized:
if cls._initialized: # pragma: no cover (safety)
return
super()._initialize(include_in_ops=False)
# No built-in IndexBinaryOps to register.
Expand Down
5 changes: 3 additions & 2 deletions graphblas/core/operator/monoid.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ def _scalar_identity(monoid_name, scalar_dtype):
return scalar_dtype.type(0)
if monoid_name == "times":
return scalar_dtype.type(1)
if monoid_name not in {"min", "max"}:
# Only the names in ``_BUILTIN_UDT_MONOIDS`` get here.
if monoid_name not in {"min", "max"}: # pragma: no cover (safety)
return None
# Not ``np.issubdtype(scalar_dtype, np.integer)``: numpy files timedelta64
# under ``np.integer``, and ``np.iinfo`` rejects it.
Expand Down Expand Up @@ -595,7 +596,7 @@ def _initialize(cls):
if _has_numba:
for name in _BUILTIN_UDT_MONOIDS:
mon = getattr(monoid, name, None)
if mon is not None:
if mon is not None: # pragma: no branch (safety)
mon._udt_types = {}
mon._udt_ops = {}
cls._initialized = True
Expand Down
2 changes: 1 addition & 1 deletion graphblas/core/operator/unary.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ def _initialize(cls):
if _has_numba:
for op_name in _BUILTIN_UDT_UNARY_OPS:
unop = getattr(unary, op_name, None)
if unop is not None:
if unop is not None: # pragma: no branch (safety)
unop._udt_types = {}
unop._udt_ops = {}
cls._initialized = True
Expand Down
3 changes: 2 additions & 1 deletion graphblas/core/operator/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala
return op[
unify(dtype, dtype2, is_left_scalar=is_left_scalar, is_right_scalar=is_right_scalar)
]
except (TypeError, AttributeError):
# Two built-in types always unify, and a UDT beside any other type returned above.
except (TypeError, AttributeError): # pragma: no cover (safety)
# Failure to unify implies a dtype is UDT; some builtin operators can handle UDTs
if op.is_positional:
return op[UINT64]
Expand Down
3 changes: 2 additions & 1 deletion graphblas/core/ss/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ def _resolve_serialized_dtype(data_obj, data_nbytes, obj_kind):
raise _error_code_lookup[info](f"{obj_kind} deserialize failed to get the size of name")
dtype_char = ffi.new(f"char[{dtype_size[0]}]")
info = lib.GxB_Serialized_get_String(data_obj, dtype_char, lib.GrB_NAME, data_nbytes)
if info != lib.GrB_SUCCESS:
# This checks the blob as GxB_Serialized_get_SIZE just did, so it cannot fail now.
if info != lib.GrB_SUCCESS: # pragma: no cover (safety)
raise _error_code_lookup[info](f"{obj_kind} deserialize failed to get the name")
return _string_to_dtype(ffi.string(dtype_char).decode())
# No GrB_NAME fallback available (older SS); surface the original
Expand Down
4 changes: 2 additions & 2 deletions graphblas/core/ss/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def __getitem__(self, key):
if ctype in self._int32_ctypes:
ctype = "int32_t"
get_function_name = f"{get_function_base}_INT32"
elif ctype.startswith("int64_t"):
elif ctype.startswith("int64_t"): # pragma: no cover (unused)
get_function_name = f"{get_function_base}_INT64"
elif ctype.startswith("double"):
get_function_name = f"{get_function_base}_FP64"
Expand Down Expand Up @@ -155,7 +155,7 @@ def __setitem__(self, key, val):
set_function_name = f"{set_function_base}_INT32"
elif ctype == "double":
set_function_name = f"{set_function_base}_FP64"
elif ctype.startswith("int64_t["):
elif ctype.startswith("int64_t["): # pragma: no cover (unused)
set_function_name = f"{set_function_base}_INT64_ARRAY"
elif ctype.startswith("double["):
set_function_name = f"{set_function_base}_FP64_ARRAY"
Expand Down
1 change: 0 additions & 1 deletion graphblas/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,6 @@ def get_shape(nrows, ncols, dtype=None, **arrays):
# We could be smarter and determine the shape of the dtype sub-arrays
if arr.ndim >= 3:
break
# BRANCH NOT COVERED
elif arr.ndim == 2:
break
else:
Expand Down
2 changes: 1 addition & 1 deletion graphblas/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ class NoJITWarning(UserWarning):
GrB_NO_VALUE = _lib.GrB_NO_VALUE

# SuiteSparse-specific errors
if hasattr(_lib, "GxB_EXHAUSTED"):
if hasattr(_lib, "GxB_EXHAUSTED"): # pragma: no branch (safety)
_error_code_lookup[_lib.GxB_EXHAUSTED] = StopIteration
if hasattr(_lib, "GxB_JIT_ERROR"): # Added in 9.4
_error_code_lookup[_lib.GxB_JIT_ERROR] = JitError
Expand Down
13 changes: 13 additions & 0 deletions graphblas/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,19 @@ def compute(x):
return x


def skip_without_jit(reason):
"""Skip a test the SuiteSparse JIT cannot run here, or fail it where it must run.

CI's pytest_jit job sets GRAPHBLAS_REQUIRE_JIT, so a setup there that stops
compiling turns the job red instead of skipping the JIT tests and losing their
coverage.
"""
__tracebackhide__ = True # report the skip at the test's line, not this one
if os.environ.get("GRAPHBLAS_REQUIRE_JIT"):
pytest.fail(f"GRAPHBLAS_REQUIRE_JIT is set, but {reason}", pytrace=False)
pytest.skip(reason)


def shouldhave(module, opname):
"""Whether an "operator" module should have the given operator."""
return supports_udfs or hasattr(module, opname)
Expand Down
4 changes: 3 additions & 1 deletion graphblas/tests/test_dtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from graphblas.core.utils import _NP2, values_to_numpy_buffer
from graphblas.dtypes import lookup_dtype

from .conftest import skip_without_jit

suitesparse = gb.backend == "suitesparse"
is_win = sys.platform.startswith("win")

Expand Down Expand Up @@ -603,7 +605,7 @@ def test_udt_layouts_under_one_name_get_their_own_jit_c_names():
gb.ss.config["jit_c_control"] = "on"
try:
if not _enable_jit_for_udt():
pytest.skip("the JIT cannot compile here (no usable C compiler)")
skip_without_jit("the JIT cannot compile here (no usable C compiler)")
for udt in [small, big]:
shape = udt.np_type.shape
one = np.full(shape, 1, np.int16)
Expand Down
38 changes: 37 additions & 1 deletion graphblas/tests/test_indexbinary.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ def add_theta(x, ix, jx, y, iy, jy, theta): # pragma: no cover (numba)

op = indexbinary.register_anonymous(add_theta)
typed = op[int]
assert typed.orig_func is add_theta
assert typed._numba_func is op._numba_func
binop = typed(10)
assert binop.opclass == "BinaryOp"

Expand Down Expand Up @@ -209,6 +211,10 @@ def add_theta(x, ix, jx, y, iy, jy, theta): # pragma: no cover (numba)
B = Matrix.from_coo([0], [0], [5])
C = A.ewise_mult(B, binop).new()
assert C.to_coo()[2][0] == 8 # 3 + 5 + 0
# Binding no theta binds 0, to the typed op or the op given a dtype
for binop in [op[int](), op(dtype=int)]:
assert binop.type == dtypes.INT64
assert A.ewise_mult(B, binop).new().isequal(C, check_dtype=True)


def test_bool_return():
Expand Down Expand Up @@ -236,6 +242,14 @@ def add_theta(x, ix, jx, y, iy, jy, theta): # pragma: no cover (numba)
B = Matrix.from_coo([0], [0], [5])
C = A.ewise_mult(B, binop).new()
assert C.to_coo()[2][0] == 50 # 3 + 5 + 42
# A C scalar or a GraphBLAS scalar, to the typed op or the op typed when it runs
for theta in [
Scalar.from_value(42, is_cscalar=True),
Scalar.from_value(42, is_cscalar=False), # pragma: is_grbscalar
]:
assert op[int](theta).type == dtypes.INT64
for binop in [op[int](theta), op(theta)]:
assert A.ewise_mult(B, binop).new().isequal(C, check_dtype=True)


def test_parameterized():
Expand Down Expand Up @@ -435,6 +449,12 @@ def theta_only(x, ix, jx, y, iy, jy, theta): # pragma: no cover (numba)
# Its array UDT is shared, under the name it got first (another test's)
with pytest.raises(KeyError, match="_ibo_add_theta does not work with "):
indexbinary.register_anonymous(_ibo_add_theta)(np.array([1, 2, 3]))
# With the dtype known, a value that is not one raises its own error
rec_udt = dtypes.register_anonymous(np.dtype([("rnd_a", np.int64)]), "_RawThetaRec")
with pytest.raises(TypeError, match="not 'object'"):
op[rec_udt](object())
with pytest.raises(TypeError, match="not 'object'"):
op(object(), dtype=rec_udt)
finally:
delattr(indexbinary, "raw_no_dtype_op")

Expand Down Expand Up @@ -547,7 +567,7 @@ def test_bound_ibo_memory_bounded():
pytest.importorskip("resource")
import resource

if hasattr(indexbinary, "leak_test_bound_ibo_op"):
if hasattr(indexbinary, "leak_test_bound_ibo_op"): # pragma: no cover (defensive)
delattr(indexbinary, "leak_test_bound_ibo_op")
indexbinary.register_new("leak_test_bound_ibo_op", _ibo_add_theta)
op = indexbinary.leak_test_bound_ibo_op
Expand Down Expand Up @@ -648,6 +668,22 @@ def add_vals(x, ix, jx, y, iy, jy, theta): # pragma: no cover (numba)
expr()


def test_unbound_op_is_not_an_operator():
"""An IndexBinaryOp is an operator once a theta is bound to it."""

def add_vals(x, ix, jx, y, iy, jy, theta): # pragma: no cover (numba)
return x + y + theta

v = Vector.from_coo([0, 1], [1, 2])
A = Matrix.from_coo([0, 1], [1, 0], [1, 2])
for is_udt in [False, True]:
op = indexbinary.register_anonymous(add_vals, is_udt=is_udt)
with pytest.raises(TypeError, match="Bad type for argument `op` in Vector.reduce"):
v.reduce(op)
with pytest.raises(TypeError, match="Bad type for argument `op` in Matrix.reduce_rowwise"):
A.reduce_rowwise(op)


def test_with_mask():
def add_vals(x, ix, jx, y, iy, jy, theta): # pragma: no cover (numba)
return x + y + theta
Expand Down
5 changes: 5 additions & 0 deletions graphblas/tests/test_infix.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,11 @@ def test_multi_infix_vector():
assert result.isequal(expected)
result = binary.plus(v1 | (v2 | v3)).new()
assert result.isequal(expected)
# Both operands an expression: v1 + v2 is [1, 3, 2] and v2 + v3 is [2, 1, 3]
result = binary.plus((v1 | v2) | (v2 | v3)).new()
assert result.isequal(Vector.from_coo([0, 1, 2], [3, 4, 5]), check_dtype=True)
result = binary.plus((v1 | v2) | (v2 | v3.dup(float))).new()
assert result.isequal(Vector.from_coo([0, 1, 2], [3.0, 4.0, 5.0]), check_dtype=True)
result = monoid.min(v1 | v2 | v3).new()
expected = Vector.from_scalar(1, size=3)
assert result.isequal(expected)
Expand Down
Loading
Loading