Repository: python-graphblas/python-graphblas (language: python)

Issue:
ENH: Add "pure GrB" backend that doesn't rely on SuiteSparse-specific behavior
It would be good discipline for `python-graphblas` to have a backend option that doesn't rely on SuiteSparse-specific functions and behaviors.  This will make it easier for `python-graphblas` to support other GraphBLAS libraries in the future.

I believe we've done a pretty decent job identifying and isolating SuiteSparse-specific functionality.  Much is already in `.ss` attributes.  Others are identified by `# SS, SuiteSparse-specific` comments.

To support such a backend, we can still use SuiteSparse:GraphBLAS, but create a `lib` object that doesn't have `GxB` items.


Discussion / hints:

Fix patch:
diff --git a/.codecov.yml b/.codecov.yml
new file mode 100644
index 000000000..926666ac2
--- /dev/null
+++ b/.codecov.yml
@@ -0,0 +1,4 @@
+comment: off
+ignore:
+  - graphblas/_version.py
+  - graphblas/viz.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d5df8fbf7..696c7d05b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,6 +2,8 @@
 #
 # Before first use: `pre-commit install`
 # To run: `pre-commit run --all-files`
+# To update: `pre-commit autoupdate`
+#  - &flake8_dependencies below needs updated manually
 fail_fast: true
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
@@ -26,7 +28,7 @@ repos:
       - id: isort
         language_version: python3
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.1.0
+    rev: v3.2.2
     hooks:
       - id: pyupgrade
         args: [--py38-plus]
@@ -45,9 +47,10 @@ repos:
     hooks:
       - id: flake8
         additional_dependencies: &flake8_dependencies
+        # These versions need updated manually
         - flake8==5.0.4
-        - flake8-comprehensions==3.10.0
-        - flake8-bugbear==22.9.23
+        - flake8-comprehensions==3.10.1
+        - flake8-bugbear==22.10.27
   - repo: https://github.com/asottile/yesqa
     rev: v1.4.0
     hooks:
diff --git a/graphblas/__init__.py b/graphblas/__init__.py
index f68da1c52..60b39b9a2 100644
--- a/graphblas/__init__.py
+++ b/graphblas/__init__.py
@@ -69,13 +69,18 @@ def __getattr__(name):
     if name in _SPECIAL_ATTRS:
         if _init_params is None:
             _init("suitesparse", None, automatic=True)
+            # _init("suitesparse-vanilla", None, automatic=True)
+        if name == "ss" and backend != "suitesparse":
+            raise AttributeError(
+                f'module {__name__!r} only has attribute "ss" when backend is "suitesparse"'
+            )
         if name not in globals():
             if f"graphblas.{name}" in _sys.modules:
                 globals()[name] = _sys.modules[f"graphblas.{name}"]
             else:
                 _load(name)
         return globals()[name]
-    elif name == "_autoinit":
+    if name == "_autoinit":
         if _init_params is None:
             _init("suitesparse", None, automatic=True)
     else:
@@ -83,7 +88,10 @@ def __getattr__(name):
 
 
 def __dir__():
-    return list(globals().keys() | _SPECIAL_ATTRS)
+    names = globals().keys() | _SPECIAL_ATTRS
+    if backend is not None and backend != "suitesparse":
+        names.remove("ss")
+    return list(names)
 
 
 def init(backend="suitesparse", blocking=False):
@@ -113,15 +121,14 @@ def _init(backend_arg, blocking, automatic=False):
                 raise GraphblasException(
                     "graphblas objects accessed prior to manual initialization"
                 )
-            else:
-                raise GraphblasException(
-                    "graphblas initialized multiple times with different init parameters"
-                )
+            raise GraphblasException(
+                "graphblas initialized multiple times with different init parameters"
+            )
         # Already initialized with these parameters; nothing more to do
         return
 
     backend = backend_arg
-    if backend == "suitesparse":
+    if backend in {"suitesparse", "suitesparse-vanilla"}:
         from suitesparse_graphblas import ffi, initialize, is_initialized, lib
 
         if is_initialized():
@@ -139,6 +146,20 @@ def _init(backend_arg, blocking, automatic=False):
                 blocking = False
                 passed_params["blocking"] = blocking
             initialize(blocking=blocking, memory_manager="numpy")
+        if backend == "suitesparse-vanilla":
+            # Exclude functions that start with GxB
+
+            class Lib:
+                pass
+
+            orig_lib = lib
+            lib = Lib()
+            for key, val in vars(orig_lib).items():
+                # TODO: handle GxB objects
+                if callable(val) and key.startswith("GxB") or "FC32" in key or "FC64" in key:
+                    continue
+                setattr(lib, key, getattr(orig_lib, key))
+
     else:
         raise ValueError(f'Bad backend name.  Must be "suitesparse".  Got: {backend}')
     _init_params = passed_params
diff --git a/graphblas/binary/numpy.py b/graphblas/binary/numpy.py
index 51831d11d..7d55d83fa 100644
--- a/graphblas/binary/numpy.py
+++ b/graphblas/binary/numpy.py
@@ -170,7 +170,7 @@ def __getattr__(name):
 
         numpy_func = getattr(_np, name)
 
-        def func(x, y):  # pragma: no cover
+        def func(x, y):  # pragma: no cover (numba)
             return numpy_func(x, y)
 
         operator.BinaryOp.register_new(f"numpy.{name}", func)
diff --git a/graphblas/core/__init__.py b/graphblas/core/__init__.py
index 22c8ac6f1..0464289a5 100644
--- a/graphblas/core/__init__.py
+++ b/graphblas/core/__init__.py
@@ -3,5 +3,4 @@ def __getattr__(name):
         from .. import _autoinit  # noqa
 
         return globals()[name]
-    else:
-        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/graphblas/core/agg.py b/graphblas/core/agg.py
index 7e7772d2d..f4cd02e71 100644
--- a/graphblas/core/agg.py
+++ b/graphblas/core/agg.py
@@ -19,7 +19,7 @@ def _get_types(ops, initdtype):
         cur = {}
         types = op.types
         for in_type, out_type in prev.items():
-            if out_type not in types:  # pragma: no cover
+            if out_type not in types:  # pragma: no cover (safety)
                 continue
             cur[in_type] = types[out_type]
         prev = cur
@@ -63,7 +63,7 @@ def __init__(
                 if finalize is not None:
                     types.append(finalize)
                 initval = self._initval
-            else:  # pragma: no cover
+            else:  # pragma: no cover (sanity)
                 raise TypeError("types must be provided for composite and custom aggregators")
         self._types_orig = types
         self._types = None
@@ -112,23 +112,21 @@ def __call__(self, val, *, rowwise=False, columnwise=False):
                     "rowwise and columnwise arguments should not be used with Vector input"
                 )
             return val.reduce(self)
-        elif typ in {Matrix, TransposedMatrix}:
+        if typ in {Matrix, TransposedMatrix}:
             if rowwise:
                 if columnwise:
                     raise ValueError("rowwise and columnwise arguments cannot both be True")
                 return val.reduce_rowwise(self)
-            elif columnwise:
+            if columnwise:
                 return val.reduce_columnwise(self)
-            else:
-                return val.reduce_scalar(self)
-        else:
-            raise TypeError(
-                f"Bad type when calling {self!r}.\n"
-                "    - Expected type: Vector, Matrix, TransposedMatrix.\n"
-                f"    - Got: {type(val)}.\n"
-                "Calling an Aggregator is syntactic sugar for calling reduce methods.  "
-                f"For example, `A.reduce_scalar({self!r})` is the same as `{self!r}(A)`."
-            )
+            return val.reduce_scalar(self)
+        raise TypeError(
+            f"Bad type when calling {self!r}.\n"
+            "    - Expected type: Vector, Matrix, TransposedMatrix.\n"
+            f"    - Got: {type(val)}.\n"
+            "Calling an Aggregator is syntactic sugar for calling reduce methods.  "
+            f"For example, `A.reduce_scalar({self!r})` is the same as `{self!r}(A)`."
+        )
 
 
 class TypedAggregator:
@@ -523,7 +521,7 @@ def _argminmax(agg, updater, expr, *, in_composite, monoid):
             row_semiring=semiring.min_firsti,
             col_semiring=semiring.min_secondi,
         )
-    elif expr.cfunc_name.startswith("GrB_Vector_reduce"):
+    if expr.cfunc_name.startswith("GrB_Vector_reduce"):
         return _argminmaxij(
             agg,
             updater,
@@ -533,10 +531,9 @@ def _argminmax(agg, updater, expr, *, in_composite, monoid):
             row_semiring=semiring.min_firsti,
             col_semiring=semiring.min_secondi,
         )
-    elif expr.cfunc_name.startswith("GrB_Matrix_reduce"):
+    if expr.cfunc_name.startswith("GrB_Matrix_reduce"):
         raise ValueError(f"Aggregator {agg.name} may not be used with Matrix.reduce_scalar.")
-    else:
-        raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}")
+    raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}")
 
 
 # These "do the right thing", but don't work with `reduce_scalar`
diff --git a/graphblas/core/automethods.py b/graphblas/core/automethods.py
index 1c33acf86..049515da2 100644
--- a/graphblas/core/automethods.py
+++ b/graphblas/core/automethods.py
@@ -16,8 +16,7 @@ def _get_value(self, attr=None, default=None):
             self._value = self.new()
         if attr is None:
             return self._value
-        else:
-            return getattr(self._value, attr)
+        return getattr(self._value, attr)
     if default is not None:
         return default.__get__(self)
     raise TypeError(
@@ -491,7 +490,12 @@ def get_name(line):
     lines = []
     lines.append("    _get_value = automethods._get_value")
     for name in sorted(common | vector_matrix | vector):
-        lines.append(f"    {name} = wrapdoc(Vector.{name})(property(automethods.{name}))")
+        if name == "ss":
+            lines.append('    if backend == "suitesparse":')
+            indent = "    "
+        else:
+            indent = ""
+        lines.append(f"    {indent}{name} = wrapdoc(Vector.{name})(property(automethods.{name}))")
         if name == "name":
             lines.append("    name = name.setter(automethods._set_name)")
     lines.append("    # These raise exceptions")
@@ -509,7 +513,12 @@ def get_name(line):
     lines = []
     lines.append("    _get_value = automethods._get_value")
     for name in sorted(common | vector_matrix | matrix):
-        lines.append(f"    {name} = wrapdoc(Matrix.{name})(property(automethods.{name}))")
+        if name == "ss":
+            lines.append('    if backend == "suitesparse":')
+            indent = "    "
+        else:
+            indent = ""
+        lines.append(f"    {indent}{name} = wrapdoc(Matrix.{name})(property(automethods.{name}))")
         if name == "name":
             lines.append("    name = name.setter(automethods._set_name)")
     lines.append("    # These raise exceptions")
diff --git a/graphblas/core/base.py b/graphblas/core/base.py
index c33b641a2..8a9f9223c 100644
--- a/graphblas/core/base.py
+++ b/graphblas/core/base.py
@@ -1,6 +1,6 @@
 from contextvars import ContextVar
 
-from .. import config
+from .. import backend, config
 from .. import replace as replace_singleton
 from ..dtypes import BOOL
 from ..exceptions import check_status
@@ -61,7 +61,7 @@ def _expect_type_message(
     if type(types) is tuple:
         if type(x) in types:
             return x, None
-        elif output_type(x) in types:
+        if output_type(x) in types:
             if config.get("autocompute"):
                 return x.new(), None
             extra_message = f"{extra_message}\n\n" if extra_message else ""
@@ -123,7 +123,7 @@ def _expect_op_message(
         argmsg = f"for argument `{argname}` "
     elif keyword_name:
         argmsg = f"for keyword argument `{keyword_name}=` "
-    else:  # pragma: no cover
+    else:  # pragma: no cover (safety)
         argmsg = ""
     if type(values) is tuple:
         expected = ", ".join(values)
@@ -334,7 +334,7 @@ def _update(self, expr, mask=None, accum=None, replace=False, input_mask=None):
 
                 # Extract (C << A[rows, cols])
                 if input_mask is not None:
-                    if mask is not None:  # pragma: no cover (can this be covered?)
+                    if mask is not None:  # pragma: no cover (can this be covered?!)
                         raise TypeError("mask and input_mask arguments cannot both be given")
                     input_mask = _check_mask(input_mask, expr.parent)
                     mask = expr._input_mask_to_mask(input_mask)
@@ -420,11 +420,11 @@ def _update(self, expr, mask=None, accum=None, replace=False, input_mask=None):
 
         if input_mask is not None:
             raise TypeError("`input_mask` argument may only be used for extract")
-        elif expr.op is not None and expr.op.opclass == "Aggregator":
+        if expr.op is not None and expr.op.opclass == "Aggregator":
             updater = self(mask=mask, accum=accum, replace=replace)
             expr.op._new(updater, expr)
             return
-        elif expr.cfunc_name is None:  # Custom recipe
+        if expr.cfunc_name is None:  # Custom recipe
             updater = self(mask=mask, accum=accum, replace=replace)
             expr.args[-2](updater, *expr.args[-1])
             return
@@ -477,7 +477,7 @@ def _update(self, expr, mask=None, accum=None, replace=False, input_mask=None):
         call(cfunc_name, args)
         if self._is_scalar:
             if scalar_as_vector:
-                if self._is_cscalar:
+                if self._is_cscalar or backend != "suitesparse":
                     self.value = fake_self[0].new(is_cscalar=True, name="")
                 # SS: this assumes GrB_Scalar was cast to Vector
             elif is_temp_scalar:
@@ -545,7 +545,7 @@ def __init__(
                 expr_repr = "{0.name}.{method_name}({op})"
             elif len(args) == 2 or cfunc_name is None and len(args) == 4:
                 expr_repr = "{0.name}.{method_name}({1.name}, op={op})"
-            else:  # pragma: no cover
+            else:  # pragma: no cover (sanity)
                 raise ValueError(f"No default expr_repr for len(args) == {len(args)}")
         self.expr_repr = expr_repr
         if dtype is None:
diff --git a/graphblas/core/descriptor.py b/graphblas/core/descriptor.py
index c0e6a02bf..51bc81fef 100644
--- a/graphblas/core/descriptor.py
+++ b/graphblas/core/descriptor.py
@@ -80,7 +80,7 @@ def lookup(
         transpose_first,
         transpose_second,
     )
-    if key not in _desc_map:  # pragma: no cover
+    if key not in _desc_map:  # pragma: no cover (unnecessary)
         # We currently don't need this block of code!
         # All 32 possible descriptors are currently already added to _desc_map.
         # Nevertheless, this code may be useful some day, because we will want
diff --git a/graphblas/core/expr.py b/graphblas/core/expr.py
index 57fbde130..2ec732808 100644
--- a/graphblas/core/expr.py
+++ b/graphblas/core/expr.py
@@ -49,8 +49,7 @@ def _expr_name(self):
             return rv
         if idx.size < 6:
             return f"[{', '.join(map(str, idx))}]"
-        else:
-            return f"[{', '.join(map(str, idx[:3]))}, ...]"
+        return f"[{', '.join(map(str, idx[:3]))}, ...]"
 
     def _py_index(self):
         """Convert resolved index back into a valid Python index"""
@@ -226,7 +225,7 @@ def parse_index(self, index, typ, size):
                     "If you want to assign with a mask, perhaps do something like "
                     f"`x(mask={index.name}.S) << value`."
                 )
-            elif typ is TransposedMatrix:
+            if typ is TransposedMatrix:
                 raise TypeError(f"Invalid type for index: {typ.__name__}.")
             try:
                 index = list(index)
@@ -419,10 +418,15 @@ def _setitem(self, resolved_indexes, obj, *, is_submask):
             self.parent._assign_element(resolved_indexes, obj)
         else:
             mask = self.kwargs.get("mask")
-            expr = self.parent._prep_for_assign(
-                resolved_indexes, obj, mask=mask, is_submask=is_submask
+            replace = self.kwargs.get("replace", False)
+            expr, alt_mask = self.parent._prep_for_assign(
+                resolved_indexes, obj, mask=mask, is_submask=is_submask, replace=replace
             )
-            self.update(expr)
+            if alt_mask is mask:
+                self.update(expr)
+            else:
+                kwargs = dict(self.kwargs, mask=alt_mask)
+                Updater(self.parent, **kwargs).update(expr)
 
     def __setitem__(self, keys, obj):
         if self.parent._is_scalar:
@@ -507,7 +511,7 @@ def _repr_html_(self):
 
         if self.output_type.__name__ == "VectorExpression":
             return formatting.format_vector_infix_expression_html(self)
-        elif self.output_type.__name__ == "MatrixExpression":
+        if self.output_type.__name__ == "MatrixExpression":
             return formatting.format_matrix_infix_expression_html(self)
         return formatting.format_scalar_infix_expression_html(self)
 
@@ -516,7 +520,7 @@ def __repr__(self):
 
         if self.output_type.__name__ == "VectorExpression":
             return formatting.format_vector_infix_expression(self)
-        elif self.output_type.__name__ == "MatrixExpression":
+        if self.output_type.__name__ == "MatrixExpression":
             return formatting.format_matrix_infix_expression(self)
         return formatting.format_scalar_infix_expression(self)
 
diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py
index 95506dac7..306bb32c4 100644
--- a/graphblas/core/formatting.py
+++ b/graphblas/core/formatting.py
@@ -1,6 +1,6 @@
 import numpy as np
 
-from .. import config, monoid, unary
+from .. import backend, config, monoid, unary
 from ..dtypes import BOOL
 from ..exceptions import OutOfMemory
 from .matrix import Matrix, TransposedMatrix
@@ -10,7 +10,7 @@
     import pandas as pd
 
     has_pandas = True
-except ImportError:  # pragma: no cover
+except ImportError:  # pragma: no cover (import)
     has_pandas = False
 
 # This was written by a complete novice at CSS.
@@ -285,7 +285,7 @@ def _get_matrix_dataframe(matrix, max_rows, min_rows, max_columns, *, mask=None)
         if min(nonzero._nvals, num_rows) > 2 * df.count().sum():
             rows, cols, vals = nonzero.ss.head(num_rows, sort=True)
             if mask.complement:
-                if not vals.flags.writeable:  # pragma: no branch
+                if not vals.flags.writeable:  # pragma: no cover (safety)
                     vals = vals.copy()
                 vals[:] = 0
             df = pd.DataFrame({"row": rows, "col": cols, "val": vals})
@@ -335,7 +335,7 @@ def _get_vector_dataframe(vector, max_rows, min_rows, max_columns, *, mask=None)
         if min(nonzero._nvals, num_rows) > 2 * df.count().sum():
             indices, vals = nonzero.ss.head(num_rows, sort=True)
             if mask.complement:
-                if not vals.flags.writeable:  # pragma: no branch
+                if not vals.flags.writeable:  # pragma: no cover (safety)
                     vals = vals.copy()
                 vals[:] = 0
             df = pd.DataFrame({"index": indices, "val": vals})
@@ -365,7 +365,7 @@ def matrix_info(matrix, *, mask=None, expr=None, for_html=True):
         name = f"gb.{type(matrix).__name__}"
     keys = ["nvals", "nrows", "ncols", "dtype"]
     vals = [matrix._nvals, matrix._nrows, matrix._ncols, matrix.dtype.name]
-    if expr is None:
+    if expr is None and backend == "suitesparse":
         keys.append("format")
         if type(matrix) is Matrix:
             vals.append(get_format(matrix))
@@ -382,10 +382,9 @@ def vector_info(vector, *, mask=None, expr=None, for_html=True):
             name = [f"{type(mask).__name__}", f"of gb.{type(vector).__name__}"]
     else:
         name = f"gb.{type(vector).__name__}"
-    # SS, SuiteSparse-specific: format
     keys = ["nvals", "size", "dtype"]
     vals = [vector._nvals, vector._size, vector.dtype.name]
-    if expr is None:
+    if expr is None and backend == "suitesparse":
         keys.append("format")
         vals.append(get_format(vector))
     return name, keys, vals
diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py
index e72108a4e..aa030ec36 100644
--- a/graphblas/core/infix.py
+++ b/graphblas/core/infix.py
@@ -1,4 +1,4 @@
-from .. import binary
+from .. import backend, binary
 from ..dtypes import BOOL
 from ..monoid import land, lor
 from ..semiring import any_pair
@@ -18,7 +18,7 @@
 def _ewise_add_to_expr(self):
     if self._value is not None:
         return self._value
-    elif self.left.dtype == BOOL and self.right.dtype == BOOL:
+    if self.left.dtype == BOOL and self.right.dtype == BOOL:
         self._value = self.left.ewise_add(self.right, lor)
         return self._value
     raise TypeError(
@@ -32,7 +32,7 @@ def _ewise_add_to_expr(self):
 def _ewise_mult_to_expr(self):
     if self._value is not None:
         return self._value
-    elif self.left.dtype == BOOL and self.right.dtype == BOOL:
+    if self.left.dtype == BOOL and self.right.dtype == BOOL:
         self._value = self.left.ewise_mult(self.right, land)
         return self._value
     raise TypeError(
@@ -94,7 +94,8 @@ def shape(self):
     reduce = wrapdoc(Vector.reduce)(property(automethods.reduce))
     reposition = wrapdoc(Vector.reposition)(property(automethods.reposition))
     select = wrapdoc(Vector.select)(property(automethods.select))
-    ss = wrapdoc(Vector.ss)(property(automethods.ss))
+    if backend == "suitesparse":
+        ss = wrapdoc(Vector.ss)(property(automethods.ss))
     to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict))
     to_pygraphblas = wrapdoc(Vector.to_pygraphblas)(property(automethods.to_pygraphblas))
     to_values = wrapdoc(Vector.to_values)(property(automethods.to_values))
@@ -216,7 +217,8 @@ def shape(self):
     reduce_scalar = wrapdoc(Matrix.reduce_scalar)(property(automethods.reduce_scalar))
     reposition = wrapdoc(Matrix.reposition)(property(automethods.reposition))
     select = wrapdoc(Matrix.select)(property(automethods.select))
-    ss = wrapdoc(Matrix.ss)(property(automethods.ss))
+    if backend == "suitesparse":
+        ss = wrapdoc(Matrix.ss)(property(automethods.ss))
     to_coo = wrapdoc(Matrix.to_coo)(property(automethods.to_coo))
     to_csc = wrapdoc(Matrix.to_csc)(property(automethods.to_csc))
     to_csr = wrapdoc(Matrix.to_csr)(property(automethods.to_csr))
@@ -248,7 +250,6 @@ class MatrixEwiseAddExpr(MatrixInfixExpr):
     method_name = "ewise_add"
     _example_op = "plus"
     _infix = "|"
-    __networkx_plugin__ = "graphblas"
 
     _to_expr = _ewise_add_to_expr
 
@@ -258,7 +259,6 @@ class MatrixEwiseMultExpr(MatrixInfixExpr):
     method_name = "ewise_mult"
     _example_op = "times"
     _infix = "&"
-    __networkx_plugin__ = "graphblas"
 
     _to_expr = _ewise_mult_to_expr
 
@@ -268,7 +268,6 @@ class MatrixMatMulExpr(MatrixInfixExpr):
     method_name = "mxm"
     _example_op = "plus_times"
     _infix = "@"
-    __networkx_plugin__ = "graphblas"
 
     def __init__(self, left, right, *, nrows, ncols):
         super().__init__(left, right)
@@ -363,18 +362,18 @@ def _ewise_infix_expr(left, right, *, method, within):
             if method == "ewise_mult":
                 return VectorEwiseMultExpr(left, right)
             return VectorEwiseAddExpr(left, right)
-        elif method == "ewise_mult":
+        if method == "ewise_mult":
             return MatrixEwiseMultExpr(left, right)
         return MatrixEwiseAddExpr(left, right)
     if within == "__or__" and isinstance(right, Mask):
         return right.__ror__(left)
-    elif within == "__and__" and isinstance(right, Mask):
+    if within == "__and__" and isinstance(right, Mask):
         return right.__rand__(left)
-    elif left_type in types:
+    if left_type in types:
         left._expect_type(right, tuple(types), within=within, argname="right")
     elif right_type in types:
         right._expect_type(left, tuple(types), within=within, argname="left")
-    else:  # pragma: no cover
+    else:  # pragma: no cover (sanity)
         raise TypeError(f"Bad types for ewise infix: {type(left).__name__}, {type(right).__name__}")
 
 
@@ -420,7 +419,7 @@ def _matmul_infix_expr(left, right, *, within):
             within=within,
             argname="left",
         )
-    else:  # pragma: no cover
+    else:  # pragma: no cover (sanity)
         raise TypeError(
             f"Bad types for matmul infix: {type(left).__name__}, {type(right).__name__}"
         )
@@ -429,7 +428,7 @@ def _matmul_infix_expr(left, right, *, within):
     expr = getattr(left, method)(right, any_pair[bool])
     if expr.output_type is Vector:
         return VectorMatMulExpr(left, right, method_name=method, size=expr._size)
-    elif expr.output_type is Matrix:
+    if expr.output_type is Matrix:
         return MatrixMatMulExpr(left, right, nrows=expr._nrows, ncols=expr._ncols)
     return ScalarMatMulExpr(left, right)
 
diff --git a/graphblas/core/infixmethods.py b/graphblas/core/infixmethods.py
index 7a314861c..f7a99f688 100644
--- a/graphblas/core/infixmethods.py
+++ b/graphblas/core/infixmethods.py
@@ -13,10 +13,9 @@ def call_op(self, other, method, op, *, outer=False, union=False):
     if type1 in types and type2 in types:
         if outer:
             return self.ewise_add(other, op)
-        elif union:
+        if union:
             return self.ewise_union(other, op, False, False)
-        else:
-            return self.ewise_mult(other, op)
+        return self.ewise_mult(other, op)
     return op(self, other)
 
 
diff --git a/graphblas/core/mask.py b/graphblas/core/mask.py
index daf6bbc57..0bf96a2d9 100644
--- a/graphblas/core/mask.py
+++ b/graphblas/core/mask.py
@@ -1,6 +1,6 @@
 import warnings
 
-from .. import monoid
+from .. import backend, monoid
 from ..binary import land, lor, pair
 from ..dtypes import BOOL
 from ..select import valuene
@@ -112,8 +112,7 @@ def __and__(self, other):
         val = func(self, other, bool, None)
         if complement:
             return ComplementedStructuralMask(val)
-        else:
-            return StructuralMask(val)
+        return StructuralMask(val)
 
     __rand__ = __and__
 
@@ -238,7 +237,7 @@ def _combine_V_A(m1, m2, dtype, name):
     if isinstance(m2, ValueMask) and m1.parent._nvals > m2.parent._nvals:
         m1, m2 = m2, m1
     val = valuene(m1.parent).new(dtype, mask=m2, name=name)
-    if dtype != BOOL or not val.ss.is_iso:
+    if dtype != BOOL or backend != "suitesparse" or not val.ss.is_iso:
         val(val.S) << True
     return val
 
@@ -246,7 +245,7 @@ def _combine_V_A(m1, m2, dtype, name):
 def _combine_A_V(m1, m2, dtype, name):
     """S-V, V-V, CS-V, CV-V"""
     val = valuene(m2.parent).new(dtype, mask=m1, name=name)
-    if dtype != BOOL or not val.ss.is_iso:
+    if dtype != BOOL or backend != "suitesparse" or not val.ss.is_iso:
         val(val.S) << True
     return val
 
diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py
index 8dfec3f7e..0158f9a73 100644
--- a/graphblas/core/matrix.py
+++ b/graphblas/core/matrix.py
@@ -4,11 +4,11 @@
 import numpy as np
 
 from .. import backend, binary, monoid, select, semiring
-from ..dtypes import _INDEX, FP64, lookup_dtype, unify
+from ..dtypes import _INDEX, FP64, INT64, lookup_dtype, unify
 from ..exceptions import DimensionMismatch, InvalidValue, NoValue, check_status
 from . import automethods, ffi, lib, utils
 from .base import BaseExpression, BaseType, _check_mask, call
-from .expr import AmbiguousAssignOrExtract, IndexerResolver, Updater
+from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, Updater
 from .mask import Mask, StructuralMask, ValueMask
 from .operator import UNKNOWN_OPCLASS, find_opclass, get_semiring, get_typed_op, op_from_string
 from .scalar import (
@@ -19,7 +19,6 @@
     _as_scalar,
     _scalar_index,
 )
-from .ss.matrix import ss
 from .utils import (
     _CArray,
     _Pointer,
@@ -32,6 +31,9 @@
 )
 from .vector import Vector, VectorExpression, VectorIndexExpr, _select_mask
 
+if backend == "suitesparse":
+    from .ss.matrix import ss
+
 ffi_new = ffi.new
 
 _CSR_FORMAT = Scalar.from_value(
@@ -101,7 +103,8 @@ def __new__(cls, dtype=FP64, nrows=0, ncols=0, *, name=None):
         self._nrows = nrows.value
         self._ncols = ncols.value
         self._parent = None
-        self.ss = ss(self)
+        if backend == "suitesparse":
+            self.ss = ss(self)
         return self
 
     @classmethod
@@ -113,7 +116,8 @@ def _from_obj(cls, gb_obj, dtype, nrows, ncols, *, parent=None, name=None):
         self._nrows = nrows
         self._ncols = ncols
         self._parent = parent
-        self.ss = ss(self)
+        if backend == "suitesparse":
+            self.ss = ss(self)
         return self
 
     def __del__(self):
@@ -137,13 +141,15 @@ def _as_vector(self, *, name=None):
             raise ValueError(
                 f"Matrix must have a single column (not {self._ncols}) to be cast to a Vector"
             )
-        return Vector._from_obj(
-            ffi.cast("GrB_Vector*", self.gb_obj),
-            self.dtype,
-            self._nrows,
-            parent=self,
-            name=f"(GrB_Vector){self.name}" if name is None else name,
-        )
+        if backend == "suitesparse":
+            return Vector._from_obj(
+                ffi.cast("GrB_Vector*", self.gb_obj),
+                self.dtype,
+                self._nrows,
+                parent=self,
+                name=f"(GrB_Vector){self.name}" if name is None else name,
+            )
+        return self[:, 0].new(name=self.name if name is None else name)
 
     def __repr__(self, mask=None, expr=None):
         from .formatting import format_matrix
@@ -168,14 +174,20 @@ def _name_html(self):
         return super()._name_html
 
     def __reduce__(self):
-        # SS, SuiteSparse-specific: export
-        pieces = self.ss.export(raw=True)
+        # TODO: we should probably use (or compare to) GraphBLAS serialize methods
+        if backend == "suitesparse":
+            pieces = self.ss.export(raw=True)
+        else:
+            rows, cols, vals = self.to_values(sort=False)
+            pieces = (rows, cols, vals, self.dtype, self._nrows, self._ncols)
         return self._deserialize, (pieces, self.name)
 
     @staticmethod
     def _deserialize(pieces, name):
-        # SS, SuiteSparse-specific: import
-        return Matrix.ss.import_any(name=name, **pieces)
+        if backend == "suitesparse":
+            return Matrix.ss.import_any(name=name, **pieces)
+        rows, cols, vals, dtype, nrows, ncols = pieces
+        return Matrix.from_values(rows, cols, vals, dtype, nrows=nrows, ncols=ncols, name=name)
 
     @property
     def S(self):
@@ -214,10 +226,9 @@ def __getitem__(self, keys):
         shape = resolved_indexes.shape
         if not shape:
             return ScalarIndexExpr(self, resolved_indexes)
-        elif len(shape) == 1:
+        if len(shape) == 1:
             return VectorIndexExpr(self, resolved_indexes, *shape)
-        else:
-            return MatrixIndexExpr(self, resolved_indexes, *shape)
+        return MatrixIndexExpr(self, resolved_indexes, *shape)
 
     def __setitem__(self, keys, expr):
         """Assign values to a single element, row/column, or submatrix.
@@ -259,9 +270,11 @@ def __iter__(self):
         return zip(rows.flat, columns.flat)
 
     def __sizeof__(self):
-        size = ffi_new("size_t*")
-        check_status(lib.GxB_Matrix_memoryUsage(size, self.gb_obj[0]), self)
-        return size[0] + object.__sizeof__(self)
+        if backend == "suitesparse":
+            size = ffi_new("size_t*")
+            check_status(lib.GxB_Matrix_memoryUsage(size, self.gb_obj[0]), self)
+            return size[0] + object.__sizeof__(self)
+        raise TypeError("Unable to get size of Matrix with backend: {backend}")
 
     def isequal(self, other, *, check_dtype=False):
         """Check for exact equality (same size, same structure).
@@ -436,9 +449,7 @@ def to_values(self, dtype=None, *, rows=True, columns=True, values=True, sort=Tr
         np.ndarray[dtype=uint64] : Columns
         np.ndarray : Values
         """
-        if sort:
-            if backend != "suitesparse":
-                raise NotImplementedError()
+        if sort and backend == "suitesparse":
             self.wait()  # sort in SS
         nvals = self._nvals
         if rows or backend != "suitesparse":
@@ -466,6 +477,15 @@ def to_values(self, dtype=None, *, rows=True, columns=True, values=True, sort=Tr
                 dtype = lookup_dtype(dtype)
                 if dtype != self.dtype:
                     c_values = c_values.astype(dtype.np_type)  # copies
+        if sort and backend != "suitesparse":
+            col = c_columns.array
+            row = c_rows.array
+            ind = np.lexsort((col, row))  # sort by rows, then columns
+            return (
+                row[ind] if rows else None,
+                col[ind] if columns else None,
+                c_values[ind] if values else None,
+            )
         return (
             c_rows.array if rows else None,
             c_columns.array if columns else None,
@@ -576,9 +596,34 @@ def diag(self, k=0, dtype=None, *, name=None):
         -------
         :class:`~graphblas.Vector`
         """
-        from ..ss._core import diag
+        if backend == "suitesparse":
+            from ..ss._core import diag
 
-        return diag(self, k=k, dtype=dtype, name=name)
+            return diag(self, k=k, dtype=dtype, name=name)
+
+        # GraphBLAS spec could use GrB_Vector_diag
+        if dtype is None:
+            dtype = self.dtype
+        if type(k) is not Scalar:
+            k = Scalar.from_value(k, INT64, is_cscalar=True, name="")
+        D = select.diag(self, k).new(dtype, name="Diag_temp")
+        k = k.value
+        if k < 0:
+            size = min(self._nrows + k, self._ncols)
+        else:
+            size = min(self._ncols - k, self._nrows)
+        if size < 0:
+            size = 0
+        rv = Vector(dtype, size=size, name=name)
+        if k == 0:
+            rv << D.reduce_rowwise(monoid.any)
+        else:
+            d = D.reduce_rowwise(monoid.any).new(name="diag_temp")
+            if k < 0:
+                rv << d[d._size - rv._size :]
+            else:
+                rv << d[: rv._size]
+        return rv
 
     def wait(self):
         """Wait for a computation to complete.
@@ -690,8 +735,10 @@ def from_values(
                     "dup_op must be None if values is a scalar so that all "
                     "values can be identical.  Duplicate indices will be ignored."
                 )
-            # SS, SuiteSparse-specific: build_Scalar
-            C.ss.build_scalar(rows, columns, values.tolist())
+            if backend == "suitesparse":
+                C.ss.build_scalar(rows, columns, values.tolist())
+            else:
+                C.build(rows, columns, np.broadcast_to(values, rows.size), dup_op=binary.any)
         else:
             # Add the data
             # This needs to be the original data to get proper error messages
@@ -722,7 +769,30 @@ def _from_csx(cls, fmt, indptr, indices, values, dtype, num, name):
             # Look for array-subtdype
             new_dtype = lookup_dtype(np.dtype((new_dtype.np_type, values.shape[1:])))
         if values.ndim == 0:
-            values = np.repeat(values, indices.size)
+            if backend == "suitesparse":
+                # SuiteSparse GxB can handle iso-value
+                if fmt is _CSR_FORMAT:
+                    return cls.ss.import_csr(
+                        nrows=nrows,
+                        ncols=ncols,
+                        indptr=indptr,
+                        col_indices=indices,
+                        values=values,
+                        is_iso=True,
+                        dtype=dtype,
+                        name=name,
+                    )
+                return cls.ss.import_csc(
+                    nrows=nrows,
+                    ncols=ncols,
+                    indptr=indptr,
+                    row_indices=indices,
+                    values=values,
+                    is_iso=True,
+                    dtype=dtype,
+                    name=name,
+                )
+            values = np.broadcast_to(values, indices.size)
         new_mat = ffi_new("GrB_Matrix*")
         rv = Matrix._from_obj(new_mat, new_dtype, nrows, ncols, name=name)
         if new_dtype._is_udt:
@@ -897,19 +967,20 @@ def from_dcsr(
                 dtype=dtype,
                 name=name,
             )
+        indptr = ints_to_numpy_buffer(indptr, np.int64, name="indptr")  # Ensure int, not uint
+        if indptr.size == 0:
+            raise InvalidValue("indptr array must not be empty")
+        if indptr.size == 1:
+            nrows = 0 if nrows is None else nrows
+            ncols = 0 if ncols is None else ncols
+            rows = np.empty(0, np.uint64)
         else:
-            indptr = ints_to_numpy_buffer(indptr, np.int64, name="indptr")  # Ensure int, not uint
-            if indptr.size == 0:
-                raise InvalidValue("indptr array must not be empty")
-            elif indptr.size == 1:
-                nrows = 0 if nrows is None else nrows
-                ncols = 0 if ncols is None else ncols
-                rows = np.empty(0, np.uint64)
-            else:
-                rows = np.repeat(compressed_rows, np.diff(indptr))
-            return cls.from_coo(
-                rows, col_indices, values, dtype, nrows=nrows, ncols=ncols, name=name
-            )
+            rows = np.repeat(compressed_rows, np.diff(indptr))
+            if nrows is None:
+                nrows = int(np.max(compressed_rows)) + 1
+            if ncols is None and rows.size == 0:
+                ncols = 0
+        return cls.from_coo(rows, col_indices, values, dtype, nrows=nrows, ncols=ncols, name=name)
 
     @classmethod
     def from_dcsc(
@@ -979,19 +1050,46 @@ def from_dcsc(
                 dtype=dtype,
                 name=name,
             )
+        indptr = ints_to_numpy_buffer(indptr, np.int64, name="indptr")  # Ensure int, not uint
+        if indptr.size == 0:
+            raise InvalidValue("indptr array must not be empty")
+        if indptr.size == 1:
+            nrows = 0 if nrows is None else nrows
+            ncols = 0 if ncols is None else ncols
+            cols = np.empty(0, np.uint64)
         else:
-            indptr = ints_to_numpy_buffer(indptr, np.int64, name="indptr")  # Ensure int, not uint
-            if indptr.size == 0:
-                raise InvalidValue("indptr array must not be empty")
-            elif indptr.size == 1:
-                nrows = 0 if nrows is None else nrows
-                ncols = 0 if ncols is None else ncols
-                cols = np.empty(0, np.uint64)
-            else:
-                cols = np.repeat(compressed_cols, np.diff(indptr))
-            return cls.from_coo(
-                row_indices, cols, values, dtype, nrows=nrows, ncols=ncols, name=name
-            )
+            cols = np.repeat(compressed_cols, np.diff(indptr))
+            if ncols is None:
+                ncols = int(np.max(compressed_cols)) + 1
+            if nrows is None and cols.size == 0:
+                nrows = 0
+        return cls.from_coo(row_indices, cols, values, dtype, nrows=nrows, ncols=ncols, name=name)
+
+    @classmethod
+    def _from_dense(cls, values, dtype=None, *, name=None):
+        """Create a new Matrix from a dense numpy array"""
+        # TODO: GraphBLAS needs a way to import or assign dense
+        # We could also handle F-contiguous data w/o a copy
+        # TODO: handle `Matrix._from_dense(np.arange(3*4*5).reshape(3, 4, 5))` as 3x4 Matrix
+        if backend == "suitesparse":  # pragma: no cover (unused)
+            return Matrix.ss.import_fullr(values, dtype=dtype, name=name)
+        values, dtype = values_to_numpy_buffer(values, dtype)
+        if values.ndim < 2:
+            raise ValueError("A 2d array is required to create a dense Matrix")
+        if dtype.np_type.subdtype is not None and values.ndim < 3:
+            raise ValueError("A >2d array is required to create a dense Matrix with subdtype")
+        nrows, ncols, *rest = values.shape
+        cols, rows = np.meshgrid(
+            np.arange(ncols, dtype=np.uint64),
+            np.arange(nrows, dtype=np.uint64),
+        )
+        rows = rows.ravel()
+        cols = cols.ravel()
+        if values.ndim > 2:
+            values = values.reshape([nrows * ncols] + rest)
+        else:
+            values = values.ravel()
+        return cls.from_values(rows, cols, values, dtype, nrows=nrows, ncols=ncols, name=name)
 
     @classmethod
     def from_dicts(
@@ -1063,11 +1161,11 @@ def _to_csx(self, fmt, dtype=None):
         )
 
         # Unwrap objects with walrus operator and trim to proper size if necessary
-        if (Ap := Ap.array).size > (Ap_len := Ap_len.gb_obj[0]):  # pragma: no cover
+        if (Ap := Ap.array).size > (Ap_len := Ap_len.gb_obj[0]):  # pragma: no cover (suitesparse)
             Ap = Ap[:Ap_len]
-        if (Ai := Ai.array).size > (Ai_len := Ai_len.gb_obj[0]):  # pragma: no cover
+        if (Ai := Ai.array).size > (Ai_len := Ai_len.gb_obj[0]):  # pragma: no cover (suitesparse)
             Ai = Ai[:Ai_len]
-        if (Ax := Ax.array).shape[0] > (Ax_len := Ax_len.gb_obj[0]):  # pragma: no cover
+        if (Ax := Ax.array).shape[0] > (Ax_len := Ax_len.gb_obj[0]):  # pragma: no cover (suitespar)
             Ax = Ax[:Ax_len]
 
         if dtype is not None:
@@ -1158,6 +1256,8 @@ def to_dcsr(self, dtype=None):
             indptr = info["indptr"]
             cols = info["col_indices"]
             values = info["values"]
+            if info["is_iso"]:
+                values = np.broadcast_to(values, cols.size)
         else:
             rows, cols, values = self.to_coo()  # sorted by row then col
             compressed_rows, indices = np.unique(rows, return_index=True)
@@ -1202,6 +1302,8 @@ def to_dcsc(self, dtype=None):
             indptr = info["indptr"]
             rows = info["row_indices"]
             values = info["values"]
+            if info["is_iso"]:
+                values = np.broadcast_to(values, rows.size)
         else:
             rows, cols, values = self.to_coo(sort=False)
             ind = np.lexsort((rows, cols))  # sort by columns, then rows
@@ -1324,7 +1426,7 @@ def ewise_add(self, other, op=monoid.plus, *, require_monoid=None):
         )
         op = get_typed_op(op, self.dtype, other.dtype, kind="binary")
         # Per the spec, op may be a semiring, but this is weird, so don't.
-        if require_monoid:  # pragma: no cover
+        if require_monoid:  # pragma: no cover (deprecated)
             if op.opclass != "BinaryOp" or op.monoid is None:
                 self._expect_op(
                     op,
@@ -1462,7 +1564,6 @@ def ewise_union(self, other, op, left_default, right_default):
             # Functional syntax
             C << binary.div(A | B, left_default=1, right_default=1)
         """
-        # SS, SuiteSparse-specific: eWiseUnion
         method_name = "ewise_union"
         other = self._expect_type(
             other, (Matrix, TransposedMatrix, Vector), within=method_name, argname="other", op=op
@@ -1524,15 +1625,35 @@ def ewise_union(self, other, op, left_default, right_default):
                 ncols=self._ncols,
                 op=op,
             )
-        expr = MatrixExpression(
-            method_name,
-            "GxB_Matrix_eWiseUnion",
-            [self, left, other, right],
-            op=op,
-            at=self._is_transposed,
-            bt=other._is_transposed,
-            expr_repr=expr_repr,
-        )
+        if backend == "suitesparse":
+            expr = MatrixExpression(
+                method_name,
+                "GxB_Matrix_eWiseUnion",
+                [self, left, other, right],
+                op=op,
+                at=self._is_transposed,
+                bt=other._is_transposed,
+                expr_repr=expr_repr,
+            )
+        else:
+            dtype = unify(scalar_dtype, nonscalar_dtype, is_left_scalar=True)
+            new_left = other.dup(dtype, clear=True)
+            new_right = self.dup(dtype, clear=True)
+            if self._is_transposed:
+                new_right << binary.second(self, right)
+            else:
+                new_right(self.S) << right
+            if other._is_transposed:
+                new_left << binary.second(other, left)
+                new_right << binary.first(other | new_right)
+            else:
+                new_left(other.S) << left
+                new_right(other.S) << other
+            if self._is_transposed:
+                new_left << binary.first(self | new_left)
+            else:
+                new_left(self.S) << self
+            expr = op(new_left & new_right)
         if self.shape != other.shape:
             expr.new(name="")  # incompatible shape; raise now
         return expr
@@ -2179,7 +2300,7 @@ def _prep_for_extract(self, resolved_indexes):
                 dtype=self.dtype,
                 at=not self._is_transposed,
             )
-        elif colidx.size is None:
+        if colidx.size is None:
             # Column-only selection
             return VectorExpression(
                 method_name,
@@ -2190,17 +2311,16 @@ def _prep_for_extract(self, resolved_indexes):
                 dtype=self.dtype,
                 at=self._is_transposed,
             )
-        else:
-            return MatrixExpression(
-                method_name,
-                "GrB_Matrix_extract",
-                [self, rowidx, rowidx.cscalar, colidx, colidx.cscalar],
-                expr_repr="{0.name}[{1._expr_name}, {3._expr_name}]",
-                nrows=rowidx.size,
-                ncols=colidx.size,
-                dtype=self.dtype,
-                at=self._is_transposed,
-            )
+        return MatrixExpression(
+            method_name,
+            "GrB_Matrix_extract",
+            [self, rowidx, rowidx.cscalar, colidx, colidx.cscalar],
+            expr_repr="{0.name}[{1._expr_name}, {3._expr_name}]",
+            nrows=rowidx.size,
+            ncols=colidx.size,
+            dtype=self.dtype,
+            at=self._is_transposed,
+        )
 
     def _assign_element(self, resolved_indexes, value):
         rowidx, colidx = resolved_indexes.indices
@@ -2230,7 +2350,7 @@ def _assign_element(self, resolved_indexes, value):
             cfunc_name = "GrB_Matrix_setElement_Scalar"
         call(cfunc_name, [self, value, rowidx.index, colidx.index])
 
-    def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False):
+    def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False, replace=False):
         method_name = "__setitem__"
         rowidx, colidx = resolved_indexes.indices
         rowsize = rowidx.size
@@ -2262,29 +2382,32 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                             "Indices for subassign imply Vector submask, "
                             "but got Matrix mask instead"
                         )
-                    else:
-                        # C(M)[i, J] << v
-                        # Upcast v to a Matrix and use Matrix_assign
-                        rows = _CArray([rows.value])
-                        rowscalar = _as_scalar(1, _INDEX, is_cscalar=True)
-                        expr = MatrixExpression(
-                            method_name,
-                            "GrB_Matrix_assign",
-                            [value._as_matrix(), rows, rowscalar, cols, colscalar],
-                            expr_repr="[[{2._expr_name} rows], [{4._expr_name} cols]] = {0.name}",
-                            nrows=self._nrows,
-                            ncols=self._ncols,
-                            dtype=self.dtype,
-                            at=True,
-                        )
+                    # C(M)[i, J] << v
+                    # Upcast v to a Matrix and use Matrix_assign
+                    rows = _CArray([rows.value])
+                    rowscalar = _as_scalar(1, _INDEX, is_cscalar=True)
+                    expr = MatrixExpression(
+                        method_name,
+                        "GrB_Matrix_assign",
+                        [value._as_matrix(), rows, rowscalar, cols, colscalar],
+                        expr_repr="[[{2._expr_name} rows], [{4._expr_name} cols]] = {0.name}",
+                        nrows=self._nrows,
+                        ncols=self._ncols,
+                        dtype=self.dtype,
+                        at=True,
+                    )
                 else:
                     if is_submask:
                         # C[i, J](m) << v
-                        # SS, SuiteSparse-specific: subassign
-                        cfunc_name = "GrB_Row_subassign"
                         expr_repr = (
                             "[{1._expr_name}, [{3._expr_name} cols]](%s) << {0.name}" % mask.name
                         )
+                        if backend == "suitesparse":
+                            cfunc_name = "GxB_Row_subassign"
+                        else:
+                            # Recipe: create a new mask by expanding the old mask
+                            cfunc_name = "GrB_Row_assign"
+                            mask = _vanilla_subassign_mask(self, mask, rowidx, colidx, replace)
                     else:
                         # C(m)[i, J] << v
                         # C[i, J] << v
@@ -2308,28 +2431,31 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                             "Indices for subassign imply Vector submask, "
                             "but got Matrix mask instead"
                         )
