`__.
+
"""
how = how.lower()
if how == "materialize":
@@ -477,6 +501,7 @@ def get(self, default=None):
Returns
-------
Python scalar
+
"""
return default if self._is_empty else self.value
@@ -500,6 +525,7 @@ def from_value(cls, value, dtype=None, *, is_cscalar=False, name=None):
Returns
-------
Scalar
+
"""
typ = output_type(value)
if dtype is None:
@@ -609,8 +635,25 @@ def ewise_add(self, other, op=monoid.plus):
# Functional syntax
c << monoid.max(a | b)
+
"""
+ return self._ewise_add(other, op)
+
+ def _ewise_add(self, other, op=monoid.plus, is_infix=False):
method_name = "ewise_add"
+ if is_infix:
+ from .infix import ScalarEwiseAddExpr
+
+ # This is a little different than how we handle ewise_add for Vector and
+ # Matrix where we are super-careful to handle dtypes well to support UDTs.
+ # For Scalar, we're going to let dtypes in expressions resolve themselves.
+ # Scalars are more challenging, because they may be literal scalars.
+ # Also, we have not yet resolved `op` here, so errors may be different.
+ if isinstance(self, ScalarEwiseAddExpr):
+ self = op(self).new()
+ if isinstance(other, ScalarEwiseAddExpr):
+ other = op(other).new()
+
if type(other) is not Scalar:
dtype = self.dtype if self.dtype._is_udt else None
try:
@@ -663,8 +706,25 @@ def ewise_mult(self, other, op=binary.times):
# Functional syntax
c << binary.gt(a & b)
+
"""
+ return self._ewise_mult(other, op)
+
+ def _ewise_mult(self, other, op=binary.times, is_infix=False):
method_name = "ewise_mult"
+ if is_infix:
+ from .infix import ScalarEwiseMultExpr
+
+ # This is a little different than how we handle ewise_mult for Vector and
+ # Matrix where we are super-careful to handle dtypes well to support UDTs.
+ # For Scalar, we're going to let dtypes in expressions resolve themselves.
+ # Scalars are more challenging, because they may be literal scalars.
+ # Also, we have not yet resolved `op` here, so errors may be different.
+ if isinstance(self, ScalarEwiseMultExpr):
+ self = op(self).new()
+ if isinstance(other, ScalarEwiseMultExpr):
+ other = op(other).new()
+
if type(other) is not Scalar:
dtype = self.dtype if self.dtype._is_udt else None
try:
@@ -721,9 +781,27 @@ def ewise_union(self, other, op, left_default, right_default):
# Functional syntax
c << binary.div(a | b, left_default=1, right_default=1)
+
"""
+ return self._ewise_union(other, op, left_default, right_default)
+
+ def _ewise_union(self, other, op, left_default, right_default, is_infix=False):
method_name = "ewise_union"
- dtype = self.dtype if self.dtype._is_udt else None
+ if is_infix:
+ from .infix import ScalarEwiseAddExpr
+
+ # This is a little different than how we handle ewise_union for Vector and
+ # Matrix where we are super-careful to handle dtypes well to support UDTs.
+ # For Scalar, we're going to let dtypes in expressions resolve themselves.
+ # Scalars are more challenging, because they may be literal scalars.
+ # Also, we have not yet resolved `op` here, so errors may be different.
+ if isinstance(self, ScalarEwiseAddExpr):
+ self = op(self, left_default=left_default, right_default=right_default).new()
+ if isinstance(other, ScalarEwiseAddExpr):
+ other = op(other, left_default=left_default, right_default=right_default).new()
+
+ right_dtype = self.dtype
+ dtype = right_dtype if right_dtype._is_udt else None
if type(other) is not Scalar:
try:
other = Scalar.from_value(other, dtype, is_cscalar=False, name="")
@@ -736,6 +814,13 @@ def ewise_union(self, other, op, left_default, right_default):
extra_message="Literal scalars also accepted.",
op=op,
)
+ else:
+ other = _as_scalar(other, dtype, is_cscalar=False) # pragma: is_grbscalar
+
+ temp_op = get_typed_op(op, self.dtype, other.dtype, kind="binary")
+
+ left_dtype = temp_op.type
+ dtype = left_dtype if left_dtype._is_udt else None
if type(left_default) is not Scalar:
try:
left = Scalar.from_value(
@@ -752,6 +837,8 @@ def ewise_union(self, other, op, left_default, right_default):
)
else:
left = _as_scalar(left_default, dtype, is_cscalar=False) # pragma: is_grbscalar
+ right_dtype = temp_op.type2
+ dtype = right_dtype if right_dtype._is_udt else None
if type(right_default) is not Scalar:
try:
right = Scalar.from_value(
@@ -768,9 +855,15 @@ def ewise_union(self, other, op, left_default, right_default):
)
else:
right = _as_scalar(right_default, dtype, is_cscalar=False) # pragma: is_grbscalar
- defaults_dtype = unify(left.dtype, right.dtype)
- args_dtype = unify(self.dtype, other.dtype)
- op = get_typed_op(op, defaults_dtype, args_dtype, kind="binary")
+
+ op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary")
+ op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary")
+ if op1 is not op2:
+ left_dtype = unify(op1.type, op2.type, is_right_scalar=True)
+ right_dtype = unify(op1.type2, op2.type2, is_left_scalar=True)
+ op = get_typed_op(op, left_dtype, right_dtype, kind="binary")
+ else:
+ op = op1
self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op")
if op.opclass == "Monoid":
op = op.binaryop
@@ -786,11 +879,10 @@ def ewise_union(self, other, op, left_default, right_default):
scalar_as_vector=True,
)
else:
- dtype = unify(defaults_dtype, args_dtype)
expr = ScalarExpression(
method_name,
None,
- [self, left, other, right, _s_union_s, (self, other, left, right, op, dtype)],
+ [self, left, other, right, _s_union_s, (self, other, left, right, op)],
op=op,
expr_repr=expr_repr,
is_cscalar=False,
@@ -835,6 +927,7 @@ def apply(self, op, right=None, *, left=None):
# Functional syntax
b << op.abs(a)
+
"""
expr = self._as_vector().apply(op, right, left=left)
return ScalarExpression(
@@ -1041,7 +1134,7 @@ def _as_scalar(scalar, dtype=None, *, is_cscalar):
def _dict_to_record(np_type, d):
- """Converts e.g. `{"x": 1, "y": 2.3}` to `(1, 2.3)`."""
+ """Converts e.g. ``{"x": 1, "y": 2.3}`` to ``(1, 2.3)``."""
rv = []
for name, (dtype, _) in np_type.fields.items():
val = d[name]
diff --git a/graphblas/core/ss/__init__.py b/graphblas/core/ss/__init__.py
index e69de29bb..10a6fed94 100644
--- a/graphblas/core/ss/__init__.py
+++ b/graphblas/core/ss/__init__.py
@@ -0,0 +1,5 @@
+import suitesparse_graphblas as _ssgb
+
+(version_major, version_minor, version_bug) = map(int, _ssgb.__version__.split(".")[:3])
+
+_IS_SSGB7 = version_major == 7
diff --git a/graphblas/core/ss/binary.py b/graphblas/core/ss/binary.py
new file mode 100644
index 000000000..d53608818
--- /dev/null
+++ b/graphblas/core/ss/binary.py
@@ -0,0 +1,128 @@
+from ... import backend
+from ...dtypes import lookup_dtype
+from ...exceptions import check_status_carg
+from .. import NULL, ffi, lib
+from ..operator.base import TypedOpBase
+from ..operator.binary import BinaryOp, TypedUserBinaryOp
+from . import _IS_SSGB7
+
+ffi_new = ffi.new
+
+
+class TypedJitBinaryOp(TypedOpBase):
+ __slots__ = "_monoid", "_jit_c_definition"
+ opclass = "BinaryOp"
+
+ def __init__(self, parent, name, type_, return_type, gb_obj, jit_c_definition, dtype2=None):
+ super().__init__(parent, name, type_, return_type, gb_obj, name, dtype2=dtype2)
+ self._monoid = None
+ self._jit_c_definition = jit_c_definition
+
+ @property
+ def jit_c_definition(self):
+ return self._jit_c_definition
+
+ monoid = TypedUserBinaryOp.monoid
+ commutes_to = TypedUserBinaryOp.commutes_to
+ _semiring_commutes_to = TypedUserBinaryOp._semiring_commutes_to
+ is_commutative = TypedUserBinaryOp.is_commutative
+ type2 = TypedUserBinaryOp.type2
+ __call__ = TypedUserBinaryOp.__call__
+
+
+def register_new(name, jit_c_definition, left_type, right_type, ret_type):
+ """Register a new BinaryOp using the SuiteSparse:GraphBLAS JIT compiler.
+
+ This creates a BinaryOp by compiling the C string definition of the function.
+ It requires a shell call to a C compiler. The resulting operator will be as
+ fast as if it were built-in to SuiteSparse:GraphBLAS and does not have the
+ overhead of additional function calls as when using ``gb.binary.register_new``.
+
+ This is an advanced feature that requires a C compiler and proper configuration.
+ Configuration is handled by ``gb.ss.config``; see its docstring for details.
+ By default, the JIT caches results in ``~/.SuiteSparse/``. For more information,
+ see the SuiteSparse:GraphBLAS user guide.
+
+ Only one type signature may be registered at a time, but repeated calls using
+ the same name with different input types is allowed.
+
+ Parameters
+ ----------
+ name : str
+ The name of the operator. This will show up as ``gb.binary.ss.{name}``.
+ The name may contain periods, ".", which will result in nested objects
+ such as ``gb.binary.ss.x.y.z`` for name ``"x.y.z"``.
+ jit_c_definition : str
+ The C definition as a string of the user-defined function. For example:
+ ``"void absdiff (double *z, double *x, double *y) { (*z) = fabs ((*x) - (*y)) ; }"``.
+ left_type : dtype
+ The dtype of the left operand of the binary operator.
+ right_type : dtype
+ The dtype of the right operand of the binary operator.
+ ret_type : dtype
+ The dtype of the result of the binary operator.
+
+ Returns
+ -------
+ BinaryOp
+
+ See Also
+ --------
+ gb.binary.register_new
+ gb.binary.register_anonymous
+ gb.unary.ss.register_new
+
+ """
+ if backend != "suitesparse": # pragma: no cover (safety)
+ raise RuntimeError(
+ "`gb.binary.ss.register_new` invalid when not using 'suitesparse' backend"
+ )
+ if _IS_SSGB7:
+ # JIT was introduced in SuiteSparse:GraphBLAS 8.0
+ import suitesparse_graphblas as ssgb
+
+ raise RuntimeError(
+ "JIT was added to SuiteSparse:GraphBLAS in version 8; "
+ f"current version is {ssgb.__version__}"
+ )
+ left_type = lookup_dtype(left_type)
+ right_type = lookup_dtype(right_type)
+ ret_type = lookup_dtype(ret_type)
+ name = name if name.startswith("ss.") else f"ss.{name}"
+ module, funcname = BinaryOp._remove_nesting(name, strict=False)
+ if hasattr(module, funcname):
+ rv = getattr(module, funcname)
+ if not isinstance(rv, BinaryOp):
+ BinaryOp._remove_nesting(name)
+ if (
+ (left_type, right_type) in rv.types
+ or rv._udt_types is not None
+ and (left_type, right_type) in rv._udt_types
+ ):
+ raise TypeError(
+ f"BinaryOp gb.binary.{name} already defined for "
+ f"({left_type}, {right_type}) input types"
+ )
+ else:
+ # We use `is_udt=True` to make dtype handling flexible and explicit.
+ rv = BinaryOp(name, is_udt=True)
+ gb_obj = ffi_new("GrB_BinaryOp*")
+ check_status_carg(
+ lib.GxB_BinaryOp_new(
+ gb_obj,
+ NULL,
+ ret_type._carg,
+ left_type._carg,
+ right_type._carg,
+ ffi_new("char[]", funcname.encode()),
+ ffi_new("char[]", jit_c_definition.encode()),
+ ),
+ "BinaryOp",
+ gb_obj[0],
+ )
+ op = TypedJitBinaryOp(
+ rv, funcname, left_type, ret_type, gb_obj[0], jit_c_definition, dtype2=right_type
+ )
+ rv._add(op, is_jit=True)
+ setattr(module, funcname, rv)
+ return rv
diff --git a/graphblas/core/ss/config.py b/graphblas/core/ss/config.py
index ca91cc198..70a7dd196 100644
--- a/graphblas/core/ss/config.py
+++ b/graphblas/core/ss/config.py
@@ -1,10 +1,9 @@
from collections.abc import MutableMapping
-from numbers import Integral
from ...dtypes import lookup_dtype
from ...exceptions import _error_code_lookup, check_status
from .. import NULL, ffi, lib
-from ..utils import values_to_numpy_buffer
+from ..utils import maybe_integral, values_to_numpy_buffer
class BaseConfig(MutableMapping):
@@ -12,6 +11,9 @@ class BaseConfig(MutableMapping):
# Subclasses should redefine these
_get_function = None
_set_function = None
+ _context_get_function = "GxB_Context_get"
+ _context_set_function = "GxB_Context_set"
+ _context_keys = set()
_null_valid = {}
_options = {}
_defaults = {}
@@ -28,7 +30,7 @@ class BaseConfig(MutableMapping):
"GxB_Format_Value",
}
- def __init__(self, parent=None):
+ def __init__(self, parent=None, context=None):
cls = type(self)
if not cls._initialized:
cls._reverse_enumerations = {}
@@ -51,6 +53,7 @@ def __init__(self, parent=None):
rd[k] = k
cls._initialized = True
self._parent = parent
+ self._context = context
def __delitem__(self, key):
raise TypeError("Configuration options can't be deleted.")
@@ -61,19 +64,27 @@ def __getitem__(self, key):
raise KeyError(key)
key_obj, ctype = self._options[key]
is_bool = ctype == "bool"
+ if is_context := (key in self._context_keys):
+ get_function_base = self._context_get_function
+ else:
+ get_function_base = self._get_function
if ctype in self._int32_ctypes:
ctype = "int32_t"
- get_function_name = f"{self._get_function}_INT32"
+ get_function_name = f"{get_function_base}_INT32"
elif ctype.startswith("int64_t"):
- get_function_name = f"{self._get_function}_INT64"
+ get_function_name = f"{get_function_base}_INT64"
elif ctype.startswith("double"):
- get_function_name = f"{self._get_function}_FP64"
+ get_function_name = f"{get_function_base}_FP64"
+ elif ctype.startswith("char"):
+ get_function_name = f"{get_function_base}_CHAR"
else: # pragma: no cover (sanity)
raise ValueError(ctype)
get_function = getattr(lib, get_function_name)
is_array = "[" in ctype
val_ptr = ffi.new(ctype if is_array else f"{ctype}*")
- if self._parent is None:
+ if is_context:
+ info = get_function(self._context._carg, key_obj, val_ptr)
+ elif self._parent is None:
info = get_function(key_obj, val_ptr)
else:
info = get_function(self._parent._carg, key_obj, val_ptr)
@@ -88,11 +99,13 @@ def __getitem__(self, key):
return {reverse_bitwise[val]}
rv = set()
for k, v in self._bitwise[key].items():
- if isinstance(k, str) and val & v and bin(v).count("1") == 1:
+ if isinstance(k, str) and val & v and (v).bit_count() == 1:
rv.add(k)
return rv
if is_bool:
return bool(val_ptr[0])
+ if ctype.startswith("char"):
+ return ffi.string(val_ptr[0]).decode()
return val_ptr[0]
raise _error_code_lookup[info](f"Failed to get info for {key!r}") # pragma: no cover
@@ -103,15 +116,21 @@ def __setitem__(self, key, val):
if key in self._read_only:
raise ValueError(f"Config option {key!r} is read-only")
key_obj, ctype = self._options[key]
+ if is_context := (key in self._context_keys):
+ set_function_base = self._context_set_function
+ else:
+ set_function_base = self._set_function
if ctype in self._int32_ctypes:
ctype = "int32_t"
- set_function_name = f"{self._set_function}_INT32"
+ set_function_name = f"{set_function_base}_INT32"
elif ctype == "double":
- set_function_name = f"{self._set_function}_FP64"
+ set_function_name = f"{set_function_base}_FP64"
elif ctype.startswith("int64_t["):
- set_function_name = f"{self._set_function}_INT64_ARRAY"
+ set_function_name = f"{set_function_base}_INT64_ARRAY"
elif ctype.startswith("double["):
- set_function_name = f"{self._set_function}_FP64_ARRAY"
+ set_function_name = f"{set_function_base}_FP64_ARRAY"
+ elif ctype.startswith("char"):
+ set_function_name = f"{set_function_base}_CHAR"
else: # pragma: no cover (sanity)
raise ValueError(ctype)
set_function = getattr(lib, set_function_name)
@@ -127,8 +146,8 @@ def __setitem__(self, key, val):
bitwise = self._bitwise[key]
if isinstance(val, str):
val = bitwise[val.lower()]
- elif isinstance(val, Integral):
- val = bitwise.get(val, val)
+ elif (x := maybe_integral(val)) is not None:
+ val = bitwise.get(x, x)
else:
bits = 0
for x in val:
@@ -154,9 +173,19 @@ def __setitem__(self, key, val):
f"expected {size}, got {vals.size}: {val}"
)
val_obj = ffi.from_buffer(ctype, vals)
+ elif ctype.startswith("char"):
+ val_obj = ffi.new("char[]", val.encode())
else:
val_obj = ffi.cast(ctype, val)
- if self._parent is None:
+ if is_context:
+ if self._context is None:
+ from .context import Context
+
+ self._context = Context(engage=False)
+ self._context._engage() # Disengage when context goes out of scope
+ self._parent._context = self._context # Set context to descriptor
+ info = set_function(self._context._carg, key_obj, val_obj)
+ elif self._parent is None:
info = set_function(key_obj, val_obj)
else:
info = set_function(self._parent._carg, key_obj, val_obj)
@@ -174,7 +203,12 @@ def __len__(self):
return len(self._options)
def __repr__(self):
- return "{" + ",\n ".join(f"{k!r}: {v!r}" for k, v in self.items()) + "}"
+ return (
+ type(self).__name__
+ + "({"
+ + ",\n ".join(f"{k!r}: {v!r}" for k, v in self.items())
+ + "})"
+ )
def _ipython_key_completions_(self): # pragma: no cover (ipython)
return list(self)
diff --git a/graphblas/core/ss/context.py b/graphblas/core/ss/context.py
new file mode 100644
index 000000000..f93d1ec1c
--- /dev/null
+++ b/graphblas/core/ss/context.py
@@ -0,0 +1,147 @@
+import threading
+
+from ...exceptions import InvalidValue, check_status, check_status_carg
+from .. import ffi, lib
+from . import _IS_SSGB7
+from .config import BaseConfig
+
+ffi_new = ffi.new
+if _IS_SSGB7:
+ # Context was introduced in SuiteSparse:GraphBLAS 8.0
+ import suitesparse_graphblas as ssgb
+
+ raise ImportError(
+ "Context was added to SuiteSparse:GraphBLAS in version 8; "
+ f"current version is {ssgb.__version__}"
+ )
+
+
+class Context(BaseConfig):
+ _context_keys = {"chunk", "gpu_id", "nthreads"}
+ _options = {
+ "chunk": (lib.GxB_CONTEXT_CHUNK, "double"),
+ "gpu_id": (lib.GxB_CONTEXT_GPU_ID, "int"),
+ "nthreads": (lib.GxB_CONTEXT_NTHREADS, "int"),
+ }
+ _defaults = {
+ "nthreads": 0,
+ "chunk": 0,
+ "gpu_id": -1, # -1 means no GPU
+ }
+
+ def __init__(self, engage=True, *, stack=True, nthreads=None, chunk=None, gpu_id=None):
+ super().__init__()
+ self.gb_obj = ffi_new("GxB_Context*")
+ check_status_carg(lib.GxB_Context_new(self.gb_obj), "Context", self.gb_obj[0])
+ if stack:
+ context = threadlocal.context
+ self["nthreads"] = context["nthreads"] if nthreads is None else nthreads
+ self["chunk"] = context["chunk"] if chunk is None else chunk
+ self["gpu_id"] = context["gpu_id"] if gpu_id is None else gpu_id
+ else:
+ if nthreads is not None:
+ self["nthreads"] = nthreads
+ if chunk is not None:
+ self["chunk"] = chunk
+ if gpu_id is not None:
+ self["gpu_id"] = gpu_id
+ self._prev_context = None
+ if engage:
+ self.engage()
+
+ @classmethod
+ def _from_obj(cls, gb_obj=None):
+ self = object.__new__(cls)
+ self.gb_obj = gb_obj
+ self._prev_context = None
+ super().__init__(self)
+ return self
+
+ @property
+ def _carg(self):
+ return self.gb_obj[0]
+
+ def dup(self, engage=True, *, nthreads=None, chunk=None, gpu_id=None):
+ if nthreads is None:
+ nthreads = self["nthreads"]
+ if chunk is None:
+ chunk = self["chunk"]
+ if gpu_id is None:
+ gpu_id = self["gpu_id"]
+ return type(self)(engage, stack=False, nthreads=nthreads, chunk=chunk, gpu_id=gpu_id)
+
+ def __del__(self):
+ gb_obj = getattr(self, "gb_obj", None)
+ if gb_obj is not None and lib is not None: # pragma: no branch (safety)
+ try:
+ self.disengage()
+ except InvalidValue:
+ pass
+ lib.GxB_Context_free(gb_obj)
+
+ def engage(self):
+ if self._prev_context is None and (context := threadlocal.context) is not self:
+ self._prev_context = context
+ check_status(lib.GxB_Context_engage(self._carg), self)
+ threadlocal.context = self
+
+ def _engage(self):
+ """Like engage, but don't set to threadlocal.context.
+
+ This is useful if you want to disengage when the object is deleted by going out of scope.
+ """
+ if self._prev_context is None and (context := threadlocal.context) is not self:
+ self._prev_context = context
+ check_status(lib.GxB_Context_engage(self._carg), self)
+
+ def disengage(self):
+ prev_context = self._prev_context
+ self._prev_context = None
+ if threadlocal.context is self:
+ if prev_context is not None:
+ threadlocal.context = prev_context
+ prev_context.engage()
+ else:
+ threadlocal.context = global_context
+ check_status(lib.GxB_Context_disengage(self._carg), self)
+ elif prev_context is not None and threadlocal.context is prev_context:
+ prev_context.engage()
+ else:
+ check_status(lib.GxB_Context_disengage(self._carg), self)
+
+ def __enter__(self):
+ self.engage()
+ return self
+
+ def __exit__(self, exc_type, exc, exc_tb):
+ self.disengage()
+
+ @property
+ def _context(self):
+ return self
+
+ @_context.setter
+ def _context(self, val):
+ if val is not None and val is not self:
+ raise AttributeError("'_context' attribute is read-only")
+
+
+class GlobalContext(Context):
+ @property
+ def _carg(self):
+ return self.gb_obj
+
+ def __del__(self): # pragma: no cover (safety)
+ pass
+
+
+global_context = GlobalContext._from_obj(lib.GxB_CONTEXT_WORLD)
+
+
+class ThreadLocal(threading.local):
+ """Hold the active context for the current thread."""
+
+ context = global_context
+
+
+threadlocal = ThreadLocal()
diff --git a/graphblas/core/ss/descriptor.py b/graphblas/core/ss/descriptor.py
index dffc4dec1..781661b7b 100644
--- a/graphblas/core/ss/descriptor.py
+++ b/graphblas/core/ss/descriptor.py
@@ -1,6 +1,7 @@
from ...exceptions import check_status, check_status_carg
from .. import ffi, lib
from ..descriptor import Descriptor
+from . import _IS_SSGB7
from .config import BaseConfig
ffi_new = ffi.new
@@ -18,6 +19,8 @@
class _DescriptorConfig(BaseConfig):
_get_function = "GxB_Desc_get"
_set_function = "GxB_Desc_set"
+ if not _IS_SSGB7:
+ _context_keys = {"chunk", "gpu_id", "nthreads"}
_options = {
# GrB
"output_replace": (lib.GrB_OUTP, "GrB_Desc_Value"),
@@ -26,13 +29,25 @@ class _DescriptorConfig(BaseConfig):
"transpose_first": (lib.GrB_INP0, "GrB_Desc_Value"),
"transpose_second": (lib.GrB_INP1, "GrB_Desc_Value"),
# GxB
- "nthreads": (lib.GxB_DESCRIPTOR_NTHREADS, "int"),
- "chunk": (lib.GxB_DESCRIPTOR_CHUNK, "double"),
"axb_method": (lib.GxB_AxB_METHOD, "GrB_Desc_Value"),
"sort": (lib.GxB_SORT, "int"),
"secure_import": (lib.GxB_IMPORT, "int"),
- # "gpu_control": (GxB_DESCRIPTOR_GPU_CONTROL, "GrB_Desc_Value"), # Coming soon...
}
+ if _IS_SSGB7:
+ _options.update(
+ {
+ "nthreads": (lib.GxB_DESCRIPTOR_NTHREADS, "int"),
+ "chunk": (lib.GxB_DESCRIPTOR_CHUNK, "double"),
+ }
+ )
+ else:
+ _options.update(
+ {
+ "chunk": (lib.GxB_CONTEXT_CHUNK, "double"),
+ "gpu_id": (lib.GxB_CONTEXT_GPU_ID, "int"),
+ "nthreads": (lib.GxB_CONTEXT_NTHREADS, "int"),
+ }
+ )
_enumerations = {
# GrB
"output_replace": {
@@ -71,10 +86,6 @@ class _DescriptorConfig(BaseConfig):
False: False,
True: lib.GxB_SORT,
},
- # "gpu_control": { # Coming soon...
- # "always": lib.GxB_GPU_ALWAYS,
- # "never": lib.GxB_GPU_NEVER,
- # },
}
_defaults = {
# GrB
@@ -90,7 +101,8 @@ class _DescriptorConfig(BaseConfig):
"sort": False,
"secure_import": False,
}
- _count = 0
+ if not _IS_SSGB7:
+ _defaults["gpu_id"] = -1
def __init__(self):
gb_obj = ffi_new("GrB_Descriptor*")
@@ -132,7 +144,7 @@ def get_descriptor(**opts):
sort : bool, default False
A hint for whether methods may return a "jumbled" matrix
secure_import : bool, default False
- Whether to trust the data for `import` and `pack` functions.
+ Whether to trust the data for ``import`` and ``pack`` functions.
When True, checks are performed to ensure input data is valid.
compression : str, {"none", "default", "lz4", "lz4hc", "zstd"}
Whether and how to compress the data for serialization.
@@ -145,6 +157,7 @@ def get_descriptor(**opts):
Returns
-------
Descriptor or None
+
"""
if not opts or all(val is False or val is None for val in opts.values()):
return
diff --git a/graphblas/core/ss/dtypes.py b/graphblas/core/ss/dtypes.py
new file mode 100644
index 000000000..d2eb5b416
--- /dev/null
+++ b/graphblas/core/ss/dtypes.py
@@ -0,0 +1,88 @@
+import numpy as np
+
+from ... import backend, core, dtypes
+from ...exceptions import check_status_carg
+from .. import _has_numba, ffi, lib
+from . import _IS_SSGB7
+
+ffi_new = ffi.new
+if _has_numba:
+ import numba
+ from cffi import FFI
+ from numba.core.typing import cffi_utils
+
+ jit_ffi = FFI()
+
+
+def register_new(name, jit_c_definition, *, np_type=None):
+ if backend != "suitesparse": # pragma: no cover (safety)
+ raise RuntimeError(
+ "`gb.dtypes.ss.register_new` invalid when not using 'suitesparse' backend"
+ )
+ if _IS_SSGB7:
+ # JIT was introduced in SuiteSparse:GraphBLAS 8.0
+ import suitesparse_graphblas as ssgb
+
+ raise RuntimeError(
+ "JIT was added to SuiteSparse:GraphBLAS in version 8; "
+ f"current version is {ssgb.__version__}"
+ )
+ if not name.isidentifier():
+ raise ValueError(f"`name` argument must be a valid Python identifier; got: {name!r}")
+ if name in core.dtypes._registry or hasattr(dtypes.ss, name):
+ raise ValueError(f"{name!r} name for dtype is unavailable")
+ if len(name) > lib.GxB_MAX_NAME_LEN:
+ raise ValueError(
+ f"`name` argument is too large. Max size is {lib.GxB_MAX_NAME_LEN}; got {len(name)}"
+ )
+ if name not in jit_c_definition:
+ raise ValueError("`name` argument must be same name as the typedef in `jit_c_definition`")
+ if "struct" not in jit_c_definition:
+ raise ValueError("Only struct typedefs are currently allowed for JIT dtypes")
+
+ gb_obj = ffi.new("GrB_Type*")
+ status = lib.GxB_Type_new(
+ gb_obj, 0, ffi_new("char[]", name.encode()), ffi_new("char[]", jit_c_definition.encode())
+ )
+ check_status_carg(status, "Type", gb_obj[0])
+
+ # Let SuiteSparse:GraphBLAS determine the size (we gave 0 as size above)
+ size_ptr = ffi_new("size_t*")
+ check_status_carg(lib.GxB_Type_size(size_ptr, gb_obj[0]), "Type", gb_obj[0])
+ size = size_ptr[0]
+
+ save_np_type = True
+ if np_type is None and _has_numba and numba.__version__[:5] > "0.56.":
+ jit_ffi.cdef(jit_c_definition)
+ numba_type = cffi_utils.map_type(jit_ffi.typeof(name), use_record_dtype=True)
+ np_type = numba_type.dtype
+ if np_type.itemsize != size: # pragma: no cover
+ raise RuntimeError(
+ "Size of compiled user-defined type does not match size of inferred numpy type: "
+ f"{size} != {np_type.itemsize} != {size}.\n\n"
+ f"UDT C definition: {jit_c_definition}\n"
+ f"numpy dtype: {np_type}\n\n"
+ "To get around this, you may pass `np_type=` keyword argument."
+ )
+ else:
+ if np_type is not None:
+ np_type = np.dtype(np_type)
+ else:
+ # Not an ideal numpy type, but minimally useful
+ np_type = np.dtype((np.uint8, size))
+ save_np_type = False
+ if _has_numba:
+ numba_type = numba.typeof(np_type).dtype
+ else:
+ numba_type = None
+
+ # For now, let's use "opaque" unsigned bytes for the c type.
+ rv = core.dtypes.DataType(name, gb_obj, None, f"uint8_t[{size}]", numba_type, np_type)
+ core.dtypes._registry[gb_obj] = rv
+ if save_np_type or np_type not in core.dtypes._registry:
+ core.dtypes._registry[np_type] = rv
+ if numba_type is not None and (save_np_type or numba_type not in core.dtypes._registry):
+ core.dtypes._registry[numba_type] = rv
+ core.dtypes._registry[numba_type.name] = rv
+ setattr(dtypes.ss, name, rv)
+ return rv
diff --git a/graphblas/core/ss/indexunary.py b/graphblas/core/ss/indexunary.py
new file mode 100644
index 000000000..b60837acf
--- /dev/null
+++ b/graphblas/core/ss/indexunary.py
@@ -0,0 +1,153 @@
+from ... import backend
+from ...dtypes import BOOL, lookup_dtype
+from ...exceptions import check_status_carg
+from .. import NULL, ffi, lib
+from ..operator.base import TypedOpBase
+from ..operator.indexunary import IndexUnaryOp, TypedUserIndexUnaryOp
+from . import _IS_SSGB7
+
+ffi_new = ffi.new
+
+
+class TypedJitIndexUnaryOp(TypedOpBase):
+ __slots__ = "_jit_c_definition"
+ opclass = "IndexUnaryOp"
+
+ def __init__(self, parent, name, type_, return_type, gb_obj, jit_c_definition, dtype2=None):
+ super().__init__(parent, name, type_, return_type, gb_obj, name, dtype2=dtype2)
+ self._jit_c_definition = jit_c_definition
+
+ @property
+ def jit_c_definition(self):
+ return self._jit_c_definition
+
+ thunk_type = TypedUserIndexUnaryOp.thunk_type
+ __call__ = TypedUserIndexUnaryOp.__call__
+
+
+def register_new(name, jit_c_definition, input_type, thunk_type, ret_type):
+ """Register a new IndexUnaryOp using the SuiteSparse:GraphBLAS JIT compiler.
+
+ This creates a IndexUnaryOp by compiling the C string definition of the function.
+ It requires a shell call to a C compiler. The resulting operator will be as
+ fast as if it were built-in to SuiteSparse:GraphBLAS and does not have the
+ overhead of additional function calls as when using ``gb.indexunary.register_new``.
+
+ This is an advanced feature that requires a C compiler and proper configuration.
+ Configuration is handled by ``gb.ss.config``; see its docstring for details.
+ By default, the JIT caches results in ``~/.SuiteSparse/``. For more information,
+ see the SuiteSparse:GraphBLAS user guide.
+
+ Only one type signature may be registered at a time, but repeated calls using
+ the same name with different input types is allowed.
+
+ This will also create a SelectOp operator under ``gb.select.ss`` if the return
+ type is boolean.
+
+ Parameters
+ ----------
+ name : str
+ The name of the operator. This will show up as ``gb.indexunary.ss.{name}``.
+ The name may contain periods, ".", which will result in nested objects
+ such as ``gb.indexunary.ss.x.y.z`` for name ``"x.y.z"``.
+ jit_c_definition : str
+ The C definition as a string of the user-defined function. For example:
+ ``"void diffy (double *z, double *x, GrB_Index i, GrB_Index j, double *y) "``
+ ``"{ (*z) = (i + j) * fabs ((*x) - (*y)) ; }"``
+ input_type : dtype
+ The dtype of the operand of the indexunary operator.
+ thunk_type : dtype
+ The dtype of the thunk of the indexunary operator.
+ ret_type : dtype
+ The dtype of the result of the indexunary operator.
+
+ Returns
+ -------
+ IndexUnaryOp
+
+ See Also
+ --------
+ gb.indexunary.register_new
+ gb.indexunary.register_anonymous
+ gb.select.ss.register_new
+
+ """
+ if backend != "suitesparse": # pragma: no cover (safety)
+ raise RuntimeError(
+ "`gb.indexunary.ss.register_new` invalid when not using 'suitesparse' backend"
+ )
+ if _IS_SSGB7:
+ # JIT was introduced in SuiteSparse:GraphBLAS 8.0
+ import suitesparse_graphblas as ssgb
+
+ raise RuntimeError(
+ "JIT was added to SuiteSparse:GraphBLAS in version 8; "
+ f"current version is {ssgb.__version__}"
+ )
+ input_type = lookup_dtype(input_type)
+ thunk_type = lookup_dtype(thunk_type)
+ ret_type = lookup_dtype(ret_type)
+ name = name if name.startswith("ss.") else f"ss.{name}"
+ module, funcname = IndexUnaryOp._remove_nesting(name, strict=False)
+ if hasattr(module, funcname):
+ rv = getattr(module, funcname)
+ if not isinstance(rv, IndexUnaryOp):
+ IndexUnaryOp._remove_nesting(name)
+ if (
+ (input_type, thunk_type) in rv.types
+ or rv._udt_types is not None
+ and (input_type, thunk_type) in rv._udt_types
+ ):
+ raise TypeError(
+ f"IndexUnaryOp gb.indexunary.{name} already defined for "
+ f"({input_type}, {thunk_type}) input types"
+ )
+ else:
+ # We use `is_udt=True` to make dtype handling flexible and explicit.
+ rv = IndexUnaryOp(name, is_udt=True)
+ gb_obj = ffi_new("GrB_IndexUnaryOp*")
+ check_status_carg(
+ lib.GxB_IndexUnaryOp_new(
+ gb_obj,
+ NULL,
+ ret_type._carg,
+ input_type._carg,
+ thunk_type._carg,
+ ffi_new("char[]", funcname.encode()),
+ ffi_new("char[]", jit_c_definition.encode()),
+ ),
+ "IndexUnaryOp",
+ gb_obj[0],
+ )
+ op = TypedJitIndexUnaryOp(
+ rv, funcname, input_type, ret_type, gb_obj[0], jit_c_definition, dtype2=thunk_type
+ )
+ rv._add(op, is_jit=True)
+ if ret_type == BOOL:
+ from ..operator.select import SelectOp
+ from .select import TypedJitSelectOp
+
+ select_module, funcname = SelectOp._remove_nesting(name, strict=False)
+ if hasattr(select_module, funcname):
+ selectop = getattr(select_module, funcname)
+ if not isinstance(selectop, SelectOp):
+ SelectOp._remove_nesting(name)
+ if (
+ (input_type, thunk_type) in selectop.types
+ or selectop._udt_types is not None
+ and (input_type, thunk_type) in selectop._udt_types
+ ):
+ raise TypeError(
+ f"SelectOp gb.select.{name} already defined for "
+ f"({input_type}, {thunk_type}) input types"
+ )
+ else:
+ # We use `is_udt=True` to make dtype handling flexible and explicit.
+ selectop = SelectOp(name, is_udt=True)
+ op2 = TypedJitSelectOp(
+ selectop, funcname, input_type, ret_type, gb_obj[0], jit_c_definition, dtype2=thunk_type
+ )
+ selectop._add(op2, is_jit=True)
+ setattr(select_module, funcname, selectop)
+ setattr(module, funcname, rv)
+ return rv
diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py
index b455d760e..509c56113 100644
--- a/graphblas/core/ss/matrix.py
+++ b/graphblas/core/ss/matrix.py
@@ -1,18 +1,16 @@
import itertools
-import warnings
-import numba
import numpy as np
-from numba import njit
from suitesparse_graphblas.utils import claim_buffer, claim_buffer_2d, unclaim_buffer
import graphblas as gb
from ... import binary, monoid
-from ...dtypes import _INDEX, BOOL, INT64, UINT64, _string_to_dtype, lookup_dtype
+from ...dtypes import _INDEX, BOOL, INT64, UINT64, lookup_dtype
from ...exceptions import _error_code_lookup, check_status, check_status_carg
-from .. import NULL, ffi, lib
+from .. import NULL, _has_numba, ffi, lib
from ..base import call
+from ..dtypes import _string_to_dtype
from ..operator import get_typed_op
from ..scalar import Scalar, _as_scalar, _scalar_index
from ..utils import (
@@ -30,6 +28,16 @@
from .config import BaseConfig
from .descriptor import get_descriptor
+if _has_numba:
+ from numba import njit, prange
+else:
+
+ def njit(func=None, **kwargs):
+ if func is not None:
+ return func
+ return njit
+
+ prange = range
ffi_new = ffi.new
@@ -50,12 +58,12 @@ def head(matrix, n=10, dtype=None, *, sort=False):
dtype = matrix.dtype
else:
dtype = lookup_dtype(dtype)
- rows, cols, vals = zip(*itertools.islice(matrix.ss.iteritems(), n))
+ rows, cols, vals = zip(*itertools.islice(matrix.ss.iteritems(), n), strict=True)
return np.array(rows, np.uint64), np.array(cols, np.uint64), np.array(vals, dtype.np_type)
def _concat_mn(tiles, *, is_matrix=None):
- """Argument checking for `Matrix.ss.concat` and returns number of tiles in each dimension."""
+ """Argument checking for ``Matrix.ss.concat`` and returns number of tiles in each dimension."""
from ..matrix import Matrix, TransposedMatrix
from ..vector import Vector
@@ -242,8 +250,7 @@ def orientation(self):
return "rowwise"
def build_diag(self, vector, k=0, **opts):
- """
- GxB_Matrix_diag.
+ """GxB_Matrix_diag.
Construct a diagonal Matrix from the given vector.
Existing entries in the Matrix are discarded.
@@ -253,8 +260,8 @@ def build_diag(self, vector, k=0, **opts):
vector : Vector
Create a diagonal from this Vector.
k : int, default 0
- Diagonal in question. Use `k>0` for diagonals above the main diagonal,
- and `k<0` for diagonals below the main diagonal.
+ Diagonal in question. Use ``k>0`` for diagonals above the main diagonal,
+ and ``k<0`` for diagonals below the main diagonal.
See Also
--------
@@ -271,15 +278,14 @@ def build_diag(self, vector, k=0, **opts):
)
def split(self, chunks, *, name=None, **opts):
- """
- GxB_Matrix_split.
+ """GxB_Matrix_split.
- Split a Matrix into a 2D array of sub-matrices according to `chunks`.
+ Split a Matrix into a 2D array of sub-matrices according to ``chunks``.
This performs the opposite operation as ``concat``.
- `chunks` is short for "chunksizes" and indicates the chunk sizes for each dimension.
- `chunks` may be a single integer, or a length 2 tuple or list. Example chunks:
+ ``chunks`` is short for "chunksizes" and indicates the chunk sizes for each dimension.
+ ``chunks`` may be a single integer, or a length 2 tuple or list. Example chunks:
- ``chunks=10``
- Split each dimension into chunks of size 10 (the last chunk may be smaller).
@@ -287,13 +293,14 @@ def split(self, chunks, *, name=None, **opts):
- Split rows into chunks of size 10 and columns into chunks of size 20.
- ``chunks=(None, [5, 10])``
- Don't split rows into chunks, and split columns into two chunks of size 5 and 10.
- ` ``chunks=(10, [20, None])``
+ - ``chunks=(10, [20, None])``
- Split columns into two chunks of size 20 and ``ncols - 20``
See Also
--------
Matrix.ss.concat
graphblas.ss.concat
+
"""
from ..matrix import Matrix
@@ -353,14 +360,13 @@ def _concat(self, tiles, m, n, opts):
)
def concat(self, tiles, **opts):
- """
- GxB_Matrix_concat.
+ """GxB_Matrix_concat.
Concatenate a 2D list of Matrix objects into the current Matrix.
Any existing values in the current Matrix will be discarded.
- To concatenate into a new Matrix, use `graphblas.ss.concat`.
+ To concatenate into a new Matrix, use ``graphblas.ss.concat``.
- Vectors may be used as `Nx1` Matrix objects.
+ Vectors may be used as ``Nx1`` Matrix objects.
This performs the opposite operation as ``split``.
@@ -368,13 +374,13 @@ def concat(self, tiles, **opts):
--------
Matrix.ss.split
graphblas.ss.concat
+
"""
tiles, m, n, is_matrix = _concat_mn(tiles, is_matrix=True)
self._concat(tiles, m, n, opts)
def build_scalar(self, rows, columns, value):
- """
- GxB_Matrix_build_Scalar.
+ """GxB_Matrix_build_Scalar.
Like ``build``, but uses a scalar for all the values.
@@ -382,6 +388,7 @@ def build_scalar(self, rows, columns, value):
--------
Matrix.build
Matrix.from_coo
+
"""
rows = ints_to_numpy_buffer(rows, np.uint64, name="row indices")
columns = ints_to_numpy_buffer(columns, np.uint64, name="column indices")
@@ -528,14 +535,13 @@ def iteritems(self, seek=0):
lib.GxB_Iterator_free(it_ptr)
def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **opts):
- """
- GxB_Matrix_export_xxx.
+ """GxB_Matrix_export_xxx.
Parameters
----------
format : str, optional
- If `format` is not specified, this method exports in the currently stored format.
- To control the export format, set `format` to one of:
+ If ``format`` is not specified, this method exports in the currently stored format.
+ To control the export format, set ``format`` to one of:
- "csr"
- "csc"
- "hypercsr"
@@ -570,7 +576,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **
Returns
-------
- dict; keys depend on `format` and `raw` arguments (see below).
+ dict; keys depend on ``format`` and ``raw`` arguments (see below).
See Also
--------
@@ -710,6 +716,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **
>>> pieces = A.ss.export()
>>> A2 = Matrix.ss.import_any(**pieces)
+
"""
return self._export(
format,
@@ -721,13 +728,12 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **
)
def unpack(self, format=None, *, sort=False, raw=False, **opts):
- """
- GxB_Matrix_unpack_xxx.
+ """GxB_Matrix_unpack_xxx.
- `unpack` is like `export`, except that the Matrix remains valid but empty.
- `pack_*` methods are the opposite of `unpack`.
+ ``unpack`` is like ``export``, except that the Matrix remains valid but empty.
+ ``pack_*`` methods are the opposite of ``unpack``.
- See `Matrix.ss.export` documentation for more details.
+ See ``Matrix.ss.export`` documentation for more details.
"""
return self._export(
format, sort=sort, raw=raw, give_ownership=True, method="unpack", opts=opts
@@ -888,16 +894,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:
+ if indptr.size > nrows + 1: # pragma: no cover (suitesparse)
indptr = indptr[: nrows + 1]
if col_indices.size > nvals:
col_indices = col_indices[:nvals]
if is_iso:
if values.size > 1: # pragma: no branch (suitesparse)
values = values[:1]
- else:
- if values.size > nvals: # pragma: no branch (suitesparse)
- values = values[:nvals]
+ elif values.size > nvals: # pragma: no branch (suitesparse)
+ values = values[:nvals]
# Note: nvals is also at `indptr[nrows]`
rv = {
"indptr": indptr,
@@ -930,16 +935,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:
+ if indptr.size > ncols + 1: # pragma: no cover (suitesparse)
indptr = indptr[: ncols + 1]
if row_indices.size > nvals:
row_indices = row_indices[:nvals]
if is_iso:
if values.size > 1: # pragma: no cover (suitesparse)
values = values[:1]
- else:
- if values.size > nvals:
- values = values[:nvals]
+ elif values.size > nvals:
+ values = values[:nvals]
# Note: nvals is also at `indptr[ncols]`
rv = {
"indptr": indptr,
@@ -989,9 +993,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
if is_iso:
if values.size > 1: # pragma: no cover (suitesparse)
values = values[:1]
- else:
- if values.size > nvals:
- values = values[:nvals]
+ elif values.size > nvals:
+ values = values[:nvals]
# Note: nvals is also at `indptr[nvec]`
rv = {
"indptr": indptr,
@@ -1044,9 +1047,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
if is_iso:
if values.size > 1: # pragma: no cover (suitesparse)
values = values[:1]
- else:
- if values.size > nvals:
- values = values[:nvals]
+ elif values.size > nvals:
+ values = values[:nvals]
# Note: nvals is also at `indptr[nvec]`
rv = {
"indptr": indptr,
@@ -1175,8 +1177,7 @@ def import_csr(
name=None,
**opts,
):
- """
- GxB_Matrix_import_CSR.
+ """GxB_Matrix_import_CSR.
Create a new Matrix from standard CSR format.
@@ -1189,7 +1190,7 @@ def import_csr(
col_indices : array-like
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
sorted_cols : bool, default False
Indicate whether the values in "col_indices" are sorted.
take_ownership : bool, default False
@@ -1206,7 +1207,7 @@ def import_csr(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "csr" or None. This is included to be compatible with
the dict returned from exporting.
@@ -1216,6 +1217,7 @@ def import_csr(
Returns
-------
Matrix
+
"""
return cls._import_csr(
nrows=nrows,
@@ -1252,13 +1254,12 @@ def pack_csr(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_CSR.
+ """GxB_Matrix_pack_CSR.
- `pack_csr` is like `import_csr` except it "packs" data into an
+ ``pack_csr`` is like ``import_csr`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("csr")``
- See `Matrix.ss.import_csr` documentation for more details.
+ See ``Matrix.ss.import_csr`` documentation for more details.
"""
return self._import_csr(
indptr=indptr,
@@ -1365,8 +1366,7 @@ def import_csc(
name=None,
**opts,
):
- """
- GxB_Matrix_import_CSC.
+ """GxB_Matrix_import_CSC.
Create a new Matrix from standard CSC format.
@@ -1379,7 +1379,7 @@ def import_csc(
row_indices : array-like
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
sorted_rows : bool, default False
Indicate whether the values in "row_indices" are sorted.
take_ownership : bool, default False
@@ -1396,7 +1396,7 @@ def import_csc(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "csc" or None. This is included to be compatible with
the dict returned from exporting.
@@ -1406,6 +1406,7 @@ def import_csc(
Returns
-------
Matrix
+
"""
return cls._import_csc(
nrows=nrows,
@@ -1442,13 +1443,12 @@ def pack_csc(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_CSC.
+ """GxB_Matrix_pack_CSC.
- `pack_csc` is like `import_csc` except it "packs" data into an
+ ``pack_csc`` is like ``import_csc`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("csc")``
- See `Matrix.ss.import_csc` documentation for more details.
+ See ``Matrix.ss.import_csc`` documentation for more details.
"""
return self._import_csc(
indptr=indptr,
@@ -1557,8 +1557,7 @@ def import_hypercsr(
name=None,
**opts,
):
- """
- GxB_Matrix_import_HyperCSR.
+ """GxB_Matrix_import_HyperCSR.
Create a new Matrix from standard HyperCSR format.
@@ -1575,7 +1574,7 @@ def import_hypercsr(
If not specified, will be set to ``len(rows)``.
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
sorted_cols : bool, default False
Indicate whether the values in "col_indices" are sorted.
take_ownership : bool, default False
@@ -1592,7 +1591,7 @@ def import_hypercsr(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "hypercsr" or None. This is included to be compatible with
the dict returned from exporting.
@@ -1602,6 +1601,7 @@ def import_hypercsr(
Returns
-------
Matrix
+
"""
return cls._import_hypercsr(
nrows=nrows,
@@ -1642,13 +1642,12 @@ def pack_hypercsr(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_HyperCSR.
+ """GxB_Matrix_pack_HyperCSR.
- `pack_hypercsr` is like `import_hypercsr` except it "packs" data into an
+ ``pack_hypercsr`` is like ``import_hypercsr`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("hypercsr")``
- See `Matrix.ss.import_hypercsr` documentation for more details.
+ See ``Matrix.ss.import_hypercsr`` documentation for more details.
"""
return self._import_hypercsr(
rows=rows,
@@ -1781,8 +1780,7 @@ def import_hypercsc(
name=None,
**opts,
):
- """
- GxB_Matrix_import_HyperCSC.
+ """GxB_Matrix_import_HyperCSC.
Create a new Matrix from standard HyperCSC format.
@@ -1790,6 +1788,7 @@ def import_hypercsc(
----------
nrows : int
ncols : int
+ cols : array-like
indptr : array-like
values : array-like
row_indices : array-like
@@ -1798,7 +1797,7 @@ def import_hypercsc(
If not specified, will be set to ``len(cols)``.
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
sorted_rows : bool, default False
Indicate whether the values in "row_indices" are sorted.
take_ownership : bool, default False
@@ -1815,7 +1814,7 @@ def import_hypercsc(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "hypercsc" or None. This is included to be compatible with
the dict returned from exporting.
@@ -1825,6 +1824,7 @@ def import_hypercsc(
Returns
-------
Matrix
+
"""
return cls._import_hypercsc(
nrows=nrows,
@@ -1865,13 +1865,12 @@ def pack_hypercsc(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_HyperCSC.
+ """GxB_Matrix_pack_HyperCSC.
- `pack_hypercsc` is like `import_hypercsc` except it "packs" data into an
+ ``pack_hypercsc`` is like ``import_hypercsc`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("hypercsc")``
- See `Matrix.ss.import_hypercsc` documentation for more details.
+ See ``Matrix.ss.import_hypercsc`` documentation for more details.
"""
return self._import_hypercsc(
cols=cols,
@@ -2001,8 +2000,7 @@ def import_bitmapr(
name=None,
**opts,
):
- """
- GxB_Matrix_import_BitmapR.
+ """GxB_Matrix_import_BitmapR.
Create a new Matrix from values and bitmap (as mask) arrays.
@@ -2023,7 +2021,7 @@ def import_bitmapr(
If not provided, will be inferred from values or bitmap if either is 2d.
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
take_ownership : bool, default False
If True, perform a zero-copy data transfer from input numpy arrays
to GraphBLAS if possible. To give ownership of the underlying
@@ -2038,7 +2036,7 @@ def import_bitmapr(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "bitmapr" or None. This is included to be compatible with
the dict returned from exporting.
@@ -2048,6 +2046,7 @@ def import_bitmapr(
Returns
-------
Matrix
+
"""
return cls._import_bitmapr(
bitmap=bitmap,
@@ -2082,13 +2081,12 @@ def pack_bitmapr(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_BitmapR.
+ """GxB_Matrix_pack_BitmapR.
- `pack_bitmapr` is like `import_bitmapr` except it "packs" data into an
+ ``pack_bitmapr`` is like ``import_bitmapr`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("bitmapr")``
- See `Matrix.ss.import_bitmapr` documentation for more details.
+ See ``Matrix.ss.import_bitmapr`` documentation for more details.
"""
return self._import_bitmapr(
bitmap=bitmap,
@@ -2194,8 +2192,7 @@ def import_bitmapc(
name=None,
**opts,
):
- """
- GxB_Matrix_import_BitmapC.
+ """GxB_Matrix_import_BitmapC.
Create a new Matrix from values and bitmap (as mask) arrays.
@@ -2216,7 +2213,7 @@ def import_bitmapc(
If not provided, will be inferred from values or bitmap if either is 2d.
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
take_ownership : bool, default False
If True, perform a zero-copy data transfer from input numpy arrays
to GraphBLAS if possible. To give ownership of the underlying
@@ -2231,7 +2228,7 @@ def import_bitmapc(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "bitmapc" or None. This is included to be compatible with
the dict returned from exporting.
@@ -2241,6 +2238,7 @@ def import_bitmapc(
Returns
-------
Matrix
+
"""
return cls._import_bitmapc(
bitmap=bitmap,
@@ -2275,13 +2273,12 @@ def pack_bitmapc(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_BitmapC.
+ """GxB_Matrix_pack_BitmapC.
- `pack_bitmapc` is like `import_bitmapc` except it "packs" data into an
+ ``pack_bitmapc`` is like ``import_bitmapc`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("bitmapc")``
- See `Matrix.ss.import_bitmapc` documentation for more details.
+ See ``Matrix.ss.import_bitmapc`` documentation for more details.
"""
return self._import_bitmapc(
bitmap=bitmap,
@@ -2385,8 +2382,7 @@ def import_fullr(
name=None,
**opts,
):
- """
- GxB_Matrix_import_FullR.
+ """GxB_Matrix_import_FullR.
Create a new Matrix from values.
@@ -2402,7 +2398,7 @@ def import_fullr(
If not provided, will be inferred from values if it is 2d.
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
take_ownership : bool, default False
If True, perform a zero-copy data transfer from input numpy arrays
to GraphBLAS if possible. To give ownership of the underlying
@@ -2417,7 +2413,7 @@ def import_fullr(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "fullr" or None. This is included to be compatible with
the dict returned from exporting.
@@ -2427,6 +2423,7 @@ def import_fullr(
Returns
-------
Matrix
+
"""
return cls._import_fullr(
values=values,
@@ -2457,13 +2454,12 @@ def pack_fullr(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_FullR.
+ """GxB_Matrix_pack_FullR.
- `pack_fullr` is like `import_fullr` except it "packs" data into an
+ ``pack_fullr`` is like ``import_fullr`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("fullr")``
- See `Matrix.ss.import_fullr` documentation for more details.
+ See ``Matrix.ss.import_fullr`` documentation for more details.
"""
return self._import_fullr(
values=values,
@@ -2544,8 +2540,7 @@ def import_fullc(
name=None,
**opts,
):
- """
- GxB_Matrix_import_FullC.
+ """GxB_Matrix_import_FullC.
Create a new Matrix from values.
@@ -2561,7 +2556,7 @@ def import_fullc(
If not provided, will be inferred from values if it is 2d.
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
take_ownership : bool, default False
If True, perform a zero-copy data transfer from input numpy arrays
to GraphBLAS if possible. To give ownership of the underlying
@@ -2576,7 +2571,7 @@ def import_fullc(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "fullc" or None. This is included to be compatible with
the dict returned from exporting.
@@ -2586,6 +2581,7 @@ def import_fullc(
Returns
-------
Matrix
+
"""
return cls._import_fullc(
values=values,
@@ -2616,13 +2612,12 @@ def pack_fullc(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_FullC.
+ """GxB_Matrix_pack_FullC.
- `pack_fullc` is like `import_fullc` except it "packs" data into an
+ ``pack_fullc`` is like ``import_fullc`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("fullc")``
- See `Matrix.ss.import_fullc` documentation for more details.
+ See ``Matrix.ss.import_fullc`` documentation for more details.
"""
return self._import_fullc(
values=values,
@@ -2706,8 +2701,7 @@ def import_coo(
name=None,
**opts,
):
- """
- GrB_Matrix_build_XXX and GxB_Matrix_build_Scalar.
+ """GrB_Matrix_build_XXX and GxB_Matrix_build_Scalar.
Create a new Matrix from indices and values in coordinate format.
@@ -2722,7 +2716,7 @@ def import_coo(
The number of columns for the Matrix.
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
sorted_rows : bool, default False
True if rows are sorted or when (cols, rows) are sorted lexicographically
sorted_cols : bool, default False
@@ -2731,7 +2725,7 @@ def import_coo(
Ignored. Zero-copy is not possible for "coo" format.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "coo" or None. This is included to be compatible with
the dict returned from exporting.
@@ -2741,6 +2735,7 @@ def import_coo(
Returns
-------
Matrix
+
"""
return cls._import_coo(
rows=rows,
@@ -2779,13 +2774,12 @@ def pack_coo(
name=None,
**opts,
):
- """
- GrB_Matrix_build_XXX and GxB_Matrix_build_Scalar.
+ """GrB_Matrix_build_XXX and GxB_Matrix_build_Scalar.
- `pack_coo` is like `import_coo` except it "packs" data into an
+ ``pack_coo`` is like ``import_coo`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("coo")``
- See `Matrix.ss.import_coo` documentation for more details.
+ See ``Matrix.ss.import_coo`` documentation for more details.
"""
return self._import_coo(
nrows=self._parent._nrows,
@@ -2892,8 +2886,7 @@ def import_coor(
name=None,
**opts,
):
- """
- GxB_Matrix_import_CSR.
+ """GxB_Matrix_import_CSR.
Create a new Matrix from indices and values in coordinate format.
Rows must be sorted.
@@ -2909,7 +2902,7 @@ def import_coor(
The number of columns for the Matrix.
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
sorted_cols : bool, default False
True indicates indices are sorted by column, then row.
take_ownership : bool, default False
@@ -2927,7 +2920,7 @@ def import_coor(
For "coor", ownership of "rows" will never change.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "coor" or None. This is included to be compatible with
the dict returned from exporting.
@@ -2937,6 +2930,7 @@ def import_coor(
Returns
-------
Matrix
+
"""
return cls._import_coor(
rows=rows,
@@ -2975,13 +2969,12 @@ def pack_coor(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_CSR.
+ """GxB_Matrix_pack_CSR.
- `pack_coor` is like `import_coor` except it "packs" data into an
+ ``pack_coor`` is like ``import_coor`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("coor")``
- See `Matrix.ss.import_coor` documentation for more details.
+ See ``Matrix.ss.import_coor`` documentation for more details.
"""
return self._import_coor(
rows=rows,
@@ -3061,8 +3054,7 @@ def import_cooc(
name=None,
**opts,
):
- """
- GxB_Matrix_import_CSC.
+ """GxB_Matrix_import_CSC.
Create a new Matrix from indices and values in coordinate format.
Rows must be sorted.
@@ -3078,7 +3070,7 @@ def import_cooc(
The number of columns for the Matrix.
is_iso : bool, default False
Is the Matrix iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
sorted_rows : bool, default False
True indicates indices are sorted by column, then row.
take_ownership : bool, default False
@@ -3096,7 +3088,7 @@ def import_cooc(
For "cooc", ownership of "cols" will never change.
dtype : dtype, optional
dtype of the new Matrix.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "cooc" or None. This is included to be compatible with
the dict returned from exporting.
@@ -3106,6 +3098,7 @@ def import_cooc(
Returns
-------
Matrix
+
"""
return cls._import_cooc(
rows=rows,
@@ -3144,13 +3137,12 @@ def pack_cooc(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_CSC.
+ """GxB_Matrix_pack_CSC.
- `pack_cooc` is like `import_cooc` except it "packs" data into an
+ ``pack_cooc`` is like ``import_cooc`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack("cooc")``
- See `Matrix.ss.import_cooc` documentation for more details.
+ See ``Matrix.ss.import_cooc`` documentation for more details.
"""
return self._import_cooc(
ncols=self._parent._ncols,
@@ -3246,11 +3238,10 @@ def import_any(
nvals=None, # optional
**opts,
):
- """
- GxB_Matrix_import_xxx.
+ """GxB_Matrix_import_xxx.
Dispatch to appropriate import method inferred from inputs.
- See the other import functions and `Matrix.ss.export`` for details.
+ See the other import functions and ``Matrix.ss.export`` for details.
Returns
-------
@@ -3275,6 +3266,7 @@ def import_any(
>>> pieces = A.ss.export()
>>> A2 = Matrix.ss.import_any(**pieces)
+
"""
return cls._import_any(
values=values,
@@ -3344,13 +3336,12 @@ def pack_any(
name=None,
**opts,
):
- """
- GxB_Matrix_pack_xxx.
+ """GxB_Matrix_pack_xxx.
- `pack_any` is like `import_any` except it "packs" data into an
+ ``pack_any`` is like ``import_any`` except it "packs" data into an
existing Matrix. This is the opposite of ``unpack()``
- See `Matrix.ss.import_any` documentation for more details.
+ See ``Matrix.ss.import_any`` documentation for more details.
"""
return self._import_any(
values=values,
@@ -3480,15 +3471,10 @@ def _import_any(
format = "cooc"
else:
format = "coo"
+ elif isinstance(values, np.ndarray) and values.ndim == 2 and values.flags.f_contiguous:
+ format = "fullc"
else:
- if (
- isinstance(values, np.ndarray)
- and values.ndim == 2
- and values.flags.f_contiguous
- ):
- format = "fullc"
- else:
- format = "fullr"
+ format = "fullr"
else:
format = format.lower()
if method == "pack":
@@ -3664,8 +3650,10 @@ def _import_any(
def unpack_hyperhash(self, *, compute=False, name=None, **opts):
"""Unpacks the hyper_hash of a hypersparse matrix if possible.
- Will return None if the matrix is not hypersparse or if the hash is not computed.
- Use ``compute=True`` to compute the hyper_hash if the input is hypersparse.
+ Will return None if the matrix is not hypersparse, if the hash is not computed,
+ or if the hash is not needed. Use ``compute=True`` to try to compute the hyper_hash
+ if the input is hypersparse. The hyper_hash is optional in SuiteSparse:GraphBLAS,
+ so it may not be computed even with ``compute=True``.
Use ``pack_hyperhash`` to move a hyper_hash matrix that was previously unpacked
back into a matrix.
@@ -3701,12 +3689,13 @@ def head(self, n=10, dtype=None, *, sort=False):
def scan(self, op=monoid.plus, order="rowwise", *, name=None, **opts):
"""Perform a prefix scan across rows (default) or columns with the given monoid.
- For example, use `monoid.plus` (the default) to perform a cumulative sum,
- and `monoid.times` for cumulative product. Works with any monoid.
+ For example, use ``monoid.plus`` (the default) to perform a cumulative sum,
+ and ``monoid.times`` for cumulative product. Works with any monoid.
Returns
-------
Matrix
+
"""
order = get_order(order)
parent = self._parent
@@ -3714,51 +3703,6 @@ def scan(self, op=monoid.plus, order="rowwise", *, name=None, **opts):
parent = parent.T
return prefix_scan(parent, op, name=name, within="scan", **opts)
- def scan_columnwise(self, op=monoid.plus, *, name=None, **opts):
- """Perform a prefix scan across columns with the given monoid.
-
- .. deprecated:: 2022.11.1
- `Matrix.ss.scan_columnwise` will be removed in a future release.
- Use `Matrix.ss.scan(order="columnwise")` instead.
- Will be removed in version 2023.7.0 or later
-
- For example, use `monoid.plus` (the default) to perform a cumulative sum,
- and `monoid.times` for cumulative product. Works with any monoid.
-
- Returns
- -------
- Matrix
- """
- warnings.warn(
- "`Matrix.ss.scan_columnwise` is deprecated; "
- 'please use `Matrix.ss.scan(order="columnwise")` instead.',
- DeprecationWarning,
- stacklevel=2,
- )
- return prefix_scan(self._parent.T, op, name=name, within="scan_columnwise", **opts)
-
- def scan_rowwise(self, op=monoid.plus, *, name=None, **opts):
- """Perform a prefix scan across rows with the given monoid.
-
- .. deprecated:: 2022.11.1
- `Matrix.ss.scan_rowwise` will be removed in a future release.
- Use `Matrix.ss.scan` instead.
- Will be removed in version 2023.7.0 or later
-
- For example, use `monoid.plus` (the default) to perform a cumulative sum,
- and `monoid.times` for cumulative product. Works with any monoid.
-
- Returns
- -------
- Matrix
- """
- warnings.warn(
- "`Matrix.ss.scan_rowwise` is deprecated; please use `Matrix.ss.scan` instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- return prefix_scan(self._parent, op, name=name, within="scan_rowwise", **opts)
-
def flatten(self, order="rowwise", *, name=None, **opts):
"""Return a copy of the Matrix collapsed into a Vector.
@@ -3780,6 +3724,7 @@ def flatten(self, order="rowwise", *, name=None, **opts):
See Also
--------
Vector.ss.reshape : copy a Vector to a Matrix.
+
"""
rv = self.reshape(-1, 1, order=order, name=name, **opts)
return rv._as_vector()
@@ -3816,6 +3761,7 @@ def reshape(self, nrows, ncols=None, order="rowwise", *, inplace=False, name=Non
--------
Matrix.ss.flatten : flatten a Matrix into a Vector.
Vector.ss.reshape : copy a Vector to a Matrix.
+
"""
from ..matrix import Matrix
@@ -3870,6 +3816,7 @@ def selectk(self, how, k, order="rowwise", *, name=None):
The number of elements to choose from each row
**THIS API IS EXPERIMENTAL AND MAY CHANGE**
+
"""
# TODO: largest, smallest, random_weighted
order = get_order(order)
@@ -3900,99 +3847,6 @@ def selectk(self, how, k, order="rowwise", *, name=None):
k, fmt, indices, sort_axis, choose_func, is_random, do_sort, name
)
- def selectk_rowwise(self, how, k, *, name=None): # pragma: no cover (deprecated)
- """Select (up to) k elements from each row.
-
- .. deprecated:: 2022.11.1
- `Matrix.ss.selectk_rowwise` will be removed in a future release.
- Use `Matrix.ss.selectk` instead.
- Will be removed in version 2023.7.0 or later
-
- Parameters
- ----------
- how : str
- "random": choose k elements with equal probability
- "first": choose the first k elements
- "last": choose the last k elements
- k : int
- The number of elements to choose from each row
-
- **THIS API IS EXPERIMENTAL AND MAY CHANGE**
- """
- warnings.warn(
- "`Matrix.ss.selectk_rowwise` is deprecated; please use `Matrix.ss.selectk` instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- how = how.lower()
- fmt = "hypercsr"
- indices = "col_indices"
- sort_axis = "sorted_cols"
- if how == "random":
- choose_func = choose_random
- is_random = True
- do_sort = False
- elif how == "first":
- choose_func = choose_first
- is_random = False
- do_sort = True
- elif how == "last":
- choose_func = choose_last
- is_random = False
- do_sort = True
- else:
- raise ValueError('`how` argument must be one of: "random", "first", "last"')
- return self._select_random(
- k, fmt, indices, sort_axis, choose_func, is_random, do_sort, name
- )
-
- def selectk_columnwise(self, how, k, *, name=None): # pragma: no cover (deprecated)
- """Select (up to) k elements from each column.
-
- .. deprecated:: 2022.11.1
- `Matrix.ss.selectk_columnwise` will be removed in a future release.
- Use `Matrix.ss.selectk(order="columnwise")` instead.
- Will be removed in version 2023.7.0 or later
-
- Parameters
- ----------
- how : str
- - "random": choose elements with equal probability
- - "first": choose the first k elements
- - "last": choose the last k elements
- k : int
- The number of elements to choose from each column
-
- **THIS API IS EXPERIMENTAL AND MAY CHANGE**
- """
- warnings.warn(
- "`Matrix.ss.selectk_columnwise` is deprecated; "
- 'please use `Matrix.ss.selectk(order="columnwise")` instead.',
- DeprecationWarning,
- stacklevel=2,
- )
- how = how.lower()
- fmt = "hypercsc"
- indices = "row_indices"
- sort_axis = "sorted_rows"
- if how == "random":
- choose_func = choose_random
- is_random = True
- do_sort = False
- elif how == "first":
- choose_func = choose_first
- is_random = False
- do_sort = True
- elif how == "last":
- choose_func = choose_last
- is_random = False
- do_sort = True
- else:
- raise ValueError('`how` argument must be one of: "random", "first", "last"')
- return self._select_random(
- k, fmt, indices, sort_axis, choose_func, is_random, do_sort, name
- )
-
def _select_random(self, k, fmt, indices, sort_axis, choose_func, is_random, do_sort, name):
if k < 0:
raise ValueError("negative k is not allowed")
@@ -4057,92 +3911,6 @@ def compactify(
indices = "row_indices"
return self._compactify(how, reverse, asindex, dimname, k, fmt, indices, name)
- def compactify_rowwise(
- self, how="first", ncols=None, *, reverse=False, asindex=False, name=None
- ):
- """Shift all values to the left so all values in a row are contiguous.
-
- This returns a new Matrix.
-
- Parameters
- ----------
- how : {"first", "last", "smallest", "largest", "random"}, optional
- How to compress the values:
- - first : take the values furthest to the left
- - last : take the values furthest to the right
- - smallest : take the smallest values (if tied, may take any)
- - largest : take the largest values (if tied, may take any)
- - random : take values randomly with equal probability and without replacement
- Chosen values may not be ordered randomly
- reverse : bool, default False
- Reverse the values in each row when True
- asindex : bool, default False
- Return the column index of the value when True. If there are ties for
- "smallest" and "largest", then any valid index may be returned.
- ncols : int, optional
- The number of columns of the returned Matrix. If not specified, then
- the Matrix will be "compacted" to the smallest ncols that doesn't lose
- values.
-
- **THIS API IS EXPERIMENTAL AND MAY CHANGE**
-
- See Also
- --------
- Matrix.ss.sort
- """
- warnings.warn(
- "`Matrix.ss.compactify_rowwise` is deprecated; "
- "please use `Matrix.ss.compactify` instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- return self._compactify(
- how, reverse, asindex, "ncols", ncols, "hypercsr", "col_indices", name
- )
-
- def compactify_columnwise(
- self, how="first", nrows=None, *, reverse=False, asindex=False, name=None
- ):
- """Shift all values to the top so all values in a column are contiguous.
-
- This returns a new Matrix.
-
- Parameters
- ----------
- how : {"first", "last", "smallest", "largest", "random"}, optional
- How to compress the values:
- - first : take the values furthest to the top
- - last : take the values furthest to the bottom
- - smallest : take the smallest values (if tied, may take any)
- - largest : take the largest values (if tied, may take any)
- - random : take values randomly with equal probability and without replacement
- Chosen values may not be ordered randomly
- reverse : bool, default False
- Reverse the values in each column when True
- asindex : bool, default False
- Return the row index of the value when True. If there are ties for
- "smallest" and "largest", then any valid index may be returned.
- nrows : int, optional
- The number of rows of the returned Matrix. If not specified, then
- the Matrix will be "compacted" to the smallest nrows that doesn't lose
- values.
-
- **THIS API IS EXPERIMENTAL AND MAY CHANGE**
-
- See Also
- --------
- Matrix.ss.sort
- """
- warnings.warn(
- "`Matrix.ss.compactify_columnwise` is deprecated; "
- 'please use `Matrix.ss.compactify(order="columnwise")` instead.',
- DeprecationWarning,
- stacklevel=2,
- )
- return self._compactify(
- how, reverse, asindex, "nrows", nrows, "hypercsc", "row_indices", name
- )
-
def _compactify(self, how, reverse, asindex, nkey, nval, fmt, indices_name, name):
how = how.lower()
if how not in {"first", "last", "smallest", "largest", "random"}:
@@ -4216,23 +3984,23 @@ def sort(self, op=binary.lt, order="rowwise", *, values=True, permutation=True,
"""GxB_Matrix_sort to sort values along the rows (default) or columns of the Matrix.
Sorting moves all the elements to the left (if rowwise) or top (if columnwise) just
- like `compactify`. The returned matrices will be the same shape as the input Matrix.
+ like ``compactify``. The returned matrices will be the same shape as the input Matrix.
Parameters
----------
op : :class:`~graphblas.core.operator.BinaryOp`, optional
Binary operator with a bool return type used to sort the values.
- For example, `binary.lt` (the default) sorts the smallest elements first.
+ For example, ``binary.lt`` (the default) sorts the smallest elements first.
Ties are broken according to indices (smaller first).
order : {"rowwise", "columnwise"}, optional
Whether to sort rowwise or columnwise. Rowwise shifts all values to the left,
and columnwise shifts all values to the top. The default is "rowwise".
values : bool, default=True
- Whether to return values; will return `None` for values if `False`.
+ Whether to return values; will return ``None`` for values if ``False``.
permutation : bool, default=True
Whether to compute the permutation Matrix that has the original column
indices (if rowwise) or row indices (if columnwise) of the sorted values.
- Will return None if `False`.
+ Will return None if ``False``.
nthreads : int, optional
The maximum number of threads to use for this operation.
None, 0 or negative nthreads means to use the default number of threads.
@@ -4245,6 +4013,7 @@ def sort(self, op=binary.lt, order="rowwise", *, values=True, permutation=True,
See Also
--------
Matrix.ss.compactify
+
"""
from ..matrix import Matrix
@@ -4301,16 +4070,32 @@ def serialize(self, compression="default", level=None, **opts):
None, 0 or negative nthreads means to use the default number of threads.
For best performance, this function returns a numpy array with uint8 dtype.
- Use `Matrix.ss.deserialize(blob)` to create a Matrix from the result of serialization
+ Use ``Matrix.ss.deserialize(blob)`` to create a Matrix from the result of serialization
This method is intended to support all serialization options from SuiteSparse:GraphBLAS.
*Warning*: Behavior of serializing UDTs is experimental and may change in a future release.
+
"""
desc = get_descriptor(compression=compression, compression_level=level, **opts)
blob_handle = ffi_new("void**")
blob_size_handle = ffi_new("GrB_Index*")
parent = self._parent
+ if parent.dtype._is_udt and hasattr(lib, "GrB_Type_get_String"):
+ # Get the name from the dtype and set it to the name of the matrix so we can
+ # recreate the UDT. This is a bit hacky and we should restore the original name.
+ # First get the size of name.
+ dtype_size = ffi_new("size_t*")
+ status = lib.GrB_Type_get_SIZE(parent.dtype.gb_obj[0], dtype_size, lib.GrB_NAME)
+ check_status_carg(status, "Type", parent.dtype.gb_obj[0])
+ # Then get the name
+ dtype_char = ffi_new(f"char[{dtype_size[0]}]")
+ status = lib.GrB_Type_get_String(parent.dtype.gb_obj[0], dtype_char, lib.GrB_NAME)
+ check_status_carg(status, "Type", parent.dtype.gb_obj[0])
+ # Then set the name
+ status = lib.GrB_Matrix_set_String(parent._carg, dtype_char, lib.GrB_NAME)
+ check_status_carg(status, "Matrix", parent._carg)
+
check_status(
lib.GxB_Matrix_serialize(
blob_handle,
@@ -4327,7 +4112,7 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts):
"""Deserialize a Matrix from bytes, buffer, or numpy array using GxB_Matrix_deserialize.
The data should have been previously serialized with a compatible version of
- SuiteSparse:GraphBLAS. For example, from the result of `data = matrix.ss.serialize()`.
+ SuiteSparse:GraphBLAS. For example, from the result of ``data = matrix.ss.serialize()``.
Examples
--------
@@ -4345,14 +4130,15 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts):
nthreads : int, optional
The maximum number of threads to use when deserializing.
None, 0 or negative nthreads means to use the default number of threads.
+
"""
if isinstance(data, np.ndarray):
data = ints_to_numpy_buffer(data, np.uint8)
else:
data = np.frombuffer(data, np.uint8)
data_obj = ffi.from_buffer("void*", data)
- # Get the dtype name first
if dtype is None:
+ # Get the dtype name first (for non-UDTs)
cname = ffi_new(f"char[{lib.GxB_MAX_NAME_LEN}]")
info = lib.GxB_deserialize_type_name(
cname,
@@ -4362,6 +4148,22 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts):
if info != lib.GrB_SUCCESS:
raise _error_code_lookup[info]("Matrix deserialize failed to get the dtype name")
dtype_name = b"".join(itertools.takewhile(b"\x00".__ne__, cname)).decode()
+ if not dtype_name and hasattr(lib, "GxB_Serialized_get_String"):
+ # Handle UDTs. First get the size of name
+ dtype_size = ffi_new("size_t*")
+ info = lib.GxB_Serialized_get_SIZE(data_obj, dtype_size, lib.GrB_NAME, data.nbytes)
+ if info != lib.GrB_SUCCESS:
+ raise _error_code_lookup[info](
+ "Matrix deserialize failed to get the size of name"
+ )
+ # Then get the name
+ dtype_char = ffi_new(f"char[{dtype_size[0]}]")
+ info = lib.GxB_Serialized_get_String(
+ data_obj, dtype_char, lib.GrB_NAME, data.nbytes
+ )
+ if info != lib.GrB_SUCCESS:
+ raise _error_code_lookup[info]("Matrix deserialize failed to get the name")
+ dtype_name = ffi.string(dtype_char).decode()
dtype = _string_to_dtype(dtype_name)
else:
dtype = lookup_dtype(dtype)
@@ -4380,28 +4182,28 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts):
return rv
-@numba.njit(parallel=True)
+@njit(parallel=True)
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):
+ for i in prange(indptr.size - 1):
rv[indptr[i] : indptr[i + 1]] = indices[
np.int64(indptr[i]) + np.argsort(values[indptr[i] : indptr[i + 1]])
]
return rv
-@numba.njit(parallel=True)
+@njit(parallel=True)
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):
+ for i in prange(indptr.size - 1):
rv[indptr[i] : indptr[i + 1]] = np.sort(values[indptr[i] : indptr[i + 1]])
return rv
-@numba.njit(parallel=True)
+@njit(parallel=True)
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):
+ for i in prange(new_indptr.size - 1):
start = np.int64(new_indptr[i])
offset = np.int64(old_indptr[i]) - start
for j in range(start, new_indptr[i + 1]):
@@ -4409,17 +4211,17 @@ def compact_values(old_indptr, new_indptr, values): # pragma: no cover (numba)
return rv
-@numba.njit(parallel=True)
+@njit(parallel=True)
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):
+ for i in prange(indptr.size - 1):
offset = np.int64(indptr[i]) + np.int64(indptr[i + 1]) - 1
for j in range(indptr[i], indptr[i + 1]):
rv[j] = values[offset - j]
return rv
-@numba.njit(parallel=True)
+@njit(parallel=True)
def compact_indices(indptr, k): # pragma: no cover (numba)
"""Given indptr from hypercsr, create a new col_indices array that is compact.
@@ -4429,7 +4231,7 @@ def compact_indices(indptr, k): # pragma: no cover (numba)
indptr = create_indptr(indptr, k)
col_indices = np.empty(indptr[-1], dtype=np.uint64)
N = np.int64(0)
- for i in numba.prange(indptr.size - 1):
+ for i in prange(indptr.size - 1):
start = np.int64(indptr[i])
deg = np.int64(indptr[i + 1]) - start
N = max(N, deg)
@@ -4442,7 +4244,7 @@ def compact_indices(indptr, k): # pragma: no cover (numba)
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):
+ for i in prange(indptr.size - 1):
idx = np.int64(indptr[i])
deg = np.int64(indptr[i + 1]) - idx
if deg == 1:
@@ -4479,7 +4281,7 @@ def choose_random(indptr, k): # pragma: no cover (numba)
# be nice to have them sorted if convenient to do so.
new_indptr = create_indptr(indptr, k)
choices = np.empty(new_indptr[-1], dtype=indptr.dtype)
- for i in numba.prange(indptr.size - 1):
+ for i in prange(indptr.size - 1):
idx = np.int64(indptr[i])
deg = np.int64(indptr[i + 1]) - idx
if k < deg:
@@ -4560,7 +4362,7 @@ def choose_first(indptr, k): # pragma: no cover (numba)
new_indptr = create_indptr(indptr, k)
choices = np.empty(new_indptr[-1], dtype=indptr.dtype)
- for i in numba.prange(indptr.size - 1):
+ for i in prange(indptr.size - 1):
idx = np.int64(indptr[i])
deg = np.int64(indptr[i + 1]) - idx
if k < deg:
@@ -4584,7 +4386,7 @@ def choose_last(indptr, k): # pragma: no cover (numba)
new_indptr = create_indptr(indptr, k)
choices = np.empty(new_indptr[-1], dtype=indptr.dtype)
- for i in numba.prange(indptr.size - 1):
+ for i in prange(indptr.size - 1):
idx = np.int64(indptr[i])
deg = np.int64(indptr[i + 1]) - idx
if k < deg:
@@ -4617,19 +4419,20 @@ 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)
+ one = np.uint64(1)
for i in range(indices.size):
row = indices[i]
if row != index:
- indptr[index + 1] = i
+ indptr[index + one] = i
index = row
- indptr[index + 1] = indices.size
+ indptr[index + one] = indices.size
return indptr
@njit(parallel=True)
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 i in prange(indptr.size - 1):
for j in range(indptr[i], indptr[i + 1]):
indices[j] = i
return indices
diff --git a/graphblas/core/ss/select.py b/graphblas/core/ss/select.py
new file mode 100644
index 000000000..3ba135eee
--- /dev/null
+++ b/graphblas/core/ss/select.py
@@ -0,0 +1,89 @@
+from ... import backend, indexunary
+from ...dtypes import BOOL, lookup_dtype
+from .. import ffi
+from ..operator.base import TypedOpBase
+from ..operator.select import SelectOp, TypedUserSelectOp
+from . import _IS_SSGB7
+
+ffi_new = ffi.new
+
+
+class TypedJitSelectOp(TypedOpBase):
+ __slots__ = "_jit_c_definition"
+ opclass = "SelectOp"
+
+ def __init__(self, parent, name, type_, return_type, gb_obj, jit_c_definition, dtype2=None):
+ super().__init__(parent, name, type_, return_type, gb_obj, name, dtype2=dtype2)
+ self._jit_c_definition = jit_c_definition
+
+ @property
+ def jit_c_definition(self):
+ return self._jit_c_definition
+
+ thunk_type = TypedUserSelectOp.thunk_type
+ __call__ = TypedUserSelectOp.__call__
+
+
+def register_new(name, jit_c_definition, input_type, thunk_type):
+ """Register a new SelectOp using the SuiteSparse:GraphBLAS JIT compiler.
+
+ This creates a SelectOp by compiling the C string definition of the function.
+ It requires a shell call to a C compiler. The resulting operator will be as
+ fast as if it were built-in to SuiteSparse:GraphBLAS and does not have the
+ overhead of additional function calls as when using ``gb.select.register_new``.
+
+ This is an advanced feature that requires a C compiler and proper configuration.
+ Configuration is handled by ``gb.ss.config``; see its docstring for details.
+ By default, the JIT caches results in ``~/.SuiteSparse/``. For more information,
+ see the SuiteSparse:GraphBLAS user guide.
+
+ Only one type signature may be registered at a time, but repeated calls using
+ the same name with different input types is allowed.
+
+ This will also create an IndexUnary operator under ``gb.indexunary.ss``
+
+ Parameters
+ ----------
+ name : str
+ The name of the operator. This will show up as ``gb.select.ss.{name}``.
+ The name may contain periods, ".", which will result in nested objects
+ such as ``gb.select.ss.x.y.z`` for name ``"x.y.z"``.
+ jit_c_definition : str
+ The C definition as a string of the user-defined function. For example:
+ ``"void woot (bool *z, const int32_t *x, GrB_Index i, GrB_Index j, int32_t *y) "``
+ ``"{ (*z) = ((*x) + i + j == (*y)) ; }"``
+ input_type : dtype
+ The dtype of the operand of the select operator.
+ thunk_type : dtype
+ The dtype of the thunk of the select operator.
+
+ Returns
+ -------
+ SelectOp
+
+ See Also
+ --------
+ gb.select.register_new
+ gb.select.register_anonymous
+ gb.indexunary.ss.register_new
+
+ """
+ if backend != "suitesparse": # pragma: no cover (safety)
+ raise RuntimeError(
+ "`gb.select.ss.register_new` invalid when not using 'suitesparse' backend"
+ )
+ if _IS_SSGB7:
+ # JIT was introduced in SuiteSparse:GraphBLAS 8.0
+ import suitesparse_graphblas as ssgb
+
+ raise RuntimeError(
+ "JIT was added to SuiteSparse:GraphBLAS in version 8; "
+ f"current version is {ssgb.__version__}"
+ )
+ input_type = lookup_dtype(input_type)
+ thunk_type = lookup_dtype(thunk_type)
+ name = name if name.startswith("ss.") else f"ss.{name}"
+ # Register to both `gb.indexunary.ss` and `gb.select.ss.`
+ indexunary.ss.register_new(name, jit_c_definition, input_type, thunk_type, BOOL)
+ module, funcname = SelectOp._remove_nesting(name, strict=False)
+ return getattr(module, funcname)
diff --git a/graphblas/core/ss/unary.py b/graphblas/core/ss/unary.py
new file mode 100644
index 000000000..0b7ced3c8
--- /dev/null
+++ b/graphblas/core/ss/unary.py
@@ -0,0 +1,109 @@
+from ... import backend
+from ...dtypes import lookup_dtype
+from ...exceptions import check_status_carg
+from .. import NULL, ffi, lib
+from ..operator.base import TypedOpBase
+from ..operator.unary import TypedUserUnaryOp, UnaryOp
+from . import _IS_SSGB7
+
+ffi_new = ffi.new
+
+
+class TypedJitUnaryOp(TypedOpBase):
+ __slots__ = "_jit_c_definition"
+ opclass = "UnaryOp"
+
+ def __init__(self, parent, name, type_, return_type, gb_obj, jit_c_definition):
+ super().__init__(parent, name, type_, return_type, gb_obj, name)
+ self._jit_c_definition = jit_c_definition
+
+ @property
+ def jit_c_definition(self):
+ return self._jit_c_definition
+
+ __call__ = TypedUserUnaryOp.__call__
+
+
+def register_new(name, jit_c_definition, input_type, ret_type):
+ """Register a new UnaryOp using the SuiteSparse:GraphBLAS JIT compiler.
+
+ This creates a UnaryOp by compiling the C string definition of the function.
+ It requires a shell call to a C compiler. The resulting operator will be as
+ fast as if it were built-in to SuiteSparse:GraphBLAS and does not have the
+ overhead of additional function calls as when using ``gb.unary.register_new``.
+
+ This is an advanced feature that requires a C compiler and proper configuration.
+ Configuration is handled by ``gb.ss.config``; see its docstring for details.
+ By default, the JIT caches results in ``~/.SuiteSparse/``. For more information,
+ see the SuiteSparse:GraphBLAS user guide.
+
+ Only one type signature may be registered at a time, but repeated calls using
+ the same name with different input types is allowed.
+
+ Parameters
+ ----------
+ name : str
+ The name of the operator. This will show up as ``gb.unary.ss.{name}``.
+ The name may contain periods, ".", which will result in nested objects
+ such as ``gb.unary.ss.x.y.z`` for name ``"x.y.z"``.
+ jit_c_definition : str
+ The C definition as a string of the user-defined function. For example:
+ ``"void square (float *z, float *x) { (*z) = (*x) * (*x) ; } ;"``
+ input_type : dtype
+ The dtype of the operand of the unary operator.
+ ret_type : dtype
+ The dtype of the result of the unary operator.
+
+ Returns
+ -------
+ UnaryOp
+
+ See Also
+ --------
+ gb.unary.register_new
+ gb.unary.register_anonymous
+ gb.binary.ss.register_new
+
+ """
+ if backend != "suitesparse": # pragma: no cover (safety)
+ raise RuntimeError(
+ "`gb.unary.ss.register_new` invalid when not using 'suitesparse' backend"
+ )
+ if _IS_SSGB7:
+ # JIT was introduced in SuiteSparse:GraphBLAS 8.0
+ import suitesparse_graphblas as ssgb
+
+ raise RuntimeError(
+ "JIT was added to SuiteSparse:GraphBLAS in version 8; "
+ f"current version is {ssgb.__version__}"
+ )
+ input_type = lookup_dtype(input_type)
+ ret_type = lookup_dtype(ret_type)
+ name = name if name.startswith("ss.") else f"ss.{name}"
+ module, funcname = UnaryOp._remove_nesting(name, strict=False)
+ if hasattr(module, funcname):
+ rv = getattr(module, funcname)
+ if not isinstance(rv, UnaryOp):
+ UnaryOp._remove_nesting(name)
+ if input_type in rv.types or rv._udt_types is not None and input_type in rv._udt_types:
+ raise TypeError(f"UnaryOp gb.unary.{name} already defined for {input_type} input type")
+ else:
+ # We use `is_udt=True` to make dtype handling flexible and explicit.
+ rv = UnaryOp(name, is_udt=True)
+ gb_obj = ffi_new("GrB_UnaryOp*")
+ check_status_carg(
+ lib.GxB_UnaryOp_new(
+ gb_obj,
+ NULL,
+ ret_type._carg,
+ input_type._carg,
+ ffi_new("char[]", funcname.encode()),
+ ffi_new("char[]", jit_c_definition.encode()),
+ ),
+ "UnaryOp",
+ gb_obj[0],
+ )
+ op = TypedJitUnaryOp(rv, funcname, input_type, ret_type, gb_obj[0], jit_c_definition)
+ rv._add(op, is_jit=True)
+ setattr(module, funcname, rv)
+ return rv
diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py
index d13d78ac3..fdde7eb92 100644
--- a/graphblas/core/ss/vector.py
+++ b/graphblas/core/ss/vector.py
@@ -1,16 +1,16 @@
import itertools
import numpy as np
-from numba import njit
from suitesparse_graphblas.utils import claim_buffer, unclaim_buffer
import graphblas as gb
from ... import binary, monoid
-from ...dtypes import _INDEX, INT64, UINT64, _string_to_dtype, lookup_dtype
+from ...dtypes import _INDEX, INT64, UINT64, lookup_dtype
from ...exceptions import _error_code_lookup, check_status, check_status_carg
from .. import NULL, ffi, lib
from ..base import call
+from ..dtypes import _string_to_dtype
from ..operator import get_typed_op
from ..scalar import Scalar, _as_scalar
from ..utils import (
@@ -23,7 +23,7 @@
)
from .config import BaseConfig
from .descriptor import get_descriptor
-from .matrix import _concat_mn
+from .matrix import _concat_mn, njit
from .prefix_scan import prefix_scan
ffi_new = ffi.new
@@ -43,7 +43,7 @@ def head(vector, n=10, dtype=None, *, sort=False):
dtype = vector.dtype
else:
dtype = lookup_dtype(dtype)
- indices, vals = zip(*itertools.islice(vector.ss.iteritems(), n))
+ indices, vals = zip(*itertools.islice(vector.ss.iteritems(), n), strict=True)
return np.array(indices, np.uint64), np.array(vals, dtype.np_type)
@@ -145,8 +145,7 @@ def format(self):
return format
def build_diag(self, matrix, k=0, **opts):
- """
- GxB_Vector_diag.
+ """GxB_Vector_diag.
Extract a diagonal from a Matrix or TransposedMatrix into a Vector.
Existing entries in the Vector are discarded.
@@ -156,8 +155,8 @@ def build_diag(self, matrix, k=0, **opts):
matrix : Matrix or TransposedMatrix
Extract a diagonal from this matrix.
k : int, default 0
- Diagonal in question. Use `k>0` for diagonals above the main diagonal,
- and `k<0` for diagonals below the main diagonal.
+ Diagonal in question. Use ``k>0`` for diagonals above the main diagonal,
+ and ``k<0`` for diagonals below the main diagonal.
See Also
--------
@@ -183,15 +182,14 @@ def build_diag(self, matrix, k=0, **opts):
)
def split(self, chunks, *, name=None, **opts):
- """
- GxB_Matrix_split.
+ """GxB_Matrix_split.
- Split a Vector into a 1D array of sub-vectors according to `chunks`.
+ Split a Vector into a 1D array of sub-vectors according to ``chunks``.
This performs the opposite operation as ``concat``.
- `chunks` is short for "chunksizes" and indicates the chunk sizes.
- `chunks` may be a single integer, or a tuple or list. Example chunks:
+ ``chunks`` is short for "chunksizes" and indicates the chunk sizes.
+ ``chunks`` may be a single integer, or a tuple or list. Example chunks:
- ``chunks=10``
- Split vector into chunks of size 10 (the last chunk may be smaller).
@@ -202,6 +200,7 @@ def split(self, chunks, *, name=None, **opts):
--------
Vector.ss.concat
graphblas.ss.concat
+
"""
from ..vector import Vector
@@ -249,12 +248,11 @@ def _concat(self, tiles, m, opts):
)
def concat(self, tiles, **opts):
- """
- GxB_Matrix_concat.
+ """GxB_Matrix_concat.
Concatenate a 1D list of Vector objects into the current Vector.
Any existing values in the current Vector will be discarded.
- To concatenate into a new Vector, use `graphblas.ss.concat`.
+ To concatenate into a new Vector, use ``graphblas.ss.concat``.
This performs the opposite operation as ``split``.
@@ -262,13 +260,13 @@ def concat(self, tiles, **opts):
--------
Vector.ss.split
graphblas.ss.concat
+
"""
tiles, m, n, is_matrix = _concat_mn(tiles, is_matrix=False)
self._concat(tiles, m, opts)
def build_scalar(self, indices, value):
- """
- GxB_Vector_build_Scalar.
+ """GxB_Vector_build_Scalar.
Like ``build``, but uses a scalar for all the values.
@@ -276,6 +274,7 @@ def build_scalar(self, indices, value):
--------
Vector.build
Vector.from_coo
+
"""
indices = ints_to_numpy_buffer(indices, np.uint64, name="indices")
scalar = _as_scalar(value, self._parent.dtype, is_cscalar=False) # pragma: is_grbscalar
@@ -410,14 +409,13 @@ def iteritems(self, seek=0):
lib.GxB_Iterator_free(it_ptr)
def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **opts):
- """
- GxB_Vextor_export_xxx.
+ """GxB_Vextor_export_xxx.
Parameters
----------
format : str or None, default None
- If `format` is not specified, this method exports in the currently stored format.
- To control the export format, set `format` to one of:
+ If ``format`` is not specified, this method exports in the currently stored format.
+ To control the export format, set ``format`` to one of:
- "sparse"
- "bitmap"
- "full"
@@ -435,7 +433,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **
Returns
-------
- dict; keys depend on `format` and `raw` arguments (see below).
+ dict; keys depend on ``format`` and ``raw`` arguments (see below).
See Also
--------
@@ -443,7 +441,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **
Vector.ss.import_any
Return values
- - Note: for `raw=True`, arrays may be larger than specified.
+ - Note: for ``raw=True``, arrays may be larger than specified.
- "sparse" format
- indices : ndarray(dtype=uint64, size=nvals)
- values : ndarray(size=nvals)
@@ -468,6 +466,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **
>>> pieces = v.ss.export()
>>> v2 = Vector.ss.import_any(**pieces)
+
"""
return self._export(
format=format,
@@ -479,13 +478,12 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **
)
def unpack(self, format=None, *, sort=False, raw=False, **opts):
- """
- GxB_Vector_unpack_xxx.
+ """GxB_Vector_unpack_xxx.
- `unpack` is like `export`, except that the Vector remains valid but empty.
- `pack_*` methods are the opposite of `unpack`.
+ ``unpack`` is like ``export``, except that the Vector remains valid but empty.
+ ``pack_*`` methods are the opposite of ``unpack``.
- See `Vector.ss.export` documentation for more details.
+ See ``Vector.ss.export`` documentation for more details.
"""
return self._export(
format=format, sort=sort, give_ownership=True, raw=raw, method="unpack", opts=opts
@@ -551,9 +549,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
if is_iso:
if values.size > 1: # pragma: no cover (suitesparse)
values = values[:1]
- else:
- if values.size > nvals:
- values = values[:nvals]
+ elif values.size > nvals:
+ values = values[:nvals]
rv = {
"size": size,
"indices": indices,
@@ -589,9 +586,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
if is_iso:
if values.size > 1: # pragma: no cover (suitesparse)
values = values[:1]
- else:
- if values.size > size: # pragma: no branch (suitesparse)
- values = values[:size]
+ elif values.size > size: # pragma: no cover (suitesparse)
+ values = values[:size]
rv = {
"bitmap": bitmap,
"nvals": nvals[0],
@@ -616,9 +612,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m
if is_iso:
if values.size > 1:
values = values[:1]
- else:
- if values.size > size: # pragma: no branch (suitesparse)
- values = values[:size]
+ elif values.size > size: # pragma: no branch (suitesparse)
+ values = values[:size]
rv = {}
if raw or is_iso:
rv["size"] = size
@@ -658,11 +653,10 @@ def import_any(
nvals=None, # optional
**opts,
):
- """
- GxB_Vector_import_xxx.
+ """GxB_Vector_import_xxx.
Dispatch to appropriate import method inferred from inputs.
- See the other import functions and `Vector.ss.export`` for details.
+ See the other import functions and ``Vector.ss.export`` for details.
Returns
-------
@@ -682,6 +676,7 @@ def import_any(
>>> pieces = v.ss.export()
>>> v2 = Vector.ss.import_any(**pieces)
+
"""
return cls._import_any(
values=values,
@@ -725,13 +720,12 @@ def pack_any(
name=None,
**opts,
):
- """
- GxB_Vector_pack_xxx.
+ """GxB_Vector_pack_xxx.
- `pack_any` is like `import_any` except it "packs" data into an
+ ``pack_any`` is like ``import_any`` except it "packs" data into an
existing Vector. This is the opposite of ``unpack()``
- See `Vector.ss.import_any` documentation for more details.
+ See ``Vector.ss.import_any`` documentation for more details.
"""
return self._import_any(
values=values,
@@ -847,8 +841,7 @@ def import_sparse(
name=None,
**opts,
):
- """
- GxB_Vector_import_CSC.
+ """GxB_Vector_import_CSC.
Create a new Vector from sparse input.
@@ -862,7 +855,7 @@ def import_sparse(
If not specified, will be set to ``len(values)``.
is_iso : bool, default False
Is the Vector iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
sorted_index : bool, default False
Indicate whether the values in "col_indices" are sorted.
take_ownership : bool, default False
@@ -879,7 +872,7 @@ def import_sparse(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Vector.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "sparse" or None. This is included to be compatible with
the dict returned from exporting.
@@ -889,6 +882,7 @@ def import_sparse(
Returns
-------
Vector
+
"""
return cls._import_sparse(
size=size,
@@ -923,13 +917,12 @@ def pack_sparse(
name=None,
**opts,
):
- """
- GxB_Vector_pack_CSC.
+ """GxB_Vector_pack_CSC.
- `pack_sparse` is like `import_sparse` except it "packs" data into an
+ ``pack_sparse`` is like ``import_sparse`` except it "packs" data into an
existing Vector. This is the opposite of ``unpack("sparse")``
- See `Vector.ss.import_sparse` documentation for more details.
+ See ``Vector.ss.import_sparse`` documentation for more details.
"""
return self._import_sparse(
indices=indices,
@@ -1032,8 +1025,7 @@ def import_bitmap(
name=None,
**opts,
):
- """
- GxB_Vector_import_Bitmap.
+ """GxB_Vector_import_Bitmap.
Create a new Vector from values and bitmap (as mask) arrays.
@@ -1049,7 +1041,7 @@ def import_bitmap(
If not specified, it will be set to the size of values.
is_iso : bool, default False
Is the Vector iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
take_ownership : bool, default False
If True, perform a zero-copy data transfer from input numpy arrays
to GraphBLAS if possible. To give ownership of the underlying
@@ -1064,7 +1056,7 @@ def import_bitmap(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Vector.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "bitmap" or None. This is included to be compatible with
the dict returned from exporting.
@@ -1074,6 +1066,7 @@ def import_bitmap(
Returns
-------
Vector
+
"""
return cls._import_bitmap(
bitmap=bitmap,
@@ -1106,13 +1099,12 @@ def pack_bitmap(
name=None,
**opts,
):
- """
- GxB_Vector_pack_Bitmap.
+ """GxB_Vector_pack_Bitmap.
- `pack_bitmap` is like `import_bitmap` except it "packs" data into an
+ ``pack_bitmap`` is like ``import_bitmap`` except it "packs" data into an
existing Vector. This is the opposite of ``unpack("bitmap")``
- See `Vector.ss.import_bitmap` documentation for more details.
+ See ``Vector.ss.import_bitmap`` documentation for more details.
"""
return self._import_bitmap(
bitmap=bitmap,
@@ -1217,8 +1209,7 @@ def import_full(
name=None,
**opts,
):
- """
- GxB_Vector_import_Full.
+ """GxB_Vector_import_Full.
Create a new Vector from values.
@@ -1230,7 +1221,7 @@ def import_full(
If not specified, it will be set to the size of values.
is_iso : bool, default False
Is the Vector iso-valued (meaning all the same value)?
- If true, then `values` should be a length 1 array.
+ If true, then ``values`` should be a length 1 array.
take_ownership : bool, default False
If True, perform a zero-copy data transfer from input numpy arrays
to GraphBLAS if possible. To give ownership of the underlying
@@ -1245,7 +1236,7 @@ def import_full(
read-only and will no longer own the data.
dtype : dtype, optional
dtype of the new Vector.
- If not specified, this will be inferred from `values`.
+ If not specified, this will be inferred from ``values``.
format : str, optional
Must be "full" or None. This is included to be compatible with
the dict returned from exporting.
@@ -1255,6 +1246,7 @@ def import_full(
Returns
-------
Vector
+
"""
return cls._import_full(
values=values,
@@ -1283,13 +1275,12 @@ def pack_full(
name=None,
**opts,
):
- """
- GxB_Vector_pack_Full.
+ """GxB_Vector_pack_Full.
- `pack_full` is like `import_full` except it "packs" data into an
+ ``pack_full`` is like ``import_full`` except it "packs" data into an
existing Vector. This is the opposite of ``unpack("full")``
- See `Vector.ss.import_full` documentation for more details.
+ See ``Vector.ss.import_full`` documentation for more details.
"""
return self._import_full(
values=values,
@@ -1368,12 +1359,13 @@ def head(self, n=10, dtype=None, *, sort=False):
def scan(self, op=monoid.plus, *, name=None, **opts):
"""Perform a prefix scan with the given monoid.
- For example, use `monoid.plus` (the default) to perform a cumulative sum,
- and `monoid.times` for cumulative product. Works with any monoid.
+ For example, use ``monoid.plus`` (the default) to perform a cumulative sum,
+ and ``monoid.times`` for cumulative product. Works with any monoid.
Returns
-------
Scalar
+
"""
return prefix_scan(self._parent, op, name=name, within="scan", **opts)
@@ -1404,6 +1396,7 @@ def reshape(self, nrows, ncols=None, order="rowwise", *, name=None, **opts):
See Also
--------
Matrix.ss.flatten : flatten a Matrix into a Vector.
+
"""
return self._parent._as_matrix().ss.reshape(nrows, ncols, order, name=name, **opts)
@@ -1423,6 +1416,7 @@ def selectk(self, how, k, *, name=None):
The number of elements to choose
**THIS API IS EXPERIMENTAL AND MAY CHANGE**
+
"""
how = how.lower()
if k < 0:
@@ -1565,20 +1559,20 @@ def compactify(self, how="first", size=None, *, reverse=False, asindex=False, na
def sort(self, op=binary.lt, *, values=True, permutation=True, **opts):
"""GxB_Vector_sort to sort values of the Vector.
- Sorting moves all the elements to the left just like `compactify`.
+ Sorting moves all the elements to the left just like ``compactify``.
The returned vectors will be the same size as the input Vector.
Parameters
----------
op : :class:`~graphblas.core.operator.BinaryOp`, optional
Binary operator with a bool return type used to sort the values.
- For example, `binary.lt` (the default) sorts the smallest elements first.
+ For example, ``binary.lt`` (the default) sorts the smallest elements first.
Ties are broken according to indices (smaller first).
values : bool, default=True
- Whether to return values; will return `None` for values if `False`.
+ Whether to return values; will return ``None`` for values if ``False``.
permutation : bool, default=True
Whether to compute the permutation Vector that has the original indices of the
- sorted values. Will return None if `False`.
+ sorted values. Will return None if ``False``.
nthreads : int, optional
The maximum number of threads to use for this operation.
None, 0 or negative nthreads means to use the default number of threads.
@@ -1591,6 +1585,7 @@ def sort(self, op=binary.lt, *, values=True, permutation=True, **opts):
See Also
--------
Vector.ss.compactify
+
"""
from ..vector import Vector
@@ -1646,16 +1641,32 @@ def serialize(self, compression="default", level=None, **opts):
None, 0 or negative nthreads means to use the default number of threads.
For best performance, this function returns a numpy array with uint8 dtype.
- Use `Vector.ss.deserialize(blob)` to create a Vector from the result of serialization·
+ Use ``Vector.ss.deserialize(blob)`` to create a Vector from the result of serialization·
This method is intended to support all serialization options from SuiteSparse:GraphBLAS.
*Warning*: Behavior of serializing UDTs is experimental and may change in a future release.
+
"""
desc = get_descriptor(compression=compression, compression_level=level, **opts)
blob_handle = ffi_new("void**")
blob_size_handle = ffi_new("GrB_Index*")
parent = self._parent
+ if parent.dtype._is_udt and hasattr(lib, "GrB_Type_get_String"):
+ # Get the name from the dtype and set it to the name of the vector so we can
+ # recreate the UDT. This is a bit hacky and we should restore the original name.
+ # First get the size of name.
+ dtype_size = ffi_new("size_t*")
+ status = lib.GrB_Type_get_SIZE(parent.dtype.gb_obj[0], dtype_size, lib.GrB_NAME)
+ check_status_carg(status, "Type", parent.dtype.gb_obj[0])
+ # Then get the name
+ dtype_char = ffi_new(f"char[{dtype_size[0]}]")
+ status = lib.GrB_Type_get_String(parent.dtype.gb_obj[0], dtype_char, lib.GrB_NAME)
+ check_status_carg(status, "Type", parent.dtype.gb_obj[0])
+ # Then set the name
+ status = lib.GrB_Vector_set_String(parent._carg, dtype_char, lib.GrB_NAME)
+ check_status_carg(status, "Vector", parent._carg)
+
check_status(
lib.GxB_Vector_serialize(
blob_handle,
@@ -1672,7 +1683,7 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts):
"""Deserialize a Vector from bytes, buffer, or numpy array using GxB_Vector_deserialize.
The data should have been previously serialized with a compatible version of
- SuiteSparse:GraphBLAS. For example, from the result of `data = vector.ss.serialize()`.
+ SuiteSparse:GraphBLAS. For example, from the result of ``data = vector.ss.serialize()``.
Examples
--------
@@ -1690,6 +1701,7 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts):
nthreads : int, optional
The maximum number of threads to use when deserializing.
None, 0 or negative nthreads means to use the default number of threads.
+
"""
if isinstance(data, np.ndarray):
data = ints_to_numpy_buffer(data, np.uint8)
@@ -1697,7 +1709,7 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts):
data = np.frombuffer(data, np.uint8)
data_obj = ffi.from_buffer("void*", data)
if dtype is None:
- # Get the dtype name first
+ # Get the dtype name first (for non-UDTs)
cname = ffi_new(f"char[{lib.GxB_MAX_NAME_LEN}]")
info = lib.GxB_deserialize_type_name(
cname,
@@ -1707,6 +1719,22 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts):
if info != lib.GrB_SUCCESS:
raise _error_code_lookup[info]("Vector deserialize failed to get the dtype name")
dtype_name = b"".join(itertools.takewhile(b"\x00".__ne__, cname)).decode()
+ if not dtype_name and hasattr(lib, "GxB_Serialized_get_String"):
+ # Handle UDTs. First get the size of name
+ dtype_size = ffi_new("size_t*")
+ info = lib.GxB_Serialized_get_SIZE(data_obj, dtype_size, lib.GrB_NAME, data.nbytes)
+ if info != lib.GrB_SUCCESS:
+ raise _error_code_lookup[info](
+ "Vector deserialize failed to get the size of name"
+ )
+ # Then get the name
+ dtype_char = ffi_new(f"char[{dtype_size[0]}]")
+ info = lib.GxB_Serialized_get_String(
+ data_obj, dtype_char, lib.GrB_NAME, data.nbytes
+ )
+ if info != lib.GrB_SUCCESS:
+ raise _error_code_lookup[info]("Vector deserialize failed to get the name")
+ dtype_name = ffi.string(dtype_char).decode()
dtype = _string_to_dtype(dtype_name)
else:
dtype = lookup_dtype(dtype)
diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py
index 0beeb4a2a..e9a29b3a9 100644
--- a/graphblas/core/utils.py
+++ b/graphblas/core/utils.py
@@ -1,17 +1,19 @@
-from numbers import Integral, Number
+from operator import index
import numpy as np
from ..dtypes import _INDEX, lookup_dtype
from . import ffi, lib
+_NP2 = np.__version__.startswith("2.")
+
def libget(name):
"""Helper to get items from GraphBLAS which might be GrB or GxB."""
try:
return getattr(lib, name)
except AttributeError:
- if name[-4:] not in {"FC32", "FC64", "error"}:
+ if name[-4:] not in {"FC32", "FC64", "rror"}:
raise
ext_name = f"GxB_{name[4:]}"
try:
@@ -22,7 +24,7 @@ def libget(name):
def wrapdoc(func_with_doc):
- """Decorator to copy `__doc__` from a function onto the wrapped function."""
+ """Decorator to copy ``__doc__`` from a function onto the wrapped function."""
def inner(func_wo_doc):
func_wo_doc.__doc__ = func_with_doc.__doc__
@@ -43,7 +45,7 @@ def inner(func_wo_doc):
object: object,
type: type,
}
-_output_types.update((k, k) for k in np.cast)
+_output_types.update((k, k) for k in set(np.sctypeDict.values()))
def output_type(val):
@@ -60,7 +62,8 @@ def ints_to_numpy_buffer(array, dtype, *, name="array", copy=False, ownable=Fals
and not np.issubdtype(array.dtype, np.bool_)
):
raise ValueError(f"{name} must be integers, not {array.dtype.name}")
- array = np.array(array, dtype, copy=copy, order=order)
+ # https://numpy.org/doc/stable/release/2.0.0-notes.html#new-copy-keyword-meaning-for-array-and-asarray-constructors
+ array = np.array(array, dtype, copy=copy or _NP2 and None, order=order)
if ownable and (not array.flags.owndata or not array.flags.writeable):
array = array.copy(order)
return array
@@ -86,13 +89,18 @@ def values_to_numpy_buffer(
-------
np.ndarray
dtype
+
"""
if dtype is not None:
dtype = lookup_dtype(dtype)
- array = np.array(array, _get_subdtype(dtype.np_type), copy=copy, order=order)
+ # https://numpy.org/doc/stable/release/2.0.0-notes.html#new-copy-keyword-meaning-for-array-and-asarray-constructors
+ array = np.array(
+ array, _get_subdtype(dtype.np_type), copy=copy or _NP2 and None, order=order
+ )
else:
is_input_np = isinstance(array, np.ndarray)
- array = np.array(array, copy=copy, order=order)
+ # https://numpy.org/doc/stable/release/2.0.0-notes.html#new-copy-keyword-meaning-for-array-and-asarray-constructors
+ array = np.array(array, copy=copy or _NP2 and None, order=order)
if array.dtype.hasobject:
raise ValueError("object dtype for values is not allowed")
if not is_input_np and array.dtype == np.int32: # pragma: no cover
@@ -131,6 +139,7 @@ def get_shape(nrows, ncols, dtype=None, **arrays):
# We could be smarter and determine the shape of the dtype sub-arrays
if arr.ndim >= 3:
break
+ # BRANCH NOT COVERED
elif arr.ndim == 2:
break
else:
@@ -157,8 +166,19 @@ def get_order(order):
)
+def maybe_integral(val):
+ """Ensure ``val`` is an integer or return None if it's not."""
+ try:
+ return index(val)
+ except TypeError:
+ pass
+ if isinstance(val, float) and val.is_integer():
+ return int(val)
+ return None
+
+
def normalize_chunks(chunks, shape):
- """Normalize chunks argument for use by `Matrix.ss.split`.
+ """Normalize chunks argument for use by ``Matrix.ss.split``.
Examples
--------
@@ -171,11 +191,12 @@ def normalize_chunks(chunks, shape):
[(10,), (5, 15)]
>>> normalize_chunks((5, (5, None)), shape)
[(5, 5), (5, 15)]
+
"""
if isinstance(chunks, (list, tuple)):
pass
- elif isinstance(chunks, Number):
- chunks = (chunks,) * len(shape)
+ elif (chunk := maybe_integral(chunks)) is not None:
+ chunks = (chunk,) * len(shape)
elif isinstance(chunks, np.ndarray):
chunks = chunks.tolist()
else:
@@ -188,25 +209,24 @@ def normalize_chunks(chunks, shape):
f"chunks argument must be of length {len(shape)} (one for each dimension of a {typ})"
)
chunksizes = []
- for size, chunk in zip(shape, chunks):
+ for size, chunk in zip(shape, chunks, strict=True):
if chunk is None:
cur_chunks = [size]
- elif isinstance(chunk, Integral) or isinstance(chunk, float) and chunk.is_integer():
- chunk = int(chunk)
- if chunk < 0:
- raise ValueError(f"Chunksize must be greater than 0; got: {chunk}")
- div, mod = divmod(size, chunk)
- cur_chunks = [chunk] * div
+ elif (c := maybe_integral(chunk)) is not None:
+ if c < 0:
+ raise ValueError(f"Chunksize must be greater than 0; got: {c}")
+ div, mod = divmod(size, c)
+ cur_chunks = [c] * div
if mod:
cur_chunks.append(mod)
elif isinstance(chunk, (list, tuple)):
cur_chunks = []
none_index = None
for c in chunk:
- if isinstance(c, Integral) or isinstance(c, float) and c.is_integer():
- c = int(c)
- if c < 0:
- raise ValueError(f"Chunksize must be greater than 0; got: {c}")
+ if (val := maybe_integral(c)) is not None:
+ if val < 0:
+ raise ValueError(f"Chunksize must be greater than 0; got: {val}")
+ c = val
elif c is None:
if none_index is not None:
raise TypeError(
@@ -248,17 +268,17 @@ def normalize_chunks(chunks, shape):
def ensure_type(x, types):
- """Try to ensure `x` is one of the given types, computing if necessary.
+ """Try to ensure ``x`` is one of the given types, computing if necessary.
- `types` must be a type or a tuple of types as used in `isinstance`.
+ ``types`` must be a type or a tuple of types as used in ``isinstance``.
- For example, if `types` is a Vector, then a Vector input will be returned,
- and a `VectorExpression` input will be computed and returned as a Vector.
+ For example, if ``types`` is a Vector, then a Vector input will be returned,
+ and a ``VectorExpression`` input will be computed and returned as a Vector.
TypeError will be raised if the input is not or can't be converted to types.
- This function ignores `graphblas.config["autocompute"]`; it always computes
- if the return type will match `types`.
+ This function ignores ``graphblas.config["autocompute"]``; it always computes
+ if the return type will match ``types``.
"""
if isinstance(x, types):
return x
@@ -299,7 +319,10 @@ def __init__(self, array=None, dtype=_INDEX, *, size=None, name=None):
if size is not None:
self.array = np.empty(size, dtype=dtype.np_type)
else:
- self.array = np.array(array, dtype=_get_subdtype(dtype.np_type), copy=False, order="C")
+ # https://numpy.org/doc/stable/release/2.0.0-notes.html#new-copy-keyword-meaning-for-array-and-asarray-constructors
+ self.array = np.array(
+ array, dtype=_get_subdtype(dtype.np_type), copy=_NP2 and None, order="C"
+ )
c_type = dtype.c_type if dtype._is_udt else f"{dtype.c_type}*"
self._carg = ffi.cast(c_type, ffi.from_buffer(self.array))
self.dtype = dtype
@@ -357,6 +380,7 @@ def _autogenerate_code(
specializer=None,
begin="# Begin auto-generated code",
end="# End auto-generated code",
+ callblack=True,
):
"""Super low-tech auto-code generation used by automethods.py and infixmethods.py."""
with filepath.open() as f: # pragma: no branch (flaky)
@@ -383,7 +407,8 @@ def _autogenerate_code(
f.write(new_text)
import subprocess
- try:
- subprocess.check_call(["black", filepath])
- except FileNotFoundError: # pragma: no cover (safety)
- pass # It's okay if `black` isn't installed; pre-commit hooks will do linting
+ if callblack:
+ try:
+ subprocess.check_call(["black", filepath])
+ except FileNotFoundError: # pragma: no cover (safety)
+ pass # It's okay if `black` isn't installed; pre-commit hooks will do linting
diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py
index dd183d856..8bac4198e 100644
--- a/graphblas/core/vector.py
+++ b/graphblas/core/vector.py
@@ -1,17 +1,23 @@
import itertools
-import warnings
import numpy as np
-from .. import backend, binary, monoid, select, semiring
+from .. import backend, binary, monoid, select, semiring, unary
from ..dtypes import _INDEX, FP64, INT64, lookup_dtype, unify
from ..exceptions import DimensionMismatch, NoValue, check_status
-from . import automethods, ffi, lib, utils
+from . import _supports_udfs, automethods, ffi, lib, utils
from .base import BaseExpression, BaseType, _check_mask, call
from .descriptor import lookup as descriptor_lookup
-from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, Updater
+from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, InfixExprBase, Updater
from .mask import Mask, StructuralMask, ValueMask
-from .operator import UNKNOWN_OPCLASS, find_opclass, get_semiring, get_typed_op, op_from_string
+from .operator import (
+ UNKNOWN_OPCLASS,
+ _get_typed_op_from_exprs,
+ find_opclass,
+ get_semiring,
+ get_typed_op,
+ op_from_string,
+)
from .scalar import (
_COMPLETE,
_MATERIALIZE,
@@ -61,13 +67,13 @@ def _v_union_m(updater, left, right, left_default, right_default, op):
updater << temp.ewise_union(right, op, left_default=left_default, right_default=right_default)
-def _v_union_v(updater, left, right, left_default, right_default, op, dtype):
+def _v_union_v(updater, left, right, left_default, right_default, op):
mask = updater.kwargs.get("mask")
opts = updater.opts
- new_left = left.dup(dtype, clear=True)
+ new_left = left.dup(op.type, clear=True)
new_left(mask=mask, **opts) << binary.second(right, left_default)
new_left(mask=mask, **opts) << binary.first(left | new_left)
- new_right = right.dup(dtype, clear=True)
+ new_right = right.dup(op.type2, clear=True)
new_right(mask=mask, **opts) << binary.second(left, right_default)
new_right(mask=mask, **opts) << binary.first(right | new_right)
updater << op(new_left & new_right)
@@ -93,6 +99,45 @@ def _select_mask(updater, obj, mask):
updater << obj.dup(mask=mask)
+def _isclose_recipe(self, other, rel_tol, abs_tol, **opts):
+ # x == y or abs(x - y) <= max(rel_tol * max(abs(x), abs(y)), abs_tol)
+ isequal = self.ewise_mult(other, binary.eq).new(bool, name="isclose", **opts)
+ if isequal._nvals != self._nvals:
+ return False
+ if type(isequal) is Vector:
+ val = isequal.reduce(monoid.land, allow_empty=False).new(**opts).value
+ else:
+ val = isequal.reduce_scalar(monoid.land, allow_empty=False).new(**opts).value
+ if val:
+ return True
+ # So we can use structural mask below
+ isequal(**opts) << select.value(isequal == True) # noqa: E712
+
+ # abs(x)
+ x = self.apply(unary.abs).new(FP64, mask=~isequal.S, **opts)
+ # abs(y)
+ y = other.apply(unary.abs).new(FP64, mask=~isequal.S, **opts)
+ # max(abs(x), abs(y))
+ x(**opts) << x.ewise_mult(y, binary.max)
+ max_x_y = x
+ # rel_tol * max(abs(x), abs(y))
+ max_x_y(**opts) << max_x_y.apply(binary.times, rel_tol)
+ # max(rel_tol * max(abs(x), abs(y)), abs_tol)
+ max_x_y(**opts) << max_x_y.apply(binary.max, abs_tol)
+
+ # x - y
+ y(~isequal.S, replace=True, **opts) << self.ewise_mult(other, binary.minus)
+ abs_x_y = y
+ # abs(x - y)
+ abs_x_y(**opts) << abs_x_y.apply(unary.abs)
+
+ # abs(x - y) <= max(rel_tol * max(abs(x), abs(y)), abs_tol)
+ isequal(**opts) << abs_x_y.ewise_mult(max_x_y, binary.le)
+ if isequal.ndim == 1:
+ return isequal.reduce(monoid.land, allow_empty=False).new(**opts).value
+ return isequal.reduce_scalar(monoid.land, allow_empty=False).new(**opts).value
+
+
class Vector(BaseType):
"""Create a new GraphBLAS Sparse Vector.
@@ -104,6 +149,7 @@ class Vector(BaseType):
Size of the Vector.
name : str, optional
Name to give the Vector. This will be displayed in the ``__repr__``.
+
"""
__slots__ = "_size", "_parent", "ss"
@@ -220,6 +266,7 @@ def __delitem__(self, keys, **opts):
Examples
--------
>>> del v[1:-1]
+
"""
del Updater(self, opts=opts)[keys]
@@ -234,6 +281,7 @@ def __getitem__(self, keys):
.. code-block:: python
sub_v = v[[1, 3, 5]].new()
+
"""
resolved_indexes = IndexerResolver(self, keys)
shape = resolved_indexes.shape
@@ -253,6 +301,7 @@ def __setitem__(self, keys, expr, **opts):
# This makes a dense iso-value vector
v[:] = 1
+
"""
Updater(self, opts=opts)[keys] = expr
@@ -265,6 +314,7 @@ def __contains__(self, index):
# Check if v[15] is non-empty
15 in v
+
"""
extractor = self[index]
if not extractor._is_scalar:
@@ -304,6 +354,7 @@ def isequal(self, other, *, check_dtype=False, **opts):
See Also
--------
:meth:`isclose` : For equality check of floating point dtypes
+
"""
other = self._expect_type(other, Vector, within="isequal", argname="other")
if check_dtype and self.dtype != other.dtype:
@@ -346,6 +397,7 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False, **opts
Returns
-------
bool
+
"""
other = self._expect_type(other, Vector, within="isclose", argname="other")
if check_dtype and self.dtype != other.dtype:
@@ -354,6 +406,8 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False, **opts
return False
if self._nvals != other._nvals:
return False
+ if not _supports_udfs:
+ return _isclose_recipe(self, other, rel_tol, abs_tol, **opts)
matches = self.ewise_mult(other, binary.isclose(rel_tol, abs_tol)).new(
bool, name="M_isclose", **opts
@@ -408,36 +462,6 @@ def resize(self, size):
call("GrB_Vector_resize", [self, size])
self._size = size.value
- def to_values(self, dtype=None, *, indices=True, values=True, sort=True):
- """Extract the indices and values as a 2-tuple of numpy arrays.
-
- .. deprecated:: 2022.11.0
- `Vector.to_values` will be removed in a future release.
- Use `Vector.to_coo` instead. Will be removed in version 2023.9.0 or later
-
- Parameters
- ----------
- dtype :
- Requested dtype for the output values array.
- indices :bool, default=True
- Whether to return indices; will return `None` for indices if `False`
- values : bool, default=True
- Whether to return values; will return `None` for values if `False`
- sort : bool, default=True
- Whether to require sorted indices.
-
- Returns
- -------
- np.ndarray[dtype=uint64] : Indices
- np.ndarray : Values
- """
- warnings.warn(
- "`Vector.to_values(...)` is deprecated; please use `Vector.to_coo(...)` instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- return self.to_coo(dtype, indices=indices, values=values, sort=sort)
-
def to_coo(self, dtype=None, *, indices=True, values=True, sort=True):
"""Extract the indices and values as a 2-tuple of numpy arrays.
@@ -446,9 +470,9 @@ def to_coo(self, dtype=None, *, indices=True, values=True, sort=True):
dtype :
Requested dtype for the output values array.
indices :bool, default=True
- Whether to return indices; will return `None` for indices if `False`
+ Whether to return indices; will return ``None`` for indices if ``False``
values : bool, default=True
- Whether to return values; will return `None` for values if `False`
+ Whether to return values; will return ``None`` for values if ``False``
sort : bool, default=True
Whether to require sorted indices.
@@ -462,6 +486,7 @@ def to_coo(self, dtype=None, *, indices=True, values=True, sort=True):
-------
np.ndarray[dtype=uint64] : Indices
np.ndarray : Values
+
"""
if sort and backend == "suitesparse":
self.wait() # sort in SS
@@ -498,7 +523,7 @@ def build(self, indices, values, *, dup_op=None, clear=False, size=None):
"""Rarely used method to insert values into an existing Vector. The typical use case
is to create a new Vector and insert values at the same time using :meth:`from_coo`.
- All the arguments are used identically in :meth:`from_coo`, except for `clear`, which
+ All the arguments are used identically in :meth:`from_coo`, except for ``clear``, which
indicates whether to clear the Vector prior to adding the new values.
"""
# TODO: accept `dtype` keyword to match the dtype of `values`?
@@ -520,14 +545,15 @@ def build(self, indices, values, *, dup_op=None, clear=False, size=None):
if not dup_op_given:
if not self.dtype._is_udt:
dup_op = binary.plus
- else:
+ elif backend != "suitesparse":
dup_op = binary.any
- # SS:SuiteSparse-specific: we could use NULL for dup_op
- dup_op = get_typed_op(dup_op, self.dtype, kind="binary")
- if dup_op.opclass == "Monoid":
- dup_op = dup_op.binaryop
- else:
- self._expect_op(dup_op, "BinaryOp", within="build", argname="dup_op")
+ # SS:SuiteSparse-specific: we use NULL for dup_op
+ if dup_op is not None:
+ dup_op = get_typed_op(dup_op, self.dtype, kind="binary")
+ if dup_op.opclass == "Monoid":
+ dup_op = dup_op.binaryop
+ else:
+ self._expect_op(dup_op, "BinaryOp", within="build", argname="dup_op")
indices = _CArray(indices)
values = _CArray(values, self.dtype)
@@ -560,6 +586,7 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts):
Returns
-------
Vector
+
"""
if dtype is not None or mask is not None or clear:
if dtype is None:
@@ -570,7 +597,7 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts):
else:
if opts:
# Ignore opts for now
- descriptor_lookup(**opts)
+ desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context)
rv = Vector._from_obj(ffi_new("GrB_Vector*"), self.dtype, self._size, name=name)
call("GrB_Vector_dup", [_Pointer(rv), self])
return rv
@@ -590,6 +617,7 @@ def diag(self, k=0, *, name=None):
Returns
-------
:class:`~graphblas.Matrix`
+
"""
from .matrix import Matrix
@@ -614,6 +642,7 @@ def wait(self, how="materialize"):
Use wait to force completion of the Vector.
Has no effect in `blocking mode <../user_guide/init.html#graphblas-modes>`__.
+
"""
how = how.lower()
if how == "materialize":
@@ -638,6 +667,7 @@ def get(self, index, default=None):
Returns
-------
Python scalar
+
"""
expr = self[index]
if expr._is_scalar:
@@ -648,43 +678,6 @@ def get(self, index, default=None):
"A single index should be given, and the result will be a Python scalar."
)
- @classmethod
- def from_values(cls, indices, values, dtype=None, *, size=None, dup_op=None, name=None):
- """Create a new Vector from indices and values.
-
- .. deprecated:: 2022.11.0
- `Vector.from_values` will be removed in a future release.
- Use `Vector.from_coo` instead. Will be removed in version 2023.9.0 or later
-
- Parameters
- ----------
- indices : list or np.ndarray
- Vector indices.
- values : list or np.ndarray or scalar
- List of values. If a scalar is provided, all values will be set to this single value.
- dtype :
- Data type of the Vector. If not provided, the values will be inspected
- to choose an appropriate dtype.
- size : int, optional
- Size of the Vector. If not provided, ``size`` is computed from
- the maximum index found in ``indices``.
- dup_op : BinaryOp, optional
- Function used to combine values if duplicate indices are found.
- Leaving ``dup_op=None`` will raise an error if duplicates are found.
- name : str, optional
- Name to give the Vector.
-
- Returns
- -------
- Vector
- """
- warnings.warn(
- "`Vector.from_values(...)` is deprecated; please use `Vector.from_coo(...)` instead.",
- DeprecationWarning,
- stacklevel=2,
- )
- return cls.from_coo(indices, values, dtype, size=size, dup_op=dup_op, name=name)
-
@classmethod
def from_coo(cls, indices, values=1.0, dtype=None, *, size=None, dup_op=None, name=None):
"""Create a new Vector from indices and values.
@@ -717,6 +710,7 @@ def from_coo(cls, indices, values=1.0, dtype=None, *, size=None, dup_op=None, na
Returns
-------
Vector
+
"""
indices = ints_to_numpy_buffer(indices, np.uint64, name="indices")
values, dtype = values_to_numpy_buffer(values, dtype, subarray_after=1)
@@ -774,10 +768,11 @@ def from_pairs(cls, pairs, dtype=None, *, size=None, dup_op=None, name=None):
Returns
-------
Vector
+
"""
if isinstance(pairs, np.ndarray):
raise TypeError("pairs as NumPy array is not supported; use `Vector.from_coo` instead")
- unzipped = list(zip(*pairs))
+ unzipped = list(zip(*pairs, strict=True))
if len(unzipped) == 2:
indices, values = unzipped
elif not unzipped:
@@ -825,6 +820,7 @@ def from_scalar(cls, value, size, dtype=None, *, name=None, **opts):
Returns
-------
Vector
+
"""
if type(value) is not Scalar:
try:
@@ -877,6 +873,7 @@ def from_dense(cls, values, missing_value=None, *, dtype=None, name=None, **opts
Returns
-------
Vector
+
"""
values, dtype = values_to_numpy_buffer(values, dtype, subarray_after=1)
if values.ndim == 0:
@@ -925,6 +922,7 @@ def to_dense(self, fill_value=None, dtype=None, **opts):
Returns
-------
np.ndarray
+
"""
if fill_value is None or self._nvals == self._size:
if self._nvals != self._size:
@@ -995,16 +993,43 @@ def ewise_add(self, other, op=monoid.plus):
# Functional syntax
w << monoid.max(u | v)
+
"""
+ return self._ewise_add(other, op)
+
+ def _ewise_add(self, other, op=monoid.plus, is_infix=False):
from .matrix import Matrix, MatrixExpression, TransposedMatrix
method_name = "ewise_add"
- other = self._expect_type(
- other, (Vector, Matrix, TransposedMatrix), within=method_name, argname="other", op=op
- )
- 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.
- self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op")
+ if is_infix:
+ from .infix import MatrixEwiseAddExpr, VectorEwiseAddExpr
+
+ other = self._expect_type(
+ other,
+ (Vector, Matrix, TransposedMatrix, MatrixEwiseAddExpr, VectorEwiseAddExpr),
+ within=method_name,
+ argname="other",
+ op=op,
+ )
+ op = _get_typed_op_from_exprs(op, self, other, kind="binary")
+ # Per the spec, op may be a semiring, but this is weird, so don't.
+ self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op")
+ if isinstance(self, VectorEwiseAddExpr):
+ self = op(self).new()
+ if isinstance(other, InfixExprBase):
+ other = op(other).new()
+ else:
+ other = self._expect_type(
+ other,
+ (Vector, Matrix, TransposedMatrix),
+ within=method_name,
+ argname="other",
+ op=op,
+ )
+ 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.
+ self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op")
+
if other.ndim == 2:
# Broadcast columnwise from the left
if other._nrows != self._size:
@@ -1060,16 +1085,42 @@ def ewise_mult(self, other, op=binary.times):
# Functional syntax
w << binary.gt(u & v)
+
"""
+ return self._ewise_mult(other, op)
+
+ def _ewise_mult(self, other, op=binary.times, is_infix=False):
from .matrix import Matrix, MatrixExpression, TransposedMatrix
method_name = "ewise_mult"
- other = self._expect_type(
- other, (Vector, Matrix, TransposedMatrix), within=method_name, argname="other", op=op
- )
- 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.
- self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op")
+ if is_infix:
+ from .infix import MatrixEwiseMultExpr, VectorEwiseMultExpr
+
+ other = self._expect_type(
+ other,
+ (Vector, Matrix, TransposedMatrix, MatrixEwiseMultExpr, VectorEwiseMultExpr),
+ within=method_name,
+ argname="other",
+ op=op,
+ )
+ op = _get_typed_op_from_exprs(op, self, other, kind="binary")
+ # Per the spec, op may be a semiring, but this is weird, so don't.
+ self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op")
+ if isinstance(self, VectorEwiseMultExpr):
+ self = op(self).new()
+ if isinstance(other, InfixExprBase):
+ other = op(other).new()
+ else:
+ other = self._expect_type(
+ other,
+ (Vector, Matrix, TransposedMatrix),
+ within=method_name,
+ argname="other",
+ op=op,
+ )
+ 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.
+ self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op")
if other.ndim == 2:
# Broadcast columnwise from the left
if other._nrows != self._size:
@@ -1128,14 +1179,37 @@ def ewise_union(self, other, op, left_default, right_default):
# Functional syntax
w << binary.div(u | v, left_default=1, right_default=1)
+
"""
+ return self._ewise_union(other, op, left_default, right_default)
+
+ def _ewise_union(self, other, op, left_default, right_default, is_infix=False):
from .matrix import Matrix, MatrixExpression, TransposedMatrix
method_name = "ewise_union"
- other = self._expect_type(
- other, (Vector, Matrix, TransposedMatrix), within=method_name, argname="other", op=op
- )
- dtype = self.dtype if self.dtype._is_udt else None
+ if is_infix:
+ from .infix import MatrixEwiseAddExpr, VectorEwiseAddExpr
+
+ other = self._expect_type(
+ other,
+ (Vector, Matrix, TransposedMatrix, MatrixEwiseAddExpr, VectorEwiseAddExpr),
+ within=method_name,
+ argname="other",
+ op=op,
+ )
+ temp_op = _get_typed_op_from_exprs(op, self, other, kind="binary")
+ else:
+ other = self._expect_type(
+ other,
+ (Vector, Matrix, TransposedMatrix),
+ within=method_name,
+ argname="other",
+ op=op,
+ )
+ temp_op = get_typed_op(op, self.dtype, other.dtype, kind="binary")
+
+ left_dtype = temp_op.type
+ dtype = left_dtype if left_dtype._is_udt else None
if type(left_default) is not Scalar:
try:
left = Scalar.from_value(
@@ -1152,6 +1226,8 @@ def ewise_union(self, other, op, left_default, right_default):
)
else:
left = _as_scalar(left_default, dtype, is_cscalar=False) # pragma: is_grbscalar
+ right_dtype = temp_op.type2
+ dtype = right_dtype if right_dtype._is_udt else None
if type(right_default) is not Scalar:
try:
right = Scalar.from_value(
@@ -1168,12 +1244,29 @@ def ewise_union(self, other, op, left_default, right_default):
)
else:
right = _as_scalar(right_default, dtype, is_cscalar=False) # pragma: is_grbscalar
- scalar_dtype = unify(left.dtype, right.dtype)
- nonscalar_dtype = unify(self.dtype, other.dtype)
- op = get_typed_op(op, scalar_dtype, nonscalar_dtype, is_left_scalar=True, kind="binary")
+
+ if is_infix:
+ op1 = _get_typed_op_from_exprs(op, self, right, kind="binary")
+ op2 = _get_typed_op_from_exprs(op, left, other, kind="binary")
+ else:
+ op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary")
+ op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary")
+ if op1 is not op2:
+ left_dtype = unify(op1.type, op2.type, is_right_scalar=True)
+ right_dtype = unify(op1.type2, op2.type2, is_left_scalar=True)
+ op = get_typed_op(op, left_dtype, right_dtype, kind="binary")
+ else:
+ op = op1
self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op")
if op.opclass == "Monoid":
op = op.binaryop
+
+ if is_infix:
+ if isinstance(self, VectorEwiseAddExpr):
+ self = op(self, left_default=left, right_default=right).new()
+ if isinstance(other, InfixExprBase):
+ other = op(other, left_default=left, right_default=right).new()
+
expr_repr = "{0.name}.{method_name}({2.name}, {op}, {1._expr_name}, {3._expr_name})"
if other.ndim == 2:
# Broadcast columnwise from the left
@@ -1201,11 +1294,10 @@ def ewise_union(self, other, op, left_default, right_default):
expr_repr=expr_repr,
)
else:
- dtype = unify(scalar_dtype, nonscalar_dtype, is_left_scalar=True)
expr = VectorExpression(
method_name,
None,
- [self, left, other, right, _v_union_v, (self, other, left, right, op, dtype)],
+ [self, left, other, right, _v_union_v, (self, other, left, right, op)],
expr_repr=expr_repr,
size=self._size,
op=op,
@@ -1242,15 +1334,37 @@ def vxm(self, other, op=semiring.plus_times):
# Functional syntax
C << semiring.min_plus(v @ A)
+
"""
+ return self._vxm(other, op)
+
+ def _vxm(self, other, op=semiring.plus_times, is_infix=False):
from .matrix import Matrix, TransposedMatrix
method_name = "vxm"
- other = self._expect_type(
- other, (Matrix, TransposedMatrix), within=method_name, argname="other", op=op
- )
- op = get_typed_op(op, self.dtype, other.dtype, kind="semiring")
- self._expect_op(op, "Semiring", within=method_name, argname="op")
+ if is_infix:
+ from .infix import MatrixMatMulExpr, VectorMatMulExpr
+
+ other = self._expect_type(
+ other,
+ (Matrix, TransposedMatrix, MatrixMatMulExpr),
+ within=method_name,
+ argname="other",
+ op=op,
+ )
+ op = _get_typed_op_from_exprs(op, self, other, kind="semiring")
+ self._expect_op(op, "Semiring", within=method_name, argname="op")
+ if isinstance(self, VectorMatMulExpr):
+ self = op(self).new()
+ if isinstance(other, MatrixMatMulExpr):
+ other = op(other).new()
+ else:
+ other = self._expect_type(
+ other, (Matrix, TransposedMatrix), within=method_name, argname="other", op=op
+ )
+ op = get_typed_op(op, self.dtype, other.dtype, kind="semiring")
+ self._expect_op(op, "Semiring", within=method_name, argname="op")
+
expr = VectorExpression(
method_name,
"GrB_vxm",
@@ -1300,6 +1414,7 @@ def apply(self, op, right=None, *, left=None):
# Functional syntax
w << op.abs(v)
+
"""
method_name = "apply"
extra_message = (
@@ -1445,6 +1560,7 @@ def select(self, op, thunk=None):
# Functional syntax
w << select.value(v >= 1)
+
"""
method_name = "select"
if isinstance(op, str):
@@ -1500,6 +1616,7 @@ def select(self, op, thunk=None):
if thunk.dtype._is_udt:
dtype_name = "UDT"
thunk = _Pointer(thunk)
+ # NOT COVERED
else:
dtype_name = thunk.dtype.name
cfunc_name = f"GrB_Vector_select_{dtype_name}"
@@ -1538,6 +1655,7 @@ def reduce(self, op=monoid.plus, *, allow_empty=True):
.. code-block:: python
total << v.reduce(monoid.plus)
+
"""
method_name = "reduce"
op = get_typed_op(op, self.dtype, kind="binary|aggregator")
@@ -1590,11 +1708,29 @@ def inner(self, other, op=semiring.plus_times):
*Note*: This is not a standard GraphBLAS function, but fits with other functions in the
`Matrix Multiplication <../user_guide/operations.html#matrix-multiply>`__
family of functions.
+
"""
+ return self._inner(other, op)
+
+ def _inner(self, other, op=semiring.plus_times, is_infix=False):
method_name = "inner"
- other = self._expect_type(other, Vector, within=method_name, argname="other", op=op)
- op = get_typed_op(op, self.dtype, other.dtype, kind="semiring")
- self._expect_op(op, "Semiring", within=method_name, argname="op")
+ if is_infix:
+ from .infix import VectorMatMulExpr
+
+ other = self._expect_type(
+ other, (Vector, VectorMatMulExpr), within=method_name, argname="other", op=op
+ )
+ op = _get_typed_op_from_exprs(op, self, other, kind="semiring")
+ self._expect_op(op, "Semiring", within=method_name, argname="op")
+ if isinstance(self, VectorMatMulExpr):
+ self = op(self).new()
+ if isinstance(other, VectorMatMulExpr):
+ other = op(other).new()
+ else:
+ other = self._expect_type(other, Vector, within=method_name, argname="other", op=op)
+ op = get_typed_op(op, self.dtype, other.dtype, kind="semiring")
+ self._expect_op(op, "Semiring", within=method_name, argname="op")
+
expr = ScalarExpression(
method_name,
"GrB_vxm",
@@ -1628,6 +1764,7 @@ def outer(self, other, op=binary.times):
C << v.outer(w, op=binary.times)
*Note*: This is not a standard GraphBLAS function.
+
"""
from .matrix import MatrixExpression
@@ -1676,6 +1813,7 @@ def reposition(self, offset, *, size=None):
.. code-block:: python
w = v.reposition(20).new()
+
"""
if size is None:
size = self._size
@@ -1714,7 +1852,7 @@ def _extract_element(
result = Scalar(dtype, is_cscalar=is_cscalar, name=name)
if opts:
# Ignore opts for now
- descriptor_lookup(**opts)
+ desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context)
if is_cscalar:
dtype_name = "UDT" if dtype._is_udt else dtype.name
if (
@@ -1817,13 +1955,14 @@ def _prep_for_assign(self, resolved_indexes, value, mask, is_submask, replace, o
shape = values.shape
try:
vals = Vector.from_dense(values, dtype=dtype)
- except Exception: # pragma: no cover (safety)
+ 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:
+ # NOT COVERED
extra = (
" (this is assigning to a vector with sub-array dtype "
f"({dtype}), so array shape should include dtype shape)"
@@ -1868,12 +2007,11 @@ def _prep_for_assign(self, resolved_indexes, value, mask, is_submask, replace, o
else:
cfunc_name = f"GrB_Vector_assign_{dtype_name}"
mask = _vanilla_subassign_mask(self, mask, idx, replace, opts)
+ elif backend == "suitesparse":
+ cfunc_name = "GxB_Vector_subassign_Scalar"
else:
- if backend == "suitesparse":
- cfunc_name = "GxB_Vector_subassign_Scalar"
- else:
- cfunc_name = "GrB_Vector_assign_Scalar"
- mask = _vanilla_subassign_mask(self, mask, idx, replace, opts)
+ cfunc_name = "GrB_Vector_assign_Scalar"
+ mask = _vanilla_subassign_mask(self, mask, idx, replace, opts)
expr_repr = (
"[[{2._expr_name} elements]]"
f"({mask.name})" # fmt: skip
@@ -1936,6 +2074,7 @@ def from_dict(cls, d, dtype=None, *, size=None, name=None):
Returns
-------
Vector
+
"""
indices = np.fromiter(d.keys(), np.uint64)
if dtype is None:
@@ -1944,7 +2083,7 @@ def from_dict(cls, d, dtype=None, *, size=None, name=None):
# If we know the dtype, then using `np.fromiter` is much faster
dtype = lookup_dtype(dtype)
if dtype.np_type.subdtype is not None and np.__version__[:5] in {"1.21.", "1.22."}:
- values, dtype = values_to_numpy_buffer(list(d.values()), dtype)
+ values, dtype = values_to_numpy_buffer(list(d.values()), dtype) # FLAKY COVERAGE
else:
values = np.fromiter(d.values(), dtype.np_type)
if size is None and indices.size == 0:
@@ -1963,9 +2102,10 @@ def to_dict(self):
Returns
-------
dict
+
"""
indices, values = self.to_coo(sort=False)
- return dict(zip(indices.tolist(), values.tolist()))
+ return dict(zip(indices.tolist(), values.tolist(), strict=True))
if backend == "suitesparse":
@@ -2092,7 +2232,6 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts):
to_coo = wrapdoc(Vector.to_coo)(property(automethods.to_coo))
to_dense = wrapdoc(Vector.to_dense)(property(automethods.to_dense))
to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict))
- to_values = wrapdoc(Vector.to_values)(property(automethods.to_values))
vxm = wrapdoc(Vector.vxm)(property(automethods.vxm))
wait = wrapdoc(Vector.wait)(property(automethods.wait))
# These raise exceptions
@@ -2134,6 +2273,9 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts):
if clear:
if dtype is None:
dtype = self.dtype
+ if opts:
+ # Ignore opts for now
+ desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context)
return self.output_type(dtype, *self.shape, name=name)
return self.new(dtype, mask=mask, name=name, **opts)
@@ -2177,7 +2319,6 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts):
to_coo = wrapdoc(Vector.to_coo)(property(automethods.to_coo))
to_dense = wrapdoc(Vector.to_dense)(property(automethods.to_dense))
to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict))
- to_values = wrapdoc(Vector.to_values)(property(automethods.to_values))
vxm = wrapdoc(Vector.vxm)(property(automethods.vxm))
wait = wrapdoc(Vector.wait)(property(automethods.wait))
# These raise exceptions
diff --git a/graphblas/dtypes/__init__.py b/graphblas/dtypes/__init__.py
new file mode 100644
index 000000000..f9c144f13
--- /dev/null
+++ b/graphblas/dtypes/__init__.py
@@ -0,0 +1,46 @@
+from ..core.dtypes import (
+ _INDEX,
+ BOOL,
+ FP32,
+ FP64,
+ INT8,
+ INT16,
+ INT32,
+ INT64,
+ UINT8,
+ UINT16,
+ UINT32,
+ UINT64,
+ DataType,
+ _supports_complex,
+ lookup_dtype,
+ register_anonymous,
+ register_new,
+ unify,
+)
+
+if _supports_complex:
+ from ..core.dtypes import FC32, FC64
+
+
+def __dir__():
+ return globals().keys() | {"ss"}
+
+
+def __getattr__(key):
+ if key == "ss":
+ from .. import backend
+
+ if backend != "suitesparse":
+ raise AttributeError(
+ f'module {__name__!r} only has attribute "ss" when backend is "suitesparse"'
+ )
+ from importlib import import_module
+
+ ss = import_module(".ss", __name__)
+ globals()["ss"] = ss
+ return ss
+ raise AttributeError(f"module {__name__!r} has no attribute {key!r}")
+
+
+_index_dtypes = {BOOL, INT8, UINT8, INT16, UINT16, INT32, UINT32, INT64, UINT64, _INDEX}
diff --git a/graphblas/dtypes/ss.py b/graphblas/dtypes/ss.py
new file mode 100644
index 000000000..9f6083e01
--- /dev/null
+++ b/graphblas/dtypes/ss.py
@@ -0,0 +1 @@
+from ..core.ss.dtypes import register_new # noqa: F401
diff --git a/graphblas/exceptions.py b/graphblas/exceptions.py
index 0acc9ed0b..05cac988a 100644
--- a/graphblas/exceptions.py
+++ b/graphblas/exceptions.py
@@ -1,4 +1,3 @@
-from . import backend as _backend
from .core import ffi as _ffi
from .core import lib as _lib
from .core.utils import _Pointer
@@ -85,9 +84,14 @@ class NotImplementedException(GraphblasException):
"""
+# SuiteSparse errors
+class JitError(GraphblasException):
+ """SuiteSparse:GraphBLAS error using JIT."""
+
+
# Our errors
class UdfParseError(GraphblasException):
- """Unable to parse the user-defined function."""
+ """SuiteSparse:GraphBLAS unable to parse the user-defined function."""
_error_code_lookup = {
@@ -112,8 +116,12 @@ class UdfParseError(GraphblasException):
}
GrB_SUCCESS = _lib.GrB_SUCCESS
GrB_NO_VALUE = _lib.GrB_NO_VALUE
-if _backend == "suitesparse":
+
+# SuiteSparse-specific errors
+if hasattr(_lib, "GxB_EXHAUSTED"):
_error_code_lookup[_lib.GxB_EXHAUSTED] = StopIteration
+if hasattr(_lib, "GxB_JIT_ERROR"): # Added in 9.4
+ _error_code_lookup[_lib.GxB_JIT_ERROR] = JitError
def check_status(response_code, args):
@@ -121,7 +129,7 @@ def check_status(response_code, args):
return
if response_code == GrB_NO_VALUE:
return NoValue
- if type(args) is list:
+ if isinstance(args, list):
arg = args[0]
else:
arg = args
diff --git a/graphblas/indexunary/__init__.py b/graphblas/indexunary/__init__.py
index 472231597..a3cb06608 100644
--- a/graphblas/indexunary/__init__.py
+++ b/graphblas/indexunary/__init__.py
@@ -4,7 +4,7 @@
def __dir__():
- return globals().keys() | _delayed.keys()
+ return globals().keys() | _delayed.keys() | {"ss"}
def __getattr__(key):
@@ -13,6 +13,18 @@ def __getattr__(key):
rv = func(**kwargs)
globals()[key] = rv
return rv
+ if key == "ss":
+ from .. import backend
+
+ if backend != "suitesparse":
+ raise AttributeError(
+ f'module {__name__!r} only has attribute "ss" when backend is "suitesparse"'
+ )
+ from importlib import import_module
+
+ ss = import_module(".ss", __name__)
+ globals()["ss"] = ss
+ return ss
raise AttributeError(f"module {__name__!r} has no attribute {key!r}")
diff --git a/graphblas/indexunary/ss.py b/graphblas/indexunary/ss.py
new file mode 100644
index 000000000..58218df6f
--- /dev/null
+++ b/graphblas/indexunary/ss.py
@@ -0,0 +1,6 @@
+from ..core import operator
+from ..core.ss.indexunary import register_new # noqa: F401
+
+_delayed = {}
+
+del operator
diff --git a/graphblas/io.py b/graphblas/io.py
deleted file mode 100644
index e9d8ccfe6..000000000
--- a/graphblas/io.py
+++ /dev/null
@@ -1,631 +0,0 @@
-from warnings import warn as _warn
-
-import numpy as _np
-
-from . import backend as _backend
-from .core.matrix import Matrix as _Matrix
-from .core.utils import normalize_values as _normalize_values
-from .core.utils import output_type as _output_type
-from .core.vector import Vector as _Vector
-from .dtypes import lookup_dtype as _lookup_dtype
-from .exceptions import GraphblasException as _GraphblasException
-
-
-def draw(m): # pragma: no cover
- """Draw a square adjacency Matrix as a graph.
-
- Requires `networkx
`_ and
- `matplotlib
`_ to be installed.
-
- Example output:
-
- .. image:: /_static/img/draw-example.png
- """
- from . import viz
-
- _warn(
- "`graphblas.io.draw` is deprecated; it has been moved to `graphblas.viz.draw`",
- DeprecationWarning,
- )
- viz.draw(m)
-
-
-def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None):
- """Create a square adjacency Matrix from a networkx Graph.
-
- Parameters
- ----------
- G : nx.Graph
- Graph to convert
- nodelist : list, optional
- List of nodes in the nx.Graph. If not provided, all nodes will be used.
- dtype :
- Data type
- weight : str, default="weight"
- Weight attribute
- name : str, optional
- Name of resulting Matrix
-
- Returns
- -------
- :class:`~graphblas.Matrix`
- """
- import networkx as nx
-
- if dtype is not None:
- dtype = _lookup_dtype(dtype).np_type
- A = nx.to_scipy_sparse_array(G, nodelist=nodelist, dtype=dtype, weight=weight)
- return from_scipy_sparse(A, name=name)
-
-
-def from_numpy(m): # pragma: no cover (deprecated)
- """Create a sparse Vector or Matrix from a dense numpy array.
-
- .. deprecated:: 2023.2.0
- `from_numpy` will be removed in a future release.
- Use `Vector.from_dense` or `Matrix.from_dense` instead.
- Will be removed in version 2023.10.0 or later
-
- A value of 0 is considered as "missing".
-
- - m.ndim == 1 returns a `Vector`
- - m.ndim == 2 returns a `Matrix`
- - m.ndim > 2 raises an error
-
- dtype is inferred from m.dtype
-
- Parameters
- ----------
- m : np.ndarray
- Input array
-
- See Also
- --------
- Matrix.from_dense
- Vector.from_dense
- from_scipy_sparse
-
- Returns
- -------
- Vector or Matrix
- """
- _warn(
- "`graphblas.io.from_numpy` is deprecated; "
- "use `Matrix.from_dense` and `Vector.from_dense` instead.",
- DeprecationWarning,
- )
- if m.ndim > 2:
- raise _GraphblasException("m.ndim must be <= 2")
-
- try:
- from scipy.sparse import coo_array, csr_array
- 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)
- return _Vector.from_coo(A.indices, A.data, size=size, dtype=dtype)
- A = coo_array(m)
- return from_scipy_sparse(A)
-
-
-def from_scipy_sparse(A, *, dup_op=None, name=None):
- """Create a Matrix from a scipy.sparse array or matrix.
-
- Input data in "csr" or "csc" format will be efficient when importing with SuiteSparse:GraphBLAS.
-
- Parameters
- ----------
- A : scipy.sparse
- Scipy sparse array or matrix
- dup_op : BinaryOp, optional
- Aggregation function for formats that allow duplicate entries (e.g. coo)
- name : str, optional
- Name of resulting Matrix
-
- Returns
- -------
- :class:`~graphblas.Matrix`
- """
- nrows, ncols = A.shape
- dtype = _lookup_dtype(A.dtype)
- if A.nnz == 0:
- return _Matrix(dtype, nrows=nrows, ncols=ncols, name=name)
- if _backend == "suitesparse" and A.format in {"csr", "csc"}:
- data = A.data
- is_iso = (data[[0]] == data).all()
- if is_iso:
- data = data[[0]]
- if A.format == "csr":
- return _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),
- 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_coo(
- A.row, A.col, A.data, nrows=nrows, ncols=ncols, dtype=dtype, dup_op=dup_op, name=name
- )
-
-
-def from_awkward(A, *, name=None):
- """Create a Matrix or Vector from an Awkward Array.
-
- The Awkward Array must have top-level parameters: format, shape
-
- The Awkward Array must have top-level attributes based on format:
- - vec/csr/csc: values, indices
- - hypercsr/hypercsc: values, indices, offset_labels
-
- Parameters
- ----------
- A : awkward.Array
- Awkward Array with values and indices
- name : str, optional
- Name of resulting Matrix or Vector
-
- Returns
- -------
- Vector or Matrix
- """
- params = A.layout.parameters
- if missing := {"format", "shape"} - params.keys():
- raise ValueError(f"Missing parameters: {missing}")
- format = params["format"]
- shape = params["shape"]
-
- if len(shape) == 1:
- if format != "vec":
- raise ValueError(f"Invalid format for Vector: {format}")
- return _Vector.from_coo(
- A.indices.layout.data, A.values.layout.data, size=shape[0], 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}")
-
-
-def from_pydata_sparse(s, *, dup_op=None, name=None):
- """Create a Vector or a Matrix from a pydata.sparse array or matrix.
-
- Input data in "gcxs" format will be efficient when importing with SuiteSparse:GraphBLAS.
-
- Parameters
- ----------
- s : sparse
- PyData sparse array or matrix (see https://sparse.pydata.org)
- dup_op : BinaryOp, optional
- Aggregation function for formats that allow duplicate entries (e.g. coo)
- name : str, optional
- Name of resulting Matrix
-
- Returns
- -------
- :class:`~graphblas.Vector`
- :class:`~graphblas.Matrix`
- """
- try:
- import sparse
- except ImportError: # pragma: no cover (import)
- raise ImportError("sparse is required to import from pydata sparse") from None
- if not isinstance(s, sparse.SparseArray):
- raise TypeError(
- "from_pydata_sparse only accepts objects from the `sparse` library; "
- "see https://sparse.pydata.org"
- )
- if s.ndim > 2:
- raise _GraphblasException("m.ndim must be <= 2")
-
- if s.ndim == 1:
- # the .asformat('coo') makes it easier to convert dok/gcxs using a single approach
- _s = s.asformat("coo")
- return _Vector.from_coo(
- _s.coords, _s.data, dtype=_s.dtype, size=_s.shape[0], dup_op=dup_op, name=name
- )
- # handle two-dimensional arrays
- if isinstance(s, sparse.GCXS):
- return from_scipy_sparse(s.to_scipy_sparse(), dup_op=dup_op, name=name)
- if isinstance(s, (sparse.DOK, sparse.COO)):
- _s = s.asformat("coo")
- return _Matrix.from_coo(
- *_s.coords,
- _s.data,
- nrows=_s.shape[0],
- ncols=_s.shape[1],
- dtype=_s.dtype,
- dup_op=dup_op,
- name=name,
- )
- raise ValueError(f"Unknown sparse array type: {type(s).__name__}") # pragma: no cover (safety)
-
-
-# TODO: add parameters to allow different networkx classes and attribute names
-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
- -------
- nx.DiGraph
- """
- import networkx as nx
-
- rows, cols, vals = m.to_coo()
- 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): # pragma: no cover (deprecated)
- """Create a dense numpy array from a sparse Vector or Matrix.
-
- .. deprecated:: 2023.2.0
- `to_numpy` will be removed in a future release.
- Use `Vector.to_dense` or `Matrix.to_dense` instead.
- Will be removed in version 2023.10.0 or later
-
- Missing values will become 0 in the output.
-
- numpy dtype will match the GraphBLAS dtype
-
- Parameters
- ----------
- m : Vector or Matrix
- GraphBLAS Vector or Matrix
-
- See Also
- --------
- to_scipy_sparse
- Matrix.to_dense
- Vector.to_dense
-
- Returns
- -------
- np.ndarray
- """
- _warn(
- "`graphblas.io.to_numpy` is deprecated; "
- "use `Matrix.to_dense` and `Vector.to_dense` instead.",
- DeprecationWarning,
- )
- try:
- import scipy # noqa: F401
- 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]
- sparse = to_scipy_sparse(m, "coo")
- return sparse.toarray()
-
-
-def to_scipy_sparse(A, format="csr"):
- """Create a scipy.sparse array from a GraphBLAS Matrix or Vector.
-
- Parameters
- ----------
- A : Matrix or Vector
- GraphBLAS object to be converted
- format : str
- {'bsr', 'csr', 'csc', 'coo', 'lil', 'dia', 'dok'}
-
- Returns
- -------
- scipy.sparse array
-
- """
- import scipy.sparse as ss
-
- format = format.lower()
- if format not in {"bsr", "csr", "csc", "coo", "lil", "dia", "dok"}:
- raise ValueError(f"Invalid format: {format}")
- if _output_type(A) is _Vector:
- indices, data = A.to_coo()
- if format == "csc":
- return ss.csc_array((data, indices, [0, len(data)]), shape=(A._size, 1))
- 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)
- if "col_indices" in info:
- info["row_indices"] = info["col_indices"]
- else:
- info["col_indices"] = info["row_indices"]
- else:
- info = A.ss.export(format, sort=True)
- values = _normalize_values(A, info["values"], None, (A._nvals,), info["is_iso"])
- if format == "csr":
- return ss.csr_array((values, info["col_indices"], info["indptr"]), shape=A.shape)
- return ss.csc_array((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_coo()
- rv = ss.coo_array((data, (rows, cols)), shape=A.shape)
- if format == "coo":
- return rv
- return rv.asformat(format)
-
-
-_AwkwardDoublyCompressedMatrix = None
-
-
-def to_awkward(A, format=None):
- """Create an Awkward Array from a GraphBLAS Matrix.
-
- Parameters
- ----------
- A : Matrix or Vector
- GraphBLAS object to be converted
- format : str {'csr', 'csc', 'hypercsr', 'hypercsc', 'vec}
- Default format is csr for Matrix; vec for Vector
-
- The Awkward Array will have top-level attributes based on format:
- - vec/csr/csc: values, indices
- - hypercsr/hypercsc: values, indices, offset_labels
-
- Top-level parameters will also be set: format, shape
-
- Returns
- -------
- awkward.Array
-
- """
- try:
- # awkward version 1
- # MAINT: we can probably drop awkward v1 at the end of 2024 or 2025
- import awkward._v2 as ak
- from awkward._v2.forms.listoffsetform import ListOffsetForm
- from awkward._v2.forms.numpyform import NumpyForm
- from awkward._v2.forms.recordform import RecordForm
- except ImportError:
- # awkward version 2
- import awkward as ak
- from awkward.forms.listoffsetform import ListOffsetForm
- from awkward.forms.numpyform import NumpyForm
- from awkward.forms.recordform import RecordForm
-
- out_type = _output_type(A)
- if format is None:
- format = "vec" if out_type is _Vector else "csr"
- format = format.lower()
- classname = None
-
- if out_type is _Vector:
- if format != "vec":
- raise ValueError(f"Invalid format for Vector: {format}")
- size = A.nvals
- indices, values = A.to_coo()
- form = RecordForm(
- contents=[
- NumpyForm(A.dtype.numba_type.name, form_key="node1"),
- NumpyForm("int64", form_key="node0"),
- ],
- fields=["values", "indices"],
- )
- d = {"node0-data": indices, "node1-data": values}
-
- elif out_type is _Matrix:
- if format == "csr":
- indptr, cols, values = A.to_csr()
- d = {"node3-data": cols}
- size = A.nrows
- elif format == "csc":
- indptr, rows, values = A.to_csc()
- d = {"node3-data": rows}
- size = A.ncols
- 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(
- contents=[
- NumpyForm("int64", form_key="node3"),
- NumpyForm(A.dtype.numba_type.name, form_key="node4"),
- ],
- fields=["indices", "values"],
- ),
- form_key="node1",
- )
- if format.startswith("hyper"):
- global _AwkwardDoublyCompressedMatrix
- if _AwkwardDoublyCompressedMatrix is None: # pylint: disable=used-before-assignment
- # Define behaviors to make all fields function at the top-level
- @ak.behaviors.mixins.mixin_class(ak.behavior)
- class _AwkwardDoublyCompressedMatrix:
- @property
- def values(self):
- return self.data.values
-
- @property
- def indices(self):
- return self.data.indices
-
- form = RecordForm(
- contents=[
- form,
- NumpyForm("int64", form_key="node5"),
- ],
- fields=["data", "offset_labels"],
- )
- classname = "_AwkwardDoublyCompressedMatrix"
-
- else:
- raise TypeError(f"A must be a Matrix or Vector, found {type(A)}")
-
- ret = ak.from_buffers(form, size, d)
- ret = ak.with_parameter(ret, "format", format)
- ret = ak.with_parameter(ret, "shape", list(A.shape))
- if classname:
- ret = ak.with_name(ret, classname)
- return ret
-
-
-def to_pydata_sparse(A, format="coo"):
- """Create a pydata.sparse array from a GraphBLAS Matrix or Vector.
-
- Parameters
- ----------
- A : Matrix or Vector
- GraphBLAS object to be converted
- format : str
- {'coo', 'dok', 'gcxs'}
-
- Returns
- -------
- sparse array (see https://sparse.pydata.org)
-
- """
- try:
- from sparse import COO
- except ImportError: # pragma: no cover (import)
- raise ImportError("sparse is required to export to pydata sparse") from None
-
- format = format.lower()
- if format not in {"coo", "dok", "gcxs"}:
- raise ValueError(f"Invalid format: {format}")
-
- if _output_type(A) is _Vector:
- indices, values = A.to_coo(sort=False)
- s = COO(indices, values, shape=A.shape)
- else:
- if format == "gcxs":
- B = to_scipy_sparse(A, format="csr")
- else:
- # obtain an intermediate conversion via hardcoded 'coo' intermediate object
- B = to_scipy_sparse(A, format="coo")
- # convert to pydata.sparse
- s = COO.from_scipy_sparse(B)
-
- # express in the desired format
- return s.asformat(format)
-
-
-def mmread(source, *, dup_op=None, name=None):
- """Create a GraphBLAS Matrix from the contents of a Matrix Market file.
-
- This uses `scipy.io.mmread
-
`_.
-
- Parameters
- ----------
- filename : str or file
- Filename (.mtx or .mtz.gz) or file-like object
- dup_op : BinaryOp, optional
- Aggregation function for duplicate coordinates (if found)
- name : str, optional
- Name of resulting Matrix
-
- Returns
- -------
- :class:`~graphblas.Matrix`
- """
- try:
- from scipy.io import mmread
- from scipy.sparse import isspmatrix_coo
- 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):
- nrows, ncols = array.shape
- return _Matrix.from_coo(
- array.row, array.col, array.data, nrows=nrows, ncols=ncols, dup_op=dup_op, name=name
- )
- return _Matrix.from_dense(array, name=name)
-
-
-def mmwrite(target, matrix, *, comment="", field=None, precision=None, symmetry=None):
- """Write a Matrix Market file from the contents of a GraphBLAS Matrix.
-
- This uses `scipy.io.mmwrite
- `_.
-
- Parameters
- ----------
- filename : str or file target
- Filename (.mtx) or file-like object opened for writing
- matrix : Matrix
- Matrix to be written
- comment : str, optional
- Comments to be prepended to the Matrix Market file
- field : str
- {"real", "complex", "pattern", "integer"}
- precision : int, optional
- Number of digits to write for real or complex values
- symmetry : str, optional
- {"general", "symmetric", "skew-symmetric", "hermetian"}
- """
- try:
- from scipy.io import mmwrite
- 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"]
- else:
- array = to_scipy_sparse(matrix, format="coo")
- mmwrite(target, array, comment=comment, field=field, precision=precision, symmetry=symmetry)
diff --git a/graphblas/io/__init__.py b/graphblas/io/__init__.py
new file mode 100644
index 000000000..a1b71db40
--- /dev/null
+++ b/graphblas/io/__init__.py
@@ -0,0 +1,5 @@
+from ._awkward import from_awkward, to_awkward
+from ._matrixmarket import mmread, mmwrite
+from ._networkx import from_networkx, to_networkx
+from ._scipy import from_scipy_sparse, to_scipy_sparse
+from ._sparse import from_pydata_sparse, to_pydata_sparse
diff --git a/graphblas/io/_awkward.py b/graphblas/io/_awkward.py
new file mode 100644
index 000000000..b30984251
--- /dev/null
+++ b/graphblas/io/_awkward.py
@@ -0,0 +1,188 @@
+import numpy as np
+
+from ..core.matrix import Matrix
+from ..core.utils import output_type
+from ..core.vector import Vector
+
+_AwkwardDoublyCompressedMatrix = None
+
+
+def to_awkward(A, format=None):
+ """Create an Awkward Array from a GraphBLAS Matrix.
+
+ Parameters
+ ----------
+ A : Matrix or Vector
+ GraphBLAS object to be converted
+ format : str {'csr', 'csc', 'hypercsr', 'hypercsc', 'vec}
+ Default format is csr for Matrix; vec for Vector
+
+ The Awkward Array will have top-level attributes based on format:
+ - vec/csr/csc: values, indices
+ - hypercsr/hypercsc: values, indices, offset_labels
+
+ Top-level parameters will also be set: format, shape
+
+ Returns
+ -------
+ awkward.Array
+
+ """
+ try:
+ # awkward version 1
+ # MAINT: we can probably drop awkward v1 at the end of 2024 or 2025
+ import awkward._v2 as ak
+ from awkward._v2.forms.listoffsetform import ListOffsetForm
+ from awkward._v2.forms.numpyform import NumpyForm
+ from awkward._v2.forms.recordform import RecordForm
+ except ImportError:
+ # awkward version 2
+ import awkward as ak
+ from awkward.forms.listoffsetform import ListOffsetForm
+ from awkward.forms.numpyform import NumpyForm
+ from awkward.forms.recordform import RecordForm
+
+ out_type = output_type(A)
+ if format is None:
+ format = "vec" if out_type is Vector else "csr"
+ format = format.lower()
+ classname = None
+
+ if out_type is Vector:
+ if format != "vec":
+ raise ValueError(f"Invalid format for Vector: {format}")
+ size = A.nvals
+ indices, values = A.to_coo()
+ form = RecordForm(
+ contents=[
+ NumpyForm(A.dtype.np_type.name, form_key="node1"),
+ NumpyForm("int64", form_key="node0"),
+ ],
+ fields=["values", "indices"],
+ )
+ d = {"node0-data": indices, "node1-data": values}
+
+ elif out_type is Matrix:
+ if format == "csr":
+ indptr, cols, values = A.to_csr()
+ d = {"node3-data": cols}
+ size = A.nrows
+ elif format == "csc":
+ indptr, rows, values = A.to_csc()
+ d = {"node3-data": rows}
+ size = A.ncols
+ 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(
+ contents=[
+ NumpyForm("int64", form_key="node3"),
+ NumpyForm(A.dtype.np_type.name, form_key="node4"),
+ ],
+ fields=["indices", "values"],
+ ),
+ form_key="node1",
+ )
+ if format.startswith("hyper"):
+ global _AwkwardDoublyCompressedMatrix
+ if _AwkwardDoublyCompressedMatrix is None: # pylint: disable=used-before-assignment
+ # Define behaviors to make all fields function at the top-level
+ @ak.behaviors.mixins.mixin_class(ak.behavior)
+ class _AwkwardDoublyCompressedMatrix:
+ @property
+ def values(self): # pragma: no branch (???)
+ return self.data.values
+
+ @property
+ def indices(self): # pragma: no branch (???)
+ return self.data.indices
+
+ form = RecordForm(
+ contents=[
+ form,
+ NumpyForm("int64", form_key="node5"),
+ ],
+ fields=["data", "offset_labels"],
+ )
+ classname = "_AwkwardDoublyCompressedMatrix"
+
+ else:
+ raise TypeError(f"A must be a Matrix or Vector, found {type(A)}")
+
+ ret = ak.from_buffers(form, size, d)
+ ret = ak.with_parameter(ret, "format", format)
+ ret = ak.with_parameter(ret, "shape", list(A.shape))
+ if classname:
+ ret = ak.with_name(ret, classname)
+ return ret
+
+
+def from_awkward(A, *, name=None):
+ """Create a Matrix or Vector from an Awkward Array.
+
+ The Awkward Array must have top-level parameters: format, shape
+
+ The Awkward Array must have top-level attributes based on format:
+ - vec/csr/csc: values, indices
+ - hypercsr/hypercsc: values, indices, offset_labels
+
+ Parameters
+ ----------
+ A : awkward.Array
+ Awkward Array with values and indices
+ name : str, optional
+ Name of resulting Matrix or Vector
+
+ Returns
+ -------
+ Vector or Matrix
+
+ Note: the intended purpose of this function is to facilitate
+ conversion of an `awkward-array` that was created via `to_awkward`
+ function. If attempting to convert an arbitrary `awkward-array`,
+ make sure that the top-level attributes and parameters contain
+ the expected values.
+
+ """
+ params = A.layout.parameters
+ if missing := {"format", "shape"} - params.keys():
+ raise ValueError(f"Missing parameters: {missing}")
+ format = params["format"]
+ shape = params["shape"]
+
+ if len(shape) == 1:
+ if format != "vec":
+ raise ValueError(f"Invalid format for Vector: {format}")
+ return Vector.from_coo(
+ A.indices.layout.data, A.values.layout.data, size=shape[0], 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}")
diff --git a/graphblas/io/_matrixmarket.py b/graphblas/io/_matrixmarket.py
new file mode 100644
index 000000000..8cf8738a3
--- /dev/null
+++ b/graphblas/io/_matrixmarket.py
@@ -0,0 +1,142 @@
+from .. import backend
+from ..core.matrix import Matrix
+from ._scipy import to_scipy_sparse
+
+
+def mmread(source, engine="auto", *, dup_op=None, name=None, **kwargs):
+ """Create a GraphBLAS Matrix from the contents of a Matrix Market file.
+
+ This uses `scipy.io.mmread
+ `_
+ or `fast_matrix_market.mmread
+ `_.
+
+ By default, ``fast_matrix_market`` will be used if available, because it
+ is faster. Additional keyword arguments in ``**kwargs`` will be passed
+ to the engine's ``mmread``. For example, ``parallelism=8`` will set the
+ number of threads to use to 8 when using ``fast_matrix_market``.
+
+ Parameters
+ ----------
+ source : str or file
+ Filename (.mtx or .mtz.gz) or file-like object
+ engine : {"auto", "scipy", "fmm", "fast_matrix_market"}, default "auto"
+ How to read the matrix market file. "scipy" uses ``scipy.io.mmread``,
+ "fmm" and "fast_matrix_market" uses ``fast_matrix_market.mmread``,
+ and "auto" will use "fast_matrix_market" if available.
+ dup_op : BinaryOp, optional
+ Aggregation function for duplicate coordinates (if found)
+ name : str, optional
+ Name of resulting Matrix
+
+ Returns
+ -------
+ :class:`~graphblas.Matrix`
+
+ """
+ try:
+ # scipy is currently needed for *all* engines
+ from scipy.io import mmread
+ except ImportError: # pragma: no cover (import)
+ raise ImportError("scipy is required to read Matrix Market files") from None
+ engine = engine.lower()
+ if engine in {"auto", "fmm", "fast_matrix_market"}:
+ try:
+ from fast_matrix_market import mmread # noqa: F811
+ except ImportError: # pragma: no cover (import)
+ if engine != "auto":
+ raise ImportError(
+ "fast_matrix_market is required to read Matrix Market files "
+ f'using the "{engine}" engine'
+ ) from None
+ elif engine != "scipy":
+ raise ValueError(
+ f'Bad engine value: {engine!r}. Must be "auto", "scipy", "fmm", or "fast_matrix_market"'
+ )
+ array = mmread(source, **kwargs)
+ if getattr(array, "format", None) == "coo":
+ nrows, ncols = array.shape
+ return Matrix.from_coo(
+ array.row, array.col, array.data, nrows=nrows, ncols=ncols, dup_op=dup_op, name=name
+ )
+ return Matrix.from_dense(array, name=name)
+
+
+def mmwrite(
+ target,
+ matrix,
+ engine="auto",
+ *,
+ comment="",
+ field=None,
+ precision=None,
+ symmetry=None,
+ **kwargs,
+):
+ """Write a Matrix Market file from the contents of a GraphBLAS Matrix.
+
+ This uses `scipy.io.mmwrite
+ `_.
+
+ Parameters
+ ----------
+ target : str or file target
+ Filename (.mtx) or file-like object opened for writing
+ matrix : Matrix
+ Matrix to be written
+ engine : {"auto", "scipy", "fmm", "fast_matrix_market"}, default "auto"
+ How to read the matrix market file. "scipy" uses ``scipy.io.mmwrite``,
+ "fmm" and "fast_matrix_market" uses ``fast_matrix_market.mmwrite``,
+ and "auto" will use "fast_matrix_market" if available.
+ comment : str, optional
+ Comments to be prepended to the Matrix Market file
+ field : str
+ {"real", "complex", "pattern", "integer"}
+ precision : int, optional
+ Number of digits to write for real or complex values
+ symmetry : str, optional
+ {"general", "symmetric", "skew-symmetric", "hermetian"}
+
+ """
+ try:
+ # scipy is currently needed for *all* engines
+ from scipy.io import mmwrite
+ except ImportError: # pragma: no cover (import)
+ raise ImportError("scipy is required to write Matrix Market files") from None
+ engine = engine.lower()
+ if engine in {"auto", "fmm", "fast_matrix_market"}:
+ try:
+ from fast_matrix_market import __version__, mmwrite # noqa: F811
+ except ImportError: # pragma: no cover (import)
+ if engine != "auto":
+ raise ImportError(
+ "fast_matrix_market is required to write Matrix Market files "
+ f'using the "{engine}" engine'
+ ) from None
+ else:
+ import scipy as sp
+
+ engine = "fast_matrix_market"
+ elif engine != "scipy":
+ raise ValueError(
+ f'Bad engine value: {engine!r}. Must be "auto", "scipy", "fmm", or "fast_matrix_market"'
+ )
+ if backend == "suitesparse" and matrix.ss.format in {"fullr", "fullc"}:
+ array = matrix.ss.export()["values"]
+ else:
+ array = to_scipy_sparse(matrix, format="coo")
+ if engine == "fast_matrix_market" and __version__ < "1.7." and sp.__version__ > "1.11.":
+ # 2023-06-25: scipy 1.11.0 added `sparray` and changed e.g. `ss.isspmatrix_coo`.
+ # fast_matrix_market updated to handle this in version 1.7.0
+ # Also, it looks like fast_matrix_market has special writers for csr and csc;
+ # should we see if using those are faster?
+ array = sp.sparse.coo_matrix(array) # FLAKY COVERAGE
+ mmwrite(
+ target,
+ array,
+ comment=comment,
+ field=field,
+ precision=precision,
+ symmetry=symmetry,
+ **kwargs,
+ )
diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py
new file mode 100644
index 000000000..8cf84e576
--- /dev/null
+++ b/graphblas/io/_networkx.py
@@ -0,0 +1,63 @@
+from ..dtypes import lookup_dtype
+from ._scipy import from_scipy_sparse
+
+
+def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None):
+ """Create a square adjacency Matrix from a networkx Graph.
+
+ Parameters
+ ----------
+ G : nx.Graph
+ Graph to convert
+ nodelist : list, optional
+ List of nodes in the nx.Graph. If not provided, all nodes will be used.
+ dtype :
+ Data type
+ weight : str, default="weight"
+ Weight attribute
+ name : str, optional
+ Name of resulting Matrix
+
+ Returns
+ -------
+ :class:`~graphblas.Matrix`
+
+ """
+ import networkx as nx
+
+ if dtype is not None:
+ dtype = lookup_dtype(dtype).np_type
+ A = nx.to_scipy_sparse_array(G, nodelist=nodelist, dtype=dtype, weight=weight)
+ return from_scipy_sparse(A, name=name)
+
+
+# TODO: add parameters to allow different networkx classes and attribute names
+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
+ -------
+ nx.DiGraph
+
+ """
+ import networkx as nx
+
+ rows, cols, vals = m.to_coo()
+ rows = rows.tolist()
+ cols = cols.tolist()
+ G = nx.DiGraph()
+ if edge_attribute is None:
+ G.add_edges_from(zip(rows, cols, strict=True))
+ else:
+ G.add_weighted_edges_from(
+ zip(rows, cols, vals.tolist(), strict=True), weight=edge_attribute
+ )
+ return G
diff --git a/graphblas/io/_scipy.py b/graphblas/io/_scipy.py
new file mode 100644
index 000000000..228432eed
--- /dev/null
+++ b/graphblas/io/_scipy.py
@@ -0,0 +1,119 @@
+from .. import backend
+from ..core.matrix import Matrix
+from ..core.utils import normalize_values, output_type
+from ..core.vector import Vector
+from ..dtypes import lookup_dtype
+
+
+def from_scipy_sparse(A, *, dup_op=None, name=None):
+ """Create a Matrix from a scipy.sparse array or matrix.
+
+ Input data in "csr" or "csc" format will be efficient when importing with SuiteSparse:GraphBLAS.
+
+ Parameters
+ ----------
+ A : scipy.sparse
+ Scipy sparse array or matrix
+ dup_op : BinaryOp, optional
+ Aggregation function for formats that allow duplicate entries (e.g. coo)
+ name : str, optional
+ Name of resulting Matrix
+
+ Returns
+ -------
+ :class:`~graphblas.Matrix`
+
+ """
+ nrows, ncols = A.shape
+ dtype = lookup_dtype(A.dtype)
+ if A.nnz == 0:
+ return Matrix(dtype, nrows=nrows, ncols=ncols, name=name)
+ if backend == "suitesparse" and A.format in {"csr", "csc"}:
+ data = A.data
+ is_iso = (data[[0]] == data).all()
+ if is_iso:
+ data = data[[0]]
+ if A.format == "csr":
+ return 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),
+ 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_coo(
+ A.row, A.col, A.data, nrows=nrows, ncols=ncols, dtype=dtype, dup_op=dup_op, name=name
+ )
+
+
+def to_scipy_sparse(A, format="csr"):
+ """Create a scipy.sparse array from a GraphBLAS Matrix or Vector.
+
+ Parameters
+ ----------
+ A : Matrix or Vector
+ GraphBLAS object to be converted
+ format : str
+ {'bsr', 'csr', 'csc', 'coo', 'lil', 'dia', 'dok'}
+
+ Returns
+ -------
+ scipy.sparse array
+
+ """
+ import scipy.sparse as ss
+
+ format = format.lower()
+ if format not in {"bsr", "csr", "csc", "coo", "lil", "dia", "dok"}:
+ raise ValueError(f"Invalid format: {format}")
+ if output_type(A) is Vector:
+ indices, data = A.to_coo()
+ if format == "csc":
+ return ss.csc_array((data, indices, [0, len(data)]), shape=(A._size, 1))
+ 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)
+ if "col_indices" in info:
+ info["row_indices"] = info["col_indices"]
+ else:
+ info["col_indices"] = info["row_indices"]
+ else:
+ info = A.ss.export(format, sort=True)
+ values = normalize_values(A, info["values"], None, (A._nvals,), info["is_iso"])
+ if format == "csr":
+ return ss.csr_array((values, info["col_indices"], info["indptr"]), shape=A.shape)
+ return ss.csc_array((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_coo()
+ rv = ss.coo_array((data, (rows, cols)), shape=A.shape)
+ if format == "coo":
+ return rv
+ return rv.asformat(format)
diff --git a/graphblas/io/_sparse.py b/graphblas/io/_sparse.py
new file mode 100644
index 000000000..c0d4beabb
--- /dev/null
+++ b/graphblas/io/_sparse.py
@@ -0,0 +1,100 @@
+from ..core.matrix import Matrix
+from ..core.utils import output_type
+from ..core.vector import Vector
+from ..exceptions import GraphblasException
+from ._scipy import from_scipy_sparse, to_scipy_sparse
+
+
+def from_pydata_sparse(s, *, dup_op=None, name=None):
+ """Create a Vector or a Matrix from a pydata.sparse array or matrix.
+
+ Input data in "gcxs" format will be efficient when importing with SuiteSparse:GraphBLAS.
+
+ Parameters
+ ----------
+ s : sparse
+ PyData sparse array or matrix (see https://sparse.pydata.org)
+ dup_op : BinaryOp, optional
+ Aggregation function for formats that allow duplicate entries (e.g. coo)
+ name : str, optional
+ Name of resulting Matrix
+
+ Returns
+ -------
+ :class:`~graphblas.Vector`
+ :class:`~graphblas.Matrix`
+
+ """
+ try:
+ import sparse
+ except ImportError: # pragma: no cover (import)
+ raise ImportError("sparse is required to import from pydata sparse") from None
+ if not isinstance(s, sparse.SparseArray):
+ raise TypeError(
+ "from_pydata_sparse only accepts objects from the `sparse` library; "
+ "see https://sparse.pydata.org"
+ )
+ if s.ndim > 2:
+ raise GraphblasException("m.ndim must be <= 2")
+
+ if s.ndim == 1:
+ # the .asformat('coo') makes it easier to convert dok/gcxs using a single approach
+ _s = s.asformat("coo")
+ return Vector.from_coo(
+ _s.coords, _s.data, dtype=_s.dtype, size=_s.shape[0], dup_op=dup_op, name=name
+ )
+ # handle two-dimensional arrays
+ if isinstance(s, sparse.GCXS):
+ return from_scipy_sparse(s.to_scipy_sparse(), dup_op=dup_op, name=name)
+ if isinstance(s, (sparse.DOK, sparse.COO)):
+ _s = s.asformat("coo")
+ return Matrix.from_coo(
+ *_s.coords,
+ _s.data,
+ nrows=_s.shape[0],
+ ncols=_s.shape[1],
+ dtype=_s.dtype,
+ dup_op=dup_op,
+ name=name,
+ )
+ raise ValueError(f"Unknown sparse array type: {type(s).__name__}") # pragma: no cover (safety)
+
+
+def to_pydata_sparse(A, format="coo"):
+ """Create a pydata.sparse array from a GraphBLAS Matrix or Vector.
+
+ Parameters
+ ----------
+ A : Matrix or Vector
+ GraphBLAS object to be converted
+ format : str
+ {'coo', 'dok', 'gcxs'}
+
+ Returns
+ -------
+ sparse array (see https://sparse.pydata.org)
+
+ """
+ try:
+ from sparse import COO
+ except ImportError: # pragma: no cover (import)
+ raise ImportError("sparse is required to export to pydata sparse") from None
+
+ format = format.lower()
+ if format not in {"coo", "dok", "gcxs"}:
+ raise ValueError(f"Invalid format: {format}")
+
+ if output_type(A) is Vector:
+ indices, values = A.to_coo(sort=False)
+ s = COO(indices, values, shape=A.shape)
+ else:
+ if format == "gcxs":
+ B = to_scipy_sparse(A, format="csr")
+ else:
+ # obtain an intermediate conversion via hardcoded 'coo' intermediate object
+ B = to_scipy_sparse(A, format="coo")
+ # convert to pydata.sparse
+ s = COO.from_scipy_sparse(B)
+
+ # express in the desired format
+ return s.asformat(format)
diff --git a/graphblas/monoid/__init__.py b/graphblas/monoid/__init__.py
index 007aba416..027fc0afe 100644
--- a/graphblas/monoid/__init__.py
+++ b/graphblas/monoid/__init__.py
@@ -4,19 +4,31 @@
def __dir__():
- return globals().keys() | _delayed.keys()
+ return globals().keys() | _delayed.keys() | {"ss"}
def __getattr__(key):
if key in _delayed:
func, kwargs = _delayed.pop(key)
- if type(kwargs["binaryop"]) is str:
+ if isinstance(kwargs["binaryop"], str):
from ..binary import from_string
kwargs["binaryop"] = from_string(kwargs["binaryop"])
rv = func(**kwargs)
globals()[key] = rv
return rv
+ if key == "ss":
+ from .. import backend
+
+ if backend != "suitesparse":
+ raise AttributeError(
+ f'module {__name__!r} only has attribute "ss" when backend is "suitesparse"'
+ )
+ from importlib import import_module
+
+ ss = import_module(".ss", __name__)
+ globals()["ss"] = ss
+ return ss
raise AttributeError(f"module {__name__!r} has no attribute {key!r}")
diff --git a/graphblas/monoid/numpy.py b/graphblas/monoid/numpy.py
index 475266d5c..b9ff2b502 100644
--- a/graphblas/monoid/numpy.py
+++ b/graphblas/monoid/numpy.py
@@ -5,15 +5,19 @@
https://numba.readthedocs.io/en/stable/reference/numpysupported.html#math-operations
"""
-import numba as _numba
+
import numpy as _np
from .. import _STANDARD_OPERATOR_NAMES
from .. import binary as _binary
from .. import config as _config
from .. import monoid as _monoid
+from ..core import _has_numba, _supports_udfs
from ..dtypes import _supports_complex
+if _has_numba:
+ import numba as _numba
+
_delayed = {}
_complex_dtypes = {"FC32", "FC64"}
_float_dtypes = {"FP32", "FP64"}
@@ -86,8 +90,8 @@
# To increase import speed, only call njit when `_config.get("mapnumpy")` is False
if (
_config.get("mapnumpy")
- or type(_numba.njit(lambda x, y: _np.fmax(x, y))(1, 2)) # pragma: no branch (numba)
- is not float
+ or _has_numba
+ and not isinstance(_numba.njit(lambda x, y: _np.fmax(x, y))(1, 2), float) # pragma: no branch
):
# Incorrect behavior was introduced in numba 0.56.2 and numpy 1.23
# See: https://github.com/numba/numba/issues/8478
@@ -140,15 +144,33 @@
# _graphblas_to_numpy = {val: key for key, val in _numpy_to_graphblas.items()} # Soon...
# Not included: maximum, minimum, gcd, hypot, logaddexp, logaddexp2
+# True if ``monoid(x, x) == x`` for any x.
+_idempotent = {
+ "bitwise_and",
+ "bitwise_or",
+ "fmax",
+ "fmin",
+ "gcd",
+ "logical_and",
+ "logical_or",
+ "maximum",
+ "minimum",
+}
+
def __dir__():
- return globals().keys() | _delayed.keys() | _monoid_identities.keys()
+ if not _supports_udfs and not _config.get("mapnumpy"):
+ return globals().keys() # FLAKY COVERAGE
+ attrs = _delayed.keys() | _monoid_identities.keys()
+ if not _supports_udfs:
+ attrs &= _numpy_to_graphblas.keys()
+ return attrs | globals().keys()
def __getattr__(name):
if name in _delayed:
func, kwargs = _delayed.pop(name)
- if type(kwargs["binaryop"]) is str:
+ if isinstance(kwargs["binaryop"], str):
from ..binary import from_string
kwargs["binaryop"] = from_string(kwargs["binaryop"])
@@ -160,8 +182,8 @@ def __getattr__(name):
if _config.get("mapnumpy") and name in _numpy_to_graphblas:
globals()[name] = getattr(_monoid, _numpy_to_graphblas[name])
else:
- from ..core import operator
-
func = getattr(_binary.numpy, name)
- operator.Monoid.register_new(f"numpy.{name}", func, _monoid_identities[name])
+ _monoid.register_new(
+ f"numpy.{name}", func, _monoid_identities[name], is_idempotent=name in _idempotent
+ )
return globals()[name]
diff --git a/graphblas/monoid/ss.py b/graphblas/monoid/ss.py
new file mode 100644
index 000000000..97852fc12
--- /dev/null
+++ b/graphblas/monoid/ss.py
@@ -0,0 +1,5 @@
+from ..core import operator
+
+_delayed = {}
+
+del operator
diff --git a/graphblas/op/__init__.py b/graphblas/op/__init__.py
index af05cbef4..1eb2b51d7 100644
--- a/graphblas/op/__init__.py
+++ b/graphblas/op/__init__.py
@@ -39,10 +39,18 @@ def __getattr__(key):
ss = import_module(".ss", __name__)
globals()["ss"] = ss
return ss
+ if not _supports_udfs:
+ from .. import binary, semiring
+
+ if key in binary._udfs or key in semiring._udfs:
+ raise AttributeError(
+ f"module {__name__!r} unable to compile UDF for {key!r}; "
+ "install numba for UDF support"
+ )
raise AttributeError(f"module {__name__!r} has no attribute {key!r}")
-from ..core import operator # noqa: E402 isort:skip
+from ..core import operator, _supports_udfs # noqa: E402 isort:skip
from . import numpy # noqa: E402 isort:skip
del operator
diff --git a/graphblas/op/numpy.py b/graphblas/op/numpy.py
index 497a6037c..cadba17eb 100644
--- a/graphblas/op/numpy.py
+++ b/graphblas/op/numpy.py
@@ -1,4 +1,5 @@
from ..binary import numpy as _np_binary
+from ..core import _supports_udfs
from ..semiring import numpy as _np_semiring
from ..unary import numpy as _np_unary
@@ -10,7 +11,10 @@
def __dir__():
- return globals().keys() | _delayed.keys() | _op_to_mod.keys()
+ attrs = _delayed.keys() | _op_to_mod.keys()
+ if not _supports_udfs:
+ attrs &= _np_unary.__dir__() | _np_binary.__dir__() | _np_semiring.__dir__()
+ return attrs | globals().keys()
def __getattr__(name):
diff --git a/graphblas/op/ss.py b/graphblas/op/ss.py
index e45cbcda0..97852fc12 100644
--- a/graphblas/op/ss.py
+++ b/graphblas/op/ss.py
@@ -1,3 +1,5 @@
from ..core import operator
+_delayed = {}
+
del operator
diff --git a/graphblas/select/__init__.py b/graphblas/select/__init__.py
index c7a1897f5..b55766ff8 100644
--- a/graphblas/select/__init__.py
+++ b/graphblas/select/__init__.py
@@ -8,7 +8,7 @@
def __dir__():
- return globals().keys() | _delayed.keys()
+ return globals().keys() | _delayed.keys() | {"ss"}
def __getattr__(key):
@@ -17,6 +17,18 @@ def __getattr__(key):
rv = func(**kwargs)
globals()[key] = rv
return rv
+ if key == "ss":
+ from .. import backend
+
+ if backend != "suitesparse":
+ raise AttributeError(
+ f'module {__name__!r} only has attribute "ss" when backend is "suitesparse"'
+ )
+ from importlib import import_module
+
+ ss = import_module(".ss", __name__)
+ globals()["ss"] = ss
+ return ss
raise AttributeError(f"module {__name__!r} has no attribute {key!r}")
@@ -57,9 +69,9 @@ def _resolve_expr(expr, callname, opname):
def _match_expr(parent, expr):
- """Match expressions to rewrite `A.select(A < 5)` into select expression.
+ """Match expressions to rewrite ``A.select(A < 5)`` into select expression.
- The argument must match the parent, so this _won't_ be rewritten: `A.select(B < 5)`
+ The argument must match the parent, so this _won't_ be rewritten: ``A.select(B < 5)``
"""
args = expr.args
op = expr.op
@@ -76,56 +88,49 @@ def _match_expr(parent, expr):
def value(expr):
- """
- An advanced select method which allows for easily expressing
- value comparison logic.
+ """An advanced select method for easily expressing value comparison logic.
Example usage:
>>> gb.select.value(A > 0)
- The example will dispatch to `gb.select.valuegt(A, 0)`
+ The example will dispatch to ``gb.select.valuegt(A, 0)``
while being nicer to read.
"""
return _resolve_expr(expr, "value", "value")
def row(expr):
- """
- An advanced select method which allows for easily expressing
- Matrix row index comparison logic.
+ """An advanced select method for easily expressing Matrix row index comparison logic.
Example usage:
>>> gb.select.row(A <= 5)
- The example will dispatch to `gb.select.rowle(A, 5)`
+ The example will dispatch to ``gb.select.rowle(A, 5)``
while being potentially nicer to read.
"""
return _resolve_expr(expr, "row", "row")
def column(expr):
- """
- An advanced select method which allows for easily expressing
- Matrix column index comparison logic.
+ """An advanced select method for easily expressing Matrix column index comparison logic.
Example usage:
>>> gb.select.column(A <= 5)
- The example will dispatch to `gb.select.colle(A, 5)`
+ The example will dispatch to ``gb.select.colle(A, 5)``
while being potentially nicer to read.
"""
return _resolve_expr(expr, "column", "col")
def index(expr):
- """
- An advanced select method which allows for easily expressing
+ """An advanced select method which allows for easily expressing
Vector index comparison logic.
Example usage:
>>> gb.select.index(v <= 5)
- The example will dispatch to `gb.select.indexle(v, 5)`
+ The example will dispatch to ``gb.select.indexle(v, 5)``
while being potentially nicer to read.
"""
return _resolve_expr(expr, "index", "index")
diff --git a/graphblas/select/ss.py b/graphblas/select/ss.py
new file mode 100644
index 000000000..173067382
--- /dev/null
+++ b/graphblas/select/ss.py
@@ -0,0 +1,6 @@
+from ..core import operator
+from ..core.ss.select import register_new # noqa: F401
+
+_delayed = {}
+
+del operator
diff --git a/graphblas/semiring/__init__.py b/graphblas/semiring/__init__.py
index 904ae192f..95a44261a 100644
--- a/graphblas/semiring/__init__.py
+++ b/graphblas/semiring/__init__.py
@@ -1,7 +1,29 @@
# All items are dynamically added by classes in operator.py
# This module acts as a container of Semiring instances
+from ..core import _supports_udfs
+
_delayed = {}
_deprecated = {}
+_udfs = {
+ # Used by aggregators
+ "max_absfirst",
+ "max_abssecond",
+ "plus_absfirst",
+ "plus_abssecond",
+ "plus_rpow",
+ # floordiv
+ "any_floordiv",
+ "max_floordiv",
+ "min_floordiv",
+ "plus_floordiv",
+ "times_floordiv",
+ # rfloordiv
+ "any_rfloordiv",
+ "max_rfloordiv",
+ "min_rfloordiv",
+ "plus_rfloordiv",
+ "times_rfloordiv",
+}
def __dir__():
@@ -24,11 +46,11 @@ def __getattr__(key):
return rv
if key in _delayed:
func, kwargs = _delayed.pop(key)
- if type(kwargs["binaryop"]) is str:
+ if isinstance(kwargs["binaryop"], str):
from ..binary import from_string
kwargs["binaryop"] = from_string(kwargs["binaryop"])
- if type(kwargs["monoid"]) is str:
+ if isinstance(kwargs["monoid"], str):
from ..monoid import from_string
kwargs["monoid"] = from_string(kwargs["monoid"])
@@ -47,6 +69,11 @@ def __getattr__(key):
ss = import_module(".ss", __name__)
globals()["ss"] = ss
return ss
+ if not _supports_udfs and key in _udfs:
+ raise AttributeError(
+ f"module {__name__!r} unable to compile UDF for {key!r}; "
+ "install numba for UDF support"
+ )
raise AttributeError(f"module {__name__!r} has no attribute {key!r}")
diff --git a/graphblas/semiring/numpy.py b/graphblas/semiring/numpy.py
index 64169168a..10a680ea0 100644
--- a/graphblas/semiring/numpy.py
+++ b/graphblas/semiring/numpy.py
@@ -5,6 +5,7 @@
https://numba.readthedocs.io/en/stable/reference/numpysupported.html#math-operations
"""
+
import itertools as _itertools
from .. import _STANDARD_OPERATOR_NAMES
@@ -12,6 +13,7 @@
from .. import config as _config
from .. import monoid as _monoid
from ..binary.numpy import _binary_names
+from ..core import _supports_udfs
from ..monoid.numpy import _fmin_is_float, _monoid_identities
_delayed = {}
@@ -132,19 +134,29 @@
def __dir__():
- return globals().keys() | _delayed.keys() | _semiring_names
+ if not _supports_udfs and not _config.get("mapnumpy"):
+ return globals().keys() # FLAKY COVERAGE
+ attrs = _delayed.keys() | _semiring_names
+ if not _supports_udfs:
+ attrs &= {
+ f"{monoid_name}_{binary_name}"
+ for monoid_name, binary_name in _itertools.product(
+ dir(_monoid.numpy), dir(_binary.numpy)
+ )
+ }
+ return attrs | globals().keys()
def __getattr__(name):
- from ..core import operator
+ from ..core.operator import get_semiring
if name in _delayed:
func, kwargs = _delayed.pop(name)
- if type(kwargs["binaryop"]) is str:
+ if isinstance(kwargs["binaryop"], str):
from ..binary import from_string
kwargs["binaryop"] = from_string(kwargs["binaryop"])
- if type(kwargs["monoid"]) is str:
+ if isinstance(kwargs["monoid"], str):
from ..monoid import from_string
kwargs["monoid"] = from_string(kwargs["monoid"])
@@ -161,7 +173,7 @@ def __getattr__(name):
binary_name = "_".join(words[i:])
if hasattr(_binary.numpy, binary_name): # pragma: no branch
break
- operator.get_semiring(
+ get_semiring(
getattr(_monoid.numpy, monoid_name),
getattr(_binary.numpy, binary_name),
name=f"numpy.{name}",
diff --git a/graphblas/semiring/ss.py b/graphblas/semiring/ss.py
index e45cbcda0..97852fc12 100644
--- a/graphblas/semiring/ss.py
+++ b/graphblas/semiring/ss.py
@@ -1,3 +1,5 @@
from ..core import operator
+_delayed = {}
+
del operator
diff --git a/graphblas/ss/__init__.py b/graphblas/ss/__init__.py
index b36bc1bdc..1f059771b 100644
--- a/graphblas/ss/__init__.py
+++ b/graphblas/ss/__init__.py
@@ -1 +1,7 @@
-from ._core import about, concat, config, diag
+from suitesparse_graphblas import burble
+
+from ._core import _IS_SSGB7, about, concat, config, diag
+
+if not _IS_SSGB7:
+ # Context was introduced in SuiteSparse:GraphBLAS 8.0
+ from ..core.ss.context import Context, global_context
diff --git a/graphblas/ss/_core.py b/graphblas/ss/_core.py
index 441458a42..b42ea72b4 100644
--- a/graphblas/ss/_core.py
+++ b/graphblas/ss/_core.py
@@ -2,8 +2,10 @@
from ..core import ffi, lib
from ..core.base import _expect_type
+from ..core.descriptor import lookup as descriptor_lookup
from ..core.matrix import Matrix, TransposedMatrix
from ..core.scalar import _as_scalar
+from ..core.ss import _IS_SSGB7
from ..core.ss.config import BaseConfig
from ..core.ss.matrix import _concat_mn
from ..core.vector import Vector
@@ -12,7 +14,7 @@
class _graphblas_ss:
- """Used in `_expect_type`."""
+ """Used in ``_expect_type``."""
_graphblas_ss.__name__ = "graphblas.ss"
@@ -20,8 +22,7 @@ class _graphblas_ss:
def diag(x, k=0, dtype=None, *, name=None, **opts):
- """
- GxB_Matrix_diag, GxB_Vector_diag.
+ """GxB_Matrix_diag, GxB_Vector_diag.
Extract a diagonal Vector from a Matrix, or construct a diagonal Matrix
from a Vector. Unlike ``Matrix.diag`` and ``Vector.diag``, this function
@@ -33,8 +34,8 @@ def diag(x, k=0, dtype=None, *, name=None, **opts):
The Vector to assign to the diagonal, or the Matrix from which to
extract the diagonal.
k : int, default 0
- Diagonal in question. Use `k>0` for diagonals above the main diagonal,
- and `k<0` for diagonals below the main diagonal.
+ Diagonal in question. Use ``k>0`` for diagonals above the main diagonal,
+ and ``k<0`` for diagonals below the main diagonal.
See Also
--------
@@ -52,6 +53,9 @@ def diag(x, k=0, dtype=None, *, name=None, **opts):
dtype = x.dtype
typ = type(x)
if typ is Vector:
+ if opts:
+ # Ignore opts for now
+ desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context)
size = x._size + abs(k.value)
rv = Matrix(dtype, nrows=size, ncols=size, name=name)
rv.ss.build_diag(x, k)
@@ -66,14 +70,13 @@ def diag(x, k=0, dtype=None, *, name=None, **opts):
def concat(tiles, dtype=None, *, name=None, **opts):
- """
- GxB_Matrix_concat.
+ """GxB_Matrix_concat.
Concatenate a 2D list of Matrix objects into a new Matrix, or a 1D list of
Vector objects into a new Vector. To concatenate into existing objects,
- use ``Matrix.ss.concat`` or `Vector.ss.concat`.
+ use ``Matrix.ss.concat`` or ``Vector.ss.concat``.
- Vectors may be used as `Nx1` Matrix objects when creating a new Matrix.
+ Vectors may be used as ``Nx1`` Matrix objects when creating a new Matrix.
This performs the opposite operation as ``split``.
@@ -117,18 +120,65 @@ class GlobalConfig(BaseConfig):
Threshold that determines when to switch to bitmap format
nthreads : int
Maximum number of OpenMP threads to use
- memory_pool : List[int]
+ chunk : double
+ Control the number of threads used for small problems.
+ For example, ``nthreads = floor(work / chunk)``.
burble : bool
Enable diagnostic printing from SuiteSparse:GraphBLAS
- print_1based: bool
+ print_1based : bool
gpu_control : str, {"always", "never"}
+ Only available for SuiteSparse:GraphBLAS 7
+ **GPU support is a work in progress--not recommended to use**
gpu_chunk : double
+ Only available for SuiteSparse:GraphBLAS 7
+ **GPU support is a work in progress--not recommended to use**
+ gpu_id : int
+ Which GPU to use; default is -1, which means do not run on the GPU.
+ Only available for SuiteSparse:GraphBLAS >=8
+ **GPU support is a work in progress--not recommended to use**
+ jit_c_control : {"off", "pause", "run", "load", "on}
+ Control the CPU JIT:
+ "off" : do not use the JIT and free all JIT kernels if loaded
+ "pause" : do not run JIT kernels, but keep any loaded
+ "run" : run JIT kernels if already loaded, but don't load or compile
+ "load" : able to load and run JIT kernels; may not compile
+ "on" : full JIT: able to compile, load, and run
+ Only available for SuiteSparse:GraphBLAS >=8
+ jit_use_cmake : bool
+ Whether to use cmake to compile the JIT kernels.
+ Only available for SuiteSparse:GraphBLAS >=8
+ jit_c_compiler_name : str
+ C compiler for JIT kernels.
+ Only available for SuiteSparse:GraphBLAS >=8
+ jit_c_compiler_flags : str
+ Flags for the C compiler.
+ Only available for SuiteSparse:GraphBLAS >=8
+ jit_c_linker_flags : str
+ Link flags for the C compiler
+ Only available for SuiteSparse:GraphBLAS >=8
+ jit_c_libraries : str
+ Libraries to link against.
+ Only available for SuiteSparse:GraphBLAS >=8
+ jit_c_cmake_libs : str
+ Libraries to link against when cmake is used.
+ Only available for SuiteSparse:GraphBLAS >=8
+ jit_c_preface : str
+ C code as preface to JIT kernels.
+ Only available for SuiteSparse:GraphBLAS >=8
+ jit_error_log : str
+ Error log file.
+ Only available for SuiteSparse:GraphBLAS >=8
+ jit_cache_path : str
+ The folder with the compiled kernels.
+ Only available for SuiteSparse:GraphBLAS >=8
Setting values to None restores the default value for most configurations.
"""
_get_function = "GxB_Global_Option_get"
_set_function = "GxB_Global_Option_set"
+ if not _IS_SSGB7:
+ _context_keys = {"chunk", "gpu_id", "nthreads"}
_null_valid = {"bitmap_switch"}
_options = {
# Matrix/Vector format
@@ -139,14 +189,36 @@ class GlobalConfig(BaseConfig):
"nthreads": (lib.GxB_GLOBAL_NTHREADS, "int"),
"chunk": (lib.GxB_GLOBAL_CHUNK, "double"),
# Memory pool control
- "memory_pool": (lib.GxB_MEMORY_POOL, "int64_t[64]"),
+ # "memory_pool": (lib.GxB_MEMORY_POOL, "int64_t[64]"), # No longer used
# Diagnostics (skipping "printf" and "flush" for now)
"burble": (lib.GxB_BURBLE, "bool"),
"print_1based": (lib.GxB_PRINT_1BASED, "bool"),
- # CUDA GPU control
- "gpu_control": (lib.GxB_GLOBAL_GPU_CONTROL, "GrB_Desc_Value"),
- "gpu_chunk": (lib.GxB_GLOBAL_GPU_CHUNK, "double"),
}
+ if _IS_SSGB7:
+ _options.update(
+ {
+ "gpu_control": (lib.GxB_GLOBAL_GPU_CONTROL, "GrB_Desc_Value"),
+ "gpu_chunk": (lib.GxB_GLOBAL_GPU_CHUNK, "double"),
+ }
+ )
+ else:
+ _options.update(
+ {
+ # JIT control
+ "jit_c_control": (lib.GxB_JIT_C_CONTROL, "int"),
+ "jit_use_cmake": (lib.GxB_JIT_USE_CMAKE, "bool"),
+ "jit_c_compiler_name": (lib.GxB_JIT_C_COMPILER_NAME, "char*"),
+ "jit_c_compiler_flags": (lib.GxB_JIT_C_COMPILER_FLAGS, "char*"),
+ "jit_c_linker_flags": (lib.GxB_JIT_C_LINKER_FLAGS, "char*"),
+ "jit_c_libraries": (lib.GxB_JIT_C_LIBRARIES, "char*"),
+ "jit_c_cmake_libs": (lib.GxB_JIT_C_CMAKE_LIBS, "char*"),
+ "jit_c_preface": (lib.GxB_JIT_C_PREFACE, "char*"),
+ "jit_error_log": (lib.GxB_JIT_ERROR_LOG, "char*"),
+ "jit_cache_path": (lib.GxB_JIT_CACHE_PATH, "char*"),
+ # CUDA GPU control
+ "gpu_id": (lib.GxB_GLOBAL_GPU_ID, "int"),
+ }
+ )
# Values to restore defaults
_defaults = {
"hyper_switch": lib.GxB_HYPER_DEFAULT,
@@ -157,17 +229,28 @@ class GlobalConfig(BaseConfig):
"burble": 0,
"print_1based": 0,
}
+ if not _IS_SSGB7:
+ _defaults["gpu_id"] = -1 # -1 means no GPU
_enumerations = {
"format": {
"by_row": lib.GxB_BY_ROW,
"by_col": lib.GxB_BY_COL,
# "no_format": lib.GxB_NO_FORMAT, # Used by iterators; not valid here
},
- "gpu_control": {
+ }
+ if _IS_SSGB7:
+ _enumerations["gpu_control"] = {
"always": lib.GxB_GPU_ALWAYS,
"never": lib.GxB_GPU_NEVER,
- },
- }
+ }
+ else:
+ _enumerations["jit_c_control"] = {
+ "off": lib.GxB_JIT_OFF,
+ "pause": lib.GxB_JIT_PAUSE,
+ "run": lib.GxB_JIT_RUN,
+ "load": lib.GxB_JIT_LOAD,
+ "on": lib.GxB_JIT_ON,
+ }
class About(Mapping):
@@ -254,4 +337,10 @@ def __len__(self):
about = About()
-config = GlobalConfig()
+if _IS_SSGB7:
+ config = GlobalConfig()
+else:
+ # Context was introduced in SuiteSparse:GraphBLAS 8.0
+ from ..core.ss.context import global_context
+
+ config = GlobalConfig(context=global_context)
diff --git a/graphblas/tests/conftest.py b/graphblas/tests/conftest.py
index 24aba085f..964325e0d 100644
--- a/graphblas/tests/conftest.py
+++ b/graphblas/tests/conftest.py
@@ -1,39 +1,50 @@
import atexit
+import contextlib
import functools
import itertools
+import platform
+import sys
from pathlib import Path
import numpy as np
import pytest
import graphblas as gb
+from graphblas.core import _supports_udfs as supports_udfs
orig_binaryops = set()
orig_semirings = set()
+pypy = platform.python_implementation() == "PyPy"
+
def pytest_configure(config):
rng = np.random.default_rng()
- randomly = config.getoption("--randomly", False)
+ randomly = config.getoption("--randomly", None)
+ if randomly is None: # pragma: no cover
+ options_unavailable = True
+ randomly = True
+ config.addinivalue_line("markers", "slow: Skipped unless --runslow passed")
+ else:
+ options_unavailable = False
backend = config.getoption("--backend", None)
if backend is None:
if randomly:
backend = "suitesparse" if rng.random() < 0.5 else "suitesparse-vanilla"
else:
backend = "suitesparse"
- blocking = config.getoption("--blocking", True)
+ blocking = config.getoption("--blocking", None)
if blocking is None: # pragma: no branch
blocking = rng.random() < 0.5 if randomly else True
record = config.getoption("--record", False)
if record is None: # pragma: no branch
record = rng.random() < 0.5 if randomly else False
- mapnumpy = config.getoption("--mapnumpy", False)
+ mapnumpy = config.getoption("--mapnumpy", None)
if mapnumpy is None:
mapnumpy = rng.random() < 0.5 if randomly else False
- runslow = config.getoption("--runslow", False)
+ runslow = config.getoption("--runslow", None)
if runslow is None:
- # Add a small amount of randomization to be safer
- runslow = rng.random() < 0.05 if randomly else False
+ runslow = options_unavailable
config.runslow = runslow
gb.config.set(autocompute=False, mapnumpy=mapnumpy)
@@ -48,7 +59,7 @@ def pytest_configure(config):
rec.start()
def save_records():
- with Path("record.txt").open("w") as f: # pragma: no cover
+ with Path("record.txt").open("w") as f: # pragma: no cover (???)
f.write("\n".join(rec.data))
# I'm sure there's a `pytest` way to do this...
@@ -58,9 +69,11 @@ def save_records():
for key in dir(gb.semiring)
if key != "ss"
and isinstance(
- getattr(gb.semiring, key)
- if key not in gb.semiring._deprecated
- else gb.semiring._deprecated[key],
+ (
+ getattr(gb.semiring, key)
+ if key not in gb.semiring._deprecated
+ else gb.semiring._deprecated[key]
+ ),
(gb.core.operator.Semiring, gb.core.operator.ParameterizedSemiring),
)
)
@@ -69,9 +82,11 @@ def save_records():
for key in dir(gb.binary)
if key != "ss"
and isinstance(
- getattr(gb.binary, key)
- if key not in gb.binary._deprecated
- else gb.binary._deprecated[key],
+ (
+ getattr(gb.binary, key)
+ if key not in gb.binary._deprecated
+ else gb.binary._deprecated[key]
+ ),
(gb.core.operator.BinaryOp, gb.core.operator.ParameterizedBinaryOp),
)
)
@@ -105,6 +120,27 @@ def ic(): # pragma: no cover (debug)
return icecream.ic
+@contextlib.contextmanager
+def burble(): # pragma: no cover (debug)
+ """Show the burble diagnostics within a context."""
+ if gb.backend != "suitesparse":
+ yield
+ return
+ prev = gb.ss.config["burble"]
+ gb.ss.config["burble"] = True
+ try:
+ yield
+ finally:
+ gb.ss.config["burble"] = prev
+
+
+@pytest.fixture(scope="session")
+def burble_all(): # pragma: no cover (debug)
+ """Show the burble diagnostics for the entire test."""
+ with burble():
+ yield burble
+
+
def autocompute(func):
@functools.wraps(func)
def inner(*args, **kwargs):
@@ -116,3 +152,15 @@ def inner(*args, **kwargs):
def compute(x):
return x
+
+
+def shouldhave(module, opname):
+ """Whether an "operator" module should have the given operator."""
+ return supports_udfs or hasattr(module, opname)
+
+
+def dprint(*args, **kwargs): # pragma: no cover (debug)
+ """Print to stderr for debugging purposes."""
+ kwargs["file"] = sys.stderr
+ kwargs["flush"] = True
+ print(*args, **kwargs)
diff --git a/graphblas/tests/pickle1-vanilla.pkl b/graphblas/tests/pickle1-vanilla.pkl
index 36ea20760..a494e405a 100644
Binary files a/graphblas/tests/pickle1-vanilla.pkl and b/graphblas/tests/pickle1-vanilla.pkl differ
diff --git a/graphblas/tests/pickle1.pkl b/graphblas/tests/pickle1.pkl
index 98a1fdf05..273b49901 100644
Binary files a/graphblas/tests/pickle1.pkl and b/graphblas/tests/pickle1.pkl differ
diff --git a/graphblas/tests/pickle2-vanilla.pkl b/graphblas/tests/pickle2-vanilla.pkl
index 3c6e18ba4..dd091c823 100644
Binary files a/graphblas/tests/pickle2-vanilla.pkl and b/graphblas/tests/pickle2-vanilla.pkl differ
diff --git a/graphblas/tests/pickle2.pkl b/graphblas/tests/pickle2.pkl
index 3c6e18ba4..dd091c823 100644
Binary files a/graphblas/tests/pickle2.pkl and b/graphblas/tests/pickle2.pkl differ
diff --git a/graphblas/tests/pickle3-vanilla.pkl b/graphblas/tests/pickle3-vanilla.pkl
index 29e79d7db..7f8408c95 100644
Binary files a/graphblas/tests/pickle3-vanilla.pkl and b/graphblas/tests/pickle3-vanilla.pkl differ
diff --git a/graphblas/tests/pickle3.pkl b/graphblas/tests/pickle3.pkl
index d04a53cb9..28b308452 100644
Binary files a/graphblas/tests/pickle3.pkl and b/graphblas/tests/pickle3.pkl differ
diff --git a/graphblas/tests/test_core.py b/graphblas/tests/test_core.py
index c08ca416f..3586eb4a8 100644
--- a/graphblas/tests/test_core.py
+++ b/graphblas/tests/test_core.py
@@ -1,7 +1,18 @@
+import pathlib
+
import pytest
import graphblas as gb
+try:
+ import setuptools
+except ImportError: # pragma: no cover (import)
+ setuptools = None
+try:
+ import tomli
+except ImportError: # pragma: no cover (import)
+ tomli = None
+
def test_import_special_attrs():
not_hidden = {x for x in dir(gb) if not x.startswith("__")}
@@ -57,3 +68,29 @@ def test_version():
from packaging.version import parse
assert parse(gb.__version__) > parse("2022.11.0")
+
+
+@pytest.mark.skipif("not setuptools or not tomli or not gb.__file__")
+def test_packages():
+ """Ensure all packages are declared in pyproject.toml."""
+ # Currently assume s`pyproject.toml` is at the same level as `graphblas` folder.
+ # This probably isn't always True, and we can probably do a better job of finding it.
+ path = pathlib.Path(gb.__file__).parent
+ pkgs = [f"graphblas.{x}" for x in setuptools.find_packages(str(path))]
+ pkgs.append("graphblas")
+ pkgs.sort()
+ pyproject = path.parent / "pyproject.toml"
+ if not pyproject.exists(): # pragma: no cover (safety)
+ pytest.skip("Did not find pyproject.toml")
+ with pyproject.open("rb") as f:
+ cfg = tomli.load(f)
+ if cfg.get("project", {}).get("name") != "python-graphblas": # pragma: no cover (safety)
+ pytest.skip("Did not find correct pyproject.toml")
+ pkgs2 = sorted(cfg["tool"]["setuptools"]["packages"])
+ assert (
+ pkgs == pkgs2
+ ), "If there are extra items on the left, add them to pyproject.toml:tool.setuptools.packages"
+
+
+def test_index_max():
+ assert gb.MAX_SIZE == 2**60 # True for all current backends
diff --git a/graphblas/tests/test_descriptor.py b/graphblas/tests/test_descriptor.py
index 9209a8055..6ec9df36a 100644
--- a/graphblas/tests/test_descriptor.py
+++ b/graphblas/tests/test_descriptor.py
@@ -2,8 +2,7 @@
def test_caching():
- """
- Test that building a descriptor is actually caching rather than building
+ """Test that building a descriptor is actually caching rather than building
a new object for each call.
"""
tocr = descriptor.lookup(
diff --git a/graphblas/tests/test_dtype.py b/graphblas/tests/test_dtype.py
index 64e6d69ab..ecbca707f 100644
--- a/graphblas/tests/test_dtype.py
+++ b/graphblas/tests/test_dtype.py
@@ -7,8 +7,9 @@
import pytest
import graphblas as gb
-from graphblas import dtypes
+from graphblas import core, dtypes
from graphblas.core import lib
+from graphblas.core.utils import _NP2
from graphblas.dtypes import lookup_dtype
suitesparse = gb.backend == "suitesparse"
@@ -123,7 +124,7 @@ def test_dtype_bad_comparison():
def test_dtypes_match_numpy():
- for key, val in dtypes._registry.items():
+ for key, val in core.dtypes._registry.items():
try:
if key is int or (isinstance(key, str) and key == "int"):
# For win64, numpy treats int as int32, not int64
@@ -137,7 +138,7 @@ def test_dtypes_match_numpy():
def test_pickle():
- for val in dtypes._registry.values():
+ for val in core.dtypes._registry.values():
s = pickle.dumps(val)
val2 = pickle.loads(s)
if val._is_udt: # pragma: no cover
@@ -205,7 +206,7 @@ def test_auto_register():
def test_default_names():
- from graphblas.dtypes import _default_name
+ from graphblas.core.dtypes import _default_name
assert _default_name(np.dtype([("x", np.int32), ("y", np.float64)], align=True)) == (
"{'x': INT32, 'y': FP64}"
@@ -224,15 +225,22 @@ def test_record_dtype_from_dict():
def test_dtype_to_from_string():
types = [dtypes.BOOL, dtypes.FP64]
for c in string.ascii_letters:
+ if c == "T":
+ # See NEP 55 about StringDtype "T". Notably, this doesn't work:
+ # >>> np.dtype(np.dtype("T").str)
+ continue
+ if _NP2 and c == "a":
+ # Data type alias 'a' was deprecated in NumPy 2.0. Use the 'S' alias instead.
+ continue
try:
dtype = np.dtype(c)
types.append(dtype)
except Exception:
pass
for dtype in types:
- s = dtypes._dtype_to_string(dtype)
+ s = core.dtypes._dtype_to_string(dtype)
try:
- dtype2 = dtypes._string_to_dtype(s)
+ dtype2 = core.dtypes._string_to_dtype(s)
except Exception:
with pytest.raises(ValueError, match="Unknown dtype"):
lookup_dtype(dtype)
@@ -241,7 +249,7 @@ def test_dtype_to_from_string():
def test_has_complex():
- """Only SuiteSparse has complex (with Windows support in Python after v7.4.3.1)"""
+ """Only SuiteSparse has complex (with Windows support in Python after v7.4.3.1)."""
if not suitesparse:
assert not dtypes._supports_complex
return
@@ -252,7 +260,21 @@ def test_has_complex():
import suitesparse_graphblas as ssgb
from packaging.version import parse
- if parse(ssgb.__version__) < parse("7.4.3.1"):
- assert not dtypes._supports_complex
+ assert dtypes._supports_complex == (parse(ssgb.__version__) >= parse("7.4.3.1"))
+
+
+def test_has_ss_attribute():
+ if suitesparse:
+ assert dtypes.ss is not None
else:
- assert dtypes._supports_complex
+ with pytest.raises(AttributeError):
+ dtypes.ss
+
+
+def test_dir():
+ must_have = {"DataType", "lookup_dtype", "register_anonymous", "register_new", "ss", "unify"}
+ must_have.update({"FP32", "FP64", "INT8", "INT16", "INT32", "INT64"})
+ must_have.update({"BOOL", "UINT8", "UINT16", "UINT32", "UINT64"})
+ if dtypes._supports_complex:
+ must_have.update({"FC32", "FC64"})
+ assert set(dir(dtypes)) & must_have == must_have
diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py
index 3094aea91..faadc983b 100644
--- a/graphblas/tests/test_formatting.py
+++ b/graphblas/tests/test_formatting.py
@@ -40,9 +40,8 @@ def _printer(text, name, repr_name, indent):
# line = f"f'{{CSS_STYLE}}'"
in_style = False
is_style = True
- else: # pragma: no cover (???)
- # This definitely gets covered, but why is it not picked up?
- continue
+ else:
+ continue # FLAKY COVERAGE
if repr_name == "repr_html" and line.startswith("\n"
+ '\n'
+ " \n"
+ ' \n'
+ " | \n"
+ " 0 | \n"
+ " 1 | \n"
+ "
\n"
+ " \n"
+ " \n"
+ " \n"
+ " | \n"
+ " | \n"
+ " 2 | \n"
+ "
\n"
+ " \n"
+ "
\n"
+ " "
+ )
diff --git a/graphblas/tests/test_infix.py b/graphblas/tests/test_infix.py
index f496ade15..601f282a7 100644
--- a/graphblas/tests/test_infix.py
+++ b/graphblas/tests/test_infix.py
@@ -1,6 +1,6 @@
import pytest
-from graphblas import monoid, op
+from graphblas import binary, monoid, op
from graphblas.exceptions import DimensionMismatch
from .conftest import autocompute
@@ -342,3 +342,440 @@ def test_inplace_infix(s1, v1, v2, A1, A2):
expr @= A
with pytest.raises(TypeError, match="not supported"):
s1 @= v1
+
+
+@autocompute
+def test_infix_expr_value_types():
+ """Test bug where `infix_expr._value` was used as MatrixExpression or Matrix."""
+ from graphblas.core.matrix import MatrixExpression
+
+ A = Matrix(int, 3, 3)
+ A << 1
+ expr = A @ A.T
+ assert expr._expr is None
+ assert expr._value is None
+ assert type(expr._get_value()) is Matrix
+ assert type(expr._expr) is MatrixExpression
+ assert type(expr.new()) is Matrix
+ assert expr._expr is not None
+ assert expr._value is None
+ assert type(expr.new()) is Matrix
+ assert type(expr._get_value()) is Matrix
+ assert expr._expr is not None
+ assert expr._value is not None
+ assert expr._expr._value is not None
+ expr._value = None
+ assert expr._value is None
+ assert expr._expr._value is None
+
+
+def test_multi_infix_vector():
+ D0 = Vector.from_scalar(0, 3).diag()
+ v1 = Vector.from_coo([0, 1], [1, 2], size=3) # 1 2 .
+ v2 = Vector.from_coo([1, 2], [1, 2], size=3) # . 1 2
+ v3 = Vector.from_coo([2, 0], [1, 2], size=3) # 2 . 1
+ # ewise_add
+ result = binary.plus((v1 | v2) | v3).new()
+ expected = Vector.from_scalar(3, size=3)
+ assert result.isequal(expected)
+ result = binary.plus(v1 | (v2 | v3)).new()
+ assert result.isequal(expected)
+ result = monoid.min(v1 | v2 | v3).new()
+ expected = Vector.from_scalar(1, size=3)
+ assert result.isequal(expected)
+ # ewise_mult
+ result = monoid.max((v1 & v2) & v3).new()
+ expected = Vector(int, size=3)
+ assert result.isequal(expected)
+ result = monoid.max(v1 & (v2 & v3)).new()
+ assert result.isequal(expected)
+ result = monoid.min((v1 & v2) & v1).new()
+ expected = Vector.from_coo([1], [1], size=3)
+ assert result.isequal(expected)
+ # ewise_union
+ result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10).new()
+ expected = Vector.from_scalar(13, size=3)
+ assert result.isequal(expected)
+ result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10.0).new()
+ expected = Vector.from_scalar(13.0, size=3)
+ assert result.isequal(expected)
+ result = binary.plus(v1 | (v2 | v3), left_default=10, right_default=10).new()
+ assert result.isequal(expected)
+ # inner
+ assert op.plus_plus(v1 @ v1).new().value == 6
+ assert op.plus_plus(v1 @ (v1 @ D0)).new().value == 6
+ assert op.plus_plus((D0 @ v1) @ v1).new().value == 6
+ # matrix-vector ewise_add
+ result = binary.plus((D0 | v1) | v2).new()
+ expected = binary.plus(binary.plus(D0 | v1).new() | v2).new()
+ assert result.isequal(expected)
+ result = binary.plus(D0 | (v1 | v2)).new()
+ assert result.isequal(expected)
+ result = binary.plus((v1 | v2) | D0).new()
+ assert result.isequal(expected.T)
+ result = binary.plus(v1 | (v2 | D0)).new()
+ assert result.isequal(expected.T)
+ # matrix-vector ewise_mult
+ result = binary.plus((D0 & v1) & v2).new()
+ expected = binary.plus(binary.plus(D0 & v1).new() & v2).new()
+ assert result.isequal(expected)
+ assert result.nvals > 0
+ result = binary.plus(D0 & (v1 & v2)).new()
+ assert result.isequal(expected)
+ result = binary.plus((v1 & v2) & D0).new()
+ assert result.isequal(expected.T)
+ result = binary.plus(v1 & (v2 & D0)).new()
+ assert result.isequal(expected.T)
+ # matrix-vector ewise_union
+ kwargs = {"left_default": 10, "right_default": 20}
+ result = binary.plus((D0 | v1) | v2, **kwargs).new()
+ expected = binary.plus(binary.plus(D0 | v1, **kwargs).new() | v2, **kwargs).new()
+ assert result.isequal(expected)
+ result = binary.plus(D0 | (v1 | v2), **kwargs).new()
+ expected = binary.plus(D0 | binary.plus(v1 | v2, **kwargs).new(), **kwargs).new()
+ assert result.isequal(expected)
+ result = binary.plus((v1 | v2) | D0, **kwargs).new()
+ expected = binary.plus(binary.plus(v1 | v2, **kwargs).new() | D0, **kwargs).new()
+ assert result.isequal(expected)
+ result = binary.plus(v1 | (v2 | D0), **kwargs).new()
+ expected = binary.plus(v1 | binary.plus(v2 | D0, **kwargs).new(), **kwargs).new()
+ assert result.isequal(expected)
+ # vxm, mxv
+ result = op.plus_plus((D0 @ v1) @ D0).new()
+ assert result.isequal(v1)
+ result = op.plus_plus(D0 @ (v1 @ D0)).new()
+ assert result.isequal(v1)
+ result = op.plus_plus(v1 @ (D0 @ D0)).new()
+ assert result.isequal(v1)
+ result = op.plus_plus((D0 @ D0) @ v1).new()
+ assert result.isequal(v1)
+ result = op.plus_plus((v1 @ D0) @ D0).new()
+ assert result.isequal(v1)
+ result = op.plus_plus(D0 @ (D0 @ v1)).new()
+ assert result.isequal(v1)
+
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) | v3
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2).__ror__(v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) | (v2 & v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) | (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1 | (v2 & v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1.__ror__(v2 & v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) | (v2 & v3)
+
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1 & (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1.__rand__(v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) & (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) & (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) & v3
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2).__rand__(v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) & (v2 & v3)
+
+ # We differentiate between infix and methods
+ with pytest.raises(TypeError, match="to automatically compute"):
+ v1.ewise_add(v2 & v3)
+ with pytest.raises(TypeError, match="Automatic computation"):
+ (v1 & v2).ewise_add(v3)
+ with pytest.raises(TypeError, match="to automatically compute"):
+ v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="Automatic computation"):
+ (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="to automatically compute"):
+ v1.ewise_mult(v2 | v3)
+ with pytest.raises(TypeError, match="Automatic computation"):
+ (v1 | v2).ewise_mult(v3)
+
+
+@autocompute
+def test_multi_infix_vector_auto():
+ v1 = Vector.from_coo([0, 1], [1, 2], size=3) # 1 2 .
+ v2 = Vector.from_coo([1, 2], [1, 2], size=3) # . 1 2
+ v3 = Vector.from_coo([2, 0], [1, 2], size=3) # 2 . 1
+ # We differentiate between infix and methods
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ v1.ewise_add(v2 & v3)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ (v1 & v2).ewise_add(v3)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ v1.ewise_mult(v2 | v3)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ (v1 | v2).ewise_mult(v3)
+
+
+def test_multi_infix_matrix():
+ # Adapted from test_multi_infix_vector
+ D0 = Vector.from_scalar(0, 3).diag()
+ v1 = Matrix.from_coo([0, 1], [0, 0], [1, 2], nrows=3) # 1 2 .
+ v2 = Matrix.from_coo([1, 2], [0, 0], [1, 2], nrows=3) # . 1 2
+ v3 = Matrix.from_coo([2, 0], [0, 0], [1, 2], nrows=3) # 2 . 1
+ # ewise_add
+ result = binary.plus((v1 | v2) | v3).new()
+ expected = Matrix.from_scalar(3, 3, 1)
+ assert result.isequal(expected)
+ result = binary.plus(v1 | (v2 | v3)).new()
+ assert result.isequal(expected)
+ result = monoid.min(v1 | v2 | v3).new()
+ expected = Matrix.from_scalar(1, 3, 1)
+ assert result.isequal(expected)
+ result = binary.plus(v1 | v1 | v1 | v1 | v1).new()
+ expected = (5 * v1).new()
+ assert result.isequal(expected)
+ # ewise_mult
+ result = monoid.max((v1 & v2) & v3).new()
+ expected = Matrix(int, 3, 1)
+ assert result.isequal(expected)
+ result = monoid.max(v1 & (v2 & v3)).new()
+ assert result.isequal(expected)
+ result = monoid.min((v1 & v2) & v1).new()
+ expected = Matrix.from_coo([1], [0], [1], nrows=3)
+ assert result.isequal(expected)
+ result = binary.plus(v1 & v1 & v1 & v1 & v1).new()
+ expected = (5 * v1).new()
+ assert result.isequal(expected)
+ # ewise_union
+ result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10).new()
+ expected = Matrix.from_scalar(13, 3, 1)
+ assert result.isequal(expected)
+ result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10.0).new()
+ expected = Matrix.from_scalar(13.0, 3, 1)
+ assert result.isequal(expected)
+ result = binary.plus(v1 | (v2 | v3), left_default=10, right_default=10).new()
+ assert result.isequal(expected)
+ # mxm
+ assert op.plus_plus(v1.T @ v1).new()[0, 0].new().value == 6
+ assert op.plus_plus(v1 @ (v1.T @ D0)).new()[0, 0].new().value == 2
+ assert op.plus_plus((v1.T @ D0) @ v1).new()[0, 0].new().value == 6
+ assert op.plus_plus(D0 @ D0 @ D0 @ D0 @ D0).new().isequal(D0)
+
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) | v3
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2).__ror__(v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) | (v2 & v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) | (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1 | (v2 & v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1.__ror__(v2 & v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) | (v2 & v3)
+
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1 & (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1.__rand__(v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) & (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) & (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) & v3
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2).__rand__(v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) & (v2 & v3)
+
+ # We differentiate between infix and methods
+ with pytest.raises(TypeError, match="to automatically compute"):
+ v1.ewise_add(v2 & v3)
+ with pytest.raises(TypeError, match="Automatic computation"):
+ (v1 & v2).ewise_add(v3)
+ with pytest.raises(TypeError, match="to automatically compute"):
+ v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="Automatic computation"):
+ (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="to automatically compute"):
+ v1.ewise_mult(v2 | v3)
+ with pytest.raises(TypeError, match="Automatic computation"):
+ (v1 | v2).ewise_mult(v3)
+
+
+@autocompute
+def test_multi_infix_matrix_auto():
+ v1 = Matrix.from_coo([0, 1], [0, 0], [1, 2], nrows=3) # 1 2 .
+ v2 = Matrix.from_coo([1, 2], [0, 0], [1, 2], nrows=3) # . 1 2
+ v3 = Matrix.from_coo([2, 0], [0, 0], [1, 2], nrows=3) # 2 . 1
+ # We differentiate between infix and methods
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ v1.ewise_add(v2 & v3)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ (v1 & v2).ewise_add(v3)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ v1.ewise_mult(v2 | v3)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ (v1 | v2).ewise_mult(v3)
+
+
+def test_multi_infix_scalar():
+ # Adapted from test_multi_infix_vector
+ v1 = Scalar.from_value(1)
+ v2 = Scalar.from_value(2)
+ v3 = Scalar(int)
+ # ewise_add
+ result = binary.plus((v1 | v2) | v3).new()
+ expected = 3
+ assert result.isequal(expected)
+ result = binary.plus((1 | v2) | v3).new()
+ assert result.isequal(expected)
+ result = binary.plus((1 | v2) | 0).new()
+ assert result.isequal(expected)
+ result = binary.plus((v1 | 2) | v3).new()
+ assert result.isequal(expected)
+ result = binary.plus((v1 | 2) | 0).new()
+ assert result.isequal(expected)
+ result = binary.plus((v1 | v2) | 0).new()
+ assert result.isequal(expected)
+
+ result = binary.plus(v1 | (v2 | v3)).new()
+ assert result.isequal(expected)
+ result = binary.plus(1 | (v2 | v3)).new()
+ assert result.isequal(expected)
+ result = binary.plus(1 | (2 | v3)).new()
+ assert result.isequal(expected)
+ result = binary.plus(1 | (v2 | 0)).new()
+ assert result.isequal(expected)
+ result = binary.plus(v1 | (2 | v3)).new()
+ assert result.isequal(expected)
+ result = binary.plus(v1 | (v2 | 0)).new()
+ assert result.isequal(expected)
+
+ result = monoid.min(v1 | v2 | v3).new()
+ expected = 1
+ assert result.isequal(expected)
+ # ewise_mult
+ result = monoid.max((v1 & v2) & v3).new()
+ expected = None
+ assert result.isequal(expected)
+ result = monoid.max(v1 & (v2 & v3)).new()
+ assert result.isequal(expected)
+ result = monoid.min((v1 & v2) & v1).new()
+ expected = 1
+ assert result.isequal(expected)
+
+ result = monoid.min((1 & v2) & v1).new()
+ assert result.isequal(expected)
+ result = monoid.min((1 & v2) & 1).new()
+ assert result.isequal(expected)
+ result = monoid.min((v1 & 2) & v1).new()
+ assert result.isequal(expected)
+ result = monoid.min((v1 & 2) & 1).new()
+ assert result.isequal(expected)
+ result = monoid.min((v1 & v2) & 1).new()
+ assert result.isequal(expected)
+
+ result = monoid.min(1 & (v2 & v1)).new()
+ assert result.isequal(expected)
+ result = monoid.min(1 & (2 & v1)).new()
+ assert result.isequal(expected)
+ result = monoid.min(1 & (v2 & 1)).new()
+ assert result.isequal(expected)
+ result = monoid.min(v1 & (2 & v1)).new()
+ assert result.isequal(expected)
+ result = monoid.min(v1 & (v2 & 1)).new()
+ assert result.isequal(expected)
+
+ # ewise_union
+ result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10).new()
+ expected = 13
+ assert result.isequal(expected)
+ result = binary.plus((1 | v2) | v3, left_default=10, right_default=10).new()
+ assert result.isequal(expected)
+ result = binary.plus((v1 | 2) | v3, left_default=10, right_default=10).new()
+ assert result.isequal(expected)
+ result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10.0).new()
+ assert result.isequal(expected)
+ result = binary.plus(v1 | (v2 | v3), left_default=10, right_default=10).new()
+ assert result.isequal(expected)
+ result = binary.plus(1 | (v2 | v3), left_default=10, right_default=10).new()
+ assert result.isequal(expected)
+ result = binary.plus(1 | (2 | v3), left_default=10, right_default=10).new()
+ assert result.isequal(expected)
+ result = binary.plus(v1 | (2 | v3), left_default=10, right_default=10).new()
+ assert result.isequal(expected)
+
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) | v3
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2).__ror__(v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) | (v2 & v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) | (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1 | (v2 & v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1.__ror__(v2 & v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) | (v2 & v3)
+
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1 & (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ v1.__rand__(v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) & (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 & v2) & (v2 | v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) & v3
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2).__rand__(v3)
+ with pytest.raises(TypeError, match="XXX"): # TODO
+ (v1 | v2) & (v2 & v3)
+
+ # We differentiate between infix and methods
+ with pytest.raises(TypeError, match="to automatically compute"):
+ v1.ewise_add(v2 & v3)
+ with pytest.raises(TypeError, match="Automatic computation"):
+ (v1 & v2).ewise_add(v3)
+ with pytest.raises(TypeError, match="to automatically compute"):
+ v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="Automatic computation"):
+ (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="to automatically compute"):
+ v1.ewise_mult(v2 | v3)
+ with pytest.raises(TypeError, match="Automatic computation"):
+ (v1 | v2).ewise_mult(v3)
+
+
+@autocompute
+def test_multi_infix_scalar_auto():
+ v1 = Scalar.from_value(1)
+ v2 = Scalar.from_value(2)
+ v3 = Scalar(int)
+ # We differentiate between infix and methods
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ v1.ewise_add(v2 & v3)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ (v1 & v2).ewise_add(v3)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ v1.ewise_mult(v2 | v3)
+ with pytest.raises(TypeError, match="only valid for BOOL"):
+ (v1 | v2).ewise_mult(v3)
diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py
index 6fa43ebbc..7e786f0da 100644
--- a/graphblas/tests/test_io.py
+++ b/graphblas/tests/test_io.py
@@ -30,21 +30,14 @@
except ImportError: # pragma: no cover (import)
ak = None
+try:
+ import fast_matrix_market as fmm
+except ImportError: # pragma: no cover (import)
+ fmm = None
suitesparse = gb.backend == "suitesparse"
-@pytest.mark.skipif("not ss")
-def test_deprecated():
- a = np.array([0.0, 2.0, 4.1])
- with pytest.warns(DeprecationWarning):
- v = gb.io.from_numpy(a)
- assert v.isequal(gb.Vector.from_coo([1, 2], [2.0, 4.1]), check_dtype=True)
- with pytest.warns(DeprecationWarning):
- a2 = gb.io.to_numpy(v)
- np.testing.assert_array_equal(a, a2)
-
-
@pytest.mark.skipif("not ss")
def test_vector_to_from_numpy():
a = np.array([0.0, 2.0, 4.1])
@@ -55,18 +48,24 @@ def test_vector_to_from_numpy():
csr = gb.io.to_scipy_sparse(v, "csr")
assert csr.nnz == 2
- assert ss.isspmatrix_csr(csr)
+ # 2023-06-25: scipy 1.11.0 added `sparray` and changed e.g. `ss.isspmatrix_csr`
+ assert isinstance(csr, getattr(ss, "sparray", ss.spmatrix))
+ assert csr.format == "csr"
np.testing.assert_array_equal(csr.toarray(), np.array([[0.0, 2.0, 4.1]]))
csc = gb.io.to_scipy_sparse(v, "csc")
assert csc.nnz == 2
- assert ss.isspmatrix_csc(csc)
+ # 2023-06-25: scipy 1.11.0 added `sparray` and changed e.g. `ss.isspmatrix_csc`
+ assert isinstance(csc, getattr(ss, "sparray", ss.spmatrix))
+ assert csc.format == "csc"
np.testing.assert_array_equal(csc.toarray(), np.array([[0.0, 2.0, 4.1]]).T)
# default to csr-like
coo = gb.io.to_scipy_sparse(v, "coo")
assert coo.shape == csr.shape
- assert ss.isspmatrix_coo(coo)
+ # 2023-06-25: scipy 1.11.0 added `sparray` and changed e.g. `ss.isspmatrix_coo`
+ assert isinstance(coo, getattr(ss, "sparray", ss.spmatrix))
+ assert coo.format == "coo"
assert coo.nnz == 2
np.testing.assert_array_equal(coo.toarray(), np.array([[0.0, 2.0, 4.1]]))
@@ -95,7 +94,9 @@ def test_matrix_to_from_numpy():
for format in ["csr", "csc", "coo"]:
sparse = gb.io.to_scipy_sparse(M, format)
- assert getattr(ss, f"isspmatrix_{format}")(sparse)
+ # 2023-06-25: scipy 1.11.0 added `sparray` and changed e.g. `ss.isspmatrix_csr`
+ assert isinstance(sparse, getattr(ss, "sparray", ss.spmatrix))
+ assert sparse.format == format
assert sparse.nnz == 3
np.testing.assert_array_equal(sparse.toarray(), a)
M2 = gb.io.from_scipy_sparse(sparse)
@@ -145,7 +146,7 @@ def test_matrix_to_from_networkx():
M = gb.io.from_networkx(G, nodelist=range(7))
if suitesparse:
assert M.ss.is_iso
- rows, cols = zip(*edges)
+ rows, cols = zip(*edges, strict=True)
expected = gb.Matrix.from_coo(rows, cols, 1)
assert expected.isequal(M)
# Test empty
@@ -159,8 +160,15 @@ def test_matrix_to_from_networkx():
@pytest.mark.skipif("not ss")
-def test_mmread_mmwrite():
- from scipy.io.tests import test_mmio
+@pytest.mark.parametrize("engine", ["auto", "scipy", "fmm"])
+def test_mmread_mmwrite(engine):
+ if engine == "fmm" and fmm is None: # pragma: no cover (import)
+ pytest.skip("needs fast_matrix_market")
+ try:
+ from scipy.io.tests import test_mmio
+ except ImportError:
+ # Test files are mysteriously missing from some conda-forge builds
+ pytest.skip("scipy.io.tests.test_mmio unavailable :(")
p31 = 2**31
p63 = 2**63
@@ -256,10 +264,19 @@ def test_mmread_mmwrite():
continue
mm_in = StringIO(getattr(test_mmio, example))
if over64:
- with pytest.raises(OverflowError):
- M = gb.io.mmread(mm_in)
+ with pytest.raises((OverflowError, ValueError)):
+ # fast_matrix_market v1.4.5 raises ValueError instead of OverflowError
+ M = gb.io.mmread(mm_in, engine)
else:
- M = gb.io.mmread(mm_in)
+ if (
+ example == "_empty_lines_example"
+ and engine in {"fmm", "auto"}
+ and fmm is not None
+ and fmm.__version__ in {"1.4.5"}
+ ):
+ # `fast_matrix_market` __version__ v1.4.5 does not handle this, but v1.5.0 does
+ continue
+ M = gb.io.mmread(mm_in, engine)
if not M.isequal(expected): # pragma: no cover (debug)
print(example)
print("Expected:")
@@ -268,12 +285,12 @@ def test_mmread_mmwrite():
print(M)
raise AssertionError("Matrix M not as expected. See print output above")
mm_out = BytesIO()
- gb.io.mmwrite(mm_out, M)
+ gb.io.mmwrite(mm_out, M, engine)
mm_out.flush()
mm_out.seek(0)
mm_out_str = b"".join(mm_out.readlines()).decode()
mm_out.seek(0)
- M2 = gb.io.mmread(mm_out)
+ M2 = gb.io.mmread(mm_out, engine)
if not M2.isequal(expected): # pragma: no cover (debug)
print(example)
print("Expected:")
@@ -299,23 +316,38 @@ def test_from_scipy_sparse_duplicates():
@pytest.mark.skipif("not ss")
-def test_matrix_market_sparse_duplicates():
- mm = StringIO(
- """%%MatrixMarket matrix coordinate real general
+@pytest.mark.parametrize("engine", ["auto", "scipy", "fast_matrix_market"])
+def test_matrix_market_sparse_duplicates(engine):
+ if engine == "fast_matrix_market" and fmm is None: # pragma: no cover (import)
+ pytest.skip("needs fast_matrix_market")
+ string = """%%MatrixMarket matrix coordinate real general
3 3 4
1 3 1
2 2 2
3 1 3
3 1 4"""
- )
+ mm = StringIO(string)
with pytest.raises(ValueError, match="Duplicate indices found"):
- gb.io.mmread(mm)
- mm.seek(0)
- a = gb.io.mmread(mm, dup_op=gb.binary.plus)
+ gb.io.mmread(mm, engine)
+ # mm.seek(0) # Doesn't work with `fast_matrix_market` 1.4.5
+ mm = StringIO(string)
+ a = gb.io.mmread(mm, engine, dup_op=gb.binary.plus)
expected = gb.Matrix.from_coo([0, 1, 2], [2, 1, 0], [1, 2, 7])
assert a.isequal(expected)
+@pytest.mark.skipif("not ss")
+def test_matrix_market_bad_engine():
+ A = gb.Matrix.from_coo([0, 0, 3, 5], [1, 4, 0, 2], [1, 0, 2, -1], nrows=7, ncols=6)
+ with pytest.raises(ValueError, match="Bad engine value"):
+ gb.io.mmwrite(BytesIO(), A, engine="bad_engine")
+ mm_out = BytesIO()
+ gb.io.mmwrite(mm_out, A)
+ mm_out.seek(0)
+ with pytest.raises(ValueError, match="Bad engine value"):
+ gb.io.mmread(mm_out, engine="bad_engine")
+
+
@pytest.mark.skipif("not ss")
def test_scipy_sparse():
a = np.arange(12).reshape(3, 4)
@@ -334,6 +366,7 @@ def test_scipy_sparse():
@pytest.mark.skipif("not ak")
+@pytest.mark.xfail(np.__version__[:5] in {"1.25.", "1.26."}, reason="awkward bug with numpy >=1.25")
def test_awkward_roundtrip():
# Vector
v = gb.Vector.from_coo([1, 3, 5], [20, 21, -5], size=22)
@@ -355,6 +388,7 @@ def test_awkward_roundtrip():
@pytest.mark.skipif("not ak")
+@pytest.mark.xfail(np.__version__[:5] in {"1.25.", "1.26."}, reason="awkward bug with numpy >=1.25")
def test_awkward_iso_roundtrip():
# Vector
v = gb.Vector.from_coo([1, 3, 5], [20, 20, 20], size=22)
@@ -398,6 +432,7 @@ def test_awkward_errors():
@pytest.mark.skipif("not sparse")
+@pytest.mark.slow
def test_vector_to_from_pydata_sparse():
coords = np.array([0, 1, 2, 3, 4], dtype="int64")
data = np.array([10, 20, 30, 40, 50], dtype="int64")
@@ -411,6 +446,7 @@ def test_vector_to_from_pydata_sparse():
@pytest.mark.skipif("not sparse")
+@pytest.mark.slow
def test_matrix_to_from_pydata_sparse():
coords = np.array([[0, 1, 2, 3, 4], [0, 1, 2, 3, 4]], dtype="int64")
data = np.array([10, 20, 30, 40, 50], dtype="int64")
diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py
index 40676f71a..24f0e73d7 100644
--- a/graphblas/tests/test_matrix.py
+++ b/graphblas/tests/test_matrix.py
@@ -11,6 +11,7 @@
import graphblas as gb
from graphblas import agg, backend, binary, dtypes, indexunary, monoid, select, semiring, unary
+from graphblas.core import _supports_udfs as supports_udfs
from graphblas.core import lib
from graphblas.exceptions import (
DimensionMismatch,
@@ -23,7 +24,7 @@
OutputNotEmpty,
)
-from .conftest import autocompute, compute
+from .conftest import autocompute, compute, pypy, shouldhave
from graphblas import Matrix, Scalar, Vector # isort:skip (for dask-graphblas)
@@ -1230,6 +1231,8 @@ def test_apply_indexunary(A):
assert w4.isequal(A3)
with pytest.raises(TypeError, match="left"):
A.apply(select.valueeq, left=s3)
+ assert pickle.loads(pickle.dumps(indexunary.tril)) is indexunary.tril
+ assert pickle.loads(pickle.dumps(indexunary.tril[int])) is indexunary.tril[int]
def test_select(A):
@@ -1259,6 +1262,16 @@ def test_select(A):
with pytest.raises(TypeError, match="thunk"):
A.select(select.valueeq, object())
+ A3rows = Matrix.from_coo([0, 0, 1, 1, 2], [1, 3, 4, 6, 5], [2, 3, 8, 4, 1], nrows=7, ncols=7)
+ w8 = select.rowle(A, 2).new()
+ w9 = A.select("row<=", 2).new()
+ w10 = select.row(A < 3).new()
+ assert w8.isequal(A3rows)
+ assert w9.isequal(A3rows)
+ assert w10.isequal(A3rows)
+ assert pickle.loads(pickle.dumps(select.tril)) is select.tril
+ assert pickle.loads(pickle.dumps(select.tril[bool])) is select.tril[bool]
+
@autocompute
def test_select_bools_and_masks(A):
@@ -1283,16 +1296,27 @@ def test_select_bools_and_masks(A):
A.select(A[0, :].new().S)
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_indexunary_udf(A):
def threex_minusthunk(x, row, col, thunk): # pragma: no cover (numba)
return 3 * x - thunk
- indexunary.register_new("threex_minusthunk", threex_minusthunk)
+ assert indexunary.register_new("threex_minusthunk", threex_minusthunk) is not None
assert hasattr(indexunary, "threex_minusthunk")
assert not hasattr(select, "threex_minusthunk")
with pytest.raises(ValueError, match="SelectOp must have BOOL return type"):
select.register_anonymous(threex_minusthunk)
+ with pytest.raises(ValueError, match="SelectOp must have BOOL return type"):
+ select.register_new("bad_select", threex_minusthunk)
+ assert not hasattr(indexunary, "bad_select")
+ assert not hasattr(select, "bad_select")
+ assert select.register_new("bad_select", threex_minusthunk, lazy=True) is None
+ with pytest.raises(ValueError, match="SelectOp must have BOOL return type"):
+ select.bad_select
+ assert not hasattr(select, "bad_select")
+ assert hasattr(indexunary, "bad_select") # Keep it
+
expected = Matrix.from_coo(
[3, 0, 3, 5, 6, 0, 6, 1, 6, 2, 4, 1],
[0, 1, 2, 2, 2, 3, 3, 4, 4, 5, 5, 6],
@@ -1308,6 +1332,8 @@ def iii(x, row, col, thunk): # pragma: no cover (numba)
select.register_new("iii", iii)
assert hasattr(indexunary, "iii")
assert hasattr(select, "iii")
+ assert indexunary.iii[int].orig_func is select.iii[int].orig_func is select.iii.orig_func
+ assert indexunary.iii[int]._numba_func is select.iii[int]._numba_func is select.iii._numba_func
iii_apply = indexunary.register_anonymous(iii)
expected = Matrix.from_coo(
[3, 0, 3, 5, 6, 0, 6, 1, 6, 2, 4, 1],
@@ -1353,15 +1379,17 @@ def test_reduce_agg(A):
expected = unary.sqrt[float](squared).new()
w5 = A.reduce_rowwise(agg.hypot).new()
assert w5.isclose(expected)
- w6 = A.reduce_rowwise(monoid.numpy.hypot[float]).new()
- assert w6.isclose(expected)
+ if shouldhave(monoid.numpy, "hypot"):
+ w6 = A.reduce_rowwise(monoid.numpy.hypot[float]).new()
+ assert w6.isclose(expected)
w7 = Vector(w5.dtype, size=w5.size)
w7 << A.reduce_rowwise(agg.hypot)
assert w7.isclose(expected)
w8 = A.reduce_rowwise(agg.logaddexp).new()
- expected = A.reduce_rowwise(monoid.numpy.logaddexp[float]).new()
- assert w8.isclose(w8)
+ if shouldhave(monoid.numpy, "logaddexp"):
+ expected = A.reduce_rowwise(monoid.numpy.logaddexp[float]).new()
+ assert w8.isclose(w8)
result = Vector.from_coo([0, 1, 2, 3, 4, 5, 6], [3, 2, 9, 10, 11, 8, 4])
w9 = A.reduce_columnwise(agg.sum).new()
@@ -1598,6 +1626,7 @@ def test_reduce_agg_empty():
assert compute(s.value) is None
+@pytest.mark.skipif("not supports_udfs")
def test_reduce_row_udf(A):
result = Vector.from_coo([0, 1, 2, 3, 4, 5, 6], [5, 12, 1, 6, 7, 1, 15])
@@ -2007,6 +2036,12 @@ def test_ss_import_export(A, do_iso, methods):
B4 = Matrix.ss.import_any(**d)
assert B4.isequal(A)
assert B4.ss.is_iso is do_iso
+ if do_iso:
+ d["values"] = 1
+ d["is_iso"] = False
+ B4b = Matrix.ss.import_any(**d)
+ assert B4b.isequal(A)
+ assert B4b.ss.is_iso is True
else:
A4.ss.pack_any(**d)
assert A4.isequal(A)
@@ -2173,15 +2208,14 @@ def test_ss_import_export(A, do_iso, methods):
C1.ss.pack_any(**d)
assert C1.isequal(C)
assert C1.ss.is_iso is do_iso
+ elif in_method == "import":
+ D1 = Matrix.ss.import_any(**d)
+ assert D1.isequal(C)
+ assert D1.ss.is_iso is do_iso
else:
- if in_method == "import":
- D1 = Matrix.ss.import_any(**d)
- assert D1.isequal(C)
- assert D1.ss.is_iso is do_iso
- else:
- C1.ss.pack_any(**d)
- assert C1.isequal(C)
- assert C1.ss.is_iso is do_iso
+ C1.ss.pack_any(**d)
+ assert C1.isequal(C)
+ assert C1.ss.is_iso is do_iso
C2 = C.dup()
d = getattr(C2.ss, out_method)("fullc")
@@ -2263,6 +2297,11 @@ def test_ss_import_on_view():
A = Matrix.from_coo([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)
+ values = np.arange(16).reshape(4, 4)[::2, ::2]
+ bitmap = np.ones((4, 4), dtype=bool)[::2, ::2]
+ C = Matrix.ss.import_any(values=values, bitmap=bitmap)
+ D = Matrix.ss.import_any(values=values.copy(), bitmap=bitmap.copy())
+ assert C.isequal(D)
@pytest.mark.skipif("not suitesparse")
@@ -2564,12 +2603,14 @@ def test_iter(A):
zip(
[3, 0, 3, 5, 6, 0, 6, 1, 6, 2, 4, 1],
[0, 1, 2, 2, 2, 3, 3, 4, 4, 5, 5, 6],
+ strict=True,
)
)
assert set(A.T) == set(
zip(
[0, 1, 2, 2, 2, 3, 3, 4, 4, 5, 5, 6],
[3, 0, 3, 5, 6, 0, 6, 1, 6, 2, 4, 1],
+ strict=True,
)
)
@@ -2692,8 +2733,8 @@ def test_ss_split(A):
for results in [A.ss.split([4, 3]), A.ss.split([[4, None], 3], name="split")]:
row_boundaries = [0, 4, 7]
col_boundaries = [0, 3, 6, 7]
- for i, (i1, i2) in enumerate(zip(row_boundaries[:-1], row_boundaries[1:])):
- for j, (j1, j2) in enumerate(zip(col_boundaries[:-1], col_boundaries[1:])):
+ for i, (i1, i2) in enumerate(itertools.pairwise(row_boundaries)):
+ for j, (j1, j2) in enumerate(itertools.pairwise(col_boundaries)):
expected = A[i1:i2, j1:j2].new()
assert expected.isequal(results[i][j])
with pytest.raises(DimensionMismatch):
@@ -2766,6 +2807,8 @@ def test_ss_nbytes(A):
@autocompute
def test_auto(A, v):
+ from graphblas.core.infix import MatrixEwiseMultExpr
+
expected = binary.land[bool](A & A).new()
B = A.dup(dtype=bool)
for expr in [(B & B), binary.land[bool](A & A)]:
@@ -2788,14 +2831,26 @@ def test_auto(A, v):
"__and__",
"__or__",
# "kronecker",
+ "__rand__",
+ "__ror__",
]:
+ # print(type(expr).__name__, method)
val1 = getattr(expected, method)(expected).new()
- val2 = getattr(expected, method)(expr)
- val3 = getattr(expr, method)(expected)
- val4 = getattr(expr, method)(expr)
- assert val1.isequal(val2)
- assert val1.isequal(val3)
- assert val1.isequal(val4)
+ if method in {"__or__", "__ror__"} and type(expr) is MatrixEwiseMultExpr:
+ # Doing e.g. `plus(A & B | C)` isn't allowed--make user be explicit
+ with pytest.raises(TypeError):
+ val2 = getattr(expected, method)(expr)
+ with pytest.raises(TypeError):
+ val3 = getattr(expr, method)(expected)
+ with pytest.raises(TypeError):
+ val4 = getattr(expr, method)(expr)
+ else:
+ val2 = getattr(expected, method)(expr)
+ assert val1.isequal(val2)
+ val3 = getattr(expr, method)(expected)
+ assert val1.isequal(val3)
+ val4 = getattr(expr, method)(expr)
+ assert val1.isequal(val4)
for method in ["reduce_rowwise", "reduce_columnwise", "reduce_scalar"]:
s1 = getattr(expected, method)(monoid.lor).new()
s2 = getattr(expr, method)(monoid.lor)
@@ -2899,22 +2954,23 @@ def test_expr_is_like_matrix(A):
"from_dicts",
"from_edgelist",
"from_scalar",
- "from_values",
"resize",
+ "setdiag",
"update",
}
- assert attrs - expr_attrs == expected, (
+ ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union", "_mxm", "_mxv"}
+ assert attrs - expr_attrs - ignore == expected, (
"If you see this message, you probably added a method to Matrix. You may need to "
"add an entry to `matrix` or `matrix_vector` set in `graphblas.core.automethods` "
"and then run `python -m graphblas.core.automethods`. If you're messing with infix "
"methods, then you may need to run `python -m graphblas.core.infixmethods`."
)
- assert attrs - infix_attrs == expected
+ assert attrs - infix_attrs - ignore == expected
# TransposedMatrix is used differently than other expressions,
# so maybe it shouldn't support everything.
if suitesparse:
expected.add("ss")
- assert attrs - transposed_attrs == (expected | {"_as_vector", "S", "V"}) - {
+ assert attrs - transposed_attrs - ignore == (expected | {"_as_vector", "S", "V"}) - {
"_prep_for_extract",
"_extract_element",
}
@@ -2962,11 +3018,12 @@ def test_index_expr_is_like_matrix(A):
"from_dense",
"from_dicts",
"from_edgelist",
- "from_values",
"from_scalar",
"resize",
+ "setdiag",
}
- assert attrs - expr_attrs == expected, (
+ ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union", "_mxm", "_mxv"}
+ assert attrs - expr_attrs - ignore == expected, (
"If you see this message, you probably added a method to Matrix. You may need to "
"add an entry to `matrix` or `matrix_vector` set in `graphblas.core.automethods` "
"and then run `python -m graphblas.core.automethods`. If you're messing with infix "
@@ -3013,7 +3070,7 @@ def test_ss_flatten(A):
[3, 2, 3, 1, 5, 3, 7, 8, 3, 1, 7, 4],
]
# row-wise
- indices = [row * A.ncols + col for row, col in zip(data[0], data[1])]
+ indices = [row * A.ncols + col for row, col in zip(data[0], data[1], strict=True)]
expected = Vector.from_coo(indices, data[2], size=A.nrows * A.ncols)
for fmt in ["csr", "hypercsr", "bitmapr"]:
B = Matrix.ss.import_any(**A.ss.export(format=fmt))
@@ -3032,7 +3089,7 @@ def test_ss_flatten(A):
assert C.isequal(B)
# column-wise
- indices = [col * A.nrows + row for row, col in zip(data[0], data[1])]
+ indices = [col * A.nrows + row for row, col in zip(data[0], data[1], strict=True)]
expected = Vector.from_coo(indices, data[2], size=A.nrows * A.ncols)
for fmt in ["csc", "hypercsc", "bitmapc"]:
B = Matrix.ss.import_any(**A.ss.export(format=fmt))
@@ -3095,6 +3152,10 @@ def test_ss_reshape(A):
def test_autocompute_argument_messages(A, v):
with pytest.raises(TypeError, match="autocompute"):
A.ewise_mult(A & A)
+ with pytest.raises(TypeError, match="autocompute"):
+ A.ewise_mult(binary.plus(A & A))
+ with pytest.raises(TypeError, match="autocompute"):
+ A.ewise_mult(A + A)
with pytest.raises(TypeError, match="autocompute"):
A.mxv(A @ v)
@@ -3111,10 +3172,12 @@ def test_infix_sugar(A):
assert binary.times(2, A).isequal(2 * A)
assert binary.truediv(A, 2).isequal(A / 2)
assert binary.truediv(5, A).isequal(5 / A)
- assert binary.floordiv(A, 2).isequal(A // 2)
- assert binary.floordiv(5, A).isequal(5 // A)
- assert binary.numpy.mod(A, 2).isequal(A % 2)
- assert binary.numpy.mod(5, A).isequal(5 % A)
+ if shouldhave(binary, "floordiv"):
+ assert binary.floordiv(A, 2).isequal(A // 2)
+ assert binary.floordiv(5, A).isequal(5 // A)
+ if shouldhave(binary.numpy, "mod"):
+ assert binary.numpy.mod(A, 2).isequal(A % 2)
+ assert binary.numpy.mod(5, A).isequal(5 % A)
assert binary.pow(A, 2).isequal(A**2)
assert binary.pow(2, A).isequal(2**A)
assert binary.pow(A, 2).isequal(pow(A, 2))
@@ -3141,26 +3204,27 @@ def test_infix_sugar(A):
assert binary.ge(A, 4).isequal(A >= 4)
assert binary.eq(A, 4).isequal(A == 4)
assert binary.ne(A, 4).isequal(A != 4)
- x, y = divmod(A, 3)
- assert binary.floordiv(A, 3).isequal(x)
- assert binary.numpy.mod(A, 3).isequal(y)
- assert binary.fmod(A, 3).isequal(y)
- assert A.isequal(binary.plus((3 * x) & y))
- x, y = divmod(-A, 3)
- assert binary.floordiv(-A, 3).isequal(x)
- assert binary.numpy.mod(-A, 3).isequal(y)
- # assert binary.fmod(-A, 3).isequal(y) # The reason we use numpy.mod
- assert (-A).isequal(binary.plus((3 * x) & y))
- x, y = divmod(3, A)
- assert binary.floordiv(3, A).isequal(x)
- assert binary.numpy.mod(3, A).isequal(y)
- assert binary.fmod(3, A).isequal(y)
- assert binary.plus(binary.times(A & x) & y).isequal(3 * unary.one(A))
- x, y = divmod(-3, A)
- assert binary.floordiv(-3, A).isequal(x)
- assert binary.numpy.mod(-3, A).isequal(y)
- # assert binary.fmod(-3, A).isequal(y) # The reason we use numpy.mod
- assert binary.plus(binary.times(A & x) & y).isequal(-3 * unary.one(A))
+ if shouldhave(binary, "floordiv") and shouldhave(binary.numpy, "mod"):
+ x, y = divmod(A, 3)
+ assert binary.floordiv(A, 3).isequal(x)
+ assert binary.numpy.mod(A, 3).isequal(y)
+ assert binary.fmod(A, 3).isequal(y)
+ assert A.isequal(binary.plus((3 * x) & y))
+ x, y = divmod(-A, 3)
+ assert binary.floordiv(-A, 3).isequal(x)
+ assert binary.numpy.mod(-A, 3).isequal(y)
+ # assert binary.fmod(-A, 3).isequal(y) # The reason we use numpy.mod
+ assert (-A).isequal(binary.plus((3 * x) & y))
+ x, y = divmod(3, A)
+ assert binary.floordiv(3, A).isequal(x)
+ assert binary.numpy.mod(3, A).isequal(y)
+ assert binary.fmod(3, A).isequal(y)
+ assert binary.plus(binary.times(A & x) & y).isequal(3 * unary.one(A))
+ x, y = divmod(-3, A)
+ assert binary.floordiv(-3, A).isequal(x)
+ assert binary.numpy.mod(-3, A).isequal(y)
+ # assert binary.fmod(-3, A).isequal(y) # The reason we use numpy.mod
+ assert binary.plus(binary.times(A & x) & y).isequal(-3 * unary.one(A))
assert binary.eq(A & A).isequal(A == A)
assert binary.ne(A.T & A.T).isequal(A.T != A.T)
@@ -3183,14 +3247,16 @@ def test_infix_sugar(A):
B /= 2
assert type(B) is Matrix
assert binary.truediv(A, 2).isequal(B)
- B = A.dup()
- B //= 2
- assert type(B) is Matrix
- assert binary.floordiv(A, 2).isequal(B)
- B = A.dup()
- B %= 2
- assert type(B) is Matrix
- assert binary.numpy.mod(A, 2).isequal(B)
+ if shouldhave(binary, "floordiv"):
+ B = A.dup()
+ B //= 2
+ assert type(B) is Matrix
+ assert binary.floordiv(A, 2).isequal(B)
+ if shouldhave(binary.numpy, "mod"):
+ B = A.dup()
+ B %= 2
+ assert type(B) is Matrix
+ assert binary.numpy.mod(A, 2).isequal(B)
B = A.dup()
B **= 2
assert type(B) is Matrix
@@ -3491,28 +3557,6 @@ def compare(A, expected, isequal=True, **kwargs):
A.ss.compactify("bad_how")
-def test_deprecated(A):
- if suitesparse:
- with pytest.warns(DeprecationWarning):
- A.ss.compactify_rowwise()
- with pytest.warns(DeprecationWarning):
- A.ss.compactify_columnwise()
- with pytest.warns(DeprecationWarning):
- A.ss.scan_rowwise()
- with pytest.warns(DeprecationWarning):
- A.ss.scan_columnwise()
- with pytest.warns(DeprecationWarning):
- A.ss.selectk_rowwise("first", 3)
- with pytest.warns(DeprecationWarning):
- A.ss.selectk_columnwise("first", 3)
- with pytest.warns(DeprecationWarning):
- A.to_values()
- with pytest.warns(DeprecationWarning):
- A.T.to_values()
- with pytest.warns(DeprecationWarning):
- A.from_values([1], [2], [3])
-
-
def test_ndim(A):
assert A.ndim == 2
assert A.ewise_mult(A).ndim == 2
@@ -3521,7 +3565,7 @@ def test_ndim(A):
def test_sizeof(A):
- if suitesparse:
+ if suitesparse and not pypy:
assert sys.getsizeof(A) > A.nvals * 16
else:
with pytest.raises(TypeError):
@@ -3584,9 +3628,9 @@ def test_ss_iteration(A):
assert not list(B.ss.itervalues())
assert not list(B.ss.iteritems())
rows, columns, values = A.to_coo()
- assert sorted(zip(rows, columns)) == sorted(A.ss.iterkeys())
+ assert sorted(zip(rows, columns, strict=True)) == sorted(A.ss.iterkeys())
assert sorted(values) == sorted(A.ss.itervalues())
- assert sorted(zip(rows, columns, values)) == sorted(A.ss.iteritems())
+ assert sorted(zip(rows, columns, values, strict=True)) == sorted(A.ss.iteritems())
N = rows.size
A = Matrix.ss.import_bitmapr(**A.ss.export("bitmapr"))
@@ -3608,6 +3652,7 @@ def test_ss_iteration(A):
assert next(A.ss.iteritems()) is not None
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_udt():
record_dtype = np.dtype([("x", np.bool_), ("y", np.float64)], align=True)
@@ -3844,7 +3889,7 @@ def test_get(A):
assert compute(A.T.get(0, 1)) is None
assert A.T.get(1, 0) == 2
assert A.get(0, 1, "mittens") == 2
- assert type(compute(A.get(0, 1))) is int
+ assert isinstance(compute(A.get(0, 1)), int)
with pytest.raises(ValueError, match="Bad row, col"):
# Not yet supported
A.get(0, [0, 1])
@@ -3918,7 +3963,7 @@ def test_ss_config(A):
def test_to_csr_from_csc(A):
- assert Matrix.from_csr(*A.to_csr(dtype=int)).isequal(A, check_dtype=True)
+ assert Matrix.from_csr(*A.to_csr(sort=False, dtype=int)).isequal(A, check_dtype=True)
assert Matrix.from_csr(*A.T.to_csc()).isequal(A, check_dtype=True)
assert Matrix.from_csc(*A.to_csc()).isequal(A)
assert Matrix.from_csc(*A.T.to_csr()).isequal(A)
@@ -4029,10 +4074,11 @@ def test_ss_pack_hyperhash(A):
Y = C.ss.unpack_hyperhash()
Y = C.ss.unpack_hyperhash(compute=True)
assert C.ss.unpack_hyperhash() is None
- assert Y.nrows == C.nrows
- C.ss.pack_hyperhash(Y)
- assert Y.gb_obj[0] == gb.core.NULL
- assert C.ss.unpack_hyperhash() is not None
+ if Y is not None: # hyperhash may or may not be computed
+ assert Y.nrows == C.nrows
+ C.ss.pack_hyperhash(Y)
+ assert Y.gb_obj[0] == gb.core.NULL
+ assert C.ss.unpack_hyperhash() is not None # May or may not be computed
def test_to_dicts_from_dicts(A):
@@ -4127,7 +4173,11 @@ def test_from_scalar():
A = Matrix.from_scalar(1, dtype="INT64[2]", nrows=3, ncols=4)
B = Matrix("INT64[2]", nrows=3, ncols=4)
B << [1, 1]
- assert A.isequal(B, check_dtype=True)
+ if supports_udfs:
+ assert A.isequal(B, check_dtype=True)
+ else:
+ with pytest.raises(KeyError, match="eq does not work with"):
+ assert A.isequal(B, check_dtype=True)
def test_to_dense_from_dense():
@@ -4247,13 +4297,13 @@ def test_ss_descriptors(A):
A(nthreads=4, axb_method="dot", sort=True) << A @ A
assert A.isequal(C2)
# Bad option should show list of valid options
- with pytest.raises(ValueError, match="nthreads"):
+ with pytest.raises(ValueError, match="axb_method"):
C1(bad_opt=True) << A
with pytest.raises(ValueError, match="Duplicate descriptor"):
(A @ A).new(nthreads=4, Nthreads=5)
with pytest.raises(ValueError, match="escriptor"):
A[0, 0].new(bad_opt=True)
- A[0, 0].new(nthreads=4) # ignored, but okay
+ A[0, 0].new(nthreads=4, sort=None) # ignored, but okay
with pytest.raises(ValueError, match="escriptor"):
A.__setitem__((0, 0), 1, bad_opt=True)
A.__setitem__((0, 0), 1, nthreads=4) # ignored, but okay
@@ -4287,6 +4337,7 @@ def test_wait_chains(A):
assert result == 47
+@pytest.mark.skipif("not supports_udfs")
def test_subarray_dtypes():
a = np.arange(3 * 4, dtype=np.int64).reshape(3, 4)
A = Matrix.from_coo([1, 3, 5], [0, 1, 3], a)
@@ -4323,3 +4374,174 @@ def test_subarray_dtypes():
if suitesparse:
Full2 = Matrix.ss.import_fullr(b2)
assert Full1.isequal(Full2, check_dtype=True)
+
+
+def test_power(A):
+ expected = A.dup()
+ for i in range(1, 50):
+ result = A.power(i).new()
+ assert result.isequal(expected)
+ expected << A @ expected
+ # Test transpose
+ expected = A.T.new()
+ for i in range(1, 10):
+ result = A.T.power(i).new()
+ assert result.isequal(expected)
+ expected << A.T @ expected
+ # Test other semiring
+ expected = A.dup()
+ for i in range(1, 10):
+ result = A.power(i, semiring.min_plus).new()
+ assert result.isequal(expected)
+ expected << semiring.min_plus(A @ expected)
+ # n == 0
+ result = A.power(0).new()
+ expected = Vector.from_scalar(1, A.nrows, A.dtype).diag()
+ assert result.isequal(expected)
+ result = A.power(0, semiring.plus_min).new()
+ identity = semiring.plus_min[A.dtype].binaryop.monoid.identity
+ assert identity != 1
+ expected = Vector.from_scalar(identity, A.nrows, A.dtype).diag()
+ assert result.isequal(expected)
+ # Exceptional
+ with pytest.raises(TypeError, match="must be a nonnegative integer"):
+ A.power(1.5)
+ with pytest.raises(ValueError, match="must be a nonnegative integer"):
+ A.power(-1)
+ with pytest.raises(ValueError, match="binaryop must be associated with a monoid"):
+ A.power(0, semiring.min_first)
+ B = A[:2, :3].new()
+ with pytest.raises(DimensionMismatch):
+ B.power(2)
+
+
+def test_setdiag():
+ A = Matrix(int, 2, 3)
+ A.setdiag(1)
+ expected = Matrix(int, 2, 3)
+ expected[0, 0] = 1
+ expected[1, 1] = 1
+ assert A.isequal(expected)
+ A.setdiag(Scalar.from_value(2), 2)
+ expected[0, 2] = 2
+ assert A.isequal(expected)
+ A.setdiag(3, k=-1)
+ expected[1, 0] = 3
+ assert A.isequal(expected)
+ # List (or array) is treated as dense
+ A.setdiag([10, 20], 1)
+ expected[0, 1] = 10
+ expected[1, 2] = 20
+ assert A.isequal(expected)
+ # Size 0 diagonals, which does not set anything.
+ # This could be valid (esp. given a size 0 vector), but let's raise for now.
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag(-1, 3)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag(-1, -2)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag([], 3)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag(Vector(int, 0), -2)
+ # Now we're definitely out of bounds
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag(-1, 4)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag(-1, -3)
+ with pytest.raises(TypeError, match="k must be an integer"):
+ A.setdiag(-1, 0.5)
+ with pytest.raises(TypeError, match="Bad type for argument `values` in Matrix.setdiag"):
+ A.setdiag(object())
+ with pytest.raises(DimensionMismatch, match="Dimensions not compatible"):
+ A.setdiag([10, 20, 30], 1)
+ with pytest.raises(DimensionMismatch, match="Dimensions not compatible"):
+ A.setdiag([10], 1)
+
+ # Special care for dimensions of length 0
+ A = Matrix(int, 0, 2, name="A")
+ A.setdiag(0, 0)
+ A.setdiag(0, 1)
+ A.setdiag([], 0)
+ A.setdiag([], 1)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag(0, -1)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag([], -1)
+ A = Matrix(int, 2, 0, name="A")
+ A.setdiag(0, 0)
+ A.setdiag(0, -1)
+ A.setdiag([], 0)
+ A.setdiag([], -1)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag(0, 1)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag([], 1)
+ A = Matrix(int, 0, 0, name="A")
+ A.setdiag(0, 0)
+ A.setdiag([], 0)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag(0, 1)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag([], 1)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag(0, -1)
+ with pytest.raises(IndexError, match="diagonal is out of range"):
+ A.setdiag([], -1)
+
+ A = Matrix(int, 2, 2, name="A")
+ expected = Matrix(int, 2, 2, name="expected")
+ v = Vector(int, 2, name="v")
+ Vector(int, 2)
+ v[0] = 1
+ A.setdiag(v)
+ expected[0, 0] = 1
+ assert A.isequal(expected)
+ A.setdiag(v, accum=binary.plus)
+ expected[0, 0] = 2
+ assert A.isequal(expected)
+ A.setdiag(10, mask=v.S)
+ expected[0, 0] = 10
+ assert A.isequal(expected)
+ A.setdiag(10, mask=v.S, accum="+")
+ expected[0, 0] = 20
+ assert A.isequal(expected)
+ # Allow mask to be a matrix
+ A.setdiag(10, mask=A.S, accum="+")
+ expected[0, 0] = 30
+ assert A.isequal(expected)
+ # Test how to clear or not clear missing elements
+ A.clear()
+ A.setdiag(99)
+ A.setdiag(v)
+ expected[0, 0] = 1
+ assert A.isequal(expected)
+ A.setdiag(99)
+ A.setdiag(v, accum="second")
+ expected[1, 1] = 99
+ assert A.isequal(expected)
+ A.setdiag(99)
+ A.setdiag(v, mask=v.S)
+ assert A.isequal(expected)
+
+ # We handle complemented masks!
+ A.clear()
+ expected.clear()
+ A.setdiag(42, mask=~v.S)
+ expected[1, 1] = 42
+ assert A.isequal(expected)
+ A.setdiag(7, mask=~A.V)
+ expected[0, 0] = 7
+ assert A.isequal(expected)
+
+ with pytest.raises(DimensionMismatch, match="Matrix mask in setdiag is the wrong "):
+ A.setdiag(9, mask=Matrix(int, 3, 3).S)
+ with pytest.raises(DimensionMismatch, match="Vector mask in setdiag is the wrong "):
+ A.setdiag(10, mask=Vector(int, 3).S)
+
+ A.clear()
+ A.resize(2, 3)
+ expected.clear()
+ expected.resize(2, 3)
+ A.setdiag(30, mask=v.S)
+ expected[0, 0] = 30
+ assert A.isequal(expected)
diff --git a/graphblas/tests/test_numpyops.py b/graphblas/tests/test_numpyops.py
index c528d4051..999c6d5e0 100644
--- a/graphblas/tests/test_numpyops.py
+++ b/graphblas/tests/test_numpyops.py
@@ -5,28 +5,32 @@
import numpy as np
import pytest
+from packaging.version import parse
import graphblas as gb
import graphblas.binary.numpy as npbinary
import graphblas.monoid.numpy as npmonoid
import graphblas.semiring.numpy as npsemiring
import graphblas.unary.numpy as npunary
-from graphblas import Vector, backend
+from graphblas import Vector, backend, config
+from graphblas.core import _supports_udfs as supports_udfs
from graphblas.dtypes import _supports_complex
-from .conftest import compute
+from .conftest import compute, shouldhave
is_win = sys.platform.startswith("win")
suitesparse = backend == "suitesparse"
def test_numpyops_dir():
- assert "exp2" in dir(npunary)
- assert "logical_and" in dir(npbinary)
- assert "logaddexp" in dir(npmonoid)
- assert "add_add" in dir(npsemiring)
+ udf_or_mapped = supports_udfs or config["mapnumpy"]
+ assert ("exp2" in dir(npunary)) == udf_or_mapped
+ assert ("logical_and" in dir(npbinary)) == udf_or_mapped
+ assert ("logaddexp" in dir(npmonoid)) == supports_udfs
+ assert ("add_add" in dir(npsemiring)) == udf_or_mapped
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_bool_doesnt_get_too_large():
a = Vector.from_coo([0, 1, 2, 3], [True, False, True, False])
@@ -70,9 +74,12 @@ def test_npunary():
# due to limitation of MSVC with complex
blocklist["FC64"].update({"arcsin", "arcsinh"})
blocklist["FC32"] = {"arcsin", "arcsinh"}
- isclose = gb.binary.isclose(1e-6, 0)
+ if shouldhave(gb.binary, "isclose"):
+ isclose = gb.binary.isclose(1e-6, 0)
+ else:
+ isclose = None
for gb_input, np_input in data:
- for unary_name in sorted(npunary._unary_names):
+ for unary_name in sorted(npunary._unary_names & npunary.__dir__()):
op = getattr(npunary, unary_name)
if gb_input.dtype not in op.types or unary_name in blocklist.get(
gb_input.dtype.name, ()
@@ -99,11 +106,22 @@ def test_npunary():
list(range(np_input.size)), list(np_result), dtype=gb_result.dtype
)
assert gb_result.nvals == np_result.size
+ if compare_op is None:
+ continue # FLAKY COVERAGE
match = gb_result.ewise_mult(np_result, compare_op).new()
if gb_result.dtype.name.startswith("F"):
match(accum=gb.binary.lor) << gb_result.apply(npunary.isnan)
compare = match.reduce(gb.monoid.land).new()
if not compare: # pragma: no cover (debug)
+ import numba
+
+ if (
+ unary_name in {"sign"}
+ and np.__version__.startswith("2.")
+ and parse(numba.__version__) < parse("0.61.0")
+ ):
+ # numba <0.61.0 does not match numpy 2.0
+ continue
print(unary_name, gb_input.dtype)
print(compute(gb_result))
print(np_result)
@@ -149,9 +167,24 @@ def test_npbinary():
"FP64": {"floor_divide"}, # numba/numpy difference for 1.0 / 0.0
"BOOL": {"gcd", "lcm", "subtract"}, # not supported by numpy
}
- isclose = gb.binary.isclose(1e-7, 0)
+ if shouldhave(gb.binary, "isclose"):
+ isclose = gb.binary.isclose(1e-7, 0)
+ else:
+ isclose = None
+ if shouldhave(npbinary, "equal"):
+ equal = npbinary.equal
+ else:
+ equal = gb.binary.eq
+ if shouldhave(npbinary, "isnan"):
+ isnan = npunary.isnan
+ else:
+ isnan = gb.unary.isnan
+ if shouldhave(npbinary, "isinf"):
+ isinf = npunary.isinf
+ else:
+ isinf = gb.unary.isinf
for (gb_left, gb_right), (np_left, np_right) in data:
- for binary_name in sorted(npbinary._binary_names):
+ for binary_name in sorted(npbinary._binary_names & npbinary.__dir__()):
op = getattr(npbinary, binary_name)
if gb_left.dtype not in op.types or binary_name in blocklist.get(
gb_left.dtype.name, ()
@@ -168,7 +201,10 @@ def test_npbinary():
compare_op = isclose
else:
np_result = getattr(np, binary_name)(np_left, np_right)
- compare_op = npbinary.equal
+ if binary_name in {"arctan2"}:
+ compare_op = isclose
+ else:
+ compare_op = equal
except Exception: # pragma: no cover (debug)
print(f"Error computing numpy result for {binary_name}")
print(f"dtypes: ({gb_left.dtype}, {gb_right.dtype}) -> {gb_result.dtype}")
@@ -176,19 +212,23 @@ def test_npbinary():
np_result = Vector.from_coo(np.arange(np_left.size), np_result, dtype=gb_result.dtype)
assert gb_result.nvals == np_result.size
+ if compare_op is None:
+ continue # FLAKY COVERAGE
match = gb_result.ewise_mult(np_result, compare_op).new()
if gb_result.dtype.name.startswith("F"):
- match(accum=gb.binary.lor) << gb_result.apply(npunary.isnan)
+ match(accum=gb.binary.lor) << gb_result.apply(isnan)
if gb_result.dtype.name.startswith("FC"):
# Divide by 0j sometimes result in different behavior, such as `nan` or `(inf+0j)`
- match(accum=gb.binary.lor) << gb_result.apply(npunary.isinf)
+ match(accum=gb.binary.lor) << gb_result.apply(isinf)
compare = match.reduce(gb.monoid.land).new()
if not compare: # pragma: no cover (debug)
+ print(compare_op)
print(binary_name)
print(compute(gb_left))
print(compute(gb_right))
print(compute(gb_result))
print(np_result)
+ print((np_result - compute(gb_result)).new().to_coo()[1])
assert compare
@@ -218,7 +258,7 @@ def test_npmonoid():
],
]
# Complex monoids not working yet (they segfault upon creation in gb.core.operators)
- if _supports_complex: # pragma: no branch
+ if _supports_complex:
data.append(
[
[
@@ -236,13 +276,13 @@ def test_npmonoid():
"BOOL": {"add"},
}
for (gb_left, gb_right), (np_left, np_right) in data:
- for binary_name in sorted(npmonoid._monoid_identities):
+ for binary_name in sorted(npmonoid._monoid_identities.keys() & npmonoid.__dir__()):
op = getattr(npmonoid, binary_name)
assert len(op.types) > 0, op.name
if gb_left.dtype not in op.types or binary_name in blocklist.get(
gb_left.dtype.name, ()
- ): # pragma: no cover (flaky)
- continue
+ ):
+ continue # FLAKY COVERAGE
with np.errstate(divide="ignore", over="ignore", under="ignore", invalid="ignore"):
gb_result = gb_left.ewise_mult(gb_right, op).new()
np_result = getattr(np, binary_name)(np_left, np_right)
@@ -274,7 +314,8 @@ def test_npmonoid():
@pytest.mark.slow
def test_npsemiring():
for monoid_name, binary_name in itertools.product(
- sorted(npmonoid._monoid_identities), sorted(npbinary._binary_names)
+ sorted(npmonoid._monoid_identities.keys() & npmonoid.__dir__()),
+ sorted(npbinary._binary_names & npbinary.__dir__()),
):
monoid = getattr(npmonoid, monoid_name)
binary = getattr(npbinary, binary_name)
diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py
index e32606290..41fae80ae 100644
--- a/graphblas/tests/test_op.py
+++ b/graphblas/tests/test_op.py
@@ -4,9 +4,30 @@
import pytest
import graphblas as gb
-from graphblas import agg, backend, binary, dtypes, indexunary, monoid, op, select, semiring, unary
+from graphblas import (
+ agg,
+ backend,
+ binary,
+ config,
+ dtypes,
+ indexunary,
+ monoid,
+ op,
+ select,
+ semiring,
+ unary,
+)
+from graphblas.core import _supports_udfs as supports_udfs
from graphblas.core import lib, operator
-from graphblas.core.operator import BinaryOp, IndexUnaryOp, Monoid, Semiring, UnaryOp, get_semiring
+from graphblas.core.operator import (
+ BinaryOp,
+ IndexUnaryOp,
+ Monoid,
+ SelectOp,
+ Semiring,
+ UnaryOp,
+ get_semiring,
+)
from graphblas.dtypes import (
BOOL,
FP32,
@@ -22,6 +43,8 @@
)
from graphblas.exceptions import DomainMismatch, UdfParseError
+from .conftest import shouldhave
+
if dtypes._supports_complex:
from graphblas.dtypes import FC32, FC64
@@ -142,6 +165,36 @@ def test_get_typed_op():
operator.get_typed_op(binary.plus, dtypes.INT64, "bad dtype")
+@pytest.mark.skipif("supports_udfs")
+def test_udf_mentions_numba():
+ with pytest.raises(AttributeError, match="install numba"):
+ binary.rfloordiv
+ assert "rfloordiv" not in dir(binary)
+ with pytest.raises(AttributeError, match="install numba"):
+ semiring.any_rfloordiv
+ assert "any_rfloordiv" not in dir(semiring)
+ with pytest.raises(AttributeError, match="install numba"):
+ op.absfirst
+ assert "absfirst" not in dir(op)
+ with pytest.raises(AttributeError, match="install numba"):
+ op.plus_rpow
+ assert "plus_rpow" not in dir(op)
+ with pytest.raises(AttributeError, match="install numba"):
+ binary.numpy.gcd
+ assert "gcd" not in dir(binary.numpy)
+ assert "gcd" not in dir(op.numpy)
+
+
+@pytest.mark.skipif("supports_udfs")
+def test_unaryop_udf_no_support():
+ def plus_one(x): # pragma: no cover (numba)
+ return x + 1
+
+ with pytest.raises(RuntimeError, match="UnaryOp.register_new.* unavailable"):
+ unary.register_new("plus_one", plus_one)
+
+
+@pytest.mark.skipif("not supports_udfs")
def test_unaryop_udf():
def plus_one(x):
return x + 1 # pragma: no cover (numba)
@@ -150,6 +203,7 @@ def plus_one(x):
assert hasattr(unary, "plus_one")
assert unary.plus_one.orig_func is plus_one
assert unary.plus_one[int].orig_func is plus_one
+ assert unary.plus_one[int]._numba_func(1) == 2
comp_set = {
INT8,
INT16,
@@ -179,9 +233,10 @@ def plus_one(x):
UnaryOp.register_new("bad", object())
assert not hasattr(unary, "bad")
with pytest.raises(UdfParseError, match="Unable to parse function using Numba"):
- UnaryOp.register_new("bad", lambda x: v)
+ UnaryOp.register_new("bad", lambda x: v) # pragma: no branch (numba)
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_unaryop_parameterized():
def plus_x(x=0):
@@ -207,6 +262,7 @@ def inner(val):
assert r10.isequal(v11, check_dtype=True)
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_binaryop_parameterized():
def plus_plus_x(x=0):
@@ -268,6 +324,7 @@ def my_add(x, y):
assert op.name == "my_add"
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_monoid_parameterized():
def plus_plus_x(x=0):
@@ -363,6 +420,7 @@ def bad_identity(x=0):
assert monoid.is_idempotent
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_semiring_parameterized():
def plus_plus_x(x=0):
@@ -490,6 +548,7 @@ def inner(y):
assert B.isequal(A.kronecker(A, binary.plus).new())
+@pytest.mark.skipif("not supports_udfs")
def test_unaryop_udf_bool_result():
# numba has trouble compiling this, but we have a work-around
def is_positive(x):
@@ -516,12 +575,14 @@ def is_positive(x):
assert w.isequal(result)
+@pytest.mark.skipif("not supports_udfs")
def test_binaryop_udf():
def times_minus_sum(x, y):
return x * y - (x + y) # pragma: no cover (numba)
BinaryOp.register_new("bin_test_func", times_minus_sum)
assert hasattr(binary, "bin_test_func")
+ assert binary.bin_test_func[int].orig_func is times_minus_sum
comp_set = {
BOOL, # goes to INT64
INT8,
@@ -545,6 +606,7 @@ def times_minus_sum(x, y):
assert w.isequal(result)
+@pytest.mark.skipif("not supports_udfs")
def test_monoid_udf():
def plus_plus_one(x, y):
return x + y + 1 # pragma: no cover (numba)
@@ -579,6 +641,7 @@ def plus_plus_one(x, y):
Monoid.register_anonymous(binary.plus_plus_one, {"BOOL": -1})
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_semiring_udf():
def plus_plus_two(x, y):
@@ -608,10 +671,12 @@ def test_binary_updates():
vec4 = Vector.from_coo([0], [-3], dtype=dtypes.INT64)
result2 = vec4.ewise_mult(vec2, binary.cdiv).new()
assert result2.isequal(Vector.from_coo([0], [-1], dtype=dtypes.INT64), check_dtype=True)
- result3 = vec4.ewise_mult(vec2, binary.floordiv).new()
- assert result3.isequal(Vector.from_coo([0], [-2], dtype=dtypes.INT64), check_dtype=True)
+ if shouldhave(binary, "floordiv"):
+ result3 = vec4.ewise_mult(vec2, binary.floordiv).new()
+ assert result3.isequal(Vector.from_coo([0], [-2], dtype=dtypes.INT64), check_dtype=True)
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_nested_names():
def plus_three(x):
@@ -671,12 +736,17 @@ def test_op_namespace():
assert op.plus is binary.plus
assert op.plus_times is semiring.plus_times
- assert op.numpy.fabs is unary.numpy.fabs
- assert op.numpy.subtract is binary.numpy.subtract
- assert op.numpy.add is binary.numpy.add
- assert op.numpy.add_add is semiring.numpy.add_add
+ if shouldhave(unary.numpy, "fabs"):
+ assert op.numpy.fabs is unary.numpy.fabs
+ if shouldhave(binary.numpy, "subtract"):
+ assert op.numpy.subtract is binary.numpy.subtract
+ if shouldhave(binary.numpy, "add"):
+ assert op.numpy.add is binary.numpy.add
+ if shouldhave(semiring.numpy, "add_add"):
+ assert op.numpy.add_add is semiring.numpy.add_add
assert len(dir(op)) > 300
- assert len(dir(op.numpy)) > 500
+ if supports_udfs:
+ assert len(dir(op.numpy)) > 500
with pytest.raises(
AttributeError, match="module 'graphblas.op.numpy' has no attribute 'bad_attr'"
@@ -740,10 +810,18 @@ def test_op_namespace():
@pytest.mark.slow
def test_binaryop_attributes_numpy():
# Some coverage from this test depends on order of tests
- assert binary.numpy.add[int].monoid is monoid.numpy.add[int]
- assert binary.numpy.subtract[int].monoid is None
- assert binary.numpy.add.monoid is monoid.numpy.add
- assert binary.numpy.subtract.monoid is None
+ if shouldhave(monoid.numpy, "add"):
+ assert binary.numpy.add[int].monoid is monoid.numpy.add[int]
+ assert binary.numpy.add.monoid is monoid.numpy.add
+ if shouldhave(binary.numpy, "subtract"):
+ assert binary.numpy.subtract[int].monoid is None
+ assert binary.numpy.subtract.monoid is None
+
+
+@pytest.mark.skipif("not supports_udfs")
+@pytest.mark.slow
+def test_binaryop_monoid_numpy():
+ assert gb.binary.numpy.minimum[int].monoid is gb.monoid.numpy.minimum[int]
@pytest.mark.slow
@@ -756,18 +834,21 @@ def test_binaryop_attributes():
def plus(x, y):
return x + y # pragma: no cover (numba)
- op = BinaryOp.register_anonymous(plus, name="plus")
- assert op.monoid is None
- assert op[int].monoid is None
+ if supports_udfs:
+ op = BinaryOp.register_anonymous(plus, name="plus")
+ assert op.monoid is None
+ assert op[int].monoid is None
+ assert op[int].parent is op
assert binary.plus[int].parent is binary.plus
- assert binary.numpy.add[int].parent is binary.numpy.add
- assert op[int].parent is op
+ if shouldhave(binary.numpy, "add"):
+ assert binary.numpy.add[int].parent is binary.numpy.add
# bad type
assert binary.plus[bool].monoid is None
- assert binary.numpy.equal[int].monoid is None
- assert binary.numpy.equal[bool].monoid is monoid.numpy.equal[bool] # sanity
+ if shouldhave(binary.numpy, "equal"):
+ assert binary.numpy.equal[int].monoid is None
+ assert binary.numpy.equal[bool].monoid is monoid.numpy.equal[bool] # sanity
for attr, val in vars(binary).items():
if not isinstance(val, BinaryOp):
@@ -790,22 +871,25 @@ def test_monoid_attributes():
assert monoid.plus.binaryop is binary.plus
assert monoid.plus.identities == {typ: 0 for typ in monoid.plus.types}
- assert monoid.numpy.add[int].binaryop is binary.numpy.add[int]
- assert monoid.numpy.add[int].identity == 0
- assert monoid.numpy.add.binaryop is binary.numpy.add
- assert monoid.numpy.add.identities == {typ: 0 for typ in monoid.numpy.add.types}
+ if shouldhave(monoid.numpy, "add"):
+ assert monoid.numpy.add[int].binaryop is binary.numpy.add[int]
+ assert monoid.numpy.add[int].identity == 0
+ 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 (numba)
return x + y
- binop = BinaryOp.register_anonymous(plus, name="plus")
- op = Monoid.register_anonymous(binop, 0, name="plus")
- assert op.binaryop is binop
- assert op[int].binaryop is binop[int]
+ if supports_udfs:
+ binop = BinaryOp.register_anonymous(plus, name="plus")
+ op = Monoid.register_anonymous(binop, 0, name="plus")
+ assert op.binaryop is binop
+ assert op[int].binaryop is binop[int]
+ assert op[int].parent is op
assert monoid.plus[int].parent is monoid.plus
- assert monoid.numpy.add[int].parent is monoid.numpy.add
- assert op[int].parent is op
+ if shouldhave(monoid.numpy, "add"):
+ assert monoid.numpy.add[int].parent is monoid.numpy.add
for attr, val in vars(monoid).items():
if not isinstance(val, Monoid):
@@ -826,25 +910,27 @@ def test_semiring_attributes():
assert semiring.min_plus.monoid is monoid.min
assert semiring.min_plus.binaryop is binary.plus
- assert semiring.numpy.add_subtract[int].monoid is monoid.numpy.add[int]
- assert semiring.numpy.add_subtract[int].binaryop is binary.numpy.subtract[int]
- assert semiring.numpy.add_subtract.monoid is monoid.numpy.add
- assert semiring.numpy.add_subtract.binaryop is binary.numpy.subtract
+ if shouldhave(semiring.numpy, "add_subtract"):
+ assert semiring.numpy.add_subtract[int].monoid is monoid.numpy.add[int]
+ assert semiring.numpy.add_subtract[int].binaryop is binary.numpy.subtract[int]
+ assert semiring.numpy.add_subtract.monoid is monoid.numpy.add
+ assert semiring.numpy.add_subtract.binaryop is binary.numpy.subtract
+ assert semiring.numpy.add_subtract[int].parent is semiring.numpy.add_subtract
def plus(x, y):
return x + y # pragma: no cover (numba)
- binop = BinaryOp.register_anonymous(plus, name="plus")
- mymonoid = Monoid.register_anonymous(binop, 0, name="plus")
- op = Semiring.register_anonymous(mymonoid, binop, name="plus_plus")
- assert op.binaryop is binop
- assert op.binaryop[int] is binop[int]
- assert op.monoid is mymonoid
- assert op.monoid[int] is mymonoid[int]
+ if supports_udfs:
+ binop = BinaryOp.register_anonymous(plus, name="plus")
+ mymonoid = Monoid.register_anonymous(binop, 0, name="plus")
+ op = Semiring.register_anonymous(mymonoid, binop, name="plus_plus")
+ assert op.binaryop is binop
+ assert op.binaryop[int] is binop[int]
+ assert op.monoid is mymonoid
+ assert op.monoid[int] is mymonoid[int]
+ assert op[int].parent is op
assert semiring.min_plus[int].parent is semiring.min_plus
- assert semiring.numpy.add_subtract[int].parent is semiring.numpy.add_subtract
- assert op[int].parent is op
for attr, val in vars(semiring).items():
if not isinstance(val, Semiring):
@@ -881,9 +967,10 @@ def test_div_semirings():
assert result[0, 0].new() == -2
assert result.dtype == dtypes.FP64
- result = A1.T.mxm(A2, semiring.plus_floordiv).new()
- assert result[0, 0].new() == -3
- assert result.dtype == dtypes.INT64
+ if shouldhave(semiring, "plus_floordiv"):
+ result = A1.T.mxm(A2, semiring.plus_floordiv).new()
+ assert result[0, 0].new() == -3
+ assert result.dtype == dtypes.INT64
@pytest.mark.slow
@@ -902,30 +989,32 @@ def test_get_semiring():
def myplus(x, y):
return x + y # pragma: no cover (numba)
- binop = BinaryOp.register_anonymous(myplus, name="myplus")
- st = get_semiring(monoid.plus, binop)
- assert st.monoid is monoid.plus
- assert st.binaryop is binop
+ if supports_udfs:
+ binop = BinaryOp.register_anonymous(myplus, name="myplus")
+ st = get_semiring(monoid.plus, binop)
+ assert st.monoid is monoid.plus
+ assert st.binaryop is binop
- binop = BinaryOp.register_new("myplus", myplus)
- assert binop is binary.myplus
- st = get_semiring(monoid.plus, binop)
- assert st.monoid is monoid.plus
- assert st.binaryop is binop
+ binop = BinaryOp.register_new("myplus", myplus)
+ assert binop is binary.myplus
+ st = get_semiring(monoid.plus, binop)
+ assert st.monoid is monoid.plus
+ assert st.binaryop is binop
with pytest.raises(TypeError, match="Monoid"):
get_semiring(None, binary.times)
with pytest.raises(TypeError, match="Binary"):
get_semiring(monoid.plus, None)
- sr = get_semiring(monoid.plus, binary.numpy.copysign)
- assert sr.monoid is monoid.plus
- assert sr.binaryop is binary.numpy.copysign
+ if shouldhave(binary.numpy, "copysign"):
+ sr = get_semiring(monoid.plus, binary.numpy.copysign)
+ assert sr.monoid is monoid.plus
+ assert sr.binaryop is binary.numpy.copysign
def test_create_semiring():
# stress test / sanity check
- monoid_names = {x for x in dir(monoid) if not x.startswith("_")}
+ monoid_names = {x for x in dir(monoid) if not x.startswith("_") and x != "ss"}
binary_names = {x for x in dir(binary) if not x.startswith("_") and x != "ss"}
for monoid_name, binary_name in itertools.product(monoid_names, binary_names):
cur_monoid = getattr(monoid, monoid_name)
@@ -958,17 +1047,22 @@ def test_commutes():
assert semiring.plus_times.is_commutative
if suitesparse:
assert semiring.ss.min_secondi.commutes_to is semiring.ss.min_firstj
- assert semiring.plus_pow.commutes_to is semiring.plus_rpow
+ if shouldhave(semiring, "plus_pow") and shouldhave(semiring, "plus_rpow"):
+ assert semiring.plus_pow.commutes_to is semiring.plus_rpow
assert not semiring.plus_pow.is_commutative
- assert binary.isclose.commutes_to is binary.isclose
- assert binary.isclose.is_commutative
- assert binary.isclose(0.1).commutes_to is binary.isclose(0.1)
- assert binary.floordiv.commutes_to is binary.rfloordiv
- assert not binary.floordiv.is_commutative
- assert binary.numpy.add.commutes_to is binary.numpy.add
- assert binary.numpy.add.is_commutative
- assert binary.numpy.less.commutes_to is binary.numpy.greater
- assert not binary.numpy.less.is_commutative
+ if shouldhave(binary, "isclose"):
+ assert binary.isclose.commutes_to is binary.isclose
+ assert binary.isclose.is_commutative
+ assert binary.isclose(0.1).commutes_to is binary.isclose(0.1)
+ if shouldhave(binary, "floordiv") and shouldhave(binary, "rfloordiv"):
+ assert binary.floordiv.commutes_to is binary.rfloordiv
+ assert not binary.floordiv.is_commutative
+ if shouldhave(binary.numpy, "add"):
+ assert binary.numpy.add.commutes_to is binary.numpy.add
+ assert binary.numpy.add.is_commutative
+ if shouldhave(binary.numpy, "less") and shouldhave(binary.numpy, "greater"):
+ assert binary.numpy.less.commutes_to is binary.numpy.greater
+ assert not binary.numpy.less.is_commutative
# Typed
assert binary.plus[int].commutes_to is binary.plus[int]
@@ -985,15 +1079,20 @@ def test_commutes():
assert semiring.plus_times[int].is_commutative
if suitesparse:
assert semiring.ss.min_secondi[int].commutes_to is semiring.ss.min_firstj[int]
- assert semiring.plus_pow[int].commutes_to is semiring.plus_rpow[int]
+ if shouldhave(semiring, "plus_rpow"):
+ assert semiring.plus_pow[int].commutes_to is semiring.plus_rpow[int]
assert not semiring.plus_pow[int].is_commutative
- assert binary.isclose(0.1)[int].commutes_to is binary.isclose(0.1)[int]
- assert binary.floordiv[int].commutes_to is binary.rfloordiv[int]
- assert not binary.floordiv[int].is_commutative
- assert binary.numpy.add[int].commutes_to is binary.numpy.add[int]
- assert binary.numpy.add[int].is_commutative
- assert binary.numpy.less[int].commutes_to is binary.numpy.greater[int]
- assert not binary.numpy.less[int].is_commutative
+ if shouldhave(binary, "isclose"):
+ assert binary.isclose(0.1)[int].commutes_to is binary.isclose(0.1)[int]
+ if shouldhave(binary, "floordiv") and shouldhave(binary, "rfloordiv"):
+ assert binary.floordiv[int].commutes_to is binary.rfloordiv[int]
+ assert not binary.floordiv[int].is_commutative
+ if shouldhave(binary.numpy, "add"):
+ assert binary.numpy.add[int].commutes_to is binary.numpy.add[int]
+ assert binary.numpy.add[int].is_commutative
+ if shouldhave(binary.numpy, "less") and shouldhave(binary.numpy, "greater"):
+ assert binary.numpy.less[int].commutes_to is binary.numpy.greater[int]
+ assert not binary.numpy.less[int].is_commutative
# Stress test (this can create extra semirings)
names = dir(semiring)
@@ -1014,9 +1113,12 @@ def test_from_string():
assert unary.from_string("abs[float]") is unary.abs[float]
assert binary.from_string("+") is binary.plus
assert binary.from_string("-[int]") is binary.minus[int]
- assert binary.from_string("true_divide") is binary.numpy.true_divide
- assert binary.from_string("//") is binary.floordiv
- assert binary.from_string("%") is binary.numpy.mod
+ if config["mapnumpy"] or shouldhave(binary.numpy, "true_divide"):
+ assert binary.from_string("true_divide") is binary.numpy.true_divide
+ if shouldhave(binary, "floordiv"):
+ assert binary.from_string("//") is binary.floordiv
+ if shouldhave(binary.numpy, "mod"):
+ assert binary.from_string("%") is binary.numpy.mod
assert monoid.from_string("*[FP64]") is monoid.times["FP64"]
assert semiring.from_string("min.plus") is semiring.min_plus
assert semiring.from_string("min.+") is semiring.min_plus
@@ -1053,6 +1155,7 @@ def test_from_string():
agg.from_string("bad_agg")
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_lazy_op():
UnaryOp.register_new("lazy", lambda x: x, lazy=True) # pragma: no branch (numba)
@@ -1115,6 +1218,7 @@ def test_positional():
assert semiring.ss.any_secondj[int].is_positional
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_udt():
record_dtype = np.dtype([("x", np.bool_), ("y", np.float64)], align=True)
@@ -1240,6 +1344,19 @@ def badfunc2(x, y): # pragma: no cover (numba)
assert binary.first[udt, dtypes.INT8].type2 is dtypes.INT8
assert monoid.any[udt].type2 is udt
+ def _this_or_that(val, idx, _, thunk): # pragma: no cover (numba)
+ return val["x"]
+
+ sel = SelectOp.register_anonymous(_this_or_that, is_udt=True)
+ sel[udt]
+ assert udt in sel
+ result = v.select(sel, 0).new()
+ assert result.nvals == 0
+ assert result.dtype == v.dtype
+ result = w.select(sel, 0).new()
+ assert result.nvals == 3
+ assert result.isequal(w)
+
def test_dir():
for mod in [unary, binary, monoid, semiring, op]:
@@ -1280,6 +1397,7 @@ def test_binaryop_commute_exists():
raise AssertionError("Missing binaryops: " + ", ".join(sorted(missing)))
+@pytest.mark.skipif("not supports_udfs")
def test_binom():
v = Vector.from_coo([0, 1, 2], [3, 4, 5])
result = v.apply(binary.binom, 2).new()
@@ -1334,14 +1452,28 @@ def test_deprecated():
gb.agg.argmin
+@pytest.mark.slow
def test_is_idempotent():
assert monoid.min.is_idempotent
assert monoid.max[int].is_idempotent
assert monoid.lor.is_idempotent
assert monoid.band.is_idempotent
- assert monoid.numpy.gcd.is_idempotent
+ if shouldhave(monoid.numpy, "gcd"):
+ assert monoid.numpy.gcd.is_idempotent
assert not monoid.plus.is_idempotent
assert not monoid.times[float].is_idempotent
- assert not monoid.numpy.equal.is_idempotent
+ if config["mapnumpy"] or shouldhave(monoid.numpy, "equal"):
+ assert not monoid.numpy.equal.is_idempotent
with pytest.raises(AttributeError):
binary.min.is_idempotent
+
+
+def test_ops_have_ss():
+ modules = [unary, binary, monoid, semiring, indexunary, select, op]
+ if suitesparse:
+ for mod in modules:
+ assert mod.ss is not None
+ else:
+ for mod in modules:
+ with pytest.raises(AttributeError):
+ mod.ss
diff --git a/graphblas/tests/test_operator_types.py b/graphblas/tests/test_operator_types.py
index 522b42ad2..027f02fcc 100644
--- a/graphblas/tests/test_operator_types.py
+++ b/graphblas/tests/test_operator_types.py
@@ -2,6 +2,7 @@
from collections import defaultdict
from graphblas import backend, binary, dtypes, monoid, semiring, unary
+from graphblas.core import _supports_udfs as supports_udfs
from graphblas.core import operator
from graphblas.dtypes import (
BOOL,
@@ -83,6 +84,11 @@
BINARY[(ALL, POS)] = {
"firsti", "firsti1", "firstj", "firstj1", "secondi", "secondi1", "secondj", "secondj1",
}
+if not supports_udfs:
+ udfs = {"absfirst", "abssecond", "binom", "floordiv", "rfloordiv", "rpow"}
+ for funcnames in BINARY.values():
+ funcnames -= udfs
+ BINARY = {key: val for key, val in BINARY.items() if val}
MONOID = {
(UINT, UINT): {"band", "bor", "bxnor", "bxor"},
diff --git a/graphblas/tests/test_pickle.py b/graphblas/tests/test_pickle.py
index de2d9cfda..724f43d76 100644
--- a/graphblas/tests/test_pickle.py
+++ b/graphblas/tests/test_pickle.py
@@ -5,6 +5,7 @@
import pytest
import graphblas as gb
+from graphblas.core import _supports_udfs as supports_udfs # noqa: F401
suitesparse = gb.backend == "suitesparse"
@@ -36,6 +37,7 @@ def extra():
return ""
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_deserialize(extra):
path = Path(__file__).parent / f"pickle1{extra}.pkl"
@@ -62,6 +64,7 @@ def test_deserialize(extra):
assert d3["semiring_pickle"] is gb.semiring.semiring_pickle
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_serialize():
v = gb.Vector.from_coo([1], 2)
@@ -232,6 +235,7 @@ def identity_par(z):
return -z
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_serialize_parameterized():
# unary_pickle = gb.core.operator.UnaryOp.register_new(
@@ -285,6 +289,7 @@ def test_serialize_parameterized():
pickle.loads(pkl) # TODO: check results
+@pytest.mark.skipif("not supports_udfs")
@pytest.mark.slow
def test_deserialize_parameterized(extra):
path = Path(__file__).parent / f"pickle2{extra}.pkl"
@@ -295,6 +300,7 @@ def test_deserialize_parameterized(extra):
pickle.load(f) # TODO: check results
+@pytest.mark.skipif("not supports_udfs")
def test_udt(extra):
record_dtype = np.dtype([("x", np.bool_), ("y", np.int64)], align=True)
udt = gb.dtypes.register_new("PickleUDT", record_dtype)
diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py
index 6ee70311c..e93511914 100644
--- a/graphblas/tests/test_scalar.py
+++ b/graphblas/tests/test_scalar.py
@@ -12,7 +12,7 @@
from graphblas import backend, binary, dtypes, monoid, replace, select, unary
from graphblas.exceptions import EmptyObject
-from .conftest import autocompute, compute
+from .conftest import autocompute, compute, pypy
from graphblas import Matrix, Scalar, Vector # isort:skip (for dask-graphblas)
@@ -50,7 +50,7 @@ def test_dup(s):
s_empty = Scalar(dtypes.FP64)
s_unempty = Scalar.from_value(0.0)
if s_empty.is_cscalar:
- # NumPy wraps around
+ # NumPy <2 wraps around; >=2 raises OverflowError
uint_data = [
("UINT8", 2**8 - 2),
("UINT16", 2**16 - 2),
@@ -73,6 +73,10 @@ def test_dup(s):
("FP32", -2.5),
*uint_data,
]:
+ if dtype.startswith("UINT") and s_empty.is_cscalar and not np.__version__.startswith("1."):
+ with pytest.raises(OverflowError, match="out of bounds for uint"):
+ s4.dup(dtype=dtype, name="s5")
+ continue
s5 = s4.dup(dtype=dtype, name="s5")
assert s5.dtype == dtype
assert s5.value == val
@@ -128,12 +132,14 @@ def test_equal(s):
def test_casting(s):
assert int(s) == 5
- assert type(int(s)) is int
+ assert isinstance(int(s), int)
assert float(s) == 5.0
- assert type(float(s)) is float
+ assert isinstance(float(s), float)
assert range(s) == range(5)
+ with pytest.raises(AttributeError, match="Scalar .* only .*__index__.*integral"):
+ range(s.dup(float))
assert complex(s) == complex(5)
- assert type(complex(s)) is complex
+ assert isinstance(complex(s), complex)
def test_truthy(s):
@@ -209,12 +215,12 @@ def test_unsupported_ops(s):
s[0]
with pytest.raises(TypeError, match="does not support"):
s[0] = 0
- with pytest.raises(TypeError, match="doesn't support"):
+ with pytest.raises(TypeError, match="doesn't support|does not support"):
del s[0]
def test_is_empty(s):
- with pytest.raises(AttributeError, match="can't set attribute"):
+ with pytest.raises(AttributeError, match="can't set attribute|object has no setter"):
s.is_empty = True
@@ -226,7 +232,7 @@ def test_update(s):
s << Scalar.from_value(3)
assert s == 3
if s._is_cscalar:
- with pytest.raises(TypeError, match="an integer is required"):
+ with pytest.raises(TypeError, match="an integer is required|expected integer"):
s << Scalar.from_value(4.4)
else:
s << Scalar.from_value(4.4)
@@ -248,7 +254,7 @@ def test_update(s):
def test_not_hashable(s):
with pytest.raises(TypeError, match="unhashable type"):
- {s}
+ _ = {s}
with pytest.raises(TypeError, match="unhashable type"):
hash(s)
@@ -358,14 +364,15 @@ def test_expr_is_like_scalar(s):
}
if s.is_cscalar:
expected.add("_empty")
- assert attrs - expr_attrs == expected, (
+ ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union"}
+ assert attrs - expr_attrs - ignore == expected, (
"If you see this message, you probably added a method to Scalar. You may need to "
"add an entry to `scalar` set in `graphblas.core.automethods` "
"and then run `python -m graphblas.core.automethods`. If you're messing with infix "
"methods, then you may need to run `python -m graphblas.core.infixmethods`."
)
- assert attrs - infix_attrs == expected
- assert attrs - scalar_infix_attrs == expected
+ assert attrs - infix_attrs - ignore == expected
+ assert attrs - scalar_infix_attrs - ignore == expected
# Make sure signatures actually match. `expr.dup` has `**opts`
skip = {"__init__", "__repr__", "_repr_html_", "dup"}
for expr in [v.inner(v), v @ v, t & t]:
@@ -399,7 +406,8 @@ def test_index_expr_is_like_scalar(s):
}
if s.is_cscalar:
expected.add("_empty")
- assert attrs - expr_attrs == expected, (
+ ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union"}
+ assert attrs - expr_attrs - ignore == expected, (
"If you see this message, you probably added a method to Scalar. You may need to "
"add an entry to `scalar` set in `graphblas.core.automethods` "
"and then run `python -m graphblas.core.automethods`. If you're messing with infix "
@@ -505,10 +513,10 @@ def test_scalar_expr(s):
def test_sizeof(s):
- if suitesparse or s._is_cscalar:
+ if (suitesparse or s._is_cscalar) and not pypy:
assert 1 < sys.getsizeof(s) < 1000
else:
- with pytest.raises(TypeError):
+ with pytest.raises(TypeError): # flakey coverage (why?!)
sys.getsizeof(s)
@@ -576,7 +584,7 @@ def test_record_from_dict():
def test_get(s):
assert s.get() == 5
assert s.get("mittens") == 5
- assert type(compute(s.get())) is int
+ assert isinstance(compute(s.get()), int)
s.clear()
assert compute(s.get()) is None
assert s.get("mittens") == "mittens"
diff --git a/graphblas/tests/test_ss_utils.py b/graphblas/tests/test_ss_utils.py
index d21f41f03..2df7ab939 100644
--- a/graphblas/tests/test_ss_utils.py
+++ b/graphblas/tests/test_ss_utils.py
@@ -4,6 +4,7 @@
import graphblas as gb
from graphblas import Matrix, Vector, backend
+from graphblas.exceptions import InvalidValue
if backend != "suitesparse":
pytest.skip("gb.ss and A.ss only available with suitesparse backend", allow_module_level=True)
@@ -198,6 +199,11 @@ def test_about():
assert "library_name" in repr(about)
+def test_openmp_enabled():
+ # SuiteSparse:GraphBLAS without OpenMP enabled is very undesirable
+ assert gb.ss.about["openmp"]
+
+
def test_global_config():
d = {}
config = gb.ss.config
@@ -226,6 +232,65 @@ def test_global_config():
else:
with pytest.raises(ValueError, match="Unable to set default value for"):
config[k] = None
- with pytest.raises(ValueError, match="Wrong number"):
- config["memory_pool"] = [1, 2]
+ # with pytest.raises(ValueError, match="Wrong number"):
+ # config["memory_pool"] = [1, 2] # No longer used
assert "format" in repr(config)
+
+
+@pytest.mark.skipif("gb.core.ss._IS_SSGB7")
+def test_context():
+ context = gb.ss.Context()
+ prev = dict(context)
+ context["chunk"] += 1
+ context["nthreads"] += 1
+ assert context["chunk"] == prev["chunk"] + 1
+ assert context["nthreads"] == prev["nthreads"] + 1
+ context2 = gb.ss.Context(stack=True)
+ assert context2 == context
+ context3 = gb.ss.Context(stack=False)
+ assert context3 == prev
+ context4 = gb.ss.Context(
+ chunk=context["chunk"] + 1, nthreads=context["nthreads"] + 1, stack=False
+ )
+ assert context4["chunk"] == context["chunk"] + 1
+ assert context4["nthreads"] == context["nthreads"] + 1
+ assert context == context.dup()
+ assert context4 == context.dup(chunk=context["chunk"] + 1, nthreads=context["nthreads"] + 1)
+ assert context.dup(gpu_id=-1)["gpu_id"] == -1
+
+ context.engage()
+ assert gb.core.ss.context.threadlocal.context is context
+ with gb.ss.Context(nthreads=1) as ctx:
+ assert gb.core.ss.context.threadlocal.context is ctx
+ v = Vector(int, 5)
+ v(nthreads=2) << v + v
+ assert gb.core.ss.context.threadlocal.context is ctx
+ assert gb.core.ss.context.threadlocal.context is context
+ with pytest.raises(InvalidValue):
+ # Wait, why does this raise?!
+ ctx.disengage()
+ assert gb.core.ss.context.threadlocal.context is context
+ context.disengage()
+ assert gb.core.ss.context.threadlocal.context is gb.core.ss.context.global_context
+ assert context._prev_context is None
+
+ # hackery
+ gb.core.ss.context.threadlocal.context = context
+ context.disengage()
+ context.disengage()
+ context.disengage()
+ assert gb.core.ss.context.threadlocal.context is gb.core.ss.context.global_context
+
+ # Actually engaged, but not set in threadlocal
+ context._engage()
+ assert gb.core.ss.context.threadlocal.context is gb.core.ss.context.global_context
+ context.disengage()
+
+ context.engage()
+ context._engage()
+ assert gb.core.ss.context.threadlocal.context is context
+ context.disengage()
+
+ context._context = context # This is allowed to work with config
+ with pytest.raises(AttributeError, match="_context"):
+ context._context = ctx # This is not
diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py
new file mode 100644
index 000000000..4cea0b563
--- /dev/null
+++ b/graphblas/tests/test_ssjit.py
@@ -0,0 +1,438 @@
+import os
+import pathlib
+import platform
+import sys
+import sysconfig
+
+import numpy as np
+import pytest
+from numpy.testing import assert_array_equal
+
+import graphblas as gb
+from graphblas import backend, binary, dtypes, indexunary, select, unary
+from graphblas.core import _supports_udfs as supports_udfs
+from graphblas.core.ss import _IS_SSGB7
+
+from .conftest import autocompute, burble
+
+from graphblas import Vector # isort:skip (for dask-graphblas)
+
+try:
+ import numba
+except ImportError:
+ numba = None
+
+if backend != "suitesparse":
+ pytest.skip("not suitesparse backend", allow_module_level=True)
+
+
+@pytest.fixture(scope="module", autouse=True)
+def _setup_jit():
+ """Set up the SuiteSparse:GraphBLAS JIT."""
+ if _IS_SSGB7:
+ # SuiteSparse JIT was added in SSGB 8
+ yield
+ return
+
+ if not os.environ.get("GITHUB_ACTIONS"):
+ # Try to run the tests with defaults from sysconfig if not running in CI
+ prev = gb.ss.config["jit_c_control"]
+ cc = sysconfig.get_config_var("CC")
+ cflags = sysconfig.get_config_var("CFLAGS")
+ include = sysconfig.get_path("include")
+ libs = sysconfig.get_config_var("LIBS")
+ if not (cc is None or cflags is None or include is None or libs is None):
+ gb.ss.config["jit_c_control"] = "on"
+ gb.ss.config["jit_c_compiler_name"] = cc
+ gb.ss.config["jit_c_compiler_flags"] = f"{cflags} -I{include}"
+ gb.ss.config["jit_c_libraries"] = libs
+ else:
+ # Should we skip or try to run if sysconfig vars aren't set?
+ gb.ss.config["jit_c_control"] = "on" # "off"
+ try:
+ yield
+ finally:
+ gb.ss.config["jit_c_control"] = prev
+ return
+
+ if (
+ sys.platform == "darwin"
+ or sys.platform == "linux"
+ and "conda" not in gb.ss.config["jit_c_compiler_name"]
+ ):
+ # XXX TODO: tests for SuiteSparse JIT are not passing on linux when using wheels or on osx
+ # This should be understood and fixed!
+ gb.ss.config["jit_c_control"] = "off"
+ yield
+ return
+
+ # Configuration values below were obtained from the output of the JIT config
+ # in CI, but with paths changed to use `{conda_prefix}` where appropriate.
+ conda_prefix = os.environ["CONDA_PREFIX"]
+ prev = gb.ss.config["jit_c_control"]
+ gb.ss.config["jit_c_control"] = "on"
+ if sys.platform == "linux":
+ gb.ss.config["jit_c_compiler_name"] = f"{conda_prefix}/bin/x86_64-conda-linux-gnu-cc"
+ gb.ss.config["jit_c_compiler_flags"] = (
+ "-march=nocona -mtune=haswell -ftree-vectorize -fPIC -fstack-protector-strong "
+ f"-fno-plt -O2 -ffunction-sections -pipe -isystem {conda_prefix}/include -Wundef "
+ "-std=c11 -lm -Wno-pragmas -fexcess-precision=fast -fcx-limited-range "
+ "-fno-math-errno -fwrapv -O3 -DNDEBUG -fopenmp -fPIC"
+ )
+ gb.ss.config["jit_c_linker_flags"] = (
+ "-Wl,-O2 -Wl,--sort-common -Wl,--as-needed -Wl,-z,relro -Wl,-z,now "
+ "-Wl,--disable-new-dtags -Wl,--gc-sections -Wl,--allow-shlib-undefined "
+ f"-Wl,-rpath,{conda_prefix}/lib -Wl,-rpath-link,{conda_prefix}/lib "
+ f"-L{conda_prefix}/lib -shared"
+ )
+ gb.ss.config["jit_c_libraries"] = (
+ f"-lm -ldl {conda_prefix}/lib/libgomp.so "
+ f"{conda_prefix}/x86_64-conda-linux-gnu/sysroot/usr/lib/libpthread.so"
+ )
+ gb.ss.config["jit_c_cmake_libs"] = (
+ f"m;dl;{conda_prefix}/lib/libgomp.so;"
+ f"{conda_prefix}/x86_64-conda-linux-gnu/sysroot/usr/lib/libpthread.so"
+ )
+ elif sys.platform == "darwin":
+ gb.ss.config["jit_c_compiler_name"] = f"{conda_prefix}/bin/clang"
+ gb.ss.config["jit_c_compiler_flags"] = (
+ "-march=core2 -mtune=haswell -mssse3 -ftree-vectorize -fPIC -fPIE "
+ f"-fstack-protector-strong -O2 -pipe -isystem {conda_prefix}/include -DGBNCPUFEAT "
+ f"-Wno-pointer-sign -O3 -DNDEBUG -fopenmp=libomp -fPIC -arch {platform.machine()}"
+ )
+ gb.ss.config["jit_c_linker_flags"] = (
+ "-Wl,-pie -Wl,-headerpad_max_install_names -Wl,-dead_strip_dylibs "
+ f"-Wl,-rpath,{conda_prefix}/lib -L{conda_prefix}/lib -dynamiclib"
+ )
+ gb.ss.config["jit_c_libraries"] = f"-lm -ldl {conda_prefix}/lib/libomp.dylib"
+ gb.ss.config["jit_c_cmake_libs"] = f"m;dl;{conda_prefix}/lib/libomp.dylib"
+ elif sys.platform == "win32": # pragma: no branch (sanity)
+ if "mingw" in gb.ss.config["jit_c_libraries"]:
+ # This probably means we're testing a `python-suitesparse-graphblas` wheel
+ # in a conda environment. This is not yet working.
+ gb.ss.config["jit_c_control"] = "off"
+ yield
+ return
+
+ gb.ss.config["jit_c_compiler_name"] = f"{conda_prefix}/bin/cc"
+ gb.ss.config["jit_c_compiler_flags"] = (
+ '/DWIN32 /D_WINDOWS -DGBNCPUFEAT /O2 -wd"4244" -wd"4146" -wd"4018" '
+ '-wd"4996" -wd"4047" -wd"4554" /O2 /Ob2 /DNDEBUG -openmp'
+ )
+ gb.ss.config["jit_c_linker_flags"] = "/machine:x64"
+ gb.ss.config["jit_c_libraries"] = ""
+ gb.ss.config["jit_c_cmake_libs"] = ""
+
+ if not pathlib.Path(gb.ss.config["jit_c_compiler_name"]).exists():
+ # Can't use the JIT if we don't have a compiler!
+ gb.ss.config["jit_c_control"] = "off"
+ yield
+ return
+ try:
+ yield
+ finally:
+ gb.ss.config["jit_c_control"] = prev
+
+
+@pytest.fixture
+def v():
+ return Vector.from_coo([1, 3, 4, 6], [1, 1, 2, 0])
+
+
+@autocompute
+def test_jit_udt():
+ if _IS_SSGB7:
+ with pytest.raises(RuntimeError, match="JIT was added"):
+ dtypes.ss.register_new(
+ "myquaternion", "typedef struct { float x [4][4] ; int color ; } myquaternion ;"
+ )
+ return
+ if gb.ss.config["jit_c_control"] == "off":
+ return
+ with burble():
+ dtype = dtypes.ss.register_new(
+ "myquaternion", "typedef struct { float x [4][4] ; int color ; } myquaternion ;"
+ )
+ assert not hasattr(dtypes, "myquaternion")
+ assert dtypes.ss.myquaternion is dtype
+ assert dtype.name == "myquaternion"
+ assert str(dtype) == "myquaternion"
+ assert dtype.gb_name is None
+ v = Vector(dtype, 2)
+ np_type = np.dtype([("x", "