diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 6b36da3bc..0dfa08859 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -143,26 +143,27 @@ jobs: nxver=$(python -c 'import random ; print(random.choice(["=2.7", "=2.8", "=3.0", ""]))') yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') sparsever=$(python -c 'import random ; print(random.choice(["=0.12", "=0.13", "=0.14", ""]))') + fmmver=$(python -c 'import random ; print(random.choice(["=1.4", ""]))') if [[ ${{ steps.pyver.outputs.selected }} == "3.8" ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", ""]))') elif [[ ${{ steps.pyver.outputs.selected }} == "3.9" ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", ""]))') elif [[ ${{ steps.pyver.outputs.selected }} == "3.10" ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", ""]))') else # Python 3.11 npver=$(python -c 'import random ; print(random.choice(["=1.23", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.5", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0.5", "=2.0.6", "=2.0.7", "=2.0.8", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", ""]))') fi if [[ ${{ steps.sourcetype.outputs.selected }} == "source" || ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then # TODO: there are currently issues with some numpy versions when @@ -188,7 +189,7 @@ jobs: # Once we have wheels for all OSes, we can delete the last two lines. mamba install packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig pyyaml${yamlver} sparse${sparsever} \ - pandas${pdver} scipy${spver} numpy${npver} awkward${akver} networkx${nxver} numba${numbaver} \ + pandas${pdver} scipy${spver} numpy${npver} awkward${akver} networkx${nxver} numba${numbaver} fast_matrix_market${fmmver} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ ${{ matrix.slowtask == 'notebooks' && 'matplotlib nbconvert jupyter "ipython>=7"' || '' }} \ ${{ steps.sourcetype.outputs.selected == 'upstream' && 'cython' || '' }} \ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c9c94988..ab097216e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,15 +24,13 @@ repos: hooks: - id: validate-pyproject name: Validate pyproject.toml - - repo: https://github.com/myint/autoflake - rev: v2.0.1 - hooks: - - id: autoflake - args: [--in-place] + # We can probably remove `isort` if we come to trust `ruff --fix`, + # but we'll need to figure out the configuration to do this in `ruff` - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort + # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it - repo: https://github.com/asottile/pyupgrade rev: v3.3.1 hooks: @@ -48,6 +46,13 @@ repos: hooks: - id: black - id: black-jupyter + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.0.257 + hooks: + - id: ruff + args: [--fix-only] + # Let's keep `flake8` even though `ruff` does much of the same. + # `flake8-bugbear` and `flake8-simplify` have caught things missed by `ruff`. - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: @@ -55,8 +60,7 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.0.0 - - flake8-comprehensions==3.10.1 - - flake8-bugbear==23.2.13 + - flake8-bugbear==23.3.12 - flake8-simplify==0.19.3 - repo: https://github.com/asottile/yesqa rev: v1.4.0 @@ -64,14 +68,14 @@ repos: - id: yesqa additional_dependencies: *flake8_dependencies - repo: https://github.com/codespell-project/codespell - rev: v2.2.2 + rev: v2.2.4 hooks: - id: codespell types_or: [python, rst, markdown] additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.252 + rev: v0.0.257 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint @@ -79,6 +83,13 @@ repos: hooks: - id: sphinx-lint args: [--enable, all, "--disable=line-too-long,leaked-markup"] + # `pyroma` may help keep our package standards up to date if best practices change. + # This is probably a "low value" check though and safe to remove if we want faster pre-commit. + - repo: https://github.com/regebro/pyroma + rev: "4.2" + hooks: + - id: pyroma + args: [-n, "10", .] - repo: local hooks: # Add `--hook-stage manual` to pre-commit command to run (very slow) diff --git a/README.md b/README.md index 2c4b2d1b7..dab91782a 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,8 @@ The following are not required by python-graphblas, but may be needed for certai - `pandas` – required for nicer `__repr__`; - `matplotlib` – required for basic plotting of graphs; - `scipy` – used in io module to read/write `scipy.sparse` format; -- `networkx` – used in `io` module to interface with `networkx` graphs. +- `networkx` – used in `io` module to interface with `networkx` graphs; +- `fast-matrix-market` - for faster read/write of Matrix Market files with `gb.io.mmread` and `gb.io.mmwrite`. ## Description Currently works with [SuiteSparse:GraphBLAS](https://github.com/DrTimothyAldenDavis/GraphBLAS), but the goal is to make it work with all implementations of the GraphBLAS spec. diff --git a/dev-requirements.txt b/dev-requirements.txt index b84c0e849..273980db9 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -6,6 +6,7 @@ pyyaml pandas # For I/O awkward +fast_matrix_market networkx scipy sparse @@ -16,6 +17,7 @@ matplotlib # For linting pre-commit # For testing +packaging pytest-cov # For debugging icecream diff --git a/docs/getting_started/index.rst b/docs/getting_started/index.rst index 661550803..d603df30b 100644 --- a/docs/getting_started/index.rst +++ b/docs/getting_started/index.rst @@ -34,6 +34,7 @@ to work. - `matplotlib `__ -- required for basic plotting of graphs - `scipy `__ -- used in ``io`` module to read/write ``scipy.sparse`` format - `networkx `__ -- used in ``io`` module to interface with networkx graphs + - `fast-matrix-market `__ -- for faster read/write of Matrix Market files with ``gb.io.mmread`` and ``gb.io.mmwrite`` GraphBLAS Fundamentals ---------------------- diff --git a/docs/user_guide/io.rst b/docs/user_guide/io.rst index 9431ff413..c13fda5d6 100644 --- a/docs/user_guide/io.rst +++ b/docs/user_guide/io.rst @@ -129,3 +129,19 @@ Note that A is unchanged in the above example. The SuiteSparse export has a ``give_ownership`` option. This performs a zero-copy move operation and invalidates the original python-graphblas object. When extreme speed is needed or memory is too limited to make a copy, this option may be needed. + +Matrix Market files +------------------- + +The `Matrix Market file format `_ is a common +file format for storing sparse arrays in human-readable ASCII. +Matrix Market files--also called MM files--often use ".mtx" file extension. +For example, many datasets in MM format can be found in `the SuiteSparse Matrix Collection `_. + +Use ``gb.io.mmread()`` to read a Matrix Market file to a python-graphblas Matrix, +and ``gb.io.mmwrite()`` to write a Matrix to a Matrix Market file. +These names match the equivalent functions in `scipy.sparse `_. + +``scipy`` is required to be installed to read Matrix Market files. +If ``fast_matrix_market`` is installed, it will be used by default for +`much better performance `_. diff --git a/environment.yml b/environment.yml index f327a6980..5ffd588da 100644 --- a/environment.yml +++ b/environment.yml @@ -23,7 +23,7 @@ dependencies: - pandas # For I/O - awkward - # - fast_matrix_market # Coming soon... + - fast_matrix_market - networkx - scipy - sparse diff --git a/graphblas/core/expr.py b/graphblas/core/expr.py index 9046795db..affe06112 100644 --- a/graphblas/core/expr.py +++ b/graphblas/core/expr.py @@ -160,9 +160,8 @@ def parse_indices(self, indices, shape): raise TypeError(f"Index for {type(self.obj).__name__} cannot be a tuple") # Convert to tuple for consistent processing indices = (indices,) - else: # len(shape) == 2 - if type(indices) is not tuple or len(indices) != 2: - raise TypeError(f"Index for {type(self.obj).__name__} must be a 2-tuple") + elif type(indices) is not tuple or len(indices) != 2: + raise TypeError(f"Index for {type(self.obj).__name__} must be a 2-tuple") out = [] for i, idx in enumerate(indices): diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 8b9b4b678..1935fcee7 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -3154,14 +3154,11 @@ def _prep_for_assign(self, resolved_indexes, value, mask, is_submask, replace, o mask = _vanilla_subassign_mask( self, mask, rowidx, colidx, replace, opts ) + elif backend == "suitesparse": + cfunc_name = "GxB_Matrix_subassign_Scalar" else: - if backend == "suitesparse": - cfunc_name = "GxB_Matrix_subassign_Scalar" - else: - cfunc_name = "GrB_Matrix_assign_Scalar" - mask = _vanilla_subassign_mask( - self, mask, rowidx, colidx, replace, opts - ) + cfunc_name = "GrB_Matrix_assign_Scalar" + mask = _vanilla_subassign_mask(self, mask, rowidx, colidx, replace, opts) expr_repr = ( "[[{2._expr_name} rows], [{4._expr_name} cols]]" f"({mask.name})" diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index b455d760e..b1869f198 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -895,9 +895,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m if is_iso: if values.size > 1: # pragma: no branch (suitesparse) values = values[:1] - else: - if values.size > nvals: # pragma: no branch (suitesparse) - values = values[:nvals] + elif values.size > nvals: # pragma: no branch (suitesparse) + values = values[:nvals] # Note: nvals is also at `indptr[nrows]` rv = { "indptr": indptr, @@ -937,9 +936,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m if is_iso: if values.size > 1: # pragma: no cover (suitesparse) values = values[:1] - else: - if values.size > nvals: - values = values[:nvals] + elif values.size > nvals: + values = values[:nvals] # Note: nvals is also at `indptr[ncols]` rv = { "indptr": indptr, @@ -989,9 +987,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m if is_iso: if values.size > 1: # pragma: no cover (suitesparse) values = values[:1] - else: - if values.size > nvals: - values = values[:nvals] + elif values.size > nvals: + values = values[:nvals] # Note: nvals is also at `indptr[nvec]` rv = { "indptr": indptr, @@ -1044,9 +1041,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m if is_iso: if values.size > 1: # pragma: no cover (suitesparse) values = values[:1] - else: - if values.size > nvals: - values = values[:nvals] + elif values.size > nvals: + values = values[:nvals] # Note: nvals is also at `indptr[nvec]` rv = { "indptr": indptr, @@ -3480,15 +3476,10 @@ def _import_any( format = "cooc" else: format = "coo" + elif isinstance(values, np.ndarray) and values.ndim == 2 and values.flags.f_contiguous: + format = "fullc" else: - if ( - isinstance(values, np.ndarray) - and values.ndim == 2 - and values.flags.f_contiguous - ): - format = "fullc" - else: - format = "fullr" + format = "fullr" else: format = format.lower() if method == "pack": diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py index d13d78ac3..343335773 100644 --- a/graphblas/core/ss/vector.py +++ b/graphblas/core/ss/vector.py @@ -551,9 +551,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m if is_iso: if values.size > 1: # pragma: no cover (suitesparse) values = values[:1] - else: - if values.size > nvals: - values = values[:nvals] + elif values.size > nvals: + values = values[:nvals] rv = { "size": size, "indices": indices, @@ -589,9 +588,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m if is_iso: if values.size > 1: # pragma: no cover (suitesparse) values = values[:1] - else: - if values.size > size: # pragma: no branch (suitesparse) - values = values[:size] + elif values.size > size: # pragma: no branch (suitesparse) + values = values[:size] rv = { "bitmap": bitmap, "nvals": nvals[0], @@ -616,9 +614,8 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m if is_iso: if values.size > 1: values = values[:1] - else: - if values.size > size: # pragma: no branch (suitesparse) - values = values[:size] + elif values.size > size: # pragma: no branch (suitesparse) + values = values[:size] rv = {} if raw or is_iso: rv["size"] = size diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index dd183d856..8231691c6 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -1868,12 +1868,11 @@ def _prep_for_assign(self, resolved_indexes, value, mask, is_submask, replace, o else: cfunc_name = f"GrB_Vector_assign_{dtype_name}" mask = _vanilla_subassign_mask(self, mask, idx, replace, opts) + elif backend == "suitesparse": + cfunc_name = "GxB_Vector_subassign_Scalar" else: - if backend == "suitesparse": - cfunc_name = "GxB_Vector_subassign_Scalar" - else: - cfunc_name = "GrB_Vector_assign_Scalar" - mask = _vanilla_subassign_mask(self, mask, idx, replace, opts) + cfunc_name = "GrB_Vector_assign_Scalar" + mask = _vanilla_subassign_mask(self, mask, idx, replace, opts) expr_repr = ( "[[{2._expr_name} elements]]" f"({mask.name})" # fmt: skip diff --git a/graphblas/io.py b/graphblas/io.py index e9d8ccfe6..bc57c2084 100644 --- a/graphblas/io.py +++ b/graphblas/io.py @@ -26,6 +26,7 @@ def draw(m): # pragma: no cover _warn( "`graphblas.io.draw` is deprecated; it has been moved to `graphblas.viz.draw`", DeprecationWarning, + stacklevel=2, ) viz.draw(m) @@ -93,6 +94,7 @@ def from_numpy(m): # pragma: no cover (deprecated) "`graphblas.io.from_numpy` is deprecated; " "use `Matrix.from_dense` and `Vector.from_dense` instead.", DeprecationWarning, + stacklevel=2, ) if m.ndim > 2: raise _GraphblasException("m.ndim must be <= 2") @@ -336,6 +338,7 @@ def to_numpy(m): # pragma: no cover (deprecated) "`graphblas.io.to_numpy` is deprecated; " "use `Matrix.to_dense` and `Vector.to_dense` instead.", DeprecationWarning, + stacklevel=2, ) try: import scipy # noqa: F401 @@ -566,16 +569,27 @@ def to_pydata_sparse(A, format="coo"): return s.asformat(format) -def mmread(source, *, dup_op=None, name=None): +def mmread(source, engine="auto", *, dup_op=None, name=None, **kwargs): """Create a GraphBLAS Matrix from the contents of a Matrix Market file. This uses `scipy.io.mmread - `_. + `_ + or `fast_matrix_market.mmread + `_. + + By default, ``fast_matrix_market`` will be used if available, because it + is faster. Additional keyword arguments in ``**kwargs`` will be passed + to the engine's ``mmread``. For example, ``parallelism=8`` will set the + number of threads to use to 8 when using ``fast_matrix_market``. Parameters ---------- - filename : str or file + source : str or file Filename (.mtx or .mtz.gz) or file-like object + engine : {"auto", "scipy", "fmm", "fast_matrix_market"}, default "auto" + How to read the matrix market file. "scipy" uses ``scipy.io.mmread``, + "fmm" and "fast_matrix_market" uses ``fast_matrix_market.mmread``, + and "auto" will use "fast_matrix_market" if available. dup_op : BinaryOp, optional Aggregation function for duplicate coordinates (if found) name : str, optional @@ -586,11 +600,26 @@ def mmread(source, *, dup_op=None, name=None): :class:`~graphblas.Matrix` """ try: + # scipy is currently needed for *all* engines from scipy.io import mmread from scipy.sparse import isspmatrix_coo except ImportError: # pragma: no cover (import) raise ImportError("scipy is required to read Matrix Market files") from None - array = mmread(source) + engine = engine.lower() + if engine in {"auto", "fmm", "fast_matrix_market"}: + try: + from fast_matrix_market import mmread # noqa: F811 + except ImportError: # pragma: no cover (import) + if engine != "auto": + raise ImportError( + "fast_matrix_market is required to read Matrix Market files " + f'using the "{engine}" engine' + ) from None + elif engine != "scipy": + raise ValueError( + f'Bad engine value: {engine!r}. Must be "auto", "scipy", "fmm", or "fast_matrix_market"' + ) + array = mmread(source, **kwargs) if isspmatrix_coo(array): nrows, ncols = array.shape return _Matrix.from_coo( @@ -599,7 +628,17 @@ def mmread(source, *, dup_op=None, name=None): return _Matrix.from_dense(array, name=name) -def mmwrite(target, matrix, *, comment="", field=None, precision=None, symmetry=None): +def mmwrite( + target, + matrix, + engine="auto", + *, + comment="", + field=None, + precision=None, + symmetry=None, + **kwargs, +): """Write a Matrix Market file from the contents of a GraphBLAS Matrix. This uses `scipy.io.mmwrite @@ -607,10 +646,14 @@ def mmwrite(target, matrix, *, comment="", field=None, precision=None, symmetry= Parameters ---------- - filename : str or file target + target : str or file target Filename (.mtx) or file-like object opened for writing matrix : Matrix Matrix to be written + engine : {"auto", "scipy", "fmm", "fast_matrix_market"}, default "auto" + How to read the matrix market file. "scipy" uses ``scipy.io.mmwrite``, + "fmm" and "fast_matrix_market" uses ``fast_matrix_market.mmwrite``, + and "auto" will use "fast_matrix_market" if available. comment : str, optional Comments to be prepended to the Matrix Market file field : str @@ -621,11 +664,34 @@ def mmwrite(target, matrix, *, comment="", field=None, precision=None, symmetry= {"general", "symmetric", "skew-symmetric", "hermetian"} """ try: + # scipy is currently needed for *all* engines from scipy.io import mmwrite except ImportError: # pragma: no cover (import) raise ImportError("scipy is required to write Matrix Market files") from None + engine = engine.lower() + if engine in {"auto", "fmm", "fast_matrix_market"}: + try: + from fast_matrix_market import mmwrite # noqa: F811 + except ImportError: # pragma: no cover (import) + if engine != "auto": + raise ImportError( + "fast_matrix_market is required to write Matrix Market files " + f'using the "{engine}" engine' + ) from None + elif engine != "scipy": + raise ValueError( + f'Bad engine value: {engine!r}. Must be "auto", "scipy", "fmm", or "fast_matrix_market"' + ) if _backend == "suitesparse" and matrix.ss.format in {"fullr", "fullc"}: array = matrix.ss.export()["values"] else: array = to_scipy_sparse(matrix, format="coo") - mmwrite(target, array, comment=comment, field=field, precision=precision, symmetry=symmetry) + mmwrite( + target, + array, + comment=comment, + field=field, + precision=precision, + symmetry=symmetry, + **kwargs, + ) diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 6fa43ebbc..ada092025 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -30,6 +30,10 @@ except ImportError: # pragma: no cover (import) ak = None +try: + import fast_matrix_market as fmm +except ImportError: # pragma: no cover (import) + fmm = None suitesparse = gb.backend == "suitesparse" @@ -159,7 +163,10 @@ def test_matrix_to_from_networkx(): @pytest.mark.skipif("not ss") -def test_mmread_mmwrite(): +@pytest.mark.parametrize("engine", ["auto", "scipy", "fmm"]) +def test_mmread_mmwrite(engine): + if engine == "fmm" and fmm is None: # pragma: no cover (import) + pytest.skip("needs fast_matrix_market") from scipy.io.tests import test_mmio p31 = 2**31 @@ -256,10 +263,15 @@ def test_mmread_mmwrite(): continue mm_in = StringIO(getattr(test_mmio, example)) if over64: - with pytest.raises(OverflowError): - M = gb.io.mmread(mm_in) + with pytest.raises((OverflowError, ValueError)): + # fast_matrix_market v1.4.5 raises ValueError instead of OverflowError + M = gb.io.mmread(mm_in, engine) else: - M = gb.io.mmread(mm_in) + if example == "_empty_lines_example" and engine in {"fmm", "auto"} and fmm is not None: + # TODO MAINT: is this a bug in fast_matrix_market, or does scipy.io.mmread + # read an invalid file? `fast_matrix_market` v1.4.5 does not handle this. + continue + M = gb.io.mmread(mm_in, engine) if not M.isequal(expected): # pragma: no cover (debug) print(example) print("Expected:") @@ -268,12 +280,12 @@ def test_mmread_mmwrite(): print(M) raise AssertionError("Matrix M not as expected. See print output above") mm_out = BytesIO() - gb.io.mmwrite(mm_out, M) + gb.io.mmwrite(mm_out, M, engine) mm_out.flush() mm_out.seek(0) mm_out_str = b"".join(mm_out.readlines()).decode() mm_out.seek(0) - M2 = gb.io.mmread(mm_out) + M2 = gb.io.mmread(mm_out, engine) if not M2.isequal(expected): # pragma: no cover (debug) print(example) print("Expected:") @@ -299,23 +311,38 @@ def test_from_scipy_sparse_duplicates(): @pytest.mark.skipif("not ss") -def test_matrix_market_sparse_duplicates(): - mm = StringIO( - """%%MatrixMarket matrix coordinate real general +@pytest.mark.parametrize("engine", ["auto", "scipy", "fast_matrix_market"]) +def test_matrix_market_sparse_duplicates(engine): + if engine == "fast_matrix_market" and fmm is None: # pragma: no cover (import) + pytest.skip("needs fast_matrix_market") + string = """%%MatrixMarket matrix coordinate real general 3 3 4 1 3 1 2 2 2 3 1 3 3 1 4""" - ) + mm = StringIO(string) with pytest.raises(ValueError, match="Duplicate indices found"): - gb.io.mmread(mm) - mm.seek(0) - a = gb.io.mmread(mm, dup_op=gb.binary.plus) + gb.io.mmread(mm, engine) + # mm.seek(0) # Doesn't work with `fast_matrix_market` 1.4.5 + mm = StringIO(string) + a = gb.io.mmread(mm, engine, dup_op=gb.binary.plus) expected = gb.Matrix.from_coo([0, 1, 2], [2, 1, 0], [1, 2, 7]) assert a.isequal(expected) +@pytest.mark.skipif("not ss") +def test_matrix_market_bad_engine(): + A = gb.Matrix.from_coo([0, 0, 3, 5], [1, 4, 0, 2], [1, 0, 2, -1], nrows=7, ncols=6) + with pytest.raises(ValueError, match="Bad engine value"): + gb.io.mmwrite(BytesIO(), A, engine="bad_engine") + mm_out = BytesIO() + gb.io.mmwrite(mm_out, A) + mm_out.seek(0) + with pytest.raises(ValueError, match="Bad engine value"): + gb.io.mmread(mm_out, engine="bad_engine") + + @pytest.mark.skipif("not ss") def test_scipy_sparse(): a = np.arange(12).reshape(3, 4) diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 40676f71a..1d42035a3 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -2173,15 +2173,14 @@ def test_ss_import_export(A, do_iso, methods): C1.ss.pack_any(**d) assert C1.isequal(C) assert C1.ss.is_iso is do_iso + elif in_method == "import": + D1 = Matrix.ss.import_any(**d) + assert D1.isequal(C) + assert D1.ss.is_iso is do_iso else: - if in_method == "import": - D1 = Matrix.ss.import_any(**d) - assert D1.isequal(C) - assert D1.ss.is_iso is do_iso - else: - C1.ss.pack_any(**d) - assert C1.isequal(C) - assert C1.ss.is_iso is do_iso + C1.ss.pack_any(**d) + assert C1.isequal(C) + assert C1.ss.is_iso is do_iso C2 = C.dup() d = getattr(C2.ss, out_method)("fullc") diff --git a/graphblas/viz.py b/graphblas/viz.py index 89010bc3d..d8a96d343 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -182,30 +182,30 @@ def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kw images.extend(image_row) return hv.Layout(images).cols(ncols) - kwds = dict( # noqa: C408 pylint: disable=use-dict-literal - x="col", - y="row", - c="val", - aggregator=agg, - frame_width=width, - frame_height=height, - cmap="fire", - cnorm="eq_hist", - xlim=(0, M.ncols), - ylim=(0, M.nrows), - rasterize=True, - flip_yaxis=True, - hover=True, - xlabel="", - ylabel="", - data_aspect=1, - x_sampling=1, - y_sampling=1, - xaxis="top", - xformatter="%d", - yformatter="%d", - rot=60, - ) + kwds = { + "x": "col", + "y": "row", + "c": "val", + "aggregator": agg, + "frame_width": width, + "frame_height": height, + "cmap": "fire", + "cnorm": "eq_hist", + "xlim": (0, M.ncols), + "ylim": (0, M.nrows), + "rasterize": True, + "flip_yaxis": True, + "hover": True, + "xlabel": "", + "ylabel": "", + "data_aspect": 1, + "x_sampling": 1, + "y_sampling": 1, + "xaxis": "top", + "xformatter": "%d", + "yformatter": "%d", + "rot": 60, + } # Only show axes on outer-most plots if kwargs.pop("_col", 0) != 0: kwds["yaxis"] = None diff --git a/pyproject.toml b/pyproject.toml index 55d490d78..0b3f38577 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,9 @@ readme = "README.md" requires-python = ">=3.8" license = {file = "LICENSE"} authors = [ - {name = "Erik Welch"}, + {name = "Erik Welch", email = "erik.n.welch@gmail.com"}, {name = "Jim Kitchen"}, + {name = "Python-graphblas contributors"}, ] maintainers = [ {name = "Erik Welch", email = "erik.n.welch@gmail.com"}, @@ -78,6 +79,7 @@ io = [ "scipy >=1.8", "awkward >=1.9", "sparse >=0.12", + "fast-matrix-market >=1.4.5", ] viz = [ "matplotlib >=3.5", @@ -94,6 +96,7 @@ complete = [ "scipy >=1.8", "awkward >=1.9", "sparse >=0.12", + "fast-matrix-market >=1.4.5", "matplotlib >=3.5", "pytest", "packaging", @@ -280,6 +283,7 @@ ignore = [ "PLR0913", # Too many arguments to function call "PLR0915", # Too many statements "PLR2004", # Magic number used in comparison, consider replacing magic with a constant variable + "PLW0603", # Using the global statement to update ... is discouraged (Note: yeah, discouraged, but too strict) "PLW2901", # Outer for loop variable ... overwritten by inner assignment target (Note: good advice, but too strict) "RET502", # Do not implicitly `return None` in function able to return non-`None` value "RET503", # Missing explicit `return` at the end of function able to return non-`None` value diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index d42952cf0..d08ad6476 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -7,10 +7,10 @@ conda search 'numpy[channel=conda-forge]>=1.24.2' conda search 'pandas[channel=conda-forge]>=1.5.3' conda search 'scipy[channel=conda-forge]>=1.10.1' conda search 'networkx[channel=conda-forge]>=3.0' -conda search 'awkward[channel=conda-forge]>=2.0.8' +conda search 'awkward[channel=conda-forge]>=2.1.1' conda search 'sparse[channel=conda-forge]>=0.14.0' +conda search 'fast_matrix_market[channel=conda-forge]>=1.4.5' conda search 'numba[channel=conda-forge]>=0.56.4' conda search 'pyyaml[channel=conda-forge]>=6.0' -conda search 'flake8-comprehensions[channel=conda-forge]>=3.10.1' -conda search 'flake8-bugbear[channel=conda-forge]>=23.2.13' +conda search 'flake8-bugbear[channel=conda-forge]>=23.3.12' conda search 'flake8-simplify[channel=conda-forge]>=0.19.3'