-                    else:
-                        # C(M)[I, j] << v
-                        # Upcast v to a Matrix and use Matrix_assign
-                        cols = _CArray([cols.value])
-                        colscalar = _as_scalar(1, _INDEX, is_cscalar=True)
-                        expr = MatrixExpression(
-                            method_name,
-                            "GrB_Matrix_assign",
-                            [value._as_matrix(), rows, rowscalar, cols, colscalar],
-                            expr_repr="[[{2._expr_name} rows], [{4._expr_name} cols]] = {0.name}",
-                            nrows=self._nrows,
-                            ncols=self._ncols,
-                            dtype=self.dtype,
-                        )
+                    # C(M)[I, j] << v
+                    # Upcast v to a Matrix and use Matrix_assign
+                    cols = _CArray([cols.value])
+                    colscalar = _as_scalar(1, _INDEX, is_cscalar=True)
+                    expr = MatrixExpression(
+                        method_name,
+                        "GrB_Matrix_assign",
+                        [value._as_matrix(), rows, rowscalar, cols, colscalar],
+                        expr_repr="[[{2._expr_name} rows], [{4._expr_name} cols]] = {0.name}",
+                        nrows=self._nrows,
+                        ncols=self._ncols,
+                        dtype=self.dtype,
+                    )
                 else:
                     if is_submask:
                         # C[I, j](m) << v
-                        # SS, SuiteSparse-specific: subassign
-                        cfunc_name = "GrB_Col_subassign"
                         expr_repr = (
                             "[{1._expr_name}, [{3._expr_name} cols]](%s) << {0.name}" % mask.name
                         )
+                        if backend == "suitesparse":
+                            cfunc_name = "GxB_Col_subassign"
+                        else:
+                            # Recipe: create a new mask by expanding the old mask
+                            cfunc_name = "GrB_Col_assign"
+                            mask = _vanilla_subassign_mask(self, mask, rowidx, colidx, replace)
                     else:
                         # C(m)[I, j] << v
                         # C[I, j] << v
@@ -2387,11 +2513,14 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                     )
             if is_submask:
                 # C[I, J](M) << A
-                # SS, SuiteSparse-specific: subassign
-                cfunc_name = "GrB_Matrix_subassign"
                 expr_repr = (
                     "[[{2._expr_name} rows], [{4._expr_name} cols]](%s) << {0.name}" % mask.name
                 )
+                if backend == "suitesparse":
+                    cfunc_name = "GxB_Matrix_subassign"
+                else:
+                    cfunc_name = "GrB_Matrix_assign"
+                    mask = _vanilla_subassign_mask(self, mask, rowidx, colidx, replace)
             else:
                 # C[I, J] << A
                 # C(M)[I, J] << A
@@ -2415,7 +2544,10 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                 except (TypeError, ValueError):
                     if rowsize is not None or colsize is not None:
                         try:
-                            values, dtype = values_to_numpy_buffer(value, dtype, copy=True)
+                            # Do a copy for suitesparse so we can give ownership to suitesparse
+                            values, dtype = values_to_numpy_buffer(
+                                value, dtype, copy=backend == "suitesparse"
+                            )
                         except Exception:
                             pass
                         else:
@@ -2429,26 +2561,37 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                                 # C[i, J](m) << [1, 2, 3]
                                 expected_shape = (rowsize or colsize,)
                                 try:
-                                    vals = Vector.ss.import_full(
-                                        values, dtype=dtype, take_ownership=True
-                                    )
-                                    if dtype.np_type.subdtype is not None:
-                                        shape = vals.shape
+                                    if backend == "suitesparse":
+                                        vals = Vector.ss.import_full(
+                                            values, dtype=dtype, take_ownership=True
+                                        )
+                                    else:
+                                        # TODO: GraphBLAS needs a way to import or assign dense
+                                        vals = Vector.from_values(
+                                            np.arange(shape[0]), values, dtype, size=shape[0]
+                                        )
                                 except Exception:
                                     vals = None
+                                else:
+                                    if dtype.np_type.subdtype is not None:
+                                        shape = vals.shape
                             else:
                                 # C[I, J] << [[1, 2, 3], [4, 5, 6]]
                                 # C(M)[I, J] << [[1, 2, 3], [4, 5, 6]]
                                 # C[I, J](M) << [[1, 2, 3], [4, 5, 6]]
                                 expected_shape = (rowsize, colsize)
                                 try:
-                                    vals = Matrix.ss.import_fullr(
-                                        values, dtype=dtype, take_ownership=True
-                                    )
-                                    if dtype.np_type.subdtype is not None:
-                                        shape = vals.shape
+                                    if backend == "suitesparse":
+                                        vals = Matrix.ss.import_fullr(
+                                            values, dtype=dtype, take_ownership=True
+                                        )
+                                    else:
+                                        vals = Matrix._from_dense(values, dtype)
                                 except Exception:
                                     vals = None
+                                else:
+                                    if dtype.np_type.subdtype is not None:
+                                        shape = vals.shape
                             if vals is None or shape != expected_shape:
                                 if dtype.np_type.subdtype is not None:
                                     extra = (
@@ -2463,7 +2606,11 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                                     f"{extra}"
                                 ) from None
                             return self._prep_for_assign(
-                                resolved_indexes, vals, mask=mask, is_submask=is_submask
+                                resolved_indexes,
+                                vals,
+                                mask=mask,
+                                is_submask=is_submask,
+                                replace=replace,
                             )
                     if rowsize is None or colsize is None:
                         types = (Scalar, Vector)
@@ -2480,20 +2627,23 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                 if rowsize is None and colsize is not None:
                     if is_submask:
                         # C[i, J](m) << c
-                        # SS, SuiteSparse-specific: subassign
-                        cfunc_name = "GrB_Row_subassign"
                         value_vector = Vector(value.dtype, size=mask.parent._size, name="v_temp")
+                        value_vector << value
                         expr_repr = (
                             "[{1._expr_name}, [{3._expr_name} cols]](%s) << {0.name}" % mask.name
                         )
+                        if backend == "suitesparse":
+                            cfunc_name = "GxB_Row_subassign"
+                        else:
+                            cfunc_name = "GrB_Row_assign"
+                            mask = _vanilla_subassign_mask(self, mask, rowidx, colidx, replace)
                     else:
                         # C(m)[i, J] << c
                         # C[i, J] << c
                         cfunc_name = "GrB_Row_assign"
                         value_vector = Vector(value.dtype, size=colsize, name="v_temp")
                         expr_repr = "[{1._expr_name}, [{3._expr_name} cols]] = {0.name}"
