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 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/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/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/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/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/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/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/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 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_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_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) 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) diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index e6a253de6..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]] @@ -4189,6 +4203,55 @@ 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())) + + +@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) @@ -4469,12 +4532,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 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 32bf229c7..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 @@ -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): @@ -1642,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. @@ -3692,7 +3705,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"): @@ -4816,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. @@ -7046,6 +7076,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(): @@ -7240,6 +7309,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 @@ -7251,6 +7321,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_pickle.py b/graphblas/tests/test_pickle.py index 3659b666c..b78b5c0bd 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) @@ -364,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] @@ -379,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_ @@ -430,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) @@ -456,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) @@ -481,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 @@ -517,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_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) diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index 573d0f236..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"], @@ -803,24 +816,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]) @@ -910,6 +1032,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: @@ -1076,6 +1211,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): @@ -1375,6 +1528,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") @@ -1846,9 +2001,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( 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)