From a6147eaecd9327a3807b43e6157775fd91cc0ba1 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 10:36:09 -0500 Subject: [PATCH 01/18] Test UDT monoid identities, aggregator lookups and named monoid unpickling A bool field takes min's and max's identity as logical and/or; a date or text field has none, so the monoid names the field and the aggregator does not work with the UDT; a user monoid without an identity says so. A named monoid unpickled where its name is gone is registered again with its identity and is_idempotent. agg.sum[udt].jit_c_name follows its monoid. The name check in _scalar_identity and the initializer's lookup, which no input reaches, get pragmas with the reason. --- graphblas/core/operator/monoid.py | 5 ++-- graphblas/tests/test_op.py | 39 +++++++++++++++++++++++++++++++ graphblas/tests/test_pickle.py | 28 ++++++++++++++++++++++ graphblas/tests/test_ssjit.py | 2 ++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/graphblas/core/operator/monoid.py b/graphblas/core/operator/monoid.py index e6d2ce07e..bae9c9db6 100644 --- a/graphblas/core/operator/monoid.py +++ b/graphblas/core/operator/monoid.py @@ -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. @@ -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 diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index 32bf229c7..90edb55a7 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -7046,6 +7046,45 @@ def test_udt_aggregators(): np.testing.assert_array_equal(a.reduce(agg.min).new().value, [1.0, 2.0, 3.0]) +@pytest.mark.skipif("not supports_udfs") +def test_udt_monoid_identity_follows_each_field_type(): + # A bool field counts as 0 or 1: min (logical and) starts from True, max from False. + flags = dtypes.register_anonymous( + np.dtype([("idf_flag", np.bool_), ("idf_count", np.int16)]), "_IdentityFlags" + ) + assert monoid.min[flags].identity.value.tolist() == (True, np.iinfo(np.int16).max) + assert monoid.max[flags].identity.value.tolist() == (False, np.iinfo(np.int16).min) + v = Vector(flags, size=3) + v[0] = (True, 3) + v[1] = (False, 5) + assert v.reduce(agg.min).new().value.tolist() == (False, 3) + assert v.reduce(agg.max).new().value.tolist() == (True, 5) + + # binary.min compares dates and text, but no identity is generated for them, so the + # monoid names the field, and the aggregator built on it does not work with the UDT. + for np_type, field in [ + (np.dtype([("idf_when", "M8[s]"), ("idf_n", np.int32)]), "idf_when"), + (np.dtype([("idf_label", "S4"), ("idf_m", np.int32)]), "idf_label"), + ]: + udt = dtypes.register_anonymous(np_type) + with pytest.raises(KeyError, match=f"field '{field}' has unsupported dtype"): + monoid.min[udt] + with pytest.raises(KeyError, match="min does not work with"): + agg.min[udt] + assert udt not in agg.min + + # Without an identity, a monoid on a user op cannot be made. + def add_pairs(x, y): # pragma: no cover (numba) + return (x["idf_flag"] | y["idf_flag"], x["idf_count"] + y["idf_count"]) + + no_identity = Monoid.register_anonymous( + BinaryOp.register_anonymous(add_pairs, is_udt=True), None + ) + with pytest.raises(KeyError, match="no identity value"): + no_identity[flags] + assert flags not in no_identity + + @pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_udt_lazy_registration(): diff --git a/graphblas/tests/test_pickle.py b/graphblas/tests/test_pickle.py index 3659b666c..8a4635dba 100644 --- a/graphblas/tests/test_pickle.py +++ b/graphblas/tests/test_pickle.py @@ -206,6 +206,34 @@ def check_values(d): assert d["replace"] is gb.replace +def binaryor(x, y): + return x | y # pragma: no cover (numba) + + +@pytest.mark.skipif("not supports_udfs") +def test_named_monoid_reregisters_on_unpickle(): + # A named monoid unpickled where its name is not registered is registered again, with + # its identity and is_idempotent. The name is removed when done, since + # test_operator_types enumerates the operator namespaces. + name = "_monoid_reregistered" + binop = gb.core.operator.BinaryOp.register_anonymous(binaryor) + try: + named = gb.core.operator.Monoid.register_new(name, binop, 0, is_idempotent=True) + payload = pickle.dumps(named) + assert pickle.loads(payload) is named + delattr(gb.monoid, name) + vars(gb.op).pop(name) + new = pickle.loads(payload) + assert new is not named + assert getattr(gb.monoid, name) is new + assert new.is_idempotent + assert new[int].identity == 0 + assert gb.Vector.from_coo([0, 2], [3, 5]).reduce(new).new() == 7 + finally: + vars(gb.monoid).pop(name, None) + vars(gb.op).pop(name, None) + + def unarypickle_par(x): def inner(y): return x + y # pragma: no cover (numba) diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index 573d0f236..150f3da91 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -1375,6 +1375,8 @@ def test_udt_jit_c_source_introspection(): # Monoid-based aggregator walks to its monoid; composite agg has no kernel. assert gb.agg.sum[udt].jit_c_source == plus_binop_src assert gb.agg.count[udt].jit_c_source is None + assert gb.agg.sum[udt].jit_c_name == "plus_gbudt__IntrospectUDT" + assert gb.agg.count[udt].jit_c_name is None @pytest.mark.skipif("not supports_udfs") From deac332216fa3d51e2f0af787c66fabc7678a6c6 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 10:40:16 -0500 Subject: [PATCH 02/18] Test IndexBinaryOp thetas, typed op attributes and old SuiteSparse errors Binding no theta binds 0, through the typed op or the op given a dtype; a C scalar or GraphBLAS scalar theta binds through either; a UDT-typed op given a value that is not one raises that value's TypeError. The typed op reports its parent's function. An op with no theta bound, UDT or not, is refused as an operator by reduce. Before SuiteSparse:GraphBLAS 9.4 both register_anonymous and indexbinary.ss.register_new say what is missing, and gb.indexbinary.ss is absent on the vanilla backend. The arms no IndexBinaryOp can reach, and a test's re-run cleanup, get pragmas with the reason. --- graphblas/core/operator/indexbinary.py | 6 ++-- graphblas/tests/test_indexbinary.py | 38 +++++++++++++++++++++++++- graphblas/tests/test_op.py | 3 ++ graphblas/tests/test_ssjit.py | 18 ++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/graphblas/core/operator/indexbinary.py b/graphblas/core/operator/indexbinary.py index d9c427d65..41f7e3b3d 100644 --- a/graphblas/core/operator/indexbinary.py +++ b/graphblas/core/operator/indexbinary.py @@ -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( @@ -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. diff --git a/graphblas/tests/test_indexbinary.py b/graphblas/tests/test_indexbinary.py index 857160282..196787d17 100644 --- a/graphblas/tests/test_indexbinary.py +++ b/graphblas/tests/test_indexbinary.py @@ -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" @@ -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(): @@ -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(): @@ -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") @@ -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 @@ -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 diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index 90edb55a7..de750073f 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -7279,6 +7279,7 @@ def test_op_ss(): gb.semiring.ss.max_secondj gb.op.ss.positionj gb.agg.ss.argmin + gb.indexbinary.ss.register_new else: with pytest.raises(AttributeError, match="suitesparse"): gb.unary.ss @@ -7290,6 +7291,8 @@ def test_op_ss(): gb.op.ss with pytest.raises(AttributeError, match="suitesparse"): gb.agg.ss + with pytest.raises(AttributeError, match="suitesparse"): + gb.indexbinary.ss def test_deprecated(): diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index 150f3da91..d9d976c7c 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -1076,6 +1076,24 @@ def test_jit_indexunary(v): assert ("FP32", "FP64") not in diffy +@pytest.mark.skipif(_has_idxbinop, reason="SuiteSparse:GraphBLAS has IndexBinaryOp") +def test_indexbinary_needs_suitesparse_9_4(): + """Before SuiteSparse:GraphBLAS 9.4, both ways to make an IndexBinaryOp say what is missing.""" + cdef = ( + "void ibo_first (double *z, double *x, GrB_Index ix, GrB_Index jx, " + "double *y, GrB_Index iy, GrB_Index jy, double *theta) { (*z) = (*x) ; }" + ) + with pytest.raises(RuntimeError, match="requires SuiteSparse:GraphBLAS 9.4"): + indexbinary.ss.register_new("ibo_first", cdef, "FP64", "FP64", "FP64", "FP64") + if supports_udfs: + + def ibo_first(x, ix, jx, y, iy, jy, theta): # pragma: no cover (numba) + return x + + with pytest.raises(RuntimeError, match="requires SuiteSparse:GraphBLAS 9.4"): + indexbinary.register_anonymous(ibo_first) + + @pytest.mark.slow @pytest.mark.skipif(not _has_idxbinop, reason="requires SuiteSparse:GraphBLAS 9.4+") def test_jit_indexbinary(v): From 9a2bec7c73874c1e5d01fe4ea8eede81e5bfa9c9 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 10:43:19 -0500 Subject: [PATCH 03/18] Test the JIT compiler repair without conda, without xcrun and without probing fix_jit_config(probe=False) enables the JIT without trying a compile. Without CONDA_PREFIX the compiler, flags and libraries come from sysconfig, and a Python that records no compiler gets None and an untouched config. A baked-in -isysroot that does not exist takes xcrun's SDK, or is dropped when there is no xcrun. The import-time repair leaves a SuiteSparse with no JIT options alone, and an error the control does not explain is re-raised as it came. All run against a stand-in config. The no-environment test drops CONDA_PREFIX with monkeypatch rather than by hand, so it has no branch that depends on the environment. --- graphblas/tests/test_ssjit.py | 125 +++++++++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 8 deletions(-) diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index d9d976c7c..ef7ae4bcc 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -803,24 +803,133 @@ def test_public_fix_jit_config_repairs_a_broken_compiler(): @pytest.mark.skipif("_IS_SSGB7") -def test_public_fix_jit_config_returns_none_without_env(): +def test_public_fix_jit_config_returns_none_without_env(monkeypatch): """With no ``CONDA_PREFIX`` and ``use_sysconfig=False``, the helper signals no-environment.""" - if os.environ.get("CONDA_PREFIX"): - # Drop the env for this call only. - prev_env = os.environ.pop("CONDA_PREFIX") - else: - prev_env = None + monkeypatch.delenv("CONDA_PREFIX", raising=False) prev_cc = gb.ss.config["jit_c_compiler_name"] try: gb.ss.config["jit_c_compiler_name"] = "/nonexistent/path/to/cc" result = gb.ss.fix_jit_config(use_sysconfig=False) assert result is None finally: - if prev_env is not None: - os.environ["CONDA_PREFIX"] = prev_env gb.ss.config["jit_c_compiler_name"] = prev_cc +@pytest.mark.skipif("_IS_SSGB7") +def test_fix_jit_config_takes_the_compiler_from_sysconfig(monkeypatch): + """Without ``CONDA_PREFIX``, the compiler is the one Python was built with. + + Driven through a stand-in config and ``sysconfig``, as for a pip-installed + Python, and with ``probe=False``, which repairs and enables without trying + a compile. + """ + import sysconfig + + from graphblas.core.ss import jit_config + + monkeypatch.delenv("CONDA_PREFIX", raising=False) + monkeypatch.setattr(jit_config, "_jit_enabled_for_udt", None) + monkeypatch.setattr(jit_config, "_warned_no_jit_for", set()) + probes = [] + monkeypatch.setattr(jit_config, "_probe_jit", probes.append) + monkeypatch.setattr(sysconfig, "get_path", {"include": "/py/include"}.get) + baked = { + "jit_c_control": "run", + "jit_c_compiler_name": "/build/host/cc", + "jit_c_compiler_flags": "-O2", + "jit_c_libraries": "-lm", + } + for config_vars, expected in [ + ( + {"CC": "gcc -pthread", "CFLAGS": "-O3", "LIBS": "-ldl"}, + {"jit_c_compiler_name": "gcc -pthread", "jit_c_compiler_flags": "-O3 -I/py/include"}, + ), + # Without LIBS, the libraries SuiteSparse was built with stay. + ({"CC": "cc", "CFLAGS": "-O1"}, {"jit_c_compiler_name": "cc"}), + ]: + fake = dict(baked) + monkeypatch.setattr(jit_config, "_ss_config", lambda fake=fake: fake) + monkeypatch.setattr(sysconfig, "get_config_var", config_vars.get) + assert gb.ss.fix_jit_config(probe=False) is True + assert fake["jit_c_control"] == "on" + assert fake["jit_c_compiler_name"] == expected["jit_c_compiler_name"] + assert fake["jit_c_compiler_flags"].startswith(config_vars["CFLAGS"]) + assert fake["jit_c_libraries"] == config_vars.get("LIBS", "-lm") + assert probes == [] + # A Python that records no compiler (Windows) has none to offer. + fake = dict(baked) + monkeypatch.setattr(jit_config, "_ss_config", lambda: fake) + monkeypatch.setattr(sysconfig, "get_config_var", {"CFLAGS": "-O3"}.get) + assert gb.ss.fix_jit_config() is None + assert fake == baked + + +@pytest.mark.skipif("_IS_SSGB7") +def test_fix_jit_config_replaces_a_missing_sdk(monkeypatch, tmp_path): + """A baked-in ``-isysroot`` that does not exist here takes xcrun's SDK, or goes. + + Without xcrun (Linux, or macOS without the command line tools) the flag is + dropped. Driven through a stand-in config, with the compiler taken from + a stand-in ``CONDA_PREFIX``. + """ + import subprocess + + from graphblas.core.ss import jit_config + + (tmp_path / "bin").mkdir() + (tmp_path / "bin" / "cc").touch() + monkeypatch.setenv("CONDA_PREFIX", str(tmp_path)) + monkeypatch.setattr(jit_config, "_jit_enabled_for_udt", None) + monkeypatch.setattr(jit_config, "_warned_no_jit_for", set()) + + def no_xcrun(*args, **kwargs): + raise FileNotFoundError("xcrun") + + for check_output, flags in [ + ( + lambda *args, **kwargs: "/Library/SDKs/MacOSX.sdk\n", + "-O2 -isysroot /Library/SDKs/MacOSX.sdk -fPIC", + ), + (no_xcrun, "-O2 -fPIC"), + ]: + fake = { + "jit_c_control": "run", + "jit_c_compiler_name": "/build/host/bin/cc", + "jit_c_compiler_flags": "-O2 -isysroot /build/host/MacOSX.sdk -fPIC", + } + monkeypatch.setattr(jit_config, "_ss_config", lambda fake=fake: fake) + monkeypatch.setattr(subprocess, "check_output", check_output) + assert gb.ss.fix_jit_config(probe=False) is True + assert fake["jit_c_compiler_name"] == str(tmp_path / "bin" / "cc") + assert fake["jit_c_compiler_flags"].split() == flags.split() + + +@pytest.mark.skipif("_IS_SSGB7") +def test_auto_fix_jit_at_import_without_jit_options(monkeypatch): + """A SuiteSparse with no JIT options (7.x) is left as it is at import.""" + from graphblas.core.ss import jit_config + + fake = {"nthreads": 1} + monkeypatch.setattr(jit_config, "_ss_config", lambda: fake) + jit_config._auto_fix_jit_at_import() + assert fake == {"nthreads": 1} + + +@pytest.mark.skipif("_IS_SSGB7") +def test_compiling_c_source_reraises_an_error_the_control_does_not_explain(monkeypatch): + """A failure while the JIT stays on is not about compiling, so it is raised as it came.""" + from graphblas.core.ss import jit_config + from graphblas.exceptions import NullPointer + + fake = {"jit_c_control": "on"} + monkeypatch.setattr(jit_config, "_ss_config", lambda: fake) + monkeypatch.setattr(jit_config, "_jit_enabled_for_udt", True) + error = NullPointer("from SuiteSparse") + with pytest.raises(NullPointer) as info, jit_config._compiling_c_source("gb.unary.ss.op"): + raise error + assert info.value is error + + @pytest.fixture def v(): return Vector.from_coo([1, 3, 4, 6], [1, 1, 2, 0]) From 5e1a9db213d9c276b8a4f7ce57186b58a537d926 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 10:43:41 -0500 Subject: [PATCH 04/18] Test that dtypes.ss.register_new checks its name and typedef A name that is not an identifier, a typedef that does not declare the name, and a typedef of something other than a struct are refused with ValueError before SuiteSparse is asked to compile anything, and register nothing. --- graphblas/tests/test_ssjit.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index ef7ae4bcc..dce459f10 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -1019,6 +1019,19 @@ def test_jit_udt_name_leaves_room_for_the_nul(): dtypes.ss.register_new(name, f"typedef struct {{ int64_t a ; }} {name} ;") +@pytest.mark.skipif("_IS_SSGB7") +def test_jit_udt_name_and_typedef_are_checked(): + """The name and the C typedef are checked before SuiteSparse is asked to compile them.""" + with pytest.raises(ValueError, match="must be a valid Python identifier; got: 'chk-dash'"): + dtypes.ss.register_new("chk-dash", "typedef struct { int64_t a ; } chk_dash ;") + with pytest.raises(ValueError, match="must be same name as the typedef"): + dtypes.ss.register_new("chk_named", "typedef struct { int64_t a ; } chk_other ;") + with pytest.raises(ValueError, match="Only struct typedefs"): + dtypes.ss.register_new("chk_scalar", "typedef int64_t chk_scalar ;") + assert not hasattr(dtypes.ss, "chk_named") + assert not hasattr(dtypes.ss, "chk_scalar") + + def test_jit_unary(v): cdef = "void square (float *z, float *x) { (*z) = (*x) * (*x) ; } ;" if _IS_SSGB7: From 51c7861912281c71c8e8661e34039652e2cc9572 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 10:44:07 -0500 Subject: [PATCH 05/18] Test the element count check of array settings in gb.ss.config Setting bitmap_switch to the wrong number of values raises ValueError and leaves the setting as it was. This replaces the commented-out check on memory_pool, which is no longer an option. No option has an int64_t type since then, so those get and set arms get pragmas. --- graphblas/core/ss/config.py | 4 ++-- graphblas/tests/test_ss_utils.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/graphblas/core/ss/config.py b/graphblas/core/ss/config.py index 4d2a20573..b80f89872 100644 --- a/graphblas/core/ss/config.py +++ b/graphblas/core/ss/config.py @@ -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" @@ -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" diff --git a/graphblas/tests/test_ss_utils.py b/graphblas/tests/test_ss_utils.py index e76f6e45d..fab780ac1 100644 --- a/graphblas/tests/test_ss_utils.py +++ b/graphblas/tests/test_ss_utils.py @@ -300,8 +300,10 @@ def test_global_config(monkeypatch): else: with pytest.raises(ValueError, match="Unable to set default value for"): config[k] = None - # with pytest.raises(ValueError, match="Wrong number"): - # config["memory_pool"] = [1, 2] # No longer used + bitmap_switch = config["bitmap_switch"] + with pytest.raises(ValueError, match="Wrong number of elements when setting 'bitmap_switch'"): + config["bitmap_switch"] = [0.5, 0.5] + assert config["bitmap_switch"] == bitmap_switch assert "format" in repr(config) From 339296937b73a1c7470a6d5a57c320f8817d8952 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 12:35:52 -0500 Subject: [PATCH 06/18] Test Matrix.ss.serialize of a UDT whose name is too long for the blob As test_vector.py's test_udt does for Vector: an anonymous UDT with a long numpy repr round-trips given dtype=, and a named one is found by its registered name. --- graphblas/tests/test_matrix.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index e6a253de6..65d738a00 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -4189,6 +4189,39 @@ def test_udt(): A[[0, 1], :] = [[2, 3, 4], [5, 6, 7]] +@pytest.mark.skipif("not suitesparse") +def test_ss_serialize_udt_with_a_long_name(): + # The numpy repr of these UDTs is too long to keep in a serialized blob. + def register(register, *args): + if hasattr(lib, "GrB_Type_set_String"): + return register(*args) + with pytest.warns(UserWarning, match="too large"): + return register(*args) + + def assert_same(A, B): + # Compared by the arrays, since isequal on a record UDT needs Numba. + assert B.dtype == A.dtype + for a, b in zip(A.to_coo(), B.to_coo(), strict=True): + assert_array_equal(a, b) + + long_dtype = np.dtype([("x", np.bool_), ("z" * 1000, np.float64)], align=True) + long_udt = register(dtypes.register_anonymous, long_dtype) + A = Matrix(long_udt, nrows=2, ncols=3) + A[0, 0] = 0 + A[1, 2] = (1, 5.6) + assert_same(A, Matrix.ss.deserialize(A.ss.serialize(), dtype=long_udt)) + # An anonymous one needs dtype= (see test_vector.py's test_udt for the errors) + with pytest.raises(SyntaxError if hasattr(lib, "GxB_Serialized_get_String") else ValueError): + Matrix.ss.deserialize(A.ss.serialize()) + # A named one is found by its name + named_long_dtype = np.dtype([("x", np.bool_), ("z" * 1000, np.float64)], align=False) + named_long_udt = register(dtypes.register_new, "LongMatrixUDT", named_long_dtype) + A = Matrix(named_long_udt, nrows=2, ncols=3) + A[0, 0] = 0 + A[1, 2] = (1, 5.6) + assert_same(A, Matrix.ss.deserialize(A.ss.serialize())) + + def test_reposition(A): rows, cols, values = A.to_coo() rows = rows.astype(int) From 3d05e363630560c9446f400231e55535bebe97de Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 12:36:44 -0500 Subject: [PATCH 07/18] Test Matrix Market I/O with fast_matrix_market installed CI does not install fast_matrix_market, so the engine was never used. A stand-in module that reads and writes with scipy plays it: engine "auto" and "fmm" both use it, each with its deprecation warning, and a version before 1.7 is given a scipy sparse matrix to write rather than an array. --- graphblas/tests/test_io.py | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 2cf57ff67..5c3a9382c 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -730,6 +730,59 @@ def test_matrix_market_fmm_engine_unavailable(): gb.io.mmread(BytesIO(), engine="fast_matrix_market") +@pytest.mark.skipif("not ss") +def test_matrix_market_fmm_engine_installed(monkeypatch): + """With fast_matrix_market installed, "auto" and "fmm" use it and warn that it is deprecated. + + A stand-in module plays the package, which CI does not install: it reads + and writes with scipy and records what it was given. A version before 1.7 + cannot write the sparse arrays of scipy 1.11 and later, so it gets a sparse + matrix. + """ + import types + + import scipy as sp + import scipy.io as sio + + calls = [] + + def stub_mmread(source, **kwargs): + calls.append("mmread") + return gb.io.to_scipy_sparse(gb.io.mmread(source, engine="scipy"), "coo") + + def stub_mmwrite(target, a, **kwargs): + calls.append(a) + sio.mmwrite(target, a, **kwargs) + + fmm_stub = types.ModuleType("fast_matrix_market") + fmm_stub.mmread = stub_mmread + fmm_stub.mmwrite = stub_mmwrite + monkeypatch.setitem(sys.modules, "fast_matrix_market", fmm_stub) + A = gb.Matrix.from_coo([0, 0, 3, 5], [1, 4, 0, 2], [1, 0, 2, -1], nrows=7, ncols=6) + scipy_has_sparse_arrays = tuple(map(int, sp.__version__.split(".")[:2])) >= (1, 11) + for version, written_type in [ + ("1.7.0", type(gb.io.to_scipy_sparse(A, "coo"))), + ("1.6.0", ss.coo_matrix if scipy_has_sparse_arrays else None), + ]: + fmm_stub.__version__ = version + for engine, match in [ + ("auto", "fast_matrix_market is installed but is no longer maintained"), + ("fmm", "fast_matrix_market is no longer maintained"), + ]: + calls.clear() + mm = BytesIO() + with pytest.warns(DeprecationWarning, match=match): + gb.io.mmwrite(mm, A, engine) + mm.seek(0) + with pytest.warns(DeprecationWarning, match=match): + M = gb.io.mmread(mm, engine) + assert M.isequal(A, check_dtype=True) + written, read = calls + assert read == "mmread" + if written_type is not None: + assert type(written) is written_type + + @pytest.mark.skipif("not ss") def test_scipy_sparse(): a = np.arange(12).reshape(3, 4) From 4df4081d9fa7aa760f9a22332d72de9661cff479 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 12:38:26 -0500 Subject: [PATCH 08/18] Exclude arms no input reaches from coverage Each gets a pragma with its reason, and a comment where the reason is not already written next to it: - dtypes: a UDT's C typedef is asked for only with its numpy type, and the explicit layout dict recurses only into records and arrays; - get_typed_op: two built-in types always unify, and a UDT beside another type returns before that; - typed op finalizer: the shutdown and no-free-function guards; - the GxB_EXHAUSTED check and the lookup of unary.abs and unary.ainv, which every supported build has. --- graphblas/core/dtypes.py | 6 ++++-- graphblas/core/operator/base.py | 4 ++-- graphblas/core/operator/unary.py | 2 +- graphblas/core/operator/utils.py | 3 ++- graphblas/exceptions.py | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/graphblas/core/dtypes.py b/graphblas/core/dtypes.py index 97cbf1f62..3ba87aff6 100644 --- a/graphblas/core/dtypes.py +++ b/graphblas/core/dtypes.py @@ -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) @@ -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): diff --git a/graphblas/core/operator/base.py b/graphblas/core/operator/base.py index 0b539744d..0654c3d22 100644 --- a/graphblas/core/operator/base.py +++ b/graphblas/core/operator/base.py @@ -291,7 +291,7 @@ 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 @@ -299,7 +299,7 @@ def __del__(self): 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. diff --git a/graphblas/core/operator/unary.py b/graphblas/core/operator/unary.py index 23438328a..c879abe80 100644 --- a/graphblas/core/operator/unary.py +++ b/graphblas/core/operator/unary.py @@ -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 diff --git a/graphblas/core/operator/utils.py b/graphblas/core/operator/utils.py index a4d727aab..e8413a168 100644 --- a/graphblas/core/operator/utils.py +++ b/graphblas/core/operator/utils.py @@ -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] diff --git a/graphblas/exceptions.py b/graphblas/exceptions.py index 2c034c212..6e7226cdc 100644 --- a/graphblas/exceptions.py +++ b/graphblas/exceptions.py @@ -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 From 7747523b807ade14dc15f8bfce30ec0a95fe1b91 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 12:38:29 -0500 Subject: [PATCH 09/18] Test an infix ewise_add of two infix expressions Both operands of the outer | are themselves expressions: with the same dtype the inner typed op is reused, and with another dtype the result is promoted. --- graphblas/tests/test_infix.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/graphblas/tests/test_infix.py b/graphblas/tests/test_infix.py index 75150d7d0..fb37dc2b3 100644 --- a/graphblas/tests/test_infix.py +++ b/graphblas/tests/test_infix.py @@ -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) From 683e4d6cd03b106ac782941b307b66b7a08e1f63 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 12:38:31 -0500 Subject: [PATCH 10/18] Test gb.ss.diag of a Vector without options test_diag always passed nthreads, so the arm without options never ran. --- graphblas/tests/test_vector.py | 1 + 1 file changed, 1 insertion(+) diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index 69849c47b..e7b7e63f7 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -1674,6 +1674,7 @@ def test_diag(v): if suitesparse: A = gb.ss.diag(v, k=k, nthreads=2) assert expected.isequal(A) + assert expected.isequal(gb.ss.diag(v, k=k)) A = v.diag(k) assert expected.isequal(A) From e3ff94046beb97efe3ca293fe2071d7cb1a1a5e5 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 12:39:35 -0500 Subject: [PATCH 11/18] Exclude test lines that run only on failure or in a child process The cross-process pickle tests unpickle and compute in a spawned child, which coverage does not measure; the pre-test cleanups run only when an earlier run in the same process left the name behind; the identity mismatch messages are built only on failure; and a stand-in lookup passes other keys through only if the code under test asks for them. The JIT parity check's comparison is one expression, so it has no failure-only line. --- graphblas/tests/test_numpyops.py | 4 ++-- graphblas/tests/test_op.py | 2 +- graphblas/tests/test_pickle.py | 14 +++++++------- graphblas/tests/test_ssjit.py | 8 +++++--- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/graphblas/tests/test_numpyops.py b/graphblas/tests/test_numpyops.py index 9e9a8fdd9..fecba83e9 100644 --- a/graphblas/tests/test_numpyops.py +++ b/graphblas/tests/test_numpyops.py @@ -135,7 +135,7 @@ def test_numpy_monoid_identity_matches_builtin(): gb_id = gb_monoid[dtype].identity np_cast = np.asarray(np_id).astype(dtype.np_type) gb_cast = np.asarray(gb_id).astype(dtype.np_type) - if np_cast != gb_cast: + if np_cast != gb_cast: # pragma: no cover (only on failure) mismatches.append(f"{np_name}[{dtype.name}]={np_id!r} vs {gb_name}={gb_id!r}") assert not mismatches, mismatches @@ -164,7 +164,7 @@ def test_numpy_monoid_unmapped_identity_consistency(): np_type = gb.dtypes.lookup_dtype(name).np_type np_cast = np.asarray(table[name]).astype(np_type) twin_cast = np.asarray(twin_table[name]).astype(np_type) - if np_cast != twin_cast: + if np_cast != twin_cast: # pragma: no cover (only on failure) mismatches.append( f"{np_name}[{name}]={table[name]!r} vs {twin}[{name}]={twin_table[name]!r}" ) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index de750073f..724c73924 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -3692,7 +3692,7 @@ def test_udt_array_udf_known_extents_never_match_by_rank(monkeypatch): def refuse_nested(key, value=None): if isinstance(key, numba.core.types.NestedArray): raise ValueError("simulated: SuiteSparse refused to register this UDT") - return real_lookup(key, value) + return real_lookup(key, value) # pragma: no cover (defensive) monkeypatch.setattr(_udf, "lookup_dtype", refuse_nested) with pytest.raises(UdfParseError, match="matches no input array UDT"): diff --git a/graphblas/tests/test_pickle.py b/graphblas/tests/test_pickle.py index 8a4635dba..b78b5c0bd 100644 --- a/graphblas/tests/test_pickle.py +++ b/graphblas/tests/test_pickle.py @@ -392,11 +392,11 @@ def _crossproc_ibo_add_theta(x, ix, jx, y, iy, jy, theta): # pragma: no cover ( # Operations dispatched by ``_crossproc_worker``. Each takes the unpickled dict # and returns a JSON-friendly value to send through the Pipe. -def _op_udt_reduce(d): +def _op_udt_reduce(d): # pragma: no cover (child process) return d["vector"].reduce(d["op"]).new().value -def _op_bound_ibo_ewise(d): +def _op_bound_ibo_ewise(d): # pragma: no cover (child process) rows, cols, vals = d["A"].ewise_mult(d["B"], d["bound"]).new().to_coo() return list(rows), list(cols), [int(v) for v in vals] @@ -407,7 +407,7 @@ def _op_bound_ibo_ewise(d): } -def _crossproc_worker(backend, op_name, payload, conn): +def _crossproc_worker(backend, op_name, payload, conn): # pragma: no cover (child process) """Run ``_CROSSPROC_OPS[op_name]`` on the unpickled payload in a fresh interpreter.""" try: import graphblas as gb_ @@ -458,7 +458,7 @@ def test_udt_pickle_crossprocess(): record_dtype = np.dtype([("x", np.int64), ("y", np.int64)], align=True) udt_name = "CrossProcPickleUDT" # Use register_new so the child can re-register under the same name. - if hasattr(gb.dtypes, udt_name): + if hasattr(gb.dtypes, udt_name): # pragma: no cover (defensive) delattr(gb.dtypes, udt_name) gb.core.dtypes._registry.pop(udt_name, None) udt = gb.dtypes.register_new(udt_name, record_dtype) @@ -484,7 +484,7 @@ def inner(x, y): return inner -def _op_compile_parameterized_is_udt(d): +def _op_compile_parameterized_is_udt(d): # pragma: no cover (child process) op = d["op"] # ``op._is_udt`` must survive the spawn so the right compile path runs in the child. return bool(op._is_udt) @@ -509,7 +509,7 @@ def test_parameterized_is_udt_pickle_crossprocess(module_name): """ module = getattr(gb, module_name) op_name = f"_cpu_param_is_udt_{module_name}" - if hasattr(module, op_name): + if hasattr(module, op_name): # pragma: no cover (defensive) delattr(module, op_name) module.register_new( op_name, _crossproc_parameterized_is_udt_factory, parameterized=True, is_udt=True @@ -545,7 +545,7 @@ def test_bound_ibo_pickle_crossprocess(): pytest.skip("requires SuiteSparse:GraphBLAS 9.4+") op_name = "CrossProcBoundIBO" - if hasattr(gb.indexbinary, op_name): + if hasattr(gb.indexbinary, op_name): # pragma: no cover (defensive) delattr(gb.indexbinary, op_name) gb.indexbinary.register_new(op_name, _crossproc_ibo_add_theta) try: diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index dce459f10..8a82f831b 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -1988,9 +1988,11 @@ def values_equal(j, c): # unequal under ``isequal``, so ``isequal`` can't distinguish "JIT and # cfunc agree on a NaN bit-pattern" from "they disagree". Byte-equality # of the dense numpy view captures the parity question correctly. - if j.dtype != c.dtype or j.nvals != c.nvals: - return False - return j.to_dense().tobytes() == c.to_dense().tobytes() + return ( + j.dtype == c.dtype + and j.nvals == c.nvals + and j.to_dense().tobytes() == c.to_dense().tobytes() + ) for op in binary_ops: assert values_equal( From 748aab835cfb7fa61e8673f356b35747edf9a465 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 12:40:47 -0500 Subject: [PATCH 12/18] Pack a hyper_hash back in on every SuiteSparse version test_ss_pack_hyperhash packed the hash back only when SuiteSparse had computed one, which for its two rows SuiteSparse 8.2 does and 10.5 does not, so the pack arm ran on some CI draws only. With 1025 rows every version checked computes it (8.2.1, 10.5.0 and 10.5.1); the hash stays optional, so the guard stays, with a pragma. --- graphblas/tests/test_matrix.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 65d738a00..f84879a73 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -4502,12 +4502,14 @@ def test_ss_pack_hyperhash(A): C = Matrix(int, 20000, 200000) C.ss.config["sparsity_control"] = "hypersparse" - C[100, 2000] = 2 - C[10, 20] = 1 + # More rows than the 1024 below which SuiteSparse 10 skips the hash + rows = np.arange(1025) * 3 + C << Matrix.from_coo(rows, rows * 7, 1, nrows=20000, ncols=200000) Y = C.ss.unpack_hyperhash() Y = C.ss.unpack_hyperhash(compute=True) assert C.ss.unpack_hyperhash() is None - if Y is not None: # hyperhash may or may not be computed + # The hash is optional, but every SuiteSparse we test computes this one + if Y is not None: # pragma: no branch (suitesparse) assert Y.nrows == C.nrows C.ss.pack_hyperhash(Y) assert Y.gb_obj[0] == gb.core.NULL From a9eb4f45ac05c24aafba4d58a72f3a98b7953356 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 12:42:04 -0500 Subject: [PATCH 13/18] Test that unary identity and one on a UDT need Numba Without Numba, applying unary.identity or unary.one to a UDT raises the usual KeyError that the op does not work with it. --- graphblas/tests/test_op.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index 724c73924..a15af94cc 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -200,6 +200,19 @@ def plus_one(x): # pragma: no cover (numba) unary.register_new("plus_one", plus_one) +@pytest.mark.skipif("supports_udfs") +def test_unaryop_on_udt_needs_numba(): + # identity and one apply to any UDT through a Numba function + udt = dtypes.register_anonymous( + np.dtype([("nn_a", np.int64), ("nn_b", np.float64)]), "_NoNumbaUnary" + ) + v = Vector(udt, size=2) + v[0] = (1, 2.0) + for unop in [unary.identity, unary.one]: + with pytest.raises(KeyError, match=f"{unop.name} does not work with _NoNumbaUnary"): + v.apply(unop).new() + + @pytest.mark.skipif("not supports_udfs") def test_unaryop_udf(): def plus_one(x): From 79aea09ef7207beb30e9d3573ff6abe3eae25d34 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 12:42:08 -0500 Subject: [PATCH 14/18] Test deserializing a blob whose integer sizes SuiteSparse cannot read SuiteSparse 10 refuses a blob whose encoding word asks for integer sizes larger than it knows, as a later version may write, when GrB_NAME's size is read from it, though it read the dtype name. A UDT Matrix blob edited so raises InvalidObject naming the read that failed (on SuiteSparse 9 the edit makes the type code invalid, and the dtype name read fails first). Reading GrB_NAME itself checks the blob as reading its size just did, so that status check gets a pragma. --- graphblas/core/ss/_utils.py | 3 ++- graphblas/tests/test_matrix.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/graphblas/core/ss/_utils.py b/graphblas/core/ss/_utils.py index ca4fc87d3..fafa06107 100644 --- a/graphblas/core/ss/_utils.py +++ b/graphblas/core/ss/_utils.py @@ -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 diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index f84879a73..cc495175f 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -4222,6 +4222,22 @@ def assert_same(A, B): assert_same(A, Matrix.ss.deserialize(A.ss.serialize())) +@pytest.mark.skipif("not suitesparse or not hasattr(lib, 'GxB_Serialized_get_String')") +def test_ss_deserialize_integer_sizes_it_cannot_read(): + # Byte 9 is in the blob's encoding word. There SuiteSparse 10 keeps the integer size of + # the column pointers, and refuses a size above what it knows, as from a later version; + # SuiteSparse 9 keeps a type code, which this makes invalid. The dtype name of this UDT + # does not resolve, so its GrB_NAME is read too: 9 refuses the blob when the dtype name + # is read, and 10 when GrB_NAME is. + udt = dtypes.register_anonymous(np.dtype([("isz_a", np.int64), ("isz_b", np.float64)])) + blob = Matrix(udt, nrows=3, ncols=3).ss.serialize() + blob[9] = (blob[9] & 0x0F) | 0x20 + with pytest.raises( + InvalidObject, match="Matrix deserialize failed to get the (dtype name|size of name)" + ): + Matrix.ss.deserialize(blob) + + def test_reposition(A): rows, cols, values = A.to_coo() rows = rows.astype(int) From 82ae7bd037c8dab1cdd4d71f6aa60d0c9d6f4aa7 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 21 Oct 2026 12:45:18 -0500 Subject: [PATCH 15/18] Test the shape of a full array-UDT import without nrows and ncols The values of a full array-UDT Matrix carry its shape in their first two of three dimensions, and its export then leaves out nrows and ncols; an iso export has a single element, so importing it without nrows and ncols asks for them. That arm of get_shape is covered now, so its "BRANCH NOT COVERED" marker goes. --- graphblas/core/utils.py | 1 - graphblas/tests/test_matrix.py | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py index 86105f960..74d6c8344 100644 --- a/graphblas/core/utils.py +++ b/graphblas/core/utils.py @@ -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: diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index cc495175f..91235a307 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -4171,6 +4171,20 @@ def test_udt(): info = A.ss.export("coor") result = A.ss.import_any(**info) assert result.isequal(A) + # Without nrows and ncols, the shape comes from values of three dimensions, + # which an iso export (one element) does not have. + B = A.dup() + B[0, 0] = (4, 5, 6) + info = B.ss.export("fullr") + assert info["values"].shape == (2, 2, 3) + assert "nrows" not in info + assert "ncols" not in info + assert Matrix.ss.import_any(**info).isequal(B) + info = A.ss.export("fullr") + assert info["values"].shape == (1, 3) + del info["nrows"], info["ncols"] + with pytest.raises(ValueError, match="nrows and ncols must be provided"): + Matrix.ss.import_any(**info) assert Matrix.from_csr(*A.to_csr()).isequal(A) A.clear() A[[0, 1], 1] = [(2, 3, 4), [5, 6, 7]] From daebd9e7b5efd38dac4ea9dd2a4080a917a5f18d Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Thu, 22 Oct 2026 11:01:00 -0500 Subject: [PATCH 16/18] Test that the reprs of an apply name its UDT scalar operand apply passes a UDT C scalar to GraphBLAS through a pointer object, and both the text and the HTML repr of the expression name the scalar, which the HTML one asks the pointer for. --- graphblas/tests/test_op.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index a15af94cc..f2f62287c 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -4829,6 +4829,23 @@ def test_udt_array_pair_promotes_the_base(): assert (v + w).new().isequal(v.ewise_union(w, binary.plus, 0, 0.0).new()) +@pytest.mark.skipif("not supports_udfs") +def test_udt_scalar_operand_is_named_in_reprs(): + # apply passes a UDT C scalar to GraphBLAS through a pointer, which the reprs name + rec = dtypes.register_anonymous( + np.dtype([("srp_a", np.int64), ("srp_b", np.float64)]), "_ReprRec" + ) + v = Vector(rec, size=3, name="v") + v[0] = (1, 2.0) + s = gb.Scalar.from_value((3, 4.0), rec, is_cscalar=True, name="s") + for expr, text in [ + (v.apply(binary.first, left=s), "v.apply(binary.first[_ReprRec, _ReprRec], left=s)"), + (v.apply(binary.second, right=s), "v.apply(binary.second[_ReprRec, _ReprRec], right=s)"), + ]: + assert text in repr(expr) + assert text in expr._repr_html_() + + @pytest.mark.skipif("not supports_udfs") def test_udt_array_scalar_promotes_like_builtin(): """A number beside an array UDT keeps its dtype, and each element promotes as the op does. From f8b307bcc4114c88d0fadc583777ba952ef0e848 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 7 Nov 2026 22:24:06 -0600 Subject: [PATCH 17/18] Fail the JIT tests instead of skipping them under GRAPHBLAS_REQUIRE_JIT The JIT tests skip where SuiteSparse cannot compile: no JIT in the build, no usable C compiler, a failed probe. That is right on a random CI draw, but it also lets a setup that should compile drop every JIT test, and their coverage, without a red job. With GRAPHBLAS_REQUIRE_JIT set, those skips (now one helper, skip_without_jit, in conftest.py) fail instead, in test_ssjit.py, test_op.py's udt_op_path and test_dtype.py. A GraphBLAS 7 or a missing Numba skips whole tests by marker, and a _require_jit_on that sees jit_c_control "on" runs its test even when the probe failed, so test_required_jit_works checks all three where the variable is set, and skips elsewhere. --- graphblas/tests/conftest.py | 13 +++++++++++++ graphblas/tests/test_dtype.py | 4 +++- graphblas/tests/test_op.py | 4 ++-- graphblas/tests/test_ssjit.py | 33 +++++++++++++++++++++++---------- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/graphblas/tests/conftest.py b/graphblas/tests/conftest.py index 7f16d9fdd..13643594a 100644 --- a/graphblas/tests/conftest.py +++ b/graphblas/tests/conftest.py @@ -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) diff --git a/graphblas/tests/test_dtype.py b/graphblas/tests/test_dtype.py index a7edc6d7b..5b2a565b0 100644 --- a/graphblas/tests/test_dtype.py +++ b/graphblas/tests/test_dtype.py @@ -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") @@ -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) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index f2f62287c..57c14c215 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -49,7 +49,7 @@ ) from graphblas.exceptions import DimensionMismatch, DomainMismatch, InvalidValue, UdfParseError -from .conftest import shouldhave +from .conftest import shouldhave, skip_without_jit if dtypes._supports_complex: from graphblas.dtypes import FC32, FC64 @@ -1655,7 +1655,7 @@ def udt_op_path(request): previous = gb.ss.config["jit_c_control"] if path == "jit": if not _jit_can_compile(): - pytest.skip("C JIT compilation not available (probe failed or compiler missing)") + skip_without_jit("C JIT compilation not available (probe failed or compiler missing)") # Set it rather than assume it. SuiteSparse demotes ``on`` to ``load`` # after a failed compile, and a demoted control routes to the cfunc # silently, so this parameter would pass while running the other path. diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index 8a82f831b..9cfe880a5 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -30,7 +30,7 @@ from graphblas.core.ss.jit_config import _enable_jit_for_udt from graphblas.exceptions import EmptyObject -from .conftest import autocompute, burble +from .conftest import autocompute, burble, skip_without_jit from graphblas import Vector # isort:skip (for dask-graphblas) @@ -110,7 +110,7 @@ def _setup_jit(): def _require_jit_on(): """Skip the test if the SuiteSparse JIT can't compile in this environment.""" if gb.ss.config["jit_c_control"] != "on": - pytest.skip("JIT compilation not available (probe failed or compiler missing)") + skip_without_jit("JIT compilation not available (probe failed or compiler missing)") @contextlib.contextmanager @@ -177,6 +177,19 @@ def control(): """ +@pytest.mark.skipif("not os.environ.get('GRAPHBLAS_REQUIRE_JIT')") +def test_required_jit_works(): + """Where GRAPHBLAS_REQUIRE_JIT is set (CI's pytest_jit job), the JIT tests can all run. + + Most of them skip what an environment cannot do through ``skip_without_jit``, which + fails instead there. A GraphBLAS without a JIT and a missing Numba skip whole tests + by marker, so check those here. + """ + assert not _IS_SSGB7, "SuiteSparse:GraphBLAS 7 has no JIT" + assert _JIT_WORKS_AT_IMPORT, "the JIT cannot compile here (probe failed or compiler missing)" + assert supports_udfs, "Numba is missing, so no UDT op or kernel can be built" + + @pytest.mark.skipif("_IS_SSGB7") @pytest.mark.parametrize("jit_c_control", [None, "run", "on", "pause"]) def test_ss_import_leaves_jit_c_control_alone(jit_c_control): @@ -376,7 +389,7 @@ def test_ss_register_new_enables_the_jit(): first. """ if not _JIT_WORKS_AT_IMPORT: - pytest.skip("JIT not compiled in or not working here; nothing can compile") + skip_without_jit("JIT not compiled in or not working here; nothing can compile") lines, report = _run_in_fresh_process(_SS_REGISTER_NEW_PROBE) kinds = ["unary", "binary", "indexunary", "select"] kinds += ["indexbinary"] if _has_idxbinop else [] @@ -540,7 +553,7 @@ def test_udt_work_raises_only_the_default_run(form, env_control, kept, tmp_path) was told apart. """ if not kept and not _JIT_WORKS_AT_IMPORT: - pytest.skip("JIT not compiled in or not working here; nothing raises to 'on'") + skip_without_jit("JIT not compiled in or not working here; nothing raises to 'on'") extra_env = {} if form == "YAML file": (tmp_path / "jit.yaml").write_text("jit_c_control: run\n") @@ -570,7 +583,7 @@ def test_udt_work_raises_only_the_default_run(form, env_control, kept, tmp_path) def test_ss_register_new_says_the_compile_failed(): """C source that does not compile says so, and that ``jit_c_control`` is now ``'load'``.""" if not _JIT_WORKS_AT_IMPORT: - pytest.skip("JIT not compiled in or not working here; nothing compiles to fail") + skip_without_jit("JIT not compiled in or not working here; nothing compiles to fail") from graphblas.exceptions import JitError, NullPointer cdef = "void why_bad (double *z, double *x) { (*z) = why_undeclared ; }" @@ -589,7 +602,7 @@ def test_ss_register_new_says_the_compile_failed(): def test_auto_fix_jit_at_import_left_compiler_usable(): """The import-time repair leaves a compiler the UDT path can actually use.""" if not gb.ss.jit_compiler_is_usable(): - pytest.skip("sandboxed env without a usable compiler; auto-fix had nothing to repair") + skip_without_jit("sandboxed env without a usable compiler; auto-fix had nothing to repair") if not _JIT_WORKS_AT_IMPORT: # Compiler file exists but the probe failed (e.g., the baked-in flags # target a different arch than the host). After a compile failure @@ -597,7 +610,7 @@ def test_auto_fix_jit_at_import_left_compiler_usable(): # downstream ops punt to generic cleanly; the probe absorbs that first # failure. Which mode it lands in varies by SuiteSparse version. assert gb.ss.config["jit_c_control"] in {"load", "run"} - pytest.skip("compiler present but JIT probe failed in this env") + skip_without_jit("compiler present but JIT probe failed in this env") assert _JIT_WORKS_AT_IMPORT assert gb.ss.config["jit_c_control"] == "on" @@ -684,7 +697,7 @@ def test_enable_jit_for_udt_re_raises_the_live_control(monkeypatch): and kept. None is the default either way. """ if not _JIT_WORKS_AT_IMPORT: - pytest.skip("JIT not compiled in or not working here; the control need not hold 'on'") + skip_without_jit("JIT not compiled in or not working here; the control need not hold 'on'") from graphblas.core.ss import jit_config @@ -729,7 +742,7 @@ def test_failed_compile_is_not_retried_at_the_next_arming(): with ``'off'`` the only way out. """ if not _JIT_WORKS_AT_IMPORT: - pytest.skip("JIT not compiled in or not working here; nothing compiles to fail") + skip_without_jit("JIT not compiled in or not working here; nothing compiles to fail") from graphblas.core.ss import jit_config from graphblas.exceptions import JitError @@ -774,7 +787,7 @@ def test_failed_compile_is_not_retried_at_the_next_arming(): def test_public_fix_jit_config_repairs_a_broken_compiler(): """``gb.ss.fix_jit_config()`` repairs a clobbered compiler path and returns ``True``.""" if not _JIT_WORKS_AT_IMPORT: - pytest.skip("env JIT doesn't actually compile; nothing to repair to") + skip_without_jit("env JIT doesn't actually compile; nothing to repair to") prev = { "control": gb.ss.config["jit_c_control"], "cc": gb.ss.config["jit_c_compiler_name"], From bc87dda71d9035de24e7b509babdbb8039dc017c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 7 Nov 2026 22:24:19 -0600 Subject: [PATCH 18/18] Add a CI job that always compiles the JIT, so its coverage stops swinging The JIT tests ran, and counted for coverage, only on the random draws that happened to get the suitesparse backend, --runslow, Numba and a GraphBLAS that compiles, so Codecov's total moved by up to 0.7 points from run to run. Add one fixed entry, (ubuntu-latest, pytest_jit), outside the draw: Python 3.15 from conda-forge with the newest psg, Numba always (no random skip) and conda-forge's C compiler, running the suite once with --runslow and --backend=suitesparse, and uploading coverage like the others. That setup compiled in recent runs (graphblas 10.5.2, psg 10.5.2.0, numba 0.68.1: test_ssjit passed but for the IndexBinaryOp version check). The job sets GRAPHBLAS_REQUIRE_JIT, so a setup that stops compiling fails it instead of skipping the JIT tests. It skips the C scalar pass, which the other jobs run. Codecov counts no uploads (.codecov.yml sets no after_n_builds), so the twelfth merges like the other eleven. --- .github/workflows/test_and_build.yml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index b98adfa3d..65d697823 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -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 @@ -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" @@ -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" @@ -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". @@ -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 @@ -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' || '' }} @@ -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