-                    # SS, SuiteSparse-specific: assume efficient vector with single scalar
-                    value_vector << value
+                        value_vector << value
 
                     # Row-only selection
                     expr = MatrixExpression(
@@ -2508,16 +2658,19 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                 elif colsize is None and rowsize is not None:
                     if is_submask:
                         # C[I, j](m) << c
-                        # SS, SuiteSparse-specific: subassign
-                        cfunc_name = "GrB_Col_subassign"
                         value_vector = Vector(value.dtype, size=mask.parent._size, name="v_temp")
+                        value_vector << value
+                        if backend == "suitesparse":
+                            cfunc_name = "GxB_Col_subassign"
+                        else:
+                            cfunc_name = "GrB_Col_assign"
+                            mask = _vanilla_subassign_mask(self, mask, rowidx, colidx, replace)
                     else:
                         # C(m)[I, j] << c
                         # C[I, j] << c
                         cfunc_name = "GrB_Col_assign"
                         value_vector = Vector(value.dtype, size=rowsize, name="v_temp")
-                    # SS, SuiteSparse-specific: assume efficient vector with single scalar
-                    value_vector << value
+                        value_vector << value
 
                     # Column-only selection
                     expr = MatrixExpression(
@@ -2547,24 +2700,30 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                         if rowsize is None and colsize is None:
                             # C[i, j](M) << c
                             raise TypeError("Single element assign does not accept a submask")
-                        else:
-                            # C[i, J](M) << c
-                            # C[I, j](M) << c
-                            raise TypeError(
-                                "Indices for subassign imply Vector submask, "
-                                "but got Matrix mask instead"
-                            )
+                        # C[i, J](M) << c
+                        # C[I, j](M) << c
+                        raise TypeError(
+                            "Indices for subassign imply Vector submask, "
+                            "but got Matrix mask instead"
+                        )
                     # C[I, J](M) << c
-                    # SS, SuiteSparse-specific: subassign
                     if value._is_cscalar:
                         if value.dtype._is_udt:
                             dtype_name = "UDT"
                             value = _Pointer(value)
                         else:
                             dtype_name = value.dtype.name
-                        cfunc_name = f"GrB_Matrix_subassign_{dtype_name}"
+                        if backend == "suitesparse":
+                            cfunc_name = f"GxB_Matrix_subassign_{dtype_name}"
+                        else:
+                            cfunc_name = f"GrB_Matrix_assign_{dtype_name}"
+                            mask = _vanilla_subassign_mask(self, mask, rowidx, colidx, replace)
                     else:
-                        cfunc_name = "GrB_Matrix_subassign_Scalar"
+                        if backend == "suitesparse":
+                            cfunc_name = "GxB_Matrix_subassign_Scalar"
+                        else:
+                            cfunc_name = "GrB_Matrix_assign_Scalar"
+                            mask = _vanilla_subassign_mask(self, mask, rowidx, colidx, replace)
                     expr_repr = (
                         "[[{2._expr_name} rows], [{4._expr_name} cols]](%s) = {0._expr_name}"
                         % mask.name
@@ -2599,13 +2758,13 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                     ncols=self._ncols,
                     dtype=self.dtype,
                 )
-        return expr
+        return expr, mask
 
     def _delete_element(self, resolved_indexes):
         rowidx, colidx = resolved_indexes.indices
         call("GrB_Matrix_removeElement", [self, rowidx.index, colidx.index])
 
-    def to_pygraphblas(self):  # pragma: no cover
+    def to_pygraphblas(self):  # pragma: no cover (outdated)
         """Convert to a ``pygraphblas.Matrix`` without copying data.
 
         This gives control of the underlying GraphBLAS memory to pygraphblas,
@@ -2626,7 +2785,7 @@ def to_pygraphblas(self):  # pragma: no cover
         return matrix
 
     @classmethod
-    def from_pygraphblas(cls, matrix):  # pragma: no cover
+    def from_pygraphblas(cls, matrix):  # pragma: no cover (outdated)
         """Convert a ``pygraphblas.Matrix`` to a new :class:`Matrix` without copying data.
 
         This gives control of the underlying GraphBLAS memory to python-graphblas,
@@ -2652,7 +2811,52 @@ def from_pygraphblas(cls, matrix):  # pragma: no cover
         return rv
 
 
-Matrix.ss = class_property(Matrix.ss, ss)
+if backend == "suitesparse":
+    Matrix.ss = class_property(Matrix.ss, ss)
+else:
+    Matrix.ss = class_property(
+        Matrix.ss, 'ss attribute is only available with "suitesparse" backend', exceptional=True
+    )
+
+
+def _vanilla_subassign_mask(self, mask, rows, cols, replace):
+    is_rowwise = rows.size is None and cols.size is not None
+    is_colwise = rows.size is not None and cols.size is None
+    is_matrix = not is_rowwise and not is_colwise
+    if is_matrix:
+        if not replace and rows.index is _ALL_INDICES and cols.index is _ALL_INDICES:
+            return mask
+        rows = rows._py_index()
+        cols = cols._py_index()
+        val = Matrix(mask.parent.dtype, nrows=self._nrows, ncols=self._ncols, name="M_temp")
+        val[rows, cols] = mask.parent
+    elif is_rowwise:
+        if not replace and cols.index is _ALL_INDICES:
+            return mask
+        cols = cols._py_index()
+        val = Vector(mask.parent.dtype, size=self._ncols, name="m_temp")
+        val[cols] = mask.parent
+    elif is_colwise:
+        if not replace and rows.index is _ALL_INDICES:
+            return mask
+        rows = rows._py_index()
+        val = Vector(mask.parent.dtype, size=self._nrows, name="m_temp")
+        val[rows] = mask.parent
+    else:  # pragma: no cover (sanity)
+        raise RuntimeError("oops! Submask does not work on scalars")
+    mask = type(mask)(val)
+    if replace:
+        if is_matrix:
+            val = self.dup()
+            del val[rows, cols]
+        elif is_rowwise:
+            val = self[rows.index, :].new()
+            del val[cols]
+        else:  # is_colwise:
+            val = self[:, cols.index].new()
+            del val[rows]
+        mask |= val.S
+    return mask
 
 
 class MatrixExpression(BaseExpression):
@@ -2758,7 +2962,8 @@ def shape(self):
     reduce_scalar = wrapdoc(Matrix.reduce_scalar)(property(automethods.reduce_scalar))
     reposition = wrapdoc(Matrix.reposition)(property(automethods.reposition))
     select = wrapdoc(Matrix.select)(property(automethods.select))
-    ss = wrapdoc(Matrix.ss)(property(automethods.ss))
+    if backend == "suitesparse":
+        ss = wrapdoc(Matrix.ss)(property(automethods.ss))
     to_coo = wrapdoc(Matrix.to_coo)(property(automethods.to_coo))
     to_csc = wrapdoc(Matrix.to_csc)(property(automethods.to_csc))
     to_csr = wrapdoc(Matrix.to_csr)(property(automethods.to_csr))
@@ -2847,7 +3052,8 @@ def shape(self):
     reduce_scalar = wrapdoc(Matrix.reduce_scalar)(property(automethods.reduce_scalar))
     reposition = wrapdoc(Matrix.reposition)(property(automethods.reposition))
     select = wrapdoc(Matrix.select)(property(automethods.select))
-    ss = wrapdoc(Matrix.ss)(property(automethods.ss))
+    if backend == "suitesparse":
+        ss = wrapdoc(Matrix.ss)(property(automethods.ss))
     to_coo = wrapdoc(Matrix.to_coo)(property(automethods.to_coo))
     to_csc = wrapdoc(Matrix.to_csc)(property(automethods.to_csc))
     to_csr = wrapdoc(Matrix.to_csr)(property(automethods.to_csr))
@@ -2906,7 +3112,12 @@ def new(self, dtype=None, *, mask=None, name=None):
             output(mask=mask).update(self)
         return output
 
-    dup = new
+    def dup(self, dtype=None, *, clear=False, mask=None, name=None):
+        if dtype is None:
+            dtype = self.dtype
+        if clear:
+            return Matrix(dtype, self._nrows, self._ncols, name=name)
+        return self.new(dtype, mask=mask, name=name)
 
     @property
     def T(self):
diff --git a/graphblas/core/operator.py b/graphblas/core/operator.py
index c717f81e6..85ecf1e67 100644
--- a/graphblas/core/operator.py
+++ b/graphblas/core/operator.py
@@ -92,9 +92,8 @@ def _call_op(op, left, right=None, thunk=None, **kwargs):
     if output_type(left) in {Vector, Matrix, TransposedMatrix}:
         if thunk is not None:
             return left.select(op, thunk=thunk, **kwargs)
-        else:
-            return left.apply(op, right=right, **kwargs)
-    elif output_type(right) in {Vector, Matrix, TransposedMatrix}:
+        return left.apply(op, right=right, **kwargs)
+    if output_type(right) in {Vector, Matrix, TransposedMatrix}:
         return right.apply(op, left=left, **kwargs)
     raise TypeError(
         f"Bad types when calling {op!r}.  Got types: {type(left)}, {type(right)}.\n"
@@ -173,8 +172,7 @@ def is_positional(self):
     def __reduce__(self):
         if self._type2 is None or self.type == self._type2:
             return (getitem, (self.parent, self.type))
-        else:
-            return (getitem, (self.parent, (self.type, self._type2)))
+        return (getitem, (self.parent, (self.type, self._type2)))
 
 
 class TypedBuiltinUnaryOp(TypedOpBase):
@@ -804,7 +802,7 @@ def __getitem__(self, type_):
             dtype1 = lookup_dtype(dtype1)
             dtype2 = lookup_dtype(dtype2)
             return get_typed_op(self, dtype1, dtype2)
-        elif not self._is_udt:
+        if not self._is_udt:
             type_ = lookup_dtype(type_)
             if type_ not in self._typed_ops:
                 if self._udt_types is None:
@@ -873,7 +871,7 @@ def _initialize(cls, include_in_ops=True):
         include_in_ops determines whether the operators are included in the
         `gb.ops` namespace in addition to the defined module.
         """
-        if cls._initialized:  # pragma: no cover
+        if cls._initialized:  # pragma: no cover (safety)
             return
         # Read in the parse configs
         trim_from_front = cls._parse_config.get("trim_from_front", 0)
@@ -930,7 +928,7 @@ def _initialize(cls, include_in_ops=True):
                             else:
                                 # Grab the number of bits from type_
                                 num_bits = type_[-2:]
-                                if num_bits not in {"32", "64"}:  # pragma: no cover
+                                if num_bits not in {"32", "64"}:  # pragma: no cover (safety)
                                     raise TypeError(f"Unexpected number of bits: {num_bits}")
                                 return_type = f"{return_prefix}{num_bits}"
                         builtin_op = cls._typed_class(
@@ -951,11 +949,11 @@ def _deserialize(cls, name, *args):
 
 
 def _identity(x):
-    return x  # pragma: no cover
+    return x  # pragma: no cover (numba)
 
 
 def _one(x):
-    return 1  # pragma: no cover
+    return 1  # pragma: no cover (numba)
 
 
 class UnaryOp(OpBase):
@@ -1046,22 +1044,22 @@ def _build(cls, name, func, *, anonymous=False, is_udt=False):
                     if ret_type == BOOL:
 
                         def unary_wrapper(z, x):
-                            z[0] = bool(unary_udf(bool(x[0])))  # pragma: no cover
+                            z[0] = bool(unary_udf(bool(x[0])))  # pragma: no cover (numba)
 
                     else:
 
                         def unary_wrapper(z, x):
-                            z[0] = unary_udf(bool(x[0]))  # pragma: no cover
+                            z[0] = unary_udf(bool(x[0]))  # pragma: no cover (numba)
 
                 elif ret_type == BOOL:
 
                     def unary_wrapper(z, x):
-                        z[0] = bool(unary_udf(x[0]))  # pragma: no cover
+                        z[0] = bool(unary_udf(x[0]))  # pragma: no cover (numba)
 
                 else:
 
                     def unary_wrapper(z, x):
-                        z[0] = unary_udf(x[0])  # pragma: no cover
+                        z[0] = unary_udf(x[0])  # pragma: no cover (numba)
 
                 unary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(unary_wrapper)
                 new_unary = ffi_new("GrB_UnaryOp*")
@@ -1078,8 +1076,7 @@ def unary_wrapper(z, x):
                 return_types[type_] = ret_type
         if success or is_udt:
             return new_type_obj
-        else:
-            raise UdfParseError("Unable to parse function using Numba")
+        raise UdfParseError("Unable to parse function using Numba")
 
     def _compile_udt(self, dtype, dtype2):
         if dtype in self._udt_types:
@@ -1192,7 +1189,7 @@ def _initialize(cls):
                     typed_op = op._typed_ops[target_type]
                     output_type = op.types[target_type]
                     for dtype in input_types:
-                        if dtype not in op.types:  # pragma: no branch
+                        if dtype not in op.types:  # pragma: no branch (safety)
                             op.types[dtype] = output_type
                             op._typed_ops[dtype] = typed_op
                             op.coercions[dtype] = target_type
@@ -1328,27 +1325,23 @@ def _build(cls, name, func, *, is_udt=False, anonymous=False):
                 if type_ == BOOL:
                     if ret_type == BOOL:
 
-                        def indexunary_wrapper(z, x, row, col, y):
-                            z[0] = bool(
-                                indexunary_udf(bool(x[0]), row, col, bool(y[0]))
-                            )  # pragma: no cover
+                        def indexunary_wrapper(z, x, row, col, y):  # pragma: no cover (numba)
+                            z[0] = bool(indexunary_udf(bool(x[0]), row, col, bool(y[0])))
 
                     else:
 
-                        def indexunary_wrapper(z, x, row, col, y):
-                            z[0] = indexunary_udf(
-                                bool(x[0]), row, col, bool(y[0])
-                            )  # pragma: no cover
+                        def indexunary_wrapper(z, x, row, col, y):  # pragma: no cover (numba)
+                            z[0] = indexunary_udf(bool(x[0]), row, col, bool(y[0]))
 
                 elif ret_type == BOOL:
 
-                    def indexunary_wrapper(z, x, row, col, y):
-                        z[0] = bool(indexunary_udf(x[0], row, col, y[0]))  # pragma: no cover
+                    def indexunary_wrapper(z, x, row, col, y):  # pragma: no cover (numba)
+                        z[0] = bool(indexunary_udf(x[0], row, col, y[0]))
 
                 else:
 
-                    def indexunary_wrapper(z, x, row, col, y):
-                        z[0] = indexunary_udf(x[0], row, col, y[0])  # pragma: no cover
+                    def indexunary_wrapper(z, x, row, col, y):  # pragma: no cover (numba)
+                        z[0] = indexunary_udf(x[0], row, col, y[0])
 
                 indexunary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(indexunary_wrapper)
                 new_indexunary = ffi_new("GrB_IndexUnaryOp*")
@@ -1369,8 +1362,7 @@ def indexunary_wrapper(z, x, row, col, y):
                 return_types[type_] = ret_type
         if success or is_udt:
             return new_type_obj
-        else:
-            raise UdfParseError("Unable to parse function using Numba")
+        raise UdfParseError("Unable to parse function using Numba")
 
     def _compile_udt(self, dtype, dtype2):
         if dtype2 is None:  # pragma: no cover
@@ -1462,7 +1454,7 @@ def _initialize(cls):
             op = getattr(indexunary, name)
             typed_op = op._typed_ops[BOOL]
             output_type = op.types[BOOL]
-            if UINT64 not in op.types:  # pragma: no cover
+            if UINT64 not in op.types:
                 op.types[UINT64] = output_type
                 op._typed_ops[UINT64] = typed_op
                 op.coercions[UINT64] = BOOL
@@ -1470,7 +1462,7 @@ def _initialize(cls):
             op = getattr(indexunary, name)
             typed_op = op._typed_ops[INT64]
             output_type = op.types[INT64]
-            if UINT64 not in op.types:  # pragma: no cover
+            if UINT64 not in op.types:
                 op.types[UINT64] = output_type
                 op._typed_ops[UINT64] = typed_op
                 op.coercions[UINT64] = INT64
@@ -1604,7 +1596,7 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal
 
     @classmethod
     def _initialize(cls):
-        if cls._initialized:  # pragma: no cover
+        if cls._initialized:  # pragma: no cover (safety)
             return
         # IndexUnaryOp adds it boolean-returning objects to SelectOp
         IndexUnaryOp._initialize()
@@ -1642,27 +1634,27 @@ def __reduce__(self):
 
 
 def _floordiv(x, y):
-    return x // y  # pragma: no cover
+    return x // y  # pragma: no cover (numba)
 
 
 def _rfloordiv(x, y):
-    return y // x  # pragma: no cover
+    return y // x  # pragma: no cover (numba)
 
 
 def _absfirst(x, y):
-    return np.abs(x)  # pragma: no cover
+    return np.abs(x)  # pragma: no cover (numba)
 
 
 def _abssecond(x, y):
-    return np.abs(y)  # pragma: no cover
+    return np.abs(y)  # pragma: no cover (numba)
 
 
 def _rpow(x, y):
-    return y**x  # pragma: no cover
+    return y**x  # pragma: no cover (numba)
 
 
 def _isclose(rel_tol=1e-7, abs_tol=0.0):
-    def inner(x, y):  # pragma: no cover
+    def inner(x, y):  # pragma: no cover (numba)
         return x == y or abs(x - y) <= max(rel_tol * max(abs(x), abs(y)), abs_tol)
 
     return inner
@@ -1671,7 +1663,7 @@ def inner(x, y):  # pragma: no cover
 _MAX_INT64 = np.iinfo(np.int64).max
 
 
-def _binom(N, k):  # pragma: no cover
+def _binom(N, k):  # pragma: no cover (numba)
     # Returns 0 if overflow or out-of-bounds
     if k > N or k < 0:
         return 0
@@ -1704,29 +1696,25 @@ def _register_binom():
 
 
 def _first(x, y):
-    return x  # pragma: no cover
+    return x  # pragma: no cover (numba)
 
 
 def _second(x, y):
-    return y  # pragma: no cover
+    return y  # pragma: no cover (numba)
 
 
 def _pair(x, y):
-    return 1  # pragma: no cover
+    return 1  # pragma: no cover (numba)
 
 
 def _first_dtype(op, dtype, dtype2):
-    if dtype._is_udt:
+    if dtype._is_udt or dtype2._is_udt:
         return op._compile_udt(dtype, dtype2)
-    else:
-        return op[dtype]
 
 
 def _second_dtype(op, dtype, dtype2):
-    if dtype2._is_udt:
+    if dtype._is_udt or dtype2._is_udt:
         return op._compile_udt(dtype, dtype2)
-    else:
-        return op[dtype2]
 
 
 def _pair_dtype(op, dtype, dtype2):
@@ -1964,23 +1952,23 @@ def _build(cls, name, func, *, is_udt=False, anonymous=False):
                 if type_ == BOOL:
                     if ret_type == BOOL:
 
-                        def binary_wrapper(z, x, y):
-                            z[0] = bool(binary_udf(bool(x[0]), bool(y[0])))  # pragma: no cover
+                        def binary_wrapper(z, x, y):  # pragma: no cover (numba)
+                            z[0] = bool(binary_udf(bool(x[0]), bool(y[0])))
 
                     else:
 
-                        def binary_wrapper(z, x, y):
-                            z[0] = binary_udf(bool(x[0]), bool(y[0]))  # pragma: no cover
+                        def binary_wrapper(z, x, y):  # pragma: no cover (numba)
+                            z[0] = binary_udf(bool(x[0]), bool(y[0]))
 
                 elif ret_type == BOOL:
 
-                    def binary_wrapper(z, x, y):
-                        z[0] = bool(binary_udf(x[0], y[0]))  # pragma: no cover
+                    def binary_wrapper(z, x, y):  # pragma: no cover (numba)
+                        z[0] = bool(binary_udf(x[0], y[0]))
 
                 else:
 
-                    def binary_wrapper(z, x, y):
-                        z[0] = binary_udf(x[0], y[0])  # pragma: no cover
+                    def binary_wrapper(z, x, y):  # pragma: no cover (numba)
+                        z[0] = binary_udf(x[0], y[0])
 
                 binary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(binary_wrapper)
                 new_binary = ffi_new("GrB_BinaryOp*")
@@ -2001,8 +1989,7 @@ def binary_wrapper(z, x, y):
                 return_types[type_] = ret_type
         if success or is_udt:
             return new_type_obj
-        else:
-            raise UdfParseError("Unable to parse function using Numba")
+        raise UdfParseError("Unable to parse function using Numba")
 
     def _compile_udt(self, dtype, dtype2):
         if dtype2 is None:
@@ -2025,7 +2012,7 @@ def _compile_udt(self, dtype, dtype2):
             # PERF: we can probably make this faster
             if mask.all():
 
-                def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover
+                def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover (numba)
                     x = numba.carray(x_ptr, itemsize)
                     y = numba.carray(y_ptr, itemsize)
                     # for i in range(itemsize):
@@ -2038,7 +2025,7 @@ def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover
 
             else:
 
-                def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover
+                def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover (numba)
                     x = numba.carray(x_ptr, itemsize)
                     y = numba.carray(y_ptr, itemsize)
                     # for i in range(itemsize):
@@ -2061,7 +2048,7 @@ def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover
             )
             if mask.all():
 
-                def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover
+                def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover (numba)
                     x = numba.carray(x_ptr, itemsize)
                     y = numba.carray(y_ptr, itemsize)
                     # for i in range(itemsize):
@@ -2074,7 +2061,7 @@ def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover
 
             else:
 
-                def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover
+                def binary_wrapper(z_ptr, x_ptr, y_ptr):  # pragma: no cover (numba)
                     x = numba.carray(x_ptr, itemsize)
                     y = numba.carray(y_ptr, itemsize)
                     # for i in range(itemsize):
@@ -2165,7 +2152,7 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal
 
     @classmethod
     def _initialize(cls):
-        if cls._initialized:  # pragma: no cover
+        if cls._initialized:  # pragma: no cover (safety)
             return
         super()._initialize()
         # Rename div to cdiv
@@ -2272,7 +2259,7 @@ def _initialize(cls):
                     typed_op = cur_op._typed_ops[target_type]
                     output_type = cur_op.types[target_type]
                     for dtype in input_types:
-                        if dtype not in cur_op.types:  # pragma: no branch
+                        if dtype not in cur_op.types:  # pragma: no branch (safety)
                             cur_op.types[dtype] = output_type
                             cur_op._typed_ops[dtype] = typed_op
                             cur_op.coercions[dtype] = target_type
@@ -2552,7 +2539,7 @@ def _is_udt(self):
 
     @classmethod
     def _initialize(cls):
-        if cls._initialized:  # pragma: no cover
+        if cls._initialized:  # pragma: no cover (safety)
             return
         super()._initialize()
         lor = monoid.lor._typed_ops[BOOL]
@@ -2563,7 +2550,7 @@ def _initialize(cls):
             # (monoid.plus, lor),  # two choices: lor, or plus[int]
             (monoid.times, land),
         ]:
-            if BOOL not in cur_op.types:  # pragma: no branch
+            if BOOL not in cur_op.types:  # pragma: no branch (safety)
                 cur_op.types[BOOL] = BOOL
                 cur_op.coercions[BOOL] = BOOL
                 cur_op._typed_ops[BOOL] = typed_op
@@ -2582,7 +2569,7 @@ def _initialize(cls):
                 UINT32,
                 UINT64,
             ):
-                if dtype in cur_op.types:  # pragma: no cover
+                if dtype in cur_op.types:  # pragma: no cover (safety)
                     continue
                 cur_op.types[dtype] = BOOL
                 cur_op.coercions[dtype] = BOOL
@@ -2773,7 +2760,7 @@ def register_new(cls, name, monoid, binaryop, *, lazy=False):
 
     @classmethod
     def _initialize(cls):
-        if cls._initialized:  # pragma: no cover
+        if cls._initialized:  # pragma: no cover (safety)
             return
         super()._initialize()
         # Rename div to cdiv (truncate towards 0)
@@ -2821,7 +2808,7 @@ def _initialize(cls):
             if not hasattr(semiring, target_name):
                 continue
             target_op = getattr(semiring, target_name)
-            if BOOL not in target_op.types:  # pragma: no branch
+            if BOOL not in target_op.types:  # pragma: no branch (safety)
                 source_op = getattr(semiring, source_name)
                 typed_op = source_op._typed_ops[BOOL]
                 target_op.types[BOOL] = BOOL
@@ -2926,7 +2913,7 @@ def _initialize(cls):
         ):
             cur_op = getattr(semiring, opname)
             target = getattr(semiring, targetname)
-            if BOOL in cur_op.types or BOOL not in target.types:  # pragma: no cover
+            if BOOL in cur_op.types or BOOL not in target.types:  # pragma: no cover (safety)
                 continue
             cur_op.types[BOOL] = target.types[BOOL]
             cur_op._typed_ops[BOOL] = target._typed_ops[BOOL]
@@ -2991,11 +2978,12 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala
         if op._is_udt:
             return op._compile_udt(dtype, dtype2)
         # Single dtype is simple lookup
-        elif dtype2 is None:
+        if dtype2 is None:
             return op[dtype]
         # Handle special cases such as first and second (may have UDTs)
-        elif op._custom_dtype is not None:
-            return op._custom_dtype(op, dtype, dtype2)
+        if op._custom_dtype is not None:
+            if (rv := op._custom_dtype(op, dtype, dtype2)) is not None:
+                return rv
         # Generic case: try to unify the two dtypes
         try:
             return op[
@@ -3005,11 +2993,10 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala
             # Failure to unify implies a dtype is UDT; some builtin operators can handle UDTs
             if op.is_positional:
                 return op[UINT64]
-            elif op._udt_types is None:
+            if op._udt_types is None:
                 raise
-            else:
-                return op._compile_udt(dtype, dtype2)
-    elif isinstance(op, ParameterizedUdf):
+            return op._compile_udt(dtype, dtype2)
+    if isinstance(op, ParameterizedUdf):
         op = op()  # Use default parameters of parameterized UDFs
         return get_typed_op(
             op,
@@ -3019,16 +3006,16 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala
             is_right_scalar=is_right_scalar,
             kind=kind,
         )
-    elif isinstance(op, TypedOpBase):
+    if isinstance(op, TypedOpBase):
         return op
 
     from .agg import Aggregator, TypedAggregator
 
     if isinstance(op, Aggregator):
         return op[dtype]
-    elif isinstance(op, TypedAggregator):
+    if isinstance(op, TypedAggregator):
         return op
-    elif isinstance(op, str):
+    if isinstance(op, str):
         if kind == "unary":
             op = unary_from_string(op)
         elif kind == "select":
@@ -3064,11 +3051,11 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala
             is_right_scalar=is_right_scalar,
             kind=kind,
         )
-    elif isinstance(op, FunctionType):
+    if isinstance(op, FunctionType):
         if kind == "unary":
             op = UnaryOp.register_anonymous(op, is_udt=True)
             return op._compile_udt(dtype, dtype2)
-        elif kind.startswith("binary"):
+        if kind.startswith("binary"):
             op = BinaryOp.register_anonymous(op, is_udt=True)
             return op._compile_udt(dtype, dtype2)
     raise TypeError(f"Unable to get typed operator from object with type {type(op)}")
@@ -3173,15 +3160,14 @@ def get_semiring(monoid, binaryop, name=None):
             rv = Semiring.register_anonymous(monoid, binaryop, name=name)
         if name != canonical_name:
             module, funcname = Semiring._remove_nesting(name, strict=False)
-            if not _hasop(module, funcname):  # pragma: no branch
+            if not _hasop(module, funcname):  # pragma: no branch (safety)
                 setattr(module, funcname, rv)
 
     if binary_type is not None:
         return rv[binary_type]
-    elif monoid_type is not None:
+    if monoid_type is not None:
         return rv[monoid_type]
-    else:
-        return rv
+    return rv
 
 
 # Now initialize all the things!
@@ -3192,7 +3178,7 @@ def get_semiring(monoid, binaryop, name=None):
     BinaryOp._initialize()
     Monoid._initialize()
     Semiring._initialize()
-except Exception:  # pragma: no cover
+except Exception:  # pragma: no cover (debug)
     # Exceptions here can often get ignored by Python
     import traceback
 
@@ -3284,7 +3270,7 @@ def _from_string(string, module, mapping, example):
         raise ValueError(
             f'Bad {name} string: {string!r}.  Contains too many "[".  Example usage: {example!r}'
         )
-    elif dtype:
+    if dtype:
         dtype = dtype[0]
         if not dtype.endswith("]"):
             name = module.__name__.split(".")[-1]
diff --git a/graphblas/core/recorder.py b/graphblas/core/recorder.py
index 7218192c6..0afe63173 100644
--- a/graphblas/core/recorder.py
+++ b/graphblas/core/recorder.py
@@ -14,7 +14,7 @@ def gbstr(arg):
     """Convert arg to a string as an argument in a GraphBLAS call"""
     if arg is None:
         return "NULL"
-    elif isinstance(arg, TypedOpBase):
+    if isinstance(arg, TypedOpBase):
         name = arg.gb_name
     elif isinstance(arg, Mask):
         name = arg.parent.name
@@ -27,9 +27,8 @@ def gbstr(arg):
     if not name:
         if type(arg) is Scalar and arg._is_cscalar:
             return repr(arg.value)
-        else:
-            c = type(arg).__name__[0]
-            return f"{'M' if c == 'M' else c.lower()}_temp"
+        c = type(arg).__name__[0]
+        return f"{'M' if c == 'M' else c.lower()}_temp"
     return name
 
 
@@ -44,7 +43,7 @@ class Recorder:
     >>> with Recorder() as rec:
     ...     C = A.mxm(B).new()
     >>> rec.data[0]
-    'GrB_mxm(C, NULL, NULL, GxB_PLUS_TIMES_INT64, A, B, NULL)'
+    'GrB_mxm(C, NULL, NULL, GrB_PLUS_TIMES_SEMIRING_INT64, A, B, NULL)'
 
     Currently, only one recorder will record at a time within a context.
     """
diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py
index 05e7e2b21..32bee3429 100644
--- a/graphblas/core/scalar.py
+++ b/graphblas/core/scalar.py
@@ -174,10 +174,11 @@ def __sizeof__(self):
         base = object.__sizeof__(self)
         if self._is_cscalar:
             return base + self.gb_obj.__sizeof__() + ffi.sizeof(self.dtype.c_type)
-        else:
+        if backend == "suitesparse":
             size = ffi_new("size_t*")
             check_status(lib.GxB_Scalar_memoryUsage(size, self.gb_obj[0]), self)
             return base + size[0]
+        raise TypeError("Unable to get size of GrB_Scalar with backend: {backend}")
 
     def isequal(self, other, *, check_dtype=False):
         """Check for exact equality (including whether the value is missing).
@@ -215,17 +216,16 @@ def isequal(self, other, *, check_dtype=False):
             check_dtype = False
         if check_dtype and self.dtype != other.dtype:
             return False
-        elif self._is_empty:
+        if self._is_empty:
             return other._is_empty
-        elif other._is_empty:
+        if other._is_empty:
             return False
-        else:
-            # For now, compare values in Python
-            rv = self.value == other.value
-            try:
-                return bool(rv)
-            except ValueError:
-                return bool(rv.all())
+        # For now, compare values in Python
+        rv = self.value == other.value
+        try:
+            return bool(rv)
+        except ValueError:
+            return bool(rv.all())
 
     def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False):
         """Check for approximate equality (including whether the value is missing).
@@ -264,9 +264,9 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False):
             check_dtype = False
         if check_dtype and self.dtype != other.dtype:
             return False
-        elif self._is_empty:
+        if self._is_empty:
             return other._is_empty
-        elif other._is_empty:
+        if other._is_empty:
             return False
         # We can't yet call a UDF on a scalar as part of the spec, so let's do it ourselves
         isclose_func = isclose(rel_tol, abs_tol)
@@ -335,10 +335,8 @@ def value(self):
             rv = np.array(ffi.buffer(scalar.gb_obj[0 : np_type.itemsize]))
             if np_type.subdtype is None:
                 return rv.view(np_type)[0]
-            else:
-                return rv.view(np_type.subdtype[0]).reshape(np_type.subdtype[1])
-        else:
-            return scalar.gb_obj[0]
+            return rv.view(np_type.subdtype[0]).reshape(np_type.subdtype[1])
+        return scalar.gb_obj[0]
 
     @value.setter
     def value(self, val):
@@ -538,7 +536,7 @@ def __reduce__(self):
     def _deserialize(value, dtype, is_cscalar, name):
         return Scalar.from_value(value, dtype, is_cscalar=is_cscalar, name=name)
 
-    def to_pygraphblas(self):  # pragma: no cover
+    def to_pygraphblas(self):  # pragma: no cover (outdated)
         """Convert to a ``pygraphblas.Scalar`` by making a copy of the internal value.
 
         This method requires the
@@ -554,7 +552,7 @@ def to_pygraphblas(self):  # pragma: no cover
         return pg.Scalar.from_value(self.value)
 
     @classmethod
-    def from_pygraphblas(cls, scalar):  # pragma: no cover
+    def from_pygraphblas(cls, scalar):  # pragma: no cover (outdated)
         """Convert a ``pygraphblas.Scalar`` to a new :class:`Scalar` by making
         a copy of the internal value.
 
@@ -580,12 +578,7 @@ def _as_vector(self, *, name=None):
         """
         from .vector import Vector
 
-        if self._is_cscalar:
-            rv = Vector(self.dtype, size=1, name=name)
-            if not self._is_empty:
-                rv[0] = self
-            return rv
-        else:
+        if backend == "suitesparse" and not self._is_cscalar:
             return Vector._from_obj(
                 ffi.cast("GrB_Vector*", self.gb_obj),
                 self.dtype,
@@ -593,6 +586,10 @@ def _as_vector(self, *, name=None):
                 parent=self,
                 name=f"(GrB_Vector){self.name or 's_temp'}" if name is None else name,
             )
+        rv = Vector(self.dtype, size=1, name=name)
+        if not self._is_empty:
+            rv[0] = self
+        return rv
 
     def _as_matrix(self, *, name=None):
         """Copy or cast this Scalar to a Matrix
@@ -601,12 +598,7 @@ def _as_matrix(self, *, name=None):
         """
         from .matrix import Matrix
 
-        if self._is_cscalar:
-            rv = Matrix(self.dtype, ncols=1, nrows=1, name=name)
-            if not self._is_empty:
-                rv[0, 0] = self
-            return rv
-        else:
+        if backend == "suitesparse" and not self._is_cscalar:
             return Matrix._from_obj(
                 ffi.cast("GrB_Matrix*", self.gb_obj),
                 self.dtype,
@@ -615,6 +607,10 @@ def _as_matrix(self, *, name=None):
                 parent=self,
                 name=f"(GrB_Matrix){self.name or 's_temp'}" if name is None else name,
             )
+        rv = Matrix(self.dtype, ncols=1, nrows=1, name=name)
+        if not self._is_empty:
+            rv[0, 0] = self
+        return rv
 
 
 class ScalarExpression(BaseExpression):
@@ -756,10 +752,9 @@ def is_grbscalar(self):
 def _as_scalar(scalar, dtype=None, *, is_cscalar):
     if type(scalar) is not Scalar:
         return Scalar.from_value(scalar, dtype, is_cscalar=is_cscalar, name="")
-    elif scalar._is_cscalar != is_cscalar or dtype is not None and scalar.dtype != dtype:
+    if scalar._is_cscalar != is_cscalar or dtype is not None and scalar.dtype != dtype:
         return scalar.dup(dtype, is_cscalar=is_cscalar, name=scalar.name)
-    else:
-        return scalar
+    return scalar
 
 
 def _dict_to_record(np_type, d):
diff --git a/graphblas/core/slice.py b/graphblas/core/slice.py
index eae1442e2..b8930a406 100644
--- a/graphblas/core/slice.py
+++ b/graphblas/core/slice.py
@@ -31,5 +31,4 @@ def slice_to_index(index, size):
         start += 1
     if step == 1:
         return AxisIndex(length, _CArray([start, stop]), gxb_range, size)
-    else:
-        return AxisIndex(length, _CArray([start, stop, step]), gxb_stride, size)
+    return AxisIndex(length, _CArray([start, stop, step]), gxb_stride, size)
diff --git a/graphblas/core/ss/config.py b/graphblas/core/ss/config.py
index 4697cbe42..db2605ca1 100644
--- a/graphblas/core/ss/config.py
+++ b/graphblas/core/ss/config.py
@@ -48,12 +48,12 @@ def __getitem__(self, key):
             info = self._get_function(key_obj, vararg(val_ptr))
         else:
             info = self._get_function(self._parent._carg, key_obj, vararg(val_ptr))
-        if info == lib.GrB_SUCCESS:  # pragma: no branch
+        if info == lib.GrB_SUCCESS:  # pragma: no branch (safety)
             if is_array:
                 return list(val_ptr)
-            elif key in self._enumerations:
+            if key in self._enumerations:
                 return self._enumerations[key][val_ptr[0]]
-            elif key in self._bitwise:
+            if key in self._bitwise:
                 bitwise = self._bitwise[key]
                 val = val_ptr[0]
                 if val in bitwise:
@@ -125,5 +125,5 @@ def __len__(self):
     def __repr__(self):
         return "{" + ",\n ".join(f"{k!r}: {v!r}" for k, v in self.items()) + "}"
 
-    def _ipython_key_completions_(self):  # pragma: no cover
+    def _ipython_key_completions_(self):  # pragma: no cover (ipython)
         return list(self)
diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py
index d759cee46..555e7889a 100644
--- a/graphblas/core/ss/matrix.py
+++ b/graphblas/core/ss/matrix.py
@@ -222,7 +222,7 @@ def format(self):
             format = "bitmap"
         elif sparsity_status == lib.GxB_FULL:
             format = "full"
-        else:  # pragma: no cover
+        else:  # pragma: no cover (sanity)
             raise NotImplementedError(f"Unknown sparsity status: {sparsity_status}")
         if format_ptr[0] == lib.GxB_BY_COL:
             format = f"{format}c"
@@ -240,8 +240,7 @@ def orientation(self):
         )
         if format_ptr[0] == lib.GxB_BY_COL:
             return "columnwise"
-        else:
-            return "rowwise"
+        return "rowwise"
 
     def diag(self, vector, k=0):
         """
@@ -420,7 +419,7 @@ def _begin_iter(self, seek):
         it = it_ptr[0]
         success = lib.GrB_SUCCESS
         info = lib.GxB_Matrix_Iterator_attach(it, self._parent._carg, NULL)
-        if info != success:  # pragma: no cover
+        if info != success:  # pragma: no cover (safety)
             lib.GxB_Iterator_free(it_ptr)
             raise _error_code_lookup[info]("Matrix iterator failed to attach")
         if seek < 0:
@@ -466,7 +465,7 @@ def iterkeys(self, seek=0):
         except GeneratorExit:
             pass
         else:
-            if info != lib.GxB_EXHAUSTED:  # pragma: no cover
+            if info != lib.GxB_EXHAUSTED:  # pragma: no cover (safety)
                 raise _error_code_lookup[info]("Matrix iterator failed")
         finally:
             lib.GxB_Iterator_free(it_ptr)
@@ -500,7 +499,7 @@ def itervalues(self, seek=0):
         except GeneratorExit:
             pass
         else:
-            if info != lib.GxB_EXHAUSTED:  # pragma: no cover
+            if info != lib.GxB_EXHAUSTED:  # pragma: no cover (safety)
                 raise _error_code_lookup[info]("Matrix iterator failed")
         finally:
             lib.GxB_Iterator_free(it_ptr)
@@ -538,7 +537,7 @@ def iteritems(self, seek=0):
         except GeneratorExit:
             pass
         else:
-            if info != lib.GxB_EXHAUSTED:  # pragma: no cover
+            if info != lib.GxB_EXHAUSTED:  # pragma: no cover (safety)
                 raise _error_code_lookup[info]("Matrix iterator failed")
         finally:
             lib.GxB_Iterator_free(it_ptr)
@@ -747,10 +746,15 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
             format = self.format
         else:
             format = format.lower()
-            if format == "rowwise":
-                format = f"{self.format[:-1]}r"
-            elif format == "columnwise":
-                format = f"{self.format[:-1]}c"
+            try:
+                order = get_order(format)
+            except ValueError:
+                pass
+            else:
+                if order == "rowwise":
+                    format = f"{self.format[:-1]}r"
+                else:  # columnwise
+                    format = f"{self.format[:-1]}c"
         if give_ownership or format == "coo":
             parent = self._parent
         else:
@@ -880,15 +884,15 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
             col_indices = claim_buffer(ffi, Aj[0], Aj_size[0] // index_dtype.itemsize, index_dtype)
             values = claim_buffer(ffi, Ax[0], Ax_size[0] // dtype.itemsize, dtype)
             if not raw:
-                if indptr.size > nrows + 1:  # pragma: no cover
+                if indptr.size > nrows + 1:
                     indptr = indptr[: nrows + 1]
-                if col_indices.size > nvals:  # pragma: no cover
+                if col_indices.size > nvals:
                     col_indices = col_indices[:nvals]
                 if is_iso:
-                    if values.size > 1:  # pragma: no cover
+                    if values.size > 1:  # pragma: no branch (suitesparse)
                         values = values[:1]
                 else:
-                    if values.size > nvals:  # pragma: no cover
+                    if values.size > nvals:  # pragma: no branch (suitesparse)
                         values = values[:nvals]
             # Note: nvals is also at `indptr[nrows]`
             rv = {
@@ -922,15 +926,15 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
             row_indices = claim_buffer(ffi, Ai[0], Ai_size[0] // index_dtype.itemsize, index_dtype)
             values = claim_buffer(ffi, Ax[0], Ax_size[0] // dtype.itemsize, dtype)
             if not raw:
-                if indptr.size > ncols + 1:  # pragma: no cover
+                if indptr.size > ncols + 1:
                     indptr = indptr[: ncols + 1]
-                if row_indices.size > nvals:  # pragma: no cover
+                if row_indices.size > nvals:
                     row_indices = row_indices[:nvals]
                 if is_iso:
-                    if values.size > 1:  # pragma: no cover
+                    if values.size > 1:  # pragma: no cover (suitesparse)
                         values = values[:1]
                 else:
-                    if values.size > nvals:  # pragma: no cover
+                    if values.size > nvals:
                         values = values[:nvals]
             # Note: nvals is also at `indptr[ncols]`
             rv = {
@@ -972,17 +976,17 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
             values = claim_buffer(ffi, Ax[0], Ax_size[0] // dtype.itemsize, dtype)
             nvec = nvec[0]
             if not raw:
-                if indptr.size > nvec + 1:  # pragma: no cover
+                if indptr.size > nvec + 1:
                     indptr = indptr[: nvec + 1]
-                if rows.size > nvec:  # pragma: no cover
+                if rows.size > nvec:
                     rows = rows[:nvec]
-                if col_indices.size > nvals:  # pragma: no cover
+                if col_indices.size > nvals:
                     col_indices = col_indices[:nvals]
                 if is_iso:
-                    if values.size > 1:  # pragma: no cover
+                    if values.size > 1:  # pragma: no cover (suitesparse)
                         values = values[:1]
                 else:
-                    if values.size > nvals:  # pragma: no cover
+                    if values.size > nvals:
                         values = values[:nvals]
             # Note: nvals is also at `indptr[nvec]`
             rv = {
@@ -1027,17 +1031,17 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
             values = claim_buffer(ffi, Ax[0], Ax_size[0] // dtype.itemsize, dtype)
             nvec = nvec[0]
             if not raw:
-                if indptr.size > nvec + 1:  # pragma: no cover
+                if indptr.size > nvec + 1:
                     indptr = indptr[: nvec + 1]
-                if cols.size > nvec:  # pragma: no cover
+                if cols.size > nvec:
                     cols = cols[:nvec]
-                if row_indices.size > nvals:  # pragma: no cover
+                if row_indices.size > nvals:
                     row_indices = row_indices[:nvals]
                 if is_iso:
-                    if values.size > 1:  # pragma: no cover
+                    if values.size > 1:  # pragma: no cover (suitesparse)
                         values = values[:1]
                 else:
-                    if values.size > nvals:  # pragma: no cover
+                    if values.size > nvals:
                         values = values[:nvals]
             # Note: nvals is also at `indptr[nvec]`
             rv = {
@@ -1090,7 +1094,7 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
                 )
                 if is_iso:
                     values = claim_buffer(ffi, Ax[0], Ax_size[0] // dtype.itemsize, dtype)
-                    if values.size > 1:  # pragma: no cover
+                    if values.size > 1:  # pragma: no cover (suitesparse)
                         values = values[:1]
                 else:
                     values = claim_buffer_2d(
@@ -1128,7 +1132,7 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
                 rv = {"nrows": nrows, "ncols": ncols}
             elif is_iso:
                 values = claim_buffer(ffi, Ax[0], Ax_size[0] // dtype.itemsize, dtype)
-                if values.size > 1:  # pragma: no cover
+                if values.size > 1:
                     values = values[:1]
                 rv = {"nrows": nrows, "ncols": ncols}
             else:
@@ -2694,7 +2698,7 @@ def _import_coo(
                 method=method,
                 matrix=matrix,
             )
-        elif sorted_cols and (not sorted_rows or issorted(cols)):
+        if sorted_cols and (not sorted_rows or issorted(cols)):
             return cls._import_cooc(
                 rows=rows,
                 cols=cols,
@@ -3314,7 +3318,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "csc":
+        if format == "csc":
             return getattr(obj, f"{method}_csc")(
                 nrows=nrows,
                 ncols=ncols,
@@ -3327,7 +3331,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "hypercsr":
+        if format == "hypercsr":
             return getattr(obj, f"{method}_hypercsr")(
                 nrows=nrows,
                 ncols=ncols,
@@ -3342,7 +3346,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "hypercsc":
+        if format == "hypercsc":
             return getattr(obj, f"{method}_hypercsc")(
                 nrows=nrows,
                 ncols=ncols,
@@ -3357,7 +3361,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "bitmapr":
+        if format == "bitmapr":
             return getattr(obj, f"{method}_bitmapr")(
                 nrows=nrows,
                 ncols=ncols,
@@ -3369,7 +3373,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "bitmapc":
+        if format == "bitmapc":
             return getattr(obj, f"{method}_bitmapc")(
                 nrows=nrows,
                 ncols=ncols,
@@ -3381,7 +3385,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "fullr":
+        if format == "fullr":
             return getattr(obj, f"{method}_fullr")(
                 nrows=nrows,
                 ncols=ncols,
@@ -3391,7 +3395,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "fullc":
+        if format == "fullc":
             return getattr(obj, f"{method}_fullc")(
                 nrows=nrows,
                 ncols=ncols,
@@ -3401,7 +3405,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "coo":
+        if format == "coo":
             return getattr(obj, f"{method}_coo")(
                 nrows=nrows,
                 ncols=ncols,
@@ -3415,7 +3419,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "coor":
+        if format == "coor":
             return getattr(obj, f"{method}_coor")(
                 nrows=nrows,
                 ncols=ncols,
@@ -3429,7 +3433,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "cooc":
+        if format == "cooc":
             return getattr(obj, f"{method}_cooc")(
                 nrows=nrows,
                 ncols=ncols,
@@ -3443,8 +3447,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        else:
-            raise ValueError(f"Invalid format: {format}")
+        raise ValueError(f"Invalid format: {format}")
 
     def unpack_hyperhash(self, *, compute=False, name=None):
         """Unpacks the hyper_hash of a hypersparse matrix if possible.
@@ -3591,20 +3594,19 @@ def reshape(self, nrows, ncols=None, order="rowwise", *, inplace=False, name=Non
             parent._nrows = nrows
             parent._ncols = ncols
             return
-        else:
-            rv = Matrix._from_obj(ffi_new("GrB_Matrix*"), parent.dtype, nrows, ncols, name=name)
-            call(
-                "GxB_Matrix_reshapeDup",
-                [
-                    _Pointer(rv),
-                    parent,
-                    _as_scalar(order == "columnwise", BOOL, is_cscalar=True),
-                    _as_scalar(nrows, _INDEX, is_cscalar=True),
-                    _as_scalar(ncols, _INDEX, is_cscalar=True),
-                    None,
-                ],
-            )
-            return rv
+        rv = Matrix._from_obj(ffi_new("GrB_Matrix*"), parent.dtype, nrows, ncols, name=name)
+        call(
+            "GxB_Matrix_reshapeDup",
+            [
+                _Pointer(rv),
+                parent,
+                _as_scalar(order == "columnwise", BOOL, is_cscalar=True),
+                _as_scalar(nrows, _INDEX, is_cscalar=True),
+                _as_scalar(ncols, _INDEX, is_cscalar=True),
+                None,
+            ],
+        )
+        return rv
 
     def selectk_rowwise(self, how, k, *, name=None):
         """Select (up to) k elements from each row.
@@ -3938,7 +3940,7 @@ def deserialize(cls, data, dtype=None, *, nthreads=None, name=None):
 
 
 @numba.njit(parallel=True)
-def argsort_values(indptr, indices, values):  # pragma: no cover
+def argsort_values(indptr, indices, values):  # pragma: no cover (numba)
     rv = np.empty(indptr[-1], dtype=np.uint64)
     for i in numba.prange(indptr.size - 1):
         rv[indptr[i] : indptr[i + 1]] = indices[
@@ -3948,7 +3950,7 @@ def argsort_values(indptr, indices, values):  # pragma: no cover
 
 
 @numba.njit(parallel=True)
-def sort_values(indptr, values):  # pragma: no cover
+def sort_values(indptr, values):  # pragma: no cover (numba)
     rv = np.empty(indptr[-1], dtype=values.dtype)
     for i in numba.prange(indptr.size - 1):
         rv[indptr[i] : indptr[i + 1]] = np.sort(values[indptr[i] : indptr[i + 1]])
@@ -3956,7 +3958,7 @@ def sort_values(indptr, values):  # pragma: no cover
 
 
 @numba.njit(parallel=True)
-def compact_values(old_indptr, new_indptr, values):  # pragma: no cover
+def compact_values(old_indptr, new_indptr, values):  # pragma: no cover (numba)
     rv = np.empty(new_indptr[-1], dtype=values.dtype)
     for i in numba.prange(new_indptr.size - 1):
         start = np.int64(new_indptr[i])
@@ -3967,7 +3969,7 @@ def compact_values(old_indptr, new_indptr, values):  # pragma: no cover
 
 
 @numba.njit(parallel=True)
-def reverse_values(indptr, values):  # pragma: no cover
+def reverse_values(indptr, values):  # pragma: no cover (numba)
     rv = np.empty(indptr[-1], dtype=values.dtype)
     for i in numba.prange(indptr.size - 1):
         offset = np.int64(indptr[i]) + np.int64(indptr[i + 1]) - 1
@@ -3977,7 +3979,7 @@ def reverse_values(indptr, values):  # pragma: no cover
 
 
 @numba.njit(parallel=True)
-def compact_indices(indptr, k):  # pragma: no cover
+def compact_indices(indptr, k):  # pragma: no cover (numba)
     """Given indptr from hypercsr, create a new col_indices array that is compact.
 
     That is, for each row with degree N, the column indices will be 0..N-1.
@@ -3996,7 +3998,7 @@ def compact_indices(indptr, k):  # pragma: no cover
 
 
 @njit(parallel=True)
-def choose_random1(indptr):  # pragma: no cover
+def choose_random1(indptr):  # pragma: no cover (numba)
     choices = np.empty(indptr.size - 1, dtype=indptr.dtype)
     new_indptr = np.arange(indptr.size, dtype=indptr.dtype)
     for i in numba.prange(indptr.size - 1):
@@ -4010,7 +4012,7 @@ def choose_random1(indptr):  # pragma: no cover
 
 
 @njit
-def create_indptr(indptr, k):  # pragma: no cover
+def create_indptr(indptr, k):  # pragma: no cover (numba)
     new_indptr = np.empty(indptr.size, dtype=indptr.dtype)
     new_indptr[0] = 0
     prev = np.int64(indptr[0])
@@ -4028,7 +4030,7 @@ def create_indptr(indptr, k):  # pragma: no cover
 
 # Assume we are HyperCSR or HyperCSC
 @njit(parallel=True)
-def choose_random(indptr, k):  # pragma: no cover
+def choose_random(indptr, k):  # pragma: no cover (numba)
     if k == 1:
         return choose_random1(indptr)
 
@@ -4109,7 +4111,7 @@ def choose_random(indptr, k):  # pragma: no cover
 
 # Assume we are HyperCSR or HyperCSC
 @njit(parallel=True)
-def choose_first(indptr, k):  # pragma: no cover
+def choose_first(indptr, k):  # pragma: no cover (numba)
     if k == 1:
         choices = indptr[:-1]
         new_indptr = np.arange(indptr.size, dtype=indptr.dtype)
@@ -4133,7 +4135,7 @@ def choose_first(indptr, k):  # pragma: no cover
 
 # Assume we are HyperCSR or HyperCSC
 @njit(parallel=True)
-def choose_last(indptr, k):  # pragma: no cover
+def choose_last(indptr, k):  # pragma: no cover (numba)
     if k == 1:
         choices = (indptr[1:].astype(np.int64) - 1).astype(indptr.dtype)
         new_indptr = np.arange(indptr.size, dtype=indptr.dtype)
@@ -4156,7 +4158,7 @@ def choose_last(indptr, k):  # pragma: no cover
 
 
 @njit
-def issorted(arr):  # pragma: no cover
+def issorted(arr):  # pragma: no cover (numba)
     if arr.size > 1:
         prev = arr[0]
         for i in range(1, arr.size):
@@ -4171,7 +4173,7 @@ def issorted(arr):  # pragma: no cover
 
 
 @njit
-def indices_to_indptr(indices, size):  # pragma: no cover
+def indices_to_indptr(indices, size):  # pragma: no cover (numba)
     """Calculate the indptr for e.g. CSR from sorted COO rows."""
     indptr = np.zeros(size, dtype=indices.dtype)
     index = np.uint64(0)
@@ -4185,7 +4187,7 @@ def indices_to_indptr(indices, size):  # pragma: no cover
 
 
 @njit(parallel=True)
-def indptr_to_indices(indptr):  # pragma: no cover
+def indptr_to_indices(indptr):  # pragma: no cover (numba)
     indices = np.empty(indptr[-1], dtype=indptr.dtype)
     for i in numba.prange(indptr.size - 1):
         for j in range(indptr[i], indptr[i + 1]):
diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py
index 66a01af8a..0c4df6d37 100644
--- a/graphblas/core/ss/vector.py
+++ b/graphblas/core/ss/vector.py
@@ -142,7 +142,7 @@ def format(self):
             format = "bitmap"
         elif sparsity_status == lib.GxB_FULL:
             format = "full"
-        else:  # pragma: no cover
+        else:  # pragma: no cover (sanity)
             raise NotImplementedError(f"Unknown sparsity status: {sparsity_status}")
         return format
 
@@ -308,7 +308,7 @@ def _begin_iter(self, seek):
         it = it_ptr[0]
         success = lib.GrB_SUCCESS
         info = lib.GxB_Vector_Iterator_attach(it, self._parent._carg, NULL)
-        if info != success:  # pragma: no cover
+        if info != success:  # pragma: no cover (safety)
             lib.GxB_Iterator_free(it_ptr)
             raise _error_code_lookup[info]("Vector iterator failed to attach")
         if seek < 0:
@@ -351,7 +351,7 @@ def iterkeys(self, seek=0):
         except GeneratorExit:
             pass
         else:
-            if info != lib.GxB_EXHAUSTED:  # pragma: no cover
+            if info != lib.GxB_EXHAUSTED:  # pragma: no cover (safety)
                 raise _error_code_lookup[info]("Vector iterator failed")
         finally:
             lib.GxB_Iterator_free(it_ptr)
@@ -385,7 +385,7 @@ def itervalues(self, seek=0):
         except GeneratorExit:
             pass
         else:
-            if info != lib.GxB_EXHAUSTED:  # pragma: no cover
+            if info != lib.GxB_EXHAUSTED:  # pragma: no cover (safety)
                 raise _error_code_lookup[info]("Vector iterator failed")
         finally:
             lib.GxB_Iterator_free(it_ptr)
@@ -420,7 +420,7 @@ def iteritems(self, seek=0):
         except GeneratorExit:
             pass
         else:
-            if info != lib.GxB_EXHAUSTED:  # pragma: no cover
+            if info != lib.GxB_EXHAUSTED:  # pragma: no cover (safety)
                 raise _error_code_lookup[info]("Vector iterator failed")
         finally:
             lib.GxB_Iterator_free(it_ptr)
@@ -553,13 +553,13 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
             indices = claim_buffer(ffi, vi[0], vi_size[0] // index_dtype.itemsize, index_dtype)
             values = claim_buffer(ffi, vx[0], vx_size[0] // dtype.itemsize, dtype)
             if not raw:
-                if indices.size > nvals:  # pragma: no cover
+                if indices.size > nvals:
                     indices = indices[:nvals]
                 if is_iso:
-                    if values.size > 1:  # pragma: no cover
+                    if values.size > 1:  # pragma: no cover (suitesparse)
                         values = values[:1]
                 else:
-                    if values.size > nvals:  # pragma: no cover
+                    if values.size > nvals:
                         values = values[:nvals]
             rv = {
                 "size": size,
@@ -583,13 +583,13 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
             bitmap = claim_buffer(ffi, vb[0], vb_size[0] // bool_dtype.itemsize, bool_dtype)
             values = claim_buffer(ffi, vx[0], vx_size[0] // dtype.itemsize, dtype)
             if not raw:
-                if bitmap.size > size:  # pragma: no cover
+                if bitmap.size > size:  # pragma: no branch (suitesparse)
                     bitmap = bitmap[:size]
                 if is_iso:
-                    if values.size > 1:  # pragma: no cover
+                    if values.size > 1:  # pragma: no cover (suitesparse)
                         values = values[:1]
                 else:
-                    if values.size > size:  # pragma: no cover
+                    if values.size > size:  # pragma: no branch (suitesparse)
                         values = values[:size]
             rv = {
                 "bitmap": bitmap,
@@ -606,10 +606,10 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
             values = claim_buffer(ffi, vx[0], vx_size[0] // dtype.itemsize, dtype)
             if not raw:
                 if is_iso:
-                    if values.size > 1:  # pragma: no cover
+                    if values.size > 1:
                         values = values[:1]
                 else:
-                    if values.size > size:  # pragma: no cover
+                    if values.size > size:  # pragma: no branch (suitesparse)
                         values = values[:size]
             rv = {}
             if raw or is_iso:
@@ -784,7 +784,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "bitmap":
+        if format == "bitmap":
             return getattr(obj, f"{method}_bitmap")(
                 nvals=nvals,
                 bitmap=bitmap,
@@ -795,7 +795,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        elif format == "full":
+        if format == "full":
             return getattr(obj, f"{method}_full")(
                 values=values,
                 size=size,
@@ -804,8 +804,7 @@ def _import_any(
                 dtype=dtype,
                 name=name,
             )
-        else:
-            raise ValueError(f"Invalid format: {format}")
+        raise ValueError(f"Invalid format: {format}")
 
     @classmethod
     def import_sparse(
@@ -1419,8 +1418,7 @@ def compactify(self, how="first", size=None, *, reverse=False, asindex=False, na
         if size is None and self._parent._nvals == 0 or size == 0:
             if asindex:
                 return gb.Vector(UINT64, size=0, name=name)
-            else:
-                return gb.Vector(self._parent.dtype, size=0, name=name)
+            return gb.Vector(self._parent.dtype, size=0, name=name)
         do_sort = how in {"first", "last"}
         info = self._parent.ss.export("sparse", sort=do_sort)
         if size is None:
@@ -1595,7 +1593,7 @@ def deserialize(cls, data, dtype=None, *, nthreads=None, name=None):
 
 
 @njit
-def random_choice(n, k):  # pragma: no cover
+def random_choice(n, k):  # pragma: no cover (numba)
     if k >= n:
         return np.arange(n, dtype=np.uint64)
     choices = np.empty(k, dtype=np.uint64)
diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py
index 53b4f9897..285b3c1b0 100644
--- a/graphblas/core/utils.py
+++ b/graphblas/core/utils.py
@@ -11,12 +11,14 @@ def libget(name):
     try:
         return getattr(lib, name)
     except AttributeError:
+        if name[-4:] not in {"FC32", "FC64", "error"}:
+            raise
         ext_name = f"GxB_{name[4:]}"
         try:
             return getattr(lib, ext_name)
-        except AttributeError:
+        except AttributeError:  # pragma: no cover (sanity)
             pass
-        raise
+        raise  # pragma: no cover (sanity)
 
 
 def wrapdoc(func_with_doc):
@@ -115,13 +117,12 @@ def get_order(order):
     val = order.lower()
     if val in {"c", "row", "rows", "rowwise"}:
         return "rowwise"
-    elif val in {"f", "col", "cols", "column", "columns", "colwise", "columnwise"}:
+    if val in {"f", "col", "cols", "column", "columns", "colwise", "columnwise"}:
         return "columnwise"
-    else:
-        raise ValueError(
-            f"Bad value for order: {order!r}.  "
-            'Expected "rowwise", "columnwise", "rows", "columns", "C", or "F"'
-        )
+    raise ValueError(
+        f"Bad value for order: {order!r}.  "
+        'Expected "rowwise", "columnwise", "rows", "columns", "C", or "F"'
+    )
 
 
 def normalize_chunks(chunks, shape):
@@ -229,7 +230,7 @@ def ensure_type(x, types):
     """
     if isinstance(x, types):
         return x
-    elif isinstance(types, tuple):
+    if isinstance(types, tuple):
         if output_type(x) in types:
             return x.new()
     elif output_type(x) is types:
@@ -238,13 +239,16 @@ def ensure_type(x, types):
 
 
 class class_property:
-    __slots__ = "classval", "member_property"
+    __slots__ = "classval", "member_property", "exceptional"
 
-    def __init__(self, member_property, classval):
+    def __init__(self, member_property, classval, exceptional=False):
         self.classval = classval
         self.member_property = member_property
+        self.exceptional = exceptional
 
     def __get__(self, obj, type=None):
+        if self.exceptional:
+            raise AttributeError(self.classval)
         if obj is None:
             return self.classval
         return self.member_property.__get__(obj, type)
diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py
index 002738109..94fad2484 100644
--- a/graphblas/core/vector.py
+++ b/graphblas/core/vector.py
@@ -8,7 +8,7 @@
 from ..exceptions import DimensionMismatch, NoValue, check_status
 from . import automethods, ffi, lib, utils
 from .base import BaseExpression, BaseType, _check_mask, call
-from .expr import AmbiguousAssignOrExtract, IndexerResolver, Updater
+from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, Updater
 from .mask import Mask, StructuralMask, ValueMask
 from .operator import UNKNOWN_OPCLASS, find_opclass, get_semiring, get_typed_op, op_from_string
 from .scalar import (
@@ -19,7 +19,6 @@
     _as_scalar,
     _scalar_index,
 )
-from .ss.vector import ss
 from .utils import (
     _CArray,
     _Pointer,
@@ -30,6 +29,9 @@
     wrapdoc,
 )
 
+if backend == "suitesparse":
+    from .ss.vector import ss
+
 ffi_new = ffi.new
 
 
@@ -98,7 +100,8 @@ def __new__(cls, dtype=FP64, size=0, *, name=None):
         call("GrB_Vector_new", [_Pointer(self), self.dtype, size])
         self._size = size.value
         self._parent = None
-        self.ss = ss(self)
+        if backend == "suitesparse":
+            self.ss = ss(self)
         return self
 
     @classmethod
@@ -109,7 +112,8 @@ def _from_obj(cls, gb_obj, dtype, size, *, parent=None, name=None):
         self.dtype = dtype
         self._size = size
         self._parent = parent
-        self.ss = ss(self)
+        if backend == "suitesparse":
+            self.ss = ss(self)
         return self
 
     def __del__(self):
@@ -129,14 +133,18 @@ def _as_matrix(self, *, name=None):
         """
         from .matrix import Matrix
 
-        return Matrix._from_obj(
-            ffi.cast("GrB_Matrix*", self.gb_obj),
-            self.dtype,
-            self._size,
-            1,
-            parent=self,
-            name=f"(GrB_Matrix){self.name}" if name is None else name,
-        )
+        if backend == "suitesparse":
+            return Matrix._from_obj(
+                ffi.cast("GrB_Matrix*", self.gb_obj),
+                self.dtype,
+                self._size,
+                1,
+                parent=self,
+                name=f"(GrB_Matrix){self.name}" if name is None else name,
+            )
+        rv = Matrix(self.dtype, self._size, 1, name=self.name if name is None else name)
+        rv[:, 0] = self
+        return rv
 
     def __repr__(self, mask=None, expr=None):
         from .formatting import format_vector
@@ -162,14 +170,20 @@ def _name_html(self):
         return super()._name_html
 
     def __reduce__(self):
-        # SS, SuiteSparse-specific: export
-        pieces = self.ss.export(raw=True)
+        # TODO: we should probably use (or compare to) GraphBLAS serialize methods
+        if backend == "suitesparse":
+            pieces = self.ss.export(raw=True)
+        else:
+            indices, values = self.to_values(sort=False)
+            pieces = (indices, values, self.dtype, self._size)
         return self._deserialize, (pieces, self.name)
 
     @staticmethod
     def _deserialize(pieces, name):
-        # SS, SuiteSparse-specific: import
-        return Vector.ss.import_any(name=name, **pieces)
+        if backend == "suitesparse":
+            return Vector.ss.import_any(name=name, **pieces)
+        indices, values, dtype, size = pieces
+        return Vector.from_values(indices, values, dtype, size=size, name=name)
 
     @property
     def S(self):
@@ -208,8 +222,7 @@ def __getitem__(self, keys):
         shape = resolved_indexes.shape
         if not shape:
             return ScalarIndexExpr(self, resolved_indexes)
-        else:
-            return VectorIndexExpr(self, resolved_indexes, *shape)
+        return VectorIndexExpr(self, resolved_indexes, *shape)
 
     def __setitem__(self, keys, expr):
         """Assign values to a single element or subvector.
@@ -253,9 +266,11 @@ def __iter__(self):
         return indices.flat
 
     def __sizeof__(self):
-        size = ffi_new("size_t*")
-        check_status(lib.GxB_Vector_memoryUsage(size, self.gb_obj[0]), self)
-        return size[0] + object.__sizeof__(self)
+        if backend == "suitesparse":
+            size = ffi_new("size_t*")
+            check_status(lib.GxB_Vector_memoryUsage(size, self.gb_obj[0]), self)
+            return size[0] + object.__sizeof__(self)
+        raise TypeError("Unable to get size of Vector with backend: {backend}")
 
     def isequal(self, other, *, check_dtype=False):
         """Check for exact equality (same size, same structure)
@@ -397,9 +412,7 @@ def to_values(self, dtype=None, *, indices=True, values=True, sort=True):
         np.ndarray[dtype=uint64] : Indices
         np.ndarray : Values
         """
-        if sort:
-            if backend != "suitesparse":
-                raise NotImplementedError()
+        if sort and backend == "suitesparse":
             self.wait()  # sort in SS
         nvals = self._nvals
         if indices or backend != "suitesparse":
@@ -422,6 +435,13 @@ def to_values(self, dtype=None, *, indices=True, values=True, sort=True):
                 dtype = lookup_dtype(dtype)
                 if dtype != self.dtype:
                     c_values = c_values.astype(dtype.np_type)  # copies
+        if sort and backend != "suitesparse":
+            c_indices = c_indices.array
+            ind = np.argsort(c_indices)
+            return (
+                c_indices[ind] if indices else None,
+                c_values[ind] if values else None,
+            )
         return (
             c_indices.array if indices else None,
             c_values if values else None,
@@ -616,8 +636,10 @@ def from_values(cls, indices, values, dtype=None, *, size=None, dup_op=None, nam
                     "dup_op must be None if values is a scalar so that all "
                     "values can be identical.  Duplicate indices will be ignored."
                 )
-            # SS, SuiteSparse-specific: build_Scalar
-            w.ss.build_scalar(indices, values.tolist())
+            if backend == "suitesparse":
+                w.ss.build_scalar(indices, values.tolist())
+            else:
+                w.build(indices, np.broadcast_to(values, indices.size), dup_op=binary.any)
         else:
             # This needs to be the original data to get proper error messages
             w.build(indices, values, dup_op=dup_op)
@@ -667,7 +689,7 @@ def ewise_add(self, other, op=monoid.plus, *, require_monoid=None):
         """
         from .matrix import Matrix, MatrixExpression, TransposedMatrix
 
-        if require_monoid is not None:  # pragma: no cover
+        if require_monoid is not None:  # pragma: no cover (deprecated)
             warnings.warn(
                 "require_monoid keyword is deprecated; "
                 "future behavior will be like `require_monoid=False`",
@@ -681,7 +703,7 @@ def ewise_add(self, other, op=monoid.plus, *, require_monoid=None):
         )
         op = get_typed_op(op, self.dtype, other.dtype, kind="binary")
         # Per the spec, op may be a semiring, but this is weird, so don't.
-        if require_monoid:  # pragma: no cover
+        if require_monoid:  # pragma: no cover (deprecated)
             if op.opclass != "BinaryOp" or op.monoid is None:
                 self._expect_op(
                     op,
@@ -818,7 +840,6 @@ def ewise_union(self, other, op, left_default, right_default):
             # Functional syntax
             w << binary.div(u | v, left_default=1, right_default=1)
         """
-        # SS, SuiteSparse-specific: eWiseUnion
         from .matrix import Matrix, MatrixExpression, TransposedMatrix
 
         method_name = "ewise_union"
@@ -882,13 +903,23 @@ def ewise_union(self, other, op, left_default, right_default):
                 ncols=other._ncols,
                 op=op,
             )
-        expr = VectorExpression(
-            method_name,
-            "GxB_Vector_eWiseUnion",
-            [self, left, other, right],
-            op=op,
-            expr_repr=expr_repr,
-        )
+        if backend == "suitesparse":
+            expr = VectorExpression(
+                method_name,
+                "GxB_Vector_eWiseUnion",
+                [self, left, other, right],
+                op=op,
+                expr_repr=expr_repr,
+            )
+        else:
+            dtype = unify(scalar_dtype, nonscalar_dtype, is_left_scalar=True)
+            new_left = other.dup(dtype, clear=True)
+            new_left(other.S) << left
+            new_left(self.S) << self
+            new_right = self.dup(dtype, clear=True)
+            new_right(self.S) << right
+            new_right(other.S) << other
+            expr = op(new_left & new_right)
         if self._size != other._size:
             expr.new(name="")  # incompatible shape; raise now
         return expr
@@ -1446,7 +1477,7 @@ def _assign_element(self, resolved_indexes, value):
             cfunc_name = "GrB_Vector_setElement_Scalar"
         call(cfunc_name, [self, value, idx.index])
 
-    def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False):
+    def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False, replace=False):
         method_name = "__setitem__"
         idx = resolved_indexes.indices[0]
         size = idx.size
@@ -1465,7 +1496,11 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                     # v[i](m) << w
                     raise TypeError("Single element assign does not accept a submask")
                 # v[I](m) << w
-                cfunc_name = "GrB_Vector_subassign"
+                if backend == "suitesparse":
+                    cfunc_name = "GxB_Vector_subassign"
+                else:
+                    cfunc_name = "GrB_Vector_assign"
+                    mask = _vanilla_subassign_mask(self, mask, idx, replace)
                 expr_repr = "[[{2._expr_name} elements]](%s) = {0.name}" % mask.name
             else:
                 # v(m)[I] << w
@@ -1483,19 +1518,29 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                         # v(m)[I] << [1, 2, 3]
                         # v[I](m) << [1, 2, 3]
                         try:
-                            values, dtype = values_to_numpy_buffer(value, dtype, copy=True)
+                            # Do a copy for suitesparse so we can give ownership to suitesparse
+                            values, dtype = values_to_numpy_buffer(
+                                value, dtype, copy=backend == "suitesparse"
+                            )
                         except Exception:
                             extra_message = "Literal scalars and lists also accepted."
                         else:
                             shape = values.shape
                             try:
-                                vals = Vector.ss.import_full(
-                                    values, dtype=dtype, take_ownership=True
-                                )
-                                if dtype.np_type.subdtype is not None:
-                                    shape = vals.shape
+                                if backend == "suitesparse":
+                                    vals = Vector.ss.import_full(
+                                        values, dtype=dtype, take_ownership=True
+                                    )
+                                else:
+                                    # TODO: GraphBLAS needs a way to import or assign dense
+                                    vals = Vector.from_values(
+                                        np.arange(shape[0]), values, dtype, size=shape[0]
+                                    )
                             except Exception:
                                 vals = None
+                            else:
+                                if dtype.np_type.subdtype is not None:
+                                    shape = vals.shape
                             if vals is None or shape != (size,):
                                 if dtype.np_type.subdtype is not None:
                                     extra = (
@@ -1510,7 +1555,11 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                                     f"{extra}"
                                 ) from None
                             return self._prep_for_assign(
-                                resolved_indexes, vals, mask=mask, is_submask=is_submask
+                                resolved_indexes,
+                                vals,
+                                mask=mask,
+                                is_submask=is_submask,
+                                replace=False,
                             )
                     else:
                         extra_message = "Literal scalars also accepted."
@@ -1532,9 +1581,17 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                         value = _Pointer(value)
                     else:
                         dtype_name = value.dtype.name
-                    cfunc_name = f"GrB_Vector_subassign_{dtype_name}"
+                    if backend == "suitesparse":
+                        cfunc_name = f"GxB_Vector_subassign_{dtype_name}"
+                    else:
+                        cfunc_name = f"GrB_Vector_assign_{dtype_name}"
+                        mask = _vanilla_subassign_mask(self, mask, idx, replace)
                 else:
-                    cfunc_name = "GrB_Vector_subassign_Scalar"
+                    if backend == "suitesparse":
+                        cfunc_name = "GxB_Vector_subassign_Scalar"
+                    else:
+                        cfunc_name = "GrB_Vector_assign_Scalar"
+                        mask = _vanilla_subassign_mask(self, mask, idx, replace)
                 expr_repr = "[[{2._expr_name} elements]](%s) = {0._expr_name}" % mask.name
             else:
                 # v(m)[I] << c
@@ -1552,7 +1609,7 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
                 else:
                     cfunc_name = "GrB_Vector_assign_Scalar"
                 expr_repr = "[[{2._expr_name} elements]] = {0._expr_name}"
-        return VectorExpression(
+        expr = VectorExpression(
             method_name,
             cfunc_name,
             [value, index, cscalar],
@@ -1560,12 +1617,13 @@ def _prep_for_assign(self, resolved_indexes, value, mask=None, is_submask=False)
             size=self._size,
             dtype=self.dtype,
         )
+        return expr, mask
 
     def _delete_element(self, resolved_indexes):
         idx = resolved_indexes.indices[0]
         call("GrB_Vector_removeElement", [self, idx.index])
 
-    def to_pygraphblas(self):  # pragma: no cover
+    def to_pygraphblas(self):  # pragma: no cover (outdated)
         """Convert to a ``pygraphblas.Vector`` without copying data.
 
         This gives control of the underlying GraphBLAS memory to pygraphblas,
@@ -1586,7 +1644,7 @@ def to_pygraphblas(self):  # pragma: no cover
         return vector
 
     @classmethod
-    def from_pygraphblas(cls, vector):  # pragma: no cover
+    def from_pygraphblas(cls, vector):  # pragma: no cover (outdated)
         """Convert a ``pygraphblas.Vector`` to a new :class:`Vector` without copying data.
 
         This gives control of the underlying GraphBLAS memory to python-graphblas,
@@ -1654,7 +1712,27 @@ def to_dict(self):
         return dict(zip(indices.tolist(), values.tolist()))
 
 
-Vector.ss = class_property(Vector.ss, ss)
+if backend == "suitesparse":
+    Vector.ss = class_property(Vector.ss, ss)
+else:
+    Vector.ss = class_property(
+        Vector.ss, 'ss attribute is only available with "suitesparse" backend', exceptional=True
+    )
+
+
+def _vanilla_subassign_mask(self, mask, indices, replace):
+    _check_mask(mask, self)
+    if not replace and indices.index is _ALL_INDICES:
+        return mask
+    indices = indices._py_index()
+    val = Vector(mask.parent.dtype, size=self._size, name="v_temp")
+    val[indices] = mask.parent
+    mask = type(mask)(val)
+    if replace:
+        val = self.dup()
+        del val[indices]
+        mask |= val.S
+    return mask
 
 
 class VectorExpression(BaseExpression):
@@ -1746,7 +1824,8 @@ def shape(self):
     reduce = wrapdoc(Vector.reduce)(property(automethods.reduce))
     reposition = wrapdoc(Vector.reposition)(property(automethods.reposition))
     select = wrapdoc(Vector.select)(property(automethods.select))
-    ss = wrapdoc(Vector.ss)(property(automethods.ss))
+    if backend == "suitesparse":
+        ss = wrapdoc(Vector.ss)(property(automethods.ss))
     to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict))
     to_pygraphblas = wrapdoc(Vector.to_pygraphblas)(property(automethods.to_pygraphblas))
     to_values = wrapdoc(Vector.to_values)(property(automethods.to_values))
@@ -1820,7 +1899,8 @@ def shape(self):
     reduce = wrapdoc(Vector.reduce)(property(automethods.reduce))
     reposition = wrapdoc(Vector.reposition)(property(automethods.reposition))
     select = wrapdoc(Vector.select)(property(automethods.select))
-    ss = wrapdoc(Vector.ss)(property(automethods.ss))
+    if backend == "suitesparse":
+        ss = wrapdoc(Vector.ss)(property(automethods.ss))
     to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict))
     to_pygraphblas = wrapdoc(Vector.to_pygraphblas)(property(automethods.to_pygraphblas))
     to_values = wrapdoc(Vector.to_values)(property(automethods.to_values))
diff --git a/graphblas/dtypes.py b/graphblas/dtypes.py
index fd66ba3b2..795dd54b4 100644
--- a/graphblas/dtypes.py
+++ b/graphblas/dtypes.py
@@ -5,6 +5,7 @@
 from numpy import find_common_type as _find_common_type
 from numpy import promote_types as _promote_types
 
+from . import backend
 from .core import NULL as _NULL
 from .core import ffi as _ffi
 from .core import lib as _lib
@@ -107,34 +108,34 @@ def register_anonymous(dtype, name=None):
                 raise ValueError("dtype must not be a builtin type")
             rv.name = name  # Rename an existing object (a little weird, but okay)
         return rv
-    elif dtype.hasobject:
+    if dtype.hasobject:
         raise ValueError("dtype must not allow Python objects")
+
+    from .exceptions import check_status_carg
+
+    gb_obj = _ffi.new("GrB_Type*")
+    if backend == "suitesparse":
+        # We name this so that we can serialize and deserialize UDTs
+        # We don't yet have C definitions
+        np_repr = _dtype_to_string(dtype).encode()
+        if len(np_repr) > _lib.GxB_MAX_NAME_LEN:
+            msg = (
+                f"UDT repr is too large to serialize ({len(repr(dtype).encode())} > "
+                f"{_lib.GxB_MAX_NAME_LEN})."
+            )
+            if name is not None:
+                np_repr = name.encode()[: _lib.GxB_MAX_NAME_LEN]
+            else:
+                np_repr = np_repr[: _lib.GxB_MAX_NAME_LEN]
+            _warnings.warn(
+                f"{msg}.  It will use the following name, "
+                f"and the dtype may need to be specified when deserializing: {np_repr}"
+            )
+        status = _lib.GxB_Type_new(gb_obj, dtype.itemsize, np_repr, _NULL)
     else:
-        from .exceptions import check_status_carg
-
-        gb_obj = _ffi.new("GrB_Type*")
-        if hasattr(_lib, "GxB_MAX_NAME_LEN"):
-            # SS, SuiteSparse-specific: naming dtypes
-            # We name this so that we can serialize and deserialize UDTs
-            # We don't yet have C definitions
-            np_repr = _dtype_to_string(dtype).encode()
-            if len(np_repr) > _lib.GxB_MAX_NAME_LEN:
-                msg = (
-                    f"UDT repr is too large to serialize ({len(repr(dtype).encode())} > "
-                    f"{_lib.GxB_MAX_NAME_LEN})."
-                )
-                if name is not None:
-                    np_repr = name.encode()[: _lib.GxB_MAX_NAME_LEN]
-                else:
-                    np_repr = np_repr[: _lib.GxB_MAX_NAME_LEN]
-                _warnings.warn(
-                    f"{msg}.  It will use the following name, "
-                    f"and the dtype may need to be specified when deserializing: {np_repr}"
-                )
-            status = _lib.GxB_Type_new(gb_obj, dtype.itemsize, np_repr, _NULL)
-        else:  # pragma: no cover
-            status = _lib.GrB_Type_new(gb_obj, dtype.itemsize)
-        check_status_carg(status, "Type", gb_obj[0])
+        status = _lib.GrB_Type_new(gb_obj, dtype.itemsize)
+    check_status_carg(status, "Type", gb_obj[0])
+
     # For now, let's use "opaque" unsigned bytes for the c type.
     if name is None:
         name = _default_name(dtype)
@@ -173,7 +174,7 @@ def register_anonymous(dtype, name=None):
     FC32 = DataType(
         "FC32", _lib.GxB_FC32, "GxB_FC32", "float _Complex", _numba.types.complex64, _np.complex64
     )
-if _supports_complex and hasattr(_lib, "GrB_FC32"):  # pragma: no coverage
+if _supports_complex and hasattr(_lib, "GrB_FC32"):  # pragma: no coverage (invalid)
     FC32 = DataType(
         "FC32", _lib.GrB_FC32, "GrB_FC32", "float _Complex", _numba.types.complex64, _np.complex64
     )
@@ -186,7 +187,7 @@ def register_anonymous(dtype, name=None):
         _numba.types.complex128,
         _np.complex128,
     )
-if _supports_complex and hasattr(_lib, "GrB_FC64"):  # pragma: no coverage
+if _supports_complex and hasattr(_lib, "GrB_FC64"):  # pragma: no coverage (invalid)
     FC64 = DataType(
         "FC64",
         _lib.GrB_FC64,
@@ -325,13 +326,12 @@ def _default_name(dtype):
         subdtype = _default_name(dtype.subdtype[0])
         shape = ", ".join(map(str, dtype.subdtype[1]))
         return f"{subdtype}[{shape}]"
-    elif dtype.names:
+    if dtype.names:
         args = ", ".join(
             f"{name!r}: {_default_name(dtype.fields[name][0])}" for name in dtype.names
         )
         return "{%s}" % args
-    else:
-        return repr(dtype)
+    return repr(dtype)
 
 
 def _dtype_to_string(dtype):
@@ -353,13 +353,13 @@ def _dtype_to_string(dtype):
         np_type = dtype.np_type
     s = str(np_type)
     try:
-        if _np.dtype(_np.lib.format.safe_eval(s)) == np_type:
+        if _np.dtype(_np.lib.format.safe_eval(s)) == np_type:  # pragma: no branch (safety)
             return s
     except Exception:
         pass
     if _np.dtype(np_type.str) == np_type:
         return repr(np_type.str)
-    else:  # pragma: no cover
+    else:  # pragma: no cover (safety)
         raise ValueError(f"Unable to reliably convert dtype to string and back: {dtype}")
 
 
diff --git a/graphblas/exceptions.py b/graphblas/exceptions.py
index cd1b46307..0acc9ed0b 100644
--- a/graphblas/exceptions.py
+++ b/graphblas/exceptions.py
@@ -1,3 +1,4 @@
+from . import backend as _backend
 from .core import ffi as _ffi
 from .core import lib as _lib
 from .core.utils import _Pointer
@@ -108,11 +109,11 @@ class UdfParseError(GraphblasException):
     _lib.GrB_INDEX_OUT_OF_BOUNDS: IndexOutOfBound,
     _lib.GrB_PANIC: Panic,
     _lib.GrB_NOT_IMPLEMENTED: NotImplementedException,
-    # GxB Errors
-    _lib.GxB_EXHAUSTED: StopIteration,
 }
 GrB_SUCCESS = _lib.GrB_SUCCESS
 GrB_NO_VALUE = _lib.GrB_NO_VALUE
+if _backend == "suitesparse":
+    _error_code_lookup[_lib.GxB_EXHAUSTED] = StopIteration
 
 
 def check_status(response_code, args):
@@ -136,11 +137,11 @@ def check_status(response_code, args):
 def check_status_carg(response_code, type_name, carg):
     if response_code == GrB_SUCCESS:
         return
-    if response_code == GrB_NO_VALUE:  # pragma: no cover
+    if response_code == GrB_NO_VALUE:  # pragma: no cover (safety)
         return NoValue
     try:
         error_func = _libget(f"GrB_{type_name}_error")
-    except AttributeError:  # pragma: no cover
+    except AttributeError:  # pragma: no cover (sanity)
         text = (
             f"Unable to get the error string for type {type_name}.  "
             "This is most likely a bug in graphblas.  Please report this as an issue at:\n"
diff --git a/graphblas/io.py b/graphblas/io.py
index a49b7259d..84f248bef 100644
--- a/graphblas/io.py
+++ b/graphblas/io.py
@@ -51,31 +51,10 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None):
     """
     import networkx as nx
 
-    dtype = dtype if dtype is None else _lookup_dtype(dtype).np_type
+    if dtype is not None:
+        dtype = _lookup_dtype(dtype).np_type
     A = nx.to_scipy_sparse_array(G, nodelist=nodelist, dtype=dtype, weight=weight)
-    nrows, ncols = A.shape
-    data = A.data
-    if data.size == 0:
-        return _Matrix(A.dtype, nrows=nrows, ncols=ncols, name=name)
-    if _backend == "suitesparse":
-        is_iso = (data[[0]] == data).all()
-        if is_iso:
-            data = data[[0]]
-        M = _Matrix.ss.import_csr(
-            nrows=nrows,
-            ncols=ncols,
-            indptr=A.indptr,
-            col_indices=A.indices,
-            values=data,
-            is_iso=is_iso,
-            sorted_cols=getattr(A, "_has_sorted_indices", False),
-            take_ownership=True,
-            name=name,
-        )
-    else:  # pragma: no cover
-        rows, cols = A.nonzero()
-        M = _Matrix.from_values(rows, cols, data, nrows=nrows, ncols=ncols, name=name)
-    return M
+    return from_scipy_sparse(A, name=name)
 
 
 # TODO: add parameter to indicate empty value (default is 0 and NaN)
@@ -104,18 +83,16 @@ def from_numpy(m):
 
     try:
         from scipy.sparse import coo_array, csr_array
-    except ImportError:  # pragma: no cover
+    except ImportError:  # pragma: no cover (import)
         raise ImportError("scipy is required to import from numpy") from None
 
     if m.ndim == 1:
         A = csr_array(m)
         _, size = A.shape
         dtype = _lookup_dtype(m.dtype)
-        g = _Vector.from_values(A.indices, A.data, size=size, dtype=dtype)
-        return g
-    else:
-        A = coo_array(m)
-        return from_scipy_sparse(A)
+        return _Vector.from_values(A.indices, A.data, size=size, dtype=dtype)
+    A = coo_array(m)
+    return from_scipy_sparse(A)
 
 
 def from_scipy_sparse_matrix(m, *, dup_op=None, name=None):
@@ -127,10 +104,9 @@ def from_scipy_sparse_matrix(m, *, dup_op=None, name=None):
     A = m.tocoo()
     nrows, ncols = A.shape
     dtype = _lookup_dtype(m.dtype)
-    g = _Matrix.from_values(
+    return _Matrix.from_values(
         A.row, A.col, A.data, nrows=nrows, ncols=ncols, dtype=dtype, dup_op=dup_op, name=name
     )
-    return g
 
 
 def from_scipy_sparse(A, *, dup_op=None, name=None):
@@ -171,17 +147,20 @@ def from_scipy_sparse(A, *, dup_op=None, name=None):
                 sorted_cols=getattr(A, "_has_sorted_indices", False),
                 name=name,
             )
-        else:
-            return _Matrix.ss.import_csc(
-                nrows=nrows,
-                ncols=ncols,
-                indptr=A.indptr,
-                row_indices=A.indices,
-                values=data,
-                is_iso=is_iso,
-                sorted_rows=getattr(A, "_has_sorted_indices", False),
-                name=name,
-            )
+        return _Matrix.ss.import_csc(
+            nrows=nrows,
+            ncols=ncols,
+            indptr=A.indptr,
+            row_indices=A.indices,
+            values=data,
+            is_iso=is_iso,
+            sorted_rows=getattr(A, "_has_sorted_indices", False),
+            name=name,
+        )
+    if A.format == "csr":
+        return _Matrix.from_csr(A.indptr, A.indices, A.data, ncols=ncols, name=name)
+    if A.format == "csc":
+        return _Matrix.from_csc(A.indptr, A.indices, A.data, nrows=nrows, name=name)
     if A.format != "coo":
         A = A.tocoo()
     return _Matrix.from_values(
@@ -221,37 +200,37 @@ def from_awkward(A, *, name=None):
         return _Vector.from_values(
             A.indices.layout.data, A.values.layout.data, size=shape[0], name=name
         )
-    else:
-        if format not in {"csr", "csc", "hypercsr", "hypercsc"}:
-            raise ValueError(f"Invalid format for Matrix: {format}")
-        d = {
-            "format": format,
-            "nrows": shape[0],
-            "ncols": shape[1],
-            "values": A.values.layout.content.data,
-            "indptr": A.values.layout.offsets.data,
-        }
-        if format[-1] == "r":
-            indices = "col"
-            labels = "rows"
-        else:
-            indices = "row"
-            labels = "cols"
-        d[f"{indices}_indices"] = A.indices.layout.content.data
-        d[f"sorted_{indices}s"] = True
-        if format[:5] == "hyper":
-            d[labels] = A.offset_labels.layout.data
-        return _Matrix.ss.import_any(**d, name=name)
+    nrows, ncols = shape
+    values = A.values.layout.content.data
+    indptr = A.values.layout.offsets.data
+    if format == "csr":
+        cols = A.indices.layout.content.data
+        return _Matrix.from_csr(indptr, cols, values, ncols=ncols, name=name)
+    if format == "csc":
+        rows = A.indices.layout.content.data
+        return _Matrix.from_csc(indptr, rows, values, nrows=nrows, name=name)
+    if format == "hypercsr":
+        rows = A.offset_labels.layout.data
+        cols = A.indices.layout.content.data
+        return _Matrix.from_dcsr(rows, indptr, cols, values, nrows=nrows, ncols=ncols, name=name)
+    if format == "hypercsc":
+        cols = A.offset_labels.layout.data
+        rows = A.indices.layout.content.data
+        return _Matrix.from_dcsc(cols, indptr, rows, values, nrows=nrows, ncols=ncols, name=name)
+    raise ValueError(f"Invalid format for Matrix: {format}")
 
 
 # TODO: add parameters to allow different networkx classes and attribute names
-def to_networkx(m):
+def to_networkx(m, edge_attribute="weight"):
     """Create a networkx DiGraph from a square adjacency Matrix
 
     Parameters
     ----------
     m : Matrix
         Square adjacency Matrix
+    edge_attribute : str, optional
+        Name of edge attribute from values of Matrix. If None, values will be skipped.
+        Default is "weight".
 
     Returns
     -------
@@ -259,10 +238,15 @@ def to_networkx(m):
     """
     import networkx as nx
 
-    g = nx.DiGraph()
-    for row, col, val in zip(*m.to_values()):
-        g.add_edge(row, col, weight=val)
-    return g
+    rows, cols, vals = m.to_values()
+    rows = rows.tolist()
+    cols = cols.tolist()
+    G = nx.DiGraph()
+    if edge_attribute is None:
+        G.add_edges_from(zip(rows, cols))
+    else:
+        G.add_weighted_edges_from(zip(rows, cols, vals.tolist()), weight=edge_attribute)
+    return G
 
 
 def to_numpy(m):
@@ -283,16 +267,15 @@ def to_numpy(m):
     """
     try:
         import scipy  # noqa
-    except ImportError:  # pragma: no cover
+    except ImportError:  # pragma: no cover (import)
         raise ImportError("scipy is required to export to numpy") from None
     if _output_type(m) is _Vector:
         return to_scipy_sparse(m).toarray()[0]
-    else:
-        sparse = to_scipy_sparse(m, "coo")
-        return sparse.toarray()
+    sparse = to_scipy_sparse(m, "coo")
+    return sparse.toarray()
 
 
-def to_scipy_sparse_matrix(m, format="csr"):  # pragma: no cover
+def to_scipy_sparse_matrix(m, format="csr"):  # pragma: no cover (deprecated)
     """format: str in {'bsr', 'csr', 'csc', 'coo', 'lil', 'dia', 'dok'}"""
     import scipy.sparse as ss
 
@@ -305,10 +288,9 @@ def to_scipy_sparse_matrix(m, format="csr"):  # pragma: no cover
         indices, data = m.to_values()
         if format == "csc":
             return ss.csc_matrix((data, indices, [0, len(data)]), shape=(m._size, 1))
-        else:
-            rv = ss.csr_matrix((data, indices, [0, len(data)]), shape=(1, m._size))
-            if format == "csr":
-                return rv
+        rv = ss.csr_matrix((data, indices, [0, len(data)]), shape=(1, m._size))
+        if format == "csr":
+            return rv
     else:
         rows, cols, data = m.to_values()
         rv = ss.coo_matrix((data, (rows, cols)), shape=m.shape)
@@ -343,10 +325,9 @@ def to_scipy_sparse(A, format="csr"):
         indices, data = A.to_values()
         if format == "csc":
             return ss.csc_array((data, indices, [0, len(data)]), shape=(A._size, 1))
-        else:
-            rv = ss.csr_array((data, indices, [0, len(data)]), shape=(1, A._size))
-            if format == "csr":
-                return rv
+        rv = ss.csr_array((data, indices, [0, len(data)]), shape=(1, A._size))
+        if format == "csr":
+            return rv
     elif _backend == "suitesparse" and format in {"csr", "csc"}:
         if A._is_transposed:
             info = A.T.ss.export("csc" if format == "csr" else "csr", sort=True)
@@ -357,15 +338,18 @@ def to_scipy_sparse(A, format="csr"):
         else:
             info = A.ss.export(format, sort=True)
         if info["is_iso"]:
-            info["values"] = _np.broadcast_to(info["values"], (A._nvals,))
+            info["values"] = _np.broadcast_to(info["values"], A._nvals)
         if format == "csr":
             return ss.csr_array(
                 (info["values"], info["col_indices"], info["indptr"]), shape=A.shape
             )
-        else:
-            return ss.csc_array(
-                (info["values"], info["row_indices"], info["indptr"]), shape=A.shape
-            )
+        return ss.csc_array((info["values"], info["row_indices"], info["indptr"]), shape=A.shape)
+    elif format == "csr":
+        indptr, cols, vals = A.to_csr()
+        return ss.csr_array((vals, cols, indptr), shape=A.shape)
+    elif format == "csc":
+        indptr, rows, vals = A.to_csc()
+        return ss.csc_array((vals, rows, indptr), shape=A.shape)
     else:
         rows, cols, data = A.to_values()
         rv = ss.coo_array((data, (rows, cols)), shape=A.shape)
@@ -424,23 +408,27 @@ def to_awkward(A, format=None):
         d = {"node0-data": indices, "node1-data": values}
 
     elif out_type is _Matrix:
-        if _backend != "suitesparse":
-            raise NotImplementedError(
-                f"Conversion of Matrix to Awkward Array not supported for backend '{_backend}'"
-            )
-        if format not in {"csr", "csc", "hypercsr", "hypercsc"}:
-            raise ValueError(f"Invalid format for Matrix: {format}")
-        if format[-1] == "r":
+        if format == "csr":
+            indptr, cols, values = A.to_csr()
+            d = {"node3-data": cols}
             size = A.nrows
-            indices = "col_indices"
-            labels = "rows"
-        else:
+        elif format == "csc":
+            indptr, rows, values = A.to_csc()
+            d = {"node3-data": rows}
             size = A.ncols
-            indices = "row_indices"
-            labels = "cols"
-        info = A.ss.export(format, sort=True)
-        if info["is_iso"]:
-            info["values"] = _np.ascontiguousarray(_np.broadcast_to(info["values"], A.nvals))
+        elif format == "hypercsr":
+            rows, indptr, cols, values = A.to_dcsr()
+            d = {"node3-data": cols, "node5-data": rows}
+            size = len(rows)
+        elif format == "hypercsc":
+            cols, indptr, rows, values = A.to_dcsc()
+            d = {"node3-data": rows, "node5-data": cols}
+            size = len(cols)
+        else:
+            raise ValueError(f"Invalid format for Matrix: {format}")
+        d["node1-offsets"] = indptr
+        d["node4-data"] = _np.ascontiguousarray(values)
+
         form = ListOffsetForm(
             "i64",
             RecordForm(
@@ -452,11 +440,6 @@ def to_awkward(A, format=None):
             ),
             form_key="node1",
         )
-        d = {
-            "node1-offsets": info["indptr"],
-            "node3-data": info[indices],
-            "node4-data": info["values"],
-        }
         if format.startswith("hyper"):
             global _AwkwardDoublyCompressedMatrix
             if _AwkwardDoublyCompressedMatrix is None:
@@ -471,7 +454,6 @@ def values(self):
                     def indices(self):
                         return self.data.indices
 
-            size = len(info[labels])
             form = RecordForm(
                 contents=[
                     form,
@@ -479,7 +461,6 @@ def indices(self):
                 ],
                 fields=["data", "offset_labels"],
             )
-            d["node5-data"] = info[labels]
             classname = "_AwkwardDoublyCompressedMatrix"
 
     else:
@@ -515,7 +496,7 @@ def mmread(source, *, dup_op=None, name=None):
     try:
         from scipy.io import mmread
         from scipy.sparse import isspmatrix_coo
-    except ImportError:  # pragma: no cover
+    except ImportError:  # pragma: no cover (import)
         raise ImportError("scipy is required to read Matrix Market files") from None
     array = mmread(source)
     if isspmatrix_coo(array):
@@ -523,8 +504,11 @@ def mmread(source, *, dup_op=None, name=None):
         return _Matrix.from_values(
             array.row, array.col, array.data, nrows=nrows, ncols=ncols, dup_op=dup_op, name=name
         )
-    # SS, SuiteSparse-specific: import_full
-    return _Matrix.ss.import_fullr(values=array, take_ownership=True, name=name)
+    if _backend == "suitesparse":
+        return _Matrix.ss.import_fullr(values=array, take_ownership=True, name=name)
+    rv = _Matrix(array.dtype, *array.shape, name=name)
+    rv[...] = array
+    return rv
 
 
 def mmwrite(target, matrix, *, comment="", field=None, precision=None, symmetry=None):
@@ -550,7 +534,7 @@ def mmwrite(target, matrix, *, comment="", field=None, precision=None, symmetry=
     """
     try:
         from scipy.io import mmwrite
-    except ImportError:  # pragma: no cover
+    except ImportError:  # pragma: no cover (import)
         raise ImportError("scipy is required to write Matrix Market files") from None
     if _backend == "suitesparse" and matrix.ss.format in {"fullr", "fullc"}:
         array = matrix.ss.export()["values"]
diff --git a/graphblas/ss/_core.py b/graphblas/ss/_core.py
index ae9a8b80f..5c74ab887 100644
--- a/graphblas/ss/_core.py
+++ b/graphblas/ss/_core.py
@@ -212,29 +212,29 @@ def __getitem__(self, key):
         if key in self._mode_options:
             val_ptr = ffi.new("GrB_Mode*")
             info = lib.GxB_Global_Option_get(self._mode_options[key], vararg(val_ptr))
-            if info == lib.GrB_SUCCESS:  # pragma: no branch
+            if info == lib.GrB_SUCCESS:  # pragma: no branch (safety)
                 val = val_ptr[0]
-                if val not in self._modes:  # pragma: no cover
+                if val not in self._modes:  # pragma: no cover (sanity)
                     raise ValueError(f"Unknown mode: {val}")
                 return self._modes[val]
         elif key in self._int3_options:
             val_ptr = ffi.new("int[3]")
             info = lib.GxB_Global_Option_get(self._int3_options[key], vararg(val_ptr))
-            if info == lib.GrB_SUCCESS:  # pragma: no branch
+            if info == lib.GrB_SUCCESS:  # pragma: no branch (safety)
                 return (val_ptr[0], val_ptr[1], val_ptr[2])
         elif key in self._str_options:
             val_ptr = ffi.new("char**")
             info = lib.GxB_Global_Option_get(self._str_options[key], vararg(val_ptr))
-            if info == lib.GrB_SUCCESS:  # pragma: no branch
+            if info == lib.GrB_SUCCESS:  # pragma: no branch (safety)
                 return ffi.string(val_ptr[0]).decode()
         elif key in self._bool_options:
             val_ptr = ffi.new("bool*")
             info = lib.GxB_Global_Option_get(self._bool_options[key], vararg(val_ptr))
-            if info == lib.GrB_SUCCESS:  # pragma: no branch
+            if info == lib.GrB_SUCCESS:  # pragma: no branch (safety)
                 return val_ptr[0]
         else:
             raise KeyError(key)
-        raise _error_code_lookup[info](f"Failed to get info for {key}")  # pragma: no cover
+        raise _error_code_lookup[info](f"Failed to get info for {key}")  # pragma: no cover (safety)
 
     def __iter__(self):
         return iter(
diff --git a/graphblas/unary/numpy.py b/graphblas/unary/numpy.py
index 965d7dd87..46698d5e1 100644
--- a/graphblas/unary/numpy.py
+++ b/graphblas/unary/numpy.py
@@ -137,13 +137,13 @@ def __getattr__(name):
 
         numpy_func = getattr(_np, name)
 
-        def func(x):  # pragma: no cover
+        def func(x):  # pragma: no cover (numba)
             return numpy_func(x)
 
         operator.UnaryOp.register_new(f"numpy.{name}", func)
         if name == "reciprocal":
             # numba doesn't match numpy here
-            def reciprocal(x):  # pragma: no cover
+            def reciprocal(x):  # pragma: no cover (numba)
                 return 1 if x else 0
 
             op = operator.UnaryOp.register_anonymous(reciprocal)
diff --git a/graphblas/viz.py b/graphblas/viz.py
index 836c888c4..394f03c1e 100644
--- a/graphblas/viz.py
+++ b/graphblas/viz.py
@@ -25,7 +25,7 @@ def _get_imports(names, within):
         names = [names]
     rv = []
     for name in names:
-        if name not in _LAZY_IMPORTS:  # pragma: no cover
+        if name not in _LAZY_IMPORTS:  # pragma: no cover (safety)
             raise KeyError(f"Unknown library to import: {name}")
         if name in globals():
             val = globals()[name]
diff --git a/scripts/create_pickle.py b/scripts/create_pickle.py
index ddd940d08..2613e34b8 100755
--- a/scripts/create_pickle.py
+++ b/scripts/create_pickle.py
@@ -4,6 +4,7 @@
 Note that the exact binary of the pickle files may differ depending on which
 Python version is used to create them.
 """
+import argparse
 import os
 import pickle
 
@@ -145,7 +146,17 @@ def pickle3(filename):
 
 
 if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "--backend", choices={"suitesparse", "suitesparse-vanilla"}, default="suitesparse"
+    )
+    args = parser.parse_args()
+    gb.init(args.backend)
+    if args.backend == "suitesparse-vanilla":
+        extra = "-vanilla"
+    else:
+        extra = ""
     basedir = os.path.dirname(gb.tests.__file__)
-    pickle1(os.path.join(basedir, "pickle1.pkl"))
-    pickle2(os.path.join(basedir, "pickle2.pkl"))
-    pickle3(os.path.join(basedir, "pickle3.pkl"))
+    pickle1(os.path.join(basedir, f"pickle1{extra}.pkl"))
+    pickle2(os.path.join(basedir, f"pickle2{extra}.pkl"))
+    pickle3(os.path.join(basedir, f"pickle3{extra}.pkl"))
diff --git a/setup.cfg b/setup.cfg
index 4710b6ad3..8101e81e0 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -37,9 +37,10 @@ markers:
 
 [coverage:run]
 source = graphblas
+# TODO: test and get coverage for viz.py
 omit =
     graphblas/_version.py,
-    graphblas/viz.py  # TODO: test and get coverage for viz.py
+    graphblas/viz.py
 
 [coverage:report]
 # Regexes for lines to exclude from consideration


Test patch:
diff --git a/conftest.py b/conftest.py
index 57b59bf72..9cf6191d4 100644
--- a/conftest.py
+++ b/conftest.py
@@ -2,7 +2,7 @@ def pytest_addoption(parser):
     parser.addoption(
         "--backend",
         action="store",
-        default="suitesparse",
+        default=None,
         help="name of a graphblas backend",
     )
     parser.addoption("--runslow", default=None, action="store_true", help="run slow tests")
diff --git a/graphblas/tests/conftest.py b/graphblas/tests/conftest.py
index f9b461bd8..f15d8842a 100644
--- a/graphblas/tests/conftest.py
+++ b/graphblas/tests/conftest.py
@@ -13,7 +13,12 @@
 
 def pytest_configure(config):
     randomly = config.getoption("--randomly", False)
-    backend = config.getoption("--backend", "suitesparse")
+    backend = config.getoption("--backend", None)
+    if backend is None:  # pragma: no branch
+        if randomly:
+            backend = "suitesparse" if np.random.rand() < 0.5 else "suitesparse-vanilla"
+        else:
+            backend = "suitesparse"
     blocking = config.getoption("--blocking", True)
     if blocking is None:  # pragma: no branch
         blocking = np.random.rand() < 0.5 if randomly else True
diff --git a/graphblas/tests/pickle1-vanilla.pkl b/graphblas/tests/pickle1-vanilla.pkl
new file mode 100644
index 000000000..42ff4a7c7
Binary files /dev/null and b/graphblas/tests/pickle1-vanilla.pkl differ
diff --git a/graphblas/tests/pickle2-vanilla.pkl b/graphblas/tests/pickle2-vanilla.pkl
new file mode 100644
index 000000000..3c6e18ba4
Binary files /dev/null and b/graphblas/tests/pickle2-vanilla.pkl differ
diff --git a/graphblas/tests/pickle3-vanilla.pkl b/graphblas/tests/pickle3-vanilla.pkl
new file mode 100644
index 000000000..29e79d7db
Binary files /dev/null and b/graphblas/tests/pickle3-vanilla.pkl differ
diff --git a/graphblas/tests/test_auto_init.py b/graphblas/tests/test_auto_init.py
index efd1fb529..8d38b14ab 100644
--- a/graphblas/tests/test_auto_init.py
+++ b/graphblas/tests/test_auto_init.py
@@ -1,21 +1,21 @@
 if __name__ == "__main__":
     import pytest
 
-    import graphblas
+    import graphblas as gb
 
     with pytest.raises(ValueError, match="Bad backend name"):
-        graphblas.init("bad_name")
+        gb.init("bad_name")
 
-    graphblas.core.ffi
-    graphblas.op
-    graphblas.Matrix
+    gb.core.ffi
+    gb.op
+    gb.Matrix
     with pytest.raises(
-        graphblas.exceptions.GraphblasException,
+        gb.exceptions.GraphblasException,
         match="graphblas objects accessed prior to manual initialization",
     ):
-        graphblas.init()
+        gb.init()
     # hack it to test this edge case
-    graphblas._init_params = None
-    graphblas.init(blocking=None)
-    graphblas.init(blocking=None)
-    graphblas._autoinit
+    gb._init_params = None
+    gb.init(blocking=None)
+    gb.init(blocking=None)
+    gb._autoinit
diff --git a/graphblas/tests/test_core.py b/graphblas/tests/test_core.py
index 195fbb444..79caebab7 100644
--- a/graphblas/tests/test_core.py
+++ b/graphblas/tests/test_core.py
@@ -1,39 +1,43 @@
 import pytest
 
-import graphblas
+import graphblas as gb
 
 
 def test_import_special_attrs():
-    not_hidden = {x for x in dir(graphblas) if not x.startswith("__")}
+    not_hidden = {x for x in dir(gb) if not x.startswith("__")}
     # Is everything imported?
-    assert len(not_hidden & graphblas._SPECIAL_ATTRS) == len(graphblas._SPECIAL_ATTRS)
+    exclude = {"ss"} if gb.backend != "suitesparse" else set()
+    assert len(not_hidden & gb._SPECIAL_ATTRS) == len(gb._SPECIAL_ATTRS - exclude)
     # Is everything special that needs to be?
-    not_special = {x for x in dir(graphblas) if not x.startswith("_")} - graphblas._SPECIAL_ATTRS
+    not_special = {x for x in dir(gb) if not x.startswith("_")} - gb._SPECIAL_ATTRS
     assert not_special == {"backend", "config", "init", "replace", "tests"}
     # Make sure these "not special" objects don't have objects that look special within them
     for attr in not_special:
-        assert not set(dir(getattr(graphblas, attr))) & graphblas._SPECIAL_ATTRS
+        assert not set(dir(getattr(gb, attr))) & gb._SPECIAL_ATTRS
+    if gb.backend != "suitesparse":
+        with pytest.raises(AttributeError, match="suitesparse"):
+            gb.ss
 
 
 def test_bad_init():
     # same params is okay
-    params = dict(graphblas._init_params)
+    params = dict(gb._init_params)
     del params["automatic"]
-    graphblas.init(**params)
+    gb.init(**params)
     # different params is bad
     params["blocking"] = not params["blocking"]
-    with pytest.raises(graphblas.exceptions.GraphblasException, match="different init parameters"):
-        graphblas.init(**params)
+    with pytest.raises(gb.exceptions.GraphblasException, match="different init parameters"):
+        gb.init(**params)
 
 
 def test_bad_libget():
     with pytest.raises(AttributeError, match="GrB_bad_name"):
-        graphblas.core.base.libget("GrB_bad_name")
+        gb.core.base.libget("GrB_bad_name")
 
 
 def test_lib_attrs():
-    for attr in dir(graphblas.core.lib):
-        getattr(graphblas.core.lib, attr)
+    for attr in dir(gb.core.lib):
+        getattr(gb.core.lib, attr)
 
 
 def test_bad_call():
@@ -42,8 +46,8 @@ class bad:
         _carg = 1
 
     with pytest.raises(TypeError, match="Error calling GrB_Matrix_apply"):
-        graphblas.core.base.call("GrB_Matrix_apply", [bad, bad, bad, bad, bad])
+        gb.core.base.call("GrB_Matrix_apply", [bad, bad, bad, bad, bad])
     with pytest.raises(
         TypeError, match=r"Call objects: GrB_Matrix_apply\(bad, bad, bad, bad, bad, bad\)"
     ):
-        graphblas.core.base.call("GrB_Matrix_apply", [bad, bad, bad, bad, bad, bad])
+        gb.core.base.call("GrB_Matrix_apply", [bad, bad, bad, bad, bad, bad])
diff --git a/graphblas/tests/test_external_init.py b/graphblas/tests/test_external_init.py
index 651975f9d..8aea1205c 100644
--- a/graphblas/tests/test_external_init.py
+++ b/graphblas/tests/test_external_init.py
@@ -2,10 +2,10 @@
     import pytest
     import suitesparse_graphblas as ssgb
 
-    import graphblas
+    import graphblas as gb
 
     ssgb.initialize(blocking=False)
 
     with pytest.raises(RuntimeError, match="GraphBLAS has already been initialized with"):
-        graphblas.init(blocking=True)
-    graphblas.init(blocking=False)
+        gb.init(blocking=True)
+    gb.init(blocking=False)
diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py
index 42e01ef8c..cfeb5f873 100644
--- a/graphblas/tests/test_formatting.py
+++ b/graphblas/tests/test_formatting.py
@@ -1,8 +1,7 @@
 import numpy as np
 import pytest
 
-import graphblas
-from graphblas import dtypes, unary
+from graphblas import backend, dtypes, unary
 from graphblas.core import formatting
 from graphblas.core.formatting import CSS_STYLE
 
@@ -13,10 +12,14 @@
 
 try:
     import pandas as pd
-except ImportError:  # pragma: no cover
+except ImportError:  # pragma: no cover (import)
     pd = None
 
 
+if backend != "suitesparse":
+    pytest.skip("Formatting tests only work with suitesparse backend", allow_module_level=True)
+
+
 def repr_html(x):
     return x._repr_html_()
 
@@ -37,7 +40,7 @@ def _printer(text, name, repr_name, indent):
                 # line = f"f'{{CSS_STYLE}}'"
                 in_style = False
                 is_style = True
-            else:  # pragma: no cover
+            else:  # pragma: no cover (???)
                 # This definitely gets covered, but why is it not picked up?
                 continue
         if repr_name == "repr_html" and line.startswith("<style>"):
@@ -4061,7 +4064,7 @@ def test_inner_outer_repr(v):
 
 @autocompute
 def test_autocompute(A, B, v):
-    if not pd:  # pragma: no cover
+    if not pd:  # pragma: no cover (import)
         pytest.skip("needs pandas")
     repr_printer(A & A, "A & A")
     assert repr(A & A) == (
@@ -4143,7 +4146,7 @@ def test_autocompute(A, B, v):
 
 @autocompute
 def test_autocompute_html(A, B, v):
-    if not pd:  # pragma: no cover
+    if not pd:  # pragma: no cover (import)
         pytest.skip("needs pandas")
     html_printer(A & A, "A & A")
     assert repr_html(A & A) == (
@@ -4888,7 +4891,7 @@ def test_scalar_as_vector():
 
 @autocompute
 def test_index_expr_autocompute(v):
-    if not pd:  # pragma: no cover
+    if not pd:  # pragma: no cover (import)
         pytest.skip("needs pandas")
     html_printer(v[[0, 1]], "v[[0, 1]]")
     assert repr_html(v[[0, 1]]) == (
diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py
index 59280eb38..75bc55e2e 100644
--- a/graphblas/tests/test_io.py
+++ b/graphblas/tests/test_io.py
@@ -8,19 +8,22 @@
 
 try:
     import networkx as nx
-except ImportError:  # pragma: no cover
+except ImportError:  # pragma: no cover (import)
     nx = None
 try:
     import scipy.sparse as ss
-except ImportError:  # pragma: no cover
+except ImportError:  # pragma: no cover (import)
     ss = None
 
 try:
     import awkward._v2 as ak
-except ImportError:  # pragma: no cover
+except ImportError:  # pragma: no cover (import)
     ak = None
 
 
+suitesparse = gb.backend == "suitesparse"
+
+
 @pytest.mark.skipif("not ss")
 def test_vector_to_from_numpy():
     a = np.array([0.0, 2.0, 4.1])
@@ -97,6 +100,14 @@ def test_matrix_to_from_networkx():
     assert G.number_of_nodes() == G2.number_of_nodes() == 2
     np.testing.assert_array_equal(nx.to_numpy_array(G), a)
 
+    # No weights
+    G3 = gb.io.to_networkx(M, edge_attribute=None)
+    a2 = np.array([[1, 0], [1, 1]])
+    G4 = nx.from_numpy_array(a2, create_using=nx.DiGraph)
+    assert G3.number_of_edges() == G4.number_of_edges() == 3
+    assert G3.number_of_nodes() == G4.number_of_nodes() == 2
+    np.testing.assert_array_equal(nx.to_numpy_array(G3), a2)
+
     M2 = gb.io.from_networkx(G, dtype=int)
     assert M.isequal(M2, check_dtype=True)
     # Check iso-value
@@ -117,7 +128,8 @@ def test_matrix_to_from_networkx():
     G.add_edges_from(edges)
     G.add_node(0)
     M = gb.io.from_networkx(G, nodelist=range(7))
-    assert M.ss.is_iso
+    if suitesparse:
+        assert M.ss.is_iso
     rows, cols = zip(*edges)
     expected = gb.Matrix.from_values(rows, cols, 1)
     assert expected.isequal(M)
@@ -233,7 +245,7 @@ def test_mmread_mmwrite():
                 M = gb.io.mmread(mm_in)
         else:
             M = gb.io.mmread(mm_in)
-            if not M.isequal(expected):  # pragma: no cover
+            if not M.isequal(expected):  # pragma: no cover (debug)
                 print(example)
                 print("Expected:")
                 print(expected)
@@ -247,7 +259,7 @@ def test_mmread_mmwrite():
             mm_out_str = b"".join(mm_out.readlines()).decode()
             mm_out.seek(0)
             M2 = gb.io.mmread(mm_out)
-            if not M2.isequal(expected):  # pragma: no cover
+            if not M2.isequal(expected):  # pragma: no cover (debug)
                 print(example)
                 print("Expected:")
                 print(expected)
@@ -334,14 +346,16 @@ def test_awkward_roundtrip():
 def test_awkward_iso_roundtrip():
     # Vector
     v = gb.Vector.from_values([1, 3, 5], [20, 20, 20], size=22)
-    assert v.ss.is_iso
+    if suitesparse:
+        assert v.ss.is_iso
     kv = gb.io.to_awkward(v)
     assert isinstance(kv, ak.Array)
     v2 = gb.io.from_awkward(kv)
     assert v2.isequal(v)
     # Matrix
     m = gb.Matrix.from_values([0, 0, 3, 5], [1, 4, 0, 2], [1, 1, 1, 1], nrows=7, ncols=6)
-    assert m.ss.is_iso
+    if suitesparse:
+        assert m.ss.is_iso
     for format in ["csr", "csc", "hypercsr", "hypercsc"]:
         km = gb.io.to_awkward(m, format=format)
         assert isinstance(km, ak.Array)
diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py
index 193c748d9..dc95ef90d 100644
--- a/graphblas/tests/test_matrix.py
+++ b/graphblas/tests/test_matrix.py
@@ -8,8 +8,8 @@
 import pytest
 from numpy.testing import assert_array_equal
 
-import graphblas
-from graphblas import agg, binary, dtypes, indexunary, monoid, select, semiring, unary
+import graphblas as gb
+from graphblas import agg, backend, binary, dtypes, indexunary, monoid, select, semiring, unary
 from graphblas.core import lib
 from graphblas.exceptions import (
     DimensionMismatch,
@@ -25,6 +25,8 @@
 
 from graphblas import Matrix, Scalar, Vector  # isort:skip (for dask-graphblas)
 
+suitesparse = backend == "suitesparse"
+
 
 @pytest.fixture
 def A():
@@ -151,8 +153,9 @@ def test_from_values_scalar():
     assert C.ncols == 3
     assert C.nvals == 3
     assert C.dtype == dtypes.INT64
-    assert C.ss.is_iso
-    assert C.ss.iso_value == 7
+    if suitesparse:
+        assert C.ss.is_iso
+        assert C.ss.iso_value == 7
     assert C.reduce_scalar(monoid.any).new() == 7
 
     # iso drumps duplicates
@@ -161,14 +164,16 @@ def test_from_values_scalar():
     assert C.ncols == 3
     assert C.nvals == 3
     assert C.dtype == dtypes.INT64
-    assert C.ss.is_iso
-    assert C.ss.iso_value == 7
+    if suitesparse:
+        assert C.ss.is_iso
+        assert C.ss.iso_value == 7
     assert C.reduce_scalar(monoid.any).new() == 7
     with pytest.raises(ValueError, match="dup_op must be None"):
         Matrix.from_values([0, 1, 3, 0], [1, 1, 2, 1], 7, dup_op=binary.plus)
     C[0, 0] = 0
-    with pytest.raises(ValueError, match="not iso"):
-        C.ss.iso_value
+    if suitesparse:
+        with pytest.raises(ValueError, match="not iso"):
+            C.ss.iso_value
 
 
 def test_clear(A):
@@ -227,7 +232,8 @@ def test_build(A):
     assert C.isequal(Matrix.from_values([0, 0], [0, 11], [1, 1], nrows=2))
 
 
-def test_build_scalar(A):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_build_scalar(A):
     assert A.nvals == 12
     with pytest.raises(OutputNotEmpty):
         A.ss.build_scalar([1, 5], [2, 3], 3)
@@ -950,6 +956,98 @@ def test_assign_row_col_matrix_mask():
         C[0, 0](B.S) << 100
 
 
+@pytest.mark.parametrize("index", [slice(12), list(range(12))])
+def test_subassign_combos(index):
+    #         0  1  2  3  4  5  6  7  8  9 10 11 12 13
+    # mask    1  1  1  1  0  0  0  0  _  _  _  _
+    # val     1  2  _  _  3  4  _  _  5  6  _  _
+    # self   10  _ 20  _ 30  _ 40  _ 50  _ 60  _ 70  _
+    mask_base = Vector.from_values(
+        [0, 1, 2, 3, 4, 5, 6, 7], [1, 1, 1, 1, 0, 0, 0, 0], size=12, name="mask"
+    )
+    val_base = Vector.from_values([0, 1, 4, 5, 8, 9], [1, 2, 3, 4, 5, 6], size=12)
+    self_base = Vector.from_values([0, 2, 4, 6, 8, 10, 12], [10, 20, 30, 40, 50, 60, 70], size=14)
+
+    S = gb.core.mask.StructuralMask
+    V = gb.core.mask.ValueMask
+    CS = gb.core.mask.ComplementedStructuralMask
+    CV = gb.core.mask.ComplementedValueMask
+    params = [  # mask_type, replace, indices, values
+        [S, False, [0, 1, 2, 4, 5, 6, 8, 10, 12], [11, 2, 20, 33, 4, 40, 50, 60, 70]],
+        [V, False, [0, 1, 2, 4, 6, 8, 10, 12], [11, 2, 20, 30, 40, 50, 60, 70]],
+        [CS, False, [0, 2, 4, 6, 8, 9, 10, 12], [10, 20, 30, 40, 55, 6, 60, 70]],
+        [CV, False, [0, 2, 4, 5, 6, 8, 9, 10, 12], [10, 20, 33, 4, 40, 55, 6, 60, 70]],
+        [S, True, [0, 1, 2, 4, 5, 6, 12], [11, 2, 20, 33, 4, 40, 70]],
+        [V, True, [0, 1, 2, 12], [11, 2, 20, 70]],
+        [CS, True, [8, 9, 10, 12], [55, 6, 60, 70]],
+        [CV, True, [4, 5, 6, 8, 9, 10, 12], [33, 4, 40, 55, 6, 60, 70]],
+    ]
+    # Vector-Vector
+    for mask_type, replace, indices, values in params:
+        self = self_base.dup(name="self")
+        mask = mask_type(mask_base)
+        val = val_base
+        self[index](binary.plus, mask, replace=replace) << val
+        expected = Vector.from_values(indices, values, size=14, name="expected")
+        if not self.isequal(expected):  # pragma: no cover (debug)
+            print(mask_type, replace)
+            print(expected)
+            print(self)
+            raise AssertionError("incorrect; see printed data")
+
+    def asrow(v):
+        Row = Matrix(v.dtype, nrows=1, ncols=v.size, name=v.name)
+        Row[0, :] = v
+        return Row
+
+    # Matrix-vector (row-wise)
+    for mask_type, replace, indices, values in params:
+        self = asrow(self_base)
+        mask = mask_type(mask_base)
+        val = val_base
+        self[0, index](binary.plus, mask, replace=replace) << val
+        expected = Vector.from_values(indices, values, size=14, name="expected")
+        expected = asrow(expected)
+        if not self.isequal(expected):  # pragma: no cover (debug)
+            print(mask_type, replace)
+            print(expected)
+            print(self)
+            raise AssertionError("incorrect; see printed data")
+
+    def ascol(v):
+        Col = Matrix(v.dtype, nrows=v.size, ncols=1, name=v.name)
+        Col[:, 0] = v
+        return Col
+
+    # Matrix-vector (column-wwise)
+    for mask_type, replace, indices, values in params:
+        self = ascol(self_base)
+        mask = mask_type(mask_base)
+        val = val_base
+        self[index, 0](binary.plus, mask, replace=replace) << val
+        expected = Vector.from_values(indices, values, size=14, name="expected")
+        expected = ascol(expected)
+        if not self.isequal(expected):  # pragma: no cover (debug)
+            print(mask_type, replace)
+            print(expected)
+            print(self)
+            raise AssertionError("incorrect; see printed data")
+
+    # Matrix-matrix
+    for mask_type, replace, indices, values in params:
+        self = asrow(self_base)
+        mask = mask_type(asrow(mask_base))
+        val = asrow(val_base)
+        self[[0], index](binary.plus, mask, replace=replace) << val
+        expected = Vector.from_values(indices, values, size=14, name="expected")
+        expected = asrow(expected)
+        if not self.isequal(expected):  # pragma: no cover (debug)
+            print(mask_type, replace)
+            print(expected)
+            print(self)
+            raise AssertionError("incorrect; see printed data")
+
+
 def test_assign_column_scalar(A, v):
     C = A.dup()
     C[:, 0](v.S) << v
@@ -1182,7 +1280,7 @@ def test_select_bools_and_masks(A):
 
 @pytest.mark.slow
 def test_indexunary_udf(A):
-    def threex_minusthunk(x, row, col, thunk):  # pragma: no cover
+    def threex_minusthunk(x, row, col, thunk):  # pragma: no cover (numba)
         return 3 * x - thunk
 
     indexunary.register_new("threex_minusthunk", threex_minusthunk)
@@ -1199,7 +1297,7 @@ def threex_minusthunk(x, row, col, thunk):  # pragma: no cover
     assert result.isequal(expected)
     delattr(indexunary, "threex_minusthunk")
 
-    def iii(x, row, col, thunk):  # pragma: no cover
+    def iii(x, row, col, thunk):  # pragma: no cover (numba)
         return (row + col) // 2 >= thunk
 
     select.register_new("iii", iii)
@@ -1495,15 +1593,15 @@ def test_reduce_agg_empty():
 def test_reduce_row_udf(A):
     result = Vector.from_values([0, 1, 2, 3, 4, 5, 6], [5, 12, 1, 6, 7, 1, 15])
 
-    def plus(x, y):  # pragma: no cover
+    def plus(x, y):  # pragma: no cover (numba)
         return x + y
 
-    binop = graphblas.core.operator.BinaryOp.register_anonymous(plus)
+    binop = gb.core.operator.BinaryOp.register_anonymous(plus)
     with pytest.raises(NotImplementedException):
         # Although allowed by the spec, SuiteSparse doesn't like user-defined binarops here
         A.reduce_rowwise(binop).new()
     # If the user creates a monoid from the binop, then we can use the monoid instead
-    monoid = graphblas.core.operator.Monoid.register_anonymous(binop, 0)
+    monoid = gb.core.operator.Monoid.register_anonymous(binop, 0)
     w = A.reduce_rowwise(binop).new()
     assert w.isequal(result)
     w2 = A.reduce_rowwise(monoid).new()
@@ -1804,7 +1902,7 @@ def test_del(capsys):
     # A has `gb_obj` of NULL
     A = Matrix.from_values([0, 1], [0, 1], [0, 1])
     gb_obj = A.gb_obj
-    A.gb_obj = graphblas.core.NULL
+    A.gb_obj = gb.core.NULL
     del A
     # let's clean up so we don't have a memory leak
     A2 = object.__new__(Matrix)
@@ -1816,9 +1914,10 @@ def test_del(capsys):
     assert not captured.err
 
 
+@pytest.mark.skipif("not suitesparse")
 @pytest.mark.parametrize("do_iso", [False, True])
 @pytest.mark.parametrize("methods", [("export", "import"), ("unpack", "pack")])
-def test_import_export(A, do_iso, methods):
+def test_ss_import_export(A, do_iso, methods):
     if do_iso:
         A(A.S) << 1
     A1 = A.dup()
@@ -2147,14 +2246,21 @@ def test_import_export(A, do_iso, methods):
     E = Matrix.ss.import_any(**info)
     assert E.isequal(D)
 
+    info = D.ss.export("rowwise")
+    assert info["format"] in {"csr", "dcsr", "bitmapr", "fullr"}
+    info = D.ss.export("colwise")
+    assert info["format"] in {"csc", "dcsc", "bitmapc", "fullc"}
+
 
-def test_import_on_view():
+@pytest.mark.skipif("not suitesparse")
+def test_ss_import_on_view():
     A = Matrix.from_values([0, 0, 1, 1], [0, 1, 0, 1], [1, 2, 3, 4])
     B = Matrix.ss.import_any(nrows=2, ncols=2, values=np.array([1, 2, 3, 4, 99, 99, 99])[:4])
     assert A.isequal(B)
 
 
-def test_import_export_empty():
+@pytest.mark.skipif("not suitesparse")
+def test_ss_import_export_empty():
     A = Matrix(int, 2, 3)
     A1 = A.dup()
     d = A1.ss.export("csr")
@@ -2251,9 +2357,10 @@ def test_import_export_empty():
         A.ss.export(format="bad_format")
 
 
+@pytest.mark.skipif("not suitesparse")
 @pytest.mark.parametrize("do_iso", [False, True])
 @pytest.mark.parametrize("methods", [("export", "import"), ("unpack", "pack")])
-def test_import_export_auto(A, do_iso, methods):
+def test_ss_import_export_auto(A, do_iso, methods):
     if do_iso:
         A(A.S) << 1
     A_orig = A.dup()
@@ -2510,20 +2617,21 @@ def test_not_to_array(A):
 def test_diag(A, params):
     k, indices, values = params
     expected = Vector.from_values(indices, values, dtype=A.dtype, size=max(0, A.nrows - abs(k)))
-    v = graphblas.ss.diag(A, k=k)
-    assert expected.isequal(v)
-    v[:] = 0
-    v.ss.build_diag(A, k=k)
-    assert expected.isequal(v)
     v = A.diag(k)
     assert expected.isequal(v)
-    v = graphblas.ss.diag(A.T, k=-k)
-    assert expected.isequal(v)
-    v[:] = 0
-    v.ss.build_diag(A.T, -k)
-    assert expected.isequal(v)
     v = A.T.diag(-k)
     assert expected.isequal(v)
+    if suitesparse:
+        v = gb.ss.diag(A, k=k)
+        assert expected.isequal(v)
+        v[:] = 0
+        v.ss.build_diag(A, k=k)
+        assert expected.isequal(v)
+        v = gb.ss.diag(A.T, k=-k)
+        assert expected.isequal(v)
+        v[:] = 0
+        v.ss.build_diag(A.T, -k)
+        assert expected.isequal(v)
 
 
 def test_normalize_chunks():
@@ -2567,7 +2675,8 @@ def test_normalize_chunks():
         normalize_chunks([10, np.array([-1, 2])], shape)
 
 
-def test_split(A):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_split(A):
     results = A.ss.split([4, 3])
     for results in [A.ss.split([4, 3]), A.ss.split([[4, None], 3], name="split")]:
         row_boundaries = [0, 4, 7]
@@ -2580,8 +2689,9 @@ def test_split(A):
         A.ss.split([[5, 5], 3])
 
 
-def test_concat(A, v):
-    B1 = graphblas.ss.concat([[A, A]], dtype=float)
+@pytest.mark.skipif("not suitesparse")
+def test_ss_concat(A, v):
+    B1 = gb.ss.concat([[A, A]], dtype=float)
     assert B1.dtype == "FP64"
     expected = Matrix(A.dtype, nrows=A.nrows, ncols=2 * A.ncols)
     expected[:, : A.ncols] = A
@@ -2596,49 +2706,50 @@ def test_concat(A, v):
     assert B2.isequal(expected)
 
     tiles = A.ss.split([4, 3])
-    A2 = graphblas.ss.concat(tiles)
+    A2 = gb.ss.concat(tiles)
     assert A2.isequal(A)
 
     with pytest.raises(TypeError, match="tiles argument must be list or tuple"):
-        graphblas.ss.concat(1)
+        gb.ss.concat(1)
     # with pytest.raises(TypeError, match="Each tile must be a Matrix"):
-    assert graphblas.ss.concat([[A.T]]).isequal(A.T)
+    assert gb.ss.concat([[A.T]]).isequal(A.T)
     with pytest.raises(TypeError, match="tiles must be lists or tuples"):
-        graphblas.ss.concat([A])
+        gb.ss.concat([A])
 
     with pytest.raises(ValueError, match="tiles argument must not be empty"):
-        graphblas.ss.concat([])
+        gb.ss.concat([])
     with pytest.raises(ValueError, match="tiles must not be empty"):
-        graphblas.ss.concat([[]])
+        gb.ss.concat([[]])
     with pytest.raises(ValueError, match="tiles must all be the same length"):
-        graphblas.ss.concat([[A], [A, A]])
+        gb.ss.concat([[A], [A, A]])
 
     # Treat vectors like Nx1 matrices
-    B3 = graphblas.ss.concat([[v, v]])
+    B3 = gb.ss.concat([[v, v]])
     expected = Matrix(v.dtype, nrows=v.size, ncols=2)
     expected[:, 0] = v
     expected[:, 1] = v
     assert B3.isequal(expected)
 
-    B4 = graphblas.ss.concat([[v], [v]])
+    B4 = gb.ss.concat([[v], [v]])
     expected = Matrix(v.dtype, nrows=2 * v.size, ncols=1)
     expected[: v.size, 0] = v
     expected[v.size :, 0] = v
     assert B4.isequal(expected)
 
-    B5 = graphblas.ss.concat([[A, v]])
+    B5 = gb.ss.concat([[A, v]])
     expected = Matrix(v.dtype, nrows=v.size, ncols=A.ncols + 1)
     expected[:, : A.ncols] = A
     expected[:, A.ncols] = v
     assert B5.isequal(expected)
 
     with pytest.raises(TypeError, match=""):
-        graphblas.ss.concat([v, [v]])
+        gb.ss.concat([v, [v]])
     with pytest.raises(TypeError):
-        graphblas.ss.concat([[v], v])
+        gb.ss.concat([[v], v])
 
 
-def test_nbytes(A):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_nbytes(A):
     assert A.ss.nbytes > 0
 
 
@@ -2759,6 +2870,7 @@ def test_expr_is_like_matrix(A):
         "_deserialize",
         "_extract_element",
         "_from_csx",
+        "_from_dense",
         "_from_obj",
         "_name_counter",
         "_parent",
@@ -2788,9 +2900,9 @@ def test_expr_is_like_matrix(A):
     assert attrs - infix_attrs == expected
     # TransposedMatrix is used differently than other expressions,
     # so maybe it shouldn't support everything.
-    assert attrs - transposed_attrs == (
-        expected | {"_as_vector", "S", "V", "ss", "to_pygraphblas"}
-    ) - {
+    if suitesparse:
+        expected.add("ss")
+    assert attrs - transposed_attrs == (expected | {"_as_vector", "S", "V", "to_pygraphblas"}) - {
         "_prep_for_extract",
         "_extract_element",
     }
@@ -2811,6 +2923,7 @@ def test_index_expr_is_like_matrix(A):
         "_deserialize",
         "_extract_element",
         "_from_csx",
+        "_from_dense",
         "_from_obj",
         "_name_counter",
         "_parent",
@@ -2838,7 +2951,8 @@ def test_index_expr_is_like_matrix(A):
     )
 
 
-def test_flatten(A):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_flatten(A):
     data = [
         [3, 0, 3, 5, 6, 0, 6, 1, 6, 2, 4, 1],
         [0, 1, 2, 2, 2, 3, 3, 4, 4, 5, 5, 6],
@@ -2889,6 +3003,7 @@ def test_flatten(A):
         v.ss.reshape(A.shape + (1,))
 
 
+@pytest.mark.skipif("not suitesparse")
 def test_ss_reshape(A):
     A.resize(8, 8)
     r, c, v = A.to_values()
@@ -3058,8 +3173,9 @@ def test_infix_sugar(A):
         expr ^= 1
 
 
+@pytest.mark.skipif("not suitesparse")
 @pytest.mark.slow
-def test_random(A):
+def test_ss_random(A):
     R = A.ss.selectk_rowwise("random", 1)
     counts = R.reduce_rowwise(agg.count).new()
     expected = Vector.from_values(range(A.ncols), 1)
@@ -3090,7 +3206,8 @@ def test_random(A):
         A.ss.selectk_columnwise("random", -1)
 
 
-def test_firstk(A):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_firstk(A):
     B = A.ss.selectk_rowwise("first", 1)
     expected = Matrix.from_values(
         [0, 1, 2, 3, 4, 5, 6],
@@ -3138,7 +3255,8 @@ def test_firstk(A):
     assert B.isequal(A)
 
 
-def test_lastk(A):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_lastk(A):
     B = A.ss.selectk_rowwise("last", 1)
     expected = Matrix.from_values(
         [0, 3, 5, 6, 2, 4, 1],
@@ -3186,9 +3304,10 @@ def test_lastk(A):
     assert B.isequal(A)
 
 
+@pytest.mark.skipif("not suitesparse")
 @pytest.mark.parametrize("do_iso", [False, True])
 @pytest.mark.slow
-def test_compactify(A, do_iso):
+def test_ss_compactify(A, do_iso):
     if do_iso:
         r, c, v = A.to_values()
         A = Matrix.from_values(r, c, 1)
@@ -3322,10 +3441,11 @@ def compare(A, expected, isequal=True, **kwargs):
 
 def test_deprecated(A):
     v = A.diag()
-    with pytest.warns(DeprecationWarning):
-        A.ss.diag(v)
-    with pytest.warns(DeprecationWarning):
-        v.ss.diag(A)
+    if suitesparse:
+        with pytest.warns(DeprecationWarning):
+            A.ss.diag(v)
+        with pytest.warns(DeprecationWarning):
+            v.ss.diag(A)
     with pytest.warns(DeprecationWarning):
         Matrix.new(int)
     with pytest.warns(DeprecationWarning):
@@ -3346,7 +3466,11 @@ def test_ndim(A):
 
 
 def test_sizeof(A):
-    assert sys.getsizeof(A) > A.nvals * 16
+    if suitesparse:
+        assert sys.getsizeof(A) > A.nvals * 16
+    else:
+        with pytest.raises(TypeError):
+            sys.getsizeof(A)
 
 
 def test_ewise_union():
@@ -3398,7 +3522,8 @@ def test_delete_via_scalar(A):
     assert A.nvals == 0
 
 
-def test_iteration(A):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_iteration(A):
     B = Matrix(int, 2, 2)
     assert list(B.ss.iterkeys()) == []
     assert list(B.ss.itervalues()) == []
@@ -3483,12 +3608,13 @@ def test_udt():
     rows, cols, values = A.to_values()
     assert A.isequal(Matrix.from_values(rows, cols, values))
     assert A.isequal(Matrix.from_values(rows, cols, values, dtype=A.dtype))
-    info = A.ss.export()
-    result = A.ss.import_any(**info)
-    assert result.isequal(A)
-    info = A.ss.export("cooc")
-    result = A.ss.import_any(**info)
-    assert result.isequal(A)
+    if suitesparse:
+        info = A.ss.export()
+        result = A.ss.import_any(**info)
+        assert result.isequal(A)
+        info = A.ss.export("cooc")
+        result = A.ss.import_any(**info)
+        assert result.isequal(A)
     result = unary.positioni(A).new()
     expected = Matrix.from_values([0, 0, 1, 1], [0, 1, 0, 1], [0, 0, 1, 1])
     assert result.isequal(expected)
@@ -3515,10 +3641,11 @@ def test_udt():
     with pytest.raises(ValueError, match="shape mismatch"):
         A[[0, 1], [1]] = [[(2, 3), (4, 5)]]
 
-    AA = Matrix.ss.deserialize(A.ss.serialize())
-    assert A.isequal(AA, check_dtype=True)
-    AA = Matrix.ss.deserialize(A.ss.serialize(), dtype=A.dtype)
-    assert A.isequal(AA, check_dtype=True)
+    if suitesparse:
+        AA = Matrix.ss.deserialize(A.ss.serialize())
+        assert A.isequal(AA, check_dtype=True)
+        AA = Matrix.ss.deserialize(A.ss.serialize(), dtype=A.dtype)
+        assert A.isequal(AA, check_dtype=True)
 
     np_dtype = np.dtype("(3,)uint16")
     udt = dtypes.register_anonymous(np_dtype, "has_subdtype")
@@ -3531,20 +3658,22 @@ def test_udt():
     assert result.isequal(A)
     result = Matrix.from_values(rows, cols, values, dtype=udt)
     assert result.isequal(A)
-    info = A.ss.export()
-    result = A.ss.import_any(**info)
-    assert result.isequal(A)
-    info = A.ss.export("coor")
-    result = A.ss.import_any(**info)
-    assert result.isequal(A)
+    if suitesparse:
+        info = A.ss.export()
+        result = A.ss.import_any(**info)
+        assert result.isequal(A)
+        info = A.ss.export("coor")
+        result = A.ss.import_any(**info)
+        assert result.isequal(A)
     assert Matrix.from_csr(*A.to_csr()).isequal(A)
     A.clear()
     A[[0, 1], 1] = [(2, 3, 4), [5, 6, 7]]
     expected = Matrix.from_values([0, 1], [1, 1], [[2, 3, 4], [5, 6, 7]], dtype=udt)
     assert A.isequal(expected)
     A[[0, 1], [1]] = [[[2, 3, 4]], [[5, 6, 7]]]
-    AA = Matrix.ss.deserialize(A.ss.serialize())
-    assert A.isequal(AA, check_dtype=True)
+    if suitesparse:
+        AA = Matrix.ss.deserialize(A.ss.serialize())
+        assert A.isequal(AA, check_dtype=True)
     with pytest.raises(ValueError, match="shape mismatch"):
         A[[0, 1], [1]] = [[[2, 3, 4], [5, 6, 7]]]
     with pytest.raises(ValueError, match="shape mismatch"):
@@ -3667,6 +3796,7 @@ def test_bool_as_mask(A):
     assert A.isequal(expected)
 
 
+@pytest.mark.skipif("not suitesparse")
 def test_ss_serialize(A):
     for compression, level, nthreads in itertools.product(
         [None, "none", "default", "lz4", "lz4hc", "zstd"], [None, 1, 5, 9], [None, -1, 1, 10]
@@ -3693,6 +3823,7 @@ def test_ss_serialize(A):
         Matrix.ss.deserialize(a[:-5])
 
 
+@pytest.mark.skipif("not suitesparse")
 def test_ss_config(A):
     d = {}
     for key in A.ss.config:
@@ -3745,6 +3876,9 @@ def test_to_csr_from_csc(A):
     expected = Matrix.from_values([0, 1], [1, 0], [100, 100], nrows=2, ncols=2)
     assert expected.isequal(B, check_dtype=True)
 
+    B = Matrix.from_csc([0, 1, 2], [1, 0], 100)
+    assert expected.isequal(B, check_dtype=True)
+
     with pytest.raises(InvalidValue):
         Matrix.from_csr([0, 1, 2, 3], [1, 0], [10, 20], ncols=3)
 
@@ -3758,10 +3892,12 @@ def test_to_csr_from_csc(A):
 
 def test_to_dcsr_from_dcsc(A):
     assert Matrix.from_dcsr(*A.to_dcsr(dtype=int)).isequal(A, check_dtype=True)
+    assert Matrix.from_dcsc(*A.to_dcsc(dtype=int)).isequal(A, check_dtype=True)
     assert Matrix.from_dcsr(*A.T.to_dcsc()).isequal(A, check_dtype=True)
     assert Matrix.from_dcsc(*A.to_dcsc()).isequal(A)
     assert Matrix.from_dcsc(*A.T.to_dcsr()).isequal(A)
     assert Matrix.from_dcsr(*A.to_dcsr(dtype=float)).isequal(A.dup(float), check_dtype=True)
+    assert Matrix.from_dcsc(*A.to_dcsc(dtype=float)).isequal(A.dup(float), check_dtype=True)
 
     #    0 1 2
     # 0 [- 1 -]
@@ -3785,6 +3921,17 @@ def test_to_dcsr_from_dcsc(A):
     assert Matrix.from_dcsr(*B.to_dcsr(), nrows=2, ncols=2).isequal(B)
     assert Matrix.from_dcsr(*B.to_dcsr()).isequal(B[0:0, 0:0].new())
 
+    B = Matrix.from_dcsc([], [0], [], [], nrows=2, ncols=2, dtype=int)
+    expected = Matrix(int, 2, 2)
+    assert expected.isequal(B, check_dtype=True)
+    assert Matrix.from_dcsc(*B.to_dcsc(), nrows=2, ncols=2).isequal(B)
+    assert Matrix.from_dcsc(*B.to_dcsc()).isequal(B[0:0, 0:0].new())
+
+    B = Matrix.from_dcsr([2], [0, 0], [], [], dtype=int)
+    assert B.shape == (3, 0)
+    B = Matrix.from_dcsc([2], [0, 0], [], [], dtype=int)
+    assert B.shape == (0, 3)
+
     # indptr must not be empty
     with pytest.raises(InvalidValue):
         Matrix.from_dcsr([], [], [], [], dtype=int)
@@ -3801,6 +3948,7 @@ def test_as_vector(A):
     assert v.isequal(expected)
 
 
+@pytest.mark.skipif("not suitesparse")
 def test_ss_pack_hyperhash(A):
     A.ss.config["sparsity_control"] = "sparse"
     assert A.ss.unpack_hyperhash() is None
@@ -3814,7 +3962,7 @@ def test_ss_pack_hyperhash(A):
     assert C.ss.unpack_hyperhash() is None
     assert Y.nrows == C.nrows
     C.ss.pack_hyperhash(Y)
-    assert Y.gb_obj[0] == graphblas.core.NULL
+    assert Y.gb_obj[0] == gb.core.NULL
     assert C.ss.unpack_hyperhash() is not None
 
 
diff --git a/graphblas/tests/test_numpyops.py b/graphblas/tests/test_numpyops.py
index 53d89fb56..baf0e9d40 100644
--- a/graphblas/tests/test_numpyops.py
+++ b/graphblas/tests/test_numpyops.py
@@ -5,7 +5,7 @@
 import numpy as np
 import pytest
 
-import graphblas
+import graphblas as gb
 import graphblas.binary.numpy as npbinary
 import graphblas.monoid.numpy as npmonoid
 import graphblas.semiring.numpy as npsemiring
@@ -27,18 +27,18 @@ def test_numpyops_dir():
 def test_bool_doesnt_get_too_large():
     a = Vector.from_values([0, 1, 2, 3], [True, False, True, False])
     b = Vector.from_values([0, 1, 2, 3], [True, True, False, False])
-    if graphblas.config["mapnumpy"]:
+    if gb.config["mapnumpy"]:
         with pytest.raises(KeyError, match="plus does not work with BOOL"):
-            z = a.ewise_mult(b, graphblas.monoid.numpy.add).new()
+            z = a.ewise_mult(b, gb.monoid.numpy.add).new()
     else:
-        z = a.ewise_mult(b, graphblas.monoid.numpy.add).new()
+        z = a.ewise_mult(b, gb.monoid.numpy.add).new()
         x, y = z.to_values()
         np.testing.assert_array_equal(y, (True, True, True, False))
 
-    def func(x):  # pragma: no cover
+    def func(x):  # pragma: no cover (numba)
         return np.add(x, x)
 
-    op = graphblas.core.operator.UnaryOp.register_anonymous(func)
+    op = gb.core.operator.UnaryOp.register_anonymous(func)
     z = a.apply(op).new()
     x, y = z.to_values()
     np.testing.assert_array_equal(y, (True, False, True, False))
@@ -61,7 +61,7 @@ def test_npunary():
         "INT64": {"reciprocal"},
         "FC64": {"ceil", "floor", "trunc"},
     }
-    isclose = graphblas.binary.isclose(1e-6, 0)
+    isclose = gb.binary.isclose(1e-6, 0)
     for gb_input, np_input in data:
         for unary_name in sorted(npunary._unary_names):
             op = getattr(npunary, unary_name)
@@ -92,9 +92,9 @@ def test_npunary():
             assert gb_result.nvals == np_result.size
             match = gb_result.ewise_mult(np_result, compare_op).new()
             if gb_result.dtype.name.startswith("F"):
-                match(accum=graphblas.binary.lor) << gb_result.apply(npunary.isnan)
-            compare = match.reduce(graphblas.monoid.land).new()
-            if not compare:  # pragma: no cover
+                match(accum=gb.binary.lor) << gb_result.apply(npunary.isnan)
+            compare = match.reduce(gb.monoid.land).new()
+            if not compare:  # pragma: no cover (debug)
                 print(unary_name, gb_input.dtype)
                 print(compute(gb_result))
                 print(np_result)
@@ -140,7 +140,7 @@ def test_npbinary():
         "FP64": {"floor_divide"},  # numba/numpy difference for 1.0 / 0.0
         "BOOL": {"gcd", "lcm", "subtract"},  # not supported by numpy
     }
-    isclose = graphblas.binary.isclose(1e-7, 0)
+    isclose = gb.binary.isclose(1e-7, 0)
     for (gb_left, gb_right), (np_left, np_right) in data:
         for binary_name in sorted(npbinary._binary_names):
             op = getattr(npbinary, binary_name)
@@ -167,12 +167,12 @@ def test_npbinary():
             assert gb_result.nvals == np_result.size
             match = gb_result.ewise_mult(np_result, compare_op).new()
             if gb_result.dtype.name.startswith("F"):
-                match(accum=graphblas.binary.lor) << gb_result.apply(npunary.isnan)
+                match(accum=gb.binary.lor) << gb_result.apply(npunary.isnan)
             if gb_result.dtype.name.startswith("FC"):
                 # Divide by 0j sometimes result in different behavior, such as `nan` or `(inf+0j)`
-                match(accum=graphblas.binary.lor) << gb_result.apply(npunary.isinf)
-            compare = match.reduce(graphblas.monoid.land).new()
-            if not compare:  # pragma: no cover
+                match(accum=gb.binary.lor) << gb_result.apply(npunary.isinf)
+            compare = match.reduce(gb.monoid.land).new()
+            if not compare:  # pragma: no cover (debug)
                 print(binary_name)
                 print(compute(gb_left))
                 print(compute(gb_right))
@@ -206,7 +206,7 @@ def test_npmonoid():
             [np.array([True, False, True, False]), np.array([True, True, False, False])],
         ],
     ]
-    # Complex monoids not working yet (they segfault upon creation in graphblas.core.operators)
+    # Complex monoids not working yet (they segfault upon creation in gb.core.operators)
     if _supports_complex:  # pragma: no branch
         data.append(
             [
@@ -241,9 +241,9 @@ def test_npmonoid():
             assert gb_result.nvals == np_result.size
             match = gb_result.ewise_mult(np_result, npbinary.equal).new()
             if gb_result.dtype.name.startswith("F"):
-                match(accum=graphblas.binary.lor) << gb_result.apply(npunary.isnan)
-            compare = match.reduce(graphblas.monoid.land).new()
-            if not compare:  # pragma: no cover
+                match(accum=gb.binary.lor) << gb_result.apply(npunary.isnan)
+            compare = match.reduce(gb.monoid.land).new()
+            if not compare:  # pragma: no cover (debug)
                 print(binary_name, gb_left.dtype)
                 print(compute(gb_result))
                 print(np_result)
@@ -272,9 +272,9 @@ def test_npsemiring():
         name = monoid.name.split(".")[-1] + "_" + binary.name.split(".")[-1]
         if name in {"eq_pow", "eq_minus"}:
             continue
-        semiring = graphblas.core.operator.Semiring.register_anonymous(monoid, binary, name)
+        semiring = gb.core.operator.Semiring.register_anonymous(monoid, binary, name)
         if len(semiring.types) == 0:
-            if not graphblas.config["mapnumpy"] and "logical" not in name:
+            if not gb.config["mapnumpy"] and "logical" not in name:
                 assert not hasattr(npsemiring, semiring.name), name
         else:
             assert hasattr(npsemiring, f"{monoid_name}_{binary_name}"), (name, semiring.name)
diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py
index ed062abdc..584753738 100644
--- a/graphblas/tests/test_op.py
+++ b/graphblas/tests/test_op.py
@@ -136,7 +136,7 @@ def test_get_typed_op():
 
 def test_unaryop_udf():
     def plus_one(x):
-        return x + 1  # pragma: no cover
+        return x + 1  # pragma: no cover (numba)
 
     unary.register_new("plus_one", plus_one)
     assert hasattr(unary, "plus_one")
@@ -178,7 +178,7 @@ def plus_one(x):
 def test_unaryop_parameterized():
     def plus_x(x=0):
         def inner(val):
-            return val + x  # pragma: no cover
+            return val + x  # pragma: no cover (numba)
 
         return inner
 
@@ -203,7 +203,7 @@ def inner(val):
 def test_binaryop_parameterized():
     def plus_plus_x(x=0):
         def inner(left, right):
-            return left + right + x  # pragma: no cover
+            return left + right + x  # pragma: no cover (numba)
 
         return inner
 
@@ -248,13 +248,13 @@ def inner(left, right):
     assert not hasattr(binary, "bad")
     with pytest.raises(UdfParseError, match="Unable to parse function using Numba"):
 
-        def bad(x, y):  # pragma: no cover
+        def bad(x, y):  # pragma: no cover (numba)
             return v
 
         BinaryOp.register_new("bad", bad)
 
     def my_add(x, y):
-        return x + y  # pragma: no cover
+        return x + y  # pragma: no cover (numba)
 
     op = BinaryOp.register_anonymous(my_add)
     assert op.name == "my_add"
@@ -264,7 +264,7 @@ def my_add(x, y):
 def test_monoid_parameterized():
     def plus_plus_x(x=0):
         def inner(left, right):
-            return left + right + x  # pragma: no cover
+            return left + right + x  # pragma: no cover (numba)
 
         return inner
 
@@ -272,9 +272,9 @@ def inner(left, right):
 
     # signatures must match
     with pytest.raises(ValueError, match="Signatures"):
-        Monoid.register_anonymous(bin_op, lambda x: -x)  # pragma: no cover
+        Monoid.register_anonymous(bin_op, lambda x: -x)  # pragma: no cover (numba)
     with pytest.raises(ValueError, match="Signatures"):
-        Monoid.register_anonymous(bin_op, lambda y=0: -y)  # pragma: no cover
+        Monoid.register_anonymous(bin_op, lambda y=0: -y)  # pragma: no cover (numba)
     with pytest.raises(TypeError, match="binaryop must be parameterized"):
         operator.ParameterizedMonoid("bad_monoid", binary.plus, 0)
 
@@ -314,7 +314,7 @@ def plus_plus_x_identity(x=0):
     # identity may be a value
     def logaddexp(base):
         def inner(x, y):
-            return np.log(base**x + base**y) / np.log(base)  # pragma: no cover
+            return np.log(base**x + base**y) / np.log(base)  # pragma: no cover (numba)
 
         return inner
 
@@ -324,7 +324,7 @@ def inner(x, y):
     monoid = gb.monoid._user_defined_monoid
     fv2 = fv.ewise_mult(fv, monoid(2)).new()
 
-    def plus1(x):  # pragma: no cover
+    def plus1(x):  # pragma: no cover (numba)
         return x + 1
 
     plus1 = UnaryOp.register_anonymous(plus1)
@@ -335,7 +335,7 @@ def plus1(x):  # pragma: no cover
 
     def plus_times_x(x=0):
         def inner(left, right):
-            return (left + right) * x  # pragma: no cover
+            return (left + right) * x  # pragma: no cover (numba)
 
         return inner
 
@@ -354,7 +354,7 @@ def bad_identity(x=0):
 def test_semiring_parameterized():
     def plus_plus_x(x=0):
         def inner(left, right):
-            return left + right + x  # pragma: no cover
+            return left + right + x  # pragma: no cover (numba)
 
         return inner
 
@@ -398,14 +398,14 @@ def plus_plus_x_identity(x=0):
         A.ewise_add(A, mysemiring)
 
     # mismatched signatures.
-    def other_binary(y=0):  # pragma: no cover
+    def other_binary(y=0):  # pragma: no cover (numba)
         def inner(left, right):
             return left + right - y
 
         return inner
 
     def other_identity(y=0):
-        return x  # pragma: no cover
+        return x  # pragma: no cover (numba)
 
     other_op = BinaryOp.register_anonymous(other_binary, parameterized=True)
     other_monoid = Monoid.register_anonymous(other_op, other_identity)
@@ -455,7 +455,7 @@ def other_identity(y=0):
 
     def plus_x(x=0):
         def inner(y):
-            return x + y  # pragma: no cover
+            return x + y  # pragma: no cover (numba)
 
         return inner
 
@@ -482,7 +482,7 @@ def inner(y):
 def test_unaryop_udf_bool_result():
     # numba has trouble compiling this, but we have a work-around
     def is_positive(x):
-        return x > 0  # pragma: no cover
+        return x > 0  # pragma: no cover (numba)
 
     UnaryOp.register_new("is_positive", is_positive)
     assert hasattr(unary, "is_positive")
@@ -507,7 +507,7 @@ def is_positive(x):
 
 def test_binaryop_udf():
     def times_minus_sum(x, y):
-        return x * y - (x + y)  # pragma: no cover
+        return x * y - (x + y)  # pragma: no cover (numba)
 
     BinaryOp.register_new("bin_test_func", times_minus_sum)
     assert hasattr(binary, "bin_test_func")
@@ -536,7 +536,7 @@ def times_minus_sum(x, y):
 
 def test_monoid_udf():
     def plus_plus_one(x, y):
-        return x + y + 1  # pragma: no cover
+        return x + y + 1  # pragma: no cover (numba)
 
     BinaryOp.register_new("plus_plus_one", plus_plus_one)
     Monoid.register_new("plus_plus_one", binary.plus_plus_one, -1)
@@ -570,7 +570,7 @@ def plus_plus_one(x, y):
 
 def test_semiring_udf():
     def plus_plus_two(x, y):
-        return x + y + 2  # pragma: no cover
+        return x + y + 2  # pragma: no cover (numba)
 
     BinaryOp.register_new("plus_plus_two", plus_plus_two)
     Semiring.register_new("extra_twos", monoid.plus, binary.plus_plus_two)
@@ -603,7 +603,7 @@ def test_binary_updates():
 @pytest.mark.slow
 def test_nested_names():
     def plus_three(x):
-        return x + 3  # pragma: no cover
+        return x + 3  # pragma: no cover (numba)
 
     UnaryOp.register_new("incrementers.plus_three", plus_three)
     assert hasattr(unary, "incrementers")
@@ -632,7 +632,7 @@ def plus_three(x):
     assert v.isequal(result), v
 
     def plus_four(x):
-        return x + 4  # pragma: no cover
+        return x + 4  # pragma: no cover (numba)
 
     UnaryOp.register_new("incrementers.plus_four", plus_four)
     assert hasattr(unary.incrementers, "plus_four")
@@ -642,7 +642,7 @@ def plus_four(x):
     assert v.isequal(result2), v
 
     def bad_will_overwrite_path(x):
-        return x + 7  # pragma: no cover
+        return x + 7  # pragma: no cover (numba)
 
     with pytest.raises(AttributeError):
         UnaryOp.register_new("incrementers", bad_will_overwrite_path)
@@ -672,7 +672,7 @@ def test_op_namespace():
         op.numpy.bad_attr
 
     # Make sure all have been initialized so `vars` below works
-    for key in list(op._delayed):  # pragma: no cover
+    for key in list(op._delayed):  # pragma: no cover (safety)
         getattr(op, key)
     opnames = {
         key
@@ -732,7 +732,7 @@ def test_binaryop_attributes():
     assert binary.numpy.subtract.monoid is None
 
     def plus(x, y):
-        return x + y  # pragma: no cover
+        return x + y  # pragma: no cover (numba)
 
     op = BinaryOp.register_anonymous(plus, name="plus")
     assert op.monoid is None
@@ -773,7 +773,7 @@ def test_monoid_attributes():
     assert monoid.numpy.add.binaryop is binary.numpy.add
     assert monoid.numpy.add.identities == {typ: 0 for typ in monoid.numpy.add.types}
 
-    def plus(x, y):  # pragma: no cover
+    def plus(x, y):  # pragma: no cover (numba)
         return x + y
 
     binop = BinaryOp.register_anonymous(plus, name="plus")
@@ -810,7 +810,7 @@ def test_semiring_attributes():
     assert semiring.numpy.add_subtract.binaryop is binary.numpy.subtract
 
     def plus(x, y):
-        return x + y  # pragma: no cover
+        return x + y  # pragma: no cover (numba)
 
     binop = BinaryOp.register_anonymous(plus, name="plus")
     mymonoid = Monoid.register_anonymous(binop, 0, name="plus")
@@ -878,7 +878,7 @@ def test_get_semiring():
         get_semiring(binary.plus, monoid.times)
 
     def myplus(x, y):
-        return x + y  # pragma: no cover
+        return x + y  # pragma: no cover (numba)
 
     binop = BinaryOp.register_anonymous(myplus, name="myplus")
     st = get_semiring(monoid.plus, binop)
@@ -1022,10 +1022,10 @@ def test_from_string():
 
 @pytest.mark.slow
 def test_lazy_op():
-    UnaryOp.register_new("lazy", lambda x: x, lazy=True)  # pragma: no branch
+    UnaryOp.register_new("lazy", lambda x: x, lazy=True)  # pragma: no branch (numba)
     assert isinstance(op.lazy, UnaryOp)
     assert isinstance(unary.lazy, UnaryOp)
-    BinaryOp.register_new("lazy", lambda x, y: x + y, lazy=True)  # pragma: no branch
+    BinaryOp.register_new("lazy", lambda x, y: x + y, lazy=True)  # pragma: no branch (numba)
     Monoid.register_new("lazy", "lazy", 0, lazy=True)
     assert isinstance(monoid.lazy, Monoid)
     assert isinstance(binary.lazy, BinaryOp)
@@ -1037,9 +1037,9 @@ def test_lazy_op():
     Semiring.register_new("lazy_lazy", monoid.lazy, binary.lazy, lazy=True)
     assert isinstance(semiring.lazy_lazy, Semiring)
     # numpy
-    UnaryOp.register_new("numpy.lazy", lambda x: x, lazy=True)  # pragma: no branch
+    UnaryOp.register_new("numpy.lazy", lambda x: x, lazy=True)  # pragma: no branch (numba)
     assert isinstance(unary.numpy.lazy, UnaryOp)
-    BinaryOp.register_new("numpy.lazy", lambda x, y: x + y, lazy=True)  # pragma: no branch
+    BinaryOp.register_new("numpy.lazy", lambda x, y: x + y, lazy=True)  # pragma: no branch (numba)
     Monoid.register_new("numpy.lazy", "numpy.lazy", 0, lazy=True)
     assert isinstance(monoid.numpy.lazy, Monoid)
     assert isinstance(binary.numpy.lazy, BinaryOp)
@@ -1052,7 +1052,7 @@ def test_lazy_op():
     Semiring.register_new("numpy.lazy_lazy", monoid.numpy.lazy, binary.numpy.lazy, lazy=True)
     assert isinstance(semiring.numpy.lazy_lazy, Semiring)
     # misc
-    UnaryOp.register_new("misc.lazy", lambda x: x, lazy=True)  # pragma: no branch
+    UnaryOp.register_new("misc.lazy", lambda x: x, lazy=True)  # pragma: no branch (numba)
     assert isinstance(unary.misc.lazy, UnaryOp)
     with pytest.raises(AttributeError):
         unary.misc.bad
@@ -1094,7 +1094,7 @@ def test_udt():
     w[:] = 1
 
     def _udt_identity(val):
-        return val  # pragma: no cover
+        return val  # pragma: no cover (numba)
 
     udt_identity = UnaryOp.register_new("udt_identity", _udt_identity, is_udt=True)
     assert udt in udt_identity
@@ -1111,7 +1111,7 @@ def _udt_identity(val):
         assert "badname" in udt_identity
 
     def _udt_getx(val):
-        return val["x"]  # pragma: no cover
+        return val["x"]  # pragma: no cover (numba)
 
     udt_getx = UnaryOp.register_anonymous(_udt_getx, "udt_getx", is_udt=True)
     assert udt in udt_getx
@@ -1119,10 +1119,10 @@ def _udt_getx(val):
     expected = Vector.from_values([0, 1, 2], 0)
     assert result.isequal(expected)
 
-    def _udt_index(val, idx, _, thunk):
-        if idx == 0:  # pragma: no cover
+    def _udt_index(val, idx, _, thunk):  # pragma: no cover (numba)
+        if idx == 0:
             return thunk["y"]
-        return -thunk["y"]  # pragma: no cover
+        return -thunk["y"]
 
     _udt_index = IndexUnaryOp.register_anonymous(_udt_index, "_udt_index", is_udt=True)
     assert udt in _udt_index
@@ -1131,7 +1131,7 @@ def _udt_index(val, idx, _, thunk):
     assert result.isequal(expected)
 
     def _udt_first(x, y):
-        return x  # pragma: no cover
+        return x  # pragma: no cover (numba)
 
     udt_first = BinaryOp.register_anonymous(_udt_first, "udt_first", is_udt=True)
     assert udt in udt_first
@@ -1177,14 +1177,14 @@ def _udt_first(x, y):
     class BreakCompile:
         pass
 
-    def badfunc(x):  # pragma: no cover
+    def badfunc(x):  # pragma: no cover (numba)
         return BreakCompile(x)
 
     badunary = UnaryOp.register_anonymous(badfunc, is_udt=True)
     assert udt not in badunary
     assert int not in badunary
 
-    def badfunc(x, y):  # pragma: no cover
+    def badfunc(x, y):  # pragma: no cover (numba)
         return BreakCompile(x)
 
     badbinary = BinaryOp.register_anonymous(badfunc, is_udt=True)
@@ -1198,12 +1198,12 @@ def badfunc(x, y):  # pragma: no cover
 
     assert binary.second[udt].type is udt
     assert binary.second[udt].type2 is udt
-    assert binary.second[udt, dtypes.INT8].type is dtypes.INT8  # ignore first arg
+    assert binary.second[udt, dtypes.INT8].type is udt
     assert binary.second[udt, dtypes.INT8].type2 is dtypes.INT8
-    assert semiring.any_second[udt, dtypes.INT8].type is dtypes.INT8  # ignore first arg
+    assert semiring.any_second[udt, dtypes.INT8].type is udt
     assert semiring.any_second[udt, dtypes.INT8].type2 is dtypes.INT8
     assert binary.first[udt, dtypes.INT8].type is udt
-    assert binary.first[udt, dtypes.INT8].type2 is dtypes.INT8  # include second arg!
+    assert binary.first[udt, dtypes.INT8].type2 is dtypes.INT8
     assert monoid.any[udt].type2 is udt
 
 
@@ -1220,7 +1220,7 @@ def test_semiring_commute_exists():
     for key in orig_semirings:
         val = getattr(semiring, key)
         commutes_to = val.commutes_to
-        if commutes_to is not None and commutes_to not in vals:  # pragma: no cover
+        if commutes_to is not None and commutes_to not in vals:  # pragma: no cover (debug)
             missing.add(commutes_to.name)
     if missing:
         raise AssertionError("Missing semirings: " + ", ".join(sorted(missing)))
@@ -1234,7 +1234,7 @@ def test_binaryop_commute_exists():
     for key in orig_binaryops:
         val = getattr(binary, key)
         commutes_to = val.commutes_to
-        if commutes_to is not None and commutes_to not in vals:  # pragma: no cover
+        if commutes_to is not None and commutes_to not in vals:  # pragma: no cover (debug)
             missing.add(commutes_to.name)
     if missing:
         raise AssertionError("Missing binaryops: " + ", ".join(sorted(missing)))
diff --git a/graphblas/tests/test_operator_types.py b/graphblas/tests/test_operator_types.py
index 588a2bbd6..74a866e16 100644
--- a/graphblas/tests/test_operator_types.py
+++ b/graphblas/tests/test_operator_types.py
@@ -318,7 +318,7 @@ def _run_test(module, typ, expected):
         key = (frozenset(val.types.keys()), frozenset(val.types.values()))
         seen[key].add(name)
     seen = dict(seen)
-    if seen != expected:  # pragma: no cover
+    if seen != expected:  # pragma: no cover (debug)
         seen_names = set()
         for names in seen.values():
             seen_names.update(names)
diff --git a/graphblas/tests/test_pickle.py b/graphblas/tests/test_pickle.py
index 1f81c4166..9cdc15fd1 100644
--- a/graphblas/tests/test_pickle.py
+++ b/graphblas/tests/test_pickle.py
@@ -8,33 +8,40 @@
 
 
 def unarypickle(x):
-    return x + 1  # pragma: no cover
+    return x + 1  # pragma: no cover (numba)
 
 
 def binarypickle(x, y):
-    return x + y  # pragma: no cover
+    return x + y  # pragma: no cover (numba)
 
 
 def unaryanon(x):
-    return x + 2  # pragma: no cover
+    return x + 2  # pragma: no cover (numba)
 
 
 def binaryanon(x, y):
-    return x + y  # pragma: no cover
+    return x + y  # pragma: no cover (numba)
 
 
 def indexunaryanon(x, row, col, thunk):
-    return x >= thunk  # pragma: no cover
+    return x >= thunk  # pragma: no cover (numba)
+
+
+@pytest.fixture
+def extra():
+    if gb.backend == "suitesparse-vanilla":
+        return "-vanilla"
+    return ""
 
 
 @pytest.mark.slow
-def test_deserialize():
+def test_deserialize(extra):
     thisdir = os.path.dirname(__file__)
-    with open(os.path.join(thisdir, "pickle1.pkl"), "rb") as f:
+    with open(os.path.join(thisdir, f"pickle1{extra}.pkl"), "rb") as f:
         d = pickle.load(f)
     check_values(d)
     # Again!
-    with open(os.path.join(thisdir, "pickle1.pkl"), "rb") as f:
+    with open(os.path.join(thisdir, f"pickle1{extra}.pkl"), "rb") as f:
         d = pickle.load(f)
     check_values(d)
 
@@ -120,7 +127,7 @@ def test_serialize():
     }
     try:
         pkl = pickle.dumps(d)
-    except Exception:  # pragma: no cover
+    except Exception:  # pragma: no cover (debug)
         for key, val in d.items():
             try:
                 pickle.dumps(val)
@@ -191,28 +198,28 @@ def check_values(d):
 
 def unarypickle_par(x):
     def inner(y):
-        return x + y  # pragma: no cover
+        return x + y  # pragma: no cover (numba)
 
     return inner
 
 
 def binarypickle_par(z):
     def inner(x, y):
-        return x + y + z  # pragma: no cover
+        return x + y + z  # pragma: no cover (numba)
 
     return inner
 
 
 def unaryanon_par(x):
     def inner(y):
-        return y + x  # pragma: no cover
+        return y + x  # pragma: no cover (numba)
 
     return inner
 
 
 def binaryanon_par(z):
     def inner(x, y):
-        return x + y + z  # pragma: no cover
+        return x + y + z  # pragma: no cover (numba)
 
     return inner
 
@@ -264,7 +271,7 @@ def test_serialize_parameterized():
     }
     try:
         pkl = pickle.dumps(d)
-    except Exception:  # pragma: no cover
+    except Exception:  # pragma: no cover (debug)
         for key, val in d.items():
             try:
                 pickle.dumps(val)
@@ -275,16 +282,16 @@ def test_serialize_parameterized():
 
 
 @pytest.mark.slow
-def test_deserialize_parameterized():
+def test_deserialize_parameterized(extra):
     thisdir = os.path.dirname(__file__)
-    with open(os.path.join(thisdir, "pickle2.pkl"), "rb") as f:
+    with open(os.path.join(thisdir, f"pickle2{extra}.pkl"), "rb") as f:
         pickle.load(f)  # TODO: check results
     # Again!
-    with open(os.path.join(thisdir, "pickle2.pkl"), "rb") as f:
+    with open(os.path.join(thisdir, f"pickle2{extra}.pkl"), "rb") as f:
         pickle.load(f)  # TODO: check results
 
 
-def test_udt():
+def test_udt(extra):
     record_dtype = np.dtype([("x", np.bool_), ("y", np.int64)], align=True)
     udt = gb.dtypes.register_new("PickleUDT", record_dtype)
     assert not udt._is_anonymous
@@ -296,7 +303,7 @@ def test_udt():
     assert pickle.loads(pickle.dumps(udt2)).np_type == udt2.np_type
 
     thisdir = os.path.dirname(__file__)
-    with open(os.path.join(thisdir, "pickle3.pkl"), "rb") as f:
+    with open(os.path.join(thisdir, f"pickle3{extra}.pkl"), "rb") as f:
         d = pickle.load(f)
     udt3 = d["PickledUDT"]
     v = d["v"]
diff --git a/graphblas/tests/test_prefix_scan.py b/graphblas/tests/test_prefix_scan.py
index d0f7e02d8..361dd8ae3 100644
--- a/graphblas/tests/test_prefix_scan.py
+++ b/graphblas/tests/test_prefix_scan.py
@@ -2,18 +2,20 @@
 import pytest
 
 import graphblas as gb
-from graphblas import binary, monoid
+from graphblas import backend, binary, monoid
 
 from graphblas import Matrix, Vector  # isort:skip (for dask-graphblas)
 
 try:
     # gb.io.to_numpy currently requires scipy
     import scipy.sparse as ss
-except ImportError:  # pragma: no cover
+except ImportError:  # pragma: no cover (import)
     ss = None
 
+suitesparse = backend == "suitesparse"
 
-@pytest.mark.skipif("not ss")
+
+@pytest.mark.skipif("not ss or not suitesparse")
 @pytest.mark.parametrize("method", ["scan_rowwise", "scan_columnwise"])
 @pytest.mark.parametrize("length", list(range(34)))
 @pytest.mark.parametrize("do_random", [False, True])
@@ -39,12 +41,12 @@ def test_scan_matrix(method, length, do_random):
     result = gb.io.to_numpy(R)
     try:
         np.testing.assert_array_equal(result, expected)
-    except Exception:  # pragma: no cover
+    except Exception:  # pragma: no cover (debug)
         print(M)
         raise
 
 
-@pytest.mark.skipif("not ss")
+@pytest.mark.skipif("not ss or not suitesparse")
 @pytest.mark.parametrize("length", list(range(34)))
 @pytest.mark.parametrize("do_random", [False, True])
 def test_scan_vector(length, do_random):
@@ -63,11 +65,12 @@ def test_scan_vector(length, do_random):
     result = gb.io.to_numpy(r)
     try:
         np.testing.assert_array_equal(result, expected)
-    except Exception:  # pragma: no cover
+    except Exception:  # pragma: no cover (debug)
         print(v)
         raise
 
 
+@pytest.mark.skipif("not suitesparse")
 def test_cumprod():
     v = Vector.from_values([1, 3, 4, 6], [2, 3, 4, 5])
     expected = Vector.from_values([1, 3, 4, 6], [2, 6, 24, 120])
@@ -75,6 +78,7 @@ def test_cumprod():
     assert r.isequal(expected)
 
 
+@pytest.mark.skipif("not suitesparse")
 def test_bad_scan():
     v = Vector.from_values(range(10), range(10))
     with pytest.raises(TypeError, match="Bad type for argument `op`"):
diff --git a/graphblas/tests/test_pygraphblas.py b/graphblas/tests/test_pygraphblas.py
index 2dff57726..3bb9c2d7c 100644
--- a/graphblas/tests/test_pygraphblas.py
+++ b/graphblas/tests/test_pygraphblas.py
@@ -9,7 +9,7 @@
 
 
 @pytest.mark.skipif("not pg")
-def test_pygraphblas_matrix():  # pragma: no cover
+def test_pygraphblas_matrix():  # pragma: no cover (outdated)
     if gb.backend != "suitesparse":  # pragma: no cover
         return
     import pygraphblas as pg
@@ -25,7 +25,7 @@ def test_pygraphblas_matrix():  # pragma: no cover
 
 
 @pytest.mark.skipif("not pg")
-def test_pygraphblas_vector():  # pragma: no cover
+def test_pygraphblas_vector():  # pragma: no cover (outdated)
     if gb.backend != "suitesparse":  # pragma: no cover
         return
     import pygraphblas as pg
@@ -41,7 +41,7 @@ def test_pygraphblas_vector():  # pragma: no cover
 
 
 @pytest.mark.skipif("not pg")
-def test_pygraphblas_scalar():  # pragma: no cover
+def test_pygraphblas_scalar():  # pragma: no cover (outdated)
     if gb.backend != "suitesparse":  # pragma: no cover
         return
     import pygraphblas as pg
diff --git a/graphblas/tests/test_recorder.py b/graphblas/tests/test_recorder.py
index 0fe02bbcb..3546d9f45 100644
--- a/graphblas/tests/test_recorder.py
+++ b/graphblas/tests/test_recorder.py
@@ -4,6 +4,8 @@
 from graphblas.core.formatting import CSS_STYLE
 from graphblas.exceptions import OutOfMemory
 
+suitesparse = gb.backend == "suitesparse"
+
 
 @pytest.fixture
 def switch():
@@ -221,7 +223,7 @@ def test_record_repr_html(switch):
     except ImportError:
         with pytest.raises(NotImplementedError):
             rec._repr_html_()
-    else:  # pragma: no cover
+    else:  # pragma: no cover (import)
         assert isinstance(rec._repr_html_(), str)
 
 
@@ -238,7 +240,8 @@ def test_record_failed_call():
     assert "ERROR: OutOfMemory" in rec.data[-1]
 
 
-def test_record_inner(switch):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_record_inner(switch):
     v = gb.Vector.from_values([0, 1, 2], 1, size=3)
     with gb.Recorder() as rec:
         (v @ v).new(name="s_0")
diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py
index 17116cd75..58e7280ef 100644
--- a/graphblas/tests/test_scalar.py
+++ b/graphblas/tests/test_scalar.py
@@ -8,12 +8,14 @@
 import pytest
 
 import graphblas as gb
-from graphblas import binary, dtypes, replace
+from graphblas import backend, binary, dtypes, replace
 
 from .conftest import autocompute, compute
 
 from graphblas import Matrix, Scalar, Vector  # isort:skip (for dask-graphblas)
 
+suitesparse = backend == "suitesparse"
+
 
 @pytest.fixture
 def s():
@@ -438,10 +440,15 @@ def test_scalar_expr(s):
 
 
 def test_sizeof(s):
-    assert 1 < sys.getsizeof(s) < 1000
+    if suitesparse or s._is_cscalar:
+        assert 1 < sys.getsizeof(s) < 1000
+    else:
+        with pytest.raises(TypeError):
+            sys.getsizeof(s)
 
 
-def test_concat(s):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_concat(s):
     empty = Scalar(int)
     v = gb.ss.concat([s, s, empty])
     expected = Vector.from_values([0, 1], 5, size=3)
diff --git a/graphblas/tests/test_ss_utils.py b/graphblas/tests/test_ss_utils.py
index 01aef2627..6db577e9e 100644
--- a/graphblas/tests/test_ss_utils.py
+++ b/graphblas/tests/test_ss_utils.py
@@ -3,7 +3,10 @@
 from numpy.testing import assert_array_equal
 
 import graphblas as gb
-from graphblas import Matrix, Vector
+from graphblas import Matrix, Vector, backend
+
+if backend != "suitesparse":
+    pytest.skip("gb.ss and A.ss only available with suitesparse backend", allow_module_level=True)
 
 
 @pytest.mark.parametrize("do_iso", [False, True])
@@ -45,6 +48,11 @@ def test_vector_head(do_iso):
             assert indices.dtype == np.uint64
             assert vals.dtype == expected_dtype
 
+            indices, vals = v3.ss.head(2, sort=False, dtype=dtype)
+            assert indices.size == vals.size == 2
+            assert indices.dtype == np.uint64
+            assert vals.dtype == expected_dtype
+
             indices, vals = v3.ss.head(2, sort=True, dtype=dtype)
             assert_array_equal(indices, [100, 200])
             assert_array_equal(vals, values3[:2])
@@ -161,6 +169,11 @@ def test_matrix_head(do_iso):
             assert rows.dtype == cols.dtype == np.uint64
             assert vals.dtype == expected_dtype
 
+            rows, cols, vals = A8.ss.head(2, sort=False, dtype=dtype)
+            assert rows.size == cols.size == vals.size == 2
+            assert rows.dtype == cols.dtype == np.uint64
+            assert vals.dtype == expected_dtype
+
             rows, cols, vals = A8.ss.head(2, sort=True, dtype=dtype)
             assert_array_equal(rows, [400, 500])
             assert_array_equal(cols, [500, 500])
diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py
index 3c21d0968..f6b84101c 100644
--- a/graphblas/tests/test_vector.py
+++ b/graphblas/tests/test_vector.py
@@ -8,8 +8,8 @@
 import pytest
 from numpy.testing import assert_array_equal
 
-import graphblas
-from graphblas import agg, binary, dtypes, indexunary, monoid, select, semiring, unary
+import graphblas as gb
+from graphblas import agg, backend, binary, dtypes, indexunary, monoid, select, semiring, unary
 from graphblas.exceptions import (
     DimensionMismatch,
     EmptyObject,
@@ -24,6 +24,9 @@
 from graphblas import Matrix, Scalar, Vector  # isort:skip (for dask-graphblas)
 
 
+suitesparse = backend == "suitesparse"
+
+
 @pytest.fixture
 def A():
     data = [
@@ -137,7 +140,7 @@ def test_from_values_scalar():
     assert u.size == 4
     assert u.nvals == 3
     assert u.dtype == dtypes.INT64
-    if hasattr(u, "ss"):  # pragma: no branch
+    if suitesparse:
         assert u.ss.is_iso
         assert u.ss.iso_value == 7
     assert u.reduce(monoid.any).new() == 7
@@ -146,15 +149,16 @@ def test_from_values_scalar():
     u = Vector.from_values([0, 1, 1, 3], 7)
     assert u.size == 4
     assert u.nvals == 3
-    if hasattr(u, "ss"):  # pragma: no branch
+    if suitesparse:
         assert u.ss.is_iso
         assert u.ss.iso_value == 7
     assert u.reduce(monoid.any).new() == 7
     with pytest.raises(ValueError, match="dup_op must be None"):
         Vector.from_values([0, 1, 1, 3], 7, dup_op=binary.plus)
     u[0] = 0
-    with pytest.raises(ValueError, match="not iso"):
-        u.ss.iso_value
+    if suitesparse:
+        with pytest.raises(ValueError, match="not iso"):
+            u.ss.iso_value
 
 
 def test_clear(v):
@@ -202,7 +206,8 @@ def test_build(v):
     assert w.isequal(Vector.from_values([0, 11], [1, 1]))
 
 
-def test_build_scalar(v):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_build_scalar(v):
     with pytest.raises(OutputNotEmpty):
         v.ss.build_scalar([1, 5], 3)
     v.clear()
@@ -722,6 +727,9 @@ def test_select(v):
     assert w6.isequal(result)
     with pytest.raises(TypeError, match="thunk"):
         v.select(select.valueeq, object())
+    w7 = v.select("==").new()
+    expected = Vector.from_values([6], 0)
+    assert w7.isequal(expected)
 
 
 @autocompute
@@ -776,7 +784,7 @@ def test_select_bools_and_masks(v):
 
 @pytest.mark.slow
 def test_indexunary_udf(v):
-    def twox_minusthunk(x, row, col, thunk):  # pragma: no cover
+    def twox_minusthunk(x, row, col, thunk):  # pragma: no cover (numba)
         return 2 * x - thunk
 
     indexunary.register_new("twox_minusthunk", twox_minusthunk)
@@ -791,7 +799,7 @@ def twox_minusthunk(x, row, col, thunk):  # pragma: no cover
     assert result.isequal(expected)
     delattr(indexunary, "twox_minusthunk")
 
-    def ii(x, idx, _, thunk):  # pragma: no cover
+    def ii(x, idx, _, thunk):  # pragma: no cover (numba)
         return idx // 2 >= thunk
 
     select.register_new("ii", ii)
@@ -1078,7 +1086,7 @@ def test_del(capsys):
     # v has `gb_obj` of NULL
     v = Vector.from_values([0, 1], [0, 1])
     gb_obj = v.gb_obj
-    v.gb_obj = graphblas.core.NULL
+    v.gb_obj = gb.core.NULL
     del v
     # let's clean up so we don't have a memory leak
     v2 = object.__new__(Vector)
@@ -1090,9 +1098,10 @@ def test_del(capsys):
     assert not captured.err
 
 
+@pytest.mark.skipif("not suitesparse")
 @pytest.mark.parametrize("do_iso", [False, True])
 @pytest.mark.parametrize("methods", [("export", "import"), ("unpack", "pack")])
-def test_import_export(v, do_iso, methods):
+def test_ss_import_export(v, do_iso, methods):
     if do_iso:
         v(v.S) << 1
     v1 = v.dup()
@@ -1232,9 +1241,10 @@ def test_import_export(v, do_iso, methods):
         Vector.ss.import_any(take_ownership=True, **d)
 
 
+@pytest.mark.skipif("not suitesparse")
 @pytest.mark.parametrize("do_iso", [False, True])
 @pytest.mark.parametrize("methods", [("export", "import"), ("unpack", "pack")])
-def test_import_export_auto(v, do_iso, methods):
+def test_ss_import_export_auto(v, do_iso, methods):
     if do_iso:
         v(v.S) << 1
     v_orig = v.dup()
@@ -1381,27 +1391,37 @@ def test_vector_index_with_scalar():
 def test_diag(v):
     indices, values = v.to_values()
     for k in range(-5, 5):
-        # Construct diagonal matrix A
-        A = graphblas.ss.diag(v, k=k)
         size = v.size + abs(k)
         rows = indices + max(0, -k)
         cols = indices + max(0, k)
         expected = Matrix.from_values(rows, cols, values, nrows=size, ncols=size, dtype=v.dtype)
-        assert expected.isequal(A)
+        # Construct diagonal matrix A
+        if suitesparse:
+            A = gb.ss.diag(v, k=k)
+            assert expected.isequal(A)
         A = v.diag(k)
         assert expected.isequal(A)
 
         # Extract diagonal from A
-        w = graphblas.ss.diag(A, Scalar.from_value(k))
+        if suitesparse:
+            w = gb.ss.diag(A, Scalar.from_value(k))
+            assert v.isequal(w)
+            assert w.dtype == "INT64"
+
+            w = gb.ss.diag(A.T, -k, dtype=float)
+            assert v.isequal(w)
+            assert w.dtype == "FP64"
+        w = A.diag(Scalar.from_value(k))
         assert v.isequal(w)
         assert w.dtype == "INT64"
 
-        w = graphblas.ss.diag(A.T, -k, dtype=float)
+        w = A.T.diag(-k, dtype=float)
         assert v.isequal(w)
         assert w.dtype == "FP64"
 
 
-def test_nbytes(v):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_nbytes(v):
     assert v.ss.nbytes > 0
 
 
@@ -1621,13 +1641,14 @@ def test_index_expr_is_like_vector(v):
     )
 
 
-def test_random(v):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_random(v):
     r1 = Vector.from_values([1], [1], size=v.size)
     r2 = Vector.from_values([3], [1], size=v.size)
     r3 = Vector.from_values([4], [2], size=v.size)
     r4 = Vector.from_values([6], [0], size=v.size)
     seen = set()
-    for _i in range(1000000):  # pragma: no branch
+    for _i in range(1000000):  # pragma: no branch (sanity)
         r = v.ss.selectk("random", 1)
         if r.isequal(r1):
             seen.add("r1")
@@ -1637,7 +1658,7 @@ def test_random(v):
             seen.add("r3")
         elif r.isequal(r4):
             seen.add("r4")
-        else:  # pragma: no cover
+        else:  # pragma: no cover (sanity)
             raise AssertionError()
         if len(seen) == 4:
             break
@@ -1655,7 +1676,8 @@ def test_random(v):
         v.ss.selectk("bad", 1)
 
 
-def test_firstk(v):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_firstk(v):
     mixed_data = [[1, 3, 4, 6], [1, 1, 2, 0]]
     iso_data = [[1, 3, 4, 6], [1, 1, 1, 1]]
     iso_v = v.dup()
@@ -1669,7 +1691,8 @@ def test_firstk(v):
         v.ss.selectk("first", -1)
 
 
-def test_lastk(v):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_lastk(v):
     mixed_data = [[1, 3, 4, 6], [1, 1, 2, 0]]
     iso_data = [[1, 3, 4, 6], [1, 1, 1, 1]]
     iso_v = v.dup()
@@ -1681,7 +1704,8 @@ def test_lastk(v):
             assert x.isequal(expected)
 
 
-def test_largestk(v):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_largestk(v):
     w = v.ss.selectk("largest", 1)
     expected = Vector.from_values([4], [2], size=v.size)
     assert w.isequal(expected)
@@ -1696,7 +1720,8 @@ def test_largestk(v):
     assert w.isequal(expected)
 
 
-def test_smallestk(v):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_smallestk(v):
     w = v.ss.selectk("smallest", 1)
     expected = Vector.from_values([6], [0], size=v.size)
     assert w.isequal(expected)
@@ -1711,8 +1736,9 @@ def test_smallestk(v):
     assert w.isequal(expected)
 
 
+@pytest.mark.skipif("not suitesparse")
 @pytest.mark.parametrize("do_iso", [False, True])
-def test_compactify(do_iso):
+def test_ss_compactify(do_iso):
     orig_indices = [1, 3, 4, 6]
     new_indices = [0, 1, 2, 3]
     if do_iso:
@@ -1823,23 +1849,25 @@ def test_slice():
     assert w.isequal(expected)
 
 
-def test_concat(v):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_concat(v):
     expected = Vector(v.dtype, size=2 * v.size)
     expected[: v.size] = v
     expected[v.size :] = v
-    w1 = graphblas.ss.concat([v, v])
+    w1 = gb.ss.concat([v, v])
     assert w1.isequal(expected, check_dtype=True)
     w2 = Vector(v.dtype, size=2 * v.size)
     w2.ss.concat([v, v])
     assert w2.isequal(expected, check_dtype=True)
     with pytest.raises(TypeError):
         w2.ss.concat([[v, v]])
-    w3 = graphblas.ss.concat([v, v], dtype=float)
+    w3 = gb.ss.concat([v, v], dtype=float)
     assert w3.isequal(expected)
     assert w3.dtype == float
 
 
-def test_split(v):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_split(v):
     w1, w2 = v.ss.split(4)
     expected1 = Vector.from_values([1, 3], 1)
     expected2 = Vector.from_values([0, 2], [2, 0])
@@ -1860,7 +1888,11 @@ def test_ndim(A, v):
 
 
 def test_sizeof(v):
-    assert sys.getsizeof(v) > v.nvals * 16
+    if suitesparse:
+        assert sys.getsizeof(v) > v.nvals * 16
+    else:
+        with pytest.raises(TypeError):
+            sys.getsizeof(v)
 
 
 def test_ewise_union():
@@ -1958,18 +1990,20 @@ def test_udt():
     assert v.reduce(agg.first_index).new() == 0
     assert v.reduce(agg.last_index).new() == 2
     assert v.reduce(agg.count).new() == 3
-    info = v.ss.export()
-    result = Vector.ss.import_any(**info)
-    assert result.isequal(v)
+    if suitesparse:
+        info = v.ss.export()
+        result = Vector.ss.import_any(**info)
+        assert result.isequal(v)
     for aggop in [agg.any_value, agg.first, agg.last, agg.count, agg.first_index, agg.last_index]:
         v.reduce(aggop).new()
     v.clear()
     v[[0, 1]] = [(2, 3), (4, 5)]
     expected = Vector.from_values([0, 1], [(2, 3), (4, 5)], dtype=udt, size=v.size)
-    vv = Vector.ss.deserialize(v.ss.serialize())
-    assert v.isequal(vv, check_dtype=True)
-    vv = Vector.ss.deserialize(v.ss.serialize(), dtype=v.dtype)
-    assert v.isequal(vv, check_dtype=True)
+    if suitesparse:
+        vv = Vector.ss.deserialize(v.ss.serialize())
+        assert v.isequal(vv, check_dtype=True)
+        vv = Vector.ss.deserialize(v.ss.serialize(), dtype=v.dtype)
+        assert v.isequal(vv, check_dtype=True)
 
     # arrays as dtypes!
     np_dtype = np.dtype("(3,)uint16")
@@ -2005,9 +2039,10 @@ def test_udt():
     assert_array_equal(values, np.ones((2, 3), dtype=np.uint16))
     assert v.isequal(Vector.from_values(indices, values, dtype=v.dtype))
     assert v.isequal(Vector.from_values(indices, values))
-    info = v.ss.export()
-    result = Vector.ss.import_any(**info)
-    assert result.isequal(v)
+    if suitesparse:
+        info = v.ss.export()
+        result = Vector.ss.import_any(**info)
+        assert result.isequal(v)
 
     s = v.reduce(monoid.any).new()
     assert (s.value == [1, 1, 1]).all()
@@ -2027,29 +2062,39 @@ def test_udt():
     v[[0, 1]] = [[2, 3, 4], [5, 6, 7]]
     expected = Vector.from_values([0, 1], [[2, 3, 4], [5, 6, 7]], dtype=udt2, size=v.size)
     assert v.isequal(expected)
-    vv = Vector.ss.deserialize(v.ss.serialize())
-    assert v.isequal(vv, check_dtype=True)
+    if suitesparse:
+        vv = Vector.ss.deserialize(v.ss.serialize())
+        assert v.isequal(vv, check_dtype=True)
 
     long_dtype = np.dtype([("x", np.bool_), ("y" * 1000, np.float64)], align=True)
-    with pytest.warns(UserWarning, match="too large"):
+    if suitesparse:
+        with pytest.warns(UserWarning, match="too large"):
+            long_udt = dtypes.register_anonymous(long_dtype)
+    else:
+        # UDTs don't currently have a name in vanilla GraphBLAS
         long_udt = dtypes.register_anonymous(long_dtype)
     v = Vector(long_udt, size=3)
     v[0] = 0
     v[1] = (1, 5.6)
-    vv = Vector.ss.deserialize(v.ss.serialize(), dtype=long_udt)
-    assert v.isequal(vv, check_dtype=True)
-    with pytest.raises(Exception):
-        # The size of the UDT name is limited
-        Vector.ss.deserialize(v.ss.serialize())
+    if suitesparse:
+        vv = Vector.ss.deserialize(v.ss.serialize(), dtype=long_udt)
+        assert v.isequal(vv, check_dtype=True)
+        with pytest.raises(Exception):
+            # The size of the UDT name is limited
+            Vector.ss.deserialize(v.ss.serialize())
     # May be able to look up non-anonymous dtypes by name if their names are too long
     named_long_dtype = np.dtype([("x", np.bool_), ("y" * 1000, np.float64)], align=False)
-    with pytest.warns(UserWarning, match="too large"):
+    if suitesparse:
+        with pytest.warns(UserWarning, match="too large"):
+            named_long_udt = dtypes.register_new("LongUDT", named_long_dtype)
+    else:
         named_long_udt = dtypes.register_new("LongUDT", named_long_dtype)
     v = Vector(named_long_udt, size=3)
     v[0] = 0
     v[1] = (1, 5.6)
-    vv = Vector.ss.deserialize(v.ss.serialize())
-    assert v.isequal(vv, check_dtype=True)
+    if suitesparse:
+        vv = Vector.ss.deserialize(v.ss.serialize())
+        assert v.isequal(vv, check_dtype=True)
 
 
 def test_infix_outer():
@@ -2077,7 +2122,8 @@ def test_infix_outer():
     assert w.reduce(binary.plus[int]).new() == 2
 
 
-def test_iteration(v):
+@pytest.mark.skipif("not suitesparse")
+def test_ss_iteration(v):
     w = Vector(int, 2)
     assert list(w.ss.iterkeys()) == []
     assert list(w.ss.itervalues()) == []
@@ -2253,17 +2299,17 @@ def test_to_values_subset(v):
 
 
 def test_lambda_udfs(v):
-    result = v.apply(lambda x: x + 1).new()  # pragma: no branch
+    result = v.apply(lambda x: x + 1).new()  # pragma: no branch (numba)
     expected = binary.plus(v, 1).new()
     assert result.isequal(expected)
-    result = v.ewise_mult(v, lambda x, y: x + y).new()  # pragma: no branch
+    result = v.ewise_mult(v, lambda x, y: x + y).new()  # pragma: no branch (numba)
     expected = binary.plus(v & v).new()
     assert result.isequal(expected)
-    result = v.ewise_add(v, lambda x, y: x + y).new()  # pragma: no branch
+    result = v.ewise_add(v, lambda x, y: x + y).new()  # pragma: no branch (numba)
     assert result.isequal(expected)
     # Should we also allow lambdas for monoids with assumed identity of 0?
     # with pytest.raises(TypeError):
-    v.ewise_add(v, lambda x, y: x + y)  # pragma: no branch
+    v.ewise_add(v, lambda x, y: x + y)  # pragma: no branch (numba)
     with pytest.raises(TypeError):
         v.inner(v, lambda x, y: x + y)
 
@@ -2279,6 +2325,7 @@ def test_get(v):
         v.get([0, 1])
 
 
+@pytest.mark.skipif("not suitesparse")
 def test_ss_serialize(v):
     for compression, level, nthreads in itertools.product(
         [None, "none", "default", "lz4", "lz4hc", "zstd"], [None, 1, 5, 9], [None, -1, 1, 10]
@@ -2305,6 +2352,7 @@ def test_ss_serialize(v):
         Vector.ss.deserialize(a[:-5])
 
 
+@pytest.mark.skipif("not suitesparse")
 def test_ss_config(v):
     d = {}
     for key in v.ss.config: