diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index a54084e34..f7466a7d4 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -56,9 +56,29 @@ jobs: channel-priority: strict activate-environment: graphblas auto-activate-base: false + # Randomly test one slow task per job (about 5% chance one group isn't run). + # + # TODO: have build matrix be [slowtask]x[os] and randomly choose [pyver] and [sourcetype]. + # This should ensure we'll have full code coverage (i.e., no chance of getting unlucky). + # If important in the future, we could also randomize dependency versions. + - name: RNG for tests + uses: ddradar/choose-random-action@v2.0.2 + id: slowtask + with: + contents: | + pytest_normal + pytest_bizarro + pylint + notebooks + weights: | + 1 + 1 + 1 + 1 - name: Update env run: | - mamba install pytest coverage coveralls=3.3.1 pytest-randomly black \ + mamba install pytest coverage coveralls=3.3.1 pytest-randomly \ + ${{ steps.slowtask.outputs.selected == 'pylint' && 'black pylint' || '' }} \ pandas numba scipy networkx cffi donfig pyyaml awkward - name: Build extension module run: | @@ -78,58 +98,58 @@ jobs: pip install --no-deps -e . - name: Unit tests run: | - coverage run --branch -m pytest ${{ matrix.cfg.testopts }} -v + coverage run -m pytest ${{ matrix.cfg.testopts }} -v \ + ${{ steps.slowtask.outputs.selected == 'pytest_normal' && '--runslow' || '' }} + - name: Unit tests (bizarro scalars) + run: | # Run tests again with Scalars being C scalars by default find graphblas -type f -name "*.py" -print0 | xargs -0 sed -i -s \ -e '/# pragma: is_grbscalar/! s/is_cscalar=False/is_cscalar=True/g' \ -e '/# pragma: is_grbscalar/! s/is_cscalar = False/is_cscalar = True/g' \ -e '/# pragma: to_grb/ s/is_cscalar=True/is_cscalar=False/g' \ -e '/# pragma: to_grb/ s/is_cscalar = True/is_cscalar = False/g' - coverage run -a --branch -m pytest ${{ matrix.cfg.testopts }} -v + coverage run -a -m pytest ${{ matrix.cfg.testopts }} -v \ + ${{ steps.slowtask.outputs.selected == 'pytest_bizarro' && '--runslow' || ''}} git checkout . # Undo changes to scalar default - + - name: Miscellaneous tests + if: steps.slowtask.outputs.selected == 'pylint' + run: | # Test (and cover) automatic initialization - coverage run -a --branch graphblas/tests/test_auto_init.py - coverage run -a --branch graphblas/tests/test_external_init.py + coverage run -a graphblas/tests/test_auto_init.py + coverage run -a graphblas/tests/test_external_init.py # Test (and cover) lazy module loader echo "from graphblas.agg import count" > script.py - coverage run -a --branch script.py + coverage run -a script.py echo "from graphblas import agg" > script.py # Does this still cover? echo "from graphblas.core import agg" >> script.py - coverage run -a --branch script.py + coverage run -a script.py # Tests lazy loading of lib, ffi, and NULL in gb.core echo "from graphblas.core import base" > script.py - coverage run -a --branch script.py + coverage run -a script.py rm script.py - name: Auto-generated code check + if: steps.slowtask.outputs.selected == 'pylint' run: | - coverage run -a --branch -m graphblas.core.automethods - coverage run -a --branch -m graphblas.core.infixmethods + # This step uses `black` + coverage run -a -m graphblas.core.automethods + coverage run -a -m graphblas.core.infixmethods git diff --exit-code - coverage xml + - name: Pylint (informational only; never fails) + if: steps.slowtask.outputs.selected == 'pylint' + run: pylint --exit-zero graphblas/ - name: Coverage env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_FLAG_NAME: ${{ matrix.cfg.pyver}}/${{ matrix.cfg.testopts }} COVERALLS_PARALLEL: true run: | + coverage xml coverage report --show-missing coveralls --service=github - name: codecov uses: codecov/codecov-action@v3 - # Randomly test notebooks 1/3 of the time - - name: random number - uses: ddradar/choose-random-action@v2.0.2 - id: rand - with: - contents: | - true - false - weights: | - 1 - 2 - name: Notebooks Execution check - if: ${{ steps.rand.outputs.selected == 'true' }} + if: steps.slowtask.outputs.selected == 'notebooks' run: | mamba install matplotlib nbconvert jupyter 'ipython>=7' jupyter nbconvert --to notebook --execute notebooks/*ipynb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index df5683491..80d0218c8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,6 +55,7 @@ repos: - flake8==6.0.0 - flake8-comprehensions==3.10.1 - flake8-bugbear==22.12.6 + - flake8-simplify==0.19.3 - repo: https://github.com/asottile/yesqa rev: v1.4.0 hooks: @@ -65,13 +66,18 @@ repos: hooks: - id: codespell types_or: [python, rst, markdown] + additional_dependencies: [tomli] files: ^(graphblas|docs)/ - args: ["--ignore-words-list=coo,ba"] - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.205 + rev: v0.0.212 hooks: - id: ruff - args: ["--force-exclude"] + args: [--force-exclude] + - repo: https://github.com/sphinx-contrib/sphinx-lint + rev: v0.6.7 + hooks: + - id: sphinx-lint + args: [--enable, all, "--disable=line-too-long,leaked-markup"] - repo: local hooks: # Add `--hook-stage manual` to pre-commit command to run (very slow) @@ -81,7 +87,7 @@ repos: language: system types: [python] stages: [manual] - args: ["graphblas/"] + args: [graphblas/] pass_filenames: false - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 diff --git a/docs/getting_started/faq.rst b/docs/getting_started/faq.rst index ddd69be47..c2cc51646 100644 --- a/docs/getting_started/faq.rst +++ b/docs/getting_started/faq.rst @@ -29,7 +29,7 @@ For simple expressions, the two libraries are very similar. A += 1 For more complex expressions, however, the two libraries diverge significantly. -`pygraphblas` tends towards the `numpy` style of immediate execution and using +``pygraphblas`` tends towards the ``numpy`` style of immediate execution and using keyword arguments to affect the output. .. code-block:: python @@ -37,7 +37,7 @@ keyword arguments to affect the output. # pygraphblas A.mxm(B.transpose(), mask=A, out=C, accum=FP64.PLUS, semiring=FP64.MIN_PLUS) -`python-graphblas` uses delayed expressions and keeps the output-affecting arguments +``python-graphblas`` uses delayed expressions and keeps the output-affecting arguments together with the output. .. code-block:: python @@ -45,7 +45,7 @@ together with the output. # python-graphblas C(A, accum=binary.plus) << semiring.min_plus(A @ B.T) -`python-graphblas` also contains additional features, such as the `Recorder` and advanced aggregators. +``python-graphblas`` also contains additional features, such as the ``Recorder`` and advanced aggregators. What is the performance penalty of writing algorithms with python-graphblas vs writing them directly in C? ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ diff --git a/docs/getting_started/primer.rst b/docs/getting_started/primer.rst index d95cde0cc..710dca702 100644 --- a/docs/getting_started/primer.rst +++ b/docs/getting_started/primer.rst @@ -55,7 +55,7 @@ as their start and end point. Common ways to store a graph ---------------------------- -The most common way to store a graph is as an edge list, containing the `(start_node, end_node, weight)`. +The most common way to store a graph is as an edge list, containing the ``(start_node, end_node, weight)``. The following graph and its edge list are shown below. diff --git a/docs/user_guide/init.rst b/docs/user_guide/init.rst index 7e1fdfa00..62f81b50f 100644 --- a/docs/user_guide/init.rst +++ b/docs/user_guide/init.rst @@ -42,7 +42,7 @@ moment. Default Initialization ---------------------- -If submodules are imported before calling `graphblas.init`, a default initialization occurs. +If submodules are imported before calling ``graphblas.init``, a default initialization occurs. The default backend is ``suitesparse`` and the default mode is ``nonblocking``. If ``graphblas.init`` is called after the default initialization with different parameters, diff --git a/docs/user_guide/operations.rst b/docs/user_guide/operations.rst index 1f1c19ed4..d06c79a83 100644 --- a/docs/user_guide/operations.rst +++ b/docs/user_guide/operations.rst @@ -134,7 +134,7 @@ Element-wise Intersection Two identically shaped collections can be intersected element-wise. Locations where only one of the two collections contains a value will be missing in the output. -The GraphBLAS spec calls this operation `eWiseMult` because it has the same behavior as sparse +The GraphBLAS spec calls this operation ``eWiseMult`` because it has the same behavior as sparse multiplication when missing values are treated as zero. As a result, ``binary.times`` is the default operator for element-wise intersection. diff --git a/environment.yml b/environment.yml index ff1ba1351..f8e3610dd 100644 --- a/environment.yml +++ b/environment.yml @@ -57,6 +57,9 @@ dependencies: # - flake8 # - flake8-bugbear # - flake8-comprehensions + # - flake8-print + # - flake8-quotes + # - flake8-simplify # - gcc # - gh # - graph-tool @@ -89,6 +92,8 @@ dependencies: # - ruff # - scalene # - snakeviz + # - sphinx-lint # - sympy + # - vim # - yesqa # - zarr diff --git a/graphblas/binary/__init__.py b/graphblas/binary/__init__.py index 726d26764..05e80e868 100644 --- a/graphblas/binary/__init__.py +++ b/graphblas/binary/__init__.py @@ -21,10 +21,7 @@ def __getattr__(key): globals()[key] = rv if key in _delayed_commutes_to: other_key = _delayed_commutes_to[key] - if other_key in globals(): - other = globals()[other_key] - else: - other = other_key + other = globals().get(other_key, other_key) rv._commutes_to = other return rv raise AttributeError(f"module {__name__!r} has no attribute {key!r}") diff --git a/graphblas/binary/numpy.py b/graphblas/binary/numpy.py index 7d55d83fa..a5855718c 100644 --- a/graphblas/binary/numpy.py +++ b/graphblas/binary/numpy.py @@ -135,8 +135,8 @@ def __dir__(): def __getattr__(name): if name in _delayed: - func, kwargs = _delayed.pop(name) - rv = func(**kwargs) + delayed_func, kwargs = _delayed.pop(name) + rv = delayed_func(**kwargs) globals()[name] = rv return rv if name not in _binary_names: diff --git a/graphblas/core/automethods.py b/graphblas/core/automethods.py index 57dbb87a3..f4aea3bdf 100644 --- a/graphblas/core/automethods.py +++ b/graphblas/core/automethods.py @@ -93,10 +93,6 @@ def __int__(self): return self._get_value("__int__") -def __invert__(self): - return self._get_value("__invert__") - - def __iter__(self): return self._get_value("__iter__") @@ -105,8 +101,8 @@ def __matmul__(self): return self._get_value("__matmul__") -def __neg__(self): - return self._get_value("__neg__") +def __ne__(self): + return self._get_value("__ne__") def __or__(self): @@ -349,6 +345,17 @@ def _main(): "name", "nvals", "wait", + # For infix + "__and__", + "__or__", + "__rand__", + "__ror__", + # Delayed methods + "apply", + "ewise_add", + "ewise_mult", + "ewise_union", + "select", } scalar = { "__array__", @@ -358,8 +365,7 @@ def _main(): "__float__", "__index__", "__int__", - "__invert__", - "__neg__", + "__ne__", "_as_matrix", "_as_vector", "_is_empty", @@ -369,23 +375,14 @@ def _main(): vector_matrix = { "S", "V", - "__and__", "__contains__", "__getitem__", "__iter__", "__matmul__", - "__or__", - "__rand__", "__rmatmul__", - "__ror__", "_carg", - "apply", "diag", - "ewise_add", - "ewise_mult", - "ewise_union", "reposition", - "select", "ss", "to_coo", "to_values", @@ -415,12 +412,8 @@ def _main(): } common_raises = set() scalar_raises = { - "__and__", "__matmul__", - "__or__", - "__rand__", "__rmatmul__", - "__ror__", } vector_matrix_raises = { "__array__", @@ -463,12 +456,20 @@ def _main(): lines = [] lines.append(" _get_value = automethods._get_value") for name in sorted(common | scalar): - lines.append(f" {name} = wrapdoc(Scalar.{name})(property(automethods.{name}))") if name == "name": - lines.append(" name = name.setter(automethods._set_name)") + lines.append( + " name = wrapdoc(Scalar.name)(property(automethods.name))" + ".setter(automethods._set_name)" + ) + else: + lines.append(f" {name} = wrapdoc(Scalar.{name})(property(automethods.{name}))") lines.append(" # These raise exceptions") for name in sorted(common_raises | scalar_raises): lines.append(f" {name} = Scalar.{name}") + for name in sorted(bad_sugar): + if name == "__imatmul__": + continue + lines.append(f" {name} = automethods.{name}") thisdir = os.path.dirname(__file__) infix_exclude = {"_get_value"} @@ -490,9 +491,17 @@ def get_name(line): indent = " " else: indent = "" - lines.append(f" {indent}{name} = wrapdoc(Vector.{name})(property(automethods.{name}))") if name == "name": - lines.append(" name = name.setter(automethods._set_name)") + lines.append( + " name = wrapdoc(Vector.name)(property(automethods.name))" + ".setter(automethods._set_name)" + ) + else: + lines.append( + f" {indent}{name} = wrapdoc(Vector.{name})(property(automethods.{name}))" + ) + if name == "ss": + lines.append(' else:\n ss = Vector.__dict__["ss"] # raise if used') lines.append(" # These raise exceptions") for name in sorted(common_raises | vector_matrix_raises): lines.append(f" {name} = Vector.{name}") @@ -513,9 +522,17 @@ def get_name(line): indent = " " else: indent = "" - lines.append(f" {indent}{name} = wrapdoc(Matrix.{name})(property(automethods.{name}))") if name == "name": - lines.append(" name = name.setter(automethods._set_name)") + lines.append( + " name = wrapdoc(Matrix.name)(property(automethods.name))" + ".setter(automethods._set_name)" + ) + else: + lines.append( + f" {indent}{name} = wrapdoc(Matrix.{name})(property(automethods.{name}))" + ) + if name == "ss": + lines.append(' else:\n ss = Matrix.__dict__["ss"] # raise if used') lines.append(" # These raise exceptions") for name in sorted(common_raises | vector_matrix_raises): lines.append(f" {name} = Matrix.{name}") diff --git a/graphblas/core/base.py b/graphblas/core/base.py index 6ad9b0175..a0be99f2d 100644 --- a/graphblas/core/base.py +++ b/graphblas/core/base.py @@ -157,8 +157,8 @@ def _expect_op_message( ) -def _expect_op(self, op, values, **kwargs): - if (message := _expect_op_message(self, op, values, **kwargs)) is not None: +def _expect_op(self, op, values, *, within, **kwargs): + if (message := _expect_op_message(self, op, values, within=within, **kwargs)) is not None: raise TypeError(message) from None @@ -184,6 +184,7 @@ def _check_mask(mask, output=None): class BaseType: + # pylint: disable=assigning-non-slot __slots__ = "gb_obj", "dtype", "name", "__weakref__" # Flag for operations which depend on scalar vs vector/matrix _is_scalar = False @@ -262,29 +263,21 @@ def __call__( ) def __or__(self, other): - if self._is_scalar: - return NotImplemented from .infix import _ewise_infix_expr return _ewise_infix_expr(self, other, method="ewise_add", within="__or__") def __ror__(self, other): - if self._is_scalar: - return NotImplemented from .infix import _ewise_infix_expr return _ewise_infix_expr(other, self, method="ewise_add", within="__ror__") def __and__(self, other): - if self._is_scalar: - return NotImplemented from .infix import _ewise_infix_expr return _ewise_infix_expr(self, other, method="ewise_mult", within="__and__") def __rand__(self, other): - if self._is_scalar: - return NotImplemented from .infix import _ewise_infix_expr return _ewise_infix_expr(other, self, method="ewise_mult", within="__rand__") @@ -330,10 +323,8 @@ def _update(self, expr, mask=None, accum=None, replace=False, input_mask=None, * if expr._is_scalar and self._is_scalar: # Extract element (s << v[1]) if accum is not None: - raise TypeError( - "Scalar accumulation with extract element" - "--such as `s(accum=accum) << v[0]`--is not supported" - ) + self(**opts) << self.ewise_add(expr, accum) + return expr.parent._extract_element( expr.resolved_indexes, self.dtype, @@ -355,10 +346,8 @@ def _update(self, expr, mask=None, accum=None, replace=False, input_mask=None, * # Simple assignment (w << v) if self._is_scalar: if accum is not None: - raise TypeError( - "Scalar update with accumulation--such as `s(accum=accum) << t`" - "--is not supported" - ) + self(**opts) << self.ewise_add(expr, accum) + return if opts: # Ignore opts for now descriptor_lookup(**opts) @@ -379,10 +368,8 @@ def _update(self, expr, mask=None, accum=None, replace=False, input_mask=None, * # s << (v @ v) expr = expr._to_expr() elif accum is not None: - raise TypeError( - "Scalar update with accumulation--such as `s(accum=accum) << t`" - "--is not supported" - ) + self(**opts) << self.ewise_add(expr, accum) + return else: if opts: # Ignore opts for now @@ -469,8 +456,7 @@ def _update(self, expr, mask=None, accum=None, replace=False, input_mask=None, * **opts, ) if self._is_scalar: - scalar_as_vector = expr.method_name == "inner" - if scalar_as_vector: + if expr._scalar_as_vector: fake_self = self._as_vector() cfunc_name = expr.cfunc_name args = [fake_self, mask, accum] @@ -498,7 +484,7 @@ def _update(self, expr, mask=None, accum=None, replace=False, input_mask=None, * # Make the GraphBLAS call call(cfunc_name, args) if self._is_scalar: - if scalar_as_vector: + if expr._scalar_as_vector: if self._is_cscalar or backend != "suitesparse": self.value = fake_self[0].new(is_cscalar=True, name="") # SS: this assumes GrB_Scalar was cast to Vector @@ -611,8 +597,6 @@ def _new(self, dtype, mask, name, is_cscalar=None, **opts): output(mask=mask, **opts).update(self) return output - dup = new - def _format_expr(self): args = self.args[:-2] if self.cfunc_name is None else self.args return self.expr_repr.format(*args, method_name=self.method_name, op=self.op) diff --git a/graphblas/core/expr.py b/graphblas/core/expr.py index 4c656840b..d6c4c89b1 100644 --- a/graphblas/core/expr.py +++ b/graphblas/core/expr.py @@ -270,13 +270,13 @@ def __init__(self, updater, resolved_indexes, *, is_submask): if updater.kwargs.get("input_mask") is not None: raise TypeError("`input_mask` argument may only be used for extract") - def update(self, obj): - # Occurs when user calls `C[index](...).update(obj)` or `C(...)[index].update(obj)` - self.updater._setitem(self.resolved_indexes, obj, is_submask=self.is_submask) + def update(self, expr): + # Occurs when user calls `C[index](...).update(expr)` or `C(...)[index].update(expr)` + self.updater._setitem(self.resolved_indexes, expr, is_submask=self.is_submask) - def __lshift__(self, obj): - # Occurs when user calls `C[index](...) << obj` or `C(...)[index] << obj` - self.updater._setitem(self.resolved_indexes, obj, is_submask=self.is_submask) + def __lshift__(self, expr): + # Occurs when user calls `C[index](...) << expr` or `C(...)[index] << expr` + self.updater._setitem(self.resolved_indexes, expr, is_submask=self.is_submask) def __eq__(self, other): raise TypeError(f"__eq__ not defined for objects of type {type(self)}.") @@ -301,15 +301,15 @@ def __call__(self, *args, **kwargs): is_submask = updater.kwargs.get("mask") is not None return Assigner(updater, self.resolved_indexes, is_submask=is_submask) - def __lshift__(self, obj, **opts): - # Occurs when user calls `C[index] << obj` - self.update(obj, **opts) + def __lshift__(self, expr, **opts): + # Occurs when user calls `C[index] << expr` + self.update(expr, **opts) - def update(self, obj, **opts): - # Occurs when user calls `C[index].update(obj)` + def update(self, expr, **opts): + # Occurs when user calls `C[index].update(expr)` if getattr(self.parent, "_is_transposed", False): raise TypeError("'TransposedMatrix' object does not support item assignment") - Updater(self.parent, opts=opts)._setitem(self.resolved_indexes, obj, is_submask=False) + Updater(self.parent, opts=opts)._setitem(self.resolved_indexes, expr, is_submask=False) def new(self, dtype=None, *, mask=None, input_mask=None, name=None, **opts): """ @@ -326,8 +326,6 @@ def new(self, dtype=None, *, mask=None, input_mask=None, name=None, **opts): delayed_extractor = self.parent._prep_for_extract(self.resolved_indexes) return delayed_extractor.new(dtype, mask=mask, name=name, **opts) - dup = new - def _extract_delayed(self): """Return an Expression object, treating this as an extract call""" return self.parent._prep_for_extract(self.resolved_indexes) @@ -502,8 +500,6 @@ def new(self, dtype=None, *, mask=None, name=None, **opts): expr = self._to_expr() return expr.new(dtype, mask=mask, name=name, **opts) - dup = new - def _to_expr(self): if self._value is None: # Rely on the default operator for `x @ y` diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index 67c4a83d5..464335d42 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -44,6 +44,109 @@ def _ewise_mult_to_expr(self): ) +class ScalarInfixExpr(InfixExprBase): + __slots__ = () + ndim = 0 + shape = () + output_type = ScalarExpression + _is_scalar = True + _is_cscalar = False + is_cscalar = False + + def new(self, dtype=None, *, is_cscalar=False, name=None, **opts): + expr = self._to_expr() + return expr.new(dtype, is_cscalar=is_cscalar, name=name, **opts) + + @wrapdoc(Scalar.dup) + def dup(self, dtype=None, *, clear=False, is_cscalar=False, name=None, **opts): + if dtype is None: + dtype = self.dtype + if clear: + return Scalar(dtype, is_cscalar=is_cscalar, name=name) + return self.new(dtype, is_cscalar=is_cscalar, name=name, **opts) + + @property + def is_grbscalar(self): + return not self.is_cscalar + + # Begin auto-generated code: Scalar + __and__ = wrapdoc(Scalar.__and__)(property(automethods.__and__)) + __array__ = wrapdoc(Scalar.__array__)(property(automethods.__array__)) + __bool__ = wrapdoc(Scalar.__bool__)(property(automethods.__bool__)) + __complex__ = wrapdoc(Scalar.__complex__)(property(automethods.__complex__)) + __eq__ = wrapdoc(Scalar.__eq__)(property(automethods.__eq__)) + __float__ = wrapdoc(Scalar.__float__)(property(automethods.__float__)) + __index__ = wrapdoc(Scalar.__index__)(property(automethods.__index__)) + __int__ = wrapdoc(Scalar.__int__)(property(automethods.__int__)) + __ne__ = wrapdoc(Scalar.__ne__)(property(automethods.__ne__)) + __or__ = wrapdoc(Scalar.__or__)(property(automethods.__or__)) + __rand__ = wrapdoc(Scalar.__rand__)(property(automethods.__rand__)) + __ror__ = wrapdoc(Scalar.__ror__)(property(automethods.__ror__)) + _as_matrix = wrapdoc(Scalar._as_matrix)(property(automethods._as_matrix)) + _as_vector = wrapdoc(Scalar._as_vector)(property(automethods._as_vector)) + _is_empty = wrapdoc(Scalar._is_empty)(property(automethods._is_empty)) + _name_html = wrapdoc(Scalar._name_html)(property(automethods._name_html)) + _nvals = wrapdoc(Scalar._nvals)(property(automethods._nvals)) + apply = wrapdoc(Scalar.apply)(property(automethods.apply)) + ewise_add = wrapdoc(Scalar.ewise_add)(property(automethods.ewise_add)) + ewise_mult = wrapdoc(Scalar.ewise_mult)(property(automethods.ewise_mult)) + ewise_union = wrapdoc(Scalar.ewise_union)(property(automethods.ewise_union)) + gb_obj = wrapdoc(Scalar.gb_obj)(property(automethods.gb_obj)) + get = wrapdoc(Scalar.get)(property(automethods.get)) + is_empty = wrapdoc(Scalar.is_empty)(property(automethods.is_empty)) + isclose = wrapdoc(Scalar.isclose)(property(automethods.isclose)) + isequal = wrapdoc(Scalar.isequal)(property(automethods.isequal)) + name = wrapdoc(Scalar.name)(property(automethods.name)).setter(automethods._set_name) + nvals = wrapdoc(Scalar.nvals)(property(automethods.nvals)) + select = wrapdoc(Scalar.select)(property(automethods.select)) + value = wrapdoc(Scalar.value)(property(automethods.value)) + wait = wrapdoc(Scalar.wait)(property(automethods.wait)) + # These raise exceptions + __matmul__ = Scalar.__matmul__ + __rmatmul__ = Scalar.__rmatmul__ + __iadd__ = automethods.__iadd__ + __iand__ = automethods.__iand__ + __ifloordiv__ = automethods.__ifloordiv__ + __imod__ = automethods.__imod__ + __imul__ = automethods.__imul__ + __ior__ = automethods.__ior__ + __ipow__ = automethods.__ipow__ + __isub__ = automethods.__isub__ + __itruediv__ = automethods.__itruediv__ + __ixor__ = automethods.__ixor__ + # End auto-generated code: Scalar + + +class ScalarEwiseAddExpr(ScalarInfixExpr): + __slots__ = () + method_name = "ewise_add" + _example_op = "plus" + _infix = "|" + + _to_expr = _ewise_add_to_expr + + +class ScalarEwiseMultExpr(ScalarInfixExpr): + __slots__ = () + method_name = "ewise_mult" + _example_op = "times" + _infix = "&" + + _to_expr = _ewise_mult_to_expr + + +class ScalarMatMulExpr(ScalarInfixExpr): + __slots__ = () + method_name = "inner" + _example_op = "plus_times" + _infix = "@" + + +utils._output_types[ScalarEwiseAddExpr] = Scalar +utils._output_types[ScalarEwiseMultExpr] = Scalar +utils._output_types[ScalarMatMulExpr] = Scalar + + class VectorInfixExpr(InfixExprBase): __slots__ = "_size" ndim = 1 @@ -61,6 +164,13 @@ def size(self): def shape(self): return (self._size,) + @wrapdoc(Vector.dup) + def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): + if clear: + expr = self._to_expr() + return expr.dup(dtype, clear=clear, name=name, **opts) + return self.new(dtype, mask=mask, name=name, **opts) + # Begin auto-generated code: Vector S = wrapdoc(Vector.S)(property(automethods.S)) V = wrapdoc(Vector.V)(property(automethods.V)) @@ -87,8 +197,7 @@ def shape(self): inner = wrapdoc(Vector.inner)(property(automethods.inner)) isclose = wrapdoc(Vector.isclose)(property(automethods.isclose)) isequal = wrapdoc(Vector.isequal)(property(automethods.isequal)) - name = wrapdoc(Vector.name)(property(automethods.name)) - name = name.setter(automethods._set_name) + name = wrapdoc(Vector.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Vector.nvals)(property(automethods.nvals)) outer = wrapdoc(Vector.outer)(property(automethods.outer)) reduce = wrapdoc(Vector.reduce)(property(automethods.reduce)) @@ -96,6 +205,8 @@ def shape(self): select = wrapdoc(Vector.select)(property(automethods.select)) if backend == "suitesparse": ss = wrapdoc(Vector.ss)(property(automethods.ss)) + else: + ss = Vector.__dict__["ss"] # raise if used to_coo = wrapdoc(Vector.to_coo)(property(automethods.to_coo)) to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict)) to_values = wrapdoc(Vector.to_values)(property(automethods.to_values)) @@ -180,6 +291,13 @@ def ncols(self): def shape(self): return (self._nrows, self._ncols) + @wrapdoc(Matrix.dup) + def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): + if clear: + expr = self._to_expr() + return expr.dup(dtype, clear=clear, name=name, **opts) + return self.new(dtype, mask=mask, name=name, **opts) + # Begin auto-generated code: Matrix S = wrapdoc(Matrix.S)(property(automethods.S)) T = wrapdoc(Matrix.T)(property(automethods.T)) @@ -209,8 +327,7 @@ def shape(self): kronecker = wrapdoc(Matrix.kronecker)(property(automethods.kronecker)) mxm = wrapdoc(Matrix.mxm)(property(automethods.mxm)) mxv = wrapdoc(Matrix.mxv)(property(automethods.mxv)) - name = wrapdoc(Matrix.name)(property(automethods.name)) - name = name.setter(automethods._set_name) + name = wrapdoc(Matrix.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Matrix.nvals)(property(automethods.nvals)) reduce_columnwise = wrapdoc(Matrix.reduce_columnwise)(property(automethods.reduce_columnwise)) reduce_rowwise = wrapdoc(Matrix.reduce_rowwise)(property(automethods.reduce_rowwise)) @@ -219,6 +336,8 @@ def shape(self): select = wrapdoc(Matrix.select)(property(automethods.select)) if backend == "suitesparse": ss = wrapdoc(Matrix.ss)(property(automethods.ss)) + else: + ss = Matrix.__dict__["ss"] # raise if used to_coo = wrapdoc(Matrix.to_coo)(property(automethods.to_coo)) to_csc = wrapdoc(Matrix.to_csc)(property(automethods.to_csc)) to_csr = wrapdoc(Matrix.to_csr)(property(automethods.to_csr)) @@ -279,75 +398,6 @@ def __init__(self, left, right, *, nrows, ncols): utils._output_types[MatrixMatMulExpr] = Matrix -class ScalarMatMulExpr(InfixExprBase): - __slots__ = () - method_name = "inner" - ndim = 0 - output_type = ScalarExpression - shape = () - _example_op = "plus_times" - _infix = "@" - _is_scalar = True - - def new(self, dtype=None, *, is_cscalar=None, name=None, **opts): - # Rely on the default operator for the method - expr = getattr(self.left, self.method_name)(self.right) - return expr.new(dtype, is_cscalar=is_cscalar, name=name, **opts) - - dup = new - - @property - def is_cscalar(self): - if self._value is not None: - return self._value._is_cscalar - return self._to_expr()._is_cscalar - - _is_cscalar = is_cscalar - - @property - def is_grbscalar(self): - if self._value is not None: - return not self._value._is_cscalar - return not self._to_expr()._is_cscalar - - # Begin auto-generated code: Scalar - __array__ = wrapdoc(Scalar.__array__)(property(automethods.__array__)) - __bool__ = wrapdoc(Scalar.__bool__)(property(automethods.__bool__)) - __complex__ = wrapdoc(Scalar.__complex__)(property(automethods.__complex__)) - __eq__ = wrapdoc(Scalar.__eq__)(property(automethods.__eq__)) - __float__ = wrapdoc(Scalar.__float__)(property(automethods.__float__)) - __index__ = wrapdoc(Scalar.__index__)(property(automethods.__index__)) - __int__ = wrapdoc(Scalar.__int__)(property(automethods.__int__)) - __invert__ = wrapdoc(Scalar.__invert__)(property(automethods.__invert__)) - __neg__ = wrapdoc(Scalar.__neg__)(property(automethods.__neg__)) - _as_matrix = wrapdoc(Scalar._as_matrix)(property(automethods._as_matrix)) - _as_vector = wrapdoc(Scalar._as_vector)(property(automethods._as_vector)) - _is_empty = wrapdoc(Scalar._is_empty)(property(automethods._is_empty)) - _name_html = wrapdoc(Scalar._name_html)(property(automethods._name_html)) - _nvals = wrapdoc(Scalar._nvals)(property(automethods._nvals)) - gb_obj = wrapdoc(Scalar.gb_obj)(property(automethods.gb_obj)) - get = wrapdoc(Scalar.get)(property(automethods.get)) - is_empty = wrapdoc(Scalar.is_empty)(property(automethods.is_empty)) - isclose = wrapdoc(Scalar.isclose)(property(automethods.isclose)) - isequal = wrapdoc(Scalar.isequal)(property(automethods.isequal)) - name = wrapdoc(Scalar.name)(property(automethods.name)) - name = name.setter(automethods._set_name) - nvals = wrapdoc(Scalar.nvals)(property(automethods.nvals)) - value = wrapdoc(Scalar.value)(property(automethods.value)) - wait = wrapdoc(Scalar.wait)(property(automethods.wait)) - # These raise exceptions - __and__ = Scalar.__and__ - __matmul__ = Scalar.__matmul__ - __or__ = Scalar.__or__ - __rand__ = Scalar.__rand__ - __rmatmul__ = Scalar.__rmatmul__ - __ror__ = Scalar.__ror__ - # End auto-generated code: Scalar - - -utils._output_types[ScalarMatMulExpr] = Scalar - - def _ewise_infix_expr(left, right, *, method, within): left_type = output_type(left) right_type = output_type(right) @@ -371,6 +421,18 @@ def _ewise_infix_expr(left, right, *, method, within): left._expect_type(right, tuple(types), within=within, argname="right") elif right_type in types: right._expect_type(left, tuple(types), within=within, argname="left") + elif left_type is Scalar: + # Create dummy expression to check compatibility of dimensions, etc. + expr = getattr(left, method)(right, binary.any) + if method == "ewise_mult": + return ScalarEwiseMultExpr(left, right) + return ScalarEwiseAddExpr(left, right) + elif right_type is Scalar: + # Create dummy expression to check compatibility of dimensions, etc. + expr = getattr(right, method)(left, binary.any) + if method == "ewise_mult": + return ScalarEwiseMultExpr(right, left) + return ScalarEwiseAddExpr(right, left) else: # pragma: no cover (sanity) raise TypeError(f"Bad types for ewise infix: {type(left).__name__}, {type(right).__name__}") diff --git a/graphblas/core/infixmethods.py b/graphblas/core/infixmethods.py index 3abc90c3f..d3b0dd5e4 100644 --- a/graphblas/core/infixmethods.py +++ b/graphblas/core/infixmethods.py @@ -1,7 +1,8 @@ from .. import binary, unary from ..dtypes import BOOL -from .infix import MatrixInfixExpr, VectorInfixExpr +from .infix import MatrixInfixExpr, ScalarInfixExpr, VectorInfixExpr from .matrix import Matrix, MatrixExpression, MatrixIndexExpr, TransposedMatrix +from .scalar import Scalar, ScalarExpression, ScalarIndexExpr from .utils import output_type from .vector import Vector, VectorExpression, VectorIndexExpr @@ -10,13 +11,28 @@ def call_op(self, other, op, *, outer=False, union=False): type1 = output_type(self) type2 = output_type(other) types = {Matrix, TransposedMatrix, Vector} - if type1 in types and type2 in types: - if outer: + if type1 in types: + if type2 in types: + if outer: + return self.ewise_add(other, op) + if union: + return self.ewise_union(other, op, False, False) + return self.ewise_mult(other, op) + return op(self, other) + if type2 in types: + return op(self, other) + # Scalars + if outer: + if type1 is Scalar: return self.ewise_add(other, op) - if union: + return other.ewise_add(self, op.commutes_to) + if union: + if type1 is Scalar: return self.ewise_union(other, op, False, False) + return other.ewise_union(self, op.commutes_to, False, False) + if type1 is Scalar: return self.ewise_mult(other, op) - return op(self, other) + return other.ewise_mult(self, op.commutes_to) def __divmod__(self, other): @@ -69,7 +85,8 @@ def __ixor__(self, other): if ( other_type is Vector and self.ndim == 2 - or other_type not in {Vector, Matrix, TransposedMatrix} + or not self._is_scalar + and other_type not in {Vector, Matrix, TransposedMatrix} ): self << __xor__(self, other) elif other.dtype != BOOL: @@ -87,7 +104,8 @@ def __ior__(self, other): if ( other_type is Vector and self.ndim == 2 - or other_type not in {Vector, Matrix, TransposedMatrix} + or not self._is_scalar + and other_type not in {Vector, Matrix, TransposedMatrix} ): expr = call_op(self, other, binary.lor, outer=True) if expr.dtype != BOOL: @@ -155,7 +173,8 @@ def __iadd__(self, other): if ( other_type is Vector and self.ndim == 2 - or other_type not in {Vector, Matrix, TransposedMatrix} + or not self._is_scalar + and other_type not in {Vector, Matrix, TransposedMatrix} ): self << __add__(self, other) else: @@ -282,6 +301,12 @@ def __itruediv__(self, other): "__xor__", ]: val = d[name] + if name not in {"__eq__", "__ne__"}: + setattr(Scalar, name, val) + if not name.startswith("__i") or name == "__invert__": + setattr(ScalarExpression, name, val) + setattr(ScalarInfixExpr, name, val) + setattr(ScalarIndexExpr, name, val) setattr(Vector, name, val) setattr(Matrix, name, val) if not name.startswith("__i") or name == "__invert__": @@ -363,7 +388,8 @@ def _main(): " if (\n" " other_type is Vector\n" " and self.ndim == 2\n" - " or other_type not in {Vector, Matrix, TransposedMatrix}\n" + " or not self._is_scalar\n" + " and other_type not in {Vector, Matrix, TransposedMatrix}\n" " ):\n" f" self << __{method}__(self, other)\n" " else:\n" @@ -383,6 +409,12 @@ def _main(): "d = globals()\n" f"for name in {methods}:\n" " val = d[name]\n" + " if name not in {'__eq__', '__ne__'}:\n" + " setattr(Scalar, name, val)\n" + " if not name.startswith('__i') or name == '__invert__':\n" + " setattr(ScalarExpression, name, val)\n" + " setattr(ScalarInfixExpr, name, val)\n" + " setattr(ScalarIndexExpr, name, val)\n" " setattr(Vector, name, val)\n" " setattr(Matrix, name, val)\n" " if not name.startswith('__i') or name == '__invert__':\n" diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index c5bf3b3f9..f8b4f5291 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -247,7 +247,8 @@ def __getitem__(self, keys): return ScalarIndexExpr(self, resolved_indexes) if len(shape) == 1: return VectorIndexExpr(self, resolved_indexes, *shape) - return MatrixIndexExpr(self, resolved_indexes, *shape) + nrows, ncols = shape + return MatrixIndexExpr(self, resolved_indexes, nrows, ncols) def __setitem__(self, keys, expr, **opts): """Assign values to a single element, row/column, or submatrix. @@ -1561,7 +1562,7 @@ def ewise_add(self, other, op=monoid.plus, *, require_monoid=None): Returns ------- - Matrix with a structure formed as the union of the input structures + MatrixExpression with a structure formed as the union of the input structures Examples -------- @@ -1648,7 +1649,7 @@ def ewise_mult(self, other, op=binary.times): Returns ------- - Matrix with a structure formed as the intersection of the input structures + MatrixExpression with a structure formed as the intersection of the input structures Examples -------- @@ -1717,7 +1718,7 @@ def ewise_union(self, other, op, left_default, right_default): Returns ------- - Matrix with a structure formed as the union of the input structures + MatrixExpression with a structure formed as the union of the input structures Examples -------- @@ -1831,7 +1832,7 @@ def mxv(self, other, op=semiring.plus_times): Returns ------- - :class:`~graphblas.Vector` + VectorExpression Examples -------- @@ -1875,7 +1876,7 @@ def mxm(self, other, op=semiring.plus_times): Returns ------- - Matrix + MatrixExpression Examples -------- @@ -1923,7 +1924,7 @@ def kronecker(self, other, op=binary.times): Returns ------- - Matrix + MatrixExpression Examples -------- @@ -1976,7 +1977,7 @@ def apply(self, op, right=None, *, left=None): Returns ------- - Matrix + MatrixExpression Examples -------- @@ -2125,7 +2126,7 @@ def select(self, op, thunk=None): Returns ------- - Matrix + MatrixExpression Examples -------- @@ -2223,7 +2224,7 @@ def reduce_rowwise(self, op=monoid.plus): Returns ------- - Vector + VectorExpression Examples -------- @@ -2261,7 +2262,7 @@ def reduce_columnwise(self, op=monoid.plus): Returns ------- - :class:`~graphblas.Vector` + VectorExpression Examples -------- @@ -2303,7 +2304,7 @@ def reduce_scalar(self, op=monoid.plus, *, allow_empty=True): Returns ------- - :class:`~graphblas.Scalar` + ScalarExpression Examples -------- @@ -2364,7 +2365,7 @@ def reposition(self, row_offset, column_offset, *, nrows=None, ncols=None): Returns ------- - Matrix + MatrixExpression Examples -------- @@ -2684,7 +2685,9 @@ def _prep_for_assign(self, resolved_indexes, value, mask, is_submask, replace, o if is_submask: # C[I, J](M) << A expr_repr = ( - "[[{2._expr_name} rows], [{4._expr_name} cols]]" f"({mask.name})" " << {0.name}" + "[[{2._expr_name} rows], [{4._expr_name} cols]]" + f"({mask.name})" # fmt: skip + " << {0.name}" ) if backend == "suitesparse": cfunc_name = "GxB_Matrix_subassign" @@ -2740,7 +2743,7 @@ def _prep_for_assign(self, resolved_indexes, value, mask, is_submask, replace, o vals = Vector.from_coo( np.arange(shape[0]), values, dtype, size=shape[0] ) - except Exception: + except Exception: # pragma: no cover (safety) vals = None else: if dtype.np_type.subdtype is not None: @@ -3060,6 +3063,14 @@ def nrows(self): def shape(self): return (self._nrows, self._ncols) + @wrapdoc(Matrix.dup) + def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): + if dtype is None: + dtype = self.dtype + if clear: + return Matrix(dtype, self._nrows, self._ncols, name=name) + return self._new(dtype, mask, name, **opts) + # Begin auto-generated code: Matrix _get_value = automethods._get_value S = wrapdoc(Matrix.S)(property(automethods.S)) @@ -3090,8 +3101,7 @@ def shape(self): kronecker = wrapdoc(Matrix.kronecker)(property(automethods.kronecker)) mxm = wrapdoc(Matrix.mxm)(property(automethods.mxm)) mxv = wrapdoc(Matrix.mxv)(property(automethods.mxv)) - name = wrapdoc(Matrix.name)(property(automethods.name)) - name = name.setter(automethods._set_name) + name = wrapdoc(Matrix.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Matrix.nvals)(property(automethods.nvals)) reduce_columnwise = wrapdoc(Matrix.reduce_columnwise)(property(automethods.reduce_columnwise)) reduce_rowwise = wrapdoc(Matrix.reduce_rowwise)(property(automethods.reduce_rowwise)) @@ -3100,6 +3110,8 @@ def shape(self): select = wrapdoc(Matrix.select)(property(automethods.select)) if backend == "suitesparse": ss = wrapdoc(Matrix.ss)(property(automethods.ss)) + else: + ss = Matrix.__dict__["ss"] # raise if used to_coo = wrapdoc(Matrix.to_coo)(property(automethods.to_coo)) to_csc = wrapdoc(Matrix.to_csc)(property(automethods.to_csc)) to_csr = wrapdoc(Matrix.to_csr)(property(automethods.to_csr)) @@ -3149,6 +3161,14 @@ def nrows(self): def shape(self): return (self._nrows, self._ncols) + @wrapdoc(Matrix.dup) + def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): + if clear: + if dtype is None: + dtype = self.dtype + return self.output_type(dtype, *self.shape, name=name) + return self.new(dtype, mask=mask, name=name, **opts) + # Begin auto-generated code: Matrix _get_value = automethods._get_value S = wrapdoc(Matrix.S)(property(automethods.S)) @@ -3179,8 +3199,7 @@ def shape(self): kronecker = wrapdoc(Matrix.kronecker)(property(automethods.kronecker)) mxm = wrapdoc(Matrix.mxm)(property(automethods.mxm)) mxv = wrapdoc(Matrix.mxv)(property(automethods.mxv)) - name = wrapdoc(Matrix.name)(property(automethods.name)) - name = name.setter(automethods._set_name) + name = wrapdoc(Matrix.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Matrix.nvals)(property(automethods.nvals)) reduce_columnwise = wrapdoc(Matrix.reduce_columnwise)(property(automethods.reduce_columnwise)) reduce_rowwise = wrapdoc(Matrix.reduce_rowwise)(property(automethods.reduce_rowwise)) @@ -3189,6 +3208,8 @@ def shape(self): select = wrapdoc(Matrix.select)(property(automethods.select)) if backend == "suitesparse": ss = wrapdoc(Matrix.ss)(property(automethods.ss)) + else: + ss = Matrix.__dict__["ss"] # raise if used to_coo = wrapdoc(Matrix.to_coo)(property(automethods.to_coo)) to_csc = wrapdoc(Matrix.to_csc)(property(automethods.to_csc)) to_csr = wrapdoc(Matrix.to_csr)(property(automethods.to_csr)) @@ -3243,6 +3264,7 @@ def new(self, dtype=None, *, mask=None, name=None, **opts): output(mask=mask, **opts) << self return output + @wrapdoc(Matrix.dup) def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): if dtype is None: dtype = self.dtype @@ -3300,11 +3322,11 @@ def to_csr(self, dtype=None): def to_csc(self, dtype=None): return self._matrix.to_csr(dtype) - @wrapdoc(Matrix.to_csr) + @wrapdoc(Matrix.to_dcsr) def to_dcsr(self, dtype=None): return self._matrix.to_dcsc(dtype) - @wrapdoc(Matrix.to_csc) + @wrapdoc(Matrix.to_dcsc) def to_dcsc(self, dtype=None): return self._matrix.to_dcsr(dtype) diff --git a/graphblas/core/operator.py b/graphblas/core/operator.py index c9274f0fb..77b5c7023 100644 --- a/graphblas/core/operator.py +++ b/graphblas/core/operator.py @@ -89,12 +89,29 @@ def _call_op(op, left, right=None, thunk=None, **kwargs): from .matrix import Matrix, TransposedMatrix from .vector import Vector - if output_type(left) in {Vector, Matrix, TransposedMatrix}: + if (left_type := output_type(left)) in {Vector, Matrix, TransposedMatrix}: if thunk is not None: return left.select(op, thunk=thunk, **kwargs) return left.apply(op, right=right, **kwargs) - if output_type(right) in {Vector, Matrix, TransposedMatrix}: + if (right_type := output_type(right)) in {Vector, Matrix, TransposedMatrix}: return right.apply(op, left=left, **kwargs) + + from .scalar import Scalar, _as_scalar + + if left_type is Scalar: + if thunk is not None: + return left.select(op, thunk=thunk, **kwargs) + return left.apply(op, right=right, **kwargs) + if right_type is Scalar: + return right.apply(op, left=left, **kwargs) + try: + left_scalar = _as_scalar(left, is_cscalar=False) + except Exception: + pass + else: + if thunk is not None: + return left_scalar.select(op, thunk=thunk, **kwargs) + return left_scalar.apply(op, right=right, **kwargs) raise TypeError( f"Bad types when calling {op!r}. Got types: {type(left)}, {type(right)}.\n" "Expected an infix expression or an apply with a Vector or Matrix and a scalar:\n" @@ -183,11 +200,21 @@ def __call__(self, val): from .matrix import Matrix, TransposedMatrix from .vector import Vector - if output_type(val) in {Vector, Matrix, TransposedMatrix}: + if (typ := output_type(val)) in {Vector, Matrix, TransposedMatrix}: return val.apply(self) + from .scalar import Scalar, _as_scalar + + if typ is Scalar: + return val.apply(self) + try: + scalar = _as_scalar(val, is_cscalar=False) + except Exception: + pass + else: + return scalar.apply(self) raise TypeError( f"Bad type when calling {self!r}.\n" - " - Expected type: Vector, Matrix, TransposedMatrix.\n" + " - Expected type: Scalar, Vector, Matrix, TransposedMatrix.\n" f" - Got: {type(val)}.\n" "Calling a UnaryOp is syntactic sugar for calling apply. " f"For example, `A.apply({self!r})` is the same as `{self!r}(A)`." @@ -621,7 +648,7 @@ def _call(self, *args, **kwargs): binop._monoid = binop # temporary! try: # If this call is successful, then it will set `binop._monoid` - self._monoid(*args, **kwargs) + self._monoid(*args, **kwargs) # pylint: disable=not-callable except Exception: binop._monoid = None # assert binop._monoid is not binop @@ -1268,10 +1295,8 @@ class IndexUnaryOp(OpBase): re.compile("^GxB_(VALUEEQ|VALUENE)_(FC32|FC64)$"), ], } - # fmt: off _positional = {"tril", "triu", "diag", "offdiag", "colle", "colgt", "rowle", "rowgt", - "rowindex", "colindex"} - # fmt: on + "rowindex", "colindex"} # fmt: skip @classmethod def _build(cls, name, func, *, is_udt=False, anonymous=False): @@ -1780,7 +1805,7 @@ def _get_udt_wrapper(numba_func, return_type, dtype, dtype2=None, *, include_ind f"{zarray}{xarray}{yarray}" f" {zname} = {BL}numba_func({xname}{rcidx}{yname}){BR}\n" ) - exec(text, d) + exec(text, d) # pylint: disable=exec-used return d["wrapper"], wrapper_sig @@ -2981,9 +3006,8 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala if dtype2 is None: return op[dtype] # Handle special cases such as first and second (may have UDTs) - if op._custom_dtype is not None: - if (rv := op._custom_dtype(op, dtype, dtype2)) is not None: - return rv + if op._custom_dtype is not None and (rv := op._custom_dtype(op, dtype, dtype2)) is not None: + return rv # Generic case: try to unify the two dtypes try: return op[ diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index c1bf996b4..1e6420f16 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -3,9 +3,9 @@ import numpy as np -from .. import backend, config +from .. import backend, binary, config, monoid from ..binary import isclose -from ..dtypes import _INDEX, BOOL, FP64, lookup_dtype +from ..dtypes import _INDEX, FP64, lookup_dtype, unify from ..exceptions import EmptyObject, check_status from . import automethods, ffi, lib, utils from .base import BaseExpression, BaseType, call @@ -27,6 +27,17 @@ def _scalar_index(name): return self +def _s_union_s(updater, left, right, left_default, right_default, op, dtype): + opts = updater.opts + new_left = left.dup(dtype, clear=True) + new_left(**opts) << binary.second(right, left_default) + new_left(**opts) << binary.first(left | new_left) + new_right = right.dup(dtype, clear=True) + new_right(**opts) << binary.second(left, right_default) + new_right(**opts) << binary.first(right | new_right) + updater << op(new_left & new_right) + + class Scalar(BaseType): """Create a new GraphBLAS Sparse Scalar. @@ -121,6 +132,9 @@ def __eq__(self, other): """ return self.isequal(other) + def __ne__(self, other): + return not self.isequal(other) + def __bool__(self): """Truthiness check. @@ -141,28 +155,6 @@ def __int__(self): def __complex__(self): return complex(self.value) - def __neg__(self): - dtype = self.dtype - if dtype.name[0] == "U" or dtype == BOOL or dtype._is_udt: - raise TypeError(f"The negative operator, `-`, is not supported for {dtype.name} dtype") - rv = Scalar(dtype, is_cscalar=self._is_cscalar, name=f"-{self.name or 's_temp'}") - if self._is_empty: - return rv - rv.value = -self.value - return rv - - def __invert__(self): - if self.dtype != BOOL: - raise TypeError( - f"The invert operator, `~`, is not supported for {self.dtype.name} dtype. " - "It is only supported for BOOL dtype." - ) - rv = Scalar(BOOL, is_cscalar=self._is_cscalar, name=f"~{self.name or 's_temp'}") - if self._is_empty: - return rv - rv.value = not self.value - return rv - __index__ = __int__ def __array__(self, dtype=None): @@ -192,7 +184,7 @@ def isequal(self, other, *, check_dtype=False): Returns ------- - Bool + bool See Also -------- @@ -531,7 +523,7 @@ def from_value(cls, value, dtype=None, *, is_cscalar=False, name=None): if typ is Scalar and type(value) is not Scalar: if config.get("autocompute"): return value.new(dtype=dtype, is_cscalar=is_cscalar, name=name) - cls._expect_type( + cls()._expect_type( value, Scalar, within="from_value", @@ -590,17 +582,309 @@ def _as_matrix(self, *, name=None): rv[0, 0] = self return rv + ######################################################### + # Delayed methods + # + # These return a delayed expression object which must be passed + # to update to trigger a call to GraphBLAS + ######################################################### + + def ewise_add(self, other, op=monoid.plus): + """Perform element-wise computation on the union of sparse values, similar to how + one expects addition to work for sparse data. + + See the `Element-wise Union <../user_guide/operations.html#element-wise-union>`__ + section in the User Guide for more details, especially about the difference between + ewise_add and :meth:`ewise_union`. + + Parameters + ---------- + other : Scalar + The other scalar in the computation; Python scalars also accepted + op : :class:`~graphblas.core.operator.Monoid` or :class:`~graphblas.core.operator.BinaryOp` + Operator to use on intersecting values + + Returns + ------- + ScalarExpression that will be non-empty if any of the inputs is non-empty + + Examples + -------- + + .. code-block:: python + + # Method syntax + c << a.ewise_add(b, op=monoid.max) + + # Functional syntax + c << monoid.max(a | b) + """ + method_name = "ewise_add" + if type(other) is not Scalar: + dtype = self.dtype if self.dtype._is_udt else None + try: + other = Scalar.from_value(other, dtype, is_cscalar=False, name="") + except TypeError: + other = self._expect_type( + other, + Scalar, + within=method_name, + keyword_name="other", + extra_message="Literal scalars also accepted.", + op=op, + ) + op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + return ScalarExpression( + method_name, + f"GrB_Vector_eWiseAdd_{op.opclass}", + [self._as_vector(), other._as_vector()], + op=op, + is_cscalar=False, + scalar_as_vector=True, + ) + + def ewise_mult(self, other, op=binary.times): + """Perform element-wise computation on the intersection of sparse values, + similar to how one expects multiplication to work for sparse data. + + See the + `Element-wise Intersection <../user_guide/operations.html#element-wise-intersection>`__ + section in the User Guide for more details. + + Parameters + ---------- + other : Scalar + The other scalar in the computation; Python scalars also accepted + op : :class:`~graphblas.core.operator.Monoid` or :class:`~graphblas.core.operator.BinaryOp` + Operator to use on intersecting values + + Returns + ------- + ScalarExpression that will be empty if any of the inputs is empty + + Examples + -------- + + .. code-block:: python + + # Method syntax + c << a.ewise_mult(b, op=binary.gt) + + # Functional syntax + c << binary.gt(a & b) + """ + method_name = "ewise_mult" + if type(other) is not Scalar: + dtype = self.dtype if self.dtype._is_udt else None + try: + other = Scalar.from_value(other, dtype, is_cscalar=False, name="") + except TypeError: + other = self._expect_type( + other, + Scalar, + within=method_name, + keyword_name="other", + extra_message="Literal scalars also accepted.", + op=op, + ) + op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + return ScalarExpression( + method_name, + f"GrB_Vector_eWiseMult_{op.opclass}", + [self._as_vector(), other._as_vector()], + op=op, + is_cscalar=False, + scalar_as_vector=True, + ) + + def ewise_union(self, other, op, left_default, right_default): + """Perform element-wise computation on the union of sparse values, + similar to how one expects subtraction to work for sparse data. + + See the `Element-wise Union <../user_guide/operations.html#element-wise-union>`__ + section in the User Guide for more details, especially about the difference between + ewise_union and :meth:`ewise_add`. + + Parameters + ---------- + other : Scalar + The other scalar in the computation; Python scalars also accepted + op : :class:`~graphblas.core.operator.Monoid` or :class:`~graphblas.core.operator.BinaryOp` + Operator to use + left_default : + Scalar value to use when the index on the left is missing + right_default : + Scalar value to use when the index on the right is missing + + Returns + ------- + ScalarExpression with a structure formed as the union of the input structures + + Examples + -------- + + .. code-block:: python + + # Method syntax + c << a.ewise_union(b, op=binary.div, left_default=1, right_default=1) + + # Functional syntax + c << binary.div(a | b, left_default=1, right_default=1) + """ + method_name = "ewise_union" + dtype = self.dtype if self.dtype._is_udt else None + if type(other) is not Scalar: + try: + other = Scalar.from_value(other, dtype, is_cscalar=False, name="") + except TypeError: + other = self._expect_type( + other, + Scalar, + within=method_name, + keyword_name="other", + extra_message="Literal scalars also accepted.", + op=op, + ) + if type(left_default) is not Scalar: + try: + left = Scalar.from_value( + left_default, dtype, is_cscalar=False, name="" # pragma: is_grbscalar + ) + except TypeError: + left = self._expect_type( + left_default, + Scalar, + within=method_name, + keyword_name="left_default", + extra_message="Literal scalars also accepted.", + op=op, + ) + else: + left = _as_scalar(left_default, dtype, is_cscalar=False) # pragma: is_grbscalar + if type(right_default) is not Scalar: + try: + right = Scalar.from_value( + right_default, dtype, is_cscalar=False, name="" # pragma: is_grbscalar + ) + except TypeError: + right = self._expect_type( + right_default, + Scalar, + within=method_name, + keyword_name="right_default", + extra_message="Literal scalars also accepted.", + op=op, + ) + 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") + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if op.opclass == "Monoid": + op = op.binaryop + expr_repr = "{0.name}.{method_name}({2.name}, {op}, {1._expr_name}, {3._expr_name})" + if backend == "suitesparse": + expr = ScalarExpression( + method_name, + "GxB_Vector_eWiseUnion", + [self._as_vector(), left, other._as_vector(), right], + op=op, + expr_repr=expr_repr, + is_cscalar=False, + 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)], + op=op, + expr_repr=expr_repr, + is_cscalar=False, + scalar_as_vector=True, + ) + return expr + + def apply(self, op, right=None, *, left=None): + """Create a new Scalar by applying ``op`` + + See the `Apply <../user_guide/operations.html#apply>`__ + section in the User Guide for more details. + + Common usage is to pass a :class:`~graphblas.core.operator.UnaryOp`, + in which case ``right`` and ``left`` may not be defined. + + A :class:`~graphblas.core.operator.BinaryOp` can also be used, in + which case a scalar must be passed as ``left`` or ``right``. + + An :class:`~graphblas.core.operator.IndexUnaryOp` can also be used + with the thunk passed in as ``right``. + + Parameters + ---------- + op : UnaryOp or BinaryOp or IndexUnaryOp + Operator to apply + right : + Scalar used with BinaryOp or IndexUnaryOp + left : + Scalar used with BinaryOp + + Returns + ------- + ScalarExpression + + Examples + -------- + + .. code-block:: python + + # Method syntax + b << a.apply(op.abs) + + # Functional syntax + b << op.abs(a) + """ + expr = self._as_vector().apply(op, right, left=left) + return ScalarExpression( + expr.method_name, + expr.cfunc_name, + expr.args, + op=expr.op, + dtype=expr.dtype, + expr_repr=expr.expr_repr, + is_cscalar=False, + scalar_as_vector=True, + ) + + def select(self, op, thunk=None): + expr = self._as_vector().select(op, thunk) + return ScalarExpression( + expr.method_name, + expr.cfunc_name, + expr.args, + op=expr.op, + dtype=expr.dtype, + expr_repr=expr.expr_repr, + is_cscalar=False, + scalar_as_vector=True, + ) + class ScalarExpression(BaseExpression): - __slots__ = "_is_cscalar" + __slots__ = "_is_cscalar", "_scalar_as_vector" output_type = Scalar ndim = 0 shape = () _is_scalar = True - def __init__(self, *args, is_cscalar, **kwargs): + def __init__(self, *args, is_cscalar, scalar_as_vector=False, **kwargs): super().__init__(*args, **kwargs) self._is_cscalar = is_cscalar + self._scalar_as_vector = scalar_as_vector def construct_output(self, dtype=None, *, is_cscalar=None, name=None): if dtype is None: @@ -614,7 +898,15 @@ def new(self, dtype=None, *, is_cscalar=None, name=None, **opts): is_cscalar = self._is_cscalar return super()._new(dtype, None, name, is_cscalar=is_cscalar, **opts) - dup = new + @wrapdoc(Scalar.dup) + def dup(self, dtype=None, *, clear=False, is_cscalar=None, name=None, **opts): + if dtype is None: + dtype = self.dtype + if is_cscalar is None: + is_cscalar = self._is_cscalar + if clear: + return Scalar(dtype, is_cscalar=is_cscalar, name=name) + return self.new(dtype, is_cscalar=is_cscalar, name=name, **opts) def __repr__(self): from .formatting import format_scalar_expression @@ -631,6 +923,7 @@ def _repr_html_(self): # Begin auto-generated code: Scalar _get_value = automethods._get_value + __and__ = wrapdoc(Scalar.__and__)(property(automethods.__and__)) __array__ = wrapdoc(Scalar.__array__)(property(automethods.__array__)) __bool__ = wrapdoc(Scalar.__bool__)(property(automethods.__bool__)) __complex__ = wrapdoc(Scalar.__complex__)(property(automethods.__complex__)) @@ -638,30 +931,42 @@ def _repr_html_(self): __float__ = wrapdoc(Scalar.__float__)(property(automethods.__float__)) __index__ = wrapdoc(Scalar.__index__)(property(automethods.__index__)) __int__ = wrapdoc(Scalar.__int__)(property(automethods.__int__)) - __invert__ = wrapdoc(Scalar.__invert__)(property(automethods.__invert__)) - __neg__ = wrapdoc(Scalar.__neg__)(property(automethods.__neg__)) + __ne__ = wrapdoc(Scalar.__ne__)(property(automethods.__ne__)) + __or__ = wrapdoc(Scalar.__or__)(property(automethods.__or__)) + __rand__ = wrapdoc(Scalar.__rand__)(property(automethods.__rand__)) + __ror__ = wrapdoc(Scalar.__ror__)(property(automethods.__ror__)) _as_matrix = wrapdoc(Scalar._as_matrix)(property(automethods._as_matrix)) _as_vector = wrapdoc(Scalar._as_vector)(property(automethods._as_vector)) _is_empty = wrapdoc(Scalar._is_empty)(property(automethods._is_empty)) _name_html = wrapdoc(Scalar._name_html)(property(automethods._name_html)) _nvals = wrapdoc(Scalar._nvals)(property(automethods._nvals)) + apply = wrapdoc(Scalar.apply)(property(automethods.apply)) + ewise_add = wrapdoc(Scalar.ewise_add)(property(automethods.ewise_add)) + ewise_mult = wrapdoc(Scalar.ewise_mult)(property(automethods.ewise_mult)) + ewise_union = wrapdoc(Scalar.ewise_union)(property(automethods.ewise_union)) gb_obj = wrapdoc(Scalar.gb_obj)(property(automethods.gb_obj)) get = wrapdoc(Scalar.get)(property(automethods.get)) is_empty = wrapdoc(Scalar.is_empty)(property(automethods.is_empty)) isclose = wrapdoc(Scalar.isclose)(property(automethods.isclose)) isequal = wrapdoc(Scalar.isequal)(property(automethods.isequal)) - name = wrapdoc(Scalar.name)(property(automethods.name)) - name = name.setter(automethods._set_name) + name = wrapdoc(Scalar.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Scalar.nvals)(property(automethods.nvals)) + select = wrapdoc(Scalar.select)(property(automethods.select)) value = wrapdoc(Scalar.value)(property(automethods.value)) wait = wrapdoc(Scalar.wait)(property(automethods.wait)) # These raise exceptions - __and__ = Scalar.__and__ __matmul__ = Scalar.__matmul__ - __or__ = Scalar.__or__ - __rand__ = Scalar.__rand__ __rmatmul__ = Scalar.__rmatmul__ - __ror__ = Scalar.__ror__ + __iadd__ = automethods.__iadd__ + __iand__ = automethods.__iand__ + __ifloordiv__ = automethods.__ifloordiv__ + __imod__ = automethods.__imod__ + __imul__ = automethods.__imul__ + __ior__ = automethods.__ior__ + __ipow__ = automethods.__ipow__ + __isub__ = automethods.__isub__ + __itruediv__ = automethods.__itruediv__ + __ixor__ = automethods.__ixor__ # End auto-generated code: Scalar @@ -679,18 +984,20 @@ def new(self, dtype=None, *, is_cscalar=None, name=None, **opts): self.resolved_indexes, dtype, opts, is_cscalar=is_cscalar, name=name ) - dup = new - - @property - def is_cscalar(self): - return self._is_cscalar + @wrapdoc(Scalar.dup) + def dup(self, dtype=None, *, clear=False, is_cscalar=False, name=None, **opts): + if dtype is None: + dtype = self.dtype + if clear: + return Scalar(dtype, is_cscalar=is_cscalar, name=name) + return self.new(dtype, is_cscalar=is_cscalar, name=name, **opts) - @property - def is_grbscalar(self): - return not self._is_cscalar + is_cscalar = Scalar.is_cscalar + is_grbscalar = Scalar.is_grbscalar # Begin auto-generated code: Scalar _get_value = automethods._get_value + __and__ = wrapdoc(Scalar.__and__)(property(automethods.__and__)) __array__ = wrapdoc(Scalar.__array__)(property(automethods.__array__)) __bool__ = wrapdoc(Scalar.__bool__)(property(automethods.__bool__)) __complex__ = wrapdoc(Scalar.__complex__)(property(automethods.__complex__)) @@ -698,30 +1005,42 @@ def is_grbscalar(self): __float__ = wrapdoc(Scalar.__float__)(property(automethods.__float__)) __index__ = wrapdoc(Scalar.__index__)(property(automethods.__index__)) __int__ = wrapdoc(Scalar.__int__)(property(automethods.__int__)) - __invert__ = wrapdoc(Scalar.__invert__)(property(automethods.__invert__)) - __neg__ = wrapdoc(Scalar.__neg__)(property(automethods.__neg__)) + __ne__ = wrapdoc(Scalar.__ne__)(property(automethods.__ne__)) + __or__ = wrapdoc(Scalar.__or__)(property(automethods.__or__)) + __rand__ = wrapdoc(Scalar.__rand__)(property(automethods.__rand__)) + __ror__ = wrapdoc(Scalar.__ror__)(property(automethods.__ror__)) _as_matrix = wrapdoc(Scalar._as_matrix)(property(automethods._as_matrix)) _as_vector = wrapdoc(Scalar._as_vector)(property(automethods._as_vector)) _is_empty = wrapdoc(Scalar._is_empty)(property(automethods._is_empty)) _name_html = wrapdoc(Scalar._name_html)(property(automethods._name_html)) _nvals = wrapdoc(Scalar._nvals)(property(automethods._nvals)) + apply = wrapdoc(Scalar.apply)(property(automethods.apply)) + ewise_add = wrapdoc(Scalar.ewise_add)(property(automethods.ewise_add)) + ewise_mult = wrapdoc(Scalar.ewise_mult)(property(automethods.ewise_mult)) + ewise_union = wrapdoc(Scalar.ewise_union)(property(automethods.ewise_union)) gb_obj = wrapdoc(Scalar.gb_obj)(property(automethods.gb_obj)) get = wrapdoc(Scalar.get)(property(automethods.get)) is_empty = wrapdoc(Scalar.is_empty)(property(automethods.is_empty)) isclose = wrapdoc(Scalar.isclose)(property(automethods.isclose)) isequal = wrapdoc(Scalar.isequal)(property(automethods.isequal)) - name = wrapdoc(Scalar.name)(property(automethods.name)) - name = name.setter(automethods._set_name) + name = wrapdoc(Scalar.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Scalar.nvals)(property(automethods.nvals)) + select = wrapdoc(Scalar.select)(property(automethods.select)) value = wrapdoc(Scalar.value)(property(automethods.value)) wait = wrapdoc(Scalar.wait)(property(automethods.wait)) # These raise exceptions - __and__ = Scalar.__and__ __matmul__ = Scalar.__matmul__ - __or__ = Scalar.__or__ - __rand__ = Scalar.__rand__ __rmatmul__ = Scalar.__rmatmul__ - __ror__ = Scalar.__ror__ + __iadd__ = automethods.__iadd__ + __iand__ = automethods.__iand__ + __ifloordiv__ = automethods.__ifloordiv__ + __imod__ = automethods.__imod__ + __imul__ = automethods.__imul__ + __ior__ = automethods.__ior__ + __ipow__ = automethods.__ipow__ + __isub__ = automethods.__isub__ + __itruediv__ = automethods.__itruediv__ + __ixor__ = automethods.__ixor__ # End auto-generated code: Scalar @@ -751,3 +1070,6 @@ def _dict_to_record(np_type, d): utils._output_types[Scalar] = Scalar utils._output_types[ScalarIndexExpr] = Scalar utils._output_types[ScalarExpression] = Scalar + +# Import vector to import matrix to import infix to import infixmethods, which has side effects +from . import vector # noqa: E402, F401 isort:skip diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index 783248043..f89c61d30 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -4180,7 +4180,8 @@ def _compactify(self, how, reverse, asindex, nkey, nval, fmt, indices_name, name values = compact_values(orig_indptr, new_indptr, values) if reverse: values = reverse_values(new_indptr, values) - newinfo = dict(info, indptr=new_indptr, values=values) + newinfo = dict(info, indptr=new_indptr) + newinfo["values"] = values newinfo[indices_name] = new_indices newinfo[nkey] = nval return self.import_any( diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py index 3cb739140..f03fe9ccd 100644 --- a/graphblas/core/utils.py +++ b/graphblas/core/utils.py @@ -254,7 +254,7 @@ def __get__(self, obj, type=None): return self.member_property.__get__(obj, type) @property - def __set__(self): + def __set__(self): # pylint: disable=unexpected-special-method-signature return self.member_property.__set__ diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index aafd78e89..59c910900 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -773,7 +773,7 @@ def ewise_add(self, other, op=monoid.plus, *, require_monoid=None): Returns ------- - Vector with a structure formed as the union of the input structures + VectorExpression with a structure formed as the union of the input structures Examples -------- @@ -857,7 +857,7 @@ def ewise_mult(self, other, op=binary.times): Returns ------- - Vector with a structure formed as the intersection of the input structures + VectorExpression with a structure formed as the intersection of the input structures Examples -------- @@ -907,7 +907,7 @@ def ewise_mult(self, other, op=binary.times): def ewise_union(self, other, op, left_default, right_default): """Perform element-wise computation on the union of sparse values, - similar to how one expects subtraction to work for sparse matrices. + similar to how one expects subtraction to work for sparse vectors. See the `Element-wise Union <../user_guide/operations.html#element-wise-union>`__ section in the User Guide for more details, especially about the difference between @@ -926,7 +926,7 @@ def ewise_union(self, other, op, left_default, right_default): Returns ------- - Vector with a structure formed as the union of the input structures + VectorExpression with a structure formed as the union of the input structures Examples -------- @@ -1041,7 +1041,7 @@ def vxm(self, other, op=semiring.plus_times): Returns ------- - Vector + VectorExpression Examples -------- @@ -1100,7 +1100,7 @@ def apply(self, op, right=None, *, left=None): Returns ------- - Vector + VectorExpression Examples -------- @@ -1246,7 +1246,7 @@ def select(self, op, thunk=None): Returns ------- - Vector + VectorExpression Examples -------- @@ -1344,7 +1344,7 @@ def reduce(self, op=monoid.plus, *, allow_empty=True): Returns ------- - Scalar + ScalarExpression Examples -------- @@ -1389,7 +1389,7 @@ def inner(self, other, op=semiring.plus_times): Returns ------- - Scalar + ScalarExpression Examples -------- @@ -1416,6 +1416,7 @@ def inner(self, other, op=semiring.plus_times): [self, other._as_matrix()], op=op, is_cscalar=False, + scalar_as_vector=True, ) if self._size != other._size: expr.new(name="") # incompatible shape; raise now @@ -1433,7 +1434,7 @@ def outer(self, other, op=binary.times): Returns ------- - Matrix + MatrixExpression Examples -------- @@ -1484,7 +1485,7 @@ def reposition(self, offset, *, size=None): Returns ------- - Vector + VectorExpression Examples -------- @@ -1605,7 +1606,11 @@ def _prep_for_assign(self, resolved_indexes, value, mask, is_submask, replace, o else: cfunc_name = "GrB_Vector_assign" mask = _vanilla_subassign_mask(self, mask, idx, replace, opts) - expr_repr = "[[{2._expr_name} elements]]" f"({mask.name})" " = {0.name}" + expr_repr = ( + "[[{2._expr_name} elements]]" + f"({mask.name})" # fmt: skip + " = {0.name}" + ) else: # v(m)[I] << w # v[I] << w @@ -1640,7 +1645,7 @@ def _prep_for_assign(self, resolved_indexes, value, mask, is_submask, replace, o vals = Vector.from_coo( np.arange(shape[0]), values, dtype, size=shape[0] ) - except Exception: + except Exception: # pragma: no cover (safety) vals = None else: if dtype.np_type.subdtype is not None: @@ -1697,7 +1702,11 @@ def _prep_for_assign(self, resolved_indexes, value, mask, is_submask, replace, o else: cfunc_name = "GrB_Vector_assign_Scalar" mask = _vanilla_subassign_mask(self, mask, idx, replace, opts) - expr_repr = "[[{2._expr_name} elements]]" f"({mask.name})" " = {0._expr_name}" + expr_repr = ( + "[[{2._expr_name} elements]]" + f"({mask.name})" # fmt: skip + " = {0._expr_name}" + ) else: # v(m)[I] << c # v[I] << c @@ -1850,6 +1859,12 @@ def size(self): def shape(self): return (self._size,) + @wrapdoc(Vector.dup) + def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): + if clear: + return Vector(self.dtype if dtype is None else dtype, self.size, name=name) + return self._new(dtype, mask, name) + # Begin auto-generated code: Vector _get_value = automethods._get_value S = wrapdoc(Vector.S)(property(automethods.S)) @@ -1877,8 +1892,7 @@ def shape(self): inner = wrapdoc(Vector.inner)(property(automethods.inner)) isclose = wrapdoc(Vector.isclose)(property(automethods.isclose)) isequal = wrapdoc(Vector.isequal)(property(automethods.isequal)) - name = wrapdoc(Vector.name)(property(automethods.name)) - name = name.setter(automethods._set_name) + name = wrapdoc(Vector.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Vector.nvals)(property(automethods.nvals)) outer = wrapdoc(Vector.outer)(property(automethods.outer)) reduce = wrapdoc(Vector.reduce)(property(automethods.reduce)) @@ -1886,6 +1900,8 @@ def shape(self): select = wrapdoc(Vector.select)(property(automethods.select)) if backend == "suitesparse": ss = wrapdoc(Vector.ss)(property(automethods.ss)) + else: + ss = Vector.__dict__["ss"] # raise if used to_coo = wrapdoc(Vector.to_coo)(property(automethods.to_coo)) to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict)) to_values = wrapdoc(Vector.to_values)(property(automethods.to_values)) @@ -1925,6 +1941,14 @@ def size(self): def shape(self): return (self._size,) + @wrapdoc(Vector.dup) + def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): + if clear: + if dtype is None: + dtype = self.dtype + return self.output_type(dtype, *self.shape, name=name) + return self.new(dtype, mask=mask, name=name, **opts) + # Begin auto-generated code: Vector _get_value = automethods._get_value S = wrapdoc(Vector.S)(property(automethods.S)) @@ -1952,8 +1976,7 @@ def shape(self): inner = wrapdoc(Vector.inner)(property(automethods.inner)) isclose = wrapdoc(Vector.isclose)(property(automethods.isclose)) isequal = wrapdoc(Vector.isequal)(property(automethods.isequal)) - name = wrapdoc(Vector.name)(property(automethods.name)) - name = name.setter(automethods._set_name) + name = wrapdoc(Vector.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Vector.nvals)(property(automethods.nvals)) outer = wrapdoc(Vector.outer)(property(automethods.outer)) reduce = wrapdoc(Vector.reduce)(property(automethods.reduce)) @@ -1961,6 +1984,8 @@ def shape(self): select = wrapdoc(Vector.select)(property(automethods.select)) if backend == "suitesparse": ss = wrapdoc(Vector.ss)(property(automethods.ss)) + else: + ss = Vector.__dict__["ss"] # raise if used to_coo = wrapdoc(Vector.to_coo)(property(automethods.to_coo)) to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict)) to_values = wrapdoc(Vector.to_values)(property(automethods.to_values)) diff --git a/graphblas/io.py b/graphblas/io.py index 51b9bc844..6d98a5969 100644 --- a/graphblas/io.py +++ b/graphblas/io.py @@ -382,10 +382,18 @@ def to_awkward(A, format=None): awkward.Array """ - 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 + try: # pragma: no cover (import) + # awkward version 1 + 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: @@ -442,7 +450,7 @@ def to_awkward(A, format=None): ) if format.startswith("hyper"): global _AwkwardDoublyCompressedMatrix - if _AwkwardDoublyCompressedMatrix is None: + 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: diff --git a/graphblas/select/__init__.py b/graphblas/select/__init__.py index f7166b036..4f9376ee3 100644 --- a/graphblas/select/__init__.py +++ b/graphblas/select/__init__.py @@ -21,13 +21,12 @@ def __getattr__(key): def _resolve_expr(expr, callname, opname): - from ..core.matrix import MatrixExpression - from ..core.vector import VectorExpression + from ..core.base import BaseExpression - if not isinstance(expr, (VectorExpression, MatrixExpression)): + if not isinstance(expr, BaseExpression): raise TypeError( - f"Expected VectorExpression or MatrixExpression; found {type(expr)}\n" - f"Typical usage: select.{callname}(x <= 5)" + f"Expected ScalarExpression, VectorExpression, or MatrixExpression; " + f"found {type(expr)}\nTypical usage: select.{callname}(x <= 5)" ) tensor = expr.args[0] thunk = expr.args[1] @@ -44,8 +43,16 @@ def _resolve_expr(expr, callname, opname): elif expr.op.name == "ge": method = f"{opname}gt" thunk -= 1 - if method not in globals(): + if method not in globals(): # pragma: no cover (sanity) raise ValueError(f"Unknown or unregistered select method: {method}") + if expr._is_scalar: + # Handle ScalarExpressions that change their arguments to Vector + if tensor._parent is not None: # e.g., suitesparse + tensor = tensor._parent + thunk = thunk._parent + else: # e.g., suitesparse-vanilla + tensor = tensor[0].new() + thunk = thunk[0].new() return globals()[method](tensor, thunk) diff --git a/graphblas/ss/_core.py b/graphblas/ss/_core.py index 640d7886b..a68222c87 100644 --- a/graphblas/ss/_core.py +++ b/graphblas/ss/_core.py @@ -3,7 +3,7 @@ from ..core import ffi, lib from ..core.base import _expect_type from ..core.matrix import Matrix, TransposedMatrix -from ..core.scalar import Scalar +from ..core.scalar import _as_scalar from ..core.ss.config import BaseConfig from ..core.ss.matrix import _concat_mn from ..core.vector import Vector @@ -47,8 +47,7 @@ def diag(x, k=0, dtype=None, *, name=None, **opts): x = _expect_type( _graphblas_ss, x, (Matrix, TransposedMatrix, Vector), within="diag", argname="x" ) - if type(k) is not Scalar: - k = Scalar.from_value(k, INT64, is_cscalar=True, name="") + k = _as_scalar(k, INT64, is_cscalar=True) if dtype is None: dtype = x.dtype typ = type(x) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 82212d895..3094aea91 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -159,7 +159,7 @@ def test_no_pandas_repr(A, C, v, w): ) repr_printer(v, "v", indent=8) assert repr(v) == ( - '"v" nvals size dtype format\n' "gb.Vector 3 5 FP64 bitmap" + '"v" nvals size dtype format\ngb.Vector 3 5 FP64 bitmap' ) repr_printer(~w.V, "~w.V", indent=8) assert repr(~w.V) == ( @@ -503,8 +503,8 @@ def test_vector_mask_repr_large(w): def test_scalar_repr(s, t): repr_printer(s, "s") - assert repr(s) == ('"s_1" value dtype\n' "gb.Scalar 42 INT64") - assert repr(t) == ('"t" value dtype\n' "gb.Scalar None INT64") + assert repr(s) == ('"s_1" value dtype\ngb.Scalar 42 INT64') + assert repr(t) == ('"t" value dtype\ngb.Scalar None INT64') def test_no_pandas_repr_html(A, C, v, w): diff --git a/graphblas/tests/test_infix.py b/graphblas/tests/test_infix.py index d7e27d550..826ae03c4 100644 --- a/graphblas/tests/test_infix.py +++ b/graphblas/tests/test_infix.py @@ -164,14 +164,15 @@ def test_bad_ewise(s1, v1, A1, A2): with raises(DimensionMismatch): A1.T & A1 - with raises(TypeError): - s1 | 1 - with raises(TypeError): - 1 | s1 - with raises(TypeError): - s1 & 1 - with raises(TypeError): - 1 & s1 + # These are okay now + # with raises(TypeError): + s1 | 1 + # with raises(TypeError): + 1 | s1 + # with raises(TypeError): + s1 & 1 + # with raises(TypeError): + 1 & s1 with raises(TypeError, match="not supported for FP64"): v1 |= v1 @@ -254,10 +255,10 @@ def test_apply_unary_bad(s1, v1): op.exp(v1, 1) with raises(TypeError, match="__call__"): op.exp(1, v1) - with raises(TypeError, match="Bad type when calling unary.exp"): - op.exp(s1) - with raises(TypeError, match="Bad type when calling unary.exp"): - op.exp(1) + # with raises(TypeError, match="Bad type when calling unary.exp"): + op.exp(s1) # Okay now + # with raises(TypeError, match="Bad type when calling unary.exp"): + op.exp(1) # Okay now with raises(TypeError, match="Bad dtype"): op.exp(v1 | v1) @@ -282,8 +283,8 @@ def test_apply_binary(v1, A1): def test_apply_binary_bad(v1): - with raises(TypeError, match="Bad types when calling binary.plus"): - op.plus(1, 1) + # with raises(TypeError, match="Bad types when calling binary.plus"): + op.plus(1, 1) # Okay now with raises(TypeError, match="Bad type when calling binary.plus"): op.plus(v1) with raises(TypeError, match="Bad type for keyword argument `right="): diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index fe26373fb..b420fa5c5 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -17,8 +17,11 @@ try: import awkward._v2 as ak -except ImportError: # pragma: no cover (import) - ak = None +except ImportError: + try: + import awkward as ak + except ImportError: # pragma: no cover (import) + ak = None suitesparse = gb.backend == "suitesparse" diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 9a9bf526d..bfcf5da99 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -2,6 +2,7 @@ import itertools import pickle import sys +import types import weakref import numpy as np @@ -1798,13 +1799,11 @@ def test_isclose(A, v): [3.0, 2.0, 3.0, 1.0, 5.0, 3.0, 7.0, 8.0, 3.0, 1.0, 7.0, 4.0], ) assert not C4.isclose(A, check_dtype=True), "different datatypes are not equal" - # fmt: off C5 = 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], + [0, 1, 2, 2, 2, 3, 3, 4, 4, 5, 5, 6], # fmt: skip [3.0, 2.0, 3.0, 1.0, 5.0, 3.000000000000000001, 7.0, 8.0, 3.0, 1 - 1e-11, 7.0, 4.0], ) - # fmt: on assert C5.isclose(A) C6 = Matrix.from_coo( [3, 0, 3, 5, 6, 0, 6, 1, 6, 2, 4, 1], @@ -2441,17 +2440,17 @@ def import_func(x, import_name, **kwargs): d = C2.ss.unpack(format, raw=raw) if in_method == "import": - def import_func(x, import_name, **kwargs): + def import_func2(x, import_name, **kwargs): return getattr(Matrix.ss, f"import_{import_name}")(**kwargs) else: - def import_func(x, import_name, **kwargs): + def import_func2(x, import_name, **kwargs): getattr(x.ss, f"pack_{import_name}")(**kwargs) return x d["format"] = import_format - other = import_func(C2, import_name, take_ownership=take_ownership, **d) + other = import_func2(C2, import_name, take_ownership=take_ownership, **d) if format == "fullc" and raw and import_format is None and import_name == "any": # It's 1d, so we can't tell we're column-oriented w/o format keyword if do_iso: @@ -2617,6 +2616,7 @@ def test_not_to_array(A): (-10, [], []), ], ) +@autocompute def test_diag(A, params): k, indices, values = params expected = Vector.from_coo(indices, values, dtype=A.dtype, size=max(0, A.nrows - abs(k))) @@ -2908,6 +2908,16 @@ def test_expr_is_like_matrix(A): "_prep_for_extract", "_extract_element", } + # Make sure signatures actually match + skip = {"__init__", "__repr__", "_repr_html_", "new"} + for expr in [binary.times(B & B), B & B, B.T]: + print(type(expr).__name__) + for attr, val in inspect.getmembers(expr): + if attr in skip or not isinstance(val, types.MethodType) or not hasattr(B, attr): + continue + val2 = getattr(B, attr) + assert inspect.signature(val) == inspect.signature(val2), attr + assert val.__doc__ == val2.__doc__ @autocompute @@ -2950,6 +2960,37 @@ def test_index_expr_is_like_matrix(A): "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`." ) + # Make sure signatures actually match. `update` has different docstring. + skip = {"__call__", "__init__", "__repr__", "_repr_html_", "new", "update"} + for attr, val in inspect.getmembers(B[[0, 1], [0, 1]]): + if attr in skip or not isinstance(val, types.MethodType) or not hasattr(B, attr): + continue + val2 = getattr(B, attr) + assert inspect.signature(val) == inspect.signature(val2), attr + assert val.__doc__ == val2.__doc__ + + +@autocompute +def test_dup_expr(A): + result = (A + A).dup() + assert result.isequal(2 * A) + result = (A + A).dup(clear=True) + assert result.isequal(A.dup(clear=True), check_dtype=True) + result = (A + A).dup(float, clear=True) + assert result.isequal(A.dup(float, clear=True), check_dtype=True) + result = (A * A).dup(mask=A.V) + assert result.isequal((A**2).new(mask=A.V)) + result = A[:, :].dup() + assert result.isequal(A) + result = A[:, :].dup(clear=True) + assert result.isequal(A.dup(clear=True), check_dtype=True) + result = A[:, :].dup(float, clear=True) + assert result.isequal(A.dup(float, clear=True), check_dtype=True) + B = A.dup(bool) + result = (B | B).dup() + assert result.isequal(B) + result = (B | B).dup(clear=True) + assert result.isequal(B.dup(clear=True)) @pytest.mark.skipif("not suitesparse") diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index 08beb86fa..c3aaec813 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -1182,10 +1182,10 @@ def badfunc(x): # pragma: no cover (numba) assert udt not in badunary assert int not in badunary - def badfunc(x, y): # pragma: no cover (numba) + def badfunc2(x, y): # pragma: no cover (numba) return BreakCompile(x) - badbinary = BinaryOp.register_anonymous(badfunc, is_udt=True) + badbinary = BinaryOp.register_anonymous(badfunc2, is_udt=True) assert udt not in badbinary assert int not in badbinary diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index 87e2b32b2..5f856b8d4 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -2,13 +2,15 @@ import pickle import random import sys +import types import weakref import numpy as np import pytest import graphblas as gb -from graphblas import backend, binary, dtypes, replace +from graphblas import backend, binary, dtypes, monoid, replace, select, unary +from graphblas.exceptions import EmptyObject from .conftest import autocompute, compute @@ -229,8 +231,9 @@ def test_update(s): assert s == 4 s() << 5 assert s == 5 - with pytest.raises(TypeError, match="is not supported"): - s(accum=binary.plus) << 6 + # with pytest.raises(TypeError, match="is not supported"): + s(accum=binary.plus) << 6 # Now okay + assert s == 11 with pytest.raises(TypeError, match="Mask not allowed for Scalars"): s(s) with pytest.raises(TypeError, match="input_mask not allowed for Scalars"): @@ -275,27 +278,33 @@ def test_scalar_to_numpy(s): assert a.shape == b.shape +@autocompute def test_neg(): for dtype in sorted( - (dtype for dtype in vars(dtypes).values() if isinstance(dtype, dtypes.DataType)), + ( + dtype + for attr, dtype in vars(dtypes).items() + if isinstance(dtype, dtypes.DataType) and attr not in {"_INDEX"} + ), key=lambda x: x.name, reverse=random.choice([False, True]), # used to segfault when False ): s = Scalar.from_value(1, dtype=dtype) empty = Scalar(dtype) - if dtype.name == "BOOL" or dtype.name.startswith("U") or dtype._is_udt: - with pytest.raises(TypeError, match="The negative operator, `-`, is not supported"): + if dtype._is_udt: + with pytest.raises(KeyError, match="ainv does not work with"): -s - with pytest.raises(TypeError, match="The negative operator, `-`, is not supported"): + with pytest.raises(KeyError, match="ainv does not work with"): -empty else: - minus_s = Scalar.from_value(-1, dtype=dtype) + minus_s = Scalar.from_value(-1, dtype=dtype, is_cscalar=False) # pragma: is_grbscalar assert s == -minus_s assert (-s).value == minus_s.value assert empty == -empty assert compute((-empty).value) is None +@autocompute def test_invert(): empty = Scalar(bool) assert empty == ~empty @@ -304,7 +313,9 @@ def test_invert(): s = Scalar.from_value(1, dtype=bool) assert ~s == not_s assert (~s).value == not_s.value - assert not (s.value) == not_s.value + compare = s.value == not_s.value + assert not compare + assert s.value != not_s.value bad = Scalar(int) with pytest.raises(TypeError, match="The invert operator"): ~bad @@ -321,9 +332,11 @@ def test_wait(s): @autocompute def test_expr_is_like_scalar(s): v = Vector.from_coo([1], [2]) + t = s.dup(bool) attrs = {attr for attr, val in inspect.getmembers(s)} expr_attrs = {attr for attr, val in inspect.getmembers(v.inner(v))} infix_attrs = {attr for attr, val in inspect.getmembers(v @ v)} + scalar_infix_attrs = {attr for attr, val in inspect.getmembers(t & t)} # Should we make any of these raise informative errors? expected = { "__call__", @@ -350,6 +363,17 @@ def test_expr_is_like_scalar(s): "methods, then you may need to run `python -m graphblas.core.infixmethods`." ) assert attrs - infix_attrs == expected + assert attrs - scalar_infix_attrs == expected + # Make sure signatures actually match. `expr.dup` has `**opts` + skip = {"__init__", "__repr__", "_repr_html_", "new", "dup"} + for expr in [v.inner(v), v @ v, t & t]: + print(type(expr).__name__) + for attr, val in inspect.getmembers(expr): + if attr in skip or not isinstance(val, types.MethodType) or not hasattr(s, attr): + continue + val2 = getattr(s, attr) + assert inspect.signature(val) == inspect.signature(val2), attr + assert val.__doc__ == val2.__doc__ @autocompute @@ -379,6 +403,43 @@ def test_index_expr_is_like_scalar(s): "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`." ) + # Make sure signatures actually match. `update` has different docstring. + skip = {"__call__", "__init__", "__repr__", "_repr_html_", "new", "update", "dup"} + for attr, val in inspect.getmembers(v[0]): + if attr in skip or not isinstance(val, types.MethodType) or not hasattr(s, attr): + continue + val2 = getattr(s, attr) + assert inspect.signature(val) == inspect.signature(val2), attr + assert val.__doc__ == val2.__doc__ + + +@autocompute +def test_dup_expr(s): + v = Vector.from_coo([1], [2]) + result = (s + s).dup() + assert result.isequal(2 * s) + result = (s + s).dup(is_cscalar=not s._is_cscalar) + assert result.isequal(2 * s) + assert result._is_cscalar != s._is_cscalar + result = (s + s).dup(dtype=float) + assert result.isequal(10.0, check_dtype=True) + result = (s + s).dup(clear=True) + assert result.isequal(s.dup(clear=True)) + b = s.dup(bool) + result = (b | b).dup() + assert result.isequal(b, check_dtype=True) + result = (b | b).dup(clear=True) + assert result.isequal(b.dup(clear=True), check_dtype=True) + result = (b | b).dup(float) + assert result.isequal(b.dup(float), check_dtype=True) + result = (v @ v).dup() + assert result.isequal(4) + result = v[1].dup() + assert result.isequal(2, check_dtype=True) + result = v[1].dup(float) + assert result.isequal(2.0, check_dtype=True) + result = v[1].dup(clear=True) + assert result.isequal(v[0]) def test_ndim(s): @@ -449,6 +510,46 @@ def test_sizeof(s): sys.getsizeof(s) +def test_ewise_union(s): + t = Scalar(int) + result = s.ewise_union(t, binary.plus, 10, 20).new() + assert result == 25 + with pytest.raises(EmptyObject): + s.ewise_union(t, binary.plus, 10, t).new() + result = s.ewise_union(s, monoid.plus, 10, 20).new() + assert result == 10 + result = t.ewise_union(t, binary.plus, 10, 20).new() + assert result.is_empty + with pytest.raises(EmptyObject): + t.ewise_union(t, binary.plus, t, t).new() + v = Vector(int, 2) + with pytest.raises(TypeError, match="Literal scalars also"): + s.ewise_union(v, binary.plus, 10, 20) + with pytest.raises(TypeError, match="Literal scalars also"): + s.ewise_union(t, binary.plus, v, 20) + with pytest.raises(TypeError, match="Literal scalars also"): + s.ewise_union(t, binary.plus, 10, v) + + +def test_ewise_mult_add(s): + assert s.ewise_add(s).new() == 10 + assert s.ewise_mult(s).new() == 25 + v = Vector(int, 2) + with pytest.raises(TypeError, match="Literal scalars also"): + s.ewise_add(v) + with pytest.raises(TypeError, match="Literal scalars also"): + s.ewise_mult(v) + + +def test_select(s): + assert select.value(s < 10).new() == s + assert select.value(s > 10).new().is_empty + assert select.valueeq(s, 5).new() == s + assert select.valuene(5, s).new().is_empty + with pytest.raises(TypeError): + select.value(s | s) + + @pytest.mark.skipif("not suitesparse") def test_ss_concat(s): empty = Scalar(int) @@ -499,3 +600,26 @@ def test_ss_descriptors(s): v[0].new(nthreads=4) with pytest.raises(ValueError, match="escriptor"): s(nthreads=4) << 1 + + +@autocompute +def test_scalar_operators(s): + assert -s == -5 + assert s + 1 == 6 + assert 1 + s == 6 + assert s - 1 == 4 + assert 1 - s == -4 + assert s * 2 == 10 + assert 2 * s == 10 + assert s * s == 25 + assert s**2 == 25 + assert unary.cos(0) == 1 + assert binary.plus(s | 2) == 7 + assert binary.plus(s, 2) == 7 + assert binary.plus(5, 2) == 7 + assert binary.plus(2, s) == 7 + assert (-s).apply(unary.abs) == 5 + with pytest.raises(TypeError): + unary.sin(object()) + with pytest.raises(TypeError): + binary.plus(object(), object()) diff --git a/graphblas/tests/test_ss_utils.py b/graphblas/tests/test_ss_utils.py index 298a8a53e..dd3b99208 100644 --- a/graphblas/tests/test_ss_utils.py +++ b/graphblas/tests/test_ss_utils.py @@ -194,7 +194,7 @@ def test_about(): about["badkey"] assert "SuiteSparse" in about["library_name"] with pytest.raises(TypeError): - del about["library_name"] + del about["library_name"] # pylint: disable=unsupported-delete-operation assert "library_name" in repr(about) diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index 93f78a87b..3e561c168 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -2,6 +2,7 @@ import itertools import pickle import sys +import types import weakref import numpy as np @@ -452,10 +453,12 @@ def test_extract_fancy_scalars(v): assert s.dtype == dtypes.FP64 t = Scalar(float) - with pytest.raises(TypeError, match="is not supported"): - t(accum=binary.plus) << s - with pytest.raises(TypeError, match="is not supported"): - t(accum=binary.plus) << 1 + # with pytest.raises(TypeError, match="is not supported"): + t(accum=binary.plus) << s # Now okay + assert t == 1.0 + # with pytest.raises(TypeError, match="is not supported"): + t(accum=binary.plus) << 1 # Now okay + assert t == 2.0 with pytest.raises(TypeError, match="Mask not allowed for Scalars"): t(mask=t) << s @@ -467,8 +470,12 @@ def test_extract_fancy_scalars(v): t = Scalar(float) t() << v[1] assert t.value == 1.0 - with pytest.raises(TypeError, match="Scalar accumulation with extract element"): - t(accum=binary.plus) << v[0] + # with pytest.raises(TypeError, match="Scalar accumulation with extract element"): + with pytest.raises(TypeError, match="autocompute"): + t(accum=binary.plus) << v[1] # Now okay, but autocomputes + with gb.config.set(autocompute=True): + t(accum=binary.plus) << v[1] # Now okay, but autocomputes + assert t == 2.0 def test_extract_negative_indices(v): @@ -731,6 +738,9 @@ def test_select(v): w7 = v.select("==").new() expected = Vector.from_coo([6], 0) assert w7.isequal(expected) + w8 = select.index(v >= 4).new() + expected = Vector.from_coo([4, 6], [2, 0], size=7) + assert w8.isequal(expected) @autocompute @@ -1272,7 +1282,6 @@ def test_ss_import_export_auto(v, do_iso, methods): else: d = v2.ss.unpack(format, sort=sort, raw=raw) if in_method == "import": - import_func = getattr(Vector.ss, f"import_{import_name}") def import_func(x, import_name, **kwargs): return getattr(Vector.ss, f"import_{import_name}")(**kwargs) @@ -1313,22 +1322,22 @@ def import_func(x, import_name, **kwargs): d = w2.ss.unpack(format, raw=raw) if in_method == "import": - def import_func(x, import_name, **kwargs): + def import_func2(x, import_name, **kwargs): return getattr(Vector.ss, f"import_{import_name}")(**kwargs) else: - def import_func(x, import_name, **kwargs): + def import_func2(x, import_name, **kwargs): getattr(x.ss, f"pack_{import_name}")(**kwargs) return x d["format"] = import_format - other = import_func(w2, import_name, take_ownership=take_ownership, **d) + other = import_func2(w2, import_name, take_ownership=take_ownership, **d) assert other.isequal(w_orig) assert other.ss.is_iso is do_iso d["format"] = "bad_format" with pytest.raises(ValueError, match="Invalid format"): - import_func(w2, import_name, **d) + import_func2(w2, import_name, **d) assert w.isequal(w_orig) assert w.ss.is_iso is do_iso assert w_orig.ss.is_iso is do_iso @@ -1393,6 +1402,7 @@ def test_vector_index_with_scalar(): v[s] +@autocompute def test_diag(v): indices, values = v.to_coo() for k in range(-5, 5): @@ -1610,6 +1620,16 @@ def test_expr_is_like_vector(v): "methods, then you may need to run `python -m graphblas.core.infixmethods`." ) assert attrs - infix_attrs == expected + # Make sure signatures actually match + skip = {"__init__", "__repr__", "_repr_html_", "new"} + for expr in [binary.times(w & w), w & w]: + print(type(expr).__name__) + for attr, val in inspect.getmembers(expr): + if attr in skip or not isinstance(val, types.MethodType) or not hasattr(w, attr): + continue + val2 = getattr(w, attr) + assert inspect.signature(val) == inspect.signature(val2), attr + assert val.__doc__ == val2.__doc__ @autocompute @@ -1644,6 +1664,35 @@ def test_index_expr_is_like_vector(v): "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`." ) + # Make sure signatures actually match. `update` has different docstring. + skip = {"__call__", "__init__", "__repr__", "_repr_html_", "new", "update"} + for attr, val in inspect.getmembers(w[[0, 1]]): + if attr in skip or not isinstance(val, types.MethodType) or not hasattr(w, attr): + continue + val2 = getattr(w, attr) + assert inspect.signature(val) == inspect.signature(val2), attr + assert val.__doc__ == val2.__doc__ + + +@autocompute +def test_dup_expr(v): + result = (v + v).dup() + assert result.isequal(2 * v) + result = (v + v).dup(clear=True) + assert result.isequal(v.dup(clear=True)) + result = (v * v).dup(mask=v.V) + assert result.isequal((v**2).new(mask=v.V)) + result = v[:].dup() + assert result.isequal(v) + result = v[:].dup(clear=True) + assert result.isequal(v.dup(clear=True), check_dtype=True) + result = v[:].dup(float, clear=True) + assert result.isequal(v.dup(float, clear=True), check_dtype=True) + b = v.dup(bool) + result = (b | b).dup() + assert result.isequal(b) + result = (b | b).dup(clear=True) + assert result.isequal(b.dup(clear=True)) @pytest.mark.skipif("not suitesparse") diff --git a/graphblas/unary/numpy.py b/graphblas/unary/numpy.py index 46698d5e1..b4cea6ae0 100644 --- a/graphblas/unary/numpy.py +++ b/graphblas/unary/numpy.py @@ -124,8 +124,8 @@ def __dir__(): def __getattr__(name): if name in _delayed: - func, kwargs = _delayed.pop(name) - rv = func(**kwargs) + delayed_func, kwargs = _delayed.pop(name) + rv = delayed_func(**kwargs) globals()[name] = rv return rv if name not in _unary_names: diff --git a/graphblas/viz.py b/graphblas/viz.py index 8f1f9fc36..c58a1b81b 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -53,15 +53,10 @@ def draw(m): # pragma: no cover .. image:: /_static/img/draw-example.png """ - try: - nx, plt = _get_imports(["nx", "plt"], "draw") - except ImportError as exc: - print(exc.msg) - return + nx, plt = _get_imports(["nx", "plt"], "draw") typ = _output_type(m) if typ is not _Matrix and typ is not _TransposedMatrix: - print(f"Can only draw a Matrix, not {type(m)}") - return + raise TypeError(f"Can only draw a Matrix, not {type(m)}") g = to_networkx(m) pos = nx.spring_layout(g) diff --git a/pyproject.toml b/pyproject.toml index 9e3abcd10..b1701c645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,66 +5,121 @@ requires = ["setuptools", "wheel"] line-length = 100 target-version = ["py38", "py39", "py310", "py311"] +[tool.isort] +sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] +profile = "black" +skip_gitignore = true +float_to_top = true +default_section = "THIRDPARTY" +known_first_party = "graphblas" +line_length = 100 + +[tool.pytest.ini_options] +testpaths = "graphblas/tests" +xfail_strict = true +markers = [ + "slow: Skipped unless --runslow passed", +] + +[tool.coverage.run] +branch = true +source = ["graphblas"] +omit = [ + "graphblas/_version.py", + "graphblas/viz.py", # TODO: test and get coverage for viz.py +] + +[tool.coverage.report] +ignore_errors = false +precision = 1 +fail_under = 0 +skip_covered = true +skip_empty = true +exclude_lines = [ + "pragma: no cover", + "raise AssertionError", + "raise NotImplementedError", +] + +[tool.codespell] +ignore-words-list = "coo,ba" + [tool.ruff] +# https://github.com/charliermarsh/ruff/ line-length = 100 target-version = "py38" select = [ + # Have we enabled too many checks that they'll become a nuisance? We'll see... "F", # pyflakes "E", # pycodestyle Error "W", # pycodestyle Warning + # "C90", # mccabe + # "I", # isort + # "D", # pydocstyle "UP", # pyupgrade + # "N", # pep8-naming "YTT", # flake8-2020 + # "ANN", # flake8-annotations "S", # bandit + # "BLE", # flake8-blind-except + # "FBT", # flake8-boolean-trap "B", # flake8-bugbear + # "A", # flake8-builtins "C4", # flake8-comprehensions "T10", # flake8-debugger + "ISC", # flake8-implicit-str-concat + # "ICN", # flake8-import-conventions + "T20", # flake8-print + # "PT", # flake8-pytest-style "Q", # flake8-quotes + # "RET", # flake8-return "SIM", # flake8-simplify + # "TID", # flake8-tidy-imports + # "ARG", # flake8-unused-arguments "DTZ", # flake8-datetimez + # "ERA", # eradicate + # "PD", # pandas-vet "PGH", # pygrep-hooks "PLC", # pylint Convention "PLE", # pylint Error "PLR", # pylint Refactor "PLW", # pylint Warning + "PIE", # flake8-pie "RUF", # ruff-specific rules ] +ignore = [ + "D203", # 1 blank line required before class docstring (Note: conflicts with D211, which is preferred) + "SIM105", # Use contextlib.suppress(...) instead of try-except-pass (Note: try-except-pass is much faster) +] exclude = [ "versioneer.py", + "graphblas/_version.py", ] [tool.ruff.per-file-ignores] "graphblas/_version.py" = ["S101"] "graphblas/core/operator.py" = ["S102"] -"graphblas/tests/*py" = ["S101"] +"graphblas/tests/*py" = ["S101", "T201"] "graphblas/tests/test_dtype.py" = ["UP003"] "graphblas/tests/test_formatting.py" = ["E501"] "scripts/create_pickle.py" = ["F403", "F405"] [tool.pylint.messages_control] +# To run a single check, do: pylint graphblas --disable E,W,R,C,I --enable assignment-from-no-return max-line-length = 100 py-version = "3.8" ignore-paths = ["graphblas/_version.py", "versioneer.py"] +enable = ["I"] disable = [ # Error - "assigning-non-slot", "assignment-from-no-return", - "function-redefined", - "missing-kwoa", - "no-value-for-parameter", - "not-callable", - "repeated-keyword", - "unexpected-special-method-signature", - "unsupported-delete-operation", - "used-before-assignment", # Warning "arguments-differ", "arguments-out-of-order", - "exec-used", "expression-not-assigned", "fixme", "global-statement", "non-parent-init-called", - "protected-access", "redefined-builtin", "redefined-outer-name", "super-init-not-called", @@ -73,8 +128,6 @@ disable = [ "unspecified-encoding", "unused-argument", "unused-variable", - "unused-wildcard-import", - "wildcard-import", # Refactor "cyclic-import", @@ -100,6 +153,7 @@ disable = [ # warning "broad-except", "pointless-statement", + "protected-access", "undefined-loop-variable", "unused-import", # refactor @@ -123,4 +177,7 @@ disable = [ "unnecessary-dunder-call", "wrong-import-order", "wrong-import-position", + # informative + "locally-disabled", + "suppressed-message", ] diff --git a/setup.cfg b/setup.cfg index f103bdd3d..9f8b59000 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,21 +6,17 @@ max-line-length = 100 inline-quotes = " exclude = versioneer.py, + graphblas/_version.py, graphblas/tests/test_formatting.py, ssgb, extend-ignore = E203, + SIM105, # E203 whitespace before ':' (to be compatible with black) -per-file-ignores = scripts/create_pickle.py:F403,F405 - -[isort] -sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -profile = black -skip_gitignore = true -float_to_top = true -default_section = THIRDPARTY -known_first_party = graphblas -line_length = 100 +per-file-ignores = + scripts/create_pickle.py:F403,F405, + graphblas/tests/*.py:T201, + graphblas/core/ss/matrix.py:SIM113, [versioneer] VCS = git @@ -30,32 +26,6 @@ versionfile_build = graphblas/_version.py tag_prefix= parentdir_prefix=python-graphblas- -[tool:pytest] -testpaths = graphblas/tests -markers: - slow: Skipped unless --runslow passed - -[coverage:run] -source = graphblas -# TODO: test and get coverage for viz.py -omit = - graphblas/_version.py, - graphblas/viz.py - -[coverage:report] -# Regexes for lines to exclude from consideration -exclude_lines = - pragma: no cover - - raise AssertionError - raise NotImplementedError - -ignore_errors = True -precision = 1 -fail_under = 0 -skip_covered = True -skip_empty = True - [build_sphinx] all-files = 1 source-dir = docs