From 360bfe33ff096a4f1cf69416e086709106f52d30 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 7 Mar 2023 11:53:52 -0600 Subject: [PATCH 01/87] Handle deprecation warning added in setuptools v67.5.0 (#399) --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index adbf2d5b0..a369253e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,6 +156,9 @@ filterwarnings = [ # https://setuptools.pypa.io/en/latest/history.html#v67-3-0 # MAINT: check if this is still necessary in 2025 "ignore:Deprecated call to `pkg_resources.declare_namespace:DeprecationWarning:pkg_resources", + # And this deprecation warning was added in setuptools v67.5.0 (8 Mar 2023). See: + # https://setuptools.pypa.io/en/latest/history.html#v67-5-0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pkg_resources", ] [tool.coverage.run] From 80a92e7b3577556775c2b0bd6738a7bfadeabee8 Mon Sep 17 00:00:00 2001 From: Sultan Orazbayev Date: Wed, 8 Mar 2023 04:20:29 +0600 Subject: [PATCH 02/87] Update fundamentals.rst (#397) Resolve https://github.com/python-graphblas/python-graphblas/issues/396 --- docs/user_guide/fundamentals.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user_guide/fundamentals.rst b/docs/user_guide/fundamentals.rst index f47296c47..6e4a5d195 100644 --- a/docs/user_guide/fundamentals.rst +++ b/docs/user_guide/fundamentals.rst @@ -37,7 +37,7 @@ The descriptor is a set of bitwise flags. - Replace mode indicates that elements outside the mask area should be cleared in the final output. When not in replace mode, elements outside the mask are left untouched. -For more details, look at the official API spec at `graphblas.org `_. +For more details, look at the official API spec at `graphblas.org `_. C-to-Python Mapping ------------------- From 673650dde0d11f169fb56eb516a427239fc45d19 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 10 Mar 2023 10:24:53 -0600 Subject: [PATCH 03/87] Fix path to logo in docs (include `_static` in path) (#400) * Fix path to logo in docs (include `_static` in path) * Fix dark mode for docs - Update and pin pydata-sphinx-theme to 0.13.1 - Remove circle around light/dark theme change icon - Remove "Show Source" button --------- Co-authored-by: Jim Kitchen --- docs/_static/custom.css | 24 ++++++++++++++++++++++++ docs/conf.py | 5 +++-- docs/env.yml | 4 ++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 07834b3bc..93600d107 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -6,6 +6,7 @@ } .intro-card { + background-color: var(--pst-color-background); margin-bottom: 30px; } @@ -51,6 +52,29 @@ p.rubric { border-bottom: none; } +button.navbar-btn.rounded-circle { + padding: 0.25rem; +} + +button.navbar-btn.search-button { + color: var(--pst-color-text-muted); + padding: 0; +} + +button.navbar-btn:hover +{ + color: var(--pst-color-primary); +} + +button.theme-switch-button { + font-size: calc(var(--pst-font-size-icon) - .1rem); + border: none; +} + +button span.theme-switch:hover { + color: var(--pst-color-primary); +} + /* Styling for Jupyter Notebook ReST Exports */ .dataframe tbody th, .dataframe tbody td { diff --git a/docs/conf.py b/docs/conf.py index ddd360326..dc73c8304 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,11 +61,12 @@ html_static_path = ["_static"] html_theme_options = { "logo": { - "image_light": "img/logo-name-light.svg", - "image_dark": "img/logo-name-dark.svg", + "image_light": "_static/img/logo-name-light.svg", + "image_dark": "_static/img/logo-name-dark.svg", }, "github_url": "https://github.com/python-graphblas/python-graphblas", } +html_show_sourcelink = False autodoc_member_order = "groupwise" diff --git a/docs/env.yml b/docs/env.yml index 7bc373c06..c0c4c8999 100644 --- a/docs/env.yml +++ b/docs/env.yml @@ -19,5 +19,5 @@ dependencies: - commonmark # For RTD - nbsphinx - numpydoc - - pydata-sphinx-theme - - sphinx-panels + - pydata-sphinx-theme=0.13.1 + - sphinx-panels=0.6.0 From a0c152cced32ecb14865fbe75f2df43f01f931fe Mon Sep 17 00:00:00 2001 From: Sultan Orazbayev Date: Sat, 11 Mar 2023 22:40:42 +0600 Subject: [PATCH 04/87] Update README.md (#404) * Update README.md Adds Python version badges optional dependencies copied from docs * Update pyproject.toml Add Sultan Orazbayev as maintainer --- README.md | 10 ++++++++++ pyproject.toml | 1 + 2 files changed, 11 insertions(+) diff --git a/README.md b/README.md index 34c1c1994..2c4b2d1b7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![conda-forge](https://img.shields.io/conda/vn/conda-forge/python-graphblas.svg)](https://anaconda.org/conda-forge/python-graphblas) [![pypi](https://img.shields.io/pypi/v/python-graphblas.svg)](https://pypi.python.org/pypi/python-graphblas/) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-graphblas) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/python-graphblas/python-graphblas/blob/main/LICENSE) [![Tests](https://github.com/python-graphblas/python-graphblas/workflows/Tests/badge.svg?branch=main)](https://github.com/python-graphblas/python-graphblas/actions) [![Docs](https://readthedocs.org/projects/python-graphblas/badge/?version=latest)](https://python-graphblas.readthedocs.io/en/latest/) @@ -32,6 +33,15 @@ $ pip install python-graphblas ``` This will also install the [SuiteSparse:GraphBLAS](https://github.com/DrTimothyAldenDavis/GraphBLAS) compiled C library. +### Optional Dependencies + +The following are not required by python-graphblas, but may be needed for certain functionality to work. + +- `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. + ## 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/pyproject.toml b/pyproject.toml index a369253e9..55d490d78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ authors = [ maintainers = [ {name = "Erik Welch", email = "erik.n.welch@gmail.com"}, {name = "Jim Kitchen", email = "jim22k@gmail.com"}, + {name = "Sultan Orazbayev", email = "contact@econpoint.com"}, ] keywords = [ "graphblas", From c3baea8d55df1ab015e27c9b9db16e40f64f446b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Mar 2023 15:23:17 -0500 Subject: [PATCH 05/87] Bump pypa/gh-action-pypi-publish from 1.6.4 to 1.7.1 (#405) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.6.4 to 1.7.1. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.6.4...v1.7.1) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 93a7e31c8..3dcf14bbc 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.6.4 + uses: pypa/gh-action-pypi-publish@v1.7.1 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 4920dc8333634b930e7ddabbe2747a31dc375d91 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 22 Mar 2023 10:17:53 -0500 Subject: [PATCH 06/87] Read Matrix Market with `fast_matrix_market` (#391) * Read Matrix Market with `fast_matrix_market` * Update usage of `from_dense` and `to_dense` in docs and notebooks * Ignore warning from new version of pydata/sparse (we should investigate later) * `to_pydata_sparse(v)` on Vector should create 1-d array * bump awkward to 2.1.1 * Maybe trust `ruff` to fix some things But run `autoflake`, `isort`, `pyupgrade`, and `black` first (for now). * Add a few notes to pre-commit hooks * Add `fast-matrix-market` to optional dependencies documentation * Drop autoflake in pre-commit (use ruff instead) * Note that `scipy` is needed for all backends for mmread and mmwrite * Add Matrix Market to `io` docs --- .github/workflows/test_and_build.yml | 11 ++-- .pre-commit-config.yaml | 29 ++++++---- README.md | 3 +- dev-requirements.txt | 2 + docs/getting_started/index.rst | 1 + docs/user_guide/io.rst | 16 ++++++ environment.yml | 2 +- graphblas/core/expr.py | 5 +- graphblas/core/matrix.py | 11 ++-- graphblas/core/ss/matrix.py | 31 ++++------- graphblas/core/ss/vector.py | 15 +++--- graphblas/core/vector.py | 9 ++-- graphblas/io.py | 80 +++++++++++++++++++++++++--- graphblas/tests/test_io.py | 53 +++++++++++++----- graphblas/tests/test_matrix.py | 15 +++--- graphblas/viz.py | 48 ++++++++--------- pyproject.toml | 6 ++- scripts/check_versions.sh | 6 +-- 18 files changed, 227 insertions(+), 116 deletions(-) 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' From c6850c027de68c8e80d68e33775816f152ea2cef Mon Sep 17 00:00:00 2001 From: Jim Kitchen Date: Wed, 22 Mar 2023 17:13:57 -0500 Subject: [PATCH 07/87] Fix right panel visibility overlap issue (#413) Also split up API reference into separate pages for better navigation experience --- docs/_static/custom.css | 8 -- docs/api_reference/collections.rst | 23 +++++ docs/api_reference/exceptions.rst | 7 ++ docs/api_reference/index.rst | 133 ++--------------------------- docs/api_reference/io.rst | 55 ++++++++++++ docs/api_reference/operators.rst | 38 +++++++++ 6 files changed, 129 insertions(+), 135 deletions(-) create mode 100644 docs/api_reference/collections.rst create mode 100644 docs/api_reference/exceptions.rst create mode 100644 docs/api_reference/io.rst create mode 100644 docs/api_reference/operators.rst diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 93600d107..1b14402cd 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,10 +1,6 @@ /* Main Page Stylings */ -.container-xl { - max-width: 1400px; -} - .intro-card { background-color: var(--pst-color-background); margin-bottom: 30px; @@ -80,7 +76,3 @@ button span.theme-switch:hover { .dataframe tbody th, .dataframe tbody td { padding: 10px; } - -.bd-sidebar-primary, .bd-sidebar-secondary { - position: sticky; -} diff --git a/docs/api_reference/collections.rst b/docs/api_reference/collections.rst new file mode 100644 index 000000000..83cabfd21 --- /dev/null +++ b/docs/api_reference/collections.rst @@ -0,0 +1,23 @@ +Collections +----------- + +Matrix +~~~~~~ + +.. autoclass:: graphblas.Matrix + :members: + :special-members: __getitem__, __setitem__, __delitem__, __contains__, __iter__ + +Vector +~~~~~~ + +.. autoclass:: graphblas.Vector + :members: + :special-members: __getitem__, __setitem__, __delitem__, __contains__, __iter__ + +Scalar +~~~~~~ + +.. autoclass:: graphblas.Scalar + :members: + :special-members: __eq__, __bool__ diff --git a/docs/api_reference/exceptions.rst b/docs/api_reference/exceptions.rst new file mode 100644 index 000000000..7968f854c --- /dev/null +++ b/docs/api_reference/exceptions.rst @@ -0,0 +1,7 @@ +Exceptions +---------- + +.. automodule:: graphblas.exceptions + :members: InvalidObject, InvalidIndex, DomainMismatch, DimensionMismatch, + OutputNotEmpty, OutOfMemory, IndexOutOfBound, Panic, EmptyObject, + NotImplementedException, UdfParseError diff --git a/docs/api_reference/index.rst b/docs/api_reference/index.rst index 2f829e29a..84e7d65eb 100644 --- a/docs/api_reference/index.rst +++ b/docs/api_reference/index.rst @@ -4,131 +4,10 @@ API Reference ============= -Collections ------------ +.. toctree:: + :maxdepth: 2 -Matrix -~~~~~~ - -.. autoclass:: graphblas.Matrix - :members: - :special-members: __getitem__, __setitem__, __delitem__, __contains__, __iter__ - -Vector -~~~~~~ - -.. autoclass:: graphblas.Vector - :members: - :special-members: __getitem__, __setitem__, __delitem__, __contains__, __iter__ - -Scalar -~~~~~~ - -.. autoclass:: graphblas.Scalar - :members: - :special-members: __eq__, __bool__ - -Operators ---------- - -UnaryOp -~~~~~~~ - -.. autoclass:: graphblas.core.operator.UnaryOp() - :members: - -BinaryOp -~~~~~~~~ - -.. autoclass:: graphblas.core.operator.BinaryOp() - :members: - -Monoid -~~~~~~ - -.. autoclass:: graphblas.core.operator.Monoid() - :members: - -Semiring -~~~~~~~~ - -.. autoclass:: graphblas.core.operator.Semiring() - :members: - -IndexUnaryOp -~~~~~~~~~~~~ - -.. autoclass:: graphblas.core.operator.IndexUnaryOp() - :members: - -SelectOp -~~~~~~~~ - -.. autoclass:: graphblas.core.operator.SelectOp() - :members: - - -Input/Output ------------- - -NetworkX -~~~~~~~~ - -These methods require `networkx `_ to be installed. - -.. autofunction:: graphblas.io.from_networkx - -.. autofunction:: graphblas.io.to_networkx - -Numpy -~~~~~ - -These methods require `scipy `_ to be installed, as some -of the scipy.sparse machinery is used during the conversion process. - -.. autofunction:: graphblas.io.from_numpy - -.. autofunction:: graphblas.io.to_numpy - -Scipy Sparse -~~~~~~~~~~~~ - -These methods require `scipy `_ to be installed. - -.. autofunction:: graphblas.io.from_scipy_sparse - -.. autofunction:: graphblas.io.to_scipy_sparse - -PyData Sparse -~~~~~~~~~~~~~ - -These methods require `sparse `_ to be installed. - -.. autofunction:: graphblas.io.from_pydata_sparse - -.. autofunction:: graphblas.io.to_pydata_sparse - -Matrix Market -~~~~~~~~~~~~~ - -Matrix Market is a `plain-text format `_ for storing graphs. - -These methods require `scipy `_ to be installed. - -.. autofunction:: graphblas.io.mmread - -.. autofunction:: graphblas.io.mmwrite - -Visualization -~~~~~~~~~~~~~ - -.. autofunction:: graphblas.io.draw - - -Exceptions ----------- - -.. automodule:: graphblas.exceptions - :members: InvalidObject, InvalidIndex, DomainMismatch, DimensionMismatch, - OutputNotEmpty, OutOfMemory, IndexOutOfBound, Panic, EmptyObject, - NotImplementedException, UdfParseError + collections + operators + io + exceptions diff --git a/docs/api_reference/io.rst b/docs/api_reference/io.rst new file mode 100644 index 000000000..1b42c0648 --- /dev/null +++ b/docs/api_reference/io.rst @@ -0,0 +1,55 @@ +Input/Output +------------ + +NetworkX +~~~~~~~~ + +These methods require `networkx `_ to be installed. + +.. autofunction:: graphblas.io.from_networkx + +.. autofunction:: graphblas.io.to_networkx + +Numpy +~~~~~ + +These methods require `scipy `_ to be installed, as some +of the scipy.sparse machinery is used during the conversion process. + +.. autofunction:: graphblas.io.from_numpy + +.. autofunction:: graphblas.io.to_numpy + +Scipy Sparse +~~~~~~~~~~~~ + +These methods require `scipy `_ to be installed. + +.. autofunction:: graphblas.io.from_scipy_sparse + +.. autofunction:: graphblas.io.to_scipy_sparse + +PyData Sparse +~~~~~~~~~~~~~ + +These methods require `sparse `_ to be installed. + +.. autofunction:: graphblas.io.from_pydata_sparse + +.. autofunction:: graphblas.io.to_pydata_sparse + +Matrix Market +~~~~~~~~~~~~~ + +Matrix Market is a `plain-text format `_ for storing graphs. + +These methods require `scipy `_ to be installed. + +.. autofunction:: graphblas.io.mmread + +.. autofunction:: graphblas.io.mmwrite + +Visualization +~~~~~~~~~~~~~ + +.. autofunction:: graphblas.io.draw diff --git a/docs/api_reference/operators.rst b/docs/api_reference/operators.rst new file mode 100644 index 000000000..8836bb638 --- /dev/null +++ b/docs/api_reference/operators.rst @@ -0,0 +1,38 @@ +Operators +--------- + +UnaryOp +~~~~~~~ + +.. autoclass:: graphblas.core.operator.UnaryOp() + :members: + +BinaryOp +~~~~~~~~ + +.. autoclass:: graphblas.core.operator.BinaryOp() + :members: + +Monoid +~~~~~~ + +.. autoclass:: graphblas.core.operator.Monoid() + :members: + +Semiring +~~~~~~~~ + +.. autoclass:: graphblas.core.operator.Semiring() + :members: + +IndexUnaryOp +~~~~~~~~~~~~ + +.. autoclass:: graphblas.core.operator.IndexUnaryOp() + :members: + +SelectOp +~~~~~~~~ + +.. autoclass:: graphblas.core.operator.SelectOp() + :members: From 0c1b102bb0699bff76e79dace0c376a73f335fa4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Mar 2023 09:22:15 -0500 Subject: [PATCH 08/87] Bump pypa/gh-action-pypi-publish from 1.7.1 to 1.8.3 (#417) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.7.1 to 1.8.3. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.7.1...v1.8.3) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 3dcf14bbc..a8e6df44e 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.7.1 + uses: pypa/gh-action-pypi-publish@v1.8.3 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 41fb71af7273f213df48cf28340e35c618e386bb Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 28 Mar 2023 13:34:05 -0500 Subject: [PATCH 09/87] Fix infix expression `_value` and `_expr` usage (#418) Previously, `infixexpr._value` was sometimes `MatrixExpression` and sometimes `Matrix`. Scary! --- .pre-commit-config.yaml | 6 +++--- environment.yml | 1 + graphblas/core/expr.py | 31 ++++++++++++++++++++----------- graphblas/core/formatting.py | 1 + graphblas/core/infix.py | 16 ++++++++-------- graphblas/core/recorder.py | 3 ++- graphblas/tests/test_infix.py | 18 ++++++++++++++++++ pyproject.toml | 3 ++- 8 files changed, 55 insertions(+), 24 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab097216e..05469a926 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.12.1 + rev: v0.12.2 hooks: - id: validate-pyproject name: Validate pyproject.toml @@ -47,7 +47,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.257 + rev: v0.0.259 hooks: - id: ruff args: [--fix-only] @@ -75,7 +75,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.257 + rev: v0.0.259 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/environment.yml b/environment.yml index 5ffd588da..41eb3c43d 100644 --- a/environment.yml +++ b/environment.yml @@ -99,6 +99,7 @@ dependencies: # - snakeviz # - sphinx-lint # - sympy + # - tuna # - twine # - vim # - yesqa diff --git a/graphblas/core/expr.py b/graphblas/core/expr.py index affe06112..48839bcff 100644 --- a/graphblas/core/expr.py +++ b/graphblas/core/expr.py @@ -478,33 +478,34 @@ def __bool__(self): class InfixExprBase: - __slots__ = "left", "right", "_value", "__weakref__" + __slots__ = "left", "right", "_expr", "__weakref__" _is_scalar = False def __init__(self, left, right): self.left = left self.right = right - self._value = None + self._expr = None def new(self, dtype=None, *, mask=None, name=None, **opts): if ( mask is None - and self._value is not None - and (dtype is None or self._value.dtype == dtype) + and self._expr is not None + and self._expr._value is not None + and (dtype is None or self._expr._value.dtype == dtype) ): - rv = self._value + rv = self._expr._value if name is not None: rv.name = name - self._value = None + self._expr._value = None return rv expr = self._to_expr() return expr.new(dtype, mask=mask, name=name, **opts) def _to_expr(self): - if self._value is None: + if self._expr is None: # Rely on the default operator for `x @ y` - self._value = getattr(self.left, self.method_name)(self.right) - return self._value + self._expr = getattr(self.left, self.method_name)(self.right) + return self._expr def _get_value(self, attr=None, default=None): expr = self._to_expr() @@ -536,10 +537,18 @@ def __repr__(self): @property def dtype(self): - if self._value is not None: - return self._value.dtype return self._to_expr().dtype + @property + def _value(self): + if self._expr is None: + return None + return self._expr._value + + @_value.setter + def _value(self, val): + self._to_expr()._value = val + # Mistakes utils._output_types[AmbiguousAssignOrExtract] = AmbiguousAssignOrExtract diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index 305df05ae..52b7ed4d0 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -1,3 +1,4 @@ +# This file imports pandas, so it should only be imported when formatting import numpy as np from .. import backend, config, monoid, unary diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index 1fc7caa95..bd1d10a92 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -16,11 +16,11 @@ def _ewise_add_to_expr(self): - if self._value is not None: - return self._value + if self._expr is not None: + return self._expr if self.left.dtype == BOOL and self.right.dtype == BOOL: - self._value = self.left.ewise_add(self.right, lor) - return self._value + self._expr = self.left.ewise_add(self.right, lor) + return self._expr raise TypeError( "Bad dtypes for `x | y`! Automatic computation of `x | y` infix expressions is only valid " f"for BOOL dtypes. The argument dtypes are {self.left.dtype} and {self.right.dtype}.\n\n" @@ -30,11 +30,11 @@ def _ewise_add_to_expr(self): def _ewise_mult_to_expr(self): - if self._value is not None: - return self._value + if self._expr is not None: + return self._expr if self.left.dtype == BOOL and self.right.dtype == BOOL: - self._value = self.left.ewise_mult(self.right, land) - return self._value + self._expr = self.left.ewise_mult(self.right, land) + return self._expr raise TypeError( "Bad dtypes for `x & y`! Automatic computation of `x & y` infix expressions is only valid " f"for BOOL dtypes. The argument dtypes are {self.left.dtype} and {self.right.dtype}.\n\n" diff --git a/graphblas/core/recorder.py b/graphblas/core/recorder.py index 455166544..ce79c85ff 100644 --- a/graphblas/core/recorder.py +++ b/graphblas/core/recorder.py @@ -3,7 +3,6 @@ from ..dtypes import DataType from . import base, lib from .base import _recorder -from .formatting import CSS_STYLE from .mask import Mask from .matrix import TransposedMatrix from .operator import TypedOpBase @@ -103,6 +102,8 @@ def is_recording(self): return self._token is not None and _recorder.get(base._prev_recorder) is self def _repr_base_(self): + from .formatting import CSS_STYLE + status = ( '
Date: Wed, 29 Mar 2023 10:05:18 -0500 Subject: [PATCH 10/87] Improve import times (#419) --- graphblas/core/operator.py | 13 ------------- graphblas/monoid/numpy.py | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/graphblas/core/operator.py b/graphblas/core/operator.py index bfd03d9df..b38add7f1 100644 --- a/graphblas/core/operator.py +++ b/graphblas/core/operator.py @@ -2741,19 +2741,6 @@ def _initialize(cls): # Builtin monoids that are idempotent; i.e., `op(x, x) == x` for any x for name in ["any", "band", "bor", "land", "lor", "max", "min"]: getattr(monoid, name)._is_idempotent = True - for name in [ - "bitwise_and", - "bitwise_or", - "fmax", - "fmin", - "gcd", - "logical_and", - "logical_or", - "maximum", - "minimum", - ]: - getattr(monoid.numpy, name)._is_idempotent = True - # Allow some functions to work on UDTs any_ = monoid.any any_._identity = 0 diff --git a/graphblas/monoid/numpy.py b/graphblas/monoid/numpy.py index 475266d5c..2d8d70c20 100644 --- a/graphblas/monoid/numpy.py +++ b/graphblas/monoid/numpy.py @@ -140,6 +140,19 @@ # _graphblas_to_numpy = {val: key for key, val in _numpy_to_graphblas.items()} # Soon... # Not included: maximum, minimum, gcd, hypot, logaddexp, logaddexp2 +# True if ``monoid(x, x) == x`` for any x. +_idempotent = { + "bitwise_and", + "bitwise_or", + "fmax", + "fmin", + "gcd", + "logical_and", + "logical_or", + "maximum", + "minimum", +} + def __dir__(): return globals().keys() | _delayed.keys() | _monoid_identities.keys() @@ -163,5 +176,7 @@ def __getattr__(name): from ..core import operator func = getattr(_binary.numpy, name) - operator.Monoid.register_new(f"numpy.{name}", func, _monoid_identities[name]) + operator.Monoid.register_new( + f"numpy.{name}", func, _monoid_identities[name], is_idempotent=name in _idempotent + ) return globals()[name] From 40e548ded297d247fac993fd11a2f7fb65174c8d Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Mar 2023 11:56:31 -0500 Subject: [PATCH 11/87] Split operator.py into multiple files (#420) * Split operator.py into multiple files * Ensure tomli is installed when testing * ruff 260 --- .flake8 | 1 + .github/workflows/test_and_build.yml | 2 +- .pre-commit-config.yaml | 16 +- dev-requirements.txt | 1 + environment.yml | 1 + graphblas/agg/__init__.py | 4 +- graphblas/agg/ss.py | 4 +- graphblas/binary/numpy.py | 13 +- graphblas/core/agg.py | 687 +---- graphblas/core/operator.py | 3585 ------------------------- graphblas/core/operator/__init__.py | 21 + graphblas/core/operator/agg.py | 680 +++++ graphblas/core/operator/base.py | 532 ++++ graphblas/core/operator/binary.py | 864 ++++++ graphblas/core/operator/indexunary.py | 357 +++ graphblas/core/operator/monoid.py | 417 +++ graphblas/core/operator/select.py | 187 ++ graphblas/core/operator/semiring.py | 545 ++++ graphblas/core/operator/unary.py | 408 +++ graphblas/core/operator/utils.py | 447 +++ graphblas/monoid/numpy.py | 4 +- graphblas/semiring/numpy.py | 4 +- graphblas/tests/pickle1-vanilla.pkl | Bin 2642 -> 2755 bytes graphblas/tests/pickle1.pkl | Bin 2913 -> 2998 bytes graphblas/tests/pickle2-vanilla.pkl | Bin 1323 -> 1453 bytes graphblas/tests/pickle2.pkl | Bin 1323 -> 1453 bytes graphblas/tests/pickle3-vanilla.pkl | Bin 870 -> 877 bytes graphblas/tests/pickle3.pkl | Bin 875 -> 882 bytes graphblas/tests/test_core.py | 30 + graphblas/tests/test_op.py | 2 + graphblas/unary/numpy.py | 6 +- pyproject.toml | 10 +- scripts/check_versions.sh | 2 +- scripts/create_pickle.py | 4 +- scripts/test_imports.sh | 13 +- 35 files changed, 4552 insertions(+), 4295 deletions(-) delete mode 100644 graphblas/core/operator.py create mode 100644 graphblas/core/operator/__init__.py create mode 100644 graphblas/core/operator/agg.py create mode 100644 graphblas/core/operator/base.py create mode 100644 graphblas/core/operator/binary.py create mode 100644 graphblas/core/operator/indexunary.py create mode 100644 graphblas/core/operator/monoid.py create mode 100644 graphblas/core/operator/select.py create mode 100644 graphblas/core/operator/semiring.py create mode 100644 graphblas/core/operator/unary.py create mode 100644 graphblas/core/operator/utils.py diff --git a/.flake8 b/.flake8 index 0dede3f1d..80124c9e8 100644 --- a/.flake8 +++ b/.flake8 @@ -12,5 +12,6 @@ extend-ignore = per-file-ignores = scripts/create_pickle.py:F403,F405, graphblas/tests/*.py:T201, + graphblas/core/agg.py:F401,F403, graphblas/core/ss/matrix.py:SIM113, graphblas/**/__init__.py:F401, diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 0dfa08859..807123889 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -188,7 +188,7 @@ jobs: echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psgver${psgver}" # 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} \ + mamba install packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli pyyaml${yamlver} sparse${sparsever} \ 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"' || '' }} \ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 05469a926..8eb2bf10b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,6 +24,12 @@ repos: hooks: - id: validate-pyproject name: Validate pyproject.toml + # I don't yet trust ruff to do what autoflake does + - repo: https://github.com/myint/autoflake + rev: v2.0.2 + 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 @@ -42,15 +48,15 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.259 + rev: v0.0.260 hooks: - id: ruff - args: [--fix-only] + args: [--fix-only, --show-fixes] # 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 @@ -60,7 +66,7 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.0.0 - - flake8-bugbear==23.3.12 + - flake8-bugbear==23.3.23 - flake8-simplify==0.19.3 - repo: https://github.com/asottile/yesqa rev: v1.4.0 @@ -75,7 +81,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.259 + rev: v0.0.260 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/dev-requirements.txt b/dev-requirements.txt index 273980db9..a281672ec 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -19,6 +19,7 @@ pre-commit # For testing packaging pytest-cov +tomli # For debugging icecream ipykernel diff --git a/environment.yml b/environment.yml index 41eb3c43d..875ec5cbd 100644 --- a/environment.yml +++ b/environment.yml @@ -36,6 +36,7 @@ dependencies: # For testing - packaging - pytest-cov + - tomli # For debugging - icecream - ipykernel diff --git a/graphblas/agg/__init__.py b/graphblas/agg/__init__.py index f2dddb851..c1319facb 100644 --- a/graphblas/agg/__init__.py +++ b/graphblas/agg/__init__.py @@ -111,6 +111,6 @@ def __getattr__(key): raise AttributeError(f"module {__name__!r} has no attribute {key!r}") -from ..core import agg # noqa: E402 isort:skip +from ..core import operator # noqa: E402 isort:skip -del agg +del operator diff --git a/graphblas/agg/ss.py b/graphblas/agg/ss.py index c3f06c0a7..e45cbcda0 100644 --- a/graphblas/agg/ss.py +++ b/graphblas/agg/ss.py @@ -1,3 +1,3 @@ -from ..core import agg +from ..core import operator -del agg +del operator diff --git a/graphblas/binary/numpy.py b/graphblas/binary/numpy.py index 21ed568ea..68764db05 100644 --- a/graphblas/binary/numpy.py +++ b/graphblas/binary/numpy.py @@ -143,17 +143,18 @@ def __getattr__(name): raise AttributeError(f"module {__name__!r} has no attribute {name!r}") if _config.get("mapnumpy") and name in _numpy_to_graphblas: if name == "float_power": - from ..core import operator + from ..core.operator import binary + from ..dtypes import FP64 - new_op = operator.BinaryOp(f"numpy.{name}") + new_op = binary.BinaryOp(f"numpy.{name}") builtin_op = _binary.pow for dtype in builtin_op.types: if dtype.name in {"FP32", "FC32", "FC64"}: orig_dtype = dtype else: - orig_dtype = operator.FP64 + orig_dtype = FP64 orig_op = builtin_op[orig_dtype] - cur_op = operator.TypedBuiltinBinaryOp( + cur_op = binary.TypedBuiltinBinaryOp( new_op, new_op.name, dtype, @@ -166,14 +167,12 @@ def __getattr__(name): else: globals()[name] = getattr(_binary, _numpy_to_graphblas[name]) else: - from ..core import operator - numpy_func = getattr(_np, name) def func(x, y): # pragma: no cover (numba) return numpy_func(x, y) - operator.BinaryOp.register_new(f"numpy.{name}", func) + _binary.register_new(f"numpy.{name}", func) rv = globals()[name] if name in _commutative: rv._commutes_to = rv diff --git a/graphblas/core/agg.py b/graphblas/core/agg.py index 3afcbc408..3418daffc 100644 --- a/graphblas/core/agg.py +++ b/graphblas/core/agg.py @@ -1,680 +1,17 @@ -from functools import partial -from operator import getitem +"""graphblas.core.agg namespace is deprecated; please use graphblas.core.operator.agg instead. -import numpy as np +.. deprecated:: 2023.3.0 +`graphblas.core.agg` will be removed in a future release. +Use `graphblas.core.operator.agg` instead. +Will be removed in version 2023.11.0 or later. -from .. import agg, backend, binary, monoid, semiring, unary -from ..dtypes import INT64, lookup_dtype -from .utils import output_type +""" +import warnings +from .operator.agg import * -def _get_types(ops, initdtype): - """Determine the input and output types of an aggregator based on a list of ops.""" - if initdtype is None: - prev = dict(ops[0].types) - else: - op = ops[0] - prev = {key: get_typed_op(op, key, initdtype).return_type for key in op.types} - for op in ops[1:]: - cur = {} - types = op.types - for in_type, out_type in prev.items(): - if out_type not in types: # pragma: no cover (safety) - continue - cur[in_type] = types[out_type] - prev = cur - return prev - - -class Aggregator: - opclass = "Aggregator" - - def __init__( - self, - name, - *, - initval=None, - monoid=None, - semiring=None, - switch=False, - semiring2=None, - finalize=None, - composite=None, - custom=None, - types=None, - any_dtype=None, - ): - self.name = name - self._initval_orig = initval - self._initval = False if initval is None else initval - self._initdtype = lookup_dtype(type(self._initval), self._initval) - self._monoid = monoid - self._semiring = semiring - self._semiring2 = semiring2 - self._switch = switch - self._finalize = finalize - self._composite = composite - self._custom = custom - if types is None: - if monoid is not None: - types = [monoid] - elif semiring is not None: - types = [semiring, semiring2] - if finalize is not None: - types.append(finalize) - initval = self._initval - else: # pragma: no cover (sanity) - raise TypeError("types must be provided for composite and custom aggregators") - self._types_orig = types - self._types = None - self._typed_ops = {} - self._any_dtype = any_dtype - - @property - def types(self): - if self._types is None: - if type(self._semiring) is str: - self._semiring = semiring.from_string(self._semiring) - if type(self._types_orig[0]) is str: # pragma: no branch - self._types_orig[0] = semiring.from_string(self._types_orig[0]) - self._types = _get_types( - self._types_orig, None if self._initval_orig is None else self._initdtype - ) - return self._types - - def __getitem__(self, dtype): - dtype = lookup_dtype(dtype) - if not self._any_dtype and dtype not in self.types: - raise KeyError(f"{self.name} does not work with {dtype}") - if dtype not in self._typed_ops: - self._typed_ops[dtype] = TypedAggregator(self, dtype) - return self._typed_ops[dtype] - - def __contains__(self, dtype): - dtype = lookup_dtype(dtype) - return self._any_dtype or dtype in self.types - - def __repr__(self): - if self.name in agg._deprecated: - return f"agg.ss.{self.name}" - return f"agg.{self.name}" - - def __reduce__(self): - if self.name in agg._deprecated: - return f"agg.ss.{self.name}" - return f"agg.{self.name}" - - def __call__(self, val, *, rowwise=False, columnwise=False): - # Should we expose `allow_empty=` keyword when reducing to Scalar? - from .matrix import Matrix, TransposedMatrix - from .vector import Vector - - typ = output_type(val) - if typ is Vector: - if rowwise or columnwise: - raise ValueError( - "rowwise and columnwise arguments should not be used with Vector input" - ) - return val.reduce(self) - if typ in {Matrix, TransposedMatrix}: - if rowwise: - if columnwise: - raise ValueError("rowwise and columnwise arguments cannot both be True") - return val.reduce_rowwise(self) - if columnwise: - return val.reduce_columnwise(self) - return val.reduce_scalar(self) - raise TypeError( - f"Bad type when calling {self!r}.\n" - " - Expected type: Vector, Matrix, TransposedMatrix.\n" - f" - Got: {type(val)}.\n" - "Calling an Aggregator is syntactic sugar for calling reduce methods. " - f"For example, `A.reduce_scalar({self!r})` is the same as `{self!r}(A)`." - ) - - -class TypedAggregator: - opclass = "Aggregator" - - def __init__(self, agg, dtype): - self.name = agg.name - self.parent = agg - self.type = dtype - if dtype in agg.types: - self.return_type = agg.types[dtype] - elif agg._any_dtype is True: - self.return_type = dtype - else: - self.return_type = agg._any_dtype - - def __repr__(self): - return f"agg.{self.name}[{self.type}]" - - def _new(self, updater, expr, *, in_composite=False): - agg = self.parent - if agg._monoid is not None: - x = expr.args[0] - method = getattr(x, expr.method_name) - if expr.output_type.__name__ == "Scalar": - expr = method(agg._monoid[self.type], allow_empty=not expr._is_cscalar) - else: - expr = method(agg._monoid[self.type]) - updater << expr - if in_composite: - parent = updater.parent - if not parent._is_scalar: - return parent - return parent._as_vector() - return - - opts = updater.opts - if agg._composite is not None: - # Masks are applied throughout the aggregation, including composite aggregations. - # Aggregations done while `in_composite is True` should return the updater parent - # if the result is not a Scalar. If the result is a Scalar, then there can be no - # output mask, and a Vector of size 1 should be returned instead. - results = [] - mask = updater.kwargs.get("mask") - for cur_agg in agg._composite: - cur_agg = cur_agg[self.type] # Hopefully works well enough - arg = expr.construct_output(cur_agg.return_type) - results.append(cur_agg._new(arg(mask=mask, **opts), expr, in_composite=True)) - final_expr = agg._finalize(*results, opts) - if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": - updater << final_expr - elif expr.cfunc_name.startswith("GrB_Vector_reduce") or expr.cfunc_name.startswith( - "GrB_Matrix_reduce" - ): - final = final_expr.new(**opts) - updater << final[0] - else: - raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}") - if in_composite: - parent = updater.parent - if not parent._is_scalar: - return parent - return parent._as_vector() - return - - if agg._custom is not None: - return agg._custom(self, updater, expr, opts, in_composite=in_composite) - - semiring = get_typed_op(agg._semiring, self.type, agg._initdtype) - if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": - # Matrix -> Vector - A = expr.args[0] - orig_updater = updater - if agg._finalize is not None: - step1 = expr.construct_output(semiring.return_type) - updater = step1(mask=updater.kwargs.get("mask"), **opts) - if expr.method_name == "reduce_columnwise": - A = A.T - size = A._ncols - init = expr._new_vector(agg._initdtype, size=size) - init(**opts)[...] = agg._initval # O(1) dense vector in SuiteSparse 5 - if agg._switch: - updater << semiring(init @ A.T) - else: - updater << semiring(A @ init) - if agg._finalize is not None: - orig_updater << agg._finalize[semiring.return_type](step1) - if in_composite: - return orig_updater.parent - elif expr.cfunc_name.startswith("GrB_Vector_reduce"): - # Vector -> Scalar - v = expr.args[0] - step1 = expr._new_vector(semiring.return_type, size=1) - init = expr._new_matrix(agg._initdtype, nrows=v._size, ncols=1) - init(**opts)[...] = agg._initval # O(1) dense column vector in SuiteSparse 5 - if agg._switch: - step1(**opts) << semiring(init.T @ v) - else: - step1(**opts) << semiring(v @ init) - if agg._finalize is not None: - finalize = agg._finalize[semiring.return_type] - if step1.dtype == finalize.return_type: - step1(**opts) << finalize(step1) - else: - step1 = finalize(step1).new(finalize.return_type, **opts) - if in_composite: - return step1 - updater << step1[0] - elif expr.cfunc_name.startswith("GrB_Matrix_reduce"): - # Matrix -> Scalar - A = expr.args[0] - # We need to compute in two steps: Matrix -> Vector -> Scalar. - # This has not been benchmarked or optimized. - # We may be able to intelligently choose the faster path. - init1 = expr._new_vector(agg._initdtype, size=A._ncols) - init1(**opts)[...] = agg._initval # O(1) dense vector in SuiteSparse 5 - step1 = expr._new_vector(semiring.return_type, size=A._nrows) - if agg._switch: - step1(**opts) << semiring(init1 @ A.T) - else: - step1(**opts) << semiring(A @ init1) - init2 = expr._new_matrix(agg._initdtype, nrows=A._nrows, ncols=1) - init2(**opts)[...] = agg._initval # O(1) dense vector in SuiteSparse 5 - semiring2 = agg._semiring2[semiring.return_type] - step2 = expr._new_vector(semiring2.return_type, size=1) - step2(**opts) << semiring2(step1 @ init2) - if agg._finalize is not None: - finalize = agg._finalize[semiring2.return_type] - if step2.dtype == finalize.return_type: - step2 << finalize(step2) - else: - step2 = finalize(step2).new(finalize.return_type, **opts) - if in_composite: - return step2 - updater << step2[0] - else: - raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}") - - def __reduce__(self): - return (getitem, (self.parent, self.type)) - - __call__ = Aggregator.__call__ - - -# Monoid-only -agg.sum = Aggregator("sum", monoid=monoid.plus) -agg.prod = Aggregator("prod", monoid=monoid.times) -agg.all = Aggregator("all", monoid=monoid.land) -agg.any = Aggregator("any", monoid=monoid.lor) -agg.min = Aggregator("min", monoid=monoid.min) -agg.max = Aggregator("max", monoid=monoid.max) -agg.any_value = Aggregator("any_value", monoid=monoid.any, any_dtype=True) -agg.bitwise_all = Aggregator("bitwise_all", monoid=monoid.band) -agg.bitwise_any = Aggregator("bitwise_any", monoid=monoid.bor) -# Other monoids: bxnor bxor eq lxnor lxor - -# Semiring-only -agg.count = Aggregator( - "count", semiring=semiring.plus_pair, semiring2=semiring.plus_first, any_dtype=INT64 -) -agg.count_nonzero = Aggregator( - "count_nonzero", semiring=semiring.plus_isne, semiring2=semiring.plus_first -) -agg.count_zero = Aggregator( - "count_zero", semiring=semiring.plus_iseq, semiring2=semiring.plus_first -) -agg.sum_of_squares = Aggregator( - "sum_of_squares", initval=2, semiring=semiring.plus_pow, semiring2=semiring.plus_first -) -agg.sum_of_inverses = Aggregator( - "sum_of_inverses", - initval=-1.0, - semiring=semiring.plus_pow, - semiring2=semiring.plus_first, -) -agg.exists = Aggregator( - "exists", semiring=semiring.any_pair, semiring2=semiring.any_pair, any_dtype=INT64 -) - -# Semiring and finalize -agg.hypot = Aggregator( - "hypot", - initval=2, - semiring=semiring.plus_pow, - semiring2=semiring.plus_first, - finalize=unary.sqrt, -) -agg.logaddexp = Aggregator( - "logaddexp", - initval=np.e, - semiring=semiring.plus_pow, - switch=True, - semiring2=semiring.plus_first, - finalize=unary.log, -) -agg.logaddexp2 = Aggregator( - "logaddexp2", - initval=2, - semiring=semiring.plus_pow, - switch=True, - semiring2=semiring.plus_first, - finalize=unary.log2, -) -# Alternatives -# logaddexp = Aggregator('logaddexp', monoid=semiring.numpy.logaddexp) -# logaddexp2 = Aggregator('logaddexp2', monoid=semiring.numpy.logaddexp2) -# hypot as monoid doesn't work if single negative element! -# hypot = Aggregator('hypot', monoid=semiring.numpy.hypot) - -agg.L0norm = agg.count_nonzero -agg.L1norm = Aggregator("L1norm", semiring="plus_absfirst", semiring2=semiring.plus_first) -agg.L2norm = agg.hypot -agg.Linfnorm = Aggregator("Linfnorm", semiring="max_absfirst", semiring2=semiring.max_first) - - -# Composite -def _mean_finalize(c, x, opts): - return binary.truediv(x & c) - - -def _ptp_finalize(max, min, opts): - return binary.minus(max & min) - - -def _varp_finalize(c, x, x2, opts): - # / n - ( / n)**2 - left = binary.truediv(x2 & c).new(**opts) - right = binary.truediv(x & c).new(**opts) - right(**opts) << binary.pow(right, 2) - return binary.minus(left & right) - - -def _vars_finalize(c, x, x2, opts): - # / (n-1) - **2 / (n * (n-1)) - x(**opts) << binary.pow(x, 2) - right = binary.truediv(x & c).new(**opts) - c(**opts) << binary.minus(c, 1) - right(**opts) << binary.truediv(right & c) - left = binary.truediv(x2 & c).new(**opts) - return binary.minus(left & right) - - -def _stdp_finalize(c, x, x2, opts): - val = _varp_finalize(c, x, x2, opts).new(**opts) - return unary.sqrt(val) - - -def _stds_finalize(c, x, x2, opts): - val = _vars_finalize(c, x, x2, opts).new(**opts) - return unary.sqrt(val) - - -def _geometric_mean_finalize(c, x, opts): - right = unary.minv["FP64"](c).new(**opts) - return binary.pow(x & right) - - -def _harmonic_mean_finalize(c, x, opts): - return binary.truediv(c & x) - - -def _root_mean_square_finalize(c, x2, opts): - val = binary.truediv(x2 & c).new(**opts) - return unary.sqrt(val) - - -agg.mean = Aggregator( - "mean", - composite=[agg.count, agg.sum], - finalize=_mean_finalize, - types=[binary.truediv], -) -agg.peak_to_peak = Aggregator( - "peak_to_peak", - composite=[agg.max, agg.min], - finalize=_ptp_finalize, - types=[monoid.min], -) -agg.varp = Aggregator( - "varp", - composite=[agg.count, agg.sum, agg.sum_of_squares], - finalize=_varp_finalize, - types=[binary.truediv], -) -agg.vars = Aggregator( - "vars", - composite=[agg.count, agg.sum, agg.sum_of_squares], - finalize=_vars_finalize, - types=[binary.truediv], +warnings.warn( + "graphblas.core.agg namespace is deprecated; please use graphblas.core.operator.agg instead.", + DeprecationWarning, + stacklevel=1, ) -agg.stdp = Aggregator( - "stdp", - composite=[agg.count, agg.sum, agg.sum_of_squares], - finalize=_stdp_finalize, - types=[binary.truediv, unary.sqrt], -) -agg.stds = Aggregator( - "stds", - composite=[agg.count, agg.sum, agg.sum_of_squares], - finalize=_stds_finalize, - types=[binary.truediv, unary.sqrt], -) -agg.geometric_mean = Aggregator( - "geometric_mean", - composite=[agg.count, agg.prod], - finalize=_geometric_mean_finalize, - types=[binary.truediv], -) -agg.harmonic_mean = Aggregator( - "harmonic_mean", - composite=[agg.count, agg.sum_of_inverses], - finalize=_harmonic_mean_finalize, - types=[agg.sum_of_inverses, binary.truediv], -) -agg.root_mean_square = Aggregator( - "root_mean_square", - composite=[agg.count, agg.sum_of_squares], - finalize=_root_mean_square_finalize, - types=[binary.truediv, unary.sqrt], -) - - -# Special recipes -def _argminmaxij( - agg, - updater, - expr, - opts, - *, - in_composite, - monoid, - col_semiring, - row_semiring, -): - if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": - A = expr.args[0] - if expr.method_name == "reduce_rowwise": - step1 = A.reduce_rowwise(monoid).new(**opts) - - D = step1.diag() - - masked = semiring.any_eq(D @ A).new(**opts) - masked(mask=masked.V, replace=True, **opts) << masked # Could use select - init = expr._new_vector(bool, size=A._ncols) - init(**opts)[...] = False # O(1) dense vector in SuiteSparse 5 - updater << row_semiring(masked @ init) - if in_composite: - return updater.parent - else: - step1 = A.reduce_columnwise(monoid).new(**opts) - - D = step1.diag() - - masked = semiring.any_eq(A @ D).new(**opts) - masked(mask=masked.V, replace=True, **opts) << masked # Could use select - init = expr._new_vector(bool, size=A._nrows) - init(**opts)[...] = False # O(1) dense vector in SuiteSparse 5 - updater << col_semiring(init @ masked) - if in_composite: - return updater.parent - elif expr.cfunc_name.startswith("GrB_Vector_reduce"): - v = expr.args[0] - step1 = v.reduce(monoid, allow_empty=False).new(**opts) - masked = binary.eq(v, step1).new(**opts) - masked(mask=masked.V, replace=True, **opts) << masked # Could use select - init = expr._new_matrix(bool, nrows=v._size, ncols=1) - init(**opts)[...] = False # O(1) dense column vector in SuiteSparse 5 - step2 = col_semiring(masked @ init).new(**opts) - if in_composite: - return step2 - updater << step2[0] - else: - raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}") - - -def _argminmax(agg, updater, expr, opts, *, in_composite, monoid): - if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": - if expr.method_name == "reduce_rowwise": - return _argminmaxij( - agg, - updater, - expr, - opts, - in_composite=in_composite, - monoid=monoid, - row_semiring=semiring._deprecated["min_firstj"], - col_semiring=semiring._deprecated["min_secondj"], - ) - return _argminmaxij( - agg, - updater, - expr, - opts, - in_composite=in_composite, - monoid=monoid, - row_semiring=semiring._deprecated["min_firsti"], - col_semiring=semiring._deprecated["min_secondi"], - ) - if expr.cfunc_name.startswith("GrB_Vector_reduce"): - return _argminmaxij( - agg, - updater, - expr, - opts, - in_composite=in_composite, - monoid=monoid, - row_semiring=semiring._deprecated["min_firsti"], - col_semiring=semiring._deprecated["min_secondi"], - ) - if expr.cfunc_name.startswith("GrB_Matrix_reduce"): - raise ValueError(f"Aggregator {agg.name} may not be used with Matrix.reduce_scalar.") - raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}") - - -# These "do the right thing", but don't work with `reduce_scalar` -_argmin = Aggregator( - "argmin", - custom=partial(_argminmax, monoid=monoid.min), - types=[semiring._deprecated["min_firsti"]], -) -_argmax = Aggregator( - "argmax", - custom=partial(_argminmax, monoid=monoid.max), - types=[semiring._deprecated["min_firsti"]], -) - - -def _first_last(agg, updater, expr, opts, *, in_composite, semiring_): - if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": - A = expr.args[0] - if expr.method_name == "reduce_columnwise": - A = A.T - init = expr._new_vector(bool, size=A._ncols) - init(**opts)[...] = False # O(1) dense vector in SuiteSparse 5 - step1 = semiring_(A @ init).new(**opts) - Is, Js = step1.to_coo() - - Matrix_ = type(expr._new_matrix(bool)) - P = Matrix_.from_coo(Js, Is, 1, nrows=A._ncols, ncols=A._nrows) - mask = step1.diag() - result = semiring.any_first(A @ P).new(mask=mask.S, **opts).diag(**opts) - - updater << result - if in_composite: - return updater.parent - elif expr.cfunc_name.startswith("GrB_Vector_reduce"): - v = expr.args[0] - init = expr._new_matrix(bool, nrows=v._size, ncols=1) - init(**opts)[...] = False # O(1) dense matrix in SuiteSparse 5 - step1 = semiring_(v @ init).new(**opts) - index = step1[0].new().value - # `==` instead of `is` automatically triggers index.compute() in dask-graphblas: - if index == None: # noqa: E711 - index = 0 - if in_composite: - return v[[index]].new(**opts) - updater << v[index] - else: # GrB_Matrix_reduce - A = expr.args[0] - init1 = expr._new_matrix(bool, nrows=A._ncols, ncols=1) - init1(**opts)[...] = False # O(1) dense matrix in SuiteSparse 5 - step1 = semiring_(A @ init1).new(**opts) - init2 = expr._new_vector(bool, size=A._nrows) - init2(**opts)[...] = False # O(1) dense vector in SuiteSparse 5 - step2 = semiring_(step1.T @ init2).new(**opts) - i = step2[0].new().value - # `==` instead of `is` automatically triggers i.compute() in dask-graphblas: - if i == None: # noqa: E711 - i = j = 0 - else: - j = step1[i, 0].new().value - if in_composite: - return A[i, [j]].new(**opts) - updater << A[i, j] - - -_first = Aggregator( - "first", - custom=partial(_first_last, semiring_=semiring._deprecated["min_secondi"]), - types=[binary.first], - any_dtype=True, -) -_last = Aggregator( - "last", - custom=partial(_first_last, semiring_=semiring._deprecated["max_secondi"]), - types=[binary.second], - any_dtype=True, -) - - -def _first_last_index(agg, updater, expr, opts, *, in_composite, semiring): - if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": - A = expr.args[0] - if expr.method_name == "reduce_columnwise": - A = A.T - init = expr._new_vector(bool, size=A._ncols) - init(**opts)[...] = False # O(1) dense vector in SuiteSparse 5 - expr = semiring(A @ init) - updater << expr - if in_composite: - return updater.parent - elif expr.cfunc_name.startswith("GrB_Vector_reduce"): - v = expr.args[0] - init = expr._new_matrix(bool, nrows=v._size, ncols=1) - init(**opts)[...] = False # O(1) dense matrix in SuiteSparse 5 - step1 = semiring(v @ init).new(**opts) - if in_composite: - return step1 - updater << step1[0] - elif expr.cfunc_name.startswith("GrB_Matrix_reduce"): - raise ValueError(f"Aggregator {agg.name} may not be used with Matrix.reduce_scalar.") - else: - raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}") - - -_first_index = Aggregator( - "first_index", - custom=partial(_first_last_index, semiring=semiring._deprecated["min_secondi"]), - types=[semiring._deprecated["min_secondi"]], - any_dtype=INT64, -) -_last_index = Aggregator( - "last_index", - custom=partial(_first_last_index, semiring=semiring._deprecated["max_secondi"]), - types=[semiring._deprecated["min_secondi"]], - any_dtype=INT64, -) -agg._deprecated = { - "argmin": _argmin, - "argmax": _argmax, - "first": _first, - "last": _last, - "first_index": _first_index, - "last_index": _last_index, -} -if backend == "suitesparse": - agg.ss.argmin = _argmin - agg.ss.argmax = _argmax - agg.ss.first = _first - agg.ss.last = _last - agg.ss.first_index = _first_index - agg.ss.last_index = _last_index - -agg.Aggregator = Aggregator -agg.TypedAggregator = TypedAggregator - -from .operator import get_typed_op # noqa: E402 isort:skip diff --git a/graphblas/core/operator.py b/graphblas/core/operator.py deleted file mode 100644 index b38add7f1..000000000 --- a/graphblas/core/operator.py +++ /dev/null @@ -1,3585 +0,0 @@ -import inspect -import itertools -import re -from collections.abc import Mapping -from functools import lru_cache, reduce -from operator import getitem, mul -from types import BuiltinFunctionType, FunctionType, ModuleType - -import numba -import numpy as np - -from .. import ( - _STANDARD_OPERATOR_NAMES, - backend, - binary, - config, - indexunary, - monoid, - op, - select, - semiring, - unary, -) -from ..dtypes import ( - BOOL, - FP32, - FP64, - INT8, - INT16, - INT32, - INT64, - UINT8, - UINT16, - UINT32, - UINT64, - _sample_values, - _supports_complex, - lookup_dtype, - unify, -) -from ..exceptions import UdfParseError, check_status_carg -from . import ffi, lib -from .expr import InfixExprBase -from .utils import libget, output_type - -if _supports_complex: - from ..dtypes import FC32, FC64 - -ffi_new = ffi.new -UNKNOWN_OPCLASS = "UnknownOpClass" - -# These now live as e.g. `gb.unary.ss.positioni` -# Deprecations such as `gb.unary.positioni` will be removed in 2023.9.0 or later. -_SS_OPERATORS = { - # unary - "erf", # scipy.special.erf - "erfc", # scipy.special.erfc - "frexpe", # np.frexp[1] - "frexpx", # np.frexp[0] - "lgamma", # scipy.special.loggamma - "tgamma", # scipy.special.gamma - # Positional - # unary - "positioni", - "positioni1", - "positionj", - "positionj1", - # binary - "firsti", - "firsti1", - "firstj", - "firstj1", - "secondi", - "secondi1", - "secondj", - "secondj1", - # semiring - "any_firsti", - "any_firsti1", - "any_firstj", - "any_firstj1", - "any_secondi", - "any_secondi1", - "any_secondj", - "any_secondj1", - "max_firsti", - "max_firsti1", - "max_firstj", - "max_firstj1", - "max_secondi", - "max_secondi1", - "max_secondj", - "max_secondj1", - "min_firsti", - "min_firsti1", - "min_firstj", - "min_firstj1", - "min_secondi", - "min_secondi1", - "min_secondj", - "min_secondj1", - "plus_firsti", - "plus_firsti1", - "plus_firstj", - "plus_firstj1", - "plus_secondi", - "plus_secondi1", - "plus_secondj", - "plus_secondj1", - "times_firsti", - "times_firsti1", - "times_firstj", - "times_firstj1", - "times_secondi", - "times_secondi1", - "times_secondj", - "times_secondj1", -} - - -def _hasop(module, name): - return ( - name in module.__dict__ - or name in module._delayed - or name in getattr(module, "_deprecated", ()) - ) - - -class OpPath: - def __init__(self, parent, name): - self._parent = parent - self._name = name - self._delayed = {} - self._delayed_commutes_to = {} - - def __getattr__(self, key): - if key in self._delayed: - func, kwargs = self._delayed.pop(key) - return func(**kwargs) - self.__getattribute__(key) # raises - - -def _call_op(op, left, right=None, thunk=None, **kwargs): - if right is None and thunk is None: - if isinstance(left, InfixExprBase): - # op(A & B), op(A | B), op(A @ B) - return getattr(left.left, left.method_name)(left.right, op, **kwargs) - if find_opclass(op)[1] == "Semiring": - raise TypeError( - f"Bad type when calling {op!r}. Got type: {type(left)}.\n" - f"Expected an infix expression, such as: {op!r}(A @ B)" - ) - raise TypeError( - f"Bad type when calling {op!r}. Got type: {type(left)}.\n" - "Expected an infix expression or an apply with a Vector or Matrix and a scalar:\n" - f" - {op!r}(A & B)\n" - f" - {op!r}(A, 1)\n" - f" - {op!r}(1, A)" - ) - - # op(A, 1) -> apply (or select if thunk provided) - from .matrix import Matrix, TransposedMatrix - from .vector import Vector - - 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 (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" - f" - {op!r}(A & B)\n" - f" - {op!r}(A, 1)\n" - f" - {op!r}(1, A)" - ) - - -_udt_mask_cache = {} - - -def _udt_mask(dtype): - """Create mask to determine which bytes of UDTs to use for equality check.""" - if dtype in _udt_mask_cache: - return _udt_mask_cache[dtype] - if dtype.subdtype is not None: - mask = _udt_mask(dtype.subdtype[0]) - N = reduce(mul, dtype.subdtype[1]) - rv = np.concatenate([mask] * N) - elif dtype.names is not None: - prev_offset = mask = None - masks = [] - for name in dtype.names: - dtype2, offset = dtype.fields[name] - if mask is not None: - masks.append(np.pad(mask, (0, offset - prev_offset - mask.size))) - mask = _udt_mask(dtype2) - prev_offset = offset - masks.append(np.pad(mask, (0, dtype.itemsize - prev_offset - mask.size))) - rv = np.concatenate(masks) - else: - rv = np.ones(dtype.itemsize, dtype=bool) - # assert rv.size == dtype.itemsize - _udt_mask_cache[dtype] = rv - return rv - - -class TypedOpBase: - __slots__ = ( - "parent", - "name", - "type", - "return_type", - "gb_obj", - "gb_name", - "_type2", - "__weakref__", - ) - - def __init__(self, parent, name, type_, return_type, gb_obj, gb_name, dtype2=None): - self.parent = parent - self.name = name - self.type = type_ - self.return_type = return_type - self.gb_obj = gb_obj - self.gb_name = gb_name - self._type2 = dtype2 - - def __repr__(self): - classname = self.opclass.lower() - if classname.endswith("op"): - classname = classname[:-2] - dtype2 = "" if self._type2 is None else f", {self._type2.name}" - return f"{classname}.{self.name}[{self.type.name}{dtype2}]" - - @property - def _carg(self): - return self.gb_obj - - @property - def is_positional(self): - return self.parent.is_positional - - def __reduce__(self): - if self._type2 is None or self.type == self._type2: - return (getitem, (self.parent, self.type)) - return (getitem, (self.parent, (self.type, self._type2))) - - -class TypedBuiltinUnaryOp(TypedOpBase): - __slots__ = () - opclass = "UnaryOp" - - def __call__(self, val): - from .matrix import Matrix, TransposedMatrix - from .vector import Vector - - 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: 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)`." - ) - - -class TypedBuiltinIndexUnaryOp(TypedOpBase): - __slots__ = () - opclass = "IndexUnaryOp" - - def __call__(self, val, thunk=None): - if thunk is None: - thunk = False # most basic form of 0 when unifying dtypes - return _call_op(self, val, right=thunk) - - -class TypedBuiltinSelectOp(TypedOpBase): - __slots__ = () - opclass = "SelectOp" - - def __call__(self, val, thunk=None): - if thunk is None: - thunk = False # most basic form of 0 when unifying dtypes - return _call_op(self, val, thunk=thunk) - - -class TypedBuiltinBinaryOp(TypedOpBase): - __slots__ = () - opclass = "BinaryOp" - - def __call__(self, left, right=None, *, left_default=None, right_default=None): - if left_default is not None or right_default is not None: - if ( - left_default is None - or right_default is None - or right is not None - or not isinstance(left, InfixExprBase) - or left.method_name != "ewise_add" - ): - raise TypeError( - "Specifying `left_default` or `right_default` keyword arguments implies " - "performing `ewise_union` operation with infix notation.\n" - "There is only one valid way to do this:\n\n" - f">>> {self}(x | y, left_default=0, right_default=0)\n\nwhere x and y " - "are Vectors or Matrices, and left_default and right_default are scalars." - ) - return left.left.ewise_union(left.right, self, left_default, right_default) - return _call_op(self, left, right) - - @property - def monoid(self): - rv = getattr(monoid, self.name, None) - if rv is not None and self.type in rv._typed_ops: - return rv[self.type] - - @property - def commutes_to(self): - commutes_to = self.parent.commutes_to - if commutes_to is not None and (self.type in commutes_to._typed_ops or self.type._is_udt): - return commutes_to[self.type] - - @property - def _semiring_commutes_to(self): - commutes_to = self.parent._semiring_commutes_to - if commutes_to is not None and (self.type in commutes_to._typed_ops or self.type._is_udt): - return commutes_to[self.type] - - @property - def is_commutative(self): - return self.commutes_to is self - - @property - def type2(self): - return self.type if self._type2 is None else self._type2 - - -class TypedBuiltinMonoid(TypedOpBase): - __slots__ = "_identity" - opclass = "Monoid" - is_commutative = True - - def __init__(self, parent, name, type_, return_type, gb_obj, gb_name): - super().__init__(parent, name, type_, return_type, gb_obj, gb_name) - self._identity = None - - def __call__(self, left, right=None, *, left_default=None, right_default=None): - if left_default is not None or right_default is not None: - if ( - left_default is None - or right_default is None - or right is not None - or not isinstance(left, InfixExprBase) - or left.method_name != "ewise_add" - ): - raise TypeError( - "Specifying `left_default` or `right_default` keyword arguments implies " - "performing `ewise_union` operation with infix notation.\n" - "There is only one valid way to do this:\n\n" - f">>> {self}(x | y, left_default=0, right_default=0)\n\nwhere x and y " - "are Vectors or Matrices, and left_default and right_default are scalars." - ) - return left.left.ewise_union(left.right, self, left_default, right_default) - return _call_op(self, left, right) - - @property - def identity(self): - if self._identity is None: - from .recorder import skip_record - from .vector import Vector - - with skip_record: - self._identity = ( - Vector(self.type, size=1, name="").reduce(self, allow_empty=False).new().value - ) - return self._identity - - @property - def binaryop(self): - return getattr(binary, self.name)[self.type] - - @property - def commutes_to(self): - return self - - @property - def type2(self): - return self.type - - @property - def is_idempotent(self): - """True if ``monoid(x, x) == x`` for any x.""" - return self.parent.is_idempotent - - -class TypedBuiltinSemiring(TypedOpBase): - __slots__ = () - opclass = "Semiring" - - def __call__(self, left, right=None): - if right is not None: - raise TypeError( - f"Bad types when calling {self!r}. Got types: {type(left)}, {type(right)}.\n" - f"Expected an infix expression, such as: {self!r}(A @ B)" - ) - return _call_op(self, left) - - @property - def binaryop(self): - name = self.name.split("_", 1)[1] - if name in _SS_OPERATORS: - binop = binary._deprecated[name] - else: - binop = getattr(binary, name) - return binop[self.type] - - @property - def monoid(self): - monoid_name, binary_name = self.name.split("_", 1) - if binary_name in _SS_OPERATORS: - binop = binary._deprecated[binary_name] - else: - binop = getattr(binary, binary_name) - binop = binop[self.type] - val = getattr(monoid, monoid_name) - return val[binop.return_type] - - @property - def commutes_to(self): - binop = self.binaryop - commutes_to = binop._semiring_commutes_to or binop.commutes_to - if commutes_to is None: - return - if commutes_to is binop: - return self - return get_semiring(self.monoid, commutes_to) - - @property - def is_commutative(self): - return self.binaryop.is_commutative - - type2 = TypedBuiltinBinaryOp.type2 - - -class TypedUserUnaryOp(TypedOpBase): - __slots__ = () - opclass = "UnaryOp" - - def __init__(self, parent, name, type_, return_type, gb_obj): - super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}") - - @property - def orig_func(self): - return self.parent.orig_func - - @property - def _numba_func(self): - return self.parent._numba_func - - __call__ = TypedBuiltinUnaryOp.__call__ - - -class TypedUserIndexUnaryOp(TypedOpBase): - __slots__ = () - opclass = "IndexUnaryOp" - - def __init__(self, parent, name, type_, return_type, gb_obj, dtype2=None): - super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}", dtype2=dtype2) - - @property - def orig_func(self): - return self.parent.orig_func - - @property - def _numba_func(self): - return self.parent._numba_func - - __call__ = TypedBuiltinIndexUnaryOp.__call__ - - -class TypedUserSelectOp(TypedOpBase): - __slots__ = () - opclass = "SelectOp" - - def __init__(self, parent, name, type_, return_type, gb_obj): - super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}") - - @property - def orig_func(self): - return self.parent.orig_func - - @property - def _numba_func(self): - return self.parent._numba_func - - __call__ = TypedBuiltinSelectOp.__call__ - - -class TypedUserBinaryOp(TypedOpBase): - __slots__ = "_monoid" - opclass = "BinaryOp" - - def __init__(self, parent, name, type_, return_type, gb_obj, dtype2=None): - super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}", dtype2=dtype2) - self._monoid = None - - @property - def monoid(self): - if self._monoid is None: - monoid = self.parent.monoid - if monoid is not None and self.type in monoid: - self._monoid = monoid[self.type] - return self._monoid - - commutes_to = TypedBuiltinBinaryOp.commutes_to - _semiring_commutes_to = TypedBuiltinBinaryOp._semiring_commutes_to - is_commutative = TypedBuiltinBinaryOp.is_commutative - orig_func = TypedUserUnaryOp.orig_func - _numba_func = TypedUserUnaryOp._numba_func - type2 = TypedBuiltinBinaryOp.type2 - __call__ = TypedBuiltinBinaryOp.__call__ - - -class TypedUserMonoid(TypedOpBase): - __slots__ = "binaryop", "identity" - opclass = "Monoid" - is_commutative = True - - def __init__(self, parent, name, type_, return_type, gb_obj, binaryop, identity): - super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}") - self.binaryop = binaryop - self.identity = identity - binaryop._monoid = self - - commutes_to = TypedBuiltinMonoid.commutes_to - type2 = TypedBuiltinMonoid.type2 - is_idempotent = TypedBuiltinMonoid.is_idempotent - __call__ = TypedBuiltinMonoid.__call__ - - -class TypedUserSemiring(TypedOpBase): - __slots__ = "monoid", "binaryop" - opclass = "Semiring" - - def __init__(self, parent, name, type_, return_type, gb_obj, monoid, binaryop, dtype2=None): - super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}", dtype2=dtype2) - self.monoid = monoid - self.binaryop = binaryop - - commutes_to = TypedBuiltinSemiring.commutes_to - is_commutative = TypedBuiltinSemiring.is_commutative - type2 = TypedBuiltinBinaryOp.type2 - __call__ = TypedBuiltinSemiring.__call__ - - -def _deserialize_parameterized(parameterized_op, args, kwargs): - return parameterized_op(*args, **kwargs) - - -class ParameterizedUdf: - __slots__ = "name", "__call__", "_anonymous", "__weakref__" - is_positional = False - _custom_dtype = None - - def __init__(self, name, anonymous): - self.name = name - self._anonymous = anonymous - # lru_cache per instance - method = self._call.__get__(self, type(self)) - self.__call__ = lru_cache(maxsize=1024)(method) - - def _call(self, *args, **kwargs): - raise NotImplementedError - - -class ParameterizedUnaryOp(ParameterizedUdf): - __slots__ = "func", "__signature__", "_is_udt" - - def __init__(self, name, func, *, anonymous=False, is_udt=False): - self.func = func - self.__signature__ = inspect.signature(func) - self._is_udt = is_udt - if name is None: - name = getattr(func, "__name__", name) - super().__init__(name, anonymous) - - def _call(self, *args, **kwargs): - unary = self.func(*args, **kwargs) - unary._parameterized_info = (self, args, kwargs) - return UnaryOp.register_anonymous(unary, self.name, is_udt=self._is_udt) - - def __reduce__(self): - name = f"unary.{self.name}" - if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: # pragma: no cover - return name - return (self._deserialize, (self.name, self.func, self._anonymous)) - - @staticmethod - def _deserialize(name, func, anonymous): - if anonymous: - return UnaryOp.register_anonymous(func, name, parameterized=True) - if (rv := UnaryOp._find(name)) is not None: - return rv - return UnaryOp.register_new(name, func, parameterized=True) - - -class ParameterizedIndexUnaryOp(ParameterizedUdf): - __slots__ = "func", "__signature__", "_is_udt" - - def __init__(self, name, func, *, anonymous=False, is_udt=False): - self.func = func - self.__signature__ = inspect.signature(func) - self._is_udt = is_udt - if name is None: - name = getattr(func, "__name__", name) - super().__init__(name, anonymous) - - def _call(self, *args, **kwargs): - indexunary = self.func(*args, **kwargs) - indexunary._parameterized_info = (self, args, kwargs) - return IndexUnaryOp.register_anonymous(indexunary, self.name, is_udt=self._is_udt) - - def __reduce__(self): - name = f"indexunary.{self.name}" - if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: - return name - return (self._deserialize, (self.name, self.func, self._anonymous)) - - @staticmethod - def _deserialize(name, func, anonymous): - if anonymous: - return IndexUnaryOp.register_anonymous(func, name, parameterized=True) - if (rv := IndexUnaryOp._find(name)) is not None: - return rv - return IndexUnaryOp.register_new(name, func, parameterized=True) - - -class ParameterizedSelectOp(ParameterizedUdf): - __slots__ = "func", "__signature__", "_is_udt" - - def __init__(self, name, func, *, anonymous=False, is_udt=False): - self.func = func - self.__signature__ = inspect.signature(func) - self._is_udt = is_udt - if name is None: - name = getattr(func, "__name__", name) - super().__init__(name, anonymous) - - def _call(self, *args, **kwargs): - sel = self.func(*args, **kwargs) - sel._parameterized_info = (self, args, kwargs) - return SelectOp.register_anonymous(sel, self.name, is_udt=self._is_udt) - - def __reduce__(self): - name = f"select.{self.name}" - if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: - return name - return (self._deserialize, (self.name, self.func, self._anonymous)) - - @staticmethod - def _deserialize(name, func, anonymous): - if anonymous: - return SelectOp.register_anonymous(func, name, parameterized=True) - if (rv := SelectOp._find(name)) is not None: - return rv - return SelectOp.register_new(name, func, parameterized=True) - - -class ParameterizedBinaryOp(ParameterizedUdf): - __slots__ = "func", "__signature__", "_monoid", "_cached_call", "_commutes_to", "_is_udt" - - def __init__(self, name, func, *, anonymous=False, is_udt=False): - self.func = func - self.__signature__ = inspect.signature(func) - self._monoid = None - self._is_udt = is_udt - if name is None: - name = getattr(func, "__name__", name) - super().__init__(name, anonymous) - method = self._call_to_cache.__get__(self, type(self)) - self._cached_call = lru_cache(maxsize=1024)(method) - self.__call__ = self._call - self._commutes_to = None - - def _call_to_cache(self, *args, **kwargs): - binary = self.func(*args, **kwargs) - binary._parameterized_info = (self, args, kwargs) - return BinaryOp.register_anonymous(binary, self.name, is_udt=self._is_udt) - - def _call(self, *args, **kwargs): - binop = self._cached_call(*args, **kwargs) - if self._monoid is not None and binop._monoid is None: - # This is all a bit funky. We try our best to associate a binaryop - # to a monoid. So, if we made a ParameterizedMonoid using this object, - # then try to create a monoid with the given arguments. - binop._monoid = binop # temporary! - try: - # If this call is successful, then it will set `binop._monoid` - self._monoid(*args, **kwargs) # pylint: disable=not-callable - except Exception: - binop._monoid = None - # assert binop._monoid is not binop - if self.is_commutative: - binop._commutes_to = binop - # Don't bother yet with creating `binop.commutes_to` (but we could!) - return binop - - @property - def monoid(self): - return self._monoid - - @property - def commutes_to(self): - if type(self._commutes_to) is str: - self._commutes_to = BinaryOp._find(self._commutes_to) - return self._commutes_to - - is_commutative = TypedBuiltinBinaryOp.is_commutative - - def __reduce__(self): - name = f"binary.{self.name}" - if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: - return name - return (self._deserialize, (self.name, self.func, self._anonymous)) - - @staticmethod - def _deserialize(name, func, anonymous): - if anonymous: - return BinaryOp.register_anonymous(func, name, parameterized=True) - if (rv := BinaryOp._find(name)) is not None: - return rv - return BinaryOp.register_new(name, func, parameterized=True) - - -class ParameterizedMonoid(ParameterizedUdf): - __slots__ = "binaryop", "identity", "_is_idempotent", "__signature__" - is_commutative = True - - def __init__(self, name, binaryop, identity, *, is_idempotent=False, anonymous=False): - if type(binaryop) is not ParameterizedBinaryOp: - raise TypeError("binaryop must be parameterized") - self.binaryop = binaryop - self.__signature__ = binaryop.__signature__ - if callable(identity): - # assume it must be parameterized as well, so signature must match - sig = inspect.signature(identity) - if sig != self.__signature__: - raise ValueError( - "Signatures of binaryop and identity passed to " - f"{type(self).__name__} must be the same. Got:\n" - f" binaryop{self.__signature__}\n" - " !=\n" - f" identity{sig}" - ) - self.identity = identity - self._is_idempotent = is_idempotent - if name is None: - name = binaryop.name - super().__init__(name, anonymous) - binaryop._monoid = self - # clear binaryop cache so it can be associated with this monoid - binaryop._cached_call.cache_clear() - - def _call(self, *args, **kwargs): - binary = self.binaryop(*args, **kwargs) - identity = self.identity - if callable(identity): - identity = identity(*args, **kwargs) - return Monoid.register_anonymous( - binary, identity, self.name, is_idempotent=self._is_idempotent - ) - - commutes_to = TypedBuiltinMonoid.commutes_to - - @property - def is_idempotent(self): - """True if ``monoid(x, x) == x`` for any x.""" - return self._is_idempotent - - def __reduce__(self): - name = f"monoid.{self.name}" - if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: # pragma: no cover - return name - return (self._deserialize, (self.name, self.binaryop, self.identity, self._anonymous)) - - @staticmethod - def _deserialize(name, binaryop, identity, anonymous): - if anonymous: - return Monoid.register_anonymous(binaryop, identity, name) - if (rv := Monoid._find(name)) is not None: - return rv - return Monoid.register_new(name, binaryop, identity) - - -class ParameterizedSemiring(ParameterizedUdf): - __slots__ = "monoid", "binaryop", "__signature__" - - def __init__(self, name, monoid, binaryop, *, anonymous=False): - if type(monoid) not in {ParameterizedMonoid, Monoid}: - raise TypeError("monoid must be of type Monoid or ParameterizedMonoid") - if type(binaryop) is ParameterizedBinaryOp: - self.__signature__ = binaryop.__signature__ - if type(monoid) is ParameterizedMonoid and monoid.__signature__ != self.__signature__: - raise ValueError( - "Signatures of monoid and binaryop passed to " - f"{type(self).__name__} must be the same. Got:\n" - f" monoid{monoid.__signature__}\n" - " !=\n" - f" binaryop{self.__signature__}\n\n" - "Perhaps call monoid or binaryop with parameters before creating the semiring." - ) - elif type(binaryop) is BinaryOp: - if type(monoid) is Monoid: - raise TypeError("At least one of monoid or binaryop must be parameterized") - self.__signature__ = monoid.__signature__ - else: - raise TypeError("binaryop must be of type BinaryOp or ParameterizedBinaryOp") - self.monoid = monoid - self.binaryop = binaryop - if name is None: - name = f"{monoid.name}_{binaryop.name}" - super().__init__(name, anonymous) - - def _call(self, *args, **kwargs): - monoid = self.monoid - if type(monoid) is ParameterizedMonoid: - monoid = monoid(*args, **kwargs) - binary = self.binaryop - if type(binary) is ParameterizedBinaryOp: - binary = binary(*args, **kwargs) - return Semiring.register_anonymous(monoid, binary, self.name) - - commutes_to = TypedBuiltinSemiring.commutes_to - is_commutative = TypedBuiltinSemiring.is_commutative - - def __reduce__(self): - name = f"semiring.{self.name}" - if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: # pragma: no cover - return name - return (self._deserialize, (self.name, self.monoid, self.binaryop, self._anonymous)) - - @staticmethod - def _deserialize(name, monoid, binaryop, anonymous): - if anonymous: - return Semiring.register_anonymous(monoid, binaryop, name) - if (rv := Semiring._find(name)) is not None: - return rv - return Semiring.register_new(name, monoid, binaryop) - - -_VARNAMES = tuple(x for x in dir(lib) if x[0] != "_") - - -class OpBase: - __slots__ = ( - "name", - "_typed_ops", - "types", - "coercions", - "_anonymous", - "_udt_types", - "_udt_ops", - "__weakref__", - ) - _parse_config = None - _initialized = False - _module = None - _positional = None - - def __init__(self, name, *, anonymous=False): - self.name = name - self._typed_ops = {} - self.types = {} - self.coercions = {} - self._anonymous = anonymous - self._udt_types = None - self._udt_ops = None - - def __repr__(self): - return f"{self._modname}.{self.name}" - - def __getitem__(self, type_): - if type(type_) is tuple: - dtype1, dtype2 = type_ - dtype1 = lookup_dtype(dtype1) - dtype2 = lookup_dtype(dtype2) - return get_typed_op(self, dtype1, dtype2) - if not self._is_udt: - type_ = lookup_dtype(type_) - if type_ not in self._typed_ops: - if self._udt_types is None: - if self.is_positional: - return self._typed_ops[UINT64] - raise KeyError(f"{self.name} does not work with {type_}") - else: - return self._typed_ops[type_] - # This is a UDT or is able to operate on UDTs such as `first` any `any` - dtype = lookup_dtype(type_) - return self._compile_udt(dtype, dtype) - - def _add(self, op): - self._typed_ops[op.type] = op - self.types[op.type] = op.return_type - - def __delitem__(self, type_): - type_ = lookup_dtype(type_) - del self._typed_ops[type_] - del self.types[type_] - - def __contains__(self, type_): - try: - self[type_] - except (TypeError, KeyError, numba.NumbaError): - return False - return True - - @classmethod - def _remove_nesting(cls, funcname, *, module=None, modname=None, strict=True): - if module is None: - module = cls._module - if modname is None: - modname = cls._modname - if "." not in funcname: - if strict and _hasop(module, funcname): - raise AttributeError(f"{modname}.{funcname} is already defined") - else: - path, funcname = funcname.rsplit(".", 1) - for folder in path.split("."): - if not _hasop(module, folder): - setattr(module, folder, OpPath(module, folder)) - module = getattr(module, folder) - modname = f"{modname}.{folder}" - if not isinstance(module, (OpPath, ModuleType)): - raise AttributeError( - f"{modname} is already defined. Cannot use as a nested path." - ) - if strict and _hasop(module, funcname): - raise AttributeError(f"{path}.{funcname} is already defined") - return module, funcname - - @classmethod - def _find(cls, funcname): - rv = cls._module - for attr in funcname.split("."): - if attr in getattr(rv, "_deprecated", ()): - rv = rv._deprecated[attr] - else: - rv = getattr(rv, attr, None) - if rv is None: - break - return rv - - @classmethod - def _initialize(cls, include_in_ops=True): - """ - include_in_ops determines whether the operators are included in the - `gb.ops` namespace in addition to the defined module. - """ - if cls._initialized: # pragma: no cover (safety) - return - # Read in the parse configs - trim_from_front = cls._parse_config.get("trim_from_front", 0) - delete_exact = cls._parse_config.get("delete_exact", None) - num_underscores = cls._parse_config["num_underscores"] - - for re_str, return_prefix in [ - ("re_exprs", None), - ("re_exprs_return_bool", "BOOL"), - ("re_exprs_return_float", "FP"), - ("re_exprs_return_complex", "FC"), - ]: - if re_str not in cls._parse_config: - continue - if "complex" in re_str and not _supports_complex: - continue - for r in reversed(cls._parse_config[re_str]): - for varname in _VARNAMES: - m = r.match(varname) - if m: - # Parse function into name and datatype - gb_name = m.string - splitname = gb_name[trim_from_front:].split("_") - if delete_exact and delete_exact in splitname: - splitname.remove(delete_exact) - if len(splitname) == num_underscores + 1: - *splitname, type_ = splitname - else: - type_ = None - name = "_".join(splitname).lower() - # Create object for name unless it already exists - if not _hasop(cls._module, name): - if backend == "suitesparse" and name in _SS_OPERATORS: - fullname = f"ss.{name}" - else: - fullname = name - if cls._positional is None: - obj = cls(fullname) - else: - obj = cls(fullname, is_positional=name in cls._positional) - if name in _SS_OPERATORS: - if backend == "suitesparse": - setattr(cls._module.ss, name, obj) - cls._module._deprecated[name] = obj - if include_in_ops and not _hasop(op, name): # pragma: no branch - op._deprecated[name] = obj - if backend == "suitesparse": - setattr(op.ss, name, obj) - else: - setattr(cls._module, name, obj) - if include_in_ops and not _hasop(op, name): - setattr(op, name, obj) - _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{fullname}") - elif name in _SS_OPERATORS: - obj = cls._module._deprecated[name] - else: - obj = getattr(cls._module, name) - gb_obj = getattr(lib, varname) - # Determine return type - if return_prefix == "BOOL": - return_type = BOOL - if type_ is None: - type_ = BOOL - else: - if type_ is None: # pragma: no cover - raise TypeError(f"Unable to determine return type for {varname}") - if return_prefix is None: - return_type = type_ - else: - # Grab the number of bits from type_ - num_bits = type_[-2:] - if num_bits not in {"32", "64"}: # pragma: no cover (safety) - raise TypeError(f"Unexpected number of bits: {num_bits}") - return_type = f"{return_prefix}{num_bits}" - builtin_op = cls._typed_class( - obj, - name, - lookup_dtype(type_), - lookup_dtype(return_type), - gb_obj, - gb_name, - ) - obj._add(builtin_op) - - @classmethod - def _deserialize(cls, name, *args): - if (rv := cls._find(name)) is not None: - return rv # Should we verify this is what the user expects? - return cls.register_new(name, *args) - - -def _identity(x): - return x # pragma: no cover (numba) - - -def _one(x): - return 1 # pragma: no cover (numba) - - -class UnaryOp(OpBase): - """Takes one input and returns one output, possibly of a different data type. - - Built-in and registered UnaryOps are located in the ``graphblas.unary`` namespace - as well as in the ``graphblas.ops`` combined namespace. - """ - - __slots__ = "orig_func", "is_positional", "_is_udt", "_numba_func" - _custom_dtype = None - _module = unary - _modname = "unary" - _typed_class = TypedBuiltinUnaryOp - _parse_config = { - "trim_from_front": 4, - "num_underscores": 1, - "re_exprs": [ - re.compile( - "^GrB_(IDENTITY|AINV|MINV|ABS|BNOT)" - "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64|FC32|FC64)$" - ), - re.compile( - "^GxB_(LNOT|ONE|POSITIONI1|POSITIONI|POSITIONJ1|POSITIONJ)" - "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" - ), - re.compile( - "^GxB_(SQRT|LOG|EXP|LOG2|SIN|COS|TAN|ACOS|ASIN|ATAN|SINH|COSH|TANH|ACOSH" - "|ASINH|ATANH|SIGNUM|CEIL|FLOOR|ROUND|TRUNC|EXP2|EXPM1|LOG10|LOG1P)" - "_(FP32|FP64|FC32|FC64)$" - ), - re.compile("^GxB_(LGAMMA|TGAMMA|ERF|ERFC|FREXPX|FREXPE|CBRT)_(FP32|FP64)$"), - re.compile("^GxB_(IDENTITY|AINV|MINV|ONE|CONJ)_(FC32|FC64)$"), - ], - "re_exprs_return_bool": [ - re.compile("^GrB_LNOT$"), - re.compile("^GxB_(ISINF|ISNAN|ISFINITE)_(FP32|FP64|FC32|FC64)$"), - ], - "re_exprs_return_float": [re.compile("^GxB_(CREAL|CIMAG|CARG|ABS)_(FC32|FC64)$")], - } - _positional = {"positioni", "positioni1", "positionj", "positionj1"} - - @classmethod - def _build(cls, name, func, *, anonymous=False, is_udt=False): - if type(func) is not FunctionType: - raise TypeError(f"UDF argument must be a function, not {type(func)}") - if name is None: - name = getattr(func, "__name__", "") - success = False - unary_udf = numba.njit(func) - new_type_obj = cls(name, func, anonymous=anonymous, is_udt=is_udt, numba_func=unary_udf) - return_types = {} - nt = numba.types - if not is_udt: - for type_ in _sample_values: - sig = (type_.numba_type,) - try: - unary_udf.compile(sig) - except numba.TypingError: - continue - ret_type = lookup_dtype(unary_udf.overloads[sig].signature.return_type) - if ret_type != type_ and ( - ("INT" in ret_type.name and "INT" in type_.name) - or ("FP" in ret_type.name and "FP" in type_.name) - or ("FC" in ret_type.name and "FC" in type_.name) - or (type_ == UINT64 and ret_type == FP64 and return_types.get(INT64) == INT64) - ): - # Downcast `ret_type` to `type_`. - # This is what users want most of the time, but we can't make a perfect rule. - # There should be a way for users to be explicit. - ret_type = type_ - elif type_ == BOOL and ret_type == INT64 and return_types.get(INT8) == INT8: - ret_type = INT8 - - # Numba is unable to handle BOOL correctly right now, but we have a workaround - # See: https://github.com/numba/numba/issues/5395 - # We're relying on coercion behaving correctly here - input_type = INT8 if type_ == BOOL else type_ - return_type = INT8 if ret_type == BOOL else ret_type - - # Build wrapper because GraphBLAS wants pointers and void return - wrapper_sig = nt.void( - nt.CPointer(return_type.numba_type), - nt.CPointer(input_type.numba_type), - ) - - if type_ == BOOL: - if ret_type == BOOL: - - def unary_wrapper(z, x): - z[0] = bool(unary_udf(bool(x[0]))) # pragma: no cover (numba) - - else: - - def unary_wrapper(z, x): - z[0] = unary_udf(bool(x[0])) # pragma: no cover (numba) - - elif ret_type == BOOL: - - def unary_wrapper(z, x): - z[0] = bool(unary_udf(x[0])) # pragma: no cover (numba) - - else: - - def unary_wrapper(z, x): - z[0] = unary_udf(x[0]) # pragma: no cover (numba) - - unary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(unary_wrapper) - new_unary = ffi_new("GrB_UnaryOp*") - check_status_carg( - lib.GrB_UnaryOp_new( - new_unary, unary_wrapper.cffi, ret_type.gb_obj, type_.gb_obj - ), - "UnaryOp", - new_unary, - ) - op = TypedUserUnaryOp(new_type_obj, name, type_, ret_type, new_unary[0]) - new_type_obj._add(op) - success = True - return_types[type_] = ret_type - if success or is_udt: - return new_type_obj - raise UdfParseError("Unable to parse function using Numba") - - def _compile_udt(self, dtype, dtype2): - if dtype in self._udt_types: - return self._udt_ops[dtype] - - numba_func = self._numba_func - sig = (dtype.numba_type,) - numba_func.compile(sig) # Should we catch and give additional error message? - ret_type = lookup_dtype(numba_func.overloads[sig].signature.return_type) - - unary_wrapper, wrapper_sig = _get_udt_wrapper(numba_func, ret_type, dtype) - unary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(unary_wrapper) - new_unary = ffi_new("GrB_UnaryOp*") - check_status_carg( - lib.GrB_UnaryOp_new(new_unary, unary_wrapper.cffi, ret_type._carg, dtype._carg), - "UnaryOp", - new_unary, - ) - op = TypedUserUnaryOp(self, self.name, dtype, ret_type, new_unary[0]) - self._udt_types[dtype] = ret_type - self._udt_ops[dtype] = op - return op - - @classmethod - def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=False): - """Register a UnaryOp without registering it in the ``graphblas.unary`` namespace. - - Because it is not registered in the namespace, the name is optional. - """ - if parameterized: - return ParameterizedUnaryOp(name, func, anonymous=True, is_udt=is_udt) - return cls._build(name, func, anonymous=True, is_udt=is_udt) - - @classmethod - def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): - """Register a UnaryOp. The name will be used to identify the UnaryOp in the - ``graphblas.unary`` namespace. - - >>> gb.core.operator.UnaryOp.register_new("plus_one", lambda x: x + 1) - >>> dir(gb.unary) - [..., 'plus_one', ...] - """ - module, funcname = cls._remove_nesting(name) - if lazy: - module._delayed[funcname] = ( - cls.register_new, - {"name": name, "func": func, "parameterized": parameterized}, - ) - elif parameterized: - unary_op = ParameterizedUnaryOp(name, func, is_udt=is_udt) - setattr(module, funcname, unary_op) - else: - unary_op = cls._build(name, func, is_udt=is_udt) - setattr(module, funcname, unary_op) - # Also save it to `graphblas.op` if not yet defined - opmodule, funcname = cls._remove_nesting(name, module=op, modname="op", strict=False) - if not _hasop(opmodule, funcname): - if lazy: - opmodule._delayed[funcname] = module - else: - setattr(opmodule, funcname, unary_op) - if not cls._initialized: # pragma: no cover - _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") - if not lazy: - return unary_op - - @classmethod - def _initialize(cls): - if cls._initialized: - return - super()._initialize() - # Update type information with sane coercion - position_dtypes = [ - BOOL, - FP32, - FP64, - INT8, - INT16, - UINT8, - UINT16, - UINT32, - UINT64, - ] - if _supports_complex: - position_dtypes.extend([FC32, FC64]) - for names, *types in [ - # fmt: off - ( - ( - "erf", "erfc", "lgamma", "tgamma", "acos", "acosh", "asin", "asinh", - "atan", "atanh", "ceil", "cos", "cosh", "exp", "exp2", "expm1", "floor", - "log", "log10", "log1p", "log2", "round", "signum", "sin", "sinh", "sqrt", - "tan", "tanh", "trunc", "cbrt", - ), - ((BOOL, INT8, INT16, UINT8, UINT16), FP32), - ((INT32, INT64, UINT32, UINT64), FP64), - ), - ( - ("positioni", "positioni1", "positionj", "positionj1"), - ( - position_dtypes, - INT64, - ), - ), - # fmt: on - ]: - for name in names: - if name in _SS_OPERATORS: - op = unary._deprecated[name] - else: - op = getattr(unary, name) - for input_types, target_type in types: - typed_op = op._typed_ops[target_type] - output_type = op.types[target_type] - for dtype in input_types: - if dtype not in op.types: # pragma: no branch (safety) - op.types[dtype] = output_type - op._typed_ops[dtype] = typed_op - op.coercions[dtype] = target_type - # Allow some functions to work on UDTs - for unop, func in [ - (unary.identity, _identity), - (unary.one, _one), - ]: - unop.orig_func = func - unop._numba_func = numba.njit(func) - unop._udt_types = {} - unop._udt_ops = {} - cls._initialized = True - - def __init__( - self, - name, - func=None, - *, - anonymous=False, - is_positional=False, - is_udt=False, - numba_func=None, - ): - super().__init__(name, anonymous=anonymous) - self.orig_func = func - self._numba_func = numba_func - self.is_positional = is_positional - self._is_udt = is_udt - if is_udt: - self._udt_types = {} # {dtype: DataType} - self._udt_ops = {} # {dtype: TypedUserUnaryOp} - - def __reduce__(self): - if self._anonymous: - if hasattr(self.orig_func, "_parameterized_info"): - return (_deserialize_parameterized, self.orig_func._parameterized_info) - return (self.register_anonymous, (self.orig_func, self.name)) - if (name := f"unary.{self.name}") in _STANDARD_OPERATOR_NAMES: - return name - return (self._deserialize, (self.name, self.orig_func)) - - __call__ = TypedBuiltinUnaryOp.__call__ - - -class IndexUnaryOp(OpBase): - """Takes one input and a thunk and returns one output, possibly of a different data type. - Along with the input value, the index(es) of the element are given to the function. - - This is an advanced form of a unary operation that allows, for example, converting - elements of a Vector to their index position to build a ramp structure. Another use - case is returning a boolean value indicating whether the element is part of the upper - triangular structure of a Matrix. - - Built-in and registered IndexUnaryOps are located in the ``graphblas.indexunary`` namespace. - """ - - __slots__ = "orig_func", "is_positional", "_is_udt", "_numba_func" - _module = indexunary - _modname = "indexunary" - _custom_dtype = None - _typed_class = TypedBuiltinIndexUnaryOp - _typed_user_class = TypedUserIndexUnaryOp - _parse_config = { - "trim_from_front": 4, - "num_underscores": 1, - "re_exprs": [ - re.compile("^GrB_(ROWINDEX|COLINDEX|DIAGINDEX)_(INT32|INT64)$"), - ], - "re_exprs_return_bool": [ - re.compile("^GrB_(TRIL|TRIU|DIAG|OFFDIAG|COLLE|COLGT|ROWLE|ROWGT)$"), - re.compile( - "^GrB_(VALUEEQ|VALUENE|VALUEGT|VALUEGE|VALUELT|VALUELE)" - "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" - ), - re.compile("^GxB_(VALUEEQ|VALUENE)_(FC32|FC64)$"), - ], - } - _positional = {"tril", "triu", "diag", "offdiag", "colle", "colgt", "rowle", "rowgt", - "rowindex", "colindex"} # fmt: skip - - @classmethod - def _build(cls, name, func, *, is_udt=False, anonymous=False): - if not isinstance(func, FunctionType): - raise TypeError(f"UDF argument must be a function, not {type(func)}") - if name is None: - name = getattr(func, "__name__", "") - success = False - indexunary_udf = numba.njit(func) - new_type_obj = cls( - name, func, anonymous=anonymous, is_udt=is_udt, numba_func=indexunary_udf - ) - return_types = {} - nt = numba.types - if not is_udt: - for type_ in _sample_values: - sig = (type_.numba_type, UINT64.numba_type, UINT64.numba_type, type_.numba_type) - try: - indexunary_udf.compile(sig) - except numba.TypingError: - continue - ret_type = lookup_dtype(indexunary_udf.overloads[sig].signature.return_type) - if ret_type != type_ and ( - ("INT" in ret_type.name and "INT" in type_.name) - or ("FP" in ret_type.name and "FP" in type_.name) - or ("FC" in ret_type.name and "FC" in type_.name) - or (type_ == UINT64 and ret_type == FP64 and return_types.get(INT64) == INT64) - ): - # Downcast `ret_type` to `type_`. - # This is what users want most of the time, but we can't make a perfect rule. - # There should be a way for users to be explicit. - ret_type = type_ - elif type_ == BOOL and ret_type == INT64 and return_types.get(INT8) == INT8: - ret_type = INT8 - - # Numba is unable to handle BOOL correctly right now, but we have a workaround - # See: https://github.com/numba/numba/issues/5395 - # We're relying on coercion behaving correctly here - input_type = INT8 if type_ == BOOL else type_ - return_type = INT8 if ret_type == BOOL else ret_type - - # Build wrapper because GraphBLAS wants pointers and void return - wrapper_sig = nt.void( - nt.CPointer(return_type.numba_type), - nt.CPointer(input_type.numba_type), - UINT64.numba_type, - UINT64.numba_type, - nt.CPointer(input_type.numba_type), - ) - - if type_ == BOOL: - if ret_type == BOOL: - - def indexunary_wrapper(z, x, row, col, y): # pragma: no cover (numba) - z[0] = bool(indexunary_udf(bool(x[0]), row, col, bool(y[0]))) - - else: - - def indexunary_wrapper(z, x, row, col, y): # pragma: no cover (numba) - z[0] = indexunary_udf(bool(x[0]), row, col, bool(y[0])) - - elif ret_type == BOOL: - - def indexunary_wrapper(z, x, row, col, y): # pragma: no cover (numba) - z[0] = bool(indexunary_udf(x[0], row, col, y[0])) - - else: - - def indexunary_wrapper(z, x, row, col, y): # pragma: no cover (numba) - z[0] = indexunary_udf(x[0], row, col, y[0]) - - indexunary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(indexunary_wrapper) - new_indexunary = ffi_new("GrB_IndexUnaryOp*") - check_status_carg( - lib.GrB_IndexUnaryOp_new( - new_indexunary, - indexunary_wrapper.cffi, - ret_type.gb_obj, - type_.gb_obj, - type_.gb_obj, - ), - "IndexUnaryOp", - new_indexunary, - ) - op = cls._typed_user_class(new_type_obj, name, type_, ret_type, new_indexunary[0]) - new_type_obj._add(op) - success = True - return_types[type_] = ret_type - if success or is_udt: - return new_type_obj - raise UdfParseError("Unable to parse function using Numba") - - def _compile_udt(self, dtype, dtype2): - if dtype2 is None: # pragma: no cover - dtype2 = dtype - dtypes = (dtype, dtype2) - if dtypes in self._udt_types: - return self._udt_ops[dtypes] - - numba_func = self._numba_func - sig = (dtype.numba_type, UINT64.numba_type, UINT64.numba_type, dtype2.numba_type) - numba_func.compile(sig) # Should we catch and give additional error message? - ret_type = lookup_dtype(numba_func.overloads[sig].signature.return_type) - indexunary_wrapper, wrapper_sig = _get_udt_wrapper( - numba_func, ret_type, dtype, dtype2, include_indexes=True - ) - - indexunary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(indexunary_wrapper) - new_indexunary = ffi_new("GrB_IndexUnaryOp*") - check_status_carg( - lib.GrB_IndexUnaryOp_new( - new_indexunary, indexunary_wrapper.cffi, ret_type._carg, dtype._carg, dtype2._carg - ), - "IndexUnaryOp", - new_indexunary, - ) - op = TypedUserIndexUnaryOp( - self, - self.name, - dtype, - ret_type, - new_indexunary[0], - dtype2=dtype2, - ) - self._udt_types[dtypes] = ret_type - self._udt_ops[dtypes] = op - return op - - @classmethod - def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=False): - """Register an IndexUnaryOp without registering it in the - ``graphblas.indexunary`` namespace. - - Because it is not registered in the namespace, the name is optional. - """ - if parameterized: - return ParameterizedIndexUnaryOp(name, func, anonymous=True, is_udt=is_udt) - return cls._build(name, func, anonymous=True, is_udt=is_udt) - - @classmethod - def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): - """Register an IndexUnaryOp. The name will be used to identify the IndexUnaryOp in the - ``graphblas.indexunary`` namespace. - - If the return type is Boolean, the function will also be registered as a SelectOp - with the same name. - - >>> gb.indexunary.register_new("row_mod", lambda x, i, j, thunk: i % max(thunk, 2)) - >>> dir(gb.indexunary) - [..., 'row_mod', ...] - """ - module, funcname = cls._remove_nesting(name) - if lazy: - module._delayed[funcname] = ( - cls.register_new, - {"name": name, "func": func, "parameterized": parameterized}, - ) - elif parameterized: - indexunary_op = ParameterizedIndexUnaryOp(name, func, is_udt=is_udt) - setattr(module, funcname, indexunary_op) - else: - indexunary_op = cls._build(name, func, is_udt=is_udt) - setattr(module, funcname, indexunary_op) - # If return type is BOOL, register additionally as a SelectOp - if all(x == BOOL for x in indexunary_op.types.values()): - setattr(select, funcname, SelectOp._from_indexunary(indexunary_op)) - - if not cls._initialized: - _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") - if not lazy: - return indexunary_op - - @classmethod - def _initialize(cls): - if cls._initialized: - return - super()._initialize(include_in_ops=False) - # Update type information to include UINT64 for positional ops - for name in ["tril", "triu", "diag", "offdiag", "colle", "colgt", "rowle", "rowgt"]: - op = getattr(indexunary, name) - typed_op = op._typed_ops[BOOL] - output_type = op.types[BOOL] - if UINT64 not in op.types: # pragma: no branch (safety) - op.types[UINT64] = output_type - op._typed_ops[UINT64] = typed_op - op.coercions[UINT64] = BOOL - for name in ["rowindex", "colindex"]: - op = getattr(indexunary, name) - typed_op = op._typed_ops[INT64] - output_type = op.types[INT64] - if UINT64 not in op.types: # pragma: no branch (safety) - op.types[UINT64] = output_type - op._typed_ops[UINT64] = typed_op - op.coercions[UINT64] = INT64 - # Add index->row alias to make it more intuitive which to use for vectors - indexunary.indexle = indexunary.rowle - indexunary.indexgt = indexunary.rowgt - indexunary.index = indexunary.rowindex - # fmt: off - # Add SelectOp when it makes sense - for name in ["tril", "triu", "diag", "offdiag", - "colle", "colgt", "rowle", "rowgt", "indexle", "indexgt", - "valueeq", "valuene", "valuegt", "valuege", "valuelt", "valuele"]: - iop = getattr(indexunary, name) - setattr(select, name, SelectOp._from_indexunary(iop)) - # fmt: on - cls._initialized = True - - def __init__( - self, - name, - func=None, - *, - anonymous=False, - is_positional=False, - is_udt=False, - numba_func=None, - ): - super().__init__(name, anonymous=anonymous) - self.orig_func = func - self._numba_func = numba_func - self.is_positional = is_positional - self._is_udt = is_udt - if is_udt: - self._udt_types = {} # {dtype: DataType} - self._udt_ops = {} # {dtype: TypedUserIndexUnaryOp} - - def __reduce__(self): - if self._anonymous: - if hasattr(self.orig_func, "_parameterized_info"): - return (_deserialize_parameterized, self.orig_func._parameterized_info) - return (self.register_anonymous, (self.orig_func, self.name)) - if (name := f"indexunary.{self.name}") in _STANDARD_OPERATOR_NAMES: - return name - return (self._deserialize, (self.name, self.orig_func)) - - __call__ = TypedBuiltinIndexUnaryOp.__call__ - - -class SelectOp(OpBase): - """Identical to an :class:`IndexUnaryOp `, - but must have a Boolean return type. - - A SelectOp is used exclusively to select a subset of values from a collection where - the function returns True. - - Built-in and registered SelectOps are located in the ``graphblas.select`` namespace. - """ - - __slots__ = "orig_func", "is_positional", "_is_udt", "_numba_func" - _module = select - _modname = "select" - _custom_dtype = None - _typed_class = TypedBuiltinSelectOp - _typed_user_class = TypedUserSelectOp - - @classmethod - def _from_indexunary(cls, iop): - obj = cls( - iop.name, - iop.orig_func, - anonymous=iop._anonymous, - is_positional=iop.is_positional, - is_udt=iop._is_udt, - numba_func=iop._numba_func, - ) - if not all(x == BOOL for x in iop.types.values()): - raise ValueError("SelectOp must have BOOL return type") - for type_, t in iop._typed_ops.items(): - if iop.orig_func is not None: - op = cls._typed_user_class( - obj, - iop.name, - t.type, - t.return_type, - t.gb_obj, - ) - else: - op = cls._typed_class( - obj, - iop.name, - t.type, - t.return_type, - t.gb_obj, - t.gb_name, - ) - # type is not always equal to t.type, so can't use op._add - # but otherwise perform the same logic - obj._typed_ops[type_] = op - obj.types[type_] = op.return_type - return obj - - @classmethod - def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=False): - """Register a SelectOp without registering it in the ``graphblas.select`` namespace. - - Because it is not registered in the namespace, the name is optional. - """ - if parameterized: - return ParameterizedSelectOp(name, func, anonymous=True, is_udt=is_udt) - iop = IndexUnaryOp._build(name, func, anonymous=True, is_udt=is_udt) - return SelectOp._from_indexunary(iop) - - @classmethod - def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): - """Register a SelectOp. The name will be used to identify the SelectOp in the - ``graphblas.select`` namespace. - - The function will also be registered as a IndexUnaryOp with the same name. - - >>> gb.select.register_new("upper_left_triangle", lambda x, i, j, thunk: i + j <= thunk) - >>> dir(gb.select) - [..., 'upper_left_triangle', ...] - """ - iop = IndexUnaryOp.register_new( - name, func, parameterized=parameterized, is_udt=is_udt, lazy=lazy - ) - if not all(x == BOOL for x in iop.types.values()): - raise ValueError("SelectOp must have BOOL return type") - if lazy: - return getattr(select, iop.name) - - @classmethod - def _initialize(cls): - if cls._initialized: # pragma: no cover (safety) - return - # IndexUnaryOp adds it boolean-returning objects to SelectOp - IndexUnaryOp._initialize() - cls._initialized = True - - def __init__( - self, - name, - func=None, - *, - anonymous=False, - is_positional=False, - is_udt=False, - numba_func=None, - ): - super().__init__(name, anonymous=anonymous) - self.orig_func = func - self._numba_func = numba_func - self.is_positional = is_positional - self._is_udt = is_udt - if is_udt: - self._udt_types = {} # {dtype: DataType} - self._udt_ops = {} # {dtype: TypedUserIndexUnaryOp} - - def __reduce__(self): - if self._anonymous: - if hasattr(self.orig_func, "_parameterized_info"): - return (_deserialize_parameterized, self.orig_func._parameterized_info) - return (self.register_anonymous, (self.orig_func, self.name)) - if (name := f"select.{self.name}") in _STANDARD_OPERATOR_NAMES: - return name - return (self._deserialize, (self.name, self.orig_func)) - - __call__ = TypedBuiltinSelectOp.__call__ - - -def _floordiv(x, y): - return x // y # pragma: no cover (numba) - - -def _rfloordiv(x, y): - return y // x # pragma: no cover (numba) - - -def _absfirst(x, y): - return np.abs(x) # pragma: no cover (numba) - - -def _abssecond(x, y): - return np.abs(y) # pragma: no cover (numba) - - -def _rpow(x, y): - return y**x # pragma: no cover (numba) - - -def _isclose(rel_tol=1e-7, abs_tol=0.0): - def inner(x, y): # pragma: no cover (numba) - return x == y or abs(x - y) <= max(rel_tol * max(abs(x), abs(y)), abs_tol) - - return inner - - -_MAX_INT64 = np.iinfo(np.int64).max - - -def _binom(N, k): # pragma: no cover (numba) - # Returns 0 if overflow or out-of-bounds - if k > N or k < 0: - return 0 - val = np.int64(1) - for i in range(min(k, N - k)): - if val > _MAX_INT64 // (N - i): # Overflow - return 0 - val *= N - i - val //= i + 1 - return val - - -# Kinda complicated, but works for now -def _register_binom(): - # "Fake" UDT so we only compile once for INT64 - op = BinaryOp.register_new("binom", _binom, is_udt=True) - typed_op = op[INT64, INT64] - # Make this look like a normal operator - for dtype in [UINT8, UINT16, UINT32, UINT64, INT8, INT16, INT32, INT64]: - op.types[dtype] = INT64 - op._typed_ops[dtype] = typed_op - if dtype != INT64: - op.coercions[dtype] = typed_op - # And make it not look like it operates on UDTs - typed_op._type2 = None - op._is_udt = False - op._udt_types = None - op._udt_ops = None - return op - - -def _first(x, y): - return x # pragma: no cover (numba) - - -def _second(x, y): - return y # pragma: no cover (numba) - - -def _pair(x, y): - return 1 # pragma: no cover (numba) - - -def _first_dtype(op, dtype, dtype2): - if dtype._is_udt or dtype2._is_udt: - return op._compile_udt(dtype, dtype2) - - -def _second_dtype(op, dtype, dtype2): - if dtype._is_udt or dtype2._is_udt: - return op._compile_udt(dtype, dtype2) - - -def _pair_dtype(op, dtype, dtype2): - return op[INT64] - - -def _get_udt_wrapper(numba_func, return_type, dtype, dtype2=None, *, include_indexes=False): - ztype = INT8 if return_type == BOOL else return_type - xtype = INT8 if dtype == BOOL else dtype - nt = numba.types - wrapper_args = [nt.CPointer(ztype.numba_type), nt.CPointer(xtype.numba_type)] - if include_indexes: - wrapper_args.extend([UINT64.numba_type, UINT64.numba_type]) - if dtype2 is not None: - ytype = INT8 if dtype2 == BOOL else dtype2 - wrapper_args.append(nt.CPointer(ytype.numba_type)) - wrapper_sig = nt.void(*wrapper_args) - - zarray = xarray = yarray = BL = BR = yarg = yname = rcidx = "" - if return_type._is_udt: - if return_type.np_type.subdtype is None: - zarray = " z = numba.carray(z_ptr, 1)\n" - zname = "z[0]" - else: - zname = "z_ptr[0]" - BR = "[0]" - else: - zname = "z_ptr[0]" - if return_type == BOOL: - BL = "bool(" - BR = ")" - - if dtype._is_udt: - if dtype.np_type.subdtype is None: - xarray = " x = numba.carray(x_ptr, 1)\n" - xname = "x[0]" - else: - xname = "x_ptr" - elif dtype == BOOL: - xname = "bool(x_ptr[0])" - else: - xname = "x_ptr[0]" - - if dtype2 is not None: - yarg = ", y_ptr" - if dtype2._is_udt: - if dtype2.np_type.subdtype is None: - yarray = " y = numba.carray(y_ptr, 1)\n" - yname = ", y[0]" - else: - yname = ", y_ptr" - elif dtype2 == BOOL: - yname = ", bool(y_ptr[0])" - else: - yname = ", y_ptr[0]" - - if include_indexes: - rcidx = ", row, col" - - d = {"numba": numba, "numba_func": numba_func} - text = ( - f"def wrapper(z_ptr, x_ptr{rcidx}{yarg}):\n" - f"{zarray}{xarray}{yarray}" - f" {zname} = {BL}numba_func({xname}{rcidx}{yname}){BR}\n" - ) - exec(text, d) # pylint: disable=exec-used - return d["wrapper"], wrapper_sig - - -class BinaryOp(OpBase): - """Takes two inputs and returns one output, possibly of a different data type. - - Built-in and registered BinaryOps are located in the ``graphblas.binary`` namespace - as well as in the ``graphblas.ops`` combined namespace. - """ - - __slots__ = ( - "_monoid", - "_commutes_to", - "_semiring_commutes_to", - "orig_func", - "is_positional", - "_is_udt", - "_numba_func", - "_custom_dtype", - ) - _module = binary - _modname = "binary" - _typed_class = TypedBuiltinBinaryOp - _parse_config = { - "trim_from_front": 4, - "num_underscores": 1, - "re_exprs": [ - re.compile( - "^GrB_(FIRST|SECOND|PLUS|MINUS|TIMES|DIV|MIN|MAX)" - "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64|FC32|FC64)$" - ), - re.compile( - "GrB_(BOR|BAND|BXOR|BXNOR)_(INT8|INT16|INT32|INT64|UINT8|UINT16|UINT32|UINT64)$" - ), - re.compile( - "^GxB_(POW|RMINUS|RDIV|PAIR|ANY|ISEQ|ISNE|ISGT|ISLT|ISGE|ISLE|LOR|LAND|LXOR)" - "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64|FC32|FC64)$" - ), - re.compile("^GxB_(FIRST|SECOND|PLUS|MINUS|TIMES|DIV)_(FC32|FC64)$"), - re.compile("^GxB_(ATAN2|HYPOT|FMOD|REMAINDER|LDEXP|COPYSIGN)_(FP32|FP64)$"), - re.compile( - "GxB_(BGET|BSET|BCLR|BSHIFT|FIRSTI1|FIRSTI|FIRSTJ1|FIRSTJ" - "|SECONDI1|SECONDI|SECONDJ1|SECONDJ)" - "_(INT8|INT16|INT32|INT64|UINT8|UINT16|UINT32|UINT64)$" - ), - # These are coerced to 0 or 1, but don't return BOOL - re.compile( - "^GxB_(LOR|LAND|LXOR|LXNOR)_" - "(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" - ), - ], - "re_exprs_return_bool": [ - re.compile("^GrB_(LOR|LAND|LXOR|LXNOR)$"), - re.compile( - "^GrB_(EQ|NE|GT|LT|GE|LE)_" - "(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" - ), - re.compile("^GxB_(EQ|NE)_(FC32|FC64)$"), - ], - "re_exprs_return_complex": [re.compile("^GxB_(CMPLX)_(FP32|FP64)$")], - } - _commutes = { - # builtins - "cdiv": "rdiv", - "first": "second", - "ge": "le", - "gt": "lt", - "isge": "isle", - "isgt": "islt", - "minus": "rminus", - "pow": "rpow", - # special - "firsti": "secondi", - "firsti1": "secondi1", - "firstj": "secondj", - "firstj1": "secondj1", - # custom - # "absfirst": "abssecond", # handled in graphblas.binary - # "floordiv": "rfloordiv", - "truediv": "rtruediv", - } - _commutes_to_in_semiring = { - "firsti": "secondj", - "firsti1": "secondj1", - "firstj": "secondi", - "firstj1": "secondi1", - } - _commutative = { - # monoids - "any", - "band", - "bor", - "bxnor", - "bxor", - "eq", - "land", - "lor", - "lxnor", - "lxor", - "max", - "min", - "plus", - "times", - # other - "hypot", - "isclose", - "iseq", - "isne", - "ne", - "pair", - } - # Don't commute: atan2, bclr, bget, bset, bshift, cmplx, copysign, fmod, ldexp, remainder - _positional = { - "firsti", - "firsti1", - "firstj", - "firstj1", - "secondi", - "secondi1", - "secondj", - "secondj1", - } - - @classmethod - def _build(cls, name, func, *, is_udt=False, anonymous=False): - if not isinstance(func, FunctionType): - raise TypeError(f"UDF argument must be a function, not {type(func)}") - if name is None: - name = getattr(func, "__name__", "") - success = False - binary_udf = numba.njit(func) - new_type_obj = cls(name, func, anonymous=anonymous, is_udt=is_udt, numba_func=binary_udf) - return_types = {} - nt = numba.types - if not is_udt: - for type_ in _sample_values: - sig = (type_.numba_type, type_.numba_type) - try: - binary_udf.compile(sig) - except numba.TypingError: - continue - ret_type = lookup_dtype(binary_udf.overloads[sig].signature.return_type) - if ret_type != type_ and ( - ("INT" in ret_type.name and "INT" in type_.name) - or ("FP" in ret_type.name and "FP" in type_.name) - or ("FC" in ret_type.name and "FC" in type_.name) - or (type_ == UINT64 and ret_type == FP64 and return_types.get(INT64) == INT64) - ): - # Downcast `ret_type` to `type_`. - # This is what users want most of the time, but we can't make a perfect rule. - # There should be a way for users to be explicit. - ret_type = type_ - elif type_ == BOOL and ret_type == INT64 and return_types.get(INT8) == INT8: - ret_type = INT8 - - # Numba is unable to handle BOOL correctly right now, but we have a workaround - # See: https://github.com/numba/numba/issues/5395 - # We're relying on coercion behaving correctly here - input_type = INT8 if type_ == BOOL else type_ - return_type = INT8 if ret_type == BOOL else ret_type - - # Build wrapper because GraphBLAS wants pointers and void return - wrapper_sig = nt.void( - nt.CPointer(return_type.numba_type), - nt.CPointer(input_type.numba_type), - nt.CPointer(input_type.numba_type), - ) - - if type_ == BOOL: - if ret_type == BOOL: - - def binary_wrapper(z, x, y): # pragma: no cover (numba) - z[0] = bool(binary_udf(bool(x[0]), bool(y[0]))) - - else: - - def binary_wrapper(z, x, y): # pragma: no cover (numba) - z[0] = binary_udf(bool(x[0]), bool(y[0])) - - elif ret_type == BOOL: - - def binary_wrapper(z, x, y): # pragma: no cover (numba) - z[0] = bool(binary_udf(x[0], y[0])) - - else: - - def binary_wrapper(z, x, y): # pragma: no cover (numba) - z[0] = binary_udf(x[0], y[0]) - - binary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(binary_wrapper) - new_binary = ffi_new("GrB_BinaryOp*") - check_status_carg( - lib.GrB_BinaryOp_new( - new_binary, - binary_wrapper.cffi, - ret_type.gb_obj, - type_.gb_obj, - type_.gb_obj, - ), - "BinaryOp", - new_binary, - ) - op = TypedUserBinaryOp(new_type_obj, name, type_, ret_type, new_binary[0]) - new_type_obj._add(op) - success = True - return_types[type_] = ret_type - if success or is_udt: - return new_type_obj - raise UdfParseError("Unable to parse function using Numba") - - def _compile_udt(self, dtype, dtype2): - if dtype2 is None: - dtype2 = dtype - dtypes = (dtype, dtype2) - if dtypes in self._udt_types: - return self._udt_ops[dtypes] - - nt = numba.types - if self.name == "eq" and not self._anonymous: - # assert dtype.np_type == dtype2.np_type - itemsize = dtype.np_type.itemsize - mask = _udt_mask(dtype.np_type) - ret_type = BOOL - wrapper_sig = nt.void( - nt.CPointer(INT8.numba_type), - nt.CPointer(UINT8.numba_type), - nt.CPointer(UINT8.numba_type), - ) - # PERF: we can probably make this faster - if mask.all(): - - def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) - x = numba.carray(x_ptr, itemsize) - y = numba.carray(y_ptr, itemsize) - # for i in range(itemsize): - # if x[i] != y[i]: - # z_ptr[0] = False - # break - # else: - # z_ptr[0] = True - z_ptr[0] = (x == y).all() - - else: - - def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) - x = numba.carray(x_ptr, itemsize) - y = numba.carray(y_ptr, itemsize) - # for i in range(itemsize): - # if mask[i] and x[i] != y[i]: - # z_ptr[0] = False - # break - # else: - # z_ptr[0] = True - z_ptr[0] = (x[mask] == y[mask]).all() - - elif self.name == "ne" and not self._anonymous: - # assert dtype.np_type == dtype2.np_type - itemsize = dtype.np_type.itemsize - mask = _udt_mask(dtype.np_type) - ret_type = BOOL - wrapper_sig = nt.void( - nt.CPointer(INT8.numba_type), - nt.CPointer(UINT8.numba_type), - nt.CPointer(UINT8.numba_type), - ) - if mask.all(): - - def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) - x = numba.carray(x_ptr, itemsize) - y = numba.carray(y_ptr, itemsize) - # for i in range(itemsize): - # if x[i] != y[i]: - # z_ptr[0] = True - # break - # else: - # z_ptr[0] = False - z_ptr[0] = (x != y).any() - - else: - - def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) - x = numba.carray(x_ptr, itemsize) - y = numba.carray(y_ptr, itemsize) - # for i in range(itemsize): - # if mask[i] and x[i] != y[i]: - # z_ptr[0] = True - # break - # else: - # z_ptr[0] = False - z_ptr[0] = (x[mask] != y[mask]).any() - - else: - numba_func = self._numba_func - sig = (dtype.numba_type, dtype2.numba_type) - numba_func.compile(sig) # Should we catch and give additional error message? - ret_type = lookup_dtype(numba_func.overloads[sig].signature.return_type) - binary_wrapper, wrapper_sig = _get_udt_wrapper(numba_func, ret_type, dtype, dtype2) - - binary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(binary_wrapper) - new_binary = ffi_new("GrB_BinaryOp*") - check_status_carg( - lib.GrB_BinaryOp_new( - new_binary, binary_wrapper.cffi, ret_type._carg, dtype._carg, dtype2._carg - ), - "BinaryOp", - new_binary, - ) - op = TypedUserBinaryOp( - self, - self.name, - dtype, - ret_type, - new_binary[0], - dtype2=dtype2, - ) - self._udt_types[dtypes] = ret_type - self._udt_ops[dtypes] = op - return op - - @classmethod - def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=False): - """Register a BinaryOp without registering it in the ``graphblas.binary`` namespace. - - Because it is not registered in the namespace, the name is optional. - """ - if parameterized: - return ParameterizedBinaryOp(name, func, anonymous=True, is_udt=is_udt) - return cls._build(name, func, anonymous=True, is_udt=is_udt) - - @classmethod - def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): - """Register a BinaryOp. The name will be used to identify the BinaryOp in the - ``graphblas.binary`` namespace. - - >>> def max_zero(x, y): - r = 0 - if x > r: - r = x - if y > r: - r = y - return r - >>> gb.core.operator.BinaryOp.register_new("max_zero", max_zero) - >>> dir(gb.binary) - [..., 'max_zero', ...] - """ - module, funcname = cls._remove_nesting(name) - if lazy: - module._delayed[funcname] = ( - cls.register_new, - {"name": name, "func": func, "parameterized": parameterized}, - ) - elif parameterized: - binary_op = ParameterizedBinaryOp(name, func, is_udt=is_udt) - setattr(module, funcname, binary_op) - else: - binary_op = cls._build(name, func, is_udt=is_udt) - setattr(module, funcname, binary_op) - # Also save it to `graphblas.op` if not yet defined - opmodule, funcname = cls._remove_nesting(name, module=op, modname="op", strict=False) - if not _hasop(opmodule, funcname): - if lazy: - opmodule._delayed[funcname] = module - else: - setattr(opmodule, funcname, binary_op) - if not cls._initialized: - _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") - if not lazy: - return binary_op - - @classmethod - def _initialize(cls): - if cls._initialized: # pragma: no cover (safety) - return - super()._initialize() - # Rename div to cdiv - cdiv = binary.cdiv = op.cdiv = BinaryOp("cdiv") - for dtype, ret_type in binary.div.types.items(): - orig_op = binary.div[dtype] - cur_op = TypedBuiltinBinaryOp( - cdiv, "cdiv", dtype, ret_type, orig_op.gb_obj, orig_op.gb_name - ) - cdiv._add(cur_op) - del binary.div - del op.div - # Add truediv which always points to floating point cdiv - # We are effectively hacking cdiv to always return floating point values - # If the inputs are FP32, we use DIV_FP32; use DIV_FP64 for all other input dtypes - truediv = binary.truediv = op.truediv = BinaryOp("truediv") - rtruediv = binary.rtruediv = op.rtruediv = BinaryOp("rtruediv") - for new_op, builtin_op in [(truediv, binary.cdiv), (rtruediv, binary.rdiv)]: - for dtype in builtin_op.types: - if dtype.name in {"FP32", "FC32", "FC64"}: - orig_dtype = dtype - else: - orig_dtype = FP64 - orig_op = builtin_op[orig_dtype] - cur_op = TypedBuiltinBinaryOp( - new_op, - new_op.name, - dtype, - builtin_op.types[orig_dtype], - orig_op.gb_obj, - orig_op.gb_name, - ) - new_op._add(cur_op) - # Add floordiv - # cdiv truncates towards 0, while floordiv truncates towards -inf - BinaryOp.register_new("floordiv", _floordiv, lazy=True) # cast to integer - BinaryOp.register_new("rfloordiv", _rfloordiv, lazy=True) # cast to integer - - # For aggregators - BinaryOp.register_new("absfirst", _absfirst, lazy=True) - BinaryOp.register_new("abssecond", _abssecond, lazy=True) - BinaryOp.register_new("rpow", _rpow, lazy=True) - - # For algorithms - binary._delayed["binom"] = (_register_binom, {}) # Lazy with custom creation - op._delayed["binom"] = binary - - BinaryOp.register_new("isclose", _isclose, parameterized=True) - - # Update type information with sane coercion - position_dtypes = [ - BOOL, - FP32, - FP64, - INT8, - INT16, - UINT8, - UINT16, - UINT32, - UINT64, - ] - if _supports_complex: - position_dtypes.extend([FC32, FC64]) - name_types = [ - # fmt: off - ( - ("atan2", "copysign", "fmod", "hypot", "ldexp", "remainder"), - ((BOOL, INT8, INT16, UINT8, UINT16), FP32), - ((INT32, INT64, UINT32, UINT64), FP64), - ), - ( - ( - "firsti", "firsti1", "firstj", "firstj1", "secondi", "secondi1", - "secondj", "secondj1"), - ( - position_dtypes, - INT64, - ), - ), - ( - ["lxnor"], - ( - ( - FP32, FP64, INT8, INT16, INT32, INT64, - UINT8, UINT16, UINT32, UINT64, - ), - BOOL, - ), - ), - # fmt: on - ] - if _supports_complex: - name_types.append( - ( - ["cmplx"], - ((BOOL, INT8, INT16, UINT8, UINT16), FP32), - ((INT32, INT64, UINT32, UINT64), FP64), - ) - ) - for names, *types in name_types: - for name in names: - if name in _SS_OPERATORS: - cur_op = binary._deprecated[name] - else: - cur_op = getattr(binary, name) - for input_types, target_type in types: - typed_op = cur_op._typed_ops[target_type] - output_type = cur_op.types[target_type] - for dtype in input_types: - if dtype not in cur_op.types: # pragma: no branch (safety) - cur_op.types[dtype] = output_type - cur_op._typed_ops[dtype] = typed_op - cur_op.coercions[dtype] = target_type - # Not valid input dtypes - del binary.ldexp[FP32] - del binary.ldexp[FP64] - # Fill in commutes info - for left_name, right_name in cls._commutes.items(): - if left_name in _SS_OPERATORS: - left = binary._deprecated[left_name] - else: - left = getattr(binary, left_name) - if backend == "suitesparse" and right_name in _SS_OPERATORS: - left._commutes_to = f"ss.{right_name}" - else: - left._commutes_to = right_name - if right_name not in binary._delayed: - if right_name in _SS_OPERATORS: - right = binary._deprecated[right_name] - else: - right = getattr(binary, right_name) - if backend == "suitesparse" and left_name in _SS_OPERATORS: - right._commutes_to = f"ss.{left_name}" - else: - right._commutes_to = left_name - for name in cls._commutative: - cur_op = getattr(binary, name) - cur_op._commutes_to = name - for left_name, right_name in cls._commutes_to_in_semiring.items(): - if left_name in _SS_OPERATORS: - left = binary._deprecated[left_name] - else: # pragma: no cover (safety) - left = getattr(binary, left_name) - if right_name in _SS_OPERATORS: - right = binary._deprecated[right_name] - else: # pragma: no cover (safety) - right = getattr(binary, right_name) - left._semiring_commutes_to = right - right._semiring_commutes_to = left - # Allow some functions to work on UDTs - for binop, func in [ - (binary.first, _first), - (binary.second, _second), - (binary.pair, _pair), - (binary.any, _first), - ]: - binop.orig_func = func - binop._numba_func = numba.njit(func) - binop._udt_types = {} - binop._udt_ops = {} - binary.any._numba_func = binary.first._numba_func - binary.eq._udt_types = {} - binary.eq._udt_ops = {} - binary.ne._udt_types = {} - binary.ne._udt_ops = {} - # Set custom dtype handling - binary.first._custom_dtype = _first_dtype - binary.second._custom_dtype = _second_dtype - binary.pair._custom_dtype = _pair_dtype - cls._initialized = True - - def __init__( - self, - name, - func=None, - *, - anonymous=False, - is_positional=False, - is_udt=False, - numba_func=None, - ): - super().__init__(name, anonymous=anonymous) - self._monoid = None - self._commutes_to = None - self._semiring_commutes_to = None - self.orig_func = func - self._numba_func = numba_func - self._is_udt = is_udt - self.is_positional = is_positional - self._custom_dtype = None - if is_udt: - self._udt_types = {} # {(dtype, dtype): DataType} - self._udt_ops = {} # {(dtype, dtype): TypedUserBinaryOp} - - def __reduce__(self): - if self._anonymous: - if hasattr(self.orig_func, "_parameterized_info"): - return (_deserialize_parameterized, self.orig_func._parameterized_info) - return (self.register_anonymous, (self.orig_func, self.name)) - if (name := f"binary.{self.name}") in _STANDARD_OPERATOR_NAMES: - return name - return (self._deserialize, (self.name, self.orig_func)) - - __call__ = TypedBuiltinBinaryOp.__call__ - is_commutative = TypedBuiltinBinaryOp.is_commutative - commutes_to = ParameterizedBinaryOp.commutes_to - - @property - def monoid(self): - if self._monoid is None and not self._anonymous: - self._monoid = Monoid._find(self.name) - return self._monoid - - -class Monoid(OpBase): - """Takes two inputs and returns one output, all of the same data type. - - Built-in and registered Monoids are located in the ``graphblas.monoid`` namespace - as well as in the ``graphblas.ops`` combined namespace. - """ - - __slots__ = "_binaryop", "_identity", "_is_idempotent" - is_commutative = True - is_positional = False - _custom_dtype = None - _module = monoid - _modname = "monoid" - _typed_class = TypedBuiltinMonoid - _parse_config = { - "trim_from_front": 4, - "delete_exact": "MONOID", - "num_underscores": 1, - "re_exprs": [ - re.compile( - "^GrB_(MIN|MAX|PLUS|TIMES|LOR|LAND|LXOR|LXNOR)_MONOID" - "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" - ), - re.compile( - "^GxB_(ANY)_(INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)_MONOID$" - ), - re.compile("^GxB_(PLUS|TIMES|ANY)_(FC32|FC64)_MONOID$"), - re.compile("^GxB_(EQ|ANY)_BOOL_MONOID$"), - re.compile("^GxB_(BOR|BAND|BXOR|BXNOR)_(UINT8|UINT16|UINT32|UINT64)_MONOID$"), - ], - } - - @classmethod - def _build(cls, name, binaryop, identity, *, is_idempotent=False, anonymous=False): - if type(binaryop) is not BinaryOp: - raise TypeError(f"binaryop must be a BinaryOp, not {type(binaryop)}") - if name is None: - name = binaryop.name - new_type_obj = cls( - name, binaryop, identity, is_idempotent=is_idempotent, anonymous=anonymous - ) - if not binaryop._is_udt: - if not isinstance(identity, Mapping): - identities = dict.fromkeys(binaryop.types, identity) - explicit_identities = False - else: - identities = {lookup_dtype(key): val for key, val in identity.items()} - explicit_identities = True - for type_, ident in identities.items(): - ret_type = binaryop[type_].return_type - # If there is a domain mismatch, then DomainMismatch will be raised - # below if identities were explicitly given. - if type_ != ret_type and not explicit_identities: - continue - new_monoid = ffi_new("GrB_Monoid*") - func = libget(f"GrB_Monoid_new_{type_.name}") - zcast = ffi.cast(type_.c_type, ident) - check_status_carg( - func(new_monoid, binaryop[type_].gb_obj, zcast), "Monoid", new_monoid[0] - ) - op = TypedUserMonoid( - new_type_obj, - name, - type_, - ret_type, - new_monoid[0], - binaryop[type_], - ident, - ) - new_type_obj._add(op) - return new_type_obj - - def _compile_udt(self, dtype, dtype2): - if dtype2 is None: - dtype2 = dtype - elif dtype != dtype2: - raise TypeError( - "Monoid inputs must be the same dtype (got {dtype} and {dtype2}); " - "unable to coerce when using UDTs." - ) - if dtype in self._udt_types: - return self._udt_ops[dtype] - binaryop = self.binaryop._compile_udt(dtype, dtype2) - from .scalar import Scalar - - ret_type = binaryop.return_type - identity = Scalar.from_value(self._identity, dtype=ret_type, is_cscalar=True) - new_monoid = ffi_new("GrB_Monoid*") - status = lib.GrB_Monoid_new_UDT(new_monoid, binaryop.gb_obj, identity.gb_obj) - check_status_carg(status, "Monoid", new_monoid[0]) - op = TypedUserMonoid( - new_monoid, - self.name, - dtype, - ret_type, - new_monoid[0], - binaryop, - identity, - ) - self._udt_types[dtype] = dtype - self._udt_ops[dtype] = op - return op - - @classmethod - def register_anonymous(cls, binaryop, identity, name=None, *, is_idempotent=False): - """Register a Monoid without registering it in the ``graphblas.monoid`` namespace. - - Because it is not registered in the namespace, the name is optional. - - Parameters - ---------- - binaryop : BinaryOp - Builtin or registered binary operator - identity : - Identity value of the monoid - name : str, optional - Name associated with the monoid - is_idempotent : bool, default False - Does ``op(x, x) == x`` for any x? - - Returns - ------- - Function handle - """ - if type(binaryop) is ParameterizedBinaryOp: - return ParameterizedMonoid( - name, binaryop, identity, is_idempotent=is_idempotent, anonymous=True - ) - return cls._build(name, binaryop, identity, is_idempotent=is_idempotent, anonymous=True) - - @classmethod - def register_new(cls, name, binaryop, identity, *, is_idempotent=False, lazy=False): - """Register a Monoid. The name will be used to identify the Monoid in the - ``graphblas.monoid`` namespace. - - >>> gb.core.operator.Monoid.register_new("max_zero", gb.binary.max_zero, 0) - >>> dir(gb.monoid) - [..., 'max_zero', ...] - """ - module, funcname = cls._remove_nesting(name) - if lazy: - module._delayed[funcname] = ( - cls.register_new, - {"name": name, "binaryop": binaryop, "identity": identity}, - ) - elif type(binaryop) is ParameterizedBinaryOp: - monoid = ParameterizedMonoid(name, binaryop, identity, is_idempotent=is_idempotent) - setattr(module, funcname, monoid) - else: - monoid = cls._build(name, binaryop, identity, is_idempotent=is_idempotent) - setattr(module, funcname, monoid) - # Also save it to `graphblas.op` if not yet defined - opmodule, funcname = cls._remove_nesting(name, module=op, modname="op", strict=False) - if not _hasop(opmodule, funcname): - if lazy: - opmodule._delayed[funcname] = module - else: - setattr(opmodule, funcname, monoid) - if not cls._initialized: # pragma: no cover - _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") - if not lazy: - return monoid - - def __init__(self, name, binaryop=None, identity=None, *, is_idempotent=False, anonymous=False): - super().__init__(name, anonymous=anonymous) - self._binaryop = binaryop - self._identity = identity - self._is_idempotent = is_idempotent - if binaryop is not None: - binaryop._monoid = self - if binaryop._is_udt: - self._udt_types = {} # {dtype: DataType} - self._udt_ops = {} # {dtype: TypedUserMonoid} - - def __reduce__(self): - if self._anonymous: - return (self.register_anonymous, (self._binaryop, self._identity, self.name)) - if (name := f"monoid.{self.name}") in _STANDARD_OPERATOR_NAMES: - return name - return (self._deserialize, (self.name, self._binaryop, self._identity)) - - @property - def binaryop(self): - """The :class:`BinaryOp` associated with the Monoid.""" - if self._binaryop is not None: - return self._binaryop - # Must be builtin - return getattr(binary, self.name) - - @property - def identities(self): - """The per-dtype identity values for the Monoid.""" - return {dtype: val.identity for dtype, val in self._typed_ops.items()} - - @property - def is_idempotent(self): - """True if ``monoid(x, x) == x`` for any x.""" - return self._is_idempotent - - @property - def _is_udt(self): - return self._binaryop is not None and self._binaryop._is_udt - - @classmethod - def _initialize(cls): - if cls._initialized: # pragma: no cover (safety) - return - super()._initialize() - lor = monoid.lor._typed_ops[BOOL] - land = monoid.land._typed_ops[BOOL] - for cur_op, typed_op in [ - (monoid.max, lor), - (monoid.min, land), - # (monoid.plus, lor), # two choices: lor, or plus[int] - (monoid.times, land), - ]: - if BOOL not in cur_op.types: # pragma: no branch (safety) - cur_op.types[BOOL] = BOOL - cur_op.coercions[BOOL] = BOOL - cur_op._typed_ops[BOOL] = typed_op - - for cur_op in [monoid.lor, monoid.land, monoid.lxnor, monoid.lxor]: - bool_op = cur_op._typed_ops[BOOL] - for dtype in [ - FP32, - FP64, - INT8, - INT16, - INT32, - INT64, - UINT8, - UINT16, - UINT32, - UINT64, - ]: - if dtype in cur_op.types: # pragma: no cover (safety) - continue - cur_op.types[dtype] = BOOL - cur_op.coercions[dtype] = BOOL - cur_op._typed_ops[dtype] = bool_op - - # Builtin monoids that are idempotent; i.e., `op(x, x) == x` for any x - for name in ["any", "band", "bor", "land", "lor", "max", "min"]: - getattr(monoid, name)._is_idempotent = True - # Allow some functions to work on UDTs - any_ = monoid.any - any_._identity = 0 - any_._udt_types = {} - any_._udt_ops = {} - cls._initialized = True - - commutes_to = TypedBuiltinMonoid.commutes_to - __call__ = TypedBuiltinMonoid.__call__ - - -class Semiring(OpBase): - """Combination of a :class:`Monoid` and a :class:`BinaryOp`. - - Semirings are most commonly used for performing matrix multiplication, - with the BinaryOp taking the place of the standard multiplication operator - and the Monoid taking the place of the standard addition operator. - - Built-in and registered Semirings are located in the ``graphblas.semiring`` namespace - as well as in the ``graphblas.ops`` combined namespace. - """ - - __slots__ = "_monoid", "_binaryop" - _module = semiring - _modname = "semiring" - _typed_class = TypedBuiltinSemiring - _parse_config = { - "trim_from_front": 4, - "delete_exact": "SEMIRING", - "num_underscores": 2, - "re_exprs": [ - re.compile( - "^GrB_(PLUS|MIN|MAX)_(PLUS|TIMES|FIRST|SECOND|MIN|MAX)_SEMIRING" - "_(INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" - ), - re.compile( - "^GxB_(MIN|MAX|PLUS|TIMES|ANY)" - "_(FIRST|SECOND|PAIR|MIN|MAX|PLUS|MINUS|RMINUS|TIMES" - "|DIV|RDIV|ISEQ|ISNE|ISGT|ISLT|ISGE|ISLE|LOR|LAND|LXOR" - "|FIRSTI1|FIRSTI|FIRSTJ1|FIRSTJ|SECONDI1|SECONDI|SECONDJ1|SECONDJ)" - "_(INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" - ), - re.compile( - "^GxB_(PLUS|TIMES|ANY)_(FIRST|SECOND|PAIR|PLUS|MINUS|TIMES|DIV|RDIV|RMINUS)" - "_(FC32|FC64)$" - ), - re.compile( - "^GxB_(BOR|BAND|BXOR|BXNOR)_(BOR|BAND|BXOR|BXNOR)_(UINT8|UINT16|UINT32|UINT64)$" - ), - ], - "re_exprs_return_bool": [ - re.compile("^GrB_(LOR|LAND|LXOR|LXNOR)_(LOR|LAND)_SEMIRING_BOOL$"), - re.compile( - "^GxB_(LOR|LAND|LXOR|EQ|ANY)_(EQ|NE|GT|LT|GE|LE)" - "_(INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" - ), - re.compile( - "^GxB_(LOR|LAND|LXOR|EQ|ANY)_(FIRST|SECOND|PAIR|LOR|LAND|LXOR|EQ|GT|LT|GE|LE)_BOOL$" - ), - ], - } - - @classmethod - def _build(cls, name, monoid, binaryop, *, anonymous=False): - if type(monoid) is not Monoid: - raise TypeError(f"monoid must be a Monoid, not {type(monoid)}") - if type(binaryop) is not BinaryOp: - raise TypeError(f"binaryop must be a BinaryOp, not {type(binaryop)}") - if name is None: - name = f"{monoid.name}_{binaryop.name}".replace(".", "_") - new_type_obj = cls(name, monoid, binaryop, anonymous=anonymous) - if binaryop._is_udt: - return new_type_obj - for binary_in, binary_func in binaryop._typed_ops.items(): - binary_out = binary_func.return_type - # Unfortunately, we can't have user-defined monoids over bools yet - # because numba can't compile correctly. - if ( - binary_out not in monoid.types - # Are all coercions bad, or just to bool? - or monoid.coercions.get(binary_out, binary_out) != binary_out - ): - continue - new_semiring = ffi_new("GrB_Semiring*") - check_status_carg( - lib.GrB_Semiring_new(new_semiring, monoid[binary_out].gb_obj, binary_func.gb_obj), - "Semiring", - new_semiring, - ) - ret_type = monoid[binary_out].return_type - op = TypedUserSemiring( - new_type_obj, - name, - binary_in, - ret_type, - new_semiring[0], - monoid[binary_out], - binary_func, - ) - new_type_obj._add(op) - return new_type_obj - - def _compile_udt(self, dtype, dtype2): - if dtype2 is None: - dtype2 = dtype - dtypes = (dtype, dtype2) - if dtypes in self._udt_types: - return self._udt_ops[dtypes] - binaryop = self.binaryop._compile_udt(dtype, dtype2) - monoid = self.monoid[binaryop.return_type] - ret_type = monoid.return_type - new_semiring = ffi_new("GrB_Semiring*") - status = lib.GrB_Semiring_new(new_semiring, monoid.gb_obj, binaryop.gb_obj) - check_status_carg(status, "Semiring", new_semiring) - op = TypedUserSemiring( - new_semiring, - self.name, - dtype, - ret_type, - new_semiring[0], - monoid, - binaryop, - dtype2=dtype2, - ) - self._udt_types[dtypes] = dtype - self._udt_ops[dtypes] = op - return op - - @classmethod - def register_anonymous(cls, monoid, binaryop, name=None): - """Register a Semiring without registering it in the ``graphblas.semiring`` namespace. - - Because it is not registered in the namespace, the name is optional. - - Parameters - ---------- - monoid : Monoid - Builtin or registered monoid - binaryop : BinaryOp - Builtin or registered binary operator - name : str, optional - Name associated with the semiring - - Returns - ------- - Function handle - """ - if type(monoid) is ParameterizedMonoid or type(binaryop) is ParameterizedBinaryOp: - return ParameterizedSemiring(name, monoid, binaryop, anonymous=True) - return cls._build(name, monoid, binaryop, anonymous=True) - - @classmethod - def register_new(cls, name, monoid, binaryop, *, lazy=False): - """Register a Semiring. The name will be used to identify the Semiring in the - ``graphblas.semiring`` namespace. - - >>> gb.core.operator.Semiring.register_new("max_max", gb.monoid.max, gb.binary.max) - >>> dir(gb.semiring) - [..., 'max_max', ...] - """ - module, funcname = cls._remove_nesting(name) - if lazy: - module._delayed[funcname] = ( - cls.register_new, - {"name": name, "monoid": monoid, "binaryop": binaryop}, - ) - elif type(monoid) is ParameterizedMonoid or type(binaryop) is ParameterizedBinaryOp: - semiring = ParameterizedSemiring(name, monoid, binaryop) - setattr(module, funcname, semiring) - else: - semiring = cls._build(name, monoid, binaryop) - setattr(module, funcname, semiring) - # Also save it to `graphblas.op` if not yet defined - opmodule, funcname = cls._remove_nesting(name, module=op, modname="op", strict=False) - if not _hasop(opmodule, funcname): - if lazy: - opmodule._delayed[funcname] = module - else: - setattr(opmodule, funcname, semiring) - if not cls._initialized: - _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") - if not lazy: - return semiring - - @classmethod - def _initialize(cls): - if cls._initialized: # pragma: no cover (safety) - return - super()._initialize() - # Rename div to cdiv (truncate towards 0) - div_semirings = { - attr: val - for attr, val in vars(semiring).items() - if type(val) is Semiring and attr.endswith("_div") - } - for orig_name, orig in div_semirings.items(): - name = f"{orig_name[:-3]}cdiv" - cdiv_semiring = Semiring(name) - setattr(semiring, name, cdiv_semiring) - setattr(op, name, cdiv_semiring) - delattr(semiring, orig_name) - delattr(op, orig_name) - for dtype, ret_type in orig.types.items(): - orig_semiring = orig[dtype] - new_semiring = TypedBuiltinSemiring( - cdiv_semiring, - name, - dtype, - ret_type, - orig_semiring.gb_obj, - orig_semiring.gb_name, - ) - cdiv_semiring._add(new_semiring) - # Also add truediv (always floating point) and floordiv (truncate towards -inf) - for orig_name, orig in div_semirings.items(): - cls.register_new(f"{orig_name[:-3]}truediv", orig.monoid, binary.truediv, lazy=True) - cls.register_new(f"{orig_name[:-3]}rtruediv", orig.monoid, "rtruediv", lazy=True) - cls.register_new(f"{orig_name[:-3]}floordiv", orig.monoid, "floordiv", lazy=True) - cls.register_new(f"{orig_name[:-3]}rfloordiv", orig.monoid, "rfloordiv", lazy=True) - # For aggregators - cls.register_new("plus_pow", monoid.plus, binary.pow) - cls.register_new("plus_rpow", monoid.plus, "rpow", lazy=True) - cls.register_new("plus_absfirst", monoid.plus, "absfirst", lazy=True) - cls.register_new("max_absfirst", monoid.max, "absfirst", lazy=True) - cls.register_new("plus_abssecond", monoid.plus, "abssecond", lazy=True) - cls.register_new("max_abssecond", monoid.max, "abssecond", lazy=True) - - # Update type information with sane coercion - for lname in ["any", "eq", "land", "lor", "lxnor", "lxor"]: - target_name = f"{lname}_ne" - source_name = f"{lname}_lxor" - if not _hasop(semiring, target_name): - continue - target_op = getattr(semiring, target_name) - if BOOL not in target_op.types: # pragma: no branch (safety) - source_op = getattr(semiring, source_name) - typed_op = source_op._typed_ops[BOOL] - target_op.types[BOOL] = BOOL - target_op._typed_ops[BOOL] = typed_op - target_op.coercions[dtype] = BOOL - - position_dtypes = [ - BOOL, - FP32, - FP64, - INT8, - INT16, - UINT8, - UINT16, - UINT32, - UINT64, - ] - notbool_dtypes = [ - FP32, - FP64, - INT8, - INT16, - INT32, - INT64, - UINT8, - UINT16, - UINT32, - UINT64, - ] - if _supports_complex: - position_dtypes.extend([FC32, FC64]) - notbool_dtypes.extend([FC32, FC64]) - for lnames, rnames, *types in [ - # fmt: off - ( - ("any", "max", "min", "plus", "times"), - ( - "firsti", "firsti1", "firstj", "firstj1", - "secondi", "secondi1", "secondj", "secondj1", - ), - ( - position_dtypes, - INT64, - ), - ), - ( - ("eq", "land", "lor", "lxnor", "lxor"), - ("first", "pair", "second"), - # TODO: check if FC coercion works here - ( - notbool_dtypes, - BOOL, - ), - ), - ( - ("band", "bor", "bxnor", "bxor"), - ("band", "bor", "bxnor", "bxor"), - ([INT8], UINT16), - ([INT16], UINT32), - ([INT32], UINT64), - ([INT64], UINT64), - ), - ( - ("any", "eq", "land", "lor", "lxnor", "lxor"), - ("eq", "land", "lor", "lxnor", "lxor", "ne"), - ( - ( - FP32, FP64, INT8, INT16, INT32, INT64, - UINT8, UINT16, UINT32, UINT64, - ), - BOOL, - ), - ), - # fmt: on - ]: - for left, right in itertools.product(lnames, rnames): - name = f"{left}_{right}" - if not _hasop(semiring, name): - continue - if name in _SS_OPERATORS: - cur_op = semiring._deprecated[name] - else: - cur_op = getattr(semiring, name) - for input_types, target_type in types: - typed_op = cur_op._typed_ops[target_type] - output_type = cur_op.types[target_type] - for dtype in input_types: - if dtype not in cur_op.types: - cur_op.types[dtype] = output_type - cur_op._typed_ops[dtype] = typed_op - cur_op.coercions[dtype] = target_type - - # Handle a few boolean cases - for opname, targetname in [ - ("max_first", "lor_first"), - ("max_second", "lor_second"), - ("max_land", "lor_land"), - ("max_lor", "lor_lor"), - ("max_lxor", "lor_lxor"), - ("min_first", "land_first"), - ("min_second", "land_second"), - ("min_land", "land_land"), - ("min_lor", "land_lor"), - ("min_lxor", "land_lxor"), - ]: - cur_op = getattr(semiring, opname) - target = getattr(semiring, targetname) - if BOOL in cur_op.types or BOOL not in target.types: # pragma: no cover (safety) - continue - cur_op.types[BOOL] = target.types[BOOL] - cur_op._typed_ops[BOOL] = target._typed_ops[BOOL] - cur_op.coercions[BOOL] = BOOL - cls._initialized = True - - def __init__(self, name, monoid=None, binaryop=None, *, anonymous=False): - super().__init__(name, anonymous=anonymous) - self._monoid = monoid - self._binaryop = binaryop - try: - if self.binaryop._udt_types is not None: - self._udt_types = {} # {(dtype, dtype): DataType} - self._udt_ops = {} # {(dtype, dtype): TypedUserSemiring} - except AttributeError: - # `*_div` semirings raise here, but don't need `_udt_types` - pass - - def __reduce__(self): - if self._anonymous: - return (self.register_anonymous, (self._monoid, self._binaryop, self.name)) - if (name := f"semiring.{self.name}") in _STANDARD_OPERATOR_NAMES: - return name - return (self._deserialize, (self.name, self._monoid, self._binaryop)) - - @property - def binaryop(self): - """The :class:`BinaryOp` associated with the Semiring.""" - if self._binaryop is not None: - return self._binaryop - # Must be builtin - name = self.name.split("_")[1] - if name in _SS_OPERATORS: - return binary._deprecated[name] - return getattr(binary, name) - - @property - def monoid(self): - """The :class:`Monoid` associated with the Semiring.""" - if self._monoid is not None: - return self._monoid - # Must be builtin - return getattr(monoid, self.name.split("_")[0].split(".")[-1]) - - @property - def is_positional(self): - return self.binaryop.is_positional - - @property - def _is_udt(self): - return self._binaryop is not None and self._binaryop._is_udt - - @property - def _custom_dtype(self): - return self.binaryop._custom_dtype - - commutes_to = TypedBuiltinSemiring.commutes_to - is_commutative = TypedBuiltinSemiring.is_commutative - __call__ = TypedBuiltinSemiring.__call__ - - -def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scalar=False, kind=None): - if isinstance(op, OpBase): - # UDTs always get compiled - if op._is_udt: - return op._compile_udt(dtype, dtype2) - # Single dtype is simple lookup - 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 and (rv := op._custom_dtype(op, dtype, dtype2)) is not None: - return rv - # Generic case: try to unify the two dtypes - try: - return op[ - unify(dtype, dtype2, is_left_scalar=is_left_scalar, is_right_scalar=is_right_scalar) - ] - except (TypeError, AttributeError): - # Failure to unify implies a dtype is UDT; some builtin operators can handle UDTs - if op.is_positional: - return op[UINT64] - if op._udt_types is None: - raise - return op._compile_udt(dtype, dtype2) - if isinstance(op, ParameterizedUdf): - op = op() # Use default parameters of parameterized UDFs - return get_typed_op( - op, - dtype, - dtype2, - is_left_scalar=is_left_scalar, - is_right_scalar=is_right_scalar, - kind=kind, - ) - if isinstance(op, TypedOpBase): - return op - - from .agg import Aggregator, TypedAggregator - - if isinstance(op, Aggregator): - return op[dtype] - if isinstance(op, TypedAggregator): - return op - if isinstance(op, str): - if kind == "unary": - op = unary_from_string(op) - elif kind == "select": - op = select_from_string(op) - elif kind == "binary": - op = binary_from_string(op) - elif kind == "monoid": - op = monoid_from_string(op) - elif kind == "semiring": - op = semiring_from_string(op) - elif kind == "binary|aggregator": - try: - op = binary_from_string(op) - except ValueError: - try: - op = aggregator_from_string(op) - except ValueError: - raise ValueError( - f"Unknown binary or aggregator string: {op!r}. Example usage: '+[int]'" - ) from None - - else: - raise ValueError( - f"Unable to get op from string {op!r}. `kind=` argument must be provided as " - '"unary", "binary", "monoid", "semiring", "indexunary", "select", ' - 'or "binary|aggregator".' - ) - return get_typed_op( - op, - dtype, - dtype2, - is_left_scalar=is_left_scalar, - is_right_scalar=is_right_scalar, - kind=kind, - ) - if isinstance(op, FunctionType): - if kind == "unary": - op = UnaryOp.register_anonymous(op, is_udt=True) - return op._compile_udt(dtype, dtype2) - if kind.startswith("binary"): - op = BinaryOp.register_anonymous(op, is_udt=True) - return op._compile_udt(dtype, dtype2) - if isinstance(op, BuiltinFunctionType) and op in _builtin_to_op: - return get_typed_op( - _builtin_to_op[op], - dtype, - dtype2, - is_left_scalar=is_left_scalar, - is_right_scalar=is_right_scalar, - kind=kind, - ) - raise TypeError(f"Unable to get typed operator from object with type {type(op)}") - - -def find_opclass(gb_op): - if isinstance(gb_op, OpBase): - opclass = type(gb_op).__name__ - elif isinstance(gb_op, TypedOpBase): - opclass = gb_op.opclass - elif isinstance(gb_op, ParameterizedUdf): - gb_op = gb_op() # Use default parameters of parameterized UDFs - gb_op, opclass = find_opclass(gb_op) - elif isinstance(gb_op, BuiltinFunctionType) and gb_op in _builtin_to_op: - gb_op, opclass = find_opclass(_builtin_to_op[gb_op]) - else: - opclass = UNKNOWN_OPCLASS - return gb_op, opclass - - -def get_semiring(monoid, binaryop, name=None): - """Get or create a Semiring object from a monoid and binaryop. - - If either are typed, then the returned semiring will also be typed. - - See Also - -------- - semiring.register_anonymous - semiring.register_new - semiring.from_string - """ - monoid, opclass = find_opclass(monoid) - switched = False - if opclass == "BinaryOp" and monoid.monoid is not None: - switched = True - monoid = monoid.monoid - elif opclass != "Monoid": - raise TypeError(f"Expected a Monoid for the monoid argument. Got type: {type(monoid)}") - binaryop, opclass = find_opclass(binaryop) - if opclass == "Monoid": - if switched: - raise TypeError( - "Got a BinaryOp for the monoid argument and a Monoid for the binaryop argument. " - "Are the arguments switched? Hint: you can do `mymonoid.binaryop` to get the " - "binaryop from a monoid." - ) - binaryop = binaryop.binaryop - elif opclass != "BinaryOp": - raise TypeError( - f"Expected a BinaryOp for the binaryop argument. Got type: {type(binaryop)}" - ) - if isinstance(monoid, Monoid): - monoid_type = None - else: - monoid_type = monoid.type - monoid = monoid.parent - if isinstance(binaryop, BinaryOp): - binary_type = None - else: - binary_type = binaryop.type - binaryop = binaryop.parent - if monoid._anonymous or binaryop._anonymous: - rv = Semiring.register_anonymous(monoid, binaryop, name=name) - else: - *monoid_prefix, monoid_name = monoid.name.rsplit(".", 1) - *binary_prefix, binary_name = binaryop.name.rsplit(".", 1) - if ( - monoid_prefix - and binary_prefix - and monoid_prefix == binary_prefix - or config.get("mapnumpy") - and ( - monoid_prefix == ["numpy"] - and not binary_prefix - or binary_prefix == ["numpy"] - and not monoid_prefix - ) - or backend == "suitesparse" - and binary_name in _SS_OPERATORS - ): - canonical_name = ( - ".".join(monoid_prefix or binary_prefix) + f".{monoid_name}_{binary_name}" - ) - else: - canonical_name = f"{monoid.name}_{binaryop.name}".replace(".", "_") - if name is None: - name = canonical_name - - module, funcname = Semiring._remove_nesting(canonical_name, strict=False) - rv = ( - getattr(module, funcname) - if funcname in module.__dict__ or funcname in module._delayed - else getattr(module, "_deprecated", {}).get(funcname) - ) - if rv is None and name != canonical_name: - module, funcname = Semiring._remove_nesting(name, strict=False) - rv = ( - getattr(module, funcname) - if funcname in module.__dict__ or funcname in module._delayed - else getattr(module, "_deprecated", {}).get(funcname) - ) - if rv is None: - rv = Semiring.register_new(canonical_name, monoid, binaryop) - elif rv.monoid is not monoid or rv.binaryop is not binaryop: # pragma: no cover - # It's not the object we expect (can this happen?) - rv = Semiring.register_anonymous(monoid, binaryop, name=name) - if name != canonical_name: - module, funcname = Semiring._remove_nesting(name, strict=False) - if not _hasop(module, funcname): # pragma: no branch (safety) - setattr(module, funcname, rv) - - if binary_type is not None: - return rv[binary_type] - if monoid_type is not None: - return rv[monoid_type] - return rv - - -# Now initialize all the things! -try: - UnaryOp._initialize() - IndexUnaryOp._initialize() - SelectOp._initialize() - BinaryOp._initialize() - Monoid._initialize() - Semiring._initialize() -except Exception: # pragma: no cover (debug) - # Exceptions here can often get ignored by Python - import traceback - - traceback.print_exc() - raise - -unary.register_new = UnaryOp.register_new -unary.register_anonymous = UnaryOp.register_anonymous -indexunary.register_new = IndexUnaryOp.register_new -indexunary.register_anonymous = IndexUnaryOp.register_anonymous -select.register_new = SelectOp.register_new -select.register_anonymous = SelectOp.register_anonymous -binary.register_new = BinaryOp.register_new -binary.register_anonymous = BinaryOp.register_anonymous -monoid.register_new = Monoid.register_new -monoid.register_anonymous = Monoid.register_anonymous -semiring.register_new = Semiring.register_new -semiring.register_anonymous = Semiring.register_anonymous -semiring.get_semiring = get_semiring - -select._binary_to_select.update( - { - binary.eq: select.valueeq, - binary.ne: select.valuene, - binary.le: select.valuele, - binary.lt: select.valuelt, - binary.ge: select.valuege, - binary.gt: select.valuegt, - binary.iseq: select.valueeq, - binary.isne: select.valuene, - binary.isle: select.valuele, - binary.islt: select.valuelt, - binary.isge: select.valuege, - binary.isgt: select.valuegt, - } -) - -_builtin_to_op = { - abs: unary.abs, - max: binary.max, - min: binary.min, - # Maybe someday: all, any, pow, sum -} - -_str_to_unary = { - "-": unary.ainv, - "~": unary.lnot, -} -_str_to_select = { - "<": select.valuelt, - ">": select.valuegt, - "<=": select.valuele, - ">=": select.valuege, - "!=": select.valuene, - "==": select.valueeq, - "col<=": select.colle, - "col>": select.colgt, - "row<=": select.rowle, - "row>": select.rowgt, - "index<=": select.indexle, - "index>": select.indexgt, -} -_str_to_binary = { - "<": binary.lt, - ">": binary.gt, - "<=": binary.le, - ">=": binary.ge, - "!=": binary.ne, - "==": binary.eq, - "+": binary.plus, - "-": binary.minus, - "*": binary.times, - "/": binary.truediv, - "//": "floordiv", - "%": "numpy.mod", - "**": binary.pow, - "&": binary.land, - "|": binary.lor, - "^": binary.lxor, -} -_str_to_monoid = { - "==": monoid.eq, - "+": monoid.plus, - "*": monoid.times, - "&": monoid.land, - "|": monoid.lor, - "^": monoid.lxor, -} - - -def _from_string(string, module, mapping, example): - s = string.lower().strip() - base, *dtype = s.split("[") - if len(dtype) > 1: - name = module.__name__.split(".")[-1] - raise ValueError( - f'Bad {name} string: {string!r}. Contains too many "[". Example usage: {example!r}' - ) - if dtype: - dtype = dtype[0] - if not dtype.endswith("]"): - name = module.__name__.split(".")[-1] - raise ValueError( - f'Bad {name} string: {string!r}. Datatype specification does not end with "]". ' - f"Example usage: {example!r}" - ) - dtype = lookup_dtype(dtype[:-1]) - if "]" in base: - name = module.__name__.split(".")[-1] - raise ValueError( - f'Bad {name} string: {string!r}. "]" not matched by "[". Example usage: {example!r}' - ) - if base in mapping: - op = mapping[base] - if type(op) is str: - op = mapping[base] = module.from_string(op) - elif hasattr(module, base): - op = getattr(module, base) - elif hasattr(module, "numpy") and hasattr(module.numpy, base): - op = getattr(module.numpy, base) - else: - *paths, attr = base.split(".") - op = None - cur = module - for path in paths: - cur = getattr(cur, path, None) - if not isinstance(cur, (OpPath, ModuleType)): - cur = None - break - op = getattr(cur, attr, None) - if op is None: - name = module.__name__.split(".")[-1] - raise ValueError(f"Unknown {name} string: {string!r}. Example usage: {example!r}") - if dtype: - op = op[dtype] - return op - - -def unary_from_string(string): - return _from_string(string, unary, _str_to_unary, "abs[int]") - - -def indexunary_from_string(string): - # "select" is a variant of IndexUnary, so the string abbreviations in - # _str_to_select are appropriate to reuse here - return _from_string(string, indexunary, _str_to_select, "row_index") - - -def select_from_string(string): - return _from_string(string, select, _str_to_select, "tril") - - -def binary_from_string(string): - return _from_string(string, binary, _str_to_binary, "+[int]") - - -def monoid_from_string(string): - return _from_string(string, monoid, _str_to_monoid, "+[int]") - - -def semiring_from_string(string): - split = string.split(".") - if len(split) == 1: - try: - return _from_string(string, semiring, {}, "min.+[int]") - except Exception: - pass - if len(split) != 2: - raise ValueError( - f"Bad semiring string: {string!r}. " - 'The monoid and binaryop should be separated by exactly one period, ".". ' - "Example usage: min.+[int]" - ) - cur_monoid = monoid_from_string(split[0]) - cur_binary = binary_from_string(split[1]) - return get_semiring(cur_monoid, cur_binary) - - -def op_from_string(string): - for func in [ - # Note: order matters here - unary_from_string, - binary_from_string, - monoid_from_string, - semiring_from_string, - indexunary_from_string, - select_from_string, - aggregator_from_string, - ]: - try: - return func(string) - except Exception: - pass - raise ValueError(f"Unknown op string: {string!r}. Example usage: 'abs[int]'") - - -unary.from_string = unary_from_string -indexunary.from_string = indexunary_from_string -select.from_string = select_from_string -binary.from_string = binary_from_string -monoid.from_string = monoid_from_string -semiring.from_string = semiring_from_string -op.from_string = op_from_string - -_str_to_agg = { - "+": "sum", - "*": "prod", - "&": "all", - "|": "any", -} - - -def aggregator_from_string(string): - return _from_string(string, agg, _str_to_agg, "sum[int]") - - -from .. import agg # noqa: E402 isort:skip - -agg.from_string = aggregator_from_string diff --git a/graphblas/core/operator/__init__.py b/graphblas/core/operator/__init__.py new file mode 100644 index 000000000..509e84a04 --- /dev/null +++ b/graphblas/core/operator/__init__.py @@ -0,0 +1,21 @@ +from .base import UNKNOWN_OPCLASS, OpBase, OpPath, ParameterizedUdf, TypedOpBase, find_opclass +from .binary import BinaryOp, ParameterizedBinaryOp +from .indexunary import IndexUnaryOp, ParameterizedIndexUnaryOp +from .monoid import Monoid, ParameterizedMonoid +from .select import ParameterizedSelectOp, SelectOp +from .semiring import ParameterizedSemiring, Semiring +from .unary import ParameterizedUnaryOp, UnaryOp +from .utils import ( + aggregator_from_string, + binary_from_string, + get_semiring, + get_typed_op, + indexunary_from_string, + monoid_from_string, + op_from_string, + select_from_string, + semiring_from_string, + unary_from_string, +) + +from .agg import Aggregator # isort:skip diff --git a/graphblas/core/operator/agg.py b/graphblas/core/operator/agg.py new file mode 100644 index 000000000..036149b1f --- /dev/null +++ b/graphblas/core/operator/agg.py @@ -0,0 +1,680 @@ +from functools import partial +from operator import getitem + +import numpy as np + +from ... import agg, backend, binary, monoid, semiring, unary +from ...dtypes import INT64, lookup_dtype +from ..utils import output_type + + +def _get_types(ops, initdtype): + """Determine the input and output types of an aggregator based on a list of ops.""" + if initdtype is None: + prev = dict(ops[0].types) + else: + op = ops[0] + prev = {key: get_typed_op(op, key, initdtype).return_type for key in op.types} + for op in ops[1:]: + cur = {} + types = op.types + for in_type, out_type in prev.items(): + if out_type not in types: # pragma: no cover (safety) + continue + cur[in_type] = types[out_type] + prev = cur + return prev + + +class Aggregator: + opclass = "Aggregator" + + def __init__( + self, + name, + *, + initval=None, + monoid=None, + semiring=None, + switch=False, + semiring2=None, + finalize=None, + composite=None, + custom=None, + types=None, + any_dtype=None, + ): + self.name = name + self._initval_orig = initval + self._initval = False if initval is None else initval + self._initdtype = lookup_dtype(type(self._initval), self._initval) + self._monoid = monoid + self._semiring = semiring + self._semiring2 = semiring2 + self._switch = switch + self._finalize = finalize + self._composite = composite + self._custom = custom + if types is None: + if monoid is not None: + types = [monoid] + elif semiring is not None: + types = [semiring, semiring2] + if finalize is not None: + types.append(finalize) + initval = self._initval + else: # pragma: no cover (sanity) + raise TypeError("types must be provided for composite and custom aggregators") + self._types_orig = types + self._types = None + self._typed_ops = {} + self._any_dtype = any_dtype + + @property + def types(self): + if self._types is None: + if type(self._semiring) is str: + self._semiring = semiring.from_string(self._semiring) + if type(self._types_orig[0]) is str: # pragma: no branch + self._types_orig[0] = semiring.from_string(self._types_orig[0]) + self._types = _get_types( + self._types_orig, None if self._initval_orig is None else self._initdtype + ) + return self._types + + def __getitem__(self, dtype): + dtype = lookup_dtype(dtype) + if not self._any_dtype and dtype not in self.types: + raise KeyError(f"{self.name} does not work with {dtype}") + if dtype not in self._typed_ops: + self._typed_ops[dtype] = TypedAggregator(self, dtype) + return self._typed_ops[dtype] + + def __contains__(self, dtype): + dtype = lookup_dtype(dtype) + return self._any_dtype or dtype in self.types + + def __repr__(self): + if self.name in agg._deprecated: + return f"agg.ss.{self.name}" + return f"agg.{self.name}" + + def __reduce__(self): + if self.name in agg._deprecated: + return f"agg.ss.{self.name}" + return f"agg.{self.name}" + + def __call__(self, val, *, rowwise=False, columnwise=False): + # Should we expose `allow_empty=` keyword when reducing to Scalar? + from ..matrix import Matrix, TransposedMatrix + from ..vector import Vector + + typ = output_type(val) + if typ is Vector: + if rowwise or columnwise: + raise ValueError( + "rowwise and columnwise arguments should not be used with Vector input" + ) + return val.reduce(self) + if typ in {Matrix, TransposedMatrix}: + if rowwise: + if columnwise: + raise ValueError("rowwise and columnwise arguments cannot both be True") + return val.reduce_rowwise(self) + if columnwise: + return val.reduce_columnwise(self) + return val.reduce_scalar(self) + raise TypeError( + f"Bad type when calling {self!r}.\n" + " - Expected type: Vector, Matrix, TransposedMatrix.\n" + f" - Got: {type(val)}.\n" + "Calling an Aggregator is syntactic sugar for calling reduce methods. " + f"For example, `A.reduce_scalar({self!r})` is the same as `{self!r}(A)`." + ) + + +class TypedAggregator: + opclass = "Aggregator" + + def __init__(self, agg, dtype): + self.name = agg.name + self.parent = agg + self.type = dtype + if dtype in agg.types: + self.return_type = agg.types[dtype] + elif agg._any_dtype is True: + self.return_type = dtype + else: + self.return_type = agg._any_dtype + + def __repr__(self): + return f"agg.{self.name}[{self.type}]" + + def _new(self, updater, expr, *, in_composite=False): + agg = self.parent + if agg._monoid is not None: + x = expr.args[0] + method = getattr(x, expr.method_name) + if expr.output_type.__name__ == "Scalar": + expr = method(agg._monoid[self.type], allow_empty=not expr._is_cscalar) + else: + expr = method(agg._monoid[self.type]) + updater << expr + if in_composite: + parent = updater.parent + if not parent._is_scalar: + return parent + return parent._as_vector() + return + + opts = updater.opts + if agg._composite is not None: + # Masks are applied throughout the aggregation, including composite aggregations. + # Aggregations done while `in_composite is True` should return the updater parent + # if the result is not a Scalar. If the result is a Scalar, then there can be no + # output mask, and a Vector of size 1 should be returned instead. + results = [] + mask = updater.kwargs.get("mask") + for cur_agg in agg._composite: + cur_agg = cur_agg[self.type] # Hopefully works well enough + arg = expr.construct_output(cur_agg.return_type) + results.append(cur_agg._new(arg(mask=mask, **opts), expr, in_composite=True)) + final_expr = agg._finalize(*results, opts) + if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": + updater << final_expr + elif expr.cfunc_name.startswith("GrB_Vector_reduce") or expr.cfunc_name.startswith( + "GrB_Matrix_reduce" + ): + final = final_expr.new(**opts) + updater << final[0] + else: + raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}") + if in_composite: + parent = updater.parent + if not parent._is_scalar: + return parent + return parent._as_vector() + return + + if agg._custom is not None: + return agg._custom(self, updater, expr, opts, in_composite=in_composite) + + semiring = get_typed_op(agg._semiring, self.type, agg._initdtype) + if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": + # Matrix -> Vector + A = expr.args[0] + orig_updater = updater + if agg._finalize is not None: + step1 = expr.construct_output(semiring.return_type) + updater = step1(mask=updater.kwargs.get("mask"), **opts) + if expr.method_name == "reduce_columnwise": + A = A.T + size = A._ncols + init = expr._new_vector(agg._initdtype, size=size) + init(**opts)[...] = agg._initval # O(1) dense vector in SuiteSparse 5 + if agg._switch: + updater << semiring(init @ A.T) + else: + updater << semiring(A @ init) + if agg._finalize is not None: + orig_updater << agg._finalize[semiring.return_type](step1) + if in_composite: + return orig_updater.parent + elif expr.cfunc_name.startswith("GrB_Vector_reduce"): + # Vector -> Scalar + v = expr.args[0] + step1 = expr._new_vector(semiring.return_type, size=1) + init = expr._new_matrix(agg._initdtype, nrows=v._size, ncols=1) + init(**opts)[...] = agg._initval # O(1) dense column vector in SuiteSparse 5 + if agg._switch: + step1(**opts) << semiring(init.T @ v) + else: + step1(**opts) << semiring(v @ init) + if agg._finalize is not None: + finalize = agg._finalize[semiring.return_type] + if step1.dtype == finalize.return_type: + step1(**opts) << finalize(step1) + else: + step1 = finalize(step1).new(finalize.return_type, **opts) + if in_composite: + return step1 + updater << step1[0] + elif expr.cfunc_name.startswith("GrB_Matrix_reduce"): + # Matrix -> Scalar + A = expr.args[0] + # We need to compute in two steps: Matrix -> Vector -> Scalar. + # This has not been benchmarked or optimized. + # We may be able to intelligently choose the faster path. + init1 = expr._new_vector(agg._initdtype, size=A._ncols) + init1(**opts)[...] = agg._initval # O(1) dense vector in SuiteSparse 5 + step1 = expr._new_vector(semiring.return_type, size=A._nrows) + if agg._switch: + step1(**opts) << semiring(init1 @ A.T) + else: + step1(**opts) << semiring(A @ init1) + init2 = expr._new_matrix(agg._initdtype, nrows=A._nrows, ncols=1) + init2(**opts)[...] = agg._initval # O(1) dense vector in SuiteSparse 5 + semiring2 = agg._semiring2[semiring.return_type] + step2 = expr._new_vector(semiring2.return_type, size=1) + step2(**opts) << semiring2(step1 @ init2) + if agg._finalize is not None: + finalize = agg._finalize[semiring2.return_type] + if step2.dtype == finalize.return_type: + step2 << finalize(step2) + else: + step2 = finalize(step2).new(finalize.return_type, **opts) + if in_composite: + return step2 + updater << step2[0] + else: + raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}") + + def __reduce__(self): + return (getitem, (self.parent, self.type)) + + __call__ = Aggregator.__call__ + + +# Monoid-only +agg.sum = Aggregator("sum", monoid=monoid.plus) +agg.prod = Aggregator("prod", monoid=monoid.times) +agg.all = Aggregator("all", monoid=monoid.land) +agg.any = Aggregator("any", monoid=monoid.lor) +agg.min = Aggregator("min", monoid=monoid.min) +agg.max = Aggregator("max", monoid=monoid.max) +agg.any_value = Aggregator("any_value", monoid=monoid.any, any_dtype=True) +agg.bitwise_all = Aggregator("bitwise_all", monoid=monoid.band) +agg.bitwise_any = Aggregator("bitwise_any", monoid=monoid.bor) +# Other monoids: bxnor bxor eq lxnor lxor + +# Semiring-only +agg.count = Aggregator( + "count", semiring=semiring.plus_pair, semiring2=semiring.plus_first, any_dtype=INT64 +) +agg.count_nonzero = Aggregator( + "count_nonzero", semiring=semiring.plus_isne, semiring2=semiring.plus_first +) +agg.count_zero = Aggregator( + "count_zero", semiring=semiring.plus_iseq, semiring2=semiring.plus_first +) +agg.sum_of_squares = Aggregator( + "sum_of_squares", initval=2, semiring=semiring.plus_pow, semiring2=semiring.plus_first +) +agg.sum_of_inverses = Aggregator( + "sum_of_inverses", + initval=-1.0, + semiring=semiring.plus_pow, + semiring2=semiring.plus_first, +) +agg.exists = Aggregator( + "exists", semiring=semiring.any_pair, semiring2=semiring.any_pair, any_dtype=INT64 +) + +# Semiring and finalize +agg.hypot = Aggregator( + "hypot", + initval=2, + semiring=semiring.plus_pow, + semiring2=semiring.plus_first, + finalize=unary.sqrt, +) +agg.logaddexp = Aggregator( + "logaddexp", + initval=np.e, + semiring=semiring.plus_pow, + switch=True, + semiring2=semiring.plus_first, + finalize=unary.log, +) +agg.logaddexp2 = Aggregator( + "logaddexp2", + initval=2, + semiring=semiring.plus_pow, + switch=True, + semiring2=semiring.plus_first, + finalize=unary.log2, +) +# Alternatives +# logaddexp = Aggregator('logaddexp', monoid=semiring.numpy.logaddexp) +# logaddexp2 = Aggregator('logaddexp2', monoid=semiring.numpy.logaddexp2) +# hypot as monoid doesn't work if single negative element! +# hypot = Aggregator('hypot', monoid=semiring.numpy.hypot) + +agg.L0norm = agg.count_nonzero +agg.L1norm = Aggregator("L1norm", semiring="plus_absfirst", semiring2=semiring.plus_first) +agg.L2norm = agg.hypot +agg.Linfnorm = Aggregator("Linfnorm", semiring="max_absfirst", semiring2=semiring.max_first) + + +# Composite +def _mean_finalize(c, x, opts): + return binary.truediv(x & c) + + +def _ptp_finalize(max, min, opts): + return binary.minus(max & min) + + +def _varp_finalize(c, x, x2, opts): + # / n - ( / n)**2 + left = binary.truediv(x2 & c).new(**opts) + right = binary.truediv(x & c).new(**opts) + right(**opts) << binary.pow(right, 2) + return binary.minus(left & right) + + +def _vars_finalize(c, x, x2, opts): + # / (n-1) - **2 / (n * (n-1)) + x(**opts) << binary.pow(x, 2) + right = binary.truediv(x & c).new(**opts) + c(**opts) << binary.minus(c, 1) + right(**opts) << binary.truediv(right & c) + left = binary.truediv(x2 & c).new(**opts) + return binary.minus(left & right) + + +def _stdp_finalize(c, x, x2, opts): + val = _varp_finalize(c, x, x2, opts).new(**opts) + return unary.sqrt(val) + + +def _stds_finalize(c, x, x2, opts): + val = _vars_finalize(c, x, x2, opts).new(**opts) + return unary.sqrt(val) + + +def _geometric_mean_finalize(c, x, opts): + right = unary.minv["FP64"](c).new(**opts) + return binary.pow(x & right) + + +def _harmonic_mean_finalize(c, x, opts): + return binary.truediv(c & x) + + +def _root_mean_square_finalize(c, x2, opts): + val = binary.truediv(x2 & c).new(**opts) + return unary.sqrt(val) + + +agg.mean = Aggregator( + "mean", + composite=[agg.count, agg.sum], + finalize=_mean_finalize, + types=[binary.truediv], +) +agg.peak_to_peak = Aggregator( + "peak_to_peak", + composite=[agg.max, agg.min], + finalize=_ptp_finalize, + types=[monoid.min], +) +agg.varp = Aggregator( + "varp", + composite=[agg.count, agg.sum, agg.sum_of_squares], + finalize=_varp_finalize, + types=[binary.truediv], +) +agg.vars = Aggregator( + "vars", + composite=[agg.count, agg.sum, agg.sum_of_squares], + finalize=_vars_finalize, + types=[binary.truediv], +) +agg.stdp = Aggregator( + "stdp", + composite=[agg.count, agg.sum, agg.sum_of_squares], + finalize=_stdp_finalize, + types=[binary.truediv, unary.sqrt], +) +agg.stds = Aggregator( + "stds", + composite=[agg.count, agg.sum, agg.sum_of_squares], + finalize=_stds_finalize, + types=[binary.truediv, unary.sqrt], +) +agg.geometric_mean = Aggregator( + "geometric_mean", + composite=[agg.count, agg.prod], + finalize=_geometric_mean_finalize, + types=[binary.truediv], +) +agg.harmonic_mean = Aggregator( + "harmonic_mean", + composite=[agg.count, agg.sum_of_inverses], + finalize=_harmonic_mean_finalize, + types=[agg.sum_of_inverses, binary.truediv], +) +agg.root_mean_square = Aggregator( + "root_mean_square", + composite=[agg.count, agg.sum_of_squares], + finalize=_root_mean_square_finalize, + types=[binary.truediv, unary.sqrt], +) + + +# Special recipes +def _argminmaxij( + agg, + updater, + expr, + opts, + *, + in_composite, + monoid, + col_semiring, + row_semiring, +): + if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": + A = expr.args[0] + if expr.method_name == "reduce_rowwise": + step1 = A.reduce_rowwise(monoid).new(**opts) + + D = step1.diag() + + masked = semiring.any_eq(D @ A).new(**opts) + masked(mask=masked.V, replace=True, **opts) << masked # Could use select + init = expr._new_vector(bool, size=A._ncols) + init(**opts)[...] = False # O(1) dense vector in SuiteSparse 5 + updater << row_semiring(masked @ init) + if in_composite: + return updater.parent + else: + step1 = A.reduce_columnwise(monoid).new(**opts) + + D = step1.diag() + + masked = semiring.any_eq(A @ D).new(**opts) + masked(mask=masked.V, replace=True, **opts) << masked # Could use select + init = expr._new_vector(bool, size=A._nrows) + init(**opts)[...] = False # O(1) dense vector in SuiteSparse 5 + updater << col_semiring(init @ masked) + if in_composite: + return updater.parent + elif expr.cfunc_name.startswith("GrB_Vector_reduce"): + v = expr.args[0] + step1 = v.reduce(monoid, allow_empty=False).new(**opts) + masked = binary.eq(v, step1).new(**opts) + masked(mask=masked.V, replace=True, **opts) << masked # Could use select + init = expr._new_matrix(bool, nrows=v._size, ncols=1) + init(**opts)[...] = False # O(1) dense column vector in SuiteSparse 5 + step2 = col_semiring(masked @ init).new(**opts) + if in_composite: + return step2 + updater << step2[0] + else: + raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}") + + +def _argminmax(agg, updater, expr, opts, *, in_composite, monoid): + if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": + if expr.method_name == "reduce_rowwise": + return _argminmaxij( + agg, + updater, + expr, + opts, + in_composite=in_composite, + monoid=monoid, + row_semiring=semiring._deprecated["min_firstj"], + col_semiring=semiring._deprecated["min_secondj"], + ) + return _argminmaxij( + agg, + updater, + expr, + opts, + in_composite=in_composite, + monoid=monoid, + row_semiring=semiring._deprecated["min_firsti"], + col_semiring=semiring._deprecated["min_secondi"], + ) + if expr.cfunc_name.startswith("GrB_Vector_reduce"): + return _argminmaxij( + agg, + updater, + expr, + opts, + in_composite=in_composite, + monoid=monoid, + row_semiring=semiring._deprecated["min_firsti"], + col_semiring=semiring._deprecated["min_secondi"], + ) + if expr.cfunc_name.startswith("GrB_Matrix_reduce"): + raise ValueError(f"Aggregator {agg.name} may not be used with Matrix.reduce_scalar.") + raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}") + + +# These "do the right thing", but don't work with `reduce_scalar` +_argmin = Aggregator( + "argmin", + custom=partial(_argminmax, monoid=monoid.min), + types=[semiring._deprecated["min_firsti"]], +) +_argmax = Aggregator( + "argmax", + custom=partial(_argminmax, monoid=monoid.max), + types=[semiring._deprecated["min_firsti"]], +) + + +def _first_last(agg, updater, expr, opts, *, in_composite, semiring_): + if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": + A = expr.args[0] + if expr.method_name == "reduce_columnwise": + A = A.T + init = expr._new_vector(bool, size=A._ncols) + init(**opts)[...] = False # O(1) dense vector in SuiteSparse 5 + step1 = semiring_(A @ init).new(**opts) + Is, Js = step1.to_coo() + + Matrix_ = type(expr._new_matrix(bool)) + P = Matrix_.from_coo(Js, Is, 1, nrows=A._ncols, ncols=A._nrows) + mask = step1.diag() + result = semiring.any_first(A @ P).new(mask=mask.S, **opts).diag(**opts) + + updater << result + if in_composite: + return updater.parent + elif expr.cfunc_name.startswith("GrB_Vector_reduce"): + v = expr.args[0] + init = expr._new_matrix(bool, nrows=v._size, ncols=1) + init(**opts)[...] = False # O(1) dense matrix in SuiteSparse 5 + step1 = semiring_(v @ init).new(**opts) + index = step1[0].new().value + # `==` instead of `is` automatically triggers index.compute() in dask-graphblas: + if index == None: # noqa: E711 + index = 0 + if in_composite: + return v[[index]].new(**opts) + updater << v[index] + else: # GrB_Matrix_reduce + A = expr.args[0] + init1 = expr._new_matrix(bool, nrows=A._ncols, ncols=1) + init1(**opts)[...] = False # O(1) dense matrix in SuiteSparse 5 + step1 = semiring_(A @ init1).new(**opts) + init2 = expr._new_vector(bool, size=A._nrows) + init2(**opts)[...] = False # O(1) dense vector in SuiteSparse 5 + step2 = semiring_(step1.T @ init2).new(**opts) + i = step2[0].new().value + # `==` instead of `is` automatically triggers i.compute() in dask-graphblas: + if i == None: # noqa: E711 + i = j = 0 + else: + j = step1[i, 0].new().value + if in_composite: + return A[i, [j]].new(**opts) + updater << A[i, j] + + +_first = Aggregator( + "first", + custom=partial(_first_last, semiring_=semiring._deprecated["min_secondi"]), + types=[binary.first], + any_dtype=True, +) +_last = Aggregator( + "last", + custom=partial(_first_last, semiring_=semiring._deprecated["max_secondi"]), + types=[binary.second], + any_dtype=True, +) + + +def _first_last_index(agg, updater, expr, opts, *, in_composite, semiring): + if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": + A = expr.args[0] + if expr.method_name == "reduce_columnwise": + A = A.T + init = expr._new_vector(bool, size=A._ncols) + init(**opts)[...] = False # O(1) dense vector in SuiteSparse 5 + expr = semiring(A @ init) + updater << expr + if in_composite: + return updater.parent + elif expr.cfunc_name.startswith("GrB_Vector_reduce"): + v = expr.args[0] + init = expr._new_matrix(bool, nrows=v._size, ncols=1) + init(**opts)[...] = False # O(1) dense matrix in SuiteSparse 5 + step1 = semiring(v @ init).new(**opts) + if in_composite: + return step1 + updater << step1[0] + elif expr.cfunc_name.startswith("GrB_Matrix_reduce"): + raise ValueError(f"Aggregator {agg.name} may not be used with Matrix.reduce_scalar.") + else: + raise NotImplementedError(f"{agg.name} with {expr.cfunc_name}") + + +_first_index = Aggregator( + "first_index", + custom=partial(_first_last_index, semiring=semiring._deprecated["min_secondi"]), + types=[semiring._deprecated["min_secondi"]], + any_dtype=INT64, +) +_last_index = Aggregator( + "last_index", + custom=partial(_first_last_index, semiring=semiring._deprecated["max_secondi"]), + types=[semiring._deprecated["min_secondi"]], + any_dtype=INT64, +) +agg._deprecated = { + "argmin": _argmin, + "argmax": _argmax, + "first": _first, + "last": _last, + "first_index": _first_index, + "last_index": _last_index, +} +if backend == "suitesparse": + agg.ss.argmin = _argmin + agg.ss.argmax = _argmax + agg.ss.first = _first + agg.ss.last = _last + agg.ss.first_index = _first_index + agg.ss.last_index = _last_index + +agg.Aggregator = Aggregator +agg.TypedAggregator = TypedAggregator + +from .utils import get_typed_op # noqa: E402 isort:skip diff --git a/graphblas/core/operator/base.py b/graphblas/core/operator/base.py new file mode 100644 index 000000000..ef92b41a4 --- /dev/null +++ b/graphblas/core/operator/base.py @@ -0,0 +1,532 @@ +from functools import lru_cache, reduce +from operator import getitem, mul +from types import BuiltinFunctionType, ModuleType + +import numba +import numpy as np + +from ... import _STANDARD_OPERATOR_NAMES, backend, op +from ...dtypes import BOOL, INT8, UINT64, _supports_complex, lookup_dtype +from .. import lib +from ..expr import InfixExprBase +from ..utils import output_type + +UNKNOWN_OPCLASS = "UnknownOpClass" + +# These now live as e.g. `gb.unary.ss.positioni` +# Deprecations such as `gb.unary.positioni` will be removed in 2023.9.0 or later. +_SS_OPERATORS = { + # unary + "erf", # scipy.special.erf + "erfc", # scipy.special.erfc + "frexpe", # np.frexp[1] + "frexpx", # np.frexp[0] + "lgamma", # scipy.special.loggamma + "tgamma", # scipy.special.gamma + # Positional + # unary + "positioni", + "positioni1", + "positionj", + "positionj1", + # binary + "firsti", + "firsti1", + "firstj", + "firstj1", + "secondi", + "secondi1", + "secondj", + "secondj1", + # semiring + "any_firsti", + "any_firsti1", + "any_firstj", + "any_firstj1", + "any_secondi", + "any_secondi1", + "any_secondj", + "any_secondj1", + "max_firsti", + "max_firsti1", + "max_firstj", + "max_firstj1", + "max_secondi", + "max_secondi1", + "max_secondj", + "max_secondj1", + "min_firsti", + "min_firsti1", + "min_firstj", + "min_firstj1", + "min_secondi", + "min_secondi1", + "min_secondj", + "min_secondj1", + "plus_firsti", + "plus_firsti1", + "plus_firstj", + "plus_firstj1", + "plus_secondi", + "plus_secondi1", + "plus_secondj", + "plus_secondj1", + "times_firsti", + "times_firsti1", + "times_firstj", + "times_firstj1", + "times_secondi", + "times_secondi1", + "times_secondj", + "times_secondj1", +} + + +def _hasop(module, name): + return ( + name in module.__dict__ + or name in module._delayed + or name in getattr(module, "_deprecated", ()) + ) + + +class OpPath: + def __init__(self, parent, name): + self._parent = parent + self._name = name + self._delayed = {} + self._delayed_commutes_to = {} + + def __getattr__(self, key): + if key in self._delayed: + func, kwargs = self._delayed.pop(key) + return func(**kwargs) + self.__getattribute__(key) # raises + + +def _call_op(op, left, right=None, thunk=None, **kwargs): + if right is None and thunk is None: + if isinstance(left, InfixExprBase): + # op(A & B), op(A | B), op(A @ B) + return getattr(left.left, left.method_name)(left.right, op, **kwargs) + if find_opclass(op)[1] == "Semiring": + raise TypeError( + f"Bad type when calling {op!r}. Got type: {type(left)}.\n" + f"Expected an infix expression, such as: {op!r}(A @ B)" + ) + raise TypeError( + f"Bad type when calling {op!r}. Got type: {type(left)}.\n" + "Expected an infix expression or an apply with a Vector or Matrix and a scalar:\n" + f" - {op!r}(A & B)\n" + f" - {op!r}(A, 1)\n" + f" - {op!r}(1, A)" + ) + + # op(A, 1) -> apply (or select if thunk provided) + from ..matrix import Matrix, TransposedMatrix + from ..vector import Vector + + 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 (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" + f" - {op!r}(A & B)\n" + f" - {op!r}(A, 1)\n" + f" - {op!r}(1, A)" + ) + + +_udt_mask_cache = {} + + +def _udt_mask(dtype): + """Create mask to determine which bytes of UDTs to use for equality check.""" + if dtype in _udt_mask_cache: + return _udt_mask_cache[dtype] + if dtype.subdtype is not None: + mask = _udt_mask(dtype.subdtype[0]) + N = reduce(mul, dtype.subdtype[1]) + rv = np.concatenate([mask] * N) + elif dtype.names is not None: + prev_offset = mask = None + masks = [] + for name in dtype.names: + dtype2, offset = dtype.fields[name] + if mask is not None: + masks.append(np.pad(mask, (0, offset - prev_offset - mask.size))) + mask = _udt_mask(dtype2) + prev_offset = offset + masks.append(np.pad(mask, (0, dtype.itemsize - prev_offset - mask.size))) + rv = np.concatenate(masks) + else: + rv = np.ones(dtype.itemsize, dtype=bool) + # assert rv.size == dtype.itemsize + _udt_mask_cache[dtype] = rv + return rv + + +def _get_udt_wrapper(numba_func, return_type, dtype, dtype2=None, *, include_indexes=False): + ztype = INT8 if return_type == BOOL else return_type + xtype = INT8 if dtype == BOOL else dtype + nt = numba.types + wrapper_args = [nt.CPointer(ztype.numba_type), nt.CPointer(xtype.numba_type)] + if include_indexes: + wrapper_args.extend([UINT64.numba_type, UINT64.numba_type]) + if dtype2 is not None: + ytype = INT8 if dtype2 == BOOL else dtype2 + wrapper_args.append(nt.CPointer(ytype.numba_type)) + wrapper_sig = nt.void(*wrapper_args) + + zarray = xarray = yarray = BL = BR = yarg = yname = rcidx = "" + if return_type._is_udt: + if return_type.np_type.subdtype is None: + zarray = " z = numba.carray(z_ptr, 1)\n" + zname = "z[0]" + else: + zname = "z_ptr[0]" + BR = "[0]" + else: + zname = "z_ptr[0]" + if return_type == BOOL: + BL = "bool(" + BR = ")" + + if dtype._is_udt: + if dtype.np_type.subdtype is None: + xarray = " x = numba.carray(x_ptr, 1)\n" + xname = "x[0]" + else: + xname = "x_ptr" + elif dtype == BOOL: + xname = "bool(x_ptr[0])" + else: + xname = "x_ptr[0]" + + if dtype2 is not None: + yarg = ", y_ptr" + if dtype2._is_udt: + if dtype2.np_type.subdtype is None: + yarray = " y = numba.carray(y_ptr, 1)\n" + yname = ", y[0]" + else: + yname = ", y_ptr" + elif dtype2 == BOOL: + yname = ", bool(y_ptr[0])" + else: + yname = ", y_ptr[0]" + + if include_indexes: + rcidx = ", row, col" + + d = {"numba": numba, "numba_func": numba_func} + text = ( + f"def wrapper(z_ptr, x_ptr{rcidx}{yarg}):\n" + f"{zarray}{xarray}{yarray}" + f" {zname} = {BL}numba_func({xname}{rcidx}{yname}){BR}\n" + ) + exec(text, d) # pylint: disable=exec-used + return d["wrapper"], wrapper_sig + + +class TypedOpBase: + __slots__ = ( + "parent", + "name", + "type", + "return_type", + "gb_obj", + "gb_name", + "_type2", + "__weakref__", + ) + + def __init__(self, parent, name, type_, return_type, gb_obj, gb_name, dtype2=None): + self.parent = parent + self.name = name + self.type = type_ + self.return_type = return_type + self.gb_obj = gb_obj + self.gb_name = gb_name + self._type2 = dtype2 + + def __repr__(self): + classname = self.opclass.lower() + if classname.endswith("op"): + classname = classname[:-2] + dtype2 = "" if self._type2 is None else f", {self._type2.name}" + return f"{classname}.{self.name}[{self.type.name}{dtype2}]" + + @property + def _carg(self): + return self.gb_obj + + @property + def is_positional(self): + return self.parent.is_positional + + def __reduce__(self): + if self._type2 is None or self.type == self._type2: + return (getitem, (self.parent, self.type)) + return (getitem, (self.parent, (self.type, self._type2))) + + +def _deserialize_parameterized(parameterized_op, args, kwargs): + return parameterized_op(*args, **kwargs) + + +class ParameterizedUdf: + __slots__ = "name", "__call__", "_anonymous", "__weakref__" + is_positional = False + _custom_dtype = None + + def __init__(self, name, anonymous): + self.name = name + self._anonymous = anonymous + # lru_cache per instance + method = self._call.__get__(self, type(self)) + self.__call__ = lru_cache(maxsize=1024)(method) + + def _call(self, *args, **kwargs): + raise NotImplementedError + + +_VARNAMES = tuple(x for x in dir(lib) if x[0] != "_") + + +class OpBase: + __slots__ = ( + "name", + "_typed_ops", + "types", + "coercions", + "_anonymous", + "_udt_types", + "_udt_ops", + "__weakref__", + ) + _parse_config = None + _initialized = False + _module = None + _positional = None + + def __init__(self, name, *, anonymous=False): + self.name = name + self._typed_ops = {} + self.types = {} + self.coercions = {} + self._anonymous = anonymous + self._udt_types = None + self._udt_ops = None + + def __repr__(self): + return f"{self._modname}.{self.name}" + + def __getitem__(self, type_): + if type(type_) is tuple: + from .utils import get_typed_op + + dtype1, dtype2 = type_ + dtype1 = lookup_dtype(dtype1) + dtype2 = lookup_dtype(dtype2) + return get_typed_op(self, dtype1, dtype2) + if not self._is_udt: + type_ = lookup_dtype(type_) + if type_ not in self._typed_ops: + if self._udt_types is None: + if self.is_positional: + return self._typed_ops[UINT64] + raise KeyError(f"{self.name} does not work with {type_}") + else: + return self._typed_ops[type_] + # This is a UDT or is able to operate on UDTs such as `first` any `any` + dtype = lookup_dtype(type_) + return self._compile_udt(dtype, dtype) + + def _add(self, op): + self._typed_ops[op.type] = op + self.types[op.type] = op.return_type + + def __delitem__(self, type_): + type_ = lookup_dtype(type_) + del self._typed_ops[type_] + del self.types[type_] + + def __contains__(self, type_): + try: + self[type_] + except (TypeError, KeyError, numba.NumbaError): + return False + return True + + @classmethod + def _remove_nesting(cls, funcname, *, module=None, modname=None, strict=True): + if module is None: + module = cls._module + if modname is None: + modname = cls._modname + if "." not in funcname: + if strict and _hasop(module, funcname): + raise AttributeError(f"{modname}.{funcname} is already defined") + else: + path, funcname = funcname.rsplit(".", 1) + for folder in path.split("."): + if not _hasop(module, folder): + setattr(module, folder, OpPath(module, folder)) + module = getattr(module, folder) + modname = f"{modname}.{folder}" + if not isinstance(module, (OpPath, ModuleType)): + raise AttributeError( + f"{modname} is already defined. Cannot use as a nested path." + ) + if strict and _hasop(module, funcname): + raise AttributeError(f"{path}.{funcname} is already defined") + return module, funcname + + @classmethod + def _find(cls, funcname): + rv = cls._module + for attr in funcname.split("."): + if attr in getattr(rv, "_deprecated", ()): + rv = rv._deprecated[attr] + else: + rv = getattr(rv, attr, None) + if rv is None: + break + return rv + + @classmethod + def _initialize(cls, include_in_ops=True): + """ + include_in_ops determines whether the operators are included in the + `gb.ops` namespace in addition to the defined module. + """ + if cls._initialized: # pragma: no cover (safety) + return + # Read in the parse configs + trim_from_front = cls._parse_config.get("trim_from_front", 0) + delete_exact = cls._parse_config.get("delete_exact", None) + num_underscores = cls._parse_config["num_underscores"] + + for re_str, return_prefix in [ + ("re_exprs", None), + ("re_exprs_return_bool", "BOOL"), + ("re_exprs_return_float", "FP"), + ("re_exprs_return_complex", "FC"), + ]: + if re_str not in cls._parse_config: + continue + if "complex" in re_str and not _supports_complex: + continue + for r in reversed(cls._parse_config[re_str]): + for varname in _VARNAMES: + m = r.match(varname) + if m: + # Parse function into name and datatype + gb_name = m.string + splitname = gb_name[trim_from_front:].split("_") + if delete_exact and delete_exact in splitname: + splitname.remove(delete_exact) + if len(splitname) == num_underscores + 1: + *splitname, type_ = splitname + else: + type_ = None + name = "_".join(splitname).lower() + # Create object for name unless it already exists + if not _hasop(cls._module, name): + if backend == "suitesparse" and name in _SS_OPERATORS: + fullname = f"ss.{name}" + else: + fullname = name + if cls._positional is None: + obj = cls(fullname) + else: + obj = cls(fullname, is_positional=name in cls._positional) + if name in _SS_OPERATORS: + if backend == "suitesparse": + setattr(cls._module.ss, name, obj) + cls._module._deprecated[name] = obj + if include_in_ops and not _hasop(op, name): # pragma: no branch + op._deprecated[name] = obj + if backend == "suitesparse": + setattr(op.ss, name, obj) + else: + setattr(cls._module, name, obj) + if include_in_ops and not _hasop(op, name): + setattr(op, name, obj) + _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{fullname}") + elif name in _SS_OPERATORS: + obj = cls._module._deprecated[name] + else: + obj = getattr(cls._module, name) + gb_obj = getattr(lib, varname) + # Determine return type + if return_prefix == "BOOL": + return_type = BOOL + if type_ is None: + type_ = BOOL + else: + if type_ is None: # pragma: no cover + raise TypeError(f"Unable to determine return type for {varname}") + if return_prefix is None: + return_type = type_ + else: + # Grab the number of bits from type_ + num_bits = type_[-2:] + if num_bits not in {"32", "64"}: # pragma: no cover (safety) + raise TypeError(f"Unexpected number of bits: {num_bits}") + return_type = f"{return_prefix}{num_bits}" + builtin_op = cls._typed_class( + obj, + name, + lookup_dtype(type_), + lookup_dtype(return_type), + gb_obj, + gb_name, + ) + obj._add(builtin_op) + + @classmethod + def _deserialize(cls, name, *args): + if (rv := cls._find(name)) is not None: + return rv # Should we verify this is what the user expects? + return cls.register_new(name, *args) + + +_builtin_to_op = {} # Populated in .utils + + +def find_opclass(gb_op): + if isinstance(gb_op, OpBase): + opclass = type(gb_op).__name__ + elif isinstance(gb_op, TypedOpBase): + opclass = gb_op.opclass + elif isinstance(gb_op, ParameterizedUdf): + gb_op = gb_op() # Use default parameters of parameterized UDFs + gb_op, opclass = find_opclass(gb_op) + elif isinstance(gb_op, BuiltinFunctionType) and gb_op in _builtin_to_op: + gb_op, opclass = find_opclass(_builtin_to_op[gb_op]) + else: + opclass = UNKNOWN_OPCLASS + return gb_op, opclass diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py new file mode 100644 index 000000000..eeb72ea3b --- /dev/null +++ b/graphblas/core/operator/binary.py @@ -0,0 +1,864 @@ +import inspect +import re +from functools import lru_cache +from types import FunctionType + +import numba +import numpy as np + +from ... import _STANDARD_OPERATOR_NAMES, backend, binary, monoid, op +from ...dtypes import ( + BOOL, + FP32, + FP64, + INT8, + INT16, + INT32, + INT64, + UINT8, + UINT16, + UINT32, + UINT64, + _sample_values, + _supports_complex, + lookup_dtype, +) +from ...exceptions import UdfParseError, check_status_carg +from .. import ffi, lib +from ..expr import InfixExprBase +from .base import ( + _SS_OPERATORS, + OpBase, + ParameterizedUdf, + TypedOpBase, + _call_op, + _deserialize_parameterized, + _get_udt_wrapper, + _hasop, + _udt_mask, +) + +if _supports_complex: + from ...dtypes import FC32, FC64 + +ffi_new = ffi.new + + +class TypedBuiltinBinaryOp(TypedOpBase): + __slots__ = () + opclass = "BinaryOp" + + def __call__(self, left, right=None, *, left_default=None, right_default=None): + if left_default is not None or right_default is not None: + if ( + left_default is None + or right_default is None + or right is not None + or not isinstance(left, InfixExprBase) + or left.method_name != "ewise_add" + ): + raise TypeError( + "Specifying `left_default` or `right_default` keyword arguments implies " + "performing `ewise_union` operation with infix notation.\n" + "There is only one valid way to do this:\n\n" + f">>> {self}(x | y, left_default=0, right_default=0)\n\nwhere x and y " + "are Vectors or Matrices, and left_default and right_default are scalars." + ) + return left.left.ewise_union(left.right, self, left_default, right_default) + return _call_op(self, left, right) + + @property + def monoid(self): + rv = getattr(monoid, self.name, None) + if rv is not None and self.type in rv._typed_ops: + return rv[self.type] + + @property + def commutes_to(self): + commutes_to = self.parent.commutes_to + if commutes_to is not None and (self.type in commutes_to._typed_ops or self.type._is_udt): + return commutes_to[self.type] + + @property + def _semiring_commutes_to(self): + commutes_to = self.parent._semiring_commutes_to + if commutes_to is not None and (self.type in commutes_to._typed_ops or self.type._is_udt): + return commutes_to[self.type] + + @property + def is_commutative(self): + return self.commutes_to is self + + @property + def type2(self): + return self.type if self._type2 is None else self._type2 + + +class TypedUserBinaryOp(TypedOpBase): + __slots__ = "_monoid" + opclass = "BinaryOp" + + def __init__(self, parent, name, type_, return_type, gb_obj, dtype2=None): + super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}", dtype2=dtype2) + self._monoid = None + + @property + def monoid(self): + if self._monoid is None: + monoid = self.parent.monoid + if monoid is not None and self.type in monoid: + self._monoid = monoid[self.type] + return self._monoid + + @property + def orig_func(self): + return self.parent.orig_func + + @property + def _numba_func(self): + return self.parent._numba_func + + commutes_to = TypedBuiltinBinaryOp.commutes_to + _semiring_commutes_to = TypedBuiltinBinaryOp._semiring_commutes_to + is_commutative = TypedBuiltinBinaryOp.is_commutative + type2 = TypedBuiltinBinaryOp.type2 + __call__ = TypedBuiltinBinaryOp.__call__ + + +class ParameterizedBinaryOp(ParameterizedUdf): + __slots__ = "func", "__signature__", "_monoid", "_cached_call", "_commutes_to", "_is_udt" + + def __init__(self, name, func, *, anonymous=False, is_udt=False): + self.func = func + self.__signature__ = inspect.signature(func) + self._monoid = None + self._is_udt = is_udt + if name is None: + name = getattr(func, "__name__", name) + super().__init__(name, anonymous) + method = self._call_to_cache.__get__(self, type(self)) + self._cached_call = lru_cache(maxsize=1024)(method) + self.__call__ = self._call + self._commutes_to = None + + def _call_to_cache(self, *args, **kwargs): + binary = self.func(*args, **kwargs) + binary._parameterized_info = (self, args, kwargs) + return BinaryOp.register_anonymous(binary, self.name, is_udt=self._is_udt) + + def _call(self, *args, **kwargs): + binop = self._cached_call(*args, **kwargs) + if self._monoid is not None and binop._monoid is None: + # This is all a bit funky. We try our best to associate a binaryop + # to a monoid. So, if we made a ParameterizedMonoid using this object, + # then try to create a monoid with the given arguments. + binop._monoid = binop # temporary! + try: + # If this call is successful, then it will set `binop._monoid` + self._monoid(*args, **kwargs) # pylint: disable=not-callable + except Exception: + binop._monoid = None + # assert binop._monoid is not binop + if self.is_commutative: + binop._commutes_to = binop + # Don't bother yet with creating `binop.commutes_to` (but we could!) + return binop + + @property + def monoid(self): + return self._monoid + + @property + def commutes_to(self): + if type(self._commutes_to) is str: + self._commutes_to = BinaryOp._find(self._commutes_to) + return self._commutes_to + + is_commutative = TypedBuiltinBinaryOp.is_commutative + + def __reduce__(self): + name = f"binary.{self.name}" + if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: + return name + return (self._deserialize, (self.name, self.func, self._anonymous)) + + @staticmethod + def _deserialize(name, func, anonymous): + if anonymous: + return BinaryOp.register_anonymous(func, name, parameterized=True) + if (rv := BinaryOp._find(name)) is not None: + return rv + return BinaryOp.register_new(name, func, parameterized=True) + + +def _floordiv(x, y): + return x // y # pragma: no cover (numba) + + +def _rfloordiv(x, y): + return y // x # pragma: no cover (numba) + + +def _absfirst(x, y): + return np.abs(x) # pragma: no cover (numba) + + +def _abssecond(x, y): + return np.abs(y) # pragma: no cover (numba) + + +def _rpow(x, y): + return y**x # pragma: no cover (numba) + + +def _isclose(rel_tol=1e-7, abs_tol=0.0): + def inner(x, y): # pragma: no cover (numba) + return x == y or abs(x - y) <= max(rel_tol * max(abs(x), abs(y)), abs_tol) + + return inner + + +_MAX_INT64 = np.iinfo(np.int64).max + + +def _binom(N, k): # pragma: no cover (numba) + # Returns 0 if overflow or out-of-bounds + if k > N or k < 0: + return 0 + val = np.int64(1) + for i in range(min(k, N - k)): + if val > _MAX_INT64 // (N - i): # Overflow + return 0 + val *= N - i + val //= i + 1 + return val + + +# Kinda complicated, but works for now +def _register_binom(): + # "Fake" UDT so we only compile once for INT64 + op = BinaryOp.register_new("binom", _binom, is_udt=True) + typed_op = op[INT64, INT64] + # Make this look like a normal operator + for dtype in [UINT8, UINT16, UINT32, UINT64, INT8, INT16, INT32, INT64]: + op.types[dtype] = INT64 + op._typed_ops[dtype] = typed_op + if dtype != INT64: + op.coercions[dtype] = typed_op + # And make it not look like it operates on UDTs + typed_op._type2 = None + op._is_udt = False + op._udt_types = None + op._udt_ops = None + return op + + +def _first(x, y): + return x # pragma: no cover (numba) + + +def _second(x, y): + return y # pragma: no cover (numba) + + +def _pair(x, y): + return 1 # pragma: no cover (numba) + + +def _first_dtype(op, dtype, dtype2): + if dtype._is_udt or dtype2._is_udt: + return op._compile_udt(dtype, dtype2) + + +def _second_dtype(op, dtype, dtype2): + if dtype._is_udt or dtype2._is_udt: + return op._compile_udt(dtype, dtype2) + + +def _pair_dtype(op, dtype, dtype2): + return op[INT64] + + +class BinaryOp(OpBase): + """Takes two inputs and returns one output, possibly of a different data type. + + Built-in and registered BinaryOps are located in the ``graphblas.binary`` namespace + as well as in the ``graphblas.ops`` combined namespace. + """ + + __slots__ = ( + "_monoid", + "_commutes_to", + "_semiring_commutes_to", + "orig_func", + "is_positional", + "_is_udt", + "_numba_func", + "_custom_dtype", + ) + _module = binary + _modname = "binary" + _typed_class = TypedBuiltinBinaryOp + _parse_config = { + "trim_from_front": 4, + "num_underscores": 1, + "re_exprs": [ + re.compile( + "^GrB_(FIRST|SECOND|PLUS|MINUS|TIMES|DIV|MIN|MAX)" + "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64|FC32|FC64)$" + ), + re.compile( + "GrB_(BOR|BAND|BXOR|BXNOR)_(INT8|INT16|INT32|INT64|UINT8|UINT16|UINT32|UINT64)$" + ), + re.compile( + "^GxB_(POW|RMINUS|RDIV|PAIR|ANY|ISEQ|ISNE|ISGT|ISLT|ISGE|ISLE|LOR|LAND|LXOR)" + "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64|FC32|FC64)$" + ), + re.compile("^GxB_(FIRST|SECOND|PLUS|MINUS|TIMES|DIV)_(FC32|FC64)$"), + re.compile("^GxB_(ATAN2|HYPOT|FMOD|REMAINDER|LDEXP|COPYSIGN)_(FP32|FP64)$"), + re.compile( + "GxB_(BGET|BSET|BCLR|BSHIFT|FIRSTI1|FIRSTI|FIRSTJ1|FIRSTJ" + "|SECONDI1|SECONDI|SECONDJ1|SECONDJ)" + "_(INT8|INT16|INT32|INT64|UINT8|UINT16|UINT32|UINT64)$" + ), + # These are coerced to 0 or 1, but don't return BOOL + re.compile( + "^GxB_(LOR|LAND|LXOR|LXNOR)_" + "(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" + ), + ], + "re_exprs_return_bool": [ + re.compile("^GrB_(LOR|LAND|LXOR|LXNOR)$"), + re.compile( + "^GrB_(EQ|NE|GT|LT|GE|LE)_" + "(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" + ), + re.compile("^GxB_(EQ|NE)_(FC32|FC64)$"), + ], + "re_exprs_return_complex": [re.compile("^GxB_(CMPLX)_(FP32|FP64)$")], + } + _commutes = { + # builtins + "cdiv": "rdiv", + "first": "second", + "ge": "le", + "gt": "lt", + "isge": "isle", + "isgt": "islt", + "minus": "rminus", + "pow": "rpow", + # special + "firsti": "secondi", + "firsti1": "secondi1", + "firstj": "secondj", + "firstj1": "secondj1", + # custom + # "absfirst": "abssecond", # handled in graphblas.binary + # "floordiv": "rfloordiv", + "truediv": "rtruediv", + } + _commutes_to_in_semiring = { + "firsti": "secondj", + "firsti1": "secondj1", + "firstj": "secondi", + "firstj1": "secondi1", + } + _commutative = { + # monoids + "any", + "band", + "bor", + "bxnor", + "bxor", + "eq", + "land", + "lor", + "lxnor", + "lxor", + "max", + "min", + "plus", + "times", + # other + "hypot", + "isclose", + "iseq", + "isne", + "ne", + "pair", + } + # Don't commute: atan2, bclr, bget, bset, bshift, cmplx, copysign, fmod, ldexp, remainder + _positional = { + "firsti", + "firsti1", + "firstj", + "firstj1", + "secondi", + "secondi1", + "secondj", + "secondj1", + } + + @classmethod + def _build(cls, name, func, *, is_udt=False, anonymous=False): + if not isinstance(func, FunctionType): + raise TypeError(f"UDF argument must be a function, not {type(func)}") + if name is None: + name = getattr(func, "__name__", "") + success = False + binary_udf = numba.njit(func) + new_type_obj = cls(name, func, anonymous=anonymous, is_udt=is_udt, numba_func=binary_udf) + return_types = {} + nt = numba.types + if not is_udt: + for type_ in _sample_values: + sig = (type_.numba_type, type_.numba_type) + try: + binary_udf.compile(sig) + except numba.TypingError: + continue + ret_type = lookup_dtype(binary_udf.overloads[sig].signature.return_type) + if ret_type != type_ and ( + ("INT" in ret_type.name and "INT" in type_.name) + or ("FP" in ret_type.name and "FP" in type_.name) + or ("FC" in ret_type.name and "FC" in type_.name) + or (type_ == UINT64 and ret_type == FP64 and return_types.get(INT64) == INT64) + ): + # Downcast `ret_type` to `type_`. + # This is what users want most of the time, but we can't make a perfect rule. + # There should be a way for users to be explicit. + ret_type = type_ + elif type_ == BOOL and ret_type == INT64 and return_types.get(INT8) == INT8: + ret_type = INT8 + + # Numba is unable to handle BOOL correctly right now, but we have a workaround + # See: https://github.com/numba/numba/issues/5395 + # We're relying on coercion behaving correctly here + input_type = INT8 if type_ == BOOL else type_ + return_type = INT8 if ret_type == BOOL else ret_type + + # Build wrapper because GraphBLAS wants pointers and void return + wrapper_sig = nt.void( + nt.CPointer(return_type.numba_type), + nt.CPointer(input_type.numba_type), + nt.CPointer(input_type.numba_type), + ) + + if type_ == BOOL: + if ret_type == BOOL: + + def binary_wrapper(z, x, y): # pragma: no cover (numba) + z[0] = bool(binary_udf(bool(x[0]), bool(y[0]))) + + else: + + def binary_wrapper(z, x, y): # pragma: no cover (numba) + z[0] = binary_udf(bool(x[0]), bool(y[0])) + + elif ret_type == BOOL: + + def binary_wrapper(z, x, y): # pragma: no cover (numba) + z[0] = bool(binary_udf(x[0], y[0])) + + else: + + def binary_wrapper(z, x, y): # pragma: no cover (numba) + z[0] = binary_udf(x[0], y[0]) + + binary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(binary_wrapper) + new_binary = ffi_new("GrB_BinaryOp*") + check_status_carg( + lib.GrB_BinaryOp_new( + new_binary, + binary_wrapper.cffi, + ret_type.gb_obj, + type_.gb_obj, + type_.gb_obj, + ), + "BinaryOp", + new_binary, + ) + op = TypedUserBinaryOp(new_type_obj, name, type_, ret_type, new_binary[0]) + new_type_obj._add(op) + success = True + return_types[type_] = ret_type + if success or is_udt: + return new_type_obj + raise UdfParseError("Unable to parse function using Numba") + + def _compile_udt(self, dtype, dtype2): + if dtype2 is None: + dtype2 = dtype + dtypes = (dtype, dtype2) + if dtypes in self._udt_types: + return self._udt_ops[dtypes] + + nt = numba.types + if self.name == "eq" and not self._anonymous: + # assert dtype.np_type == dtype2.np_type + itemsize = dtype.np_type.itemsize + mask = _udt_mask(dtype.np_type) + ret_type = BOOL + wrapper_sig = nt.void( + nt.CPointer(INT8.numba_type), + nt.CPointer(UINT8.numba_type), + nt.CPointer(UINT8.numba_type), + ) + # PERF: we can probably make this faster + if mask.all(): + + def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) + x = numba.carray(x_ptr, itemsize) + y = numba.carray(y_ptr, itemsize) + # for i in range(itemsize): + # if x[i] != y[i]: + # z_ptr[0] = False + # break + # else: + # z_ptr[0] = True + z_ptr[0] = (x == y).all() + + else: + + def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) + x = numba.carray(x_ptr, itemsize) + y = numba.carray(y_ptr, itemsize) + # for i in range(itemsize): + # if mask[i] and x[i] != y[i]: + # z_ptr[0] = False + # break + # else: + # z_ptr[0] = True + z_ptr[0] = (x[mask] == y[mask]).all() + + elif self.name == "ne" and not self._anonymous: + # assert dtype.np_type == dtype2.np_type + itemsize = dtype.np_type.itemsize + mask = _udt_mask(dtype.np_type) + ret_type = BOOL + wrapper_sig = nt.void( + nt.CPointer(INT8.numba_type), + nt.CPointer(UINT8.numba_type), + nt.CPointer(UINT8.numba_type), + ) + if mask.all(): + + def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) + x = numba.carray(x_ptr, itemsize) + y = numba.carray(y_ptr, itemsize) + # for i in range(itemsize): + # if x[i] != y[i]: + # z_ptr[0] = True + # break + # else: + # z_ptr[0] = False + z_ptr[0] = (x != y).any() + + else: + + def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) + x = numba.carray(x_ptr, itemsize) + y = numba.carray(y_ptr, itemsize) + # for i in range(itemsize): + # if mask[i] and x[i] != y[i]: + # z_ptr[0] = True + # break + # else: + # z_ptr[0] = False + z_ptr[0] = (x[mask] != y[mask]).any() + + else: + numba_func = self._numba_func + sig = (dtype.numba_type, dtype2.numba_type) + numba_func.compile(sig) # Should we catch and give additional error message? + ret_type = lookup_dtype(numba_func.overloads[sig].signature.return_type) + binary_wrapper, wrapper_sig = _get_udt_wrapper(numba_func, ret_type, dtype, dtype2) + + binary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(binary_wrapper) + new_binary = ffi_new("GrB_BinaryOp*") + check_status_carg( + lib.GrB_BinaryOp_new( + new_binary, binary_wrapper.cffi, ret_type._carg, dtype._carg, dtype2._carg + ), + "BinaryOp", + new_binary, + ) + op = TypedUserBinaryOp( + self, + self.name, + dtype, + ret_type, + new_binary[0], + dtype2=dtype2, + ) + self._udt_types[dtypes] = ret_type + self._udt_ops[dtypes] = op + return op + + @classmethod + def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=False): + """Register a BinaryOp without registering it in the ``graphblas.binary`` namespace. + + Because it is not registered in the namespace, the name is optional. + """ + if parameterized: + return ParameterizedBinaryOp(name, func, anonymous=True, is_udt=is_udt) + return cls._build(name, func, anonymous=True, is_udt=is_udt) + + @classmethod + def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): + """Register a BinaryOp. The name will be used to identify the BinaryOp in the + ``graphblas.binary`` namespace. + + >>> def max_zero(x, y): + r = 0 + if x > r: + r = x + if y > r: + r = y + return r + >>> gb.core.operator.BinaryOp.register_new("max_zero", max_zero) + >>> dir(gb.binary) + [..., 'max_zero', ...] + """ + module, funcname = cls._remove_nesting(name) + if lazy: + module._delayed[funcname] = ( + cls.register_new, + {"name": name, "func": func, "parameterized": parameterized}, + ) + elif parameterized: + binary_op = ParameterizedBinaryOp(name, func, is_udt=is_udt) + setattr(module, funcname, binary_op) + else: + binary_op = cls._build(name, func, is_udt=is_udt) + setattr(module, funcname, binary_op) + # Also save it to `graphblas.op` if not yet defined + opmodule, funcname = cls._remove_nesting(name, module=op, modname="op", strict=False) + if not _hasop(opmodule, funcname): + if lazy: + opmodule._delayed[funcname] = module + else: + setattr(opmodule, funcname, binary_op) + if not cls._initialized: + _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") + if not lazy: + return binary_op + + @classmethod + def _initialize(cls): + if cls._initialized: # pragma: no cover (safety) + return + super()._initialize() + # Rename div to cdiv + cdiv = binary.cdiv = op.cdiv = BinaryOp("cdiv") + for dtype, ret_type in binary.div.types.items(): + orig_op = binary.div[dtype] + cur_op = TypedBuiltinBinaryOp( + cdiv, "cdiv", dtype, ret_type, orig_op.gb_obj, orig_op.gb_name + ) + cdiv._add(cur_op) + del binary.div + del op.div + # Add truediv which always points to floating point cdiv + # We are effectively hacking cdiv to always return floating point values + # If the inputs are FP32, we use DIV_FP32; use DIV_FP64 for all other input dtypes + truediv = binary.truediv = op.truediv = BinaryOp("truediv") + rtruediv = binary.rtruediv = op.rtruediv = BinaryOp("rtruediv") + for new_op, builtin_op in [(truediv, binary.cdiv), (rtruediv, binary.rdiv)]: + for dtype in builtin_op.types: + if dtype.name in {"FP32", "FC32", "FC64"}: + orig_dtype = dtype + else: + orig_dtype = FP64 + orig_op = builtin_op[orig_dtype] + cur_op = TypedBuiltinBinaryOp( + new_op, + new_op.name, + dtype, + builtin_op.types[orig_dtype], + orig_op.gb_obj, + orig_op.gb_name, + ) + new_op._add(cur_op) + # Add floordiv + # cdiv truncates towards 0, while floordiv truncates towards -inf + BinaryOp.register_new("floordiv", _floordiv, lazy=True) # cast to integer + BinaryOp.register_new("rfloordiv", _rfloordiv, lazy=True) # cast to integer + + # For aggregators + BinaryOp.register_new("absfirst", _absfirst, lazy=True) + BinaryOp.register_new("abssecond", _abssecond, lazy=True) + BinaryOp.register_new("rpow", _rpow, lazy=True) + + # For algorithms + binary._delayed["binom"] = (_register_binom, {}) # Lazy with custom creation + op._delayed["binom"] = binary + + BinaryOp.register_new("isclose", _isclose, parameterized=True) + + # Update type information with sane coercion + position_dtypes = [ + BOOL, + FP32, + FP64, + INT8, + INT16, + UINT8, + UINT16, + UINT32, + UINT64, + ] + if _supports_complex: + position_dtypes.extend([FC32, FC64]) + name_types = [ + # fmt: off + ( + ("atan2", "copysign", "fmod", "hypot", "ldexp", "remainder"), + ((BOOL, INT8, INT16, UINT8, UINT16), FP32), + ((INT32, INT64, UINT32, UINT64), FP64), + ), + ( + ( + "firsti", "firsti1", "firstj", "firstj1", "secondi", "secondi1", + "secondj", "secondj1"), + ( + position_dtypes, + INT64, + ), + ), + ( + ["lxnor"], + ( + ( + FP32, FP64, INT8, INT16, INT32, INT64, + UINT8, UINT16, UINT32, UINT64, + ), + BOOL, + ), + ), + # fmt: on + ] + if _supports_complex: + name_types.append( + ( + ["cmplx"], + ((BOOL, INT8, INT16, UINT8, UINT16), FP32), + ((INT32, INT64, UINT32, UINT64), FP64), + ) + ) + for names, *types in name_types: + for name in names: + if name in _SS_OPERATORS: + cur_op = binary._deprecated[name] + else: + cur_op = getattr(binary, name) + for input_types, target_type in types: + typed_op = cur_op._typed_ops[target_type] + output_type = cur_op.types[target_type] + for dtype in input_types: + if dtype not in cur_op.types: # pragma: no branch (safety) + cur_op.types[dtype] = output_type + cur_op._typed_ops[dtype] = typed_op + cur_op.coercions[dtype] = target_type + # Not valid input dtypes + del binary.ldexp[FP32] + del binary.ldexp[FP64] + # Fill in commutes info + for left_name, right_name in cls._commutes.items(): + if left_name in _SS_OPERATORS: + left = binary._deprecated[left_name] + else: + left = getattr(binary, left_name) + if backend == "suitesparse" and right_name in _SS_OPERATORS: + left._commutes_to = f"ss.{right_name}" + else: + left._commutes_to = right_name + if right_name not in binary._delayed: + if right_name in _SS_OPERATORS: + right = binary._deprecated[right_name] + else: + right = getattr(binary, right_name) + if backend == "suitesparse" and left_name in _SS_OPERATORS: + right._commutes_to = f"ss.{left_name}" + else: + right._commutes_to = left_name + for name in cls._commutative: + cur_op = getattr(binary, name) + cur_op._commutes_to = name + for left_name, right_name in cls._commutes_to_in_semiring.items(): + if left_name in _SS_OPERATORS: + left = binary._deprecated[left_name] + else: # pragma: no cover (safety) + left = getattr(binary, left_name) + if right_name in _SS_OPERATORS: + right = binary._deprecated[right_name] + else: # pragma: no cover (safety) + right = getattr(binary, right_name) + left._semiring_commutes_to = right + right._semiring_commutes_to = left + # Allow some functions to work on UDTs + for binop, func in [ + (binary.first, _first), + (binary.second, _second), + (binary.pair, _pair), + (binary.any, _first), + ]: + binop.orig_func = func + binop._numba_func = numba.njit(func) + binop._udt_types = {} + binop._udt_ops = {} + binary.any._numba_func = binary.first._numba_func + binary.eq._udt_types = {} + binary.eq._udt_ops = {} + binary.ne._udt_types = {} + binary.ne._udt_ops = {} + # Set custom dtype handling + binary.first._custom_dtype = _first_dtype + binary.second._custom_dtype = _second_dtype + binary.pair._custom_dtype = _pair_dtype + cls._initialized = True + + def __init__( + self, + name, + func=None, + *, + anonymous=False, + is_positional=False, + is_udt=False, + numba_func=None, + ): + super().__init__(name, anonymous=anonymous) + self._monoid = None + self._commutes_to = None + self._semiring_commutes_to = None + self.orig_func = func + self._numba_func = numba_func + self._is_udt = is_udt + self.is_positional = is_positional + self._custom_dtype = None + if is_udt: + self._udt_types = {} # {(dtype, dtype): DataType} + self._udt_ops = {} # {(dtype, dtype): TypedUserBinaryOp} + + def __reduce__(self): + if self._anonymous: + if hasattr(self.orig_func, "_parameterized_info"): + return (_deserialize_parameterized, self.orig_func._parameterized_info) + return (self.register_anonymous, (self.orig_func, self.name)) + if (name := f"binary.{self.name}") in _STANDARD_OPERATOR_NAMES: + return name + return (self._deserialize, (self.name, self.orig_func)) + + __call__ = TypedBuiltinBinaryOp.__call__ + is_commutative = TypedBuiltinBinaryOp.is_commutative + commutes_to = ParameterizedBinaryOp.commutes_to + + @property + def monoid(self): + if self._monoid is None and not self._anonymous: + from .monoid import Monoid + + self._monoid = Monoid._find(self.name) + return self._monoid diff --git a/graphblas/core/operator/indexunary.py b/graphblas/core/operator/indexunary.py new file mode 100644 index 000000000..5fdafb62a --- /dev/null +++ b/graphblas/core/operator/indexunary.py @@ -0,0 +1,357 @@ +import inspect +import re +from types import FunctionType + +import numba + +from ... import _STANDARD_OPERATOR_NAMES, indexunary, select +from ...dtypes import BOOL, FP64, INT8, INT64, UINT64, _sample_values, lookup_dtype +from ...exceptions import UdfParseError, check_status_carg +from .. import ffi, lib +from .base import ( + OpBase, + ParameterizedUdf, + TypedOpBase, + _call_op, + _deserialize_parameterized, + _get_udt_wrapper, +) + +ffi_new = ffi.new + + +class TypedBuiltinIndexUnaryOp(TypedOpBase): + __slots__ = () + opclass = "IndexUnaryOp" + + def __call__(self, val, thunk=None): + if thunk is None: + thunk = False # most basic form of 0 when unifying dtypes + return _call_op(self, val, right=thunk) + + +class TypedUserIndexUnaryOp(TypedOpBase): + __slots__ = () + opclass = "IndexUnaryOp" + + def __init__(self, parent, name, type_, return_type, gb_obj, dtype2=None): + super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}", dtype2=dtype2) + + @property + def orig_func(self): + return self.parent.orig_func + + @property + def _numba_func(self): + return self.parent._numba_func + + __call__ = TypedBuiltinIndexUnaryOp.__call__ + + +class ParameterizedIndexUnaryOp(ParameterizedUdf): + __slots__ = "func", "__signature__", "_is_udt" + + def __init__(self, name, func, *, anonymous=False, is_udt=False): + self.func = func + self.__signature__ = inspect.signature(func) + self._is_udt = is_udt + if name is None: + name = getattr(func, "__name__", name) + super().__init__(name, anonymous) + + def _call(self, *args, **kwargs): + indexunary = self.func(*args, **kwargs) + indexunary._parameterized_info = (self, args, kwargs) + return IndexUnaryOp.register_anonymous(indexunary, self.name, is_udt=self._is_udt) + + def __reduce__(self): + name = f"indexunary.{self.name}" + if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: + return name + return (self._deserialize, (self.name, self.func, self._anonymous)) + + @staticmethod + def _deserialize(name, func, anonymous): + if anonymous: + return IndexUnaryOp.register_anonymous(func, name, parameterized=True) + if (rv := IndexUnaryOp._find(name)) is not None: + return rv + return IndexUnaryOp.register_new(name, func, parameterized=True) + + +class IndexUnaryOp(OpBase): + """Takes one input and a thunk and returns one output, possibly of a different data type. + Along with the input value, the index(es) of the element are given to the function. + + This is an advanced form of a unary operation that allows, for example, converting + elements of a Vector to their index position to build a ramp structure. Another use + case is returning a boolean value indicating whether the element is part of the upper + triangular structure of a Matrix. + + Built-in and registered IndexUnaryOps are located in the ``graphblas.indexunary`` namespace. + """ + + __slots__ = "orig_func", "is_positional", "_is_udt", "_numba_func" + _module = indexunary + _modname = "indexunary" + _custom_dtype = None + _typed_class = TypedBuiltinIndexUnaryOp + _typed_user_class = TypedUserIndexUnaryOp + _parse_config = { + "trim_from_front": 4, + "num_underscores": 1, + "re_exprs": [ + re.compile("^GrB_(ROWINDEX|COLINDEX|DIAGINDEX)_(INT32|INT64)$"), + ], + "re_exprs_return_bool": [ + re.compile("^GrB_(TRIL|TRIU|DIAG|OFFDIAG|COLLE|COLGT|ROWLE|ROWGT)$"), + re.compile( + "^GrB_(VALUEEQ|VALUENE|VALUEGT|VALUEGE|VALUELT|VALUELE)" + "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" + ), + re.compile("^GxB_(VALUEEQ|VALUENE)_(FC32|FC64)$"), + ], + } + _positional = {"tril", "triu", "diag", "offdiag", "colle", "colgt", "rowle", "rowgt", + "rowindex", "colindex"} # fmt: skip + + @classmethod + def _build(cls, name, func, *, is_udt=False, anonymous=False): + if not isinstance(func, FunctionType): + raise TypeError(f"UDF argument must be a function, not {type(func)}") + if name is None: + name = getattr(func, "__name__", "") + success = False + indexunary_udf = numba.njit(func) + new_type_obj = cls( + name, func, anonymous=anonymous, is_udt=is_udt, numba_func=indexunary_udf + ) + return_types = {} + nt = numba.types + if not is_udt: + for type_ in _sample_values: + sig = (type_.numba_type, UINT64.numba_type, UINT64.numba_type, type_.numba_type) + try: + indexunary_udf.compile(sig) + except numba.TypingError: + continue + ret_type = lookup_dtype(indexunary_udf.overloads[sig].signature.return_type) + if ret_type != type_ and ( + ("INT" in ret_type.name and "INT" in type_.name) + or ("FP" in ret_type.name and "FP" in type_.name) + or ("FC" in ret_type.name and "FC" in type_.name) + or (type_ == UINT64 and ret_type == FP64 and return_types.get(INT64) == INT64) + ): + # Downcast `ret_type` to `type_`. + # This is what users want most of the time, but we can't make a perfect rule. + # There should be a way for users to be explicit. + ret_type = type_ + elif type_ == BOOL and ret_type == INT64 and return_types.get(INT8) == INT8: + ret_type = INT8 + + # Numba is unable to handle BOOL correctly right now, but we have a workaround + # See: https://github.com/numba/numba/issues/5395 + # We're relying on coercion behaving correctly here + input_type = INT8 if type_ == BOOL else type_ + return_type = INT8 if ret_type == BOOL else ret_type + + # Build wrapper because GraphBLAS wants pointers and void return + wrapper_sig = nt.void( + nt.CPointer(return_type.numba_type), + nt.CPointer(input_type.numba_type), + UINT64.numba_type, + UINT64.numba_type, + nt.CPointer(input_type.numba_type), + ) + + if type_ == BOOL: + if ret_type == BOOL: + + def indexunary_wrapper(z, x, row, col, y): # pragma: no cover (numba) + z[0] = bool(indexunary_udf(bool(x[0]), row, col, bool(y[0]))) + + else: + + def indexunary_wrapper(z, x, row, col, y): # pragma: no cover (numba) + z[0] = indexunary_udf(bool(x[0]), row, col, bool(y[0])) + + elif ret_type == BOOL: + + def indexunary_wrapper(z, x, row, col, y): # pragma: no cover (numba) + z[0] = bool(indexunary_udf(x[0], row, col, y[0])) + + else: + + def indexunary_wrapper(z, x, row, col, y): # pragma: no cover (numba) + z[0] = indexunary_udf(x[0], row, col, y[0]) + + indexunary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(indexunary_wrapper) + new_indexunary = ffi_new("GrB_IndexUnaryOp*") + check_status_carg( + lib.GrB_IndexUnaryOp_new( + new_indexunary, + indexunary_wrapper.cffi, + ret_type.gb_obj, + type_.gb_obj, + type_.gb_obj, + ), + "IndexUnaryOp", + new_indexunary, + ) + op = cls._typed_user_class(new_type_obj, name, type_, ret_type, new_indexunary[0]) + new_type_obj._add(op) + success = True + return_types[type_] = ret_type + if success or is_udt: + return new_type_obj + raise UdfParseError("Unable to parse function using Numba") + + def _compile_udt(self, dtype, dtype2): + if dtype2 is None: # pragma: no cover + dtype2 = dtype + dtypes = (dtype, dtype2) + if dtypes in self._udt_types: + return self._udt_ops[dtypes] + + numba_func = self._numba_func + sig = (dtype.numba_type, UINT64.numba_type, UINT64.numba_type, dtype2.numba_type) + numba_func.compile(sig) # Should we catch and give additional error message? + ret_type = lookup_dtype(numba_func.overloads[sig].signature.return_type) + indexunary_wrapper, wrapper_sig = _get_udt_wrapper( + numba_func, ret_type, dtype, dtype2, include_indexes=True + ) + + indexunary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(indexunary_wrapper) + new_indexunary = ffi_new("GrB_IndexUnaryOp*") + check_status_carg( + lib.GrB_IndexUnaryOp_new( + new_indexunary, indexunary_wrapper.cffi, ret_type._carg, dtype._carg, dtype2._carg + ), + "IndexUnaryOp", + new_indexunary, + ) + op = TypedUserIndexUnaryOp( + self, + self.name, + dtype, + ret_type, + new_indexunary[0], + dtype2=dtype2, + ) + self._udt_types[dtypes] = ret_type + self._udt_ops[dtypes] = op + return op + + @classmethod + def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=False): + """Register an IndexUnaryOp without registering it in the + ``graphblas.indexunary`` namespace. + + Because it is not registered in the namespace, the name is optional. + """ + if parameterized: + return ParameterizedIndexUnaryOp(name, func, anonymous=True, is_udt=is_udt) + return cls._build(name, func, anonymous=True, is_udt=is_udt) + + @classmethod + def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): + """Register an IndexUnaryOp. The name will be used to identify the IndexUnaryOp in the + ``graphblas.indexunary`` namespace. + + If the return type is Boolean, the function will also be registered as a SelectOp + with the same name. + + >>> gb.indexunary.register_new("row_mod", lambda x, i, j, thunk: i % max(thunk, 2)) + >>> dir(gb.indexunary) + [..., 'row_mod', ...] + """ + module, funcname = cls._remove_nesting(name) + if lazy: + module._delayed[funcname] = ( + cls.register_new, + {"name": name, "func": func, "parameterized": parameterized}, + ) + elif parameterized: + indexunary_op = ParameterizedIndexUnaryOp(name, func, is_udt=is_udt) + setattr(module, funcname, indexunary_op) + else: + indexunary_op = cls._build(name, func, is_udt=is_udt) + setattr(module, funcname, indexunary_op) + # If return type is BOOL, register additionally as a SelectOp + if all(x == BOOL for x in indexunary_op.types.values()): + from .select import SelectOp + + setattr(select, funcname, SelectOp._from_indexunary(indexunary_op)) + + if not cls._initialized: + _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") + if not lazy: + return indexunary_op + + @classmethod + def _initialize(cls): + if cls._initialized: + return + from .select import SelectOp + + super()._initialize(include_in_ops=False) + # Update type information to include UINT64 for positional ops + for name in ["tril", "triu", "diag", "offdiag", "colle", "colgt", "rowle", "rowgt"]: + op = getattr(indexunary, name) + typed_op = op._typed_ops[BOOL] + output_type = op.types[BOOL] + if UINT64 not in op.types: # pragma: no branch (safety) + op.types[UINT64] = output_type + op._typed_ops[UINT64] = typed_op + op.coercions[UINT64] = BOOL + for name in ["rowindex", "colindex"]: + op = getattr(indexunary, name) + typed_op = op._typed_ops[INT64] + output_type = op.types[INT64] + if UINT64 not in op.types: # pragma: no branch (safety) + op.types[UINT64] = output_type + op._typed_ops[UINT64] = typed_op + op.coercions[UINT64] = INT64 + # Add index->row alias to make it more intuitive which to use for vectors + indexunary.indexle = indexunary.rowle + indexunary.indexgt = indexunary.rowgt + indexunary.index = indexunary.rowindex + # fmt: off + # Add SelectOp when it makes sense + for name in ["tril", "triu", "diag", "offdiag", + "colle", "colgt", "rowle", "rowgt", "indexle", "indexgt", + "valueeq", "valuene", "valuegt", "valuege", "valuelt", "valuele"]: + iop = getattr(indexunary, name) + setattr(select, name, SelectOp._from_indexunary(iop)) + # fmt: on + cls._initialized = True + + def __init__( + self, + name, + func=None, + *, + anonymous=False, + is_positional=False, + is_udt=False, + numba_func=None, + ): + super().__init__(name, anonymous=anonymous) + self.orig_func = func + self._numba_func = numba_func + self.is_positional = is_positional + self._is_udt = is_udt + if is_udt: + self._udt_types = {} # {dtype: DataType} + self._udt_ops = {} # {dtype: TypedUserIndexUnaryOp} + + def __reduce__(self): + if self._anonymous: + if hasattr(self.orig_func, "_parameterized_info"): + return (_deserialize_parameterized, self.orig_func._parameterized_info) + return (self.register_anonymous, (self.orig_func, self.name)) + if (name := f"indexunary.{self.name}") in _STANDARD_OPERATOR_NAMES: + return name + return (self._deserialize, (self.name, self.orig_func)) + + __call__ = TypedBuiltinIndexUnaryOp.__call__ diff --git a/graphblas/core/operator/monoid.py b/graphblas/core/operator/monoid.py new file mode 100644 index 000000000..387652b63 --- /dev/null +++ b/graphblas/core/operator/monoid.py @@ -0,0 +1,417 @@ +import inspect +import re +from collections.abc import Mapping + +from ... import _STANDARD_OPERATOR_NAMES, binary, monoid, op +from ...dtypes import ( + BOOL, + FP32, + FP64, + INT8, + INT16, + INT32, + INT64, + UINT8, + UINT16, + UINT32, + UINT64, + lookup_dtype, +) +from ...exceptions import check_status_carg +from .. import ffi, lib +from ..expr import InfixExprBase +from ..utils import libget +from .base import OpBase, ParameterizedUdf, TypedOpBase, _call_op, _hasop +from .binary import BinaryOp, ParameterizedBinaryOp + +ffi_new = ffi.new + + +class TypedBuiltinMonoid(TypedOpBase): + __slots__ = "_identity" + opclass = "Monoid" + is_commutative = True + + def __init__(self, parent, name, type_, return_type, gb_obj, gb_name): + super().__init__(parent, name, type_, return_type, gb_obj, gb_name) + self._identity = None + + def __call__(self, left, right=None, *, left_default=None, right_default=None): + if left_default is not None or right_default is not None: + if ( + left_default is None + or right_default is None + or right is not None + or not isinstance(left, InfixExprBase) + or left.method_name != "ewise_add" + ): + raise TypeError( + "Specifying `left_default` or `right_default` keyword arguments implies " + "performing `ewise_union` operation with infix notation.\n" + "There is only one valid way to do this:\n\n" + f">>> {self}(x | y, left_default=0, right_default=0)\n\nwhere x and y " + "are Vectors or Matrices, and left_default and right_default are scalars." + ) + return left.left.ewise_union(left.right, self, left_default, right_default) + return _call_op(self, left, right) + + @property + def identity(self): + if self._identity is None: + from ..recorder import skip_record + from ..vector import Vector + + with skip_record: + self._identity = ( + Vector(self.type, size=1, name="").reduce(self, allow_empty=False).new().value + ) + return self._identity + + @property + def binaryop(self): + return getattr(binary, self.name)[self.type] + + @property + def commutes_to(self): + return self + + @property + def type2(self): + return self.type + + @property + def is_idempotent(self): + """True if ``monoid(x, x) == x`` for any x.""" + return self.parent.is_idempotent + + +class TypedUserMonoid(TypedOpBase): + __slots__ = "binaryop", "identity" + opclass = "Monoid" + is_commutative = True + + def __init__(self, parent, name, type_, return_type, gb_obj, binaryop, identity): + super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}") + self.binaryop = binaryop + self.identity = identity + binaryop._monoid = self + + commutes_to = TypedBuiltinMonoid.commutes_to + type2 = TypedBuiltinMonoid.type2 + is_idempotent = TypedBuiltinMonoid.is_idempotent + __call__ = TypedBuiltinMonoid.__call__ + + +class ParameterizedMonoid(ParameterizedUdf): + __slots__ = "binaryop", "identity", "_is_idempotent", "__signature__" + is_commutative = True + + def __init__(self, name, binaryop, identity, *, is_idempotent=False, anonymous=False): + if type(binaryop) is not ParameterizedBinaryOp: + raise TypeError("binaryop must be parameterized") + self.binaryop = binaryop + self.__signature__ = binaryop.__signature__ + if callable(identity): + # assume it must be parameterized as well, so signature must match + sig = inspect.signature(identity) + if sig != self.__signature__: + raise ValueError( + "Signatures of binaryop and identity passed to " + f"{type(self).__name__} must be the same. Got:\n" + f" binaryop{self.__signature__}\n" + " !=\n" + f" identity{sig}" + ) + self.identity = identity + self._is_idempotent = is_idempotent + if name is None: + name = binaryop.name + super().__init__(name, anonymous) + binaryop._monoid = self + # clear binaryop cache so it can be associated with this monoid + binaryop._cached_call.cache_clear() + + def _call(self, *args, **kwargs): + binary = self.binaryop(*args, **kwargs) + identity = self.identity + if callable(identity): + identity = identity(*args, **kwargs) + return Monoid.register_anonymous( + binary, identity, self.name, is_idempotent=self._is_idempotent + ) + + commutes_to = TypedBuiltinMonoid.commutes_to + + @property + def is_idempotent(self): + """True if ``monoid(x, x) == x`` for any x.""" + return self._is_idempotent + + def __reduce__(self): + name = f"monoid.{self.name}" + if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: # pragma: no cover + return name + return (self._deserialize, (self.name, self.binaryop, self.identity, self._anonymous)) + + @staticmethod + def _deserialize(name, binaryop, identity, anonymous): + if anonymous: + return Monoid.register_anonymous(binaryop, identity, name) + if (rv := Monoid._find(name)) is not None: + return rv + return Monoid.register_new(name, binaryop, identity) + + +class Monoid(OpBase): + """Takes two inputs and returns one output, all of the same data type. + + Built-in and registered Monoids are located in the ``graphblas.monoid`` namespace + as well as in the ``graphblas.ops`` combined namespace. + """ + + __slots__ = "_binaryop", "_identity", "_is_idempotent" + is_commutative = True + is_positional = False + _custom_dtype = None + _module = monoid + _modname = "monoid" + _typed_class = TypedBuiltinMonoid + _parse_config = { + "trim_from_front": 4, + "delete_exact": "MONOID", + "num_underscores": 1, + "re_exprs": [ + re.compile( + "^GrB_(MIN|MAX|PLUS|TIMES|LOR|LAND|LXOR|LXNOR)_MONOID" + "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" + ), + re.compile( + "^GxB_(ANY)_(INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)_MONOID$" + ), + re.compile("^GxB_(PLUS|TIMES|ANY)_(FC32|FC64)_MONOID$"), + re.compile("^GxB_(EQ|ANY)_BOOL_MONOID$"), + re.compile("^GxB_(BOR|BAND|BXOR|BXNOR)_(UINT8|UINT16|UINT32|UINT64)_MONOID$"), + ], + } + + @classmethod + def _build(cls, name, binaryop, identity, *, is_idempotent=False, anonymous=False): + if type(binaryop) is not BinaryOp: + raise TypeError(f"binaryop must be a BinaryOp, not {type(binaryop)}") + if name is None: + name = binaryop.name + new_type_obj = cls( + name, binaryop, identity, is_idempotent=is_idempotent, anonymous=anonymous + ) + if not binaryop._is_udt: + if not isinstance(identity, Mapping): + identities = dict.fromkeys(binaryop.types, identity) + explicit_identities = False + else: + identities = {lookup_dtype(key): val for key, val in identity.items()} + explicit_identities = True + for type_, ident in identities.items(): + ret_type = binaryop[type_].return_type + # If there is a domain mismatch, then DomainMismatch will be raised + # below if identities were explicitly given. + if type_ != ret_type and not explicit_identities: + continue + new_monoid = ffi_new("GrB_Monoid*") + func = libget(f"GrB_Monoid_new_{type_.name}") + zcast = ffi.cast(type_.c_type, ident) + check_status_carg( + func(new_monoid, binaryop[type_].gb_obj, zcast), "Monoid", new_monoid[0] + ) + op = TypedUserMonoid( + new_type_obj, + name, + type_, + ret_type, + new_monoid[0], + binaryop[type_], + ident, + ) + new_type_obj._add(op) + return new_type_obj + + def _compile_udt(self, dtype, dtype2): + if dtype2 is None: + dtype2 = dtype + elif dtype != dtype2: + raise TypeError( + "Monoid inputs must be the same dtype (got {dtype} and {dtype2}); " + "unable to coerce when using UDTs." + ) + if dtype in self._udt_types: + return self._udt_ops[dtype] + binaryop = self.binaryop._compile_udt(dtype, dtype2) + from ..scalar import Scalar + + ret_type = binaryop.return_type + identity = Scalar.from_value(self._identity, dtype=ret_type, is_cscalar=True) + new_monoid = ffi_new("GrB_Monoid*") + status = lib.GrB_Monoid_new_UDT(new_monoid, binaryop.gb_obj, identity.gb_obj) + check_status_carg(status, "Monoid", new_monoid[0]) + op = TypedUserMonoid( + new_monoid, + self.name, + dtype, + ret_type, + new_monoid[0], + binaryop, + identity, + ) + self._udt_types[dtype] = dtype + self._udt_ops[dtype] = op + return op + + @classmethod + def register_anonymous(cls, binaryop, identity, name=None, *, is_idempotent=False): + """Register a Monoid without registering it in the ``graphblas.monoid`` namespace. + + Because it is not registered in the namespace, the name is optional. + + Parameters + ---------- + binaryop : BinaryOp + Builtin or registered binary operator + identity : + Identity value of the monoid + name : str, optional + Name associated with the monoid + is_idempotent : bool, default False + Does ``op(x, x) == x`` for any x? + + Returns + ------- + Function handle + """ + if type(binaryop) is ParameterizedBinaryOp: + return ParameterizedMonoid( + name, binaryop, identity, is_idempotent=is_idempotent, anonymous=True + ) + return cls._build(name, binaryop, identity, is_idempotent=is_idempotent, anonymous=True) + + @classmethod + def register_new(cls, name, binaryop, identity, *, is_idempotent=False, lazy=False): + """Register a Monoid. The name will be used to identify the Monoid in the + ``graphblas.monoid`` namespace. + + >>> gb.core.operator.Monoid.register_new("max_zero", gb.binary.max_zero, 0) + >>> dir(gb.monoid) + [..., 'max_zero', ...] + """ + module, funcname = cls._remove_nesting(name) + if lazy: + module._delayed[funcname] = ( + cls.register_new, + {"name": name, "binaryop": binaryop, "identity": identity}, + ) + elif type(binaryop) is ParameterizedBinaryOp: + monoid = ParameterizedMonoid(name, binaryop, identity, is_idempotent=is_idempotent) + setattr(module, funcname, monoid) + else: + monoid = cls._build(name, binaryop, identity, is_idempotent=is_idempotent) + setattr(module, funcname, monoid) + # Also save it to `graphblas.op` if not yet defined + opmodule, funcname = cls._remove_nesting(name, module=op, modname="op", strict=False) + if not _hasop(opmodule, funcname): + if lazy: + opmodule._delayed[funcname] = module + else: + setattr(opmodule, funcname, monoid) + if not cls._initialized: # pragma: no cover + _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") + if not lazy: + return monoid + + def __init__(self, name, binaryop=None, identity=None, *, is_idempotent=False, anonymous=False): + super().__init__(name, anonymous=anonymous) + self._binaryop = binaryop + self._identity = identity + self._is_idempotent = is_idempotent + if binaryop is not None: + binaryop._monoid = self + if binaryop._is_udt: + self._udt_types = {} # {dtype: DataType} + self._udt_ops = {} # {dtype: TypedUserMonoid} + + def __reduce__(self): + if self._anonymous: + return (self.register_anonymous, (self._binaryop, self._identity, self.name)) + if (name := f"monoid.{self.name}") in _STANDARD_OPERATOR_NAMES: + return name + return (self._deserialize, (self.name, self._binaryop, self._identity)) + + @property + def binaryop(self): + """The :class:`BinaryOp` associated with the Monoid.""" + if self._binaryop is not None: + return self._binaryop + # Must be builtin + return getattr(binary, self.name) + + @property + def identities(self): + """The per-dtype identity values for the Monoid.""" + return {dtype: val.identity for dtype, val in self._typed_ops.items()} + + @property + def is_idempotent(self): + """True if ``monoid(x, x) == x`` for any x.""" + return self._is_idempotent + + @property + def _is_udt(self): + return self._binaryop is not None and self._binaryop._is_udt + + @classmethod + def _initialize(cls): + if cls._initialized: # pragma: no cover (safety) + return + super()._initialize() + lor = monoid.lor._typed_ops[BOOL] + land = monoid.land._typed_ops[BOOL] + for cur_op, typed_op in [ + (monoid.max, lor), + (monoid.min, land), + # (monoid.plus, lor), # two choices: lor, or plus[int] + (monoid.times, land), + ]: + if BOOL not in cur_op.types: # pragma: no branch (safety) + cur_op.types[BOOL] = BOOL + cur_op.coercions[BOOL] = BOOL + cur_op._typed_ops[BOOL] = typed_op + + for cur_op in [monoid.lor, monoid.land, monoid.lxnor, monoid.lxor]: + bool_op = cur_op._typed_ops[BOOL] + for dtype in [ + FP32, + FP64, + INT8, + INT16, + INT32, + INT64, + UINT8, + UINT16, + UINT32, + UINT64, + ]: + if dtype in cur_op.types: # pragma: no cover (safety) + continue + cur_op.types[dtype] = BOOL + cur_op.coercions[dtype] = BOOL + cur_op._typed_ops[dtype] = bool_op + + # Builtin monoids that are idempotent; i.e., `op(x, x) == x` for any x + for name in ["any", "band", "bor", "land", "lor", "max", "min"]: + getattr(monoid, name)._is_idempotent = True + # Allow some functions to work on UDTs + any_ = monoid.any + any_._identity = 0 + any_._udt_types = {} + any_._udt_ops = {} + cls._initialized = True + + commutes_to = TypedBuiltinMonoid.commutes_to + __call__ = TypedBuiltinMonoid.__call__ diff --git a/graphblas/core/operator/select.py b/graphblas/core/operator/select.py new file mode 100644 index 000000000..844565f3a --- /dev/null +++ b/graphblas/core/operator/select.py @@ -0,0 +1,187 @@ +import inspect + +from ... import _STANDARD_OPERATOR_NAMES, select +from ...dtypes import BOOL +from .base import OpBase, ParameterizedUdf, TypedOpBase, _call_op, _deserialize_parameterized +from .indexunary import IndexUnaryOp + + +class TypedBuiltinSelectOp(TypedOpBase): + __slots__ = () + opclass = "SelectOp" + + def __call__(self, val, thunk=None): + if thunk is None: + thunk = False # most basic form of 0 when unifying dtypes + return _call_op(self, val, thunk=thunk) + + +class TypedUserSelectOp(TypedOpBase): + __slots__ = () + opclass = "SelectOp" + + def __init__(self, parent, name, type_, return_type, gb_obj): + super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}") + + @property + def orig_func(self): + return self.parent.orig_func + + @property + def _numba_func(self): + return self.parent._numba_func + + __call__ = TypedBuiltinSelectOp.__call__ + + +class ParameterizedSelectOp(ParameterizedUdf): + __slots__ = "func", "__signature__", "_is_udt" + + def __init__(self, name, func, *, anonymous=False, is_udt=False): + self.func = func + self.__signature__ = inspect.signature(func) + self._is_udt = is_udt + if name is None: + name = getattr(func, "__name__", name) + super().__init__(name, anonymous) + + def _call(self, *args, **kwargs): + sel = self.func(*args, **kwargs) + sel._parameterized_info = (self, args, kwargs) + return SelectOp.register_anonymous(sel, self.name, is_udt=self._is_udt) + + def __reduce__(self): + name = f"select.{self.name}" + if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: + return name + return (self._deserialize, (self.name, self.func, self._anonymous)) + + @staticmethod + def _deserialize(name, func, anonymous): + if anonymous: + return SelectOp.register_anonymous(func, name, parameterized=True) + if (rv := SelectOp._find(name)) is not None: + return rv + return SelectOp.register_new(name, func, parameterized=True) + + +class SelectOp(OpBase): + """Identical to an :class:`IndexUnaryOp `, + but must have a Boolean return type. + + A SelectOp is used exclusively to select a subset of values from a collection where + the function returns True. + + Built-in and registered SelectOps are located in the ``graphblas.select`` namespace. + """ + + __slots__ = "orig_func", "is_positional", "_is_udt", "_numba_func" + _module = select + _modname = "select" + _custom_dtype = None + _typed_class = TypedBuiltinSelectOp + _typed_user_class = TypedUserSelectOp + + @classmethod + def _from_indexunary(cls, iop): + obj = cls( + iop.name, + iop.orig_func, + anonymous=iop._anonymous, + is_positional=iop.is_positional, + is_udt=iop._is_udt, + numba_func=iop._numba_func, + ) + if not all(x == BOOL for x in iop.types.values()): + raise ValueError("SelectOp must have BOOL return type") + for type_, t in iop._typed_ops.items(): + if iop.orig_func is not None: + op = cls._typed_user_class( + obj, + iop.name, + t.type, + t.return_type, + t.gb_obj, + ) + else: + op = cls._typed_class( + obj, + iop.name, + t.type, + t.return_type, + t.gb_obj, + t.gb_name, + ) + # type is not always equal to t.type, so can't use op._add + # but otherwise perform the same logic + obj._typed_ops[type_] = op + obj.types[type_] = op.return_type + return obj + + @classmethod + def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=False): + """Register a SelectOp without registering it in the ``graphblas.select`` namespace. + + Because it is not registered in the namespace, the name is optional. + """ + if parameterized: + return ParameterizedSelectOp(name, func, anonymous=True, is_udt=is_udt) + iop = IndexUnaryOp._build(name, func, anonymous=True, is_udt=is_udt) + return SelectOp._from_indexunary(iop) + + @classmethod + def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): + """Register a SelectOp. The name will be used to identify the SelectOp in the + ``graphblas.select`` namespace. + + The function will also be registered as a IndexUnaryOp with the same name. + + >>> gb.select.register_new("upper_left_triangle", lambda x, i, j, thunk: i + j <= thunk) + >>> dir(gb.select) + [..., 'upper_left_triangle', ...] + """ + iop = IndexUnaryOp.register_new( + name, func, parameterized=parameterized, is_udt=is_udt, lazy=lazy + ) + if not all(x == BOOL for x in iop.types.values()): + raise ValueError("SelectOp must have BOOL return type") + if lazy: + return getattr(select, iop.name) + + @classmethod + def _initialize(cls): + if cls._initialized: # pragma: no cover (safety) + return + # IndexUnaryOp adds it boolean-returning objects to SelectOp + IndexUnaryOp._initialize() + cls._initialized = True + + def __init__( + self, + name, + func=None, + *, + anonymous=False, + is_positional=False, + is_udt=False, + numba_func=None, + ): + super().__init__(name, anonymous=anonymous) + self.orig_func = func + self._numba_func = numba_func + self.is_positional = is_positional + self._is_udt = is_udt + if is_udt: + self._udt_types = {} # {dtype: DataType} + self._udt_ops = {} # {dtype: TypedUserIndexUnaryOp} + + def __reduce__(self): + if self._anonymous: + if hasattr(self.orig_func, "_parameterized_info"): + return (_deserialize_parameterized, self.orig_func._parameterized_info) + return (self.register_anonymous, (self.orig_func, self.name)) + if (name := f"select.{self.name}") in _STANDARD_OPERATOR_NAMES: + return name + return (self._deserialize, (self.name, self.orig_func)) + + __call__ = TypedBuiltinSelectOp.__call__ diff --git a/graphblas/core/operator/semiring.py b/graphblas/core/operator/semiring.py new file mode 100644 index 000000000..06450e007 --- /dev/null +++ b/graphblas/core/operator/semiring.py @@ -0,0 +1,545 @@ +import itertools +import re + +from ... import _STANDARD_OPERATOR_NAMES, binary, monoid, op, semiring +from ...dtypes import ( + BOOL, + FP32, + FP64, + INT8, + INT16, + INT32, + INT64, + UINT8, + UINT16, + UINT32, + UINT64, + _supports_complex, +) +from ...exceptions import check_status_carg +from .. import ffi, lib +from .base import _SS_OPERATORS, OpBase, ParameterizedUdf, TypedOpBase, _call_op, _hasop +from .binary import BinaryOp, ParameterizedBinaryOp +from .monoid import Monoid, ParameterizedMonoid + +if _supports_complex: + from ...dtypes import FC32, FC64 + +ffi_new = ffi.new + + +class TypedBuiltinSemiring(TypedOpBase): + __slots__ = () + opclass = "Semiring" + + def __call__(self, left, right=None): + if right is not None: + raise TypeError( + f"Bad types when calling {self!r}. Got types: {type(left)}, {type(right)}.\n" + f"Expected an infix expression, such as: {self!r}(A @ B)" + ) + return _call_op(self, left) + + @property + def binaryop(self): + name = self.name.split("_", 1)[1] + if name in _SS_OPERATORS: + binop = binary._deprecated[name] + else: + binop = getattr(binary, name) + return binop[self.type] + + @property + def monoid(self): + monoid_name, binary_name = self.name.split("_", 1) + if binary_name in _SS_OPERATORS: + binop = binary._deprecated[binary_name] + else: + binop = getattr(binary, binary_name) + binop = binop[self.type] + val = getattr(monoid, monoid_name) + return val[binop.return_type] + + @property + def commutes_to(self): + binop = self.binaryop + commutes_to = binop._semiring_commutes_to or binop.commutes_to + if commutes_to is None: + return + if commutes_to is binop: + return self + from .utils import get_semiring + + return get_semiring(self.monoid, commutes_to) + + @property + def is_commutative(self): + return self.binaryop.is_commutative + + @property + def type2(self): + return self.type if self._type2 is None else self._type2 + + +class TypedUserSemiring(TypedOpBase): + __slots__ = "monoid", "binaryop" + opclass = "Semiring" + + def __init__(self, parent, name, type_, return_type, gb_obj, monoid, binaryop, dtype2=None): + super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}", dtype2=dtype2) + self.monoid = monoid + self.binaryop = binaryop + + commutes_to = TypedBuiltinSemiring.commutes_to + is_commutative = TypedBuiltinSemiring.is_commutative + type2 = TypedBuiltinSemiring.type2 + __call__ = TypedBuiltinSemiring.__call__ + + +class ParameterizedSemiring(ParameterizedUdf): + __slots__ = "monoid", "binaryop", "__signature__" + + def __init__(self, name, monoid, binaryop, *, anonymous=False): + if type(monoid) not in {ParameterizedMonoid, Monoid}: + raise TypeError("monoid must be of type Monoid or ParameterizedMonoid") + if type(binaryop) is ParameterizedBinaryOp: + self.__signature__ = binaryop.__signature__ + if type(monoid) is ParameterizedMonoid and monoid.__signature__ != self.__signature__: + raise ValueError( + "Signatures of monoid and binaryop passed to " + f"{type(self).__name__} must be the same. Got:\n" + f" monoid{monoid.__signature__}\n" + " !=\n" + f" binaryop{self.__signature__}\n\n" + "Perhaps call monoid or binaryop with parameters before creating the semiring." + ) + elif type(binaryop) is BinaryOp: + if type(monoid) is Monoid: + raise TypeError("At least one of monoid or binaryop must be parameterized") + self.__signature__ = monoid.__signature__ + else: + raise TypeError("binaryop must be of type BinaryOp or ParameterizedBinaryOp") + self.monoid = monoid + self.binaryop = binaryop + if name is None: + name = f"{monoid.name}_{binaryop.name}" + super().__init__(name, anonymous) + + def _call(self, *args, **kwargs): + monoid = self.monoid + if type(monoid) is ParameterizedMonoid: + monoid = monoid(*args, **kwargs) + binary = self.binaryop + if type(binary) is ParameterizedBinaryOp: + binary = binary(*args, **kwargs) + return Semiring.register_anonymous(monoid, binary, self.name) + + commutes_to = TypedBuiltinSemiring.commutes_to + is_commutative = TypedBuiltinSemiring.is_commutative + + def __reduce__(self): + name = f"semiring.{self.name}" + if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: # pragma: no cover + return name + return (self._deserialize, (self.name, self.monoid, self.binaryop, self._anonymous)) + + @staticmethod + def _deserialize(name, monoid, binaryop, anonymous): + if anonymous: + return Semiring.register_anonymous(monoid, binaryop, name) + if (rv := Semiring._find(name)) is not None: + return rv + return Semiring.register_new(name, monoid, binaryop) + + +class Semiring(OpBase): + """Combination of a :class:`Monoid` and a :class:`BinaryOp`. + + Semirings are most commonly used for performing matrix multiplication, + with the BinaryOp taking the place of the standard multiplication operator + and the Monoid taking the place of the standard addition operator. + + Built-in and registered Semirings are located in the ``graphblas.semiring`` namespace + as well as in the ``graphblas.ops`` combined namespace. + """ + + __slots__ = "_monoid", "_binaryop" + _module = semiring + _modname = "semiring" + _typed_class = TypedBuiltinSemiring + _parse_config = { + "trim_from_front": 4, + "delete_exact": "SEMIRING", + "num_underscores": 2, + "re_exprs": [ + re.compile( + "^GrB_(PLUS|MIN|MAX)_(PLUS|TIMES|FIRST|SECOND|MIN|MAX)_SEMIRING" + "_(INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" + ), + re.compile( + "^GxB_(MIN|MAX|PLUS|TIMES|ANY)" + "_(FIRST|SECOND|PAIR|MIN|MAX|PLUS|MINUS|RMINUS|TIMES" + "|DIV|RDIV|ISEQ|ISNE|ISGT|ISLT|ISGE|ISLE|LOR|LAND|LXOR" + "|FIRSTI1|FIRSTI|FIRSTJ1|FIRSTJ|SECONDI1|SECONDI|SECONDJ1|SECONDJ)" + "_(INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" + ), + re.compile( + "^GxB_(PLUS|TIMES|ANY)_(FIRST|SECOND|PAIR|PLUS|MINUS|TIMES|DIV|RDIV|RMINUS)" + "_(FC32|FC64)$" + ), + re.compile( + "^GxB_(BOR|BAND|BXOR|BXNOR)_(BOR|BAND|BXOR|BXNOR)_(UINT8|UINT16|UINT32|UINT64)$" + ), + ], + "re_exprs_return_bool": [ + re.compile("^GrB_(LOR|LAND|LXOR|LXNOR)_(LOR|LAND)_SEMIRING_BOOL$"), + re.compile( + "^GxB_(LOR|LAND|LXOR|EQ|ANY)_(EQ|NE|GT|LT|GE|LE)" + "_(INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" + ), + re.compile( + "^GxB_(LOR|LAND|LXOR|EQ|ANY)_(FIRST|SECOND|PAIR|LOR|LAND|LXOR|EQ|GT|LT|GE|LE)_BOOL$" + ), + ], + } + + @classmethod + def _build(cls, name, monoid, binaryop, *, anonymous=False): + if type(monoid) is not Monoid: + raise TypeError(f"monoid must be a Monoid, not {type(monoid)}") + if type(binaryop) is not BinaryOp: + raise TypeError(f"binaryop must be a BinaryOp, not {type(binaryop)}") + if name is None: + name = f"{monoid.name}_{binaryop.name}".replace(".", "_") + new_type_obj = cls(name, monoid, binaryop, anonymous=anonymous) + if binaryop._is_udt: + return new_type_obj + for binary_in, binary_func in binaryop._typed_ops.items(): + binary_out = binary_func.return_type + # Unfortunately, we can't have user-defined monoids over bools yet + # because numba can't compile correctly. + if ( + binary_out not in monoid.types + # Are all coercions bad, or just to bool? + or monoid.coercions.get(binary_out, binary_out) != binary_out + ): + continue + new_semiring = ffi_new("GrB_Semiring*") + check_status_carg( + lib.GrB_Semiring_new(new_semiring, monoid[binary_out].gb_obj, binary_func.gb_obj), + "Semiring", + new_semiring, + ) + ret_type = monoid[binary_out].return_type + op = TypedUserSemiring( + new_type_obj, + name, + binary_in, + ret_type, + new_semiring[0], + monoid[binary_out], + binary_func, + ) + new_type_obj._add(op) + return new_type_obj + + def _compile_udt(self, dtype, dtype2): + if dtype2 is None: + dtype2 = dtype + dtypes = (dtype, dtype2) + if dtypes in self._udt_types: + return self._udt_ops[dtypes] + binaryop = self.binaryop._compile_udt(dtype, dtype2) + monoid = self.monoid[binaryop.return_type] + ret_type = monoid.return_type + new_semiring = ffi_new("GrB_Semiring*") + status = lib.GrB_Semiring_new(new_semiring, monoid.gb_obj, binaryop.gb_obj) + check_status_carg(status, "Semiring", new_semiring) + op = TypedUserSemiring( + new_semiring, + self.name, + dtype, + ret_type, + new_semiring[0], + monoid, + binaryop, + dtype2=dtype2, + ) + self._udt_types[dtypes] = dtype + self._udt_ops[dtypes] = op + return op + + @classmethod + def register_anonymous(cls, monoid, binaryop, name=None): + """Register a Semiring without registering it in the ``graphblas.semiring`` namespace. + + Because it is not registered in the namespace, the name is optional. + + Parameters + ---------- + monoid : Monoid + Builtin or registered monoid + binaryop : BinaryOp + Builtin or registered binary operator + name : str, optional + Name associated with the semiring + + Returns + ------- + Function handle + """ + if type(monoid) is ParameterizedMonoid or type(binaryop) is ParameterizedBinaryOp: + return ParameterizedSemiring(name, monoid, binaryop, anonymous=True) + return cls._build(name, monoid, binaryop, anonymous=True) + + @classmethod + def register_new(cls, name, monoid, binaryop, *, lazy=False): + """Register a Semiring. The name will be used to identify the Semiring in the + ``graphblas.semiring`` namespace. + + >>> gb.core.operator.Semiring.register_new("max_max", gb.monoid.max, gb.binary.max) + >>> dir(gb.semiring) + [..., 'max_max', ...] + """ + module, funcname = cls._remove_nesting(name) + if lazy: + module._delayed[funcname] = ( + cls.register_new, + {"name": name, "monoid": monoid, "binaryop": binaryop}, + ) + elif type(monoid) is ParameterizedMonoid or type(binaryop) is ParameterizedBinaryOp: + semiring = ParameterizedSemiring(name, monoid, binaryop) + setattr(module, funcname, semiring) + else: + semiring = cls._build(name, monoid, binaryop) + setattr(module, funcname, semiring) + # Also save it to `graphblas.op` if not yet defined + opmodule, funcname = cls._remove_nesting(name, module=op, modname="op", strict=False) + if not _hasop(opmodule, funcname): + if lazy: + opmodule._delayed[funcname] = module + else: + setattr(opmodule, funcname, semiring) + if not cls._initialized: + _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") + if not lazy: + return semiring + + @classmethod + def _initialize(cls): + if cls._initialized: # pragma: no cover (safety) + return + super()._initialize() + # Rename div to cdiv (truncate towards 0) + div_semirings = { + attr: val + for attr, val in vars(semiring).items() + if type(val) is Semiring and attr.endswith("_div") + } + for orig_name, orig in div_semirings.items(): + name = f"{orig_name[:-3]}cdiv" + cdiv_semiring = Semiring(name) + setattr(semiring, name, cdiv_semiring) + setattr(op, name, cdiv_semiring) + delattr(semiring, orig_name) + delattr(op, orig_name) + for dtype, ret_type in orig.types.items(): + orig_semiring = orig[dtype] + new_semiring = TypedBuiltinSemiring( + cdiv_semiring, + name, + dtype, + ret_type, + orig_semiring.gb_obj, + orig_semiring.gb_name, + ) + cdiv_semiring._add(new_semiring) + # Also add truediv (always floating point) and floordiv (truncate towards -inf) + for orig_name, orig in div_semirings.items(): + cls.register_new(f"{orig_name[:-3]}truediv", orig.monoid, binary.truediv, lazy=True) + cls.register_new(f"{orig_name[:-3]}rtruediv", orig.monoid, "rtruediv", lazy=True) + cls.register_new(f"{orig_name[:-3]}floordiv", orig.monoid, "floordiv", lazy=True) + cls.register_new(f"{orig_name[:-3]}rfloordiv", orig.monoid, "rfloordiv", lazy=True) + # For aggregators + cls.register_new("plus_pow", monoid.plus, binary.pow) + cls.register_new("plus_rpow", monoid.plus, "rpow", lazy=True) + cls.register_new("plus_absfirst", monoid.plus, "absfirst", lazy=True) + cls.register_new("max_absfirst", monoid.max, "absfirst", lazy=True) + cls.register_new("plus_abssecond", monoid.plus, "abssecond", lazy=True) + cls.register_new("max_abssecond", monoid.max, "abssecond", lazy=True) + + # Update type information with sane coercion + for lname in ["any", "eq", "land", "lor", "lxnor", "lxor"]: + target_name = f"{lname}_ne" + source_name = f"{lname}_lxor" + if not _hasop(semiring, target_name): + continue + target_op = getattr(semiring, target_name) + if BOOL not in target_op.types: # pragma: no branch (safety) + source_op = getattr(semiring, source_name) + typed_op = source_op._typed_ops[BOOL] + target_op.types[BOOL] = BOOL + target_op._typed_ops[BOOL] = typed_op + target_op.coercions[dtype] = BOOL + + position_dtypes = [ + BOOL, + FP32, + FP64, + INT8, + INT16, + UINT8, + UINT16, + UINT32, + UINT64, + ] + notbool_dtypes = [ + FP32, + FP64, + INT8, + INT16, + INT32, + INT64, + UINT8, + UINT16, + UINT32, + UINT64, + ] + if _supports_complex: + position_dtypes.extend([FC32, FC64]) + notbool_dtypes.extend([FC32, FC64]) + for lnames, rnames, *types in [ + # fmt: off + ( + ("any", "max", "min", "plus", "times"), + ( + "firsti", "firsti1", "firstj", "firstj1", + "secondi", "secondi1", "secondj", "secondj1", + ), + ( + position_dtypes, + INT64, + ), + ), + ( + ("eq", "land", "lor", "lxnor", "lxor"), + ("first", "pair", "second"), + # TODO: check if FC coercion works here + ( + notbool_dtypes, + BOOL, + ), + ), + ( + ("band", "bor", "bxnor", "bxor"), + ("band", "bor", "bxnor", "bxor"), + ([INT8], UINT16), + ([INT16], UINT32), + ([INT32], UINT64), + ([INT64], UINT64), + ), + ( + ("any", "eq", "land", "lor", "lxnor", "lxor"), + ("eq", "land", "lor", "lxnor", "lxor", "ne"), + ( + ( + FP32, FP64, INT8, INT16, INT32, INT64, + UINT8, UINT16, UINT32, UINT64, + ), + BOOL, + ), + ), + # fmt: on + ]: + for left, right in itertools.product(lnames, rnames): + name = f"{left}_{right}" + if not _hasop(semiring, name): + continue + if name in _SS_OPERATORS: + cur_op = semiring._deprecated[name] + else: + cur_op = getattr(semiring, name) + for input_types, target_type in types: + typed_op = cur_op._typed_ops[target_type] + output_type = cur_op.types[target_type] + for dtype in input_types: + if dtype not in cur_op.types: + cur_op.types[dtype] = output_type + cur_op._typed_ops[dtype] = typed_op + cur_op.coercions[dtype] = target_type + + # Handle a few boolean cases + for opname, targetname in [ + ("max_first", "lor_first"), + ("max_second", "lor_second"), + ("max_land", "lor_land"), + ("max_lor", "lor_lor"), + ("max_lxor", "lor_lxor"), + ("min_first", "land_first"), + ("min_second", "land_second"), + ("min_land", "land_land"), + ("min_lor", "land_lor"), + ("min_lxor", "land_lxor"), + ]: + cur_op = getattr(semiring, opname) + target = getattr(semiring, targetname) + if BOOL in cur_op.types or BOOL not in target.types: # pragma: no cover (safety) + continue + cur_op.types[BOOL] = target.types[BOOL] + cur_op._typed_ops[BOOL] = target._typed_ops[BOOL] + cur_op.coercions[BOOL] = BOOL + cls._initialized = True + + def __init__(self, name, monoid=None, binaryop=None, *, anonymous=False): + super().__init__(name, anonymous=anonymous) + self._monoid = monoid + self._binaryop = binaryop + try: + if self.binaryop._udt_types is not None: + self._udt_types = {} # {(dtype, dtype): DataType} + self._udt_ops = {} # {(dtype, dtype): TypedUserSemiring} + except AttributeError: + # `*_div` semirings raise here, but don't need `_udt_types` + pass + + def __reduce__(self): + if self._anonymous: + return (self.register_anonymous, (self._monoid, self._binaryop, self.name)) + if (name := f"semiring.{self.name}") in _STANDARD_OPERATOR_NAMES: + return name + return (self._deserialize, (self.name, self._monoid, self._binaryop)) + + @property + def binaryop(self): + """The :class:`BinaryOp` associated with the Semiring.""" + if self._binaryop is not None: + return self._binaryop + # Must be builtin + name = self.name.split("_")[1] + if name in _SS_OPERATORS: + return binary._deprecated[name] + return getattr(binary, name) + + @property + def monoid(self): + """The :class:`Monoid` associated with the Semiring.""" + if self._monoid is not None: + return self._monoid + # Must be builtin + return getattr(monoid, self.name.split("_")[0].split(".")[-1]) + + @property + def is_positional(self): + return self.binaryop.is_positional + + @property + def _is_udt(self): + return self._binaryop is not None and self._binaryop._is_udt + + @property + def _custom_dtype(self): + return self.binaryop._custom_dtype + + commutes_to = TypedBuiltinSemiring.commutes_to + is_commutative = TypedBuiltinSemiring.is_commutative + __call__ = TypedBuiltinSemiring.__call__ diff --git a/graphblas/core/operator/unary.py b/graphblas/core/operator/unary.py new file mode 100644 index 000000000..6b1319057 --- /dev/null +++ b/graphblas/core/operator/unary.py @@ -0,0 +1,408 @@ +import inspect +import re +from types import FunctionType + +import numba + +from ... import _STANDARD_OPERATOR_NAMES, op, unary +from ...dtypes import ( + BOOL, + FP32, + FP64, + INT8, + INT16, + INT32, + INT64, + UINT8, + UINT16, + UINT32, + UINT64, + _sample_values, + _supports_complex, + lookup_dtype, +) +from ...exceptions import UdfParseError, check_status_carg +from .. import ffi, lib +from ..utils import output_type +from .base import ( + _SS_OPERATORS, + OpBase, + ParameterizedUdf, + TypedOpBase, + _deserialize_parameterized, + _get_udt_wrapper, + _hasop, +) + +if _supports_complex: + from ...dtypes import FC32, FC64 + +ffi_new = ffi.new + + +class TypedBuiltinUnaryOp(TypedOpBase): + __slots__ = () + opclass = "UnaryOp" + + def __call__(self, val): + from ..matrix import Matrix, TransposedMatrix + from ..vector import Vector + + 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: 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)`." + ) + + +class TypedUserUnaryOp(TypedOpBase): + __slots__ = () + opclass = "UnaryOp" + + def __init__(self, parent, name, type_, return_type, gb_obj): + super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}") + + @property + def orig_func(self): + return self.parent.orig_func + + @property + def _numba_func(self): + return self.parent._numba_func + + __call__ = TypedBuiltinUnaryOp.__call__ + + +class ParameterizedUnaryOp(ParameterizedUdf): + __slots__ = "func", "__signature__", "_is_udt" + + def __init__(self, name, func, *, anonymous=False, is_udt=False): + self.func = func + self.__signature__ = inspect.signature(func) + self._is_udt = is_udt + if name is None: + name = getattr(func, "__name__", name) + super().__init__(name, anonymous) + + def _call(self, *args, **kwargs): + unary = self.func(*args, **kwargs) + unary._parameterized_info = (self, args, kwargs) + return UnaryOp.register_anonymous(unary, self.name, is_udt=self._is_udt) + + def __reduce__(self): + name = f"unary.{self.name}" + if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: # pragma: no cover + return name + return (self._deserialize, (self.name, self.func, self._anonymous)) + + @staticmethod + def _deserialize(name, func, anonymous): + if anonymous: + return UnaryOp.register_anonymous(func, name, parameterized=True) + if (rv := UnaryOp._find(name)) is not None: + return rv + return UnaryOp.register_new(name, func, parameterized=True) + + +def _identity(x): + return x # pragma: no cover (numba) + + +def _one(x): + return 1 # pragma: no cover (numba) + + +class UnaryOp(OpBase): + """Takes one input and returns one output, possibly of a different data type. + + Built-in and registered UnaryOps are located in the ``graphblas.unary`` namespace + as well as in the ``graphblas.ops`` combined namespace. + """ + + __slots__ = "orig_func", "is_positional", "_is_udt", "_numba_func" + _custom_dtype = None + _module = unary + _modname = "unary" + _typed_class = TypedBuiltinUnaryOp + _parse_config = { + "trim_from_front": 4, + "num_underscores": 1, + "re_exprs": [ + re.compile( + "^GrB_(IDENTITY|AINV|MINV|ABS|BNOT)" + "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64|FC32|FC64)$" + ), + re.compile( + "^GxB_(LNOT|ONE|POSITIONI1|POSITIONI|POSITIONJ1|POSITIONJ)" + "_(BOOL|INT8|UINT8|INT16|UINT16|INT32|UINT32|INT64|UINT64|FP32|FP64)$" + ), + re.compile( + "^GxB_(SQRT|LOG|EXP|LOG2|SIN|COS|TAN|ACOS|ASIN|ATAN|SINH|COSH|TANH|ACOSH" + "|ASINH|ATANH|SIGNUM|CEIL|FLOOR|ROUND|TRUNC|EXP2|EXPM1|LOG10|LOG1P)" + "_(FP32|FP64|FC32|FC64)$" + ), + re.compile("^GxB_(LGAMMA|TGAMMA|ERF|ERFC|FREXPX|FREXPE|CBRT)_(FP32|FP64)$"), + re.compile("^GxB_(IDENTITY|AINV|MINV|ONE|CONJ)_(FC32|FC64)$"), + ], + "re_exprs_return_bool": [ + re.compile("^GrB_LNOT$"), + re.compile("^GxB_(ISINF|ISNAN|ISFINITE)_(FP32|FP64|FC32|FC64)$"), + ], + "re_exprs_return_float": [re.compile("^GxB_(CREAL|CIMAG|CARG|ABS)_(FC32|FC64)$")], + } + _positional = {"positioni", "positioni1", "positionj", "positionj1"} + + @classmethod + def _build(cls, name, func, *, anonymous=False, is_udt=False): + if type(func) is not FunctionType: + raise TypeError(f"UDF argument must be a function, not {type(func)}") + if name is None: + name = getattr(func, "__name__", "") + success = False + unary_udf = numba.njit(func) + new_type_obj = cls(name, func, anonymous=anonymous, is_udt=is_udt, numba_func=unary_udf) + return_types = {} + nt = numba.types + if not is_udt: + for type_ in _sample_values: + sig = (type_.numba_type,) + try: + unary_udf.compile(sig) + except numba.TypingError: + continue + ret_type = lookup_dtype(unary_udf.overloads[sig].signature.return_type) + if ret_type != type_ and ( + ("INT" in ret_type.name and "INT" in type_.name) + or ("FP" in ret_type.name and "FP" in type_.name) + or ("FC" in ret_type.name and "FC" in type_.name) + or (type_ == UINT64 and ret_type == FP64 and return_types.get(INT64) == INT64) + ): + # Downcast `ret_type` to `type_`. + # This is what users want most of the time, but we can't make a perfect rule. + # There should be a way for users to be explicit. + ret_type = type_ + elif type_ == BOOL and ret_type == INT64 and return_types.get(INT8) == INT8: + ret_type = INT8 + + # Numba is unable to handle BOOL correctly right now, but we have a workaround + # See: https://github.com/numba/numba/issues/5395 + # We're relying on coercion behaving correctly here + input_type = INT8 if type_ == BOOL else type_ + return_type = INT8 if ret_type == BOOL else ret_type + + # Build wrapper because GraphBLAS wants pointers and void return + wrapper_sig = nt.void( + nt.CPointer(return_type.numba_type), + nt.CPointer(input_type.numba_type), + ) + + if type_ == BOOL: + if ret_type == BOOL: + + def unary_wrapper(z, x): + z[0] = bool(unary_udf(bool(x[0]))) # pragma: no cover (numba) + + else: + + def unary_wrapper(z, x): + z[0] = unary_udf(bool(x[0])) # pragma: no cover (numba) + + elif ret_type == BOOL: + + def unary_wrapper(z, x): + z[0] = bool(unary_udf(x[0])) # pragma: no cover (numba) + + else: + + def unary_wrapper(z, x): + z[0] = unary_udf(x[0]) # pragma: no cover (numba) + + unary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(unary_wrapper) + new_unary = ffi_new("GrB_UnaryOp*") + check_status_carg( + lib.GrB_UnaryOp_new( + new_unary, unary_wrapper.cffi, ret_type.gb_obj, type_.gb_obj + ), + "UnaryOp", + new_unary, + ) + op = TypedUserUnaryOp(new_type_obj, name, type_, ret_type, new_unary[0]) + new_type_obj._add(op) + success = True + return_types[type_] = ret_type + if success or is_udt: + return new_type_obj + raise UdfParseError("Unable to parse function using Numba") + + def _compile_udt(self, dtype, dtype2): + if dtype in self._udt_types: + return self._udt_ops[dtype] + + numba_func = self._numba_func + sig = (dtype.numba_type,) + numba_func.compile(sig) # Should we catch and give additional error message? + ret_type = lookup_dtype(numba_func.overloads[sig].signature.return_type) + + unary_wrapper, wrapper_sig = _get_udt_wrapper(numba_func, ret_type, dtype) + unary_wrapper = numba.cfunc(wrapper_sig, nopython=True)(unary_wrapper) + new_unary = ffi_new("GrB_UnaryOp*") + check_status_carg( + lib.GrB_UnaryOp_new(new_unary, unary_wrapper.cffi, ret_type._carg, dtype._carg), + "UnaryOp", + new_unary, + ) + op = TypedUserUnaryOp(self, self.name, dtype, ret_type, new_unary[0]) + self._udt_types[dtype] = ret_type + self._udt_ops[dtype] = op + return op + + @classmethod + def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=False): + """Register a UnaryOp without registering it in the ``graphblas.unary`` namespace. + + Because it is not registered in the namespace, the name is optional. + """ + if parameterized: + return ParameterizedUnaryOp(name, func, anonymous=True, is_udt=is_udt) + return cls._build(name, func, anonymous=True, is_udt=is_udt) + + @classmethod + def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): + """Register a UnaryOp. The name will be used to identify the UnaryOp in the + ``graphblas.unary`` namespace. + + >>> gb.core.operator.UnaryOp.register_new("plus_one", lambda x: x + 1) + >>> dir(gb.unary) + [..., 'plus_one', ...] + """ + module, funcname = cls._remove_nesting(name) + if lazy: + module._delayed[funcname] = ( + cls.register_new, + {"name": name, "func": func, "parameterized": parameterized}, + ) + elif parameterized: + unary_op = ParameterizedUnaryOp(name, func, is_udt=is_udt) + setattr(module, funcname, unary_op) + else: + unary_op = cls._build(name, func, is_udt=is_udt) + setattr(module, funcname, unary_op) + # Also save it to `graphblas.op` if not yet defined + opmodule, funcname = cls._remove_nesting(name, module=op, modname="op", strict=False) + if not _hasop(opmodule, funcname): + if lazy: + opmodule._delayed[funcname] = module + else: + setattr(opmodule, funcname, unary_op) + if not cls._initialized: # pragma: no cover + _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") + if not lazy: + return unary_op + + @classmethod + def _initialize(cls): + if cls._initialized: + return + super()._initialize() + # Update type information with sane coercion + position_dtypes = [ + BOOL, + FP32, + FP64, + INT8, + INT16, + UINT8, + UINT16, + UINT32, + UINT64, + ] + if _supports_complex: + position_dtypes.extend([FC32, FC64]) + for names, *types in [ + # fmt: off + ( + ( + "erf", "erfc", "lgamma", "tgamma", "acos", "acosh", "asin", "asinh", + "atan", "atanh", "ceil", "cos", "cosh", "exp", "exp2", "expm1", "floor", + "log", "log10", "log1p", "log2", "round", "signum", "sin", "sinh", "sqrt", + "tan", "tanh", "trunc", "cbrt", + ), + ((BOOL, INT8, INT16, UINT8, UINT16), FP32), + ((INT32, INT64, UINT32, UINT64), FP64), + ), + ( + ("positioni", "positioni1", "positionj", "positionj1"), + ( + position_dtypes, + INT64, + ), + ), + # fmt: on + ]: + for name in names: + if name in _SS_OPERATORS: + op = unary._deprecated[name] + else: + op = getattr(unary, name) + for input_types, target_type in types: + typed_op = op._typed_ops[target_type] + output_type = op.types[target_type] + for dtype in input_types: + if dtype not in op.types: # pragma: no branch (safety) + op.types[dtype] = output_type + op._typed_ops[dtype] = typed_op + op.coercions[dtype] = target_type + # Allow some functions to work on UDTs + for unop, func in [ + (unary.identity, _identity), + (unary.one, _one), + ]: + unop.orig_func = func + unop._numba_func = numba.njit(func) + unop._udt_types = {} + unop._udt_ops = {} + cls._initialized = True + + def __init__( + self, + name, + func=None, + *, + anonymous=False, + is_positional=False, + is_udt=False, + numba_func=None, + ): + super().__init__(name, anonymous=anonymous) + self.orig_func = func + self._numba_func = numba_func + self.is_positional = is_positional + self._is_udt = is_udt + if is_udt: + self._udt_types = {} # {dtype: DataType} + self._udt_ops = {} # {dtype: TypedUserUnaryOp} + + def __reduce__(self): + if self._anonymous: + if hasattr(self.orig_func, "_parameterized_info"): + return (_deserialize_parameterized, self.orig_func._parameterized_info) + return (self.register_anonymous, (self.orig_func, self.name)) + if (name := f"unary.{self.name}") in _STANDARD_OPERATOR_NAMES: + return name + return (self._deserialize, (self.name, self.orig_func)) + + __call__ = TypedBuiltinUnaryOp.__call__ diff --git a/graphblas/core/operator/utils.py b/graphblas/core/operator/utils.py new file mode 100644 index 000000000..00bc86cea --- /dev/null +++ b/graphblas/core/operator/utils.py @@ -0,0 +1,447 @@ +from types import BuiltinFunctionType, FunctionType, ModuleType + +from ... import backend, binary, config, indexunary, monoid, op, select, semiring, unary +from ...dtypes import UINT64, lookup_dtype, unify +from .base import ( + _SS_OPERATORS, + OpBase, + OpPath, + ParameterizedUdf, + TypedOpBase, + _builtin_to_op, + _hasop, + find_opclass, +) +from .binary import BinaryOp +from .indexunary import IndexUnaryOp +from .monoid import Monoid +from .select import SelectOp +from .semiring import Semiring +from .unary import UnaryOp + +# Now initialize all the things! +try: + UnaryOp._initialize() + IndexUnaryOp._initialize() + SelectOp._initialize() + BinaryOp._initialize() + Monoid._initialize() + Semiring._initialize() +except Exception: # pragma: no cover (debug) + # Exceptions here can often get ignored by Python + import traceback + + traceback.print_exc() + raise + + +def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scalar=False, kind=None): + if isinstance(op, OpBase): + # UDTs always get compiled + if op._is_udt: + return op._compile_udt(dtype, dtype2) + # Single dtype is simple lookup + 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 and (rv := op._custom_dtype(op, dtype, dtype2)) is not None: + return rv + # Generic case: try to unify the two dtypes + try: + return op[ + unify(dtype, dtype2, is_left_scalar=is_left_scalar, is_right_scalar=is_right_scalar) + ] + except (TypeError, AttributeError): + # Failure to unify implies a dtype is UDT; some builtin operators can handle UDTs + if op.is_positional: + return op[UINT64] + if op._udt_types is None: + raise + return op._compile_udt(dtype, dtype2) + if isinstance(op, ParameterizedUdf): + op = op() # Use default parameters of parameterized UDFs + return get_typed_op( + op, + dtype, + dtype2, + is_left_scalar=is_left_scalar, + is_right_scalar=is_right_scalar, + kind=kind, + ) + if isinstance(op, TypedOpBase): + return op + + from .agg import Aggregator, TypedAggregator + + if isinstance(op, Aggregator): + return op[dtype] + if isinstance(op, TypedAggregator): + return op + if isinstance(op, str): + if kind == "unary": + op = unary_from_string(op) + elif kind == "select": + op = select_from_string(op) + elif kind == "binary": + op = binary_from_string(op) + elif kind == "monoid": + op = monoid_from_string(op) + elif kind == "semiring": + op = semiring_from_string(op) + elif kind == "binary|aggregator": + try: + op = binary_from_string(op) + except ValueError: + try: + op = aggregator_from_string(op) + except ValueError: + raise ValueError( + f"Unknown binary or aggregator string: {op!r}. Example usage: '+[int]'" + ) from None + + else: + raise ValueError( + f"Unable to get op from string {op!r}. `kind=` argument must be provided as " + '"unary", "binary", "monoid", "semiring", "indexunary", "select", ' + 'or "binary|aggregator".' + ) + return get_typed_op( + op, + dtype, + dtype2, + is_left_scalar=is_left_scalar, + is_right_scalar=is_right_scalar, + kind=kind, + ) + if isinstance(op, FunctionType): + if kind == "unary": + op = UnaryOp.register_anonymous(op, is_udt=True) + return op._compile_udt(dtype, dtype2) + if kind.startswith("binary"): + op = BinaryOp.register_anonymous(op, is_udt=True) + return op._compile_udt(dtype, dtype2) + if isinstance(op, BuiltinFunctionType) and op in _builtin_to_op: + return get_typed_op( + _builtin_to_op[op], + dtype, + dtype2, + is_left_scalar=is_left_scalar, + is_right_scalar=is_right_scalar, + kind=kind, + ) + raise TypeError(f"Unable to get typed operator from object with type {type(op)}") + + +def get_semiring(monoid, binaryop, name=None): + """Get or create a Semiring object from a monoid and binaryop. + + If either are typed, then the returned semiring will also be typed. + + See Also + -------- + semiring.register_anonymous + semiring.register_new + semiring.from_string + """ + monoid, opclass = find_opclass(monoid) + switched = False + if opclass == "BinaryOp" and monoid.monoid is not None: + switched = True + monoid = monoid.monoid + elif opclass != "Monoid": + raise TypeError(f"Expected a Monoid for the monoid argument. Got type: {type(monoid)}") + binaryop, opclass = find_opclass(binaryop) + if opclass == "Monoid": + if switched: + raise TypeError( + "Got a BinaryOp for the monoid argument and a Monoid for the binaryop argument. " + "Are the arguments switched? Hint: you can do `mymonoid.binaryop` to get the " + "binaryop from a monoid." + ) + binaryop = binaryop.binaryop + elif opclass != "BinaryOp": + raise TypeError( + f"Expected a BinaryOp for the binaryop argument. Got type: {type(binaryop)}" + ) + if isinstance(monoid, Monoid): + monoid_type = None + else: + monoid_type = monoid.type + monoid = monoid.parent + if isinstance(binaryop, BinaryOp): + binary_type = None + else: + binary_type = binaryop.type + binaryop = binaryop.parent + if monoid._anonymous or binaryop._anonymous: + rv = Semiring.register_anonymous(monoid, binaryop, name=name) + else: + *monoid_prefix, monoid_name = monoid.name.rsplit(".", 1) + *binary_prefix, binary_name = binaryop.name.rsplit(".", 1) + if ( + monoid_prefix + and binary_prefix + and monoid_prefix == binary_prefix + or config.get("mapnumpy") + and ( + monoid_prefix == ["numpy"] + and not binary_prefix + or binary_prefix == ["numpy"] + and not monoid_prefix + ) + or backend == "suitesparse" + and binary_name in _SS_OPERATORS + ): + canonical_name = ( + ".".join(monoid_prefix or binary_prefix) + f".{monoid_name}_{binary_name}" + ) + else: + canonical_name = f"{monoid.name}_{binaryop.name}".replace(".", "_") + if name is None: + name = canonical_name + + module, funcname = Semiring._remove_nesting(canonical_name, strict=False) + rv = ( + getattr(module, funcname) + if funcname in module.__dict__ or funcname in module._delayed + else getattr(module, "_deprecated", {}).get(funcname) + ) + if rv is None and name != canonical_name: + module, funcname = Semiring._remove_nesting(name, strict=False) + rv = ( + getattr(module, funcname) + if funcname in module.__dict__ or funcname in module._delayed + else getattr(module, "_deprecated", {}).get(funcname) + ) + if rv is None: + rv = Semiring.register_new(canonical_name, monoid, binaryop) + elif rv.monoid is not monoid or rv.binaryop is not binaryop: # pragma: no cover + # It's not the object we expect (can this happen?) + rv = Semiring.register_anonymous(monoid, binaryop, name=name) + if name != canonical_name: + module, funcname = Semiring._remove_nesting(name, strict=False) + if not _hasop(module, funcname): # pragma: no branch (safety) + setattr(module, funcname, rv) + + if binary_type is not None: + return rv[binary_type] + if monoid_type is not None: + return rv[monoid_type] + return rv + + +unary.register_new = UnaryOp.register_new +unary.register_anonymous = UnaryOp.register_anonymous +indexunary.register_new = IndexUnaryOp.register_new +indexunary.register_anonymous = IndexUnaryOp.register_anonymous +select.register_new = SelectOp.register_new +select.register_anonymous = SelectOp.register_anonymous +binary.register_new = BinaryOp.register_new +binary.register_anonymous = BinaryOp.register_anonymous +monoid.register_new = Monoid.register_new +monoid.register_anonymous = Monoid.register_anonymous +semiring.register_new = Semiring.register_new +semiring.register_anonymous = Semiring.register_anonymous +semiring.get_semiring = get_semiring + +select._binary_to_select.update( + { + binary.eq: select.valueeq, + binary.ne: select.valuene, + binary.le: select.valuele, + binary.lt: select.valuelt, + binary.ge: select.valuege, + binary.gt: select.valuegt, + binary.iseq: select.valueeq, + binary.isne: select.valuene, + binary.isle: select.valuele, + binary.islt: select.valuelt, + binary.isge: select.valuege, + binary.isgt: select.valuegt, + } +) + +_builtin_to_op.update( + { + abs: unary.abs, + max: binary.max, + min: binary.min, + # Maybe someday: all, any, pow, sum + } +) + +_str_to_unary = { + "-": unary.ainv, + "~": unary.lnot, +} +_str_to_select = { + "<": select.valuelt, + ">": select.valuegt, + "<=": select.valuele, + ">=": select.valuege, + "!=": select.valuene, + "==": select.valueeq, + "col<=": select.colle, + "col>": select.colgt, + "row<=": select.rowle, + "row>": select.rowgt, + "index<=": select.indexle, + "index>": select.indexgt, +} +_str_to_binary = { + "<": binary.lt, + ">": binary.gt, + "<=": binary.le, + ">=": binary.ge, + "!=": binary.ne, + "==": binary.eq, + "+": binary.plus, + "-": binary.minus, + "*": binary.times, + "/": binary.truediv, + "//": "floordiv", + "%": "numpy.mod", + "**": binary.pow, + "&": binary.land, + "|": binary.lor, + "^": binary.lxor, +} +_str_to_monoid = { + "==": monoid.eq, + "+": monoid.plus, + "*": monoid.times, + "&": monoid.land, + "|": monoid.lor, + "^": monoid.lxor, +} + + +def _from_string(string, module, mapping, example): + s = string.lower().strip() + base, *dtype = s.split("[") + if len(dtype) > 1: + name = module.__name__.split(".")[-1] + raise ValueError( + f'Bad {name} string: {string!r}. Contains too many "[". Example usage: {example!r}' + ) + if dtype: + dtype = dtype[0] + if not dtype.endswith("]"): + name = module.__name__.split(".")[-1] + raise ValueError( + f'Bad {name} string: {string!r}. Datatype specification does not end with "]". ' + f"Example usage: {example!r}" + ) + dtype = lookup_dtype(dtype[:-1]) + if "]" in base: + name = module.__name__.split(".")[-1] + raise ValueError( + f'Bad {name} string: {string!r}. "]" not matched by "[". Example usage: {example!r}' + ) + if base in mapping: + op = mapping[base] + if type(op) is str: + op = mapping[base] = module.from_string(op) + elif hasattr(module, base): + op = getattr(module, base) + elif hasattr(module, "numpy") and hasattr(module.numpy, base): + op = getattr(module.numpy, base) + else: + *paths, attr = base.split(".") + op = None + cur = module + for path in paths: + cur = getattr(cur, path, None) + if not isinstance(cur, (OpPath, ModuleType)): + cur = None + break + op = getattr(cur, attr, None) + if op is None: + name = module.__name__.split(".")[-1] + raise ValueError(f"Unknown {name} string: {string!r}. Example usage: {example!r}") + if dtype: + op = op[dtype] + return op + + +def unary_from_string(string): + return _from_string(string, unary, _str_to_unary, "abs[int]") + + +def indexunary_from_string(string): + # "select" is a variant of IndexUnary, so the string abbreviations in + # _str_to_select are appropriate to reuse here + return _from_string(string, indexunary, _str_to_select, "row_index") + + +def select_from_string(string): + return _from_string(string, select, _str_to_select, "tril") + + +def binary_from_string(string): + return _from_string(string, binary, _str_to_binary, "+[int]") + + +def monoid_from_string(string): + return _from_string(string, monoid, _str_to_monoid, "+[int]") + + +def semiring_from_string(string): + split = string.split(".") + if len(split) == 1: + try: + return _from_string(string, semiring, {}, "min.+[int]") + except Exception: + pass + if len(split) != 2: + raise ValueError( + f"Bad semiring string: {string!r}. " + 'The monoid and binaryop should be separated by exactly one period, ".". ' + "Example usage: min.+[int]" + ) + cur_monoid = monoid_from_string(split[0]) + cur_binary = binary_from_string(split[1]) + return get_semiring(cur_monoid, cur_binary) + + +def op_from_string(string): + for func in [ + # Note: order matters here + unary_from_string, + binary_from_string, + monoid_from_string, + semiring_from_string, + indexunary_from_string, + select_from_string, + aggregator_from_string, + ]: + try: + return func(string) + except Exception: + pass + raise ValueError(f"Unknown op string: {string!r}. Example usage: 'abs[int]'") + + +unary.from_string = unary_from_string +indexunary.from_string = indexunary_from_string +select.from_string = select_from_string +binary.from_string = binary_from_string +monoid.from_string = monoid_from_string +semiring.from_string = semiring_from_string +op.from_string = op_from_string + +_str_to_agg = { + "+": "sum", + "*": "prod", + "&": "all", + "|": "any", +} + + +def aggregator_from_string(string): + return _from_string(string, agg, _str_to_agg, "sum[int]") + + +from ... import agg # noqa: E402 isort:skip + +agg.from_string = aggregator_from_string diff --git a/graphblas/monoid/numpy.py b/graphblas/monoid/numpy.py index 2d8d70c20..1d687443f 100644 --- a/graphblas/monoid/numpy.py +++ b/graphblas/monoid/numpy.py @@ -173,10 +173,8 @@ def __getattr__(name): if _config.get("mapnumpy") and name in _numpy_to_graphblas: globals()[name] = getattr(_monoid, _numpy_to_graphblas[name]) else: - from ..core import operator - func = getattr(_binary.numpy, name) - operator.Monoid.register_new( + _monoid.register_new( f"numpy.{name}", func, _monoid_identities[name], is_idempotent=name in _idempotent ) return globals()[name] diff --git a/graphblas/semiring/numpy.py b/graphblas/semiring/numpy.py index 64169168a..e47ac0336 100644 --- a/graphblas/semiring/numpy.py +++ b/graphblas/semiring/numpy.py @@ -136,7 +136,7 @@ def __dir__(): def __getattr__(name): - from ..core import operator + from ..core.operator import get_semiring if name in _delayed: func, kwargs = _delayed.pop(name) @@ -161,7 +161,7 @@ def __getattr__(name): binary_name = "_".join(words[i:]) if hasattr(_binary.numpy, binary_name): # pragma: no branch break - operator.get_semiring( + get_semiring( getattr(_monoid.numpy, monoid_name), getattr(_binary.numpy, binary_name), name=f"numpy.{name}", diff --git a/graphblas/tests/pickle1-vanilla.pkl b/graphblas/tests/pickle1-vanilla.pkl index 36ea207603fdcf1d9cc778b666097dfceeeede49..a494e405a5dd467ac8d998be27044aae3c5b8697 100644 GIT binary patch delta 565 zcmYLGO-LI-7|rVJ?j%7`sK2dRBu40AihA=RcvF!=g%x-I%LJy)Ry$lBV zMxuuW$z8B5NTHM-Jb9A_3pw;8UQFph@g#_|v&njz;eGGD@4cDdPfj|>2HE}og^Uxe z4<|^M!3s`#^)tR9{Y|{zM<~l!J|yrH83?hQ>$uiCfhCe`;?I3vjOCYd)~e-X1^yxn z%^vLFpq`NDE>7u*s0aH{-W0C|ZtI=Mb*2^ZhyGGSJkxuS7(-eKUl^0R2ZvC`4@RF< z9^sBLERk;#`Dw5Y4~_#5Zl<$YmM`Q4f9fyCRx=^}#!U9igVQ>vH3-hE{Njq6UlLHn zH8Z81Vbz?L=6^9s2YV|}0*~ai6)bN1zj8ve+ z>0H>lF}{IosBK9X9#fAwpLSeFKn2e6iuSHoc~rp)l?(6vg}nWdnp$3Q(;wdo@N7+s zMCxtgL7UL*67E#eDvt$|x}Qy@Mp?##PYMr8 delta 577 zcmX>sdP#((fn}=uMiwg;M)Aq^EaH=6Smc;8x+l+Okq1$?S=2z33Tp!Mtc(eh+gVkB z^yJCwSXEeOWlYPMKlwVV2V?hS1vY&k8Ng--B>UN%RA*(Z%9!6aC1^^IXi{cgVo{}D zUTJPYrCxe+N_1vkN$ix2Ss805e`8bUo|Ulys#0z8L^gSy=(L>tM2LdTPzm>iN$)!`9-OdYuHtQ7A|DhlbMxq462$BYGGz^a!!76>J*^sU$aLu zo}KK@;RSU2t;th3l%y_XboX#LfnDZbFlF+TjLRU$W<1EaHu(aFngmFi%{M6yhP zsYPJ1%G~_Y;wfPJuJv$&)q<4*UGWc4t!hzH^3Kg#$4HH-@)12rv+ zm63q~Y#?_o*eh@yuOT|JIDiVg8Nlkm4(Ba~`Up+@ GrFsB*E$w3f diff --git a/graphblas/tests/pickle1.pkl b/graphblas/tests/pickle1.pkl index 98a1fdf05e4e2558eae0a1e27e13d0059fb97d7b..273b499013f2d4ebf6edf05ab62f433bf1412670 100644 GIT binary patch delta 700 zcmYLGO=}ZT6rGpJd?wWjZO6v8io~=~d?NmW)`g(>fi6R+V?T0dXeKq&EV{BA74aeB zeS#25DMeuv3Plh^QScYI=}s9cWZ_E0MT&wt-td@uS3PV94Zga7lr;uu$J2;5*`+($P~_q23`|YQ6R$WmbgL4I1WmNOxupxTEj_c zBF82|M5VW^5aM`2I-X&Trqi?*ID%Bku@j+Ar|x3IYFSR5<74Tj=)ng5lFGrmg(bPH zdGH)|?(yp!=j0Kr$&_@E$!EwWZpov#C!ZqQ_(z^S?ZMkv$A;s^)M+cwF6@7PmMmVD?ibv8=j>Iz2=-@@+j1G;!ls~q)VlgXEp zKvMDmdU#tKJ?q0ZQ*UI+r1U@d3%&c{4tf;$oarh^ zNMBuDT5g(A9`-;$Ckqwgg)+aNwvyCBm+3K?x(=nTh4`FV(ai6x$~FSgCBY80(Yyoc qnNzh*0;;bc3;zs!qCX?kI9(_OVSi@D=oBtpvRdwHm|%3jHvJDcj|55p delta 613 zcmdlc{!omifn{pgMwUcI=Kl`WlQS6aN(i)0$x!Qb7GOXCMi6syB-2Jlj>+828jKE; z9hrqE2Qah1urvc$2~ZU)RF?sNhCy&WkRz};jCn32qxfV)R`JP#ta3~l(L_pDyb%QEInwqa8N((@-Lv#GEy%UG1LdU6k&2jld~SK0J|BrCfekW^q- znw-Y20F>Vbl<#Jj1?t#2c{RH_P-G8KRxp*SLC~f$X%X5)kXSVmgmz)0nYN>M(3zU55DOBCkK}Ed z`|QOQe}&lbmsr?{m4z{_uSIt+@BHq)=bYR2`wn_SuWo8|vM*S)wKC*(w2GRyY+bd= zx~=V5wwlu_dTm7+V4uUy8>&!z&_94GG=c}A`sa zX1+#4f5if-w0AlhW}V;Z{;dZnT<(39Fa163CZVu_$5bH&yrv=PH`l(CeiNT*uk*Lq fh;jt`u!Zlmzk3aKA86~JrYtF|MavK&qjJ+m$B%l4bjEJl$D(Lc8O%oB zTYn;g=O+r;6?_xawUp*ep7{rqN98(H9zAYx zwAv>>L@t=rD}}%vyJjA*^rzt;Fm-3CVlk+SIoS3YFMz@kUIr7fBD7%F87t*?4l=9m J2!oBf`VZI=v+n=^ diff --git a/graphblas/tests/pickle2.pkl b/graphblas/tests/pickle2.pkl index 3c6e18ba4fa5e720355fad969c96e63f5477b029..dd091c82319b2c97b129a1ef25c1be7a112b903c 100644 GIT binary patch delta 603 zcmZ{g%`XE{6ve%nM;aE>Rxp*SLC~f$X%X5)kXSVmgmz)0nYN>M(3zU55DOBCkK}Ed z`|QOQe}&lbmsr?{m4z{_uSIt+@BHq)=bYR2`wn_SuWo8|vM*S)wKC*(w2GRyY+bd= zx~=V5wwlu_dTm7+V4uUy8>&!z&_94GG=c}A`sa zX1+#4f5if-w0AlhW}V;Z{;dZnT<(39Fa163CZVu_$5bH&yrv=PH`l(CeiNT*uk*Lq fh;jt`u!Zlmzk3aKA86~JrYtF|MavK&qjJ+m$B%l4bjEJl$D(Lc8O%oB zTYn;g=O+r;6?_xawUp*ep7{rqN98(H9zAYx zwAv>>L@t=rD}}%vyJjA*^rzt;Fm-3CVlk+SIoS3YFMz@kUIr7fBD7%F87t*?4l=9m J2!oBf`VZI=v+n=^ diff --git a/graphblas/tests/pickle3-vanilla.pkl b/graphblas/tests/pickle3-vanilla.pkl index 29e79d7dbc02834167b144047a703c395db91127..7f8408c9556729a17b2c2e1ea59b4e373daa4e0f 100644 GIT binary patch delta 49 zcmaFH_LhyMfn{paMiwJxdAanW#Da{ZoWx?i=NF~w parse("2022.11.0") + + +@pytest.mark.skipif("not setuptools or not tomli or not gb.__file__") +def test_packages(): + """Ensure all packages are declared in pyproject.toml.""" + # Currently assume s`pyproject.toml` is at the same level as `graphblas` folder. + # This probably isn't always True, and we can probably do a better job of finding it. + path = pathlib.Path(gb.__file__).parent + pkgs = [f"graphblas.{x}" for x in setuptools.find_packages(str(path))] + pkgs.append("graphblas") + pkgs.sort() + pyproject = path.parent / "pyproject.toml" + if not pyproject.exists(): + pytest.skip("Did not find pyproject.toml") + with pyproject.open("rb") as f: + pkgs2 = sorted(tomli.load(f)["tool"]["setuptools"]["packages"]) + assert ( + pkgs == pkgs2 + ), "If there are extra items on the left, add them to pyproject.toml:tool.setuptools.packages" diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index e32606290..3a80dbe52 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -1332,6 +1332,8 @@ def test_deprecated(): gb.op.secondj with pytest.warns(DeprecationWarning, match="please use"): gb.agg.argmin + with pytest.warns(DeprecationWarning, match="please use"): + import graphblas.core.agg # noqa: F401 def test_is_idempotent(): diff --git a/graphblas/unary/numpy.py b/graphblas/unary/numpy.py index 06086569d..836da2024 100644 --- a/graphblas/unary/numpy.py +++ b/graphblas/unary/numpy.py @@ -133,19 +133,17 @@ def __getattr__(name): if _config.get("mapnumpy") and name in _numpy_to_graphblas: globals()[name] = getattr(_unary, _numpy_to_graphblas[name]) else: - from ..core import operator - numpy_func = getattr(_np, name) def func(x): # pragma: no cover (numba) return numpy_func(x) - operator.UnaryOp.register_new(f"numpy.{name}", func) + _unary.register_new(f"numpy.{name}", func) if name == "reciprocal": # numba doesn't match numpy here def reciprocal(x): # pragma: no cover (numba) return 1 if x else 0 - op = operator.UnaryOp.register_anonymous(reciprocal) + op = _unary.register_anonymous(reciprocal) globals()[name]._add(op["BOOL"]) return globals()[name] diff --git a/pyproject.toml b/pyproject.toml index c035b53db..47cf1e67f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,7 @@ test = [ "packaging", "pandas >=1.2", "scipy >=1.8", + "tomli", ] complete = [ "pandas >=1.2", @@ -100,6 +101,7 @@ complete = [ "matplotlib >=3.5", "pytest", "packaging", + "tomli", ] [tool.setuptools] @@ -112,6 +114,7 @@ packages = [ "graphblas.agg", "graphblas.binary", "graphblas.core", + "graphblas.core.operator", "graphblas.core.ss", "graphblas.indexunary", "graphblas.monoid", @@ -314,12 +317,13 @@ ignore = [ ] [tool.ruff.per-file-ignores] -"graphblas/core/operator.py" = ["S102"] # exec is used for UDF +"graphblas/core/agg.py" = ["F401", "F403"] # Deprecated +"graphblas/core/operator/base.py" = ["S102"] # exec is used for UDF "graphblas/core/ss/matrix.py" = ["NPY002"] # numba doesn't support rng generator yet "graphblas/core/ss/vector.py" = ["NPY002"] # numba doesn't support rng generator yet "graphblas/ss/_core.py" = ["N999"] # We want _core.py to be underscopre -# Allow assert, pickle, RNG, print, no docstring, and yoda in tests -"graphblas/tests/*py" = ["S101", "S301", "S311", "T201", "D103", "D100", "SIM300"] +# Allow useless expressions, assert, pickle, RNG, print, no docstring, and yoda in tests +"graphblas/tests/*py" = ["B018", "S101", "S301", "S311", "T201", "D103", "D100", "SIM300"] "graphblas/tests/test_formatting.py" = ["E501"] # Allow long lines "graphblas/**/__init__.py" = ["F401"] # Allow unused imports (w/o defining `__all__`) "scripts/*.py" = ["INP001"] # Not a package diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index d08ad6476..cdd4adf16 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -12,5 +12,5 @@ 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-bugbear[channel=conda-forge]>=23.3.12' +conda search 'flake8-bugbear[channel=conda-forge]>=23.3.23' conda search 'flake8-simplify[channel=conda-forge]>=0.19.3' diff --git a/scripts/create_pickle.py b/scripts/create_pickle.py index 9ee672c41..10fe58630 100755 --- a/scripts/create_pickle.py +++ b/scripts/create_pickle.py @@ -6,7 +6,7 @@ """ import argparse import pickle -from pathlib import PurePath +from pathlib import Path import graphblas as gb from graphblas.tests.test_pickle import * @@ -158,7 +158,7 @@ def pickle3(filepath): extra = "-vanilla" else: extra = "" - path = PurePath(gb.tests.__file__).parent + path = Path(gb.tests.__file__).parent pickle1(path / f"pickle1{extra}.pkl") pickle2(path / f"pickle2{extra}.pkl") pickle3(path / f"pickle3{extra}.pkl") diff --git a/scripts/test_imports.sh b/scripts/test_imports.sh index c38e41d3e..cc989ef06 100755 --- a/scripts/test_imports.sh +++ b/scripts/test_imports.sh @@ -3,7 +3,7 @@ # Make sure imports work. Also, this is a good way to measure import performance. if ! python -c "from graphblas import * ; Matrix" ; then exit 1 ; fi if ! python -c "from graphblas import agg" ; then exit 1 ; fi -if ! python -c "from graphblas.core import agg" ; then exit 1 ; fi +if ! python -c "from graphblas.core.operator import agg" ; then exit 1 ; fi if ! python -c "from graphblas.agg import count" ; then exit 1 ; fi if ! python -c "from graphblas.binary import plus" ; then exit 1 ; fi if ! python -c "from graphblas.indexunary import tril" ; then exit 1 ; fi @@ -20,7 +20,7 @@ if ! (for attr in Matrix Scalar Vector Recorder agg binary dtypes exceptions \ fi done ) ; then exit 1 ; fi -if ! (for attr in agg base descriptor expr formatting ffi infix lib mask \ +if ! (for attr in base descriptor expr formatting ffi infix lib mask \ matrix operator scalar vector recorder automethods infixmethods slice ss do echo python -c \"from graphblas.core import $attr\" if ! python -c "from graphblas.core import $attr" @@ -44,7 +44,7 @@ if ! (for attr in agg binary binary.numpy dtypes exceptions io monoid monoid.num fi done ) ; then exit 1 ; fi -if ! (for attr in agg base descriptor expr formatting infix mask matrix \ +if ! (for attr in base descriptor expr formatting infix mask matrix \ operator scalar vector recorder automethods infixmethods slice ss do echo python -c \"import graphblas.core.$attr\" if ! python -c "import graphblas.core.$attr" @@ -60,3 +60,10 @@ if ! python -c "from graphblas import op ; op.plus" ; then exit 1 ; fi if ! python -c "from graphblas import select ; select.tril" ; then exit 1 ; fi if ! python -c "from graphblas import semiring ; semiring.plus_times" ; then exit 1 ; fi if ! python -c "from graphblas import unary ; unary.exp" ; then exit 1 ; fi +if ! (for attr in agg unary binary monoid semiring select indexunary base utils + do echo python -c \"import graphblas.core.operator.$attr\" + if ! python -c "import graphblas.core.operator.$attr" + then exit 1 + fi + done +) ; then exit 1 ; fi From abb1c78e4b2d93d79634500c1eb0aa3f016f722f Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 31 Mar 2023 14:30:29 -0500 Subject: [PATCH 12/87] Test new wheels for `python-suitesparse-graphblas` on all OSes (#385) * Test new wheels for `python-suitesparse-graphblas` on all OSes * Use mamba or conda, b/c it's no fun when one fails to install * Try coveralls upload twice (it sometimes fails) --- .github/workflows/test_and_build.yml | 89 ++++++++++++++++++++++------ .pre-commit-config.yaml | 2 +- graphblas/core/operator/base.py | 2 +- graphblas/tests/test_numpyops.py | 7 ++- graphblas/tests/test_ss_utils.py | 5 ++ scripts/check_versions.sh | 3 +- 6 files changed, 85 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 807123889..04ffd3eb5 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -88,6 +88,10 @@ jobs: matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] slowtask: ["pytest_normal", "pytest_bizarro", "notebooks"] + env: + # Wheels on OS X come with an OpenMP that conflicts with OpenMP from conda-forge. + # Setting this is a workaround. + KMP_DUPLICATE_LIB_OK: ${{ contains(matrix.os, 'macos') && 'TRUE' || 'FALSE' }} steps: - name: Checkout uses: actions/checkout@v3 @@ -98,6 +102,9 @@ jobs: id: pyver with: # We should support major Python versions for at least 36-42 months + # We could probably support pypy if numba were optional + # 3.8.16 0_73_pypy + # 3.9.16 0_73_pypy contents: | 3.8 3.9 @@ -110,20 +117,22 @@ jobs: uses: ddradar/choose-random-action@v2.0.2 id: sourcetype with: - # Set weight to 0 to skip (such as if 'upstream' is known to not work). - # Have slightly higher weight for `conda-forge` for faster CI. + # Weights must be natural numbers, so set weights to very large to skip one + # (such as if 'upstream' is known to not work). contents: | conda-forge wheel source upstream weights: | - 2 1 1 1 - - name: Setup conda + 1 + - name: Setup mamba uses: conda-incubator/setup-miniconda@v2 + id: setup_mamba + continue-on-error: true with: miniforge-variant: Mambaforge miniforge-version: latest @@ -133,6 +142,18 @@ jobs: channel-priority: strict activate-environment: graphblas auto-activate-base: false + - name: Setup conda + uses: conda-incubator/setup-miniconda@v2 + id: setup_conda + if: steps.setup_mamba.outcome == 'failure' + continue-on-error: false + with: + auto-update-conda: true + python-version: ${{ steps.pyver.outputs.selected }} + channels: conda-forge,nodefaults + channel-priority: strict + activate-environment: graphblas + auto-activate-base: false - name: Update env run: | # Install dependencies based on the needs of the job. @@ -144,17 +165,17 @@ jobs: 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 + if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.8') }} == true ]]; 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", "=2.1", ""]))') - elif [[ ${{ steps.pyver.outputs.selected }} == "3.9" ]]; then + elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; 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", "=2.1", ""]))') - elif [[ ${{ steps.pyver.outputs.selected }} == "3.10" ]]; then + elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; 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", ""]))') @@ -175,8 +196,15 @@ jobs: # We can have a tight coupling with python-suitesparse-graphblas. # That is, we don't need to support versions of it that are two years old. # But, it's still useful for us to test with different versions! + psg="" if [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2", ""]))') + psg=python-suitesparse-graphblas${psgver} + elif [[ ${{ steps.sourcetype.outputs.selected}} == "wheel" ]] ; then + psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2", ""]))') + elif [[ ${{ steps.sourcetype.outputs.selected}} == "source" ]] ; then + # These should be exact versions + psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", ""]))') else psgver="" fi @@ -187,23 +215,30 @@ jobs: fi echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psgver${psgver}" - # 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 tomli pyyaml${yamlver} sparse${sparsever} \ - pandas${pdver} scipy${spver} numpy${npver} awkward${akver} networkx${nxver} numba${numbaver} fast_matrix_market${fmmver} \ + $(command -v mamba || command -v conda) install packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli \ + pyyaml${yamlver} sparse${sparsever} pandas${pdver} scipy${spver} numpy${npver} awkward${akver} \ + networkx${nxver} numba${numbaver} fast_matrix_market${fmmver} ${psg} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ ${{ matrix.slowtask == 'notebooks' && 'matplotlib nbconvert jupyter "ipython>=7"' || '' }} \ ${{ steps.sourcetype.outputs.selected == 'upstream' && 'cython' || '' }} \ ${{ steps.sourcetype.outputs.selected != 'wheel' && '"graphblas>=7.4.0"' || '' }} \ - ${{ steps.sourcetype.outputs.selected == 'conda-forge' && 'python-suitesparse-graphblas' || '' }}${psgver} \ - ${{ matrix.os != 'ubuntu-latest' && '"graphblas>=7.4.0"' || '' }} \ - ${{ steps.sourcetype.outputs.selected == 'wheel' && matrix.os != 'ubuntu-latest' && 'python-suitesparse-graphblas' || '' }} + ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'pypy' || '' }} - name: Build extension module run: | - # We only have wheels for Linux right now - if [[ ${{ steps.sourcetype.outputs.selected }} == "wheel" && ${{ matrix.os }} == "ubuntu-latest" ]]; then - pip install --no-deps suitesparse-graphblas + if [[ ${{ steps.sourcetype.outputs.selected }} == "wheel" ]]; then + # Add --pre if installing a pre-release + pip install --no-deps --only-binary ":all:" suitesparse-graphblas${psgver} + + # Add the below line to the conda install command above if installing from test.pypi.org + # ${{ steps.sourcetype.outputs.selected == 'wheel' && 'setuptools setuptools-git-versioning wheel cython' || '' }} \ + # pip install --no-deps --only-binary ":all:" --index-url https://test.pypi.org/simple/ "suitesparse-graphblas>=7.4.3" elif [[ ${{ steps.sourcetype.outputs.selected }} == "source" ]]; then - pip install --no-deps --no-binary=all suitesparse-graphblas + # Add --pre if installing a pre-release + pip install --no-deps --no-binary suitesparse-graphblas suitesparse-graphblas${psgver} + + # Add the below line to the conda install command above if installing from test.pypi.org + # ${{ steps.sourcetype.outputs.selected == 'source' && 'setuptools setuptools-git-versioning wheel cython' || '' }} \ + # pip install --no-deps --no-build-isolation --no-binary suitesparse-graphblas --index-url https://test.pypi.org/simple/ suitesparse-graphblas==7.4.3.3 elif [[ ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then pip install --no-deps git+https://github.com/GraphBLAS/python-suitesparse-graphblas.git@main#egg=suitesparse-graphblas fi @@ -235,6 +270,7 @@ jobs: if [[ $H && $normal ]] ; then if [[ $macos ]] ; then echo " $vanilla" ; elif [[ $windows ]] ; then echo " $suitesparse" ; fi ; fi)$( \ if [[ $H && $bizarro ]] ; then if [[ $macos ]] ; then echo " $suitesparse" ; elif [[ $windows ]] ; then echo " $vanilla" ; fi ; fi) echo $args + pytest -v --pyargs suitesparse_graphblas coverage run -m pytest --color=yes --randomly -v $args \ ${{ matrix.slowtask == 'pytest_normal' && '--runslow' || '' }} - name: Unit tests (bizarro scalars) @@ -300,7 +336,9 @@ jobs: coverage run -a -m graphblas.core.automethods coverage run -a -m graphblas.core.infixmethods git diff --exit-code - - name: Coverage + - name: Coverage1 + id: coverageAttempt1 + continue-on-error: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_FLAG_NAME: ${{ matrix.os }}/${{ matrix.slowtask }} @@ -309,6 +347,19 @@ jobs: coverage xml coverage report --show-missing coveralls --service=github + # Retry upload if first attempt failed. + # This happens somewhat randomly and for irregular reasons. + # Logic is a duplicate of previous step. + - name: Coverage2 + id: coverageAttempt2 + if: steps.coverageAttempt1.outcome == 'failure' + continue-on-error: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_FLAG_NAME: ${{ matrix.os }}/${{ matrix.slowtask }} + COVERALLS_PARALLEL: true + run: | + coveralls --service=github - name: codecov uses: codecov/codecov-action@v3 - name: Notebooks Execution check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8eb2bf10b..13caf89e4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -67,7 +67,7 @@ repos: # These versions need updated manually - flake8==6.0.0 - flake8-bugbear==23.3.23 - - flake8-simplify==0.19.3 + - flake8-simplify==0.20.0 - repo: https://github.com/asottile/yesqa rev: v1.4.0 hooks: diff --git a/graphblas/core/operator/base.py b/graphblas/core/operator/base.py index ef92b41a4..38a76cbcf 100644 --- a/graphblas/core/operator/base.py +++ b/graphblas/core/operator/base.py @@ -426,7 +426,7 @@ def _initialize(cls, include_in_ops=True): return # Read in the parse configs trim_from_front = cls._parse_config.get("trim_from_front", 0) - delete_exact = cls._parse_config.get("delete_exact", None) + delete_exact = cls._parse_config.get("delete_exact") num_underscores = cls._parse_config["num_underscores"] for re_str, return_prefix in [ diff --git a/graphblas/tests/test_numpyops.py b/graphblas/tests/test_numpyops.py index c528d4051..5b7e797f3 100644 --- a/graphblas/tests/test_numpyops.py +++ b/graphblas/tests/test_numpyops.py @@ -168,7 +168,10 @@ def test_npbinary(): compare_op = isclose else: np_result = getattr(np, binary_name)(np_left, np_right) - compare_op = npbinary.equal + if binary_name in {"arctan2"}: + compare_op = isclose + else: + compare_op = npbinary.equal except Exception: # pragma: no cover (debug) print(f"Error computing numpy result for {binary_name}") print(f"dtypes: ({gb_left.dtype}, {gb_right.dtype}) -> {gb_result.dtype}") @@ -184,11 +187,13 @@ def test_npbinary(): match(accum=gb.binary.lor) << gb_result.apply(npunary.isinf) compare = match.reduce(gb.monoid.land).new() if not compare: # pragma: no cover (debug) + print(compare_op) print(binary_name) print(compute(gb_left)) print(compute(gb_right)) print(compute(gb_result)) print(np_result) + print((np_result - compute(gb_result)).new().to_coo()[1]) assert compare diff --git a/graphblas/tests/test_ss_utils.py b/graphblas/tests/test_ss_utils.py index d21f41f03..12c8c6329 100644 --- a/graphblas/tests/test_ss_utils.py +++ b/graphblas/tests/test_ss_utils.py @@ -198,6 +198,11 @@ def test_about(): assert "library_name" in repr(about) +def test_openmp_enabled(): + # SuiteSparse:GraphBLAS without OpenMP enabled is very undesirable + assert gb.ss.about["openmp"] + + def test_global_config(): d = {} config = gb.ss.config diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index cdd4adf16..54b02d1f9 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -13,4 +13,5 @@ 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-bugbear[channel=conda-forge]>=23.3.23' -conda search 'flake8-simplify[channel=conda-forge]>=0.19.3' +conda search 'flake8-simplify[channel=conda-forge]>=0.20.0' +# conda search 'python[channel=conda-forge]>=3.8 *pypy*' From f023d9ca8b382e1900ccbdb9ff2f35b61809a441 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Apr 2023 21:54:20 -0500 Subject: [PATCH 13/87] Bump pypa/gh-action-pypi-publish from 1.8.3 to 1.8.5 (#422) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.3 to 1.8.5. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.3...v1.8.5) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index a8e6df44e..abf3fefa6 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.3 + uses: pypa/gh-action-pypi-publish@v1.8.5 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 7c3c22745b25c8973455da2ec51d162af9fb6595 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 16 Apr 2023 09:23:12 -0500 Subject: [PATCH 14/87] Add logo and images to README (#432) * Add logo and images to README * link to pypi --- .pre-commit-config.yaml | 5 ++++ MANIFEST.in | 5 ++++ README.md | 15 ++++++++--- docs/_static/img/logo-name-medium.svg | 37 +++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 docs/_static/img/logo-name-medium.svg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13caf89e4..4b00aee30 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,11 @@ # To run: `pre-commit run --all-files` # To update: `pre-commit autoupdate` # - &flake8_dependencies below needs updated manually +ci: + # See: https://pre-commit.ci/#configuration + autofix_prs: false + autoupdate_schedule: monthly + skip: [pylint, no-commit-to-branch] fail_fast: true default_language_version: python: python3 diff --git a/MANIFEST.in b/MANIFEST.in index f3f4b04bb..e2ff9c410 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,12 @@ recursive-include graphblas *.py +prune docs +prune scripts include setup.py include README.md include LICENSE include MANIFEST.in include graphblas/graphblas.yaml include graphblas/tests/pickle*.pkl +include docs/_static/img/logo-name-medium.svg +include docs/_static/img/draw-example.png +include docs/_static/img/repr-matrix.png diff --git a/README.md b/README.md index dab91782a..23fc3650d 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,21 @@ -# Python-graphblas +![Python-graphblas](docs/_static/img/logo-name-medium.svg) [![conda-forge](https://img.shields.io/conda/vn/conda-forge/python-graphblas.svg)](https://anaconda.org/conda-forge/python-graphblas) [![pypi](https://img.shields.io/pypi/v/python-graphblas.svg)](https://pypi.python.org/pypi/python-graphblas/) -![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-graphblas) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-graphblas)](https://pypi.python.org/pypi/python-graphblas/) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/python-graphblas/python-graphblas/blob/main/LICENSE) +
[![Tests](https://github.com/python-graphblas/python-graphblas/workflows/Tests/badge.svg?branch=main)](https://github.com/python-graphblas/python-graphblas/actions) [![Docs](https://readthedocs.org/projects/python-graphblas/badge/?version=latest)](https://python-graphblas.readthedocs.io/en/latest/) [![Coverage](https://coveralls.io/repos/python-graphblas/python-graphblas/badge.svg?branch=main)](https://coveralls.io/r/python-graphblas/python-graphblas) +
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.7328791.svg)](https://doi.org/10.5281/zenodo.7328791) [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/python-graphblas/python-graphblas/HEAD?filepath=notebooks%2FIntro%20to%20GraphBLAS%20%2B%20SSSP%20example.ipynb) [![Discord](https://img.shields.io/badge/Chat-Discord-blue)](https://discord.com/invite/vur45CbwMz) Python library for GraphBLAS: high-performance sparse linear algebra for scalable graph analytics. +For algorithms, see +[`graphblas-algorithms`](https://github.com/python-graphblas/graphblas-algorithms). - **Documentation:** [https://python-graphblas.readthedocs.io/](https://python-graphblas.readthedocs.io/) - **GraphBLAS C API:** [https://graphblas.org/docs/GraphBLAS_API_C_v2.0.0.pdf](https://graphblas.org/docs/GraphBLAS_API_C_v2.0.0.pdf) @@ -22,6 +26,11 @@ Python library for GraphBLAS: high-performance sparse linear algebra for scalabl - **Weekly community call:** [https://github.com/python-graphblas/python-graphblas/issues/247](https://github.com/python-graphblas/python-graphblas/issues/247) - **Chat via Discord:** [https://discord.com/invite/vur45CbwMz](https://discord.com/invite/vur45CbwMz) in the [#graphblas channel](https://discord.com/channels/786703927705862175/1024732940233605190) +

+ Directed graph + Adjacency matrix +

+ ## Install Install the latest version of Python-graphblas via conda: ``` @@ -39,7 +48,7 @@ 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; +- `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`. diff --git a/docs/_static/img/logo-name-medium.svg b/docs/_static/img/logo-name-medium.svg new file mode 100644 index 000000000..6dd12c307 --- /dev/null +++ b/docs/_static/img/logo-name-medium.svg @@ -0,0 +1,37 @@ + + + + + + + graphblas + python- + From 0eb490fe6dcb96264fed6637517de01c26f036cb Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 16 Apr 2023 13:56:48 -0500 Subject: [PATCH 15/87] making numba optional (#423) * make numba optional * bump pandas 2.0.0 * Don't test against pypy in CI * expand optional dependencies * networkx 3.1 * simplify/combine deps * Make suitesparse_graphblas optional (in the future); don't install numba on 3.11 yet * Ignore deprecation warning raised by awkward in Python 3.11 * try to ignore warning from sparse * drop sparse 0.12 (which is really old and annoying to support) * sre_constants also deprecated * `shouldhave` make tests a little more clear (I think) Also, bump version of `fast_matrix_market`, and randomly generate OS for import test. * Recipes for L1norm and Linfnorm aggregators * Better error message for default operators that are UDFs * Update isclose recipe * numba version depends on python and numpy versions * lazy indexunary handling and a bit more coverage * skip numba 0.57.0rc1 on Windows for now * Make PNGs smaller via https://tinypng.com --- .github/workflows/imports.yml | 37 ++- .github/workflows/test_and_build.yml | 105 ++++++-- .pre-commit-config.yaml | 4 +- README.md | 2 +- docs/_static/img/GraphBLAS-API-example.png | Bin 324997 -> 77589 bytes docs/_static/img/GraphBLAS-mapping.png | Bin 122647 -> 31687 bytes docs/_static/img/Matrix-A-strictly-upper.png | Bin 11022 -> 3259 bytes docs/_static/img/Matrix-A-upper.png | Bin 11276 -> 3335 bytes docs/_static/img/Recorder-output.png | Bin 34324 -> 9883 bytes docs/_static/img/adj-graph.png | Bin 29071 -> 7710 bytes docs/_static/img/draw-example.png | Bin 15744 -> 5317 bytes docs/_static/img/logo-name-dark.svg | 8 - docs/_static/img/logo-name-light.svg | 8 - docs/_static/img/logo-name-medium.svg | 8 - docs/_static/img/min-plus-semiring.png | Bin 6500 -> 2348 bytes docs/_static/img/plus-times-semiring.png | Bin 7306 -> 2472 bytes docs/_static/img/repr-matrix.png | Bin 13694 -> 3858 bytes docs/_static/img/repr-scalar.png | Bin 4573 -> 1339 bytes docs/_static/img/repr-vector.png | Bin 9254 -> 2921 bytes docs/_static/img/sssp-result.png | Bin 14490 -> 4558 bytes docs/getting_started/index.rst | 2 +- graphblas/__init__.py | 16 +- graphblas/binary/__init__.py | 16 ++ graphblas/binary/numpy.py | 16 +- graphblas/core/__init__.py | 9 + graphblas/core/formatting.py | 1 + graphblas/core/matrix.py | 24 +- graphblas/core/operator/agg.py | 27 +- graphblas/core/operator/base.py | 169 ++++++------ graphblas/core/operator/binary.py | 85 ++++-- graphblas/core/operator/indexunary.py | 31 ++- graphblas/core/operator/select.py | 33 ++- graphblas/core/operator/semiring.py | 18 +- graphblas/core/operator/unary.py | 16 +- graphblas/core/recorder.py | 4 +- graphblas/core/scalar.py | 19 +- graphblas/core/ss/matrix.py | 54 ++-- graphblas/core/ss/vector.py | 5 +- graphblas/core/utils.py | 1 + graphblas/core/vector.py | 66 ++++- graphblas/dtypes.py | 130 ++++++++-- graphblas/io.py | 10 +- graphblas/monoid/numpy.py | 15 +- graphblas/op/__init__.py | 10 +- graphblas/op/numpy.py | 6 +- graphblas/semiring/__init__.py | 27 ++ graphblas/semiring/numpy.py | 13 +- graphblas/tests/conftest.py | 11 +- graphblas/tests/test_core.py | 2 +- graphblas/tests/test_dtype.py | 5 +- graphblas/tests/test_formatting.py | 73 +++++- graphblas/tests/test_infix.py | 7 + graphblas/tests/test_io.py | 10 +- graphblas/tests/test_matrix.py | 145 +++++++---- graphblas/tests/test_numpyops.py | 62 +++-- graphblas/tests/test_op.py | 259 +++++++++++++------ graphblas/tests/test_operator_types.py | 6 + graphblas/tests/test_pickle.py | 6 + graphblas/tests/test_scalar.py | 22 +- graphblas/tests/test_vector.py | 59 ++++- graphblas/unary/numpy.py | 13 +- pyproject.toml | 73 ++++-- scripts/check_versions.sh | 8 +- 63 files changed, 1259 insertions(+), 497 deletions(-) diff --git a/.github/workflows/imports.yml b/.github/workflows/imports.yml index a9863a213..2b0b0ed9f 100644 --- a/.github/workflows/imports.yml +++ b/.github/workflows/imports.yml @@ -7,13 +7,24 @@ on: - main jobs: - test_imports: + rngs: runs-on: ubuntu-latest - # strategy: - # matrix: - # python-version: ["3.8", "3.9", "3.10"] + outputs: + os: ${{ steps.os.outputs.selected }} + pyver: ${{ steps.pyver.outputs.selected }} steps: - - uses: actions/checkout@v3 + - name: RNG for os + uses: ddradar/choose-random-action@v2.0.2 + id: os + with: + contents: | + ubuntu-latest + macos-latest + windows-latest + weights: | + 1 + 1 + 1 - name: RNG for Python version uses: ddradar/choose-random-action@v2.0.2 id: pyver @@ -22,14 +33,26 @@ jobs: 3.8 3.9 3.10 + 3.11 weights: | 1 1 1 + 1 + test_imports: + needs: rngs + runs-on: ${{ needs.rngs.outputs.os }} + # runs-on: ${{ matrix.os }} + # strategy: + # matrix: + # python-version: ["3.8", "3.9", "3.10", "3.11"] + # os: ["ubuntu-latest", "macos-latest", "windows-latest"] + steps: + - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: - python-version: ${{ steps.pyver.outputs.selected }} + python-version: ${{ needs.rngs.outputs.pyver }} # python-version: ${{ matrix.python-version }} - run: python -m pip install --upgrade pip - - run: pip install -e . + - run: pip install -e .[default] - run: ./scripts/test_imports.sh diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 04ffd3eb5..6aa692155 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -102,17 +102,19 @@ jobs: id: pyver with: # We should support major Python versions for at least 36-42 months - # We could probably support pypy if numba were optional + # We may be able to support pypy if anybody asks for it # 3.8.16 0_73_pypy # 3.9.16 0_73_pypy contents: | 3.8 3.9 3.10 + 3.11 weights: | 1 1 1 + 1 - name: RNG for source of python-suitesparse-graphblas uses: ddradar/choose-random-action@v2.0.2 id: sourcetype @@ -138,8 +140,8 @@ jobs: miniforge-version: latest use-mamba: true python-version: ${{ steps.pyver.outputs.selected }} - channels: conda-forge,nodefaults - channel-priority: strict + channels: conda-forge,${{ contains(steps.pyver.outputs.selected, 'pypy') && 'defaults' || 'nodefaults' }} + channel-priority: ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'flexible' || 'strict' }} activate-environment: graphblas auto-activate-base: false - name: Setup conda @@ -150,8 +152,8 @@ jobs: with: auto-update-conda: true python-version: ${{ steps.pyver.outputs.selected }} - channels: conda-forge,nodefaults - channel-priority: strict + channels: conda-forge,${{ contains(steps.pyver.outputs.selected, 'pypy') && 'defaults' || 'nodefaults' }} + channel-priority: ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'flexible' || 'strict' }} activate-environment: graphblas auto-activate-base: false - name: Update env @@ -161,29 +163,29 @@ jobs: # # First let's randomly get versions of dependencies to install. # Consider removing old versions when they become problematic or very old (>=2 years). - nxver=$(python -c 'import random ; print(random.choice(["=2.7", "=2.8", "=3.0", ""]))') + nxver=$(python -c 'import random ; print(random.choice(["=2.7", "=2.8", "=3.0", "=3.1", ""]))') 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", ""]))') + sparsever=$(python -c 'import random ; print(random.choice(["=0.13", "=0.14", ""]))') + fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", ""]))') if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.8') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') 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", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') 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", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') 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", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=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", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", ""]))') - pdver=$(python -c 'import random ; print(random.choice(["=1.5", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", ""]))') 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 @@ -208,16 +210,54 @@ jobs: else psgver="" fi - if [[ $npver == "=1.21" ]] ; then - numbaver=$(python -c 'import random ; print(random.choice(["=0.55", "=0.56", ""]))') + # TODO: drop 0.57.0rc1 and use 0.57 once numba 0.57 is properly released + if [[ ${npver} == "=1.24" || ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]] ; then + numbaver=$(python -c 'import random ; print(random.choice(["=0.57.0rc1", ""]))') + elif [[ ${npver} == "=1.21" ]] ; then + numbaver=$(python -c 'import random ; print(random.choice(["=0.55", "=0.56", "=0.57.0rc1", ""]))') + else + numbaver=$(python -c 'import random ; print(random.choice(["=0.56", "=0.57.0rc1", ""]))') + fi + if [[ ${{ matrix.os == 'windows-latest' }} == true && ( ${npver} == "=1.24" || ${numbaver} == "=0.57.0rc1" ) ]] ; then + # TODO: numba 0.57.0rc1 currently crashes sometimes on windows, so skip it for now + npver="" + numbaver="" + fi + fmm=fast_matrix_market${fmmver} + awkward=awkward${akver} + if [[ ${{ contains(steps.pyver.outputs.selected, 'pypy') || + startsWith(steps.pyver.outputs.selected, '3.12') }} == true || + ( ${{ matrix.slowtask != 'notebooks'}} == true && ( + ( ${{ matrix.os == 'windows-latest' }} == true && $(python -c 'import random ; print(random.random() < .2)') == True ) || + ( ${{ matrix.os == 'windows-latest' }} == false && $(python -c 'import random ; print(random.random() < .4)') == True ))) ]] + then + # Some packages aren't available for pypy or Python 3.12; randomly otherwise (if not running notebooks) + echo "skipping numba" + numba="" + numbaver=NA + sparse="" + sparsever=NA + if [[ ${{ contains(steps.pyver.outputs.selected, 'pypy') }} ]]; then + awkward="" + akver=NA + fmm="" + fmmver=NA + # Be more flexible until we determine what versions are supported by pypy + npver="" + spver="" + pdver="" + yamlver="" + fi else - numbaver=$(python -c 'import random ; print(random.choice(["=0.56", ""]))') + numba=numba${numbaver} + sparse=sparse${sparsever} fi echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psgver${psgver}" - $(command -v mamba || command -v conda) install packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli \ - pyyaml${yamlver} sparse${sparsever} pandas${pdver} scipy${spver} numpy${npver} awkward${akver} \ - networkx${nxver} numba${numbaver} fast_matrix_market${fmmver} ${psg} \ + # TODO: remove `-c numba` when numba 0.57 is properly released + $(command -v mamba || command -v conda) install -c numba packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli \ + pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \ + networkx${nxver} ${numba} ${fmm} ${psg} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ ${{ matrix.slowtask == 'notebooks' && 'matplotlib nbconvert jupyter "ipython>=7"' || '' }} \ ${{ steps.sourcetype.outputs.selected == 'upstream' && 'cython' || '' }} \ @@ -269,9 +309,9 @@ jobs: if [[ $G && $bizarro ]] ; then if [[ $ubuntu ]] ; then echo " $suitesparse" ; elif [[ $windows ]] ; then echo " $vanilla" ; fi ; fi)$( \ if [[ $H && $normal ]] ; then if [[ $macos ]] ; then echo " $vanilla" ; elif [[ $windows ]] ; then echo " $suitesparse" ; fi ; fi)$( \ if [[ $H && $bizarro ]] ; then if [[ $macos ]] ; then echo " $suitesparse" ; elif [[ $windows ]] ; then echo " $vanilla" ; fi ; fi) - echo $args + echo ${args} pytest -v --pyargs suitesparse_graphblas - coverage run -m pytest --color=yes --randomly -v $args \ + coverage run -m pytest --color=yes --randomly -v ${args} \ ${{ matrix.slowtask == 'pytest_normal' && '--runslow' || '' }} - name: Unit tests (bizarro scalars) run: | @@ -305,8 +345,8 @@ jobs: if [[ $G && $bizarro ]] ; then if [[ $ubuntu ]] ; then echo " $vanilla" ; elif [[ $windows ]] ; then echo " $suitesparse" ; fi ; fi)$( \ if [[ $H && $normal ]] ; then if [[ $macos ]] ; then echo " $suitesparse" ; elif [[ $windows ]] ; then echo " $vanilla" ; fi ; fi)$( \ if [[ $H && $bizarro ]] ; then if [[ $macos ]] ; then echo " $vanilla" ; elif [[ $windows ]] ; then echo " $suitesparse" ; fi ; fi) - echo $args - coverage run -a -m pytest --color=yes --randomly -v $args \ + echo ${args} + coverage run -a -m pytest --color=yes --randomly -v ${args} \ ${{ matrix.slowtask == 'pytest_bizarro' && '--runslow' || '' }} git checkout . # Undo changes to scalar default - name: Miscellaneous tests @@ -329,6 +369,13 @@ jobs: # TODO: understand why these are order-dependent and try to fix coverage run -a -m pytest --color=yes -x --no-mapnumpy --runslow -k test_binaryop_attributes_numpy graphblas/tests/test_op.py # coverage run -a -m pytest --color=yes -x --no-mapnumpy -k test_npmonoid graphblas/tests/test_numpyops.py --runslow + - name: More tests for coverage + if: matrix.slowtask == 'notebooks' && matrix.os == 'windows-latest' + run: | + # We use 'notebooks' slow task b/c it should have numba installed + coverage run -a -m pytest --color=yes --runslow --no-mapnumpy -p no:randomly -v -k 'test_commutes or test_bool_doesnt_get_too_large or test_npbinary or test_npmonoid or test_npsemiring' + coverage run -a -m pytest --color=yes --runslow --mapnumpy -p no:randomly -k 'test_bool_doesnt_get_too_large or test_npunary or test_binaryop_monoid_numpy' + coverage run -a -m pytest --color=yes -x --no-mapnumpy --runslow -k test_binaryop_attributes_numpy graphblas/tests/test_op.py - name: Auto-generated code check if: matrix.slowtask == 'pytest_bizarro' run: | @@ -364,7 +411,11 @@ jobs: uses: codecov/codecov-action@v3 - name: Notebooks Execution check if: matrix.slowtask == 'notebooks' - run: jupyter nbconvert --to notebook --execute notebooks/*ipynb + run: | + # Run notebooks only if numba is installed + if python -c 'import numba' 2> /dev/null ; then + jupyter nbconvert --to notebook --execute notebooks/*ipynb + fi finish: needs: build_and_test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4b00aee30..426153fee 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,7 +58,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.260 + rev: v0.0.261 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -86,7 +86,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.260 + rev: v0.0.261 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/README.md b/README.md index 23fc3650d..f07fdea12 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ $ conda install -c conda-forge python-graphblas ``` or pip: ``` -$ pip install python-graphblas +$ pip install python-graphblas[default] ``` This will also install the [SuiteSparse:GraphBLAS](https://github.com/DrTimothyAldenDavis/GraphBLAS) compiled C library. diff --git a/docs/_static/img/GraphBLAS-API-example.png b/docs/_static/img/GraphBLAS-API-example.png index c6dd4818280a1ca9e425cd1d76e1efda7cc8407a..1edc9198890db10ce401055812291d9dd263ac2f 100644 GIT binary patch literal 77589 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

hcKp#aH% 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

>O&$l)WpA+)et>-;& zHZS^2zVXoYmyzw95OP-<0*kDhG}1kLF7nUymkB>e)&o7(6s?)(?tVttj#qxaelh

7tf5x2wmd;X>KW5;9BQy$E?ne<=Sf2 zNipehmoIy5b5dt0eb_I-)>n{RNZH zZZ-X=K={e{Q@cIZxz60>z~T;swTKh$bk-fXQN#rY38uz5*c^BY7{{FAq9n0NEadh06g-*8bX2+*(c=5Pw=`zP9w zjw+Zq6xlz?weudA=`j3IMvz0#dcv5;fErLXlI`U=#;|DGU$x=S5TMh)E5L;@pk1Ie z{8uTtO*a-$5&OGfyd?$3)N9g}mU%p8RLFfHdMg9>u|55m>9KBc@7IFXY zqVxZ*iT_#T>2*eG8G-m8!L~B&zanU-uQOPrz*cqx2Tj!&X?Ku;krQ=TbB{s_814#s2)NbeCrzs zSPZ&`8ihrbt9wI2gptt8&}!G>PIuwCqmh-iPZ3QtX9e9GzxX=aGY-^O!2d{-LlF!p z40%+K5y0Eqec@gFzB{fRa4v}yooaX6AVHxiCNsOT$%V~

@>tZ?^?s;Tm5WEoIWa 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 literal 324997 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� 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Ξjs|#*=8PhLS~VCn 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 ZPpL{M=HaR*+?`02<<;f#FZ^=*e*rVJgO>mR diff --git a/docs/_static/img/GraphBLAS-mapping.png b/docs/_static/img/GraphBLAS-mapping.png index 7ef73c88deaa7fe13a8eddab4762a9d7f13b5b02..c5d1a1d4ededb9da6dac9ab9904e206c308681b4 100644 GIT binary patch literal 31687 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

f-7NGXWBMHfhV(kWorMV%SchdAQVr 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≺qpGYF+(L|OSQQh=9KXp-~V zaWAL_!7q8oKXd^RcFJeFWzR_i^ z*T6Hx$H;F;nkz(&u&xtrf;dfa6%!5XdBOkM{-S6|4wJ9#717VtM(v3$l= zlnQsvAJ3V7TO&dCDyRTYB*Sqk9)&VdjiL!S6$J_#GhxtIzPBsk;%@OcypS^-=@K%T zjIHT>;qv33AuzieU0(L~_VN?Aehb9<#{ys;#$oh4eP_w&$w8G`l=x&lN;2t(!RIgl z%ksk%VVnT9{&@fp!2MrEQtDXXZ@o&wZ6+i6I~K3QdYK8}gQFnFt3FB7%@7DF^Iv_k z6Cw|HKt{GVcZ3k(_boG{-BQWtI*HLMbkk9#P3i;c zLw_K?JFw^n=NA%krqK3@Byg*fyrR*C@{pQm>TvVzt303xPJszV13oh zbP4trFoW0m!XU3#VtgGDE4MG9@e@)>cTP?bzg>_9Z+1lYm&vz= zKf=0gezm99#^OuP?Aw^3oN(oI5tO~QnkfK#xZJGCD#>8A+PSlEnd%=Ef|22IzXB4C zLQ|2{>hffLc{qnz=Nd*>eZ1UMyGdhmczbeSv0jqD(=URbKAkN^Sg6zp$`=mX>A37x zLRF|#XMB3C@atqRmOcO&5fz~Unn7_`)#L!#!1j3$znFH`OPGWQ4r>(qz#Nw_jpw(^ zvu6~!UvrPz0AX2-dw=hdU6T0UVeKokvh#)KUDELWuMEy zc%E;xlM-1k`xOkr&JsI(5*5yCY@hPw_ZKSbqk353N4a0W^v}!G@!7Fr&_XOTex{Nk zfTA{%EaR5x(;hCUf}%d~@&$Q>HDw1r?a{^-)AENE`YiAV{niyNkvj$_9InDjwvCdr zypvP#x5ZOKr>{J3Wf<_B>~s%!S`v{yX$IdWtQ-AH(`zATRXd<%WGws?TkEODdruF5 zE{UpsGQ^Ml@DXkjyy&D$dv)WVWpCcedI3ST&GLz^;a@7BYuD*HIbv%Bs1TzfCwNoj zpDVb&#hrQp*m`(@Z($p_>!@4)4s%+~w@lp0&}jHzk|*2^fd2Zj$KP_bThtZlF_A*b zU~H;o!bc3);S4%9%^1lC7o;v%#=7|I;~O_A$Jp4z9;TbPG}(E$@7V#+%lLq)Kk{n9 zDYstb@}tGA7i(V+jJ|Fqn&@m1i0#)c6k!JwoG#UyncU&cQ;52_+`8|(As14ceB9?6 zLS)so`ol`b_r#1SS8KPS6qbD6&C0ny>e$qNG~28K_A*4&VgQ(_ECDqLs&ULgO;@0w zEO;4J>d-H!ic(EG0+oX=dXrTEbCqMxW0vRK0v@FP`Ms2n&Tun_*W<;&`D)Ac-U#v+ z2nb>lk}m*tF%(vN?p>`ym3D6|mEHwOgU#=75*LTt6&4U3qvXOyb&86I2d}24HV>)b zm_+ehqx?hBe5H}naep+~#AQUfkCBmasm+Nz7KgI{`b#pgb~1$m2&?6s{Ci^V)Y&=h zV^^Nf>%&r=@GG}@2sEnw^Nq>U^0LVRL#69#Dw#ZC*9;2I08HUb<3vHe%U~c1i`mm; ztVwIP>gs9@K*m4S>VdQL&N~-XaLYE;eBeWJcjU&yU5@6MvGVm!Y7U4XlRN2oreYUL z24+^GiS>2FXaCFy%S-eU6~1{wIdwMUiA)g)Y2jnG`T`{dK{fpHCHoWItb0Z*_;f)* zmi;dq{)#S%y%-2;)~#p$vf3*w8@OzA%+C*niFgd$#Eg-nPR;=6sl-G?$K9e6y``## zg8W-iq%1_!#(6^WzqB`5@V&WxSUMM>ErN6#cGOX*v%iPw zEV$a*OvrskF#8?!;2+*64R zhzD#pio+56IdSSv*R8*TP6bnT35#YA@Zn0O1S%c>*68*#y7$Awj{0&fKzQB9OA@h_ zy|)GU=Q%flMiB}2R0`0sL`UMjDIN*{#~{5Vm(}BK>L>7<_YH&zIo`$dwQy#-J$hGz z7Sp?@5&1&48iXwRgdS2;}Nppcn=v zGgug4hibxCi0rq?A9xu@uga$PcEW>#bVyE4;sdaXw{E;2Dt!qn2i45VTDyuecR$_H zUulbwjUHHC4I?BJ1Q_JP<-llc`6LGJ>!ShceS;oCN0`H>^v~PF6MY$kM-cUy3n`A} zOnjbk0kHFm2`muQ%I&ok%e+9_u6>Q{C1;`&^s*w}K2;V}KmZzH#~^{i+N|+t@)+)z z-aB}X>;(zlNZgZT{T zCK#i=f>=+-u2#_-W)(m%qlkEaUumto>p?~v5W%ulyY*a^1Tng+KnPd*>$_-5a(jLQ zj!AE+z3qgp^+QcJKnh`ChOqAMvO4icdt7T%hJ$fxOLMga#62?P-`a)8Du0S8cdHV5 zcd9!dk^UZIDhRZ$eFf+g<0t`w5)xA?LpeR+kH+OZG zdl?t{ z`@I_)KnN2Dh?HxO)~xadBTLyFpPRh<^`GkAeAM)Or5aYyepoS6o>fX>71zVN(ew+C zp;{M7Q+j|1P`dcx}}trlfC*L}N{`d_@yHO3o{!Q)8o>sYRmpb*lyrNqQUWL=Ma)Xw__t)sOzX4|#a0DwVqdo-E4d@&x@-TQt% zI*yIslGK$ZtI~E|m)rF&FZ5i$kGArz?*}jQIyLDjz%T&UFZ=1staZiZ zWlhgV`N@-t+jn=FQRMS!Ueui!Dx(zdbBw<%w*K)!2{$w$^m#J(?J zrs_U+O}>5>DCv}O@`&#@Oxbixomz258T*CWeEHK=X)7TJ3f{#g308nb82C&}_C-C2 zi)LOur0ej0J}!v7Ti1oNDuDjr>}RK4D|uc9KD|)DNtJVj0rD&+6u?lU>M@N@@w+H& z!>F^v3#|H`OZ<|{2u21_)%CRbWJHIDzY-Yy=~u_9T0(j17{Q+QwFX{fX5oD+joJv` zH1X?>{aKW4c&pz~l8z>gT!`ECDgeXd^3!{hm&>QfE->C504C>%wnbZeQJRzIoHB zeadHW)mU!yA-R%L_4@oYI|kP}THhEC8QIxZnZrLtY%v8xOq+bIiym~l4Hmw2y~1IH zhJW_H($>EGe(E>mw0-h1m}!p)T%96$06+v#{E-fKy!(Frl?wYEgE9g)0s=>iIaulm zE3C=3BMfwB&m|0xucwYmL3TE830r0~7M}@iD};+ez|0hbSUty^*~UF+t{{kv$1VEY2(IbW81cE zyRqHaw(YyVbIlnDd*IBDiWxwFXbE6FOK9Xwdih@DVIo=2dZWCmZ3fv2ZzTHz> zYm20^bGjN`v?Yx-lLEPA>OqzjhX%z!9sk=fT|E$|(}5lloz!k*H&{K^Sq4nZGt&Pt z6qRx=IXW8g%xVqcdKyelR%1XD@FLZ#s5+ zne8FckdQo)F94BK8UHfWRu;pg%7&4}>c*NcY;-e388w^h)%H&-a_Y^%J>TM-7B>t> zdb68=S`D#R!$q<%DMM2debsjYY5(CKf5m3Qtw8O%O!p+<2hi)C3*epf4w z?YjuTe07f%B4f>je^xLSGLjT0dt`ym?`xtM3x_aQ$Pi#z@FBRD#bADW`e_36v#X#r zAMU8hae3;>hEESr{ayW}SwOiqe<0rcie1Ni`u6ATJNt}Ds7ZGM0{|sp#+2qb!arBG ztz4NE6Z%>2XRey@fxL^TB~2c84P}z@bw1#zPyWbV9az|aW)YMrKJUI-G0YW?ggVDv z`_<$YS>>Irs|2PQjpUcSn5izm7DBpaOQptk&*kR_0~04Fd+hQ_j%zwLHxm#B*iZkSqa>1bC-dQY`w^WB*R7h2!0GgL1cX{I&Un?*=pMZH9r_YOFU3-GOo(!nj_%D|6rpsF-{Z&V8j(T zV+epxX8}q;ESN4eF6ztV&F`xF4FNDBFttOjeAC%PH8~0c;N<`~gxCx4|90h>8O8gv zB?WZ1UyVqt8e^}cj=SU zeZ^$(7MR@*8#icRwusU+wd-o5mG@#*WTRN{W=dQe-&D+)TKT^8#~UId0v^8aGmajv znzK9!q+2g1%p^4g2R=5JYrLm z?^W>1B#UI#Rcw>ttfyrV5Uhko$Oy2zHpm{iI=D(y3=Ro^jb|;7Sw4M!HnqOrVWv6M z8Rzy@O-9(=O_O7n$8=`n$j?HM4+Mi5eht&JswS_O9{2~09HcS!ubDjr6SqulKR342 zdvKa288ja;C7wR}ZCY1etc&xtnW=btD6!TmiX)CwIboYrNwIKiBB&*+l>w~6GcZc1 z4UR)uPcoUX{1oCRnzY!paM-KP(CAk zpvRlpD~05kevvYhHDs3mst;Txj z{g(*5nwZ%eeT6X^vi7NaF2 zLYO3VI1{LDRmn|YG{#SDO@o3#|Gq2caANY&nFtAHKU$1FC&G|j(42(BG zCAXyIJ$WVg!C<>ih0}vx1^tq~a+aQL(n%TcoR@=bRN-JT67Ig@Q}9Ocgan)gWf{R> zV!QLzmV=1?r6u56TD`2Og7s#^S9MNAzNSO4*gN|9KKEje@ffisXVN@-!WMN?l^Z>_&@ zT2D4~V^**3~96)*?fe|ZAp;r`&Mq?4X|A9{o5?~z9_X0>QN>jj7%q;53M02sN6vm&vx-8LZCHj}C?FVY8RF?W9K@S*+~c}{v9 zDxvK-{m>m11eYl@^Wv)SH2~aQN!ejK%~oN62(fsj?p>}DfG5XpgJ3a*UG#^H`9OfR z#ZCmwCMMfQn`>rzt0Fr60J|{Xw{shMCD&ztB(8jG;NZ#AG2SYY#uFu_0NXsi2;h_v z_v(?W;jE-(RUs=a9!k-{&x2WsC<>V@i^ zk`6{6nzN04M*ueDr4Lc0nJ+GCVqOM7vArHruHNeBr?7D0qJBb$K+^kLWe3B{`2x#aSngX}aweL8(gCq+D?C`3M8{LppI< z3HH{m4^J?>H(dK)#Mjrf(YLm;feJ+w;!ijCx5Sy5ciFSNuj(FX^xvQSi;&z%E-=x& zUZUt}muvCM5vA6!L|t+JL$QXQ%rM}F5*k9BT@wu3Zzd+MsdOm@S#(lI%>h8XS=*>v z1R!Qr@JzQ)^nP^wG!8c@o+`vFCGuSRI{M(wr!=|a=gnyMNtI48d+2E`YI9u_ z@Ppk|5<*(p*6`%X4^Cs?Z!jW4Wq(mlF^*=2T*P2Zy7TZC+?B(_oa*1zDQnpozKmNL z(DYvL`W8Qr2jy(S7cp&wy<~JLe%{FpPR9M%29{AJr2POyxp;%_gMOT}Q^1jHV+(cr zOMbps&i;6Yc!A><^aUV~mGVWQ7dr8j<@n8GrNsTlA+cEy6J9`wQHMx=FNuHPa)*b= z%kLUQW)J}SFO6T?NL3_`S%^xvhL|s zy(m{9LteBCr1+t+-0B{LqB>}!zm*Slej}k97@lSom>-sf!FW5;E+5n39v`Ez)O}Lt zaC=PK@_PP54yt|#e4S?Ih}HZF$S1(IjWjC#T&Y`E1m^7VIa=Jhk6VZ$?E>5=p$1|0 zM0YRLFYwIabrHuzwf~j6(cCfW08h#_yzA#+Ilux3M1qk31oIB6ACnO>g@1?f*fA9P z>fBt;s+%jJdgiNA5`YPy8HO2b`X}pHXf3d1>pI> zDpS<}_b-!L{7>B>+fkQXs~59m+3s)zcm5yij~pF90>Py9@t;U_rn!nER+eKEpXsMy zy2tp|%vdsggiec1+2TUA9xkOqxKeRir8rRq2)epWM-cqirb_f(2sFGRQ%(iTbQmUO zEueQ8fhgYD+3|3t@##tK$r^vC@9pT`KROBlMj0U&2&x2fP;|gLR*9qcSMg*2(^$kE z+c)=wO_5=>eSThr-QSP6{~zvnGy?kt zta1&j89x=3B)dgGUH$%xgQKGgxE2bmq7K^u9+D?m^c!47JJw6{U+WeU*ymeG?t;rLu$EC2NNTPfu^gVTXBnJXjeb z9yl13{C;9w1p)5X$-kSTo-DIOZ3`q?l(UjkL&~Ng=$*7n!W(rxg+F$U8aznvSA)>T zWwWFh6^Xe=2qA}?IGbAGj%b%W$)uYJtrj0$UFiBBnv%oZ*e!>5McgxrO15Li-L4p$ zZ&xQ}8J}=CWguvDwfV3SKW90#=g#{RdoF__@h~h z#{=F`@}wNhM*xdYsVP1_zM3ESZA3>NSxET&#fO+T^*k8Gi1nt&8U6d?nr-=Hg<3_h z<56Q28eG!o!7KQ#?Uwj{%6#0Pv-A-n5RCrpa@>itaR$#vpAUMsPEN3B{ugA=LpGA( zm~Yt-=8heqzHy>Af8|{FzcCx8+G zt5A@rR|&5F;X@P{Fw^RASlIs8NS-OW=O)1nR%-N1U^&e4s_nTi>w5b;%GJ8~#A(j; z`)yg`cg*a~>rYG0^SgcKA{g;IG6-IA^CykLYZ)c#J7|fJ#hTu>jS9PaS{MoDKY3k$ z_(F+-M3ML7s7_OeK=5`KE17tuMSBgeSCU@@P2>hApjAUVJiwIQG-XY9zt%$d_oY$F{o~0VJeZ<#{X7V4EjkUEIGbdiwl;QxI+xSjDou zuS84*6cXr`FsdU{j&CP7gRO2~p)ke2PMWa{3VJX`M^yjAR0tXSRk_;Lyih;Jotffw zx@gqb*WTVAvRTrn&H+m%vu&7D%lsr!Rr?3K;<~~=zglOR=7BW?gFV7-s!YuzJ22P=sJG#cKU!rx>CEfzu?~(Ip_HkG&#yU_)2|ZKA-o z{8u(w@CHoKX^5&n>MwBAi#Y>IZn0f`lL76--uJl}y&koB3zD5^5 zDA>gIrqJ*A_wy&9G*y6Mdh;pSrx}ydr2<#6>(k#hv+=*^+hUX2Y%)- zs{F(Tlac>vT7a zx#~2k69ivL+Xgg{^Z#lT^MuPkRLfPR78EsH*r<3w65(S2sZltsM)mgfPJcN>XS;Kv zbCM8x{bfDo;{*j)y6^cilXHL>mvCRMP=IOy(ufkuzJNPpqH|pEe=-N~A0sYcX4BzY zR1ah;v9EyI*JClYTe6wwKf+)?6s=vVeROmG;3erv2V0al0q#E5IiYJ}oCCGqI~@Vf zxn(J<+x?D0I)$OCV}zpQKR@W7_qv^vh(Mb6j zK;r}D!jY7h;o?^mdR*)|)0;JCI4}b};7z_@2Pxu)Q#x#LB++6!ju%3&>s<{gTv5uo z_lRu~%@sDZB71kO0(|4>lA((niu10W;HS-^lZcpSIwlX}k>E6~=(i&44L`}k5b1*B zZT3!TJ235?EEq+!cF6d7S9v-@S;Y2)`KY@9i1p(FTN!map^-530WIM~Onz6ycsxi# zEM_#CA_e_s8z)-!B>TWwMvvI5m!#} hbzB6zDSkkla5k_xrJRFg$M~nZ-h@LoTBb@Dr;o^Wq;Py79bu3m^ z{jbf4m5#WsP{zrs!Pu$jHeD6t`ew%rDi8k_@*cF*Gys-+V$_|1Xc0_#1U3} zJS8E_HCH}mWFJqaSAmBu)9SI^;%Bqn)Md@st$8OgXamxEoq}H<&MFAPM?$#LRf}b@ zi%PIw{t72g8)N&;OjDBDH)J#N?>1f5xq<6iEXdat7~g1JYGK}xjx_v58)s*A5gYHb zT)3+J`s&A-yd980L|#?1DuFgf`i{UKGWA9s?}#vTi@sRy?;k~Zk=}oc%qCn#f7P$ zZ^(RCGQ2&md?t@pK#@TY!$h3v+ww$(@+q!LaWk;<*yG#B=9BM;RAH3Oy_%X04OQ(y z&`CTCjj8Cq>3g&;!>?*>hR0{n$-#M6<)DK_l>ZQ{FFE3{1#8S_#-z%xr-y%w)g*vlJ=aN3TSXVBVEiFH9Zy0k_O0elL!mPop1w-k=DEh+#i% zYv&3`ZX^@vY5C8Z5P%d5)i;}^7Myl3O#z+LxU{Ux&kJ6T(5X=4Ie69Ox?4Dou%jRC z7gPfhZfsZT&^7lJ2#m9iwKgz-|G#L@b#I6M!ps}Hl^FDgtW+|Bs$`@>U+1qieD1MBkOP$v^#TDJ1@WW$Y!-OD4e)^FFpq9e^bov~T2Y8V&Sm3?4gTc7EGo}&^*>1x#g6HJ z=gn(-tgg#*hXvRNnH=Xc@_&^s*I`o&gGof93!$X5Imzp&T51ckR=mSQqxEopID;eF zh5#}_C|=l5q=4sDQd@7=o9^=Ckp2Js}yg?ykVe#Op-@rmK!%9yaO@L(V<3%`{apeFrqcIk!J98vNO)#b7Y->k-uDtifugV zBgq=xm}%(q<4whQ+$z`Ks)T5|1TJ!h44aABXb5hh6sowfDb^32h05gdd<8YK~5zP0FexBwC-v-Uwv&kr_8qL?D1;b=xS>h%y(!Iq{% zU&^y%=9bdZMHsmM3)t_j>D0cvOREij@SUmP*SRz+7HBA1U-K^bJIY9qY>zNpjH#-S z^T3P#9mNkt=6gDoIRPq*H3J=?rIQnE+OWQ;pZjx?ujA(XNYbzbnB=~Y@}J5dJ;b5V zoe|d^oF*IvWsAq;BL+=Dv%F;*{Jfn?k>%IqyYO%s$=p7sM<%htNv=nI3mvgiVk>=? z8U!N&U!Q-Ywt7&GXx9t(q_akW7_$2FwYBcy$*71MUM2j891ow@Lp@Q%O*ylO>}r*$ zdX$%A3Iw_HN?X)`@pTvs@rS3Q{svK#0QX80vFjqx*86#~0e*On3h6cn&%OpHbkS!6 zG2PI|kb`@e8!BbIYJP7(q6HDg91Kc!DyI!!!}8k)skzzF($f1RE9ZW3oOsnZ2DDwI zuj%(ZM2G8@yzM+(O$s7HGJ{_EYPV<@%I|KZLIQiCM;7K^ePCU038c(cYt3>^^5TR! z{9fFH=I~_&;U=;EaOywnBYigo!I???kILak)XEb#vj*N>k(5XwinYu%>Pb391AII+ zme|hr{jaZ5h`iGtd)+s!A$K%q8lUuw1aG)hASlz~2ucG_y zCcXq7r$BhaD`5K#*7?j)*73tT(CL+_5a`ip(LPCnb`gRwTJi@sbnwDEI>20G1bK+_R0Vr?VW*OkK^UJmtlWLPP=4ZLk_WAk3Z4vA6QR^qC6rY&wk} zejgx^4*r;d$Prc(M1b{&VGc=yT-@^TZ^Kd{fDK?;50>+n*rOO@;Yu>a$=%W^*Dqd6 zJPuuKvn*NpoNOYMQ~c$=F^c7E!(Z+F_&j+voPh0#K(&EN4sv%ZM%`>o#hw1p^>j!P zZb~17Y~U<6E3V_mB?JyaL6L3n7tYZZw!^{Yv9FKY=kJo()3{MyT@)D{5W9iRH0MJY zk$uuZQyMVD%-89==YEFbR9(Zk5ixqc{lAM0sd|M2p@?k1bU)$ zeQ_wvdnH{{vqHfZ)AArc`1Rnl%-W)+BJqm~i;XC+sQ%T@)*nACakXu+AMoF|nDQn& zUs?Yh$~~P>il>70KRBVyXM0uWeY$w5 zDqVJwV7u{QLgC{VOe%eHo>j!Ma_uTkYk5V#+_0p*7^3_5+m)7-8d`A{yS{meo+sX} zgvNYnk=(ur$Q`T=mjb4}v8dZK-Iyko=)IXCTwxt+-_aBpnpkD^TE~-Tmjl;(3@@5o zb=t#g*}xH?C%pwhJ?tE=n5EwIWC_nJvpYBh-wOL7Lu0GP7PEy9H5iSG<)LE#;5^mF zSx5&G7BdnQ87L!{e4G4WoXC>fue!XZBA@ZkmH)=RHd@k^3eKWI4UMMH6UsBp<)B*n z^Y++U7jW3VGSpj+f~x~6=;WY3^2Y6MwnS-MI@E>ibFODm8Lk)1oZANkEc{sZ8#*ciU^YBap;@%TF(9mIw#(C%*CcM+ow(p0 z5U@DLAw08C>u`&+^pSf)pm5vN!K+qyvI65-fy1wtY4aTPir*;PGIAV3kKD_?!5CZyR?%@D5aOZtfq_``rHmFk}Y?Uw>UpciE9lT+B|N z)F68~xJ5%AmtCR9pFFnbfarOjZs#tdp%>#g3 z@T4lH);T#Rp0h6nBGaucAeAb14#VN$k3_<#c9WG)rNUj7%d2B05xg`tPlbZGL?e`P z9DI}i+FT`5B&KG-X<3XM9G~~RW@aD&t8Qf_{V|=}dHzlflY=+7gC1{S%LdkdXj6&h zcbzc?Nmf2AdZH`WL2NtEpFRk22PP^i&=azJjVT!(v%9*r@k7oGE&i|JL2O&Tn-Le! zqr>JP?Kd0j`d+?(`w)pm?~7X)3cfk+L3@wL!-$}ZDib4ToZ<{z)!Zx!aS4+5P4IPX z_UK-Qv6QI3KsM9~Sm|B_k=QD6o;(WbM^J{z7woI*EYz`ka(_z>5y__J@VgcLS5O%* zqY_g+mOE~OhkJy8c$e+o$085cgYEM`;jCd2nKav<62#yuF}W&6-6l-4_+1}V+%jPX zhkpeOw5s($-tf4&b18Tz(FD-gd5&y3k+`}qOSL8rs(@R@ z&dVl8A>sBxni$;vr)}Aq<*pb_y@G9-(M=5V5ue^JWNs~d+1{zibE%u0f1~rS+L}f+ z=oj{CPe6}BI~KGf#q2!$fg+soO?n3!!{CJNdC7WM=3I#%XWh*h(OJpFTXXsTq%usw*gKd4rR#!WLy;UfI=}a8sH3gT-rt7#A9vSK z9jYinZCp(K>q=@Nxv4$`d31dLwHl|$u9(m0;hGx95KG&uz@9WtX8}yxrv4b|$X>*A zUU|bg2XPP(6h3ceMHDN9cZK>Jy(6Ibj_ zUIf~WP#efl5kgmM=x@qms4PzJ4lwz1Z()bs1$J33c9B+&Kye6^gcLM0*G|n zO7Ee+G5Vg6Fi+kZ8U}Q~mP$Jcyh$yvRhddx*({MpJLf(Z&68?-^i@vtB*jjqp@P2C zB`SiW^LFYXWeyXzFqR59((1GGa3j3bRDKdjfS=m-qP_WvY_9@cYr0 zu+zx;*w zS!djQ67;PnshX~mMUfqt;tewsMWF{c_RM0lM#tBy-lhbFe>k3QTm`SLK{x1k5H+;g z5!#Sk1uCG6P&CVPUemxC9{Gn>wtAc+AHpruHfDS6OZ?!b-Xz@Qy@P`dK1b!}{O=GA zT4;k0yT-81$*a=0W0hq2x226sPD zXLkXVvCKy+TQgmvXJGu8@jI+y;fnYv`Sh~cyjljFSFVim5rr%!{3X!4eSdMB$mYv%^v1!! zD6YVUgvICcP&Ml^1)scnyjUYb@&{J9a^Z{iqJ618jw);F5v? zlWD9HM|`^fruaG2xg06HUk)&v7}lDtK{xv66AC>QiFZFuG709zkJ1+m^P=(fl!yNc z9mz+PwmwZ;t#4FrXi6^GP3h}X{rgSFl2l&MG9B2SUc>o`BZ&jx0!L-&mshYlNGLuOQ_b0f^zbA!n|+#xT;pOk zMaPwBDeT*<%UJ4ZqifGM(LSceAXEpWVoC#Pvj!HvFz?UCNIv$aoT7pHdlC0GA{H%z zz0IUpb~iAX#h;<2W-$-VM>H!<1K5kW-|FEIjaM*g7%uyZuulz?Rn0`Nl~4R4z?Xe% zI=X(LbfnPjHBdK4N2)XOI61@kUfl;`Sm~h!o1|wsu)Pz!=oGRMsaTs?TUuJasw@;O zHx@6UpGxe2XK=7@9&2xPpM>}>rlg<-0EW8Bb&n>RaWO^b|d^>C=2>q%_GdfedxpH`O1~45m8vRtBf0hV6Hxoo# zl3{%Ic(Ywe-)h$aSFTIPzp0^;RjTli`2?q22;UO@tl0Hn`6ZcGA*AT%2ffn|v9p&w zDNHE6Y%+zA)1%SLtYX^az?IO90~Pfr^7~r|6i=3}C&}$A7_7UFRH;AA>Y;AWPu6$U z(zIrphNR#AFkCDcVzsX7U3-)il*A+q{vTo_=b)ay6H`$jTnvAT<IBdNW^QU)8*p)J zDhtUd$s06!OXoITDyOR8XsFbTqH;kdr_>i0m2>-E-@bk=43e)4qZBrjh3y_}4nacJ z#V1*cD-lT&Wf(B*-;V~@K1;59xk4ge<*J`oCu;6rv-4^Td<|30rmElNkyIolVrbsS6xj z30hmeY^_=@Gwl`Trt2r131=((8O;Sb+3s?Tx?(un!*4`V^n z#FpH?1bp?c9gRs&zsQ&h5kla_l?bZ<%UGc$#(Gq0nd@1obb6wT}dY*L#kR=EqST zni3imz%T^rQMQ31jecu=(=hPMCsZ~zA@!aq1W zIB4K*0TvAvc6F5-z`+pvon+;AED#AF%NozhCdAT7#(2`1z~qkxr5-zYi!%d;-aU2| zvn~5-Jr5-g^%eW~gU<^;pcHG+Q)8jv#>sKO_c2H*K8jLi)#B&wKo}OUTWzgMxN5D- zg*LIRm4BqlsHB<-Q2c>}3D(0YwN>F%?R!)_ZqQnNS1{tJ$B!1EJcFCj2291LA1_aybYL5woJ!g_ijA~7 z9Ke`$5SidvTc2#oKLIhIckuxJ@Y$-`f`%SCg>Nl9E2>&UX>G0CF@baFdbP=Si8qf4 z4h%}r|H%eMdMWih4TA*B>x_m^tr;LxbMKPBA?pdx(yNjRpI8j1sO+QEQ|7X;$)vJAHyO@cWqxbT@*aQ>6=s5wJdZTyehjrnw6; z=L$b}Mf_k@=OmX@LYL&f*>r50sOw9WyPi^?0YJTEJxPXk-X28#?DdK!gJKqTrQM?y z?XyCRVopV3_8Psq_9nDa!8_8JCk;%*f9{v&1OXb7@(=@^B7Kktun3bJe>Qs;zz03# z#fA*opGh({a9A1{@Ao7FRjreDp9#*jeP^8KRT3S+&&fr~ydC56B%9D4FpFJ#Gh`^0 z6IJ}M6#Z7U=2utqKP5wyjsN<7r|!hc~gOO~z zWv^t^ZTW`JztKI=)I_U|@m)qE;`FImSBQu@&eBpz(Qk5FFJkD~$>8SgZ}%U$=V>jX zQ>;dI=|7KXaZwepYSCLRzE?~KFN~^^lA>R4h=vjmdmcYHY+$$IPG`l*;F9vo_<_NU zE70|dtdenS<+Eb!I)e+*WkSWDE_uP1qLiuQQZ>-cwDG(!a=C*+(T@b#$!sXwIba-{ zxxA4fJ=ec+vt4JpMgTQ!WBzEI6qpzILcg-zv3&?1{8i#x5vF`6#LT9AnT?egA-w9cf>T0*>MT~pC0aq%tnFcv5SWys?P>_S0x zxH|U1X~`LJ_MVI2G>T~cgyO0qW$A5^xSEEHa*9HRu33{lTZ<6h*ilcWrH(&gHA26m zAM-4fF5+SjAoor#QvQWUZEg$-NZ?DM+#89bU)q~w9c-A3?B&Pm>JD1;oJ~QXQ5=Z2 z$LHANVx=J`)_K{&;9$lo5&>$W9}Kj;)No2`saAyhDA62Zt-DuJv%JU=H9At5WN818mUmvsjHya=)!P4GGdIQ= z49_|p*W)3KAN!u^tLz=jsFI`jDw`+tyQ|GvYf+>ZBwxH}&P~uT5WWx*tGm?C?yRR- zO4srNwbPi>W?-k8ACB;aHNxp0*7}CD1&)Hmb5Q^1z|O`KB69D$|lxGF*-CH^LXzW!+aa9)u4JQc@Qd8VUe#z&)y3-_R5I zLt^@e<&#mbosGeuyK+$%rc^L{U=%4E652l?K&bgU7)zSWe>YVCJT*-J!^u1(AP7Xg zHADpi3k(c|nhb%*HJwS-Hm@WV@?C7%qQN7LyWzp^l|jEv<+t7B&BOgpHeYg9TZOD0 zrVu;Jd=(n!F`DZSG-~%5;2MOkC1kEMz>S}avo7*65Kc2tP&HklQ6mWP`Wi@L{QUgP zM5M9t9ce2hGloI4R?(-B)%~dd%O?`56#Qf;1kPeHy>zItFDYib^BbHd_O#OxT`(-WVH*QWR4W4& zc+c?vmDz6*-&Xfg$)5_nqSJ)cgKF4sOrR?XIU?M|*&^G2p^#kZ2~M0*N``yI3>+g` z;ZC|M+S@(&22}85u6u1K6Ni47^f&aMU4QQAP3E zlpgn ze6W_ivWosJW~m^L9(f#1O-N1oZkT~DFqcq?Y+uip1kc=)No0UG?xeGHZUODLA20K@ zFc=H+=xQKAt@5)pO8)TI`<;UI&84d%!-TA_R`JZyl8XEkbz^`YrpC+X;m?gH3He>SdZBaEO4<-xx!G@#BoU)@UiC zO%$^<_W2o^gr^0(PoLUVtjAXS6Kcy(dYXAmn#%+lzR*mucW@Avlm=c9`4F9lo~%hQ z=v&UYxn3@GHv&yR0rDSj^;itZM2I&eP?{OGMP}RM3IR`%lFssy8_?-#qampSnJA3G z)dH_gkAHMQe@=2isQ1}eL$h-Yk+OOjDKQqY3ZB;Eg}@KWjH`0}t17F2@rK>?$?)Rf zU|qPq_4%i9kpc7h4A!H+==V-#v2MB|rczZiFeWVN6f$z3f%^(eyZh=n(l)>+Ca!@S zz5CtW*?C?^zIGvou3qW+twgw^j$IrZEcAJZ1-MnR?{@7TfG3pnYRVpdAg=r_I{60( zmTtlGy@z+HwD?Lkr@I@u=7C@hg;jybrEO#a@J6c_CnMt<1yrWHWaX%_2hw}~p{d`JA zS#ZEI|1eS0p)D1x_zC;O2NeGh4*lU(YRcAxh2brD0fnlF(Ty)L;dhB0Fa{-ho>llt z5JvSH8GaVqW1Yk7?_Sk+yW2lQ430R3r}~Dro}k3>zCKKOJ1~b3BZMO0KwbVAVXJPl z|LR)%^K!cbfW7Nrqp+`SjxcuO6Oq$^Rkq$FsrD0)zs$DS8Zhe}aFmt$0f*)Q>6tH9 zg;%Obrg-(VsLI>O=wLqSx^6tP3NVEYC;s^`f9@tZE%v0oqXYMd1buy6`3QggYe)Pq z)@rH7;OH@d-BKMP62*mvIF4F{qSL$T+uMl9JK&9-t<++LCN1S8j}6S1h!t`Mlqo+v z!5=VQ9gThEAjSZXO7f{4-!czps&%?>(I^s1vNtf#yK=R9Pvs=t=&ikmH&@YNJxl?vH+0z zAATM$Q{8^MkjMxO4&DWB1?mTmBrZ1gTl?n7&2Qk#o7huSDKJ#+TwD$Xk^TAUshzHF zZ)+7A8UBJ7v0DUlnK-bmWTrE%)QK%$I z0LmuP5gZcYXmH7%o=y%xK$eQ7tnn!1(%V2#oA&U>-_H7ZM7ZqcOW^(nO#Y~DxA}l5 zAaTe3Vols_zhK(oy<`5K}#B5q{H(qN@C8BH4`MI5X zoyRFqCa-VIfmZT2D7qn_HFCmgnyz2Nl)cGv1^@9Td?*V2?k*rh|1LnOq^!R~b7Cd8{vp6GeapZvsF26+PY9$?BG zdZ7Thb!o)Adi6|*f?CkFhj2d`J=lbXsOgEH=1XadcHLc2K%2~&363an!T1TFaQ3Tk zhWb0U{5+(YB3rC`=Z9RDQ0|j~!2Zf^I0@{pbE`}L?XQ=VCD%)7D}BQ49WSI5>c0zB zxXqm57#GA!ITKUet@%DXK4NC*$V7xDs<~B1WTedfH3u8i`chf=L=lMtVDix(xUt!X#Ro|lxP>M`J4}1FdA}du3@<^A zDI_W z0^`ceSl_QV`_CRo|8Q=&GJokP7~pRW{D;|w*fU}bilmS+8*PSiDusrtW@fCe&c3Y1 ziMfoXj~nPTDVea8{!CnTa`8ZU#du2Nd>-c9+TEGtbbFz~BTPt~WomS$Q|8C2@~$#Y zPzr6tk0~=*1m=r6tv9b};HcWZE`QC|g7)3MncW(D>m<4mtEHm}b_l5jdWeKo-Ax%j z)yoJsfEbvF<*dL5b^Z&aDAnxN*GEuC!?_s+fMOSs@Y;7RJVc>{P^8_C=`wk6aR}L977-Ek;c)0DOChAVeo$BKI0X*EB*7I zNMp@#^jOzKrIZH~quUmfzQ^oFw1zG`-ssIRHkQK3c%BSk z)mC<97(HP_q`S8u4XtZiki z9>PWgm4kn=Y3GPnf6N#qDWTJWAYCXJC%2i)c3*nTf+0%$*u%($gS zo5lyV<#%-R)Dea%g#Y zLN@E_^p{Pntbj(G%KOEzc9_m90uOj^5@g;VirCV1XEUB9tztMaxM9+O6#$p@z-JRVU1MT0k?5?{6Uu?YYXanA}d!Ri$ zxv^qzZ17z5rY_LpDC68*BzA&gGP<&Mu~d6R?6+zH9mM#$B4MX+zimaXUL6>+*V#Ux zTctavTV9^*`Olu|^!4*xL}>trz~6wAD|kXw7t-7GhJ+EplVY$vHJCCn|AgA<{;FTq zW9!S~Ay)(G4?w}dgX@&3^T{2+H_iwfvi??Obe$JLbP zP#F_8>UP5vDK_dB0lBnEbiWJRX`17hzI%EY>V2-CNbj@Cg)@^G(6nFm-!c9N;ek(@ zqn013bfaip!@d#hZBu=ICXH-Y&02HrUN|1OrfgxbbI_CvPlUKdn%BvH>pk|s?dnGV z1NSYrd>H7Z<*T*bPQXP=b-=ZCHirAo&p>W)?#QmP@mM0YE|^32H1K99m8(+o)7sMK zeh1_h>k5NPbTN%bM{!Eft)E0FOP4`=?^R5lZWT5?dsi2cN_r8b63LBT!8YHWwcjz> zZvQU};7>}qlETQ(6cUlZ4oMH}d=Ah{2O+uxdLmq#Mt@(q)lK6bSj{w%7a&bbUn1@0ieVffr=vxTJh~GH1 zY*0VVACt4UP8TX>tMz!Y=GPm{5CP*`L4&?k5JQ2NgXn6#iHuVJOp`Vs{64IdyEzwC z1;m#1#-n{=0>8Jq`79PI%Swvu)!L)e2%~<60am(zd_m2KSb6hU`M;sk^!O`u@j^G z5ZAW13qN|x{Zm#UY^8z~h1 z4)N|@k*5?cB2!bQ2MIuy7(6#PMz$=d&L>25rB~hmA5Y)lm-qkuy=rl}uIyUgvh6Lq zmRl_@EW0hcg~eqrE!)esZQHtEeSY7^{U2OB?{l7{=aOd%xQf%7zsMBw%)`gOEewD< zKRtzt>M0<%S!!GrB|kF)$(VRA{XA)MWjTM%y&o@eHRJ>EJ!oYF`=CV-F0y3f6>H}} zu)qDJI8Teqsi=lZ-NFZy8H)U$11o*`j)hURuEkEYtq8g^@ui5*JR$Kgk`B1_R2A^P ztIIX*dEFVJiA#PJ3>`BbJ2N8w`jhTcV*MW#?ggVe zX;%l!flhGy8s>_jX-2k00K?VO=k1``Vbi0gy?H^lU{ZKP{9!O0IfNrrVp~lF7VTQN zsHC)ZIh3Ro8D$WPK40E9^W9fh7rcF&uO?{Av&SpLYdx8;VTGL7u%7lO5}Xacpy?Y! z5XG(+J^k{rZp&kRY6`iA1i1eZ3z4(1?L7q_FeU}O2Y^wI3C_|&$RQUe5-bF7gXIJK z2H3xRk_-;PGB=U*-!iv&tuX4HhH0^;5lgW=P$~e`j64-ZFNH75d3%#4TC;ackZGgz z7Wh$@2dC|sA7wbg}Yt^+%KetNz4*?Tv;ZmWle6@H4PE(A4e z68RmjYeK(`H`sk&S;%2kgItKiGX|#-^oC?q`%#(nxri2>Lirr7YGJdctHdR7syvtCxK9?f{<@f&BC zZ~~6CMk|>V6jy0fR8$;fgkCIe*Vpl#b_C4eBj9zv6S!Ee!_{oshFX%KMo>E{K}{H` zz_$DH%MezC&P0BM?<!hW2JHOj~*PhP|$DGramb^rsGiCD#WxetE03Mv)ZnEmRUFZIds=| zU?2}jzr<&M6goI^xYr3PW*q$8Jn2`NTM>+j!NkCQ!X!;y>dF!bmB8;(p$eh*K9Y&a z$ACi-3bKuFQI zhuSe_pJwCnj-d&3684quX1B{fOFzRH)QehysB4%@V}1+?zGZs zp0kQ&BW#!8FNY%e)(F(UFd_tTi3G0`ddh@1sw)A;sS=shUqu%d%!OV0MndQtCQ}_8 ze8e_+PY!^zj=W&~hc0sdD>zB_cRDcfnVprxD(|Zj;Ck+_6dJrRu7qM;f4xsG`}q?$ z2G7-+ zIKba6-H6D}jk82ZAm`H7-h@74_t&M+PAnKBdzdj;qfxGY+gVOCWjoZ~69kbSg5-UY zX)0h*QVo9h9cikuIu07rzP6M#9+BGT|L}XSi>xkyTG%e-nJ}UTBVXww0F zbY%_WdG$bW(^+QH_q{Ut>!#c(m3Punao`0>~bz#?g5I` zSy)c%HtW#Al&z$}MN{j>+rj0_X9pQFw)4Z=q~Dd#fy@8JBv*BF-%JYd4dV?-I*^VYt%hTA6l>kgBzIt@JLW-v0sqs z@gw2h;WA?R>6>`X%D?{#rTV|ogSJ|RH)4=u`~Ez5$OupP&XGir!S&ufub7b{c#Tg6 zA@ny#tY`CwC|op3=8_IcD0$?8y>KPVX5Xxe8#B8~A)w&QysWfp@OpHsw3w8(%1dPZ zqTC-kh+fnxBP{$<1B=R9`>T_cfSD`5l~lq(2UGHWt=~vD=?w+;(5CM)ei50>p4kd` zTp}PU3EBwQZy}pf*&H;AU!!|~}7bb=1rT1yiXgUlGe{8BKM|W9uBR1Gj zNa{}&aGH#Q^E-NGYuU~?_Df%cT;vh>11Tie#A{0hepDKSvyJ%RqS#RZ<3B}%GIdpOlDH9HWVeKL8O%?{)JVF$V!%NW-T|Ux9XaCr!zaGK`+*dt@+7>r2U{OG5hdVv zjrd(Rm+|i~0W_ZilSfE!S037NoseHsXP$;a>(WIwUPn>7Pv1`Pa4N;o2{~1Yfd57} z+R5&vJsD@nqu%JLuS5=0L+y%U4v9yXf{(y?&}aEkM0&-E>VGbqC3sZ(RW8IjND%e7 zUb;|AvL_>GEo(K&irr5oTzhab=Oa#6gK`6nCtOQXP0V{Pzf+o+D@BE35e6((z4u~M zMPPXaeIQ;utUvJo31&0WH1$9G%!z!tDU!3!@j=q)I5M1Ri_(g8G{{+2q_bBlcJ#zy3;*2|YNa^|J6lOXQnZ*HOCvEBp@qLFyC! z$z^-5g~n{PiPn#s#*oLl2;;=h@*JhNwE@bJ`S*{bgbKwU2{f=HKlQBABh?%ufi z6DDcf{*QJci4hB3$OIq4o`aSqpK&Djc$;yW|0K%vgnsnX^Z22L6}dWTv@UGHq_1)D zP=RWP!!pDjyp^y7-kzinB=>UZM|Vx^@SBEbJ;Jwz^GBBy5LFN;%R%ilb=0`(J-(l% zSZAR8OCIcTj*~h9R|xiS2N0z5_yM10M&-aZyqg z$EnI4Xm4BDJ<+t>;SYruup#pr@_`1!CX?kh@6hG`p(&lhhyoKaWn82kM;KPQqH5`y z5%5cSXL@QlzSWHhFn&5LPY4kr0XYV5>LaNsz6pCNVmmig~NVG>W&J8!tjEjwNmj&ywT#orCo>&qajYv{LFpV7w=VGHnJh4q01 z47d0-D8&A4{}~~n*c{~XPDYbrR8Aq4KR5)!fOH6Qx*~t`3_j1tEP1Q2zkZoDAR0_O zLv)NtL)503^}3Fp%Qxe>HmxQ5NyP>8as9vy`Kv$z_jI5uoCef>%wpLkH@JKb_1ts@z<+ z?u`vj@>WF?17PPBZ2-9B4da_P0=_ci?i4JN_(O}fOkYBQyjiJnn~}RdX_;HQF1}JB z2VTrwro_r^BrT%?u&7R&AhJe_Ez?G=Ng!V>>1Tm{OY_OAOh9UK10u9-vt{-1k`zWdM<>!+Z5l1@j78-H)rsL8}@BxRt<2c4U21@pCi_R=BpIZQyBWbMJ-Y z#-&YXj8Vav7G{H^_b_gS{uo11d}8z@DLJ^io)-ysKUiCj6L?H}H0!k_qIp;46iEsP zKW6sx<$t$8h=Io9=X(5fF`F##x`P76tuETwXB5`L6LI%`Ad z(}k}GBa#nYIimv&r@q>glh&jQ6hFQ#nIyXiEQ@D$^{Fk|zrDLTV$=`BGS*D;FfFS3 zJ~u9%6|*+(aUzSTZ~r+?nEiSfJqf*dXFtrGzd}FF#&EtMACR*3lL<SHq(}asX5a}Ij05HE$-%8bZ+dl6xRNa~k9fc}cYUlql@9#+$l`bCGZ6VZ9 zU^azI%Q@HFl4z)|H&w=0{qUUbL;afFM2x{0`D#nyP0BnIM3u~cLGYPq-0cwEK_yGV zuyQYteG7}e`KMp;cce<9DVOC6psKmUoBH~0sacUg2SvX(bAJOhojyzA@;n7DvHBRk zwycNkvjmnZ`n-a4Lo1^mUQJz9?0Y+HHuIIu{&)mbRFh!`6ro5ClXw(R>b*KKknn9o zu*;l`Lj*Qyw8@lo#O97?^;)rvzZvgZ<>NXv!}VC<4^Z=&Vf-=2+Gerd|Kaw0|IkSG zi?K14ho`51$!8J&8k=PbF}SzcYaL7BV|@3OKS5Tx;mOK`JtxudQko!$+uAC)C1udQJ{Ac;uOE`-d^sEifz}a z7bw34@#wM%g;j5R(o?_tfJ%;4Ko61yW^G|YbK|_9_zXi~2wx3HPen!5+Ud^|sfuA4 z{*cZp(c8-et;)^w2XRTLF-whSiRUc6J>R=>ssJY%>eg5H9OK(H1q_bfD&;-%1a&aI zacK2jT-z1nO6i1x?4#XdibuwIdiQrNzbj3Iq7}AePq%Y+)91yX4X!No=xDb`YI~7#y8{nPCTbY*&O+R(Dme za|i^Z`UC_7>H?9lhh)=6#GvllBt=_k4mB=+gf}5dnK|aZ>Q9_Vj=~j|~^srs!Gn zk!_x_PqsrO3<`3^FA!drXId~=E*BhOmeNqIl~^4HbQBYPS^4oh6~|mxc9N%`=LaXz zWcK6(lC#d*a{n72G9YWZr1-6fKMUhs+wJfu8WHj0--2LsCdQqjkuV*Wye0P6?zH+x z^uxbn3o2hhcHH#l6(7A4?Wpa7c{tK7($)XUqI)IIw*KxY46g#U2<{rRsgCfQSKTMS z1}FEz2R*?yCd#iICm`%8k)OrTlF-+l*Ly_}2lY{* zST`B!mtt|Ep-mFF>2f*WpWl6?>Z+U%$`*$;+vskTjntYBaUItXGy<407#*%#w9=}_ zyFWfPu~@4x=*qo~7iu64C_9{~$QWjI`KV&dM0RwKv`kY~)AC#kiL`EBb&8owODphX z%#E;ldGlcFK%_7FZTq_WscA3;8el&O)h$^Cd14_wkaf-~NdtAJ(e+%|eBm_bmgjN# zi{BdoTjQ-SoP3M7w_$5A7EL?AD0&EE$N~+(Ua(ou%gwz4kcpn?x1lUzy`HiY!WO`g)%dflTkNZq#0dh(ir<>kQ@aSfK^->HRdZ%zzf60o&Fm~qdI9^^YZfH3dfSZy~^ z{99g&x!2dc`&9lKgm7|^2)JeEUIQ}9RtZ0wJ5s$$*KDbghtql?pELJ2i#$vm92D~4 z+}JrGpA#cMLoytbNJvOro}UClUwtjDT1rAPN+50n7uC15e~1gso`gVux%srzsPrHh z%d5e=1p=$H#1YqjcaJ=MMNuVlQ9HjUkm$_Z(cJymjG(wWv{g|1JWP?YuYS|5gitc4 zY}ynXa?khFo`N!`Pjb7(6k_K){SjZ@>^|o^l0&NkYH?S1&bpt`w5)2{w*(2yza{Xt zN%kA(T|j}9>c94HB=-Sbs|M0-^+Ur}T67xBiGM*hC#z*^Um~Vb%0%69s2MDONg|i; z!d-{WI&fh1VP#U-x({md3pLBos>r=8AB5m1;-;RTNBVyv$;mfzJ#{y?Y?_ATjTI1f zL+t%si~fMCslV0LMM7n!t1qd4W+P-(TlZIoFiJIB*mqG;8l4|pftbr|swlBd7hSiQ zfHlhN#CgYHCLtYUM`_kO&~XV0YWK=lyy2Yd#Nd31GW}GqeWku7aiuG$Fg~Lb;u)-WLO#gUBcI?bTbscJjPV6cfskL5;dv$!zv+ovT6p z6GseFZilDAcSU!|NBCuy8U3L>-9f=>t;?zjd=+bN!&;I__X1VhHSjlR)qTpx$jTfp z$bZij+q!KWtbH$V_x_$$A@=9s(`3FH93)KTw9KmJuictb#`%Ws;p*-_^@bIlAyD~5 z@x_G+2Hj{%V57Uk^EnWFQh9mhsQIX+S_c(@>FO!eW+|7qd>Sm0-NB}zhjZ6^i zOITvGIDsX)TlqL(Ss&<9x+^O6E?6dz^z%}>r)apUm?^8?eDeu^>Vj+F-r$=o#ajbI zM*Fv%L;T?Aj50<(ZA-Y^ay_;#xy{++u6|AbQ?KZ-+4Q60Dm2V1-27MHy}#PPAHQ;r zktdbQw>>*TJ-PS>5!g!UXDYMFIkW%hAQc{*5)(dA{*)zy%ogYi6tZl;!p&sT`!_vhe%c(Wb?ER|xQI`~B_ zUSb1shEFecRfcv+HGW1szpc2=3&mxU1iGQ9*z6l!*}w)+!CPrbL()!JYt-fg`#U!T zo6e_D1F~#6UbZ*3F#^^LJl+p7!;wR}AtdQCfE5achAmpJI|0;fnfJ5Tzs}AK!;XNP ztz?^(&75cayFfJ3+Ivh1iMns=aND&PH>d0EP14ifRO$+pGQh|fd3ciKO*iAfqLxiJ zu8#s62ea|b;m%e-$okU{ZQ-B2Zy0=$`d8!gDnGh~dDz0{Niwu}n4_wHYv*+NQiU>H z41UfIjoW!n?eh$I9B3*1_qgx-eV>h(tGlV z%0C>OG&*rP)+)Ao0Z?&Xo(XOqpM|KbOYnVZ?x*sKCY0A=YS7n~8VxW6u!WGS*fBzg zbaWMqy0MKJ^;BppCjH(K%xV9ppw3P$biX7oa_DnUmD z{KvHA-fn`_LDrhSUU6}Ridjk zg5_W{U$a~0LS8bihr0C-l?bA~a#i`>=@KW{V>dg@^PvF@`i9tB#PD)8oU<)iFb&P; z_^-~8p7@^vnR~EtqA1WpWep2IBj+3Rl>tc8=@4`?WsRrmV-V)uSQ*HglmR7OYO)_4|kE|F0$)dXmw#Hm&03wRO z9-5-s9{$4`IBL<(KN2d9nnm_jqud-pH6hxeLi(^qbAEBe7q*{%(;UOoz$960)uxeX z)o_0N<|eQWooee$+@`uW=TsQ%#G3P-!_lCbE70XZ%Leuw|2&w_k~bnh;Oz1|(E+YN zj}VBFZ|vg@1Ldd>6@ej);k{x1LWPc%mZaiOZ~Glhbah#!5t%qfI`#xm5uoJ&o6#O5 z4$g5Ng?|%O25mm=?i`c>W3v)^+9`i9=*OT{YhCz|DRJqc;%8y{D72~33{ktNHmW8| zs!E&hkZ|AYtkCaI6u`LQ+F0MN29i!JdCEXR>YWZlKd@{bT0=6u6Zx0t6aDmx&l1Hw*<5SsZ|Dn6e zKV*bi=Hs~qhY`O+0RKBhR+K)qYadWD_-(z*5JY{?{i$QMv9S>s7so@5Wso1^-jo zsp}UTDH(1dIVu*-v-eI*OoY1ER^M=mFEwR29#ld@%BO82p^F2|QhXCc&sec=uZSW_ zQYF*ur?62Z23S;*vwZi-wmMac2j4iGV3_@>oX|40Snc-X)=6B3FC8Yu;7}d1;U%{@ z*T>I3A;8Ub()t1Zd?#FrT^|E1(XE9vE<6ZN!8z3#3>y?6o52*S4w;foRJnP*%Od-CI@-G^3 zIm*(_PL2$5Y)cRE_%>b<^{bmvi2Zv{P#|gB{S_b=7Wc_=P4=@WHSOeAQv&Bo&G<=| zu=lY5ZD8FbY%`E{( zyO1^;D3Ey0Y49ACa#7(`93sFT7DJ?(>Bm*0OFH-%`M0_NLFx*~a<(>ux zhZgo$1+|sw#ZqwcHukV?IE#Z|!}EI_G$Go7m#7Os!8RU$!bP5qd;?Evdw{w@Z$^6O6oTYT8!7kYYYavuT=hgBW2E46P+S;`YP>YNl zm)e98_hoyZiWRDWOF4_>?7NsZ{r91pu)AH8GecHF}Tidvct zIk_H726Uj`d3lLqc94-Iz=z^Vnx^;FIA0*Rp6_WEc z!4J0)`J5^dnn@`O!$QlxFrGx;tK8Od^U+?cQb3X=67UbSnqh8zwOI3hz9$7z-%b>< z>a=VXg{WhN;S|0+IHwVA@F-(-l-Bp~_9S~Hai5Vd)P*#Aj-e>3_vq;v9$IU;d)wL7 zMF9$K(tBX)jh7l-(}PI2(4Xolvw(RkmR=3%r3aVxPt1FA;_bd)p$#S+^9g&jjy*rg z7d(aSbp#cn@OGNb!e$7-CjPt@c@4tUOq!(5*0t>JJHI2{A$Q%vA~33>`t!;86{ek{ z%>fCLi{dKQIr9C(MN93|HG;{+Wt2WnRk3gA8;jwVe07sSe9!F^LB+Niqi7_0{)HOs z8Vx!DSBs;XgOkaV{$d2K(ocEsNN-OFj%?C8kOyb`$vYePtpMivHsGAfy$G@qyZX#TeCf`VXDp zVpA8&7yV}HhwTr~BqAx4)N;aOcP)_h*kaV@mRLR6T2ZVgiQ|PIGpj8KlrHbJ(59nv z{2I#;%hI94pIHuweIYY2!+$mU7G3Y!O)u5CVRK3}TyE#o{^)31Ow`@h-t6QnPUebB zRSxND_arj_g+{Kq-Ab={?W4}B`Ix4!!?=mT(h`7sNj#HaRMQhL`1w(Xm+18suZm|# zCo5uK1U1vWI3lo?TG}$cNcN>R|5CNJ>tL&ViMOVb1Bys~^ufj@+<6@fi+d@)O4ziU zZ2uX3=S}&6NKe&X<~!1vGyS7Xg~x*7?->kwb_)<$^M`|Nx!>N);6IK0gVWQBXRXT) zIoJ0x$=$Bw#YO>O=I6fy3Jzhm#3_3|Ykd!L*}ZL{{dBX<13Pj5%&rz8NUZsm_;YzWvSSE+=TC_e$Mic)q zKic`YqaF4vayw8pFkLs|8g_v@F4p_&{T`vK0;P^luUMn3&jUAB|T&1Vn6M zx>|Lbv%VAdUyj%9>@BpZL#%7yH8`yOvqqhl^{cU(7rP^bRLCm~*Z`4GF9$qz!-;q> zjk>~=lD`8Y5joY@*B1i^rz?DQYbzjzMj_MXbj`v@i&yL~ya*{3F|UJSD4F5j0=prK z4;n3I03LakGb`SUC4c(2gDappYU}GmcC8()cFz4NzAO46@^YPR^me4YG56%h_7sob zz5}Dk5FFLfj<^H_A;7zB05aPaZsirFfePg>C~!R@_{o~iF%Vhe`-?$i?|1c^iWtzK z#q~#{w4@?D0V%|5Bufg7d<}ECdvTHYg`*w9XqI(sFzQD2I1hIki7UbPd9Ph%@nzyz zPpcw=nD5V#{5!epzh|5Mg};Ia!7LIMrkfQy6`iJsV%!~+-wlO=L??2M8;R{vTvd%3 zMC$8I7H2gO`uyRLvYk{v8Hm=dk~U51@^W9t(@RuF$~)V1*N4F*RD`*%$iYx=`Bcm z32)zJvAQ8w`P!>eXLA%y*H;DckPQ-Pzpn|3^az~ciab5LcuyrRX<nWeB}mMq-w*i1=Kqf_T@!hfB5nT?|+$T#!dEJ|;dvvO! zeoo8I@bTJjm$R{{V^gL@3#~n7?bq_RFn!ji*MHdAv+U-Vj(xIXc{u+iH|p0_UuH*@ zN;301Q(jvn0288=zWAZm6ai#3RecF&IX*r3hqItpkn-N?>_qrBD!KN)X{+kqU>e%5 zB)Q~rrcu-`(>Tq4fujJ?7G3;oYYegYv0VKY?p7Oht;BQ^ZmPbzI#WnZmmt|1G~&L% zW>YDxuy<_+s2T9TvF+Xo>Yd`VgFv;P&H)qA0M|oWA4g+7^ARRt2!kNSn9#QW0}nS5 zw}-KRdm{d6yp*>GhMncc2%;-T!{%7H^mbRsRPR#;4P-h^vw`kMbGYP#pr;}WC5`E8 zq6TO_HYg^gWKJ?`pEIqB*ZIQmaTi-06j~MWLl_}}P77^rw{6aYoZKe8f4HwyZz<9BjDx|H=QJjg&kEr8G`2dV+EssWUi2YolYayXC4?cCF2l7AtY% zw|Ff2!#)>+3lLbqMEnCu_0or%m;LM;jj!nO)ao0QXvVL)yUR`v^hKAek(JFl4g4!{ z{x`7;iB72rD7A}BTbyHR1BTwC?r?1r{#?!Bk${89j&JSf9kcwIbTgsNyg|`eDJy;q8CBTQQF^IP_FnSz=qPY?QZYkB@LYBQ zN$M7;jG<6|!C}GRuuEFwtBy1%CvX4mTKc8o$N4`~m|r7Huz$zZ`hz!`UkS04y40e211T49S zaH~)HWrl(tLPbeRd3%XX&ZpNs!^1(d2v)O>O22eTz`Yu>J6%c&0>!9z%ob|D;qgv999!>r@qqPhC|W zbw?0mK~4i-VQMYEWWnRx7oy6-9d<{O1~$Dmt(syMf`*_JBevlIYFFa<=}{ztGY6q9 z6uZs<;9k&XIkPPQ8|R%4o{GH4R*;TkL@eJVKM@O4%zmvUzX7jh?z z!G?^OLOPr#q!D_@Y&t}Q?vW=4uH~W%`6>YsB64!_%&qh}1&gGE;+~@rc-aDP0Mhsm z6Bew0vcG2nkpU7ej)qL{2M;LS1Qu9Le>hi*9+Z;(_GGh#douP*#)CxM1WgaLK5=|r z7L>Xfv>*5`G__J2AnYIL{@ms3jl08+&RGi+g#z3cWZuc-2#m%zNYE|G0b zQu-|m`UZ`>frG*tc=(DFdhs3G2S}`9L2AQi`q8tJi_a+!7{HcuvpZ3g~{VO7n3^tKY!5 zT;T7iS{R!!iu|J%0FqTf@R)&YGY|7NEu8=lt#cB`K93?ocA6L?aaC0xOJ5dharrc#e~>hkJ|NbzT}%3i-b#a`$Nn&_AFD(l$W$~d49l6?ebKV2 z-pLP_VW{gZeCEI2zPnv>%ue(G^RhqF0fOC=ilyhLFGP@*ztVPd3F<_*-@sos@z%s- zzHhpOTWzSJ48OsezD*7nmsGZtWsb^bJPEMquyWx+{WQ#`uLV%U+vkI%L<32_xM%2n za3!}11-=jF;XjFQRMTf=>no9Ijt;S?ujDe0|MEFBAk%wH1r_@sO_f*2!9hNtWevSE z!0?N(Wsp{abWIcawtMl^HO_dmATR7zZfjhzQu*w!t*!aLz07Uz7?nFfH?fSLN`JxR z7RC;ZQyrcSNgQ2IPn|o2w}|JkBkLbDAJpL>E^;51so#41BVBxITc5Y_AJXlb6yMca z`>lYOQbY^3|E*8R_CPI@?5Lu!vJ$Z0{x8XaDI9=e{pmjG82cYIWyE2}ByS37ku96m znQfiEef#%UZ7_6hgJFN-tmSVzdIl�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

TVpTg&=l(i3=>k60qhTDOTZx~Gb{ zFtlAk2whU<)ZPbNpXO&OpXJJ77wtYsAG3tU)$jcv7rLs@DK-*mTnxdpGn-fidsf+9 z_xCbdv55r5Kcx~ZxtF{)aSGARH_;LT4#qV;9!&2w8eglAjbSn$5L1U=GF>yqK7`1- zQ{7u^Sh$+c-3vX2O3U^|n9KV7k_=7dYp&&47Cd-^z1_cJ^r*ycM9;BOhzM<1KMj<* ziNhtJ5tr?AvKBma7_6t!yd}Tq{RkUqFx@k-HBgT;FzuKL+m@TC^V|K(`W@(19UI7$ z2V5fP{yXjJ!DFtm@xr~;voN*Pa-2q+p`kI{;c$+17unfjo|19pzP3cSuG8apu>Uec zLdd*Jnbk&A8fd}ElBx$~PtBB%@|9AW&DPk+NbyE%w>_>~ zpe(*nek9WJ!2iC4;(eFoz`OkctPQzJH>GP2>yf?kJi0hW9bIMW4n&H&r3(dZ`5LB@ zt3x8HBLE-}1N7>?F$)zn;ryCmvNlT_ZH!6wJViA`AKs^NWCDnCkY>G;l`7?jje(WP30R<>LCeZzMxKn z=l7C3$6(COSJx@Ua$-L(K&ukCF6uX}_@}9z&C_}E=~j*u*cK$dSHG8m-W=DhSk;oW zVE{PY{dg;0w+@d0nCUg`{GD0)@|ulMQV2hv!*u=mI-WTh!BJBs$HA)@R~8)9Cv7-& z=ZU^uF7jzjADJ}yZx0PfEVp!8R31W>uKoa zjKpmE%6FdRGk&={g(b|voXM2EHh|ArYVSE)@g*(TKk4aaBYZ2({Wua9iJ%Ue#eHTK zfv`|efO0;#(dhxlED0!LPw~8(i?W=j0|D3JDk7#wLXUIC>9r3Yp~8CCI(Vy@zHfnp z-sbFs#3lZLvU>m+ZL9VEIGS21?}mUp)52@Sm2ggoN1+;@ z+-Pf5*fJAPs9uPbA;Pb_@LfL!Gw$jyoB= zb7JNFp>^E$$ZuEuQ8x8_uB@GUau0ypRQl>*hNfOTdQmWkpDg!&7~BysReu7{H5Vv$ z%F52(?PUheh11xgBr5LD2J_QJEQxb=Dwgj#Wx3A=I#C2HVnxK&sBLC5oieVKs)3qJ zb;73oT@0WH@pVm5_NANbX-k@UU^QM&mJnKH61?(>Vl{VE`|)QagPE%I&DFZvPd0kt zn1?I$w{z~fC+%L;{0j~ zvyB(8?B5K=`D@PfcN`tOH@>HE?%aKE&om9;zu6Q@-l(HgjC1#093>_u#=E^>T&W&mqs?DH|Pe+KI@aPv(+ZOK?%6_b`$~9~7dRoiJ=qV0lx-liK

cAU%pXn+s$#+j6X)*dCrTa&d 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 literal 11022 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 diff --git a/docs/_static/img/Matrix-A-upper.png b/docs/_static/img/Matrix-A-upper.png index 1b930a9a3f552080ff902415ace5e7a5420190cc..e3703710ae20db0dc5569cb3869df7fa0a08e7e0 100644 GIT binary patch literal 3335 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 R2QmNv002ovPDHLkV1kJq*(d-2 literal 11276 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 diff --git a/docs/_static/img/Recorder-output.png b/docs/_static/img/Recorder-output.png index 355cc13764898bf9638e5ce9bbfc38fdfeb75475..525221c557056ba5515a197c5e2a74274ddd73cd 100644 GIT binary patch literal 9883 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 TQzjR{0e`YmN|HasjROB4=fH4r diff --git a/docs/_static/img/adj-graph.png b/docs/_static/img/adj-graph.png index da9f36447792cd70f17650879c480c45c008d8f0..13a05fcc2e4fd5d545903e30780e7bdfc83453c8 100644 GIT binary patch literal 7710 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 X4K)u?rRbxS00000NkvXXu0mjfvC&h; literal 15744 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 f|MyPu6~RaTY_q##7tL@R8Vw<A5_p diff --git a/docs/_static/img/logo-name-dark.svg b/docs/_static/img/logo-name-dark.svg index 039eb7e25..cdf5227c7 100644 --- a/docs/_static/img/logo-name-dark.svg +++ b/docs/_static/img/logo-name-dark.svg @@ -1,6 +1,4 @@ - - - - - - - - - - - - 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 diff --git a/docs/_static/img/plus-times-semiring.png b/docs/_static/img/plus-times-semiring.png index 5cea4301f65772be4ee95e0bedf40b63f4d7e8f7..bb2f527e8375dcefe5dc186c805195101d4d4d45 100644 GIT binary patch literal 2472 zcmV;Z30L-sP)U2+9>HqN3Z|+r7QL zsi~=^rlwn4TR1p4F)=X;3JTxf-~Z3getv#xYHCbOOh7kS_!H0*3RaI60 zP*DF!NJvOXJUl!a8XC;Z%(=O_m6er@jEr=2bZu>I78Vx!`}^(f?f=);($dm>D}Gk$;ru*l9G3KcW`iUSy@?7P*9JLk1Q-KB_$>D^72GPL^d`y z*c=?Pva<2x2TVbd$g}dnR4VI(Kef;8M z9RJUv-0>=rl8)two`q-b-?riZ*;vOd!npTd(S(~h_HZxhK^qWOU#GLUtA6s6*21N0`e~?b|7BdDUmB7XoH-XruQi@g-dT?4+D3@q|&*csj zXY&TR9CrchLBbVC7&z@Hnba$V$Nhp!q1%8ZeM~cIPZU>BsXB`zdH{c%JFyG0go$9) zRn8A00|#!RZ;`o(*(0Y>rU5&t0onyqu;*Jyk1@kRkHgX0?TVQNlkC=w6NTBj;SDGN zUYu(vFfo(2!xIesK)S2K&U3xyV*ZKma>&G%Ps^}`P{czsPRmSOXBswNT2xW5^5US` zs!{`-bO0`jl5XVX6II+U323|m435MKD{-K#gyA**vitXxOCS_D*8x>=izD}d65CE^ zkkqDmVsQ;SPMCE%oec(IVn-HN2H=zDL7IUu6$4f)(DMK|7o^&4O6l~CgO?(umr>nk9rpX)atPnR}Dz>dEqNoQ0G3R-`1jhQxylIue3_f z+0K`6iwzd#^vr;h5x_6|Bq;j1Caz@0j4j$>!3jIE=f(i`xd9+5j#b%hh3IUaHwE28 zi-QSW6z7FWR-wr%wq;{F#aoS^RWznmwAm7$dZn1TfZMp)R$)Fi16oxiAF|RacF15e zG6XtVb$(mKb~mkA`T>iZK0rUG13*RGaKyNyHZGfH^K2a;lLNAhM_ctu6E)5T6}_;~UEB#@s<@etJtdjQ^npy0`K%YV1e0kyyJ?e)XNRwv znB^z8{5|G9F~7UZYkP2#Q|SlKC+9JEd-?8#--_8h2S=H>XpBu#aamAO$ZMmmka2yG zBy;`?lAA#>Fa**H+1NiPlXW9O9;e}=l!)RcZ8~%V!e`*o%Sk-&s*hfpCg-Wb+XrH7 zHd1}@x*+toPchAVgbB-z*9*z0h43#jopC9hNs1*}$a-8=0qN_5N6()B5$`^}{`4ch zh`;Y153vN+vrP#5bYQVlp5cdwj~_q%Bc8wh@c#9W_$J2g9$1DV{SYFJODvY98NbSS zu+&%zACoZ+N0Dj`3&r-xZXNeoq@pmM8*5msX_~n2Pd$3rinX=1wY7U~ys^j$R)?F5 zM6o*FaH43MXRohV6?j4NI)vW@SF8#>PVxnQ6Uwhv6-XsXn(>>?id7-EM&5*Z2dh?v z+!{F*b98Ohe*6nvlia|nJ4uo(V%6D5Htzcq;q8Vbvn&7D81j%DU@3OKw`*s+X#-&t zfX}fkh%7L!Lop7RG!)Dt!je>ikg`Qh12j+)l2m>F7il}fX<00Cfw5fi-T2DVSo6<6 z<6~E*y%YH2n?+SJ z!}fVDnvmhDo5MenESDc+I{cxNoU2%sp>dfY=WuZYl%_BvSTJc^NRofsXm3+ZrNp8y zvWLwEb6c4|-Wwe(T+kSOjLcYpKTeBe8q1!a7!}F8sP#tM#0s-3U0DX7a#-k);tOJH zwJ_V*j~RywmgQLkp!F%zgmY%0)RH#-_3n<#vVs~DoWEf#w7>!L|r!0&Wq|1l%H^2{gsk2RJx5I5;>s@WU@q!3cIbjYZP{0000y literal 7306 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 BaxMS> diff --git a/docs/_static/img/repr-matrix.png b/docs/_static/img/repr-matrix.png index 39c9a42d53082a02446e8b6cac7d93e518d31b2d..34f766c9ed07e3ca073f95ffb9a696c81c25f7e1 100644 GIT binary patch literal 3858 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 literal 4573 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 T%<`VX00000NkvXXu0mjf&+omz literal 9254 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 Q01i$;Mpe2>(lq4%0B{d&+W-In diff --git a/docs/_static/img/sssp-result.png b/docs/_static/img/sssp-result.png index 18a5e1345b9cc11b106bf56252c83518097b8996..6b9bd1604507968253fd7b32cd493226bc6d86fa 100644 GIT binary patch literal 4558 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", topline) + # BRANCH NOT COVERED keys = [] values = [] diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 1935fcee7..0183893fd 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -7,7 +7,7 @@ from .. import backend, binary, monoid, select, semiring from ..dtypes import _INDEX, FP64, INT64, lookup_dtype, unify from ..exceptions import DimensionMismatch, InvalidValue, NoValue, check_status -from . import automethods, ffi, lib, utils +from . import _supports_udfs, automethods, ffi, lib, utils from .base import BaseExpression, BaseType, _check_mask, call from .descriptor import lookup as descriptor_lookup from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, Updater @@ -33,7 +33,7 @@ values_to_numpy_buffer, wrapdoc, ) -from .vector import Vector, VectorExpression, VectorIndexExpr, _select_mask +from .vector import Vector, VectorExpression, VectorIndexExpr, _isclose_recipe, _select_mask if backend == "suitesparse": from .ss.matrix import ss @@ -368,6 +368,8 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False, **opts return False if self._nvals != other._nvals: return False + if not _supports_udfs: + return _isclose_recipe(self, other, rel_tol, abs_tol, **opts) matches = self.ewise_mult(other, binary.isclose(rel_tol, abs_tol)).new( bool, name="M_isclose", **opts @@ -611,14 +613,15 @@ def build(self, rows, columns, values, *, dup_op=None, clear=False, nrows=None, if not dup_op_given: if not self.dtype._is_udt: dup_op = binary.plus - else: + elif backend != "suitesparse": dup_op = binary.any - # SS:SuiteSparse-specific: we could use NULL for dup_op - dup_op = get_typed_op(dup_op, self.dtype, kind="binary") - if dup_op.opclass == "Monoid": - dup_op = dup_op.binaryop - else: - self._expect_op(dup_op, "BinaryOp", within="build", argname="dup_op") + # SS:SuiteSparse-specific: we use NULL for dup_op + if dup_op is not None: + dup_op = get_typed_op(dup_op, self.dtype, kind="binary") + if dup_op.opclass == "Monoid": + dup_op = dup_op.binaryop + else: + self._expect_op(dup_op, "BinaryOp", within="build", argname="dup_op") rows = _CArray(rows) columns = _CArray(columns) @@ -1584,7 +1587,7 @@ def from_dicts( # If we know the dtype, then using `np.fromiter` is much faster dtype = lookup_dtype(dtype) if dtype.np_type.subdtype is not None and np.__version__[:5] in {"1.21.", "1.22."}: - values, dtype = values_to_numpy_buffer(list(iter_values), dtype) + values, dtype = values_to_numpy_buffer(list(iter_values), dtype) # FLAKY COVERAGE else: values = np.fromiter(iter_values, dtype.np_type) return getattr(cls, methodname)( @@ -2466,6 +2469,7 @@ def select(self, op, thunk=None): self._expect_op(op, ("SelectOp", "IndexUnaryOp"), within=method_name, argname="op") if thunk._is_cscalar: if thunk.dtype._is_udt: + # NOT COVERED dtype_name = "UDT" thunk = _Pointer(thunk) else: diff --git a/graphblas/core/operator/agg.py b/graphblas/core/operator/agg.py index 036149b1f..09d644c32 100644 --- a/graphblas/core/operator/agg.py +++ b/graphblas/core/operator/agg.py @@ -5,6 +5,7 @@ from ... import agg, backend, binary, monoid, semiring, unary from ...dtypes import INT64, lookup_dtype +from .. import _supports_udfs from ..utils import output_type @@ -38,6 +39,7 @@ def __init__( semiring=None, switch=False, semiring2=None, + applybegin=None, finalize=None, composite=None, custom=None, @@ -52,6 +54,7 @@ def __init__( self._semiring = semiring self._semiring2 = semiring2 self._switch = switch + self._applybegin = applybegin self._finalize = finalize self._composite = composite self._custom = custom @@ -152,8 +155,11 @@ def __repr__(self): def _new(self, updater, expr, *, in_composite=False): agg = self.parent + opts = updater.opts if agg._monoid is not None: x = expr.args[0] + if agg._applybegin is not None: # pragma: no cover (unused) + x = agg._applybegin(x).new(**opts) method = getattr(x, expr.method_name) if expr.output_type.__name__ == "Scalar": expr = method(agg._monoid[self.type], allow_empty=not expr._is_cscalar) @@ -167,7 +173,6 @@ def _new(self, updater, expr, *, in_composite=False): return parent._as_vector() return - opts = updater.opts if agg._composite is not None: # Masks are applied throughout the aggregation, including composite aggregations. # Aggregations done while `in_composite is True` should return the updater parent @@ -203,6 +208,8 @@ def _new(self, updater, expr, *, in_composite=False): if expr.cfunc_name == "GrB_Matrix_reduce_Aggregator": # Matrix -> Vector A = expr.args[0] + if agg._applybegin is not None: + A = agg._applybegin(A).new(**opts) orig_updater = updater if agg._finalize is not None: step1 = expr.construct_output(semiring.return_type) @@ -223,6 +230,8 @@ def _new(self, updater, expr, *, in_composite=False): elif expr.cfunc_name.startswith("GrB_Vector_reduce"): # Vector -> Scalar v = expr.args[0] + if agg._applybegin is not None: + v = agg._applybegin(v).new(**opts) step1 = expr._new_vector(semiring.return_type, size=1) init = expr._new_matrix(agg._initdtype, nrows=v._size, ncols=1) init(**opts)[...] = agg._initval # O(1) dense column vector in SuiteSparse 5 @@ -242,6 +251,8 @@ def _new(self, updater, expr, *, in_composite=False): elif expr.cfunc_name.startswith("GrB_Matrix_reduce"): # Matrix -> Scalar A = expr.args[0] + if agg._applybegin is not None: + A = agg._applybegin(A).new(**opts) # We need to compute in two steps: Matrix -> Vector -> Scalar. # This has not been benchmarked or optimized. # We may be able to intelligently choose the faster path. @@ -339,11 +350,21 @@ def __reduce__(self): # logaddexp2 = Aggregator('logaddexp2', monoid=semiring.numpy.logaddexp2) # hypot as monoid doesn't work if single negative element! # hypot = Aggregator('hypot', monoid=semiring.numpy.hypot) +# hypot = Aggregator('hypot', applybegin=unary.abs, monoid=semiring.numpy.hypot) agg.L0norm = agg.count_nonzero -agg.L1norm = Aggregator("L1norm", semiring="plus_absfirst", semiring2=semiring.plus_first) agg.L2norm = agg.hypot -agg.Linfnorm = Aggregator("Linfnorm", semiring="max_absfirst", semiring2=semiring.max_first) +if _supports_udfs: + agg.L1norm = Aggregator("L1norm", semiring="plus_absfirst", semiring2=semiring.plus_first) + agg.Linfnorm = Aggregator("Linfnorm", semiring="max_absfirst", semiring2=semiring.max_first) +else: + # Are these always better? + agg.L1norm = Aggregator( + "L1norm", applybegin=unary.abs, semiring=semiring.plus_first, semiring2=semiring.plus_first + ) + agg.Linfnorm = Aggregator( + "Linfnorm", applybegin=unary.abs, semiring=semiring.max_first, semiring2=semiring.max_first + ) # Composite diff --git a/graphblas/core/operator/base.py b/graphblas/core/operator/base.py index 38a76cbcf..a40438f14 100644 --- a/graphblas/core/operator/base.py +++ b/graphblas/core/operator/base.py @@ -1,16 +1,19 @@ -from functools import lru_cache, reduce -from operator import getitem, mul +from functools import lru_cache +from operator import getitem from types import BuiltinFunctionType, ModuleType -import numba -import numpy as np - from ... import _STANDARD_OPERATOR_NAMES, backend, op from ...dtypes import BOOL, INT8, UINT64, _supports_complex, lookup_dtype -from .. import lib +from .. import _has_numba, _supports_udfs, lib from ..expr import InfixExprBase from ..utils import output_type +if _has_numba: + import numba + from numba import NumbaError +else: + NumbaError = TypeError + UNKNOWN_OPCLASS = "UnknownOpClass" # These now live as e.g. `gb.unary.ss.positioni` @@ -158,96 +161,69 @@ def _call_op(op, left, right=None, thunk=None, **kwargs): ) -_udt_mask_cache = {} - - -def _udt_mask(dtype): - """Create mask to determine which bytes of UDTs to use for equality check.""" - if dtype in _udt_mask_cache: - return _udt_mask_cache[dtype] - if dtype.subdtype is not None: - mask = _udt_mask(dtype.subdtype[0]) - N = reduce(mul, dtype.subdtype[1]) - rv = np.concatenate([mask] * N) - elif dtype.names is not None: - prev_offset = mask = None - masks = [] - for name in dtype.names: - dtype2, offset = dtype.fields[name] - if mask is not None: - masks.append(np.pad(mask, (0, offset - prev_offset - mask.size))) - mask = _udt_mask(dtype2) - prev_offset = offset - masks.append(np.pad(mask, (0, dtype.itemsize - prev_offset - mask.size))) - rv = np.concatenate(masks) - else: - rv = np.ones(dtype.itemsize, dtype=bool) - # assert rv.size == dtype.itemsize - _udt_mask_cache[dtype] = rv - return rv - - -def _get_udt_wrapper(numba_func, return_type, dtype, dtype2=None, *, include_indexes=False): - ztype = INT8 if return_type == BOOL else return_type - xtype = INT8 if dtype == BOOL else dtype - nt = numba.types - wrapper_args = [nt.CPointer(ztype.numba_type), nt.CPointer(xtype.numba_type)] - if include_indexes: - wrapper_args.extend([UINT64.numba_type, UINT64.numba_type]) - if dtype2 is not None: - ytype = INT8 if dtype2 == BOOL else dtype2 - wrapper_args.append(nt.CPointer(ytype.numba_type)) - wrapper_sig = nt.void(*wrapper_args) - - zarray = xarray = yarray = BL = BR = yarg = yname = rcidx = "" - if return_type._is_udt: - if return_type.np_type.subdtype is None: - zarray = " z = numba.carray(z_ptr, 1)\n" - zname = "z[0]" +if _has_numba: + + def _get_udt_wrapper(numba_func, return_type, dtype, dtype2=None, *, include_indexes=False): + ztype = INT8 if return_type == BOOL else return_type + xtype = INT8 if dtype == BOOL else dtype + nt = numba.types + wrapper_args = [nt.CPointer(ztype.numba_type), nt.CPointer(xtype.numba_type)] + if include_indexes: + wrapper_args.extend([UINT64.numba_type, UINT64.numba_type]) + if dtype2 is not None: + ytype = INT8 if dtype2 == BOOL else dtype2 + wrapper_args.append(nt.CPointer(ytype.numba_type)) + wrapper_sig = nt.void(*wrapper_args) + + zarray = xarray = yarray = BL = BR = yarg = yname = rcidx = "" + if return_type._is_udt: + if return_type.np_type.subdtype is None: + zarray = " z = numba.carray(z_ptr, 1)\n" + zname = "z[0]" + else: + zname = "z_ptr[0]" + BR = "[0]" else: zname = "z_ptr[0]" - BR = "[0]" - else: - zname = "z_ptr[0]" - if return_type == BOOL: - BL = "bool(" - BR = ")" - - if dtype._is_udt: - if dtype.np_type.subdtype is None: - xarray = " x = numba.carray(x_ptr, 1)\n" - xname = "x[0]" - else: - xname = "x_ptr" - elif dtype == BOOL: - xname = "bool(x_ptr[0])" - else: - xname = "x_ptr[0]" - - if dtype2 is not None: - yarg = ", y_ptr" - if dtype2._is_udt: - if dtype2.np_type.subdtype is None: - yarray = " y = numba.carray(y_ptr, 1)\n" - yname = ", y[0]" + if return_type == BOOL: + BL = "bool(" + BR = ")" + + if dtype._is_udt: + if dtype.np_type.subdtype is None: + xarray = " x = numba.carray(x_ptr, 1)\n" + xname = "x[0]" else: - yname = ", y_ptr" - elif dtype2 == BOOL: - yname = ", bool(y_ptr[0])" + xname = "x_ptr" + elif dtype == BOOL: + xname = "bool(x_ptr[0])" else: - yname = ", y_ptr[0]" + xname = "x_ptr[0]" + + if dtype2 is not None: + yarg = ", y_ptr" + if dtype2._is_udt: + if dtype2.np_type.subdtype is None: + yarray = " y = numba.carray(y_ptr, 1)\n" + yname = ", y[0]" + else: + yname = ", y_ptr" + elif dtype2 == BOOL: + yname = ", bool(y_ptr[0])" + else: + yname = ", y_ptr[0]" - if include_indexes: - rcidx = ", row, col" + if include_indexes: + rcidx = ", row, col" - d = {"numba": numba, "numba_func": numba_func} - text = ( - f"def wrapper(z_ptr, x_ptr{rcidx}{yarg}):\n" - f"{zarray}{xarray}{yarray}" - f" {zname} = {BL}numba_func({xname}{rcidx}{yname}){BR}\n" - ) - exec(text, d) # pylint: disable=exec-used - return d["wrapper"], wrapper_sig + d = {"numba": numba, "numba_func": numba_func} + text = ( + f"def wrapper(z_ptr, x_ptr{rcidx}{yarg}):\n" + f"{zarray}{xarray}{yarray}" + f" {zname} = {BL}numba_func({xname}{rcidx}{yname}){BR}\n" + ) + exec(text, d) # pylint: disable=exec-used + return d["wrapper"], wrapper_sig class TypedOpBase: @@ -360,6 +336,8 @@ def __getitem__(self, type_): raise KeyError(f"{self.name} does not work with {type_}") else: return self._typed_ops[type_] + if not _supports_udfs: + raise KeyError(f"{self.name} does not work with {type_}") # This is a UDT or is able to operate on UDTs such as `first` any `any` dtype = lookup_dtype(type_) return self._compile_udt(dtype, dtype) @@ -376,7 +354,7 @@ def __delitem__(self, type_): def __contains__(self, type_): try: self[type_] - except (TypeError, KeyError, numba.NumbaError): + except (TypeError, KeyError, NumbaError): return False return True @@ -487,7 +465,7 @@ def _initialize(cls, include_in_ops=True): if type_ is None: type_ = BOOL else: - if type_ is None: # pragma: no cover + if type_ is None: # pragma: no cover (safety) raise TypeError(f"Unable to determine return type for {varname}") if return_prefix is None: return_type = type_ @@ -513,6 +491,13 @@ def _deserialize(cls, name, *args): return rv # Should we verify this is what the user expects? return cls.register_new(name, *args) + @classmethod + def _check_supports_udf(cls, method_name): + if not _supports_udfs: + raise RuntimeError( + f"{cls.__name__}.{method_name}(...) unavailable; install numba for UDF support" + ) + _builtin_to_op = {} # Populated in .utils diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py index eeb72ea3b..8d41a097e 100644 --- a/graphblas/core/operator/binary.py +++ b/graphblas/core/operator/binary.py @@ -1,9 +1,9 @@ import inspect import re -from functools import lru_cache +from functools import lru_cache, reduce +from operator import mul from types import FunctionType -import numba import numpy as np from ... import _STANDARD_OPERATOR_NAMES, backend, binary, monoid, op @@ -24,7 +24,7 @@ lookup_dtype, ) from ...exceptions import UdfParseError, check_status_carg -from .. import ffi, lib +from .. import _has_numba, _supports_udfs, ffi, lib from ..expr import InfixExprBase from .base import ( _SS_OPERATORS, @@ -33,16 +33,46 @@ TypedOpBase, _call_op, _deserialize_parameterized, - _get_udt_wrapper, _hasop, - _udt_mask, ) +if _has_numba: + import numba + + from .base import _get_udt_wrapper if _supports_complex: from ...dtypes import FC32, FC64 ffi_new = ffi.new +if _has_numba: + _udt_mask_cache = {} + + def _udt_mask(dtype): + """Create mask to determine which bytes of UDTs to use for equality check.""" + if dtype in _udt_mask_cache: + return _udt_mask_cache[dtype] + if dtype.subdtype is not None: + mask = _udt_mask(dtype.subdtype[0]) + N = reduce(mul, dtype.subdtype[1]) + rv = np.concatenate([mask] * N) + elif dtype.names is not None: + prev_offset = mask = None + masks = [] + for name in dtype.names: + dtype2, offset = dtype.fields[name] + if mask is not None: + masks.append(np.pad(mask, (0, offset - prev_offset - mask.size))) + mask = _udt_mask(dtype2) + prev_offset = offset + masks.append(np.pad(mask, (0, dtype.itemsize - prev_offset - mask.size))) + rv = np.concatenate(masks) + else: + rv = np.ones(dtype.itemsize, dtype=bool) + # assert rv.size == dtype.itemsize + _udt_mask_cache[dtype] = rv + return rv + class TypedBuiltinBinaryOp(TypedOpBase): __slots__ = () @@ -601,6 +631,7 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals Because it is not registered in the namespace, the name is optional. """ + cls._check_supports_udf("register_anonymous") if parameterized: return ParameterizedBinaryOp(name, func, anonymous=True, is_udt=is_udt) return cls._build(name, func, anonymous=True, is_udt=is_udt) @@ -621,6 +652,7 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal >>> dir(gb.binary) [..., 'max_zero', ...] """ + cls._check_supports_udf("register_new") module, funcname = cls._remove_nesting(name) if lazy: module._delayed[funcname] = ( @@ -681,21 +713,22 @@ def _initialize(cls): orig_op.gb_name, ) new_op._add(cur_op) - # Add floordiv - # cdiv truncates towards 0, while floordiv truncates towards -inf - BinaryOp.register_new("floordiv", _floordiv, lazy=True) # cast to integer - BinaryOp.register_new("rfloordiv", _rfloordiv, lazy=True) # cast to integer + if _supports_udfs: + # Add floordiv + # cdiv truncates towards 0, while floordiv truncates towards -inf + BinaryOp.register_new("floordiv", _floordiv, lazy=True) # cast to integer + BinaryOp.register_new("rfloordiv", _rfloordiv, lazy=True) # cast to integer - # For aggregators - BinaryOp.register_new("absfirst", _absfirst, lazy=True) - BinaryOp.register_new("abssecond", _abssecond, lazy=True) - BinaryOp.register_new("rpow", _rpow, lazy=True) + # For aggregators + BinaryOp.register_new("absfirst", _absfirst, lazy=True) + BinaryOp.register_new("abssecond", _abssecond, lazy=True) + BinaryOp.register_new("rpow", _rpow, lazy=True) - # For algorithms - binary._delayed["binom"] = (_register_binom, {}) # Lazy with custom creation - op._delayed["binom"] = binary + # For algorithms + binary._delayed["binom"] = (_register_binom, {}) # Lazy with custom creation + op._delayed["binom"] = binary - BinaryOp.register_new("isclose", _isclose, parameterized=True) + BinaryOp.register_new("isclose", _isclose, parameterized=True) # Update type information with sane coercion position_dtypes = [ @@ -777,14 +810,23 @@ def _initialize(cls): if right_name not in binary._delayed: if right_name in _SS_OPERATORS: right = binary._deprecated[right_name] - else: + elif _supports_udfs: right = getattr(binary, right_name) + else: + right = getattr(binary, right_name, None) + if right is None: + continue if backend == "suitesparse" and left_name in _SS_OPERATORS: right._commutes_to = f"ss.{left_name}" else: right._commutes_to = left_name for name in cls._commutative: - cur_op = getattr(binary, name) + if _supports_udfs: + cur_op = getattr(binary, name) + else: + cur_op = getattr(binary, name, None) + if cur_op is None: + continue cur_op._commutes_to = name for left_name, right_name in cls._commutes_to_in_semiring.items(): if left_name in _SS_OPERATORS: @@ -805,7 +847,10 @@ def _initialize(cls): (binary.any, _first), ]: binop.orig_func = func - binop._numba_func = numba.njit(func) + if _has_numba: + binop._numba_func = numba.njit(func) + else: + binop._numba_func = None binop._udt_types = {} binop._udt_ops = {} binary.any._numba_func = binary.first._numba_func diff --git a/graphblas/core/operator/indexunary.py b/graphblas/core/operator/indexunary.py index 5fdafb62a..ad5d841d0 100644 --- a/graphblas/core/operator/indexunary.py +++ b/graphblas/core/operator/indexunary.py @@ -2,21 +2,16 @@ import re from types import FunctionType -import numba - from ... import _STANDARD_OPERATOR_NAMES, indexunary, select from ...dtypes import BOOL, FP64, INT8, INT64, UINT64, _sample_values, lookup_dtype from ...exceptions import UdfParseError, check_status_carg -from .. import ffi, lib -from .base import ( - OpBase, - ParameterizedUdf, - TypedOpBase, - _call_op, - _deserialize_parameterized, - _get_udt_wrapper, -) +from .. import _has_numba, ffi, lib +from .base import OpBase, ParameterizedUdf, TypedOpBase, _call_op, _deserialize_parameterized + +if _has_numba: + import numba + from .base import _get_udt_wrapper ffi_new = ffi.new @@ -65,6 +60,7 @@ def _call(self, *args, **kwargs): return IndexUnaryOp.register_anonymous(indexunary, self.name, is_udt=self._is_udt) def __reduce__(self): + # NOT COVERED name = f"indexunary.{self.name}" if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: return name @@ -72,6 +68,7 @@ def __reduce__(self): @staticmethod def _deserialize(name, func, anonymous): + # NOT COVERED if anonymous: return IndexUnaryOp.register_anonymous(func, name, parameterized=True) if (rv := IndexUnaryOp._find(name)) is not None: @@ -249,6 +246,7 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals Because it is not registered in the namespace, the name is optional. """ + cls._check_supports_udf("register_anonymous") if parameterized: return ParameterizedIndexUnaryOp(name, func, anonymous=True, is_udt=is_udt) return cls._build(name, func, anonymous=True, is_udt=is_udt) @@ -265,6 +263,7 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal >>> dir(gb.indexunary) [..., 'row_mod', ...] """ + cls._check_supports_udf("register_new") module, funcname = cls._remove_nesting(name) if lazy: module._delayed[funcname] = ( @@ -281,9 +280,12 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal if all(x == BOOL for x in indexunary_op.types.values()): from .select import SelectOp - setattr(select, funcname, SelectOp._from_indexunary(indexunary_op)) + select_module, funcname = SelectOp._remove_nesting(name, strict=False) + setattr(select_module, funcname, SelectOp._from_indexunary(indexunary_op)) + if not cls._initialized: # pragma: no cover (safety) + _STANDARD_OPERATOR_NAMES.add(f"{SelectOp._modname}.{name}") - if not cls._initialized: + if not cls._initialized: # pragma: no cover (safety) _STANDARD_OPERATOR_NAMES.add(f"{cls._modname}.{name}") if not lazy: return indexunary_op @@ -323,6 +325,7 @@ def _initialize(cls): "valueeq", "valuene", "valuegt", "valuege", "valuelt", "valuele"]: iop = getattr(indexunary, name) setattr(select, name, SelectOp._from_indexunary(iop)) + _STANDARD_OPERATOR_NAMES.add(f"{SelectOp._modname}.{name}") # fmt: on cls._initialized = True @@ -348,10 +351,12 @@ def __init__( def __reduce__(self): if self._anonymous: if hasattr(self.orig_func, "_parameterized_info"): + # NOT COVERED return (_deserialize_parameterized, self.orig_func._parameterized_info) return (self.register_anonymous, (self.orig_func, self.name)) if (name := f"indexunary.{self.name}") in _STANDARD_OPERATOR_NAMES: return name + # NOT COVERED return (self._deserialize, (self.name, self.orig_func)) __call__ = TypedBuiltinIndexUnaryOp.__call__ diff --git a/graphblas/core/operator/select.py b/graphblas/core/operator/select.py index 844565f3a..27567eb2f 100644 --- a/graphblas/core/operator/select.py +++ b/graphblas/core/operator/select.py @@ -51,6 +51,7 @@ def _call(self, *args, **kwargs): return SelectOp.register_anonymous(sel, self.name, is_udt=self._is_udt) def __reduce__(self): + # NOT COVERED name = f"select.{self.name}" if not self._anonymous and name in _STANDARD_OPERATOR_NAMES: return name @@ -58,6 +59,7 @@ def __reduce__(self): @staticmethod def _deserialize(name, func, anonymous): + # NOT COVERED if anonymous: return SelectOp.register_anonymous(func, name, parameterized=True) if (rv := SelectOp._find(name)) is not None: @@ -124,6 +126,7 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals Because it is not registered in the namespace, the name is optional. """ + cls._check_supports_udf("register_anonymous") if parameterized: return ParameterizedSelectOp(name, func, anonymous=True, is_udt=is_udt) iop = IndexUnaryOp._build(name, func, anonymous=True, is_udt=is_udt) @@ -140,13 +143,36 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal >>> dir(gb.select) [..., 'upper_left_triangle', ...] """ + cls._check_supports_udf("register_new") iop = IndexUnaryOp.register_new( name, func, parameterized=parameterized, is_udt=is_udt, lazy=lazy ) + module, funcname = cls._remove_nesting(name, strict=False) + if lazy: + module._delayed[funcname] = ( + cls._get_delayed, + {"name": name}, + ) + elif parameterized: + op = ParameterizedSelectOp(funcname, func, is_udt=is_udt) + setattr(module, funcname, op) + return op + elif not all(x == BOOL for x in iop.types.values()): + # Undo registration of indexunaryop + imodule, funcname = IndexUnaryOp._remove_nesting(name, strict=False) + delattr(imodule, funcname) + raise ValueError("SelectOp must have BOOL return type") + else: + return getattr(module, funcname) + + @classmethod + def _get_delayed(cls, name): + imodule, funcname = IndexUnaryOp._remove_nesting(name, strict=False) + iop = getattr(imodule, name) if not all(x == BOOL for x in iop.types.values()): raise ValueError("SelectOp must have BOOL return type") - if lazy: - return getattr(select, iop.name) + module, funcname = cls._remove_nesting(name, strict=False) + return getattr(module, funcname) @classmethod def _initialize(cls): @@ -172,16 +198,19 @@ def __init__( self.is_positional = is_positional self._is_udt = is_udt if is_udt: + # NOT COVERED self._udt_types = {} # {dtype: DataType} self._udt_ops = {} # {dtype: TypedUserIndexUnaryOp} def __reduce__(self): if self._anonymous: if hasattr(self.orig_func, "_parameterized_info"): + # NOT COVERED return (_deserialize_parameterized, self.orig_func._parameterized_info) return (self.register_anonymous, (self.orig_func, self.name)) if (name := f"select.{self.name}") in _STANDARD_OPERATOR_NAMES: return name + # NOT COVERED return (self._deserialize, (self.name, self.orig_func)) __call__ = TypedBuiltinSelectOp.__call__ diff --git a/graphblas/core/operator/semiring.py b/graphblas/core/operator/semiring.py index 06450e007..ac716b9dd 100644 --- a/graphblas/core/operator/semiring.py +++ b/graphblas/core/operator/semiring.py @@ -17,7 +17,7 @@ _supports_complex, ) from ...exceptions import check_status_carg -from .. import ffi, lib +from .. import _supports_udfs, ffi, lib from .base import _SS_OPERATORS, OpBase, ParameterizedUdf, TypedOpBase, _call_op, _hasop from .binary import BinaryOp, ParameterizedBinaryOp from .monoid import Monoid, ParameterizedMonoid @@ -358,15 +358,17 @@ def _initialize(cls): for orig_name, orig in div_semirings.items(): cls.register_new(f"{orig_name[:-3]}truediv", orig.monoid, binary.truediv, lazy=True) cls.register_new(f"{orig_name[:-3]}rtruediv", orig.monoid, "rtruediv", lazy=True) - cls.register_new(f"{orig_name[:-3]}floordiv", orig.monoid, "floordiv", lazy=True) - cls.register_new(f"{orig_name[:-3]}rfloordiv", orig.monoid, "rfloordiv", lazy=True) + if _supports_udfs: + cls.register_new(f"{orig_name[:-3]}floordiv", orig.monoid, "floordiv", lazy=True) + cls.register_new(f"{orig_name[:-3]}rfloordiv", orig.monoid, "rfloordiv", lazy=True) # For aggregators cls.register_new("plus_pow", monoid.plus, binary.pow) - cls.register_new("plus_rpow", monoid.plus, "rpow", lazy=True) - cls.register_new("plus_absfirst", monoid.plus, "absfirst", lazy=True) - cls.register_new("max_absfirst", monoid.max, "absfirst", lazy=True) - cls.register_new("plus_abssecond", monoid.plus, "abssecond", lazy=True) - cls.register_new("max_abssecond", monoid.max, "abssecond", lazy=True) + if _supports_udfs: + cls.register_new("plus_rpow", monoid.plus, "rpow", lazy=True) + cls.register_new("plus_absfirst", monoid.plus, "absfirst", lazy=True) + cls.register_new("max_absfirst", monoid.max, "absfirst", lazy=True) + cls.register_new("plus_abssecond", monoid.plus, "abssecond", lazy=True) + cls.register_new("max_abssecond", monoid.max, "abssecond", lazy=True) # Update type information with sane coercion for lname in ["any", "eq", "land", "lor", "lxnor", "lxor"]: diff --git a/graphblas/core/operator/unary.py b/graphblas/core/operator/unary.py index 6b1319057..1432a9387 100644 --- a/graphblas/core/operator/unary.py +++ b/graphblas/core/operator/unary.py @@ -2,8 +2,6 @@ import re from types import FunctionType -import numba - from ... import _STANDARD_OPERATOR_NAMES, op, unary from ...dtypes import ( BOOL, @@ -22,7 +20,7 @@ lookup_dtype, ) from ...exceptions import UdfParseError, check_status_carg -from .. import ffi, lib +from .. import _has_numba, ffi, lib from ..utils import output_type from .base import ( _SS_OPERATORS, @@ -30,12 +28,15 @@ ParameterizedUdf, TypedOpBase, _deserialize_parameterized, - _get_udt_wrapper, _hasop, ) if _supports_complex: from ...dtypes import FC32, FC64 +if _has_numba: + import numba + + from .base import _get_udt_wrapper ffi_new = ffi.new @@ -276,6 +277,7 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals Because it is not registered in the namespace, the name is optional. """ + cls._check_supports_udf("register_anonymous") if parameterized: return ParameterizedUnaryOp(name, func, anonymous=True, is_udt=is_udt) return cls._build(name, func, anonymous=True, is_udt=is_udt) @@ -289,6 +291,7 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal >>> dir(gb.unary) [..., 'plus_one', ...] """ + cls._check_supports_udf("register_new") module, funcname = cls._remove_nesting(name) if lazy: module._delayed[funcname] = ( @@ -372,7 +375,10 @@ def _initialize(cls): (unary.one, _one), ]: unop.orig_func = func - unop._numba_func = numba.njit(func) + if _has_numba: + unop._numba_func = numba.njit(func) + else: + unop._numba_func = None unop._udt_types = {} unop._udt_ops = {} cls._initialized = True diff --git a/graphblas/core/recorder.py b/graphblas/core/recorder.py index ce79c85ff..2268c31eb 100644 --- a/graphblas/core/recorder.py +++ b/graphblas/core/recorder.py @@ -137,10 +137,10 @@ def _repr_base_(self): tail = "\n\n\n

" return "\n".join(head), tail - def _repr_html_(self): # pragma: no cover + def _repr_html_(self): try: from IPython.display import Code - except ImportError as exc: + except ImportError as exc: # pragma: no cover (import) raise NotImplementedError from exc lines = self._get_repr_lines() code = Code("\n".join(lines), language="C") diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index 93b5ebb4b..a7a251a1d 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -3,15 +3,19 @@ import numpy as np from .. import backend, binary, config, monoid -from ..binary import isclose from ..dtypes import _INDEX, FP64, lookup_dtype, unify from ..exceptions import EmptyObject, check_status -from . import automethods, ffi, lib, utils +from . import _has_numba, _supports_udfs, automethods, ffi, lib, utils from .base import BaseExpression, BaseType, call from .expr import AmbiguousAssignOrExtract from .operator import get_typed_op from .utils import _Pointer, output_type, wrapdoc +if _supports_udfs: + from ..binary import isclose +else: + from .operator.binary import _isclose as isclose + ffi_new = ffi.new @@ -261,6 +265,17 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False): return False # We can't yet call a UDF on a scalar as part of the spec, so let's do it ourselves isclose_func = isclose(rel_tol, abs_tol) + if not _has_numba: + # Check if types are compatible + get_typed_op( + binary.eq, + self.dtype, + other.dtype, + is_left_scalar=True, + is_right_scalar=True, + kind="binary", + ) + return isclose_func(self.value, other.value) isclose_func = get_typed_op( isclose_func, self.dtype, diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index b1869f198..cac0296c7 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -1,9 +1,7 @@ import itertools import warnings -import numba import numpy as np -from numba import njit from suitesparse_graphblas.utils import claim_buffer, claim_buffer_2d, unclaim_buffer import graphblas as gb @@ -11,7 +9,7 @@ from ... import binary, monoid from ...dtypes import _INDEX, BOOL, INT64, UINT64, _string_to_dtype, lookup_dtype from ...exceptions import _error_code_lookup, check_status, check_status_carg -from .. import NULL, ffi, lib +from .. import NULL, _has_numba, ffi, lib from ..base import call from ..operator import get_typed_op from ..scalar import Scalar, _as_scalar, _scalar_index @@ -30,6 +28,16 @@ from .config import BaseConfig from .descriptor import get_descriptor +if _has_numba: + from numba import njit, prange +else: + + def njit(func=None, **kwargs): + if func is not None: + return func + return njit + + prange = range ffi_new = ffi.new @@ -888,7 +896,7 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m col_indices = claim_buffer(ffi, Aj[0], Aj_size[0] // index_dtype.itemsize, index_dtype) values = claim_buffer(ffi, Ax[0], Ax_size[0] // dtype.itemsize, dtype) if not raw: - if indptr.size > nrows + 1: + if indptr.size > nrows + 1: # pragma: no cover (suitesparse) indptr = indptr[: nrows + 1] if col_indices.size > nvals: col_indices = col_indices[:nvals] @@ -929,7 +937,7 @@ def _export(self, format=None, *, sort=False, give_ownership=False, raw=False, m row_indices = claim_buffer(ffi, Ai[0], Ai_size[0] // index_dtype.itemsize, index_dtype) values = claim_buffer(ffi, Ax[0], Ax_size[0] // dtype.itemsize, dtype) if not raw: - if indptr.size > ncols + 1: + if indptr.size > ncols + 1: # pragma: no cover (suitesparse) indptr = indptr[: ncols + 1] if row_indices.size > nvals: row_indices = row_indices[:nvals] @@ -1786,6 +1794,7 @@ def import_hypercsc( ---------- nrows : int ncols : int + cols : array-like indptr : array-like values : array-like row_indices : array-like @@ -4371,28 +4380,28 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts): return rv -@numba.njit(parallel=True) +@njit(parallel=True) def argsort_values(indptr, indices, values): # pragma: no cover (numba) rv = np.empty(indptr[-1], dtype=np.uint64) - for i in numba.prange(indptr.size - 1): + for i in prange(indptr.size - 1): rv[indptr[i] : indptr[i + 1]] = indices[ np.int64(indptr[i]) + np.argsort(values[indptr[i] : indptr[i + 1]]) ] return rv -@numba.njit(parallel=True) +@njit(parallel=True) def sort_values(indptr, values): # pragma: no cover (numba) rv = np.empty(indptr[-1], dtype=values.dtype) - for i in numba.prange(indptr.size - 1): + for i in prange(indptr.size - 1): rv[indptr[i] : indptr[i + 1]] = np.sort(values[indptr[i] : indptr[i + 1]]) return rv -@numba.njit(parallel=True) +@njit(parallel=True) def compact_values(old_indptr, new_indptr, values): # pragma: no cover (numba) rv = np.empty(new_indptr[-1], dtype=values.dtype) - for i in numba.prange(new_indptr.size - 1): + for i in prange(new_indptr.size - 1): start = np.int64(new_indptr[i]) offset = np.int64(old_indptr[i]) - start for j in range(start, new_indptr[i + 1]): @@ -4400,17 +4409,17 @@ def compact_values(old_indptr, new_indptr, values): # pragma: no cover (numba) return rv -@numba.njit(parallel=True) +@njit(parallel=True) def reverse_values(indptr, values): # pragma: no cover (numba) rv = np.empty(indptr[-1], dtype=values.dtype) - for i in numba.prange(indptr.size - 1): + for i in prange(indptr.size - 1): offset = np.int64(indptr[i]) + np.int64(indptr[i + 1]) - 1 for j in range(indptr[i], indptr[i + 1]): rv[j] = values[offset - j] return rv -@numba.njit(parallel=True) +@njit(parallel=True) def compact_indices(indptr, k): # pragma: no cover (numba) """Given indptr from hypercsr, create a new col_indices array that is compact. @@ -4420,7 +4429,7 @@ def compact_indices(indptr, k): # pragma: no cover (numba) indptr = create_indptr(indptr, k) col_indices = np.empty(indptr[-1], dtype=np.uint64) N = np.int64(0) - for i in numba.prange(indptr.size - 1): + for i in prange(indptr.size - 1): start = np.int64(indptr[i]) deg = np.int64(indptr[i + 1]) - start N = max(N, deg) @@ -4433,7 +4442,7 @@ def compact_indices(indptr, k): # pragma: no cover (numba) def choose_random1(indptr): # pragma: no cover (numba) choices = np.empty(indptr.size - 1, dtype=indptr.dtype) new_indptr = np.arange(indptr.size, dtype=indptr.dtype) - for i in numba.prange(indptr.size - 1): + for i in prange(indptr.size - 1): idx = np.int64(indptr[i]) deg = np.int64(indptr[i + 1]) - idx if deg == 1: @@ -4470,7 +4479,7 @@ def choose_random(indptr, k): # pragma: no cover (numba) # be nice to have them sorted if convenient to do so. new_indptr = create_indptr(indptr, k) choices = np.empty(new_indptr[-1], dtype=indptr.dtype) - for i in numba.prange(indptr.size - 1): + for i in prange(indptr.size - 1): idx = np.int64(indptr[i]) deg = np.int64(indptr[i + 1]) - idx if k < deg: @@ -4551,7 +4560,7 @@ def choose_first(indptr, k): # pragma: no cover (numba) new_indptr = create_indptr(indptr, k) choices = np.empty(new_indptr[-1], dtype=indptr.dtype) - for i in numba.prange(indptr.size - 1): + for i in prange(indptr.size - 1): idx = np.int64(indptr[i]) deg = np.int64(indptr[i + 1]) - idx if k < deg: @@ -4575,7 +4584,7 @@ def choose_last(indptr, k): # pragma: no cover (numba) new_indptr = create_indptr(indptr, k) choices = np.empty(new_indptr[-1], dtype=indptr.dtype) - for i in numba.prange(indptr.size - 1): + for i in prange(indptr.size - 1): idx = np.int64(indptr[i]) deg = np.int64(indptr[i + 1]) - idx if k < deg: @@ -4608,19 +4617,20 @@ def indices_to_indptr(indices, size): # pragma: no cover (numba) """Calculate the indptr for e.g. CSR from sorted COO rows.""" indptr = np.zeros(size, dtype=indices.dtype) index = np.uint64(0) + one = np.uint64(1) for i in range(indices.size): row = indices[i] if row != index: - indptr[index + 1] = i + indptr[index + one] = i index = row - indptr[index + 1] = indices.size + indptr[index + one] = indices.size return indptr @njit(parallel=True) def indptr_to_indices(indptr): # pragma: no cover (numba) indices = np.empty(indptr[-1], dtype=indptr.dtype) - for i in numba.prange(indptr.size - 1): + for i in prange(indptr.size - 1): for j in range(indptr[i], indptr[i + 1]): indices[j] = i return indices diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py index 343335773..2b1e8bf05 100644 --- a/graphblas/core/ss/vector.py +++ b/graphblas/core/ss/vector.py @@ -1,7 +1,6 @@ import itertools import numpy as np -from numba import njit from suitesparse_graphblas.utils import claim_buffer, unclaim_buffer import graphblas as gb @@ -23,7 +22,7 @@ ) from .config import BaseConfig from .descriptor import get_descriptor -from .matrix import _concat_mn +from .matrix import _concat_mn, njit from .prefix_scan import prefix_scan ffi_new = ffi.new @@ -588,7 +587,7 @@ 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] - elif values.size > size: # pragma: no branch (suitesparse) + elif values.size > size: # pragma: no cover (suitesparse) values = values[:size] rv = { "bitmap": bitmap, diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py index 0beeb4a2a..77c64a7ac 100644 --- a/graphblas/core/utils.py +++ b/graphblas/core/utils.py @@ -131,6 +131,7 @@ def get_shape(nrows, ncols, dtype=None, **arrays): # We could be smarter and determine the shape of the dtype sub-arrays if arr.ndim >= 3: break + # BRANCH NOT COVERED elif arr.ndim == 2: break else: diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 8231691c6..57851420d 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -3,10 +3,10 @@ import numpy as np -from .. import backend, binary, monoid, select, semiring +from .. import backend, binary, monoid, select, semiring, unary from ..dtypes import _INDEX, FP64, INT64, lookup_dtype, unify from ..exceptions import DimensionMismatch, NoValue, check_status -from . import automethods, ffi, lib, utils +from . import _supports_udfs, automethods, ffi, lib, utils from .base import BaseExpression, BaseType, _check_mask, call from .descriptor import lookup as descriptor_lookup from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, Updater @@ -93,6 +93,45 @@ def _select_mask(updater, obj, mask): updater << obj.dup(mask=mask) +def _isclose_recipe(self, other, rel_tol, abs_tol, **opts): + # x == y or abs(x - y) <= max(rel_tol * max(abs(x), abs(y)), abs_tol) + isequal = self.ewise_mult(other, binary.eq).new(bool, name="isclose", **opts) + if isequal._nvals != self._nvals: + return False + if type(isequal) is Vector: + val = isequal.reduce(monoid.land, allow_empty=False).new(**opts).value + else: + val = isequal.reduce_scalar(monoid.land, allow_empty=False).new(**opts).value + if val: + return True + # So we can use structural mask below + isequal(**opts) << select.value(isequal == True) # noqa: E712 + + # abs(x) + x = self.apply(unary.abs).new(FP64, mask=~isequal.S, **opts) + # abs(y) + y = other.apply(unary.abs).new(FP64, mask=~isequal.S, **opts) + # max(abs(x), abs(y)) + x(**opts) << x.ewise_mult(y, binary.max) + max_x_y = x + # rel_tol * max(abs(x), abs(y)) + max_x_y(**opts) << max_x_y.apply(binary.times, rel_tol) + # max(rel_tol * max(abs(x), abs(y)), abs_tol) + max_x_y(**opts) << max_x_y.apply(binary.max, abs_tol) + + # x - y + y(~isequal.S, replace=True, **opts) << self.ewise_mult(other, binary.minus) + abs_x_y = y + # abs(x - y) + abs_x_y(**opts) << abs_x_y.apply(unary.abs) + + # abs(x - y) <= max(rel_tol * max(abs(x), abs(y)), abs_tol) + isequal(**opts) << abs_x_y.ewise_mult(max_x_y, binary.le) + if isequal.ndim == 1: + return isequal.reduce(monoid.land, allow_empty=False).new(**opts).value + return isequal.reduce_scalar(monoid.land, allow_empty=False).new(**opts).value + + class Vector(BaseType): """Create a new GraphBLAS Sparse Vector. @@ -354,6 +393,8 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False, **opts return False if self._nvals != other._nvals: return False + if not _supports_udfs: + return _isclose_recipe(self, other, rel_tol, abs_tol, **opts) matches = self.ewise_mult(other, binary.isclose(rel_tol, abs_tol)).new( bool, name="M_isclose", **opts @@ -520,14 +561,15 @@ def build(self, indices, values, *, dup_op=None, clear=False, size=None): if not dup_op_given: if not self.dtype._is_udt: dup_op = binary.plus - else: + elif backend != "suitesparse": dup_op = binary.any - # SS:SuiteSparse-specific: we could use NULL for dup_op - dup_op = get_typed_op(dup_op, self.dtype, kind="binary") - if dup_op.opclass == "Monoid": - dup_op = dup_op.binaryop - else: - self._expect_op(dup_op, "BinaryOp", within="build", argname="dup_op") + # SS:SuiteSparse-specific: we use NULL for dup_op + if dup_op is not None: + dup_op = get_typed_op(dup_op, self.dtype, kind="binary") + if dup_op.opclass == "Monoid": + dup_op = dup_op.binaryop + else: + self._expect_op(dup_op, "BinaryOp", within="build", argname="dup_op") indices = _CArray(indices) values = _CArray(values, self.dtype) @@ -1500,6 +1542,7 @@ def select(self, op, thunk=None): if thunk.dtype._is_udt: dtype_name = "UDT" thunk = _Pointer(thunk) + # NOT COVERED else: dtype_name = thunk.dtype.name cfunc_name = f"GrB_Vector_select_{dtype_name}" @@ -1817,13 +1860,14 @@ def _prep_for_assign(self, resolved_indexes, value, mask, is_submask, replace, o shape = values.shape try: vals = Vector.from_dense(values, dtype=dtype) - except Exception: # pragma: no cover (safety) + except Exception: vals = None else: if dtype.np_type.subdtype is not None: shape = vals.shape if vals is None or shape != (size,): if dtype.np_type.subdtype is not None: + # NOT COVERED extra = ( " (this is assigning to a vector with sub-array dtype " f"({dtype}), so array shape should include dtype shape)" @@ -1943,7 +1987,7 @@ def from_dict(cls, d, dtype=None, *, size=None, name=None): # If we know the dtype, then using `np.fromiter` is much faster dtype = lookup_dtype(dtype) if dtype.np_type.subdtype is not None and np.__version__[:5] in {"1.21.", "1.22."}: - values, dtype = values_to_numpy_buffer(list(d.values()), dtype) + values, dtype = values_to_numpy_buffer(list(d.values()), dtype) # FLAKY COVERAGE else: values = np.fromiter(d.values(), dtype.np_type) if size is None and indices.size == 0: diff --git a/graphblas/dtypes.py b/graphblas/dtypes.py index 22d98b8f1..920610b95 100644 --- a/graphblas/dtypes.py +++ b/graphblas/dtypes.py @@ -1,15 +1,18 @@ import warnings as _warnings -import numba as _numba import numpy as _np from numpy import find_common_type as _find_common_type from numpy import promote_types as _promote_types from . import backend from .core import NULL as _NULL +from .core import _has_numba from .core import ffi as _ffi from .core import lib as _lib +if _has_numba: + import numba as _numba + # Default assumption unless FC32/FC64 are found in lib _supports_complex = hasattr(_lib, "GrB_FC64") or hasattr(_lib, "GxB_FC64") @@ -140,44 +143,126 @@ def register_anonymous(dtype, name=None): # For now, let's use "opaque" unsigned bytes for the c type. if name is None: name = _default_name(dtype) - numba_type = _numba.typeof(dtype).dtype + numba_type = _numba.typeof(dtype).dtype if _has_numba else None rv = DataType(name, gb_obj, None, f"uint8_t[{dtype.itemsize}]", numba_type, dtype) _registry[gb_obj] = rv _registry[dtype] = rv - _registry[numba_type] = rv - _registry[numba_type.name] = rv + if _has_numba: + _registry[numba_type] = rv + _registry[numba_type.name] = rv return rv -BOOL = DataType("BOOL", _lib.GrB_BOOL, "GrB_BOOL", "_Bool", _numba.types.bool_, _np.bool_) -INT8 = DataType("INT8", _lib.GrB_INT8, "GrB_INT8", "int8_t", _numba.types.int8, _np.int8) -UINT8 = DataType("UINT8", _lib.GrB_UINT8, "GrB_UINT8", "uint8_t", _numba.types.uint8, _np.uint8) -INT16 = DataType("INT16", _lib.GrB_INT16, "GrB_INT16", "int16_t", _numba.types.int16, _np.int16) +BOOL = DataType( + "BOOL", + _lib.GrB_BOOL, + "GrB_BOOL", + "_Bool", + _numba.types.bool_ if _has_numba else None, + _np.bool_, +) +INT8 = DataType( + "INT8", _lib.GrB_INT8, "GrB_INT8", "int8_t", _numba.types.int8 if _has_numba else None, _np.int8 +) +UINT8 = DataType( + "UINT8", + _lib.GrB_UINT8, + "GrB_UINT8", + "uint8_t", + _numba.types.uint8 if _has_numba else None, + _np.uint8, +) +INT16 = DataType( + "INT16", + _lib.GrB_INT16, + "GrB_INT16", + "int16_t", + _numba.types.int16 if _has_numba else None, + _np.int16, +) UINT16 = DataType( - "UINT16", _lib.GrB_UINT16, "GrB_UINT16", "uint16_t", _numba.types.uint16, _np.uint16 + "UINT16", + _lib.GrB_UINT16, + "GrB_UINT16", + "uint16_t", + _numba.types.uint16 if _has_numba else None, + _np.uint16, +) +INT32 = DataType( + "INT32", + _lib.GrB_INT32, + "GrB_INT32", + "int32_t", + _numba.types.int32 if _has_numba else None, + _np.int32, ) -INT32 = DataType("INT32", _lib.GrB_INT32, "GrB_INT32", "int32_t", _numba.types.int32, _np.int32) UINT32 = DataType( - "UINT32", _lib.GrB_UINT32, "GrB_UINT32", "uint32_t", _numba.types.uint32, _np.uint32 + "UINT32", + _lib.GrB_UINT32, + "GrB_UINT32", + "uint32_t", + _numba.types.uint32 if _has_numba else None, + _np.uint32, +) +INT64 = DataType( + "INT64", + _lib.GrB_INT64, + "GrB_INT64", + "int64_t", + _numba.types.int64 if _has_numba else None, + _np.int64, ) -INT64 = DataType("INT64", _lib.GrB_INT64, "GrB_INT64", "int64_t", _numba.types.int64, _np.int64) # _Index (like UINT64) is for internal use only and shouldn't be exposed to the user _INDEX = DataType( - "UINT64", _lib.GrB_UINT64, "GrB_Index", "GrB_Index", _numba.types.uint64, _np.uint64 + "UINT64", + _lib.GrB_UINT64, + "GrB_Index", + "GrB_Index", + _numba.types.uint64 if _has_numba else None, + _np.uint64, ) UINT64 = DataType( - "UINT64", _lib.GrB_UINT64, "GrB_UINT64", "uint64_t", _numba.types.uint64, _np.uint64 + "UINT64", + _lib.GrB_UINT64, + "GrB_UINT64", + "uint64_t", + _numba.types.uint64 if _has_numba else None, + _np.uint64, +) +FP32 = DataType( + "FP32", + _lib.GrB_FP32, + "GrB_FP32", + "float", + _numba.types.float32 if _has_numba else None, + _np.float32, +) +FP64 = DataType( + "FP64", + _lib.GrB_FP64, + "GrB_FP64", + "double", + _numba.types.float64 if _has_numba else None, + _np.float64, ) -FP32 = DataType("FP32", _lib.GrB_FP32, "GrB_FP32", "float", _numba.types.float32, _np.float32) -FP64 = DataType("FP64", _lib.GrB_FP64, "GrB_FP64", "double", _numba.types.float64, _np.float64) if _supports_complex and hasattr(_lib, "GxB_FC32"): FC32 = DataType( - "FC32", _lib.GxB_FC32, "GxB_FC32", "float _Complex", _numba.types.complex64, _np.complex64 + "FC32", + _lib.GxB_FC32, + "GxB_FC32", + "float _Complex", + _numba.types.complex64 if _has_numba else None, + _np.complex64, ) if _supports_complex and hasattr(_lib, "GrB_FC32"): # pragma: no cover (unused) FC32 = DataType( - "FC32", _lib.GrB_FC32, "GrB_FC32", "float _Complex", _numba.types.complex64, _np.complex64 + "FC32", + _lib.GrB_FC32, + "GrB_FC32", + "float _Complex", + _numba.types.complex64 if _has_numba else None, + _np.complex64, ) if _supports_complex and hasattr(_lib, "GxB_FC64"): FC64 = DataType( @@ -185,7 +270,7 @@ def register_anonymous(dtype, name=None): _lib.GxB_FC64, "GxB_FC64", "double _Complex", - _numba.types.complex128, + _numba.types.complex128 if _has_numba else None, _np.complex128, ) if _supports_complex and hasattr(_lib, "GrB_FC64"): # pragma: no cover (unused) @@ -194,7 +279,7 @@ def register_anonymous(dtype, name=None): _lib.GrB_FC64, "GrB_FC64", "double _Complex", - _numba.types.complex128, + _numba.types.complex128 if _has_numba else None, _np.complex128, ) @@ -246,8 +331,9 @@ def register_anonymous(dtype, name=None): _registry[dtype.gb_name.lower()] = dtype _registry[dtype.c_type] = dtype _registry[dtype.c_type.upper()] = dtype - _registry[dtype.numba_type] = dtype - _registry[dtype.numba_type.name] = dtype + if _has_numba: + _registry[dtype.numba_type] = dtype + _registry[dtype.numba_type.name] = dtype val = _sample_values[dtype] _registry[val.dtype] = dtype _registry[val.dtype.name] = dtype diff --git a/graphblas/io.py b/graphblas/io.py index bc57c2084..23b9b30b7 100644 --- a/graphblas/io.py +++ b/graphblas/io.py @@ -11,7 +11,7 @@ from .exceptions import GraphblasException as _GraphblasException -def draw(m): # pragma: no cover +def draw(m): # pragma: no cover (deprecated) """Draw a square adjacency Matrix as a graph. Requires `networkx `_ and @@ -455,7 +455,7 @@ def to_awkward(A, format=None): indices, values = A.to_coo() form = RecordForm( contents=[ - NumpyForm(A.dtype.numba_type.name, form_key="node1"), + NumpyForm(A.dtype.np_type.name, form_key="node1"), NumpyForm("int64", form_key="node0"), ], fields=["values", "indices"], @@ -489,7 +489,7 @@ def to_awkward(A, format=None): RecordForm( contents=[ NumpyForm("int64", form_key="node3"), - NumpyForm(A.dtype.numba_type.name, form_key="node4"), + NumpyForm(A.dtype.np_type.name, form_key="node4"), ], fields=["indices", "values"], ), @@ -502,11 +502,11 @@ def to_awkward(A, format=None): @ak.behaviors.mixins.mixin_class(ak.behavior) class _AwkwardDoublyCompressedMatrix: @property - def values(self): + def values(self): # pragma: no branch (???) return self.data.values @property - def indices(self): + def indices(self): # pragma: no branch (???) return self.data.indices form = RecordForm( diff --git a/graphblas/monoid/numpy.py b/graphblas/monoid/numpy.py index 1d687443f..f46d57143 100644 --- a/graphblas/monoid/numpy.py +++ b/graphblas/monoid/numpy.py @@ -5,15 +5,18 @@ https://numba.readthedocs.io/en/stable/reference/numpysupported.html#math-operations """ -import numba as _numba import numpy as _np from .. import _STANDARD_OPERATOR_NAMES from .. import binary as _binary from .. import config as _config from .. import monoid as _monoid +from ..core import _has_numba, _supports_udfs from ..dtypes import _supports_complex +if _has_numba: + import numba as _numba + _delayed = {} _complex_dtypes = {"FC32", "FC64"} _float_dtypes = {"FP32", "FP64"} @@ -86,7 +89,8 @@ # To increase import speed, only call njit when `_config.get("mapnumpy")` is False if ( _config.get("mapnumpy") - or type(_numba.njit(lambda x, y: _np.fmax(x, y))(1, 2)) # pragma: no branch (numba) + or _has_numba + and type(_numba.njit(lambda x, y: _np.fmax(x, y))(1, 2)) # pragma: no branch (numba) is not float ): # Incorrect behavior was introduced in numba 0.56.2 and numpy 1.23 @@ -155,7 +159,12 @@ def __dir__(): - return globals().keys() | _delayed.keys() | _monoid_identities.keys() + if not _supports_udfs and not _config.get("mapnumpy"): + return globals().keys() # FLAKY COVERAGE + attrs = _delayed.keys() | _monoid_identities.keys() + if not _supports_udfs: + attrs &= _numpy_to_graphblas.keys() + return attrs | globals().keys() def __getattr__(name): diff --git a/graphblas/op/__init__.py b/graphblas/op/__init__.py index af05cbef4..1eb2b51d7 100644 --- a/graphblas/op/__init__.py +++ b/graphblas/op/__init__.py @@ -39,10 +39,18 @@ def __getattr__(key): ss = import_module(".ss", __name__) globals()["ss"] = ss return ss + if not _supports_udfs: + from .. import binary, semiring + + if key in binary._udfs or key in semiring._udfs: + raise AttributeError( + f"module {__name__!r} unable to compile UDF for {key!r}; " + "install numba for UDF support" + ) raise AttributeError(f"module {__name__!r} has no attribute {key!r}") -from ..core import operator # noqa: E402 isort:skip +from ..core import operator, _supports_udfs # noqa: E402 isort:skip from . import numpy # noqa: E402 isort:skip del operator diff --git a/graphblas/op/numpy.py b/graphblas/op/numpy.py index 497a6037c..cadba17eb 100644 --- a/graphblas/op/numpy.py +++ b/graphblas/op/numpy.py @@ -1,4 +1,5 @@ from ..binary import numpy as _np_binary +from ..core import _supports_udfs from ..semiring import numpy as _np_semiring from ..unary import numpy as _np_unary @@ -10,7 +11,10 @@ def __dir__(): - return globals().keys() | _delayed.keys() | _op_to_mod.keys() + attrs = _delayed.keys() | _op_to_mod.keys() + if not _supports_udfs: + attrs &= _np_unary.__dir__() | _np_binary.__dir__() | _np_semiring.__dir__() + return attrs | globals().keys() def __getattr__(name): diff --git a/graphblas/semiring/__init__.py b/graphblas/semiring/__init__.py index 904ae192f..538136406 100644 --- a/graphblas/semiring/__init__.py +++ b/graphblas/semiring/__init__.py @@ -1,7 +1,29 @@ # All items are dynamically added by classes in operator.py # This module acts as a container of Semiring instances +from ..core import _supports_udfs + _delayed = {} _deprecated = {} +_udfs = { + # Used by aggregators + "max_absfirst", + "max_abssecond", + "plus_absfirst", + "plus_abssecond", + "plus_rpow", + # floordiv + "any_floordiv", + "max_floordiv", + "min_floordiv", + "plus_floordiv", + "times_floordiv", + # rfloordiv + "any_rfloordiv", + "max_rfloordiv", + "min_rfloordiv", + "plus_rfloordiv", + "times_rfloordiv", +} def __dir__(): @@ -47,6 +69,11 @@ def __getattr__(key): ss = import_module(".ss", __name__) globals()["ss"] = ss return ss + if not _supports_udfs and key in _udfs: + raise AttributeError( + f"module {__name__!r} unable to compile UDF for {key!r}; " + "install numba for UDF support" + ) raise AttributeError(f"module {__name__!r} has no attribute {key!r}") diff --git a/graphblas/semiring/numpy.py b/graphblas/semiring/numpy.py index e47ac0336..3a59090cc 100644 --- a/graphblas/semiring/numpy.py +++ b/graphblas/semiring/numpy.py @@ -12,6 +12,7 @@ from .. import config as _config from .. import monoid as _monoid from ..binary.numpy import _binary_names +from ..core import _supports_udfs from ..monoid.numpy import _fmin_is_float, _monoid_identities _delayed = {} @@ -132,7 +133,17 @@ def __dir__(): - return globals().keys() | _delayed.keys() | _semiring_names + if not _supports_udfs and not _config.get("mapnumpy"): + return globals().keys() # FLAKY COVERAGE + attrs = _delayed.keys() | _semiring_names + if not _supports_udfs: + attrs &= { + f"{monoid_name}_{binary_name}" + for monoid_name, binary_name in _itertools.product( + dir(_monoid.numpy), dir(_binary.numpy) + ) + } + return attrs | globals().keys() def __getattr__(name): diff --git a/graphblas/tests/conftest.py b/graphblas/tests/conftest.py index 24aba085f..a4df5d336 100644 --- a/graphblas/tests/conftest.py +++ b/graphblas/tests/conftest.py @@ -1,16 +1,20 @@ import atexit import functools import itertools +import platform from pathlib import Path import numpy as np import pytest import graphblas as gb +from graphblas.core import _supports_udfs as supports_udfs orig_binaryops = set() orig_semirings = set() +pypy = platform.python_implementation() == "PyPy" + def pytest_configure(config): rng = np.random.default_rng() @@ -48,7 +52,7 @@ def pytest_configure(config): rec.start() def save_records(): - with Path("record.txt").open("w") as f: # pragma: no cover + with Path("record.txt").open("w") as f: # pragma: no cover (???) f.write("\n".join(rec.data)) # I'm sure there's a `pytest` way to do this... @@ -116,3 +120,8 @@ def inner(*args, **kwargs): def compute(x): return x + + +def shouldhave(module, opname): + """Whether an "operator" module should have the given operator.""" + return supports_udfs or hasattr(module, opname) diff --git a/graphblas/tests/test_core.py b/graphblas/tests/test_core.py index 71d0bd8a3..ae2051145 100644 --- a/graphblas/tests/test_core.py +++ b/graphblas/tests/test_core.py @@ -80,7 +80,7 @@ def test_packages(): pkgs.append("graphblas") pkgs.sort() pyproject = path.parent / "pyproject.toml" - if not pyproject.exists(): + if not pyproject.exists(): # pragma: no cover (safety) pytest.skip("Did not find pyproject.toml") with pyproject.open("rb") as f: pkgs2 = sorted(tomli.load(f)["tool"]["setuptools"]["packages"]) diff --git a/graphblas/tests/test_dtype.py b/graphblas/tests/test_dtype.py index 64e6d69ab..66c19cce5 100644 --- a/graphblas/tests/test_dtype.py +++ b/graphblas/tests/test_dtype.py @@ -252,7 +252,4 @@ def test_has_complex(): import suitesparse_graphblas as ssgb from packaging.version import parse - if parse(ssgb.__version__) < parse("7.4.3.1"): - assert not dtypes._supports_complex - else: - assert dtypes._supports_complex + assert dtypes._supports_complex == (parse(ssgb.__version__) >= parse("7.4.3.1")) diff --git a/graphblas/tests/test_formatting.py b/graphblas/tests/test_formatting.py index 3094aea91..faadc983b 100644 --- a/graphblas/tests/test_formatting.py +++ b/graphblas/tests/test_formatting.py @@ -40,9 +40,8 @@ def _printer(text, name, repr_name, indent): # line = f"f'{{CSS_STYLE}}'" in_style = False is_style = True - else: # pragma: no cover (???) - # This definitely gets covered, but why is it not picked up? - continue + else: + continue # FLAKY COVERAGE if repr_name == "repr_html" and line.startswith("\n" + '\n' + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
01
2
\n" + "" + ) diff --git a/graphblas/tests/test_infix.py b/graphblas/tests/test_infix.py index 14af6108c..72e1c8a42 100644 --- a/graphblas/tests/test_infix.py +++ b/graphblas/tests/test_infix.py @@ -360,3 +360,10 @@ def test_infix_expr_value_types(): assert expr._expr is not None assert expr._value is None assert type(expr.new()) is Matrix + assert type(expr._get_value()) is Matrix + assert expr._expr is not None + assert expr._value is not None + assert expr._expr._value is not None + expr._value = None + assert expr._value is None + assert expr._expr._value is None diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index ada092025..24df55e9d 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -267,9 +267,13 @@ def test_mmread_mmwrite(engine): # fast_matrix_market v1.4.5 raises ValueError instead of OverflowError M = gb.io.mmread(mm_in, engine) else: - 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. + if ( + example == "_empty_lines_example" + and engine in {"fmm", "auto"} + and fmm is not None + and fmm.__version__ in {"1.4.5"} + ): + # `fast_matrix_market` __version__ v1.4.5 does not handle this, but v1.5.0 does continue M = gb.io.mmread(mm_in, engine) if not M.isequal(expected): # pragma: no cover (debug) diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 1d42035a3..26017f364 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -11,6 +11,7 @@ import graphblas as gb from graphblas import agg, backend, binary, dtypes, indexunary, monoid, select, semiring, unary +from graphblas.core import _supports_udfs as supports_udfs from graphblas.core import lib from graphblas.exceptions import ( DimensionMismatch, @@ -23,7 +24,7 @@ OutputNotEmpty, ) -from .conftest import autocompute, compute +from .conftest import autocompute, compute, pypy, shouldhave from graphblas import Matrix, Scalar, Vector # isort:skip (for dask-graphblas) @@ -1230,6 +1231,8 @@ def test_apply_indexunary(A): assert w4.isequal(A3) with pytest.raises(TypeError, match="left"): A.apply(select.valueeq, left=s3) + assert pickle.loads(pickle.dumps(indexunary.tril)) is indexunary.tril + assert pickle.loads(pickle.dumps(indexunary.tril[int])) is indexunary.tril[int] def test_select(A): @@ -1259,6 +1262,16 @@ def test_select(A): with pytest.raises(TypeError, match="thunk"): A.select(select.valueeq, object()) + A3rows = Matrix.from_coo([0, 0, 1, 1, 2], [1, 3, 4, 6, 5], [2, 3, 8, 4, 1], nrows=7, ncols=7) + w8 = select.rowle(A, 2).new() + w9 = A.select("row<=", 2).new() + w10 = select.row(A < 3).new() + assert w8.isequal(A3rows) + assert w9.isequal(A3rows) + assert w10.isequal(A3rows) + assert pickle.loads(pickle.dumps(select.tril)) is select.tril + assert pickle.loads(pickle.dumps(select.tril[bool])) is select.tril[bool] + @autocompute def test_select_bools_and_masks(A): @@ -1283,16 +1296,27 @@ def test_select_bools_and_masks(A): A.select(A[0, :].new().S) +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_indexunary_udf(A): def threex_minusthunk(x, row, col, thunk): # pragma: no cover (numba) return 3 * x - thunk - indexunary.register_new("threex_minusthunk", threex_minusthunk) + assert indexunary.register_new("threex_minusthunk", threex_minusthunk) is not None assert hasattr(indexunary, "threex_minusthunk") assert not hasattr(select, "threex_minusthunk") with pytest.raises(ValueError, match="SelectOp must have BOOL return type"): select.register_anonymous(threex_minusthunk) + with pytest.raises(ValueError, match="SelectOp must have BOOL return type"): + select.register_new("bad_select", threex_minusthunk) + assert not hasattr(indexunary, "bad_select") + assert not hasattr(select, "bad_select") + assert select.register_new("bad_select", threex_minusthunk, lazy=True) is None + with pytest.raises(ValueError, match="SelectOp must have BOOL return type"): + select.bad_select + assert not hasattr(select, "bad_select") + assert hasattr(indexunary, "bad_select") # Keep it + expected = Matrix.from_coo( [3, 0, 3, 5, 6, 0, 6, 1, 6, 2, 4, 1], [0, 1, 2, 2, 2, 3, 3, 4, 4, 5, 5, 6], @@ -1308,6 +1332,8 @@ def iii(x, row, col, thunk): # pragma: no cover (numba) select.register_new("iii", iii) assert hasattr(indexunary, "iii") assert hasattr(select, "iii") + assert indexunary.iii[int].orig_func is select.iii[int].orig_func is select.iii.orig_func + assert indexunary.iii[int]._numba_func is select.iii[int]._numba_func is select.iii._numba_func iii_apply = indexunary.register_anonymous(iii) expected = Matrix.from_coo( [3, 0, 3, 5, 6, 0, 6, 1, 6, 2, 4, 1], @@ -1353,15 +1379,17 @@ def test_reduce_agg(A): expected = unary.sqrt[float](squared).new() w5 = A.reduce_rowwise(agg.hypot).new() assert w5.isclose(expected) - w6 = A.reduce_rowwise(monoid.numpy.hypot[float]).new() - assert w6.isclose(expected) + if shouldhave(monoid.numpy, "hypot"): + w6 = A.reduce_rowwise(monoid.numpy.hypot[float]).new() + assert w6.isclose(expected) w7 = Vector(w5.dtype, size=w5.size) w7 << A.reduce_rowwise(agg.hypot) assert w7.isclose(expected) w8 = A.reduce_rowwise(agg.logaddexp).new() - expected = A.reduce_rowwise(monoid.numpy.logaddexp[float]).new() - assert w8.isclose(w8) + if shouldhave(monoid.numpy, "logaddexp"): + expected = A.reduce_rowwise(monoid.numpy.logaddexp[float]).new() + assert w8.isclose(w8) result = Vector.from_coo([0, 1, 2, 3, 4, 5, 6], [3, 2, 9, 10, 11, 8, 4]) w9 = A.reduce_columnwise(agg.sum).new() @@ -1598,6 +1626,7 @@ def test_reduce_agg_empty(): assert compute(s.value) is None +@pytest.mark.skipif("not supports_udfs") def test_reduce_row_udf(A): result = Vector.from_coo([0, 1, 2, 3, 4, 5, 6], [5, 12, 1, 6, 7, 1, 15]) @@ -2007,6 +2036,12 @@ def test_ss_import_export(A, do_iso, methods): B4 = Matrix.ss.import_any(**d) assert B4.isequal(A) assert B4.ss.is_iso is do_iso + if do_iso: + d["values"] = 1 + d["is_iso"] = False + B4b = Matrix.ss.import_any(**d) + assert B4b.isequal(A) + assert B4b.ss.is_iso is True else: A4.ss.pack_any(**d) assert A4.isequal(A) @@ -2262,6 +2297,11 @@ def test_ss_import_on_view(): A = Matrix.from_coo([0, 0, 1, 1], [0, 1, 0, 1], [1, 2, 3, 4]) B = Matrix.ss.import_any(nrows=2, ncols=2, values=np.array([1, 2, 3, 4, 99, 99, 99])[:4]) assert A.isequal(B) + values = np.arange(16).reshape(4, 4)[::2, ::2] + bitmap = np.ones((4, 4), dtype=bool)[::2, ::2] + C = Matrix.ss.import_any(values=values, bitmap=bitmap) + D = Matrix.ss.import_any(values=values.copy(), bitmap=bitmap.copy()) + assert C.isequal(D) @pytest.mark.skipif("not suitesparse") @@ -2902,18 +2942,19 @@ def test_expr_is_like_matrix(A): "resize", "update", } - assert attrs - expr_attrs == expected, ( + ignore = {"__sizeof__"} + assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Matrix. You may need to " "add an entry to `matrix` or `matrix_vector` set in `graphblas.core.automethods` " "and then run `python -m graphblas.core.automethods`. If you're messing with infix " "methods, then you may need to run `python -m graphblas.core.infixmethods`." ) - assert attrs - infix_attrs == expected + assert attrs - infix_attrs - ignore == expected # TransposedMatrix is used differently than other expressions, # so maybe it shouldn't support everything. if suitesparse: expected.add("ss") - assert attrs - transposed_attrs == (expected | {"_as_vector", "S", "V"}) - { + assert attrs - transposed_attrs - ignore == (expected | {"_as_vector", "S", "V"}) - { "_prep_for_extract", "_extract_element", } @@ -2965,7 +3006,8 @@ def test_index_expr_is_like_matrix(A): "from_scalar", "resize", } - assert attrs - expr_attrs == expected, ( + ignore = {"__sizeof__"} + assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Matrix. You may need to " "add an entry to `matrix` or `matrix_vector` set in `graphblas.core.automethods` " "and then run `python -m graphblas.core.automethods`. If you're messing with infix " @@ -3110,10 +3152,12 @@ def test_infix_sugar(A): assert binary.times(2, A).isequal(2 * A) assert binary.truediv(A, 2).isequal(A / 2) assert binary.truediv(5, A).isequal(5 / A) - assert binary.floordiv(A, 2).isequal(A // 2) - assert binary.floordiv(5, A).isequal(5 // A) - assert binary.numpy.mod(A, 2).isequal(A % 2) - assert binary.numpy.mod(5, A).isequal(5 % A) + if shouldhave(binary, "floordiv"): + assert binary.floordiv(A, 2).isequal(A // 2) + assert binary.floordiv(5, A).isequal(5 // A) + if shouldhave(binary.numpy, "mod"): + assert binary.numpy.mod(A, 2).isequal(A % 2) + assert binary.numpy.mod(5, A).isequal(5 % A) assert binary.pow(A, 2).isequal(A**2) assert binary.pow(2, A).isequal(2**A) assert binary.pow(A, 2).isequal(pow(A, 2)) @@ -3140,26 +3184,27 @@ def test_infix_sugar(A): assert binary.ge(A, 4).isequal(A >= 4) assert binary.eq(A, 4).isequal(A == 4) assert binary.ne(A, 4).isequal(A != 4) - x, y = divmod(A, 3) - assert binary.floordiv(A, 3).isequal(x) - assert binary.numpy.mod(A, 3).isequal(y) - assert binary.fmod(A, 3).isequal(y) - assert A.isequal(binary.plus((3 * x) & y)) - x, y = divmod(-A, 3) - assert binary.floordiv(-A, 3).isequal(x) - assert binary.numpy.mod(-A, 3).isequal(y) - # assert binary.fmod(-A, 3).isequal(y) # The reason we use numpy.mod - assert (-A).isequal(binary.plus((3 * x) & y)) - x, y = divmod(3, A) - assert binary.floordiv(3, A).isequal(x) - assert binary.numpy.mod(3, A).isequal(y) - assert binary.fmod(3, A).isequal(y) - assert binary.plus(binary.times(A & x) & y).isequal(3 * unary.one(A)) - x, y = divmod(-3, A) - assert binary.floordiv(-3, A).isequal(x) - assert binary.numpy.mod(-3, A).isequal(y) - # assert binary.fmod(-3, A).isequal(y) # The reason we use numpy.mod - assert binary.plus(binary.times(A & x) & y).isequal(-3 * unary.one(A)) + if shouldhave(binary, "floordiv") and shouldhave(binary.numpy, "mod"): + x, y = divmod(A, 3) + assert binary.floordiv(A, 3).isequal(x) + assert binary.numpy.mod(A, 3).isequal(y) + assert binary.fmod(A, 3).isequal(y) + assert A.isequal(binary.plus((3 * x) & y)) + x, y = divmod(-A, 3) + assert binary.floordiv(-A, 3).isequal(x) + assert binary.numpy.mod(-A, 3).isequal(y) + # assert binary.fmod(-A, 3).isequal(y) # The reason we use numpy.mod + assert (-A).isequal(binary.plus((3 * x) & y)) + x, y = divmod(3, A) + assert binary.floordiv(3, A).isequal(x) + assert binary.numpy.mod(3, A).isequal(y) + assert binary.fmod(3, A).isequal(y) + assert binary.plus(binary.times(A & x) & y).isequal(3 * unary.one(A)) + x, y = divmod(-3, A) + assert binary.floordiv(-3, A).isequal(x) + assert binary.numpy.mod(-3, A).isequal(y) + # assert binary.fmod(-3, A).isequal(y) # The reason we use numpy.mod + assert binary.plus(binary.times(A & x) & y).isequal(-3 * unary.one(A)) assert binary.eq(A & A).isequal(A == A) assert binary.ne(A.T & A.T).isequal(A.T != A.T) @@ -3182,14 +3227,16 @@ def test_infix_sugar(A): B /= 2 assert type(B) is Matrix assert binary.truediv(A, 2).isequal(B) - B = A.dup() - B //= 2 - assert type(B) is Matrix - assert binary.floordiv(A, 2).isequal(B) - B = A.dup() - B %= 2 - assert type(B) is Matrix - assert binary.numpy.mod(A, 2).isequal(B) + if shouldhave(binary, "floordiv"): + B = A.dup() + B //= 2 + assert type(B) is Matrix + assert binary.floordiv(A, 2).isequal(B) + if shouldhave(binary.numpy, "mod"): + B = A.dup() + B %= 2 + assert type(B) is Matrix + assert binary.numpy.mod(A, 2).isequal(B) B = A.dup() B **= 2 assert type(B) is Matrix @@ -3520,7 +3567,7 @@ def test_ndim(A): def test_sizeof(A): - if suitesparse: + if suitesparse and not pypy: assert sys.getsizeof(A) > A.nvals * 16 else: with pytest.raises(TypeError): @@ -3607,6 +3654,7 @@ def test_ss_iteration(A): assert next(A.ss.iteritems()) is not None +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_udt(): record_dtype = np.dtype([("x", np.bool_), ("y", np.float64)], align=True) @@ -3917,7 +3965,7 @@ def test_ss_config(A): def test_to_csr_from_csc(A): - assert Matrix.from_csr(*A.to_csr(dtype=int)).isequal(A, check_dtype=True) + assert Matrix.from_csr(*A.to_csr(sort=False, dtype=int)).isequal(A, check_dtype=True) assert Matrix.from_csr(*A.T.to_csc()).isequal(A, check_dtype=True) assert Matrix.from_csc(*A.to_csc()).isequal(A) assert Matrix.from_csc(*A.T.to_csr()).isequal(A) @@ -4126,7 +4174,11 @@ def test_from_scalar(): A = Matrix.from_scalar(1, dtype="INT64[2]", nrows=3, ncols=4) B = Matrix("INT64[2]", nrows=3, ncols=4) B << [1, 1] - assert A.isequal(B, check_dtype=True) + if supports_udfs: + assert A.isequal(B, check_dtype=True) + else: + with pytest.raises(KeyError, match="eq does not work with"): + assert A.isequal(B, check_dtype=True) def test_to_dense_from_dense(): @@ -4252,7 +4304,7 @@ def test_ss_descriptors(A): (A @ A).new(nthreads=4, Nthreads=5) with pytest.raises(ValueError, match="escriptor"): A[0, 0].new(bad_opt=True) - A[0, 0].new(nthreads=4) # ignored, but okay + A[0, 0].new(nthreads=4, sort=None) # ignored, but okay with pytest.raises(ValueError, match="escriptor"): A.__setitem__((0, 0), 1, bad_opt=True) A.__setitem__((0, 0), 1, nthreads=4) # ignored, but okay @@ -4286,6 +4338,7 @@ def test_wait_chains(A): assert result == 47 +@pytest.mark.skipif("not supports_udfs") def test_subarray_dtypes(): a = np.arange(3 * 4, dtype=np.int64).reshape(3, 4) A = Matrix.from_coo([1, 3, 5], [0, 1, 3], a) diff --git a/graphblas/tests/test_numpyops.py b/graphblas/tests/test_numpyops.py index 5b7e797f3..25c52d7fd 100644 --- a/graphblas/tests/test_numpyops.py +++ b/graphblas/tests/test_numpyops.py @@ -11,22 +11,25 @@ import graphblas.monoid.numpy as npmonoid import graphblas.semiring.numpy as npsemiring import graphblas.unary.numpy as npunary -from graphblas import Vector, backend +from graphblas import Vector, backend, config +from graphblas.core import _supports_udfs as supports_udfs from graphblas.dtypes import _supports_complex -from .conftest import compute +from .conftest import compute, shouldhave is_win = sys.platform.startswith("win") suitesparse = backend == "suitesparse" def test_numpyops_dir(): - assert "exp2" in dir(npunary) - assert "logical_and" in dir(npbinary) - assert "logaddexp" in dir(npmonoid) - assert "add_add" in dir(npsemiring) + udf_or_mapped = supports_udfs or config["mapnumpy"] + assert ("exp2" in dir(npunary)) == udf_or_mapped + assert ("logical_and" in dir(npbinary)) == udf_or_mapped + assert ("logaddexp" in dir(npmonoid)) == supports_udfs + assert ("add_add" in dir(npsemiring)) == udf_or_mapped +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_bool_doesnt_get_too_large(): a = Vector.from_coo([0, 1, 2, 3], [True, False, True, False]) @@ -70,9 +73,12 @@ def test_npunary(): # due to limitation of MSVC with complex blocklist["FC64"].update({"arcsin", "arcsinh"}) blocklist["FC32"] = {"arcsin", "arcsinh"} - isclose = gb.binary.isclose(1e-6, 0) + if shouldhave(gb.binary, "isclose"): + isclose = gb.binary.isclose(1e-6, 0) + else: + isclose = None for gb_input, np_input in data: - for unary_name in sorted(npunary._unary_names): + for unary_name in sorted(npunary._unary_names & npunary.__dir__()): op = getattr(npunary, unary_name) if gb_input.dtype not in op.types or unary_name in blocklist.get( gb_input.dtype.name, () @@ -99,6 +105,8 @@ def test_npunary(): list(range(np_input.size)), list(np_result), dtype=gb_result.dtype ) assert gb_result.nvals == np_result.size + if compare_op is None: + continue # FLAKY COVERAGE match = gb_result.ewise_mult(np_result, compare_op).new() if gb_result.dtype.name.startswith("F"): match(accum=gb.binary.lor) << gb_result.apply(npunary.isnan) @@ -149,9 +157,24 @@ def test_npbinary(): "FP64": {"floor_divide"}, # numba/numpy difference for 1.0 / 0.0 "BOOL": {"gcd", "lcm", "subtract"}, # not supported by numpy } - isclose = gb.binary.isclose(1e-7, 0) + if shouldhave(gb.binary, "isclose"): + isclose = gb.binary.isclose(1e-7, 0) + else: + isclose = None + if shouldhave(npbinary, "equal"): + equal = npbinary.equal + else: + equal = gb.binary.eq + if shouldhave(npbinary, "isnan"): + isnan = npunary.isnan + else: + isnan = gb.unary.isnan + if shouldhave(npbinary, "isinf"): + isinf = npunary.isinf + else: + isinf = gb.unary.isinf for (gb_left, gb_right), (np_left, np_right) in data: - for binary_name in sorted(npbinary._binary_names): + for binary_name in sorted(npbinary._binary_names & npbinary.__dir__()): op = getattr(npbinary, binary_name) if gb_left.dtype not in op.types or binary_name in blocklist.get( gb_left.dtype.name, () @@ -171,7 +194,7 @@ def test_npbinary(): if binary_name in {"arctan2"}: compare_op = isclose else: - compare_op = npbinary.equal + compare_op = equal except Exception: # pragma: no cover (debug) print(f"Error computing numpy result for {binary_name}") print(f"dtypes: ({gb_left.dtype}, {gb_right.dtype}) -> {gb_result.dtype}") @@ -179,12 +202,14 @@ def test_npbinary(): np_result = Vector.from_coo(np.arange(np_left.size), np_result, dtype=gb_result.dtype) assert gb_result.nvals == np_result.size + if compare_op is None: + continue # FLAKY COVERAGE match = gb_result.ewise_mult(np_result, compare_op).new() if gb_result.dtype.name.startswith("F"): - match(accum=gb.binary.lor) << gb_result.apply(npunary.isnan) + match(accum=gb.binary.lor) << gb_result.apply(isnan) if gb_result.dtype.name.startswith("FC"): # Divide by 0j sometimes result in different behavior, such as `nan` or `(inf+0j)` - match(accum=gb.binary.lor) << gb_result.apply(npunary.isinf) + match(accum=gb.binary.lor) << gb_result.apply(isinf) compare = match.reduce(gb.monoid.land).new() if not compare: # pragma: no cover (debug) print(compare_op) @@ -223,7 +248,7 @@ def test_npmonoid(): ], ] # Complex monoids not working yet (they segfault upon creation in gb.core.operators) - if _supports_complex: # pragma: no branch + if _supports_complex: data.append( [ [ @@ -241,13 +266,13 @@ def test_npmonoid(): "BOOL": {"add"}, } for (gb_left, gb_right), (np_left, np_right) in data: - for binary_name in sorted(npmonoid._monoid_identities): + for binary_name in sorted(npmonoid._monoid_identities.keys() & npmonoid.__dir__()): op = getattr(npmonoid, binary_name) assert len(op.types) > 0, op.name if gb_left.dtype not in op.types or binary_name in blocklist.get( gb_left.dtype.name, () - ): # pragma: no cover (flaky) - continue + ): + continue # FLAKY COVERAGE with np.errstate(divide="ignore", over="ignore", under="ignore", invalid="ignore"): gb_result = gb_left.ewise_mult(gb_right, op).new() np_result = getattr(np, binary_name)(np_left, np_right) @@ -279,7 +304,8 @@ def test_npmonoid(): @pytest.mark.slow def test_npsemiring(): for monoid_name, binary_name in itertools.product( - sorted(npmonoid._monoid_identities), sorted(npbinary._binary_names) + sorted(npmonoid._monoid_identities.keys() & npmonoid.__dir__()), + sorted(npbinary._binary_names & npbinary.__dir__()), ): monoid = getattr(npmonoid, monoid_name) binary = getattr(npbinary, binary_name) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index 3a80dbe52..c9a176afd 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -4,7 +4,20 @@ import pytest import graphblas as gb -from graphblas import agg, backend, binary, dtypes, indexunary, monoid, op, select, semiring, unary +from graphblas import ( + agg, + backend, + binary, + config, + dtypes, + indexunary, + monoid, + op, + select, + semiring, + unary, +) +from graphblas.core import _supports_udfs as supports_udfs from graphblas.core import lib, operator from graphblas.core.operator import BinaryOp, IndexUnaryOp, Monoid, Semiring, UnaryOp, get_semiring from graphblas.dtypes import ( @@ -22,6 +35,8 @@ ) from graphblas.exceptions import DomainMismatch, UdfParseError +from .conftest import shouldhave + if dtypes._supports_complex: from graphblas.dtypes import FC32, FC64 @@ -142,6 +157,36 @@ def test_get_typed_op(): operator.get_typed_op(binary.plus, dtypes.INT64, "bad dtype") +@pytest.mark.skipif("supports_udfs") +def test_udf_mentions_numba(): + with pytest.raises(AttributeError, match="install numba"): + binary.rfloordiv + assert "rfloordiv" not in dir(binary) + with pytest.raises(AttributeError, match="install numba"): + semiring.any_rfloordiv + assert "any_rfloordiv" not in dir(semiring) + with pytest.raises(AttributeError, match="install numba"): + op.absfirst + assert "absfirst" not in dir(op) + with pytest.raises(AttributeError, match="install numba"): + op.plus_rpow + assert "plus_rpow" not in dir(op) + with pytest.raises(AttributeError, match="install numba"): + binary.numpy.gcd + assert "gcd" not in dir(binary.numpy) + assert "gcd" not in dir(op.numpy) + + +@pytest.mark.skipif("supports_udfs") +def test_unaryop_udf_no_support(): + def plus_one(x): # pragma: no cover (numba) + return x + 1 + + with pytest.raises(RuntimeError, match="UnaryOp.register_new.* unavailable"): + unary.register_new("plus_one", plus_one) + + +@pytest.mark.skipif("not supports_udfs") def test_unaryop_udf(): def plus_one(x): return x + 1 # pragma: no cover (numba) @@ -150,6 +195,7 @@ def plus_one(x): assert hasattr(unary, "plus_one") assert unary.plus_one.orig_func is plus_one assert unary.plus_one[int].orig_func is plus_one + assert unary.plus_one[int]._numba_func(1) == 2 comp_set = { INT8, INT16, @@ -182,6 +228,7 @@ def plus_one(x): UnaryOp.register_new("bad", lambda x: v) +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_unaryop_parameterized(): def plus_x(x=0): @@ -207,6 +254,7 @@ def inner(val): assert r10.isequal(v11, check_dtype=True) +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_binaryop_parameterized(): def plus_plus_x(x=0): @@ -268,6 +316,7 @@ def my_add(x, y): assert op.name == "my_add" +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_monoid_parameterized(): def plus_plus_x(x=0): @@ -363,6 +412,7 @@ def bad_identity(x=0): assert monoid.is_idempotent +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_semiring_parameterized(): def plus_plus_x(x=0): @@ -490,6 +540,7 @@ def inner(y): assert B.isequal(A.kronecker(A, binary.plus).new()) +@pytest.mark.skipif("not supports_udfs") def test_unaryop_udf_bool_result(): # numba has trouble compiling this, but we have a work-around def is_positive(x): @@ -516,12 +567,14 @@ def is_positive(x): assert w.isequal(result) +@pytest.mark.skipif("not supports_udfs") def test_binaryop_udf(): def times_minus_sum(x, y): return x * y - (x + y) # pragma: no cover (numba) BinaryOp.register_new("bin_test_func", times_minus_sum) assert hasattr(binary, "bin_test_func") + assert binary.bin_test_func[int].orig_func is times_minus_sum comp_set = { BOOL, # goes to INT64 INT8, @@ -545,6 +598,7 @@ def times_minus_sum(x, y): assert w.isequal(result) +@pytest.mark.skipif("not supports_udfs") def test_monoid_udf(): def plus_plus_one(x, y): return x + y + 1 # pragma: no cover (numba) @@ -579,6 +633,7 @@ def plus_plus_one(x, y): Monoid.register_anonymous(binary.plus_plus_one, {"BOOL": -1}) +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_semiring_udf(): def plus_plus_two(x, y): @@ -608,10 +663,12 @@ def test_binary_updates(): vec4 = Vector.from_coo([0], [-3], dtype=dtypes.INT64) result2 = vec4.ewise_mult(vec2, binary.cdiv).new() assert result2.isequal(Vector.from_coo([0], [-1], dtype=dtypes.INT64), check_dtype=True) - result3 = vec4.ewise_mult(vec2, binary.floordiv).new() - assert result3.isequal(Vector.from_coo([0], [-2], dtype=dtypes.INT64), check_dtype=True) + if shouldhave(binary, "floordiv"): + result3 = vec4.ewise_mult(vec2, binary.floordiv).new() + assert result3.isequal(Vector.from_coo([0], [-2], dtype=dtypes.INT64), check_dtype=True) +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_nested_names(): def plus_three(x): @@ -671,12 +728,17 @@ def test_op_namespace(): assert op.plus is binary.plus assert op.plus_times is semiring.plus_times - assert op.numpy.fabs is unary.numpy.fabs - assert op.numpy.subtract is binary.numpy.subtract - assert op.numpy.add is binary.numpy.add - assert op.numpy.add_add is semiring.numpy.add_add + if shouldhave(unary.numpy, "fabs"): + assert op.numpy.fabs is unary.numpy.fabs + if shouldhave(binary.numpy, "subtract"): + assert op.numpy.subtract is binary.numpy.subtract + if shouldhave(binary.numpy, "add"): + assert op.numpy.add is binary.numpy.add + if shouldhave(semiring.numpy, "add_add"): + assert op.numpy.add_add is semiring.numpy.add_add assert len(dir(op)) > 300 - assert len(dir(op.numpy)) > 500 + if supports_udfs: + assert len(dir(op.numpy)) > 500 with pytest.raises( AttributeError, match="module 'graphblas.op.numpy' has no attribute 'bad_attr'" @@ -740,10 +802,18 @@ def test_op_namespace(): @pytest.mark.slow def test_binaryop_attributes_numpy(): # Some coverage from this test depends on order of tests - assert binary.numpy.add[int].monoid is monoid.numpy.add[int] - assert binary.numpy.subtract[int].monoid is None - assert binary.numpy.add.monoid is monoid.numpy.add - assert binary.numpy.subtract.monoid is None + if shouldhave(monoid.numpy, "add"): + assert binary.numpy.add[int].monoid is monoid.numpy.add[int] + assert binary.numpy.add.monoid is monoid.numpy.add + if shouldhave(binary.numpy, "subtract"): + assert binary.numpy.subtract[int].monoid is None + assert binary.numpy.subtract.monoid is None + + +@pytest.mark.skipif("not supports_udfs") +@pytest.mark.slow +def test_binaryop_monoid_numpy(): + assert gb.binary.numpy.minimum[int].monoid is gb.monoid.numpy.minimum[int] @pytest.mark.slow @@ -756,18 +826,21 @@ def test_binaryop_attributes(): def plus(x, y): return x + y # pragma: no cover (numba) - op = BinaryOp.register_anonymous(plus, name="plus") - assert op.monoid is None - assert op[int].monoid is None + if supports_udfs: + op = BinaryOp.register_anonymous(plus, name="plus") + assert op.monoid is None + assert op[int].monoid is None + assert op[int].parent is op assert binary.plus[int].parent is binary.plus - assert binary.numpy.add[int].parent is binary.numpy.add - assert op[int].parent is op + if shouldhave(binary.numpy, "add"): + assert binary.numpy.add[int].parent is binary.numpy.add # bad type assert binary.plus[bool].monoid is None - assert binary.numpy.equal[int].monoid is None - assert binary.numpy.equal[bool].monoid is monoid.numpy.equal[bool] # sanity + if shouldhave(binary.numpy, "equal"): + assert binary.numpy.equal[int].monoid is None + assert binary.numpy.equal[bool].monoid is monoid.numpy.equal[bool] # sanity for attr, val in vars(binary).items(): if not isinstance(val, BinaryOp): @@ -790,22 +863,25 @@ def test_monoid_attributes(): assert monoid.plus.binaryop is binary.plus assert monoid.plus.identities == {typ: 0 for typ in monoid.plus.types} - assert monoid.numpy.add[int].binaryop is binary.numpy.add[int] - assert monoid.numpy.add[int].identity == 0 - assert monoid.numpy.add.binaryop is binary.numpy.add - assert monoid.numpy.add.identities == {typ: 0 for typ in monoid.numpy.add.types} + if shouldhave(monoid.numpy, "add"): + assert monoid.numpy.add[int].binaryop is binary.numpy.add[int] + assert monoid.numpy.add[int].identity == 0 + assert monoid.numpy.add.binaryop is binary.numpy.add + assert monoid.numpy.add.identities == {typ: 0 for typ in monoid.numpy.add.types} def plus(x, y): # pragma: no cover (numba) return x + y - binop = BinaryOp.register_anonymous(plus, name="plus") - op = Monoid.register_anonymous(binop, 0, name="plus") - assert op.binaryop is binop - assert op[int].binaryop is binop[int] + if supports_udfs: + binop = BinaryOp.register_anonymous(plus, name="plus") + op = Monoid.register_anonymous(binop, 0, name="plus") + assert op.binaryop is binop + assert op[int].binaryop is binop[int] + assert op[int].parent is op assert monoid.plus[int].parent is monoid.plus - assert monoid.numpy.add[int].parent is monoid.numpy.add - assert op[int].parent is op + if shouldhave(monoid.numpy, "add"): + assert monoid.numpy.add[int].parent is monoid.numpy.add for attr, val in vars(monoid).items(): if not isinstance(val, Monoid): @@ -826,25 +902,27 @@ def test_semiring_attributes(): assert semiring.min_plus.monoid is monoid.min assert semiring.min_plus.binaryop is binary.plus - assert semiring.numpy.add_subtract[int].monoid is monoid.numpy.add[int] - assert semiring.numpy.add_subtract[int].binaryop is binary.numpy.subtract[int] - assert semiring.numpy.add_subtract.monoid is monoid.numpy.add - assert semiring.numpy.add_subtract.binaryop is binary.numpy.subtract + if shouldhave(semiring.numpy, "add_subtract"): + assert semiring.numpy.add_subtract[int].monoid is monoid.numpy.add[int] + assert semiring.numpy.add_subtract[int].binaryop is binary.numpy.subtract[int] + assert semiring.numpy.add_subtract.monoid is monoid.numpy.add + assert semiring.numpy.add_subtract.binaryop is binary.numpy.subtract + assert semiring.numpy.add_subtract[int].parent is semiring.numpy.add_subtract def plus(x, y): return x + y # pragma: no cover (numba) - binop = BinaryOp.register_anonymous(plus, name="plus") - mymonoid = Monoid.register_anonymous(binop, 0, name="plus") - op = Semiring.register_anonymous(mymonoid, binop, name="plus_plus") - assert op.binaryop is binop - assert op.binaryop[int] is binop[int] - assert op.monoid is mymonoid - assert op.monoid[int] is mymonoid[int] + if supports_udfs: + binop = BinaryOp.register_anonymous(plus, name="plus") + mymonoid = Monoid.register_anonymous(binop, 0, name="plus") + op = Semiring.register_anonymous(mymonoid, binop, name="plus_plus") + assert op.binaryop is binop + assert op.binaryop[int] is binop[int] + assert op.monoid is mymonoid + assert op.monoid[int] is mymonoid[int] + assert op[int].parent is op assert semiring.min_plus[int].parent is semiring.min_plus - assert semiring.numpy.add_subtract[int].parent is semiring.numpy.add_subtract - assert op[int].parent is op for attr, val in vars(semiring).items(): if not isinstance(val, Semiring): @@ -881,9 +959,10 @@ def test_div_semirings(): assert result[0, 0].new() == -2 assert result.dtype == dtypes.FP64 - result = A1.T.mxm(A2, semiring.plus_floordiv).new() - assert result[0, 0].new() == -3 - assert result.dtype == dtypes.INT64 + if shouldhave(semiring, "plus_floordiv"): + result = A1.T.mxm(A2, semiring.plus_floordiv).new() + assert result[0, 0].new() == -3 + assert result.dtype == dtypes.INT64 @pytest.mark.slow @@ -902,25 +981,27 @@ def test_get_semiring(): def myplus(x, y): return x + y # pragma: no cover (numba) - binop = BinaryOp.register_anonymous(myplus, name="myplus") - st = get_semiring(monoid.plus, binop) - assert st.monoid is monoid.plus - assert st.binaryop is binop + if supports_udfs: + binop = BinaryOp.register_anonymous(myplus, name="myplus") + st = get_semiring(monoid.plus, binop) + assert st.monoid is monoid.plus + assert st.binaryop is binop - binop = BinaryOp.register_new("myplus", myplus) - assert binop is binary.myplus - st = get_semiring(monoid.plus, binop) - assert st.monoid is monoid.plus - assert st.binaryop is binop + binop = BinaryOp.register_new("myplus", myplus) + assert binop is binary.myplus + st = get_semiring(monoid.plus, binop) + assert st.monoid is monoid.plus + assert st.binaryop is binop with pytest.raises(TypeError, match="Monoid"): get_semiring(None, binary.times) with pytest.raises(TypeError, match="Binary"): get_semiring(monoid.plus, None) - sr = get_semiring(monoid.plus, binary.numpy.copysign) - assert sr.monoid is monoid.plus - assert sr.binaryop is binary.numpy.copysign + if shouldhave(binary.numpy, "copysign"): + sr = get_semiring(monoid.plus, binary.numpy.copysign) + assert sr.monoid is monoid.plus + assert sr.binaryop is binary.numpy.copysign def test_create_semiring(): @@ -958,17 +1039,22 @@ def test_commutes(): assert semiring.plus_times.is_commutative if suitesparse: assert semiring.ss.min_secondi.commutes_to is semiring.ss.min_firstj - assert semiring.plus_pow.commutes_to is semiring.plus_rpow + if shouldhave(semiring, "plus_pow") and shouldhave(semiring, "plus_rpow"): + assert semiring.plus_pow.commutes_to is semiring.plus_rpow assert not semiring.plus_pow.is_commutative - assert binary.isclose.commutes_to is binary.isclose - assert binary.isclose.is_commutative - assert binary.isclose(0.1).commutes_to is binary.isclose(0.1) - assert binary.floordiv.commutes_to is binary.rfloordiv - assert not binary.floordiv.is_commutative - assert binary.numpy.add.commutes_to is binary.numpy.add - assert binary.numpy.add.is_commutative - assert binary.numpy.less.commutes_to is binary.numpy.greater - assert not binary.numpy.less.is_commutative + if shouldhave(binary, "isclose"): + assert binary.isclose.commutes_to is binary.isclose + assert binary.isclose.is_commutative + assert binary.isclose(0.1).commutes_to is binary.isclose(0.1) + if shouldhave(binary, "floordiv") and shouldhave(binary, "rfloordiv"): + assert binary.floordiv.commutes_to is binary.rfloordiv + assert not binary.floordiv.is_commutative + if shouldhave(binary.numpy, "add"): + assert binary.numpy.add.commutes_to is binary.numpy.add + assert binary.numpy.add.is_commutative + if shouldhave(binary.numpy, "less") and shouldhave(binary.numpy, "greater"): + assert binary.numpy.less.commutes_to is binary.numpy.greater + assert not binary.numpy.less.is_commutative # Typed assert binary.plus[int].commutes_to is binary.plus[int] @@ -985,15 +1071,20 @@ def test_commutes(): assert semiring.plus_times[int].is_commutative if suitesparse: assert semiring.ss.min_secondi[int].commutes_to is semiring.ss.min_firstj[int] - assert semiring.plus_pow[int].commutes_to is semiring.plus_rpow[int] + if shouldhave(semiring, "plus_rpow"): + assert semiring.plus_pow[int].commutes_to is semiring.plus_rpow[int] assert not semiring.plus_pow[int].is_commutative - assert binary.isclose(0.1)[int].commutes_to is binary.isclose(0.1)[int] - assert binary.floordiv[int].commutes_to is binary.rfloordiv[int] - assert not binary.floordiv[int].is_commutative - assert binary.numpy.add[int].commutes_to is binary.numpy.add[int] - assert binary.numpy.add[int].is_commutative - assert binary.numpy.less[int].commutes_to is binary.numpy.greater[int] - assert not binary.numpy.less[int].is_commutative + if shouldhave(binary, "isclose"): + assert binary.isclose(0.1)[int].commutes_to is binary.isclose(0.1)[int] + if shouldhave(binary, "floordiv") and shouldhave(binary, "rfloordiv"): + assert binary.floordiv[int].commutes_to is binary.rfloordiv[int] + assert not binary.floordiv[int].is_commutative + if shouldhave(binary.numpy, "add"): + assert binary.numpy.add[int].commutes_to is binary.numpy.add[int] + assert binary.numpy.add[int].is_commutative + if shouldhave(binary.numpy, "less") and shouldhave(binary.numpy, "greater"): + assert binary.numpy.less[int].commutes_to is binary.numpy.greater[int] + assert not binary.numpy.less[int].is_commutative # Stress test (this can create extra semirings) names = dir(semiring) @@ -1014,9 +1105,12 @@ def test_from_string(): assert unary.from_string("abs[float]") is unary.abs[float] assert binary.from_string("+") is binary.plus assert binary.from_string("-[int]") is binary.minus[int] - assert binary.from_string("true_divide") is binary.numpy.true_divide - assert binary.from_string("//") is binary.floordiv - assert binary.from_string("%") is binary.numpy.mod + if config["mapnumpy"] or shouldhave(binary.numpy, "true_divide"): + assert binary.from_string("true_divide") is binary.numpy.true_divide + if shouldhave(binary, "floordiv"): + assert binary.from_string("//") is binary.floordiv + if shouldhave(binary.numpy, "mod"): + assert binary.from_string("%") is binary.numpy.mod assert monoid.from_string("*[FP64]") is monoid.times["FP64"] assert semiring.from_string("min.plus") is semiring.min_plus assert semiring.from_string("min.+") is semiring.min_plus @@ -1053,6 +1147,7 @@ def test_from_string(): agg.from_string("bad_agg") +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_lazy_op(): UnaryOp.register_new("lazy", lambda x: x, lazy=True) # pragma: no branch (numba) @@ -1115,6 +1210,7 @@ def test_positional(): assert semiring.ss.any_secondj[int].is_positional +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_udt(): record_dtype = np.dtype([("x", np.bool_), ("y", np.float64)], align=True) @@ -1280,6 +1376,7 @@ def test_binaryop_commute_exists(): raise AssertionError("Missing binaryops: " + ", ".join(sorted(missing))) +@pytest.mark.skipif("not supports_udfs") def test_binom(): v = Vector.from_coo([0, 1, 2], [3, 4, 5]) result = v.apply(binary.binom, 2).new() @@ -1341,9 +1438,11 @@ def test_is_idempotent(): assert monoid.max[int].is_idempotent assert monoid.lor.is_idempotent assert monoid.band.is_idempotent - assert monoid.numpy.gcd.is_idempotent + if shouldhave(monoid.numpy, "gcd"): + assert monoid.numpy.gcd.is_idempotent assert not monoid.plus.is_idempotent assert not monoid.times[float].is_idempotent - assert not monoid.numpy.equal.is_idempotent + if config["mapnumpy"] or shouldhave(monoid.numpy, "equal"): + assert not monoid.numpy.equal.is_idempotent with pytest.raises(AttributeError): binary.min.is_idempotent diff --git a/graphblas/tests/test_operator_types.py b/graphblas/tests/test_operator_types.py index 522b42ad2..027f02fcc 100644 --- a/graphblas/tests/test_operator_types.py +++ b/graphblas/tests/test_operator_types.py @@ -2,6 +2,7 @@ from collections import defaultdict from graphblas import backend, binary, dtypes, monoid, semiring, unary +from graphblas.core import _supports_udfs as supports_udfs from graphblas.core import operator from graphblas.dtypes import ( BOOL, @@ -83,6 +84,11 @@ BINARY[(ALL, POS)] = { "firsti", "firsti1", "firstj", "firstj1", "secondi", "secondi1", "secondj", "secondj1", } +if not supports_udfs: + udfs = {"absfirst", "abssecond", "binom", "floordiv", "rfloordiv", "rpow"} + for funcnames in BINARY.values(): + funcnames -= udfs + BINARY = {key: val for key, val in BINARY.items() if val} MONOID = { (UINT, UINT): {"band", "bor", "bxnor", "bxor"}, diff --git a/graphblas/tests/test_pickle.py b/graphblas/tests/test_pickle.py index de2d9cfda..724f43d76 100644 --- a/graphblas/tests/test_pickle.py +++ b/graphblas/tests/test_pickle.py @@ -5,6 +5,7 @@ import pytest import graphblas as gb +from graphblas.core import _supports_udfs as supports_udfs # noqa: F401 suitesparse = gb.backend == "suitesparse" @@ -36,6 +37,7 @@ def extra(): return "" +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_deserialize(extra): path = Path(__file__).parent / f"pickle1{extra}.pkl" @@ -62,6 +64,7 @@ def test_deserialize(extra): assert d3["semiring_pickle"] is gb.semiring.semiring_pickle +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_serialize(): v = gb.Vector.from_coo([1], 2) @@ -232,6 +235,7 @@ def identity_par(z): return -z +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_serialize_parameterized(): # unary_pickle = gb.core.operator.UnaryOp.register_new( @@ -285,6 +289,7 @@ def test_serialize_parameterized(): pickle.loads(pkl) # TODO: check results +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_deserialize_parameterized(extra): path = Path(__file__).parent / f"pickle2{extra}.pkl" @@ -295,6 +300,7 @@ def test_deserialize_parameterized(extra): pickle.load(f) # TODO: check results +@pytest.mark.skipif("not supports_udfs") def test_udt(extra): record_dtype = np.dtype([("x", np.bool_), ("y", np.int64)], align=True) udt = gb.dtypes.register_new("PickleUDT", record_dtype) diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index 6ee70311c..7b7c77177 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -12,7 +12,7 @@ from graphblas import backend, binary, dtypes, monoid, replace, select, unary from graphblas.exceptions import EmptyObject -from .conftest import autocompute, compute +from .conftest import autocompute, compute, pypy from graphblas import Matrix, Scalar, Vector # isort:skip (for dask-graphblas) @@ -209,12 +209,12 @@ def test_unsupported_ops(s): s[0] with pytest.raises(TypeError, match="does not support"): s[0] = 0 - with pytest.raises(TypeError, match="doesn't support"): + with pytest.raises(TypeError, match="doesn't support|does not support"): del s[0] def test_is_empty(s): - with pytest.raises(AttributeError, match="can't set attribute"): + with pytest.raises(AttributeError, match="can't set attribute|object has no setter"): s.is_empty = True @@ -226,7 +226,7 @@ def test_update(s): s << Scalar.from_value(3) assert s == 3 if s._is_cscalar: - with pytest.raises(TypeError, match="an integer is required"): + with pytest.raises(TypeError, match="an integer is required|expected integer"): s << Scalar.from_value(4.4) else: s << Scalar.from_value(4.4) @@ -358,14 +358,15 @@ def test_expr_is_like_scalar(s): } if s.is_cscalar: expected.add("_empty") - assert attrs - expr_attrs == expected, ( + ignore = {"__sizeof__"} + assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Scalar. You may need to " "add an entry to `scalar` set in `graphblas.core.automethods` " "and then run `python -m graphblas.core.automethods`. If you're messing with infix " "methods, then you may need to run `python -m graphblas.core.infixmethods`." ) - assert attrs - infix_attrs == expected - assert attrs - scalar_infix_attrs == expected + assert attrs - infix_attrs - ignore == expected + assert attrs - scalar_infix_attrs - ignore == expected # Make sure signatures actually match. `expr.dup` has `**opts` skip = {"__init__", "__repr__", "_repr_html_", "dup"} for expr in [v.inner(v), v @ v, t & t]: @@ -399,7 +400,8 @@ def test_index_expr_is_like_scalar(s): } if s.is_cscalar: expected.add("_empty") - assert attrs - expr_attrs == expected, ( + ignore = {"__sizeof__"} + assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Scalar. You may need to " "add an entry to `scalar` set in `graphblas.core.automethods` " "and then run `python -m graphblas.core.automethods`. If you're messing with infix " @@ -505,10 +507,10 @@ def test_scalar_expr(s): def test_sizeof(s): - if suitesparse or s._is_cscalar: + if (suitesparse or s._is_cscalar) and not pypy: assert 1 < sys.getsizeof(s) < 1000 else: - with pytest.raises(TypeError): + with pytest.raises(TypeError): # flakey coverage (why?!) sys.getsizeof(s) diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index 8505313e4..ab019b734 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -11,6 +11,7 @@ import graphblas as gb from graphblas import agg, backend, binary, dtypes, indexunary, monoid, select, semiring, unary +from graphblas.core import _supports_udfs as supports_udfs from graphblas.exceptions import ( DimensionMismatch, DomainMismatch, @@ -19,9 +20,10 @@ InvalidObject, InvalidValue, OutputNotEmpty, + UdfParseError, ) -from .conftest import autocompute, compute +from .conftest import autocompute, compute, pypy from graphblas import Matrix, Scalar, Vector # isort:skip (for dask-graphblas) @@ -798,12 +800,13 @@ def test_select_bools_and_masks(v): assert w8.isequal(w9) +@pytest.mark.skipif("not supports_udfs") @pytest.mark.slow def test_indexunary_udf(v): def twox_minusthunk(x, row, col, thunk): # pragma: no cover (numba) return 2 * x - thunk - indexunary.register_new("twox_minusthunk", twox_minusthunk) + indexunary.register_new("twox_minusthunk", twox_minusthunk, lazy=True) assert hasattr(indexunary, "twox_minusthunk") assert not hasattr(select, "twox_minusthunk") with pytest.raises(ValueError, match="SelectOp must have BOOL return type"): @@ -813,24 +816,49 @@ def twox_minusthunk(x, row, col, thunk): # pragma: no cover (numba) expected = Vector.from_coo([1, 3, 4, 6], [-2, -2, 0, -4], size=7) result = indexunary.twox_minusthunk(v, 4).new() assert result.isequal(expected) + assert pickle.loads(pickle.dumps(indexunary.triu)) is indexunary.triu + assert indexunary.twox_minusthunk[int]._numba_func(1, 2, 3, 4) == twox_minusthunk(1, 2, 3, 4) + assert indexunary.twox_minusthunk[int].orig_func is twox_minusthunk delattr(indexunary, "twox_minusthunk") def ii(x, idx, _, thunk): # pragma: no cover (numba) return idx // 2 >= thunk - select.register_new("ii", ii) - assert hasattr(indexunary, "ii") + def iin(n): + def inner(x, idx, _, thunk): # pragma: no cover (numba) + return idx // n >= thunk + + return inner + + select.register_new("ii", ii, lazy=True) + select.register_new("iin", iin, parameterized=True) + assert "ii" in dir(select) + assert "ii" in dir(indexunary) assert hasattr(select, "ii") + assert hasattr(indexunary, "ii") ii_apply = indexunary.register_anonymous(ii) expected = Vector.from_coo([1, 3, 4, 6], [False, False, True, True], size=7) result = ii_apply(v, 2).new() assert result.isequal(expected) + result = v.apply(indexunary.iin(2), 2).new() + assert result.isequal(expected) + result = v.apply(indexunary.register_anonymous(iin, parameterized=True)(2), 2).new() + assert result.isequal(expected) + ii_select = select.register_anonymous(ii) expected = Vector.from_coo([4, 6], [2, 0], size=7) result = ii_select(v, 2).new() assert result.isequal(expected) + result = v.select(select.iin(2), 2).new() + assert result.isequal(expected) + result = v.select(select.register_anonymous(iin, parameterized=True)(2), 2).new() + assert result.isequal(expected) delattr(indexunary, "ii") delattr(select, "ii") + delattr(indexunary, "iin") + delattr(select, "iin") + with pytest.raises(UdfParseError, match="Unable to parse function using Numba"): + indexunary.register_new("bad", lambda x, row, col, thunk: result) def test_reduce(v): @@ -1624,13 +1652,14 @@ def test_expr_is_like_vector(v): "resize", "update", } - assert attrs - expr_attrs == expected, ( + ignore = {"__sizeof__"} + assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Vector. You may need to " "add an entry to `vector` or `matrix_vector` set in `graphblas.core.automethods` " "and then run `python -m graphblas.core.automethods`. If you're messing with infix " "methods, then you may need to run `python -m graphblas.core.infixmethods`." ) - assert attrs - infix_attrs == expected + assert attrs - infix_attrs - ignore == expected # Make sure signatures actually match skip = {"__init__", "__repr__", "_repr_html_"} for expr in [binary.times(w & w), w & w]: @@ -1672,7 +1701,8 @@ def test_index_expr_is_like_vector(v): "from_values", "resize", } - assert attrs - expr_attrs == expected, ( + ignore = {"__sizeof__"} + assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Vector. You may need to " "add an entry to `vector` or `matrix_vector` set in `graphblas.core.automethods` " "and then run `python -m graphblas.core.automethods`. If you're messing with infix " @@ -1963,7 +1993,7 @@ def test_ndim(A, v): def test_sizeof(v): - if suitesparse: + if suitesparse and not pypy: assert sys.getsizeof(v) > v.nvals * 16 else: with pytest.raises(TypeError): @@ -2006,6 +2036,7 @@ def test_delete_via_scalar(v): assert v.nvals == 0 +@pytest.mark.skipif("not supports_udfs") def test_udt(): record_dtype = np.dtype([("x", np.bool_), ("y", np.float64)], align=True) udt = dtypes.register_anonymous(record_dtype, "VectorUDT") @@ -2380,6 +2411,7 @@ def test_to_coo_subset(v): assert vals.dtype == np.int64 +@pytest.mark.skipif("not supports_udfs") def test_lambda_udfs(v): result = v.apply(lambda x: x + 1).new() # pragma: no branch (numba) expected = binary.plus(v, 1).new() @@ -2506,7 +2538,8 @@ def test_from_scalar(): v = Vector.from_scalar(1, dtype="INT64[2]", size=3) w = Vector("INT64[2]", size=3) w << [1, 1] - assert v.isequal(w, check_dtype=True) + if supports_udfs: + assert v.isequal(w, check_dtype=True) def test_to_dense_from_dense(): @@ -2559,9 +2592,10 @@ def test_ss_sort(v): v.ss.sort(binary.plus) # Like compactify - _, p = v.ss.sort(lambda x, y: False, values=False) # pragma: no branch (numba) - expected_p = Vector.from_coo([0, 1, 2, 3], [1, 3, 4, 6], size=7) - assert p.isequal(expected_p) + if supports_udfs: + _, p = v.ss.sort(lambda x, y: False, values=False) # pragma: no branch (numba) + expected_p = Vector.from_coo([0, 1, 2, 3], [1, 3, 4, 6], size=7) + assert p.isequal(expected_p) # reversed _, p = v.ss.sort(binary.pair[bool], values=False) expected_p = Vector.from_coo([0, 1, 2, 3], [6, 4, 3, 1], size=7) @@ -2569,6 +2603,7 @@ def test_ss_sort(v): w, p = v.ss.sort(monoid.lxor) # Weird, but user-defined monoids may not commute, so okay +@pytest.mark.skipif("not supports_udfs") def test_subarray_dtypes(): a = np.arange(3 * 4, dtype=np.int64).reshape(3, 4) v = Vector.from_coo([1, 3, 5], a) diff --git a/graphblas/unary/numpy.py b/graphblas/unary/numpy.py index 836da2024..9b742d8bc 100644 --- a/graphblas/unary/numpy.py +++ b/graphblas/unary/numpy.py @@ -10,6 +10,7 @@ from .. import _STANDARD_OPERATOR_NAMES from .. import config as _config from .. import unary as _unary +from ..core import _supports_udfs from ..dtypes import _supports_complex _delayed = {} @@ -119,7 +120,12 @@ def __dir__(): - return globals().keys() | _delayed.keys() | _unary_names + if not _supports_udfs and not _config.get("mapnumpy"): + return globals().keys() # FLAKY COVERAGE + attrs = _delayed.keys() | _unary_names + if not _supports_udfs: + attrs &= _numpy_to_graphblas.keys() + return attrs | globals().keys() def __getattr__(name): @@ -132,6 +138,11 @@ def __getattr__(name): raise AttributeError(f"module {__name__!r} has no attribute {name!r}") if _config.get("mapnumpy") and name in _numpy_to_graphblas: globals()[name] = getattr(_unary, _numpy_to_graphblas[name]) + elif not _supports_udfs: + raise AttributeError( + f"module {__name__!r} unable to compile UDF for {name!r}; " + "install numba for UDF support" + ) else: numpy_func = getattr(_np, name) diff --git a/pyproject.toml b/pyproject.toml index 47cf1e67f..1eaa942e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3 :: Only", "Intended Audience :: Developers", "Intended Audience :: Other Audience", @@ -57,11 +58,13 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "suitesparse-graphblas >=7.4.0.0, <7.5", "numpy >=1.21", - "numba >=0.55", "donfig >=0.6", "pyyaml >=5.4", + # These won't be installed by default after 2024.3.0 + # Use e.g. "python-graphblas[suitesparse]" or "python-graphblas[default]" instead + "suitesparse-graphblas >=7.4.0.0, <7.5", + "numba >=0.55; python_version<'3.11'", # make optional where numba is not supported ] [project.urls] @@ -71,37 +74,56 @@ repository = "https://github.com/python-graphblas/python-graphblas" changelog = "https://github.com/python-graphblas/python-graphblas/releases" [project.optional-dependencies] -repr = [ - "pandas >=1.2", +suitesparse = [ + "suitesparse-graphblas >=7.4.0.0, <7.5", ] -io = [ +networkx = [ "networkx >=2.8", +] +numba = [ + "numba >=0.55", +] +pandas = [ + "pandas >=1.2", +] +scipy = [ "scipy >=1.8", +] +suitesparse-udf = [ # udf requires numba + "python-graphblas[suitesparse,numba]", +] +repr = [ + "python-graphblas[pandas]", +] +io = [ + "python-graphblas[networkx,scipy]", + "python-graphblas[numba]; python_version<'3.11'", "awkward >=1.9", - "sparse >=0.12", + "sparse >=0.13; python_version<'3.11'", # make optional, b/c sparse needs numba "fast-matrix-market >=1.4.5", ] viz = [ + "python-graphblas[networkx,scipy]", "matplotlib >=3.5", ] +datashade = [ # datashade requires numba + "python-graphblas[numba,pandas,scipy]", + "datashader >=0.12", + "hvplot >=0.7", +] test = [ - "pytest", - "packaging", - "pandas >=1.2", - "scipy >=1.8", - "tomli", + "python-graphblas[suitesparse,pandas,scipy]", + "packaging >=21", + "pytest >=6.2", + "tomli >=1", +] +default = [ + "python-graphblas[suitesparse,pandas,scipy]", + "python-graphblas[numba]; python_version<'3.11'", # make optional where numba is not supported ] complete = [ - "pandas >=1.2", - "networkx >=2.8", - "scipy >=1.8", - "awkward >=1.9", - "sparse >=0.12", - "fast-matrix-market >=1.4.5", - "matplotlib >=3.5", - "pytest", - "packaging", - "tomli", + "python-graphblas[default,io,viz,test]", + "python-graphblas[datashade]; python_version<'3.11'", # make optional, b/c datashade needs numba ] [tool.setuptools] @@ -154,8 +176,6 @@ filterwarnings = [ # See: https://docs.python.org/3/library/warnings.html#describing-warning-filters # and: https://docs.pytest.org/en/7.2.x/how-to/capture-warnings.html#controlling-warnings "error", - # MAINT: we can drop support for sparse <0.13 at any time - "ignore:`np.bool` is a deprecated alias:DeprecationWarning:sparse._umath", # sparse <0.13 # sparse 0.14.0 (2022-02-24) began raising this warning; it has been reported and fixed upstream. "ignore:coords should be an ndarray. This will raise a ValueError:DeprecationWarning:sparse._coo.core", @@ -166,6 +186,13 @@ filterwarnings = [ # And this deprecation warning was added in setuptools v67.5.0 (8 Mar 2023). See: # https://setuptools.pypa.io/en/latest/history.html#v67-5-0 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pkg_resources", + + # sre_parse deprecated in 3.11; this is triggered by awkward 0.10 + "ignore:module 'sre_parse' is deprecated:DeprecationWarning:", + "ignore:module 'sre_constants' is deprecated:DeprecationWarning:", + + # pypy gives this warning + "ignore:can't resolve package from __spec__ or __package__:ImportWarning:", ] [tool.coverage.run] diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 54b02d1f9..026f3a656 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -4,12 +4,12 @@ # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. conda search 'numpy[channel=conda-forge]>=1.24.2' -conda search 'pandas[channel=conda-forge]>=1.5.3' +conda search 'pandas[channel=conda-forge]>=2.0.0' conda search 'scipy[channel=conda-forge]>=1.10.1' -conda search 'networkx[channel=conda-forge]>=3.0' -conda search 'awkward[channel=conda-forge]>=2.1.1' +conda search 'networkx[channel=conda-forge]>=3.1' +conda search 'awkward[channel=conda-forge]>=2.1.2' conda search 'sparse[channel=conda-forge]>=0.14.0' -conda search 'fast_matrix_market[channel=conda-forge]>=1.4.5' +conda search 'fast_matrix_market[channel=conda-forge]>=1.5.1' conda search 'numba[channel=conda-forge]>=0.56.4' conda search 'pyyaml[channel=conda-forge]>=6.0' conda search 'flake8-bugbear[channel=conda-forge]>=23.3.23' From 9723dad446a7194d5dbe4eb3ca64167d12bc620c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 19 Apr 2023 09:23:58 -0500 Subject: [PATCH 16/87] Add link to FAQ to README (#439) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f07fdea12..d20b2a422 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ For algorithms, see [`graphblas-algorithms`](https://github.com/python-graphblas/graphblas-algorithms). - **Documentation:** [https://python-graphblas.readthedocs.io/](https://python-graphblas.readthedocs.io/) + - **FAQ:** [https://python-graphblas.readthedocs.io/en/stable/getting_started/faq.html](https://python-graphblas.readthedocs.io/en/stable/getting_started/faq.html) - **GraphBLAS C API:** [https://graphblas.org/docs/GraphBLAS_API_C_v2.0.0.pdf](https://graphblas.org/docs/GraphBLAS_API_C_v2.0.0.pdf) - **SuiteSparse:GraphBLAS User Guide:** [https://github.com/DrTimothyAldenDavis/GraphBLAS/raw/stable/Doc/GraphBLAS_UserGuide.pdf](https://github.com/DrTimothyAldenDavis/GraphBLAS/raw/stable/Doc/GraphBLAS_UserGuide.pdf) - **Source:** [https://github.com/python-graphblas/python-graphblas](https://github.com/python-graphblas/python-graphblas) From 17d5c79be379d038d24104686f43fa79d8d59c8c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 19 Apr 2023 09:28:08 -0500 Subject: [PATCH 17/87] Create output objects in operations.rst examples (#440) --- docs/user_guide/operations.rst | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/user_guide/operations.rst b/docs/user_guide/operations.rst index 41e4fc2c6..9ee76ab4c 100644 --- a/docs/user_guide/operations.rst +++ b/docs/user_guide/operations.rst @@ -30,6 +30,7 @@ a Vector is treated as an nx1 column matrix. [2., 5., 1.5, 4.25, 0.5], nrows=4, ncols=4) B = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2, 3, 3], [1, 2, 0, 1, 1, 2, 0, 1], [3., 2., 9., 6., 3., 1., 0., 5.]) + C = gb.Matrix(float, A.nrows, B.ncols) # These are equivalent C << A.mxm(B, op='min_plus') # method style @@ -69,6 +70,7 @@ a Vector is treated as an nx1 column matrix. A = gb.Matrix.from_coo([0, 0, 1, 1, 2], [1, 2, 2, 3, 3], [2., 5., 1.5, 4.25, 0.5], nrows=4, ncols=4) v = gb.Vector.from_coo([0, 1, 3], [10., 20., 40.]) + w = gb.Vector(float, A.nrows) # These are equivalent w << A.mxv(v, op='plus_times') # method style @@ -102,6 +104,7 @@ a Vector is treated as an nx1 column matrix. v = gb.Vector.from_coo([0, 1, 3], [10., 20., 40.]) B = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2, 3, 3], [1, 2, 0, 1, 1, 2, 0, 1], [3., 2., 9., 6., 3., 1., 0., 5.]) + u = gb.Vector(float, B.ncols) # These are equivalent u << v.vxm(B, op='plus_plus') # method style @@ -149,6 +152,7 @@ Example usage: [2.0, 5.0, 1.5, 4.0, 0.5]) B = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 2, 0, 1, 1, 2], [3., -2., 0., 6., 3., 1.]) + C = gb.Matrix(float, A.nrows, A.ncols) # These are equivalent C << A.ewise_mult(B, op='min') # method style @@ -221,10 +225,11 @@ should be used with the functional syntax, ``left_default`` and ``right_default` .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 0, 1, 1, 1], [0, 1, 2, 0, 2], + A = gb.Matrix.from_coo([0, 0, 0, 1, 1], [0, 1, 2, 0, 2], [9.0, 2.0, 5.0, 1.5, 4.0], nrows=3) B = gb.Matrix.from_coo([0, 0, 0, 2, 2, 2], [0, 1, 2, 0, 1, 2], [4., 0., -2., 6., 3., 1.]) + C = gb.Matrix(float, A.nrows, A.ncols) # These are equivalent C << A.ewise_add(B, op='minus') # method style @@ -258,10 +263,11 @@ should be used with the functional syntax, ``left_default`` and ``right_default` .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 0, 1, 1, 1], [0, 1, 2, 0, 2], + A = gb.Matrix.from_coo([0, 0, 0, 1, 1], [0, 1, 2, 0, 2], [9.0, 2.0, 5.0, 1.5, 4.0], nrows=3) B = gb.Matrix.from_coo([0, 0, 0, 2, 2, 2], [0, 1, 2, 0, 1, 2], [4., 0., -2., 6., 3., 1.]) + C = gb.Matrix(float, A.nrows, A.ncols) # These are equivalent C << A.ewise_union(B, op='minus', left_default=0, right_default=0) # method style @@ -315,6 +321,7 @@ Vector Slice Example: .. code-block:: python v = gb.Vector.from_coo([0, 1, 3, 4, 6], [10., 2., 40., -5., 24.]) + w = gb.Vector(float, 4) w << v[:4] @@ -336,6 +343,7 @@ Matrix List Example: A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 2, 0, 1, 0, 2], [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + C = gb.Matrix(float, 2, A.ncols) C << A[[0, 2], :] @@ -473,6 +481,7 @@ function with the collection as the argument. .. code-block:: python v = gb.Vector.from_coo([0, 1, 3], [10., 20., 40.]) + w = gb.Vector(float, v.size) # These are equivalent w << v.apply(gb.unary.minv) @@ -495,6 +504,7 @@ function with the collection as the argument. .. code-block:: python v = gb.Vector.from_coo([0, 1, 3], [10., 20., 40.]) + w = gb.Vector(int, v.size) # These are equivalent w << v.apply(gb.indexunary.index) @@ -517,6 +527,7 @@ function with the collection as the argument. .. code-block:: python v = gb.Vector.from_coo([0, 1, 3], [10., 20., 40.]) + w = gb.Vector(float, v.size) # These are all equivalent w << v.apply('minus', right=15) @@ -548,6 +559,7 @@ Upper Triangle Example: A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 2, 0, 2, 1, 2], [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + C = gb.Matrix(float, A.nrows, A.ncols) # These are equivalent C << A.select('triu') @@ -574,6 +586,7 @@ Select by Value Example: .. code-block:: python v = gb.Vector.from_coo([0, 1, 3, 4, 6], [10., 2., 40., -5., 24.]) + w = gb.Vector(float, v.size) # These are equivalent w << v.select('>=', 5) @@ -607,6 +620,7 @@ A monoid or aggregator is used to perform the reduction. A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 3, 0, 1, 0, 1], [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + w = gb.Vector(float, A.ncols) w << A.reduce_columnwise('times') @@ -630,6 +644,7 @@ A monoid or aggregator is used to perform the reduction. A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 3, 0, 1, 0, 1], [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + s = gb.Scalar(float) s << A.reduce_scalar('max') @@ -652,6 +667,7 @@ A monoid or aggregator is used to perform the reduction. .. code-block:: python v = gb.Vector.from_coo([0, 1, 3, 4, 6], [10., 2., 40., -5., 24.]) + s = gb.Scalar(int) # These are equivalent s << v.reduce('argmin') @@ -681,6 +697,7 @@ To force the transpose to be computed by itself, use it by itself as the right-h A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 3, 0, 1, 0, 2], [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + C = gb.Matrix(float, A.ncols, A.nrows) C << A.T @@ -714,6 +731,7 @@ The Kronecker product uses a binary operator. A = gb.Matrix.from_coo([0, 0, 1], [0, 1, 0], [1., -2., 3.]) B = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 2, 0, 1, 0, 2], [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + C = gb.Matrix(float, A.nrows * B.nrows, A.ncols * B.ncols) C << A.kronecker(B, 'times') From 1ed40ef8f88c4e7939cc84df1bbf87668901571e Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Thu, 20 Apr 2023 09:25:51 -0500 Subject: [PATCH 18/87] Add note about supporting GraphBLAS C API version (#441) * Add notes about supporting GraphBLAS C API version --- README.md | 3 ++- docs/getting_started/faq.rst | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d20b2a422..083483fe2 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ or pip: $ pip install python-graphblas[default] ``` This will also install the [SuiteSparse:GraphBLAS](https://github.com/DrTimothyAldenDavis/GraphBLAS) compiled C library. +We currently support the [GraphBLAS C API 2.0 specification](https://graphblas.org/docs/GraphBLAS_API_C_v2.0.0.pdf). ### Optional Dependencies @@ -56,7 +57,7 @@ The following are not required by python-graphblas, but may be needed for certai ## 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. -The approach taken with this library is to follow the C-API specification as closely as possible while making improvements +The approach taken with this library is to follow the C-API 2.0 specification as closely as possible while making improvements allowed with the Python syntax. Because the spec always passes in the output object to be written to, we follow the same, which is very different from the way Python normally operates. In fact, many who are familiar with other Python data libraries (numpy, pandas, etc) will find it strange to not create new objects for every call. diff --git a/docs/getting_started/faq.rst b/docs/getting_started/faq.rst index ab905050c..1e60a1bd4 100644 --- a/docs/getting_started/faq.rst +++ b/docs/getting_started/faq.rst @@ -112,6 +112,18 @@ This is motivated by these guidelines: For example, if a CVE is discovered, we won't retroactively apply the fix to previous releases. Instead, the fix will only be available starting with the next release. +The `GraphBLAS C API specification `_ is expected to change slowly, but it does change. +We aim to support the latest version of the GraphBLAS spec and of implementations. +We will announce plans to drop support of *old* versions of the spec or major versions of implementations +*before* we do so. We will make the announcements in the +`release notes `_ and in our Discord channel. +If the proposed changes will negatively affect you, please +`let us know `_ +so we may work together towards a solution. + +To see which versions of SuiteSparse:GraphBLAS we support, look at the version specification +of ``suitesparse`` under ``[projects.optional-dependencies]`` in ``pyproject.toml``. + What is the relationship between python-graphblas and pygraphblas? ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ From 3f05a3da7387b8f112bdbf6ad6dcc7c94009a370 Mon Sep 17 00:00:00 2001 From: Jim Kitchen Date: Wed, 26 Apr 2023 09:31:55 -0500 Subject: [PATCH 19/87] Convert logo from font svg into path svg (#447) * Convert logo from font svg into path svg * Convert all logos from font svg to path svg * minify all svgs with https://vecta.io/nano --------- Co-authored-by: Erik Welch --- docs/_static/img/directed-graph.svg | 2 +- docs/_static/img/logo-name-dark.svg | 30 +-------------------------- docs/_static/img/logo-name-light.svg | 30 +-------------------------- docs/_static/img/logo-name-medium.svg | 30 +-------------------------- docs/_static/img/social-network.svg | 2 +- docs/_static/img/super-simple.svg | 2 +- docs/_static/img/task-graph.svg | 2 +- docs/_static/img/undirected-graph.svg | 2 +- 8 files changed, 8 insertions(+), 92 deletions(-) diff --git a/docs/_static/img/directed-graph.svg b/docs/_static/img/directed-graph.svg index a08f346d1..c7a9cadad 100644 --- a/docs/_static/img/directed-graph.svg +++ b/docs/_static/img/directed-graph.svg @@ -1 +1 @@ -AC5.0B2.3G1.9F6.2E3.0D4.61.43.9H2.72.08.61.04.45.11.7 +AC5.0B2.3G1.9F6.2E3.0D4.61.43.9H2.72.08.61.04.45.11.7 diff --git a/docs/_static/img/logo-name-dark.svg b/docs/_static/img/logo-name-dark.svg index cdf5227c7..35d4d2970 100644 --- a/docs/_static/img/logo-name-dark.svg +++ b/docs/_static/img/logo-name-dark.svg @@ -1,29 +1 @@ - - - graphblas - python- - + diff --git a/docs/_static/img/logo-name-light.svg b/docs/_static/img/logo-name-light.svg index 18d9c6cc4..e9d9738ee 100644 --- a/docs/_static/img/logo-name-light.svg +++ b/docs/_static/img/logo-name-light.svg @@ -1,29 +1 @@ - - - graphblas - python- - + diff --git a/docs/_static/img/logo-name-medium.svg b/docs/_static/img/logo-name-medium.svg index 606cd63b2..2c718ba26 100644 --- a/docs/_static/img/logo-name-medium.svg +++ b/docs/_static/img/logo-name-medium.svg @@ -1,29 +1 @@ - - - graphblas - python- - + diff --git a/docs/_static/img/social-network.svg b/docs/_static/img/social-network.svg index a62230fa2..2e0335c54 100644 --- a/docs/_static/img/social-network.svg +++ b/docs/_static/img/social-network.svg @@ -1 +1 @@ -AnnaPriyaBlakeDanXavierYsabelle +AnnaPriyaBlakeDanXavierYsabelle diff --git a/docs/_static/img/super-simple.svg b/docs/_static/img/super-simple.svg index c79530f87..73ae2ee9b 100644 --- a/docs/_static/img/super-simple.svg +++ b/docs/_static/img/super-simple.svg @@ -1 +1 @@ -025.012.030.51.54.25 +025.012.030.51.54.25 diff --git a/docs/_static/img/task-graph.svg b/docs/_static/img/task-graph.svg index e06017e9f..f48284d93 100644 --- a/docs/_static/img/task-graph.svg +++ b/docs/_static/img/task-graph.svg @@ -1 +1 @@ -StartLoad File 1Load File 2Load File 3MergeCleanNormalizeWeekly SummaryDaily SummarySerializeReport 1Report 2DashboardReport 3 +StartLoad File 1Load File 2Load File 3MergeCleanNormalizeWeekly SummaryDaily SummarySerializeReport 1Report 2DashboardReport 3 diff --git a/docs/_static/img/undirected-graph.svg b/docs/_static/img/undirected-graph.svg index 96ac206f9..e29eb261d 100644 --- a/docs/_static/img/undirected-graph.svg +++ b/docs/_static/img/undirected-graph.svg @@ -1 +1 @@ -015.622.334.61.96.23.041.454.461.02.8 +015.622.334.61.96.23.041.454.461.02.8 From 059005114dfa6deedac9d3d670061601197d33f6 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 26 Apr 2023 08:35:16 -0600 Subject: [PATCH 20/87] Split `io.py` into multiple files (#448) --- graphblas/io.py | 697 ---------------------------------- graphblas/io/__init__.py | 7 + graphblas/io/_awkward.py | 181 +++++++++ graphblas/io/_matrixmarket.py | 131 +++++++ graphblas/io/_networkx.py | 59 +++ graphblas/io/_numpy.py | 104 +++++ graphblas/io/_scipy.py | 118 ++++++ graphblas/io/_sparse.py | 99 +++++ graphblas/io/_viz.py | 21 + pyproject.toml | 1 + 10 files changed, 721 insertions(+), 697 deletions(-) delete mode 100644 graphblas/io.py create mode 100644 graphblas/io/__init__.py create mode 100644 graphblas/io/_awkward.py create mode 100644 graphblas/io/_matrixmarket.py create mode 100644 graphblas/io/_networkx.py create mode 100644 graphblas/io/_numpy.py create mode 100644 graphblas/io/_scipy.py create mode 100644 graphblas/io/_sparse.py create mode 100644 graphblas/io/_viz.py diff --git a/graphblas/io.py b/graphblas/io.py deleted file mode 100644 index 23b9b30b7..000000000 --- a/graphblas/io.py +++ /dev/null @@ -1,697 +0,0 @@ -from warnings import warn as _warn - -import numpy as _np - -from . import backend as _backend -from .core.matrix import Matrix as _Matrix -from .core.utils import normalize_values as _normalize_values -from .core.utils import output_type as _output_type -from .core.vector import Vector as _Vector -from .dtypes import lookup_dtype as _lookup_dtype -from .exceptions import GraphblasException as _GraphblasException - - -def draw(m): # pragma: no cover (deprecated) - """Draw a square adjacency Matrix as a graph. - - Requires `networkx `_ and - `matplotlib `_ to be installed. - - Example output: - - .. image:: /_static/img/draw-example.png - """ - from . import viz - - _warn( - "`graphblas.io.draw` is deprecated; it has been moved to `graphblas.viz.draw`", - DeprecationWarning, - stacklevel=2, - ) - viz.draw(m) - - -def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): - """Create a square adjacency Matrix from a networkx Graph. - - Parameters - ---------- - G : nx.Graph - Graph to convert - nodelist : list, optional - List of nodes in the nx.Graph. If not provided, all nodes will be used. - dtype : - Data type - weight : str, default="weight" - Weight attribute - name : str, optional - Name of resulting Matrix - - Returns - ------- - :class:`~graphblas.Matrix` - """ - import networkx as nx - - if dtype is not None: - dtype = _lookup_dtype(dtype).np_type - A = nx.to_scipy_sparse_array(G, nodelist=nodelist, dtype=dtype, weight=weight) - return from_scipy_sparse(A, name=name) - - -def from_numpy(m): # pragma: no cover (deprecated) - """Create a sparse Vector or Matrix from a dense numpy array. - - .. deprecated:: 2023.2.0 - `from_numpy` will be removed in a future release. - Use `Vector.from_dense` or `Matrix.from_dense` instead. - Will be removed in version 2023.10.0 or later - - A value of 0 is considered as "missing". - - - m.ndim == 1 returns a `Vector` - - m.ndim == 2 returns a `Matrix` - - m.ndim > 2 raises an error - - dtype is inferred from m.dtype - - Parameters - ---------- - m : np.ndarray - Input array - - See Also - -------- - Matrix.from_dense - Vector.from_dense - from_scipy_sparse - - Returns - ------- - Vector or Matrix - """ - _warn( - "`graphblas.io.from_numpy` is deprecated; " - "use `Matrix.from_dense` and `Vector.from_dense` instead.", - DeprecationWarning, - stacklevel=2, - ) - if m.ndim > 2: - raise _GraphblasException("m.ndim must be <= 2") - - try: - from scipy.sparse import coo_array, csr_array - except ImportError: # pragma: no cover (import) - raise ImportError("scipy is required to import from numpy") from None - - if m.ndim == 1: - A = csr_array(m) - _, size = A.shape - dtype = _lookup_dtype(m.dtype) - return _Vector.from_coo(A.indices, A.data, size=size, dtype=dtype) - A = coo_array(m) - return from_scipy_sparse(A) - - -def from_scipy_sparse(A, *, dup_op=None, name=None): - """Create a Matrix from a scipy.sparse array or matrix. - - Input data in "csr" or "csc" format will be efficient when importing with SuiteSparse:GraphBLAS. - - Parameters - ---------- - A : scipy.sparse - Scipy sparse array or matrix - dup_op : BinaryOp, optional - Aggregation function for formats that allow duplicate entries (e.g. coo) - name : str, optional - Name of resulting Matrix - - Returns - ------- - :class:`~graphblas.Matrix` - """ - nrows, ncols = A.shape - dtype = _lookup_dtype(A.dtype) - if A.nnz == 0: - return _Matrix(dtype, nrows=nrows, ncols=ncols, name=name) - if _backend == "suitesparse" and A.format in {"csr", "csc"}: - data = A.data - is_iso = (data[[0]] == data).all() - if is_iso: - data = data[[0]] - if A.format == "csr": - return _Matrix.ss.import_csr( - nrows=nrows, - ncols=ncols, - indptr=A.indptr, - col_indices=A.indices, - values=data, - is_iso=is_iso, - sorted_cols=getattr(A, "_has_sorted_indices", False), - name=name, - ) - return _Matrix.ss.import_csc( - nrows=nrows, - ncols=ncols, - indptr=A.indptr, - row_indices=A.indices, - values=data, - is_iso=is_iso, - sorted_rows=getattr(A, "_has_sorted_indices", False), - name=name, - ) - if A.format == "csr": - return _Matrix.from_csr(A.indptr, A.indices, A.data, ncols=ncols, name=name) - if A.format == "csc": - return _Matrix.from_csc(A.indptr, A.indices, A.data, nrows=nrows, name=name) - if A.format != "coo": - A = A.tocoo() - return _Matrix.from_coo( - A.row, A.col, A.data, nrows=nrows, ncols=ncols, dtype=dtype, dup_op=dup_op, name=name - ) - - -def from_awkward(A, *, name=None): - """Create a Matrix or Vector from an Awkward Array. - - The Awkward Array must have top-level parameters: format, shape - - The Awkward Array must have top-level attributes based on format: - - vec/csr/csc: values, indices - - hypercsr/hypercsc: values, indices, offset_labels - - Parameters - ---------- - A : awkward.Array - Awkward Array with values and indices - name : str, optional - Name of resulting Matrix or Vector - - Returns - ------- - Vector or Matrix - """ - params = A.layout.parameters - if missing := {"format", "shape"} - params.keys(): - raise ValueError(f"Missing parameters: {missing}") - format = params["format"] - shape = params["shape"] - - if len(shape) == 1: - if format != "vec": - raise ValueError(f"Invalid format for Vector: {format}") - return _Vector.from_coo( - A.indices.layout.data, A.values.layout.data, size=shape[0], name=name - ) - nrows, ncols = shape - values = A.values.layout.content.data - indptr = A.values.layout.offsets.data - if format == "csr": - cols = A.indices.layout.content.data - return _Matrix.from_csr(indptr, cols, values, ncols=ncols, name=name) - if format == "csc": - rows = A.indices.layout.content.data - return _Matrix.from_csc(indptr, rows, values, nrows=nrows, name=name) - if format == "hypercsr": - rows = A.offset_labels.layout.data - cols = A.indices.layout.content.data - return _Matrix.from_dcsr(rows, indptr, cols, values, nrows=nrows, ncols=ncols, name=name) - if format == "hypercsc": - cols = A.offset_labels.layout.data - rows = A.indices.layout.content.data - return _Matrix.from_dcsc(cols, indptr, rows, values, nrows=nrows, ncols=ncols, name=name) - raise ValueError(f"Invalid format for Matrix: {format}") - - -def from_pydata_sparse(s, *, dup_op=None, name=None): - """Create a Vector or a Matrix from a pydata.sparse array or matrix. - - Input data in "gcxs" format will be efficient when importing with SuiteSparse:GraphBLAS. - - Parameters - ---------- - s : sparse - PyData sparse array or matrix (see https://sparse.pydata.org) - dup_op : BinaryOp, optional - Aggregation function for formats that allow duplicate entries (e.g. coo) - name : str, optional - Name of resulting Matrix - - Returns - ------- - :class:`~graphblas.Vector` - :class:`~graphblas.Matrix` - """ - try: - import sparse - except ImportError: # pragma: no cover (import) - raise ImportError("sparse is required to import from pydata sparse") from None - if not isinstance(s, sparse.SparseArray): - raise TypeError( - "from_pydata_sparse only accepts objects from the `sparse` library; " - "see https://sparse.pydata.org" - ) - if s.ndim > 2: - raise _GraphblasException("m.ndim must be <= 2") - - if s.ndim == 1: - # the .asformat('coo') makes it easier to convert dok/gcxs using a single approach - _s = s.asformat("coo") - return _Vector.from_coo( - _s.coords, _s.data, dtype=_s.dtype, size=_s.shape[0], dup_op=dup_op, name=name - ) - # handle two-dimensional arrays - if isinstance(s, sparse.GCXS): - return from_scipy_sparse(s.to_scipy_sparse(), dup_op=dup_op, name=name) - if isinstance(s, (sparse.DOK, sparse.COO)): - _s = s.asformat("coo") - return _Matrix.from_coo( - *_s.coords, - _s.data, - nrows=_s.shape[0], - ncols=_s.shape[1], - dtype=_s.dtype, - dup_op=dup_op, - name=name, - ) - raise ValueError(f"Unknown sparse array type: {type(s).__name__}") # pragma: no cover (safety) - - -# TODO: add parameters to allow different networkx classes and attribute names -def to_networkx(m, edge_attribute="weight"): - """Create a networkx DiGraph from a square adjacency Matrix. - - Parameters - ---------- - m : Matrix - Square adjacency Matrix - edge_attribute : str, optional - Name of edge attribute from values of Matrix. If None, values will be skipped. - Default is "weight". - - Returns - ------- - nx.DiGraph - """ - import networkx as nx - - rows, cols, vals = m.to_coo() - rows = rows.tolist() - cols = cols.tolist() - G = nx.DiGraph() - if edge_attribute is None: - G.add_edges_from(zip(rows, cols)) - else: - G.add_weighted_edges_from(zip(rows, cols, vals.tolist()), weight=edge_attribute) - return G - - -def to_numpy(m): # pragma: no cover (deprecated) - """Create a dense numpy array from a sparse Vector or Matrix. - - .. deprecated:: 2023.2.0 - `to_numpy` will be removed in a future release. - Use `Vector.to_dense` or `Matrix.to_dense` instead. - Will be removed in version 2023.10.0 or later - - Missing values will become 0 in the output. - - numpy dtype will match the GraphBLAS dtype - - Parameters - ---------- - m : Vector or Matrix - GraphBLAS Vector or Matrix - - See Also - -------- - to_scipy_sparse - Matrix.to_dense - Vector.to_dense - - Returns - ------- - np.ndarray - """ - _warn( - "`graphblas.io.to_numpy` is deprecated; " - "use `Matrix.to_dense` and `Vector.to_dense` instead.", - DeprecationWarning, - stacklevel=2, - ) - try: - import scipy # noqa: F401 - except ImportError: # pragma: no cover (import) - raise ImportError("scipy is required to export to numpy") from None - if _output_type(m) is _Vector: - return to_scipy_sparse(m).toarray()[0] - sparse = to_scipy_sparse(m, "coo") - return sparse.toarray() - - -def to_scipy_sparse(A, format="csr"): - """Create a scipy.sparse array from a GraphBLAS Matrix or Vector. - - Parameters - ---------- - A : Matrix or Vector - GraphBLAS object to be converted - format : str - {'bsr', 'csr', 'csc', 'coo', 'lil', 'dia', 'dok'} - - Returns - ------- - scipy.sparse array - - """ - import scipy.sparse as ss - - format = format.lower() - if format not in {"bsr", "csr", "csc", "coo", "lil", "dia", "dok"}: - raise ValueError(f"Invalid format: {format}") - if _output_type(A) is _Vector: - indices, data = A.to_coo() - if format == "csc": - return ss.csc_array((data, indices, [0, len(data)]), shape=(A._size, 1)) - rv = ss.csr_array((data, indices, [0, len(data)]), shape=(1, A._size)) - if format == "csr": - return rv - elif _backend == "suitesparse" and format in {"csr", "csc"}: - if A._is_transposed: - info = A.T.ss.export("csc" if format == "csr" else "csr", sort=True) - if "col_indices" in info: - info["row_indices"] = info["col_indices"] - else: - info["col_indices"] = info["row_indices"] - else: - info = A.ss.export(format, sort=True) - values = _normalize_values(A, info["values"], None, (A._nvals,), info["is_iso"]) - if format == "csr": - return ss.csr_array((values, info["col_indices"], info["indptr"]), shape=A.shape) - return ss.csc_array((values, info["row_indices"], info["indptr"]), shape=A.shape) - elif format == "csr": - indptr, cols, vals = A.to_csr() - return ss.csr_array((vals, cols, indptr), shape=A.shape) - elif format == "csc": - indptr, rows, vals = A.to_csc() - return ss.csc_array((vals, rows, indptr), shape=A.shape) - else: - rows, cols, data = A.to_coo() - rv = ss.coo_array((data, (rows, cols)), shape=A.shape) - if format == "coo": - return rv - return rv.asformat(format) - - -_AwkwardDoublyCompressedMatrix = None - - -def to_awkward(A, format=None): - """Create an Awkward Array from a GraphBLAS Matrix. - - Parameters - ---------- - A : Matrix or Vector - GraphBLAS object to be converted - format : str {'csr', 'csc', 'hypercsr', 'hypercsc', 'vec} - Default format is csr for Matrix; vec for Vector - - The Awkward Array will have top-level attributes based on format: - - vec/csr/csc: values, indices - - hypercsr/hypercsc: values, indices, offset_labels - - Top-level parameters will also be set: format, shape - - Returns - ------- - awkward.Array - - """ - try: - # awkward version 1 - # MAINT: we can probably drop awkward v1 at the end of 2024 or 2025 - import awkward._v2 as ak - from awkward._v2.forms.listoffsetform import ListOffsetForm - from awkward._v2.forms.numpyform import NumpyForm - from awkward._v2.forms.recordform import RecordForm - except ImportError: - # awkward version 2 - import awkward as ak - from awkward.forms.listoffsetform import ListOffsetForm - from awkward.forms.numpyform import NumpyForm - from awkward.forms.recordform import RecordForm - - out_type = _output_type(A) - if format is None: - format = "vec" if out_type is _Vector else "csr" - format = format.lower() - classname = None - - if out_type is _Vector: - if format != "vec": - raise ValueError(f"Invalid format for Vector: {format}") - size = A.nvals - indices, values = A.to_coo() - form = RecordForm( - contents=[ - NumpyForm(A.dtype.np_type.name, form_key="node1"), - NumpyForm("int64", form_key="node0"), - ], - fields=["values", "indices"], - ) - d = {"node0-data": indices, "node1-data": values} - - elif out_type is _Matrix: - if format == "csr": - indptr, cols, values = A.to_csr() - d = {"node3-data": cols} - size = A.nrows - elif format == "csc": - indptr, rows, values = A.to_csc() - d = {"node3-data": rows} - size = A.ncols - elif format == "hypercsr": - rows, indptr, cols, values = A.to_dcsr() - d = {"node3-data": cols, "node5-data": rows} - size = len(rows) - elif format == "hypercsc": - cols, indptr, rows, values = A.to_dcsc() - d = {"node3-data": rows, "node5-data": cols} - size = len(cols) - else: - raise ValueError(f"Invalid format for Matrix: {format}") - d["node1-offsets"] = indptr - d["node4-data"] = _np.ascontiguousarray(values) - - form = ListOffsetForm( - "i64", - RecordForm( - contents=[ - NumpyForm("int64", form_key="node3"), - NumpyForm(A.dtype.np_type.name, form_key="node4"), - ], - fields=["indices", "values"], - ), - form_key="node1", - ) - if format.startswith("hyper"): - global _AwkwardDoublyCompressedMatrix - if _AwkwardDoublyCompressedMatrix is None: # pylint: disable=used-before-assignment - # Define behaviors to make all fields function at the top-level - @ak.behaviors.mixins.mixin_class(ak.behavior) - class _AwkwardDoublyCompressedMatrix: - @property - def values(self): # pragma: no branch (???) - return self.data.values - - @property - def indices(self): # pragma: no branch (???) - return self.data.indices - - form = RecordForm( - contents=[ - form, - NumpyForm("int64", form_key="node5"), - ], - fields=["data", "offset_labels"], - ) - classname = "_AwkwardDoublyCompressedMatrix" - - else: - raise TypeError(f"A must be a Matrix or Vector, found {type(A)}") - - ret = ak.from_buffers(form, size, d) - ret = ak.with_parameter(ret, "format", format) - ret = ak.with_parameter(ret, "shape", list(A.shape)) - if classname: - ret = ak.with_name(ret, classname) - return ret - - -def to_pydata_sparse(A, format="coo"): - """Create a pydata.sparse array from a GraphBLAS Matrix or Vector. - - Parameters - ---------- - A : Matrix or Vector - GraphBLAS object to be converted - format : str - {'coo', 'dok', 'gcxs'} - - Returns - ------- - sparse array (see https://sparse.pydata.org) - - """ - try: - from sparse import COO - except ImportError: # pragma: no cover (import) - raise ImportError("sparse is required to export to pydata sparse") from None - - format = format.lower() - if format not in {"coo", "dok", "gcxs"}: - raise ValueError(f"Invalid format: {format}") - - if _output_type(A) is _Vector: - indices, values = A.to_coo(sort=False) - s = COO(indices, values, shape=A.shape) - else: - if format == "gcxs": - B = to_scipy_sparse(A, format="csr") - else: - # obtain an intermediate conversion via hardcoded 'coo' intermediate object - B = to_scipy_sparse(A, format="coo") - # convert to pydata.sparse - s = COO.from_scipy_sparse(B) - - # express in the desired format - return s.asformat(format) - - -def mmread(source, engine="auto", *, dup_op=None, name=None, **kwargs): - """Create a GraphBLAS Matrix from the contents of a Matrix Market file. - - This uses `scipy.io.mmread - `_ - or `fast_matrix_market.mmread - `_. - - By default, ``fast_matrix_market`` will be used if available, because it - is faster. Additional keyword arguments in ``**kwargs`` will be passed - to the engine's ``mmread``. For example, ``parallelism=8`` will set the - number of threads to use to 8 when using ``fast_matrix_market``. - - Parameters - ---------- - source : str or file - Filename (.mtx or .mtz.gz) or file-like object - engine : {"auto", "scipy", "fmm", "fast_matrix_market"}, default "auto" - How to read the matrix market file. "scipy" uses ``scipy.io.mmread``, - "fmm" and "fast_matrix_market" uses ``fast_matrix_market.mmread``, - and "auto" will use "fast_matrix_market" if available. - dup_op : BinaryOp, optional - Aggregation function for duplicate coordinates (if found) - name : str, optional - Name of resulting Matrix - - Returns - ------- - :class:`~graphblas.Matrix` - """ - try: - # scipy is currently needed for *all* engines - from scipy.io import mmread - from scipy.sparse import isspmatrix_coo - except ImportError: # pragma: no cover (import) - raise ImportError("scipy is required to read Matrix Market files") from None - engine = engine.lower() - if engine in {"auto", "fmm", "fast_matrix_market"}: - try: - from fast_matrix_market import mmread # noqa: F811 - except ImportError: # pragma: no cover (import) - if engine != "auto": - raise ImportError( - "fast_matrix_market is required to read Matrix Market files " - f'using the "{engine}" engine' - ) from None - elif engine != "scipy": - raise ValueError( - f'Bad engine value: {engine!r}. Must be "auto", "scipy", "fmm", or "fast_matrix_market"' - ) - array = mmread(source, **kwargs) - if isspmatrix_coo(array): - nrows, ncols = array.shape - return _Matrix.from_coo( - array.row, array.col, array.data, nrows=nrows, ncols=ncols, dup_op=dup_op, name=name - ) - return _Matrix.from_dense(array, name=name) - - -def mmwrite( - target, - matrix, - engine="auto", - *, - comment="", - field=None, - precision=None, - symmetry=None, - **kwargs, -): - """Write a Matrix Market file from the contents of a GraphBLAS Matrix. - - This uses `scipy.io.mmwrite - `_. - - Parameters - ---------- - target : str or file target - Filename (.mtx) or file-like object opened for writing - matrix : Matrix - Matrix to be written - engine : {"auto", "scipy", "fmm", "fast_matrix_market"}, default "auto" - How to read the matrix market file. "scipy" uses ``scipy.io.mmwrite``, - "fmm" and "fast_matrix_market" uses ``fast_matrix_market.mmwrite``, - and "auto" will use "fast_matrix_market" if available. - comment : str, optional - Comments to be prepended to the Matrix Market file - field : str - {"real", "complex", "pattern", "integer"} - precision : int, optional - Number of digits to write for real or complex values - symmetry : str, optional - {"general", "symmetric", "skew-symmetric", "hermetian"} - """ - try: - # scipy is currently needed for *all* engines - from scipy.io import mmwrite - except ImportError: # pragma: no cover (import) - raise ImportError("scipy is required to write Matrix Market files") from None - engine = engine.lower() - if engine in {"auto", "fmm", "fast_matrix_market"}: - try: - from fast_matrix_market import 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, - **kwargs, - ) diff --git a/graphblas/io/__init__.py b/graphblas/io/__init__.py new file mode 100644 index 000000000..0eafd45c8 --- /dev/null +++ b/graphblas/io/__init__.py @@ -0,0 +1,7 @@ +from ._awkward import from_awkward, to_awkward +from ._matrixmarket import mmread, mmwrite +from ._networkx import from_networkx, to_networkx +from ._numpy import from_numpy, to_numpy # deprecated +from ._scipy import from_scipy_sparse, to_scipy_sparse +from ._sparse import from_pydata_sparse, to_pydata_sparse +from ._viz import draw # deprecated diff --git a/graphblas/io/_awkward.py b/graphblas/io/_awkward.py new file mode 100644 index 000000000..3119bdf3b --- /dev/null +++ b/graphblas/io/_awkward.py @@ -0,0 +1,181 @@ +import numpy as np + +from ..core.matrix import Matrix +from ..core.utils import output_type +from ..core.vector import Vector + +_AwkwardDoublyCompressedMatrix = None + + +def from_awkward(A, *, name=None): + """Create a Matrix or Vector from an Awkward Array. + + The Awkward Array must have top-level parameters: format, shape + + The Awkward Array must have top-level attributes based on format: + - vec/csr/csc: values, indices + - hypercsr/hypercsc: values, indices, offset_labels + + Parameters + ---------- + A : awkward.Array + Awkward Array with values and indices + name : str, optional + Name of resulting Matrix or Vector + + Returns + ------- + Vector or Matrix + """ + params = A.layout.parameters + if missing := {"format", "shape"} - params.keys(): + raise ValueError(f"Missing parameters: {missing}") + format = params["format"] + shape = params["shape"] + + if len(shape) == 1: + if format != "vec": + raise ValueError(f"Invalid format for Vector: {format}") + return Vector.from_coo( + A.indices.layout.data, A.values.layout.data, size=shape[0], name=name + ) + nrows, ncols = shape + values = A.values.layout.content.data + indptr = A.values.layout.offsets.data + if format == "csr": + cols = A.indices.layout.content.data + return Matrix.from_csr(indptr, cols, values, ncols=ncols, name=name) + if format == "csc": + rows = A.indices.layout.content.data + return Matrix.from_csc(indptr, rows, values, nrows=nrows, name=name) + if format == "hypercsr": + rows = A.offset_labels.layout.data + cols = A.indices.layout.content.data + return Matrix.from_dcsr(rows, indptr, cols, values, nrows=nrows, ncols=ncols, name=name) + if format == "hypercsc": + cols = A.offset_labels.layout.data + rows = A.indices.layout.content.data + return Matrix.from_dcsc(cols, indptr, rows, values, nrows=nrows, ncols=ncols, name=name) + raise ValueError(f"Invalid format for Matrix: {format}") + + +def to_awkward(A, format=None): + """Create an Awkward Array from a GraphBLAS Matrix. + + Parameters + ---------- + A : Matrix or Vector + GraphBLAS object to be converted + format : str {'csr', 'csc', 'hypercsr', 'hypercsc', 'vec} + Default format is csr for Matrix; vec for Vector + + The Awkward Array will have top-level attributes based on format: + - vec/csr/csc: values, indices + - hypercsr/hypercsc: values, indices, offset_labels + + Top-level parameters will also be set: format, shape + + Returns + ------- + awkward.Array + + """ + try: + # awkward version 1 + # MAINT: we can probably drop awkward v1 at the end of 2024 or 2025 + import awkward._v2 as ak + from awkward._v2.forms.listoffsetform import ListOffsetForm + from awkward._v2.forms.numpyform import NumpyForm + from awkward._v2.forms.recordform import RecordForm + except ImportError: + # awkward version 2 + import awkward as ak + from awkward.forms.listoffsetform import ListOffsetForm + from awkward.forms.numpyform import NumpyForm + from awkward.forms.recordform import RecordForm + + out_type = output_type(A) + if format is None: + format = "vec" if out_type is Vector else "csr" + format = format.lower() + classname = None + + if out_type is Vector: + if format != "vec": + raise ValueError(f"Invalid format for Vector: {format}") + size = A.nvals + indices, values = A.to_coo() + form = RecordForm( + contents=[ + NumpyForm(A.dtype.np_type.name, form_key="node1"), + NumpyForm("int64", form_key="node0"), + ], + fields=["values", "indices"], + ) + d = {"node0-data": indices, "node1-data": values} + + elif out_type is Matrix: + if format == "csr": + indptr, cols, values = A.to_csr() + d = {"node3-data": cols} + size = A.nrows + elif format == "csc": + indptr, rows, values = A.to_csc() + d = {"node3-data": rows} + size = A.ncols + elif format == "hypercsr": + rows, indptr, cols, values = A.to_dcsr() + d = {"node3-data": cols, "node5-data": rows} + size = len(rows) + elif format == "hypercsc": + cols, indptr, rows, values = A.to_dcsc() + d = {"node3-data": rows, "node5-data": cols} + size = len(cols) + else: + raise ValueError(f"Invalid format for Matrix: {format}") + d["node1-offsets"] = indptr + d["node4-data"] = np.ascontiguousarray(values) + + form = ListOffsetForm( + "i64", + RecordForm( + contents=[ + NumpyForm("int64", form_key="node3"), + NumpyForm(A.dtype.np_type.name, form_key="node4"), + ], + fields=["indices", "values"], + ), + form_key="node1", + ) + if format.startswith("hyper"): + global _AwkwardDoublyCompressedMatrix + if _AwkwardDoublyCompressedMatrix is None: # pylint: disable=used-before-assignment + # Define behaviors to make all fields function at the top-level + @ak.behaviors.mixins.mixin_class(ak.behavior) + class _AwkwardDoublyCompressedMatrix: + @property + def values(self): # pragma: no branch (???) + return self.data.values + + @property + def indices(self): # pragma: no branch (???) + return self.data.indices + + form = RecordForm( + contents=[ + form, + NumpyForm("int64", form_key="node5"), + ], + fields=["data", "offset_labels"], + ) + classname = "_AwkwardDoublyCompressedMatrix" + + else: + raise TypeError(f"A must be a Matrix or Vector, found {type(A)}") + + ret = ak.from_buffers(form, size, d) + ret = ak.with_parameter(ret, "format", format) + ret = ak.with_parameter(ret, "shape", list(A.shape)) + if classname: + ret = ak.with_name(ret, classname) + return ret diff --git a/graphblas/io/_matrixmarket.py b/graphblas/io/_matrixmarket.py new file mode 100644 index 000000000..294bcfa1e --- /dev/null +++ b/graphblas/io/_matrixmarket.py @@ -0,0 +1,131 @@ +from .. import backend +from ..core.matrix import Matrix +from ._scipy import to_scipy_sparse + + +def mmread(source, engine="auto", *, dup_op=None, name=None, **kwargs): + """Create a GraphBLAS Matrix from the contents of a Matrix Market file. + + This uses `scipy.io.mmread + `_ + or `fast_matrix_market.mmread + `_. + + By default, ``fast_matrix_market`` will be used if available, because it + is faster. Additional keyword arguments in ``**kwargs`` will be passed + to the engine's ``mmread``. For example, ``parallelism=8`` will set the + number of threads to use to 8 when using ``fast_matrix_market``. + + Parameters + ---------- + source : str or file + Filename (.mtx or .mtz.gz) or file-like object + engine : {"auto", "scipy", "fmm", "fast_matrix_market"}, default "auto" + How to read the matrix market file. "scipy" uses ``scipy.io.mmread``, + "fmm" and "fast_matrix_market" uses ``fast_matrix_market.mmread``, + and "auto" will use "fast_matrix_market" if available. + dup_op : BinaryOp, optional + Aggregation function for duplicate coordinates (if found) + name : str, optional + Name of resulting Matrix + + Returns + ------- + :class:`~graphblas.Matrix` + """ + try: + # scipy is currently needed for *all* engines + from scipy.io import mmread + from scipy.sparse import isspmatrix_coo + except ImportError: # pragma: no cover (import) + raise ImportError("scipy is required to read Matrix Market files") from None + engine = engine.lower() + if engine in {"auto", "fmm", "fast_matrix_market"}: + try: + from fast_matrix_market import mmread # noqa: F811 + except ImportError: # pragma: no cover (import) + if engine != "auto": + raise ImportError( + "fast_matrix_market is required to read Matrix Market files " + f'using the "{engine}" engine' + ) from None + elif engine != "scipy": + raise ValueError( + f'Bad engine value: {engine!r}. Must be "auto", "scipy", "fmm", or "fast_matrix_market"' + ) + array = mmread(source, **kwargs) + if isspmatrix_coo(array): + nrows, ncols = array.shape + return Matrix.from_coo( + array.row, array.col, array.data, nrows=nrows, ncols=ncols, dup_op=dup_op, name=name + ) + return Matrix.from_dense(array, name=name) + + +def mmwrite( + target, + matrix, + engine="auto", + *, + comment="", + field=None, + precision=None, + symmetry=None, + **kwargs, +): + """Write a Matrix Market file from the contents of a GraphBLAS Matrix. + + This uses `scipy.io.mmwrite + `_. + + Parameters + ---------- + target : str or file target + Filename (.mtx) or file-like object opened for writing + matrix : Matrix + Matrix to be written + engine : {"auto", "scipy", "fmm", "fast_matrix_market"}, default "auto" + How to read the matrix market file. "scipy" uses ``scipy.io.mmwrite``, + "fmm" and "fast_matrix_market" uses ``fast_matrix_market.mmwrite``, + and "auto" will use "fast_matrix_market" if available. + comment : str, optional + Comments to be prepended to the Matrix Market file + field : str + {"real", "complex", "pattern", "integer"} + precision : int, optional + Number of digits to write for real or complex values + symmetry : str, optional + {"general", "symmetric", "skew-symmetric", "hermetian"} + """ + try: + # scipy is currently needed for *all* engines + from scipy.io import mmwrite + except ImportError: # pragma: no cover (import) + raise ImportError("scipy is required to write Matrix Market files") from None + engine = engine.lower() + if engine in {"auto", "fmm", "fast_matrix_market"}: + try: + from fast_matrix_market import 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, + **kwargs, + ) diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py new file mode 100644 index 000000000..2324a11c2 --- /dev/null +++ b/graphblas/io/_networkx.py @@ -0,0 +1,59 @@ +from ..dtypes import lookup_dtype +from ._scipy import from_scipy_sparse + + +def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): + """Create a square adjacency Matrix from a networkx Graph. + + Parameters + ---------- + G : nx.Graph + Graph to convert + nodelist : list, optional + List of nodes in the nx.Graph. If not provided, all nodes will be used. + dtype : + Data type + weight : str, default="weight" + Weight attribute + name : str, optional + Name of resulting Matrix + + Returns + ------- + :class:`~graphblas.Matrix` + """ + import networkx as nx + + if dtype is not None: + dtype = lookup_dtype(dtype).np_type + A = nx.to_scipy_sparse_array(G, nodelist=nodelist, dtype=dtype, weight=weight) + return from_scipy_sparse(A, name=name) + + +# TODO: add parameters to allow different networkx classes and attribute names +def to_networkx(m, edge_attribute="weight"): + """Create a networkx DiGraph from a square adjacency Matrix. + + Parameters + ---------- + m : Matrix + Square adjacency Matrix + edge_attribute : str, optional + Name of edge attribute from values of Matrix. If None, values will be skipped. + Default is "weight". + + Returns + ------- + nx.DiGraph + """ + import networkx as nx + + rows, cols, vals = m.to_coo() + rows = rows.tolist() + cols = cols.tolist() + G = nx.DiGraph() + if edge_attribute is None: + G.add_edges_from(zip(rows, cols)) + else: + G.add_weighted_edges_from(zip(rows, cols, vals.tolist()), weight=edge_attribute) + return G diff --git a/graphblas/io/_numpy.py b/graphblas/io/_numpy.py new file mode 100644 index 000000000..1c40e1633 --- /dev/null +++ b/graphblas/io/_numpy.py @@ -0,0 +1,104 @@ +from warnings import warn + +from ..core.utils import output_type +from ..core.vector import Vector +from ..dtypes import lookup_dtype +from ..exceptions import GraphblasException +from ._scipy import from_scipy_sparse, to_scipy_sparse + + +def from_numpy(m): # pragma: no cover (deprecated) + """Create a sparse Vector or Matrix from a dense numpy array. + + .. deprecated:: 2023.2.0 + `from_numpy` will be removed in a future release. + Use `Vector.from_dense` or `Matrix.from_dense` instead. + Will be removed in version 2023.10.0 or later + + A value of 0 is considered as "missing". + + - m.ndim == 1 returns a `Vector` + - m.ndim == 2 returns a `Matrix` + - m.ndim > 2 raises an error + + dtype is inferred from m.dtype + + Parameters + ---------- + m : np.ndarray + Input array + + See Also + -------- + Matrix.from_dense + Vector.from_dense + from_scipy_sparse + + Returns + ------- + Vector or Matrix + """ + warn( + "`graphblas.io.from_numpy` is deprecated; " + "use `Matrix.from_dense` and `Vector.from_dense` instead.", + DeprecationWarning, + stacklevel=2, + ) + if m.ndim > 2: + raise GraphblasException("m.ndim must be <= 2") + + try: + from scipy.sparse import coo_array, csr_array + except ImportError: # pragma: no cover (import) + raise ImportError("scipy is required to import from numpy") from None + + if m.ndim == 1: + A = csr_array(m) + _, size = A.shape + dtype = lookup_dtype(m.dtype) + return Vector.from_coo(A.indices, A.data, size=size, dtype=dtype) + A = coo_array(m) + return from_scipy_sparse(A) + + +def to_numpy(m): # pragma: no cover (deprecated) + """Create a dense numpy array from a sparse Vector or Matrix. + + .. deprecated:: 2023.2.0 + `to_numpy` will be removed in a future release. + Use `Vector.to_dense` or `Matrix.to_dense` instead. + Will be removed in version 2023.10.0 or later + + Missing values will become 0 in the output. + + numpy dtype will match the GraphBLAS dtype + + Parameters + ---------- + m : Vector or Matrix + GraphBLAS Vector or Matrix + + See Also + -------- + to_scipy_sparse + Matrix.to_dense + Vector.to_dense + + Returns + ------- + np.ndarray + """ + warn( + "`graphblas.io.to_numpy` is deprecated; " + "use `Matrix.to_dense` and `Vector.to_dense` instead.", + DeprecationWarning, + stacklevel=2, + ) + try: + import scipy # noqa: F401 + except ImportError: # pragma: no cover (import) + raise ImportError("scipy is required to export to numpy") from None + if output_type(m) is Vector: + return to_scipy_sparse(m).toarray()[0] + sparse = to_scipy_sparse(m, "coo") + return sparse.toarray() diff --git a/graphblas/io/_scipy.py b/graphblas/io/_scipy.py new file mode 100644 index 000000000..1eaa691dd --- /dev/null +++ b/graphblas/io/_scipy.py @@ -0,0 +1,118 @@ +from .. import backend +from ..core.matrix import Matrix +from ..core.utils import normalize_values, output_type +from ..core.vector import Vector +from ..dtypes import lookup_dtype + + +def from_scipy_sparse(A, *, dup_op=None, name=None): + """Create a Matrix from a scipy.sparse array or matrix. + + Input data in "csr" or "csc" format will be efficient when importing with SuiteSparse:GraphBLAS. + + Parameters + ---------- + A : scipy.sparse + Scipy sparse array or matrix + dup_op : BinaryOp, optional + Aggregation function for formats that allow duplicate entries (e.g. coo) + name : str, optional + Name of resulting Matrix + + Returns + ------- + :class:`~graphblas.Matrix` + """ + nrows, ncols = A.shape + dtype = lookup_dtype(A.dtype) + if A.nnz == 0: + return Matrix(dtype, nrows=nrows, ncols=ncols, name=name) + if backend == "suitesparse" and A.format in {"csr", "csc"}: + data = A.data + is_iso = (data[[0]] == data).all() + if is_iso: + data = data[[0]] + if A.format == "csr": + return Matrix.ss.import_csr( + nrows=nrows, + ncols=ncols, + indptr=A.indptr, + col_indices=A.indices, + values=data, + is_iso=is_iso, + sorted_cols=getattr(A, "_has_sorted_indices", False), + name=name, + ) + return Matrix.ss.import_csc( + nrows=nrows, + ncols=ncols, + indptr=A.indptr, + row_indices=A.indices, + values=data, + is_iso=is_iso, + sorted_rows=getattr(A, "_has_sorted_indices", False), + name=name, + ) + if A.format == "csr": + return Matrix.from_csr(A.indptr, A.indices, A.data, ncols=ncols, name=name) + if A.format == "csc": + return Matrix.from_csc(A.indptr, A.indices, A.data, nrows=nrows, name=name) + if A.format != "coo": + A = A.tocoo() + return Matrix.from_coo( + A.row, A.col, A.data, nrows=nrows, ncols=ncols, dtype=dtype, dup_op=dup_op, name=name + ) + + +def to_scipy_sparse(A, format="csr"): + """Create a scipy.sparse array from a GraphBLAS Matrix or Vector. + + Parameters + ---------- + A : Matrix or Vector + GraphBLAS object to be converted + format : str + {'bsr', 'csr', 'csc', 'coo', 'lil', 'dia', 'dok'} + + Returns + ------- + scipy.sparse array + + """ + import scipy.sparse as ss + + format = format.lower() + if format not in {"bsr", "csr", "csc", "coo", "lil", "dia", "dok"}: + raise ValueError(f"Invalid format: {format}") + if output_type(A) is Vector: + indices, data = A.to_coo() + if format == "csc": + return ss.csc_array((data, indices, [0, len(data)]), shape=(A._size, 1)) + rv = ss.csr_array((data, indices, [0, len(data)]), shape=(1, A._size)) + if format == "csr": + return rv + elif backend == "suitesparse" and format in {"csr", "csc"}: + if A._is_transposed: + info = A.T.ss.export("csc" if format == "csr" else "csr", sort=True) + if "col_indices" in info: + info["row_indices"] = info["col_indices"] + else: + info["col_indices"] = info["row_indices"] + else: + info = A.ss.export(format, sort=True) + values = normalize_values(A, info["values"], None, (A._nvals,), info["is_iso"]) + if format == "csr": + return ss.csr_array((values, info["col_indices"], info["indptr"]), shape=A.shape) + return ss.csc_array((values, info["row_indices"], info["indptr"]), shape=A.shape) + elif format == "csr": + indptr, cols, vals = A.to_csr() + return ss.csr_array((vals, cols, indptr), shape=A.shape) + elif format == "csc": + indptr, rows, vals = A.to_csc() + return ss.csc_array((vals, rows, indptr), shape=A.shape) + else: + rows, cols, data = A.to_coo() + rv = ss.coo_array((data, (rows, cols)), shape=A.shape) + if format == "coo": + return rv + return rv.asformat(format) diff --git a/graphblas/io/_sparse.py b/graphblas/io/_sparse.py new file mode 100644 index 000000000..2bbdad2e6 --- /dev/null +++ b/graphblas/io/_sparse.py @@ -0,0 +1,99 @@ +from ..core.matrix import Matrix +from ..core.utils import output_type +from ..core.vector import Vector +from ..exceptions import GraphblasException +from ._scipy import from_scipy_sparse, to_scipy_sparse + + +def from_pydata_sparse(s, *, dup_op=None, name=None): + """Create a Vector or a Matrix from a pydata.sparse array or matrix. + + Input data in "gcxs" format will be efficient when importing with SuiteSparse:GraphBLAS. + + Parameters + ---------- + s : sparse + PyData sparse array or matrix (see https://sparse.pydata.org) + dup_op : BinaryOp, optional + Aggregation function for formats that allow duplicate entries (e.g. coo) + name : str, optional + Name of resulting Matrix + + Returns + ------- + :class:`~graphblas.Vector` + :class:`~graphblas.Matrix` + """ + try: + import sparse + except ImportError: # pragma: no cover (import) + raise ImportError("sparse is required to import from pydata sparse") from None + if not isinstance(s, sparse.SparseArray): + raise TypeError( + "from_pydata_sparse only accepts objects from the `sparse` library; " + "see https://sparse.pydata.org" + ) + if s.ndim > 2: + raise GraphblasException("m.ndim must be <= 2") + + if s.ndim == 1: + # the .asformat('coo') makes it easier to convert dok/gcxs using a single approach + _s = s.asformat("coo") + return Vector.from_coo( + _s.coords, _s.data, dtype=_s.dtype, size=_s.shape[0], dup_op=dup_op, name=name + ) + # handle two-dimensional arrays + if isinstance(s, sparse.GCXS): + return from_scipy_sparse(s.to_scipy_sparse(), dup_op=dup_op, name=name) + if isinstance(s, (sparse.DOK, sparse.COO)): + _s = s.asformat("coo") + return Matrix.from_coo( + *_s.coords, + _s.data, + nrows=_s.shape[0], + ncols=_s.shape[1], + dtype=_s.dtype, + dup_op=dup_op, + name=name, + ) + raise ValueError(f"Unknown sparse array type: {type(s).__name__}") # pragma: no cover (safety) + + +def to_pydata_sparse(A, format="coo"): + """Create a pydata.sparse array from a GraphBLAS Matrix or Vector. + + Parameters + ---------- + A : Matrix or Vector + GraphBLAS object to be converted + format : str + {'coo', 'dok', 'gcxs'} + + Returns + ------- + sparse array (see https://sparse.pydata.org) + + """ + try: + from sparse import COO + except ImportError: # pragma: no cover (import) + raise ImportError("sparse is required to export to pydata sparse") from None + + format = format.lower() + if format not in {"coo", "dok", "gcxs"}: + raise ValueError(f"Invalid format: {format}") + + if output_type(A) is Vector: + indices, values = A.to_coo(sort=False) + s = COO(indices, values, shape=A.shape) + else: + if format == "gcxs": + B = to_scipy_sparse(A, format="csr") + else: + # obtain an intermediate conversion via hardcoded 'coo' intermediate object + B = to_scipy_sparse(A, format="coo") + # convert to pydata.sparse + s = COO.from_scipy_sparse(B) + + # express in the desired format + return s.asformat(format) diff --git a/graphblas/io/_viz.py b/graphblas/io/_viz.py new file mode 100644 index 000000000..19211573f --- /dev/null +++ b/graphblas/io/_viz.py @@ -0,0 +1,21 @@ +from warnings import warn + + +def draw(m): # pragma: no cover (deprecated) + """Draw a square adjacency Matrix as a graph. + + Requires `networkx `_ and + `matplotlib `_ to be installed. + + Example output: + + .. image:: /_static/img/draw-example.png + """ + from .. import viz + + warn( + "`graphblas.io.draw` is deprecated; it has been moved to `graphblas.viz.draw`", + DeprecationWarning, + stacklevel=2, + ) + viz.draw(m) diff --git a/pyproject.toml b/pyproject.toml index 1eaa942e1..a3a5b8276 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,6 +139,7 @@ packages = [ "graphblas.core.operator", "graphblas.core.ss", "graphblas.indexunary", + "graphblas.io", "graphblas.monoid", "graphblas.op", "graphblas.semiring", From 096c5edcb3938652a1d25f4a43d4b491418ebeac Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 3 May 2023 09:37:37 -0500 Subject: [PATCH 21/87] Document parameters in operator registrations methods (#446) * Document parameters in operator registrations methods * Change to `pip install .[all]` instead of `pip install .[complete]` * numba 0.57.0 released --- .github/workflows/test_and_build.yml | 14 ++-- .pre-commit-config.yaml | 10 +-- docs/conf.py | 2 +- graphblas/binary/__init__.py | 2 +- graphblas/core/agg.py | 2 +- graphblas/core/operator/binary.py | 95 +++++++++++++++++++++++---- graphblas/core/operator/indexunary.py | 88 ++++++++++++++++++++++--- graphblas/core/operator/monoid.py | 49 ++++++++++---- graphblas/core/operator/select.py | 85 ++++++++++++++++++++++-- graphblas/core/operator/semiring.py | 40 +++++++---- graphblas/core/operator/unary.py | 69 +++++++++++++++++-- pyproject.toml | 16 +++-- scripts/check_versions.sh | 6 +- 13 files changed, 395 insertions(+), 83 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 6aa692155..c20530fbe 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -210,18 +210,12 @@ jobs: else psgver="" fi - # TODO: drop 0.57.0rc1 and use 0.57 once numba 0.57 is properly released if [[ ${npver} == "=1.24" || ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]] ; then - numbaver=$(python -c 'import random ; print(random.choice(["=0.57.0rc1", ""]))') + numbaver=$(python -c 'import random ; print(random.choice(["=0.57", ""]))') elif [[ ${npver} == "=1.21" ]] ; then - numbaver=$(python -c 'import random ; print(random.choice(["=0.55", "=0.56", "=0.57.0rc1", ""]))') + numbaver=$(python -c 'import random ; print(random.choice(["=0.55", "=0.56", "=0.57", ""]))') else - numbaver=$(python -c 'import random ; print(random.choice(["=0.56", "=0.57.0rc1", ""]))') - fi - if [[ ${{ matrix.os == 'windows-latest' }} == true && ( ${npver} == "=1.24" || ${numbaver} == "=0.57.0rc1" ) ]] ; then - # TODO: numba 0.57.0rc1 currently crashes sometimes on windows, so skip it for now - npver="" - numbaver="" + numbaver=$(python -c 'import random ; print(random.choice(["=0.56", "=0.57", ""]))') fi fmm=fast_matrix_market${fmmver} awkward=awkward${akver} @@ -254,7 +248,7 @@ jobs: fi echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psgver${psgver}" - # TODO: remove `-c numba` when numba 0.57 is properly released + # TODO: remove `-c numba` when numba 0.57 is properly released on conda-forge $(command -v mamba || command -v conda) install -c numba packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli \ pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \ networkx${nxver} ${numba} ${fmm} ${psg} \ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 426153fee..8f4fac317 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,8 +30,8 @@ repos: - id: validate-pyproject name: Validate pyproject.toml # I don't yet trust ruff to do what autoflake does - - repo: https://github.com/myint/autoflake - rev: v2.0.2 + - repo: https://github.com/PyCQA/autoflake + rev: v2.1.1 hooks: - id: autoflake args: [--in-place] @@ -43,7 +43,7 @@ repos: - 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 + rev: v3.3.2 hooks: - id: pyupgrade args: [--py38-plus] @@ -58,7 +58,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.261 + rev: v0.0.264 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -86,7 +86,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.261 + rev: v0.0.264 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/docs/conf.py b/docs/conf.py index dc73c8304..3e1a8c85b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,7 @@ # The full version, including alpha/beta/rc tags # release = "1.3.2" # See: https://github.com/pypa/setuptools_scm/#usage-from-sphinx -from importlib.metadata import version # noqa: E402 isort: skip +from importlib.metadata import version # noqa: E402 isort:skip release = version("python-graphblas") del version diff --git a/graphblas/binary/__init__.py b/graphblas/binary/__init__.py index 68bab4d55..1b8985f73 100644 --- a/graphblas/binary/__init__.py +++ b/graphblas/binary/__init__.py @@ -1,6 +1,6 @@ # All items are dynamically added by classes in operator.py # This module acts as a container of BinaryOp instances -from ..core import _supports_udfs # isort:skip +from ..core import _supports_udfs _delayed = {} _delayed_commutes_to = { diff --git a/graphblas/core/agg.py b/graphblas/core/agg.py index 3418daffc..b9f1977ab 100644 --- a/graphblas/core/agg.py +++ b/graphblas/core/agg.py @@ -8,7 +8,7 @@ """ import warnings -from .operator.agg import * +from .operator.agg import * # pylint: disable=wildcard-import,unused-wildcard-import warnings.warn( "graphblas.core.agg namespace is deprecated; please use graphblas.core.operator.agg instead.", diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py index 8d41a097e..406405a80 100644 --- a/graphblas/core/operator/binary.py +++ b/graphblas/core/operator/binary.py @@ -630,6 +630,34 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals """Register a BinaryOp without registering it in the ``graphblas.binary`` namespace. Because it is not registered in the namespace, the name is optional. + + Parameters + ---------- + func : FunctionType + The function to compile. For all current backends, this must be able + to be compiled with ``numba.njit``. + ``func`` takes two input parameters of any dtype and returns any dtype. + name : str, optional + The name of the operator. This *does not* show up as ``gb.binary.{name}``. + parameterized : bool, default False + When True, create a parameterized user-defined operator, which means + additional parameters can be "baked into" the operator when used. + For example, ``gb.binary.isclose`` is a parameterized function that + optionally accepts ``rel_tol`` and ``abs_tol`` parameters, and it + can be used as: ``A.ewise_mult(B, gb.binary.isclose(rel_tol=1e-5))``. + When creating a parameterized user-defined operator, the ``func`` + parameter must be a callable that *returns* a function that will + then get compiled. + is_udt : bool, default False + Whether the operator is intended to operate on user-defined types. + If True, then the function will not be automatically compiled for + builtin types, and it will be compiled "just in time" when used. + Setting ``is_udt=True`` is also helpful when the left and right + dtypes need to be different. + + Returns + ------- + BinaryOp or ParameterizedBinaryOp """ cls._check_supports_udf("register_anonymous") if parameterized: @@ -638,19 +666,60 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals @classmethod def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): - """Register a BinaryOp. The name will be used to identify the BinaryOp in the - ``graphblas.binary`` namespace. - - >>> def max_zero(x, y): - r = 0 - if x > r: - r = x - if y > r: - r = y - return r - >>> gb.core.operator.BinaryOp.register_new("max_zero", max_zero) - >>> dir(gb.binary) - [..., 'max_zero', ...] + """Register a new BinaryOp and save it to ``graphblas.binary`` namespace. + + Parameters + ---------- + name : str + The name of the operator. This will show up as ``gb.binary.{name}``. + The name may contain periods, ".", which will result in nested objects + such as ``gb.binary.x.y.z`` for name ``"x.y.z"``. + func : FunctionType + The function to compile. For all current backends, this must be able + to be compiled with ``numba.njit``. + ``func`` takes two input parameters of any dtype and returns any dtype. + parameterized : bool, default False + When True, create a parameterized user-defined operator, which means + additional parameters can be "baked into" the operator when used. + For example, ``gb.binary.isclose`` is a parameterized function that + optionally accepts ``rel_tol`` and ``abs_tol`` parameters, and it + can be used as: ``A.ewise_mult(B, gb.binary.isclose(rel_tol=1e-5))``. + When creating a parameterized user-defined operator, the ``func`` + parameter must be a callable that *returns* a function that will + then get compiled. See the ``user_isclose`` example below. + is_udt : bool, default False + Whether the operator is intended to operate on user-defined types. + If True, then the function will not be automatically compiled for + builtin types, and it will be compiled "just in time" when used. + Setting ``is_udt=True`` is also helpful when the left and right + dtypes need to be different. + lazy : bool, default False + If False (the default), then the function will be automatically + compiled for builtin types (unless ``is_udt`` is True). + Compiling functions can be slow, however, so you may want to + delay compilation and only compile when the operator is used, + which is done by setting ``lazy=True``. + + Examples + -------- + >>> def max_zero(x, y): + r = 0 + if x > r: + r = x + if y > r: + r = y + return r + >>> gb.core.operator.BinaryOp.register_new("max_zero", max_zero) + >>> dir(gb.binary) + [..., 'max_zero', ...] + + This is how ``gb.binary.isclose`` is defined: + + >>> def user_isclose(rel_tol=1e-7, abs_tol=0.0): + >>> def inner(x, y): + >>> return x == y or abs(x - y) <= max(rel_tol * max(abs(x), abs(y)), abs_tol) + >>> return inner + >>> gb.binary.register_new("user_isclose", user_isclose, parameterized=True) """ cls._check_supports_udf("register_new") module, funcname = cls._remove_nesting(name) diff --git a/graphblas/core/operator/indexunary.py b/graphblas/core/operator/indexunary.py index ad5d841d0..f6637ae6d 100644 --- a/graphblas/core/operator/indexunary.py +++ b/graphblas/core/operator/indexunary.py @@ -241,10 +241,42 @@ def _compile_udt(self, dtype, dtype2): @classmethod def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=False): - """Register an IndexUnaryOp without registering it in the - ``graphblas.indexunary`` namespace. + """Register a IndexUnary without registering it in the ``graphblas.indexunary`` namespace. Because it is not registered in the namespace, the name is optional. + + Parameters + ---------- + func : FunctionType + The function to compile. For all current backends, this must be able + to be compiled with ``numba.njit``. + ``func`` takes four input parameters--any dtype, int64, int64, + any dtype and returns any dtype. The first argument (any dtype) is + the value of the input Matrix or Vector, the second argument (int64) + is the row index of the Matrix or the index of the Vector, the third + argument (int64) is the column index of the Matrix or 0 for a Vector, + and the fourth argument (any dtype) is the value of the input Scalar. + name : str, optional + The name of the operator. This *does not* show up as ``gb.indexunary.{name}``. + parameterized : bool, default False + When True, create a parameterized user-defined operator, which means + additional parameters can be "baked into" the operator when used. + For example, ``gb.binary.isclose`` is a parameterized BinaryOp that + optionally accepts ``rel_tol`` and ``abs_tol`` parameters, and it + can be used as: ``A.ewise_mult(B, gb.binary.isclose(rel_tol=1e-5))``. + When creating a parameterized user-defined operator, the ``func`` + parameter must be a callable that *returns* a function that will + then get compiled. + is_udt : bool, default False + Whether the operator is intended to operate on user-defined types. + If True, then the function will not be automatically compiled for + builtin types, and it will be compiled "just in time" when used. + Setting ``is_udt=True`` is also helpful when the left and right + dtypes need to be different. + + Returns + ------- + return IndexUnaryOp or ParameterizedIndexUnaryOp """ cls._check_supports_udf("register_anonymous") if parameterized: @@ -253,15 +285,53 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals @classmethod def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): - """Register an IndexUnaryOp. The name will be used to identify the IndexUnaryOp in the - ``graphblas.indexunary`` namespace. + """Register a new IndexUnaryOp and save it to ``graphblas.indexunary`` namespace. If the return type is Boolean, the function will also be registered as a SelectOp - with the same name. - - >>> gb.indexunary.register_new("row_mod", lambda x, i, j, thunk: i % max(thunk, 2)) - >>> dir(gb.indexunary) - [..., 'row_mod', ...] + (and saved to ``grablas.select`` namespace) with the same name. + + Parameters + ---------- + name : str + The name of the operator. This will show up as ``gb.indexunary.{name}``. + The name may contain periods, ".", which will result in nested objects + such as ``gb.indexunary.x.y.z`` for name ``"x.y.z"``. + func : FunctionType + The function to compile. For all current backends, this must be able + to be compiled with ``numba.njit``. + ``func`` takes four input parameters--any dtype, int64, int64, + any dtype and returns any dtype. The first argument (any dtype) is + the value of the input Matrix or Vector, the second argument (int64) + is the row index of the Matrix or the index of the Vector, the third + argument (int64) is the column index of the Matrix or 0 for a Vector, + and the fourth argument (any dtype) is the value of the input Scalar. + parameterized : bool, default False + When True, create a parameterized user-defined operator, which means + additional parameters can be "baked into" the operator when used. + For example, ``gb.binary.isclose`` is a parameterized BinaryOp that + optionally accepts ``rel_tol`` and ``abs_tol`` parameters, and it + can be used as: ``A.ewise_mult(B, gb.binary.isclose(rel_tol=1e-5))``. + When creating a parameterized user-defined operator, the ``func`` + parameter must be a callable that *returns* a function that will + then get compiled. + is_udt : bool, default False + Whether the operator is intended to operate on user-defined types. + If True, then the function will not be automatically compiled for + builtin types, and it will be compiled "just in time" when used. + Setting ``is_udt=True`` is also helpful when the left and right + dtypes need to be different. + lazy : bool, default False + If False (the default), then the function will be automatically + compiled for builtin types (unless ``is_udt`` is True). + Compiling functions can be slow, however, so you may want to + delay compilation and only compile when the operator is used, + which is done by setting ``lazy=True``. + + Examples + -------- + >>> gb.indexunary.register_new("row_mod", lambda x, i, j, thunk: i % max(thunk, 2)) + >>> dir(gb.indexunary) + [..., 'row_mod', ...] """ cls._check_supports_udf("register_new") module, funcname = cls._remove_nesting(name) diff --git a/graphblas/core/operator/monoid.py b/graphblas/core/operator/monoid.py index 387652b63..fc327b4a7 100644 --- a/graphblas/core/operator/monoid.py +++ b/graphblas/core/operator/monoid.py @@ -269,22 +269,25 @@ def _compile_udt(self, dtype, dtype2): def register_anonymous(cls, binaryop, identity, name=None, *, is_idempotent=False): """Register a Monoid without registering it in the ``graphblas.monoid`` namespace. + A monoid is a binary operator whose inputs and output are the same dtype. Because it is not registered in the namespace, the name is optional. Parameters ---------- - binaryop : BinaryOp - Builtin or registered binary operator - identity : - Identity value of the monoid + binaryop: BinaryOp or ParameterizedBinaryOp + The binary operator of the monoid, which should be able to use the same + dtype for both inputs and the output. + identity: scalar or Mapping + The identity of the monoid such that ``op(x, identity) == x`` for any x. + ``identity`` may also be a mapping from dtype to scalar. name : str, optional - Name associated with the monoid + The name of the operator. This *does not* show up as ``gb.monoid.{name}``. is_idempotent : bool, default False Does ``op(x, x) == x`` for any x? Returns ------- - Function handle + Monoid or ParameterizedMonoid """ if type(binaryop) is ParameterizedBinaryOp: return ParameterizedMonoid( @@ -294,12 +297,36 @@ def register_anonymous(cls, binaryop, identity, name=None, *, is_idempotent=Fals @classmethod def register_new(cls, name, binaryop, identity, *, is_idempotent=False, lazy=False): - """Register a Monoid. The name will be used to identify the Monoid in the - ``graphblas.monoid`` namespace. + """Register a new Monoid and save it to ``graphblas.monoid`` namespace. - >>> gb.core.operator.Monoid.register_new("max_zero", gb.binary.max_zero, 0) - >>> dir(gb.monoid) - [..., 'max_zero', ...] + A monoid is a binary operator whose inputs and output are the same dtype. + + Parameters + ---------- + name : str + The name of the operator. This will show up as ``gb.monoid.{name}``. + The name may contain periods, ".", which will result in nested objects + such as ``gb.monoid.x.y.z`` for name ``"x.y.z"``. + binaryop: BinaryOp or ParameterizedBinaryOp + The binary operator of the monoid, which should be able to use the same + dtype for both inputs and the output. + identity: scalar or Mapping + The identity of the monoid such that ``op(x, identity) == x`` for any x. + ``identity`` may also be a mapping from dtype to scalar. + is_idempotent : bool, default False + Does ``op(x, x) == x`` for any x? + lazy : bool, default False + If False (the default), then the function will be automatically + compiled for builtin types (unless ``is_udt`` was True for the binaryop). + Compiling functions can be slow, however, so you may want to + delay compilation and only compile when the operator is used, + which is done by setting ``lazy=True``. + + Examples + -------- + >>> gb.core.operator.Monoid.register_new("max_zero", gb.binary.max_zero, 0) + >>> dir(gb.monoid) + [..., 'max_zero', ...] """ module, funcname = cls._remove_nesting(name) if lazy: diff --git a/graphblas/core/operator/select.py b/graphblas/core/operator/select.py index 27567eb2f..4c9cd4639 100644 --- a/graphblas/core/operator/select.py +++ b/graphblas/core/operator/select.py @@ -125,6 +125,40 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals """Register a SelectOp without registering it in the ``graphblas.select`` namespace. Because it is not registered in the namespace, the name is optional. + The return type must be Boolean. + + Parameters + ---------- + func : FunctionType + The function to compile. For all current backends, this must be able + to be compiled with ``numba.njit``. + ``func`` takes four input parameters--any dtype, int64, int64, + any dtype and returns boolean. The first argument (any dtype) is + the value of the input Matrix or Vector, the second argument (int64) + is the row index of the Matrix or the index of the Vector, the third + argument (int64) is the column index of the Matrix or 0 for a Vector, + and the fourth argument (any dtype) is the value of the input Scalar. + name : str, optional + The name of the operator. This *does not* show up as ``gb.select.{name}``. + parameterized : bool, default False + When True, create a parameterized user-defined operator, which means + additional parameters can be "baked into" the operator when used. + For example, ``gb.binary.isclose`` is a parameterized BinaryOp that + optionally accepts ``rel_tol`` and ``abs_tol`` parameters, and it + can be used as: ``A.ewise_mult(B, gb.binary.isclose(rel_tol=1e-5))``. + When creating a parameterized user-defined operator, the ``func`` + parameter must be a callable that *returns* a function that will + then get compiled. + is_udt : bool, default False + Whether the operator is intended to operate on user-defined types. + If True, then the function will not be automatically compiled for + builtin types, and it will be compiled "just in time" when used. + Setting ``is_udt=True`` is also helpful when the left and right + dtypes need to be different. + + Returns + ------- + SelectOp or ParameterizedSelectOp """ cls._check_supports_udf("register_anonymous") if parameterized: @@ -134,14 +168,53 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals @classmethod def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): - """Register a SelectOp. The name will be used to identify the SelectOp in the - ``graphblas.select`` namespace. + """Register a new SelectOp and save it to ``graphblas.select`` namespace. The function will also be registered as a IndexUnaryOp with the same name. - - >>> gb.select.register_new("upper_left_triangle", lambda x, i, j, thunk: i + j <= thunk) - >>> dir(gb.select) - [..., 'upper_left_triangle', ...] + The return type must be Boolean. + + Parameters + ---------- + name : str + The name of the operator. This will show up as ``gb.select.{name}``. + The name may contain periods, ".", which will result in nested objects + such as ``gb.select.x.y.z`` for name ``"x.y.z"``. + func : FunctionType + The function to compile. For all current backends, this must be able + to be compiled with ``numba.njit``. + ``func`` takes four input parameters--any dtype, int64, int64, + any dtype and returns boolean. The first argument (any dtype) is + the value of the input Matrix or Vector, the second argument (int64) + is the row index of the Matrix or the index of the Vector, the third + argument (int64) is the column index of the Matrix or 0 for a Vector, + and the fourth argument (any dtype) is the value of the input Scalar. + parameterized : bool, default False + When True, create a parameterized user-defined operator, which means + additional parameters can be "baked into" the operator when used. + For example, ``gb.binary.isclose`` is a parameterized BinaryOp that + optionally accepts ``rel_tol`` and ``abs_tol`` parameters, and it + can be used as: ``A.ewise_mult(B, gb.binary.isclose(rel_tol=1e-5))``. + When creating a parameterized user-defined operator, the ``func`` + parameter must be a callable that *returns* a function that will + then get compiled. + is_udt : bool, default False + Whether the operator is intended to operate on user-defined types. + If True, then the function will not be automatically compiled for + builtin types, and it will be compiled "just in time" when used. + Setting ``is_udt=True`` is also helpful when the left and right + dtypes need to be different. + lazy : bool, default False + If False (the default), then the function will be automatically + compiled for builtin types (unless ``is_udt`` is True). + Compiling functions can be slow, however, so you may want to + delay compilation and only compile when the operator is used, + which is done by setting ``lazy=True``. + + Examples + -------- + >>> gb.select.register_new("upper_left_triangle", lambda x, i, j, thunk: i + j <= thunk) + >>> dir(gb.select) + [..., 'upper_left_triangle', ...] """ cls._check_supports_udf("register_new") iop = IndexUnaryOp.register_new( diff --git a/graphblas/core/operator/semiring.py b/graphblas/core/operator/semiring.py index ac716b9dd..035a1c43b 100644 --- a/graphblas/core/operator/semiring.py +++ b/graphblas/core/operator/semiring.py @@ -277,16 +277,16 @@ def register_anonymous(cls, monoid, binaryop, name=None): Parameters ---------- - monoid : Monoid - Builtin or registered monoid - binaryop : BinaryOp - Builtin or registered binary operator + monoid : Monoid or ParameterizedMonoid + The monoid of the semiring (like "plus" in the default "plus_times" semiring). + binaryop : BinaryOp or ParameterizedBinaryOp + The binaryop of the semiring (like "times" in the default "plus_times" semiring). name : str, optional - Name associated with the semiring + The name of the operator. This *does not* show up as ``gb.semiring.{name}``. Returns ------- - Function handle + Semiring or ParameterizedSemiring """ if type(monoid) is ParameterizedMonoid or type(binaryop) is ParameterizedBinaryOp: return ParameterizedSemiring(name, monoid, binaryop, anonymous=True) @@ -294,12 +294,30 @@ def register_anonymous(cls, monoid, binaryop, name=None): @classmethod def register_new(cls, name, monoid, binaryop, *, lazy=False): - """Register a Semiring. The name will be used to identify the Semiring in the - ``graphblas.semiring`` namespace. + """Register a new Semiring and save it to ``graphblas.semiring`` namespace. - >>> gb.core.operator.Semiring.register_new("max_max", gb.monoid.max, gb.binary.max) - >>> dir(gb.semiring) - [..., 'max_max', ...] + Parameters + ---------- + name : str + The name of the operator. This will show up as ``gb.semiring.{name}``. + The name may contain periods, ".", which will result in nested objects + such as ``gb.semiring.x.y.z`` for name ``"x.y.z"``. + monoid : Monoid or ParameterizedMonoid + The monoid of the semiring (like "plus" in the default "plus_times" semiring). + binaryop : BinaryOp or ParameterizedBinaryOp + The binaryop of the semiring (like "times" in the default "plus_times" semiring). + lazy : bool, default False + If False (the default), then the function will be automatically + compiled for builtin types (unless ``is_udt`` is True). + Compiling functions can be slow, however, so you may want to + delay compilation and only compile when the operator is used, + which is done by setting ``lazy=True``. + + Examples + -------- + >>> gb.core.operator.Semiring.register_new("max_max", gb.monoid.max, gb.binary.max) + >>> dir(gb.semiring) + [..., 'max_max', ...] """ module, funcname = cls._remove_nesting(name) if lazy: diff --git a/graphblas/core/operator/unary.py b/graphblas/core/operator/unary.py index 1432a9387..a02445836 100644 --- a/graphblas/core/operator/unary.py +++ b/graphblas/core/operator/unary.py @@ -276,6 +276,32 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals """Register a UnaryOp without registering it in the ``graphblas.unary`` namespace. Because it is not registered in the namespace, the name is optional. + + Parameters + ---------- + func : FunctionType + The function to compile. For all current backends, this must be able + to be compiled with ``numba.njit``. + ``func`` takes one input parameters of any dtype and returns any dtype. + name : str, optional + The name of the operator. This *does not* show up as ``gb.unary.{name}``. + parameterized : bool, default False + When True, create a parameterized user-defined operator, which means + additional parameters can be "baked into" the operator when used. + For example, ``gb.binary.isclose`` is a parameterized function that + optionally accepts ``rel_tol`` and ``abs_tol`` parameters, and it + can be used as: ``A.ewise_mult(B, gb.binary.isclose(rel_tol=1e-5))``. + When creating a parameterized user-defined operator, the ``func`` + parameter must be a callable that *returns* a function that will + then get compiled. See the ``user_isclose`` example below. + is_udt : bool, default False + Whether the operator is intended to operate on user-defined types. + If True, then the function will not be automatically compiled for + builtin types, and it will be compiled "just in time" when used. + + Returns + ------- + UnaryOp or ParameterizedUnaryOp """ cls._check_supports_udf("register_anonymous") if parameterized: @@ -284,12 +310,43 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals @classmethod def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=False): - """Register a UnaryOp. The name will be used to identify the UnaryOp in the - ``graphblas.unary`` namespace. - - >>> gb.core.operator.UnaryOp.register_new("plus_one", lambda x: x + 1) - >>> dir(gb.unary) - [..., 'plus_one', ...] + """Register a new UnaryOp and save it to ``graphblas.unary`` namespace. + + Parameters + ---------- + name : str + The name of the operator. This will show up as ``gb.unary.{name}``. + The name may contain periods, ".", which will result in nested objects + such as ``gb.unary.x.y.z`` for name ``"x.y.z"``. + func : FunctionType + The function to compile. For all current backends, this must be able + to be compiled with ``numba.njit``. + ``func`` takes one input parameters of any dtype and returns any dtype. + parameterized : bool, default False + When True, create a parameterized user-defined operator, which means + additional parameters can be "baked into" the operator when used. + For example, ``gb.binary.isclose`` is a parameterized function that + optionally accepts ``rel_tol`` and ``abs_tol`` parameters, and it + can be used as: ``A.ewise_mult(B, gb.binary.isclose(rel_tol=1e-5))``. + When creating a parameterized user-defined operator, the ``func`` + parameter must be a callable that *returns* a function that will + then get compiled. See the ``user_isclose`` example below. + is_udt : bool, default False + Whether the operator is intended to operate on user-defined types. + If True, then the function will not be automatically compiled for + builtin types, and it will be compiled "just in time" when used. + lazy : bool, default False + If False (the default), then the function will be automatically + compiled for builtin types (unless ``is_udt`` is True). + Compiling functions can be slow, however, so you may want to + delay compilation and only compile when the operator is used, + which is done by setting ``lazy=True``. + + Examples + -------- + >>> gb.core.operator.UnaryOp.register_new("plus_one", lambda x: x + 1) + >>> dir(gb.unary) + [..., 'plus_one', ...] """ cls._check_supports_udf("register_new") module, funcname = cls._remove_nesting(name) diff --git a/pyproject.toml b/pyproject.toml index a3a5b8276..245dc35bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dependencies = [ # These won't be installed by default after 2024.3.0 # Use e.g. "python-graphblas[suitesparse]" or "python-graphblas[default]" instead "suitesparse-graphblas >=7.4.0.0, <7.5", - "numba >=0.55; python_version<'3.11'", # make optional where numba is not supported + "numba >=0.55; python_version<'3.12'", # make optional where numba is not supported ] [project.urls] @@ -97,9 +97,9 @@ repr = [ ] io = [ "python-graphblas[networkx,scipy]", - "python-graphblas[numba]; python_version<'3.11'", + "python-graphblas[numba]; python_version<'3.12'", "awkward >=1.9", - "sparse >=0.13; python_version<'3.11'", # make optional, b/c sparse needs numba + "sparse >=0.13; python_version<'3.12'", # make optional, b/c sparse needs numba "fast-matrix-market >=1.4.5", ] viz = [ @@ -119,11 +119,11 @@ test = [ ] default = [ "python-graphblas[suitesparse,pandas,scipy]", - "python-graphblas[numba]; python_version<'3.11'", # make optional where numba is not supported + "python-graphblas[numba]; python_version<'3.12'", # make optional where numba is not supported ] -complete = [ +all = [ "python-graphblas[default,io,viz,test]", - "python-graphblas[datashade]; python_version<'3.11'", # make optional, b/c datashade needs numba + "python-graphblas[datashade]; python_version<'3.12'", # make optional, b/c datashade needs numba ] [tool.setuptools] @@ -321,6 +321,8 @@ ignore = [ "RET504", # Unnecessary variable assignment before `return` statement "S110", # `try`-`except`-`pass` detected, consider logging the exception (Note: good advice, but we don't log) "S112", # `try`-`except`-`continue` detected, consider logging the exception (Note: good advice, but we don't log) + "S603", # `subprocess` call: check for execution of untrusted input (Note: not important for us) + "S607", # Starting a process with a partial executable path (Note: not important for us) "SIM102", # Use a single `if` statement instead of nested `if` statements (Note: often necessary) "SIM105", # Use contextlib.suppress(...) instead of try-except-pass (Note: try-except-pass is much faster) "SIM108", # Use ternary operator ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) @@ -349,6 +351,7 @@ ignore = [ "graphblas/core/operator/base.py" = ["S102"] # exec is used for UDF "graphblas/core/ss/matrix.py" = ["NPY002"] # numba doesn't support rng generator yet "graphblas/core/ss/vector.py" = ["NPY002"] # numba doesn't support rng generator yet +"graphblas/core/utils.py" = ["PLE0302"] # `__set__` is used as a property "graphblas/ss/_core.py" = ["N999"] # We want _core.py to be underscopre # Allow useless expressions, assert, pickle, RNG, print, no docstring, and yoda in tests "graphblas/tests/*py" = ["B018", "S101", "S301", "S311", "T201", "D103", "D100", "SIM300"] @@ -358,6 +361,7 @@ ignore = [ "scripts/create_pickle.py" = ["F403", "F405"] # Allow `from foo import *` "docs/*.py" = ["INP001"] # Not a package + [tool.ruff.flake8-builtins] builtins-ignorelist = ["copyright", "format", "min", "max"] diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 026f3a656..3809eb805 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -3,11 +3,11 @@ # Use, adjust, copy/paste, etc. as necessary to answer your questions. # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. -conda search 'numpy[channel=conda-forge]>=1.24.2' -conda search 'pandas[channel=conda-forge]>=2.0.0' +conda search 'numpy[channel=conda-forge]>=1.24.3' +conda search 'pandas[channel=conda-forge]>=2.0.1' conda search 'scipy[channel=conda-forge]>=1.10.1' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.1.2' +conda search 'awkward[channel=conda-forge]>=2.1.4' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.5.1' conda search 'numba[channel=conda-forge]>=0.56.4' From 0eeebfcb3bdfad446899e8f01edeaef5b92c27a1 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 3 May 2023 13:03:45 -0500 Subject: [PATCH 22/87] Use external link for images in README (to show up on PyPI) (#450) --- MANIFEST.in | 3 --- README.md | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index e2ff9c410..bdba30a31 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,6 +7,3 @@ include LICENSE include MANIFEST.in include graphblas/graphblas.yaml include graphblas/tests/pickle*.pkl -include docs/_static/img/logo-name-medium.svg -include docs/_static/img/draw-example.png -include docs/_static/img/repr-matrix.png diff --git a/README.md b/README.md index 083483fe2..570a82de5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Python-graphblas](docs/_static/img/logo-name-medium.svg) +![Python-graphblas](https://raw.githubusercontent.com/python-graphblas/python-graphblas/main/docs/_static/img/logo-name-medium.svg) [![conda-forge](https://img.shields.io/conda/vn/conda-forge/python-graphblas.svg)](https://anaconda.org/conda-forge/python-graphblas) [![pypi](https://img.shields.io/pypi/v/python-graphblas.svg)](https://pypi.python.org/pypi/python-graphblas/) @@ -28,8 +28,8 @@ For algorithms, see - **Chat via Discord:** [https://discord.com/invite/vur45CbwMz](https://discord.com/invite/vur45CbwMz) in the [#graphblas channel](https://discord.com/channels/786703927705862175/1024732940233605190)

- Directed graph - Adjacency matrix + Directed graph + Adjacency matrix

## Install From f140f9c39dc0ddacd099cd0f5029b9c623795fa9 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 6 May 2023 09:00:02 -0500 Subject: [PATCH 23/87] include conftest.py in MANIFEST.in (#451) * include conftest.py in MANIFEST.in * Run slow tests when running via `pytest --pyargs graphblas` * bump ruff (also, test pre-commit.ci) * pre-commit.ci works fine, let's only use it for linting --- .github/workflows/lint.yml | 10 ++++++---- .pre-commit-config.yaml | 4 ++-- MANIFEST.in | 1 + graphblas/tests/conftest.py | 17 +++++++++++------ graphblas/tests/test_core.py | 5 ++++- graphblas/tests/test_op.py | 2 +- graphblas/tests/test_vector.py | 4 ++-- 7 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5ef2b1033..81d9415ad 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,10 +1,12 @@ +# Rely on pre-commit.ci instead name: Lint via pre-commit on: - pull_request: - push: - branches-ignore: - - main + workflow_dispatch: + # pull_request: + # push: + # branches-ignore: + # - main permissions: contents: read diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8f4fac317..d995f4253 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,7 +58,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.264 + rev: v0.0.265 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -86,7 +86,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.264 + rev: v0.0.265 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/MANIFEST.in b/MANIFEST.in index bdba30a31..27cd3f0c4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,7 @@ recursive-include graphblas *.py prune docs prune scripts include setup.py +include conftest.py include README.md include LICENSE include MANIFEST.in diff --git a/graphblas/tests/conftest.py b/graphblas/tests/conftest.py index a4df5d336..0d1f4008a 100644 --- a/graphblas/tests/conftest.py +++ b/graphblas/tests/conftest.py @@ -18,26 +18,31 @@ def pytest_configure(config): rng = np.random.default_rng() - randomly = config.getoption("--randomly", False) + randomly = config.getoption("--randomly", None) + if randomly is None: # pragma: no cover + options_unavailable = True + randomly = True + config.addinivalue_line("markers", "slow: Skipped unless --runslow passed") + else: + options_unavailable = False backend = config.getoption("--backend", None) if backend is None: if randomly: backend = "suitesparse" if rng.random() < 0.5 else "suitesparse-vanilla" else: backend = "suitesparse" - blocking = config.getoption("--blocking", True) + blocking = config.getoption("--blocking", None) if blocking is None: # pragma: no branch blocking = rng.random() < 0.5 if randomly else True record = config.getoption("--record", False) if record is None: # pragma: no branch record = rng.random() < 0.5 if randomly else False - mapnumpy = config.getoption("--mapnumpy", False) + mapnumpy = config.getoption("--mapnumpy", None) if mapnumpy is None: mapnumpy = rng.random() < 0.5 if randomly else False - runslow = config.getoption("--runslow", False) + runslow = config.getoption("--runslow", None) if runslow is None: - # Add a small amount of randomization to be safer - runslow = rng.random() < 0.05 if randomly else False + runslow = options_unavailable config.runslow = runslow gb.config.set(autocompute=False, mapnumpy=mapnumpy) diff --git a/graphblas/tests/test_core.py b/graphblas/tests/test_core.py index ae2051145..003affc6c 100644 --- a/graphblas/tests/test_core.py +++ b/graphblas/tests/test_core.py @@ -83,7 +83,10 @@ def test_packages(): if not pyproject.exists(): # pragma: no cover (safety) pytest.skip("Did not find pyproject.toml") with pyproject.open("rb") as f: - pkgs2 = sorted(tomli.load(f)["tool"]["setuptools"]["packages"]) + cfg = tomli.load(f) + if cfg.get("project", {}).get("name") != "python-graphblas": # pragma: no cover (safety) + pytest.skip("Did not find correct pyproject.toml") + pkgs2 = sorted(cfg["tool"]["setuptools"]["packages"]) assert ( pkgs == pkgs2 ), "If there are extra items on the left, add them to pyproject.toml:tool.setuptools.packages" diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index c9a176afd..a80012ab7 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -225,7 +225,7 @@ def plus_one(x): UnaryOp.register_new("bad", object()) assert not hasattr(unary, "bad") with pytest.raises(UdfParseError, match="Unable to parse function using Numba"): - UnaryOp.register_new("bad", lambda x: v) + UnaryOp.register_new("bad", lambda x: v) # pragma: no branch (numba) @pytest.mark.skipif("not supports_udfs") diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index ab019b734..bd2083fd1 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -858,7 +858,7 @@ def inner(x, idx, _, thunk): # pragma: no cover (numba) delattr(indexunary, "iin") delattr(select, "iin") with pytest.raises(UdfParseError, match="Unable to parse function using Numba"): - indexunary.register_new("bad", lambda x, row, col, thunk: result) + indexunary.register_new("bad", lambda x, row, col, thunk: result) # pragma: no branch def test_reduce(v): @@ -2425,7 +2425,7 @@ def test_lambda_udfs(v): # with pytest.raises(TypeError): v.ewise_add(v, lambda x, y: x + y) # pragma: no branch (numba) with pytest.raises(TypeError): - v.inner(v, lambda x, y: x + y) + v.inner(v, lambda x, y: x + y) # pragma: no branch (numba) def test_get(v): From b19836200d60727fafc0e257ae14079ef80f8549 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 May 2023 16:46:29 -0500 Subject: [PATCH 24/87] Bump pypa/gh-action-pypi-publish from 1.8.5 to 1.8.6 (#452) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.5 to 1.8.6. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.5...v1.8.6) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index abf3fefa6..eca456c28 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.5 + uses: pypa/gh-action-pypi-publish@v1.8.6 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 4c166531f8e34c25f59205e1c8aca66c93e4c5be Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 14 May 2023 11:24:10 -0500 Subject: [PATCH 25/87] Use double (not single) backtick in docstrings (#454) * Use double (not single) backtick in docstrings * That's annoying; why are scipy tests missing?! --- .github/workflows/test_and_build.yml | 8 +- .pre-commit-config.yaml | 8 +- environment.yml | 2 + graphblas/agg/__init__.py | 8 +- graphblas/core/agg.py | 4 +- graphblas/core/automethods.py | 2 +- graphblas/core/mask.py | 10 +- graphblas/core/matrix.py | 30 ++--- graphblas/core/operator/base.py | 2 +- graphblas/core/recorder.py | 2 +- graphblas/core/scalar.py | 2 +- graphblas/core/ss/descriptor.py | 2 +- graphblas/core/ss/matrix.py | 164 +++++++++++++-------------- graphblas/core/ss/vector.py | 72 ++++++------ graphblas/core/utils.py | 26 +++-- graphblas/core/vector.py | 18 +-- graphblas/io/_numpy.py | 12 +- graphblas/select/__init__.py | 12 +- graphblas/ss/_core.py | 10 +- graphblas/tests/test_io.py | 6 +- graphblas/viz.py | 6 +- scripts/check_versions.sh | 4 +- 22 files changed, 209 insertions(+), 201 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index c20530fbe..064dd93d8 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -171,22 +171,22 @@ jobs: npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') 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", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') 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", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') 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", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') else # Python 3.11 npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", ""]))') fi if [[ ${{ steps.sourcetype.outputs.selected }} == "source" || ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then # TODO: there are currently issues with some numpy versions when diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d995f4253..10fcca649 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it - repo: https://github.com/asottile/pyupgrade - rev: v3.3.2 + rev: v3.4.0 hooks: - id: pyupgrade args: [--py38-plus] @@ -58,7 +58,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.265 + rev: v0.0.267 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -71,7 +71,7 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.0.0 - - flake8-bugbear==23.3.23 + - flake8-bugbear==23.5.9 - flake8-simplify==0.20.0 - repo: https://github.com/asottile/yesqa rev: v1.4.0 @@ -86,7 +86,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.265 + rev: v0.0.267 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/environment.yml b/environment.yml index 875ec5cbd..1a7fb6fa8 100644 --- a/environment.yml +++ b/environment.yml @@ -94,8 +94,10 @@ dependencies: # - python-igraph # - python-louvain # - pyupgrade + # - rich # - ruff # - scalene + # - scikit-network # - setuptools-git-versioning # - snakeviz # - sphinx-lint diff --git a/graphblas/agg/__init__.py b/graphblas/agg/__init__.py index c1319facb..9f6ead0b5 100644 --- a/graphblas/agg/__init__.py +++ b/graphblas/agg/__init__.py @@ -1,4 +1,4 @@ -"""`graphblas.agg` is an experimental module for exploring Aggregators. +"""``graphblas.agg`` is an experimental module for exploring Aggregators. Aggregators may be used in reduce methods: - Matrix.reduce_rowwise @@ -59,9 +59,9 @@ - ss.argmax .. deprecated:: 2023.1.0 - Aggregators `first`, `last`, `first_index`, `last_index`, `argmin`, and `argmax` are - deprecated in the `agg` namespace such as `agg.first`. Use them from `agg.ss` namespace - instead such as `agg.ss.first`. Will be removed in version 2023.9.0 or later. + Aggregators ``first``, ``last``, ``first_index``, ``last_index``, ``argmin``, and ``argmax`` + are deprecated in the ``agg`` namespace such as ``agg.first``. Use them from ``agg.ss`` + namespace instead such as ``agg.ss.first``. Will be removed in version 2023.9.0 or later. # Possible aggregators: # - absolute_deviation, sum(abs(x - mean(x))), sum_absminus(x, mean(x)) diff --git a/graphblas/core/agg.py b/graphblas/core/agg.py index b9f1977ab..23848d3b9 100644 --- a/graphblas/core/agg.py +++ b/graphblas/core/agg.py @@ -1,8 +1,8 @@ """graphblas.core.agg namespace is deprecated; please use graphblas.core.operator.agg instead. .. deprecated:: 2023.3.0 -`graphblas.core.agg` will be removed in a future release. -Use `graphblas.core.operator.agg` instead. +``graphblas.core.agg`` will be removed in a future release. +Use ``graphblas.core.operator.agg`` instead. Will be removed in version 2023.11.0 or later. """ diff --git a/graphblas/core/automethods.py b/graphblas/core/automethods.py index 98dc61137..937e331fd 100644 --- a/graphblas/core/automethods.py +++ b/graphblas/core/automethods.py @@ -1,6 +1,6 @@ """Define functions to use as property methods on expressions. -These will automatically compute the value and avoid the need for `.new()`. +These will automatically compute the value and avoid the need for ``.new()``. To automatically create the functions, run: diff --git a/graphblas/core/mask.py b/graphblas/core/mask.py index 9ad209095..3bda2188a 100644 --- a/graphblas/core/mask.py +++ b/graphblas/core/mask.py @@ -35,7 +35,7 @@ def new(self, dtype=None, *, complement=False, mask=None, name=None, **opts): """Return a new object with True values determined by the mask(s). By default, the result is True wherever the mask(s) would have been applied, - and empty otherwise. If `complement` is True, then these are switched: + and empty otherwise. If ``complement`` is True, then these are switched: the result is empty where the mask(s) would have been applied, and True otherwise. In other words, these are equivalent if complement is False (and mask keyword is None): @@ -48,14 +48,14 @@ def new(self, dtype=None, *, complement=False, mask=None, name=None, **opts): >>> C(self) << expr >>> C(~result.S) << expr # equivalent when complement is True - This can also efficiently merge two masks by using the `mask=` argument. + This can also efficiently merge two masks by using the ``mask=`` argument. This is equivalent to the following (but uses more efficient recipes): >>> val = Matrix(...) >>> val(self) << True >>> val(mask, replace=True) << val - If `complement=` argument is True, then the *complement* will be returned. + If ``complement=`` argument is True, then the *complement* will be returned. This is equivalent to the following (but uses more efficient recipes): >>> val = Matrix(...) @@ -83,7 +83,7 @@ def new(self, dtype=None, *, complement=False, mask=None, name=None, **opts): def __and__(self, other, **opts): """Return the intersection of two masks as a new mask. - `new_mask = mask1 & mask2` is equivalent to the following: + ``new_mask = mask1 & mask2`` is equivalent to the following: >>> val = Matrix(bool, nrows, ncols) >>> val(mask1) << True @@ -109,7 +109,7 @@ def __and__(self, other, **opts): def __or__(self, other, **opts): """Return the union of two masks as a new mask. - `new_mask = mask1 | mask2` is equivalent to the following: + ``new_mask = mask1 | mask2`` is equivalent to the following: >>> val = Matrix(bool, nrows, ncols) >>> val(mask1) << True diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 0183893fd..b74ca347a 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -355,7 +355,7 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False, **opts Returns ------- bool - Whether all values of the Matrix are close to the values in `other`. + Whether all values of the Matrix are close to the values in ``other``. """ other = self._expect_type( other, (Matrix, TransposedMatrix), within="isclose", argname="other" @@ -448,19 +448,19 @@ def to_values(self, dtype=None, *, rows=True, columns=True, values=True, sort=Tr corresponding to the COO format of the Matrix. .. deprecated:: 2022.11.0 - `Matrix.to_values` will be removed in a future release. - Use `Matrix.to_coo` instead. Will be removed in version 2023.9.0 or later + ``Matrix.to_values`` will be removed in a future release. + Use ``Matrix.to_coo`` instead. Will be removed in version 2023.9.0 or later Parameters ---------- dtype : Requested dtype for the output values array. rows : bool, default=True - Whether to return rows; will return `None` for rows if `False` + Whether to return rows; will return ``None`` for rows if ``False`` columns :bool, default=True - Whether to return columns; will return `None` for columns if `False` + Whether to return columns; will return ``None`` for columns if ``False`` values : bool, default=True - Whether to return values; will return `None` for values if `False` + Whether to return values; will return ``None`` for values if ``False`` sort : bool, default=True Whether to require sorted indices. If internally stored rowwise, the sorting will be first by rows, then by column. @@ -488,11 +488,11 @@ def to_coo(self, dtype=None, *, rows=True, columns=True, values=True, sort=True) dtype : Requested dtype for the output values array. rows : bool, default=True - Whether to return rows; will return `None` for rows if `False` + Whether to return rows; will return ``None`` for rows if ``False`` columns :bool, default=True - Whether to return columns; will return `None` for columns if `False` + Whether to return columns; will return ``None`` for columns if ``False`` values : bool, default=True - Whether to return values; will return `None` for values if `False` + Whether to return values; will return ``None`` for values if ``False`` sort : bool, default=True Whether to require sorted indices. If internally stored rowwise, the sorting will be first by rows, then by column. @@ -559,7 +559,7 @@ def to_edgelist(self, dtype=None, *, values=True, sort=True): dtype : Requested dtype for the output values array. values : bool, default=True - Whether to return values; will return `None` for values if `False` + Whether to return values; will return ``None`` for values if ``False`` sort : bool, default=True Whether to require sorted indices. If internally stored rowwise, the sorting will be first by rows, then by column. @@ -585,7 +585,7 @@ def build(self, rows, columns, values, *, dup_op=None, clear=False, nrows=None, The typical use case is to create a new Matrix and insert values at the same time using :meth:`from_coo`. - All the arguments are used identically in :meth:`from_coo`, except for `clear`, which + All the arguments are used identically in :meth:`from_coo`, except for ``clear``, which indicates whether to clear the Matrix prior to adding the new values. """ # TODO: accept `dtype` keyword to match the dtype of `values`? @@ -781,8 +781,8 @@ def from_values( """Create a new Matrix from row and column indices and values. .. deprecated:: 2022.11.0 - `Matrix.from_values` will be removed in a future release. - Use `Matrix.from_coo` instead. Will be removed in version 2023.9.0 or later + ``Matrix.from_values`` will be removed in a future release. + Use ``Matrix.from_coo`` instead. Will be removed in version 2023.9.0 or later Parameters ---------- @@ -1086,7 +1086,7 @@ def from_csr( Parameters ---------- indptr : list or np.ndarray - Pointers for each row into col_indices and values; `indptr.size == nrows + 1`. + Pointers for each row into col_indices and values; ``indptr.size == nrows + 1``. col_indices : list or np.ndarray Column indices. values : list or np.ndarray or scalar, default 1.0 @@ -1133,7 +1133,7 @@ def from_csc( Parameters ---------- indptr : list or np.ndarray - Pointers for each column into row_indices and values; `indptr.size == ncols + 1`. + Pointers for each column into row_indices and values; ``indptr.size == ncols + 1``. col_indices : list or np.ndarray Column indices. values : list or np.ndarray or scalar, default 1.0 diff --git a/graphblas/core/operator/base.py b/graphblas/core/operator/base.py index a40438f14..cddee6a33 100644 --- a/graphblas/core/operator/base.py +++ b/graphblas/core/operator/base.py @@ -398,7 +398,7 @@ def _find(cls, funcname): def _initialize(cls, include_in_ops=True): """ include_in_ops determines whether the operators are included in the - `gb.ops` namespace in addition to the defined module. + ``gb.ops`` namespace in addition to the defined module. """ if cls._initialized: # pragma: no cover (safety) return diff --git a/graphblas/core/recorder.py b/graphblas/core/recorder.py index 2268c31eb..ca776f697 100644 --- a/graphblas/core/recorder.py +++ b/graphblas/core/recorder.py @@ -34,7 +34,7 @@ def gbstr(arg): class Recorder: """Record GraphBLAS C calls. - The recorder can use `.start()` and `.stop()` to enable/disable recording, + The recorder can use ``.start()`` and ``.stop()`` to enable/disable recording, or it can be used as a context manager. For example, diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index a7a251a1d..b55d601af 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -1056,7 +1056,7 @@ def _as_scalar(scalar, dtype=None, *, is_cscalar): def _dict_to_record(np_type, d): - """Converts e.g. `{"x": 1, "y": 2.3}` to `(1, 2.3)`.""" + """Converts e.g. ``{"x": 1, "y": 2.3}`` to ``(1, 2.3)``.""" rv = [] for name, (dtype, _) in np_type.fields.items(): val = d[name] diff --git a/graphblas/core/ss/descriptor.py b/graphblas/core/ss/descriptor.py index dffc4dec1..2f7d11ffa 100644 --- a/graphblas/core/ss/descriptor.py +++ b/graphblas/core/ss/descriptor.py @@ -132,7 +132,7 @@ def get_descriptor(**opts): sort : bool, default False A hint for whether methods may return a "jumbled" matrix secure_import : bool, default False - Whether to trust the data for `import` and `pack` functions. + Whether to trust the data for ``import`` and ``pack`` functions. When True, checks are performed to ensure input data is valid. compression : str, {"none", "default", "lz4", "lz4hc", "zstd"} Whether and how to compress the data for serialization. diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index cac0296c7..64aa43a96 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -63,7 +63,7 @@ def head(matrix, n=10, dtype=None, *, sort=False): def _concat_mn(tiles, *, is_matrix=None): - """Argument checking for `Matrix.ss.concat` and returns number of tiles in each dimension.""" + """Argument checking for ``Matrix.ss.concat`` and returns number of tiles in each dimension.""" from ..matrix import Matrix, TransposedMatrix from ..vector import Vector @@ -261,8 +261,8 @@ def build_diag(self, vector, k=0, **opts): vector : Vector Create a diagonal from this Vector. k : int, default 0 - Diagonal in question. Use `k>0` for diagonals above the main diagonal, - and `k<0` for diagonals below the main diagonal. + Diagonal in question. Use ``k>0`` for diagonals above the main diagonal, + and ``k<0`` for diagonals below the main diagonal. See Also -------- @@ -282,12 +282,12 @@ def split(self, chunks, *, name=None, **opts): """ GxB_Matrix_split. - Split a Matrix into a 2D array of sub-matrices according to `chunks`. + Split a Matrix into a 2D array of sub-matrices according to ``chunks``. This performs the opposite operation as ``concat``. - `chunks` is short for "chunksizes" and indicates the chunk sizes for each dimension. - `chunks` may be a single integer, or a length 2 tuple or list. Example chunks: + ``chunks`` is short for "chunksizes" and indicates the chunk sizes for each dimension. + ``chunks`` may be a single integer, or a length 2 tuple or list. Example chunks: - ``chunks=10`` - Split each dimension into chunks of size 10 (the last chunk may be smaller). @@ -295,7 +295,7 @@ def split(self, chunks, *, name=None, **opts): - Split rows into chunks of size 10 and columns into chunks of size 20. - ``chunks=(None, [5, 10])`` - Don't split rows into chunks, and split columns into two chunks of size 5 and 10. - ` ``chunks=(10, [20, None])`` + - ``chunks=(10, [20, None])`` - Split columns into two chunks of size 20 and ``ncols - 20`` See Also @@ -366,9 +366,9 @@ def concat(self, tiles, **opts): Concatenate a 2D list of Matrix objects into the current Matrix. Any existing values in the current Matrix will be discarded. - To concatenate into a new Matrix, use `graphblas.ss.concat`. + To concatenate into a new Matrix, use ``graphblas.ss.concat``. - Vectors may be used as `Nx1` Matrix objects. + Vectors may be used as ``Nx1`` Matrix objects. This performs the opposite operation as ``split``. @@ -542,8 +542,8 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, ** Parameters ---------- format : str, optional - If `format` is not specified, this method exports in the currently stored format. - To control the export format, set `format` to one of: + If ``format`` is not specified, this method exports in the currently stored format. + To control the export format, set ``format`` to one of: - "csr" - "csc" - "hypercsr" @@ -578,7 +578,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, ** Returns ------- - dict; keys depend on `format` and `raw` arguments (see below). + dict; keys depend on ``format`` and ``raw`` arguments (see below). See Also -------- @@ -732,10 +732,10 @@ def unpack(self, format=None, *, sort=False, raw=False, **opts): """ GxB_Matrix_unpack_xxx. - `unpack` is like `export`, except that the Matrix remains valid but empty. - `pack_*` methods are the opposite of `unpack`. + ``unpack`` is like ``export``, except that the Matrix remains valid but empty. + ``pack_*`` methods are the opposite of ``unpack``. - See `Matrix.ss.export` documentation for more details. + See ``Matrix.ss.export`` documentation for more details. """ return self._export( format, sort=sort, raw=raw, give_ownership=True, method="unpack", opts=opts @@ -1193,7 +1193,7 @@ def import_csr( col_indices : array-like is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. sorted_cols : bool, default False Indicate whether the values in "col_indices" are sorted. take_ownership : bool, default False @@ -1210,7 +1210,7 @@ def import_csr( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "csr" or None. This is included to be compatible with the dict returned from exporting. @@ -1259,10 +1259,10 @@ def pack_csr( """ GxB_Matrix_pack_CSR. - `pack_csr` is like `import_csr` except it "packs" data into an + ``pack_csr`` is like ``import_csr`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("csr")`` - See `Matrix.ss.import_csr` documentation for more details. + See ``Matrix.ss.import_csr`` documentation for more details. """ return self._import_csr( indptr=indptr, @@ -1383,7 +1383,7 @@ def import_csc( row_indices : array-like is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. sorted_rows : bool, default False Indicate whether the values in "row_indices" are sorted. take_ownership : bool, default False @@ -1400,7 +1400,7 @@ def import_csc( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "csc" or None. This is included to be compatible with the dict returned from exporting. @@ -1449,10 +1449,10 @@ def pack_csc( """ GxB_Matrix_pack_CSC. - `pack_csc` is like `import_csc` except it "packs" data into an + ``pack_csc`` is like ``import_csc`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("csc")`` - See `Matrix.ss.import_csc` documentation for more details. + See ``Matrix.ss.import_csc`` documentation for more details. """ return self._import_csc( indptr=indptr, @@ -1579,7 +1579,7 @@ def import_hypercsr( If not specified, will be set to ``len(rows)``. is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. sorted_cols : bool, default False Indicate whether the values in "col_indices" are sorted. take_ownership : bool, default False @@ -1596,7 +1596,7 @@ def import_hypercsr( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "hypercsr" or None. This is included to be compatible with the dict returned from exporting. @@ -1649,10 +1649,10 @@ def pack_hypercsr( """ GxB_Matrix_pack_HyperCSR. - `pack_hypercsr` is like `import_hypercsr` except it "packs" data into an + ``pack_hypercsr`` is like ``import_hypercsr`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("hypercsr")`` - See `Matrix.ss.import_hypercsr` documentation for more details. + See ``Matrix.ss.import_hypercsr`` documentation for more details. """ return self._import_hypercsr( rows=rows, @@ -1803,7 +1803,7 @@ def import_hypercsc( If not specified, will be set to ``len(cols)``. is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. sorted_rows : bool, default False Indicate whether the values in "row_indices" are sorted. take_ownership : bool, default False @@ -1820,7 +1820,7 @@ def import_hypercsc( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "hypercsc" or None. This is included to be compatible with the dict returned from exporting. @@ -1873,10 +1873,10 @@ def pack_hypercsc( """ GxB_Matrix_pack_HyperCSC. - `pack_hypercsc` is like `import_hypercsc` except it "packs" data into an + ``pack_hypercsc`` is like ``import_hypercsc`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("hypercsc")`` - See `Matrix.ss.import_hypercsc` documentation for more details. + See ``Matrix.ss.import_hypercsc`` documentation for more details. """ return self._import_hypercsc( cols=cols, @@ -2028,7 +2028,7 @@ def import_bitmapr( If not provided, will be inferred from values or bitmap if either is 2d. is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. take_ownership : bool, default False If True, perform a zero-copy data transfer from input numpy arrays to GraphBLAS if possible. To give ownership of the underlying @@ -2043,7 +2043,7 @@ def import_bitmapr( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "bitmapr" or None. This is included to be compatible with the dict returned from exporting. @@ -2090,10 +2090,10 @@ def pack_bitmapr( """ GxB_Matrix_pack_BitmapR. - `pack_bitmapr` is like `import_bitmapr` except it "packs" data into an + ``pack_bitmapr`` is like ``import_bitmapr`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("bitmapr")`` - See `Matrix.ss.import_bitmapr` documentation for more details. + See ``Matrix.ss.import_bitmapr`` documentation for more details. """ return self._import_bitmapr( bitmap=bitmap, @@ -2221,7 +2221,7 @@ def import_bitmapc( If not provided, will be inferred from values or bitmap if either is 2d. is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. take_ownership : bool, default False If True, perform a zero-copy data transfer from input numpy arrays to GraphBLAS if possible. To give ownership of the underlying @@ -2236,7 +2236,7 @@ def import_bitmapc( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "bitmapc" or None. This is included to be compatible with the dict returned from exporting. @@ -2283,10 +2283,10 @@ def pack_bitmapc( """ GxB_Matrix_pack_BitmapC. - `pack_bitmapc` is like `import_bitmapc` except it "packs" data into an + ``pack_bitmapc`` is like ``import_bitmapc`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("bitmapc")`` - See `Matrix.ss.import_bitmapc` documentation for more details. + See ``Matrix.ss.import_bitmapc`` documentation for more details. """ return self._import_bitmapc( bitmap=bitmap, @@ -2407,7 +2407,7 @@ def import_fullr( If not provided, will be inferred from values if it is 2d. is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. take_ownership : bool, default False If True, perform a zero-copy data transfer from input numpy arrays to GraphBLAS if possible. To give ownership of the underlying @@ -2422,7 +2422,7 @@ def import_fullr( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "fullr" or None. This is included to be compatible with the dict returned from exporting. @@ -2465,10 +2465,10 @@ def pack_fullr( """ GxB_Matrix_pack_FullR. - `pack_fullr` is like `import_fullr` except it "packs" data into an + ``pack_fullr`` is like ``import_fullr`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("fullr")`` - See `Matrix.ss.import_fullr` documentation for more details. + See ``Matrix.ss.import_fullr`` documentation for more details. """ return self._import_fullr( values=values, @@ -2566,7 +2566,7 @@ def import_fullc( If not provided, will be inferred from values if it is 2d. is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. take_ownership : bool, default False If True, perform a zero-copy data transfer from input numpy arrays to GraphBLAS if possible. To give ownership of the underlying @@ -2581,7 +2581,7 @@ def import_fullc( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "fullc" or None. This is included to be compatible with the dict returned from exporting. @@ -2624,10 +2624,10 @@ def pack_fullc( """ GxB_Matrix_pack_FullC. - `pack_fullc` is like `import_fullc` except it "packs" data into an + ``pack_fullc`` is like ``import_fullc`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("fullc")`` - See `Matrix.ss.import_fullc` documentation for more details. + See ``Matrix.ss.import_fullc`` documentation for more details. """ return self._import_fullc( values=values, @@ -2727,7 +2727,7 @@ def import_coo( The number of columns for the Matrix. is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. sorted_rows : bool, default False True if rows are sorted or when (cols, rows) are sorted lexicographically sorted_cols : bool, default False @@ -2736,7 +2736,7 @@ def import_coo( Ignored. Zero-copy is not possible for "coo" format. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "coo" or None. This is included to be compatible with the dict returned from exporting. @@ -2787,10 +2787,10 @@ def pack_coo( """ GrB_Matrix_build_XXX and GxB_Matrix_build_Scalar. - `pack_coo` is like `import_coo` except it "packs" data into an + ``pack_coo`` is like ``import_coo`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("coo")`` - See `Matrix.ss.import_coo` documentation for more details. + See ``Matrix.ss.import_coo`` documentation for more details. """ return self._import_coo( nrows=self._parent._nrows, @@ -2914,7 +2914,7 @@ def import_coor( The number of columns for the Matrix. is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. sorted_cols : bool, default False True indicates indices are sorted by column, then row. take_ownership : bool, default False @@ -2932,7 +2932,7 @@ def import_coor( For "coor", ownership of "rows" will never change. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "coor" or None. This is included to be compatible with the dict returned from exporting. @@ -2983,10 +2983,10 @@ def pack_coor( """ GxB_Matrix_pack_CSR. - `pack_coor` is like `import_coor` except it "packs" data into an + ``pack_coor`` is like ``import_coor`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("coor")`` - See `Matrix.ss.import_coor` documentation for more details. + See ``Matrix.ss.import_coor`` documentation for more details. """ return self._import_coor( rows=rows, @@ -3083,7 +3083,7 @@ def import_cooc( The number of columns for the Matrix. is_iso : bool, default False Is the Matrix iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. sorted_rows : bool, default False True indicates indices are sorted by column, then row. take_ownership : bool, default False @@ -3101,7 +3101,7 @@ def import_cooc( For "cooc", ownership of "cols" will never change. dtype : dtype, optional dtype of the new Matrix. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "cooc" or None. This is included to be compatible with the dict returned from exporting. @@ -3152,10 +3152,10 @@ def pack_cooc( """ GxB_Matrix_pack_CSC. - `pack_cooc` is like `import_cooc` except it "packs" data into an + ``pack_cooc`` is like ``import_cooc`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("cooc")`` - See `Matrix.ss.import_cooc` documentation for more details. + See ``Matrix.ss.import_cooc`` documentation for more details. """ return self._import_cooc( ncols=self._parent._ncols, @@ -3255,7 +3255,7 @@ def import_any( GxB_Matrix_import_xxx. Dispatch to appropriate import method inferred from inputs. - See the other import functions and `Matrix.ss.export`` for details. + See the other import functions and ``Matrix.ss.export`` for details. Returns ------- @@ -3352,10 +3352,10 @@ def pack_any( """ GxB_Matrix_pack_xxx. - `pack_any` is like `import_any` except it "packs" data into an + ``pack_any`` is like ``import_any`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack()`` - See `Matrix.ss.import_any` documentation for more details. + See ``Matrix.ss.import_any`` documentation for more details. """ return self._import_any( values=values, @@ -3701,8 +3701,8 @@ def head(self, n=10, dtype=None, *, sort=False): def scan(self, op=monoid.plus, order="rowwise", *, name=None, **opts): """Perform a prefix scan across rows (default) or columns with the given monoid. - For example, use `monoid.plus` (the default) to perform a cumulative sum, - and `monoid.times` for cumulative product. Works with any monoid. + For example, use ``monoid.plus`` (the default) to perform a cumulative sum, + and ``monoid.times`` for cumulative product. Works with any monoid. Returns ------- @@ -3718,12 +3718,12 @@ def scan_columnwise(self, op=monoid.plus, *, name=None, **opts): """Perform a prefix scan across columns with the given monoid. .. deprecated:: 2022.11.1 - `Matrix.ss.scan_columnwise` will be removed in a future release. - Use `Matrix.ss.scan(order="columnwise")` instead. + ``Matrix.ss.scan_columnwise`` will be removed in a future release. + Use ``Matrix.ss.scan(order="columnwise")`` instead. Will be removed in version 2023.7.0 or later - For example, use `monoid.plus` (the default) to perform a cumulative sum, - and `monoid.times` for cumulative product. Works with any monoid. + For example, use ``monoid.plus`` (the default) to perform a cumulative sum, + and ``monoid.times`` for cumulative product. Works with any monoid. Returns ------- @@ -3741,12 +3741,12 @@ def scan_rowwise(self, op=monoid.plus, *, name=None, **opts): """Perform a prefix scan across rows with the given monoid. .. deprecated:: 2022.11.1 - `Matrix.ss.scan_rowwise` will be removed in a future release. - Use `Matrix.ss.scan` instead. + ``Matrix.ss.scan_rowwise`` will be removed in a future release. + Use ``Matrix.ss.scan`` instead. Will be removed in version 2023.7.0 or later - For example, use `monoid.plus` (the default) to perform a cumulative sum, - and `monoid.times` for cumulative product. Works with any monoid. + For example, use ``monoid.plus`` (the default) to perform a cumulative sum, + and ``monoid.times`` for cumulative product. Works with any monoid. Returns ------- @@ -3904,8 +3904,8 @@ def selectk_rowwise(self, how, k, *, name=None): # pragma: no cover (deprecated """Select (up to) k elements from each row. .. deprecated:: 2022.11.1 - `Matrix.ss.selectk_rowwise` will be removed in a future release. - Use `Matrix.ss.selectk` instead. + ``Matrix.ss.selectk_rowwise`` will be removed in a future release. + Use ``Matrix.ss.selectk`` instead. Will be removed in version 2023.7.0 or later Parameters @@ -3950,8 +3950,8 @@ def selectk_columnwise(self, how, k, *, name=None): # pragma: no cover (depreca """Select (up to) k elements from each column. .. deprecated:: 2022.11.1 - `Matrix.ss.selectk_columnwise` will be removed in a future release. - Use `Matrix.ss.selectk(order="columnwise")` instead. + ``Matrix.ss.selectk_columnwise`` will be removed in a future release. + Use ``Matrix.ss.selectk(order="columnwise")`` instead. Will be removed in version 2023.7.0 or later Parameters @@ -4216,23 +4216,23 @@ def sort(self, op=binary.lt, order="rowwise", *, values=True, permutation=True, """GxB_Matrix_sort to sort values along the rows (default) or columns of the Matrix. Sorting moves all the elements to the left (if rowwise) or top (if columnwise) just - like `compactify`. The returned matrices will be the same shape as the input Matrix. + like ``compactify``. The returned matrices will be the same shape as the input Matrix. Parameters ---------- op : :class:`~graphblas.core.operator.BinaryOp`, optional Binary operator with a bool return type used to sort the values. - For example, `binary.lt` (the default) sorts the smallest elements first. + For example, ``binary.lt`` (the default) sorts the smallest elements first. Ties are broken according to indices (smaller first). order : {"rowwise", "columnwise"}, optional Whether to sort rowwise or columnwise. Rowwise shifts all values to the left, and columnwise shifts all values to the top. The default is "rowwise". values : bool, default=True - Whether to return values; will return `None` for values if `False`. + Whether to return values; will return ``None`` for values if ``False``. permutation : bool, default=True Whether to compute the permutation Matrix that has the original column indices (if rowwise) or row indices (if columnwise) of the sorted values. - Will return None if `False`. + Will return None if ``False``. nthreads : int, optional The maximum number of threads to use for this operation. None, 0 or negative nthreads means to use the default number of threads. @@ -4301,7 +4301,7 @@ def serialize(self, compression="default", level=None, **opts): None, 0 or negative nthreads means to use the default number of threads. For best performance, this function returns a numpy array with uint8 dtype. - Use `Matrix.ss.deserialize(blob)` to create a Matrix from the result of serialization + Use ``Matrix.ss.deserialize(blob)`` to create a Matrix from the result of serialization This method is intended to support all serialization options from SuiteSparse:GraphBLAS. @@ -4327,7 +4327,7 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts): """Deserialize a Matrix from bytes, buffer, or numpy array using GxB_Matrix_deserialize. The data should have been previously serialized with a compatible version of - SuiteSparse:GraphBLAS. For example, from the result of `data = matrix.ss.serialize()`. + SuiteSparse:GraphBLAS. For example, from the result of ``data = matrix.ss.serialize()``. Examples -------- diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py index 2b1e8bf05..1babc556e 100644 --- a/graphblas/core/ss/vector.py +++ b/graphblas/core/ss/vector.py @@ -155,8 +155,8 @@ def build_diag(self, matrix, k=0, **opts): matrix : Matrix or TransposedMatrix Extract a diagonal from this matrix. k : int, default 0 - Diagonal in question. Use `k>0` for diagonals above the main diagonal, - and `k<0` for diagonals below the main diagonal. + Diagonal in question. Use ``k>0`` for diagonals above the main diagonal, + and ``k<0`` for diagonals below the main diagonal. See Also -------- @@ -185,12 +185,12 @@ def split(self, chunks, *, name=None, **opts): """ GxB_Matrix_split. - Split a Vector into a 1D array of sub-vectors according to `chunks`. + Split a Vector into a 1D array of sub-vectors according to ``chunks``. This performs the opposite operation as ``concat``. - `chunks` is short for "chunksizes" and indicates the chunk sizes. - `chunks` may be a single integer, or a tuple or list. Example chunks: + ``chunks`` is short for "chunksizes" and indicates the chunk sizes. + ``chunks`` may be a single integer, or a tuple or list. Example chunks: - ``chunks=10`` - Split vector into chunks of size 10 (the last chunk may be smaller). @@ -253,7 +253,7 @@ def concat(self, tiles, **opts): Concatenate a 1D list of Vector objects into the current Vector. Any existing values in the current Vector will be discarded. - To concatenate into a new Vector, use `graphblas.ss.concat`. + To concatenate into a new Vector, use ``graphblas.ss.concat``. This performs the opposite operation as ``split``. @@ -415,8 +415,8 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, ** Parameters ---------- format : str or None, default None - If `format` is not specified, this method exports in the currently stored format. - To control the export format, set `format` to one of: + If ``format`` is not specified, this method exports in the currently stored format. + To control the export format, set ``format`` to one of: - "sparse" - "bitmap" - "full" @@ -434,7 +434,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, ** Returns ------- - dict; keys depend on `format` and `raw` arguments (see below). + dict; keys depend on ``format`` and ``raw`` arguments (see below). See Also -------- @@ -442,7 +442,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, ** Vector.ss.import_any Return values - - Note: for `raw=True`, arrays may be larger than specified. + - Note: for ``raw=True``, arrays may be larger than specified. - "sparse" format - indices : ndarray(dtype=uint64, size=nvals) - values : ndarray(size=nvals) @@ -481,10 +481,10 @@ def unpack(self, format=None, *, sort=False, raw=False, **opts): """ GxB_Vector_unpack_xxx. - `unpack` is like `export`, except that the Vector remains valid but empty. - `pack_*` methods are the opposite of `unpack`. + ``unpack`` is like ``export``, except that the Vector remains valid but empty. + ``pack_*`` methods are the opposite of ``unpack``. - See `Vector.ss.export` documentation for more details. + See ``Vector.ss.export`` documentation for more details. """ return self._export( format=format, sort=sort, give_ownership=True, raw=raw, method="unpack", opts=opts @@ -658,7 +658,7 @@ def import_any( GxB_Vector_import_xxx. Dispatch to appropriate import method inferred from inputs. - See the other import functions and `Vector.ss.export`` for details. + See the other import functions and ``Vector.ss.export`` for details. Returns ------- @@ -724,10 +724,10 @@ def pack_any( """ GxB_Vector_pack_xxx. - `pack_any` is like `import_any` except it "packs" data into an + ``pack_any`` is like ``import_any`` except it "packs" data into an existing Vector. This is the opposite of ``unpack()`` - See `Vector.ss.import_any` documentation for more details. + See ``Vector.ss.import_any`` documentation for more details. """ return self._import_any( values=values, @@ -858,7 +858,7 @@ def import_sparse( If not specified, will be set to ``len(values)``. is_iso : bool, default False Is the Vector iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. sorted_index : bool, default False Indicate whether the values in "col_indices" are sorted. take_ownership : bool, default False @@ -875,7 +875,7 @@ def import_sparse( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Vector. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "sparse" or None. This is included to be compatible with the dict returned from exporting. @@ -922,10 +922,10 @@ def pack_sparse( """ GxB_Vector_pack_CSC. - `pack_sparse` is like `import_sparse` except it "packs" data into an + ``pack_sparse`` is like ``import_sparse`` except it "packs" data into an existing Vector. This is the opposite of ``unpack("sparse")`` - See `Vector.ss.import_sparse` documentation for more details. + See ``Vector.ss.import_sparse`` documentation for more details. """ return self._import_sparse( indices=indices, @@ -1045,7 +1045,7 @@ def import_bitmap( If not specified, it will be set to the size of values. is_iso : bool, default False Is the Vector iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. take_ownership : bool, default False If True, perform a zero-copy data transfer from input numpy arrays to GraphBLAS if possible. To give ownership of the underlying @@ -1060,7 +1060,7 @@ def import_bitmap( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Vector. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "bitmap" or None. This is included to be compatible with the dict returned from exporting. @@ -1105,10 +1105,10 @@ def pack_bitmap( """ GxB_Vector_pack_Bitmap. - `pack_bitmap` is like `import_bitmap` except it "packs" data into an + ``pack_bitmap`` is like ``import_bitmap`` except it "packs" data into an existing Vector. This is the opposite of ``unpack("bitmap")`` - See `Vector.ss.import_bitmap` documentation for more details. + See ``Vector.ss.import_bitmap`` documentation for more details. """ return self._import_bitmap( bitmap=bitmap, @@ -1226,7 +1226,7 @@ def import_full( If not specified, it will be set to the size of values. is_iso : bool, default False Is the Vector iso-valued (meaning all the same value)? - If true, then `values` should be a length 1 array. + If true, then ``values`` should be a length 1 array. take_ownership : bool, default False If True, perform a zero-copy data transfer from input numpy arrays to GraphBLAS if possible. To give ownership of the underlying @@ -1241,7 +1241,7 @@ def import_full( read-only and will no longer own the data. dtype : dtype, optional dtype of the new Vector. - If not specified, this will be inferred from `values`. + If not specified, this will be inferred from ``values``. format : str, optional Must be "full" or None. This is included to be compatible with the dict returned from exporting. @@ -1282,10 +1282,10 @@ def pack_full( """ GxB_Vector_pack_Full. - `pack_full` is like `import_full` except it "packs" data into an + ``pack_full`` is like ``import_full`` except it "packs" data into an existing Vector. This is the opposite of ``unpack("full")`` - See `Vector.ss.import_full` documentation for more details. + See ``Vector.ss.import_full`` documentation for more details. """ return self._import_full( values=values, @@ -1364,8 +1364,8 @@ def head(self, n=10, dtype=None, *, sort=False): def scan(self, op=monoid.plus, *, name=None, **opts): """Perform a prefix scan with the given monoid. - For example, use `monoid.plus` (the default) to perform a cumulative sum, - and `monoid.times` for cumulative product. Works with any monoid. + For example, use ``monoid.plus`` (the default) to perform a cumulative sum, + and ``monoid.times`` for cumulative product. Works with any monoid. Returns ------- @@ -1561,20 +1561,20 @@ def compactify(self, how="first", size=None, *, reverse=False, asindex=False, na def sort(self, op=binary.lt, *, values=True, permutation=True, **opts): """GxB_Vector_sort to sort values of the Vector. - Sorting moves all the elements to the left just like `compactify`. + Sorting moves all the elements to the left just like ``compactify``. The returned vectors will be the same size as the input Vector. Parameters ---------- op : :class:`~graphblas.core.operator.BinaryOp`, optional Binary operator with a bool return type used to sort the values. - For example, `binary.lt` (the default) sorts the smallest elements first. + For example, ``binary.lt`` (the default) sorts the smallest elements first. Ties are broken according to indices (smaller first). values : bool, default=True - Whether to return values; will return `None` for values if `False`. + Whether to return values; will return ``None`` for values if ``False``. permutation : bool, default=True Whether to compute the permutation Vector that has the original indices of the - sorted values. Will return None if `False`. + sorted values. Will return None if ``False``. nthreads : int, optional The maximum number of threads to use for this operation. None, 0 or negative nthreads means to use the default number of threads. @@ -1642,7 +1642,7 @@ def serialize(self, compression="default", level=None, **opts): None, 0 or negative nthreads means to use the default number of threads. For best performance, this function returns a numpy array with uint8 dtype. - Use `Vector.ss.deserialize(blob)` to create a Vector from the result of serialization· + Use ``Vector.ss.deserialize(blob)`` to create a Vector from the result of serialization· This method is intended to support all serialization options from SuiteSparse:GraphBLAS. @@ -1668,7 +1668,7 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts): """Deserialize a Vector from bytes, buffer, or numpy array using GxB_Vector_deserialize. The data should have been previously serialized with a compatible version of - SuiteSparse:GraphBLAS. For example, from the result of `data = vector.ss.serialize()`. + SuiteSparse:GraphBLAS. For example, from the result of ``data = vector.ss.serialize()``. Examples -------- diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py index 77c64a7ac..74e03f2f9 100644 --- a/graphblas/core/utils.py +++ b/graphblas/core/utils.py @@ -22,7 +22,7 @@ def libget(name): def wrapdoc(func_with_doc): - """Decorator to copy `__doc__` from a function onto the wrapped function.""" + """Decorator to copy ``__doc__`` from a function onto the wrapped function.""" def inner(func_wo_doc): func_wo_doc.__doc__ = func_with_doc.__doc__ @@ -159,7 +159,7 @@ def get_order(order): def normalize_chunks(chunks, shape): - """Normalize chunks argument for use by `Matrix.ss.split`. + """Normalize chunks argument for use by ``Matrix.ss.split``. Examples -------- @@ -249,17 +249,17 @@ def normalize_chunks(chunks, shape): def ensure_type(x, types): - """Try to ensure `x` is one of the given types, computing if necessary. + """Try to ensure ``x`` is one of the given types, computing if necessary. - `types` must be a type or a tuple of types as used in `isinstance`. + ``types`` must be a type or a tuple of types as used in ``isinstance``. - For example, if `types` is a Vector, then a Vector input will be returned, - and a `VectorExpression` input will be computed and returned as a Vector. + For example, if ``types`` is a Vector, then a Vector input will be returned, + and a ``VectorExpression`` input will be computed and returned as a Vector. TypeError will be raised if the input is not or can't be converted to types. - This function ignores `graphblas.config["autocompute"]`; it always computes - if the return type will match `types`. + This function ignores ``graphblas.config["autocompute"]``; it always computes + if the return type will match ``types``. """ if isinstance(x, types): return x @@ -358,6 +358,7 @@ def _autogenerate_code( specializer=None, begin="# Begin auto-generated code", end="# End auto-generated code", + callblack=True, ): """Super low-tech auto-code generation used by automethods.py and infixmethods.py.""" with filepath.open() as f: # pragma: no branch (flaky) @@ -384,7 +385,8 @@ def _autogenerate_code( f.write(new_text) import subprocess - try: - subprocess.check_call(["black", filepath]) - except FileNotFoundError: # pragma: no cover (safety) - pass # It's okay if `black` isn't installed; pre-commit hooks will do linting + if callblack: + try: + subprocess.check_call(["black", filepath]) + except FileNotFoundError: # pragma: no cover (safety) + pass # It's okay if `black` isn't installed; pre-commit hooks will do linting diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 57851420d..d2ddee372 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -453,17 +453,17 @@ def to_values(self, dtype=None, *, indices=True, values=True, sort=True): """Extract the indices and values as a 2-tuple of numpy arrays. .. deprecated:: 2022.11.0 - `Vector.to_values` will be removed in a future release. - Use `Vector.to_coo` instead. Will be removed in version 2023.9.0 or later + ``Vector.to_values`` will be removed in a future release. + Use ``Vector.to_coo`` instead. Will be removed in version 2023.9.0 or later Parameters ---------- dtype : Requested dtype for the output values array. indices :bool, default=True - Whether to return indices; will return `None` for indices if `False` + Whether to return indices; will return ``None`` for indices if ``False`` values : bool, default=True - Whether to return values; will return `None` for values if `False` + Whether to return values; will return ``None`` for values if ``False`` sort : bool, default=True Whether to require sorted indices. @@ -487,9 +487,9 @@ def to_coo(self, dtype=None, *, indices=True, values=True, sort=True): dtype : Requested dtype for the output values array. indices :bool, default=True - Whether to return indices; will return `None` for indices if `False` + Whether to return indices; will return ``None`` for indices if ``False`` values : bool, default=True - Whether to return values; will return `None` for values if `False` + Whether to return values; will return ``None`` for values if ``False`` sort : bool, default=True Whether to require sorted indices. @@ -539,7 +539,7 @@ def build(self, indices, values, *, dup_op=None, clear=False, size=None): """Rarely used method to insert values into an existing Vector. The typical use case is to create a new Vector and insert values at the same time using :meth:`from_coo`. - All the arguments are used identically in :meth:`from_coo`, except for `clear`, which + All the arguments are used identically in :meth:`from_coo`, except for ``clear``, which indicates whether to clear the Vector prior to adding the new values. """ # TODO: accept `dtype` keyword to match the dtype of `values`? @@ -695,8 +695,8 @@ def from_values(cls, indices, values, dtype=None, *, size=None, dup_op=None, nam """Create a new Vector from indices and values. .. deprecated:: 2022.11.0 - `Vector.from_values` will be removed in a future release. - Use `Vector.from_coo` instead. Will be removed in version 2023.9.0 or later + ``Vector.from_values`` will be removed in a future release. + Use ``Vector.from_coo`` instead. Will be removed in version 2023.9.0 or later Parameters ---------- diff --git a/graphblas/io/_numpy.py b/graphblas/io/_numpy.py index 1c40e1633..954d28df7 100644 --- a/graphblas/io/_numpy.py +++ b/graphblas/io/_numpy.py @@ -11,14 +11,14 @@ def from_numpy(m): # pragma: no cover (deprecated) """Create a sparse Vector or Matrix from a dense numpy array. .. deprecated:: 2023.2.0 - `from_numpy` will be removed in a future release. - Use `Vector.from_dense` or `Matrix.from_dense` instead. + ``from_numpy`` will be removed in a future release. + Use ``Vector.from_dense`` or ``Matrix.from_dense`` instead. Will be removed in version 2023.10.0 or later A value of 0 is considered as "missing". - - m.ndim == 1 returns a `Vector` - - m.ndim == 2 returns a `Matrix` + - m.ndim == 1 returns a ``Vector`` + - m.ndim == 2 returns a ``Matrix`` - m.ndim > 2 raises an error dtype is inferred from m.dtype @@ -65,8 +65,8 @@ def to_numpy(m): # pragma: no cover (deprecated) """Create a dense numpy array from a sparse Vector or Matrix. .. deprecated:: 2023.2.0 - `to_numpy` will be removed in a future release. - Use `Vector.to_dense` or `Matrix.to_dense` instead. + ``to_numpy`` will be removed in a future release. + Use ``Vector.to_dense`` or ``Matrix.to_dense`` instead. Will be removed in version 2023.10.0 or later Missing values will become 0 in the output. diff --git a/graphblas/select/__init__.py b/graphblas/select/__init__.py index c7a1897f5..72aa8d226 100644 --- a/graphblas/select/__init__.py +++ b/graphblas/select/__init__.py @@ -57,9 +57,9 @@ def _resolve_expr(expr, callname, opname): def _match_expr(parent, expr): - """Match expressions to rewrite `A.select(A < 5)` into select expression. + """Match expressions to rewrite ``A.select(A < 5)`` into select expression. - The argument must match the parent, so this _won't_ be rewritten: `A.select(B < 5)` + The argument must match the parent, so this _won't_ be rewritten: ``A.select(B < 5)`` """ args = expr.args op = expr.op @@ -83,7 +83,7 @@ def value(expr): Example usage: >>> gb.select.value(A > 0) - The example will dispatch to `gb.select.valuegt(A, 0)` + The example will dispatch to ``gb.select.valuegt(A, 0)`` while being nicer to read. """ return _resolve_expr(expr, "value", "value") @@ -97,7 +97,7 @@ def row(expr): Example usage: >>> gb.select.row(A <= 5) - The example will dispatch to `gb.select.rowle(A, 5)` + The example will dispatch to ``gb.select.rowle(A, 5)`` while being potentially nicer to read. """ return _resolve_expr(expr, "row", "row") @@ -111,7 +111,7 @@ def column(expr): Example usage: >>> gb.select.column(A <= 5) - The example will dispatch to `gb.select.colle(A, 5)` + The example will dispatch to ``gb.select.colle(A, 5)`` while being potentially nicer to read. """ return _resolve_expr(expr, "column", "col") @@ -125,7 +125,7 @@ def index(expr): Example usage: >>> gb.select.index(v <= 5) - The example will dispatch to `gb.select.indexle(v, 5)` + The example will dispatch to ``gb.select.indexle(v, 5)`` while being potentially nicer to read. """ return _resolve_expr(expr, "index", "index") diff --git a/graphblas/ss/_core.py b/graphblas/ss/_core.py index 441458a42..ec5a89504 100644 --- a/graphblas/ss/_core.py +++ b/graphblas/ss/_core.py @@ -12,7 +12,7 @@ class _graphblas_ss: - """Used in `_expect_type`.""" + """Used in ``_expect_type``.""" _graphblas_ss.__name__ = "graphblas.ss" @@ -33,8 +33,8 @@ def diag(x, k=0, dtype=None, *, name=None, **opts): The Vector to assign to the diagonal, or the Matrix from which to extract the diagonal. k : int, default 0 - Diagonal in question. Use `k>0` for diagonals above the main diagonal, - and `k<0` for diagonals below the main diagonal. + Diagonal in question. Use ``k>0`` for diagonals above the main diagonal, + and ``k<0`` for diagonals below the main diagonal. See Also -------- @@ -71,9 +71,9 @@ def concat(tiles, dtype=None, *, name=None, **opts): Concatenate a 2D list of Matrix objects into a new Matrix, or a 1D list of Vector objects into a new Vector. To concatenate into existing objects, - use ``Matrix.ss.concat`` or `Vector.ss.concat`. + use ``Matrix.ss.concat`` or ``Vector.ss.concat``. - Vectors may be used as `Nx1` Matrix objects when creating a new Matrix. + Vectors may be used as ``Nx1`` Matrix objects when creating a new Matrix. This performs the opposite operation as ``split``. diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 24df55e9d..df94c6469 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -167,7 +167,11 @@ def test_matrix_to_from_networkx(): 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 + try: + from scipy.io.tests import test_mmio + except ImportError: + # Test files are mysteriously missing from some conda-forge builds + pytest.skip("scipy.io.tests.test_mmio unavailable :(") p31 = 2**31 p63 = 2**63 diff --git a/graphblas/viz.py b/graphblas/viz.py index d8a96d343..fafeae5f0 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -67,7 +67,7 @@ def draw(m): # pragma: no cover def spy(M, *, centered=False, show=True, figure=None, axes=None, figsize=None, **kwargs): - """Plot the sparsity pattern of a Matrix using `matplotlib.spy`. + """Plot the sparsity pattern of a Matrix using ``matplotlib.spy``. See: - https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.spy.html @@ -106,8 +106,8 @@ def spy(M, *, centered=False, show=True, figure=None, axes=None, figsize=None, * def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kwargs): """Interactive plot of the sparsity pattern of a Matrix using hvplot and datashader. - The `datashader` library rasterizes large data into a 2d grid of pixels. Each pixel - may contain multiple data points, which are combined by an aggregator (`agg="count"`). + The ``datashader`` library rasterizes large data into a 2d grid of pixels. Each pixel + may contain multiple data points, which are combined by an aggregator (``agg="count"``). Common aggregators are "count", "sum", "mean", "min", and "max". See full list here: - https://datashader.org/api.html#reductions diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 3809eb805..dda7adbaa 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -7,11 +7,11 @@ conda search 'numpy[channel=conda-forge]>=1.24.3' conda search 'pandas[channel=conda-forge]>=2.0.1' conda search 'scipy[channel=conda-forge]>=1.10.1' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.1.4' +conda search 'awkward[channel=conda-forge]>=2.2.0' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.5.1' conda search 'numba[channel=conda-forge]>=0.56.4' conda search 'pyyaml[channel=conda-forge]>=6.0' -conda search 'flake8-bugbear[channel=conda-forge]>=23.3.23' +conda search 'flake8-bugbear[channel=conda-forge]>=23.5.9' conda search 'flake8-simplify[channel=conda-forge]>=0.20.0' # conda search 'python[channel=conda-forge]>=3.8 *pypy*' From f8682ffd936b9fef4db896d1d3bf1272285eddff Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 19 May 2023 13:47:51 -0500 Subject: [PATCH 26/87] blacken docs (and other misc) (#455) * blacken docs (and other misc) --- .github/workflows/test_and_build.yml | 7 +- .pre-commit-config.yaml | 28 +++-- README.md | 7 +- docs/getting_started/primer.rst | 31 ++--- docs/user_guide/init.rst | 3 +- docs/user_guide/operations.rst | 172 ++++++++++++++++++--------- docs/user_guide/operators.rst | 4 +- docs/user_guide/recorder.rst | 4 +- docs/user_guide/udf.rst | 2 +- graphblas/core/matrix.py | 2 +- graphblas/viz.py | 3 +- pyproject.toml | 8 ++ 12 files changed, 176 insertions(+), 95 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 064dd93d8..d129ef26f 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -17,6 +17,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + jobs: rngs: # To achieve consistent coverage, we need a little bit of correlated collaboration. @@ -248,8 +252,7 @@ jobs: fi echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psgver${psgver}" - # TODO: remove `-c numba` when numba 0.57 is properly released on conda-forge - $(command -v mamba || command -v conda) install -c numba packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli \ + $(command -v mamba || command -v conda) install packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli \ pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \ networkx${nxver} ${numba} ${fmm} ${psg} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10fcca649..4588ed4f4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,10 +5,12 @@ # To update: `pre-commit autoupdate` # - &flake8_dependencies below needs updated manually ci: - # See: https://pre-commit.ci/#configuration - autofix_prs: false - autoupdate_schedule: monthly - skip: [pylint, no-commit-to-branch] + # See: https://pre-commit.ci/#configuration + autofix_prs: false + autoupdate_schedule: monthly + autoupdate_commit_msg: "chore: update pre-commit hooks" + autofix_commit_msg: "style: pre-commit fixes" + skip: [pylint, no-commit-to-branch] fail_fast: true default_language_version: python: python3 @@ -17,15 +19,21 @@ repos: rev: v4.4.0 hooks: - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks - id: check-ast - id: check-toml - id: check-yaml - id: debug-statements - id: end-of-file-fixer + exclude_types: [svg] - id: mixed-line-ending - id: trailing-whitespace + - id: name-tests-test + args: ["--pytest-test-first"] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.12.2 + rev: v0.13 hooks: - id: validate-pyproject name: Validate pyproject.toml @@ -58,7 +66,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.267 + rev: v0.0.269 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -86,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.267 + rev: v0.0.269 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint @@ -101,6 +109,10 @@ repos: hooks: - id: pyroma args: [-n, "10", .] + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: "v0.9.0.2" + hooks: + - id: shellcheck - repo: local hooks: # Add `--hook-stage manual` to pre-commit command to run (very slow) @@ -137,4 +149,4 @@ repos: # hooks: # - id: bandit # -# blacken-docs, blackdoc mypy, pydocstringformatter, velin, flynt, yamllint +# blacken-docs, blackdoc prettier, mypy, pydocstringformatter, velin, flynt, yamllint diff --git a/README.md b/README.md index 570a82de5..13067df6e 100644 --- a/README.md +++ b/README.md @@ -176,8 +176,9 @@ use as well as the blocking/non-blocking mode. If the context is not initialized be performed automatically. ```python import graphblas as gb + # Context initialization must happen before any other imports -gb.init('suitesparse', blocking=True) +gb.init("suitesparse", blocking=True) # Now we can import other items from graphblas from graphblas import binary, semiring @@ -195,7 +196,7 @@ def force_odd_func(x): return x + 1 return x -unary.register_new('force_odd', force_odd_func) +unary.register_new("force_odd", force_odd_func) v = Vector.from_coo([0, 1, 3], [1, 2, 3]) w = v.apply(unary.force_odd).new() @@ -210,7 +211,7 @@ import graphblas as gb # scipy.sparse matrices A = gb.io.from_scipy_sparse(m) -m = gb.io.to_scipy_sparse(m, format='csr') +m = gb.io.to_scipy_sparse(m, format="csr") # networkx graphs A = gb.io.from_networkx(g) diff --git a/docs/getting_started/primer.rst b/docs/getting_started/primer.rst index 710dca702..104eb5738 100644 --- a/docs/getting_started/primer.rst +++ b/docs/getting_started/primer.rst @@ -89,26 +89,13 @@ makes for faster graph algorithms. # networkx-style storage of an undirected graph G = { - 0: {1: {'weight': 5.6}, - 2: {'weight': 2.3}, - 3: {'weight': 4.6}}, - 1: {0: {'weight': 5.6}, - 2: {'weight': 1.9}, - 3: {'weight': 6.2}}, - 2: {0: {'weight': 2.3}, - 1: {'weight': 1.9}, - 3: {'weight': 3.0}}, - 3: {0: {'weight': 4.6}, - 1: {'weight': 6.2}, - 2: {'weight': 3.0}, - 4: {'weight': 1.4}}, - 4: {3: {'weight': 1.4}, - 5: {'weight': 4.4}, - 6: {'weight': 1.0}}, - 5: {4: {'weight': 4.4}, - 6: {'weight': 2.8}}, - 6: {4: {'weight': 1.0}, - 5: {'weight': 2.8}} + 0: {1: {"weight": 5.6}, 2: {"weight": 2.3}, 3: {"weight": 4.6}}, + 1: {0: {"weight": 5.6}, 2: {"weight": 1.9}, 3: {"weight": 6.2}}, + 2: {0: {"weight": 2.3}, 1: {"weight": 1.9}, 3: {"weight": 3.0}}, + 3: {0: {"weight": 4.6}, 1: {"weight": 6.2}, 2: {"weight": 3.0}, 4: {"weight": 1.4}}, + 4: {3: {"weight": 1.4}, 5: {"weight": 4.4}, 6: {"weight": 1.0}}, + 5: {4: {"weight": 4.4}, 6: {"weight": 2.8}}, + 6: {4: {"weight": 1.0}, 5: {"weight": 2.8}}, } An alternative way to store a graph is as an adjacency matrix. Each node becomes both a row @@ -240,7 +227,9 @@ node 0. [0, 0, 1, 1, 2], [1, 2, 2, 3, 3], [2.0, 5.0, 1.5, 4.25, 0.5], - nrows=4, ncols=4) + nrows=4, + ncols=4 + ) v = Vector.from_coo([start_node], [0.0], size=4) # Compute SSSP diff --git a/docs/user_guide/init.rst b/docs/user_guide/init.rst index 62f81b50f..ffb6a3463 100644 --- a/docs/user_guide/init.rst +++ b/docs/user_guide/init.rst @@ -8,8 +8,9 @@ GraphBLAS must be initialized before it can be used. This is done with the .. code-block:: python import graphblas as gb + # Context initialization must happen before any other imports - gb.init('suitesparse', blocking=False) + gb.init("suitesparse", blocking=False) # Now we can import other items from graphblas from graphblas import binary, semiring diff --git a/docs/user_guide/operations.rst b/docs/user_guide/operations.rst index 9ee76ab4c..ede2efb06 100644 --- a/docs/user_guide/operations.rst +++ b/docs/user_guide/operations.rst @@ -26,14 +26,22 @@ a Vector is treated as an nx1 column matrix. .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1, 1, 2], [1, 2, 2, 3, 3], - [2., 5., 1.5, 4.25, 0.5], nrows=4, ncols=4) - B = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2, 3, 3], [1, 2, 0, 1, 1, 2, 0, 1], - [3., 2., 9., 6., 3., 1., 0., 5.]) + A = gb.Matrix.from_coo( + [0, 0, 1, 1, 2], + [1, 2, 2, 3, 3], + [2., 5., 1.5, 4.25, 0.5], + nrows=4, + ncols=4 + ) + B = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2, 3, 3], + [1, 2, 0, 1, 1, 2, 0, 1], + [3., 2., 9., 6., 3., 1., 0., 5.] + ) C = gb.Matrix(float, A.nrows, B.ncols) # These are equivalent - C << A.mxm(B, op='min_plus') # method style + C << A.mxm(B, op="min_plus") # method style C << gb.semiring.min_plus(A @ B) # functional style .. csv-table:: A @@ -67,13 +75,18 @@ a Vector is treated as an nx1 column matrix. .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1, 1, 2], [1, 2, 2, 3, 3], - [2., 5., 1.5, 4.25, 0.5], nrows=4, ncols=4) + A = gb.Matrix.from_coo( + [0, 0, 1, 1, 2], + [1, 2, 2, 3, 3], + [2., 5., 1.5, 4.25, 0.5], + nrows=4, + ncols=4 + ) v = gb.Vector.from_coo([0, 1, 3], [10., 20., 40.]) w = gb.Vector(float, A.nrows) # These are equivalent - w << A.mxv(v, op='plus_times') # method style + w << A.mxv(v, op="plus_times") # method style w << gb.semiring.plus_times(A @ v) # functional style .. csv-table:: A @@ -102,12 +115,15 @@ a Vector is treated as an nx1 column matrix. .. code-block:: python v = gb.Vector.from_coo([0, 1, 3], [10., 20., 40.]) - B = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2, 3, 3], [1, 2, 0, 1, 1, 2, 0, 1], - [3., 2., 9., 6., 3., 1., 0., 5.]) + B = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2, 3, 3], + [1, 2, 0, 1, 1, 2, 0, 1], + [3., 2., 9., 6., 3., 1., 0., 5.] + ) u = gb.Vector(float, B.ncols) # These are equivalent - u << v.vxm(B, op='plus_plus') # method style + u << v.vxm(B, op="plus_plus") # method style u << gb.semiring.plus_plus(v @ B) # functional style .. csv-table:: v @@ -148,14 +164,20 @@ Example usage: .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1, 1, 2], [1, 2, 0, 2, 1], - [2.0, 5.0, 1.5, 4.0, 0.5]) - B = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 2, 0, 1, 1, 2], - [3., -2., 0., 6., 3., 1.]) + A = gb.Matrix.from_coo( + [0, 0, 1, 1, 2], + [1, 2, 0, 2, 1], + [2., 5., 1.5, 4., 0.5] + ) + B = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2], + [1, 2, 0, 1, 1, 2], + [3., -2., 0., 6., 3., 1.] + ) C = gb.Matrix(float, A.nrows, A.ncols) # These are equivalent - C << A.ewise_mult(B, op='min') # method style + C << A.ewise_mult(B, op="min") # method style C << gb.binary.min(A & B) # functional style .. csv-table:: A @@ -225,14 +247,21 @@ should be used with the functional syntax, ``left_default`` and ``right_default` .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 0, 1, 1], [0, 1, 2, 0, 2], - [9.0, 2.0, 5.0, 1.5, 4.0], nrows=3) - B = gb.Matrix.from_coo([0, 0, 0, 2, 2, 2], [0, 1, 2, 0, 1, 2], - [4., 0., -2., 6., 3., 1.]) + A = gb.Matrix.from_coo( + [0, 0, 0, 1, 1], + [0, 1, 2, 0, 2], + [9., 2., 5., 1.5, 4.], + nrows=3 + ) + B = gb.Matrix.from_coo( + [0, 0, 0, 2, 2, 2], + [0, 1, 2, 0, 1, 2], + [4., 0., -2., 6., 3., 1.] + ) C = gb.Matrix(float, A.nrows, A.ncols) # These are equivalent - C << A.ewise_add(B, op='minus') # method style + C << A.ewise_add(B, op="minus") # method style C << gb.binary.minus(A | B) # functional style .. csv-table:: A @@ -263,14 +292,21 @@ should be used with the functional syntax, ``left_default`` and ``right_default` .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 0, 1, 1], [0, 1, 2, 0, 2], - [9.0, 2.0, 5.0, 1.5, 4.0], nrows=3) - B = gb.Matrix.from_coo([0, 0, 0, 2, 2, 2], [0, 1, 2, 0, 1, 2], - [4., 0., -2., 6., 3., 1.]) + A = gb.Matrix.from_coo( + [0, 0, 0, 1, 1], + [0, 1, 2, 0, 2], + [9., 2., 5., 1.5, 4.], + nrows=3 + ) + B = gb.Matrix.from_coo( + [0, 0, 0, 2, 2, 2], + [0, 1, 2, 0, 1, 2], + [4., 0., -2., 6., 3., 1.] + ) C = gb.Matrix(float, A.nrows, A.ncols) # These are equivalent - C << A.ewise_union(B, op='minus', left_default=0, right_default=0) # method style + C << A.ewise_union(B, op="minus", left_default=0, right_default=0) # method style C << gb.binary.minus(A | B, left_default=0, right_default=0) # functional style .. csv-table:: A @@ -341,8 +377,11 @@ Matrix List Example: .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 2, 0, 1, 0, 2], - [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + A = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2], + [1, 2, 0, 1, 0, 2], + [2., 5., 1.5, 4., 0.5, -7.] + ) C = gb.Matrix(float, 2, A.ncols) C << A[[0, 2], :] @@ -382,11 +421,16 @@ Matrix-Matrix Assignment Example: .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 2, 0, 1, 0, 2], - [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) - B = gb.Matrix.from_coo([0, 0, 1, 1], [0, 1, 0, 1], - [-99., -98., -97., -96.]) - + A = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2], + [1, 2, 0, 1, 0, 2], + [2., 5., 1.5, 4., 0.5, -7.] + ) + B = gb.Matrix.from_coo( + [0, 0, 1, 1], + [0, 1, 0, 1], + [-99., -98., -97., -96.] + ) A[::2, ::2] << B .. csv-table:: A @@ -416,8 +460,11 @@ Matrix-Vector Assignment Example: .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 2, 0, 1, 0, 2], - [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + A = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2], + [1, 2, 0, 1, 0, 2], + [2., 5., 1.5, 4., 0.5, -7.] + ) v = gb.Vector.from_coo([2], [-99.]) A[1, :] << v @@ -530,7 +577,7 @@ function with the collection as the argument. w = gb.Vector(float, v.size) # These are all equivalent - w << v.apply('minus', right=15) + w << v.apply("minus", right=15) w << gb.binary.minus(v, right=15) w << v - 15 @@ -557,12 +604,15 @@ Upper Triangle Example: .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 2, 0, 2, 1, 2], - [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + A = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2], + [1, 2, 0, 2, 1, 2], + [2., 5., 1.5, 4., 0.5, -7.] + ) C = gb.Matrix(float, A.nrows, A.ncols) # These are equivalent - C << A.select('triu') + C << A.select("triu") C << gb.select.triu(A) .. csv-table:: A @@ -589,7 +639,7 @@ Select by Value Example: w = gb.Vector(float, v.size) # These are equivalent - w << v.select('>=', 5) + w << v.select(">=", 5) w << gb.select.value(v >= 5) .. csv-table:: v @@ -618,11 +668,14 @@ A monoid or aggregator is used to perform the reduction. .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 3, 0, 1, 0, 1], - [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + A = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2], + [1, 3, 0, 1, 0, 1], + [2., 5., 1.5, 4., 0.5, -7.] + ) w = gb.Vector(float, A.ncols) - w << A.reduce_columnwise('times') + w << A.reduce_columnwise("times") .. csv-table:: A :class: inline @@ -642,11 +695,14 @@ A monoid or aggregator is used to perform the reduction. .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 3, 0, 1, 0, 1], - [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + A = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2], + [1, 3, 0, 1, 0, 1], + [2., 5., 1.5, 4., 0.5, -7.] + ) s = gb.Scalar(float) - s << A.reduce_scalar('max') + s << A.reduce_scalar("max") .. csv-table:: A :class: inline @@ -670,7 +726,7 @@ A monoid or aggregator is used to perform the reduction. s = gb.Scalar(int) # These are equivalent - s << v.reduce('argmin') + s << v.reduce("argmin") s << gb.agg.argmin(v) .. csv-table:: v @@ -695,8 +751,11 @@ To force the transpose to be computed by itself, use it by itself as the right-h .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 3, 0, 1, 0, 2], - [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + A = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2], + [1, 3, 0, 1, 0, 2], + [2., 5., 1.5, 4., 0.5, -7.] + ) C = gb.Matrix(float, A.ncols, A.nrows) C << A.T @@ -728,12 +787,19 @@ The Kronecker product uses a binary operator. .. code-block:: python - A = gb.Matrix.from_coo([0, 0, 1], [0, 1, 0], [1., -2., 3.]) - B = gb.Matrix.from_coo([0, 0, 1, 1, 2, 2], [1, 2, 0, 1, 0, 2], - [2.0, 5.0, 1.5, 4.0, 0.5, -7.0]) + A = gb.Matrix.from_coo( + [0, 0, 1], + [0, 1, 0], + [1., -2., 3.] + ) + B = gb.Matrix.from_coo( + [0, 0, 1, 1, 2, 2], + [1, 2, 0, 1, 0, 2], + [2., 5., 1.5, 4., 0.5, -7.] + ) C = gb.Matrix(float, A.nrows * B.nrows, A.ncols * B.ncols) - C << A.kronecker(B, 'times') + C << A.kronecker(B, "times") .. csv-table:: A :class: inline diff --git a/docs/user_guide/operators.rst b/docs/user_guide/operators.rst index 84fe9312c..9499562f2 100644 --- a/docs/user_guide/operators.rst +++ b/docs/user_guide/operators.rst @@ -273,7 +273,7 @@ Example usage: minval = v.reduce(gb.monoid.min).value # This will force the FP32 version of min to be used, possibly type casting the elements - minvalFP32 = v.reduce(gb.monoid.min['FP32']).value + minvalFP32 = v.reduce(gb.monoid.min["FP32"]).value The gb.op Namespace @@ -431,7 +431,7 @@ the power of y for overlapping elements. .. code-block:: python - v ** w + v**w .. csv-table:: :header: 0,1,2,3,4,5 diff --git a/docs/user_guide/recorder.rst b/docs/user_guide/recorder.rst index ee6d2bbb9..3355d93ce 100644 --- a/docs/user_guide/recorder.rst +++ b/docs/user_guide/recorder.rst @@ -25,7 +25,9 @@ Instead, only the calls from the last iteration will be returned. [0, 0, 1, 1, 2], [1, 2, 2, 3, 3], [2.0, 5.0, 1.5, 4.25, 0.5], - nrows=4, ncols=4) + nrows=4, + ncols=4 + ) v = Vector.from_coo([start_node], [0.0], size=4) # Compute SSSP diff --git a/docs/user_guide/udf.rst b/docs/user_guide/udf.rst index 6c72535fc..b96097a85 100644 --- a/docs/user_guide/udf.rst +++ b/docs/user_guide/udf.rst @@ -21,7 +21,7 @@ Example user-defined UnaryOp: return x + 1 return x - unary.register_new('force_odd', force_odd_func) + unary.register_new("force_odd", force_odd_func) v = Vector.from_coo([0, 1, 3, 4, 5], [1, 2, 3, 8, 14]) w = v.apply(unary.force_odd).new() diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index b74ca347a..2542ad00e 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -457,7 +457,7 @@ def to_values(self, dtype=None, *, rows=True, columns=True, values=True, sort=Tr Requested dtype for the output values array. rows : bool, default=True Whether to return rows; will return ``None`` for rows if ``False`` - columns :bool, default=True + columns : bool, default=True Whether to return columns; will return ``None`` for columns if ``False`` values : bool, default=True Whether to return values; will return ``None`` for values if ``False`` diff --git a/graphblas/viz.py b/graphblas/viz.py index fafeae5f0..f0367e119 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -35,8 +35,7 @@ def _get_imports(names, within): except ImportError: modname = _LAZY_IMPORTS[name].split(".")[0] raise ImportError(f"`{within}` requires {modname} to be installed") from None - finally: - globals()[name] = val + globals()[name] = val rv.append(val) if is_string: return rv[0] diff --git a/pyproject.toml b/pyproject.toml index 245dc35bd..9d635c778 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -168,11 +168,18 @@ known_first_party = "graphblas" line_length = 100 [tool.pytest.ini_options] +minversion = "6.0" testpaths = "graphblas/tests" xfail_strict = true +addopts = [ + "--strict-config", # Force error if config is mispelled + "--strict-markers", # Force error if marker is mispelled (must be defined in config) + "-ra", # Print summary of all fails/errors +] markers = [ "slow: Skipped unless --runslow passed", ] +log_cli_level = "info" filterwarnings = [ # See: https://docs.python.org/3/library/warnings.html#describing-warning-filters # and: https://docs.pytest.org/en/7.2.x/how-to/capture-warnings.html#controlling-warnings @@ -342,6 +349,7 @@ ignore = [ "TID", # flake8-tidy-imports (Rely on isort and our own judgement) "TCH", # flake8-type-checking (Note: figure out type checking later) "ARG", # flake8-unused-arguments (Sometimes helpful, but too strict) + "TD", # flake8-todos (Maybe okay to add some of these) "ERA", # eradicate (We like code in comments!) "PD", # pandas-vet (Intended for scripts that use pandas, not libraries) ] From f0e03249fab46f36e12b66806a01f6e8e94cfde1 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 2 Jun 2023 19:47:47 -0500 Subject: [PATCH 27/87] Don't install graphblas=8.0 yet (#459) * Don't install graphblas=8.0 yet Fixes #458 * Don't install python-suitesparse-graphblas from upstream (which needs ss:gb 8.0) --- .github/workflows/test_and_build.yml | 19 +++++++++++-------- .pre-commit-config.yaml | 6 +++--- graphblas/tests/test_vector.py | 2 +- scripts/check_versions.sh | 8 ++++---- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index d129ef26f..ac541294f 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -17,9 +17,9 @@ on: branches: - main -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true +# concurrency: +# group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} +# cancel-in-progress: true jobs: rngs: @@ -131,9 +131,9 @@ jobs: source upstream weights: | - 1 - 1 - 1 + 1000000 + 1000000 + 1000000 1 - name: Setup mamba uses: conda-incubator/setup-miniconda@v2 @@ -170,7 +170,7 @@ jobs: nxver=$(python -c 'import random ; print(random.choice(["=2.7", "=2.8", "=3.0", "=3.1", ""]))') yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') sparsever=$(python -c 'import random ; print(random.choice(["=0.13", "=0.14", ""]))') - fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", ""]))') + fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", ""]))') if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.8') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", ""]))') @@ -252,13 +252,14 @@ jobs: fi echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psgver${psgver}" + set -x # echo on $(command -v mamba || command -v conda) install packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli \ pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \ networkx${nxver} ${numba} ${fmm} ${psg} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ ${{ matrix.slowtask == 'notebooks' && 'matplotlib nbconvert jupyter "ipython>=7"' || '' }} \ ${{ steps.sourcetype.outputs.selected == 'upstream' && 'cython' || '' }} \ - ${{ steps.sourcetype.outputs.selected != 'wheel' && '"graphblas>=7.4.0"' || '' }} \ + ${{ steps.sourcetype.outputs.selected != 'wheel' && '"graphblas=7.4"' || '' }} \ ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'pypy' || '' }} - name: Build extension module run: | @@ -308,6 +309,7 @@ jobs: if [[ $H && $bizarro ]] ; then if [[ $macos ]] ; then echo " $suitesparse" ; elif [[ $windows ]] ; then echo " $vanilla" ; fi ; fi) echo ${args} pytest -v --pyargs suitesparse_graphblas + set -x # echo on coverage run -m pytest --color=yes --randomly -v ${args} \ ${{ matrix.slowtask == 'pytest_normal' && '--runslow' || '' }} - name: Unit tests (bizarro scalars) @@ -343,6 +345,7 @@ jobs: if [[ $H && $normal ]] ; then if [[ $macos ]] ; then echo " $suitesparse" ; elif [[ $windows ]] ; then echo " $vanilla" ; fi ; fi)$( \ if [[ $H && $bizarro ]] ; then if [[ $macos ]] ; then echo " $vanilla" ; elif [[ $windows ]] ; then echo " $suitesparse" ; fi ; fi) echo ${args} + set -x # echo on coverage run -a -m pytest --color=yes --randomly -v ${args} \ ${{ matrix.slowtask == 'pytest_bizarro' && '--runslow' || '' }} git checkout . # Undo changes to scalar default diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4588ed4f4..4d0e5c0b6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -66,7 +66,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.269 + rev: v0.0.270 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.269 + rev: v0.0.270 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint @@ -110,7 +110,7 @@ repos: - id: pyroma args: [-n, "10", .] - repo: https://github.com/shellcheck-py/shellcheck-py - rev: "v0.9.0.2" + rev: "v0.9.0.5" hooks: - id: shellcheck - repo: local diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index bd2083fd1..36ab346b8 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -1432,7 +1432,7 @@ def test_vector_index_with_scalar(): s0 = Scalar.from_value(0, dtype=dtype) w = v[[s1, s0]].new() assert w.isequal(expected) - for dtype in ["bool", "fp32", "fp64"] + ["fc32", "fc64"] if dtypes._supports_complex else []: + for dtype in ["bool", "fp32", "fp64"] + (["fc32", "fc64"] if dtypes._supports_complex else []): s = Scalar.from_value(1, dtype=dtype) with pytest.raises(TypeError, match="An integer is required for indexing"): v[s] diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index dda7adbaa..af72f9655 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -4,13 +4,13 @@ # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. conda search 'numpy[channel=conda-forge]>=1.24.3' -conda search 'pandas[channel=conda-forge]>=2.0.1' +conda search 'pandas[channel=conda-forge]>=2.0.2' conda search 'scipy[channel=conda-forge]>=1.10.1' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.2.0' +conda search 'awkward[channel=conda-forge]>=2.2.1' conda search 'sparse[channel=conda-forge]>=0.14.0' -conda search 'fast_matrix_market[channel=conda-forge]>=1.5.1' -conda search 'numba[channel=conda-forge]>=0.56.4' +conda search 'fast_matrix_market[channel=conda-forge]>=1.6.0' +conda search 'numba[channel=conda-forge]>=0.57.0' conda search 'pyyaml[channel=conda-forge]>=6.0' conda search 'flake8-bugbear[channel=conda-forge]>=23.5.9' conda search 'flake8-simplify[channel=conda-forge]>=0.20.0' From a762caef6e694746988c14945c2cb223b7d1b414 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Thu, 22 Jun 2023 00:52:33 -0500 Subject: [PATCH 28/87] Fix testing fix_test_suitesparse_graphblas (#464) * Fix testing fix_test_suitesparse_graphblas * Support numpy 1.25 (`np.find_common_type` is deprecated) * Ignore `np.find_common_type` DeprecationWarning for now, b/c other dependencies use it * Don't make xfail strict while awkward is failing * Don't install scipy.sparse 1.8 with numpy 1.25 * Retry coveralls again, and don't fail if retry fails --- .github/workflows/test_and_build.yml | 35 ++++++++++++++++++++++------ graphblas/dtypes.py | 20 +++++----------- graphblas/tests/test_io.py | 2 ++ pyproject.toml | 10 ++++++-- scripts/check_versions.sh | 2 +- 5 files changed, 45 insertions(+), 24 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index ac541294f..bfad18a2e 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -85,7 +85,7 @@ jobs: shell: bash -l {0} strategy: # To "stress test" in CI, set `fail-fast` to `false` and perhaps add more items to `matrix.slowtask` - fail-fast: true + fail-fast: false # Every service seems super-flaky right now... # The build matrix is [os]x[slowtask] and then randomly chooses [pyver] and [sourcetype]. # This should ensure we'll have full code coverage (i.e., no chance of getting unlucky), # since we need to run all slow tests on Windows and non-Windoes OSes. @@ -177,17 +177,17 @@ jobs: pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') 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", "=2.0", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') 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", "=2.0", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') else # Python 3.11 - npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", ""]))') @@ -214,7 +214,12 @@ jobs: else psgver="" fi - if [[ ${npver} == "=1.24" || ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]] ; then + if [[ ${npver} == "=1.25" ]] ; then + numbaver="" + if [[ ${spver} == "=1.8" ]] ; then + spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", ""]))') + fi + elif [[ ${npver} == "=1.24" || ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]] ; then numbaver=$(python -c 'import random ; print(random.choice(["=0.57", ""]))') elif [[ ${npver} == "=1.21" ]] ; then numbaver=$(python -c 'import random ; print(random.choice(["=0.55", "=0.56", "=0.57", ""]))') @@ -246,6 +251,11 @@ jobs: pdver="" yamlver="" fi + elif [[ ${npver} == "=1.25" ]] ; then + numba="" + numbaver=NA + sparse="" + sparsever=NA else numba=numba${numbaver} sparse=sparse${sparsever} @@ -308,7 +318,7 @@ jobs: if [[ $H && $normal ]] ; then if [[ $macos ]] ; then echo " $vanilla" ; elif [[ $windows ]] ; then echo " $suitesparse" ; fi ; fi)$( \ if [[ $H && $bizarro ]] ; then if [[ $macos ]] ; then echo " $suitesparse" ; elif [[ $windows ]] ; then echo " $vanilla" ; fi ; fi) echo ${args} - pytest -v --pyargs suitesparse_graphblas + (cd .. && pytest -v --pyargs suitesparse_graphblas) # Don't use our conftest.py set -x # echo on coverage run -m pytest --color=yes --randomly -v ${args} \ ${{ matrix.slowtask == 'pytest_normal' && '--runslow' || '' }} @@ -400,7 +410,18 @@ jobs: - name: Coverage2 id: coverageAttempt2 if: steps.coverageAttempt1.outcome == 'failure' - continue-on-error: false + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_FLAG_NAME: ${{ matrix.os }}/${{ matrix.slowtask }} + COVERALLS_PARALLEL: true + run: | + coveralls --service=github + - name: Coverage3 + id: coverageAttempt3 + if: steps.coverageAttempt2.outcome == 'failure' + # Continue even if it failed 3 times... (sheesh! use codecov instead) + continue-on-error: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_FLAG_NAME: ${{ matrix.os }}/${{ matrix.slowtask }} diff --git a/graphblas/dtypes.py b/graphblas/dtypes.py index 920610b95..61b297c13 100644 --- a/graphblas/dtypes.py +++ b/graphblas/dtypes.py @@ -1,8 +1,8 @@ import warnings as _warnings import numpy as _np -from numpy import find_common_type as _find_common_type from numpy import promote_types as _promote_types +from numpy import result_type as _result_type from . import backend from .core import NULL as _NULL @@ -389,19 +389,11 @@ def unify(type1, type2, *, is_left_scalar=False, is_right_scalar=False): if type1 is type2: return type1 if is_left_scalar: - scalar_types = [type1.np_type] - array_types = [] - elif not is_right_scalar: - # Using `promote_types` is faster than `find_common_type` - return lookup_dtype(_promote_types(type1.np_type, type2.np_type)) - else: - scalar_types = [] - array_types = [type1.np_type] - if is_right_scalar: - scalar_types.append(type2.np_type) - else: - array_types.append(type2.np_type) - return lookup_dtype(_find_common_type(array_types, scalar_types)) + if not is_right_scalar: + return lookup_dtype(_result_type(_np.array(0, type1.np_type), type2.np_type)) + elif is_right_scalar: + return lookup_dtype(_result_type(type1.np_type, _np.array(0, type2.np_type))) + return lookup_dtype(_promote_types(type1.np_type, type2.np_type)) def _default_name(dtype): diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index df94c6469..bda41759b 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -369,6 +369,7 @@ def test_scipy_sparse(): @pytest.mark.skipif("not ak") +@pytest.mark.xfail(reason="Need to investigate test failure") def test_awkward_roundtrip(): # Vector v = gb.Vector.from_coo([1, 3, 5], [20, 21, -5], size=22) @@ -390,6 +391,7 @@ def test_awkward_roundtrip(): @pytest.mark.skipif("not ak") +@pytest.mark.xfail(reason="Need to investigate test failure") def test_awkward_iso_roundtrip(): # Vector v = gb.Vector.from_coo([1, 3, 5], [20, 20, 20], size=22) diff --git a/pyproject.toml b/pyproject.toml index 9d635c778..ba8ec0095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,7 +170,7 @@ line_length = 100 [tool.pytest.ini_options] minversion = "6.0" testpaths = "graphblas/tests" -xfail_strict = true +xfail_strict = false # TODO: re-enable this when awkward test failures are fixed addopts = [ "--strict-config", # Force error if config is mispelled "--strict-markers", # Force error if marker is mispelled (must be defined in config) @@ -184,6 +184,7 @@ filterwarnings = [ # See: https://docs.python.org/3/library/warnings.html#describing-warning-filters # and: https://docs.pytest.org/en/7.2.x/how-to/capture-warnings.html#controlling-warnings "error", + # sparse 0.14.0 (2022-02-24) began raising this warning; it has been reported and fixed upstream. "ignore:coords should be an ndarray. This will raise a ValueError:DeprecationWarning:sparse._coo.core", @@ -191,7 +192,8 @@ filterwarnings = [ # https://setuptools.pypa.io/en/latest/history.html#v67-3-0 # MAINT: check if this is still necessary in 2025 "ignore:Deprecated call to `pkg_resources.declare_namespace:DeprecationWarning:pkg_resources", - # And this deprecation warning was added in setuptools v67.5.0 (8 Mar 2023). See: + + # This deprecation warning was added in setuptools v67.5.0 (8 Mar 2023). See: # https://setuptools.pypa.io/en/latest/history.html#v67-5-0 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pkg_resources", @@ -199,6 +201,10 @@ filterwarnings = [ "ignore:module 'sre_parse' is deprecated:DeprecationWarning:", "ignore:module 'sre_constants' is deprecated:DeprecationWarning:", + # numpy 1.25.0 (2023-06-17) deprecated `np.find_common_type`; many other dependencies use it. + # See if we can remove this filter in 2025. + "ignore:np.find_common_type is deprecated:DeprecationWarning:", + # pypy gives this warning "ignore:can't resolve package from __spec__ or __package__:ImportWarning:", ] diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index af72f9655..f0e648fd9 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -3,7 +3,7 @@ # Use, adjust, copy/paste, etc. as necessary to answer your questions. # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. -conda search 'numpy[channel=conda-forge]>=1.24.3' +conda search 'numpy[channel=conda-forge]>=1.25.0' conda search 'pandas[channel=conda-forge]>=2.0.2' conda search 'scipy[channel=conda-forge]>=1.10.1' conda search 'networkx[channel=conda-forge]>=3.1' From f89c72dc3283a41933afd2ccbcdf82e6a371dd70 Mon Sep 17 00:00:00 2001 From: Sultan Orazbayev Date: Thu, 22 Jun 2023 05:55:33 +0000 Subject: [PATCH 29/87] Add missing CLI keyword. (#462) --- docs/contributor_guide/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributor_guide/index.rst b/docs/contributor_guide/index.rst index e8078f933..3b94f2f35 100644 --- a/docs/contributor_guide/index.rst +++ b/docs/contributor_guide/index.rst @@ -58,7 +58,7 @@ Here are instructions for two popular environment managers: :: # Create a conda environment named ``graphblas-dev`` using environment.yml in the repository root - conda create -f environment.yml + conda env create -f environment.yml # Activate it conda activate graphblas-dev # Install python-graphblas from source From 6bbf0cd51f509850565f9c326de2287c090e7538 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 23 Jun 2023 09:09:19 -0500 Subject: [PATCH 30/87] xfail awkward tests when using numpy 1.25 (#467) --- .github/workflows/test_and_build.yml | 2 +- graphblas/tests/test_io.py | 4 ++-- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index bfad18a2e..d0c3f71fb 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -421,7 +421,7 @@ jobs: id: coverageAttempt3 if: steps.coverageAttempt2.outcome == 'failure' # Continue even if it failed 3 times... (sheesh! use codecov instead) - continue-on-error: true + continue-on-error: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_FLAG_NAME: ${{ matrix.os }}/${{ matrix.slowtask }} diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index bda41759b..671b12bd6 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -369,7 +369,7 @@ def test_scipy_sparse(): @pytest.mark.skipif("not ak") -@pytest.mark.xfail(reason="Need to investigate test failure") +@pytest.mark.xfail(np.__version__[:5] == "1.25.", reason="awkward bug with numpy 1.25") def test_awkward_roundtrip(): # Vector v = gb.Vector.from_coo([1, 3, 5], [20, 21, -5], size=22) @@ -391,7 +391,7 @@ def test_awkward_roundtrip(): @pytest.mark.skipif("not ak") -@pytest.mark.xfail(reason="Need to investigate test failure") +@pytest.mark.xfail(np.__version__[:5] == "1.25.", reason="awkward bug with numpy 1.25") def test_awkward_iso_roundtrip(): # Vector v = gb.Vector.from_coo([1, 3, 5], [20, 20, 20], size=22) diff --git a/pyproject.toml b/pyproject.toml index ba8ec0095..9e57b8296 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,7 +170,7 @@ line_length = 100 [tool.pytest.ini_options] minversion = "6.0" testpaths = "graphblas/tests" -xfail_strict = false # TODO: re-enable this when awkward test failures are fixed +xfail_strict = true addopts = [ "--strict-config", # Force error if config is mispelled "--strict-markers", # Force error if marker is mispelled (must be defined in config) From cd451978361504a62b3987afa2504d8a357817bc Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 28 Jun 2023 09:27:56 -0500 Subject: [PATCH 31/87] Backport many of the SS:GB 8 changes to run on 7 (#478) * Backport many of the SS:GB 8 changes to run on 7 * Make fast_matrix_market <1.7 and scipy >=1.11 play nicely together * fix mmread to handle sparse and dense arrays --- .github/workflows/imports.yml | 4 +- .github/workflows/publish_pypi.yml | 2 +- .github/workflows/test_and_build.yml | 21 +-- .pre-commit-config.yaml | 16 +- docs/env.yml | 2 +- graphblas/binary/ss.py | 2 + graphblas/core/base.py | 6 +- graphblas/core/descriptor.py | 1 + graphblas/core/expr.py | 2 +- graphblas/core/matrix.py | 4 +- graphblas/core/operator/binary.py | 6 +- graphblas/core/operator/indexunary.py | 7 +- graphblas/core/operator/semiring.py | 4 +- graphblas/core/operator/unary.py | 6 +- graphblas/core/ss/config.py | 57 +++++-- graphblas/core/ss/descriptor.py | 1 - graphblas/core/ss/matrix.py | 3 +- graphblas/core/ss/vector.py | 3 +- graphblas/core/vector.py | 7 +- graphblas/dtypes/__init__.py | 43 +++++ graphblas/{dtypes.py => dtypes/_core.py} | 196 +++++++++++------------ graphblas/dtypes/ss.py | 0 graphblas/indexunary/__init__.py | 14 +- graphblas/indexunary/ss.py | 5 + graphblas/io/_matrixmarket.py | 15 +- graphblas/monoid/__init__.py | 14 +- graphblas/monoid/ss.py | 5 + graphblas/op/ss.py | 2 + graphblas/select/__init__.py | 14 +- graphblas/select/ss.py | 5 + graphblas/semiring/ss.py | 2 + graphblas/ss/_core.py | 6 +- graphblas/tests/conftest.py | 22 +++ graphblas/tests/test_dtype.py | 27 +++- graphblas/tests/test_io.py | 18 ++- graphblas/tests/test_matrix.py | 2 +- graphblas/tests/test_op.py | 14 +- graphblas/tests/test_vector.py | 11 +- graphblas/unary/ss.py | 2 + pyproject.toml | 4 + scripts/check_versions.sh | 10 +- 41 files changed, 408 insertions(+), 177 deletions(-) create mode 100644 graphblas/dtypes/__init__.py rename graphblas/{dtypes.py => dtypes/_core.py} (69%) create mode 100644 graphblas/dtypes/ss.py create mode 100644 graphblas/indexunary/ss.py create mode 100644 graphblas/monoid/ss.py create mode 100644 graphblas/select/ss.py diff --git a/.github/workflows/imports.yml b/.github/workflows/imports.yml index 2b0b0ed9f..18e6f637c 100644 --- a/.github/workflows/imports.yml +++ b/.github/workflows/imports.yml @@ -54,5 +54,7 @@ jobs: python-version: ${{ needs.rngs.outputs.pyver }} # python-version: ${{ matrix.python-version }} - run: python -m pip install --upgrade pip + # - run: pip install --pre suitesparse-graphblas # Use if we need pre-release - run: pip install -e .[default] - - run: ./scripts/test_imports.sh + - name: Run test imports + run: ./scripts/test_imports.sh diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index eca456c28..ffac645f5 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.6 + uses: pypa/gh-action-pypi-publish@v1.8.7 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index d0c3f71fb..209060521 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -85,7 +85,7 @@ jobs: shell: bash -l {0} strategy: # To "stress test" in CI, set `fail-fast` to `false` and perhaps add more items to `matrix.slowtask` - fail-fast: false # Every service seems super-flaky right now... + fail-fast: true # The build matrix is [os]x[slowtask] and then randomly chooses [pyver] and [sourcetype]. # This should ensure we'll have full code coverage (i.e., no chance of getting unlucky), # since we need to run all slow tests on Windows and non-Windoes OSes. @@ -170,7 +170,7 @@ jobs: nxver=$(python -c 'import random ; print(random.choice(["=2.7", "=2.8", "=3.0", "=3.1", ""]))') yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') sparsever=$(python -c 'import random ; print(random.choice(["=0.13", "=0.14", ""]))') - fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", ""]))') + fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.8') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", ""]))') @@ -178,17 +178,17 @@ jobs: akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') else # Python 3.11 npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", ""]))') fi @@ -204,20 +204,20 @@ jobs: # But, it's still useful for us to test with different versions! psg="" if [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2"]))') psg=python-suitesparse-graphblas${psgver} elif [[ ${{ steps.sourcetype.outputs.selected}} == "wheel" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2"]))') elif [[ ${{ steps.sourcetype.outputs.selected}} == "source" ]] ; then # These should be exact versions - psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2"]))') else psgver="" fi if [[ ${npver} == "=1.25" ]] ; then numbaver="" if [[ ${spver} == "=1.8" ]] ; then - spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') fi elif [[ ${npver} == "=1.24" || ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]] ; then numbaver=$(python -c 'import random ; print(random.choice(["=0.57", ""]))') @@ -374,6 +374,9 @@ jobs: # Tests lazy loading of lib, ffi, and NULL in gb.core echo "from graphblas.core import base" > script.py coverage run -a script.py + # Test another code pathway for loading lib + echo "from graphblas.core import lib" > script.py + coverage run -a script.py rm script.py # Tests whose coverage depend on order of tests :/ # TODO: understand why these are order-dependent and try to fix diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4d0e5c0b6..f0ca307e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - id: check-added-large-files - id: check-case-conflict - id: check-merge-conflict - - id: check-symlinks + # - id: check-symlinks - id: check-ast - id: check-toml - id: check-yaml @@ -39,7 +39,7 @@ repos: name: Validate pyproject.toml # I don't yet trust ruff to do what autoflake does - repo: https://github.com/PyCQA/autoflake - rev: v2.1.1 + rev: v2.2.0 hooks: - id: autoflake args: [--in-place] @@ -51,7 +51,7 @@ repos: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it - repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 + rev: v3.7.0 hooks: - id: pyupgrade args: [--py38-plus] @@ -66,7 +66,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.270 + rev: v0.0.275 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -79,22 +79,22 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.0.0 - - flake8-bugbear==23.5.9 + - flake8-bugbear==23.6.5 - flake8-simplify==0.20.0 - repo: https://github.com/asottile/yesqa - rev: v1.4.0 + rev: v1.5.0 hooks: - id: yesqa additional_dependencies: *flake8_dependencies - repo: https://github.com/codespell-project/codespell - rev: v2.2.4 + rev: v2.2.5 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.270 + rev: v0.0.275 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/docs/env.yml b/docs/env.yml index c0c4c8999..3636cfa2d 100644 --- a/docs/env.yml +++ b/docs/env.yml @@ -8,7 +8,7 @@ dependencies: # python-graphblas dependencies - donfig - numba - - python-suitesparse-graphblas>=7.4.0.0 + - python-suitesparse-graphblas>=7.4.0.0,<8 - pyyaml # extra dependencies - matplotlib diff --git a/graphblas/binary/ss.py b/graphblas/binary/ss.py index e45cbcda0..97852fc12 100644 --- a/graphblas/binary/ss.py +++ b/graphblas/binary/ss.py @@ -1,3 +1,5 @@ from ..core import operator +_delayed = {} + del operator diff --git a/graphblas/core/base.py b/graphblas/core/base.py index a4e48b612..42a4de9a1 100644 --- a/graphblas/core/base.py +++ b/graphblas/core/base.py @@ -348,7 +348,7 @@ def _update(self, expr, mask=None, accum=None, replace=False, input_mask=None, * return if opts: # Ignore opts for now - descriptor_lookup(**opts) + desc = descriptor_lookup(**opts) self.value = expr return @@ -371,7 +371,7 @@ def _update(self, expr, mask=None, accum=None, replace=False, input_mask=None, * else: if opts: # Ignore opts for now - descriptor_lookup(**opts) + desc = descriptor_lookup(**opts) self.value = expr return else: @@ -571,7 +571,7 @@ def _new(self, dtype, mask, name, is_cscalar=None, **opts): ): if opts: # Ignore opts for now - descriptor_lookup(**opts) + desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context) if self._is_scalar and self._value._is_cscalar != is_cscalar: return self._value.dup(is_cscalar=is_cscalar, name=name) rv = self._value diff --git a/graphblas/core/descriptor.py b/graphblas/core/descriptor.py index 1e195e3fe..11f634afd 100644 --- a/graphblas/core/descriptor.py +++ b/graphblas/core/descriptor.py @@ -26,6 +26,7 @@ def __init__( self.mask_structure = mask_structure self.transpose_first = transpose_first self.transpose_second = transpose_second + self._context = None # Used by SuiteSparse:GraphBLAS 8 @property def _carg(self): diff --git a/graphblas/core/expr.py b/graphblas/core/expr.py index 48839bcff..d803939a5 100644 --- a/graphblas/core/expr.py +++ b/graphblas/core/expr.py @@ -421,7 +421,7 @@ def _setitem(self, resolved_indexes, obj, *, is_submask): # Fast path using assignElement if self.opts: # Ignore opts for now - descriptor_lookup(**self.opts) + desc = descriptor_lookup(**self.opts) # noqa: F841 (keep desc in scope for context) self.parent._assign_element(resolved_indexes, obj) else: mask = self.kwargs.get("mask") diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 2542ad00e..4696d8ead 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -665,7 +665,7 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): else: if opts: # Ignore opts for now - descriptor_lookup(**opts) + desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context) new_mat = ffi_new("GrB_Matrix*") rv = Matrix._from_obj(new_mat, self.dtype, self._nrows, self._ncols, name=name) call("GrB_Matrix_dup", [_Pointer(rv), self]) @@ -2707,7 +2707,7 @@ def _extract_element( result = Scalar(dtype, is_cscalar=is_cscalar, name=name) if opts: # Ignore opts for now - descriptor_lookup(**opts) + desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context) if is_cscalar: dtype_name = "UDT" if dtype._is_udt else dtype.name if ( diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py index 406405a80..434ad91cb 100644 --- a/graphblas/core/operator/binary.py +++ b/graphblas/core/operator/binary.py @@ -19,10 +19,10 @@ UINT16, UINT32, UINT64, - _sample_values, _supports_complex, lookup_dtype, ) +from ...dtypes._core import _sample_values from ...exceptions import UdfParseError, check_status_carg from .. import _has_numba, _supports_udfs, ffi, lib from ..expr import InfixExprBase @@ -506,7 +506,7 @@ def binary_wrapper(z, x, y): # pragma: no cover (numba) type_.gb_obj, ), "BinaryOp", - new_binary, + new_binary[0], ) op = TypedUserBinaryOp(new_type_obj, name, type_, ret_type, new_binary[0]) new_type_obj._add(op) @@ -611,7 +611,7 @@ def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) new_binary, binary_wrapper.cffi, ret_type._carg, dtype._carg, dtype2._carg ), "BinaryOp", - new_binary, + new_binary[0], ) op = TypedUserBinaryOp( self, diff --git a/graphblas/core/operator/indexunary.py b/graphblas/core/operator/indexunary.py index f6637ae6d..8b1211258 100644 --- a/graphblas/core/operator/indexunary.py +++ b/graphblas/core/operator/indexunary.py @@ -3,7 +3,8 @@ from types import FunctionType from ... import _STANDARD_OPERATOR_NAMES, indexunary, select -from ...dtypes import BOOL, FP64, INT8, INT64, UINT64, _sample_values, lookup_dtype +from ...dtypes import BOOL, FP64, INT8, INT64, UINT64, lookup_dtype +from ...dtypes._core import _sample_values from ...exceptions import UdfParseError, check_status_carg from .. import _has_numba, ffi, lib from .base import OpBase, ParameterizedUdf, TypedOpBase, _call_op, _deserialize_parameterized @@ -193,7 +194,7 @@ def indexunary_wrapper(z, x, row, col, y): # pragma: no cover (numba) type_.gb_obj, ), "IndexUnaryOp", - new_indexunary, + new_indexunary[0], ) op = cls._typed_user_class(new_type_obj, name, type_, ret_type, new_indexunary[0]) new_type_obj._add(op) @@ -225,7 +226,7 @@ def _compile_udt(self, dtype, dtype2): new_indexunary, indexunary_wrapper.cffi, ret_type._carg, dtype._carg, dtype2._carg ), "IndexUnaryOp", - new_indexunary, + new_indexunary[0], ) op = TypedUserIndexUnaryOp( self, diff --git a/graphblas/core/operator/semiring.py b/graphblas/core/operator/semiring.py index 035a1c43b..d367461f6 100644 --- a/graphblas/core/operator/semiring.py +++ b/graphblas/core/operator/semiring.py @@ -228,7 +228,7 @@ def _build(cls, name, monoid, binaryop, *, anonymous=False): check_status_carg( lib.GrB_Semiring_new(new_semiring, monoid[binary_out].gb_obj, binary_func.gb_obj), "Semiring", - new_semiring, + new_semiring[0], ) ret_type = monoid[binary_out].return_type op = TypedUserSemiring( @@ -254,7 +254,7 @@ def _compile_udt(self, dtype, dtype2): ret_type = monoid.return_type new_semiring = ffi_new("GrB_Semiring*") status = lib.GrB_Semiring_new(new_semiring, monoid.gb_obj, binaryop.gb_obj) - check_status_carg(status, "Semiring", new_semiring) + check_status_carg(status, "Semiring", new_semiring[0]) op = TypedUserSemiring( new_semiring, self.name, diff --git a/graphblas/core/operator/unary.py b/graphblas/core/operator/unary.py index a02445836..11ada4e48 100644 --- a/graphblas/core/operator/unary.py +++ b/graphblas/core/operator/unary.py @@ -15,10 +15,10 @@ UINT16, UINT32, UINT64, - _sample_values, _supports_complex, lookup_dtype, ) +from ...dtypes._core import _sample_values from ...exceptions import UdfParseError, check_status_carg from .. import _has_numba, ffi, lib from ..utils import output_type @@ -239,7 +239,7 @@ def unary_wrapper(z, x): new_unary, unary_wrapper.cffi, ret_type.gb_obj, type_.gb_obj ), "UnaryOp", - new_unary, + new_unary[0], ) op = TypedUserUnaryOp(new_type_obj, name, type_, ret_type, new_unary[0]) new_type_obj._add(op) @@ -264,7 +264,7 @@ def _compile_udt(self, dtype, dtype2): check_status_carg( lib.GrB_UnaryOp_new(new_unary, unary_wrapper.cffi, ret_type._carg, dtype._carg), "UnaryOp", - new_unary, + new_unary[0], ) op = TypedUserUnaryOp(self, self.name, dtype, ret_type, new_unary[0]) self._udt_types[dtype] = ret_type diff --git a/graphblas/core/ss/config.py b/graphblas/core/ss/config.py index ca91cc198..89536479d 100644 --- a/graphblas/core/ss/config.py +++ b/graphblas/core/ss/config.py @@ -12,6 +12,9 @@ class BaseConfig(MutableMapping): # Subclasses should redefine these _get_function = None _set_function = None + _context_get_function = "GxB_Context_get" + _context_set_function = "GxB_Context_set" + _context_keys = set() _null_valid = {} _options = {} _defaults = {} @@ -28,7 +31,7 @@ class BaseConfig(MutableMapping): "GxB_Format_Value", } - def __init__(self, parent=None): + def __init__(self, parent=None, context=None): cls = type(self) if not cls._initialized: cls._reverse_enumerations = {} @@ -51,6 +54,7 @@ def __init__(self, parent=None): rd[k] = k cls._initialized = True self._parent = parent + self._context = context def __delitem__(self, key): raise TypeError("Configuration options can't be deleted.") @@ -61,19 +65,27 @@ def __getitem__(self, key): raise KeyError(key) key_obj, ctype = self._options[key] is_bool = ctype == "bool" + if is_context := (key in self._context_keys): # pragma: no cover (suitesparse 8) + get_function_base = self._context_get_function + else: + get_function_base = self._get_function if ctype in self._int32_ctypes: ctype = "int32_t" - get_function_name = f"{self._get_function}_INT32" + get_function_name = f"{get_function_base}_INT32" elif ctype.startswith("int64_t"): - get_function_name = f"{self._get_function}_INT64" + get_function_name = f"{get_function_base}_INT64" elif ctype.startswith("double"): - get_function_name = f"{self._get_function}_FP64" + get_function_name = f"{get_function_base}_FP64" + elif ctype.startswith("char"): # pragma: no cover (suitesparse 8) + get_function_name = f"{get_function_base}_CHAR" else: # pragma: no cover (sanity) raise ValueError(ctype) get_function = getattr(lib, get_function_name) is_array = "[" in ctype val_ptr = ffi.new(ctype if is_array else f"{ctype}*") - if self._parent is None: + if is_context: # pragma: no cover (suitesparse 8) + info = get_function(self._context._carg, key_obj, val_ptr) + elif self._parent is None: info = get_function(key_obj, val_ptr) else: info = get_function(self._parent._carg, key_obj, val_ptr) @@ -93,6 +105,8 @@ def __getitem__(self, key): return rv if is_bool: return bool(val_ptr[0]) + if ctype.startswith("char"): # pragma: no cover (suitesparse 8) + return ffi.string(val_ptr[0]).decode() return val_ptr[0] raise _error_code_lookup[info](f"Failed to get info for {key!r}") # pragma: no cover @@ -103,15 +117,21 @@ def __setitem__(self, key, val): if key in self._read_only: raise ValueError(f"Config option {key!r} is read-only") key_obj, ctype = self._options[key] + if is_context := (key in self._context_keys): # pragma: no cover (suitesparse 8) + set_function_base = self._context_set_function + else: + set_function_base = self._set_function if ctype in self._int32_ctypes: ctype = "int32_t" - set_function_name = f"{self._set_function}_INT32" + set_function_name = f"{set_function_base}_INT32" elif ctype == "double": - set_function_name = f"{self._set_function}_FP64" + set_function_name = f"{set_function_base}_FP64" elif ctype.startswith("int64_t["): - set_function_name = f"{self._set_function}_INT64_ARRAY" + set_function_name = f"{set_function_base}_INT64_ARRAY" elif ctype.startswith("double["): - set_function_name = f"{self._set_function}_FP64_ARRAY" + set_function_name = f"{set_function_base}_FP64_ARRAY" + elif ctype.startswith("char"): # pragma: no cover (suitesparse 8) + set_function_name = f"{set_function_base}_CHAR" else: # pragma: no cover (sanity) raise ValueError(ctype) set_function = getattr(lib, set_function_name) @@ -154,9 +174,19 @@ def __setitem__(self, key, val): f"expected {size}, got {vals.size}: {val}" ) val_obj = ffi.from_buffer(ctype, vals) + elif ctype.startswith("char"): # pragma: no cover (suitesparse 8) + val_obj = ffi.new("char[]", val.encode()) else: val_obj = ffi.cast(ctype, val) - if self._parent is None: + if is_context: # pragma: no cover (suitesparse 8) + if self._context is None: + from .context import Context + + self._context = Context(engage=False) + self._context._engage() # Disengage when context goes out of scope + self._parent._context = self._context # Set context to descriptor + info = set_function(self._context._carg, key_obj, val_obj) + elif self._parent is None: info = set_function(key_obj, val_obj) else: info = set_function(self._parent._carg, key_obj, val_obj) @@ -174,7 +204,12 @@ def __len__(self): return len(self._options) def __repr__(self): - return "{" + ",\n ".join(f"{k!r}: {v!r}" for k, v in self.items()) + "}" + return ( + type(self).__name__ + + "({" + + ",\n ".join(f"{k!r}: {v!r}" for k, v in self.items()) + + "})" + ) def _ipython_key_completions_(self): # pragma: no cover (ipython) return list(self) diff --git a/graphblas/core/ss/descriptor.py b/graphblas/core/ss/descriptor.py index 2f7d11ffa..43553f5ea 100644 --- a/graphblas/core/ss/descriptor.py +++ b/graphblas/core/ss/descriptor.py @@ -90,7 +90,6 @@ class _DescriptorConfig(BaseConfig): "sort": False, "secure_import": False, } - _count = 0 def __init__(self): gb_obj = ffi_new("GrB_Descriptor*") diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index 64aa43a96..990d692b9 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -7,7 +7,8 @@ import graphblas as gb from ... import binary, monoid -from ...dtypes import _INDEX, BOOL, INT64, UINT64, _string_to_dtype, lookup_dtype +from ...dtypes import _INDEX, BOOL, INT64, UINT64, lookup_dtype +from ...dtypes._core import _string_to_dtype from ...exceptions import _error_code_lookup, check_status, check_status_carg from .. import NULL, _has_numba, ffi, lib from ..base import call diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py index 1babc556e..ff9e233eb 100644 --- a/graphblas/core/ss/vector.py +++ b/graphblas/core/ss/vector.py @@ -6,7 +6,8 @@ import graphblas as gb from ... import binary, monoid -from ...dtypes import _INDEX, INT64, UINT64, _string_to_dtype, lookup_dtype +from ...dtypes import _INDEX, INT64, UINT64, lookup_dtype +from ...dtypes._core import _string_to_dtype from ...exceptions import _error_code_lookup, check_status, check_status_carg from .. import NULL, ffi, lib from ..base import call diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index d2ddee372..cd5b992ba 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -612,7 +612,7 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): else: if opts: # Ignore opts for now - descriptor_lookup(**opts) + desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context) rv = Vector._from_obj(ffi_new("GrB_Vector*"), self.dtype, self._size, name=name) call("GrB_Vector_dup", [_Pointer(rv), self]) return rv @@ -1757,7 +1757,7 @@ def _extract_element( result = Scalar(dtype, is_cscalar=is_cscalar, name=name) if opts: # Ignore opts for now - descriptor_lookup(**opts) + desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context) if is_cscalar: dtype_name = "UDT" if dtype._is_udt else dtype.name if ( @@ -2177,6 +2177,9 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): if clear: if dtype is None: dtype = self.dtype + if opts: + # Ignore opts for now + desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context) return self.output_type(dtype, *self.shape, name=name) return self.new(dtype, mask=mask, name=name, **opts) diff --git a/graphblas/dtypes/__init__.py b/graphblas/dtypes/__init__.py new file mode 100644 index 000000000..0d26a44a0 --- /dev/null +++ b/graphblas/dtypes/__init__.py @@ -0,0 +1,43 @@ +from ._core import ( + _INDEX, + BOOL, + FP32, + FP64, + INT8, + INT16, + INT32, + INT64, + UINT8, + UINT16, + UINT32, + UINT64, + DataType, + _supports_complex, + lookup_dtype, + register_anonymous, + register_new, + unify, +) + +if _supports_complex: + from ._core import FC32, FC64 + + +def __dir__(): + return globals().keys() | {"ss"} + + +def __getattr__(key): + if key == "ss": + from .. import backend + + if backend != "suitesparse": + raise AttributeError( + f'module {__name__!r} only has attribute "ss" when backend is "suitesparse"' + ) + from importlib import import_module + + ss = import_module(".ss", __name__) + globals()["ss"] = ss + return ss + raise AttributeError(f"module {__name__!r} has no attribute {key!r}") diff --git a/graphblas/dtypes.py b/graphblas/dtypes/_core.py similarity index 69% rename from graphblas/dtypes.py rename to graphblas/dtypes/_core.py index 61b297c13..345c1be81 100644 --- a/graphblas/dtypes.py +++ b/graphblas/dtypes/_core.py @@ -1,20 +1,16 @@ -import warnings as _warnings +import warnings -import numpy as _np -from numpy import promote_types as _promote_types -from numpy import result_type as _result_type +import numpy as np +from numpy import promote_types, result_type -from . import backend -from .core import NULL as _NULL -from .core import _has_numba -from .core import ffi as _ffi -from .core import lib as _lib +from .. import backend, dtypes +from ..core import NULL, _has_numba, ffi, lib if _has_numba: - import numba as _numba + import numba # Default assumption unless FC32/FC64 are found in lib -_supports_complex = hasattr(_lib, "GrB_FC64") or hasattr(_lib, "GxB_FC64") +_supports_complex = hasattr(lib, "GrB_FC64") or hasattr(lib, "GxB_FC64") class DataType: @@ -26,7 +22,7 @@ def __init__(self, name, gb_obj, gb_name, c_type, numba_type, np_type): self.gb_name = gb_name self.c_type = c_type self.numba_type = numba_type - self.np_type = _np.dtype(np_type) + self.np_type = np.dtype(np_type) def __repr__(self): return self.name @@ -62,7 +58,7 @@ def _carg(self): @property def _is_anonymous(self): - return globals().get(self.name) is not self + return getattr(dtypes, self.name, None) is not self @property def _is_udt(self): @@ -80,27 +76,29 @@ def _deserialize(name, dtype, is_anonymous): def register_new(name, dtype): if not name.isidentifier(): raise ValueError(f"`name` argument must be a valid Python identifier; got: {name!r}") - if name in _registry or name in globals(): + if name in _registry or hasattr(dtypes, name): raise ValueError(f"{name!r} name for dtype is unavailable") rv = register_anonymous(dtype, name) _registry[name] = rv - globals()[name] = rv + setattr(dtypes, name, rv) return rv def register_anonymous(dtype, name=None): try: - dtype = _np.dtype(dtype) + dtype = np.dtype(dtype) except TypeError: if isinstance(dtype, dict): # Allow dtypes such as `{'x': int, 'y': float}` for convenience - dtype = _np.dtype([(key, lookup_dtype(val).np_type) for key, val in dtype.items()]) + dtype = np.dtype( + [(key, lookup_dtype(val).np_type) for key, val in dtype.items()], align=True + ) elif isinstance(dtype, str) and "[" in dtype and dtype.endswith("]"): # Allow dtypes such as `"INT64[3, 4]"` for convenience base_dtype, shape = dtype.split("[", 1) base_dtype = lookup_dtype(base_dtype) - shape = _np.lib.format.safe_eval(f"[{shape}") - dtype = _np.dtype((base_dtype.np_type, shape)) + shape = np.lib.format.safe_eval(f"[{shape}") + dtype = np.dtype((base_dtype.np_type, shape)) else: raise if dtype in _registry: @@ -114,36 +112,36 @@ def register_anonymous(dtype, name=None): if dtype.hasobject: raise ValueError("dtype must not allow Python objects") - from .exceptions import check_status_carg + from ..exceptions import check_status_carg - gb_obj = _ffi.new("GrB_Type*") + gb_obj = ffi.new("GrB_Type*") if backend == "suitesparse": # We name this so that we can serialize and deserialize UDTs # We don't yet have C definitions np_repr = _dtype_to_string(dtype).encode() - if len(np_repr) > _lib.GxB_MAX_NAME_LEN: + if len(np_repr) > lib.GxB_MAX_NAME_LEN: msg = ( f"UDT repr is too large to serialize ({len(repr(dtype).encode())} > " - f"{_lib.GxB_MAX_NAME_LEN})." + f"{lib.GxB_MAX_NAME_LEN})." ) if name is not None: - np_repr = name.encode()[: _lib.GxB_MAX_NAME_LEN] + np_repr = name.encode()[: lib.GxB_MAX_NAME_LEN] else: - np_repr = np_repr[: _lib.GxB_MAX_NAME_LEN] - _warnings.warn( + np_repr = np_repr[: lib.GxB_MAX_NAME_LEN] + warnings.warn( f"{msg}. It will use the following name, " f"and the dtype may need to be specified when deserializing: {np_repr}", stacklevel=2, ) - status = _lib.GxB_Type_new(gb_obj, dtype.itemsize, np_repr, _NULL) + status = lib.GxB_Type_new(gb_obj, dtype.itemsize, np_repr, NULL) else: - status = _lib.GrB_Type_new(gb_obj, dtype.itemsize) + status = lib.GrB_Type_new(gb_obj, dtype.itemsize) check_status_carg(status, "Type", gb_obj[0]) # For now, let's use "opaque" unsigned bytes for the c type. if name is None: name = _default_name(dtype) - numba_type = _numba.typeof(dtype).dtype if _has_numba else None + numba_type = numba.typeof(dtype).dtype if _has_numba else None rv = DataType(name, gb_obj, None, f"uint8_t[{dtype.itemsize}]", numba_type, dtype) _registry[gb_obj] = rv _registry[dtype] = rv @@ -155,153 +153,153 @@ def register_anonymous(dtype, name=None): BOOL = DataType( "BOOL", - _lib.GrB_BOOL, + lib.GrB_BOOL, "GrB_BOOL", "_Bool", - _numba.types.bool_ if _has_numba else None, - _np.bool_, + numba.types.bool_ if _has_numba else None, + np.bool_, ) INT8 = DataType( - "INT8", _lib.GrB_INT8, "GrB_INT8", "int8_t", _numba.types.int8 if _has_numba else None, _np.int8 + "INT8", lib.GrB_INT8, "GrB_INT8", "int8_t", numba.types.int8 if _has_numba else None, np.int8 ) UINT8 = DataType( "UINT8", - _lib.GrB_UINT8, + lib.GrB_UINT8, "GrB_UINT8", "uint8_t", - _numba.types.uint8 if _has_numba else None, - _np.uint8, + numba.types.uint8 if _has_numba else None, + np.uint8, ) INT16 = DataType( "INT16", - _lib.GrB_INT16, + lib.GrB_INT16, "GrB_INT16", "int16_t", - _numba.types.int16 if _has_numba else None, - _np.int16, + numba.types.int16 if _has_numba else None, + np.int16, ) UINT16 = DataType( "UINT16", - _lib.GrB_UINT16, + lib.GrB_UINT16, "GrB_UINT16", "uint16_t", - _numba.types.uint16 if _has_numba else None, - _np.uint16, + numba.types.uint16 if _has_numba else None, + np.uint16, ) INT32 = DataType( "INT32", - _lib.GrB_INT32, + lib.GrB_INT32, "GrB_INT32", "int32_t", - _numba.types.int32 if _has_numba else None, - _np.int32, + numba.types.int32 if _has_numba else None, + np.int32, ) UINT32 = DataType( "UINT32", - _lib.GrB_UINT32, + lib.GrB_UINT32, "GrB_UINT32", "uint32_t", - _numba.types.uint32 if _has_numba else None, - _np.uint32, + numba.types.uint32 if _has_numba else None, + np.uint32, ) INT64 = DataType( "INT64", - _lib.GrB_INT64, + lib.GrB_INT64, "GrB_INT64", "int64_t", - _numba.types.int64 if _has_numba else None, - _np.int64, + numba.types.int64 if _has_numba else None, + np.int64, ) # _Index (like UINT64) is for internal use only and shouldn't be exposed to the user _INDEX = DataType( "UINT64", - _lib.GrB_UINT64, + lib.GrB_UINT64, "GrB_Index", "GrB_Index", - _numba.types.uint64 if _has_numba else None, - _np.uint64, + numba.types.uint64 if _has_numba else None, + np.uint64, ) UINT64 = DataType( "UINT64", - _lib.GrB_UINT64, + lib.GrB_UINT64, "GrB_UINT64", "uint64_t", - _numba.types.uint64 if _has_numba else None, - _np.uint64, + numba.types.uint64 if _has_numba else None, + np.uint64, ) FP32 = DataType( "FP32", - _lib.GrB_FP32, + lib.GrB_FP32, "GrB_FP32", "float", - _numba.types.float32 if _has_numba else None, - _np.float32, + numba.types.float32 if _has_numba else None, + np.float32, ) FP64 = DataType( "FP64", - _lib.GrB_FP64, + lib.GrB_FP64, "GrB_FP64", "double", - _numba.types.float64 if _has_numba else None, - _np.float64, + numba.types.float64 if _has_numba else None, + np.float64, ) -if _supports_complex and hasattr(_lib, "GxB_FC32"): +if _supports_complex and hasattr(lib, "GxB_FC32"): FC32 = DataType( "FC32", - _lib.GxB_FC32, + lib.GxB_FC32, "GxB_FC32", "float _Complex", - _numba.types.complex64 if _has_numba else None, - _np.complex64, + numba.types.complex64 if _has_numba else None, + np.complex64, ) -if _supports_complex and hasattr(_lib, "GrB_FC32"): # pragma: no cover (unused) +if _supports_complex and hasattr(lib, "GrB_FC32"): # pragma: no cover (unused) FC32 = DataType( "FC32", - _lib.GrB_FC32, + lib.GrB_FC32, "GrB_FC32", "float _Complex", - _numba.types.complex64 if _has_numba else None, - _np.complex64, + numba.types.complex64 if _has_numba else None, + np.complex64, ) -if _supports_complex and hasattr(_lib, "GxB_FC64"): +if _supports_complex and hasattr(lib, "GxB_FC64"): FC64 = DataType( "FC64", - _lib.GxB_FC64, + lib.GxB_FC64, "GxB_FC64", "double _Complex", - _numba.types.complex128 if _has_numba else None, - _np.complex128, + numba.types.complex128 if _has_numba else None, + np.complex128, ) -if _supports_complex and hasattr(_lib, "GrB_FC64"): # pragma: no cover (unused) +if _supports_complex and hasattr(lib, "GrB_FC64"): # pragma: no cover (unused) FC64 = DataType( "FC64", - _lib.GrB_FC64, + lib.GrB_FC64, "GrB_FC64", "double _Complex", - _numba.types.complex128 if _has_numba else None, - _np.complex128, + numba.types.complex128 if _has_numba else None, + np.complex128, ) # Used for testing user-defined functions _sample_values = { - INT8: _np.int8(1), - UINT8: _np.uint8(1), - INT16: _np.int16(1), - UINT16: _np.uint16(1), - INT32: _np.int32(1), - UINT32: _np.uint32(1), - INT64: _np.int64(1), - UINT64: _np.uint64(1), - FP32: _np.float32(0.5), - FP64: _np.float64(0.5), - BOOL: _np.bool_(True), + INT8: np.int8(1), + UINT8: np.uint8(1), + INT16: np.int16(1), + UINT16: np.uint16(1), + INT32: np.int32(1), + UINT32: np.uint32(1), + INT64: np.int64(1), + UINT64: np.uint64(1), + FP32: np.float32(0.5), + FP64: np.float64(0.5), + BOOL: np.bool_(True), } if _supports_complex: _sample_values.update( { - FC32: _np.complex64(complex(0, 0.5)), - FC64: _np.complex128(complex(0, 0.5)), + FC32: np.complex64(complex(0, 0.5)), + FC64: np.complex128(complex(0, 0.5)), } ) @@ -390,10 +388,10 @@ def unify(type1, type2, *, is_left_scalar=False, is_right_scalar=False): return type1 if is_left_scalar: if not is_right_scalar: - return lookup_dtype(_result_type(_np.array(0, type1.np_type), type2.np_type)) + return lookup_dtype(result_type(np.array(0, type1.np_type), type2.np_type)) elif is_right_scalar: - return lookup_dtype(_result_type(type1.np_type, _np.array(0, type2.np_type))) - return lookup_dtype(_promote_types(type1.np_type, type2.np_type)) + return lookup_dtype(result_type(type1.np_type, np.array(0, type2.np_type))) + return lookup_dtype(promote_types(type1.np_type, type2.np_type)) def _default_name(dtype): @@ -423,7 +421,7 @@ def _dtype_to_string(dtype): >>> dtype == new_dtype True """ - if isinstance(dtype, _np.dtype) and dtype not in _registry: + if isinstance(dtype, np.dtype) and dtype not in _registry: np_type = dtype else: dtype = lookup_dtype(dtype) @@ -432,11 +430,11 @@ def _dtype_to_string(dtype): np_type = dtype.np_type s = str(np_type) try: - if _np.dtype(_np.lib.format.safe_eval(s)) == np_type: # pragma: no branch (safety) + if np.dtype(np.lib.format.safe_eval(s)) == np_type: # pragma: no branch (safety) return s except Exception: pass - if _np.dtype(np_type.str) != np_type: # pragma: no cover (safety) + if np.dtype(np_type.str) != np_type: # pragma: no cover (safety) raise ValueError(f"Unable to reliably convert dtype to string and back: {dtype}") return repr(np_type.str) @@ -451,5 +449,5 @@ def _string_to_dtype(s): return lookup_dtype(s) except Exception: pass - np_type = _np.dtype(_np.lib.format.safe_eval(s)) + np_type = np.dtype(np.lib.format.safe_eval(s)) return lookup_dtype(np_type) diff --git a/graphblas/dtypes/ss.py b/graphblas/dtypes/ss.py new file mode 100644 index 000000000..e69de29bb diff --git a/graphblas/indexunary/__init__.py b/graphblas/indexunary/__init__.py index 472231597..a3cb06608 100644 --- a/graphblas/indexunary/__init__.py +++ b/graphblas/indexunary/__init__.py @@ -4,7 +4,7 @@ def __dir__(): - return globals().keys() | _delayed.keys() + return globals().keys() | _delayed.keys() | {"ss"} def __getattr__(key): @@ -13,6 +13,18 @@ def __getattr__(key): rv = func(**kwargs) globals()[key] = rv return rv + if key == "ss": + from .. import backend + + if backend != "suitesparse": + raise AttributeError( + f'module {__name__!r} only has attribute "ss" when backend is "suitesparse"' + ) + from importlib import import_module + + ss = import_module(".ss", __name__) + globals()["ss"] = ss + return ss raise AttributeError(f"module {__name__!r} has no attribute {key!r}") diff --git a/graphblas/indexunary/ss.py b/graphblas/indexunary/ss.py new file mode 100644 index 000000000..97852fc12 --- /dev/null +++ b/graphblas/indexunary/ss.py @@ -0,0 +1,5 @@ +from ..core import operator + +_delayed = {} + +del operator diff --git a/graphblas/io/_matrixmarket.py b/graphblas/io/_matrixmarket.py index 294bcfa1e..558605328 100644 --- a/graphblas/io/_matrixmarket.py +++ b/graphblas/io/_matrixmarket.py @@ -36,7 +36,6 @@ def mmread(source, engine="auto", *, dup_op=None, name=None, **kwargs): 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 engine = engine.lower() @@ -54,7 +53,7 @@ def mmread(source, engine="auto", *, dup_op=None, name=None, **kwargs): f'Bad engine value: {engine!r}. Must be "auto", "scipy", "fmm", or "fast_matrix_market"' ) array = mmread(source, **kwargs) - if isspmatrix_coo(array): + if getattr(array, "format", None) == "coo": nrows, ncols = array.shape return Matrix.from_coo( array.row, array.col, array.data, nrows=nrows, ncols=ncols, dup_op=dup_op, name=name @@ -105,13 +104,17 @@ def mmwrite( engine = engine.lower() if engine in {"auto", "fmm", "fast_matrix_market"}: try: - from fast_matrix_market import mmwrite # noqa: F811 + from fast_matrix_market import __version__, mmwrite # noqa: F811 except ImportError: # pragma: no cover (import) if engine != "auto": raise ImportError( "fast_matrix_market is required to write Matrix Market files " f'using the "{engine}" engine' ) from None + else: + import scipy as sp + + engine = "fast_matrix_market" elif engine != "scipy": raise ValueError( f'Bad engine value: {engine!r}. Must be "auto", "scipy", "fmm", or "fast_matrix_market"' @@ -120,6 +123,12 @@ def mmwrite( array = matrix.ss.export()["values"] else: array = to_scipy_sparse(matrix, format="coo") + if engine == "fast_matrix_market" and __version__ < "1.7." and sp.__version__ > "1.11.": + # 2023-06-25: scipy 1.11.0 added `sparray` and changed e.g. `ss.isspmatrix_coo`. + # fast_matrix_market updated to handle this in version 1.7.0 + # Also, it looks like fast_matrix_market has special writers for csr and csc; + # should we see if using those are faster? + array = sp.sparse.coo_matrix(array) # FLAKY COVERAGE mmwrite( target, array, diff --git a/graphblas/monoid/__init__.py b/graphblas/monoid/__init__.py index 007aba416..ed028c5d9 100644 --- a/graphblas/monoid/__init__.py +++ b/graphblas/monoid/__init__.py @@ -4,7 +4,7 @@ def __dir__(): - return globals().keys() | _delayed.keys() + return globals().keys() | _delayed.keys() | {"ss"} def __getattr__(key): @@ -17,6 +17,18 @@ def __getattr__(key): rv = func(**kwargs) globals()[key] = rv return rv + if key == "ss": + from .. import backend + + if backend != "suitesparse": + raise AttributeError( + f'module {__name__!r} only has attribute "ss" when backend is "suitesparse"' + ) + from importlib import import_module + + ss = import_module(".ss", __name__) + globals()["ss"] = ss + return ss raise AttributeError(f"module {__name__!r} has no attribute {key!r}") diff --git a/graphblas/monoid/ss.py b/graphblas/monoid/ss.py new file mode 100644 index 000000000..97852fc12 --- /dev/null +++ b/graphblas/monoid/ss.py @@ -0,0 +1,5 @@ +from ..core import operator + +_delayed = {} + +del operator diff --git a/graphblas/op/ss.py b/graphblas/op/ss.py index e45cbcda0..97852fc12 100644 --- a/graphblas/op/ss.py +++ b/graphblas/op/ss.py @@ -1,3 +1,5 @@ from ..core import operator +_delayed = {} + del operator diff --git a/graphblas/select/__init__.py b/graphblas/select/__init__.py index 72aa8d226..aaf8e12d0 100644 --- a/graphblas/select/__init__.py +++ b/graphblas/select/__init__.py @@ -8,7 +8,7 @@ def __dir__(): - return globals().keys() | _delayed.keys() + return globals().keys() | _delayed.keys() | {"ss"} def __getattr__(key): @@ -17,6 +17,18 @@ def __getattr__(key): rv = func(**kwargs) globals()[key] = rv return rv + if key == "ss": + from .. import backend + + if backend != "suitesparse": + raise AttributeError( + f'module {__name__!r} only has attribute "ss" when backend is "suitesparse"' + ) + from importlib import import_module + + ss = import_module(".ss", __name__) + globals()["ss"] = ss + return ss raise AttributeError(f"module {__name__!r} has no attribute {key!r}") diff --git a/graphblas/select/ss.py b/graphblas/select/ss.py new file mode 100644 index 000000000..97852fc12 --- /dev/null +++ b/graphblas/select/ss.py @@ -0,0 +1,5 @@ +from ..core import operator + +_delayed = {} + +del operator diff --git a/graphblas/semiring/ss.py b/graphblas/semiring/ss.py index e45cbcda0..97852fc12 100644 --- a/graphblas/semiring/ss.py +++ b/graphblas/semiring/ss.py @@ -1,3 +1,5 @@ from ..core import operator +_delayed = {} + del operator diff --git a/graphblas/ss/_core.py b/graphblas/ss/_core.py index ec5a89504..53287f1a5 100644 --- a/graphblas/ss/_core.py +++ b/graphblas/ss/_core.py @@ -2,6 +2,7 @@ from ..core import ffi, lib from ..core.base import _expect_type +from ..core.descriptor import lookup as descriptor_lookup from ..core.matrix import Matrix, TransposedMatrix from ..core.scalar import _as_scalar from ..core.ss.config import BaseConfig @@ -52,6 +53,9 @@ def diag(x, k=0, dtype=None, *, name=None, **opts): dtype = x.dtype typ = type(x) if typ is Vector: + if opts: + # Ignore opts for now + desc = descriptor_lookup(**opts) # noqa: F841 (keep desc in scope for context) size = x._size + abs(k.value) rv = Matrix(dtype, nrows=size, ncols=size, name=name) rv.ss.build_diag(x, k) @@ -120,7 +124,7 @@ class GlobalConfig(BaseConfig): memory_pool : List[int] burble : bool Enable diagnostic printing from SuiteSparse:GraphBLAS - print_1based: bool + print_1based : bool gpu_control : str, {"always", "never"} gpu_chunk : double diff --git a/graphblas/tests/conftest.py b/graphblas/tests/conftest.py index 0d1f4008a..ce9e6488f 100644 --- a/graphblas/tests/conftest.py +++ b/graphblas/tests/conftest.py @@ -1,4 +1,5 @@ import atexit +import contextlib import functools import itertools import platform @@ -114,6 +115,27 @@ def ic(): # pragma: no cover (debug) return icecream.ic +@contextlib.contextmanager +def burble(): # pragma: no cover (debug) + """Show the burble diagnostics within a context.""" + if gb.backend != "suitesparse": + yield + return + prev = gb.ss.config["burble"] + gb.ss.config["burble"] = True + try: + yield + finally: + gb.ss.config["burble"] = prev + + +@pytest.fixture(scope="session") +def burble_all(): # pragma: no cover (debug) + """Show the burble diagnostics for the entire test.""" + with burble(): + yield burble + + def autocompute(func): @functools.wraps(func) def inner(*args, **kwargs): diff --git a/graphblas/tests/test_dtype.py b/graphblas/tests/test_dtype.py index 66c19cce5..47a226313 100644 --- a/graphblas/tests/test_dtype.py +++ b/graphblas/tests/test_dtype.py @@ -123,7 +123,7 @@ def test_dtype_bad_comparison(): def test_dtypes_match_numpy(): - for key, val in dtypes._registry.items(): + for key, val in dtypes._core._registry.items(): try: if key is int or (isinstance(key, str) and key == "int"): # For win64, numpy treats int as int32, not int64 @@ -137,7 +137,7 @@ def test_dtypes_match_numpy(): def test_pickle(): - for val in dtypes._registry.values(): + for val in dtypes._core._registry.values(): s = pickle.dumps(val) val2 = pickle.loads(s) if val._is_udt: # pragma: no cover @@ -205,7 +205,7 @@ def test_auto_register(): def test_default_names(): - from graphblas.dtypes import _default_name + from graphblas.dtypes._core import _default_name assert _default_name(np.dtype([("x", np.int32), ("y", np.float64)], align=True)) == ( "{'x': INT32, 'y': FP64}" @@ -230,9 +230,9 @@ def test_dtype_to_from_string(): except Exception: pass for dtype in types: - s = dtypes._dtype_to_string(dtype) + s = dtypes._core._dtype_to_string(dtype) try: - dtype2 = dtypes._string_to_dtype(s) + dtype2 = dtypes._core._string_to_dtype(s) except Exception: with pytest.raises(ValueError, match="Unknown dtype"): lookup_dtype(dtype) @@ -253,3 +253,20 @@ def test_has_complex(): from packaging.version import parse assert dtypes._supports_complex == (parse(ssgb.__version__) >= parse("7.4.3.1")) + + +def test_has_ss_attribute(): + if suitesparse: + assert dtypes.ss is not None + else: + with pytest.raises(AttributeError): + dtypes.ss + + +def test_dir(): + must_have = {"DataType", "lookup_dtype", "register_anonymous", "register_new", "ss", "unify"} + must_have.update({"FP32", "FP64", "INT8", "INT16", "INT32", "INT64"}) + must_have.update({"BOOL", "UINT8", "UINT16", "UINT32", "UINT64"}) + if dtypes._supports_complex: + must_have.update({"FC32", "FC64"}) + assert set(dir(dtypes)) & must_have == must_have diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 671b12bd6..bf2ca2015 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -59,18 +59,24 @@ def test_vector_to_from_numpy(): csr = gb.io.to_scipy_sparse(v, "csr") assert csr.nnz == 2 - assert ss.isspmatrix_csr(csr) + # 2023-06-25: scipy 1.11.0 added `sparray` and changed e.g. `ss.isspmatrix_csr` + assert isinstance(csr, getattr(ss, "sparray", ss.spmatrix)) + assert csr.format == "csr" np.testing.assert_array_equal(csr.toarray(), np.array([[0.0, 2.0, 4.1]])) csc = gb.io.to_scipy_sparse(v, "csc") assert csc.nnz == 2 - assert ss.isspmatrix_csc(csc) + # 2023-06-25: scipy 1.11.0 added `sparray` and changed e.g. `ss.isspmatrix_csc` + assert isinstance(csc, getattr(ss, "sparray", ss.spmatrix)) + assert csc.format == "csc" np.testing.assert_array_equal(csc.toarray(), np.array([[0.0, 2.0, 4.1]]).T) # default to csr-like coo = gb.io.to_scipy_sparse(v, "coo") assert coo.shape == csr.shape - assert ss.isspmatrix_coo(coo) + # 2023-06-25: scipy 1.11.0 added `sparray` and changed e.g. `ss.isspmatrix_coo` + assert isinstance(coo, getattr(ss, "sparray", ss.spmatrix)) + assert coo.format == "coo" assert coo.nnz == 2 np.testing.assert_array_equal(coo.toarray(), np.array([[0.0, 2.0, 4.1]])) @@ -99,7 +105,9 @@ def test_matrix_to_from_numpy(): for format in ["csr", "csc", "coo"]: sparse = gb.io.to_scipy_sparse(M, format) - assert getattr(ss, f"isspmatrix_{format}")(sparse) + # 2023-06-25: scipy 1.11.0 added `sparray` and changed e.g. `ss.isspmatrix_csr` + assert isinstance(sparse, getattr(ss, "sparray", ss.spmatrix)) + assert sparse.format == format assert sparse.nnz == 3 np.testing.assert_array_equal(sparse.toarray(), a) M2 = gb.io.from_scipy_sparse(sparse) @@ -435,6 +443,7 @@ def test_awkward_errors(): @pytest.mark.skipif("not sparse") +@pytest.mark.slow def test_vector_to_from_pydata_sparse(): coords = np.array([0, 1, 2, 3, 4], dtype="int64") data = np.array([10, 20, 30, 40, 50], dtype="int64") @@ -448,6 +457,7 @@ def test_vector_to_from_pydata_sparse(): @pytest.mark.skipif("not sparse") +@pytest.mark.slow def test_matrix_to_from_pydata_sparse(): coords = np.array([[0, 1, 2, 3, 4], [0, 1, 2, 3, 4]], dtype="int64") data = np.array([10, 20, 30, 40, 50], dtype="int64") diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 26017f364..bc942bc49 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -4298,7 +4298,7 @@ def test_ss_descriptors(A): A(nthreads=4, axb_method="dot", sort=True) << A @ A assert A.isequal(C2) # Bad option should show list of valid options - with pytest.raises(ValueError, match="nthreads"): + with pytest.raises(ValueError, match="axb_method"): C1(bad_opt=True) << A with pytest.raises(ValueError, match="Duplicate descriptor"): (A @ A).new(nthreads=4, Nthreads=5) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index a80012ab7..b54ea76c4 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -1006,7 +1006,7 @@ def myplus(x, y): def test_create_semiring(): # stress test / sanity check - monoid_names = {x for x in dir(monoid) if not x.startswith("_")} + monoid_names = {x for x in dir(monoid) if not x.startswith("_") and x != "ss"} binary_names = {x for x in dir(binary) if not x.startswith("_") and x != "ss"} for monoid_name, binary_name in itertools.product(monoid_names, binary_names): cur_monoid = getattr(monoid, monoid_name) @@ -1433,6 +1433,7 @@ def test_deprecated(): import graphblas.core.agg # noqa: F401 +@pytest.mark.slow def test_is_idempotent(): assert monoid.min.is_idempotent assert monoid.max[int].is_idempotent @@ -1446,3 +1447,14 @@ def test_is_idempotent(): assert not monoid.numpy.equal.is_idempotent with pytest.raises(AttributeError): binary.min.is_idempotent + + +def test_ops_have_ss(): + modules = [unary, binary, monoid, semiring, indexunary, select, op] + if suitesparse: + for mod in modules: + assert mod.ss is not None + else: + for mod in modules: + with pytest.raises(AttributeError): + mod.ss diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index 36ab346b8..a1aabd183 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -1448,14 +1448,14 @@ def test_diag(v): expected = Matrix.from_coo(rows, cols, values, nrows=size, ncols=size, dtype=v.dtype) # Construct diagonal matrix A if suitesparse: - A = gb.ss.diag(v, k=k) + A = gb.ss.diag(v, k=k, nthreads=2) assert expected.isequal(A) A = v.diag(k) assert expected.isequal(A) # Extract diagonal from A if suitesparse: - w = gb.ss.diag(A, Scalar.from_value(k)) + w = gb.ss.diag(A, Scalar.from_value(k), nthreads=2) assert v.isequal(w) assert w.dtype == "INT64" @@ -1737,6 +1737,13 @@ def test_dup_expr(v): assert result.isequal(b) result = (b | b).dup(clear=True) assert result.isequal(b.dup(clear=True)) + result = v[:5].dup() + assert result.isequal(v[:5].new()) + if suitesparse: + result = v[:5].dup(nthreads=2) + assert result.isequal(v[:5].new()) + result = v[:5].dup(clear=True, nthreads=2) + assert result.isequal(Vector(v.dtype, size=5)) @pytest.mark.skipif("not suitesparse") diff --git a/graphblas/unary/ss.py b/graphblas/unary/ss.py index e45cbcda0..97852fc12 100644 --- a/graphblas/unary/ss.py +++ b/graphblas/unary/ss.py @@ -1,3 +1,5 @@ from ..core import operator +_delayed = {} + del operator diff --git a/pyproject.toml b/pyproject.toml index 9e57b8296..ddd718ef6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,6 +138,7 @@ packages = [ "graphblas.core", "graphblas.core.operator", "graphblas.core.ss", + "graphblas.dtypes", "graphblas.indexunary", "graphblas.io", "graphblas.monoid", @@ -311,6 +312,7 @@ ignore = [ # "SIM401", # Use dict.get ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) "TRY004", # Prefer `TypeError` exception for invalid type (Note: good advice, but not worth the nuisance) "TRY200", # Use `raise from` to specify exception cause (Note: sometimes okay to raise original exception) + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` (Note: no annotations yet) # Intentionally ignored "COM812", # Trailing comma missing @@ -322,6 +324,7 @@ ignore = [ "N806", # Variable ... in function should be lowercase "N807", # Function name should not start and end with `__` "N818", # Exception name ... should be named with an Error suffix (Note: good advice) + "PLR0124", # Name compared with itself, consider replacing `x == x` (Note: too strict) "PLR0911", # Too many return statements "PLR0912", # Too many branches "PLR0913", # Too many arguments to function call @@ -356,6 +359,7 @@ ignore = [ "TCH", # flake8-type-checking (Note: figure out type checking later) "ARG", # flake8-unused-arguments (Sometimes helpful, but too strict) "TD", # flake8-todos (Maybe okay to add some of these) + "FIX", # flake8-fixme (like flake8-todos) "ERA", # eradicate (We like code in comments!) "PD", # pandas-vet (Intended for scripts that use pandas, not libraries) ] diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index f0e648fd9..22f0b3cca 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -5,13 +5,13 @@ # Tip: add `--json` for more information. conda search 'numpy[channel=conda-forge]>=1.25.0' conda search 'pandas[channel=conda-forge]>=2.0.2' -conda search 'scipy[channel=conda-forge]>=1.10.1' +conda search 'scipy[channel=conda-forge]>=1.11.0' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.2.1' +conda search 'awkward[channel=conda-forge]>=2.2.4' conda search 'sparse[channel=conda-forge]>=0.14.0' -conda search 'fast_matrix_market[channel=conda-forge]>=1.6.0' -conda search 'numba[channel=conda-forge]>=0.57.0' +conda search 'fast_matrix_market[channel=conda-forge]>=1.7.2' +conda search 'numba[channel=conda-forge]>=0.57.1' conda search 'pyyaml[channel=conda-forge]>=6.0' -conda search 'flake8-bugbear[channel=conda-forge]>=23.5.9' +conda search 'flake8-bugbear[channel=conda-forge]>=23.6.5' conda search 'flake8-simplify[channel=conda-forge]>=0.20.0' # conda search 'python[channel=conda-forge]>=3.8 *pypy*' From da016617bd8413fe2dc28f6f9c041983b81512b2 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 28 Jun 2023 09:28:23 -0500 Subject: [PATCH 32/87] Update copyright to include contributors (#470) --- LICENSE | 2 +- docs/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 74a8ba6c6..935875c92 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 Anaconda, Inc + Copyright 2020-2023 Anaconda, Inc. and contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/docs/conf.py b/docs/conf.py index 3e1a8c85b..07a373203 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,7 +19,7 @@ # -- Project information ----------------------------------------------------- project = "python-graphblas" -copyright = "2022, Anaconda, Inc" +copyright = "2020-2023, Anaconda, Inc. and contributors" author = "Anaconda, Inc" # The full version, including alpha/beta/rc tags From 5e18a9c5dae8cecd40c6e08419cd30c54299db8f Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 28 Jun 2023 09:54:46 -0500 Subject: [PATCH 33/87] Clarify in docs that monoids are commutative and associative (#469) * Clarify in docs that monoids are commutative and associative --- docs/user_guide/operators.rst | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/user_guide/operators.rst b/docs/user_guide/operators.rst index 9499562f2..ec28e2fba 100644 --- a/docs/user_guide/operators.rst +++ b/docs/user_guide/operators.rst @@ -89,9 +89,12 @@ registered from numpy are located in ``graphblas.binary.numpy``. Monoids ------- -Monoids extend the concept of a binary operator to require a single domain for all inputs and -the output. Monoids are also associative, so the order of the inputs does not matter. And finally, -monoids have a default identity such that ``A op identity == A``. +Monoids extend the concept of a binary operator to require a single domain for all inputs and the output. +Monoids are also associative so the order of operations does not matter +(for example, ``(a + b) + c == a + (b + c)``). +GraphBLAS primarily uses *commutative monoids* (for example, ``a + b == b + a``), +and all standard monoids in python-graphblas commute. +And finally, monoids have a default identity such that ``A op identity == A``. Monoids are commonly for reductions, collapsing all elements down to a single value. From b7b25b7a26c54451a871be9d8488d76580e5c08f Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Thu, 29 Jun 2023 12:15:08 -0500 Subject: [PATCH 34/87] Move `dtypes._core` to `core.dtypes` (#479) * Move `dtypes._core` to `core.dtypes` I think this follows established patterns that we typically use. --- graphblas/{dtypes/_core.py => core/dtypes.py} | 0 graphblas/core/operator/binary.py | 2 +- graphblas/core/operator/indexunary.py | 2 +- graphblas/core/operator/unary.py | 2 +- graphblas/core/ss/matrix.py | 2 +- graphblas/core/ss/vector.py | 2 +- graphblas/dtypes/__init__.py | 4 ++-- graphblas/tests/test_dtype.py | 12 ++++++------ pyproject.toml | 2 +- 9 files changed, 14 insertions(+), 14 deletions(-) rename graphblas/{dtypes/_core.py => core/dtypes.py} (100%) diff --git a/graphblas/dtypes/_core.py b/graphblas/core/dtypes.py similarity index 100% rename from graphblas/dtypes/_core.py rename to graphblas/core/dtypes.py diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py index 434ad91cb..88191c39b 100644 --- a/graphblas/core/operator/binary.py +++ b/graphblas/core/operator/binary.py @@ -22,9 +22,9 @@ _supports_complex, lookup_dtype, ) -from ...dtypes._core import _sample_values from ...exceptions import UdfParseError, check_status_carg from .. import _has_numba, _supports_udfs, ffi, lib +from ..dtypes import _sample_values from ..expr import InfixExprBase from .base import ( _SS_OPERATORS, diff --git a/graphblas/core/operator/indexunary.py b/graphblas/core/operator/indexunary.py index 8b1211258..b5351e916 100644 --- a/graphblas/core/operator/indexunary.py +++ b/graphblas/core/operator/indexunary.py @@ -4,9 +4,9 @@ from ... import _STANDARD_OPERATOR_NAMES, indexunary, select from ...dtypes import BOOL, FP64, INT8, INT64, UINT64, lookup_dtype -from ...dtypes._core import _sample_values from ...exceptions import UdfParseError, check_status_carg from .. import _has_numba, ffi, lib +from ..dtypes import _sample_values from .base import OpBase, ParameterizedUdf, TypedOpBase, _call_op, _deserialize_parameterized if _has_numba: diff --git a/graphblas/core/operator/unary.py b/graphblas/core/operator/unary.py index 11ada4e48..437334ccc 100644 --- a/graphblas/core/operator/unary.py +++ b/graphblas/core/operator/unary.py @@ -18,9 +18,9 @@ _supports_complex, lookup_dtype, ) -from ...dtypes._core import _sample_values from ...exceptions import UdfParseError, check_status_carg from .. import _has_numba, ffi, lib +from ..dtypes import _sample_values from ..utils import output_type from .base import ( _SS_OPERATORS, diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index 990d692b9..56c28f52f 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -8,10 +8,10 @@ from ... import binary, monoid from ...dtypes import _INDEX, BOOL, INT64, UINT64, lookup_dtype -from ...dtypes._core import _string_to_dtype from ...exceptions import _error_code_lookup, check_status, check_status_carg from .. import NULL, _has_numba, ffi, lib from ..base import call +from ..dtypes import _string_to_dtype from ..operator import get_typed_op from ..scalar import Scalar, _as_scalar, _scalar_index from ..utils import ( diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py index ff9e233eb..a8bff4ee5 100644 --- a/graphblas/core/ss/vector.py +++ b/graphblas/core/ss/vector.py @@ -7,10 +7,10 @@ from ... import binary, monoid from ...dtypes import _INDEX, INT64, UINT64, lookup_dtype -from ...dtypes._core import _string_to_dtype from ...exceptions import _error_code_lookup, check_status, check_status_carg from .. import NULL, ffi, lib from ..base import call +from ..dtypes import _string_to_dtype from ..operator import get_typed_op from ..scalar import Scalar, _as_scalar from ..utils import ( diff --git a/graphblas/dtypes/__init__.py b/graphblas/dtypes/__init__.py index 0d26a44a0..49e46d787 100644 --- a/graphblas/dtypes/__init__.py +++ b/graphblas/dtypes/__init__.py @@ -1,4 +1,4 @@ -from ._core import ( +from ..core.dtypes import ( _INDEX, BOOL, FP32, @@ -20,7 +20,7 @@ ) if _supports_complex: - from ._core import FC32, FC64 + from ..core.dtypes import FC32, FC64 def __dir__(): diff --git a/graphblas/tests/test_dtype.py b/graphblas/tests/test_dtype.py index 47a226313..5797dda10 100644 --- a/graphblas/tests/test_dtype.py +++ b/graphblas/tests/test_dtype.py @@ -7,7 +7,7 @@ import pytest import graphblas as gb -from graphblas import dtypes +from graphblas import core, dtypes from graphblas.core import lib from graphblas.dtypes import lookup_dtype @@ -123,7 +123,7 @@ def test_dtype_bad_comparison(): def test_dtypes_match_numpy(): - for key, val in dtypes._core._registry.items(): + for key, val in core.dtypes._registry.items(): try: if key is int or (isinstance(key, str) and key == "int"): # For win64, numpy treats int as int32, not int64 @@ -137,7 +137,7 @@ def test_dtypes_match_numpy(): def test_pickle(): - for val in dtypes._core._registry.values(): + for val in core.dtypes._registry.values(): s = pickle.dumps(val) val2 = pickle.loads(s) if val._is_udt: # pragma: no cover @@ -205,7 +205,7 @@ def test_auto_register(): def test_default_names(): - from graphblas.dtypes._core import _default_name + from graphblas.core.dtypes import _default_name assert _default_name(np.dtype([("x", np.int32), ("y", np.float64)], align=True)) == ( "{'x': INT32, 'y': FP64}" @@ -230,9 +230,9 @@ def test_dtype_to_from_string(): except Exception: pass for dtype in types: - s = dtypes._core._dtype_to_string(dtype) + s = core.dtypes._dtype_to_string(dtype) try: - dtype2 = dtypes._core._string_to_dtype(s) + dtype2 = core.dtypes._string_to_dtype(s) except Exception: with pytest.raises(ValueError, match="Unknown dtype"): lookup_dtype(dtype) diff --git a/pyproject.toml b/pyproject.toml index ddd718ef6..8d3f0b213 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -196,7 +196,7 @@ filterwarnings = [ # This deprecation warning was added in setuptools v67.5.0 (8 Mar 2023). See: # https://setuptools.pypa.io/en/latest/history.html#v67-5-0 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pkg_resources", + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:", # sre_parse deprecated in 3.11; this is triggered by awkward 0.10 "ignore:module 'sre_parse' is deprecated:DeprecationWarning:", From e3118dadf1abfde29d2ec3360cfd5de81420a39b Mon Sep 17 00:00:00 2001 From: Sultan Orazbayev Date: Wed, 5 Jul 2023 14:54:54 +0000 Subject: [PATCH 35/87] Update README.md (#442) * Update README.md Follows up on #438: >Regarding "comparing to similar packages in the ecosystem", I think it would actually be informative to compare to e.g. scipy.sparse, networkx, and igraph. I think such a comparison could go in the README and/or documentation. * Update README.md * Update README.md Co-authored-by: Erik Welch --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 13067df6e..3756fbb0c 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,22 @@ w # indexes=[0, 1, 3], values=[1, 3, 3] ``` Similar methods exist for BinaryOp, Monoid, and Semiring. +## Relation to other network analysis libraries +Python-graphblas aims to provide an efficient and consistent expression +of graph operations using linear algebra. This allows the development of +high-performance implementations of existing and new graph algorithms +(also see [`graphblas-algorithms`](https://github.com/python-graphblas/graphblas-algorithms)). + +While end-to-end analysis can be done using `python-graphblas`, users +might find that other libraries in the Python ecosystem provide a more +convenient high-level interface for data pre-processing and transformation +(e.g. `pandas`, `scipy.sparse`), visualization (e.g. `networkx`, `igraph`), +interactive exploration and analysis (e.g. `networkx`, `igraph`) or for +algorithms that are not (yet) implemented in `graphblas-algorithms` (e.g. +`networkx`, `igraph`, `scipy.sparse.csgraph`). To facilitate communication with +other libraries, `graphblas.io` contains multiple connectors, see the +following section. + ## Import/Export connectors to the Python ecosystem `graphblas.io` contains functions for converting to and from: ```python From 79cf3fadc281e21a6fcbffd02c3bb0ab5d21060a Mon Sep 17 00:00:00 2001 From: Sultan Orazbayev Date: Wed, 5 Jul 2023 14:55:41 +0000 Subject: [PATCH 36/87] doc: update io.rst for awkward array (#457) * Update io.rst As raised in #436 * Update documentation for `awkward-array`-related io functions. * Update the doc string and change the order of the functions to reflect their intended use case. remove trailing blanks * Use double backticks --- docs/api_reference/io.rst | 17 ++++++ graphblas/io/_awkward.py | 110 ++++++++++++++++++++------------------ 2 files changed, 75 insertions(+), 52 deletions(-) diff --git a/docs/api_reference/io.rst b/docs/api_reference/io.rst index 1b42c0648..e8f1748fd 100644 --- a/docs/api_reference/io.rst +++ b/docs/api_reference/io.rst @@ -49,6 +49,23 @@ These methods require `scipy `_ to be installed. .. autofunction:: graphblas.io.mmwrite +Awkward Array +~~~~~~~~~~~~~ + +`Awkward Array `_ is a library for nested, +variable-sized data, including arbitrary-length lists, records, mixed types, +and missing data, using NumPy-like idioms. Note that the intended use of the +``awkward-array``-related ``io`` functions is to convert ``graphblas`` objects to awkward, +perform necessary computations/transformations and, if required, convert the +awkward array back to ``graphblas`` format. To facilitate this conversion process, +``graphblas.io.to_awkward`` adds top-level attribute ``format``, describing the +format of the ``graphblas`` object (this attributed is used by the +``graphblas.io.from_awkward`` function to reconstruct the ``graphblas`` object). + +.. autofunction:: graphblas.io.to_awkward + +.. autofunction:: graphblas.io.from_awkward + Visualization ~~~~~~~~~~~~~ diff --git a/graphblas/io/_awkward.py b/graphblas/io/_awkward.py index 3119bdf3b..6c476817f 100644 --- a/graphblas/io/_awkward.py +++ b/graphblas/io/_awkward.py @@ -7,58 +7,6 @@ _AwkwardDoublyCompressedMatrix = None -def from_awkward(A, *, name=None): - """Create a Matrix or Vector from an Awkward Array. - - The Awkward Array must have top-level parameters: format, shape - - The Awkward Array must have top-level attributes based on format: - - vec/csr/csc: values, indices - - hypercsr/hypercsc: values, indices, offset_labels - - Parameters - ---------- - A : awkward.Array - Awkward Array with values and indices - name : str, optional - Name of resulting Matrix or Vector - - Returns - ------- - Vector or Matrix - """ - params = A.layout.parameters - if missing := {"format", "shape"} - params.keys(): - raise ValueError(f"Missing parameters: {missing}") - format = params["format"] - shape = params["shape"] - - if len(shape) == 1: - if format != "vec": - raise ValueError(f"Invalid format for Vector: {format}") - return Vector.from_coo( - A.indices.layout.data, A.values.layout.data, size=shape[0], name=name - ) - nrows, ncols = shape - values = A.values.layout.content.data - indptr = A.values.layout.offsets.data - if format == "csr": - cols = A.indices.layout.content.data - return Matrix.from_csr(indptr, cols, values, ncols=ncols, name=name) - if format == "csc": - rows = A.indices.layout.content.data - return Matrix.from_csc(indptr, rows, values, nrows=nrows, name=name) - if format == "hypercsr": - rows = A.offset_labels.layout.data - cols = A.indices.layout.content.data - return Matrix.from_dcsr(rows, indptr, cols, values, nrows=nrows, ncols=ncols, name=name) - if format == "hypercsc": - cols = A.offset_labels.layout.data - rows = A.indices.layout.content.data - return Matrix.from_dcsc(cols, indptr, rows, values, nrows=nrows, ncols=ncols, name=name) - raise ValueError(f"Invalid format for Matrix: {format}") - - def to_awkward(A, format=None): """Create an Awkward Array from a GraphBLAS Matrix. @@ -179,3 +127,61 @@ def indices(self): # pragma: no branch (???) if classname: ret = ak.with_name(ret, classname) return ret + + +def from_awkward(A, *, name=None): + """Create a Matrix or Vector from an Awkward Array. + + The Awkward Array must have top-level parameters: format, shape + + The Awkward Array must have top-level attributes based on format: + - vec/csr/csc: values, indices + - hypercsr/hypercsc: values, indices, offset_labels + + Parameters + ---------- + A : awkward.Array + Awkward Array with values and indices + name : str, optional + Name of resulting Matrix or Vector + + Returns + ------- + Vector or Matrix + + Note: the intended purpose of this function is to facilitate + conversion of an `awkward-array` that was created via `to_awkward` + function. If attempting to convert an arbitrary `awkward-array`, + make sure that the top-level attributes and parameters contain + the expected values. + """ + params = A.layout.parameters + if missing := {"format", "shape"} - params.keys(): + raise ValueError(f"Missing parameters: {missing}") + format = params["format"] + shape = params["shape"] + + if len(shape) == 1: + if format != "vec": + raise ValueError(f"Invalid format for Vector: {format}") + return Vector.from_coo( + A.indices.layout.data, A.values.layout.data, size=shape[0], name=name + ) + nrows, ncols = shape + values = A.values.layout.content.data + indptr = A.values.layout.offsets.data + if format == "csr": + cols = A.indices.layout.content.data + return Matrix.from_csr(indptr, cols, values, ncols=ncols, name=name) + if format == "csc": + rows = A.indices.layout.content.data + return Matrix.from_csc(indptr, rows, values, nrows=nrows, name=name) + if format == "hypercsr": + rows = A.offset_labels.layout.data + cols = A.indices.layout.content.data + return Matrix.from_dcsr(rows, indptr, cols, values, nrows=nrows, ncols=ncols, name=name) + if format == "hypercsc": + cols = A.offset_labels.layout.data + rows = A.indices.layout.content.data + return Matrix.from_dcsc(cols, indptr, rows, values, nrows=nrows, ncols=ncols, name=name) + raise ValueError(f"Invalid format for Matrix: {format}") From f14cbace199a33b10ea010ba5cdcb5b3b5037ac9 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 5 Jul 2023 17:26:30 -0500 Subject: [PATCH 37/87] Update to support SuiteSparse:GraphBLAS 7 and 8 (#456) --- .github/workflows/test_and_build.yml | 36 ++-- .pre-commit-config.yaml | 6 +- docs/env.yml | 2 +- graphblas/binary/ss.py | 1 + graphblas/core/dtypes.py | 2 +- graphblas/core/ss/__init__.py | 3 + graphblas/core/ss/binary.py | 72 +++++++ graphblas/core/ss/config.py | 16 +- graphblas/core/ss/context.py | 146 +++++++++++++++ graphblas/core/ss/descriptor.py | 27 ++- graphblas/core/ss/dtypes.py | 88 +++++++++ graphblas/core/ss/indexunary.py | 77 ++++++++ graphblas/core/ss/select.py | 45 +++++ graphblas/core/ss/unary.py | 62 ++++++ graphblas/dtypes/ss.py | 1 + graphblas/indexunary/ss.py | 1 + graphblas/select/ss.py | 1 + graphblas/ss/__init__.py | 6 +- graphblas/ss/_core.py | 64 ++++++- graphblas/tests/test_ssjit.py | 269 +++++++++++++++++++++++++++ graphblas/unary/ss.py | 1 + pyproject.toml | 5 +- scripts/check_versions.sh | 6 +- 23 files changed, 890 insertions(+), 47 deletions(-) create mode 100644 graphblas/core/ss/binary.py create mode 100644 graphblas/core/ss/context.py create mode 100644 graphblas/core/ss/dtypes.py create mode 100644 graphblas/core/ss/indexunary.py create mode 100644 graphblas/core/ss/select.py create mode 100644 graphblas/core/ss/unary.py create mode 100644 graphblas/tests/test_ssjit.py diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 209060521..d93b4c25c 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -131,9 +131,9 @@ jobs: source upstream weights: | - 1000000 - 1000000 - 1000000 + 1 + 1 + 1 1 - name: Setup mamba uses: conda-incubator/setup-miniconda@v2 @@ -175,22 +175,22 @@ jobs: npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') 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", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", ""]))') else # Python 3.11 npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", ""]))') fi if [[ ${{ steps.sourcetype.outputs.selected }} == "source" || ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then # TODO: there are currently issues with some numpy versions when @@ -204,13 +204,13 @@ jobs: # But, it's still useful for us to test with different versions! psg="" if [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2"]))') + psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2", "=8.0.2.1", ""]))') psg=python-suitesparse-graphblas${psgver} elif [[ ${{ steps.sourcetype.outputs.selected}} == "wheel" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2"]))') + psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2", "==8.0.2.1", ""]))') elif [[ ${{ steps.sourcetype.outputs.selected}} == "source" ]] ; then # These should be exact versions - psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2"]))') + psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", "==8.0.2.1", ""]))') else psgver="" fi @@ -260,17 +260,18 @@ jobs: numba=numba${numbaver} sparse=sparse${sparsever} fi - echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psgver${psgver}" + echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psg${psgver}" set -x # echo on - $(command -v mamba || command -v conda) install packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli \ + $(command -v mamba || command -v conda) install packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli c-compiler make \ pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \ networkx${nxver} ${numba} ${fmm} ${psg} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ ${{ matrix.slowtask == 'notebooks' && 'matplotlib nbconvert jupyter "ipython>=7"' || '' }} \ ${{ steps.sourcetype.outputs.selected == 'upstream' && 'cython' || '' }} \ - ${{ steps.sourcetype.outputs.selected != 'wheel' && '"graphblas=7.4"' || '' }} \ - ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'pypy' || '' }} + ${{ steps.sourcetype.outputs.selected != 'wheel' && '"graphblas>=7.4"' || '' }} \ + ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'pypy' || '' }} \ + ${{ matrix.os == 'windows-latest' && 'cmake' || 'm4' }} - name: Build extension module run: | if [[ ${{ steps.sourcetype.outputs.selected }} == "wheel" ]]; then @@ -291,6 +292,12 @@ jobs: pip install --no-deps git+https://github.com/GraphBLAS/python-suitesparse-graphblas.git@main#egg=suitesparse-graphblas fi pip install --no-deps -e . + - name: python-suitesparse-graphblas tests + run: | + # Don't use our conftest.py ; allow `test_print_jit_config` to fail if it doesn't exist + (cd .. + pytest --pyargs suitesparse_graphblas -s -k test_print_jit_config || true + pytest -v --pyargs suitesparse_graphblas) - name: Unit tests run: | A=${{ needs.rngs.outputs.mapnumpy == 'A' || '' }} ; B=${{ needs.rngs.outputs.mapnumpy == 'B' || '' }} @@ -318,7 +325,6 @@ jobs: if [[ $H && $normal ]] ; then if [[ $macos ]] ; then echo " $vanilla" ; elif [[ $windows ]] ; then echo " $suitesparse" ; fi ; fi)$( \ if [[ $H && $bizarro ]] ; then if [[ $macos ]] ; then echo " $suitesparse" ; elif [[ $windows ]] ; then echo " $vanilla" ; fi ; fi) echo ${args} - (cd .. && pytest -v --pyargs suitesparse_graphblas) # Don't use our conftest.py set -x # echo on coverage run -m pytest --color=yes --randomly -v ${args} \ ${{ matrix.slowtask == 'pytest_normal' && '--runslow' || '' }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f0ca307e8..726538e16 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it - repo: https://github.com/asottile/pyupgrade - rev: v3.7.0 + rev: v3.8.0 hooks: - id: pyupgrade args: [--py38-plus] @@ -66,7 +66,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.275 + rev: v0.0.277 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.275 + rev: v0.0.277 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/docs/env.yml b/docs/env.yml index 3636cfa2d..c0c4c8999 100644 --- a/docs/env.yml +++ b/docs/env.yml @@ -8,7 +8,7 @@ dependencies: # python-graphblas dependencies - donfig - numba - - python-suitesparse-graphblas>=7.4.0.0,<8 + - python-suitesparse-graphblas>=7.4.0.0 - pyyaml # extra dependencies - matplotlib diff --git a/graphblas/binary/ss.py b/graphblas/binary/ss.py index 97852fc12..0c294e322 100644 --- a/graphblas/binary/ss.py +++ b/graphblas/binary/ss.py @@ -1,4 +1,5 @@ from ..core import operator +from ..core.ss.binary import register_new # noqa: F401 _delayed = {} diff --git a/graphblas/core/dtypes.py b/graphblas/core/dtypes.py index 345c1be81..d7a83c99b 100644 --- a/graphblas/core/dtypes.py +++ b/graphblas/core/dtypes.py @@ -22,7 +22,7 @@ def __init__(self, name, gb_obj, gb_name, c_type, numba_type, np_type): self.gb_name = gb_name self.c_type = c_type self.numba_type = numba_type - self.np_type = np.dtype(np_type) + self.np_type = np.dtype(np_type) if np_type is not None else None def __repr__(self): return self.name diff --git a/graphblas/core/ss/__init__.py b/graphblas/core/ss/__init__.py index e69de29bb..c2e83ddcc 100644 --- a/graphblas/core/ss/__init__.py +++ b/graphblas/core/ss/__init__.py @@ -0,0 +1,3 @@ +import suitesparse_graphblas as _ssgb + +_IS_SSGB7 = _ssgb.__version__.split(".", 1)[0] == "7" diff --git a/graphblas/core/ss/binary.py b/graphblas/core/ss/binary.py new file mode 100644 index 000000000..898257fac --- /dev/null +++ b/graphblas/core/ss/binary.py @@ -0,0 +1,72 @@ +from ... import backend +from ...dtypes import lookup_dtype +from ...exceptions import check_status_carg +from .. import NULL, ffi, lib +from ..operator.base import TypedOpBase +from ..operator.binary import BinaryOp, TypedUserBinaryOp +from . import _IS_SSGB7 + +ffi_new = ffi.new + + +class TypedJitBinaryOp(TypedOpBase): + __slots__ = "_monoid", "_jit_c_definition" + opclass = "BinaryOp" + + def __init__(self, parent, name, type_, return_type, gb_obj, jit_c_definition, dtype2=None): + super().__init__(parent, name, type_, return_type, gb_obj, name, dtype2=dtype2) + self._monoid = None + self._jit_c_definition = jit_c_definition + + @property + def jit_c_definition(self): + return self._jit_c_definition + + monoid = TypedUserBinaryOp.monoid + commutes_to = TypedUserBinaryOp.commutes_to + _semiring_commutes_to = TypedUserBinaryOp._semiring_commutes_to + is_commutative = TypedUserBinaryOp.is_commutative + type2 = TypedUserBinaryOp.type2 + __call__ = TypedUserBinaryOp.__call__ + + +def register_new(name, jit_c_definition, left_type, right_type, ret_type): + if backend != "suitesparse": # pragma: no cover (safety) + raise RuntimeError( + "`gb.binary.ss.register_new` invalid when not using 'suitesparse' backend" + ) + if _IS_SSGB7: + # JIT was introduced in SuiteSparse:GraphBLAS 8.0 + import suitesparse_graphblas as ssgb + + raise RuntimeError( + "JIT was added to SuiteSparse:GraphBLAS in version 8; " + f"current version is {ssgb.__version__}" + ) + left_type = lookup_dtype(left_type) + right_type = lookup_dtype(right_type) + ret_type = lookup_dtype(ret_type) + name = name if name.startswith("ss.") else f"ss.{name}" + module, funcname = BinaryOp._remove_nesting(name) + + rv = BinaryOp(name) + gb_obj = ffi_new("GrB_BinaryOp*") + check_status_carg( + lib.GxB_BinaryOp_new( + gb_obj, + NULL, + ret_type._carg, + left_type._carg, + right_type._carg, + ffi_new("char[]", funcname.encode()), + ffi_new("char[]", jit_c_definition.encode()), + ), + "BinaryOp", + gb_obj[0], + ) + op = TypedJitBinaryOp( + rv, funcname, left_type, ret_type, gb_obj[0], jit_c_definition, dtype2=right_type + ) + rv._add(op) + setattr(module, funcname, rv) + return rv diff --git a/graphblas/core/ss/config.py b/graphblas/core/ss/config.py index 89536479d..433716bb3 100644 --- a/graphblas/core/ss/config.py +++ b/graphblas/core/ss/config.py @@ -65,7 +65,7 @@ def __getitem__(self, key): raise KeyError(key) key_obj, ctype = self._options[key] is_bool = ctype == "bool" - if is_context := (key in self._context_keys): # pragma: no cover (suitesparse 8) + if is_context := (key in self._context_keys): get_function_base = self._context_get_function else: get_function_base = self._get_function @@ -76,14 +76,14 @@ def __getitem__(self, key): get_function_name = f"{get_function_base}_INT64" elif ctype.startswith("double"): get_function_name = f"{get_function_base}_FP64" - elif ctype.startswith("char"): # pragma: no cover (suitesparse 8) + elif ctype.startswith("char"): get_function_name = f"{get_function_base}_CHAR" else: # pragma: no cover (sanity) raise ValueError(ctype) get_function = getattr(lib, get_function_name) is_array = "[" in ctype val_ptr = ffi.new(ctype if is_array else f"{ctype}*") - if is_context: # pragma: no cover (suitesparse 8) + if is_context: info = get_function(self._context._carg, key_obj, val_ptr) elif self._parent is None: info = get_function(key_obj, val_ptr) @@ -105,7 +105,7 @@ def __getitem__(self, key): return rv if is_bool: return bool(val_ptr[0]) - if ctype.startswith("char"): # pragma: no cover (suitesparse 8) + if ctype.startswith("char"): return ffi.string(val_ptr[0]).decode() return val_ptr[0] raise _error_code_lookup[info](f"Failed to get info for {key!r}") # pragma: no cover @@ -117,7 +117,7 @@ def __setitem__(self, key, val): if key in self._read_only: raise ValueError(f"Config option {key!r} is read-only") key_obj, ctype = self._options[key] - if is_context := (key in self._context_keys): # pragma: no cover (suitesparse 8) + if is_context := (key in self._context_keys): set_function_base = self._context_set_function else: set_function_base = self._set_function @@ -130,7 +130,7 @@ def __setitem__(self, key, val): set_function_name = f"{set_function_base}_INT64_ARRAY" elif ctype.startswith("double["): set_function_name = f"{set_function_base}_FP64_ARRAY" - elif ctype.startswith("char"): # pragma: no cover (suitesparse 8) + elif ctype.startswith("char"): set_function_name = f"{set_function_base}_CHAR" else: # pragma: no cover (sanity) raise ValueError(ctype) @@ -174,11 +174,11 @@ def __setitem__(self, key, val): f"expected {size}, got {vals.size}: {val}" ) val_obj = ffi.from_buffer(ctype, vals) - elif ctype.startswith("char"): # pragma: no cover (suitesparse 8) + elif ctype.startswith("char"): val_obj = ffi.new("char[]", val.encode()) else: val_obj = ffi.cast(ctype, val) - if is_context: # pragma: no cover (suitesparse 8) + if is_context: if self._context is None: from .context import Context diff --git a/graphblas/core/ss/context.py b/graphblas/core/ss/context.py new file mode 100644 index 000000000..9b48bcaa4 --- /dev/null +++ b/graphblas/core/ss/context.py @@ -0,0 +1,146 @@ +import threading + +from ...exceptions import InvalidValue, check_status, check_status_carg +from .. import ffi, lib +from . import _IS_SSGB7 +from .config import BaseConfig + +ffi_new = ffi.new +if _IS_SSGB7: + # Context was introduced in SuiteSparse:GraphBLAS 8.0 + import suitesparse_graphblas as ssgb + + raise ImportError( + "Context was added to SuiteSparse:GraphBLAS in version 8; " + f"current version is {ssgb.__version__}" + ) + + +class Context(BaseConfig): + _context_keys = {"chunk", "gpu_id", "nthreads"} + _options = { + "chunk": (lib.GxB_CONTEXT_CHUNK, "double"), + "gpu_id": (lib.GxB_CONTEXT_GPU_ID, "int"), + "nthreads": (lib.GxB_CONTEXT_NTHREADS, "int"), + } + _defaults = { + "nthreads": 0, + "chunk": 0, + "gpu_id": -1, # -1 means no GPU + } + + def __init__(self, engage=True, *, stack=True, nthreads=None, chunk=None, gpu_id=None): + super().__init__() + self.gb_obj = ffi_new("GxB_Context*") + check_status_carg(lib.GxB_Context_new(self.gb_obj), "Context", self.gb_obj[0]) + if stack: + context = threadlocal.context + self["nthreads"] = context["nthreads"] if nthreads is None else nthreads + self["chunk"] = context["chunk"] if chunk is None else chunk + self["gpu_id"] = context["gpu_id"] if gpu_id is None else gpu_id + else: + if nthreads is not None: + self["nthreads"] = nthreads + if chunk is not None: + self["chunk"] = chunk + if gpu_id is not None: + self["gpu_id"] = gpu_id + self._prev_context = None + if engage: + self.engage() + + @classmethod + def _from_obj(cls, gb_obj=None): + self = object.__new__(cls) + self.gb_obj = gb_obj + self._prev_context = None + super().__init__(self) + return self + + @property + def _carg(self): + return self.gb_obj[0] + + def dup(self, engage=True, *, nthreads=None, chunk=None, gpu_id=None): + if nthreads is None: + nthreads = self["nthreads"] + if chunk is None: + chunk = self["chunk"] + if gpu_id is None: + gpu_id = self["gpu_id"] + return type(self)(engage, stack=False, nthreads=nthreads, chunk=chunk, gpu_id=gpu_id) + + def __del__(self): + gb_obj = getattr(self, "gb_obj", None) + if gb_obj is not None and lib is not None: # pragma: no branch (safety) + try: + self.disengage() + except InvalidValue: + pass + lib.GxB_Context_free(gb_obj) + + def engage(self): + if self._prev_context is None and (context := threadlocal.context) is not self: + self._prev_context = context + check_status(lib.GxB_Context_engage(self._carg), self) + threadlocal.context = self + + def _engage(self): + """Like engage, but don't set to threadlocal.context. + + This is useful if you want to disengage when the object is deleted by going out of scope. + """ + if self._prev_context is None and (context := threadlocal.context) is not self: + self._prev_context = context + check_status(lib.GxB_Context_engage(self._carg), self) + + def disengage(self): + prev_context = self._prev_context + self._prev_context = None + if threadlocal.context is self: + if prev_context is not None: + threadlocal.context = prev_context + prev_context.engage() + else: + threadlocal.context = global_context + check_status(lib.GxB_Context_disengage(self._carg), self) + elif prev_context is not None and threadlocal.context is prev_context: + prev_context.engage() + else: + check_status(lib.GxB_Context_disengage(self._carg), self) + + def __enter__(self): + self.engage() + + def __exit__(self, exc_type, exc, exc_tb): + self.disengage() + + @property + def _context(self): + return self + + @_context.setter + def _context(self, val): + if val is not None and val is not self: + raise AttributeError("'_context' attribute is read-only") + + +class GlobalContext(Context): + @property + def _carg(self): + return self.gb_obj + + def __del__(self): # pragma: no cover (safety) + pass + + +global_context = GlobalContext._from_obj(lib.GxB_CONTEXT_WORLD) + + +class ThreadLocal(threading.local): + """Hold the active context for the current thread.""" + + context = global_context + + +threadlocal = ThreadLocal() diff --git a/graphblas/core/ss/descriptor.py b/graphblas/core/ss/descriptor.py index 43553f5ea..52c43b95d 100644 --- a/graphblas/core/ss/descriptor.py +++ b/graphblas/core/ss/descriptor.py @@ -1,6 +1,7 @@ from ...exceptions import check_status, check_status_carg from .. import ffi, lib from ..descriptor import Descriptor +from . import _IS_SSGB7 from .config import BaseConfig ffi_new = ffi.new @@ -18,6 +19,8 @@ class _DescriptorConfig(BaseConfig): _get_function = "GxB_Desc_get" _set_function = "GxB_Desc_set" + if not _IS_SSGB7: + _context_keys = {"chunk", "gpu_id", "nthreads"} _options = { # GrB "output_replace": (lib.GrB_OUTP, "GrB_Desc_Value"), @@ -26,13 +29,25 @@ class _DescriptorConfig(BaseConfig): "transpose_first": (lib.GrB_INP0, "GrB_Desc_Value"), "transpose_second": (lib.GrB_INP1, "GrB_Desc_Value"), # GxB - "nthreads": (lib.GxB_DESCRIPTOR_NTHREADS, "int"), - "chunk": (lib.GxB_DESCRIPTOR_CHUNK, "double"), "axb_method": (lib.GxB_AxB_METHOD, "GrB_Desc_Value"), "sort": (lib.GxB_SORT, "int"), "secure_import": (lib.GxB_IMPORT, "int"), - # "gpu_control": (GxB_DESCRIPTOR_GPU_CONTROL, "GrB_Desc_Value"), # Coming soon... } + if _IS_SSGB7: + _options.update( + { + "nthreads": (lib.GxB_DESCRIPTOR_NTHREADS, "int"), + "chunk": (lib.GxB_DESCRIPTOR_CHUNK, "double"), + } + ) + else: + _options.update( + { + "chunk": (lib.GxB_CONTEXT_CHUNK, "double"), + "gpu_id": (lib.GxB_CONTEXT_GPU_ID, "int"), + "nthreads": (lib.GxB_CONTEXT_NTHREADS, "int"), + } + ) _enumerations = { # GrB "output_replace": { @@ -71,10 +86,6 @@ class _DescriptorConfig(BaseConfig): False: False, True: lib.GxB_SORT, }, - # "gpu_control": { # Coming soon... - # "always": lib.GxB_GPU_ALWAYS, - # "never": lib.GxB_GPU_NEVER, - # }, } _defaults = { # GrB @@ -90,6 +101,8 @@ class _DescriptorConfig(BaseConfig): "sort": False, "secure_import": False, } + if not _IS_SSGB7: + _defaults["gpu_id"] = -1 def __init__(self): gb_obj = ffi_new("GrB_Descriptor*") diff --git a/graphblas/core/ss/dtypes.py b/graphblas/core/ss/dtypes.py new file mode 100644 index 000000000..d2eb5b416 --- /dev/null +++ b/graphblas/core/ss/dtypes.py @@ -0,0 +1,88 @@ +import numpy as np + +from ... import backend, core, dtypes +from ...exceptions import check_status_carg +from .. import _has_numba, ffi, lib +from . import _IS_SSGB7 + +ffi_new = ffi.new +if _has_numba: + import numba + from cffi import FFI + from numba.core.typing import cffi_utils + + jit_ffi = FFI() + + +def register_new(name, jit_c_definition, *, np_type=None): + if backend != "suitesparse": # pragma: no cover (safety) + raise RuntimeError( + "`gb.dtypes.ss.register_new` invalid when not using 'suitesparse' backend" + ) + if _IS_SSGB7: + # JIT was introduced in SuiteSparse:GraphBLAS 8.0 + import suitesparse_graphblas as ssgb + + raise RuntimeError( + "JIT was added to SuiteSparse:GraphBLAS in version 8; " + f"current version is {ssgb.__version__}" + ) + if not name.isidentifier(): + raise ValueError(f"`name` argument must be a valid Python identifier; got: {name!r}") + if name in core.dtypes._registry or hasattr(dtypes.ss, name): + raise ValueError(f"{name!r} name for dtype is unavailable") + if len(name) > lib.GxB_MAX_NAME_LEN: + raise ValueError( + f"`name` argument is too large. Max size is {lib.GxB_MAX_NAME_LEN}; got {len(name)}" + ) + if name not in jit_c_definition: + raise ValueError("`name` argument must be same name as the typedef in `jit_c_definition`") + if "struct" not in jit_c_definition: + raise ValueError("Only struct typedefs are currently allowed for JIT dtypes") + + gb_obj = ffi.new("GrB_Type*") + status = lib.GxB_Type_new( + gb_obj, 0, ffi_new("char[]", name.encode()), ffi_new("char[]", jit_c_definition.encode()) + ) + check_status_carg(status, "Type", gb_obj[0]) + + # Let SuiteSparse:GraphBLAS determine the size (we gave 0 as size above) + size_ptr = ffi_new("size_t*") + check_status_carg(lib.GxB_Type_size(size_ptr, gb_obj[0]), "Type", gb_obj[0]) + size = size_ptr[0] + + save_np_type = True + if np_type is None and _has_numba and numba.__version__[:5] > "0.56.": + jit_ffi.cdef(jit_c_definition) + numba_type = cffi_utils.map_type(jit_ffi.typeof(name), use_record_dtype=True) + np_type = numba_type.dtype + if np_type.itemsize != size: # pragma: no cover + raise RuntimeError( + "Size of compiled user-defined type does not match size of inferred numpy type: " + f"{size} != {np_type.itemsize} != {size}.\n\n" + f"UDT C definition: {jit_c_definition}\n" + f"numpy dtype: {np_type}\n\n" + "To get around this, you may pass `np_type=` keyword argument." + ) + else: + if np_type is not None: + np_type = np.dtype(np_type) + else: + # Not an ideal numpy type, but minimally useful + np_type = np.dtype((np.uint8, size)) + save_np_type = False + if _has_numba: + numba_type = numba.typeof(np_type).dtype + else: + numba_type = None + + # For now, let's use "opaque" unsigned bytes for the c type. + rv = core.dtypes.DataType(name, gb_obj, None, f"uint8_t[{size}]", numba_type, np_type) + core.dtypes._registry[gb_obj] = rv + if save_np_type or np_type not in core.dtypes._registry: + core.dtypes._registry[np_type] = rv + if numba_type is not None and (save_np_type or numba_type not in core.dtypes._registry): + core.dtypes._registry[numba_type] = rv + core.dtypes._registry[numba_type.name] = rv + setattr(dtypes.ss, name, rv) + return rv diff --git a/graphblas/core/ss/indexunary.py b/graphblas/core/ss/indexunary.py new file mode 100644 index 000000000..c0f185737 --- /dev/null +++ b/graphblas/core/ss/indexunary.py @@ -0,0 +1,77 @@ +from ... import backend +from ...dtypes import BOOL, lookup_dtype +from ...exceptions import check_status_carg +from .. import NULL, ffi, lib +from ..operator.base import TypedOpBase +from ..operator.indexunary import IndexUnaryOp, TypedUserIndexUnaryOp +from . import _IS_SSGB7 + +ffi_new = ffi.new + + +class TypedJitIndexUnaryOp(TypedOpBase): + __slots__ = "_jit_c_definition" + opclass = "IndexUnaryOp" + + def __init__(self, parent, name, type_, return_type, gb_obj, jit_c_definition, dtype2=None): + super().__init__(parent, name, type_, return_type, gb_obj, name, dtype2=dtype2) + self._jit_c_definition = jit_c_definition + + @property + def jit_c_definition(self): + return self._jit_c_definition + + __call__ = TypedUserIndexUnaryOp.__call__ + + +def register_new(name, jit_c_definition, input_type, thunk_type, ret_type): + if backend != "suitesparse": # pragma: no cover (safety) + raise RuntimeError( + "`gb.indexunary.ss.register_new` invalid when not using 'suitesparse' backend" + ) + if _IS_SSGB7: + # JIT was introduced in SuiteSparse:GraphBLAS 8.0 + import suitesparse_graphblas as ssgb + + raise RuntimeError( + "JIT was added to SuiteSparse:GraphBLAS in version 8; " + f"current version is {ssgb.__version__}" + ) + input_type = lookup_dtype(input_type) + thunk_type = lookup_dtype(thunk_type) + ret_type = lookup_dtype(ret_type) + name = name if name.startswith("ss.") else f"ss.{name}" + module, funcname = IndexUnaryOp._remove_nesting(name) + + rv = IndexUnaryOp(name) + gb_obj = ffi_new("GrB_IndexUnaryOp*") + check_status_carg( + lib.GxB_IndexUnaryOp_new( + gb_obj, + NULL, + ret_type._carg, + input_type._carg, + thunk_type._carg, + ffi_new("char[]", funcname.encode()), + ffi_new("char[]", jit_c_definition.encode()), + ), + "IndexUnaryOp", + gb_obj[0], + ) + op = TypedJitIndexUnaryOp( + rv, funcname, input_type, ret_type, gb_obj[0], jit_c_definition, dtype2=thunk_type + ) + rv._add(op) + if ret_type == BOOL: + from ..operator.select import SelectOp + from .select import TypedJitSelectOp + + select_module, funcname = SelectOp._remove_nesting(name, strict=False) + selectop = SelectOp(name) + op2 = TypedJitSelectOp( + rv, funcname, input_type, ret_type, gb_obj[0], jit_c_definition, dtype2=thunk_type + ) + selectop._add(op2) + setattr(select_module, funcname, selectop) + setattr(module, funcname, rv) + return rv diff --git a/graphblas/core/ss/select.py b/graphblas/core/ss/select.py new file mode 100644 index 000000000..37c352b67 --- /dev/null +++ b/graphblas/core/ss/select.py @@ -0,0 +1,45 @@ +from ... import backend, indexunary +from ...dtypes import BOOL, lookup_dtype +from .. import ffi +from ..operator.base import TypedOpBase +from ..operator.select import SelectOp, TypedUserSelectOp +from . import _IS_SSGB7 + +ffi_new = ffi.new + + +class TypedJitSelectOp(TypedOpBase): + __slots__ = "_jit_c_definition" + opclass = "SelectOp" + + def __init__(self, parent, name, type_, return_type, gb_obj, jit_c_definition, dtype2=None): + super().__init__(parent, name, type_, return_type, gb_obj, name, dtype2=dtype2) + self._jit_c_definition = jit_c_definition + + @property + def jit_c_definition(self): + return self._jit_c_definition + + __call__ = TypedUserSelectOp.__call__ + + +def register_new(name, jit_c_definition, input_type, thunk_type): + if backend != "suitesparse": # pragma: no cover (safety) + raise RuntimeError( + "`gb.select.ss.register_new` invalid when not using 'suitesparse' backend" + ) + if _IS_SSGB7: + # JIT was introduced in SuiteSparse:GraphBLAS 8.0 + import suitesparse_graphblas as ssgb + + raise RuntimeError( + "JIT was added to SuiteSparse:GraphBLAS in version 8; " + f"current version is {ssgb.__version__}" + ) + input_type = lookup_dtype(input_type) + thunk_type = lookup_dtype(thunk_type) + name = name if name.startswith("ss.") else f"ss.{name}" + # Register to both `gb.indexunary.ss` and `gb.select.ss.` + indexunary.ss.register_new(name, jit_c_definition, input_type, thunk_type, BOOL) + module, funcname = SelectOp._remove_nesting(name, strict=False) + return getattr(module, funcname) diff --git a/graphblas/core/ss/unary.py b/graphblas/core/ss/unary.py new file mode 100644 index 000000000..97c4614c0 --- /dev/null +++ b/graphblas/core/ss/unary.py @@ -0,0 +1,62 @@ +from ... import backend +from ...dtypes import lookup_dtype +from ...exceptions import check_status_carg +from .. import NULL, ffi, lib +from ..operator.base import TypedOpBase +from ..operator.unary import TypedUserUnaryOp, UnaryOp +from . import _IS_SSGB7 + +ffi_new = ffi.new + + +class TypedJitUnaryOp(TypedOpBase): + __slots__ = "_jit_c_definition" + opclass = "UnaryOp" + + def __init__(self, parent, name, type_, return_type, gb_obj, jit_c_definition): + super().__init__(parent, name, type_, return_type, gb_obj, name) + self._jit_c_definition = jit_c_definition + + @property + def jit_c_definition(self): + return self._jit_c_definition + + __call__ = TypedUserUnaryOp.__call__ + + +def register_new(name, jit_c_definition, input_type, ret_type): + if backend != "suitesparse": # pragma: no cover (safety) + raise RuntimeError( + "`gb.unary.ss.register_new` invalid when not using 'suitesparse' backend" + ) + if _IS_SSGB7: + # JIT was introduced in SuiteSparse:GraphBLAS 8.0 + import suitesparse_graphblas as ssgb + + raise RuntimeError( + "JIT was added to SuiteSparse:GraphBLAS in version 8; " + f"current version is {ssgb.__version__}" + ) + input_type = lookup_dtype(input_type) + ret_type = lookup_dtype(ret_type) + name = name if name.startswith("ss.") else f"ss.{name}" + module, funcname = UnaryOp._remove_nesting(name) + + rv = UnaryOp(name) + gb_obj = ffi_new("GrB_UnaryOp*") + check_status_carg( + lib.GxB_UnaryOp_new( + gb_obj, + NULL, + ret_type._carg, + input_type._carg, + ffi_new("char[]", funcname.encode()), + ffi_new("char[]", jit_c_definition.encode()), + ), + "UnaryOp", + gb_obj[0], + ) + op = TypedJitUnaryOp(rv, funcname, input_type, ret_type, gb_obj[0], jit_c_definition) + rv._add(op) + setattr(module, funcname, rv) + return rv diff --git a/graphblas/dtypes/ss.py b/graphblas/dtypes/ss.py index e69de29bb..9f6083e01 100644 --- a/graphblas/dtypes/ss.py +++ b/graphblas/dtypes/ss.py @@ -0,0 +1 @@ +from ..core.ss.dtypes import register_new # noqa: F401 diff --git a/graphblas/indexunary/ss.py b/graphblas/indexunary/ss.py index 97852fc12..58218df6f 100644 --- a/graphblas/indexunary/ss.py +++ b/graphblas/indexunary/ss.py @@ -1,4 +1,5 @@ from ..core import operator +from ..core.ss.indexunary import register_new # noqa: F401 _delayed = {} diff --git a/graphblas/select/ss.py b/graphblas/select/ss.py index 97852fc12..173067382 100644 --- a/graphblas/select/ss.py +++ b/graphblas/select/ss.py @@ -1,4 +1,5 @@ from ..core import operator +from ..core.ss.select import register_new # noqa: F401 _delayed = {} diff --git a/graphblas/ss/__init__.py b/graphblas/ss/__init__.py index b36bc1bdc..b723d9cb8 100644 --- a/graphblas/ss/__init__.py +++ b/graphblas/ss/__init__.py @@ -1 +1,5 @@ -from ._core import about, concat, config, diag +from ._core import _IS_SSGB7, about, concat, config, diag + +if not _IS_SSGB7: + # Context was introduced in SuiteSparse:GraphBLAS 8.0 + from ..core.ss.context import Context, global_context diff --git a/graphblas/ss/_core.py b/graphblas/ss/_core.py index 53287f1a5..2639a7709 100644 --- a/graphblas/ss/_core.py +++ b/graphblas/ss/_core.py @@ -5,6 +5,7 @@ from ..core.descriptor import lookup as descriptor_lookup from ..core.matrix import Matrix, TransposedMatrix from ..core.scalar import _as_scalar +from ..core.ss import _IS_SSGB7 from ..core.ss.config import BaseConfig from ..core.ss.matrix import _concat_mn from ..core.vector import Vector @@ -126,13 +127,23 @@ class GlobalConfig(BaseConfig): Enable diagnostic printing from SuiteSparse:GraphBLAS print_1based : bool gpu_control : str, {"always", "never"} + Only available for SuiteSparse:GraphBLAS 7 + **GPU support is a work in progress--not recommended to use** gpu_chunk : double + Only available for SuiteSparse:GraphBLAS 7 + **GPU support is a work in progress--not recommended to use** + gpu_id : int + Which GPU to use; default is -1, which means do not run on the GPU. + Only available for SuiteSparse:GraphBLAS 8 + **GPU support is a work in progress--not recommended to use** Setting values to None restores the default value for most configurations. """ _get_function = "GxB_Global_Option_get" _set_function = "GxB_Global_Option_set" + if not _IS_SSGB7: + _context_keys = {"chunk", "gpu_id", "nthreads"} _null_valid = {"bitmap_switch"} _options = { # Matrix/Vector format @@ -147,10 +158,32 @@ class GlobalConfig(BaseConfig): # Diagnostics (skipping "printf" and "flush" for now) "burble": (lib.GxB_BURBLE, "bool"), "print_1based": (lib.GxB_PRINT_1BASED, "bool"), - # CUDA GPU control - "gpu_control": (lib.GxB_GLOBAL_GPU_CONTROL, "GrB_Desc_Value"), - "gpu_chunk": (lib.GxB_GLOBAL_GPU_CHUNK, "double"), } + if _IS_SSGB7: + _options.update( + { + "gpu_control": (lib.GxB_GLOBAL_GPU_CONTROL, "GrB_Desc_Value"), + "gpu_chunk": (lib.GxB_GLOBAL_GPU_CHUNK, "double"), + } + ) + else: + _options.update( + { + # JIT control + "jit_c_control": (lib.GxB_JIT_C_CONTROL, "int"), + "jit_use_cmake": (lib.GxB_JIT_USE_CMAKE, "bool"), + "jit_c_compiler_name": (lib.GxB_JIT_C_COMPILER_NAME, "char*"), + "jit_c_compiler_flags": (lib.GxB_JIT_C_COMPILER_FLAGS, "char*"), + "jit_c_linker_flags": (lib.GxB_JIT_C_LINKER_FLAGS, "char*"), + "jit_c_libraries": (lib.GxB_JIT_C_LIBRARIES, "char*"), + "jit_c_cmake_libs": (lib.GxB_JIT_C_CMAKE_LIBS, "char*"), + "jit_c_preface": (lib.GxB_JIT_C_PREFACE, "char*"), + "jit_error_log": (lib.GxB_JIT_ERROR_LOG, "char*"), + "jit_cache_path": (lib.GxB_JIT_CACHE_PATH, "char*"), + # CUDA GPU control + "gpu_id": (lib.GxB_GLOBAL_GPU_ID, "int"), + } + ) # Values to restore defaults _defaults = { "hyper_switch": lib.GxB_HYPER_DEFAULT, @@ -161,17 +194,28 @@ class GlobalConfig(BaseConfig): "burble": 0, "print_1based": 0, } + if not _IS_SSGB7: + _defaults["gpu_id"] = -1 # -1 means no GPU _enumerations = { "format": { "by_row": lib.GxB_BY_ROW, "by_col": lib.GxB_BY_COL, # "no_format": lib.GxB_NO_FORMAT, # Used by iterators; not valid here }, - "gpu_control": { + } + if _IS_SSGB7: + _enumerations["gpu_control"] = { "always": lib.GxB_GPU_ALWAYS, "never": lib.GxB_GPU_NEVER, - }, - } + } + else: + _enumerations["jit_c_control"] = { + "off": lib.GxB_JIT_OFF, + "pause": lib.GxB_JIT_PAUSE, + "run": lib.GxB_JIT_RUN, + "load": lib.GxB_JIT_LOAD, + "on": lib.GxB_JIT_ON, + } class About(Mapping): @@ -258,4 +302,10 @@ def __len__(self): about = About() -config = GlobalConfig() +if _IS_SSGB7: + config = GlobalConfig() +else: + # Context was introduced in SuiteSparse:GraphBLAS 8.0 + from ..core.ss.context import global_context + + config = GlobalConfig(context=global_context) diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py new file mode 100644 index 000000000..57cb2bbba --- /dev/null +++ b/graphblas/tests/test_ssjit.py @@ -0,0 +1,269 @@ +import os +import sys + +import numpy as np +import pytest +from numpy.testing import assert_array_equal + +import graphblas as gb +from graphblas import backend, binary, dtypes, indexunary, select, unary +from graphblas.core import _supports_udfs as supports_udfs +from graphblas.core.ss import _IS_SSGB7 + +from .conftest import autocompute, burble + +from graphblas import Vector # isort:skip (for dask-graphblas) + +try: + import numba +except ImportError: + numba = None + +if backend != "suitesparse": + pytest.skip("not suitesparse backend", allow_module_level=True) + + +@pytest.fixture(scope="module", autouse=True) +def _setup_jit(): + # Configuration values below were obtained from the output of the JIT config + # in CI, but with paths changed to use `{conda_prefix}` where appropriate. + if "CONDA_PREFIX" not in os.environ or _IS_SSGB7: + return + conda_prefix = os.environ["CONDA_PREFIX"] + gb.ss.config["jit_c_control"] = "on" + if sys.platform == "linux": + gb.ss.config["jit_c_compiler_name"] = f"{conda_prefix}/bin/x86_64-conda-linux-gnu-cc" + gb.ss.config["jit_c_compiler_flags"] = ( + "-march=nocona -mtune=haswell -ftree-vectorize -fPIC -fstack-protector-strong " + f"-fno-plt -O2 -ffunction-sections -pipe -isystem {conda_prefix}/include -Wundef " + "-std=c11 -lm -Wno-pragmas -fexcess-precision=fast -fcx-limited-range " + "-fno-math-errno -fwrapv -O3 -DNDEBUG -fopenmp -fPIC" + ) + gb.ss.config["jit_c_linker_flags"] = ( + "-Wl,-O2 -Wl,--sort-common -Wl,--as-needed -Wl,-z,relro -Wl,-z,now " + "-Wl,--disable-new-dtags -Wl,--gc-sections -Wl,--allow-shlib-undefined " + f"-Wl,-rpath,{conda_prefix}/lib -Wl,-rpath-link,{conda_prefix}/lib " + f"-L{conda_prefix}/lib -shared" + ) + gb.ss.config["jit_c_libraries"] = ( + f"-lm -ldl {conda_prefix}/lib/libgomp.so " + f"{conda_prefix}/x86_64-conda-linux-gnu/sysroot/usr/lib/libpthread.so" + ) + gb.ss.config["jit_c_cmake_libs"] = ( + f"m;dl;{conda_prefix}/lib/libgomp.so;" + f"{conda_prefix}/x86_64-conda-linux-gnu/sysroot/usr/lib/libpthread.so" + ) + elif sys.platform == "darwin": + gb.ss.config["jit_c_compiler_name"] = f"{conda_prefix}/bin/clang" + gb.ss.config["jit_c_compiler_flags"] = ( + "-march=core2 -mtune=haswell -mssse3 -ftree-vectorize -fPIC -fPIE " + f"-fstack-protector-strong -O2 -pipe -isystem {conda_prefix}/include -DGBNCPUFEAT " + "-Wno-pointer-sign -O3 -DNDEBUG -fopenmp=libomp -fPIC -arch x86_64" + ) + gb.ss.config["jit_c_linker_flags"] = ( + "-Wl,-pie -Wl,-headerpad_max_install_names -Wl,-dead_strip_dylibs " + f"-Wl,-rpath,{conda_prefix}/lib -L{conda_prefix}/lib -dynamiclib" + ) + gb.ss.config["jit_c_libraries"] = f"-lm -ldl {conda_prefix}/lib/libomp.dylib" + gb.ss.config["jit_c_cmake_libs"] = f"m;dl;{conda_prefix}/lib/libomp.dylib" + elif sys.platform == "win32": # pragma: no branch (sanity) + if "mingw" in gb.ss.config["jit_c_libraries"]: + # This probably means we're testing a `python-suitesparse-graphblas` wheel + # in a conda environment. This is not yet working. + gb.ss.config["jit_c_control"] = "off" + return + + gb.ss.config["jit_c_compiler_name"] = f"{conda_prefix}/bin/cc" + gb.ss.config["jit_c_compiler_flags"] = ( + '/DWIN32 /D_WINDOWS -DGBNCPUFEAT /O2 -wd"4244" -wd"4146" -wd"4018" ' + '-wd"4996" -wd"4047" -wd"4554" /O2 /Ob2 /DNDEBUG -openmp' + ) + gb.ss.config["jit_c_linker_flags"] = "/machine:x64" + gb.ss.config["jit_c_libraries"] = "" + gb.ss.config["jit_c_cmake_libs"] = "" + + +@pytest.fixture +def v(): + return Vector.from_coo([1, 3, 4, 6], [1, 1, 2, 0]) + + +@autocompute +def test_jit_udt(): + if _IS_SSGB7: + with pytest.raises(RuntimeError, match="JIT was added"): + dtypes.ss.register_new( + "myquaternion", "typedef struct { float x [4][4] ; int color ; } myquaternion ;" + ) + return + if gb.ss.config["jit_c_control"] == "off": + return + with burble(): + dtype = dtypes.ss.register_new( + "myquaternion", "typedef struct { float x [4][4] ; int color ; } myquaternion ;" + ) + assert not hasattr(dtypes, "myquaternion") + assert dtypes.ss.myquaternion is dtype + assert dtype.name == "myquaternion" + assert str(dtype) == "myquaternion" + assert dtype.gb_name is None + v = Vector(dtype, 2) + np_type = np.dtype([("x", "=1.25.0' -conda search 'pandas[channel=conda-forge]>=2.0.2' -conda search 'scipy[channel=conda-forge]>=1.11.0' +conda search 'pandas[channel=conda-forge]>=2.0.3' +conda search 'scipy[channel=conda-forge]>=1.11.1' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.2.4' +conda search 'awkward[channel=conda-forge]>=2.3.0' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.2' conda search 'numba[channel=conda-forge]>=0.57.1' From cdd9bb40a008d8be79ebb0dd5d415f39eef969b5 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 16 Jul 2023 10:59:57 -0500 Subject: [PATCH 38/87] Add pyopensci badge to README (#482) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3756fbb0c..4581ef54a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![Tests](https://github.com/python-graphblas/python-graphblas/workflows/Tests/badge.svg?branch=main)](https://github.com/python-graphblas/python-graphblas/actions) [![Docs](https://readthedocs.org/projects/python-graphblas/badge/?version=latest)](https://python-graphblas.readthedocs.io/en/latest/) [![Coverage](https://coveralls.io/repos/python-graphblas/python-graphblas/badge.svg?branch=main)](https://coveralls.io/r/python-graphblas/python-graphblas) +[![pyOpenSci](https://tinyurl.com/y22nb8up)](https://github.com/pyOpenSci/software-review/issues/81)
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.7328791.svg)](https://doi.org/10.5281/zenodo.7328791) [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/python-graphblas/python-graphblas/HEAD?filepath=notebooks%2FIntro%20to%20GraphBLAS%20%2B%20SSSP%20example.ipynb) From 8bd80f6e6c9fa60da31347bc3de8cdab4a2a35a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 13:37:44 -0500 Subject: [PATCH 39/87] Bump pypa/gh-action-pypi-publish from 1.8.7 to 1.8.8 (#484) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.7 to 1.8.8. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.7...v1.8.8) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index ffac645f5..cbe403724 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.7 + uses: pypa/gh-action-pypi-publish@v1.8.8 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From ae0366ad72ed2d24c6d8990c728945e0266b6390 Mon Sep 17 00:00:00 2001 From: Paul Nguyen Date: Wed, 26 Jul 2023 09:39:57 -0500 Subject: [PATCH 40/87] Removed deprecated draw (#485) --- docs/api_reference/io.rst | 2 +- graphblas/io/__init__.py | 1 - graphblas/io/_viz.py | 21 --------------------- 3 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 graphblas/io/_viz.py diff --git a/docs/api_reference/io.rst b/docs/api_reference/io.rst index e8f1748fd..cd6057a31 100644 --- a/docs/api_reference/io.rst +++ b/docs/api_reference/io.rst @@ -69,4 +69,4 @@ format of the ``graphblas`` object (this attributed is used by the Visualization ~~~~~~~~~~~~~ -.. autofunction:: graphblas.io.draw +.. autofunction:: graphblas.viz.draw diff --git a/graphblas/io/__init__.py b/graphblas/io/__init__.py index 0eafd45c8..b21b20963 100644 --- a/graphblas/io/__init__.py +++ b/graphblas/io/__init__.py @@ -4,4 +4,3 @@ from ._numpy import from_numpy, to_numpy # deprecated from ._scipy import from_scipy_sparse, to_scipy_sparse from ._sparse import from_pydata_sparse, to_pydata_sparse -from ._viz import draw # deprecated diff --git a/graphblas/io/_viz.py b/graphblas/io/_viz.py deleted file mode 100644 index 19211573f..000000000 --- a/graphblas/io/_viz.py +++ /dev/null @@ -1,21 +0,0 @@ -from warnings import warn - - -def draw(m): # pragma: no cover (deprecated) - """Draw a square adjacency Matrix as a graph. - - Requires `networkx `_ and - `matplotlib `_ to be installed. - - Example output: - - .. image:: /_static/img/draw-example.png - """ - from .. import viz - - warn( - "`graphblas.io.draw` is deprecated; it has been moved to `graphblas.viz.draw`", - DeprecationWarning, - stacklevel=2, - ) - viz.draw(m) From 347673157ee8afcb6abd9dff2bdca41c0df7560f Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 26 Jul 2023 11:40:20 -0500 Subject: [PATCH 41/87] Allow `__index__` only for integral dtypes on Scalars (#481) --- .pre-commit-config.yaml | 14 ++++++------- graphblas/core/scalar.py | 8 ++++++-- graphblas/core/ss/config.py | 7 +++---- graphblas/core/utils.py | 36 ++++++++++++++++++++++------------ graphblas/dtypes/__init__.py | 3 +++ graphblas/tests/test_scalar.py | 2 ++ scripts/check_versions.sh | 4 ++-- 7 files changed, 46 insertions(+), 28 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 726538e16..b8d767f05 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ ci: # See: https://pre-commit.ci/#configuration autofix_prs: false - autoupdate_schedule: monthly + autoupdate_schedule: quarterly autoupdate_commit_msg: "chore: update pre-commit hooks" autofix_commit_msg: "style: pre-commit fixes" skip: [pylint, no-commit-to-branch] @@ -51,7 +51,7 @@ repos: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it - repo: https://github.com/asottile/pyupgrade - rev: v3.8.0 + rev: v3.9.0 hooks: - id: pyupgrade args: [--py38-plus] @@ -61,12 +61,12 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black - id: black-jupyter - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.277 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.278 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -93,8 +93,8 @@ repos: types_or: [python, rst, markdown] additional_dependencies: [tomli] files: ^(graphblas|docs)/ - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.277 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.278 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index b55d601af..8a95e1d71 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -3,7 +3,7 @@ import numpy as np from .. import backend, binary, config, monoid -from ..dtypes import _INDEX, FP64, lookup_dtype, unify +from ..dtypes import _INDEX, FP64, _index_dtypes, lookup_dtype, unify from ..exceptions import EmptyObject, check_status from . import _has_numba, _supports_udfs, automethods, ffi, lib, utils from .base import BaseExpression, BaseType, call @@ -158,7 +158,11 @@ def __int__(self): def __complex__(self): return complex(self.value) - __index__ = __int__ + @property + def __index__(self): + if self.dtype in _index_dtypes: + return self.__int__ + raise AttributeError("Scalar object only has `__index__` for integral dtypes") def __array__(self, dtype=None): if dtype is None: diff --git a/graphblas/core/ss/config.py b/graphblas/core/ss/config.py index 433716bb3..20cf318e8 100644 --- a/graphblas/core/ss/config.py +++ b/graphblas/core/ss/config.py @@ -1,10 +1,9 @@ from collections.abc import MutableMapping -from numbers import Integral from ...dtypes import lookup_dtype from ...exceptions import _error_code_lookup, check_status from .. import NULL, ffi, lib -from ..utils import values_to_numpy_buffer +from ..utils import maybe_integral, values_to_numpy_buffer class BaseConfig(MutableMapping): @@ -147,8 +146,8 @@ def __setitem__(self, key, val): bitwise = self._bitwise[key] if isinstance(val, str): val = bitwise[val.lower()] - elif isinstance(val, Integral): - val = bitwise.get(val, val) + elif (x := maybe_integral(val)) is not None: + val = bitwise.get(x, x) else: bits = 0 for x in val: diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py index 74e03f2f9..7bb1a1fb0 100644 --- a/graphblas/core/utils.py +++ b/graphblas/core/utils.py @@ -1,4 +1,4 @@ -from numbers import Integral, Number +from operator import index import numpy as np @@ -158,6 +158,17 @@ def get_order(order): ) +def maybe_integral(val): + """Ensure ``val`` is an integer or return None if it's not.""" + try: + return index(val) + except TypeError: + pass + if isinstance(val, float) and val.is_integer(): + return int(val) + return None + + def normalize_chunks(chunks, shape): """Normalize chunks argument for use by ``Matrix.ss.split``. @@ -175,8 +186,8 @@ def normalize_chunks(chunks, shape): """ if isinstance(chunks, (list, tuple)): pass - elif isinstance(chunks, Number): - chunks = (chunks,) * len(shape) + elif (chunk := maybe_integral(chunks)) is not None: + chunks = (chunk,) * len(shape) elif isinstance(chunks, np.ndarray): chunks = chunks.tolist() else: @@ -192,22 +203,21 @@ def normalize_chunks(chunks, shape): for size, chunk in zip(shape, chunks): if chunk is None: cur_chunks = [size] - elif isinstance(chunk, Integral) or isinstance(chunk, float) and chunk.is_integer(): - chunk = int(chunk) - if chunk < 0: - raise ValueError(f"Chunksize must be greater than 0; got: {chunk}") - div, mod = divmod(size, chunk) - cur_chunks = [chunk] * div + elif (c := maybe_integral(chunk)) is not None: + if c < 0: + raise ValueError(f"Chunksize must be greater than 0; got: {c}") + div, mod = divmod(size, c) + cur_chunks = [c] * div if mod: cur_chunks.append(mod) elif isinstance(chunk, (list, tuple)): cur_chunks = [] none_index = None for c in chunk: - if isinstance(c, Integral) or isinstance(c, float) and c.is_integer(): - c = int(c) - if c < 0: - raise ValueError(f"Chunksize must be greater than 0; got: {c}") + if (val := maybe_integral(c)) is not None: + if val < 0: + raise ValueError(f"Chunksize must be greater than 0; got: {val}") + c = val elif c is None: if none_index is not None: raise TypeError( diff --git a/graphblas/dtypes/__init__.py b/graphblas/dtypes/__init__.py index 49e46d787..f9c144f13 100644 --- a/graphblas/dtypes/__init__.py +++ b/graphblas/dtypes/__init__.py @@ -41,3 +41,6 @@ def __getattr__(key): globals()["ss"] = ss return ss raise AttributeError(f"module {__name__!r} has no attribute {key!r}") + + +_index_dtypes = {BOOL, INT8, UINT8, INT16, UINT16, INT32, UINT32, INT64, UINT64, _INDEX} diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index 7b7c77177..cf4c6fd41 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -132,6 +132,8 @@ def test_casting(s): assert float(s) == 5.0 assert type(float(s)) is float assert range(s) == range(5) + with pytest.raises(AttributeError, match="Scalar .* only .*__index__.*integral"): + range(s.dup(float)) assert complex(s) == complex(5) assert type(complex(s)) is complex diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index ef1a76135..263b1d8f7 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -3,11 +3,11 @@ # Use, adjust, copy/paste, etc. as necessary to answer your questions. # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. -conda search 'numpy[channel=conda-forge]>=1.25.0' +conda search 'numpy[channel=conda-forge]>=1.25.1' conda search 'pandas[channel=conda-forge]>=2.0.3' conda search 'scipy[channel=conda-forge]>=1.11.1' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.3.0' +conda search 'awkward[channel=conda-forge]>=2.3.1' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.2' conda search 'numba[channel=conda-forge]>=0.57.1' From 8e42ded1b9b4965ad413073e348a9cdab7c58a3b Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 26 Jul 2023 17:39:08 -0500 Subject: [PATCH 42/87] Add `matrix.power` to compute e.g. `A @ A @ A @ ...` (#483) --- .github/workflows/test_and_build.yml | 2 +- .pre-commit-config.yaml | 6 +- graphblas/core/automethods.py | 5 ++ graphblas/core/infix.py | 1 + graphblas/core/matrix.py | 122 ++++++++++++++++++++++++++- graphblas/tests/test_matrix.py | 31 +++++++ graphblas/tests/test_vector.py | 4 +- scripts/check_versions.sh | 2 +- 8 files changed, 164 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index d93b4c25c..4c1c0e312 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -430,7 +430,7 @@ jobs: id: coverageAttempt3 if: steps.coverageAttempt2.outcome == 'failure' # Continue even if it failed 3 times... (sheesh! use codecov instead) - continue-on-error: false + continue-on-error: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_FLAG_NAME: ${{ matrix.os }}/${{ matrix.slowtask }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8d767f05..fef625a70 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -66,7 +66,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.278 + rev: v0.0.280 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -79,7 +79,7 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.0.0 - - flake8-bugbear==23.6.5 + - flake8-bugbear==23.7.10 - flake8-simplify==0.20.0 - repo: https://github.com/asottile/yesqa rev: v1.5.0 @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.278 + rev: v0.0.280 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/graphblas/core/automethods.py b/graphblas/core/automethods.py index 937e331fd..0a2aa208a 100644 --- a/graphblas/core/automethods.py +++ b/graphblas/core/automethods.py @@ -213,6 +213,10 @@ def outer(self): return self._get_value("outer") +def power(self): + return self._get_value("power") + + def reduce(self): return self._get_value("reduce") @@ -410,6 +414,7 @@ def _main(): "kronecker", "mxm", "mxv", + "power", "reduce_columnwise", "reduce_rowwise", "reduce_scalar", diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index bd1d10a92..88fc52dbe 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -330,6 +330,7 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): mxv = wrapdoc(Matrix.mxv)(property(automethods.mxv)) name = wrapdoc(Matrix.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Matrix.nvals)(property(automethods.nvals)) + power = wrapdoc(Matrix.power)(property(automethods.power)) reduce_columnwise = wrapdoc(Matrix.reduce_columnwise)(property(automethods.reduce_columnwise)) reduce_rowwise = wrapdoc(Matrix.reduce_rowwise)(property(automethods.reduce_rowwise)) reduce_scalar = wrapdoc(Matrix.reduce_scalar)(property(automethods.reduce_scalar)) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 4696d8ead..d820ca424 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -28,6 +28,7 @@ class_property, get_order, ints_to_numpy_buffer, + maybe_integral, normalize_values, output_type, values_to_numpy_buffer, @@ -91,6 +92,68 @@ def _reposition(updater, indices, chunk): updater[indices] = chunk +def _power(updater, A, n, op): + opts = updater.opts + if n == 1: + updater << A + return + # Use repeated squaring: compute A^2, A^4, A^8, etc., and combine terms as needed. + # See `numpy.linalg.matrix_power` for a simpler implementation to understand how this works. + # We reuse `result` and `square` outputs, and use `square_expr` so masks can be applied. + result = square = square_expr = None + n, bit = divmod(n, 2) + while True: + if bit != 0: + # Need to multiply `square_expr` or `A` into the result + if square_expr is not None: + # Need to evaluate `square_expr`; either into final result, or into `square` + if n == 0 and result is None: + # Handle `updater << A @ A` without an intermediate value + updater << square_expr + return + if square is None: + # Create `square = A @ A` + square = square_expr.new(name="Squares", **opts) + else: + # Compute `square << square @ square` + square(**opts) << square_expr + square_expr = None + if result is None: + # First time needing the intermediate result! + if square is None: + # Use `A` if possible to avoid unnecessary copying + # We will detect and handle `result is A` below + result = A + else: + # Copy square as intermediate result + result = square.dup(name="Power", **opts) + elif n == 0: + # All done! No more terms to compute + updater << op(result @ square) + return + elif result is A: + # Now we need to create a new matrix for the intermediate result + result = op(result @ square).new(name="Power", **opts) + else: + # Main branch: multiply `square` into `result` + result(**opts) << op(result @ square) + n, bit = divmod(n, 2) + if square_expr is not None: + # We need to perform another squaring, so evaluate current `square_expr` first + if square is None: + # Create `square` + square = square_expr.new(name="Squares", **opts) + else: + # Compute `square` + square << square_expr + if square is None: + # First iteration! Create expression for first square + square_expr = op(A @ A) + else: + # Expression for repeated squaring + square_expr = op(square @ square) + + class Matrix(BaseType): """Create a new GraphBLAS Sparse Matrix. @@ -155,8 +218,6 @@ def _as_vector(self, *, name=None): This is SuiteSparse-specific and may change in the future. This does not copy the matrix. """ - from .vector import Vector - if self._ncols != 1: raise ValueError( f"Matrix must have a single column (not {self._ncols}) to be cast to a Vector" @@ -2690,6 +2751,60 @@ def reposition(self, row_offset, column_offset, *, nrows=None, ncols=None): dtype=self.dtype, ) + def power(self, n, op=semiring.plus_times): + """Raise a square Matrix to the (positive integer) power ``n``. + + Matrix power is computed by repeated matrix squaring and matrix multiplication. + For a graph as an adjacency matrix, matrix power with default ``plus_times`` + semiring computes the number of walks connecting each pair of nodes. + The result can grow very quickly for large matrices and with larger ``n``. + + Parameters + ---------- + n : int + The exponent must be a positive integer. + op : :class:`~graphblas.core.operator.Semiring` + Semiring used in the computation + + Returns + ------- + MatrixExpression + + Examples + -------- + .. code-block:: python + + C << A.power(4, op=semiring.plus_times) + + # Is equivalent to: + tmp = (A @ A).new() + tmp << tmp @ tmp + C << tmp @ tmp + + # And is more efficient than the naive implementation: + C = A.dup() + for i in range(1, 4): + C << A @ C + """ + method_name = "power" + if self._nrows != self._ncols: + raise DimensionMismatch(f"power only works for square Matrix; shape is {self.shape}") + if (N := maybe_integral(n)) is None: + raise TypeError(f"n must be a positive integer; got bad type: {type(n)}") + if N <= 0: + raise ValueError(f"n must be a positive integer; got: {N}") + op = get_typed_op(op, self.dtype, kind="semiring") + self._expect_op(op, "Semiring", within=method_name, argname="op") + return MatrixExpression( + "power", + None, + [self, _power, (self, N, op)], # [*expr_args, func, args] + expr_repr=f"{{0.name}}.power({N}, op={op})", + nrows=self._nrows, + ncols=self._ncols, + dtype=self.dtype, + ) + ################################## # Extract and Assign index methods ################################## @@ -3358,6 +3473,7 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): mxv = wrapdoc(Matrix.mxv)(property(automethods.mxv)) name = wrapdoc(Matrix.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Matrix.nvals)(property(automethods.nvals)) + power = wrapdoc(Matrix.power)(property(automethods.power)) reduce_columnwise = wrapdoc(Matrix.reduce_columnwise)(property(automethods.reduce_columnwise)) reduce_rowwise = wrapdoc(Matrix.reduce_rowwise)(property(automethods.reduce_rowwise)) reduce_scalar = wrapdoc(Matrix.reduce_scalar)(property(automethods.reduce_scalar)) @@ -3458,6 +3574,7 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): mxv = wrapdoc(Matrix.mxv)(property(automethods.mxv)) name = wrapdoc(Matrix.name)(property(automethods.name)).setter(automethods._set_name) nvals = wrapdoc(Matrix.nvals)(property(automethods.nvals)) + power = wrapdoc(Matrix.power)(property(automethods.power)) reduce_columnwise = wrapdoc(Matrix.reduce_columnwise)(property(automethods.reduce_columnwise)) reduce_rowwise = wrapdoc(Matrix.reduce_rowwise)(property(automethods.reduce_rowwise)) reduce_scalar = wrapdoc(Matrix.reduce_scalar)(property(automethods.reduce_scalar)) @@ -3619,6 +3736,7 @@ def to_dicts(self, order="rowwise"): reduce_columnwise = Matrix.reduce_columnwise reduce_scalar = Matrix.reduce_scalar reposition = Matrix.reposition + power = Matrix.power # Operator sugar __or__ = Matrix.__or__ diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index bc942bc49..80a66a524 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -4375,3 +4375,34 @@ def test_subarray_dtypes(): if suitesparse: Full2 = Matrix.ss.import_fullr(b2) assert Full1.isequal(Full2, check_dtype=True) + + +def test_power(A): + expected = A.dup() + for i in range(1, 50): + result = A.power(i).new() + assert result.isequal(expected) + expected << A @ expected + # Test transpose + expected = A.T.new() + for i in range(1, 10): + result = A.T.power(i).new() + assert result.isequal(expected) + expected << A.T @ expected + # Test other semiring + expected = A.dup() + for i in range(1, 10): + result = A.power(i, semiring.min_plus).new() + assert result.isequal(expected) + expected << semiring.min_plus(A @ expected) + # Exceptional + with pytest.raises(TypeError, match="must be a positive integer"): + A.power(1.5) + with pytest.raises(ValueError, match="must be a positive integer"): + A.power(-1) + with pytest.raises(ValueError, match="must be a positive integer"): + # Not implemented yet... could create identity matrix + A.power(0) + B = A[:2, :3].new() + with pytest.raises(DimensionMismatch): + B.power(2) diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index a1aabd183..e321d3e9b 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -999,10 +999,10 @@ def test_reduce_agg_firstlast_index(v): def test_reduce_agg_empty(): v = Vector("UINT8", size=3) - for _attr, aggr in vars(agg).items(): + for attr, aggr in vars(agg).items(): if not isinstance(aggr, agg.Aggregator): continue - s = v.reduce(aggr).new() + s = v.reduce(aggr).new(name=attr) assert compute(s.value) is None diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 263b1d8f7..ffa440c22 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -12,6 +12,6 @@ conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.2' conda search 'numba[channel=conda-forge]>=0.57.1' conda search 'pyyaml[channel=conda-forge]>=6.0' -conda search 'flake8-bugbear[channel=conda-forge]>=23.6.5' +conda search 'flake8-bugbear[channel=conda-forge]>=23.7.10' conda search 'flake8-simplify[channel=conda-forge]>=0.20.0' # conda search 'python[channel=conda-forge]>=3.8 *pypy*' From c753947fe82f99aae2d8e19e0ce47e9f7e9b7338 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 2 Aug 2023 09:59:28 -0500 Subject: [PATCH 43/87] Remove deprecated `{scan,selectk,compactify}_{row,column}wise` (#486) --- graphblas/core/ss/matrix.py | 225 --------------------------------- graphblas/tests/test_matrix.py | 13 -- 2 files changed, 238 deletions(-) diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index 56c28f52f..64914cf02 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -1,5 +1,4 @@ import itertools -import warnings import numpy as np from suitesparse_graphblas.utils import claim_buffer, claim_buffer_2d, unclaim_buffer @@ -3715,51 +3714,6 @@ def scan(self, op=monoid.plus, order="rowwise", *, name=None, **opts): parent = parent.T return prefix_scan(parent, op, name=name, within="scan", **opts) - def scan_columnwise(self, op=monoid.plus, *, name=None, **opts): - """Perform a prefix scan across columns with the given monoid. - - .. deprecated:: 2022.11.1 - ``Matrix.ss.scan_columnwise`` will be removed in a future release. - Use ``Matrix.ss.scan(order="columnwise")`` instead. - Will be removed in version 2023.7.0 or later - - For example, use ``monoid.plus`` (the default) to perform a cumulative sum, - and ``monoid.times`` for cumulative product. Works with any monoid. - - Returns - ------- - Matrix - """ - warnings.warn( - "`Matrix.ss.scan_columnwise` is deprecated; " - 'please use `Matrix.ss.scan(order="columnwise")` instead.', - DeprecationWarning, - stacklevel=2, - ) - return prefix_scan(self._parent.T, op, name=name, within="scan_columnwise", **opts) - - def scan_rowwise(self, op=monoid.plus, *, name=None, **opts): - """Perform a prefix scan across rows with the given monoid. - - .. deprecated:: 2022.11.1 - ``Matrix.ss.scan_rowwise`` will be removed in a future release. - Use ``Matrix.ss.scan`` instead. - Will be removed in version 2023.7.0 or later - - For example, use ``monoid.plus`` (the default) to perform a cumulative sum, - and ``monoid.times`` for cumulative product. Works with any monoid. - - Returns - ------- - Matrix - """ - warnings.warn( - "`Matrix.ss.scan_rowwise` is deprecated; please use `Matrix.ss.scan` instead.", - DeprecationWarning, - stacklevel=2, - ) - return prefix_scan(self._parent, op, name=name, within="scan_rowwise", **opts) - def flatten(self, order="rowwise", *, name=None, **opts): """Return a copy of the Matrix collapsed into a Vector. @@ -3901,99 +3855,6 @@ def selectk(self, how, k, order="rowwise", *, name=None): k, fmt, indices, sort_axis, choose_func, is_random, do_sort, name ) - def selectk_rowwise(self, how, k, *, name=None): # pragma: no cover (deprecated) - """Select (up to) k elements from each row. - - .. deprecated:: 2022.11.1 - ``Matrix.ss.selectk_rowwise`` will be removed in a future release. - Use ``Matrix.ss.selectk`` instead. - Will be removed in version 2023.7.0 or later - - Parameters - ---------- - how : str - "random": choose k elements with equal probability - "first": choose the first k elements - "last": choose the last k elements - k : int - The number of elements to choose from each row - - **THIS API IS EXPERIMENTAL AND MAY CHANGE** - """ - warnings.warn( - "`Matrix.ss.selectk_rowwise` is deprecated; please use `Matrix.ss.selectk` instead.", - DeprecationWarning, - stacklevel=2, - ) - how = how.lower() - fmt = "hypercsr" - indices = "col_indices" - sort_axis = "sorted_cols" - if how == "random": - choose_func = choose_random - is_random = True - do_sort = False - elif how == "first": - choose_func = choose_first - is_random = False - do_sort = True - elif how == "last": - choose_func = choose_last - is_random = False - do_sort = True - else: - raise ValueError('`how` argument must be one of: "random", "first", "last"') - return self._select_random( - k, fmt, indices, sort_axis, choose_func, is_random, do_sort, name - ) - - def selectk_columnwise(self, how, k, *, name=None): # pragma: no cover (deprecated) - """Select (up to) k elements from each column. - - .. deprecated:: 2022.11.1 - ``Matrix.ss.selectk_columnwise`` will be removed in a future release. - Use ``Matrix.ss.selectk(order="columnwise")`` instead. - Will be removed in version 2023.7.0 or later - - Parameters - ---------- - how : str - - "random": choose elements with equal probability - - "first": choose the first k elements - - "last": choose the last k elements - k : int - The number of elements to choose from each column - - **THIS API IS EXPERIMENTAL AND MAY CHANGE** - """ - warnings.warn( - "`Matrix.ss.selectk_columnwise` is deprecated; " - 'please use `Matrix.ss.selectk(order="columnwise")` instead.', - DeprecationWarning, - stacklevel=2, - ) - how = how.lower() - fmt = "hypercsc" - indices = "row_indices" - sort_axis = "sorted_rows" - if how == "random": - choose_func = choose_random - is_random = True - do_sort = False - elif how == "first": - choose_func = choose_first - is_random = False - do_sort = True - elif how == "last": - choose_func = choose_last - is_random = False - do_sort = True - else: - raise ValueError('`how` argument must be one of: "random", "first", "last"') - return self._select_random( - k, fmt, indices, sort_axis, choose_func, is_random, do_sort, name - ) - def _select_random(self, k, fmt, indices, sort_axis, choose_func, is_random, do_sort, name): if k < 0: raise ValueError("negative k is not allowed") @@ -4058,92 +3919,6 @@ def compactify( indices = "row_indices" return self._compactify(how, reverse, asindex, dimname, k, fmt, indices, name) - def compactify_rowwise( - self, how="first", ncols=None, *, reverse=False, asindex=False, name=None - ): - """Shift all values to the left so all values in a row are contiguous. - - This returns a new Matrix. - - Parameters - ---------- - how : {"first", "last", "smallest", "largest", "random"}, optional - How to compress the values: - - first : take the values furthest to the left - - last : take the values furthest to the right - - smallest : take the smallest values (if tied, may take any) - - largest : take the largest values (if tied, may take any) - - random : take values randomly with equal probability and without replacement - Chosen values may not be ordered randomly - reverse : bool, default False - Reverse the values in each row when True - asindex : bool, default False - Return the column index of the value when True. If there are ties for - "smallest" and "largest", then any valid index may be returned. - ncols : int, optional - The number of columns of the returned Matrix. If not specified, then - the Matrix will be "compacted" to the smallest ncols that doesn't lose - values. - - **THIS API IS EXPERIMENTAL AND MAY CHANGE** - - See Also - -------- - Matrix.ss.sort - """ - warnings.warn( - "`Matrix.ss.compactify_rowwise` is deprecated; " - "please use `Matrix.ss.compactify` instead.", - DeprecationWarning, - stacklevel=2, - ) - return self._compactify( - how, reverse, asindex, "ncols", ncols, "hypercsr", "col_indices", name - ) - - def compactify_columnwise( - self, how="first", nrows=None, *, reverse=False, asindex=False, name=None - ): - """Shift all values to the top so all values in a column are contiguous. - - This returns a new Matrix. - - Parameters - ---------- - how : {"first", "last", "smallest", "largest", "random"}, optional - How to compress the values: - - first : take the values furthest to the top - - last : take the values furthest to the bottom - - smallest : take the smallest values (if tied, may take any) - - largest : take the largest values (if tied, may take any) - - random : take values randomly with equal probability and without replacement - Chosen values may not be ordered randomly - reverse : bool, default False - Reverse the values in each column when True - asindex : bool, default False - Return the row index of the value when True. If there are ties for - "smallest" and "largest", then any valid index may be returned. - nrows : int, optional - The number of rows of the returned Matrix. If not specified, then - the Matrix will be "compacted" to the smallest nrows that doesn't lose - values. - - **THIS API IS EXPERIMENTAL AND MAY CHANGE** - - See Also - -------- - Matrix.ss.sort - """ - warnings.warn( - "`Matrix.ss.compactify_columnwise` is deprecated; " - 'please use `Matrix.ss.compactify(order="columnwise")` instead.', - DeprecationWarning, - stacklevel=2, - ) - return self._compactify( - how, reverse, asindex, "nrows", nrows, "hypercsc", "row_indices", name - ) - def _compactify(self, how, reverse, asindex, nkey, nval, fmt, indices_name, name): how = how.lower() if how not in {"first", "last", "smallest", "largest", "random"}: diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 80a66a524..cd70479cc 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -3538,19 +3538,6 @@ def compare(A, expected, isequal=True, **kwargs): def test_deprecated(A): - if suitesparse: - with pytest.warns(DeprecationWarning): - A.ss.compactify_rowwise() - with pytest.warns(DeprecationWarning): - A.ss.compactify_columnwise() - with pytest.warns(DeprecationWarning): - A.ss.scan_rowwise() - with pytest.warns(DeprecationWarning): - A.ss.scan_columnwise() - with pytest.warns(DeprecationWarning): - A.ss.selectk_rowwise("first", 3) - with pytest.warns(DeprecationWarning): - A.ss.selectk_columnwise("first", 3) with pytest.warns(DeprecationWarning): A.to_values() with pytest.warns(DeprecationWarning): From 9e1a390130fcece898bf66b852edd2540f4c88b0 Mon Sep 17 00:00:00 2001 From: William Zijie Zhang <89562186+Transurgeon@users.noreply.github.com> Date: Wed, 9 Aug 2023 17:07:27 -0400 Subject: [PATCH 44/87] dropping support for python3.8 according to wiki (#489) Co-authored-by: Transurgeon --- .github/workflows/debug.yml | 2 +- .github/workflows/imports.yml | 4 +--- .github/workflows/publish_pypi.yml | 2 +- .github/workflows/test_and_build.yml | 10 +--------- .pre-commit-config.yaml | 2 +- pyproject.toml | 9 ++++----- scripts/check_versions.sh | 2 +- 7 files changed, 10 insertions(+), 21 deletions(-) diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml index 794746f77..389905db5 100644 --- a/.github/workflows/debug.yml +++ b/.github/workflows/debug.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [3.8] + pyver: [3.9] testopts: - "--blocking" # - "--non-blocking --record --runslow" diff --git a/.github/workflows/imports.yml b/.github/workflows/imports.yml index 18e6f637c..de9a7361d 100644 --- a/.github/workflows/imports.yml +++ b/.github/workflows/imports.yml @@ -30,7 +30,6 @@ jobs: id: pyver with: contents: | - 3.8 3.9 3.10 3.11 @@ -38,14 +37,13 @@ jobs: 1 1 1 - 1 test_imports: needs: rngs runs-on: ${{ needs.rngs.outputs.os }} # runs-on: ${{ matrix.os }} # strategy: # matrix: - # python-version: ["3.8", "3.9", "3.10", "3.11"] + # python-version: ["3.9", "3.10", "3.11"] # os: ["ubuntu-latest", "macos-latest", "windows-latest"] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index cbe403724..abf3057ac 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.8" + python-version: "3.9" - name: Install build dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 4c1c0e312..cc2eb27b6 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -107,10 +107,8 @@ jobs: with: # We should support major Python versions for at least 36-42 months # We may be able to support pypy if anybody asks for it - # 3.8.16 0_73_pypy # 3.9.16 0_73_pypy contents: | - 3.8 3.9 3.10 3.11 @@ -118,7 +116,6 @@ jobs: 1 1 1 - 1 - name: RNG for source of python-suitesparse-graphblas uses: ddradar/choose-random-action@v2.0.2 id: sourcetype @@ -171,12 +168,7 @@ jobs: yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') sparsever=$(python -c 'import random ; print(random.choice(["=0.13", "=0.14", ""]))') fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') - if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.8') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", ""]))') - 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", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", ""]))') - elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then + if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", ""]))') diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fef625a70..5fffc6f8f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: rev: v3.9.0 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py39-plus] - repo: https://github.com/MarcoGorelli/auto-walrus rev: v0.2.2 hooks: diff --git a/pyproject.toml b/pyproject.toml index fdd3a7a94..499faa2c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ name = "python-graphblas" dynamic = ["version"] description = "Python library for GraphBLAS: high-performance sparse linear algebra for scalable graph analytics" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" license = {file = "LICENSE"} authors = [ {name = "Erik Welch", email = "erik.n.welch@gmail.com"}, @@ -44,7 +44,6 @@ classifiers = [ "Operating System :: Microsoft :: Windows", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -157,7 +156,7 @@ dirty_template = "{tag}+{ccount}.g{sha}.dirty" [tool.black] line-length = 100 -target-version = ["py38", "py39", "py310", "py311"] +target-version = ["py39", "py310", "py311"] [tool.isort] sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] @@ -235,7 +234,7 @@ ignore-words-list = "coo,ba" [tool.ruff] # https://github.com/charliermarsh/ruff/ line-length = 100 -target-version = "py38" +target-version = "py39" select = [ # Have we enabled too many checks that they'll become a nuisance? We'll see... "F", # pyflakes @@ -396,7 +395,7 @@ convention = "numpy" [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" +py-version = "3.9" enable = ["I"] disable = [ # Error diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index ffa440c22..f849c1329 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -14,4 +14,4 @@ conda search 'numba[channel=conda-forge]>=0.57.1' conda search 'pyyaml[channel=conda-forge]>=6.0' conda search 'flake8-bugbear[channel=conda-forge]>=23.7.10' conda search 'flake8-simplify[channel=conda-forge]>=0.20.0' -# conda search 'python[channel=conda-forge]>=3.8 *pypy*' +# conda search 'python[channel=conda-forge]>=3.9 *pypy*' From c8f391757df4cd50420a0b7f16b29bf2e22e1853 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Aug 2023 10:40:25 -0500 Subject: [PATCH 45/87] Bump pypa/gh-action-pypi-publish from 1.8.8 to 1.8.10 (#491) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.8 to 1.8.10. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.8...v1.8.10) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index abf3057ac..e99dc2c0f 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.8 + uses: pypa/gh-action-pypi-publish@v1.8.10 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 45ccf4b37d2ba6b70c8a8f47ee2d1603605e8497 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 15 Aug 2023 11:32:31 -0500 Subject: [PATCH 46/87] Add tests for `gb.ss.context` (#488) --- .pre-commit-config.yaml | 12 +++---- graphblas/core/operator/agg.py | 4 +-- graphblas/core/operator/binary.py | 2 +- graphblas/core/operator/utils.py | 2 +- graphblas/core/ss/context.py | 1 + graphblas/core/utils.py | 2 +- graphblas/monoid/__init__.py | 2 +- graphblas/monoid/numpy.py | 5 ++- graphblas/semiring/__init__.py | 4 +-- graphblas/semiring/numpy.py | 4 +-- graphblas/tests/test_matrix.py | 2 +- graphblas/tests/test_scalar.py | 8 ++--- graphblas/tests/test_ss_utils.py | 60 +++++++++++++++++++++++++++++++ graphblas/tests/test_vector.py | 2 +- scripts/check_versions.sh | 4 +-- 15 files changed, 87 insertions(+), 27 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5fffc6f8f..e80d3e817 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it - repo: https://github.com/asottile/pyupgrade - rev: v3.9.0 + rev: v3.10.1 hooks: - id: pyupgrade args: [--py39-plus] @@ -66,19 +66,19 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.280 + rev: v0.0.284 hooks: - id: ruff args: [--fix-only, --show-fixes] # 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 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: &flake8_dependencies # These versions need updated manually - - flake8==6.0.0 + - flake8==6.1.0 - flake8-bugbear==23.7.10 - flake8-simplify==0.20.0 - repo: https://github.com/asottile/yesqa @@ -94,11 +94,11 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.280 + rev: v0.0.284 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.6.7 + rev: v0.6.8 hooks: - id: sphinx-lint args: [--enable, all, "--disable=line-too-long,leaked-markup"] diff --git a/graphblas/core/operator/agg.py b/graphblas/core/operator/agg.py index 09d644c32..6b463a8a6 100644 --- a/graphblas/core/operator/agg.py +++ b/graphblas/core/operator/agg.py @@ -76,9 +76,9 @@ def __init__( @property def types(self): if self._types is None: - if type(self._semiring) is str: + if isinstance(self._semiring, str): self._semiring = semiring.from_string(self._semiring) - if type(self._types_orig[0]) is str: # pragma: no branch + if isinstance(self._types_orig[0], str): # pragma: no branch self._types_orig[0] = semiring.from_string(self._types_orig[0]) self._types = _get_types( self._types_orig, None if self._initval_orig is None else self._initdtype diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py index 88191c39b..77a686868 100644 --- a/graphblas/core/operator/binary.py +++ b/graphblas/core/operator/binary.py @@ -200,7 +200,7 @@ def monoid(self): @property def commutes_to(self): - if type(self._commutes_to) is str: + if isinstance(self._commutes_to, str): self._commutes_to = BinaryOp._find(self._commutes_to) return self._commutes_to diff --git a/graphblas/core/operator/utils.py b/graphblas/core/operator/utils.py index 00bc86cea..00df31db8 100644 --- a/graphblas/core/operator/utils.py +++ b/graphblas/core/operator/utils.py @@ -340,7 +340,7 @@ def _from_string(string, module, mapping, example): ) if base in mapping: op = mapping[base] - if type(op) is str: + if isinstance(op, str): op = mapping[base] = module.from_string(op) elif hasattr(module, base): op = getattr(module, base) diff --git a/graphblas/core/ss/context.py b/graphblas/core/ss/context.py index 9b48bcaa4..f93d1ec1c 100644 --- a/graphblas/core/ss/context.py +++ b/graphblas/core/ss/context.py @@ -111,6 +111,7 @@ def disengage(self): def __enter__(self): self.engage() + return self def __exit__(self, exc_type, exc, exc_tb): self.disengage() diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py index 7bb1a1fb0..42fcf0685 100644 --- a/graphblas/core/utils.py +++ b/graphblas/core/utils.py @@ -11,7 +11,7 @@ def libget(name): try: return getattr(lib, name) except AttributeError: - if name[-4:] not in {"FC32", "FC64", "error"}: + if name[-4:] not in {"FC32", "FC64", "rror"}: raise ext_name = f"GxB_{name[4:]}" try: diff --git a/graphblas/monoid/__init__.py b/graphblas/monoid/__init__.py index ed028c5d9..027fc0afe 100644 --- a/graphblas/monoid/__init__.py +++ b/graphblas/monoid/__init__.py @@ -10,7 +10,7 @@ def __dir__(): def __getattr__(key): if key in _delayed: func, kwargs = _delayed.pop(key) - if type(kwargs["binaryop"]) is str: + if isinstance(kwargs["binaryop"], str): from ..binary import from_string kwargs["binaryop"] = from_string(kwargs["binaryop"]) diff --git a/graphblas/monoid/numpy.py b/graphblas/monoid/numpy.py index f46d57143..5f6895e5d 100644 --- a/graphblas/monoid/numpy.py +++ b/graphblas/monoid/numpy.py @@ -90,8 +90,7 @@ if ( _config.get("mapnumpy") or _has_numba - and type(_numba.njit(lambda x, y: _np.fmax(x, y))(1, 2)) # pragma: no branch (numba) - is not float + and not isinstance(_numba.njit(lambda x, y: _np.fmax(x, y))(1, 2), float) # pragma: no branch ): # Incorrect behavior was introduced in numba 0.56.2 and numpy 1.23 # See: https://github.com/numba/numba/issues/8478 @@ -170,7 +169,7 @@ def __dir__(): def __getattr__(name): if name in _delayed: func, kwargs = _delayed.pop(name) - if type(kwargs["binaryop"]) is str: + if isinstance(kwargs["binaryop"], str): from ..binary import from_string kwargs["binaryop"] = from_string(kwargs["binaryop"]) diff --git a/graphblas/semiring/__init__.py b/graphblas/semiring/__init__.py index 538136406..95a44261a 100644 --- a/graphblas/semiring/__init__.py +++ b/graphblas/semiring/__init__.py @@ -46,11 +46,11 @@ def __getattr__(key): return rv if key in _delayed: func, kwargs = _delayed.pop(key) - if type(kwargs["binaryop"]) is str: + if isinstance(kwargs["binaryop"], str): from ..binary import from_string kwargs["binaryop"] = from_string(kwargs["binaryop"]) - if type(kwargs["monoid"]) is str: + if isinstance(kwargs["monoid"], str): from ..monoid import from_string kwargs["monoid"] = from_string(kwargs["monoid"]) diff --git a/graphblas/semiring/numpy.py b/graphblas/semiring/numpy.py index 3a59090cc..97b90874b 100644 --- a/graphblas/semiring/numpy.py +++ b/graphblas/semiring/numpy.py @@ -151,11 +151,11 @@ def __getattr__(name): if name in _delayed: func, kwargs = _delayed.pop(name) - if type(kwargs["binaryop"]) is str: + if isinstance(kwargs["binaryop"], str): from ..binary import from_string kwargs["binaryop"] = from_string(kwargs["binaryop"]) - if type(kwargs["monoid"]) is str: + if isinstance(kwargs["monoid"], str): from ..monoid import from_string kwargs["monoid"] = from_string(kwargs["monoid"]) diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index cd70479cc..fe85bb9bf 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -3878,7 +3878,7 @@ def test_get(A): assert compute(A.T.get(0, 1)) is None assert A.T.get(1, 0) == 2 assert A.get(0, 1, "mittens") == 2 - assert type(compute(A.get(0, 1))) is int + assert isinstance(compute(A.get(0, 1)), int) with pytest.raises(ValueError, match="Bad row, col"): # Not yet supported A.get(0, [0, 1]) diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index cf4c6fd41..ba9903169 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -128,14 +128,14 @@ def test_equal(s): def test_casting(s): assert int(s) == 5 - assert type(int(s)) is int + assert isinstance(int(s), int) assert float(s) == 5.0 - assert type(float(s)) is float + assert isinstance(float(s), float) assert range(s) == range(5) with pytest.raises(AttributeError, match="Scalar .* only .*__index__.*integral"): range(s.dup(float)) assert complex(s) == complex(5) - assert type(complex(s)) is complex + assert isinstance(complex(s), complex) def test_truthy(s): @@ -580,7 +580,7 @@ def test_record_from_dict(): def test_get(s): assert s.get() == 5 assert s.get("mittens") == 5 - assert type(compute(s.get())) is int + assert isinstance(compute(s.get()), int) s.clear() assert compute(s.get()) is None assert s.get("mittens") == "mittens" diff --git a/graphblas/tests/test_ss_utils.py b/graphblas/tests/test_ss_utils.py index 12c8c6329..81abe5804 100644 --- a/graphblas/tests/test_ss_utils.py +++ b/graphblas/tests/test_ss_utils.py @@ -4,6 +4,7 @@ import graphblas as gb from graphblas import Matrix, Vector, backend +from graphblas.exceptions import InvalidValue if backend != "suitesparse": pytest.skip("gb.ss and A.ss only available with suitesparse backend", allow_module_level=True) @@ -234,3 +235,62 @@ def test_global_config(): with pytest.raises(ValueError, match="Wrong number"): config["memory_pool"] = [1, 2] assert "format" in repr(config) + + +@pytest.mark.skipif("gb.core.ss._IS_SSGB7") +def test_context(): + context = gb.ss.Context() + prev = dict(context) + context["chunk"] += 1 + context["nthreads"] += 1 + assert context["chunk"] == prev["chunk"] + 1 + assert context["nthreads"] == prev["nthreads"] + 1 + context2 = gb.ss.Context(stack=True) + assert context2 == context + context3 = gb.ss.Context(stack=False) + assert context3 == prev + context4 = gb.ss.Context( + chunk=context["chunk"] + 1, nthreads=context["nthreads"] + 1, stack=False + ) + assert context4["chunk"] == context["chunk"] + 1 + assert context4["nthreads"] == context["nthreads"] + 1 + assert context == context.dup() + assert context4 == context.dup(chunk=context["chunk"] + 1, nthreads=context["nthreads"] + 1) + assert context.dup(gpu_id=-1)["gpu_id"] == -1 + + context.engage() + assert gb.core.ss.context.threadlocal.context is context + with gb.ss.Context(nthreads=1) as ctx: + assert gb.core.ss.context.threadlocal.context is ctx + v = Vector(int, 5) + v(nthreads=2) << v + v + assert gb.core.ss.context.threadlocal.context is ctx + assert gb.core.ss.context.threadlocal.context is context + with pytest.raises(InvalidValue): + # Wait, why does this raise?! + ctx.disengage() + assert gb.core.ss.context.threadlocal.context is context + context.disengage() + assert gb.core.ss.context.threadlocal.context is gb.core.ss.context.global_context + assert context._prev_context is None + + # hackery + gb.core.ss.context.threadlocal.context = context + context.disengage() + context.disengage() + context.disengage() + assert gb.core.ss.context.threadlocal.context is gb.core.ss.context.global_context + + # Actually engaged, but not set in threadlocal + context._engage() + assert gb.core.ss.context.threadlocal.context is gb.core.ss.context.global_context + context.disengage() + + context.engage() + context._engage() + assert gb.core.ss.context.threadlocal.context is context + context.disengage() + + context._context = context # This is allowed to work with config + with pytest.raises(AttributeError, match="_context"): + context._context = ctx # This is not diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index e321d3e9b..2571f288b 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -2440,7 +2440,7 @@ def test_get(v): assert v.get(0, "mittens") == "mittens" assert v.get(1) == 1 assert v.get(1, "mittens") == 1 - assert type(compute(v.get(1))) is int + assert isinstance(compute(v.get(1)), int) with pytest.raises(ValueError, match="Bad index in Vector.get"): # Not yet supported v.get([0, 1]) diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index f849c1329..56bac1b64 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -3,11 +3,11 @@ # Use, adjust, copy/paste, etc. as necessary to answer your questions. # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. -conda search 'numpy[channel=conda-forge]>=1.25.1' +conda search 'numpy[channel=conda-forge]>=1.25.2' conda search 'pandas[channel=conda-forge]>=2.0.3' conda search 'scipy[channel=conda-forge]>=1.11.1' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.3.1' +conda search 'awkward[channel=conda-forge]>=2.3.2' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.2' conda search 'numba[channel=conda-forge]>=0.57.1' From 578cab9e2f76dff79f3c3fc9835b822d380396d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Sep 2023 12:51:35 -0500 Subject: [PATCH 47/87] Bump actions/checkout from 3 to 4 (#499) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. --- .github/workflows/debug.yml | 2 +- .github/workflows/imports.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/publish_pypi.yml | 2 +- .github/workflows/test_and_build.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml index 389905db5..c9dc231fe 100644 --- a/.github/workflows/debug.yml +++ b/.github/workflows/debug.yml @@ -26,7 +26,7 @@ jobs: # - "conda-forge" steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup conda env diff --git a/.github/workflows/imports.yml b/.github/workflows/imports.yml index de9a7361d..753ce5162 100644 --- a/.github/workflows/imports.yml +++ b/.github/workflows/imports.yml @@ -46,7 +46,7 @@ jobs: # python-version: ["3.9", "3.10", "3.11"] # os: ["ubuntu-latest", "macos-latest", "windows-latest"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: ${{ needs.rngs.outputs.pyver }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 81d9415ad..e0945022c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: name: pre-commit-hooks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index e99dc2c0f..45a2b7880 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -14,7 +14,7 @@ jobs: shell: bash -l {0} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index cc2eb27b6..b1f0cfdba 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -98,7 +98,7 @@ jobs: KMP_DUPLICATE_LIB_OK: ${{ contains(matrix.os, 'macos') && 'TRUE' || 'FALSE' }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: RNG for Python version From 36a25badc1bf15f3b015422279af072e02c64c94 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 5 Sep 2023 13:59:52 -0500 Subject: [PATCH 48/87] MAINT: update versions of pandas, awkward, and pre-commit linting (#500) --- .github/workflows/test_and_build.yml | 12 ++++++------ .pre-commit-config.yaml | 8 ++++---- graphblas/exceptions.py | 2 +- pyproject.toml | 1 + scripts/check_versions.sh | 8 ++++---- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index b1f0cfdba..2f48048de 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -171,18 +171,18 @@ jobs: if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') - pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') - pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') else # Python 3.11 npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') - pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", "=2.1", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') fi if [[ ${{ steps.sourcetype.outputs.selected }} == "source" || ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then # TODO: there are currently issues with some numpy versions when diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e80d3e817..5a499e8f8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,13 +33,13 @@ repos: - id: name-tests-test args: ["--pytest-test-first"] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.13 + rev: v0.14 hooks: - id: validate-pyproject name: Validate pyproject.toml # I don't yet trust ruff to do what autoflake does - repo: https://github.com/PyCQA/autoflake - rev: v2.2.0 + rev: v2.2.1 hooks: - id: autoflake args: [--in-place] @@ -66,7 +66,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.284 + rev: v0.0.287 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.284 + rev: v0.0.287 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/graphblas/exceptions.py b/graphblas/exceptions.py index 0acc9ed0b..e7f3b3a83 100644 --- a/graphblas/exceptions.py +++ b/graphblas/exceptions.py @@ -121,7 +121,7 @@ def check_status(response_code, args): return if response_code == GrB_NO_VALUE: return NoValue - if type(args) is list: + if isinstance(args, list): arg = args[0] else: arg = args diff --git a/pyproject.toml b/pyproject.toml index 499faa2c3..619ce18f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -291,6 +291,7 @@ select = [ ] external = [ # noqa codes that ruff doesn't know about: https://github.com/charliermarsh/ruff#external + "F811", ] ignore = [ # Would be nice to fix these diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 56bac1b64..1a3e894a6 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -4,14 +4,14 @@ # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. conda search 'numpy[channel=conda-forge]>=1.25.2' -conda search 'pandas[channel=conda-forge]>=2.0.3' -conda search 'scipy[channel=conda-forge]>=1.11.1' +conda search 'pandas[channel=conda-forge]>=2.1.0' +conda search 'scipy[channel=conda-forge]>=1.11.2' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.3.2' +conda search 'awkward[channel=conda-forge]>=2.4.1' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.2' conda search 'numba[channel=conda-forge]>=0.57.1' -conda search 'pyyaml[channel=conda-forge]>=6.0' +conda search 'pyyaml[channel=conda-forge]>=6.0.1' conda search 'flake8-bugbear[channel=conda-forge]>=23.7.10' conda search 'flake8-simplify[channel=conda-forge]>=0.20.0' # conda search 'python[channel=conda-forge]>=3.9 *pypy*' From 717cbdac9efda857ebb3756d20a7a673646d473e Mon Sep 17 00:00:00 2001 From: William Zijie Zhang <89562186+Transurgeon@users.noreply.github.com> Date: Thu, 7 Sep 2023 14:57:28 -0400 Subject: [PATCH 49/87] fixing broken link to graphblas.org (#503) Co-authored-by: Transurgeon --- docs/getting_started/primer.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting_started/primer.rst b/docs/getting_started/primer.rst index 104eb5738..b5bec26ee 100644 --- a/docs/getting_started/primer.rst +++ b/docs/getting_started/primer.rst @@ -263,7 +263,7 @@ and showing that linear algebra can be used to compute graph algorithms with the of semirings. This is a somewhat new field of research, so many academic papers and talks are being given every year. -`Graphblas.org `_ remains the best source for keeping up-to-date with the latest +`Graphblas.org `_ remains the best source for keeping up-to-date with the latest developments in this area. Many people will benefit from faster graph algorithms written in GraphBLAS, but for those that want From ad6c2da98cb309e5ca640f0f376b970a594a5f9c Mon Sep 17 00:00:00 2001 From: Adam Lugowski Date: Thu, 7 Sep 2023 12:44:08 -0700 Subject: [PATCH 50/87] DOC: add a matrix style (#502) --- docs/_static/matrix.css | 104 ++++++++++++++++++++++++++ docs/conf.py | 2 +- docs/user_guide/io.rst | 1 + docs/user_guide/operations.rst | 132 ++++++++++++++++++++------------- docs/user_guide/operators.rst | 11 +++ docs/user_guide/udf.rst | 2 + 6 files changed, 200 insertions(+), 52 deletions(-) create mode 100644 docs/_static/matrix.css diff --git a/docs/_static/matrix.css b/docs/_static/matrix.css new file mode 100644 index 000000000..5700ea3fc --- /dev/null +++ b/docs/_static/matrix.css @@ -0,0 +1,104 @@ +/* Based on the stylesheet used by matrepr (https://github.com/alugowski/matrepr) and modified for sphinx */ + +table.matrix { + border-collapse: collapse; + border: 0px; +} + +/* Disable a horizintal line from the default stylesheet */ +.table.matrix > :not(caption) > * > * { + border-bottom-width: 0px; +} + +/* row indices */ +table.matrix > tbody tr th { + font-size: smaller; + font-weight: bolder; + vertical-align: middle; + text-align: right; +} +/* row indices are often made bold in the source data; here make them match the boldness of the th column label style*/ +table.matrix strong { + font-weight: bold; +} + +/* column indices */ +table.matrix > thead tr th { + font-size: smaller; + font-weight: bolder; + vertical-align: middle; + text-align: center; +} + +/* cells */ +table.matrix > tbody tr td { + vertical-align: middle; + text-align: center; + position: relative; +} + +/* left border */ +table.matrix > tbody tr td:first-of-type { + border-left: solid 2px var(--pst-color-text-base); +} +/* right border */ +table.matrix > tbody tr td:last-of-type { + border-right: solid 2px var(--pst-color-text-base); +} + +/* prevents empty cells from collapsing, especially empty rows */ +table.matrix > tbody tr td:empty::before { + /* basicaly fills empty cells with   */ + content: "\00a0\00a0\00a0"; + visibility: hidden; +} +table.matrix > tbody tr td:empty::after { + content: "\00a0\00a0\00a0"; + visibility: hidden; +} + +/* matrix bracket ticks */ +table.matrix > tbody > tr:first-child > td:first-of-type::before { + content: ""; + width: 4px; + position: absolute; + top: 0; + bottom: 0; + visibility: visible; + left: 0; + right: auto; + border-top: solid 2px var(--pst-color-text-base); +} +table.matrix > tbody > tr:last-child > td:first-of-type::before { + content: ""; + width: 4px; + position: absolute; + top: 0; + bottom: 0; + visibility: visible; + left: 0; + right: auto; + border-bottom: solid 2px var(--pst-color-text-base); +} +table.matrix > tbody > tr:first-child > td:last-of-type::after { + content: ""; + width: 4px; + position: absolute; + top: 0; + bottom: 0; + visibility: visible; + left: auto; + right: 0; + border-top: solid 2px var(--pst-color-text-base); +} +table.matrix > tbody > tr:last-child > td:last-of-type::after { + content: ""; + width: 4px; + position: absolute; + top: 0; + bottom: 0; + visibility: visible; + left: auto; + right: 0; + border-bottom: solid 2px var(--pst-color-text-base); +} diff --git a/docs/conf.py b/docs/conf.py index 07a373203..2e6f616d8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -36,7 +36,7 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ["sphinx.ext.autodoc", "numpydoc", "sphinx_panels", "nbsphinx"] -html_css_files = ["custom.css"] +html_css_files = ["custom.css", "matrix.css"] html_js_files = ["custom.js"] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/user_guide/io.rst b/docs/user_guide/io.rst index c13fda5d6..ecb4c0862 100644 --- a/docs/user_guide/io.rst +++ b/docs/user_guide/io.rst @@ -29,6 +29,7 @@ array will match the collection dtype. v = gb.Vector.from_coo([1, 3, 6], [2, 3, 4], float, size=10) .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5,6,7,8,9,10 ,2.0,,3.0,,,4.0,,, diff --git a/docs/user_guide/operations.rst b/docs/user_guide/operations.rst index ede2efb06..3f710dc23 100644 --- a/docs/user_guide/operations.rst +++ b/docs/user_guide/operations.rst @@ -45,8 +45,9 @@ a Vector is treated as an nx1 column matrix. C << gb.semiring.min_plus(A @ B) # functional style .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2,3 + :stub-columns: 1 **0**,,2.0,5.0, **1**,,,1.5,4.25 @@ -54,8 +55,9 @@ a Vector is treated as an nx1 column matrix. **3**,,,, .. csv-table:: B - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,3.0,2.0 **1**,9.0,6.0, @@ -63,8 +65,9 @@ a Vector is treated as an nx1 column matrix. **3**,0.0,5.0, .. csv-table:: C << min_plus(A @ B) - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,11.0,8.0,6.0 **1**,4.25,4.5,2.5 @@ -90,8 +93,9 @@ a Vector is treated as an nx1 column matrix. w << gb.semiring.plus_times(A @ v) # functional style .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2,3 + :stub-columns: 1 **0**,,2.0,5.0, **1**,,,1.5,4.25 @@ -99,13 +103,13 @@ a Vector is treated as an nx1 column matrix. **3**,,,, .. csv-table:: v - :class: inline + :class: inline matrix :header: 0,1,2,3 10.0,20.0,,40.0 .. csv-table:: w << plus_times(A @ v) - :class: inline + :class: inline matrix :header: 0,1,2,3 40.0,170.0,20.0, @@ -127,14 +131,15 @@ a Vector is treated as an nx1 column matrix. u << gb.semiring.plus_plus(v @ B) # functional style .. csv-table:: v - :class: inline + :class: inline matrix :header: 0,1,2,3 10.0,20.0,,40.0 .. csv-table:: B - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,3.0,2.0 **1**,9.0,6.0, @@ -142,7 +147,7 @@ a Vector is treated as an nx1 column matrix. **3**,0.0,5.0, .. csv-table:: u << plus_plus(v @ B) - :class: inline + :class: inline matrix :header: 0,1,2 69.0,84.0,12.0 @@ -181,24 +186,27 @@ Example usage: C << gb.binary.min(A & B) # functional style .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,2.0,5.0 **1**,1.5,,4.0 **2**,,0.5, .. csv-table:: B - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,3.0,-2.0 **1**,0.0,6.0, **2**,,3.0,1.0 .. csv-table:: C << min(A & B) - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,2.0,-2.0 **1**,0.0,, @@ -265,24 +273,27 @@ should be used with the functional syntax, ``left_default`` and ``right_default` C << gb.binary.minus(A | B) # functional style .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,9.0,2.0,5.0 **1**,1.5,,4.0 **2**,,, .. csv-table:: B - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,4.0,0.0,-2.0 **1**,,, **2**,6.0,3.0,1.0 .. csv-table:: C << A.ewise_add(B, 'minus') - :class: inline + :class: inline matrix :header: ,0,1,2, + :stub-columns: 1 **0**,5.0,2.0,7.0 **1**,1.5,,4.0 @@ -310,24 +321,27 @@ should be used with the functional syntax, ``left_default`` and ``right_default` C << gb.binary.minus(A | B, left_default=0, right_default=0) # functional style .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,9.0,2.0,5.0 **1**,1.5,,4.0 **2**,,, .. csv-table:: B - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,4.0,0.0,-2.0 **1**,,, **2**,6.0,3.0,1.0 .. csv-table:: C << A.ewise_union(B, 'minus', 0, 0) - :class: inline + :class: inline matrix :header: ,0,1,2, + :stub-columns: 1 **0**,5.0,2.0,7.0 **1**,1.5,,4.0 @@ -362,13 +376,13 @@ Vector Slice Example: w << v[:4] .. csv-table:: v - :class: inline + :class: inline matrix :header: 0,1,2,3,4,5,6 10.0,2.0,,40.0,-5.0,,24.0 .. csv-table:: w << v[:4] - :class: inline + :class: inline matrix :header: 0,1,2,3 10.0,2.0,,40.0 @@ -387,16 +401,18 @@ Matrix List Example: C << A[[0, 2], :] .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,2.0,5.0 **1**,1.5,4.0, **2**,0.5,,-7.0 .. csv-table:: C << A[[0, 2], :] - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,2.0,5.0 **1**,0.5,,-7.0 @@ -434,23 +450,26 @@ Matrix-Matrix Assignment Example: A[::2, ::2] << B .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,2.0,5.0 **1**,1.5,4.0, **2**,0.5,,-7.0 .. csv-table:: B - :class: inline + :class: inline matrix :header: ,0,1 + :stub-columns: 1 **0**,-99.0,-98.0 **1**,-97.0,-96.0 .. csv-table:: A[::2, ::2] << B - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,-99.0,2.0,-98.0 **1**,1.5,4.0, @@ -470,22 +489,24 @@ Matrix-Vector Assignment Example: A[1, :] << v .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,2.0,5.0 **1**,1.5,4.0, **2**,0.5,,-7.0 .. csv-table:: v - :class: inline + :class: inline matrix :header: 0,1,2 ,,-99.0 .. csv-table:: A[1, :] << v - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,2.0,5.0 **1**,,,-99.0 @@ -500,13 +521,13 @@ Vector-Scalar Assignment Example: v[:4] << 99 .. csv-table:: v - :class: inline + :class: inline matrix :header: 0,1,2,3,4,5,6 10,2,,40,-5,,24 .. csv-table:: v[:4] << 99 - :class: inline + :class: inline matrix :header: 0,1,2,3,4,5,6 99,99,99,99,-5,,24 @@ -535,13 +556,13 @@ function with the collection as the argument. w << gb.unary.minv(v) .. csv-table:: v - :class: inline + :class: inline matrix :header: 0,1,2,3 10.0,20.0,,40.0 .. csv-table:: w << minv(v) - :class: inline + :class: inline matrix :header: 0,1,2,3 0.1,0.05,,0.025 @@ -558,13 +579,13 @@ function with the collection as the argument. w << gb.indexunary.index(v) .. csv-table:: v - :class: inline + :class: inline matrix :header: 0,1,2,3 10.0,20.0,,40.0 .. csv-table:: w << index(v) - :class: inline + :class: inline matrix :header: 0,1,2,3 0,1,,3 @@ -582,13 +603,13 @@ function with the collection as the argument. w << v - 15 .. csv-table:: v - :class: inline + :class: inline matrix :header: 0,1,2,3 10.0,20.0,,40.0 .. csv-table:: w << v.apply('minus', right=15) - :class: inline + :class: inline matrix :header: 0,1,2,3, -5.0,5.0,,25.0 @@ -616,16 +637,18 @@ Upper Triangle Example: C << gb.select.triu(A) .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,2.0,5.0 **1**,1.5,,4.0 **2**,,0.5,-7.0 .. csv-table:: C << select.triu(A) - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,2.0,5.0 **1**,,,4.0 @@ -643,13 +666,13 @@ Select by Value Example: w << gb.select.value(v >= 5) .. csv-table:: v - :class: inline + :class: inline matrix :header: 0,1,2,3,4,5,6 10.0,2.0,,40.0,-5.0,,24.0 .. csv-table:: w << select.value(v >= 5) - :class: inline + :class: inline matrix :header: 0,1,2,3,4,5,6 10.0,,,40.0,,,24.0 @@ -678,15 +701,16 @@ A monoid or aggregator is used to perform the reduction. w << A.reduce_columnwise("times") .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2,3 + :stub-columns: 1 **0**,,2.0,,5.0 **1**,1.5,4.0,, **2**,0.5,-7.0,, .. csv-table:: w << A.reduce_columnwise('times') - :class: inline + :class: inline matrix :header: ,0,1,2,3 ,0.75,-56.0,,5.0 @@ -705,15 +729,16 @@ A monoid or aggregator is used to perform the reduction. s << A.reduce_scalar("max") .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2,3 + :stub-columns: 1 **0**,,2.0,,5.0 **1**,1.5,4.0,, **2**,0.5,-7.0,, .. csv-table:: s << A.reduce_scalar('max') - :class: inline + :class: inline matrix :header: ,,,, 5.0 @@ -730,13 +755,13 @@ A monoid or aggregator is used to perform the reduction. s << gb.agg.argmin(v) .. csv-table:: v - :class: inline + :class: inline matrix :header: 0,1,2,3,4,5,6 10.0,2.0,,40.0,-5.0,,24.0 .. csv-table:: s << argmin(v) - :class: inline + :class: inline matrix :header: ,,, 4 @@ -761,16 +786,18 @@ To force the transpose to be computed by itself, use it by itself as the right-h C << A.T .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1,2,3 + :stub-columns: 1 **0**,,2.0,,5.0 **1**,1.5,4.0,, **2**,0.5,,-7.0, .. csv-table:: C << A.T - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,1.5,0.5 **1**,2.0,4.0, @@ -802,23 +829,26 @@ The Kronecker product uses a binary operator. C << A.kronecker(B, "times") .. csv-table:: A - :class: inline + :class: inline matrix :header: ,0,1 + :stub-columns: 1 **0**,1.0,-2.0 **1**,3.0, .. csv-table:: B - :class: inline + :class: inline matrix :header: ,0,1,2 + :stub-columns: 1 **0**,,2.0,5.0 **1**,1.5,4.0, **2**,0.5,,-7.0 .. csv-table:: C << A.kronecker(B, 'times') - :class: inline + :class: inline matrix :header: ,0,1,2,3,4,5 + :stub-columns: 1 **0**,,2.0,5.0,,-4.0,-10.0 **1**,1.5,4.0,,-3.0,-8.0, diff --git a/docs/user_guide/operators.rst b/docs/user_guide/operators.rst index ec28e2fba..8bb5e9fa8 100644 --- a/docs/user_guide/operators.rst +++ b/docs/user_guide/operators.rst @@ -314,12 +314,14 @@ each symbol. Each is detailed below. The following objects will be used to demonstrate the behavior. .. csv-table:: Vector v + :class: matrix :header: 0,1,2,3,4,5 1.0,,2.0,3.5,,9.0 .. csv-table:: Vector w + :class: matrix :header: 0,1,2,3,4,5 7.0,5.2,,3.0,,2.5 @@ -343,6 +345,7 @@ Addition performs an element-wise union between collections, adding overlapping v + w .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5 8.0,5.2,2.0,6.5,,11.5 @@ -358,6 +361,7 @@ and negating any standalone elements from the right-hand object. v - w .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5 -6.0,-5.2,2.0,0.5,,6.5 @@ -373,6 +377,7 @@ overlapping elements. v * w .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5 7.0,,,10.5,,22.5 @@ -392,6 +397,7 @@ elements and always results in a floating-point dtype. v / w .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5 0.142857,,,1.166667,,3.6 @@ -407,6 +413,7 @@ Dividing by zero with floor division will raise a ``ZeroDivisionError``. v // w .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5 0.0,,,1.0,,3.0 @@ -422,6 +429,7 @@ of dividing overlapping elements. v % w .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5 1.0,,,0.5,,1.5 @@ -437,6 +445,7 @@ the power of y for overlapping elements. v**w .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5 1.0,,,42.875,,243.0 @@ -455,6 +464,7 @@ rather than ``all(A == B)`` v > w .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5 False,,,True,,True @@ -464,6 +474,7 @@ rather than ``all(A == B)`` v == w .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5 False,,,False,,False diff --git a/docs/user_guide/udf.rst b/docs/user_guide/udf.rst index b96097a85..e7b984b44 100644 --- a/docs/user_guide/udf.rst +++ b/docs/user_guide/udf.rst @@ -27,6 +27,7 @@ Example user-defined UnaryOp: w = v.apply(unary.force_odd).new() .. csv-table:: w + :class: matrix :header: 0,1,2,3,4,5 1,3,,3,9,15 @@ -48,6 +49,7 @@ Example lambda usage: v.apply(lambda x: x % 5 - 2).new() .. csv-table:: + :class: matrix :header: 0,1,2,3,4,5 -1,0,,1,1,2 From bf28a799fdb0f9f91179036df469b26b3f04c5e6 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 22 Sep 2023 09:56:58 -0500 Subject: [PATCH 51/87] Add docstring for SuiteSparse:GraphBLAS 8 configs (#504) --- graphblas/ss/_core.py | 43 +++++++++++++++++++++++++++++--- graphblas/tests/test_ss_utils.py | 4 +-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/graphblas/ss/_core.py b/graphblas/ss/_core.py index 2639a7709..29a67e08b 100644 --- a/graphblas/ss/_core.py +++ b/graphblas/ss/_core.py @@ -122,7 +122,9 @@ class GlobalConfig(BaseConfig): Threshold that determines when to switch to bitmap format nthreads : int Maximum number of OpenMP threads to use - memory_pool : List[int] + chunk : double + Control the number of threads used for small problems. + For example, ``nthreads = floor(work / chunk)``. burble : bool Enable diagnostic printing from SuiteSparse:GraphBLAS print_1based : bool @@ -134,8 +136,43 @@ class GlobalConfig(BaseConfig): **GPU support is a work in progress--not recommended to use** gpu_id : int Which GPU to use; default is -1, which means do not run on the GPU. - Only available for SuiteSparse:GraphBLAS 8 + Only available for SuiteSparse:GraphBLAS >=8 **GPU support is a work in progress--not recommended to use** + jit_c_control : {"off", "pause", "run", "load", "on} + Control the CPU JIT: + "off" : do not use the JIT and free all JIT kernels if loaded + "pause" : do not run JIT kernels, but keep any loaded + "run" : run JIT kernels if already loaded, but don't load or compile + "load" : able to load and run JIT kernels; may not compile + "on" : full JIT: able to compile, load, and run + Only available for SuiteSparse:GraphBLAS >=8 + jit_use_cmake : bool + Whether to use cmake to compile the JIT kernels. + Only available for SuiteSparse:GraphBLAS >=8 + jit_c_compiler_name : str + C compiler for JIT kernels. + Only available for SuiteSparse:GraphBLAS >=8 + jit_c_compiler_flags : str + Flags for the C compiler. + Only available for SuiteSparse:GraphBLAS >=8 + jit_c_linker_flags : str + Link flags for the C compiler + Only available for SuiteSparse:GraphBLAS >=8 + jit_c_libraries : str + Libraries to link against. + Only available for SuiteSparse:GraphBLAS >=8 + jit_c_cmake_libs : str + Libraries to link against when cmake is used. + Only available for SuiteSparse:GraphBLAS >=8 + jit_c_preface : str + C code as preface to JIT kernels. + Only available for SuiteSparse:GraphBLAS >=8 + jit_error_log : str + Error log file. + Only available for SuiteSparse:GraphBLAS >=8 + jit_cache_path : str + The folder with the compiled kernels. + Only available for SuiteSparse:GraphBLAS >=8 Setting values to None restores the default value for most configurations. """ @@ -154,7 +191,7 @@ class GlobalConfig(BaseConfig): "nthreads": (lib.GxB_GLOBAL_NTHREADS, "int"), "chunk": (lib.GxB_GLOBAL_CHUNK, "double"), # Memory pool control - "memory_pool": (lib.GxB_MEMORY_POOL, "int64_t[64]"), + # "memory_pool": (lib.GxB_MEMORY_POOL, "int64_t[64]"), # No longer used # Diagnostics (skipping "printf" and "flush" for now) "burble": (lib.GxB_BURBLE, "bool"), "print_1based": (lib.GxB_PRINT_1BASED, "bool"), diff --git a/graphblas/tests/test_ss_utils.py b/graphblas/tests/test_ss_utils.py index 81abe5804..2df7ab939 100644 --- a/graphblas/tests/test_ss_utils.py +++ b/graphblas/tests/test_ss_utils.py @@ -232,8 +232,8 @@ def test_global_config(): else: with pytest.raises(ValueError, match="Unable to set default value for"): config[k] = None - with pytest.raises(ValueError, match="Wrong number"): - config["memory_pool"] = [1, 2] + # with pytest.raises(ValueError, match="Wrong number"): + # config["memory_pool"] = [1, 2] # No longer used assert "format" in repr(config) From 2d6faf2c26144a56d170506709f404de54ebe056 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 22 Sep 2023 10:37:03 -0500 Subject: [PATCH 52/87] Add `A.setdiag(x, k)` (#493) Also, support numpy 1.26.0 --- .github/workflows/test_and_build.yml | 11 ++- .pre-commit-config.yaml | 10 +- graphblas/core/matrix.py | 113 ++++++++++++++++++++++ graphblas/tests/test_matrix.py | 136 ++++++++++++++++++++++++++- graphblas/tests/test_ssjit.py | 5 + pyproject.toml | 3 + scripts/check_versions.sh | 12 +-- 7 files changed, 273 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 2f48048de..5f1ab7dde 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -169,17 +169,17 @@ jobs: sparsever=$(python -c 'import random ; print(random.choice(["=0.13", "=0.14", ""]))') fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') else # Python 3.11 - npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", "=1.26", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", "=2.1", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') @@ -206,7 +206,7 @@ jobs: else psgver="" fi - if [[ ${npver} == "=1.25" ]] ; then + if [[ ${npver} == "=1.25" || ${npver} == "=1.26" ]] ; then numbaver="" if [[ ${spver} == "=1.8" ]] ; then spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') @@ -243,7 +243,8 @@ jobs: pdver="" yamlver="" fi - elif [[ ${npver} == "=1.25" ]] ; then + elif [[ ${npver} == "=1.25" || ${npver} == "=1.26" ]] ; then + # Don't install numba for unsupported versions of numpy numba="" numbaver=NA sparse="" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5a499e8f8..a945fe49a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it - repo: https://github.com/asottile/pyupgrade - rev: v3.10.1 + rev: v3.12.0 hooks: - id: pyupgrade args: [--py39-plus] @@ -61,12 +61,12 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.287 + rev: v0.0.290 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -79,7 +79,7 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.1.0 - - flake8-bugbear==23.7.10 + - flake8-bugbear==23.9.16 - flake8-simplify==0.20.0 - repo: https://github.com/asottile/yesqa rev: v1.5.0 @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.287 + rev: v0.0.290 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index d820ca424..aed98f57d 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -2805,6 +2805,119 @@ def power(self, n, op=semiring.plus_times): dtype=self.dtype, ) + def setdiag(self, values, k=0, *, mask=None, accum=None, **opts): + """Set k'th diagonal with a Scalar, Vector, or array. + + This is not a built-in GraphBLAS operation. It is implemented as a recipe. + + Parameters + ---------- + values : Vector or list or np.ndarray or scalar + New values to assign to the diagonal. The length of Vector and array + values must match the size of the diagonal being assigned to. + k : int, default=0 + Which diagonal or off-diagonal to set. For example, set the elements + ``A[i, i+k] = values[i]``. The default, k=0, is the main diagonal. + mask : Mask, optional + Vector or Matrix Mask to control which diagonal elements to set. + If it is Matrix Mask, then only the diagonal is used as the mask. + accum : Monoid or BinaryOp, optional + Operator to use to combine existing diagonal values and new values. + """ + if (K := maybe_integral(k)) is None: + raise TypeError(f"k must be an integer; got bad type: {type(k)}") + k = K + if k < 0: + if (size := min(self._nrows + k, self._ncols)) <= 0 and k <= -self._nrows: + raise IndexError( + f"k={k} is too small; the k'th diagonal is out of range. " + f"Valid k for Matrix with shape {self._nrows}x{self._ncols}: " + f"{-self._nrows} {'<' if self._nrows else '<='} k " + f"{'<' if self._ncols else '<='} {self._ncols}" + ) + elif (size := min(self._ncols - k, self._nrows)) <= 0 and k > 0 and k >= self._ncols: + raise IndexError( + f"k={k} is too large; the k'th diagonal is out of range. " + f"Valid k for Matrix with shape {self._nrows}x{self._ncols}: " + f"{-self._nrows} {'<' if self._nrows else '<='} k " + f"{'<' if self._ncols else '<='} {self._ncols}" + ) + + # Convert `values` to Vector if necessary (i.e., it's scalar or array) + is_scalar = clear_diag = False + if output_type(values) is Vector: + v = values + clear_diag = accum is None and v._nvals != v._size + elif type(values) is Scalar: + is_scalar = True + else: + dtype = self.dtype if self.dtype._is_udt else None + try: + # Try to make it a Scalar + values = Scalar.from_value(values, dtype, is_cscalar=None, name="") + is_scalar = True + except (TypeError, ValueError): + try: + # Else try to make it a numpy array + values, dtype = values_to_numpy_buffer(values, dtype) + except Exception: + self._expect_type( + values, + (Scalar, Vector, np.ndarray), + within="setdiag", + argname="values", + extra_message="Literal scalars also accepted.", + ) + else: + v = Vector.from_dense(values, dtype=dtype, **opts) + + if is_scalar: + v = Vector.from_scalar(values, size, **opts) + elif v._size != size: + raise DimensionMismatch( + f"Dimensions not compatible for assigning length {v._size} Vector " + f"to {k}'th diagonal of Matrix with shape {self._nrows}x{self._ncols}." + f"The Vector should be size {size}." + ) + + if mask is not None: + mask = _check_mask(mask) + if mask.parent.ndim == 2: + if mask.parent.shape != self.shape: + raise DimensionMismatch( + "Matrix mask in setdiag is the wrong shape; " + f"expected shape {self._nrows}x{self._ncols}, " + f"got {mask.parent._nrows}x{mask.parent._ncols}" + ) + if mask.complement: + mval = type(mask)(mask.parent.diag(k)).new(**opts) + mask = mval.S + M = mval.diag() + else: + M = select.diag(mask.parent, k).new(**opts) + elif mask.parent._size != size: + raise DimensionMismatch( + "Vector mask in setdiag is the wrong length; " + f"expected size {size}, got size {mask.parent._size}." + ) + else: + if mask.complement: + mask = mask.new(**opts).S + M = mask.parent.diag() + if M.shape != self.shape: + M.resize(self._nrows, self._ncols) + mask = type(mask)(M) + + if clear_diag: + self(mask=mask, **opts) << select.offdiag(self, k) + + Diag = v.diag(k) + if Diag.shape != self.shape: + Diag.resize(self._nrows, self._ncols) + if mask is None: + mask = Diag.S + self(accum=accum, mask=mask, **opts) << Diag + ################################## # Extract and Assign index methods ################################## diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index fe85bb9bf..e08f96b32 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -2940,6 +2940,7 @@ def test_expr_is_like_matrix(A): "from_scalar", "from_values", "resize", + "setdiag", "update", } ignore = {"__sizeof__"} @@ -3002,9 +3003,10 @@ def test_index_expr_is_like_matrix(A): "from_dense", "from_dicts", "from_edgelist", - "from_values", "from_scalar", + "from_values", "resize", + "setdiag", } ignore = {"__sizeof__"} assert attrs - expr_attrs - ignore == expected, ( @@ -4393,3 +4395,135 @@ def test_power(A): B = A[:2, :3].new() with pytest.raises(DimensionMismatch): B.power(2) + + +def test_setdiag(): + A = Matrix(int, 2, 3) + A.setdiag(1) + expected = Matrix(int, 2, 3) + expected[0, 0] = 1 + expected[1, 1] = 1 + assert A.isequal(expected) + A.setdiag(Scalar.from_value(2), 2) + expected[0, 2] = 2 + assert A.isequal(expected) + A.setdiag(3, k=-1) + expected[1, 0] = 3 + assert A.isequal(expected) + # List (or array) is treated as dense + A.setdiag([10, 20], 1) + expected[0, 1] = 10 + expected[1, 2] = 20 + assert A.isequal(expected) + # Size 0 diagonals, which does not set anything. + # This could be valid (esp. given a size 0 vector), but let's raise for now. + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag(-1, 3) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag(-1, -2) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag([], 3) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag(Vector(int, 0), -2) + # Now we're definitely out of bounds + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag(-1, 4) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag(-1, -3) + with pytest.raises(TypeError, match="k must be an integer"): + A.setdiag(-1, 0.5) + with pytest.raises(TypeError, match="Bad type for argument `values` in Matrix.setdiag"): + A.setdiag(object()) + with pytest.raises(DimensionMismatch, match="Dimensions not compatible"): + A.setdiag([10, 20, 30], 1) + with pytest.raises(DimensionMismatch, match="Dimensions not compatible"): + A.setdiag([10], 1) + + # Special care for dimensions of length 0 + A = Matrix(int, 0, 2, name="A") + A.setdiag(0, 0) + A.setdiag(0, 1) + A.setdiag([], 0) + A.setdiag([], 1) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag(0, -1) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag([], -1) + A = Matrix(int, 2, 0, name="A") + A.setdiag(0, 0) + A.setdiag(0, -1) + A.setdiag([], 0) + A.setdiag([], -1) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag(0, 1) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag([], 1) + A = Matrix(int, 0, 0, name="A") + A.setdiag(0, 0) + A.setdiag([], 0) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag(0, 1) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag([], 1) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag(0, -1) + with pytest.raises(IndexError, match="diagonal is out of range"): + A.setdiag([], -1) + + A = Matrix(int, 2, 2, name="A") + expected = Matrix(int, 2, 2, name="expected") + v = Vector(int, 2, name="v") + Vector(int, 2) + v[0] = 1 + A.setdiag(v) + expected[0, 0] = 1 + assert A.isequal(expected) + A.setdiag(v, accum=binary.plus) + expected[0, 0] = 2 + assert A.isequal(expected) + A.setdiag(10, mask=v.S) + expected[0, 0] = 10 + assert A.isequal(expected) + A.setdiag(10, mask=v.S, accum="+") + expected[0, 0] = 20 + assert A.isequal(expected) + # Allow mask to be a matrix + A.setdiag(10, mask=A.S, accum="+") + expected[0, 0] = 30 + assert A.isequal(expected) + # Test how to clear or not clear missing elements + A.clear() + A.setdiag(99) + A.setdiag(v) + expected[0, 0] = 1 + assert A.isequal(expected) + A.setdiag(99) + A.setdiag(v, accum="second") + expected[1, 1] = 99 + assert A.isequal(expected) + A.setdiag(99) + A.setdiag(v, mask=v.S) + assert A.isequal(expected) + + # We handle complemented masks! + A.clear() + expected.clear() + A.setdiag(42, mask=~v.S) + expected[1, 1] = 42 + assert A.isequal(expected) + A.setdiag(7, mask=~A.V) + expected[0, 0] = 7 + assert A.isequal(expected) + + with pytest.raises(DimensionMismatch, match="Matrix mask in setdiag is the wrong "): + A.setdiag(9, mask=Matrix(int, 3, 3).S) + with pytest.raises(DimensionMismatch, match="Vector mask in setdiag is the wrong "): + A.setdiag(10, mask=Vector(int, 3).S) + + A.clear() + A.resize(2, 3) + expected.clear() + expected.resize(2, 3) + A.setdiag(30, mask=v.S) + expected[0, 0] = 30 + assert A.isequal(expected) diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index 57cb2bbba..bd05cf2db 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -1,4 +1,5 @@ import os +import pathlib import sys import numpy as np @@ -82,6 +83,10 @@ def _setup_jit(): gb.ss.config["jit_c_libraries"] = "" gb.ss.config["jit_c_cmake_libs"] = "" + if not pathlib.Path(gb.ss.config["jit_c_compiler_name"]).exists(): + # Can't use the JIT if we don't have a compiler! + gb.ss.config["jit_c_control"] = "off" + @pytest.fixture def v(): diff --git a/pyproject.toml b/pyproject.toml index 619ce18f2..ff970cc0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -235,6 +235,9 @@ ignore-words-list = "coo,ba" # https://github.com/charliermarsh/ruff/ line-length = 100 target-version = "py39" +unfixable = [ + "F841" # unused-variable (Note: can leave useless expression) +] select = [ # Have we enabled too many checks that they'll become a nuisance? We'll see... "F", # pyflakes diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 1a3e894a6..a76fee1d2 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -3,15 +3,15 @@ # Use, adjust, copy/paste, etc. as necessary to answer your questions. # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. -conda search 'numpy[channel=conda-forge]>=1.25.2' -conda search 'pandas[channel=conda-forge]>=2.1.0' +conda search 'flake8-bugbear[channel=conda-forge]>=23.9.16' +conda search 'flake8-simplify[channel=conda-forge]>=0.20.0' +conda search 'numpy[channel=conda-forge]>=1.26.0' +conda search 'pandas[channel=conda-forge]>=2.1.1' conda search 'scipy[channel=conda-forge]>=1.11.2' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.4.1' +conda search 'awkward[channel=conda-forge]>=2.4.3' conda search 'sparse[channel=conda-forge]>=0.14.0' -conda search 'fast_matrix_market[channel=conda-forge]>=1.7.2' +conda search 'fast_matrix_market[channel=conda-forge]>=1.7.3' conda search 'numba[channel=conda-forge]>=0.57.1' conda search 'pyyaml[channel=conda-forge]>=6.0.1' -conda search 'flake8-bugbear[channel=conda-forge]>=23.7.10' -conda search 'flake8-simplify[channel=conda-forge]>=0.20.0' # conda search 'python[channel=conda-forge]>=3.9 *pypy*' From add56c4dd0455f5d9660d713d12828884396ebfd Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 26 Sep 2023 13:29:22 -0500 Subject: [PATCH 53/87] Try to fix failing test (#505) --- graphblas/tests/test_io.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index bf2ca2015..6ad92a950 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -377,7 +377,7 @@ def test_scipy_sparse(): @pytest.mark.skipif("not ak") -@pytest.mark.xfail(np.__version__[:5] == "1.25.", reason="awkward bug with numpy 1.25") +@pytest.mark.xfail(np.__version__[:5] in {"1.25.", "1.26."}, reason="awkward bug with numpy >=1.25") def test_awkward_roundtrip(): # Vector v = gb.Vector.from_coo([1, 3, 5], [20, 21, -5], size=22) @@ -399,7 +399,7 @@ def test_awkward_roundtrip(): @pytest.mark.skipif("not ak") -@pytest.mark.xfail(np.__version__[:5] == "1.25.", reason="awkward bug with numpy 1.25") +@pytest.mark.xfail(np.__version__[:5] in {"1.25.", "1.26."}, reason="awkward bug with numpy >=1.25") def test_awkward_iso_roundtrip(): # Vector v = gb.Vector.from_coo([1, 3, 5], [20, 20, 20], size=22) From 1964ebbbb87f1fd1c6aac16c851220f09a723881 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Oct 2023 18:00:40 -0500 Subject: [PATCH 54/87] chore: update pre-commit hooks (#507) --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a945fe49a..bff5b80cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it - repo: https://github.com/asottile/pyupgrade - rev: v3.12.0 + rev: v3.13.0 hooks: - id: pyupgrade args: [--py39-plus] @@ -66,7 +66,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.290 + rev: v0.0.292 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.290 + rev: v0.0.292 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint @@ -110,7 +110,7 @@ repos: - id: pyroma args: [-n, "10", .] - repo: https://github.com/shellcheck-py/shellcheck-py - rev: "v0.9.0.5" + rev: "v0.9.0.6" hooks: - id: shellcheck - repo: local From 43deb66f5ded8f37e303e9dc1bf525653fe336a5 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 7 Oct 2023 16:40:10 -0500 Subject: [PATCH 55/87] Add notebook for creating (and exploring) logos (#506) * Add notebook for creating (and exploring) logos * Install drawsvg when testing notebooks * Add docs/_static/img/python-graphblas-logo.svg * update text logos * Add horizontal and vertical logos (w/ img and text) and update usage --- .github/workflows/test_and_build.yml | 9 +- .pre-commit-config.yaml | 6 +- README.md | 2 +- binder/environment.yml | 4 +- docs/_static/img/logo-horizontal-dark.svg | 1 + docs/_static/img/logo-horizontal-light.svg | 1 + .../img/logo-horizontal-medium-big.svg | 1 + docs/_static/img/logo-horizontal-medium.svg | 1 + docs/_static/img/logo-name-light.svg | 2 +- docs/_static/img/logo-name-medium-big.svg | 1 + docs/_static/img/logo-name-medium.svg | 2 +- docs/_static/img/logo-vertical-dark.svg | 1 + docs/_static/img/logo-vertical-light.svg | 1 + docs/_static/img/logo-vertical-medium.svg | 1 + docs/_static/img/python-graphblas-logo.svg | 1 + docs/conf.py | 6 +- environment.yml | 3 + notebooks/logos_and_colors.ipynb | 1467 +++++++++++++++++ scripts/check_versions.sh | 6 +- 19 files changed, 1502 insertions(+), 14 deletions(-) create mode 100644 docs/_static/img/logo-horizontal-dark.svg create mode 100644 docs/_static/img/logo-horizontal-light.svg create mode 100644 docs/_static/img/logo-horizontal-medium-big.svg create mode 100644 docs/_static/img/logo-horizontal-medium.svg create mode 100644 docs/_static/img/logo-name-medium-big.svg create mode 100644 docs/_static/img/logo-vertical-dark.svg create mode 100644 docs/_static/img/logo-vertical-light.svg create mode 100644 docs/_static/img/logo-vertical-medium.svg create mode 100644 docs/_static/img/python-graphblas-logo.svg create mode 100644 notebooks/logos_and_colors.ipynb diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 5f1ab7dde..e3930a853 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -206,7 +206,12 @@ jobs: else psgver="" fi - if [[ ${npver} == "=1.25" || ${npver} == "=1.26" ]] ; then + if [[ ${npver} == "=1.26" ]] ; then + numbaver="" + if [[ ${spver} == "=1.8" || ${spver} == "=1.9" ]] ; then + spver=$(python -c 'import random ; print(random.choice(["=1.10", "=1.11", ""]))') + fi + elif [[ ${npver} == "=1.25" ]] ; then numbaver="" if [[ ${spver} == "=1.8" ]] ; then spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') @@ -260,7 +265,7 @@ jobs: pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \ networkx${nxver} ${numba} ${fmm} ${psg} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ - ${{ matrix.slowtask == 'notebooks' && 'matplotlib nbconvert jupyter "ipython>=7"' || '' }} \ + ${{ matrix.slowtask == 'notebooks' && 'matplotlib nbconvert jupyter "ipython>=7" drawsvg' || '' }} \ ${{ steps.sourcetype.outputs.selected == 'upstream' && 'cython' || '' }} \ ${{ steps.sourcetype.outputs.selected != 'wheel' && '"graphblas>=7.4"' || '' }} \ ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'pypy' || '' }} \ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bff5b80cd..565e1dc0d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it - repo: https://github.com/asottile/pyupgrade - rev: v3.13.0 + rev: v3.14.0 hooks: - id: pyupgrade args: [--py39-plus] @@ -80,14 +80,14 @@ repos: # These versions need updated manually - flake8==6.1.0 - flake8-bugbear==23.9.16 - - flake8-simplify==0.20.0 + - flake8-simplify==0.21.0 - repo: https://github.com/asottile/yesqa rev: v1.5.0 hooks: - id: yesqa additional_dependencies: *flake8_dependencies - repo: https://github.com/codespell-project/codespell - rev: v2.2.5 + rev: v2.2.6 hooks: - id: codespell types_or: [python, rst, markdown] diff --git a/README.md b/README.md index 4581ef54a..4509e44ac 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Python-graphblas](https://raw.githubusercontent.com/python-graphblas/python-graphblas/main/docs/_static/img/logo-name-medium.svg) +![Python-graphblas](https://raw.githubusercontent.com/python-graphblas/python-graphblas/main/docs/_static/img/logo-horizontal-medium-big.svg) [![conda-forge](https://img.shields.io/conda/vn/conda-forge/python-graphblas.svg)](https://anaconda.org/conda-forge/python-graphblas) [![pypi](https://img.shields.io/pypi/v/python-graphblas.svg)](https://pypi.python.org/pypi/python-graphblas/) diff --git a/binder/environment.yml b/binder/environment.yml index ef72a4d2b..11cd98e0c 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -2,9 +2,11 @@ name: graphblas channels: - conda-forge dependencies: - - python=3.10 + - python=3.11 - python-graphblas - matplotlib - networkx - pandas - scipy + - drawsvg + - cairosvg diff --git a/docs/_static/img/logo-horizontal-dark.svg b/docs/_static/img/logo-horizontal-dark.svg new file mode 100644 index 000000000..be9e5ccca --- /dev/null +++ b/docs/_static/img/logo-horizontal-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/img/logo-horizontal-light.svg b/docs/_static/img/logo-horizontal-light.svg new file mode 100644 index 000000000..5894eed9a --- /dev/null +++ b/docs/_static/img/logo-horizontal-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/img/logo-horizontal-medium-big.svg b/docs/_static/img/logo-horizontal-medium-big.svg new file mode 100644 index 000000000..649c2aef3 --- /dev/null +++ b/docs/_static/img/logo-horizontal-medium-big.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/img/logo-horizontal-medium.svg b/docs/_static/img/logo-horizontal-medium.svg new file mode 100644 index 000000000..038781a3f --- /dev/null +++ b/docs/_static/img/logo-horizontal-medium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/img/logo-name-light.svg b/docs/_static/img/logo-name-light.svg index e9d9738ee..3331ae561 100644 --- a/docs/_static/img/logo-name-light.svg +++ b/docs/_static/img/logo-name-light.svg @@ -1 +1 @@ - + diff --git a/docs/_static/img/logo-name-medium-big.svg b/docs/_static/img/logo-name-medium-big.svg new file mode 100644 index 000000000..7bb245898 --- /dev/null +++ b/docs/_static/img/logo-name-medium-big.svg @@ -0,0 +1 @@ + diff --git a/docs/_static/img/logo-name-medium.svg b/docs/_static/img/logo-name-medium.svg index 2c718ba26..3128fda35 100644 --- a/docs/_static/img/logo-name-medium.svg +++ b/docs/_static/img/logo-name-medium.svg @@ -1 +1 @@ - + diff --git a/docs/_static/img/logo-vertical-dark.svg b/docs/_static/img/logo-vertical-dark.svg new file mode 100644 index 000000000..25dcefc17 --- /dev/null +++ b/docs/_static/img/logo-vertical-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/img/logo-vertical-light.svg b/docs/_static/img/logo-vertical-light.svg new file mode 100644 index 000000000..1cb22644d --- /dev/null +++ b/docs/_static/img/logo-vertical-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/img/logo-vertical-medium.svg b/docs/_static/img/logo-vertical-medium.svg new file mode 100644 index 000000000..db2fcaefe --- /dev/null +++ b/docs/_static/img/logo-vertical-medium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_static/img/python-graphblas-logo.svg b/docs/_static/img/python-graphblas-logo.svg new file mode 100644 index 000000000..2422973ff --- /dev/null +++ b/docs/_static/img/python-graphblas-logo.svg @@ -0,0 +1 @@ + diff --git a/docs/conf.py b/docs/conf.py index 2e6f616d8..283f6d047 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,14 +55,16 @@ # html_theme = "pydata_sphinx_theme" +html_favicon = "_static/img/python-graphblas-logo.svg" + # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] html_theme_options = { "logo": { - "image_light": "_static/img/logo-name-light.svg", - "image_dark": "_static/img/logo-name-dark.svg", + "image_light": "_static/img/logo-horizontal-light.svg", + "image_dark": "_static/img/logo-horizontal-dark.svg", }, "github_url": "https://github.com/python-graphblas/python-graphblas", } diff --git a/environment.yml b/environment.yml index 1a7fb6fa8..4455f4ac6 100644 --- a/environment.yml +++ b/environment.yml @@ -48,6 +48,9 @@ dependencies: - numpydoc - pydata-sphinx-theme - sphinx-panels + # For building logo + - drawsvg + - cairosvg # EXTRA (optional; uncomment as desired) # - autoflake # - black diff --git a/notebooks/logos_and_colors.ipynb b/notebooks/logos_and_colors.ipynb new file mode 100644 index 000000000..7b64a2208 --- /dev/null +++ b/notebooks/logos_and_colors.ipynb @@ -0,0 +1,1467 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1ade2e62-38f4-4017-a0d3-e09f8587c376", + "metadata": {}, + "source": [ + "# Logos and Color Palette of Python-graphblas\n", + "\n", + "To create a minimal environment to run this notebook:\n", + "```bash\n", + "$ conda create -n drawsvg -c conda-forge drawsvg cairosvg scipy jupyter\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "bf42676c-190a-4803-a567-09e0ed260d6a", + "metadata": {}, + "outputs": [], + "source": [ + "import drawsvg as draw\n", + "import numpy as np\n", + "from scipy.spatial.transform import Rotation" + ] + }, + { + "cell_type": "markdown", + "id": "876a6128-94e4-4fb0-938d-0980a2033701", + "metadata": {}, + "source": [ + "## Define color palette" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "786f9c9e-d999-4286-bf79-009ca1681604", + "metadata": {}, + "outputs": [], + "source": [ + "# primary\n", + "blue = \"#409DC1\"\n", + "orange = \"#FF8552\"\n", + "dark_gray = \"#39393A\"\n", + "light_gray = \"#C3C3C7\"\n", + "\n", + "# Neutral, light/dark compatible\n", + "medium_gray = \"#848487\"\n", + "\n", + "# secondary\n", + "light_blue = \"#81B7CC\"\n", + "light_orange = \"#FFBB9E\"\n", + "red = \"#6D213C\"\n", + "light_red = \"#BA708A\"\n", + "green = \"#85FFC7\"\n", + "\n", + "french_rose = \"#FA4B88\" # ;)" + ] + }, + { + "cell_type": "markdown", + "id": "adb66550-f1e8-4846-a12a-e178fe801295", + "metadata": {}, + "source": [ + "## Display color palette" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "983b0cb8-db8b-4ad0-ad5a-36975d59289e", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "Primary\n", + "\n", + "#409DC1\n", + "\n", + "#FF8552\n", + "\n", + "#39393A\n", + "\n", + "#C3C3C7\n", + "\n", + "#848487\n", + "Secondary\n", + "\n", + "#81B7CC\n", + "\n", + "#FFBB9E\n", + "\n", + "#6D213C\n", + "\n", + "#BA708A\n", + "\n", + "#85FFC7\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "d = draw.Drawing(750, 500, origin=\"center\")\n", + "d.append(\n", + " draw.Rectangle(-375, -250, 750, 500, fill=\"white\")\n", + ") # Add `stroke=\"black\"` border to see boundaries for testing\n", + "\n", + "dy = 25\n", + "dx = 0\n", + "w = h = 150\n", + "b = 25\n", + "x = -400 + 62.5 + dx\n", + "y = -200 + dy\n", + "\n", + "d.draw(\n", + " draw.Text(\n", + " \"Primary\",\n", + " x=x + 1.5 * (b + w) + w / 2,\n", + " y=y - b,\n", + " font_size=1.5 * b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"black\",\n", + " )\n", + ")\n", + "d.draw(draw.Rectangle(x, y, w, h, fill=blue))\n", + "d.draw(\n", + " draw.Text(\n", + " blue.upper(),\n", + " x=x + w / 2,\n", + " y=y + h - b,\n", + " font_size=b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"black\",\n", + " )\n", + ")\n", + "d.draw(draw.Rectangle(x + b + w, y, w, h, fill=orange))\n", + "d.draw(\n", + " draw.Text(\n", + " orange.upper(),\n", + " x=x + (b + w) + w / 2,\n", + " y=y + h - b,\n", + " font_size=b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"black\",\n", + " )\n", + ")\n", + "d.draw(draw.Rectangle(x + 2 * (b + w), y, w, h, fill=dark_gray))\n", + "d.draw(\n", + " draw.Text(\n", + " dark_gray.upper(),\n", + " x=x + 2 * (b + w) + w / 2,\n", + " y=y + h - b,\n", + " font_size=b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"white\",\n", + " )\n", + ")\n", + "d.draw(draw.Rectangle(x + 3 * (b + w), y, w, h, fill=light_gray))\n", + "d.draw(\n", + " draw.Text(\n", + " light_gray.upper(),\n", + " x=x + 3 * (b + w) + w / 2,\n", + " y=y + h - b,\n", + " font_size=b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"black\",\n", + " )\n", + ")\n", + "\n", + "d.draw(draw.Rectangle(x, -25 + dy, 675, 45, fill=medium_gray))\n", + "d.draw(\n", + " draw.Text(\n", + " medium_gray.upper(),\n", + " x=x + 675 / 2,\n", + " y=-25 + 30 + dy,\n", + " font_size=22.5,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"black\",\n", + " )\n", + ")\n", + "\n", + "y = 40 + dy\n", + "w = h = 119\n", + "b = 20\n", + "d.draw(\n", + " draw.Text(\n", + " \"Secondary\",\n", + " x=x + 2 * (b + w) + w / 2,\n", + " y=y + h + 2 * b,\n", + " font_size=1.5 * b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"black\",\n", + " )\n", + ")\n", + "d.draw(draw.Rectangle(x, y, w, h, fill=light_blue))\n", + "d.draw(\n", + " draw.Text(\n", + " light_blue.upper(),\n", + " x=x + w / 2,\n", + " y=y + h - b,\n", + " font_size=b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"black\",\n", + " )\n", + ")\n", + "d.draw(draw.Rectangle(x + b + w, y, w, h, fill=light_orange))\n", + "d.draw(\n", + " draw.Text(\n", + " light_orange.upper(),\n", + " x=x + (b + w) + w / 2,\n", + " y=y + h - b,\n", + " font_size=b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"black\",\n", + " )\n", + ")\n", + "d.draw(draw.Rectangle(x + 2 * (b + w), y, w, h, fill=red))\n", + "d.draw(\n", + " draw.Text(\n", + " red.upper(),\n", + " x=x + 2 * (b + w) + w / 2,\n", + " y=y + h - b,\n", + " font_size=b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"white\",\n", + " )\n", + ")\n", + "d.draw(draw.Rectangle(x + 3 * (b + w), y, w, h, fill=light_red))\n", + "d.draw(\n", + " draw.Text(\n", + " light_red.upper(),\n", + " x=x + 3 * (b + w) + w / 2,\n", + " y=y + h - b,\n", + " font_size=b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"black\",\n", + " )\n", + ")\n", + "d.draw(draw.Rectangle(x + 4 * (b + w), y, w, h, fill=green))\n", + "d.draw(\n", + " draw.Text(\n", + " green.upper(),\n", + " x=x + 4 * (b + w) + w / 2,\n", + " y=y + h - b,\n", + " font_size=b,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Arial\",\n", + " fill=\"black\",\n", + " )\n", + ")\n", + "\n", + "color_palette = d\n", + "d" + ] + }, + { + "cell_type": "markdown", + "id": "e59c3941-c73b-455e-88f2-4b3aae228421", + "metadata": {}, + "source": [ + "## Display color wheel" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "c27e8ef2-04f2-4752-9c3b-cf297a0c87a5", + "metadata": {}, + "outputs": [], + "source": [ + "def create_color_wheel(color_wheel):\n", + " d = draw.Drawing(300, 300, origin=\"center\")\n", + " theta = np.pi / 3\n", + "\n", + " angle = 0\n", + " for i, color in enumerate(color_wheel):\n", + " angle = i * np.pi / 3\n", + " clip = draw.ClipPath()\n", + " if i == 5:\n", + " angle_offset = theta\n", + " else:\n", + " angle_offset = theta * 1.05\n", + " clip.append(\n", + " draw.Lines(\n", + " 0,\n", + " 0,\n", + " 300 * np.sin(angle),\n", + " 300 * np.cos(angle),\n", + " 300 * np.sin(angle + angle_offset),\n", + " 300 * np.cos(angle + angle_offset),\n", + " close=True,\n", + " )\n", + " )\n", + " if i == 0:\n", + " clip = None\n", + " d.append(draw.Circle(0, 0, 145, fill=color, clip_path=clip))\n", + "\n", + " angle = 3 * theta\n", + " for i, color in enumerate(color_wheel):\n", + " angle = ((i + 3) % 6) * np.pi / 3\n", + " clip = draw.ClipPath()\n", + " if i == 5:\n", + " angle_offset = theta\n", + " else:\n", + " angle_offset = theta * 1.05\n", + " clip.append(\n", + " draw.Lines(\n", + " 0,\n", + " 0,\n", + " 300 * np.sin(angle),\n", + " 300 * np.cos(angle),\n", + " 300 * np.sin(angle + angle_offset),\n", + " 300 * np.cos(angle + angle_offset),\n", + " close=True,\n", + " )\n", + " )\n", + " if i == 0:\n", + " clip = None\n", + " d.append(draw.Circle(0, 0, 105, fill=color, clip_path=clip))\n", + "\n", + " angle = theta\n", + " for i, color in enumerate(color_wheel):\n", + " angle = ((i + 1) % 6) * np.pi / 3\n", + " clip = draw.ClipPath()\n", + " if i == 5:\n", + " angle_offset = theta\n", + " else:\n", + " angle_offset = theta * 1.05\n", + " clip.append(\n", + " draw.Lines(\n", + " 0,\n", + " 0,\n", + " 300 * np.sin(angle),\n", + " 300 * np.cos(angle),\n", + " 300 * np.sin(angle + angle_offset),\n", + " 300 * np.cos(angle + angle_offset),\n", + " close=True,\n", + " )\n", + " )\n", + " if i == 0:\n", + " clip = None\n", + " d.append(draw.Circle(0, 0, 65, fill=color, clip_path=clip))\n", + "\n", + " d.append(draw.Circle(0, 0, 25, fill=medium_gray))\n", + " return d" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "2564bf63-8293-4828-8e38-d00a3b96b067", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Standard\n", + "standard_wheel = create_color_wheel(\n", + " [\n", + " blue,\n", + " light_gray,\n", + " light_blue,\n", + " dark_gray,\n", + " orange,\n", + " light_orange,\n", + " ]\n", + ")\n", + "standard_wheel" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "7a500a39-4114-49bb-aa19-912c6a8a8d95", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# High contrast\n", + "high_wheel = create_color_wheel(\n", + " [\n", + " light_gray,\n", + " blue,\n", + " green,\n", + " dark_gray,\n", + " orange,\n", + " red,\n", + " ]\n", + ")\n", + "high_wheel" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8f404efe-2b88-4bdf-9102-2e6ad9389ca3", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Low contrast\n", + "low_wheel = create_color_wheel(\n", + " [\n", + " green,\n", + " light_red,\n", + " orange,\n", + " light_blue,\n", + " light_orange,\n", + " blue,\n", + " ]\n", + ")\n", + "low_wheel" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "fd913698-ea45-4219-8003-0fd30124d091", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Warm :)\n", + "warm_wheel = create_color_wheel(\n", + " [\n", + " light_gray, # or dark_gray\n", + " light_red,\n", + " french_rose, # ;)\n", + " red,\n", + " orange,\n", + " light_orange,\n", + " ]\n", + ")\n", + "warm_wheel" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "c7a3a5e6-4be4-4def-9687-00d1e3f80375", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Cool\n", + "cool_wheel = create_color_wheel(\n", + " [\n", + " light_blue,\n", + " light_gray,\n", + " blue,\n", + " light_red,\n", + " green,\n", + " dark_gray,\n", + " ]\n", + ")\n", + "cool_wheel" + ] + }, + { + "cell_type": "markdown", + "id": "343256c8-35a7-4c89-aa60-c6bf60930c09", + "metadata": {}, + "source": [ + "## Create logos" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "7855cd3f-8155-4d11-9730-b6041578e112", + "metadata": {}, + "outputs": [], + "source": [ + "default_angles = [\n", + " 180, # Don't modify this\n", + " 30, # How much of the \"left face\" to see\n", + " 22.5, # How much of the \"top face\" to see\n", + "]\n", + "R = Rotation.from_euler(\"ZYX\", default_angles, degrees=True).as_matrix()\n", + "\n", + "gcube = np.array(\n", + " [\n", + " [-1, 1, -1],\n", + " [-1, 1, 1],\n", + " [1, 1, 1],\n", + " [-1, -1, 1],\n", + " [1, -1, 1],\n", + " [1, 0, 1],\n", + " [0, 0, 1],\n", + " ]\n", + ")\n", + "gcube_major = gcube[:5] # Big circles\n", + "gcube_minor = gcube[5:] # Small circles\n", + "lines = np.array(\n", + " [\n", + " [gcube[1], gcube[0]],\n", + " ]\n", + ")\n", + "Gpath = np.array(\n", + " [\n", + " gcube[2],\n", + " gcube[1],\n", + " gcube[3],\n", + " gcube[4],\n", + " gcube[5],\n", + " gcube[6],\n", + " ]\n", + ")\n", + "\n", + "\n", + "def create_logo(\n", + " *,\n", + " bracket_color=None,\n", + " bg_color=None,\n", + " edge_color=None,\n", + " edge_width=8,\n", + " edge_border_color=\"white\",\n", + " edge_border_width=16,\n", + " node_color=None,\n", + " large_node_width=16,\n", + " small_node_width=8,\n", + " node_border_color=\"white\",\n", + " node_stroke_width=4,\n", + " large_border=True,\n", + " g_color=None,\n", + " angles=None,\n", + "):\n", + " if angles is None:\n", + " angles = default_angles\n", + " if edge_color is None:\n", + " edge_color = blue\n", + " if bracket_color is None:\n", + " bracket_color = edge_color\n", + " if node_color is None:\n", + " node_color = orange\n", + " if g_color is None:\n", + " g_color = edge_color\n", + "\n", + " d = draw.Drawing(190, 190, origin=\"center\")\n", + " if bg_color:\n", + " d.append(\n", + " draw.Rectangle(-95, -95, 190, 190, fill=bg_color)\n", + " ) # Add `stroke=\"black\"` border to see boundaries for testing\n", + "\n", + " scale = 40\n", + " dx = 0\n", + " dy = -2\n", + "\n", + " if edge_border_width:\n", + " # Add white border around lines\n", + " d.append(\n", + " draw.Lines(\n", + " *(((Gpath @ R) * scale)[:, :2] * [-1, 1]).ravel().tolist(),\n", + " fill=\"none\",\n", + " stroke=edge_border_color,\n", + " stroke_width=edge_border_width,\n", + " )\n", + " )\n", + " for (x0, y0, z0), (x1, y1, z1) in ((lines @ R) * scale).tolist():\n", + " x0 = -x0\n", + " x1 = -x1 # Just live with this\n", + " d.append(\n", + " draw.Line(\n", + " x0 + dx,\n", + " y0 + dy,\n", + " x1 + dx,\n", + " y1 + dy,\n", + " stroke=edge_border_color,\n", + " stroke_width=edge_border_width,\n", + " )\n", + " )\n", + "\n", + " # Add edges\n", + " d.append(\n", + " draw.Lines(\n", + " *(((Gpath @ R) * scale)[:, :2] * [-1, 1]).ravel().tolist(),\n", + " fill=\"none\",\n", + " stroke=g_color,\n", + " stroke_width=edge_width,\n", + " )\n", + " )\n", + " for (x0, y0, z0), (x1, y1, z1) in ((lines @ R) * scale).tolist():\n", + " x0 = -x0\n", + " x1 = -x1\n", + " d.append(\n", + " draw.Line(\n", + " x0 + dx, y0 + dy, x1 + dx, y1 + dy, stroke=edge_color, stroke_width=edge_width\n", + " )\n", + " )\n", + "\n", + " # Add vertices\n", + " for x, y, z in ((gcube_major @ R) * scale).tolist():\n", + " x = -x\n", + " d.append(\n", + " draw.Circle(\n", + " x + dx,\n", + " y + dy,\n", + " large_node_width,\n", + " fill=node_color,\n", + " stroke=node_border_color,\n", + " stroke_width=node_stroke_width if large_border else 0,\n", + " )\n", + " )\n", + " for x, y, z in ((gcube_minor @ R) * scale).tolist():\n", + " x = -x\n", + " d.append(\n", + " draw.Circle(\n", + " x + dx,\n", + " y + dy,\n", + " small_node_width,\n", + " fill=node_color,\n", + " stroke=node_border_color,\n", + " stroke_width=node_stroke_width,\n", + " )\n", + " )\n", + "\n", + " # Add brackets\n", + " d.append(\n", + " draw.Text(\n", + " \"[\",\n", + " x=-85,\n", + " y=52,\n", + " font_size=214,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Courier New\",\n", + " fill=bracket_color,\n", + " )\n", + " )\n", + " d.append(\n", + " draw.Text(\n", + " \"]\",\n", + " x=85,\n", + " y=52,\n", + " font_size=214,\n", + " text_anchor=\"middle\",\n", + " font_family=\"Courier New\",\n", + " fill=bracket_color,\n", + " )\n", + " )\n", + "\n", + " return d" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "4325e0b8-dbbb-4219-a2b3-4d9cdee2bdc8", + "metadata": {}, + "outputs": [], + "source": [ + "logo_defaults = dict(\n", + " bracket_color=blue,\n", + " edge_color=blue,\n", + " node_color=orange,\n", + " edge_border_width=0,\n", + " edge_width=12,\n", + " small_node_width=11,\n", + " large_node_width=17,\n", + " node_border_color=\"none\",\n", + " node_stroke_width=0,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "f886df89-b3b5-4671-bcc0-98e8705feb5a", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "[\n", + "]\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "create_logo(bg_color=\"white\", **logo_defaults)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "68e01137-55e3-4973-bf97-4fcd36c8c662", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "[\n", + "]\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "create_logo(bg_color=\"black\", **logo_defaults)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "b1d5e928-16c5-4377-aee1-1489ab45efc8", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "[\n", + "]\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Transparent background\n", + "logo = create_logo(**logo_defaults)\n", + "logo" + ] + }, + { + "cell_type": "markdown", + "id": "b187c131-d337-4a7b-ab54-80ebe0f48ab4", + "metadata": {}, + "source": [ + "## Alternatives with gray brackets\n", + "### Background-agnostic (works with light and dark mode)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "acca9b2e-2f54-4b86-9a33-2c57502f6160", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "[\n", + "]\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "medium_logo = create_logo(**{**logo_defaults, \"bracket_color\": medium_gray})\n", + "create_logo(bg_color=\"white\", **{**logo_defaults, \"bracket_color\": medium_gray})" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "f5d0086d-b50e-49eb-9aae-b0953cdc0045", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "[\n", + "]\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "create_logo(bg_color=\"black\", **{**logo_defaults, \"bracket_color\": medium_gray})" + ] + }, + { + "cell_type": "markdown", + "id": "c4dce89d-e34c-4190-a068-7e78cdeea745", + "metadata": {}, + "source": [ + "### For light mode" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "27137343-141a-422e-abd6-123af3416ea4", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "[\n", + "]\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "light_logo = create_logo(**{**logo_defaults, \"bracket_color\": dark_gray})\n", + "create_logo(bg_color=\"white\", **{**logo_defaults, \"bracket_color\": dark_gray})" + ] + }, + { + "cell_type": "markdown", + "id": "8a70b0f7-c3c4-44ae-af09-8992400f362e", + "metadata": {}, + "source": [ + "### For dark mode" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "3ab9bb40-d7a8-4788-9971-54a5779d284d", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "[\n", + "]\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "dark_logo = create_logo(**{**logo_defaults, \"bracket_color\": light_gray})\n", + "create_logo(bg_color=\"black\", **{**logo_defaults, \"bracket_color\": light_gray})" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "d53046c1-8cbb-47fa-a88b-4d98958df26b", + "metadata": {}, + "outputs": [], + "source": [ + "if False:\n", + " logo.save_svg(\"python-graphblas-logo.svg\")\n", + " light_logo.save_svg(\"python-graphblas-logo-light.svg\")\n", + " medium_logo.save_svg(\"python-graphblas-logo-medium.svg\")\n", + " dark_logo.save_svg(\"python-graphblas-logo-dark.svg\")\n", + " color_palette.save_svg(\"color-palette.svg\")\n", + " standard_wheel.save_svg(\"color-wheel.svg\")\n", + " high_wheel.save_svg(\"color-wheel-high.svg\")\n", + " low_wheel.save_svg(\"color-wheel-low.svg\")\n", + " warm_wheel.save_svg(\"color-wheel-warm.svg\")\n", + " cool_wheel.save_svg(\"color-wheel-cool.svg\")" + ] + }, + { + "cell_type": "markdown", + "id": "51093fab-600b-47d7-9809-fa0f16e7246f", + "metadata": {}, + "source": [ + "### *NOTE: The font in the SVG files should be converted to paths, because not all systems have Courier New*\n", + "Also, SVG files can be minified here: https://vecta.io/nano" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index a76fee1d2..9051ebe6e 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -4,12 +4,12 @@ # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. conda search 'flake8-bugbear[channel=conda-forge]>=23.9.16' -conda search 'flake8-simplify[channel=conda-forge]>=0.20.0' +conda search 'flake8-simplify[channel=conda-forge]>=0.21.0' conda search 'numpy[channel=conda-forge]>=1.26.0' conda search 'pandas[channel=conda-forge]>=2.1.1' -conda search 'scipy[channel=conda-forge]>=1.11.2' +conda search 'scipy[channel=conda-forge]>=1.11.3' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.4.3' +conda search 'awkward[channel=conda-forge]>=2.4.4' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.3' conda search 'numba[channel=conda-forge]>=0.57.1' From 39d52b10905919f22c5e735b8b268d880e743dfc Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 11 Oct 2023 09:22:50 -0500 Subject: [PATCH 56/87] Add support for Python 3.12 (#508) * Add support for Python 3.12 Also, test against python-suitesparse-graphblas 8.2.0.1 * Update codecov config --- .codecov.yml | 9 +++++++ .github/workflows/imports.yml | 4 ++- .github/workflows/test_and_build.yml | 39 ++++++++++++++++++++++------ .pre-commit-config.yaml | 6 ++--- environment.yml | 1 + pyproject.toml | 10 ++++--- scripts/check_versions.sh | 2 +- 7 files changed, 55 insertions(+), 16 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 1720ac027..7a57a7568 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,3 +1,12 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true + changes: false comment: off ignore: - graphblas/viz.py diff --git a/.github/workflows/imports.yml b/.github/workflows/imports.yml index 753ce5162..18be6256a 100644 --- a/.github/workflows/imports.yml +++ b/.github/workflows/imports.yml @@ -33,17 +33,19 @@ jobs: 3.9 3.10 3.11 + 3.12 weights: | 1 1 1 + 1 test_imports: needs: rngs runs-on: ${{ needs.rngs.outputs.os }} # runs-on: ${{ matrix.os }} # strategy: # matrix: - # python-version: ["3.9", "3.10", "3.11"] + # python-version: ["3.9", "3.10", "3.11", "3.12"] # os: ["ubuntu-latest", "macos-latest", "windows-latest"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index e3930a853..b1ca58616 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -112,10 +112,12 @@ jobs: 3.9 3.10 3.11 + 3.12 weights: | 1 1 1 + 1 - name: RNG for source of python-suitesparse-graphblas uses: ddradar/choose-random-action@v2.0.2 id: sourcetype @@ -178,11 +180,16 @@ jobs: spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') - else # Python 3.11 + elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", "=1.26", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", "=2.1", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') + else # Python 3.12 + npver=$(python -c 'import random ; print(random.choice(["=1.26", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.11", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=2.1", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=2.4", ""]))') fi if [[ ${{ steps.sourcetype.outputs.selected }} == "source" || ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then # TODO: there are currently issues with some numpy versions when @@ -195,16 +202,23 @@ jobs: # That is, we don't need to support versions of it that are two years old. # But, it's still useful for us to test with different versions! psg="" - if [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2", "=8.0.2.1", ""]))') + if [[ ${{ steps.sourcetype.outputs.selected}} == "upstream" ]] ; then + psgver="" + elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]] ; then + if [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then + psgver=$(python -c 'import random ; print(random.choice(["=8.2.0.1", ""]))') + psg=python-suitesparse-graphblas${psgver} + else + psgver=$(python -c 'import random ; print(random.choice(["==8.2.0.1", ""]))') + fi + elif [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then + psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2", "=8.0.2.1", "=8.2.0.1", ""]))') psg=python-suitesparse-graphblas${psgver} elif [[ ${{ steps.sourcetype.outputs.selected}} == "wheel" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2", "==8.0.2.1", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2", "==8.0.2.1", "==8.2.0.1", ""]))') elif [[ ${{ steps.sourcetype.outputs.selected}} == "source" ]] ; then # These should be exact versions - psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", "==8.0.2.1", ""]))') - else - psgver="" + psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", "==8.0.2.1", "==8.2.0.1", ""]))') fi if [[ ${npver} == "=1.26" ]] ; then numbaver="" @@ -258,10 +272,15 @@ jobs: numba=numba${numbaver} sparse=sparse${sparsever} fi + if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]]; then + coveralls="" + else + coveralls="coveralls=3.3.1" + fi echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psg${psgver}" set -x # echo on - $(command -v mamba || command -v conda) install packaging pytest coverage coveralls=3.3.1 pytest-randomly cffi donfig tomli c-compiler make \ + $(command -v mamba || command -v conda) install packaging pytest coverage ${coveralls} pytest-randomly cffi donfig tomli c-compiler make \ pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \ networkx${nxver} ${numba} ${fmm} ${psg} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ @@ -397,11 +416,15 @@ jobs: if: matrix.slowtask == 'pytest_bizarro' run: | # This step uses `black` + if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]]; then + pip install black # Latest version of black on conda-forge does not have builds for Python 3.12 + fi coverage run -a -m graphblas.core.automethods coverage run -a -m graphblas.core.infixmethods git diff --exit-code - name: Coverage1 id: coverageAttempt1 + if: startsWith(steps.pyver.outputs.selected, '3.12') != true continue-on-error: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 565e1dc0d..ee6600327 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ default_language_version: python: python3 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-added-large-files - id: check-case-conflict @@ -51,7 +51,7 @@ repos: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it - repo: https://github.com/asottile/pyupgrade - rev: v3.14.0 + rev: v3.15.0 hooks: - id: pyupgrade args: [--py39-plus] @@ -126,7 +126,7 @@ repos: args: [graphblas/] pass_filenames: false - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: no-commit-to-branch # no commit directly to main # diff --git a/environment.yml b/environment.yml index 4455f4ac6..1863d4006 100644 --- a/environment.yml +++ b/environment.yml @@ -69,6 +69,7 @@ dependencies: # - flake8-simplify # - gcc # - gh + # - git # - graph-tool # - xorg-libxcursor # for graph-tool # - grayskull diff --git a/pyproject.toml b/pyproject.toml index ff970cc0f..9579b1c16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3 :: Only", "Intended Audience :: Developers", "Intended Audience :: Other Audience", @@ -62,7 +63,7 @@ dependencies = [ "pyyaml >=5.4", # These won't be installed by default after 2024.3.0 # Use e.g. "python-graphblas[suitesparse]" or "python-graphblas[default]" instead - "suitesparse-graphblas >=7.4.0.0, <7.5", + "suitesparse-graphblas >=7.4.0.0, <9", "numba >=0.55; python_version<'3.12'", # make optional where numba is not supported ] @@ -74,7 +75,7 @@ changelog = "https://github.com/python-graphblas/python-graphblas/releases" [project.optional-dependencies] suitesparse = [ - "suitesparse-graphblas >=7.4.0.0, <7.5", + "suitesparse-graphblas >=7.4.0.0, <9", ] networkx = [ "networkx >=2.8", @@ -156,7 +157,7 @@ dirty_template = "{tag}+{ccount}.g{sha}.dirty" [tool.black] line-length = 100 -target-version = ["py39", "py310", "py311"] +target-version = ["py39", "py310", "py311", "py312"] [tool.isort] sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] @@ -207,6 +208,9 @@ filterwarnings = [ # pypy gives this warning "ignore:can't resolve package from __spec__ or __package__:ImportWarning:", + + # Python 3.12 introduced this deprecation, which is triggered by pandas 2.1.1 + "ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil", ] [tool.coverage.run] diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 9051ebe6e..c373692ed 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -9,7 +9,7 @@ conda search 'numpy[channel=conda-forge]>=1.26.0' conda search 'pandas[channel=conda-forge]>=2.1.1' conda search 'scipy[channel=conda-forge]>=1.11.3' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.4.4' +conda search 'awkward[channel=conda-forge]>=2.4.5' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.3' conda search 'numba[channel=conda-forge]>=0.57.1' From 486b422571df1bc7708f884fcdef84ae82fcdefc Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 11 Oct 2023 11:44:20 -0500 Subject: [PATCH 57/87] Drop coveralls (use codecov instead) (#509) --- .github/workflows/test_and_build.yml | 56 +--------------------------- README.md | 2 +- 2 files changed, 3 insertions(+), 55 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index b1ca58616..bf34f1ce5 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -272,15 +272,10 @@ jobs: numba=numba${numbaver} sparse=sparse${sparsever} fi - if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]]; then - coveralls="" - else - coveralls="coveralls=3.3.1" - fi echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psg${psgver}" set -x # echo on - $(command -v mamba || command -v conda) install packaging pytest coverage ${coveralls} pytest-randomly cffi donfig tomli c-compiler make \ + $(command -v mamba || command -v conda) install packaging pytest coverage pytest-randomly cffi donfig tomli c-compiler make \ pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \ networkx${nxver} ${numba} ${fmm} ${psg} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ @@ -422,42 +417,10 @@ jobs: coverage run -a -m graphblas.core.automethods coverage run -a -m graphblas.core.infixmethods git diff --exit-code - - name: Coverage1 - id: coverageAttempt1 - if: startsWith(steps.pyver.outputs.selected, '3.12') != true - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COVERALLS_FLAG_NAME: ${{ matrix.os }}/${{ matrix.slowtask }} - COVERALLS_PARALLEL: true + - name: Coverage run: | coverage xml coverage report --show-missing - coveralls --service=github - # Retry upload if first attempt failed. - # This happens somewhat randomly and for irregular reasons. - # Logic is a duplicate of previous step. - - name: Coverage2 - id: coverageAttempt2 - if: steps.coverageAttempt1.outcome == 'failure' - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COVERALLS_FLAG_NAME: ${{ matrix.os }}/${{ matrix.slowtask }} - COVERALLS_PARALLEL: true - run: | - coveralls --service=github - - name: Coverage3 - id: coverageAttempt3 - if: steps.coverageAttempt2.outcome == 'failure' - # Continue even if it failed 3 times... (sheesh! use codecov instead) - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COVERALLS_FLAG_NAME: ${{ matrix.os }}/${{ matrix.slowtask }} - COVERALLS_PARALLEL: true - run: | - coveralls --service=github - name: codecov uses: codecov/codecov-action@v3 - name: Notebooks Execution check @@ -467,18 +430,3 @@ jobs: if python -c 'import numba' 2> /dev/null ; then jupyter nbconvert --to notebook --execute notebooks/*ipynb fi - - finish: - needs: build_and_test - if: always() - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - run: python -m pip install --upgrade pip - - run: pip install coveralls - - name: Coveralls Finished - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: coveralls --finish diff --git a/README.md b/README.md index 4509e44ac..0a4342dd3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@
[![Tests](https://github.com/python-graphblas/python-graphblas/workflows/Tests/badge.svg?branch=main)](https://github.com/python-graphblas/python-graphblas/actions) [![Docs](https://readthedocs.org/projects/python-graphblas/badge/?version=latest)](https://python-graphblas.readthedocs.io/en/latest/) -[![Coverage](https://coveralls.io/repos/python-graphblas/python-graphblas/badge.svg?branch=main)](https://coveralls.io/r/python-graphblas/python-graphblas) +[![Coverage](https://codecov.io/gh/python-graphblas/python-graphblas/graph/badge.svg?token=D7HHLDPQ2Q)](https://codecov.io/gh/python-graphblas/python-graphblas) [![pyOpenSci](https://tinyurl.com/y22nb8up)](https://github.com/pyOpenSci/software-review/issues/81)
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.7328791.svg)](https://doi.org/10.5281/zenodo.7328791) From c1dcc385d42e8f8925476c9c9143dd93e277999d Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 14 Oct 2023 10:54:20 -0500 Subject: [PATCH 58/87] Add NumFOCUS badge (#510) --- .pre-commit-config.yaml | 4 ++-- README.md | 7 ++++--- scripts/check_versions.sh | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee6600327..afdc6c75b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: - id: name-tests-test args: ["--pytest-test-first"] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.14 + rev: v0.15 hooks: - id: validate-pyproject name: Validate pyproject.toml @@ -98,7 +98,7 @@ repos: hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.6.8 + rev: v0.7.0 hooks: - id: sphinx-lint args: [--enable, all, "--disable=line-too-long,leaked-markup"] diff --git a/README.md b/README.md index 0a4342dd3..c10562783 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ ![Python-graphblas](https://raw.githubusercontent.com/python-graphblas/python-graphblas/main/docs/_static/img/logo-horizontal-medium-big.svg) +[![Powered by NumFOCUS](https://img.shields.io/badge/powered%20by-NumFOCUS-orange.svg?style=flat&colorA=E1523D&colorB=007D8A)](https://numfocus.org) +[![pyOpenSci](https://tinyurl.com/y22nb8up)](https://github.com/pyOpenSci/software-review/issues/81) +[![Discord](https://img.shields.io/badge/Chat-Discord-Blue?color=5865f2)](https://discord.com/invite/vur45CbwMz) +
[![conda-forge](https://img.shields.io/conda/vn/conda-forge/python-graphblas.svg)](https://anaconda.org/conda-forge/python-graphblas) [![pypi](https://img.shields.io/pypi/v/python-graphblas.svg)](https://pypi.python.org/pypi/python-graphblas/) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-graphblas)](https://pypi.python.org/pypi/python-graphblas/) @@ -8,11 +12,8 @@ [![Tests](https://github.com/python-graphblas/python-graphblas/workflows/Tests/badge.svg?branch=main)](https://github.com/python-graphblas/python-graphblas/actions) [![Docs](https://readthedocs.org/projects/python-graphblas/badge/?version=latest)](https://python-graphblas.readthedocs.io/en/latest/) [![Coverage](https://codecov.io/gh/python-graphblas/python-graphblas/graph/badge.svg?token=D7HHLDPQ2Q)](https://codecov.io/gh/python-graphblas/python-graphblas) -[![pyOpenSci](https://tinyurl.com/y22nb8up)](https://github.com/pyOpenSci/software-review/issues/81) -
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.7328791.svg)](https://doi.org/10.5281/zenodo.7328791) [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/python-graphblas/python-graphblas/HEAD?filepath=notebooks%2FIntro%20to%20GraphBLAS%20%2B%20SSSP%20example.ipynb) -[![Discord](https://img.shields.io/badge/Chat-Discord-blue)](https://discord.com/invite/vur45CbwMz) Python library for GraphBLAS: high-performance sparse linear algebra for scalable graph analytics. For algorithms, see diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index c373692ed..dc0331359 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -9,7 +9,7 @@ conda search 'numpy[channel=conda-forge]>=1.26.0' conda search 'pandas[channel=conda-forge]>=2.1.1' conda search 'scipy[channel=conda-forge]>=1.11.3' conda search 'networkx[channel=conda-forge]>=3.1' -conda search 'awkward[channel=conda-forge]>=2.4.5' +conda search 'awkward[channel=conda-forge]>=2.4.6' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.3' conda search 'numba[channel=conda-forge]>=0.57.1' From cb9be5406b529546d859638424100848f5d7fb64 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 14 Oct 2023 12:00:44 -0500 Subject: [PATCH 59/87] Test against python-suitesparse-graphblas 8.2.1.0 in CI (#511) * Test against python-suitesparse-graphblas 8.2.1.0 in CI --- .github/workflows/test_and_build.yml | 13 +++++++------ CODE_OF_CONDUCT.md | 6 +++--- LICENSE | 4 ++-- README.md | 2 +- docs/make.bat | 2 +- notebooks/Example B.1 -- Level BFS.ipynb | 2 +- notebooks/Example B.3 -- Parent BFS.ipynb | 2 +- 7 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index bf34f1ce5..1a7dff313 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -144,7 +144,8 @@ jobs: use-mamba: true python-version: ${{ steps.pyver.outputs.selected }} channels: conda-forge,${{ contains(steps.pyver.outputs.selected, 'pypy') && 'defaults' || 'nodefaults' }} - channel-priority: ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'flexible' || 'strict' }} + # mamba does not yet implement strict priority + # channel-priority: ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'flexible' || 'strict' }} activate-environment: graphblas auto-activate-base: false - name: Setup conda @@ -206,19 +207,19 @@ jobs: psgver="" elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]] ; then if [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["=8.2.0.1", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["=8.2.0.1", "=8.2.1.0", ""]))') psg=python-suitesparse-graphblas${psgver} else - psgver=$(python -c 'import random ; print(random.choice(["==8.2.0.1", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["==8.2.0.1", "==8.2.1.0", ""]))') fi elif [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2", "=8.0.2.1", "=8.2.0.1", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2", "=8.0.2.1", "=8.2.0.1", "=8.2.1.0", ""]))') psg=python-suitesparse-graphblas${psgver} elif [[ ${{ steps.sourcetype.outputs.selected}} == "wheel" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2", "==8.0.2.1", "==8.2.0.1", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2", "==8.0.2.1", "==8.2.0.1", "==8.2.1.0", ""]))') elif [[ ${{ steps.sourcetype.outputs.selected}} == "source" ]] ; then # These should be exact versions - psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", "==8.0.2.1", "==8.2.0.1", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", "==8.0.2.1", "==8.2.0.1", "==8.2.1.0", ""]))') fi if [[ ${npver} == "=1.26" ]] ; then numbaver="" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 7cfcb10f9..814c8052a 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -54,10 +54,10 @@ incident. This Code of Conduct is adapted from the [Numba Code of Conduct][numba], which is based on the [Contributor Covenant][homepage], version 1.3.0, available at -[http://contributor-covenant.org/version/1/3/0/][version], +[https://contributor-covenant.org/version/1/3/0/][version], and the [Swift Code of Conduct][swift]. [numba]: https://github.com/numba/numba-governance/blob/accepted/code-of-conduct.md -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/3/0/ +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/3/0/ [swift]: https://swift.org/community/#code-of-conduct diff --git a/LICENSE b/LICENSE index 935875c92..21c605c21 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ Apache License Version 2.0, January 2004 - http://www.apache.org/licenses/ + https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION @@ -192,7 +192,7 @@ you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, diff --git a/README.md b/README.md index c10562783..42ed0d41e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ For algorithms, see - **Source:** [https://github.com/python-graphblas/python-graphblas](https://github.com/python-graphblas/python-graphblas) - **Bug reports:** [https://github.com/python-graphblas/python-graphblas/issues](https://github.com/python-graphblas/python-graphblas/issues) - **Github discussions:** [https://github.com/python-graphblas/python-graphblas/discussions](https://github.com/python-graphblas/python-graphblas/discussions) -- **Weekly community call:** [https://github.com/python-graphblas/python-graphblas/issues/247](https://github.com/python-graphblas/python-graphblas/issues/247) +- **Weekly community call:** [python-graphblas#247](https://github.com/python-graphblas/python-graphblas/issues/247) or [https://scientific-python.org/calendars/](https://scientific-python.org/calendars/) - **Chat via Discord:** [https://discord.com/invite/vur45CbwMz](https://discord.com/invite/vur45CbwMz) in the [#graphblas channel](https://discord.com/channels/786703927705862175/1024732940233605190)

diff --git a/docs/make.bat b/docs/make.bat index 2119f5109..153be5e2f 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -21,7 +21,7 @@ if errorlevel 9009 ( echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ + echo.https://www.sphinx-doc.org/ exit /b 1 ) diff --git a/notebooks/Example B.1 -- Level BFS.ipynb b/notebooks/Example B.1 -- Level BFS.ipynb index cdee2f2fc..e96d6d7d5 100644 --- a/notebooks/Example B.1 -- Level BFS.ipynb +++ b/notebooks/Example B.1 -- Level BFS.ipynb @@ -6,7 +6,7 @@ "source": [ "## Example B.1 Level Breadth-first Search\n", "\n", - "Examples come from http://people.eecs.berkeley.edu/~aydin/GraphBLAS_API_C_v13.pdf" + "Examples come from https://people.eecs.berkeley.edu/~aydin/GraphBLAS_API_C_v13.pdf" ] }, { diff --git a/notebooks/Example B.3 -- Parent BFS.ipynb b/notebooks/Example B.3 -- Parent BFS.ipynb index d1fbd82c5..d3c7c761f 100644 --- a/notebooks/Example B.3 -- Parent BFS.ipynb +++ b/notebooks/Example B.3 -- Parent BFS.ipynb @@ -6,7 +6,7 @@ "source": [ "## Example B.3 Parent Breadth-first Search\n", "\n", - "Examples come from http://people.eecs.berkeley.edu/~aydin/GraphBLAS_API_C_v13.pdf" + "Examples come from https://people.eecs.berkeley.edu/~aydin/GraphBLAS_API_C_v13.pdf" ] }, { From f0fd1945db3630504e2d208df65f4f291782d751 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 16 Oct 2023 22:08:21 -0500 Subject: [PATCH 60/87] Add docstrings for using SS JIT, and make better (#512) * Add docstrings for using SS JIT, and make better Also: - allow a SS JIT function to be registered under the same name with different input types - be extra-strict about input types for SS JIT - test numba JIT with select with udt - add codecov comment to PRs --- .codecov.yml | 6 +- .github/workflows/test_and_build.yml | 6 +- .pre-commit-config.yaml | 2 +- graphblas/core/operator/base.py | 17 +++- graphblas/core/operator/binary.py | 9 +- graphblas/core/operator/indexunary.py | 7 ++ graphblas/core/operator/select.py | 57 ++++++++++- graphblas/core/operator/unary.py | 2 + graphblas/core/ss/binary.py | 63 +++++++++++- graphblas/core/ss/indexunary.py | 89 +++++++++++++++-- graphblas/core/ss/select.py | 43 ++++++++ graphblas/core/ss/unary.py | 54 +++++++++- graphblas/tests/test_op.py | 23 ++++- graphblas/tests/test_ssjit.py | 136 ++++++++++++++++++++++++-- 14 files changed, 470 insertions(+), 44 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 7a57a7568..1894009c1 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -7,6 +7,10 @@ coverage: default: informational: true changes: false -comment: off +comment: + layout: "header, diff" + behavior: default +github_checks: + annotations: false ignore: - graphblas/viz.py diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 1a7dff313..d4504e2fd 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -144,8 +144,7 @@ jobs: use-mamba: true python-version: ${{ steps.pyver.outputs.selected }} channels: conda-forge,${{ contains(steps.pyver.outputs.selected, 'pypy') && 'defaults' || 'nodefaults' }} - # mamba does not yet implement strict priority - # channel-priority: ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'flexible' || 'strict' }} + channel-priority: ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'flexible' || 'strict' }} activate-environment: graphblas auto-activate-base: false - name: Setup conda @@ -412,9 +411,6 @@ jobs: if: matrix.slowtask == 'pytest_bizarro' run: | # This step uses `black` - if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]]; then - pip install black # Latest version of black on conda-forge does not have builds for Python 3.12 - fi coverage run -a -m graphblas.core.automethods coverage run -a -m graphblas.core.infixmethods git diff --exit-code diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index afdc6c75b..96c8b9aeb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -98,7 +98,7 @@ repos: hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.7.0 + rev: v0.8.0 hooks: - id: sphinx-lint args: [--enable, all, "--disable=line-too-long,leaked-markup"] diff --git a/graphblas/core/operator/base.py b/graphblas/core/operator/base.py index cddee6a33..d66aa2f4a 100644 --- a/graphblas/core/operator/base.py +++ b/graphblas/core/operator/base.py @@ -336,15 +336,22 @@ def __getitem__(self, type_): raise KeyError(f"{self.name} does not work with {type_}") else: return self._typed_ops[type_] - if not _supports_udfs: - raise KeyError(f"{self.name} does not work with {type_}") # This is a UDT or is able to operate on UDTs such as `first` any `any` dtype = lookup_dtype(type_) return self._compile_udt(dtype, dtype) - def _add(self, op): - self._typed_ops[op.type] = op - self.types[op.type] = op.return_type + def _add(self, op, *, is_jit=False): + if is_jit: + if hasattr(op, "type2") or hasattr(op, "thunk_type"): + dtypes = (op.type, op._type2) + else: + dtypes = op.type + self.types[dtypes] = op.return_type # This is a different use of .types + self._udt_types[dtypes] = op.return_type + self._udt_ops[dtypes] = op + else: + self._typed_ops[op.type] = op + self.types[op.type] = op.return_type def __delitem__(self, type_): type_ = lookup_dtype(type_) diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py index 77a686868..676ed0970 100644 --- a/graphblas/core/operator/binary.py +++ b/graphblas/core/operator/binary.py @@ -523,8 +523,8 @@ def _compile_udt(self, dtype, dtype2): if dtypes in self._udt_types: return self._udt_ops[dtypes] - nt = numba.types - if self.name == "eq" and not self._anonymous: + if self.name == "eq" and not self._anonymous and _has_numba: + nt = numba.types # assert dtype.np_type == dtype2.np_type itemsize = dtype.np_type.itemsize mask = _udt_mask(dtype.np_type) @@ -561,7 +561,8 @@ def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) # z_ptr[0] = True z_ptr[0] = (x[mask] == y[mask]).all() - elif self.name == "ne" and not self._anonymous: + elif self.name == "ne" and not self._anonymous and _has_numba: + nt = numba.types # assert dtype.np_type == dtype2.np_type itemsize = dtype.np_type.itemsize mask = _udt_mask(dtype.np_type) @@ -597,6 +598,8 @@ def binary_wrapper(z_ptr, x_ptr, y_ptr): # pragma: no cover (numba) # z_ptr[0] = False z_ptr[0] = (x[mask] != y[mask]).any() + elif self._numba_func is None: + raise KeyError(f"{self.name} does not work with {dtypes} types") else: numba_func = self._numba_func sig = (dtype.numba_type, dtype2.numba_type) diff --git a/graphblas/core/operator/indexunary.py b/graphblas/core/operator/indexunary.py index b5351e916..b6fc74e91 100644 --- a/graphblas/core/operator/indexunary.py +++ b/graphblas/core/operator/indexunary.py @@ -25,6 +25,10 @@ def __call__(self, val, thunk=None): thunk = False # most basic form of 0 when unifying dtypes return _call_op(self, val, right=thunk) + @property + def thunk_type(self): + return self.type if self._type2 is None else self._type2 + class TypedUserIndexUnaryOp(TypedOpBase): __slots__ = () @@ -41,6 +45,7 @@ def orig_func(self): def _numba_func(self): return self.parent._numba_func + thunk_type = TypedBuiltinIndexUnaryOp.thunk_type __call__ = TypedBuiltinIndexUnaryOp.__call__ @@ -210,6 +215,8 @@ def _compile_udt(self, dtype, dtype2): dtypes = (dtype, dtype2) if dtypes in self._udt_types: return self._udt_ops[dtypes] + if self._numba_func is None: + raise KeyError(f"{self.name} does not work with {dtypes} types") numba_func = self._numba_func sig = (dtype.numba_type, UINT64.numba_type, UINT64.numba_type, dtype2.numba_type) diff --git a/graphblas/core/operator/select.py b/graphblas/core/operator/select.py index 4c9cd4639..4dd65ef16 100644 --- a/graphblas/core/operator/select.py +++ b/graphblas/core/operator/select.py @@ -1,9 +1,17 @@ import inspect from ... import _STANDARD_OPERATOR_NAMES, select -from ...dtypes import BOOL +from ...dtypes import BOOL, UINT64 +from ...exceptions import check_status_carg +from .. import _has_numba, ffi, lib from .base import OpBase, ParameterizedUdf, TypedOpBase, _call_op, _deserialize_parameterized -from .indexunary import IndexUnaryOp +from .indexunary import IndexUnaryOp, TypedBuiltinIndexUnaryOp + +if _has_numba: + import numba + + from .base import _get_udt_wrapper +ffi_new = ffi.new class TypedBuiltinSelectOp(TypedOpBase): @@ -15,13 +23,15 @@ def __call__(self, val, thunk=None): thunk = False # most basic form of 0 when unifying dtypes return _call_op(self, val, thunk=thunk) + thunk_type = TypedBuiltinIndexUnaryOp.thunk_type + class TypedUserSelectOp(TypedOpBase): __slots__ = () opclass = "SelectOp" - def __init__(self, parent, name, type_, return_type, gb_obj): - super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}") + def __init__(self, parent, name, type_, return_type, gb_obj, dtype2=None): + super().__init__(parent, name, type_, return_type, gb_obj, f"{name}_{type_}", dtype2=dtype2) @property def orig_func(self): @@ -31,6 +41,7 @@ def orig_func(self): def _numba_func(self): return self.parent._numba_func + thunk_type = TypedBuiltinSelectOp.thunk_type __call__ = TypedBuiltinSelectOp.__call__ @@ -120,6 +131,44 @@ def _from_indexunary(cls, iop): obj.types[type_] = op.return_type return obj + def _compile_udt(self, dtype, dtype2): + if dtype2 is None: # pragma: no cover + dtype2 = dtype + dtypes = (dtype, dtype2) + if dtypes in self._udt_types: + return self._udt_ops[dtypes] + if self._numba_func is None: + raise KeyError(f"{self.name} does not work with {dtypes} types") + + # It would be nice if we could reuse compiling done for IndexUnaryOp + numba_func = self._numba_func + sig = (dtype.numba_type, UINT64.numba_type, UINT64.numba_type, dtype2.numba_type) + numba_func.compile(sig) # Should we catch and give additional error message? + select_wrapper, wrapper_sig = _get_udt_wrapper( + numba_func, BOOL, dtype, dtype2, include_indexes=True + ) + + select_wrapper = numba.cfunc(wrapper_sig, nopython=True)(select_wrapper) + new_select = ffi_new("GrB_IndexUnaryOp*") + check_status_carg( + lib.GrB_IndexUnaryOp_new( + new_select, select_wrapper.cffi, BOOL._carg, dtype._carg, dtype2._carg + ), + "IndexUnaryOp", + new_select[0], + ) + op = TypedUserSelectOp( + self, + self.name, + dtype, + BOOL, + new_select[0], + dtype2=dtype2, + ) + self._udt_types[dtypes] = BOOL + self._udt_ops[dtypes] = op + return op + @classmethod def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=False): """Register a SelectOp without registering it in the ``graphblas.select`` namespace. diff --git a/graphblas/core/operator/unary.py b/graphblas/core/operator/unary.py index 437334ccc..7484f74d9 100644 --- a/graphblas/core/operator/unary.py +++ b/graphblas/core/operator/unary.py @@ -252,6 +252,8 @@ def unary_wrapper(z, x): def _compile_udt(self, dtype, dtype2): if dtype in self._udt_types: return self._udt_ops[dtype] + if self._numba_func is None: + raise KeyError(f"{self.name} does not work with {dtype}") numba_func = self._numba_func sig = (dtype.numba_type,) diff --git a/graphblas/core/ss/binary.py b/graphblas/core/ss/binary.py index 898257fac..6965aeaf1 100644 --- a/graphblas/core/ss/binary.py +++ b/graphblas/core/ss/binary.py @@ -31,6 +31,47 @@ def jit_c_definition(self): def register_new(name, jit_c_definition, left_type, right_type, ret_type): + """Register a new BinaryOp using the SuiteSparse:GraphBLAS JIT compiler. + + This creates a BinaryOp by compiling the C string definition of the function. + It requires a shell call to a C compiler. The resulting operator will be as + fast as if it were built-in to SuiteSparse:GraphBLAS and does not have the + overhead of additional function calls as when using ``gb.binary.register_new``. + + This is an advanced feature that requires a C compiler and proper configuration. + Configuration is handled by ``gb.ss.config``; see its docstring for details. + By default, the JIT caches results in ``~/.SuiteSparse/``. For more information, + see the SuiteSparse:GraphBLAS user guide. + + Only one type signature may be registered at a time, but repeated calls using + the same name with different input types is allowed. + + Parameters + ---------- + name : str + The name of the operator. This will show up as ``gb.binary.ss.{name}``. + The name may contain periods, ".", which will result in nested objects + such as ``gb.binary.ss.x.y.z`` for name ``"x.y.z"``. + jit_c_definition : str + The C definition as a string of the user-defined function. For example: + ``"void absdiff (double *z, double *x, double *y) { (*z) = fabs ((*x) - (*y)) ; }"``. + left_type : dtype + The dtype of the left operand of the binary operator. + right_type : dtype + The dtype of the right operand of the binary operator. + ret_type : dtype + The dtype of the result of the binary operator. + + Returns + ------- + BinaryOp + + See Also + -------- + gb.binary.register_new + gb.binary.register_anonymous + gb.unary.ss.register_new + """ if backend != "suitesparse": # pragma: no cover (safety) raise RuntimeError( "`gb.binary.ss.register_new` invalid when not using 'suitesparse' backend" @@ -47,9 +88,23 @@ def register_new(name, jit_c_definition, left_type, right_type, ret_type): right_type = lookup_dtype(right_type) ret_type = lookup_dtype(ret_type) name = name if name.startswith("ss.") else f"ss.{name}" - module, funcname = BinaryOp._remove_nesting(name) - - rv = BinaryOp(name) + module, funcname = BinaryOp._remove_nesting(name, strict=False) + if hasattr(module, funcname): + rv = getattr(module, funcname) + if not isinstance(rv, BinaryOp): + BinaryOp._remove_nesting(name) + if ( + (left_type, right_type) in rv.types + or rv._udt_types is not None + and (left_type, right_type) in rv._udt_types + ): + raise TypeError( + f"BinaryOp gb.binary.{name} already defined for " + f"({left_type}, {right_type}) input types" + ) + else: + # We use `is_udt=True` to make dtype handling flexible and explicit. + rv = BinaryOp(name, is_udt=True) gb_obj = ffi_new("GrB_BinaryOp*") check_status_carg( lib.GxB_BinaryOp_new( @@ -67,6 +122,6 @@ def register_new(name, jit_c_definition, left_type, right_type, ret_type): op = TypedJitBinaryOp( rv, funcname, left_type, ret_type, gb_obj[0], jit_c_definition, dtype2=right_type ) - rv._add(op) + rv._add(op, is_jit=True) setattr(module, funcname, rv) return rv diff --git a/graphblas/core/ss/indexunary.py b/graphblas/core/ss/indexunary.py index c0f185737..d5f709526 100644 --- a/graphblas/core/ss/indexunary.py +++ b/graphblas/core/ss/indexunary.py @@ -21,10 +21,56 @@ def __init__(self, parent, name, type_, return_type, gb_obj, jit_c_definition, d def jit_c_definition(self): return self._jit_c_definition + thunk_type = TypedUserIndexUnaryOp.thunk_type __call__ = TypedUserIndexUnaryOp.__call__ def register_new(name, jit_c_definition, input_type, thunk_type, ret_type): + """Register a new IndexUnaryOp using the SuiteSparse:GraphBLAS JIT compiler. + + This creates a IndexUnaryOp by compiling the C string definition of the function. + It requires a shell call to a C compiler. The resulting operator will be as + fast as if it were built-in to SuiteSparse:GraphBLAS and does not have the + overhead of additional function calls as when using ``gb.indexunary.register_new``. + + This is an advanced feature that requires a C compiler and proper configuration. + Configuration is handled by ``gb.ss.config``; see its docstring for details. + By default, the JIT caches results in ``~/.SuiteSparse/``. For more information, + see the SuiteSparse:GraphBLAS user guide. + + Only one type signature may be registered at a time, but repeated calls using + the same name with different input types is allowed. + + This will also create a SelectOp operator under ``gb.select.ss`` if the return + type is boolean. + + Parameters + ---------- + name : str + The name of the operator. This will show up as ``gb.indexunary.ss.{name}``. + The name may contain periods, ".", which will result in nested objects + such as ``gb.indexunary.ss.x.y.z`` for name ``"x.y.z"``. + jit_c_definition : str + The C definition as a string of the user-defined function. For example: + ``"void diffy (double *z, double *x, GrB_Index i, GrB_Index j, double *y) "`` + ``"{ (*z) = (i + j) * fabs ((*x) - (*y)) ; }"`` + input_type : dtype + The dtype of the operand of the indexunary operator. + thunk_type : dtype + The dtype of the thunk of the indexunary operator. + ret_type : dtype + The dtype of the result of the indexunary operator. + + Returns + ------- + IndexUnaryOp + + See Also + -------- + gb.indexunary.register_new + gb.indexunary.register_anonymous + gb.select.ss.register_new + """ if backend != "suitesparse": # pragma: no cover (safety) raise RuntimeError( "`gb.indexunary.ss.register_new` invalid when not using 'suitesparse' backend" @@ -41,9 +87,23 @@ def register_new(name, jit_c_definition, input_type, thunk_type, ret_type): thunk_type = lookup_dtype(thunk_type) ret_type = lookup_dtype(ret_type) name = name if name.startswith("ss.") else f"ss.{name}" - module, funcname = IndexUnaryOp._remove_nesting(name) - - rv = IndexUnaryOp(name) + module, funcname = IndexUnaryOp._remove_nesting(name, strict=False) + if hasattr(module, funcname): + rv = getattr(module, funcname) + if not isinstance(rv, IndexUnaryOp): + IndexUnaryOp._remove_nesting(name) + if ( + (input_type, thunk_type) in rv.types + or rv._udt_types is not None + and (input_type, thunk_type) in rv._udt_types + ): + raise TypeError( + f"IndexUnaryOp gb.indexunary.{name} already defined for " + f"({input_type}, {thunk_type}) input types" + ) + else: + # We use `is_udt=True` to make dtype handling flexible and explicit. + rv = IndexUnaryOp(name, is_udt=True) gb_obj = ffi_new("GrB_IndexUnaryOp*") check_status_carg( lib.GxB_IndexUnaryOp_new( @@ -61,17 +121,32 @@ def register_new(name, jit_c_definition, input_type, thunk_type, ret_type): op = TypedJitIndexUnaryOp( rv, funcname, input_type, ret_type, gb_obj[0], jit_c_definition, dtype2=thunk_type ) - rv._add(op) + rv._add(op, is_jit=True) if ret_type == BOOL: from ..operator.select import SelectOp from .select import TypedJitSelectOp select_module, funcname = SelectOp._remove_nesting(name, strict=False) - selectop = SelectOp(name) + if hasattr(select_module, funcname): + selectop = getattr(select_module, funcname) + if not isinstance(selectop, SelectOp): + SelectOp._remove_nesting(name) + if ( + (input_type, thunk_type) in selectop.types + or selectop._udt_types is not None + and (input_type, thunk_type) in selectop._udt_types + ): + raise TypeError( + f"SelectOp gb.select.{name} already defined for " + f"({input_type}, {thunk_type}) input types" + ) + else: + # We use `is_udt=True` to make dtype handling flexible and explicit. + selectop = SelectOp(name, is_udt=True) op2 = TypedJitSelectOp( - rv, funcname, input_type, ret_type, gb_obj[0], jit_c_definition, dtype2=thunk_type + selectop, funcname, input_type, ret_type, gb_obj[0], jit_c_definition, dtype2=thunk_type ) - selectop._add(op2) + selectop._add(op2, is_jit=True) setattr(select_module, funcname, selectop) setattr(module, funcname, rv) return rv diff --git a/graphblas/core/ss/select.py b/graphblas/core/ss/select.py index 37c352b67..ff12f80fa 100644 --- a/graphblas/core/ss/select.py +++ b/graphblas/core/ss/select.py @@ -20,10 +20,53 @@ def __init__(self, parent, name, type_, return_type, gb_obj, jit_c_definition, d def jit_c_definition(self): return self._jit_c_definition + thunk_type = TypedUserSelectOp.thunk_type __call__ = TypedUserSelectOp.__call__ def register_new(name, jit_c_definition, input_type, thunk_type): + """Register a new SelectOp using the SuiteSparse:GraphBLAS JIT compiler. + + This creates a SelectOp by compiling the C string definition of the function. + It requires a shell call to a C compiler. The resulting operator will be as + fast as if it were built-in to SuiteSparse:GraphBLAS and does not have the + overhead of additional function calls as when using ``gb.select.register_new``. + + This is an advanced feature that requires a C compiler and proper configuration. + Configuration is handled by ``gb.ss.config``; see its docstring for details. + By default, the JIT caches results in ``~/.SuiteSparse/``. For more information, + see the SuiteSparse:GraphBLAS user guide. + + Only one type signature may be registered at a time, but repeated calls using + the same name with different input types is allowed. + + This will also create an IndexUnary operator under ``gb.indexunary.ss`` + + Parameters + ---------- + name : str + The name of the operator. This will show up as ``gb.select.ss.{name}``. + The name may contain periods, ".", which will result in nested objects + such as ``gb.select.ss.x.y.z`` for name ``"x.y.z"``. + jit_c_definition : str + The C definition as a string of the user-defined function. For example: + ``"void woot (bool *z, const int32_t *x, GrB_Index i, GrB_Index j, int32_t *y) "`` + ``"{ (*z) = ((*x) + i + j == (*y)) ; }"`` + input_type : dtype + The dtype of the operand of the select operator. + thunk_type : dtype + The dtype of the thunk of the select operator. + + Returns + ------- + SelectOp + + See Also + -------- + gb.select.register_new + gb.select.register_anonymous + gb.indexunary.ss.register_new + """ if backend != "suitesparse": # pragma: no cover (safety) raise RuntimeError( "`gb.select.ss.register_new` invalid when not using 'suitesparse' backend" diff --git a/graphblas/core/ss/unary.py b/graphblas/core/ss/unary.py index 97c4614c0..5a5c63632 100644 --- a/graphblas/core/ss/unary.py +++ b/graphblas/core/ss/unary.py @@ -25,6 +25,45 @@ def jit_c_definition(self): def register_new(name, jit_c_definition, input_type, ret_type): + """Register a new UnaryOp using the SuiteSparse:GraphBLAS JIT compiler. + + This creates a UnaryOp by compiling the C string definition of the function. + It requires a shell call to a C compiler. The resulting operator will be as + fast as if it were built-in to SuiteSparse:GraphBLAS and does not have the + overhead of additional function calls as when using ``gb.unary.register_new``. + + This is an advanced feature that requires a C compiler and proper configuration. + Configuration is handled by ``gb.ss.config``; see its docstring for details. + By default, the JIT caches results in ``~/.SuiteSparse/``. For more information, + see the SuiteSparse:GraphBLAS user guide. + + Only one type signature may be registered at a time, but repeated calls using + the same name with different input types is allowed. + + Parameters + ---------- + name : str + The name of the operator. This will show up as ``gb.unary.ss.{name}``. + The name may contain periods, ".", which will result in nested objects + such as ``gb.unary.ss.x.y.z`` for name ``"x.y.z"``. + jit_c_definition : str + The C definition as a string of the user-defined function. For example: + ``"void square (float *z, float *x) { (*z) = (*x) * (*x) ; } ;"`` + input_type : dtype + The dtype of the operand of the unary operator. + ret_type : dtype + The dtype of the result of the unary operator. + + Returns + ------- + UnaryOp + + See Also + -------- + gb.unary.register_new + gb.unary.register_anonymous + gb.binary.ss.register_new + """ if backend != "suitesparse": # pragma: no cover (safety) raise RuntimeError( "`gb.unary.ss.register_new` invalid when not using 'suitesparse' backend" @@ -40,9 +79,16 @@ def register_new(name, jit_c_definition, input_type, ret_type): input_type = lookup_dtype(input_type) ret_type = lookup_dtype(ret_type) name = name if name.startswith("ss.") else f"ss.{name}" - module, funcname = UnaryOp._remove_nesting(name) - - rv = UnaryOp(name) + module, funcname = UnaryOp._remove_nesting(name, strict=False) + if hasattr(module, funcname): + rv = getattr(module, funcname) + if not isinstance(rv, UnaryOp): + UnaryOp._remove_nesting(name) + if input_type in rv.types or rv._udt_types is not None and input_type in rv._udt_types: + raise TypeError(f"UnaryOp gb.unary.{name} already defined for {input_type} input type") + else: + # We use `is_udt=True` to make dtype handling flexible and explicit. + rv = UnaryOp(name, is_udt=True) gb_obj = ffi_new("GrB_UnaryOp*") check_status_carg( lib.GxB_UnaryOp_new( @@ -57,6 +103,6 @@ def register_new(name, jit_c_definition, input_type, ret_type): gb_obj[0], ) op = TypedJitUnaryOp(rv, funcname, input_type, ret_type, gb_obj[0], jit_c_definition) - rv._add(op) + rv._add(op, is_jit=True) setattr(module, funcname, rv) return rv diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index b54ea76c4..c7d1ce97c 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -19,7 +19,15 @@ ) from graphblas.core import _supports_udfs as supports_udfs from graphblas.core import lib, operator -from graphblas.core.operator import BinaryOp, IndexUnaryOp, Monoid, Semiring, UnaryOp, get_semiring +from graphblas.core.operator import ( + BinaryOp, + IndexUnaryOp, + Monoid, + SelectOp, + Semiring, + UnaryOp, + get_semiring, +) from graphblas.dtypes import ( BOOL, FP32, @@ -1336,6 +1344,19 @@ def badfunc2(x, y): # pragma: no cover (numba) assert binary.first[udt, dtypes.INT8].type2 is dtypes.INT8 assert monoid.any[udt].type2 is udt + def _this_or_that(val, idx, _, thunk): # pragma: no cover (numba) + return val["x"] + + sel = SelectOp.register_anonymous(_this_or_that, is_udt=True) + sel[udt] + assert udt in sel + result = v.select(sel, 0).new() + assert result.nvals == 0 + assert result.dtype == v.dtype + result = w.select(sel, 0).new() + assert result.nvals == 3 + assert result.isequal(w) + def test_dir(): for mod in [unary, binary, monoid, semiring, op]: diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index bd05cf2db..3c974c50d 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -165,6 +165,20 @@ def test_jit_unary(v): expected = Vector.from_coo([1, 3, 4, 6], [1, 1, 4, 0], dtype="FP32") assert expected.isequal(v) assert square["FP32"].jit_c_definition == cdef + assert "FP64" not in square + with burble(): + square_fp64 = unary.ss.register_new( + "square", cdef.replace("float", "double"), "FP64", "FP64" + ) + assert square_fp64 is square + assert "FP64" in square + with pytest.raises( + TypeError, match="UnaryOp gb.unary.ss.square already defined for FP32 input type" + ): + unary.ss.register_new("square", cdef, "FP32", "FP32") + unary.ss.register_new("nested.square", cdef, "FP32", "FP32") + with pytest.raises(AttributeError, match="nested is already defined"): + unary.ss.register_new("nested", cdef, "FP32", "FP32") def test_jit_binary(v): @@ -186,9 +200,11 @@ def test_jit_binary(v): assert not hasattr(binary, "absdiff") assert binary.ss.absdiff is absdiff assert absdiff.name == "ss.absdiff" - assert absdiff.types == {dtypes.FP64: dtypes.FP64} + assert absdiff.types == {(dtypes.FP64, dtypes.FP64): dtypes.FP64} # different than normal + assert "FP64" in absdiff + assert absdiff["FP64"].return_type == dtypes.FP64 # The JIT is unforgiving and does not coerce--use the correct types! - with pytest.raises(KeyError, match="absdiff does not work with INT64"): + with pytest.raises(KeyError, match="absdiff does not work with .INT64, INT64. types"): v << absdiff(v & v) w = (v - 1).new("FP64") v = v.dup("FP64") @@ -198,6 +214,36 @@ def test_jit_binary(v): res = absdiff(w & v).new() assert expected.isequal(res) assert absdiff["FP64"].jit_c_definition == cdef + assert "FP32" not in absdiff + with burble(): + absdiff_fp32 = binary.ss.register_new( + "absdiff", + cdef.replace("FP64", "FP32").replace("fabs", "fabsf"), + "FP32", + "FP32", + "FP32", + ) + assert absdiff_fp32 is absdiff + assert "FP32" in absdiff + with pytest.raises( + TypeError, + match="BinaryOp gb.binary.ss.absdiff already defined for .FP64, FP64. input types", + ): + binary.ss.register_new("absdiff", cdef, "FP64", "FP64", "FP64") + binary.ss.register_new("nested.absdiff", cdef, "FP64", "FP64", "FP64") + with pytest.raises(AttributeError, match="nested is already defined"): + binary.ss.register_new("nested", cdef, "FP64", "FP64", "FP64") + # Make sure we can be specific with left/right dtypes + absdiff_mixed = binary.ss.register_new( + "absdiff", + "void absdiff (double *z, double *x, float *y) { (*z) = fabs ((*x) - (double)(*y)) ; }", + "FP64", + "FP32", + "FP64", + ) + assert absdiff_mixed is absdiff + assert ("FP64", "FP32") in absdiff + assert ("FP32", "FP64") not in absdiff def test_jit_indexunary(v): @@ -218,15 +264,50 @@ def test_jit_indexunary(v): assert not hasattr(select, "diffy") assert not hasattr(select.ss, "diffy") assert diffy.name == "ss.diffy" - assert diffy.types == {dtypes.FP64: dtypes.FP64} + assert diffy.types == {(dtypes.FP64, dtypes.FP64): dtypes.FP64} + assert "FP64" in diffy + assert diffy["FP64"].return_type == dtypes.FP64 # The JIT is unforgiving and does not coerce--use the correct types! - with pytest.raises(KeyError, match="diffy does not work with INT64"): + with pytest.raises(KeyError, match="diffy does not work with .INT64, INT64. types"): v << diffy(v, 1) v = v.dup("FP64") - res = diffy(v, -1).new() + with pytest.raises(KeyError, match="diffy does not work with .FP64, INT64. types"): + v << diffy(v, -1) + res = diffy(v, -1.0).new() expected = Vector.from_coo([1, 3, 4, 6], [2, 6, 12, 6], dtype="FP64") assert expected.isequal(res) assert diffy["FP64"].jit_c_definition == cdef + assert "FP32" not in diffy + with burble(): + diffy_fp32 = indexunary.ss.register_new( + "diffy", + cdef.replace("double", "float").replace("fabs", "fabsf"), + "FP32", + "FP32", + "FP32", + ) + assert diffy_fp32 is diffy + assert "FP32" in diffy + with pytest.raises( + TypeError, + match="IndexUnaryOp gb.indexunary.ss.diffy already defined for .FP64, FP64. input types", + ): + indexunary.ss.register_new("diffy", cdef, "FP64", "FP64", "FP64") + indexunary.ss.register_new("nested.diffy", cdef, "FP64", "FP64", "FP64") + with pytest.raises(AttributeError, match="nested is already defined"): + indexunary.ss.register_new("nested", cdef, "FP64", "FP64", "FP64") + # Make sure we can be specific with left/right dtypes + diffy_mixed = indexunary.ss.register_new( + "diffy", + "void diffy (double *z, double *x, GrB_Index i, GrB_Index j, float *y) " + "{ (*z) = (i + j) * fabs ((*x) - (double)(*y)) ; }", + "FP64", + "FP32", + "FP64", + ) + assert diffy_mixed is diffy + assert ("FP64", "FP32") in diffy + assert ("FP32", "FP64") not in diffy def test_jit_select(v): @@ -248,20 +329,57 @@ def test_jit_select(v): assert not hasattr(indexunary, "woot") assert hasattr(indexunary.ss, "woot") assert woot.name == "ss.woot" - assert woot.types == {dtypes.INT32: dtypes.BOOL} + assert woot.types == {(dtypes.INT32, dtypes.INT32): dtypes.BOOL} + assert "INT32" in woot + assert woot["INT32"].return_type == dtypes.BOOL # The JIT is unforgiving and does not coerce--use the correct types! - with pytest.raises(KeyError, match="woot does not work with INT64"): + with pytest.raises(KeyError, match="woot does not work with .INT64, INT64. types"): v << woot(v, 1) v = v.dup("INT32") - res = woot(v, 6).new() + with pytest.raises(KeyError, match="woot does not work with .INT32, INT64. types"): + v << woot(v, 6) + res = woot(v, gb.Scalar.from_value(6, "INT32")).new() expected = Vector.from_coo([4, 6], [2, 0]) assert expected.isequal(res) - res = indexunary.ss.woot(v, 6).new() + res = indexunary.ss.woot(v, gb.Scalar.from_value(6, "INT32")).new() expected = Vector.from_coo([1, 3, 4, 6], [False, False, True, True]) assert expected.isequal(res) assert woot["INT32"].jit_c_definition == cdef + assert "INT64" not in woot + with burble(): + woot_int64 = select.ss.register_new( + "woot", cdef.replace("int32", "int64"), "INT64", "INT64" + ) + assert woot_int64 is woot + assert "INT64" in woot + with pytest.raises(TypeError, match="ss.woot already defined for .INT32, INT32. input types"): + select.ss.register_new("woot", cdef, "INT32", "INT32") + del indexunary.ss.woot + with pytest.raises(TypeError, match="ss.woot already defined for .INT32, INT32. input types"): + select.ss.register_new("woot", cdef, "INT32", "INT32") + select.ss.register_new("nested.woot", cdef, "INT32", "INT32") + with pytest.raises(AttributeError, match="nested is already defined"): + select.ss.register_new("nested", cdef, "INT32", "INT32") + del indexunary.ss.nested + with pytest.raises(AttributeError, match="nested is already defined"): + select.ss.register_new("nested", cdef.replace("woot", "nested"), "INT32", "INT32") + select.ss.haha = "haha" + with pytest.raises(AttributeError, match="haha is already defined"): + select.ss.register_new("haha", cdef.replace("woot", "haha"), "INT32", "INT32") + # Make sure we can be specific with left/right dtypes + woot_mixed = select.ss.register_new( + "woot", + "void woot (bool *z, const int64_t *x, GrB_Index i, GrB_Index j, int32_t *y) " + "{ (*z) = ((*x) + i + j == (*y)) ; }", + "INT64", + "INT32", + ) + assert woot_mixed is woot + assert ("INT64", "INT32") in woot + assert ("INT32", "INT64") not in woot + def test_context_importable(): if _IS_SSGB7: From 228faa1a67605bd9bfc0a6d9a64e6e49097fe0a0 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 18 Oct 2023 08:18:52 -0500 Subject: [PATCH 61/87] Add `gb.ss.burble` from python-suitesparse-graphblas (#514) --- graphblas/ss/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graphblas/ss/__init__.py b/graphblas/ss/__init__.py index b723d9cb8..1f059771b 100644 --- a/graphblas/ss/__init__.py +++ b/graphblas/ss/__init__.py @@ -1,3 +1,5 @@ +from suitesparse_graphblas import burble + from ._core import _IS_SSGB7, about, concat, config, diag if not _IS_SSGB7: From bbb1f4fa2d26b9435a0921b20383c174a2491831 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 22 Oct 2023 10:29:38 -0500 Subject: [PATCH 62/87] Handle numpy 1.21 and pandas 2.1 incompatibility (& more maint) (#516) --- .github/workflows/test_and_build.yml | 7 ++++++- .pre-commit-config.yaml | 8 ++++---- pyproject.toml | 2 +- scripts/check_versions.sh | 4 ++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index d4504e2fd..56d13557f 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -166,10 +166,11 @@ jobs: # # First let's randomly get versions of dependencies to install. # Consider removing old versions when they become problematic or very old (>=2 years). - nxver=$(python -c 'import random ; print(random.choice(["=2.7", "=2.8", "=3.0", "=3.1", ""]))') + nxver=$(python -c 'import random ; print(random.choice(["=2.7", "=2.8", "=3.0", "=3.1", "=3.2", ""]))') yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') sparsever=$(python -c 'import random ; print(random.choice(["=0.13", "=0.14", ""]))') fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') + # Randomly choosing versions of dependencies based on Python version works surprisingly well... if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') @@ -191,6 +192,10 @@ jobs: pdver=$(python -c 'import random ; print(random.choice(["=2.1", ""]))') akver=$(python -c 'import random ; print(random.choice(["=2.4", ""]))') fi + # But there may be edge cases of incompatibility we need to handle (more handled below) + if [[ ${pdver} == "=2.1" && ${npver} == "=1.21" ]]; then + pdver="=2.0" + fi if [[ ${{ steps.sourcetype.outputs.selected }} == "source" || ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then # TODO: there are currently issues with some numpy versions when # installing python-suitesparse-grphblas from source or upstream. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 96c8b9aeb..b2e08e638 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,12 +61,12 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 23.10.0 hooks: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.292 + rev: v0.1.1 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -94,11 +94,11 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.292 + rev: v0.1.1 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.8.0 + rev: v0.8.1 hooks: - id: sphinx-lint args: [--enable, all, "--disable=line-too-long,leaked-markup"] diff --git a/pyproject.toml b/pyproject.toml index 9579b1c16..04ef28645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ pandas = [ "pandas >=1.2", ] scipy = [ - "scipy >=1.8", + "scipy >=1.9", ] suitesparse-udf = [ # udf requires numba "python-graphblas[suitesparse,numba]", diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index dc0331359..7c09bc168 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -8,10 +8,10 @@ conda search 'flake8-simplify[channel=conda-forge]>=0.21.0' conda search 'numpy[channel=conda-forge]>=1.26.0' conda search 'pandas[channel=conda-forge]>=2.1.1' conda search 'scipy[channel=conda-forge]>=1.11.3' -conda search 'networkx[channel=conda-forge]>=3.1' +conda search 'networkx[channel=conda-forge]>=3.2' conda search 'awkward[channel=conda-forge]>=2.4.6' conda search 'sparse[channel=conda-forge]>=0.14.0' -conda search 'fast_matrix_market[channel=conda-forge]>=1.7.3' +conda search 'fast_matrix_market[channel=conda-forge]>=1.7.4' conda search 'numba[channel=conda-forge]>=0.57.1' conda search 'pyyaml[channel=conda-forge]>=6.0.1' # conda search 'python[channel=conda-forge]>=3.9 *pypy*' From 7935e50eeb8e9e479544e876e1e42d16a6fa90e7 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 30 Oct 2023 16:14:48 -0500 Subject: [PATCH 63/87] Handle dtypes (esp. UDTs) better in ewise_union (#517) --- .pre-commit-config.yaml | 6 +- graphblas/core/infix.py | 123 +++++++++++++++++------------- graphblas/core/matrix.py | 29 ++++--- graphblas/core/operator/monoid.py | 26 +------ graphblas/core/scalar.py | 33 +++++--- graphblas/core/vector.py | 29 ++++--- graphblas/tests/test_matrix.py | 7 ++ graphblas/tests/test_vector.py | 1 + scripts/check_versions.sh | 4 +- 9 files changed, 151 insertions(+), 107 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b2e08e638..3766e2e7c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,12 +61,12 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.10.0 + rev: 23.10.1 hooks: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.1 + rev: v0.1.3 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.1 + rev: v0.1.3 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index 88fc52dbe..09b6a6811 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -1,8 +1,9 @@ from .. import backend, binary from ..dtypes import BOOL +from ..exceptions import DimensionMismatch from ..monoid import land, lor from ..semiring import any_pair -from . import automethods, utils +from . import automethods, recorder, utils from .base import _expect_op, _expect_type from .expr import InfixExprBase from .mask import Mask @@ -402,43 +403,62 @@ def __init__(self, left, right, *, nrows, ncols): utils._output_types[MatrixMatMulExpr] = Matrix +def _dummy(obj, obj_type): + with recorder.skip_record: + return output_type(obj)(BOOL, *obj.shape, name="") + + +def _mismatched(left, right, method, op): + # Create dummy expression to raise on incompatible dimensions + getattr(_dummy(left) if isinstance(left, InfixExprBase) else left, method)( + _dummy(right) if isinstance(right, InfixExprBase) else right, op + ) + raise DimensionMismatch # pragma: no cover + + def _ewise_infix_expr(left, right, *, method, within): left_type = output_type(left) right_type = output_type(right) types = {Vector, Matrix, TransposedMatrix} if left_type in types and right_type in types: - # Create dummy expression to check compatibility of dimensions, etc. - expr = getattr(left, method)(right, binary.any) - if expr.output_type is Vector: - if method == "ewise_mult": - return VectorEwiseMultExpr(left, right) - return VectorEwiseAddExpr(left, right) + if left_type is Vector: + if right_type is Vector: + if left._size != right._size: + _mismatched(left, right, method, binary.first) + if method == "ewise_mult": + return VectorEwiseMultExpr(left, right) + return VectorEwiseAddExpr(left, right) + if left._size != right._nrows: + _mismatched(left, right, method, binary.first) + elif right_type is Vector: + if left._ncols != right._size: + _mismatched(left, right, method, binary.first) + elif left.shape != right.shape: + _mismatched(left, right, method, binary.first) if method == "ewise_mult": return MatrixEwiseMultExpr(left, right) return MatrixEwiseAddExpr(left, right) + if within == "__or__" and isinstance(right, Mask): return right.__ror__(left) if within == "__and__" and isinstance(right, Mask): return right.__rand__(left) if left_type in types: left._expect_type(right, tuple(types), within=within, argname="right") - elif right_type in types: + if 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 left_type is Scalar: 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 right_type is Scalar: 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__}") + raise TypeError( # pragma: no cover (sanity) + f"Bad types for ewise infix: {type(left).__name__}, {type(right).__name__}" + ) def _matmul_infix_expr(left, right, *, within): @@ -447,54 +467,51 @@ def _matmul_infix_expr(left, right, *, within): if left_type is Vector: if right_type is Matrix or right_type is TransposedMatrix: - method = "vxm" - elif right_type is Vector: - method = "inner" - else: - right = left._expect_type( - right, - (Matrix, TransposedMatrix), - within=within, - argname="right", - ) - elif left_type is Matrix or left_type is TransposedMatrix: + if left._size != right._nrows: + _mismatched(left, right, "vxm", any_pair[BOOL]) + return VectorMatMulExpr(left, right, method_name="vxm", size=right._ncols) if right_type is Vector: - method = "mxv" - elif right_type is Matrix or right_type is TransposedMatrix: - method = "mxm" - else: - right = left._expect_type( - right, - (Vector, Matrix, TransposedMatrix), - within=within, - argname="right", - ) - elif right_type is Vector: - left = right._expect_type( + if left._size != right._size: + _mismatched(left, right, "inner", any_pair[BOOL]) + return ScalarMatMulExpr(left, right) + left._expect_type( + right, + (Matrix, TransposedMatrix, Vector), + within=within, + argname="right", + ) + if left_type is Matrix or left_type is TransposedMatrix: + if right_type is Vector: + if left._ncols != right._size: + _mismatched(left, right, "mxv", any_pair[BOOL]) + return VectorMatMulExpr(left, right, method_name="mxv", size=left._nrows) + if right_type is Matrix or right_type is TransposedMatrix: + if left._ncols != right._nrows: + _mismatched(left, right, "mxm", any_pair[BOOL]) + return MatrixMatMulExpr(left, right, nrows=left._nrows, ncols=right._ncols) + left._expect_type( + right, + (Vector, Matrix, TransposedMatrix), + within=within, + argname="right", + ) + if right_type is Vector: + right._expect_type( left, (Matrix, TransposedMatrix), within=within, argname="left", ) - elif right_type is Matrix or right_type is TransposedMatrix: - left = right._expect_type( + if right_type is Matrix or right_type is TransposedMatrix: + right._expect_type( left, (Vector, Matrix, TransposedMatrix), within=within, argname="left", ) - else: # pragma: no cover (sanity) - raise TypeError( - f"Bad types for matmul infix: {type(left).__name__}, {type(right).__name__}" - ) - - # Create dummy expression to check compatibility of dimensions, etc. - expr = getattr(left, method)(right, any_pair[bool]) - if expr.output_type is Vector: - return VectorMatMulExpr(left, right, method_name=method, size=expr._size) - if expr.output_type is Matrix: - return MatrixMatMulExpr(left, right, nrows=expr._nrows, ncols=expr._ncols) - return ScalarMatMulExpr(left, right) + raise TypeError( # pragma: no cover (sanity) + f"Bad types for matmul infix: {type(left).__name__}, {type(right).__name__}" + ) # Import infixmethods, which has side effects diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index aed98f57d..5e1a76720 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -67,13 +67,13 @@ def _m_mult_v(updater, left, right, op): updater << left.mxm(right.diag(name="M_temp"), get_semiring(monoid.any, op)) -def _m_union_m(updater, left, right, left_default, right_default, op, dtype): +def _m_union_m(updater, left, right, left_default, right_default, op): mask = updater.kwargs.get("mask") opts = updater.opts - new_left = left.dup(dtype, clear=True) + new_left = left.dup(op.type, clear=True) new_left(mask=mask, **opts) << binary.second(right, left_default) new_left(mask=mask, **opts) << binary.first(left | new_left) - new_right = right.dup(dtype, clear=True) + new_right = right.dup(op.type2, clear=True) new_right(mask=mask, **opts) << binary.second(left, right_default) new_right(mask=mask, **opts) << binary.first(right | new_right) updater << op(new_left & new_right) @@ -2078,7 +2078,10 @@ def ewise_union(self, other, op, left_default, right_default): other = self._expect_type( other, (Matrix, TransposedMatrix, Vector), within=method_name, argname="other", op=op ) - dtype = self.dtype if self.dtype._is_udt else None + temp_op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + + left_dtype = temp_op.type + dtype = left_dtype if left_dtype._is_udt else None if type(left_default) is not Scalar: try: left = Scalar.from_value( @@ -2095,6 +2098,8 @@ def ewise_union(self, other, op, left_default, right_default): ) else: left = _as_scalar(left_default, dtype, is_cscalar=False) # pragma: is_grbscalar + right_dtype = temp_op.type2 + dtype = right_dtype if right_dtype._is_udt else None if type(right_default) is not Scalar: try: right = Scalar.from_value( @@ -2111,12 +2116,19 @@ def ewise_union(self, other, op, left_default, right_default): ) else: right = _as_scalar(right_default, dtype, is_cscalar=False) # pragma: is_grbscalar - scalar_dtype = unify(left.dtype, right.dtype) - nonscalar_dtype = unify(self.dtype, other.dtype) - op = get_typed_op(op, scalar_dtype, nonscalar_dtype, is_left_scalar=True, kind="binary") + + op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary") + op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary") + if op1 is not op2: + left_dtype = unify(op1.type, op2.type, is_right_scalar=True) + right_dtype = unify(op1.type2, op2.type2, is_left_scalar=True) + op = get_typed_op(op, left_dtype, right_dtype, kind="binary") + else: + op = op1 self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") if op.opclass == "Monoid": op = op.binaryop + expr_repr = "{0.name}.{method_name}({2.name}, {op}, {1._expr_name}, {3._expr_name})" if other.ndim == 1: # Broadcast rowwise from the right @@ -2146,11 +2158,10 @@ def ewise_union(self, other, op, left_default, right_default): expr_repr=expr_repr, ) else: - dtype = unify(scalar_dtype, nonscalar_dtype, is_left_scalar=True) expr = MatrixExpression( method_name, None, - [self, left, other, right, _m_union_m, (self, other, left, right, op, dtype)], + [self, left, other, right, _m_union_m, (self, other, left, right, op)], expr_repr=expr_repr, nrows=self._nrows, ncols=self._ncols, diff --git a/graphblas/core/operator/monoid.py b/graphblas/core/operator/monoid.py index fc327b4a7..21d2b7cac 100644 --- a/graphblas/core/operator/monoid.py +++ b/graphblas/core/operator/monoid.py @@ -19,10 +19,9 @@ ) from ...exceptions import check_status_carg from .. import ffi, lib -from ..expr import InfixExprBase from ..utils import libget -from .base import OpBase, ParameterizedUdf, TypedOpBase, _call_op, _hasop -from .binary import BinaryOp, ParameterizedBinaryOp +from .base import OpBase, ParameterizedUdf, TypedOpBase, _hasop +from .binary import BinaryOp, ParameterizedBinaryOp, TypedBuiltinBinaryOp ffi_new = ffi.new @@ -36,25 +35,6 @@ def __init__(self, parent, name, type_, return_type, gb_obj, gb_name): super().__init__(parent, name, type_, return_type, gb_obj, gb_name) self._identity = None - def __call__(self, left, right=None, *, left_default=None, right_default=None): - if left_default is not None or right_default is not None: - if ( - left_default is None - or right_default is None - or right is not None - or not isinstance(left, InfixExprBase) - or left.method_name != "ewise_add" - ): - raise TypeError( - "Specifying `left_default` or `right_default` keyword arguments implies " - "performing `ewise_union` operation with infix notation.\n" - "There is only one valid way to do this:\n\n" - f">>> {self}(x | y, left_default=0, right_default=0)\n\nwhere x and y " - "are Vectors or Matrices, and left_default and right_default are scalars." - ) - return left.left.ewise_union(left.right, self, left_default, right_default) - return _call_op(self, left, right) - @property def identity(self): if self._identity is None: @@ -84,6 +64,8 @@ def is_idempotent(self): """True if ``monoid(x, x) == x`` for any x.""" return self.parent.is_idempotent + __call__ = TypedBuiltinBinaryOp.__call__ + class TypedUserMonoid(TypedOpBase): __slots__ = "binaryop", "identity" diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index 8a95e1d71..b822bd58a 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -30,12 +30,12 @@ def _scalar_index(name): return self -def _s_union_s(updater, left, right, left_default, right_default, op, dtype): +def _s_union_s(updater, left, right, left_default, right_default, op): opts = updater.opts - new_left = left.dup(dtype, clear=True) + new_left = left.dup(op.type, 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 = right.dup(op.type2, clear=True) new_right(**opts) << binary.second(left, right_default) new_right(**opts) << binary.first(right | new_right) updater << op(new_left & new_right) @@ -742,7 +742,8 @@ def ewise_union(self, other, op, left_default, right_default): 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 + right_dtype = self.dtype + dtype = right_dtype if right_dtype._is_udt else None if type(other) is not Scalar: try: other = Scalar.from_value(other, dtype, is_cscalar=False, name="") @@ -755,6 +756,13 @@ def ewise_union(self, other, op, left_default, right_default): extra_message="Literal scalars also accepted.", op=op, ) + else: + other = _as_scalar(other, dtype, is_cscalar=False) # pragma: is_grbscalar + + temp_op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + + left_dtype = temp_op.type + dtype = left_dtype if left_dtype._is_udt else None if type(left_default) is not Scalar: try: left = Scalar.from_value( @@ -771,6 +779,8 @@ def ewise_union(self, other, op, left_default, right_default): ) else: left = _as_scalar(left_default, dtype, is_cscalar=False) # pragma: is_grbscalar + right_dtype = temp_op.type2 + dtype = right_dtype if right_dtype._is_udt else None if type(right_default) is not Scalar: try: right = Scalar.from_value( @@ -787,9 +797,15 @@ def ewise_union(self, other, op, left_default, right_default): ) else: right = _as_scalar(right_default, dtype, is_cscalar=False) # pragma: is_grbscalar - defaults_dtype = unify(left.dtype, right.dtype) - args_dtype = unify(self.dtype, other.dtype) - op = get_typed_op(op, defaults_dtype, args_dtype, kind="binary") + + op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary") + op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary") + if op1 is not op2: + left_dtype = unify(op1.type, op2.type, is_right_scalar=True) + right_dtype = unify(op1.type2, op2.type2, is_left_scalar=True) + op = get_typed_op(op, left_dtype, right_dtype, kind="binary") + else: + op = op1 self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") if op.opclass == "Monoid": op = op.binaryop @@ -805,11 +821,10 @@ def ewise_union(self, other, op, left_default, right_default): scalar_as_vector=True, ) else: - dtype = unify(defaults_dtype, args_dtype) expr = ScalarExpression( method_name, None, - [self, left, other, right, _s_union_s, (self, other, left, right, op, dtype)], + [self, left, other, right, _s_union_s, (self, other, left, right, op)], op=op, expr_repr=expr_repr, is_cscalar=False, diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index cd5b992ba..9d19d80da 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -61,13 +61,13 @@ def _v_union_m(updater, left, right, left_default, right_default, op): updater << temp.ewise_union(right, op, left_default=left_default, right_default=right_default) -def _v_union_v(updater, left, right, left_default, right_default, op, dtype): +def _v_union_v(updater, left, right, left_default, right_default, op): mask = updater.kwargs.get("mask") opts = updater.opts - new_left = left.dup(dtype, clear=True) + new_left = left.dup(op.type, clear=True) new_left(mask=mask, **opts) << binary.second(right, left_default) new_left(mask=mask, **opts) << binary.first(left | new_left) - new_right = right.dup(dtype, clear=True) + new_right = right.dup(op.type2, clear=True) new_right(mask=mask, **opts) << binary.second(left, right_default) new_right(mask=mask, **opts) << binary.first(right | new_right) updater << op(new_left & new_right) @@ -1177,7 +1177,10 @@ def ewise_union(self, other, op, left_default, right_default): other = self._expect_type( other, (Vector, Matrix, TransposedMatrix), within=method_name, argname="other", op=op ) - dtype = self.dtype if self.dtype._is_udt else None + temp_op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + + left_dtype = temp_op.type + dtype = left_dtype if left_dtype._is_udt else None if type(left_default) is not Scalar: try: left = Scalar.from_value( @@ -1194,6 +1197,8 @@ def ewise_union(self, other, op, left_default, right_default): ) else: left = _as_scalar(left_default, dtype, is_cscalar=False) # pragma: is_grbscalar + right_dtype = temp_op.type2 + dtype = right_dtype if right_dtype._is_udt else None if type(right_default) is not Scalar: try: right = Scalar.from_value( @@ -1210,12 +1215,19 @@ def ewise_union(self, other, op, left_default, right_default): ) else: right = _as_scalar(right_default, dtype, is_cscalar=False) # pragma: is_grbscalar - scalar_dtype = unify(left.dtype, right.dtype) - nonscalar_dtype = unify(self.dtype, other.dtype) - op = get_typed_op(op, scalar_dtype, nonscalar_dtype, is_left_scalar=True, kind="binary") + + op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary") + op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary") + if op1 is not op2: + left_dtype = unify(op1.type, op2.type, is_right_scalar=True) + right_dtype = unify(op1.type2, op2.type2, is_left_scalar=True) + op = get_typed_op(op, left_dtype, right_dtype, kind="binary") + else: + op = op1 self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") if op.opclass == "Monoid": op = op.binaryop + expr_repr = "{0.name}.{method_name}({2.name}, {op}, {1._expr_name}, {3._expr_name})" if other.ndim == 2: # Broadcast columnwise from the left @@ -1243,11 +1255,10 @@ def ewise_union(self, other, op, left_default, right_default): expr_repr=expr_repr, ) else: - dtype = unify(scalar_dtype, nonscalar_dtype, is_left_scalar=True) expr = VectorExpression( method_name, None, - [self, left, other, right, _v_union_v, (self, other, left, right, op, dtype)], + [self, left, other, right, _v_union_v, (self, other, left, right, op)], expr_repr=expr_repr, size=self._size, op=op, diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index e08f96b32..3f66e46ef 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -2827,7 +2827,10 @@ def test_auto(A, v): "__and__", "__or__", # "kronecker", + "__rand__", + "__ror__", ]: + # print(type(expr).__name__, method) val1 = getattr(expected, method)(expected).new() val2 = getattr(expected, method)(expr) val3 = getattr(expr, method)(expected) @@ -3138,6 +3141,10 @@ def test_ss_reshape(A): def test_autocompute_argument_messages(A, v): with pytest.raises(TypeError, match="autocompute"): A.ewise_mult(A & A) + with pytest.raises(TypeError, match="autocompute"): + A.ewise_mult(binary.plus(A & A)) + with pytest.raises(TypeError, match="autocompute"): + A.ewise_mult(A + A) with pytest.raises(TypeError, match="autocompute"): A.mxv(A @ v) diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index 2571f288b..b66bc96c9 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -1579,6 +1579,7 @@ def test_auto(v): "__rand__", "__ror__", ]: + # print(type(expr).__name__, method) val1 = getattr(expected, method)(expected).new() val2 = getattr(expected, method)(expr) val3 = getattr(expr, method)(expected) diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 7c09bc168..d197f2af2 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -6,9 +6,9 @@ conda search 'flake8-bugbear[channel=conda-forge]>=23.9.16' conda search 'flake8-simplify[channel=conda-forge]>=0.21.0' conda search 'numpy[channel=conda-forge]>=1.26.0' -conda search 'pandas[channel=conda-forge]>=2.1.1' +conda search 'pandas[channel=conda-forge]>=2.1.2' conda search 'scipy[channel=conda-forge]>=1.11.3' -conda search 'networkx[channel=conda-forge]>=3.2' +conda search 'networkx[channel=conda-forge]>=3.2.1' conda search 'awkward[channel=conda-forge]>=2.4.6' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.4' From c6d1e3113f4712fd217b15deee2722d3ffc82dad Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 4 Nov 2023 11:39:36 -0500 Subject: [PATCH 64/87] Add `semiring(A @ B @ C)` that applies semiring to both matmuls (#501) * Add `semiring(A @ B @ C)` that applies semiring to both matmuls * Also support e.g. `binaryop(x | y | z)` and `monoid(x & y & z)` --- graphblas/core/base.py | 16 +- graphblas/core/infix.py | 72 +++++ graphblas/core/matrix.py | 192 ++++++++++--- graphblas/core/operator/__init__.py | 1 + graphblas/core/operator/base.py | 4 +- graphblas/core/operator/binary.py | 4 +- graphblas/core/operator/utils.py | 25 ++ graphblas/core/scalar.py | 48 ++++ graphblas/core/vector.py | 181 ++++++++++-- graphblas/tests/test_infix.py | 414 +++++++++++++++++++++++++++- graphblas/tests/test_matrix.py | 27 +- graphblas/tests/test_scalar.py | 4 +- graphblas/tests/test_vector.py | 33 ++- 13 files changed, 933 insertions(+), 88 deletions(-) diff --git a/graphblas/core/base.py b/graphblas/core/base.py index 42a4de9a1..5658e99c1 100644 --- a/graphblas/core/base.py +++ b/graphblas/core/base.py @@ -263,23 +263,31 @@ def __call__( ) def __or__(self, other): - from .infix import _ewise_infix_expr + from .infix import _ewise_infix_expr, _ewise_mult_expr_types + if isinstance(other, _ewise_mult_expr_types): + raise TypeError("XXX") return _ewise_infix_expr(self, other, method="ewise_add", within="__or__") def __ror__(self, other): - from .infix import _ewise_infix_expr + from .infix import _ewise_infix_expr, _ewise_mult_expr_types + if isinstance(other, _ewise_mult_expr_types): + raise TypeError("XXX") return _ewise_infix_expr(other, self, method="ewise_add", within="__ror__") def __and__(self, other): - from .infix import _ewise_infix_expr + from .infix import _ewise_add_expr_types, _ewise_infix_expr + if isinstance(other, _ewise_add_expr_types): + raise TypeError("XXX") return _ewise_infix_expr(self, other, method="ewise_mult", within="__and__") def __rand__(self, other): - from .infix import _ewise_infix_expr + from .infix import _ewise_add_expr_types, _ewise_infix_expr + if isinstance(other, _ewise_add_expr_types): + raise TypeError("XXX") return _ewise_infix_expr(other, self, method="ewise_mult", within="__rand__") def __matmul__(self, other): diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index 09b6a6811..51714633c 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -126,6 +126,19 @@ class ScalarEwiseAddExpr(ScalarInfixExpr): _to_expr = _ewise_add_to_expr + # Allow e.g. `plus(x | y | z)` + __or__ = Scalar.__or__ + __ror__ = Scalar.__ror__ + _ewise_add = Scalar._ewise_add + _ewise_union = Scalar._ewise_union + + # Don't allow e.g. `plus(x | y & z)` + def __and__(self, other): + raise TypeError("XXX") + + def __rand__(self, other): + raise TypeError("XXX") + class ScalarEwiseMultExpr(ScalarInfixExpr): __slots__ = () @@ -135,6 +148,18 @@ class ScalarEwiseMultExpr(ScalarInfixExpr): _to_expr = _ewise_mult_to_expr + # Allow e.g. `plus(x & y & z)` + __and__ = Scalar.__and__ + __rand__ = Scalar.__rand__ + _ewise_mult = Scalar._ewise_mult + + # Don't allow e.g. `plus(x | y & z)` + def __or__(self, other): + raise TypeError("XXX") + + def __ror__(self, other): + raise TypeError("XXX") + class ScalarMatMulExpr(ScalarInfixExpr): __slots__ = () @@ -239,6 +264,15 @@ class VectorEwiseAddExpr(VectorInfixExpr): _to_expr = _ewise_add_to_expr + # Allow e.g. `plus(x | y | z)` + __or__ = Vector.__or__ + __ror__ = Vector.__ror__ + _ewise_add = Vector._ewise_add + _ewise_union = Vector._ewise_union + # Don't allow e.g. `plus(x | y & z)` + __and__ = ScalarEwiseAddExpr.__and__ # raises + __rand__ = ScalarEwiseAddExpr.__rand__ # raises + class VectorEwiseMultExpr(VectorInfixExpr): __slots__ = () @@ -248,6 +282,14 @@ class VectorEwiseMultExpr(VectorInfixExpr): _to_expr = _ewise_mult_to_expr + # Allow e.g. `plus(x & y & z)` + __and__ = Vector.__and__ + __rand__ = Vector.__rand__ + _ewise_mult = Vector._ewise_mult + # Don't allow e.g. `plus(x | y & z)` + __or__ = ScalarEwiseMultExpr.__or__ # raises + __ror__ = ScalarEwiseMultExpr.__ror__ # raises + class VectorMatMulExpr(VectorInfixExpr): __slots__ = "method_name" @@ -259,6 +301,11 @@ def __init__(self, left, right, *, method_name, size): self.method_name = method_name self._size = size + __matmul__ = Vector.__matmul__ + __rmatmul__ = Vector.__rmatmul__ + _inner = Vector._inner + _vxm = Vector._vxm + utils._output_types[VectorEwiseAddExpr] = Vector utils._output_types[VectorEwiseMultExpr] = Vector @@ -376,6 +423,15 @@ class MatrixEwiseAddExpr(MatrixInfixExpr): _to_expr = _ewise_add_to_expr + # Allow e.g. `plus(x | y | z)` + __or__ = Matrix.__or__ + __ror__ = Matrix.__ror__ + _ewise_add = Matrix._ewise_add + _ewise_union = Matrix._ewise_union + # Don't allow e.g. `plus(x | y & z)` + __and__ = VectorEwiseAddExpr.__and__ # raises + __rand__ = VectorEwiseAddExpr.__rand__ # raises + class MatrixEwiseMultExpr(MatrixInfixExpr): __slots__ = () @@ -385,6 +441,14 @@ class MatrixEwiseMultExpr(MatrixInfixExpr): _to_expr = _ewise_mult_to_expr + # Allow e.g. `plus(x & y & z)` + __and__ = Matrix.__and__ + __rand__ = Matrix.__rand__ + _ewise_mult = Matrix._ewise_mult + # Don't allow e.g. `plus(x | y & z)` + __or__ = VectorEwiseMultExpr.__or__ # raises + __ror__ = VectorEwiseMultExpr.__ror__ # raises + class MatrixMatMulExpr(MatrixInfixExpr): __slots__ = () @@ -397,6 +461,11 @@ def __init__(self, left, right, *, nrows, ncols): self._nrows = nrows self._ncols = ncols + __matmul__ = Matrix.__matmul__ + __rmatmul__ = Matrix.__rmatmul__ + _mxm = Matrix._mxm + _mxv = Matrix._mxv + utils._output_types[MatrixEwiseAddExpr] = Matrix utils._output_types[MatrixEwiseMultExpr] = Matrix @@ -514,5 +583,8 @@ def _matmul_infix_expr(left, right, *, within): ) +_ewise_add_expr_types = (MatrixEwiseAddExpr, VectorEwiseAddExpr, ScalarEwiseAddExpr) +_ewise_mult_expr_types = (MatrixEwiseMultExpr, VectorEwiseMultExpr, ScalarEwiseMultExpr) + # Import infixmethods, which has side effects from . import infixmethods # noqa: E402, F401 isort:skip diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 5e1a76720..34789d68d 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -10,9 +10,16 @@ from . import _supports_udfs, automethods, ffi, lib, utils from .base import BaseExpression, BaseType, _check_mask, call from .descriptor import lookup as descriptor_lookup -from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, Updater +from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, InfixExprBase, Updater from .mask import Mask, StructuralMask, ValueMask -from .operator import UNKNOWN_OPCLASS, find_opclass, get_semiring, get_typed_op, op_from_string +from .operator import ( + UNKNOWN_OPCLASS, + _get_typed_op_from_exprs, + find_opclass, + get_semiring, + get_typed_op, + op_from_string, +) from .scalar import ( _COMPLETE, _MATERIALIZE, @@ -1938,17 +1945,39 @@ def ewise_add(self, other, op=monoid.plus): # Functional syntax C << monoid.max(A | B) """ + return self._ewise_add(other, op) + + def _ewise_add(self, other, op=monoid.plus, is_infix=False): method_name = "ewise_add" - other = self._expect_type( - other, - (Matrix, TransposedMatrix, Vector), - within=method_name, - argname="other", - op=op, - ) - op = get_typed_op(op, self.dtype, other.dtype, kind="binary") - # Per the spec, op may be a semiring, but this is weird, so don't. - self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if is_infix: + from .infix import MatrixEwiseAddExpr, VectorEwiseAddExpr + + other = self._expect_type( + other, + (Matrix, TransposedMatrix, Vector, MatrixEwiseAddExpr, VectorEwiseAddExpr), + within=method_name, + argname="other", + op=op, + ) + op = _get_typed_op_from_exprs(op, self, other, kind="binary") + # Per the spec, op may be a semiring, but this is weird, so don't. + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if isinstance(self, MatrixEwiseAddExpr): + self = op(self).new() + if isinstance(other, InfixExprBase): + other = op(other).new() + else: + other = self._expect_type( + other, + (Matrix, TransposedMatrix, Vector), + within=method_name, + argname="other", + op=op, + ) + op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + # Per the spec, op may be a semiring, but this is weird, so don't. + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if other.ndim == 1: # Broadcast rowwise from the right if self._ncols != other._size: @@ -2006,13 +2035,39 @@ def ewise_mult(self, other, op=binary.times): # Functional syntax C << binary.gt(A & B) """ + return self._ewise_mult(other, op) + + def _ewise_mult(self, other, op=binary.times, is_infix=False): method_name = "ewise_mult" - other = self._expect_type( - other, (Matrix, TransposedMatrix, Vector), within=method_name, argname="other", op=op - ) - op = get_typed_op(op, self.dtype, other.dtype, kind="binary") - # Per the spec, op may be a semiring, but this is weird, so don't. - self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if is_infix: + from .infix import MatrixEwiseMultExpr, VectorEwiseMultExpr + + other = self._expect_type( + other, + (Matrix, TransposedMatrix, Vector, MatrixEwiseMultExpr, VectorEwiseMultExpr), + within=method_name, + argname="other", + op=op, + ) + op = _get_typed_op_from_exprs(op, self, other, kind="binary") + # Per the spec, op may be a semiring, but this is weird, so don't. + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if isinstance(self, MatrixEwiseMultExpr): + self = op(self).new() + if isinstance(other, InfixExprBase): + other = op(other).new() + else: + other = self._expect_type( + other, + (Matrix, TransposedMatrix, Vector), + within=method_name, + argname="other", + op=op, + ) + op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + # Per the spec, op may be a semiring, but this is weird, so don't. + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if other.ndim == 1: # Broadcast rowwise from the right if self._ncols != other._size: @@ -2074,11 +2129,30 @@ def ewise_union(self, other, op, left_default, right_default): # Functional syntax C << binary.div(A | B, left_default=1, right_default=1) """ + return self._ewise_union(other, op, left_default, right_default) + + def _ewise_union(self, other, op, left_default, right_default, is_infix=False): method_name = "ewise_union" - other = self._expect_type( - other, (Matrix, TransposedMatrix, Vector), within=method_name, argname="other", op=op - ) - temp_op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + if is_infix: + from .infix import MatrixEwiseAddExpr, VectorEwiseAddExpr + + other = self._expect_type( + other, + (Matrix, TransposedMatrix, Vector, MatrixEwiseAddExpr, VectorEwiseAddExpr), + within=method_name, + argname="other", + op=op, + ) + temp_op = _get_typed_op_from_exprs(op, self, other, kind="binary") + else: + other = self._expect_type( + other, + (Matrix, TransposedMatrix, Vector), + within=method_name, + argname="other", + op=op, + ) + temp_op = get_typed_op(op, self.dtype, other.dtype, kind="binary") left_dtype = temp_op.type dtype = left_dtype if left_dtype._is_udt else None @@ -2117,8 +2191,12 @@ def ewise_union(self, other, op, left_default, right_default): else: right = _as_scalar(right_default, dtype, is_cscalar=False) # pragma: is_grbscalar - op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary") - op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary") + if is_infix: + op1 = _get_typed_op_from_exprs(op, self, right, kind="binary") + op2 = _get_typed_op_from_exprs(op, left, other, kind="binary") + else: + op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary") + op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary") if op1 is not op2: left_dtype = unify(op1.type, op2.type, is_right_scalar=True) right_dtype = unify(op1.type2, op2.type2, is_left_scalar=True) @@ -2129,6 +2207,12 @@ def ewise_union(self, other, op, left_default, right_default): if op.opclass == "Monoid": op = op.binaryop + if is_infix: + if isinstance(self, MatrixEwiseAddExpr): + self = op(self, left_default=left, right_default=right).new() + if isinstance(other, InfixExprBase): + other = op(other, left_default=left, right_default=right).new() + expr_repr = "{0.name}.{method_name}({2.name}, {op}, {1._expr_name}, {3._expr_name})" if other.ndim == 1: # Broadcast rowwise from the right @@ -2198,10 +2282,27 @@ def mxv(self, other, op=semiring.plus_times): # Functional syntax C << semiring.min_plus(A @ v) """ + return self._mxv(other, op) + + def _mxv(self, other, op=semiring.plus_times, is_infix=False): method_name = "mxv" - other = self._expect_type(other, Vector, within=method_name, argname="other", op=op) - op = get_typed_op(op, self.dtype, other.dtype, kind="semiring") - self._expect_op(op, "Semiring", within=method_name, argname="op") + if is_infix: + from .infix import MatrixMatMulExpr, VectorMatMulExpr + + other = self._expect_type( + other, (Vector, VectorMatMulExpr), within=method_name, argname="other", op=op + ) + op = _get_typed_op_from_exprs(op, self, other, kind="semiring") + self._expect_op(op, "Semiring", within=method_name, argname="op") + if isinstance(self, MatrixMatMulExpr): + self = op(self).new() + if isinstance(other, VectorMatMulExpr): + other = op(other).new() + else: + other = self._expect_type(other, Vector, within=method_name, argname="other", op=op) + op = get_typed_op(op, self.dtype, other.dtype, kind="semiring") + self._expect_op(op, "Semiring", within=method_name, argname="op") + expr = VectorExpression( method_name, "GrB_mxv", @@ -2241,12 +2342,33 @@ def mxm(self, other, op=semiring.plus_times): # Functional syntax C << semiring.min_plus(A @ B) """ + return self._mxm(other, op) + + def _mxm(self, other, op=semiring.plus_times, is_infix=False): method_name = "mxm" - other = self._expect_type( - other, (Matrix, TransposedMatrix), within=method_name, argname="other", op=op - ) - op = get_typed_op(op, self.dtype, other.dtype, kind="semiring") - self._expect_op(op, "Semiring", within=method_name, argname="op") + if is_infix: + from .infix import MatrixMatMulExpr + + other = self._expect_type( + other, + (Matrix, TransposedMatrix, MatrixMatMulExpr), + within=method_name, + argname="other", + op=op, + ) + op = _get_typed_op_from_exprs(op, self, other, kind="semiring") + self._expect_op(op, "Semiring", within=method_name, argname="op") + if isinstance(self, MatrixMatMulExpr): + self = op(self).new() + if isinstance(other, MatrixMatMulExpr): + other = op(other).new() + else: + other = self._expect_type( + other, (Matrix, TransposedMatrix), within=method_name, argname="other", op=op + ) + op = get_typed_op(op, self.dtype, other.dtype, kind="semiring") + self._expect_op(op, "Semiring", within=method_name, argname="op") + expr = MatrixExpression( method_name, "GrB_mxm", @@ -3862,6 +3984,12 @@ def to_dicts(self, order="rowwise"): reposition = Matrix.reposition power = Matrix.power + _ewise_add = Matrix._ewise_add + _ewise_mult = Matrix._ewise_mult + _ewise_union = Matrix._ewise_union + _mxv = Matrix._mxv + _mxm = Matrix._mxm + # Operator sugar __or__ = Matrix.__or__ __ror__ = Matrix.__ror__ diff --git a/graphblas/core/operator/__init__.py b/graphblas/core/operator/__init__.py index 509e84a04..d59c835b3 100644 --- a/graphblas/core/operator/__init__.py +++ b/graphblas/core/operator/__init__.py @@ -6,6 +6,7 @@ from .semiring import ParameterizedSemiring, Semiring from .unary import ParameterizedUnaryOp, UnaryOp from .utils import ( + _get_typed_op_from_exprs, aggregator_from_string, binary_from_string, get_semiring, diff --git a/graphblas/core/operator/base.py b/graphblas/core/operator/base.py index d66aa2f4a..59482b47d 100644 --- a/graphblas/core/operator/base.py +++ b/graphblas/core/operator/base.py @@ -111,7 +111,9 @@ def _call_op(op, left, right=None, thunk=None, **kwargs): if right is None and thunk is None: if isinstance(left, InfixExprBase): # op(A & B), op(A | B), op(A @ B) - return getattr(left.left, left.method_name)(left.right, op, **kwargs) + return getattr(left.left, f"_{left.method_name}")( + left.right, op, is_infix=True, **kwargs + ) if find_opclass(op)[1] == "Semiring": raise TypeError( f"Bad type when calling {op!r}. Got type: {type(left)}.\n" diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py index 676ed0970..278ee3183 100644 --- a/graphblas/core/operator/binary.py +++ b/graphblas/core/operator/binary.py @@ -94,7 +94,9 @@ def __call__(self, left, right=None, *, left_default=None, right_default=None): f">>> {self}(x | y, left_default=0, right_default=0)\n\nwhere x and y " "are Vectors or Matrices, and left_default and right_default are scalars." ) - return left.left.ewise_union(left.right, self, left_default, right_default) + return left.left._ewise_union( + left.right, self, left_default, right_default, is_infix=True + ) return _call_op(self, left, right) @property diff --git a/graphblas/core/operator/utils.py b/graphblas/core/operator/utils.py index 00df31db8..cd0b82d3c 100644 --- a/graphblas/core/operator/utils.py +++ b/graphblas/core/operator/utils.py @@ -2,6 +2,7 @@ from ... import backend, binary, config, indexunary, monoid, op, select, semiring, unary from ...dtypes import UINT64, lookup_dtype, unify +from ..expr import InfixExprBase from .base import ( _SS_OPERATORS, OpBase, @@ -132,6 +133,30 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala raise TypeError(f"Unable to get typed operator from object with type {type(op)}") +def _get_typed_op_from_exprs(op, left, right, *, kind=None): + if isinstance(left, InfixExprBase): + left_op = _get_typed_op_from_exprs(op, left.left, left.right, kind=kind) + left_dtype = left_op.type + else: + left_op = None + left_dtype = left.dtype + if isinstance(right, InfixExprBase): + right_op = _get_typed_op_from_exprs(op, right.left, right.right, kind=kind) + if right_op is left_op: + return right_op + right_dtype = right_op.type2 + else: + right_dtype = right.dtype + return get_typed_op( + op, + left_dtype, + right_dtype, + is_left_scalar=left._is_scalar, + is_right_scalar=right._is_scalar, + kind=kind, + ) + + def get_semiring(monoid, binaryop, name=None): """Get or create a Semiring object from a monoid and binaryop. diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index b822bd58a..9cdf3043e 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -629,7 +629,23 @@ def ewise_add(self, other, op=monoid.plus): # Functional syntax c << monoid.max(a | b) """ + return self._ewise_add(other, op) + + def _ewise_add(self, other, op=monoid.plus, is_infix=False): method_name = "ewise_add" + if is_infix: + from .infix import ScalarEwiseAddExpr + + # This is a little different than how we handle ewise_add for Vector and + # Matrix where we are super-careful to handle dtypes well to support UDTs. + # For Scalar, we're going to let dtypes in expressions resolve themselves. + # Scalars are more challenging, because they may be literal scalars. + # Also, we have not yet resolved `op` here, so errors may be different. + if isinstance(self, ScalarEwiseAddExpr): + self = op(self).new() + if isinstance(other, ScalarEwiseAddExpr): + other = op(other).new() + if type(other) is not Scalar: dtype = self.dtype if self.dtype._is_udt else None try: @@ -683,7 +699,23 @@ def ewise_mult(self, other, op=binary.times): # Functional syntax c << binary.gt(a & b) """ + return self._ewise_mult(other, op) + + def _ewise_mult(self, other, op=binary.times, is_infix=False): method_name = "ewise_mult" + if is_infix: + from .infix import ScalarEwiseMultExpr + + # This is a little different than how we handle ewise_mult for Vector and + # Matrix where we are super-careful to handle dtypes well to support UDTs. + # For Scalar, we're going to let dtypes in expressions resolve themselves. + # Scalars are more challenging, because they may be literal scalars. + # Also, we have not yet resolved `op` here, so errors may be different. + if isinstance(self, ScalarEwiseMultExpr): + self = op(self).new() + if isinstance(other, ScalarEwiseMultExpr): + other = op(other).new() + if type(other) is not Scalar: dtype = self.dtype if self.dtype._is_udt else None try: @@ -741,7 +773,23 @@ def ewise_union(self, other, op, left_default, right_default): # Functional syntax c << binary.div(a | b, left_default=1, right_default=1) """ + return self._ewise_union(other, op, left_default, right_default) + + def _ewise_union(self, other, op, left_default, right_default, is_infix=False): method_name = "ewise_union" + if is_infix: + from .infix import ScalarEwiseAddExpr + + # This is a little different than how we handle ewise_union for Vector and + # Matrix where we are super-careful to handle dtypes well to support UDTs. + # For Scalar, we're going to let dtypes in expressions resolve themselves. + # Scalars are more challenging, because they may be literal scalars. + # Also, we have not yet resolved `op` here, so errors may be different. + if isinstance(self, ScalarEwiseAddExpr): + self = op(self, left_default=left_default, right_default=right_default).new() + if isinstance(other, ScalarEwiseAddExpr): + other = op(other, left_default=left_default, right_default=right_default).new() + right_dtype = self.dtype dtype = right_dtype if right_dtype._is_udt else None if type(other) is not Scalar: diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 9d19d80da..feb95ed02 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -9,9 +9,16 @@ from . import _supports_udfs, automethods, ffi, lib, utils from .base import BaseExpression, BaseType, _check_mask, call from .descriptor import lookup as descriptor_lookup -from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, Updater +from .expr import _ALL_INDICES, AmbiguousAssignOrExtract, IndexerResolver, InfixExprBase, Updater from .mask import Mask, StructuralMask, ValueMask -from .operator import UNKNOWN_OPCLASS, find_opclass, get_semiring, get_typed_op, op_from_string +from .operator import ( + UNKNOWN_OPCLASS, + _get_typed_op_from_exprs, + find_opclass, + get_semiring, + get_typed_op, + op_from_string, +) from .scalar import ( _COMPLETE, _MATERIALIZE, @@ -1038,15 +1045,41 @@ def ewise_add(self, other, op=monoid.plus): # Functional syntax w << monoid.max(u | v) """ + return self._ewise_add(other, op) + + def _ewise_add(self, other, op=monoid.plus, is_infix=False): from .matrix import Matrix, MatrixExpression, TransposedMatrix method_name = "ewise_add" - other = self._expect_type( - other, (Vector, Matrix, TransposedMatrix), within=method_name, argname="other", op=op - ) - op = get_typed_op(op, self.dtype, other.dtype, kind="binary") - # Per the spec, op may be a semiring, but this is weird, so don't. - self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if is_infix: + from .infix import MatrixEwiseAddExpr, VectorEwiseAddExpr + + other = self._expect_type( + other, + (Vector, Matrix, TransposedMatrix, MatrixEwiseAddExpr, VectorEwiseAddExpr), + within=method_name, + argname="other", + op=op, + ) + op = _get_typed_op_from_exprs(op, self, other, kind="binary") + # Per the spec, op may be a semiring, but this is weird, so don't. + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if isinstance(self, VectorEwiseAddExpr): + self = op(self).new() + if isinstance(other, InfixExprBase): + other = op(other).new() + else: + other = self._expect_type( + other, + (Vector, Matrix, TransposedMatrix), + within=method_name, + argname="other", + op=op, + ) + op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + # Per the spec, op may be a semiring, but this is weird, so don't. + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if other.ndim == 2: # Broadcast columnwise from the left if other._nrows != self._size: @@ -1103,15 +1136,40 @@ def ewise_mult(self, other, op=binary.times): # Functional syntax w << binary.gt(u & v) """ + return self._ewise_mult(other, op) + + def _ewise_mult(self, other, op=binary.times, is_infix=False): from .matrix import Matrix, MatrixExpression, TransposedMatrix method_name = "ewise_mult" - other = self._expect_type( - other, (Vector, Matrix, TransposedMatrix), within=method_name, argname="other", op=op - ) - op = get_typed_op(op, self.dtype, other.dtype, kind="binary") - # Per the spec, op may be a semiring, but this is weird, so don't. - self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if is_infix: + from .infix import MatrixEwiseMultExpr, VectorEwiseMultExpr + + other = self._expect_type( + other, + (Vector, Matrix, TransposedMatrix, MatrixEwiseMultExpr, VectorEwiseMultExpr), + within=method_name, + argname="other", + op=op, + ) + op = _get_typed_op_from_exprs(op, self, other, kind="binary") + # Per the spec, op may be a semiring, but this is weird, so don't. + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") + if isinstance(self, VectorEwiseMultExpr): + self = op(self).new() + if isinstance(other, InfixExprBase): + other = op(other).new() + else: + other = self._expect_type( + other, + (Vector, Matrix, TransposedMatrix), + within=method_name, + argname="other", + op=op, + ) + op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + # Per the spec, op may be a semiring, but this is weird, so don't. + self._expect_op(op, ("BinaryOp", "Monoid"), within=method_name, argname="op") if other.ndim == 2: # Broadcast columnwise from the left if other._nrows != self._size: @@ -1171,13 +1229,32 @@ def ewise_union(self, other, op, left_default, right_default): # Functional syntax w << binary.div(u | v, left_default=1, right_default=1) """ + return self._ewise_union(other, op, left_default, right_default) + + def _ewise_union(self, other, op, left_default, right_default, is_infix=False): from .matrix import Matrix, MatrixExpression, TransposedMatrix method_name = "ewise_union" - other = self._expect_type( - other, (Vector, Matrix, TransposedMatrix), within=method_name, argname="other", op=op - ) - temp_op = get_typed_op(op, self.dtype, other.dtype, kind="binary") + if is_infix: + from .infix import MatrixEwiseAddExpr, VectorEwiseAddExpr + + other = self._expect_type( + other, + (Vector, Matrix, TransposedMatrix, MatrixEwiseAddExpr, VectorEwiseAddExpr), + within=method_name, + argname="other", + op=op, + ) + temp_op = _get_typed_op_from_exprs(op, self, other, kind="binary") + else: + other = self._expect_type( + other, + (Vector, Matrix, TransposedMatrix), + within=method_name, + argname="other", + op=op, + ) + temp_op = get_typed_op(op, self.dtype, other.dtype, kind="binary") left_dtype = temp_op.type dtype = left_dtype if left_dtype._is_udt else None @@ -1216,8 +1293,12 @@ def ewise_union(self, other, op, left_default, right_default): else: right = _as_scalar(right_default, dtype, is_cscalar=False) # pragma: is_grbscalar - op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary") - op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary") + if is_infix: + op1 = _get_typed_op_from_exprs(op, self, right, kind="binary") + op2 = _get_typed_op_from_exprs(op, left, other, kind="binary") + else: + op1 = get_typed_op(op, self.dtype, right.dtype, kind="binary") + op2 = get_typed_op(op, left.dtype, other.dtype, kind="binary") if op1 is not op2: left_dtype = unify(op1.type, op2.type, is_right_scalar=True) right_dtype = unify(op1.type2, op2.type2, is_left_scalar=True) @@ -1228,6 +1309,12 @@ def ewise_union(self, other, op, left_default, right_default): if op.opclass == "Monoid": op = op.binaryop + if is_infix: + if isinstance(self, VectorEwiseAddExpr): + self = op(self, left_default=left, right_default=right).new() + if isinstance(other, InfixExprBase): + other = op(other, left_default=left, right_default=right).new() + expr_repr = "{0.name}.{method_name}({2.name}, {op}, {1._expr_name}, {3._expr_name})" if other.ndim == 2: # Broadcast columnwise from the left @@ -1296,14 +1383,35 @@ def vxm(self, other, op=semiring.plus_times): # Functional syntax C << semiring.min_plus(v @ A) """ + return self._vxm(other, op) + + def _vxm(self, other, op=semiring.plus_times, is_infix=False): from .matrix import Matrix, TransposedMatrix method_name = "vxm" - other = self._expect_type( - other, (Matrix, TransposedMatrix), within=method_name, argname="other", op=op - ) - op = get_typed_op(op, self.dtype, other.dtype, kind="semiring") - self._expect_op(op, "Semiring", within=method_name, argname="op") + if is_infix: + from .infix import MatrixMatMulExpr, VectorMatMulExpr + + other = self._expect_type( + other, + (Matrix, TransposedMatrix, MatrixMatMulExpr), + within=method_name, + argname="other", + op=op, + ) + op = _get_typed_op_from_exprs(op, self, other, kind="semiring") + self._expect_op(op, "Semiring", within=method_name, argname="op") + if isinstance(self, VectorMatMulExpr): + self = op(self).new() + if isinstance(other, MatrixMatMulExpr): + other = op(other).new() + else: + other = self._expect_type( + other, (Matrix, TransposedMatrix), within=method_name, argname="other", op=op + ) + op = get_typed_op(op, self.dtype, other.dtype, kind="semiring") + self._expect_op(op, "Semiring", within=method_name, argname="op") + expr = VectorExpression( method_name, "GrB_vxm", @@ -1645,10 +1753,27 @@ def inner(self, other, op=semiring.plus_times): `Matrix Multiplication <../user_guide/operations.html#matrix-multiply>`__ family of functions. """ + return self._inner(other, op) + + def _inner(self, other, op=semiring.plus_times, is_infix=False): method_name = "inner" - other = self._expect_type(other, Vector, within=method_name, argname="other", op=op) - op = get_typed_op(op, self.dtype, other.dtype, kind="semiring") - self._expect_op(op, "Semiring", within=method_name, argname="op") + if is_infix: + from .infix import VectorMatMulExpr + + other = self._expect_type( + other, (Vector, VectorMatMulExpr), within=method_name, argname="other", op=op + ) + op = _get_typed_op_from_exprs(op, self, other, kind="semiring") + self._expect_op(op, "Semiring", within=method_name, argname="op") + if isinstance(self, VectorMatMulExpr): + self = op(self).new() + if isinstance(other, VectorMatMulExpr): + other = op(other).new() + else: + other = self._expect_type(other, Vector, within=method_name, argname="other", op=op) + op = get_typed_op(op, self.dtype, other.dtype, kind="semiring") + self._expect_op(op, "Semiring", within=method_name, argname="op") + expr = ScalarExpression( method_name, "GrB_vxm", diff --git a/graphblas/tests/test_infix.py b/graphblas/tests/test_infix.py index 72e1c8a42..e688086b9 100644 --- a/graphblas/tests/test_infix.py +++ b/graphblas/tests/test_infix.py @@ -1,6 +1,6 @@ import pytest -from graphblas import monoid, op +from graphblas import binary, monoid, op from graphblas.exceptions import DimensionMismatch from .conftest import autocompute @@ -367,3 +367,415 @@ def test_infix_expr_value_types(): expr._value = None assert expr._value is None assert expr._expr._value is None + + +def test_multi_infix_vector(): + D0 = Vector.from_scalar(0, 3).diag() + v1 = Vector.from_coo([0, 1], [1, 2], size=3) # 1 2 . + v2 = Vector.from_coo([1, 2], [1, 2], size=3) # . 1 2 + v3 = Vector.from_coo([2, 0], [1, 2], size=3) # 2 . 1 + # ewise_add + result = binary.plus((v1 | v2) | v3).new() + expected = Vector.from_scalar(3, size=3) + assert result.isequal(expected) + result = binary.plus(v1 | (v2 | v3)).new() + assert result.isequal(expected) + result = monoid.min(v1 | v2 | v3).new() + expected = Vector.from_scalar(1, size=3) + assert result.isequal(expected) + # ewise_mult + result = monoid.max((v1 & v2) & v3).new() + expected = Vector(int, size=3) + assert result.isequal(expected) + result = monoid.max(v1 & (v2 & v3)).new() + assert result.isequal(expected) + result = monoid.min((v1 & v2) & v1).new() + expected = Vector.from_coo([1], [1], size=3) + assert result.isequal(expected) + # ewise_union + result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10).new() + expected = Vector.from_scalar(13, size=3) + assert result.isequal(expected) + result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10.0).new() + expected = Vector.from_scalar(13.0, size=3) + assert result.isequal(expected) + result = binary.plus(v1 | (v2 | v3), left_default=10, right_default=10).new() + assert result.isequal(expected) + # inner + assert op.plus_plus(v1 @ v1).new().value == 6 + assert op.plus_plus(v1 @ (v1 @ D0)).new().value == 6 + assert op.plus_plus((D0 @ v1) @ v1).new().value == 6 + # matrix-vector ewise_add + result = binary.plus((D0 | v1) | v2).new() + expected = binary.plus(binary.plus(D0 | v1).new() | v2).new() + assert result.isequal(expected) + result = binary.plus(D0 | (v1 | v2)).new() + assert result.isequal(expected) + result = binary.plus((v1 | v2) | D0).new() + assert result.isequal(expected.T) + result = binary.plus(v1 | (v2 | D0)).new() + assert result.isequal(expected.T) + # matrix-vector ewise_mult + result = binary.plus((D0 & v1) & v2).new() + expected = binary.plus(binary.plus(D0 & v1).new() & v2).new() + assert result.isequal(expected) + assert result.nvals > 0 + result = binary.plus(D0 & (v1 & v2)).new() + assert result.isequal(expected) + result = binary.plus((v1 & v2) & D0).new() + assert result.isequal(expected.T) + result = binary.plus(v1 & (v2 & D0)).new() + assert result.isequal(expected.T) + # matrix-vector ewise_union + kwargs = {"left_default": 10, "right_default": 20} + result = binary.plus((D0 | v1) | v2, **kwargs).new() + expected = binary.plus(binary.plus(D0 | v1, **kwargs).new() | v2, **kwargs).new() + assert result.isequal(expected) + result = binary.plus(D0 | (v1 | v2), **kwargs).new() + expected = binary.plus(D0 | binary.plus(v1 | v2, **kwargs).new(), **kwargs).new() + assert result.isequal(expected) + result = binary.plus((v1 | v2) | D0, **kwargs).new() + expected = binary.plus(binary.plus(v1 | v2, **kwargs).new() | D0, **kwargs).new() + assert result.isequal(expected) + result = binary.plus(v1 | (v2 | D0), **kwargs).new() + expected = binary.plus(v1 | binary.plus(v2 | D0, **kwargs).new(), **kwargs).new() + assert result.isequal(expected) + # vxm, mxv + result = op.plus_plus((D0 @ v1) @ D0).new() + assert result.isequal(v1) + result = op.plus_plus(D0 @ (v1 @ D0)).new() + assert result.isequal(v1) + result = op.plus_plus(v1 @ (D0 @ D0)).new() + assert result.isequal(v1) + result = op.plus_plus((D0 @ D0) @ v1).new() + assert result.isequal(v1) + result = op.plus_plus((v1 @ D0) @ D0).new() + assert result.isequal(v1) + result = op.plus_plus(D0 @ (D0 @ v1)).new() + assert result.isequal(v1) + + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) | v3 + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2).__ror__(v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) | (v2 & v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) | (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + v1 | (v2 & v3) + with pytest.raises(TypeError, match="XXX"): # TODO + v1.__ror__(v2 & v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) | (v2 & v3) + + with pytest.raises(TypeError, match="XXX"): # TODO + v1 & (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + v1.__rand__(v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) & (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) & (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) & v3 + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2).__rand__(v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) & (v2 & v3) + + # We differentiate between infix and methods + with pytest.raises(TypeError, match="to automatically compute"): + v1.ewise_add(v2 & v3) + with pytest.raises(TypeError, match="Automatic computation"): + (v1 & v2).ewise_add(v3) + with pytest.raises(TypeError, match="to automatically compute"): + v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="Automatic computation"): + (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="to automatically compute"): + v1.ewise_mult(v2 | v3) + with pytest.raises(TypeError, match="Automatic computation"): + (v1 | v2).ewise_mult(v3) + + +@autocompute +def test_multi_infix_vector_auto(): + v1 = Vector.from_coo([0, 1], [1, 2], size=3) # 1 2 . + v2 = Vector.from_coo([1, 2], [1, 2], size=3) # . 1 2 + v3 = Vector.from_coo([2, 0], [1, 2], size=3) # 2 . 1 + # We differentiate between infix and methods + with pytest.raises(TypeError, match="only valid for BOOL"): + v1.ewise_add(v2 & v3) + with pytest.raises(TypeError, match="only valid for BOOL"): + (v1 & v2).ewise_add(v3) + with pytest.raises(TypeError, match="only valid for BOOL"): + v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="only valid for BOOL"): + (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="only valid for BOOL"): + v1.ewise_mult(v2 | v3) + with pytest.raises(TypeError, match="only valid for BOOL"): + (v1 | v2).ewise_mult(v3) + + +def test_multi_infix_matrix(): + # Adapted from test_multi_infix_vector + D0 = Vector.from_scalar(0, 3).diag() + v1 = Matrix.from_coo([0, 1], [0, 0], [1, 2], nrows=3) # 1 2 . + v2 = Matrix.from_coo([1, 2], [0, 0], [1, 2], nrows=3) # . 1 2 + v3 = Matrix.from_coo([2, 0], [0, 0], [1, 2], nrows=3) # 2 . 1 + # ewise_add + result = binary.plus((v1 | v2) | v3).new() + expected = Matrix.from_scalar(3, 3, 1) + assert result.isequal(expected) + result = binary.plus(v1 | (v2 | v3)).new() + assert result.isequal(expected) + result = monoid.min(v1 | v2 | v3).new() + expected = Matrix.from_scalar(1, 3, 1) + assert result.isequal(expected) + result = binary.plus(v1 | v1 | v1 | v1 | v1).new() + expected = (5 * v1).new() + assert result.isequal(expected) + # ewise_mult + result = monoid.max((v1 & v2) & v3).new() + expected = Matrix(int, 3, 1) + assert result.isequal(expected) + result = monoid.max(v1 & (v2 & v3)).new() + assert result.isequal(expected) + result = monoid.min((v1 & v2) & v1).new() + expected = Matrix.from_coo([1], [0], [1], nrows=3) + assert result.isequal(expected) + result = binary.plus(v1 & v1 & v1 & v1 & v1).new() + expected = (5 * v1).new() + assert result.isequal(expected) + # ewise_union + result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10).new() + expected = Matrix.from_scalar(13, 3, 1) + assert result.isequal(expected) + result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10.0).new() + expected = Matrix.from_scalar(13.0, 3, 1) + assert result.isequal(expected) + result = binary.plus(v1 | (v2 | v3), left_default=10, right_default=10).new() + assert result.isequal(expected) + # mxm + assert op.plus_plus(v1.T @ v1).new()[0, 0].new().value == 6 + assert op.plus_plus(v1 @ (v1.T @ D0)).new()[0, 0].new().value == 2 + assert op.plus_plus((v1.T @ D0) @ v1).new()[0, 0].new().value == 6 + assert op.plus_plus(D0 @ D0 @ D0 @ D0 @ D0).new().isequal(D0) + + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) | v3 + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2).__ror__(v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) | (v2 & v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) | (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + v1 | (v2 & v3) + with pytest.raises(TypeError, match="XXX"): # TODO + v1.__ror__(v2 & v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) | (v2 & v3) + + with pytest.raises(TypeError, match="XXX"): # TODO + v1 & (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + v1.__rand__(v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) & (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) & (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) & v3 + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2).__rand__(v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) & (v2 & v3) + + # We differentiate between infix and methods + with pytest.raises(TypeError, match="to automatically compute"): + v1.ewise_add(v2 & v3) + with pytest.raises(TypeError, match="Automatic computation"): + (v1 & v2).ewise_add(v3) + with pytest.raises(TypeError, match="to automatically compute"): + v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="Automatic computation"): + (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="to automatically compute"): + v1.ewise_mult(v2 | v3) + with pytest.raises(TypeError, match="Automatic computation"): + (v1 | v2).ewise_mult(v3) + + +@autocompute +def test_multi_infix_matrix_auto(): + v1 = Matrix.from_coo([0, 1], [0, 0], [1, 2], nrows=3) # 1 2 . + v2 = Matrix.from_coo([1, 2], [0, 0], [1, 2], nrows=3) # . 1 2 + v3 = Matrix.from_coo([2, 0], [0, 0], [1, 2], nrows=3) # 2 . 1 + # We differentiate between infix and methods + with pytest.raises(TypeError, match="only valid for BOOL"): + v1.ewise_add(v2 & v3) + with pytest.raises(TypeError, match="only valid for BOOL"): + (v1 & v2).ewise_add(v3) + with pytest.raises(TypeError, match="only valid for BOOL"): + v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="only valid for BOOL"): + (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="only valid for BOOL"): + v1.ewise_mult(v2 | v3) + with pytest.raises(TypeError, match="only valid for BOOL"): + (v1 | v2).ewise_mult(v3) + + +def test_multi_infix_scalar(): + # Adapted from test_multi_infix_vector + v1 = Scalar.from_value(1) + v2 = Scalar.from_value(2) + v3 = Scalar(int) + # ewise_add + result = binary.plus((v1 | v2) | v3).new() + expected = 3 + assert result.isequal(expected) + result = binary.plus((1 | v2) | v3).new() + assert result.isequal(expected) + result = binary.plus((1 | v2) | 0).new() + assert result.isequal(expected) + result = binary.plus((v1 | 2) | v3).new() + assert result.isequal(expected) + result = binary.plus((v1 | 2) | 0).new() + assert result.isequal(expected) + result = binary.plus((v1 | v2) | 0).new() + assert result.isequal(expected) + + result = binary.plus(v1 | (v2 | v3)).new() + assert result.isequal(expected) + result = binary.plus(1 | (v2 | v3)).new() + assert result.isequal(expected) + result = binary.plus(1 | (2 | v3)).new() + assert result.isequal(expected) + result = binary.plus(1 | (v2 | 0)).new() + assert result.isequal(expected) + result = binary.plus(v1 | (2 | v3)).new() + assert result.isequal(expected) + result = binary.plus(v1 | (v2 | 0)).new() + assert result.isequal(expected) + + result = monoid.min(v1 | v2 | v3).new() + expected = 1 + assert result.isequal(expected) + # ewise_mult + result = monoid.max((v1 & v2) & v3).new() + expected = None + assert result.isequal(expected) + result = monoid.max(v1 & (v2 & v3)).new() + assert result.isequal(expected) + result = monoid.min((v1 & v2) & v1).new() + expected = 1 + assert result.isequal(expected) + + result = monoid.min((1 & v2) & v1).new() + assert result.isequal(expected) + result = monoid.min((1 & v2) & 1).new() + assert result.isequal(expected) + result = monoid.min((v1 & 2) & v1).new() + assert result.isequal(expected) + result = monoid.min((v1 & 2) & 1).new() + assert result.isequal(expected) + result = monoid.min((v1 & v2) & 1).new() + assert result.isequal(expected) + + result = monoid.min(1 & (v2 & v1)).new() + assert result.isequal(expected) + result = monoid.min(1 & (2 & v1)).new() + assert result.isequal(expected) + result = monoid.min(1 & (v2 & 1)).new() + assert result.isequal(expected) + result = monoid.min(v1 & (2 & v1)).new() + assert result.isequal(expected) + result = monoid.min(v1 & (v2 & 1)).new() + assert result.isequal(expected) + + # ewise_union + result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10).new() + expected = 13 + assert result.isequal(expected) + result = binary.plus((1 | v2) | v3, left_default=10, right_default=10).new() + assert result.isequal(expected) + result = binary.plus((v1 | 2) | v3, left_default=10, right_default=10).new() + assert result.isequal(expected) + result = binary.plus((v1 | v2) | v3, left_default=10, right_default=10.0).new() + assert result.isequal(expected) + result = binary.plus(v1 | (v2 | v3), left_default=10, right_default=10).new() + assert result.isequal(expected) + result = binary.plus(1 | (v2 | v3), left_default=10, right_default=10).new() + assert result.isequal(expected) + result = binary.plus(1 | (2 | v3), left_default=10, right_default=10).new() + assert result.isequal(expected) + result = binary.plus(v1 | (2 | v3), left_default=10, right_default=10).new() + assert result.isequal(expected) + + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) | v3 + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2).__ror__(v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) | (v2 & v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) | (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + v1 | (v2 & v3) + with pytest.raises(TypeError, match="XXX"): # TODO + v1.__ror__(v2 & v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) | (v2 & v3) + + with pytest.raises(TypeError, match="XXX"): # TODO + v1 & (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + v1.__rand__(v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) & (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 & v2) & (v2 | v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) & v3 + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2).__rand__(v3) + with pytest.raises(TypeError, match="XXX"): # TODO + (v1 | v2) & (v2 & v3) + + # We differentiate between infix and methods + with pytest.raises(TypeError, match="to automatically compute"): + v1.ewise_add(v2 & v3) + with pytest.raises(TypeError, match="Automatic computation"): + (v1 & v2).ewise_add(v3) + with pytest.raises(TypeError, match="to automatically compute"): + v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="Automatic computation"): + (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="to automatically compute"): + v1.ewise_mult(v2 | v3) + with pytest.raises(TypeError, match="Automatic computation"): + (v1 | v2).ewise_mult(v3) + + +@autocompute +def test_multi_infix_scalar_auto(): + v1 = Scalar.from_value(1) + v2 = Scalar.from_value(2) + v3 = Scalar(int) + # We differentiate between infix and methods + with pytest.raises(TypeError, match="only valid for BOOL"): + v1.ewise_add(v2 & v3) + with pytest.raises(TypeError, match="only valid for BOOL"): + (v1 & v2).ewise_add(v3) + with pytest.raises(TypeError, match="only valid for BOOL"): + v1.ewise_union(v2 & v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="only valid for BOOL"): + (v1 & v2).ewise_union(v3, binary.plus, left_default=1, right_default=1) + with pytest.raises(TypeError, match="only valid for BOOL"): + v1.ewise_mult(v2 | v3) + with pytest.raises(TypeError, match="only valid for BOOL"): + (v1 | v2).ewise_mult(v3) diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 3f66e46ef..c716c97a9 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -2805,6 +2805,8 @@ def test_ss_nbytes(A): @autocompute def test_auto(A, v): + from graphblas.core.infix import MatrixEwiseMultExpr + expected = binary.land[bool](A & A).new() B = A.dup(dtype=bool) for expr in [(B & B), binary.land[bool](A & A)]: @@ -2832,12 +2834,21 @@ def test_auto(A, v): ]: # print(type(expr).__name__, method) val1 = getattr(expected, method)(expected).new() - val2 = getattr(expected, method)(expr) - val3 = getattr(expr, method)(expected) - val4 = getattr(expr, method)(expr) - assert val1.isequal(val2) - assert val1.isequal(val3) - assert val1.isequal(val4) + if method in {"__or__", "__ror__"} and type(expr) is MatrixEwiseMultExpr: + # Doing e.g. `plus(A & B | C)` isn't allowed--make user be explicit + with pytest.raises(TypeError): + val2 = getattr(expected, method)(expr) + with pytest.raises(TypeError): + val3 = getattr(expr, method)(expected) + with pytest.raises(TypeError): + val4 = getattr(expr, method)(expr) + else: + val2 = getattr(expected, method)(expr) + assert val1.isequal(val2) + val3 = getattr(expr, method)(expected) + assert val1.isequal(val3) + val4 = getattr(expr, method)(expr) + assert val1.isequal(val4) for method in ["reduce_rowwise", "reduce_columnwise", "reduce_scalar"]: s1 = getattr(expected, method)(monoid.lor).new() s2 = getattr(expr, method)(monoid.lor) @@ -2946,7 +2957,7 @@ def test_expr_is_like_matrix(A): "setdiag", "update", } - ignore = {"__sizeof__"} + ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union", "_mxm", "_mxv"} assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Matrix. You may need to " "add an entry to `matrix` or `matrix_vector` set in `graphblas.core.automethods` " @@ -3011,7 +3022,7 @@ def test_index_expr_is_like_matrix(A): "resize", "setdiag", } - ignore = {"__sizeof__"} + ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union", "_mxm", "_mxv"} assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Matrix. You may need to " "add an entry to `matrix` or `matrix_vector` set in `graphblas.core.automethods` " diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index ba9903169..aeb19e170 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -360,7 +360,7 @@ def test_expr_is_like_scalar(s): } if s.is_cscalar: expected.add("_empty") - ignore = {"__sizeof__"} + ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union"} assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Scalar. You may need to " "add an entry to `scalar` set in `graphblas.core.automethods` " @@ -402,7 +402,7 @@ def test_index_expr_is_like_scalar(s): } if s.is_cscalar: expected.add("_empty") - ignore = {"__sizeof__"} + ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union"} assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Scalar. You may need to " "add an entry to `scalar` set in `graphblas.core.automethods` " diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index b66bc96c9..1c9a8d38c 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -1532,6 +1532,8 @@ def test_outer(v): @autocompute def test_auto(v): + from graphblas.core.infix import VectorEwiseMultExpr + v = v.dup(dtype=bool) expected = binary.land(v & v).new() assert 0 not in expected @@ -1581,15 +1583,24 @@ def test_auto(v): ]: # print(type(expr).__name__, method) val1 = getattr(expected, method)(expected).new() - val2 = getattr(expected, method)(expr) - val3 = getattr(expr, method)(expected) - val4 = getattr(expr, method)(expr) - assert val1.isequal(val2) - assert val1.isequal(val3) - assert val1.isequal(val4) - assert val1.isequal(val2.new()) - assert val1.isequal(val3.new()) - assert val1.isequal(val4.new()) + if method in {"__or__", "__ror__"} and type(expr) is VectorEwiseMultExpr: + # Doing e.g. `plus(x & y | z)` isn't allowed--make user be explicit + with pytest.raises(TypeError): + val2 = getattr(expected, method)(expr) + with pytest.raises(TypeError): + val3 = getattr(expr, method)(expected) + with pytest.raises(TypeError): + val4 = getattr(expr, method)(expr) + else: + val2 = getattr(expected, method)(expr) + assert val1.isequal(val2) + assert val1.isequal(val2.new()) + val3 = getattr(expr, method)(expected) + assert val1.isequal(val3) + assert val1.isequal(val3.new()) + val4 = getattr(expr, method)(expr) + assert val1.isequal(val4) + assert val1.isequal(val4.new()) s1 = expected.reduce(monoid.lor).new() s2 = expr.reduce(monoid.lor) assert s1.isequal(s2.new()) @@ -1653,7 +1664,7 @@ def test_expr_is_like_vector(v): "resize", "update", } - ignore = {"__sizeof__"} + ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union", "_inner", "_vxm"} assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Vector. You may need to " "add an entry to `vector` or `matrix_vector` set in `graphblas.core.automethods` " @@ -1702,7 +1713,7 @@ def test_index_expr_is_like_vector(v): "from_values", "resize", } - ignore = {"__sizeof__"} + ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union", "_inner", "_vxm"} assert attrs - expr_attrs - ignore == expected, ( "If you see this message, you probably added a method to Vector. You may need to " "add an entry to `vector` or `matrix_vector` set in `graphblas.core.automethods` " From 1ecf3334182e5aa15604b94cf966d7a9c603095c Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 5 Nov 2023 19:28:06 -0600 Subject: [PATCH 65/87] Add `gb.MAX_SIZE`, which is `GrB_INDEX_MAX + 1` (#519) --- graphblas/__init__.py | 5 +++++ graphblas/tests/test_core.py | 4 ++++ scripts/test_imports.sh | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/graphblas/__init__.py b/graphblas/__init__.py index a9895cb6a..63110eeeb 100644 --- a/graphblas/__init__.py +++ b/graphblas/__init__.py @@ -39,6 +39,7 @@ def get_config(): backend = None _init_params = None _SPECIAL_ATTRS = { + "MAX_SIZE", # The maximum size of Vector and Matrix dimensions (GrB_INDEX_MAX + 1) "Matrix", "Recorder", "Scalar", @@ -205,6 +206,10 @@ def _load(name): if name in {"Matrix", "Vector", "Scalar", "Recorder"}: module = _import_module(f".core.{name.lower()}", __name__) globals()[name] = getattr(module, name) + elif name == "MAX_SIZE": + from .core import lib + + globals()[name] = lib.GrB_INDEX_MAX + 1 else: # Everything else is a module globals()[name] = _import_module(f".{name}", __name__) diff --git a/graphblas/tests/test_core.py b/graphblas/tests/test_core.py index 003affc6c..3586eb4a8 100644 --- a/graphblas/tests/test_core.py +++ b/graphblas/tests/test_core.py @@ -90,3 +90,7 @@ def test_packages(): assert ( pkgs == pkgs2 ), "If there are extra items on the left, add them to pyproject.toml:tool.setuptools.packages" + + +def test_index_max(): + assert gb.MAX_SIZE == 2**60 # True for all current backends diff --git a/scripts/test_imports.sh b/scripts/test_imports.sh index cc989ef06..6ce88c83e 100755 --- a/scripts/test_imports.sh +++ b/scripts/test_imports.sh @@ -13,7 +13,7 @@ if ! python -c "from graphblas.select import tril" ; then exit 1 ; fi if ! python -c "from graphblas.semiring import plus_times" ; then exit 1 ; fi if ! python -c "from graphblas.unary import exp" ; then exit 1 ; fi if ! (for attr in Matrix Scalar Vector Recorder agg binary dtypes exceptions \ - init io monoid op select semiring tests unary ss viz + init io monoid op select semiring tests unary ss viz MAX_SIZE do echo python -c \"from graphblas import $attr\" if ! python -c "from graphblas import $attr" then exit 1 From 303c5a1abec06cb05b0d9dc1bb84295a37b6b2e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Dec 2023 15:02:48 -0600 Subject: [PATCH 66/87] Bump conda-incubator/setup-miniconda from 2 to 3 (#521) Bumps [conda-incubator/setup-miniconda](https://github.com/conda-incubator/setup-miniconda) from 2 to 3. - [Release notes](https://github.com/conda-incubator/setup-miniconda/releases) - [Changelog](https://github.com/conda-incubator/setup-miniconda/blob/main/CHANGELOG.md) - [Commits](https://github.com/conda-incubator/setup-miniconda/compare/v2...v3) --- updated-dependencies: - dependency-name: conda-incubator/setup-miniconda dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test_and_build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 56d13557f..492b9e62a 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -135,7 +135,7 @@ jobs: 1 1 - name: Setup mamba - uses: conda-incubator/setup-miniconda@v2 + uses: conda-incubator/setup-miniconda@v3 id: setup_mamba continue-on-error: true with: @@ -148,7 +148,7 @@ jobs: activate-environment: graphblas auto-activate-base: false - name: Setup conda - uses: conda-incubator/setup-miniconda@v2 + uses: conda-incubator/setup-miniconda@v3 id: setup_conda if: steps.setup_mamba.outcome == 'failure' continue-on-error: false From 34f5c40fd6bb6df12c9776e24154b0bf79de6d4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Dec 2023 15:02:58 -0600 Subject: [PATCH 67/87] Bump pypa/gh-action-pypi-publish from 1.8.10 to 1.8.11 (#522) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.10 to 1.8.11. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.10...v1.8.11) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 45a2b7880..28ecd13d8 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.10 + uses: pypa/gh-action-pypi-publish@v1.8.11 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 8a800327e71e08029192b6a964281a986e7c84a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Dec 2023 14:05:44 -0600 Subject: [PATCH 68/87] Bump actions/setup-python from 4 to 5 (#523) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/imports.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/publish_pypi.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/imports.yml b/.github/workflows/imports.yml index 18be6256a..0116f615d 100644 --- a/.github/workflows/imports.yml +++ b/.github/workflows/imports.yml @@ -49,7 +49,7 @@ jobs: # os: ["ubuntu-latest", "macos-latest", "windows-latest"] steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ needs.rngs.outputs.pyver }} # python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e0945022c..97bb856f6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: "3.10" - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 28ecd13d8..406f7c269 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -18,7 +18,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.9" - name: Install build dependencies From 0bfcb6665706d7169095ae6b2f8d3bd5a7165db4 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 12 Dec 2023 14:06:41 -0600 Subject: [PATCH 69/87] Support `A.power(0)` (#518) --- .pre-commit-config.yaml | 4 ++-- graphblas/core/matrix.py | 22 ++++++++++++++++++---- graphblas/tests/test_matrix.py | 18 +++++++++++++----- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3766e2e7c..97bf22889 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -66,7 +66,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.3 + rev: v0.1.4 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.3 + rev: v0.1.4 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 34789d68d..1ea24f479 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -101,6 +101,10 @@ def _reposition(updater, indices, chunk): def _power(updater, A, n, op): opts = updater.opts + if n == 0: + v = Vector.from_scalar(op.binaryop.monoid.identity, A._nrows, A.dtype, name="v_diag") + updater << v.diag(name="M_diag") + return if n == 1: updater << A return @@ -2895,7 +2899,11 @@ def power(self, n, op=semiring.plus_times): Parameters ---------- n : int - The exponent must be a positive integer. + The exponent must be a nonnegative integer. If n=0, the result will be a diagonal + matrix with values equal to the identity of the semiring's binary operator. + For example, ``plus_times`` will have diagonal values of 1, which is the + identity of ``times``. The binary operator must be associated with a monoid + when n=0 so the identity can be determined; otherwise, ValueError is raised. op : :class:`~graphblas.core.operator.Semiring` Semiring used in the computation @@ -2923,11 +2931,17 @@ def power(self, n, op=semiring.plus_times): if self._nrows != self._ncols: raise DimensionMismatch(f"power only works for square Matrix; shape is {self.shape}") if (N := maybe_integral(n)) is None: - raise TypeError(f"n must be a positive integer; got bad type: {type(n)}") - if N <= 0: - raise ValueError(f"n must be a positive integer; got: {N}") + raise TypeError(f"n must be a nonnegative integer; got bad type: {type(n)}") + if N < 0: + raise ValueError(f"n must be a nonnegative integer; got: {N}") op = get_typed_op(op, self.dtype, kind="semiring") self._expect_op(op, "Semiring", within=method_name, argname="op") + if N == 0 and op.binaryop.monoid is None: + raise ValueError( + f"Binary operator of {op} semiring does not have a monoid with an identity. " + "When n=0, the result is a diagonal matrix with values equal to the " + "identity of the binaryop, so the binaryop must be associated with a monoid." + ) return MatrixExpression( "power", None, diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index c716c97a9..233fc9a9b 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -4402,14 +4402,22 @@ def test_power(A): result = A.power(i, semiring.min_plus).new() assert result.isequal(expected) expected << semiring.min_plus(A @ expected) + # n == 0 + result = A.power(0).new() + expected = Vector.from_scalar(1, A.nrows, A.dtype).diag() + assert result.isequal(expected) + result = A.power(0, semiring.plus_min).new() + identity = semiring.plus_min[A.dtype].binaryop.monoid.identity + assert identity != 1 + expected = Vector.from_scalar(identity, A.nrows, A.dtype).diag() + assert result.isequal(expected) # Exceptional - with pytest.raises(TypeError, match="must be a positive integer"): + with pytest.raises(TypeError, match="must be a nonnegative integer"): A.power(1.5) - with pytest.raises(ValueError, match="must be a positive integer"): + with pytest.raises(ValueError, match="must be a nonnegative integer"): A.power(-1) - with pytest.raises(ValueError, match="must be a positive integer"): - # Not implemented yet... could create identity matrix - A.power(0) + with pytest.raises(ValueError, match="binaryop must be associated with a monoid"): + A.power(0, semiring.min_first) B = A[:2, :3].new() with pytest.raises(DimensionMismatch): B.power(2) From 6ec05d10d8fd3b6d1170091c6219227d43b2308e Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 13 Dec 2023 06:20:13 -0600 Subject: [PATCH 70/87] Fix the return type of e.g. `agg.count` to be INT64 by default (#524) This should fix https://github.com/python-graphblas/graphblas-algorithms/issues/82 --- .pre-commit-config.yaml | 12 ++++++------ graphblas/core/operator/utils.py | 3 +++ graphblas/tests/test_scalar.py | 2 +- graphblas/tests/test_vector.py | 15 +++++++++++++++ scripts/check_versions.sh | 2 +- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 97bf22889..b1d264509 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,7 +46,7 @@ repos: # 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 + rev: 5.13.1 hooks: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it @@ -61,12 +61,12 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.10.1 + rev: 23.12.0 hooks: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.4 + rev: v0.1.7 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -79,7 +79,7 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.1.0 - - flake8-bugbear==23.9.16 + - flake8-bugbear==23.12.2 - flake8-simplify==0.21.0 - repo: https://github.com/asottile/yesqa rev: v1.5.0 @@ -94,11 +94,11 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.4 + rev: v0.1.7 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.8.1 + rev: v0.9.1 hooks: - id: sphinx-lint args: [--enable, all, "--disable=line-too-long,leaked-markup"] diff --git a/graphblas/core/operator/utils.py b/graphblas/core/operator/utils.py index cd0b82d3c..543df793e 100644 --- a/graphblas/core/operator/utils.py +++ b/graphblas/core/operator/utils.py @@ -75,6 +75,9 @@ def get_typed_op(op, dtype, dtype2=None, *, is_left_scalar=False, is_right_scala from .agg import Aggregator, TypedAggregator if isinstance(op, Aggregator): + # agg._any_dtype basically serves the same purpose as op._custom_dtype + if op._any_dtype is not None and op._any_dtype is not True: + return op[op._any_dtype] return op[dtype] if isinstance(op, TypedAggregator): return op diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index aeb19e170..3c7bffa9a 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -250,7 +250,7 @@ def test_update(s): def test_not_hashable(s): with pytest.raises(TypeError, match="unhashable type"): - {s} + _ = {s} with pytest.raises(TypeError, match="unhashable type"): hash(s) diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index 1c9a8d38c..8a2cd0824 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -948,6 +948,21 @@ def test_reduce_agg(v): assert s.is_empty +def test_reduce_agg_count_is_int64(v): + """Aggregators that count should default to INT64 return dtype.""" + assert v.dtype == dtypes.INT64 + res = v.reduce(agg.count).new() + assert res.dtype == dtypes.INT64 + assert res == 4 + res = v.dup(dtypes.INT8).reduce(agg.count).new() + assert res.dtype == dtypes.INT64 + assert res == 4 + # Allow return dtype to be specified + res = v.dup(dtypes.INT8).reduce(agg.count[dtypes.INT16]).new() + assert res.dtype == dtypes.INT16 + assert res == 4 + + @pytest.mark.skipif("not suitesparse") def test_reduce_agg_argminmax(v): assert v.reduce(agg.ss.argmin).new() == 6 diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index d197f2af2..db786b190 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -3,7 +3,7 @@ # Use, adjust, copy/paste, etc. as necessary to answer your questions. # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. -conda search 'flake8-bugbear[channel=conda-forge]>=23.9.16' +conda search 'flake8-bugbear[channel=conda-forge]>=23.12.2' conda search 'flake8-simplify[channel=conda-forge]>=0.21.0' conda search 'numpy[channel=conda-forge]>=1.26.0' conda search 'pandas[channel=conda-forge]>=2.1.2' From 5fcdbf667807c7fd6f5a300ee539388b3c87a893 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 13 Dec 2023 08:14:48 -0600 Subject: [PATCH 71/87] Update dependency versions (#525) --- .github/workflows/test_and_build.yml | 12 ++++++------ scripts/check_versions.sh | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 492b9e62a..4b9035cc3 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -175,22 +175,22 @@ jobs: npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", "=1.26", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", "=2.1", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') else # Python 3.12 npver=$(python -c 'import random ; print(random.choice(["=1.26", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=2.1", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=2.4", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=2.4", "=2.5", ""]))') fi # But there may be edge cases of incompatibility we need to handle (more handled below) if [[ ${pdver} == "=2.1" && ${npver} == "=1.21" ]]; then @@ -236,11 +236,11 @@ jobs: spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') fi elif [[ ${npver} == "=1.24" || ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]] ; then - numbaver=$(python -c 'import random ; print(random.choice(["=0.57", ""]))') + numbaver=$(python -c 'import random ; print(random.choice(["=0.57", "=0.58", ""]))') elif [[ ${npver} == "=1.21" ]] ; then numbaver=$(python -c 'import random ; print(random.choice(["=0.55", "=0.56", "=0.57", ""]))') else - numbaver=$(python -c 'import random ; print(random.choice(["=0.56", "=0.57", ""]))') + numbaver=$(python -c 'import random ; print(random.choice(["=0.56", "=0.57", "=0.58", ""]))') fi fmm=fast_matrix_market${fmmver} awkward=awkward${akver} diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index db786b190..958bf2210 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -5,13 +5,13 @@ # Tip: add `--json` for more information. conda search 'flake8-bugbear[channel=conda-forge]>=23.12.2' conda search 'flake8-simplify[channel=conda-forge]>=0.21.0' -conda search 'numpy[channel=conda-forge]>=1.26.0' -conda search 'pandas[channel=conda-forge]>=2.1.2' -conda search 'scipy[channel=conda-forge]>=1.11.3' +conda search 'numpy[channel=conda-forge]>=1.26.2' +conda search 'pandas[channel=conda-forge]>=2.1.4' +conda search 'scipy[channel=conda-forge]>=1.11.4' conda search 'networkx[channel=conda-forge]>=3.2.1' -conda search 'awkward[channel=conda-forge]>=2.4.6' +conda search 'awkward[channel=conda-forge]>=2.5.0' conda search 'sparse[channel=conda-forge]>=0.14.0' -conda search 'fast_matrix_market[channel=conda-forge]>=1.7.4' -conda search 'numba[channel=conda-forge]>=0.57.1' +conda search 'fast_matrix_market[channel=conda-forge]>=1.7.5' +conda search 'numba[channel=conda-forge]>=0.58.1' conda search 'pyyaml[channel=conda-forge]>=6.0.1' # conda search 'python[channel=conda-forge]>=3.9 *pypy*' From e673d603ebf637286d7b8bb5e9028d0c2adbb13a Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 13 Dec 2023 12:00:46 -0600 Subject: [PATCH 72/87] Fix link to test status badge (#526) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 42ed0d41e..de942f88e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-graphblas)](https://pypi.python.org/pypi/python-graphblas/) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/python-graphblas/python-graphblas/blob/main/LICENSE)
-[![Tests](https://github.com/python-graphblas/python-graphblas/workflows/Tests/badge.svg?branch=main)](https://github.com/python-graphblas/python-graphblas/actions) +[![Tests](https://github.com/python-graphblas/python-graphblas/actions/workflows/test_and_build.yml/badge.svg?branch=main)](https://github.com/python-graphblas/python-graphblas/actions) [![Docs](https://readthedocs.org/projects/python-graphblas/badge/?version=latest)](https://python-graphblas.readthedocs.io/en/latest/) [![Coverage](https://codecov.io/gh/python-graphblas/python-graphblas/graph/badge.svg?token=D7HHLDPQ2Q)](https://codecov.io/gh/python-graphblas/python-graphblas) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.7328791.svg)](https://doi.org/10.5281/zenodo.7328791) From ee5f4e757996a4b881a67c94d7c4ba752dffb298 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 17 Dec 2023 06:56:59 -0600 Subject: [PATCH 73/87] NetworkX 3.2 doesn't support SciPy 1.8 (#530) --- .github/workflows/test_and_build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 4b9035cc3..7c7a7691b 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -196,6 +196,9 @@ jobs: if [[ ${pdver} == "=2.1" && ${npver} == "=1.21" ]]; then pdver="=2.0" fi + if [[ ${nxver} == "=3.2" && ${spver} == "=1.8" ]]; then + spver="=1.9" + fi if [[ ${{ steps.sourcetype.outputs.selected }} == "source" || ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then # TODO: there are currently issues with some numpy versions when # installing python-suitesparse-grphblas from source or upstream. From 0149cdefb03b9e63d3f502abff2dd1cb5b93ddcf Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 17 Dec 2023 11:31:18 -0600 Subject: [PATCH 74/87] Remove deprecated `gb.io.from_numpy` (and `to_numpy`) (#528) * Remove deprecated `gb.io.from_numpy` (and `to_numpy`) These have been deprecated since 2023-02-27, so could be removed after 2023-10-27 per our policy. --- .github/workflows/test_and_build.yml | 12 +--- docs/api_reference/io.rst | 13 ++-- docs/user_guide/collections.rst | 2 +- docs/user_guide/io.rst | 2 + graphblas/io/__init__.py | 1 - graphblas/io/_numpy.py | 104 --------------------------- graphblas/tests/test_io.py | 11 --- 7 files changed, 14 insertions(+), 131 deletions(-) delete mode 100644 graphblas/io/_numpy.py diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 7c7a7691b..190c1840e 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -173,12 +173,12 @@ jobs: # Randomly choosing versions of dependencies based on Python version works surprisingly well... if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.8", "=1.9", "=1.10", "=1.11", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]]; then @@ -196,9 +196,6 @@ jobs: if [[ ${pdver} == "=2.1" && ${npver} == "=1.21" ]]; then pdver="=2.0" fi - if [[ ${nxver} == "=3.2" && ${spver} == "=1.8" ]]; then - spver="=1.9" - fi if [[ ${{ steps.sourcetype.outputs.selected }} == "source" || ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then # TODO: there are currently issues with some numpy versions when # installing python-suitesparse-grphblas from source or upstream. @@ -230,14 +227,11 @@ jobs: fi if [[ ${npver} == "=1.26" ]] ; then numbaver="" - if [[ ${spver} == "=1.8" || ${spver} == "=1.9" ]] ; then + if [[ ${spver} == "=1.9" ]] ; then spver=$(python -c 'import random ; print(random.choice(["=1.10", "=1.11", ""]))') fi elif [[ ${npver} == "=1.25" ]] ; then numbaver="" - if [[ ${spver} == "=1.8" ]] ; then - spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') - fi elif [[ ${npver} == "=1.24" || ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]] ; then numbaver=$(python -c 'import random ; print(random.choice(["=0.57", "=0.58", ""]))') elif [[ ${npver} == "=1.21" ]] ; then diff --git a/docs/api_reference/io.rst b/docs/api_reference/io.rst index cd6057a31..1cfc98516 100644 --- a/docs/api_reference/io.rst +++ b/docs/api_reference/io.rst @@ -10,15 +10,18 @@ These methods require `networkx `_ to be installed. .. autofunction:: graphblas.io.to_networkx -Numpy +NumPy ~~~~~ -These methods require `scipy `_ to be installed, as some -of the scipy.sparse machinery is used during the conversion process. +These methods convert to and from dense arrays. For more, see :ref:`IO in the user guide `. -.. autofunction:: graphblas.io.from_numpy +.. automethod:: graphblas.core.matrix.Matrix.from_dense -.. autofunction:: graphblas.io.to_numpy +.. automethod:: graphblas.core.matrix.Matrix.to_dense + +.. automethod:: graphblas.core.vector.Vector.from_dense + +.. automethod:: graphblas.core.vector.Vector.to_dense Scipy Sparse ~~~~~~~~~~~~ diff --git a/docs/user_guide/collections.rst b/docs/user_guide/collections.rst index 2ce759bf4..de7469c6d 100644 --- a/docs/user_guide/collections.rst +++ b/docs/user_guide/collections.rst @@ -145,7 +145,7 @@ The shape and dtype remain unchanged, but the collection will be fully sparse (i to_coo ~~~~~~ -To go from a collection back to the index and values, ``.to_coo()`` can be called. Numpy arrays +To go from a collection back to the index and values, ``.to_coo()`` can be called. NumPy arrays will be returned in a tuple. .. code-block:: python diff --git a/docs/user_guide/io.rst b/docs/user_guide/io.rst index ecb4c0862..f27b40bd3 100644 --- a/docs/user_guide/io.rst +++ b/docs/user_guide/io.rst @@ -4,6 +4,8 @@ Input/Output There are several ways to get data into and out of python-graphblas. +.. _from-to-values: + From/To Values -------------- diff --git a/graphblas/io/__init__.py b/graphblas/io/__init__.py index b21b20963..a1b71db40 100644 --- a/graphblas/io/__init__.py +++ b/graphblas/io/__init__.py @@ -1,6 +1,5 @@ from ._awkward import from_awkward, to_awkward from ._matrixmarket import mmread, mmwrite from ._networkx import from_networkx, to_networkx -from ._numpy import from_numpy, to_numpy # deprecated from ._scipy import from_scipy_sparse, to_scipy_sparse from ._sparse import from_pydata_sparse, to_pydata_sparse diff --git a/graphblas/io/_numpy.py b/graphblas/io/_numpy.py deleted file mode 100644 index 954d28df7..000000000 --- a/graphblas/io/_numpy.py +++ /dev/null @@ -1,104 +0,0 @@ -from warnings import warn - -from ..core.utils import output_type -from ..core.vector import Vector -from ..dtypes import lookup_dtype -from ..exceptions import GraphblasException -from ._scipy import from_scipy_sparse, to_scipy_sparse - - -def from_numpy(m): # pragma: no cover (deprecated) - """Create a sparse Vector or Matrix from a dense numpy array. - - .. deprecated:: 2023.2.0 - ``from_numpy`` will be removed in a future release. - Use ``Vector.from_dense`` or ``Matrix.from_dense`` instead. - Will be removed in version 2023.10.0 or later - - A value of 0 is considered as "missing". - - - m.ndim == 1 returns a ``Vector`` - - m.ndim == 2 returns a ``Matrix`` - - m.ndim > 2 raises an error - - dtype is inferred from m.dtype - - Parameters - ---------- - m : np.ndarray - Input array - - See Also - -------- - Matrix.from_dense - Vector.from_dense - from_scipy_sparse - - Returns - ------- - Vector or Matrix - """ - warn( - "`graphblas.io.from_numpy` is deprecated; " - "use `Matrix.from_dense` and `Vector.from_dense` instead.", - DeprecationWarning, - stacklevel=2, - ) - if m.ndim > 2: - raise GraphblasException("m.ndim must be <= 2") - - try: - from scipy.sparse import coo_array, csr_array - except ImportError: # pragma: no cover (import) - raise ImportError("scipy is required to import from numpy") from None - - if m.ndim == 1: - A = csr_array(m) - _, size = A.shape - dtype = lookup_dtype(m.dtype) - return Vector.from_coo(A.indices, A.data, size=size, dtype=dtype) - A = coo_array(m) - return from_scipy_sparse(A) - - -def to_numpy(m): # pragma: no cover (deprecated) - """Create a dense numpy array from a sparse Vector or Matrix. - - .. deprecated:: 2023.2.0 - ``to_numpy`` will be removed in a future release. - Use ``Vector.to_dense`` or ``Matrix.to_dense`` instead. - Will be removed in version 2023.10.0 or later - - Missing values will become 0 in the output. - - numpy dtype will match the GraphBLAS dtype - - Parameters - ---------- - m : Vector or Matrix - GraphBLAS Vector or Matrix - - See Also - -------- - to_scipy_sparse - Matrix.to_dense - Vector.to_dense - - Returns - ------- - np.ndarray - """ - warn( - "`graphblas.io.to_numpy` is deprecated; " - "use `Matrix.to_dense` and `Vector.to_dense` instead.", - DeprecationWarning, - stacklevel=2, - ) - try: - import scipy # noqa: F401 - except ImportError: # pragma: no cover (import) - raise ImportError("scipy is required to export to numpy") from None - if output_type(m) is Vector: - return to_scipy_sparse(m).toarray()[0] - sparse = to_scipy_sparse(m, "coo") - return sparse.toarray() diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 6ad92a950..109c90a2c 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -38,17 +38,6 @@ suitesparse = gb.backend == "suitesparse" -@pytest.mark.skipif("not ss") -def test_deprecated(): - a = np.array([0.0, 2.0, 4.1]) - with pytest.warns(DeprecationWarning): - v = gb.io.from_numpy(a) - assert v.isequal(gb.Vector.from_coo([1, 2], [2.0, 4.1]), check_dtype=True) - with pytest.warns(DeprecationWarning): - a2 = gb.io.to_numpy(v) - np.testing.assert_array_equal(a, a2) - - @pytest.mark.skipif("not ss") def test_vector_to_from_numpy(): a = np.array([0.0, 2.0, 4.1]) From 919f41c91769a5e44a6eee36a08426036da8d7ac Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 17 Dec 2023 11:31:38 -0600 Subject: [PATCH 75/87] Remove deprecated `gb.core.agg` (#527) * Remove deprecated `gb.core.agg` This was deprecated on 2023-03-31, so we can remove it after 2023-11-30 per our deprecation policy. --- .flake8 | 1 - .github/workflows/test_and_build.yml | 2 +- .pre-commit-config.yaml | 6 +++--- graphblas/agg/__init__.py | 2 +- graphblas/core/agg.py | 17 ----------------- graphblas/tests/test_op.py | 2 -- pyproject.toml | 1 - scripts/check_versions.sh | 2 +- 8 files changed, 6 insertions(+), 27 deletions(-) delete mode 100644 graphblas/core/agg.py diff --git a/.flake8 b/.flake8 index 80124c9e8..0dede3f1d 100644 --- a/.flake8 +++ b/.flake8 @@ -12,6 +12,5 @@ extend-ignore = per-file-ignores = scripts/create_pickle.py:F403,F405, graphblas/tests/*.py:T201, - graphblas/core/agg.py:F401,F403, graphblas/core/ss/matrix.py:SIM113, graphblas/**/__init__.py:F401, diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 190c1840e..29c6d4a5a 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -389,7 +389,7 @@ jobs: echo "from graphblas.agg import count" > 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 + echo "from graphblas.core.operator import agg" >> 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b1d264509..bc97547cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,7 +46,7 @@ repos: # 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.13.1 + rev: 5.13.2 hooks: - id: isort # Let's keep `pyupgrade` even though `ruff --fix` probably does most of it @@ -66,7 +66,7 @@ repos: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.7 + rev: v0.1.8 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.7 + rev: v0.1.8 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/graphblas/agg/__init__.py b/graphblas/agg/__init__.py index 9f6ead0b5..725c11aab 100644 --- a/graphblas/agg/__init__.py +++ b/graphblas/agg/__init__.py @@ -73,7 +73,7 @@ # - bxnor monoid: even bits # - bnor monoid: odd bits """ -# All items are dynamically added by classes in core/agg.py +# All items are dynamically added by classes in core/operator/agg.py # This module acts as a container of Aggregator instances _deprecated = {} diff --git a/graphblas/core/agg.py b/graphblas/core/agg.py deleted file mode 100644 index 23848d3b9..000000000 --- a/graphblas/core/agg.py +++ /dev/null @@ -1,17 +0,0 @@ -"""graphblas.core.agg namespace is deprecated; please use graphblas.core.operator.agg instead. - -.. deprecated:: 2023.3.0 -``graphblas.core.agg`` will be removed in a future release. -Use ``graphblas.core.operator.agg`` instead. -Will be removed in version 2023.11.0 or later. - -""" -import warnings - -from .operator.agg import * # pylint: disable=wildcard-import,unused-wildcard-import - -warnings.warn( - "graphblas.core.agg namespace is deprecated; please use graphblas.core.operator.agg instead.", - DeprecationWarning, - stacklevel=1, -) diff --git a/graphblas/tests/test_op.py b/graphblas/tests/test_op.py index c7d1ce97c..41fae80ae 100644 --- a/graphblas/tests/test_op.py +++ b/graphblas/tests/test_op.py @@ -1450,8 +1450,6 @@ def test_deprecated(): gb.op.secondj with pytest.warns(DeprecationWarning, match="please use"): gb.agg.argmin - with pytest.warns(DeprecationWarning, match="please use"): - import graphblas.core.agg # noqa: F401 @pytest.mark.slow diff --git a/pyproject.toml b/pyproject.toml index 04ef28645..3bd4a4310 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -375,7 +375,6 @@ ignore = [ ] [tool.ruff.per-file-ignores] -"graphblas/core/agg.py" = ["F401", "F403"] # Deprecated "graphblas/core/operator/base.py" = ["S102"] # exec is used for UDF "graphblas/core/ss/matrix.py" = ["NPY002"] # numba doesn't support rng generator yet "graphblas/core/ss/vector.py" = ["NPY002"] # numba doesn't support rng generator yet diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 958bf2210..75d6283f0 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -9,7 +9,7 @@ conda search 'numpy[channel=conda-forge]>=1.26.2' conda search 'pandas[channel=conda-forge]>=2.1.4' conda search 'scipy[channel=conda-forge]>=1.11.4' conda search 'networkx[channel=conda-forge]>=3.2.1' -conda search 'awkward[channel=conda-forge]>=2.5.0' +conda search 'awkward[channel=conda-forge]>=2.5.1' conda search 'sparse[channel=conda-forge]>=0.14.0' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.5' conda search 'numba[channel=conda-forge]>=0.58.1' From a9598c56d109d973d2615d9695e1920b21266a98 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 17 Dec 2023 12:00:50 -0600 Subject: [PATCH 76/87] Remove deprecated `to_values` and `from_values`. (#529) These have been deprecated since 2022-11-16, so it's been over a year. We wanted to give these a longer deprecation cycle to allow people to swith to `to_coo` and `from_coo`, but I think it's time to clean up. --- graphblas/core/automethods.py | 5 -- graphblas/core/infix.py | 2 - graphblas/core/matrix.py | 101 --------------------------------- graphblas/core/vector.py | 70 ----------------------- graphblas/tests/test_matrix.py | 11 ---- graphblas/tests/test_vector.py | 9 --- 6 files changed, 198 deletions(-) diff --git a/graphblas/core/automethods.py b/graphblas/core/automethods.py index 0a2aa208a..31b349280 100644 --- a/graphblas/core/automethods.py +++ b/graphblas/core/automethods.py @@ -281,10 +281,6 @@ def to_edgelist(self): return self._get_value("to_edgelist") -def to_values(self): - return self._get_value("to_values") - - def value(self): return self._get_value("value") @@ -398,7 +394,6 @@ def _main(): "ss", "to_coo", "to_dense", - "to_values", } vector = { "_as_matrix", diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index 51714633c..2c1014fe5 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -236,7 +236,6 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): to_coo = wrapdoc(Vector.to_coo)(property(automethods.to_coo)) to_dense = wrapdoc(Vector.to_dense)(property(automethods.to_dense)) to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict)) - to_values = wrapdoc(Vector.to_values)(property(automethods.to_values)) vxm = wrapdoc(Vector.vxm)(property(automethods.vxm)) wait = wrapdoc(Vector.wait)(property(automethods.wait)) # These raise exceptions @@ -396,7 +395,6 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): to_dense = wrapdoc(Matrix.to_dense)(property(automethods.to_dense)) to_dicts = wrapdoc(Matrix.to_dicts)(property(automethods.to_dicts)) to_edgelist = wrapdoc(Matrix.to_edgelist)(property(automethods.to_edgelist)) - to_values = wrapdoc(Matrix.to_values)(property(automethods.to_values)) wait = wrapdoc(Matrix.wait)(property(automethods.wait)) # These raise exceptions __array__ = Matrix.__array__ diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 1ea24f479..16483c2a1 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -1,5 +1,4 @@ import itertools -import warnings from collections.abc import Sequence import numpy as np @@ -515,42 +514,6 @@ def resize(self, nrows, ncols): self._nrows = nrows.value self._ncols = ncols.value - def to_values(self, dtype=None, *, rows=True, columns=True, values=True, sort=True): - """Extract the indices and values as a 3-tuple of numpy arrays - corresponding to the COO format of the Matrix. - - .. deprecated:: 2022.11.0 - ``Matrix.to_values`` will be removed in a future release. - Use ``Matrix.to_coo`` instead. Will be removed in version 2023.9.0 or later - - Parameters - ---------- - dtype : - Requested dtype for the output values array. - rows : bool, default=True - Whether to return rows; will return ``None`` for rows if ``False`` - columns : bool, default=True - Whether to return columns; will return ``None`` for columns if ``False`` - values : bool, default=True - Whether to return values; will return ``None`` for values if ``False`` - sort : bool, default=True - Whether to require sorted indices. - If internally stored rowwise, the sorting will be first by rows, then by column. - If internally stored columnwise, the sorting will be first by column, then by row. - - Returns - ------- - np.ndarray[dtype=uint64] : Rows - np.ndarray[dtype=uint64] : Columns - np.ndarray : Values - """ - warnings.warn( - "`Matrix.to_values(...)` is deprecated; please use `Matrix.to_coo(...)` instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.to_coo(dtype, rows=rows, columns=columns, values=values, sort=sort) - def to_coo(self, dtype=None, *, rows=True, columns=True, values=True, sort=True): """Extract the indices and values as a 3-tuple of numpy arrays corresponding to the COO format of the Matrix. @@ -837,61 +800,6 @@ def get(self, row, col, default=None): "Indices should get a single element, which will be extracted as a Python scalar." ) - @classmethod - def from_values( - cls, - rows, - columns, - values, - dtype=None, - *, - nrows=None, - ncols=None, - dup_op=None, - name=None, - ): - """Create a new Matrix from row and column indices and values. - - .. deprecated:: 2022.11.0 - ``Matrix.from_values`` will be removed in a future release. - Use ``Matrix.from_coo`` instead. Will be removed in version 2023.9.0 or later - - Parameters - ---------- - rows : list or np.ndarray - Row indices. - columns : list or np.ndarray - Column indices. - values : list or np.ndarray or scalar - List of values. If a scalar is provided, all values will be set to this single value. - dtype : - Data type of the Matrix. If not provided, the values will be inspected - to choose an appropriate dtype. - nrows : int, optional - Number of rows in the Matrix. If not provided, ``nrows`` is computed - from the maximum row index found in ``rows``. - ncols : int, optional - Number of columns in the Matrix. If not provided, ``ncols`` is computed - from the maximum column index found in ``columns``. - dup_op : :class:`~graphblas.core.operator.BinaryOp`, optional - Function used to combine values if duplicate indices are found. - Leaving ``dup_op=None`` will raise an error if duplicates are found. - name : str, optional - Name to give the Matrix. - - Returns - ------- - Matrix - """ - warnings.warn( - "`Matrix.from_values(...)` is deprecated; please use `Matrix.from_coo(...)` instead.", - DeprecationWarning, - stacklevel=2, - ) - return cls.from_coo( - rows, columns, values, dtype, nrows=nrows, ncols=ncols, dup_op=dup_op, name=name - ) - @classmethod def from_coo( cls, @@ -3751,7 +3659,6 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): to_dense = wrapdoc(Matrix.to_dense)(property(automethods.to_dense)) to_dicts = wrapdoc(Matrix.to_dicts)(property(automethods.to_dicts)) to_edgelist = wrapdoc(Matrix.to_edgelist)(property(automethods.to_edgelist)) - to_values = wrapdoc(Matrix.to_values)(property(automethods.to_values)) wait = wrapdoc(Matrix.wait)(property(automethods.wait)) # These raise exceptions __array__ = Matrix.__array__ @@ -3852,7 +3759,6 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): to_dense = wrapdoc(Matrix.to_dense)(property(automethods.to_dense)) to_dicts = wrapdoc(Matrix.to_dicts)(property(automethods.to_dicts)) to_edgelist = wrapdoc(Matrix.to_edgelist)(property(automethods.to_edgelist)) - to_values = wrapdoc(Matrix.to_values)(property(automethods.to_values)) wait = wrapdoc(Matrix.wait)(property(automethods.wait)) # These raise exceptions __array__ = Matrix.__array__ @@ -3927,13 +3833,6 @@ def to_coo(self, dtype=None, *, rows=True, columns=True, values=True, sort=True) ) return cols, rows, vals - @wrapdoc(Matrix.to_values) - def to_values(self, dtype=None, *, rows=True, columns=True, values=True, sort=True): - rows, cols, vals = self._matrix.to_values( - dtype, rows=rows, columns=columns, values=values, sort=sort - ) - return cols, rows, vals - @wrapdoc(Matrix.diag) def diag(self, k=0, dtype=None, *, name=None, **opts): return self._matrix.diag(-k, dtype, name=name, **opts) diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index feb95ed02..a631cc4af 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -1,5 +1,4 @@ import itertools -import warnings import numpy as np @@ -456,36 +455,6 @@ def resize(self, size): call("GrB_Vector_resize", [self, size]) self._size = size.value - def to_values(self, dtype=None, *, indices=True, values=True, sort=True): - """Extract the indices and values as a 2-tuple of numpy arrays. - - .. deprecated:: 2022.11.0 - ``Vector.to_values`` will be removed in a future release. - Use ``Vector.to_coo`` instead. Will be removed in version 2023.9.0 or later - - Parameters - ---------- - dtype : - Requested dtype for the output values array. - indices :bool, default=True - Whether to return indices; will return ``None`` for indices if ``False`` - values : bool, default=True - Whether to return values; will return ``None`` for values if ``False`` - sort : bool, default=True - Whether to require sorted indices. - - Returns - ------- - np.ndarray[dtype=uint64] : Indices - np.ndarray : Values - """ - warnings.warn( - "`Vector.to_values(...)` is deprecated; please use `Vector.to_coo(...)` instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.to_coo(dtype, indices=indices, values=values, sort=sort) - def to_coo(self, dtype=None, *, indices=True, values=True, sort=True): """Extract the indices and values as a 2-tuple of numpy arrays. @@ -697,43 +666,6 @@ def get(self, index, default=None): "A single index should be given, and the result will be a Python scalar." ) - @classmethod - def from_values(cls, indices, values, dtype=None, *, size=None, dup_op=None, name=None): - """Create a new Vector from indices and values. - - .. deprecated:: 2022.11.0 - ``Vector.from_values`` will be removed in a future release. - Use ``Vector.from_coo`` instead. Will be removed in version 2023.9.0 or later - - Parameters - ---------- - indices : list or np.ndarray - Vector indices. - values : list or np.ndarray or scalar - List of values. If a scalar is provided, all values will be set to this single value. - dtype : - Data type of the Vector. If not provided, the values will be inspected - to choose an appropriate dtype. - size : int, optional - Size of the Vector. If not provided, ``size`` is computed from - the maximum index found in ``indices``. - dup_op : BinaryOp, optional - Function used to combine values if duplicate indices are found. - Leaving ``dup_op=None`` will raise an error if duplicates are found. - name : str, optional - Name to give the Vector. - - Returns - ------- - Vector - """ - warnings.warn( - "`Vector.from_values(...)` is deprecated; please use `Vector.from_coo(...)` instead.", - DeprecationWarning, - stacklevel=2, - ) - return cls.from_coo(indices, values, dtype, size=size, dup_op=dup_op, name=name) - @classmethod def from_coo(cls, indices, values=1.0, dtype=None, *, size=None, dup_op=None, name=None): """Create a new Vector from indices and values. @@ -2271,7 +2203,6 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): to_coo = wrapdoc(Vector.to_coo)(property(automethods.to_coo)) to_dense = wrapdoc(Vector.to_dense)(property(automethods.to_dense)) to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict)) - to_values = wrapdoc(Vector.to_values)(property(automethods.to_values)) vxm = wrapdoc(Vector.vxm)(property(automethods.vxm)) wait = wrapdoc(Vector.wait)(property(automethods.wait)) # These raise exceptions @@ -2359,7 +2290,6 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): to_coo = wrapdoc(Vector.to_coo)(property(automethods.to_coo)) to_dense = wrapdoc(Vector.to_dense)(property(automethods.to_dense)) to_dict = wrapdoc(Vector.to_dict)(property(automethods.to_dict)) - to_values = wrapdoc(Vector.to_values)(property(automethods.to_values)) vxm = wrapdoc(Vector.vxm)(property(automethods.vxm)) wait = wrapdoc(Vector.wait)(property(automethods.wait)) # These raise exceptions diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 233fc9a9b..06e4ee868 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -2952,7 +2952,6 @@ def test_expr_is_like_matrix(A): "from_dicts", "from_edgelist", "from_scalar", - "from_values", "resize", "setdiag", "update", @@ -3018,7 +3017,6 @@ def test_index_expr_is_like_matrix(A): "from_dicts", "from_edgelist", "from_scalar", - "from_values", "resize", "setdiag", } @@ -3557,15 +3555,6 @@ def compare(A, expected, isequal=True, **kwargs): A.ss.compactify("bad_how") -def test_deprecated(A): - with pytest.warns(DeprecationWarning): - A.to_values() - with pytest.warns(DeprecationWarning): - A.T.to_values() - with pytest.warns(DeprecationWarning): - A.from_values([1], [2], [3]) - - def test_ndim(A): assert A.ndim == 2 assert A.ewise_mult(A).ndim == 2 diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index 8a2cd0824..77f608969 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -1675,7 +1675,6 @@ def test_expr_is_like_vector(v): "from_dict", "from_pairs", "from_scalar", - "from_values", "resize", "update", } @@ -1725,7 +1724,6 @@ def test_index_expr_is_like_vector(v): "from_dict", "from_pairs", "from_scalar", - "from_values", "resize", } ignore = {"__sizeof__", "_ewise_add", "_ewise_mult", "_ewise_union", "_inner", "_vxm"} @@ -2012,13 +2010,6 @@ def test_ss_split(v): assert x2.name == "split_1" -def test_deprecated(v): - with pytest.warns(DeprecationWarning): - v.to_values() - with pytest.warns(DeprecationWarning): - Vector.from_values([1], [2]) - - def test_ndim(A, v): assert v.ndim == 1 assert v.ewise_mult(v).ndim == 1 From a4e3bf6cb09a146651b306df90704f29567d2384 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 05:34:29 -0600 Subject: [PATCH 77/87] chore: update pre-commit hooks (#533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.12.0 → 23.12.1](https://github.com/psf/black/compare/23.12.0...23.12.1) - [github.com/astral-sh/ruff-pre-commit: v0.1.8 → v0.1.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.8...v0.1.9) - [github.com/astral-sh/ruff-pre-commit: v0.1.8 → v0.1.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.8...v0.1.9) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc97547cc..67600553b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,12 +61,12 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.12.0 + rev: 23.12.1 hooks: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.8 + rev: v0.1.9 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.8 + rev: v0.1.9 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint From 522b696e157a0fb00b63a6a846913e075106a1d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 06:07:12 -0600 Subject: [PATCH 78/87] Bump actions/upload-artifact from 3 to 4 (#531) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 406f7c269..366d01e97 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -27,7 +27,7 @@ jobs: python -m pip install build twine - name: Build wheel and sdist run: python -m build --sdist --wheel - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: releases path: dist From 8f36d463468e880fd6c1fc67576161cfa69f058d Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 5 Feb 2024 22:00:04 -0600 Subject: [PATCH 79/87] Update to numba 0.59 and other version updates (#536) --- .github/workflows/test_and_build.yml | 55 +++++++------ .pre-commit-config.yaml | 14 ++-- graphblas/agg/__init__.py | 1 + graphblas/binary/numpy.py | 1 + graphblas/core/automethods.py | 1 + graphblas/core/dtypes.py | 3 +- graphblas/core/expr.py | 8 +- graphblas/core/matrix.py | 45 +++++++++- graphblas/core/operator/base.py | 3 +- graphblas/core/operator/binary.py | 2 + graphblas/core/operator/indexunary.py | 2 + graphblas/core/operator/monoid.py | 2 + graphblas/core/operator/select.py | 2 + graphblas/core/operator/semiring.py | 2 + graphblas/core/operator/unary.py | 2 + graphblas/core/operator/utils.py | 1 + graphblas/core/scalar.py | 11 +++ graphblas/core/ss/binary.py | 1 + graphblas/core/ss/descriptor.py | 1 + graphblas/core/ss/indexunary.py | 1 + graphblas/core/ss/matrix.py | 113 ++++++++++++-------------- graphblas/core/ss/select.py | 1 + graphblas/core/ss/unary.py | 1 + graphblas/core/ss/vector.py | 56 ++++++------- graphblas/core/utils.py | 4 +- graphblas/core/vector.py | 29 +++++++ graphblas/io/_awkward.py | 1 + graphblas/io/_matrixmarket.py | 2 + graphblas/io/_networkx.py | 2 + graphblas/io/_scipy.py | 1 + graphblas/io/_sparse.py | 1 + graphblas/monoid/numpy.py | 1 + graphblas/select/__init__.py | 15 +--- graphblas/semiring/numpy.py | 1 + graphblas/ss/_core.py | 6 +- graphblas/tests/conftest.py | 16 ++-- graphblas/tests/test_descriptor.py | 3 +- graphblas/tests/test_dtype.py | 2 +- graphblas/tests/test_infix.py | 2 +- graphblas/unary/numpy.py | 1 + graphblas/viz.py | 2 + pyproject.toml | 29 ++++--- scripts/check_versions.sh | 16 ++-- 43 files changed, 290 insertions(+), 173 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 29c6d4a5a..7086d8779 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -167,35 +167,38 @@ jobs: # First let's randomly get versions of dependencies to install. # Consider removing old versions when they become problematic or very old (>=2 years). nxver=$(python -c 'import random ; print(random.choice(["=2.7", "=2.8", "=3.0", "=3.1", "=3.2", ""]))') - yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') - sparsever=$(python -c 'import random ; print(random.choice(["=0.13", "=0.14", ""]))') - fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') + sparsever=$(python -c 'import random ; print(random.choice(["=0.13", "=0.14", "=0.15", ""]))') # Randomly choosing versions of dependencies based on Python version works surprisingly well... if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') - pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", "=1.12", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", "=2.1", "=2.2", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') + fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') + yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.21", "=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') - pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", "=2.1", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", "=1.12", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", "=2.1", "=2.2", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') + fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') + yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]]; then npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", "=1.26", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", ""]))') - pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", "=2.1", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", "=1.12", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", "=2.1", "=2.2", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') + fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') + yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') else # Python 3.12 npver=$(python -c 'import random ; print(random.choice(["=1.26", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.11", ""]))') - pdver=$(python -c 'import random ; print(random.choice(["=2.1", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.11", "=1.12", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=2.1", "=2.2", ""]))') akver=$(python -c 'import random ; print(random.choice(["=2.4", "=2.5", ""]))') + fmmver=$(python -c 'import random ; print(random.choice(["=1.7", ""]))') + yamlver=$(python -c 'import random ; print(random.choice(["=6.0", ""]))') fi # But there may be edge cases of incompatibility we need to handle (more handled below) - if [[ ${pdver} == "=2.1" && ${npver} == "=1.21" ]]; then - pdver="=2.0" - fi if [[ ${{ steps.sourcetype.outputs.selected }} == "source" || ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then # TODO: there are currently issues with some numpy versions when # installing python-suitesparse-grphblas from source or upstream. @@ -226,28 +229,32 @@ jobs: psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", "==8.0.2.1", "==8.2.0.1", "==8.2.1.0", ""]))') fi if [[ ${npver} == "=1.26" ]] ; then - numbaver="" + numbaver=$(python -c 'import random ; print(random.choice(["=0.58", "=0.59", ""]))') if [[ ${spver} == "=1.9" ]] ; then spver=$(python -c 'import random ; print(random.choice(["=1.10", "=1.11", ""]))') fi elif [[ ${npver} == "=1.25" ]] ; then - numbaver="" + numbaver=$(python -c 'import random ; print(random.choice(["=0.58", "=0.59", ""]))') elif [[ ${npver} == "=1.24" || ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]] ; then - numbaver=$(python -c 'import random ; print(random.choice(["=0.57", "=0.58", ""]))') + numbaver=$(python -c 'import random ; print(random.choice(["=0.57", "=0.58", "=0.59", ""]))') elif [[ ${npver} == "=1.21" ]] ; then numbaver=$(python -c 'import random ; print(random.choice(["=0.55", "=0.56", "=0.57", ""]))') else - numbaver=$(python -c 'import random ; print(random.choice(["=0.56", "=0.57", "=0.58", ""]))') + numbaver=$(python -c 'import random ; print(random.choice(["=0.56", "=0.57", "=0.58", "=0.59", ""]))') + fi + # Only numba 0.59 support Python 3.12 + if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]] ; then + numbaver=$(python -c 'import random ; print(random.choice(["=0.59", ""]))') fi fmm=fast_matrix_market${fmmver} awkward=awkward${akver} if [[ ${{ contains(steps.pyver.outputs.selected, 'pypy') || - startsWith(steps.pyver.outputs.selected, '3.12') }} == true || + startsWith(steps.pyver.outputs.selected, '3.13') }} == true || ( ${{ matrix.slowtask != 'notebooks'}} == true && ( ( ${{ matrix.os == 'windows-latest' }} == true && $(python -c 'import random ; print(random.random() < .2)') == True ) || ( ${{ matrix.os == 'windows-latest' }} == false && $(python -c 'import random ; print(random.random() < .4)') == True ))) ]] then - # Some packages aren't available for pypy or Python 3.12; randomly otherwise (if not running notebooks) + # Some packages aren't available for pypy or Python 3.13; randomly otherwise (if not running notebooks) echo "skipping numba" numba="" numbaver=NA @@ -264,7 +271,7 @@ jobs: pdver="" yamlver="" fi - elif [[ ${npver} == "=1.25" || ${npver} == "=1.26" ]] ; then + elif [[ ${npver} == "=2.0" ]] ; then # Don't install numba for unsupported versions of numpy numba="" numbaver=NA @@ -421,7 +428,7 @@ jobs: coverage xml coverage report --show-missing - name: codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 - name: Notebooks Execution check if: matrix.slowtask == 'notebooks' run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 67600553b..fa563b639 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: - id: name-tests-test args: ["--pytest-test-first"] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.15 + rev: v0.16 hooks: - id: validate-pyproject name: Validate pyproject.toml @@ -61,25 +61,25 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.1.1 hooks: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.9 + rev: v0.2.1 hooks: - id: ruff args: [--fix-only, --show-fixes] # 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.1.0 + rev: 7.0.0 hooks: - id: flake8 additional_dependencies: &flake8_dependencies # These versions need updated manually - - flake8==6.1.0 - - flake8-bugbear==23.12.2 + - flake8==7.0.0 + - flake8-bugbear==24.1.17 - flake8-simplify==0.21.0 - repo: https://github.com/asottile/yesqa rev: v1.5.0 @@ -94,7 +94,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.9 + rev: v0.2.1 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/graphblas/agg/__init__.py b/graphblas/agg/__init__.py index 725c11aab..da7c13591 100644 --- a/graphblas/agg/__init__.py +++ b/graphblas/agg/__init__.py @@ -73,6 +73,7 @@ # - bxnor monoid: even bits # - bnor monoid: odd bits """ + # All items are dynamically added by classes in core/operator/agg.py # This module acts as a container of Aggregator instances _deprecated = {} diff --git a/graphblas/binary/numpy.py b/graphblas/binary/numpy.py index 7c03977e4..bb22d0b07 100644 --- a/graphblas/binary/numpy.py +++ b/graphblas/binary/numpy.py @@ -5,6 +5,7 @@ https://numba.readthedocs.io/en/stable/reference/numpysupported.html#math-operations """ + import numpy as _np from .. import _STANDARD_OPERATOR_NAMES diff --git a/graphblas/core/automethods.py b/graphblas/core/automethods.py index 31b349280..600a6e139 100644 --- a/graphblas/core/automethods.py +++ b/graphblas/core/automethods.py @@ -7,6 +7,7 @@ $ python -m graphblas.core.automethods """ + from .. import config diff --git a/graphblas/core/dtypes.py b/graphblas/core/dtypes.py index d7a83c99b..28ce60d03 100644 --- a/graphblas/core/dtypes.py +++ b/graphblas/core/dtypes.py @@ -375,8 +375,7 @@ def lookup_dtype(key, value=None): def unify(type1, type2, *, is_left_scalar=False, is_right_scalar=False): - """ - Returns a type that can hold both type1 and type2. + """Returns a type that can hold both type1 and type2. For example: unify(INT32, INT64) -> INT64 diff --git a/graphblas/core/expr.py b/graphblas/core/expr.py index d803939a5..efec2db5f 100644 --- a/graphblas/core/expr.py +++ b/graphblas/core/expr.py @@ -147,13 +147,13 @@ def py_indices(self): return self.indices[0]._py_index() def parse_indices(self, indices, shape): - """ - Returns + """Returns ------- [(rows, rowsize), (cols, colsize)] for Matrix [(idx, idx_size)] for Vector Within each tuple, if the index is of type int, the size will be None + """ if len(shape) == 1: if type(indices) is tuple: @@ -312,8 +312,8 @@ def update(self, expr, **opts): 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): - """ - Force extraction of the indexes into a new object + """Force extraction of the indexes into a new object. + dtype and mask are the only controllable parameters. """ if input_mask is not None: diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 16483c2a1..359477d4c 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -177,6 +177,7 @@ class Matrix(BaseType): Number of columns. name : str, optional Name to give the Matrix. This will be displayed in the ``__repr__``. + """ __slots__ = "_nrows", "_ncols", "_parent", "ss" @@ -296,6 +297,7 @@ def __delitem__(self, keys, **opts): Examples -------- >>> del M[1, 5] + """ del Updater(self, opts=opts)[keys] @@ -310,6 +312,7 @@ def __getitem__(self, keys): .. code-block:: python subM = M[[1, 3, 5], :].new() + """ resolved_indexes = IndexerResolver(self, keys) shape = resolved_indexes.shape @@ -331,6 +334,7 @@ def __setitem__(self, keys, expr, **opts): .. code-block:: python M[0, 0:3] = 17 + """ Updater(self, opts=opts)[keys] = expr @@ -342,6 +346,7 @@ def __contains__(self, index): .. code-block:: python (10, 15) in M + """ extractor = self[index] if not extractor._is_scalar: @@ -381,6 +386,7 @@ def isequal(self, other, *, check_dtype=False, **opts): See Also -------- :meth:`isclose` : For equality check of floating point dtypes + """ other = self._expect_type( other, (Matrix, TransposedMatrix), within="isequal", argname="other" @@ -427,6 +433,7 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False, **opts ------- bool Whether all values of the Matrix are close to the values in ``other``. + """ other = self._expect_type( other, (Matrix, TransposedMatrix), within="isclose", argname="other" @@ -544,6 +551,7 @@ def to_coo(self, dtype=None, *, rows=True, columns=True, values=True, sort=True) np.ndarray[dtype=uint64] : Rows np.ndarray[dtype=uint64] : Columns np.ndarray : Values + """ if sort and backend == "suitesparse": self.wait() # sort in SS @@ -610,6 +618,7 @@ def to_edgelist(self, dtype=None, *, values=True, sort=True): ------- np.ndarray[dtype=uint64] : Edgelist np.ndarray : Values + """ rows, columns, values = self.to_coo(dtype, values=values, sort=sort) return (np.column_stack([rows, columns]), values) @@ -690,6 +699,7 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): Returns ------- Matrix + """ if dtype is not None or mask is not None or clear: if dtype is None: @@ -721,6 +731,7 @@ def diag(self, k=0, dtype=None, *, name=None, **opts): Returns ------- :class:`~graphblas.Vector` + """ if backend == "suitesparse": from ..ss._core import diag @@ -764,6 +775,7 @@ def wait(self, how="materialize"): Use wait to force completion of the Matrix. Has no effect in `blocking mode <../user_guide/init.html#graphblas-modes>`__. + """ how = how.lower() if how == "materialize": @@ -790,6 +802,7 @@ def get(self, row, col, default=None): Returns ------- Python scalar + """ expr = self[row, col] if expr._is_scalar: @@ -847,6 +860,7 @@ def from_coo( Returns ------- Matrix + """ rows = ints_to_numpy_buffer(rows, np.uint64, name="row indices") columns = ints_to_numpy_buffer(columns, np.uint64, name="column indices") @@ -926,6 +940,7 @@ def from_edgelist( Returns ------- Matrix + """ edgelist_values = None if isinstance(edgelist, np.ndarray): @@ -1095,6 +1110,7 @@ def from_csr( to_csr Matrix.ss.import_csr io.from_scipy_sparse + """ return cls._from_csx(_CSR_FORMAT, indptr, col_indices, values, dtype, ncols, nrows, name) @@ -1142,6 +1158,7 @@ def from_csc( to_csc Matrix.ss.import_csc io.from_scipy_sparse + """ return cls._from_csx(_CSC_FORMAT, indptr, row_indices, values, dtype, nrows, ncols, name) @@ -1202,6 +1219,7 @@ def from_dcsr( to_dcsr Matrix.ss.import_hypercsr io.from_scipy_sparse + """ if backend == "suitesparse": return cls.ss.import_hypercsr( @@ -1286,6 +1304,7 @@ def from_dcsc( to_dcsc Matrix.ss.import_hypercsc io.from_scipy_sparse + """ if backend == "suitesparse": return cls.ss.import_hypercsc( @@ -1347,6 +1366,7 @@ def from_scalar(cls, value, nrows, ncols, dtype=None, *, name=None, **opts): Returns ------- Matrix + """ if type(value) is not Scalar: try: @@ -1400,6 +1420,7 @@ def from_dense(cls, values, missing_value=None, *, dtype=None, name=None, **opts Returns ------- Matrix + """ values, dtype = values_to_numpy_buffer(values, dtype, subarray_after=2) if values.ndim == 0: @@ -1459,6 +1480,7 @@ def to_dense(self, fill_value=None, dtype=None, **opts): Returns ------- np.ndarray + """ max_nvals = self._nrows * self._ncols if fill_value is None or self._nvals == max_nvals: @@ -1534,6 +1556,7 @@ def from_dicts( Returns ------- Matrix + """ order = get_order(order) if isinstance(nested_dicts, Sequence): @@ -1643,6 +1666,7 @@ def to_csr(self, dtype=None, *, sort=True): from_csr Matrix.ss.export io.to_scipy_sparse + """ if backend == "suitesparse": info = self.ss.export("csr", sort=sort) @@ -1674,6 +1698,7 @@ def to_csc(self, dtype=None, *, sort=True): from_csc Matrix.ss.export io.to_scipy_sparse + """ if backend == "suitesparse": info = self.ss.export("csc", sort=sort) @@ -1708,6 +1733,7 @@ def to_dcsr(self, dtype=None, *, sort=True): from_dcsc Matrix.ss.export io.to_scipy_sparse + """ if backend == "suitesparse": info = self.ss.export("hypercsr", sort=sort) @@ -1750,6 +1776,7 @@ def to_dcsc(self, dtype=None, *, sort=True): from_dcsc Matrix.ss.export io.to_scipy_sparse + """ if backend == "suitesparse": info = self.ss.export("hypercsc", sort=sort) @@ -1787,6 +1814,7 @@ def to_dicts(self, order="rowwise"): Returns ------- dict + """ order = get_order(order) if order == "rowwise": @@ -1856,6 +1884,7 @@ def ewise_add(self, other, op=monoid.plus): # Functional syntax C << monoid.max(A | B) + """ return self._ewise_add(other, op) @@ -1946,6 +1975,7 @@ def ewise_mult(self, other, op=binary.times): # Functional syntax C << binary.gt(A & B) + """ return self._ewise_mult(other, op) @@ -2040,6 +2070,7 @@ def ewise_union(self, other, op, left_default, right_default): # Functional syntax C << binary.div(A | B, left_default=1, right_default=1) + """ return self._ewise_union(other, op, left_default, right_default) @@ -2193,6 +2224,7 @@ def mxv(self, other, op=semiring.plus_times): # Functional syntax C << semiring.min_plus(A @ v) + """ return self._mxv(other, op) @@ -2253,6 +2285,7 @@ def mxm(self, other, op=semiring.plus_times): # Functional syntax C << semiring.min_plus(A @ B) + """ return self._mxm(other, op) @@ -2317,6 +2350,7 @@ def kronecker(self, other, op=binary.times): .. code-block:: python C << A.kronecker(B, op=binary.times) + """ method_name = "kronecker" other = self._expect_type( @@ -2373,6 +2407,7 @@ def apply(self, op, right=None, *, left=None): # Functional syntax C << op.abs(A) + """ method_name = "apply" extra_message = ( @@ -2521,6 +2556,7 @@ def select(self, op, thunk=None): # Functional syntax C << select.value(A >= 1) + """ method_name = "select" if isinstance(op, str): @@ -2615,6 +2651,7 @@ def reduce_rowwise(self, op=monoid.plus): .. code-block:: python w << A.reduce_rowwise(monoid.plus) + """ method_name = "reduce_rowwise" op = get_typed_op(op, self.dtype, kind="binary|aggregator") @@ -2652,6 +2689,7 @@ def reduce_columnwise(self, op=monoid.plus): .. code-block:: python w << A.reduce_columnwise(monoid.plus) + """ method_name = "reduce_columnwise" op = get_typed_op(op, self.dtype, kind="binary|aggregator") @@ -2670,8 +2708,7 @@ def reduce_columnwise(self, op=monoid.plus): ) def reduce_scalar(self, op=monoid.plus, *, allow_empty=True): - """ - Reduce all values in the Matrix into a single value using ``op``. + """Reduce all values in the Matrix into a single value using ``op``. See the `Reduce <../user_guide/operations.html#reduce>`__ section in the User Guide for more details. @@ -2693,6 +2730,7 @@ def reduce_scalar(self, op=monoid.plus, *, allow_empty=True): .. code-block:: python total << A.reduce_scalar(monoid.plus) + """ method_name = "reduce_scalar" op = get_typed_op(op, self.dtype, kind="binary|aggregator") @@ -2753,6 +2791,7 @@ def reposition(self, row_offset, column_offset, *, nrows=None, ncols=None): .. code-block:: python C = A.reposition(1, 2).new() + """ if nrows is None: nrows = self._nrows @@ -2834,6 +2873,7 @@ def power(self, n, op=semiring.plus_times): C = A.dup() for i in range(1, 4): C << A @ C + """ method_name = "power" if self._nrows != self._ncols: @@ -2878,6 +2918,7 @@ def setdiag(self, values, k=0, *, mask=None, accum=None, **opts): If it is Matrix Mask, then only the diagonal is used as the mask. accum : Monoid or BinaryOp, optional Operator to use to combine existing diagonal values and new values. + """ if (K := maybe_integral(k)) is None: raise TypeError(f"k must be an integer; got bad type: {type(k)}") diff --git a/graphblas/core/operator/base.py b/graphblas/core/operator/base.py index 59482b47d..4e19fbe96 100644 --- a/graphblas/core/operator/base.py +++ b/graphblas/core/operator/base.py @@ -405,7 +405,8 @@ def _find(cls, funcname): @classmethod def _initialize(cls, include_in_ops=True): - """ + """Initialize operators for this operator type. + include_in_ops determines whether the operators are included in the ``gb.ops`` namespace in addition to the defined module. """ diff --git a/graphblas/core/operator/binary.py b/graphblas/core/operator/binary.py index 278ee3183..3ee089fe4 100644 --- a/graphblas/core/operator/binary.py +++ b/graphblas/core/operator/binary.py @@ -663,6 +663,7 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals Returns ------- BinaryOp or ParameterizedBinaryOp + """ cls._check_supports_udf("register_anonymous") if parameterized: @@ -725,6 +726,7 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal >>> return x == y or abs(x - y) <= max(rel_tol * max(abs(x), abs(y)), abs_tol) >>> return inner >>> gb.binary.register_new("user_isclose", user_isclose, parameterized=True) + """ cls._check_supports_udf("register_new") module, funcname = cls._remove_nesting(name) diff --git a/graphblas/core/operator/indexunary.py b/graphblas/core/operator/indexunary.py index b6fc74e91..6fdacbcc1 100644 --- a/graphblas/core/operator/indexunary.py +++ b/graphblas/core/operator/indexunary.py @@ -285,6 +285,7 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals Returns ------- return IndexUnaryOp or ParameterizedIndexUnaryOp + """ cls._check_supports_udf("register_anonymous") if parameterized: @@ -340,6 +341,7 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal >>> gb.indexunary.register_new("row_mod", lambda x, i, j, thunk: i % max(thunk, 2)) >>> dir(gb.indexunary) [..., 'row_mod', ...] + """ cls._check_supports_udf("register_new") module, funcname = cls._remove_nesting(name) diff --git a/graphblas/core/operator/monoid.py b/graphblas/core/operator/monoid.py index 21d2b7cac..e3f218a90 100644 --- a/graphblas/core/operator/monoid.py +++ b/graphblas/core/operator/monoid.py @@ -270,6 +270,7 @@ def register_anonymous(cls, binaryop, identity, name=None, *, is_idempotent=Fals Returns ------- Monoid or ParameterizedMonoid + """ if type(binaryop) is ParameterizedBinaryOp: return ParameterizedMonoid( @@ -309,6 +310,7 @@ def register_new(cls, name, binaryop, identity, *, is_idempotent=False, lazy=Fal >>> gb.core.operator.Monoid.register_new("max_zero", gb.binary.max_zero, 0) >>> dir(gb.monoid) [..., 'max_zero', ...] + """ module, funcname = cls._remove_nesting(name) if lazy: diff --git a/graphblas/core/operator/select.py b/graphblas/core/operator/select.py index 4dd65ef16..6de4fa89a 100644 --- a/graphblas/core/operator/select.py +++ b/graphblas/core/operator/select.py @@ -208,6 +208,7 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals Returns ------- SelectOp or ParameterizedSelectOp + """ cls._check_supports_udf("register_anonymous") if parameterized: @@ -264,6 +265,7 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal >>> gb.select.register_new("upper_left_triangle", lambda x, i, j, thunk: i + j <= thunk) >>> dir(gb.select) [..., 'upper_left_triangle', ...] + """ cls._check_supports_udf("register_new") iop = IndexUnaryOp.register_new( diff --git a/graphblas/core/operator/semiring.py b/graphblas/core/operator/semiring.py index d367461f6..a8d18f1bf 100644 --- a/graphblas/core/operator/semiring.py +++ b/graphblas/core/operator/semiring.py @@ -287,6 +287,7 @@ def register_anonymous(cls, monoid, binaryop, name=None): Returns ------- Semiring or ParameterizedSemiring + """ if type(monoid) is ParameterizedMonoid or type(binaryop) is ParameterizedBinaryOp: return ParameterizedSemiring(name, monoid, binaryop, anonymous=True) @@ -318,6 +319,7 @@ def register_new(cls, name, monoid, binaryop, *, lazy=False): >>> gb.core.operator.Semiring.register_new("max_max", gb.monoid.max, gb.binary.max) >>> dir(gb.semiring) [..., 'max_max', ...] + """ module, funcname = cls._remove_nesting(name) if lazy: diff --git a/graphblas/core/operator/unary.py b/graphblas/core/operator/unary.py index 7484f74d9..26e0ca61c 100644 --- a/graphblas/core/operator/unary.py +++ b/graphblas/core/operator/unary.py @@ -304,6 +304,7 @@ def register_anonymous(cls, func, name=None, *, parameterized=False, is_udt=Fals Returns ------- UnaryOp or ParameterizedUnaryOp + """ cls._check_supports_udf("register_anonymous") if parameterized: @@ -349,6 +350,7 @@ def register_new(cls, name, func, *, parameterized=False, is_udt=False, lazy=Fal >>> gb.core.operator.UnaryOp.register_new("plus_one", lambda x: x + 1) >>> dir(gb.unary) [..., 'plus_one', ...] + """ cls._check_supports_udf("register_new") module, funcname = cls._remove_nesting(name) diff --git a/graphblas/core/operator/utils.py b/graphblas/core/operator/utils.py index 543df793e..1442a9b5e 100644 --- a/graphblas/core/operator/utils.py +++ b/graphblas/core/operator/utils.py @@ -170,6 +170,7 @@ def get_semiring(monoid, binaryop, name=None): semiring.register_anonymous semiring.register_new semiring.from_string + """ monoid, opclass = find_opclass(monoid) switched = False diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index 9cdf3043e..7e759e5d0 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -53,6 +53,7 @@ class Scalar(BaseType): with a proper GrB_Scalar object. name : str, optional Name to give the Scalar. This will be displayed in the ``__repr__``. + """ __slots__ = "_empty", "_is_cscalar" @@ -196,6 +197,7 @@ def isequal(self, other, *, check_dtype=False): See Also -------- :meth:`isclose` : For equality check of floating point dtypes + """ if type(other) is not Scalar: if other is None: @@ -245,6 +247,7 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False): Returns ------- bool + """ if type(other) is not Scalar: if other is None: @@ -428,6 +431,7 @@ def dup(self, dtype=None, *, clear=False, is_cscalar=None, name=None): Returns ------- Scalar + """ if is_cscalar is None: is_cscalar = self._is_cscalar @@ -473,6 +477,7 @@ def wait(self, how="materialize"): Use wait to force completion of the Scalar. Has no effect in `blocking mode <../user_guide/init.html#graphblas-modes>`__. + """ how = how.lower() if how == "materialize": @@ -496,6 +501,7 @@ def get(self, default=None): Returns ------- Python scalar + """ return default if self._is_empty else self.value @@ -519,6 +525,7 @@ def from_value(cls, value, dtype=None, *, is_cscalar=False, name=None): Returns ------- Scalar + """ typ = output_type(value) if dtype is None: @@ -628,6 +635,7 @@ def ewise_add(self, other, op=monoid.plus): # Functional syntax c << monoid.max(a | b) + """ return self._ewise_add(other, op) @@ -698,6 +706,7 @@ def ewise_mult(self, other, op=binary.times): # Functional syntax c << binary.gt(a & b) + """ return self._ewise_mult(other, op) @@ -772,6 +781,7 @@ def ewise_union(self, other, op, left_default, right_default): # Functional syntax c << binary.div(a | b, left_default=1, right_default=1) + """ return self._ewise_union(other, op, left_default, right_default) @@ -917,6 +927,7 @@ def apply(self, op, right=None, *, left=None): # Functional syntax b << op.abs(a) + """ expr = self._as_vector().apply(op, right, left=left) return ScalarExpression( diff --git a/graphblas/core/ss/binary.py b/graphblas/core/ss/binary.py index 6965aeaf1..d53608818 100644 --- a/graphblas/core/ss/binary.py +++ b/graphblas/core/ss/binary.py @@ -71,6 +71,7 @@ def register_new(name, jit_c_definition, left_type, right_type, ret_type): gb.binary.register_new gb.binary.register_anonymous gb.unary.ss.register_new + """ if backend != "suitesparse": # pragma: no cover (safety) raise RuntimeError( diff --git a/graphblas/core/ss/descriptor.py b/graphblas/core/ss/descriptor.py index 52c43b95d..781661b7b 100644 --- a/graphblas/core/ss/descriptor.py +++ b/graphblas/core/ss/descriptor.py @@ -157,6 +157,7 @@ def get_descriptor(**opts): Returns ------- Descriptor or None + """ if not opts or all(val is False or val is None for val in opts.values()): return diff --git a/graphblas/core/ss/indexunary.py b/graphblas/core/ss/indexunary.py index d5f709526..b60837acf 100644 --- a/graphblas/core/ss/indexunary.py +++ b/graphblas/core/ss/indexunary.py @@ -70,6 +70,7 @@ def register_new(name, jit_c_definition, input_type, thunk_type, ret_type): gb.indexunary.register_new gb.indexunary.register_anonymous gb.select.ss.register_new + """ if backend != "suitesparse": # pragma: no cover (safety) raise RuntimeError( diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index 64914cf02..0489cb5d6 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -250,8 +250,7 @@ def orientation(self): return "rowwise" def build_diag(self, vector, k=0, **opts): - """ - GxB_Matrix_diag. + """GxB_Matrix_diag. Construct a diagonal Matrix from the given vector. Existing entries in the Matrix are discarded. @@ -279,8 +278,7 @@ def build_diag(self, vector, k=0, **opts): ) def split(self, chunks, *, name=None, **opts): - """ - GxB_Matrix_split. + """GxB_Matrix_split. Split a Matrix into a 2D array of sub-matrices according to ``chunks``. @@ -302,6 +300,7 @@ def split(self, chunks, *, name=None, **opts): -------- Matrix.ss.concat graphblas.ss.concat + """ from ..matrix import Matrix @@ -361,8 +360,7 @@ def _concat(self, tiles, m, n, opts): ) def concat(self, tiles, **opts): - """ - GxB_Matrix_concat. + """GxB_Matrix_concat. Concatenate a 2D list of Matrix objects into the current Matrix. Any existing values in the current Matrix will be discarded. @@ -376,13 +374,13 @@ def concat(self, tiles, **opts): -------- Matrix.ss.split graphblas.ss.concat + """ tiles, m, n, is_matrix = _concat_mn(tiles, is_matrix=True) self._concat(tiles, m, n, opts) def build_scalar(self, rows, columns, value): - """ - GxB_Matrix_build_Scalar. + """GxB_Matrix_build_Scalar. Like ``build``, but uses a scalar for all the values. @@ -390,6 +388,7 @@ def build_scalar(self, rows, columns, value): -------- Matrix.build Matrix.from_coo + """ rows = ints_to_numpy_buffer(rows, np.uint64, name="row indices") columns = ints_to_numpy_buffer(columns, np.uint64, name="column indices") @@ -536,8 +535,7 @@ def iteritems(self, seek=0): lib.GxB_Iterator_free(it_ptr) def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **opts): - """ - GxB_Matrix_export_xxx. + """GxB_Matrix_export_xxx. Parameters ---------- @@ -718,6 +716,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, ** >>> pieces = A.ss.export() >>> A2 = Matrix.ss.import_any(**pieces) + """ return self._export( format, @@ -729,8 +728,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, ** ) def unpack(self, format=None, *, sort=False, raw=False, **opts): - """ - GxB_Matrix_unpack_xxx. + """GxB_Matrix_unpack_xxx. ``unpack`` is like ``export``, except that the Matrix remains valid but empty. ``pack_*`` methods are the opposite of ``unpack``. @@ -1179,8 +1177,7 @@ def import_csr( name=None, **opts, ): - """ - GxB_Matrix_import_CSR. + """GxB_Matrix_import_CSR. Create a new Matrix from standard CSR format. @@ -1220,6 +1217,7 @@ def import_csr( Returns ------- Matrix + """ return cls._import_csr( nrows=nrows, @@ -1256,8 +1254,7 @@ def pack_csr( name=None, **opts, ): - """ - GxB_Matrix_pack_CSR. + """GxB_Matrix_pack_CSR. ``pack_csr`` is like ``import_csr`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("csr")`` @@ -1369,8 +1366,7 @@ def import_csc( name=None, **opts, ): - """ - GxB_Matrix_import_CSC. + """GxB_Matrix_import_CSC. Create a new Matrix from standard CSC format. @@ -1410,6 +1406,7 @@ def import_csc( Returns ------- Matrix + """ return cls._import_csc( nrows=nrows, @@ -1446,8 +1443,7 @@ def pack_csc( name=None, **opts, ): - """ - GxB_Matrix_pack_CSC. + """GxB_Matrix_pack_CSC. ``pack_csc`` is like ``import_csc`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("csc")`` @@ -1561,8 +1557,7 @@ def import_hypercsr( name=None, **opts, ): - """ - GxB_Matrix_import_HyperCSR. + """GxB_Matrix_import_HyperCSR. Create a new Matrix from standard HyperCSR format. @@ -1606,6 +1601,7 @@ def import_hypercsr( Returns ------- Matrix + """ return cls._import_hypercsr( nrows=nrows, @@ -1646,8 +1642,7 @@ def pack_hypercsr( name=None, **opts, ): - """ - GxB_Matrix_pack_HyperCSR. + """GxB_Matrix_pack_HyperCSR. ``pack_hypercsr`` is like ``import_hypercsr`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("hypercsr")`` @@ -1785,8 +1780,7 @@ def import_hypercsc( name=None, **opts, ): - """ - GxB_Matrix_import_HyperCSC. + """GxB_Matrix_import_HyperCSC. Create a new Matrix from standard HyperCSC format. @@ -1830,6 +1824,7 @@ def import_hypercsc( Returns ------- Matrix + """ return cls._import_hypercsc( nrows=nrows, @@ -1870,8 +1865,7 @@ def pack_hypercsc( name=None, **opts, ): - """ - GxB_Matrix_pack_HyperCSC. + """GxB_Matrix_pack_HyperCSC. ``pack_hypercsc`` is like ``import_hypercsc`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("hypercsc")`` @@ -2006,8 +2000,7 @@ def import_bitmapr( name=None, **opts, ): - """ - GxB_Matrix_import_BitmapR. + """GxB_Matrix_import_BitmapR. Create a new Matrix from values and bitmap (as mask) arrays. @@ -2053,6 +2046,7 @@ def import_bitmapr( Returns ------- Matrix + """ return cls._import_bitmapr( bitmap=bitmap, @@ -2087,8 +2081,7 @@ def pack_bitmapr( name=None, **opts, ): - """ - GxB_Matrix_pack_BitmapR. + """GxB_Matrix_pack_BitmapR. ``pack_bitmapr`` is like ``import_bitmapr`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("bitmapr")`` @@ -2199,8 +2192,7 @@ def import_bitmapc( name=None, **opts, ): - """ - GxB_Matrix_import_BitmapC. + """GxB_Matrix_import_BitmapC. Create a new Matrix from values and bitmap (as mask) arrays. @@ -2246,6 +2238,7 @@ def import_bitmapc( Returns ------- Matrix + """ return cls._import_bitmapc( bitmap=bitmap, @@ -2280,8 +2273,7 @@ def pack_bitmapc( name=None, **opts, ): - """ - GxB_Matrix_pack_BitmapC. + """GxB_Matrix_pack_BitmapC. ``pack_bitmapc`` is like ``import_bitmapc`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("bitmapc")`` @@ -2390,8 +2382,7 @@ def import_fullr( name=None, **opts, ): - """ - GxB_Matrix_import_FullR. + """GxB_Matrix_import_FullR. Create a new Matrix from values. @@ -2432,6 +2423,7 @@ def import_fullr( Returns ------- Matrix + """ return cls._import_fullr( values=values, @@ -2462,8 +2454,7 @@ def pack_fullr( name=None, **opts, ): - """ - GxB_Matrix_pack_FullR. + """GxB_Matrix_pack_FullR. ``pack_fullr`` is like ``import_fullr`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("fullr")`` @@ -2549,8 +2540,7 @@ def import_fullc( name=None, **opts, ): - """ - GxB_Matrix_import_FullC. + """GxB_Matrix_import_FullC. Create a new Matrix from values. @@ -2591,6 +2581,7 @@ def import_fullc( Returns ------- Matrix + """ return cls._import_fullc( values=values, @@ -2621,8 +2612,7 @@ def pack_fullc( name=None, **opts, ): - """ - GxB_Matrix_pack_FullC. + """GxB_Matrix_pack_FullC. ``pack_fullc`` is like ``import_fullc`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("fullc")`` @@ -2711,8 +2701,7 @@ def import_coo( name=None, **opts, ): - """ - GrB_Matrix_build_XXX and GxB_Matrix_build_Scalar. + """GrB_Matrix_build_XXX and GxB_Matrix_build_Scalar. Create a new Matrix from indices and values in coordinate format. @@ -2746,6 +2735,7 @@ def import_coo( Returns ------- Matrix + """ return cls._import_coo( rows=rows, @@ -2784,8 +2774,7 @@ def pack_coo( name=None, **opts, ): - """ - GrB_Matrix_build_XXX and GxB_Matrix_build_Scalar. + """GrB_Matrix_build_XXX and GxB_Matrix_build_Scalar. ``pack_coo`` is like ``import_coo`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("coo")`` @@ -2897,8 +2886,7 @@ def import_coor( name=None, **opts, ): - """ - GxB_Matrix_import_CSR. + """GxB_Matrix_import_CSR. Create a new Matrix from indices and values in coordinate format. Rows must be sorted. @@ -2942,6 +2930,7 @@ def import_coor( Returns ------- Matrix + """ return cls._import_coor( rows=rows, @@ -2980,8 +2969,7 @@ def pack_coor( name=None, **opts, ): - """ - GxB_Matrix_pack_CSR. + """GxB_Matrix_pack_CSR. ``pack_coor`` is like ``import_coor`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("coor")`` @@ -3066,8 +3054,7 @@ def import_cooc( name=None, **opts, ): - """ - GxB_Matrix_import_CSC. + """GxB_Matrix_import_CSC. Create a new Matrix from indices and values in coordinate format. Rows must be sorted. @@ -3111,6 +3098,7 @@ def import_cooc( Returns ------- Matrix + """ return cls._import_cooc( rows=rows, @@ -3149,8 +3137,7 @@ def pack_cooc( name=None, **opts, ): - """ - GxB_Matrix_pack_CSC. + """GxB_Matrix_pack_CSC. ``pack_cooc`` is like ``import_cooc`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack("cooc")`` @@ -3251,8 +3238,7 @@ def import_any( nvals=None, # optional **opts, ): - """ - GxB_Matrix_import_xxx. + """GxB_Matrix_import_xxx. Dispatch to appropriate import method inferred from inputs. See the other import functions and ``Matrix.ss.export`` for details. @@ -3280,6 +3266,7 @@ def import_any( >>> pieces = A.ss.export() >>> A2 = Matrix.ss.import_any(**pieces) + """ return cls._import_any( values=values, @@ -3349,8 +3336,7 @@ def pack_any( name=None, **opts, ): - """ - GxB_Matrix_pack_xxx. + """GxB_Matrix_pack_xxx. ``pack_any`` is like ``import_any`` except it "packs" data into an existing Matrix. This is the opposite of ``unpack()`` @@ -3707,6 +3693,7 @@ def scan(self, op=monoid.plus, order="rowwise", *, name=None, **opts): Returns ------- Matrix + """ order = get_order(order) parent = self._parent @@ -3735,6 +3722,7 @@ def flatten(self, order="rowwise", *, name=None, **opts): See Also -------- Vector.ss.reshape : copy a Vector to a Matrix. + """ rv = self.reshape(-1, 1, order=order, name=name, **opts) return rv._as_vector() @@ -3771,6 +3759,7 @@ def reshape(self, nrows, ncols=None, order="rowwise", *, inplace=False, name=Non -------- Matrix.ss.flatten : flatten a Matrix into a Vector. Vector.ss.reshape : copy a Vector to a Matrix. + """ from ..matrix import Matrix @@ -3825,6 +3814,7 @@ def selectk(self, how, k, order="rowwise", *, name=None): The number of elements to choose from each row **THIS API IS EXPERIMENTAL AND MAY CHANGE** + """ # TODO: largest, smallest, random_weighted order = get_order(order) @@ -4021,6 +4011,7 @@ def sort(self, op=binary.lt, order="rowwise", *, values=True, permutation=True, See Also -------- Matrix.ss.compactify + """ from ..matrix import Matrix @@ -4082,6 +4073,7 @@ def serialize(self, compression="default", level=None, **opts): This method is intended to support all serialization options from SuiteSparse:GraphBLAS. *Warning*: Behavior of serializing UDTs is experimental and may change in a future release. + """ desc = get_descriptor(compression=compression, compression_level=level, **opts) blob_handle = ffi_new("void**") @@ -4121,6 +4113,7 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts): nthreads : int, optional The maximum number of threads to use when deserializing. None, 0 or negative nthreads means to use the default number of threads. + """ if isinstance(data, np.ndarray): data = ints_to_numpy_buffer(data, np.uint8) diff --git a/graphblas/core/ss/select.py b/graphblas/core/ss/select.py index ff12f80fa..3ba135eee 100644 --- a/graphblas/core/ss/select.py +++ b/graphblas/core/ss/select.py @@ -66,6 +66,7 @@ def register_new(name, jit_c_definition, input_type, thunk_type): gb.select.register_new gb.select.register_anonymous gb.indexunary.ss.register_new + """ if backend != "suitesparse": # pragma: no cover (safety) raise RuntimeError( diff --git a/graphblas/core/ss/unary.py b/graphblas/core/ss/unary.py index 5a5c63632..0b7ced3c8 100644 --- a/graphblas/core/ss/unary.py +++ b/graphblas/core/ss/unary.py @@ -63,6 +63,7 @@ def register_new(name, jit_c_definition, input_type, ret_type): gb.unary.register_new gb.unary.register_anonymous gb.binary.ss.register_new + """ if backend != "suitesparse": # pragma: no cover (safety) raise RuntimeError( diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py index a8bff4ee5..d1f7a5bcb 100644 --- a/graphblas/core/ss/vector.py +++ b/graphblas/core/ss/vector.py @@ -145,8 +145,7 @@ def format(self): return format def build_diag(self, matrix, k=0, **opts): - """ - GxB_Vector_diag. + """GxB_Vector_diag. Extract a diagonal from a Matrix or TransposedMatrix into a Vector. Existing entries in the Vector are discarded. @@ -183,8 +182,7 @@ def build_diag(self, matrix, k=0, **opts): ) def split(self, chunks, *, name=None, **opts): - """ - GxB_Matrix_split. + """GxB_Matrix_split. Split a Vector into a 1D array of sub-vectors according to ``chunks``. @@ -202,6 +200,7 @@ def split(self, chunks, *, name=None, **opts): -------- Vector.ss.concat graphblas.ss.concat + """ from ..vector import Vector @@ -249,8 +248,7 @@ def _concat(self, tiles, m, opts): ) def concat(self, tiles, **opts): - """ - GxB_Matrix_concat. + """GxB_Matrix_concat. Concatenate a 1D list of Vector objects into the current Vector. Any existing values in the current Vector will be discarded. @@ -262,13 +260,13 @@ def concat(self, tiles, **opts): -------- Vector.ss.split graphblas.ss.concat + """ tiles, m, n, is_matrix = _concat_mn(tiles, is_matrix=False) self._concat(tiles, m, opts) def build_scalar(self, indices, value): - """ - GxB_Vector_build_Scalar. + """GxB_Vector_build_Scalar. Like ``build``, but uses a scalar for all the values. @@ -276,6 +274,7 @@ def build_scalar(self, indices, value): -------- Vector.build Vector.from_coo + """ indices = ints_to_numpy_buffer(indices, np.uint64, name="indices") scalar = _as_scalar(value, self._parent.dtype, is_cscalar=False) # pragma: is_grbscalar @@ -410,8 +409,7 @@ def iteritems(self, seek=0): lib.GxB_Iterator_free(it_ptr) def export(self, format=None, *, sort=False, give_ownership=False, raw=False, **opts): - """ - GxB_Vextor_export_xxx. + """GxB_Vextor_export_xxx. Parameters ---------- @@ -468,6 +466,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, ** >>> pieces = v.ss.export() >>> v2 = Vector.ss.import_any(**pieces) + """ return self._export( format=format, @@ -479,8 +478,7 @@ def export(self, format=None, *, sort=False, give_ownership=False, raw=False, ** ) def unpack(self, format=None, *, sort=False, raw=False, **opts): - """ - GxB_Vector_unpack_xxx. + """GxB_Vector_unpack_xxx. ``unpack`` is like ``export``, except that the Vector remains valid but empty. ``pack_*`` methods are the opposite of ``unpack``. @@ -655,8 +653,7 @@ def import_any( nvals=None, # optional **opts, ): - """ - GxB_Vector_import_xxx. + """GxB_Vector_import_xxx. Dispatch to appropriate import method inferred from inputs. See the other import functions and ``Vector.ss.export`` for details. @@ -679,6 +676,7 @@ def import_any( >>> pieces = v.ss.export() >>> v2 = Vector.ss.import_any(**pieces) + """ return cls._import_any( values=values, @@ -722,8 +720,7 @@ def pack_any( name=None, **opts, ): - """ - GxB_Vector_pack_xxx. + """GxB_Vector_pack_xxx. ``pack_any`` is like ``import_any`` except it "packs" data into an existing Vector. This is the opposite of ``unpack()`` @@ -844,8 +841,7 @@ def import_sparse( name=None, **opts, ): - """ - GxB_Vector_import_CSC. + """GxB_Vector_import_CSC. Create a new Vector from sparse input. @@ -886,6 +882,7 @@ def import_sparse( Returns ------- Vector + """ return cls._import_sparse( size=size, @@ -920,8 +917,7 @@ def pack_sparse( name=None, **opts, ): - """ - GxB_Vector_pack_CSC. + """GxB_Vector_pack_CSC. ``pack_sparse`` is like ``import_sparse`` except it "packs" data into an existing Vector. This is the opposite of ``unpack("sparse")`` @@ -1029,8 +1025,7 @@ def import_bitmap( name=None, **opts, ): - """ - GxB_Vector_import_Bitmap. + """GxB_Vector_import_Bitmap. Create a new Vector from values and bitmap (as mask) arrays. @@ -1071,6 +1066,7 @@ def import_bitmap( Returns ------- Vector + """ return cls._import_bitmap( bitmap=bitmap, @@ -1103,8 +1099,7 @@ def pack_bitmap( name=None, **opts, ): - """ - GxB_Vector_pack_Bitmap. + """GxB_Vector_pack_Bitmap. ``pack_bitmap`` is like ``import_bitmap`` except it "packs" data into an existing Vector. This is the opposite of ``unpack("bitmap")`` @@ -1214,8 +1209,7 @@ def import_full( name=None, **opts, ): - """ - GxB_Vector_import_Full. + """GxB_Vector_import_Full. Create a new Vector from values. @@ -1252,6 +1246,7 @@ def import_full( Returns ------- Vector + """ return cls._import_full( values=values, @@ -1280,8 +1275,7 @@ def pack_full( name=None, **opts, ): - """ - GxB_Vector_pack_Full. + """GxB_Vector_pack_Full. ``pack_full`` is like ``import_full`` except it "packs" data into an existing Vector. This is the opposite of ``unpack("full")`` @@ -1371,6 +1365,7 @@ def scan(self, op=monoid.plus, *, name=None, **opts): Returns ------- Scalar + """ return prefix_scan(self._parent, op, name=name, within="scan", **opts) @@ -1401,6 +1396,7 @@ def reshape(self, nrows, ncols=None, order="rowwise", *, name=None, **opts): See Also -------- Matrix.ss.flatten : flatten a Matrix into a Vector. + """ return self._parent._as_matrix().ss.reshape(nrows, ncols, order, name=name, **opts) @@ -1420,6 +1416,7 @@ def selectk(self, how, k, *, name=None): The number of elements to choose **THIS API IS EXPERIMENTAL AND MAY CHANGE** + """ how = how.lower() if k < 0: @@ -1588,6 +1585,7 @@ def sort(self, op=binary.lt, *, values=True, permutation=True, **opts): See Also -------- Vector.ss.compactify + """ from ..vector import Vector @@ -1648,6 +1646,7 @@ def serialize(self, compression="default", level=None, **opts): This method is intended to support all serialization options from SuiteSparse:GraphBLAS. *Warning*: Behavior of serializing UDTs is experimental and may change in a future release. + """ desc = get_descriptor(compression=compression, compression_level=level, **opts) blob_handle = ffi_new("void**") @@ -1687,6 +1686,7 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts): nthreads : int, optional The maximum number of threads to use when deserializing. None, 0 or negative nthreads means to use the default number of threads. + """ if isinstance(data, np.ndarray): data = ints_to_numpy_buffer(data, np.uint8) diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py index 42fcf0685..184272124 100644 --- a/graphblas/core/utils.py +++ b/graphblas/core/utils.py @@ -43,7 +43,7 @@ def inner(func_wo_doc): object: object, type: type, } -_output_types.update((k, k) for k in np.cast) +_output_types.update((k, k) for k in set(np.sctypeDict.values())) def output_type(val): @@ -86,6 +86,7 @@ def values_to_numpy_buffer( ------- np.ndarray dtype + """ if dtype is not None: dtype = lookup_dtype(dtype) @@ -183,6 +184,7 @@ def normalize_chunks(chunks, shape): [(10,), (5, 15)] >>> normalize_chunks((5, (5, None)), shape) [(5, 5), (5, 15)] + """ if isinstance(chunks, (list, tuple)): pass diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index a631cc4af..863d186ec 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -149,6 +149,7 @@ class Vector(BaseType): Size of the Vector. name : str, optional Name to give the Vector. This will be displayed in the ``__repr__``. + """ __slots__ = "_size", "_parent", "ss" @@ -265,6 +266,7 @@ def __delitem__(self, keys, **opts): Examples -------- >>> del v[1:-1] + """ del Updater(self, opts=opts)[keys] @@ -279,6 +281,7 @@ def __getitem__(self, keys): .. code-block:: python sub_v = v[[1, 3, 5]].new() + """ resolved_indexes = IndexerResolver(self, keys) shape = resolved_indexes.shape @@ -298,6 +301,7 @@ def __setitem__(self, keys, expr, **opts): # This makes a dense iso-value vector v[:] = 1 + """ Updater(self, opts=opts)[keys] = expr @@ -310,6 +314,7 @@ def __contains__(self, index): # Check if v[15] is non-empty 15 in v + """ extractor = self[index] if not extractor._is_scalar: @@ -349,6 +354,7 @@ def isequal(self, other, *, check_dtype=False, **opts): See Also -------- :meth:`isclose` : For equality check of floating point dtypes + """ other = self._expect_type(other, Vector, within="isequal", argname="other") if check_dtype and self.dtype != other.dtype: @@ -391,6 +397,7 @@ def isclose(self, other, *, rel_tol=1e-7, abs_tol=0.0, check_dtype=False, **opts Returns ------- bool + """ other = self._expect_type(other, Vector, within="isclose", argname="other") if check_dtype and self.dtype != other.dtype: @@ -479,6 +486,7 @@ def to_coo(self, dtype=None, *, indices=True, values=True, sort=True): ------- np.ndarray[dtype=uint64] : Indices np.ndarray : Values + """ if sort and backend == "suitesparse": self.wait() # sort in SS @@ -578,6 +586,7 @@ def dup(self, dtype=None, *, clear=False, mask=None, name=None, **opts): Returns ------- Vector + """ if dtype is not None or mask is not None or clear: if dtype is None: @@ -608,6 +617,7 @@ def diag(self, k=0, *, name=None): Returns ------- :class:`~graphblas.Matrix` + """ from .matrix import Matrix @@ -632,6 +642,7 @@ def wait(self, how="materialize"): Use wait to force completion of the Vector. Has no effect in `blocking mode <../user_guide/init.html#graphblas-modes>`__. + """ how = how.lower() if how == "materialize": @@ -656,6 +667,7 @@ def get(self, index, default=None): Returns ------- Python scalar + """ expr = self[index] if expr._is_scalar: @@ -698,6 +710,7 @@ def from_coo(cls, indices, values=1.0, dtype=None, *, size=None, dup_op=None, na Returns ------- Vector + """ indices = ints_to_numpy_buffer(indices, np.uint64, name="indices") values, dtype = values_to_numpy_buffer(values, dtype, subarray_after=1) @@ -755,6 +768,7 @@ def from_pairs(cls, pairs, dtype=None, *, size=None, dup_op=None, name=None): Returns ------- Vector + """ if isinstance(pairs, np.ndarray): raise TypeError("pairs as NumPy array is not supported; use `Vector.from_coo` instead") @@ -806,6 +820,7 @@ def from_scalar(cls, value, size, dtype=None, *, name=None, **opts): Returns ------- Vector + """ if type(value) is not Scalar: try: @@ -858,6 +873,7 @@ def from_dense(cls, values, missing_value=None, *, dtype=None, name=None, **opts Returns ------- Vector + """ values, dtype = values_to_numpy_buffer(values, dtype, subarray_after=1) if values.ndim == 0: @@ -906,6 +922,7 @@ def to_dense(self, fill_value=None, dtype=None, **opts): Returns ------- np.ndarray + """ if fill_value is None or self._nvals == self._size: if self._nvals != self._size: @@ -976,6 +993,7 @@ def ewise_add(self, other, op=monoid.plus): # Functional syntax w << monoid.max(u | v) + """ return self._ewise_add(other, op) @@ -1067,6 +1085,7 @@ def ewise_mult(self, other, op=binary.times): # Functional syntax w << binary.gt(u & v) + """ return self._ewise_mult(other, op) @@ -1160,6 +1179,7 @@ def ewise_union(self, other, op, left_default, right_default): # Functional syntax w << binary.div(u | v, left_default=1, right_default=1) + """ return self._ewise_union(other, op, left_default, right_default) @@ -1314,6 +1334,7 @@ def vxm(self, other, op=semiring.plus_times): # Functional syntax C << semiring.min_plus(v @ A) + """ return self._vxm(other, op) @@ -1393,6 +1414,7 @@ def apply(self, op, right=None, *, left=None): # Functional syntax w << op.abs(v) + """ method_name = "apply" extra_message = ( @@ -1538,6 +1560,7 @@ def select(self, op, thunk=None): # Functional syntax w << select.value(v >= 1) + """ method_name = "select" if isinstance(op, str): @@ -1632,6 +1655,7 @@ def reduce(self, op=monoid.plus, *, allow_empty=True): .. code-block:: python total << v.reduce(monoid.plus) + """ method_name = "reduce" op = get_typed_op(op, self.dtype, kind="binary|aggregator") @@ -1684,6 +1708,7 @@ def inner(self, other, op=semiring.plus_times): *Note*: This is not a standard GraphBLAS function, but fits with other functions in the `Matrix Multiplication <../user_guide/operations.html#matrix-multiply>`__ family of functions. + """ return self._inner(other, op) @@ -1739,6 +1764,7 @@ def outer(self, other, op=binary.times): C << v.outer(w, op=binary.times) *Note*: This is not a standard GraphBLAS function. + """ from .matrix import MatrixExpression @@ -1787,6 +1813,7 @@ def reposition(self, offset, *, size=None): .. code-block:: python w = v.reposition(20).new() + """ if size is None: size = self._size @@ -2047,6 +2074,7 @@ def from_dict(cls, d, dtype=None, *, size=None, name=None): Returns ------- Vector + """ indices = np.fromiter(d.keys(), np.uint64) if dtype is None: @@ -2074,6 +2102,7 @@ def to_dict(self): Returns ------- dict + """ indices, values = self.to_coo(sort=False) return dict(zip(indices.tolist(), values.tolist())) diff --git a/graphblas/io/_awkward.py b/graphblas/io/_awkward.py index 6c476817f..b30984251 100644 --- a/graphblas/io/_awkward.py +++ b/graphblas/io/_awkward.py @@ -154,6 +154,7 @@ def from_awkward(A, *, name=None): function. If attempting to convert an arbitrary `awkward-array`, make sure that the top-level attributes and parameters contain the expected values. + """ params = A.layout.parameters if missing := {"format", "shape"} - params.keys(): diff --git a/graphblas/io/_matrixmarket.py b/graphblas/io/_matrixmarket.py index 558605328..8cf8738a3 100644 --- a/graphblas/io/_matrixmarket.py +++ b/graphblas/io/_matrixmarket.py @@ -32,6 +32,7 @@ def mmread(source, engine="auto", *, dup_op=None, name=None, **kwargs): Returns ------- :class:`~graphblas.Matrix` + """ try: # scipy is currently needed for *all* engines @@ -95,6 +96,7 @@ def mmwrite( Number of digits to write for real or complex values symmetry : str, optional {"general", "symmetric", "skew-symmetric", "hermetian"} + """ try: # scipy is currently needed for *all* engines diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index 2324a11c2..dab04c82d 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -21,6 +21,7 @@ def from_networkx(G, nodelist=None, dtype=None, weight="weight", name=None): Returns ------- :class:`~graphblas.Matrix` + """ import networkx as nx @@ -45,6 +46,7 @@ def to_networkx(m, edge_attribute="weight"): Returns ------- nx.DiGraph + """ import networkx as nx diff --git a/graphblas/io/_scipy.py b/graphblas/io/_scipy.py index 1eaa691dd..228432eed 100644 --- a/graphblas/io/_scipy.py +++ b/graphblas/io/_scipy.py @@ -22,6 +22,7 @@ def from_scipy_sparse(A, *, dup_op=None, name=None): Returns ------- :class:`~graphblas.Matrix` + """ nrows, ncols = A.shape dtype = lookup_dtype(A.dtype) diff --git a/graphblas/io/_sparse.py b/graphblas/io/_sparse.py index 2bbdad2e6..c0d4beabb 100644 --- a/graphblas/io/_sparse.py +++ b/graphblas/io/_sparse.py @@ -23,6 +23,7 @@ def from_pydata_sparse(s, *, dup_op=None, name=None): ------- :class:`~graphblas.Vector` :class:`~graphblas.Matrix` + """ try: import sparse diff --git a/graphblas/monoid/numpy.py b/graphblas/monoid/numpy.py index 5f6895e5d..b9ff2b502 100644 --- a/graphblas/monoid/numpy.py +++ b/graphblas/monoid/numpy.py @@ -5,6 +5,7 @@ https://numba.readthedocs.io/en/stable/reference/numpysupported.html#math-operations """ + import numpy as _np from .. import _STANDARD_OPERATOR_NAMES diff --git a/graphblas/select/__init__.py b/graphblas/select/__init__.py index aaf8e12d0..b55766ff8 100644 --- a/graphblas/select/__init__.py +++ b/graphblas/select/__init__.py @@ -88,9 +88,7 @@ def _match_expr(parent, expr): def value(expr): - """ - An advanced select method which allows for easily expressing - value comparison logic. + """An advanced select method for easily expressing value comparison logic. Example usage: >>> gb.select.value(A > 0) @@ -102,9 +100,7 @@ def value(expr): def row(expr): - """ - An advanced select method which allows for easily expressing - Matrix row index comparison logic. + """An advanced select method for easily expressing Matrix row index comparison logic. Example usage: >>> gb.select.row(A <= 5) @@ -116,9 +112,7 @@ def row(expr): def column(expr): - """ - An advanced select method which allows for easily expressing - Matrix column index comparison logic. + """An advanced select method for easily expressing Matrix column index comparison logic. Example usage: >>> gb.select.column(A <= 5) @@ -130,8 +124,7 @@ def column(expr): def index(expr): - """ - An advanced select method which allows for easily expressing + """An advanced select method which allows for easily expressing Vector index comparison logic. Example usage: diff --git a/graphblas/semiring/numpy.py b/graphblas/semiring/numpy.py index 97b90874b..10a680ea0 100644 --- a/graphblas/semiring/numpy.py +++ b/graphblas/semiring/numpy.py @@ -5,6 +5,7 @@ https://numba.readthedocs.io/en/stable/reference/numpysupported.html#math-operations """ + import itertools as _itertools from .. import _STANDARD_OPERATOR_NAMES diff --git a/graphblas/ss/_core.py b/graphblas/ss/_core.py index 29a67e08b..b42ea72b4 100644 --- a/graphblas/ss/_core.py +++ b/graphblas/ss/_core.py @@ -22,8 +22,7 @@ class _graphblas_ss: def diag(x, k=0, dtype=None, *, name=None, **opts): - """ - GxB_Matrix_diag, GxB_Vector_diag. + """GxB_Matrix_diag, GxB_Vector_diag. Extract a diagonal Vector from a Matrix, or construct a diagonal Matrix from a Vector. Unlike ``Matrix.diag`` and ``Vector.diag``, this function @@ -71,8 +70,7 @@ def diag(x, k=0, dtype=None, *, name=None, **opts): def concat(tiles, dtype=None, *, name=None, **opts): - """ - GxB_Matrix_concat. + """GxB_Matrix_concat. Concatenate a 2D list of Matrix objects into a new Matrix, or a 1D list of Vector objects into a new Vector. To concatenate into existing objects, diff --git a/graphblas/tests/conftest.py b/graphblas/tests/conftest.py index ce9e6488f..a3acb3a94 100644 --- a/graphblas/tests/conftest.py +++ b/graphblas/tests/conftest.py @@ -68,9 +68,11 @@ def save_records(): for key in dir(gb.semiring) if key != "ss" and isinstance( - getattr(gb.semiring, key) - if key not in gb.semiring._deprecated - else gb.semiring._deprecated[key], + ( + getattr(gb.semiring, key) + if key not in gb.semiring._deprecated + else gb.semiring._deprecated[key] + ), (gb.core.operator.Semiring, gb.core.operator.ParameterizedSemiring), ) ) @@ -79,9 +81,11 @@ def save_records(): for key in dir(gb.binary) if key != "ss" and isinstance( - getattr(gb.binary, key) - if key not in gb.binary._deprecated - else gb.binary._deprecated[key], + ( + getattr(gb.binary, key) + if key not in gb.binary._deprecated + else gb.binary._deprecated[key] + ), (gb.core.operator.BinaryOp, gb.core.operator.ParameterizedBinaryOp), ) ) diff --git a/graphblas/tests/test_descriptor.py b/graphblas/tests/test_descriptor.py index 9209a8055..6ec9df36a 100644 --- a/graphblas/tests/test_descriptor.py +++ b/graphblas/tests/test_descriptor.py @@ -2,8 +2,7 @@ def test_caching(): - """ - Test that building a descriptor is actually caching rather than building + """Test that building a descriptor is actually caching rather than building a new object for each call. """ tocr = descriptor.lookup( diff --git a/graphblas/tests/test_dtype.py b/graphblas/tests/test_dtype.py index 5797dda10..3bd65f2b4 100644 --- a/graphblas/tests/test_dtype.py +++ b/graphblas/tests/test_dtype.py @@ -241,7 +241,7 @@ def test_dtype_to_from_string(): def test_has_complex(): - """Only SuiteSparse has complex (with Windows support in Python after v7.4.3.1)""" + """Only SuiteSparse has complex (with Windows support in Python after v7.4.3.1).""" if not suitesparse: assert not dtypes._supports_complex return diff --git a/graphblas/tests/test_infix.py b/graphblas/tests/test_infix.py index e688086b9..601f282a7 100644 --- a/graphblas/tests/test_infix.py +++ b/graphblas/tests/test_infix.py @@ -346,7 +346,7 @@ def test_inplace_infix(s1, v1, v2, A1, A2): @autocompute def test_infix_expr_value_types(): - """Test bug where `infix_expr._value` was used as MatrixExpression or Matrix""" + """Test bug where `infix_expr._value` was used as MatrixExpression or Matrix.""" from graphblas.core.matrix import MatrixExpression A = Matrix(int, 3, 3) diff --git a/graphblas/unary/numpy.py b/graphblas/unary/numpy.py index 9b742d8bc..0c36565ec 100644 --- a/graphblas/unary/numpy.py +++ b/graphblas/unary/numpy.py @@ -5,6 +5,7 @@ https://numba.readthedocs.io/en/stable/reference/numpysupported.html#math-operations """ + import numpy as _np from .. import _STANDARD_OPERATOR_NAMES diff --git a/graphblas/viz.py b/graphblas/viz.py index f0367e119..b6d5f6ba7 100644 --- a/graphblas/viz.py +++ b/graphblas/viz.py @@ -79,6 +79,7 @@ def spy(M, *, centered=False, show=True, figure=None, axes=None, figsize=None, * See Also -------- datashade + """ mpl, plt, ss = _get_imports(["mpl", "plt", "ss"], "spy") A = to_scipy_sparse(M, "coo") @@ -129,6 +130,7 @@ def datashade(M, agg="count", *, width=None, height=None, opts_kwargs=None, **kw See Also -------- spy + """ np, pd, bk, hv, hp, ds = _get_imports(["np", "pd", "bk", "hv", "hp", "ds"], "datashade") if "df" not in kwargs: diff --git a/pyproject.toml b/pyproject.toml index 3bd4a4310..e9ce9da86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,13 +58,13 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "numpy >=1.21", + "numpy >=1.22", "donfig >=0.6", "pyyaml >=5.4", # These won't be installed by default after 2024.3.0 # Use e.g. "python-graphblas[suitesparse]" or "python-graphblas[default]" instead "suitesparse-graphblas >=7.4.0.0, <9", - "numba >=0.55; python_version<'3.12'", # make optional where numba is not supported + "numba >=0.55; python_version<'3.13'", # make optional where numba is not supported ] [project.urls] @@ -97,9 +97,9 @@ repr = [ ] io = [ "python-graphblas[networkx,scipy]", - "python-graphblas[numba]; python_version<'3.12'", + "python-graphblas[numba]; python_version<'3.13'", "awkward >=1.9", - "sparse >=0.13; python_version<'3.12'", # make optional, b/c sparse needs numba + "sparse >=0.13; python_version<'3.13'", # make optional, b/c sparse needs numba "fast-matrix-market >=1.4.5", ] viz = [ @@ -119,11 +119,11 @@ test = [ ] default = [ "python-graphblas[suitesparse,pandas,scipy]", - "python-graphblas[numba]; python_version<'3.12'", # make optional where numba is not supported + "python-graphblas[numba]; python_version<'3.13'", # make optional where numba is not supported ] all = [ "python-graphblas[default,io,viz,test]", - "python-graphblas[datashade]; python_version<'3.12'", # make optional, b/c datashade needs numba + "python-graphblas[datashade]; python_version<'3.13'", # make optional, b/c datashade needs numba ] [tool.setuptools] @@ -211,6 +211,9 @@ filterwarnings = [ # Python 3.12 introduced this deprecation, which is triggered by pandas 2.1.1 "ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil", + + # Pandas 2.2 warns that pyarrow will become a required dependency in pandas 3.0 + "ignore:\\nPyarrow will become a required dependency of pandas:DeprecationWarning:", ] [tool.coverage.run] @@ -239,6 +242,7 @@ ignore-words-list = "coo,ba" # https://github.com/charliermarsh/ruff/ line-length = 100 target-version = "py39" +[tool.ruff.lint] unfixable = [ "F841" # unused-variable (Note: can leave useless expression) ] @@ -308,23 +312,26 @@ ignore = [ "D103", # Missing docstring in public function "D104", # Missing docstring in public package "D105", # Missing docstring in magic method + "D107", # Missing docstring in `__init__` # "D107", # Missing docstring in `__init__` "D205", # 1 blank line required between summary line and description "D401", # First line of docstring should be in imperative mood: + "D417", # D417 Missing argument description in the docstring for ...: ... # "D417", # Missing argument description in the docstring: "PLE0605", # Invalid format for `__all__`, must be `tuple` or `list` (Note: broken in v0.0.237) # Maybe consider # "SIM300", # Yoda conditions are discouraged, use ... instead (Note: we're not this picky) # "SIM401", # Use dict.get ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) + "B904", # Use `raise from` to specify exception cause (Note: sometimes okay to raise original exception) "TRY004", # Prefer `TypeError` exception for invalid type (Note: good advice, but not worth the nuisance) - "TRY200", # Use `raise from` to specify exception cause (Note: sometimes okay to raise original exception) "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` (Note: no annotations yet) "PERF401", # Use a list comprehension to create a transformed list (Note: poorly implemented atm) # Intentionally ignored "COM812", # Trailing comma missing "D203", # 1 blank line required before class docstring (Note: conflicts with D211, which is preferred) + "D213", # (Note: conflicts with D212, which is preferred) "D400", # First line should end with a period (Note: prefer D415, which also allows "?" and "!") "N801", # Class name ... should use CapWords convention (Note:we have a few exceptions to this) "N802", # Function name ... should be lowercase @@ -374,7 +381,7 @@ ignore = [ "PD", # pandas-vet (Intended for scripts that use pandas, not libraries) ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "graphblas/core/operator/base.py" = ["S102"] # exec is used for UDF "graphblas/core/ss/matrix.py" = ["NPY002"] # numba doesn't support rng generator yet "graphblas/core/ss/vector.py" = ["NPY002"] # numba doesn't support rng generator yet @@ -389,14 +396,14 @@ ignore = [ "docs/*.py" = ["INP001"] # Not a package -[tool.ruff.flake8-builtins] +[tool.ruff.lint.flake8-builtins] builtins-ignorelist = ["copyright", "format", "min", "max"] -[tool.ruff.flake8-pytest-style] +[tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false mark-parentheses = false -[tool.ruff.pydocstyle] +[tool.lint.ruff.pydocstyle] convention = "numpy" [tool.pylint.messages_control] diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 75d6283f0..59fb59d5f 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -3,15 +3,15 @@ # Use, adjust, copy/paste, etc. as necessary to answer your questions. # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. -conda search 'flake8-bugbear[channel=conda-forge]>=23.12.2' +conda search 'flake8-bugbear[channel=conda-forge]>=24.1.17' conda search 'flake8-simplify[channel=conda-forge]>=0.21.0' -conda search 'numpy[channel=conda-forge]>=1.26.2' -conda search 'pandas[channel=conda-forge]>=2.1.4' -conda search 'scipy[channel=conda-forge]>=1.11.4' +conda search 'numpy[channel=conda-forge]>=1.26.3' +conda search 'pandas[channel=conda-forge]>=2.2.0' +conda search 'scipy[channel=conda-forge]>=1.12.0' conda search 'networkx[channel=conda-forge]>=3.2.1' -conda search 'awkward[channel=conda-forge]>=2.5.1' -conda search 'sparse[channel=conda-forge]>=0.14.0' -conda search 'fast_matrix_market[channel=conda-forge]>=1.7.5' -conda search 'numba[channel=conda-forge]>=0.58.1' +conda search 'awkward[channel=conda-forge]>=2.5.2' +conda search 'sparse[channel=conda-forge]>=0.15.1' +conda search 'fast_matrix_market[channel=conda-forge]>=1.7.6' +conda search 'numba[channel=conda-forge]>=0.59.0' conda search 'pyyaml[channel=conda-forge]>=6.0.1' # conda search 'python[channel=conda-forge]>=3.9 *pypy*' From a621468d021b3e484fd749eed43525af67cb444b Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 18 Feb 2024 12:39:32 -0600 Subject: [PATCH 80/87] Adopt SPEC 0 (#537) --- .github/workflows/debug.yml | 2 +- .github/workflows/imports.yml | 4 +--- .github/workflows/publish_pypi.yml | 2 +- .github/workflows/test_and_build.yml | 23 ++++++----------------- .pre-commit-config.yaml | 2 +- docs/getting_started/faq.rst | 5 ++--- graphblas/core/formatting.py | 2 +- graphblas/core/matrix.py | 7 ++++--- graphblas/core/ss/matrix.py | 2 +- graphblas/core/ss/vector.py | 2 +- graphblas/core/utils.py | 2 +- graphblas/core/vector.py | 4 ++-- graphblas/io/_networkx.py | 6 ++++-- graphblas/tests/test_io.py | 2 +- graphblas/tests/test_matrix.py | 14 ++++++++------ graphblas/tests/test_vector.py | 2 +- pyproject.toml | 25 +++++++++++++------------ scripts/check_versions.sh | 2 +- 18 files changed, 50 insertions(+), 58 deletions(-) diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml index c9dc231fe..64d4bc12b 100644 --- a/.github/workflows/debug.yml +++ b/.github/workflows/debug.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [3.9] + pyver: [3.10] testopts: - "--blocking" # - "--non-blocking --record --runslow" diff --git a/.github/workflows/imports.yml b/.github/workflows/imports.yml index 0116f615d..b9e9d4406 100644 --- a/.github/workflows/imports.yml +++ b/.github/workflows/imports.yml @@ -30,7 +30,6 @@ jobs: id: pyver with: contents: | - 3.9 3.10 3.11 3.12 @@ -38,14 +37,13 @@ jobs: 1 1 1 - 1 test_imports: needs: rngs runs-on: ${{ needs.rngs.outputs.os }} # runs-on: ${{ matrix.os }} # strategy: # matrix: - # python-version: ["3.9", "3.10", "3.11", "3.12"] + # python-version: ["3.10", "3.11", "3.12"] # os: ["ubuntu-latest", "macos-latest", "windows-latest"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 366d01e97..b01d2a502 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.10" - name: Install build dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 7086d8779..6c55a0eca 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -105,11 +105,10 @@ jobs: uses: ddradar/choose-random-action@v2.0.2 id: pyver with: - # We should support major Python versions for at least 36-42 months + # We should support major Python versions for at least 36 months as per SPEC 0 # We may be able to support pypy if anybody asks for it # 3.9.16 0_73_pypy contents: | - 3.9 3.10 3.11 3.12 @@ -117,7 +116,6 @@ jobs: 1 1 1 - 1 - name: RNG for source of python-suitesparse-graphblas uses: ddradar/choose-random-action@v2.0.2 id: sourcetype @@ -166,20 +164,13 @@ jobs: # # First let's randomly get versions of dependencies to install. # Consider removing old versions when they become problematic or very old (>=2 years). - nxver=$(python -c 'import random ; print(random.choice(["=2.7", "=2.8", "=3.0", "=3.1", "=3.2", ""]))') - sparsever=$(python -c 'import random ; print(random.choice(["=0.13", "=0.14", "=0.15", ""]))') + nxver=$(python -c 'import random ; print(random.choice(["=2.8", "=3.0", "=3.1", "=3.2", ""]))') + sparsever=$(python -c 'import random ; print(random.choice(["=0.14", "=0.15", ""]))') # Randomly choosing versions of dependencies based on Python version works surprisingly well... - if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.9') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", "=1.12", ""]))') - pdver=$(python -c 'import random ; print(random.choice(["=1.2", "=1.3", "=1.4", "=1.5", "=2.0", "=2.1", "=2.2", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') - fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') - yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') - elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.22", "=1.23", "=1.24", "=1.25", "=1.26", ""]))') + if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then + npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", "=1.26", ""]))') spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", "=1.12", ""]))') - pdver=$(python -c 'import random ; print(random.choice(["=1.3", "=1.4", "=1.5", "=2.0", "=2.1", "=2.2", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", "=2.1", "=2.2", ""]))') akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') @@ -237,8 +228,6 @@ jobs: numbaver=$(python -c 'import random ; print(random.choice(["=0.58", "=0.59", ""]))') elif [[ ${npver} == "=1.24" || ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]] ; then numbaver=$(python -c 'import random ; print(random.choice(["=0.57", "=0.58", "=0.59", ""]))') - elif [[ ${npver} == "=1.21" ]] ; then - numbaver=$(python -c 'import random ; print(random.choice(["=0.55", "=0.56", "=0.57", ""]))') else numbaver=$(python -c 'import random ; print(random.choice(["=0.56", "=0.57", "=0.58", "=0.59", ""]))') fi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fa563b639..12e5dd865 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: rev: v3.15.0 hooks: - id: pyupgrade - args: [--py39-plus] + args: [--py310-plus] - repo: https://github.com/MarcoGorelli/auto-walrus rev: v0.2.2 hooks: diff --git a/docs/getting_started/faq.rst b/docs/getting_started/faq.rst index 1e60a1bd4..2609e7929 100644 --- a/docs/getting_started/faq.rst +++ b/docs/getting_started/faq.rst @@ -101,11 +101,10 @@ Bugs are not considered deprecations and may be fixed immediately. What is the version support policy? +++++++++++++++++++++++++++++++++++ -Each major Python version will be supported for at least 36 to 42 months. +Each major Python version will be supported for at least 36. Major dependencies such as NumPy should be supported for at least 24 months. -This is motivated by these guidelines: +We aim to follow SPEC 0: -- https://numpy.org/neps/nep-0029-deprecation_policy.html - https://scientific-python.org/specs/spec-0000/ ``python-graphblas`` itself follows a "single trunk" versioning strategy. diff --git a/graphblas/core/formatting.py b/graphblas/core/formatting.py index aefb87f94..0b6252101 100644 --- a/graphblas/core/formatting.py +++ b/graphblas/core/formatting.py @@ -630,7 +630,7 @@ def create_header(type_name, keys, vals, *, lower_border=False, name="", quote=T name = f'"{name}"' key_text = [] val_text = [] - for key, val in zip(keys, vals): + for key, val in zip(keys, vals, strict=True): width = max(len(key), len(val)) + 2 key_text.append(key.rjust(width)) val_text.append(val.rjust(width)) diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index 359477d4c..e28e92a65 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -360,7 +360,7 @@ def __contains__(self, index): def __iter__(self): """Iterate over (row, col) indices which are present in the matrix.""" rows, columns, _ = self.to_coo(values=False) - return zip(rows.flat, columns.flat) + return zip(rows.flat, columns.flat, strict=True) def __sizeof__(self): if backend == "suitesparse": @@ -961,7 +961,7 @@ def from_edgelist( rows = edgelist[:, 0] cols = edgelist[:, 1] else: - unzipped = list(zip(*edgelist)) + unzipped = list(zip(*edgelist, strict=True)) if len(unzipped) == 2: rows, cols = unzipped elif len(unzipped) == 3: @@ -1826,10 +1826,11 @@ def to_dicts(self, order="rowwise"): cols = cols.tolist() values = values.tolist() return { - row: dict(zip(cols[start:stop], values[start:stop])) + row: dict(zip(cols[start:stop], values[start:stop], strict=True)) for row, (start, stop) in zip( compressed_rows.tolist(), np.lib.stride_tricks.sliding_window_view(indptr, 2).tolist(), + strict=True, ) } # Alternative diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index 0489cb5d6..0a08c50e2 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -58,7 +58,7 @@ def head(matrix, n=10, dtype=None, *, sort=False): dtype = matrix.dtype else: dtype = lookup_dtype(dtype) - rows, cols, vals = zip(*itertools.islice(matrix.ss.iteritems(), n)) + rows, cols, vals = zip(*itertools.islice(matrix.ss.iteritems(), n), strict=True) return np.array(rows, np.uint64), np.array(cols, np.uint64), np.array(vals, dtype.np_type) diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py index d1f7a5bcb..a21d54de9 100644 --- a/graphblas/core/ss/vector.py +++ b/graphblas/core/ss/vector.py @@ -43,7 +43,7 @@ def head(vector, n=10, dtype=None, *, sort=False): dtype = vector.dtype else: dtype = lookup_dtype(dtype) - indices, vals = zip(*itertools.islice(vector.ss.iteritems(), n)) + indices, vals = zip(*itertools.islice(vector.ss.iteritems(), n), strict=True) return np.array(indices, np.uint64), np.array(vals, dtype.np_type) diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py index 184272124..6e91edd1b 100644 --- a/graphblas/core/utils.py +++ b/graphblas/core/utils.py @@ -202,7 +202,7 @@ def normalize_chunks(chunks, shape): f"chunks argument must be of length {len(shape)} (one for each dimension of a {typ})" ) chunksizes = [] - for size, chunk in zip(shape, chunks): + for size, chunk in zip(shape, chunks, strict=True): if chunk is None: cur_chunks = [size] elif (c := maybe_integral(chunk)) is not None: diff --git a/graphblas/core/vector.py b/graphblas/core/vector.py index 863d186ec..8bac4198e 100644 --- a/graphblas/core/vector.py +++ b/graphblas/core/vector.py @@ -772,7 +772,7 @@ def from_pairs(cls, pairs, dtype=None, *, size=None, dup_op=None, name=None): """ if isinstance(pairs, np.ndarray): raise TypeError("pairs as NumPy array is not supported; use `Vector.from_coo` instead") - unzipped = list(zip(*pairs)) + unzipped = list(zip(*pairs, strict=True)) if len(unzipped) == 2: indices, values = unzipped elif not unzipped: @@ -2105,7 +2105,7 @@ def to_dict(self): """ indices, values = self.to_coo(sort=False) - return dict(zip(indices.tolist(), values.tolist())) + return dict(zip(indices.tolist(), values.tolist(), strict=True)) if backend == "suitesparse": diff --git a/graphblas/io/_networkx.py b/graphblas/io/_networkx.py index dab04c82d..8cf84e576 100644 --- a/graphblas/io/_networkx.py +++ b/graphblas/io/_networkx.py @@ -55,7 +55,9 @@ def to_networkx(m, edge_attribute="weight"): cols = cols.tolist() G = nx.DiGraph() if edge_attribute is None: - G.add_edges_from(zip(rows, cols)) + G.add_edges_from(zip(rows, cols, strict=True)) else: - G.add_weighted_edges_from(zip(rows, cols, vals.tolist()), weight=edge_attribute) + G.add_weighted_edges_from( + zip(rows, cols, vals.tolist(), strict=True), weight=edge_attribute + ) return G diff --git a/graphblas/tests/test_io.py b/graphblas/tests/test_io.py index 109c90a2c..7e786f0da 100644 --- a/graphblas/tests/test_io.py +++ b/graphblas/tests/test_io.py @@ -146,7 +146,7 @@ def test_matrix_to_from_networkx(): M = gb.io.from_networkx(G, nodelist=range(7)) if suitesparse: assert M.ss.is_iso - rows, cols = zip(*edges) + rows, cols = zip(*edges, strict=True) expected = gb.Matrix.from_coo(rows, cols, 1) assert expected.isequal(M) # Test empty diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 06e4ee868..63561930b 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -2603,12 +2603,14 @@ def test_iter(A): zip( [3, 0, 3, 5, 6, 0, 6, 1, 6, 2, 4, 1], [0, 1, 2, 2, 2, 3, 3, 4, 4, 5, 5, 6], + strict=True, ) ) assert set(A.T) == set( zip( [0, 1, 2, 2, 2, 3, 3, 4, 4, 5, 5, 6], [3, 0, 3, 5, 6, 0, 6, 1, 6, 2, 4, 1], + strict=True, ) ) @@ -2731,8 +2733,8 @@ def test_ss_split(A): for results in [A.ss.split([4, 3]), A.ss.split([[4, None], 3], name="split")]: row_boundaries = [0, 4, 7] col_boundaries = [0, 3, 6, 7] - for i, (i1, i2) in enumerate(zip(row_boundaries[:-1], row_boundaries[1:])): - for j, (j1, j2) in enumerate(zip(col_boundaries[:-1], col_boundaries[1:])): + for i, (i1, i2) in enumerate(itertools.pairwise(row_boundaries)): + for j, (j1, j2) in enumerate(itertools.pairwise(col_boundaries)): expected = A[i1:i2, j1:j2].new() assert expected.isequal(results[i][j]) with pytest.raises(DimensionMismatch): @@ -3068,7 +3070,7 @@ def test_ss_flatten(A): [3, 2, 3, 1, 5, 3, 7, 8, 3, 1, 7, 4], ] # row-wise - indices = [row * A.ncols + col for row, col in zip(data[0], data[1])] + indices = [row * A.ncols + col for row, col in zip(data[0], data[1], strict=True)] expected = Vector.from_coo(indices, data[2], size=A.nrows * A.ncols) for fmt in ["csr", "hypercsr", "bitmapr"]: B = Matrix.ss.import_any(**A.ss.export(format=fmt)) @@ -3087,7 +3089,7 @@ def test_ss_flatten(A): assert C.isequal(B) # column-wise - indices = [col * A.nrows + row for row, col in zip(data[0], data[1])] + indices = [col * A.nrows + row for row, col in zip(data[0], data[1], strict=True)] expected = Vector.from_coo(indices, data[2], size=A.nrows * A.ncols) for fmt in ["csc", "hypercsc", "bitmapc"]: B = Matrix.ss.import_any(**A.ss.export(format=fmt)) @@ -3626,9 +3628,9 @@ def test_ss_iteration(A): assert not list(B.ss.itervalues()) assert not list(B.ss.iteritems()) rows, columns, values = A.to_coo() - assert sorted(zip(rows, columns)) == sorted(A.ss.iterkeys()) + assert sorted(zip(rows, columns, strict=True)) == sorted(A.ss.iterkeys()) assert sorted(values) == sorted(A.ss.itervalues()) - assert sorted(zip(rows, columns, values)) == sorted(A.ss.iteritems()) + assert sorted(zip(rows, columns, values, strict=True)) == sorted(A.ss.iteritems()) N = rows.size A = Matrix.ss.import_bitmapr(**A.ss.export("bitmapr")) diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index 77f608969..df1f5c86e 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -2270,7 +2270,7 @@ def test_ss_iteration(v): # This is what I would expect assert sorted(indices) == sorted(v.ss.iterkeys()) assert sorted(values) == sorted(v.ss.itervalues()) - assert sorted(zip(indices, values)) == sorted(v.ss.iteritems()) + assert sorted(zip(indices, values, strict=True)) == sorted(v.ss.iteritems()) N = indices.size v = Vector.ss.import_bitmap(**v.ss.export("bitmap")) diff --git a/pyproject.toml b/pyproject.toml index e9ce9da86..a3447b751 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ name = "python-graphblas" dynamic = ["version"] description = "Python library for GraphBLAS: high-performance sparse linear algebra for scalable graph analytics" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = {file = "LICENSE"} authors = [ {name = "Erik Welch", email = "erik.n.welch@gmail.com"}, @@ -44,7 +44,6 @@ classifiers = [ "Operating System :: Microsoft :: Windows", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -58,7 +57,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "numpy >=1.22", + "numpy >=1.23", "donfig >=0.6", "pyyaml >=5.4", # These won't be installed by default after 2024.3.0 @@ -84,7 +83,7 @@ numba = [ "numba >=0.55", ] pandas = [ - "pandas >=1.2", + "pandas >=1.5", ] scipy = [ "scipy >=1.9", @@ -99,17 +98,17 @@ io = [ "python-graphblas[networkx,scipy]", "python-graphblas[numba]; python_version<'3.13'", "awkward >=1.9", - "sparse >=0.13; python_version<'3.13'", # make optional, b/c sparse needs numba + "sparse >=0.14; python_version<'3.13'", # make optional, b/c sparse needs numba "fast-matrix-market >=1.4.5", ] viz = [ "python-graphblas[networkx,scipy]", - "matplotlib >=3.5", + "matplotlib >=3.6", ] datashade = [ # datashade requires numba "python-graphblas[numba,pandas,scipy]", - "datashader >=0.12", - "hvplot >=0.7", + "datashader >=0.14", + "hvplot >=0.8", ] test = [ "python-graphblas[suitesparse,pandas,scipy]", @@ -157,7 +156,7 @@ dirty_template = "{tag}+{ccount}.g{sha}.dirty" [tool.black] line-length = 100 -target-version = ["py39", "py310", "py311", "py312"] +target-version = ["py310", "py311", "py312"] [tool.isort] sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] @@ -241,10 +240,11 @@ ignore-words-list = "coo,ba" [tool.ruff] # https://github.com/charliermarsh/ruff/ line-length = 100 -target-version = "py39" +target-version = "py310" [tool.ruff.lint] unfixable = [ - "F841" # unused-variable (Note: can leave useless expression) + "F841", # unused-variable (Note: can leave useless expression) + "B905", # zip-without-explicit-strict (Note: prefer `zip(x, y, strict=True)`) ] select = [ # Have we enabled too many checks that they'll become a nuisance? We'll see... @@ -360,6 +360,7 @@ ignore = [ "SIM105", # Use contextlib.suppress(...) instead of try-except-pass (Note: try-except-pass is much faster) "SIM108", # Use ternary operator ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) "TRY003", # Avoid specifying long messages outside the exception class (Note: why?) + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` (Note: using `|` is slower atm) # Ignored categories "C90", # mccabe (Too strict, but maybe we should make things less complex) @@ -409,7 +410,7 @@ convention = "numpy" [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.9" +py-version = "3.10" enable = ["I"] disable = [ # Error diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 59fb59d5f..893f09539 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -14,4 +14,4 @@ conda search 'sparse[channel=conda-forge]>=0.15.1' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.6' conda search 'numba[channel=conda-forge]>=0.59.0' conda search 'pyyaml[channel=conda-forge]>=6.0.1' -# conda search 'python[channel=conda-forge]>=3.9 *pypy*' +# conda search 'python[channel=conda-forge]>=3.10 *pypy*' From 27b23e414a9a73daa9c3dda9698e227562d9a18c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Feb 2024 12:40:58 -0600 Subject: [PATCH 81/87] Bump pre-commit/action from 3.0.0 to 3.0.1 (#538) Bumps [pre-commit/action](https://github.com/pre-commit/action) from 3.0.0 to 3.0.1. - [Release notes](https://github.com/pre-commit/action/releases) - [Commits](https://github.com/pre-commit/action/compare/v3.0.0...v3.0.1) --- updated-dependencies: - dependency-name: pre-commit/action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 97bb856f6..d0182dd0c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,4 +20,4 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.10" - - uses: pre-commit/action@v3.0.0 + - uses: pre-commit/action@v3.0.1 From 3c389f8434de146c8391a8b7c46f075eb304463a Mon Sep 17 00:00:00 2001 From: Sophia Lockton <69818937+slockton24@users.noreply.github.com> Date: Wed, 23 Oct 2024 06:30:10 -0400 Subject: [PATCH 82/87] Update README.md (#548) Include quotes when installing with pip --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index de942f88e..96908989c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ $ conda install -c conda-forge python-graphblas ``` or pip: ``` -$ pip install python-graphblas[default] +$ pip install 'python-graphblas[default]' ``` This will also install the [SuiteSparse:GraphBLAS](https://github.com/DrTimothyAldenDavis/GraphBLAS) compiled C library. We currently support the [GraphBLAS C API 2.0 specification](https://graphblas.org/docs/GraphBLAS_API_C_v2.0.0.pdf). From cf73b37d95c5497fe4b7d9bf17da82d9ed808f7b Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 17 Feb 2025 09:40:04 -0600 Subject: [PATCH 83/87] Update to support latest versions, including NumPy 2 (#546) Support latest versions: Python 3.13, numpy 2, numba 0.61, SS:GB 9.3.1, etc. Also, add pre-commit hooks: prettier, taplo, actionlint, check-jsonschema, yamllint, zizmore, meta --------- Co-authored-by: Jim Kitchen --- .github/dependabot.yml | 6 +- .github/workflows/debug.yml | 3 +- .github/workflows/imports.yml | 11 +- .github/workflows/lint.yml | 3 + .github/workflows/publish_pypi.yml | 5 +- .github/workflows/test_and_build.yml | 193 +++++-- .github/zizmor.yml | 16 + .pre-commit-config.yaml | 92 ++-- .yamllint.yaml | 6 + CODE_OF_CONDUCT.md | 14 +- README.md | 39 ++ binder/environment.yml | 18 +- docs/_static/custom.css | 54 +- docs/_static/matrix.css | 118 ++--- docs/env.yml | 40 +- docs/user_guide/operations.rst | 2 +- environment.yml | 195 ++++---- graphblas/core/base.py | 2 +- graphblas/core/dtypes.py | 19 +- graphblas/core/infix.py | 1 + graphblas/core/matrix.py | 4 + graphblas/core/operator/base.py | 3 +- graphblas/core/scalar.py | 2 +- graphblas/core/ss/__init__.py | 4 +- graphblas/core/ss/config.py | 2 +- graphblas/core/ss/matrix.py | 39 +- graphblas/core/ss/vector.py | 33 +- graphblas/core/utils.py | 18 +- graphblas/exceptions.py | 14 +- graphblas/tests/conftest.py | 8 + graphblas/tests/test_dtype.py | 4 + graphblas/tests/test_matrix.py | 9 +- graphblas/tests/test_numpyops.py | 10 + graphblas/tests/test_scalar.py | 6 +- graphblas/tests/test_ssjit.py | 52 +- graphblas/tests/test_vector.py | 19 +- pyproject.toml | 719 ++++++++++++++------------- scripts/check_versions.sh | 18 +- 38 files changed, 1092 insertions(+), 709 deletions(-) create mode 100644 .github/zizmor.yml create mode 100644 .yamllint.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b18fd2935..5ace4600a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - - package-ecosystem: 'github-actions' - directory: '/' + - package-ecosystem: "github-actions" + directory: "/" schedule: - interval: 'weekly' + interval: "weekly" diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml index 64d4bc12b..6c2b202b1 100644 --- a/.github/workflows/debug.yml +++ b/.github/workflows/debug.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: debug_enabled: - description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' + description: "Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)" required: false default: false @@ -29,6 +29,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Setup conda env run: | source "$CONDA/etc/profile.d/conda.sh" diff --git a/.github/workflows/imports.yml b/.github/workflows/imports.yml index b9e9d4406..e24d0d4db 100644 --- a/.github/workflows/imports.yml +++ b/.github/workflows/imports.yml @@ -14,7 +14,7 @@ jobs: pyver: ${{ steps.pyver.outputs.selected }} steps: - name: RNG for os - uses: ddradar/choose-random-action@v2.0.2 + uses: ddradar/choose-random-action@v3.0.0 id: os with: contents: | @@ -26,27 +26,32 @@ jobs: 1 1 - name: RNG for Python version - uses: ddradar/choose-random-action@v2.0.2 + uses: ddradar/choose-random-action@v3.0.0 id: pyver with: contents: | 3.10 3.11 3.12 + 3.13 weights: | 1 1 1 + 1 test_imports: needs: rngs runs-on: ${{ needs.rngs.outputs.os }} # runs-on: ${{ matrix.os }} # strategy: # matrix: - # python-version: ["3.10", "3.11", "3.12"] + # python-version: ["3.10", "3.11", "3.12", "3.13"] # os: ["ubuntu-latest", "macos-latest", "windows-latest"] steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false - uses: actions/setup-python@v5 with: python-version: ${{ needs.rngs.outputs.pyver }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d0182dd0c..655a576e5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,6 +17,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false - uses: actions/setup-python@v5 with: python-version: "3.10" diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index b01d2a502..a9ad0be8c 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -3,7 +3,7 @@ name: Publish to PyPI on: push: tags: - - '20*' + - "20*" jobs: build_and_deploy: @@ -17,6 +17,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Set up Python uses: actions/setup-python@v5 with: @@ -35,7 +36,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.11 + uses: pypa/gh-action-pypi-publish@v1.9.0 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 6c55a0eca..7a8f06900 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -50,7 +50,7 @@ jobs: backend: ${{ steps.backend.outputs.selected }} steps: - name: RNG for mapnumpy - uses: ddradar/choose-random-action@v2.0.2 + uses: ddradar/choose-random-action@v3.0.0 id: mapnumpy with: contents: | @@ -64,7 +64,7 @@ jobs: 1 1 - name: RNG for backend - uses: ddradar/choose-random-action@v2.0.2 + uses: ddradar/choose-random-action@v3.0.0 id: backend with: contents: | @@ -84,14 +84,15 @@ jobs: run: shell: bash -l {0} strategy: - # To "stress test" in CI, set `fail-fast` to `false` and perhaps add more items to `matrix.slowtask` - fail-fast: true + # To "stress test" in CI, set `fail-fast` to `false` and use `repeat` in matrix below + fail-fast: false # The build matrix is [os]x[slowtask] and then randomly chooses [pyver] and [sourcetype]. # This should ensure we'll have full code coverage (i.e., no chance of getting unlucky), # since we need to run all slow tests on Windows and non-Windoes OSes. matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] slowtask: ["pytest_normal", "pytest_bizarro", "notebooks"] + # repeat: [1, 2, 3] # For stress testing env: # Wheels on OS X come with an OpenMP that conflicts with OpenMP from conda-forge. # Setting this is a workaround. @@ -101,8 +102,9 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: RNG for Python version - uses: ddradar/choose-random-action@v2.0.2 + uses: ddradar/choose-random-action@v3.0.0 id: pyver with: # We should support major Python versions for at least 36 months as per SPEC 0 @@ -112,12 +114,14 @@ jobs: 3.10 3.11 3.12 + 3.13 weights: | 1 1 1 + 1 - name: RNG for source of python-suitesparse-graphblas - uses: ddradar/choose-random-action@v2.0.2 + uses: ddradar/choose-random-action@v3.0.0 id: sourcetype with: # Weights must be natural numbers, so set weights to very large to skip one @@ -132,28 +136,14 @@ jobs: 1 1 1 - - name: Setup mamba - uses: conda-incubator/setup-miniconda@v3 - id: setup_mamba - continue-on-error: true - with: - miniforge-variant: Mambaforge - miniforge-version: latest - use-mamba: true - python-version: ${{ steps.pyver.outputs.selected }} - channels: conda-forge,${{ contains(steps.pyver.outputs.selected, 'pypy') && 'defaults' || 'nodefaults' }} - channel-priority: ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'flexible' || 'strict' }} - activate-environment: graphblas - auto-activate-base: false - name: Setup conda uses: conda-incubator/setup-miniconda@v3 id: setup_conda - if: steps.setup_mamba.outcome == 'failure' - continue-on-error: false with: auto-update-conda: true python-version: ${{ steps.pyver.outputs.selected }} - channels: conda-forge,${{ contains(steps.pyver.outputs.selected, 'pypy') && 'defaults' || 'nodefaults' }} + channels: conda-forge${{ contains(steps.pyver.outputs.selected, 'pypy') && ',defaults' || '' }} + conda-remove-defaults: ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'false' || 'true' }} channel-priority: ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'flexible' || 'strict' }} activate-environment: graphblas auto-activate-base: false @@ -164,81 +154,154 @@ jobs: # # First let's randomly get versions of dependencies to install. # Consider removing old versions when they become problematic or very old (>=2 years). - nxver=$(python -c 'import random ; print(random.choice(["=2.8", "=3.0", "=3.1", "=3.2", ""]))') - sparsever=$(python -c 'import random ; print(random.choice(["=0.14", "=0.15", ""]))') + # Randomly choosing versions of dependencies based on Python version works surprisingly well... if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.10') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", "=1.26", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", "=1.12", ""]))') + nxver=$(python -c 'import random ; print(random.choice(["=2.8", "=3.0", "=3.1", "=3.2", "=3.3", "=3.4", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.24", "=1.25", "=1.26", "=2.0", "=2.1", "=2.2", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", "=1.12", "=1.13", "=1.14", "=1.15", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", "=2.1", "=2.2", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", "=2.6", "=2.7", ""]))') fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') + sparsever=$(python -c 'import random ; print(random.choice(["=0.14", "=0.15", ""]))') elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]]; then - npver=$(python -c 'import random ; print(random.choice(["=1.23", "=1.24", "=1.25", "=1.26", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", "=1.12", ""]))') + nxver=$(python -c 'import random ; print(random.choice(["=2.8", "=3.0", "=3.1", "=3.2", "=3.3", "=3.4", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.24", "=1.25", "=1.26", "=2.0", "=2.1", "=2.2", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.9", "=1.10", "=1.11", "=1.12", "=1.13", "=1.14", "=1.15", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=1.5", "=2.0", "=2.1", "=2.2", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=1.10", "=2.0", "=2.1", "=2.2", "=2.3", "=2.4", "=2.5", "=2.6", "=2.7", ""]))') fmmver=$(python -c 'import random ; print(random.choice(["=1.4", "=1.5", "=1.6", "=1.7", ""]))') yamlver=$(python -c 'import random ; print(random.choice(["=5.4", "=6.0", ""]))') - else # Python 3.12 - npver=$(python -c 'import random ; print(random.choice(["=1.26", ""]))') - spver=$(python -c 'import random ; print(random.choice(["=1.11", "=1.12", ""]))') + sparsever=$(python -c 'import random ; print(random.choice(["=0.14", "=0.15", ""]))') + elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]]; then + nxver=$(python -c 'import random ; print(random.choice(["=3.2", "=3.3", "=3.4", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=1.26", "=2.0", "=2.1", "=2.2", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.11", "=1.12", "=1.13", "=1.14", "=1.15", ""]))') pdver=$(python -c 'import random ; print(random.choice(["=2.1", "=2.2", ""]))') - akver=$(python -c 'import random ; print(random.choice(["=2.4", "=2.5", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=2.4", "=2.5", "=2.6", "=2.7", ""]))') fmmver=$(python -c 'import random ; print(random.choice(["=1.7", ""]))') yamlver=$(python -c 'import random ; print(random.choice(["=6.0", ""]))') + sparsever=$(python -c 'import random ; print(random.choice(["=0.14", "=0.15", ""]))') + else # Python 3.13 + nxver=$(python -c 'import random ; print(random.choice(["=3.4", ""]))') + npver=$(python -c 'import random ; print(random.choice(["=2.1", "=2.2", ""]))') + spver=$(python -c 'import random ; print(random.choice(["=1.14", "=1.15", ""]))') + pdver=$(python -c 'import random ; print(random.choice(["=2.2", ""]))') + akver=$(python -c 'import random ; print(random.choice(["=2.7", ""]))') + fmmver=NA # Not yet supported + yamlver=$(python -c 'import random ; print(random.choice(["=6.0", ""]))') + sparsever=NA # Not yet supported fi + # But there may be edge cases of incompatibility we need to handle (more handled below) - if [[ ${{ steps.sourcetype.outputs.selected }} == "source" || ${{ steps.sourcetype.outputs.selected }} == "upstream" ]]; then + if [[ ${{ steps.sourcetype.outputs.selected }} == "source" ]]; then # TODO: there are currently issues with some numpy versions when - # installing python-suitesparse-grphblas from source or upstream. + # installing python-suitesparse-grphblas from source. npver="" spver="" pdver="" fi + # We can have a tight coupling with python-suitesparse-graphblas. # That is, we don't need to support versions of it that are two years old. # But, it's still useful for us to test with different versions! psg="" if [[ ${{ steps.sourcetype.outputs.selected}} == "upstream" ]] ; then + # Upstream needs to build with numpy 2 psgver="" + if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.13') }} == true ]]; then + npver=$(python -c 'import random ; print(random.choice(["=2.1", "=2.2", ""]))') + else + npver=$(python -c 'import random ; print(random.choice(["=2.0", "=2.1", "=2.2", ""]))') + fi + elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.13') }} == true ]] ; then + if [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then + psgver=$(python -c 'import random ; print(random.choice(["=9.3.1.0", ""]))') + psg=python-suitesparse-graphblas${psgver} + else + psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", ""]))') + fi elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]] ; then if [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["=8.2.0.1", "=8.2.1.0", ""]))') + if [[ $npver == =1.* ]] ; then + psgver=$(python -c 'import random ; print(random.choice(["=8.2.0.1", "=8.2.1.0"]))') + else + psgver=$(python -c 'import random ; print(random.choice(["=9.3.1.0", ""]))') + fi psg=python-suitesparse-graphblas${psgver} else - psgver=$(python -c 'import random ; print(random.choice(["==8.2.0.1", "==8.2.1.0", ""]))') + if [[ $npver == =1.* ]] ; then + psgver=$(python -c 'import random ; print(random.choice(["==8.2.0.1", "==8.2.1.0"]))') + else + psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", ""]))') + fi fi + # python-suitsparse-graphblas support is the same for Python 3.10 and 3.11 elif [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2", "=8.0.2.1", "=8.2.0.1", "=8.2.1.0", ""]))') + if [[ $npver == =1.* ]] ; then + psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2", "=8.0.2.1", "=8.2.0.1", "=8.2.1.0"]))') + else + psgver=$(python -c 'import random ; print(random.choice(["=9.3.1.0", ""]))') + fi psg=python-suitesparse-graphblas${psgver} elif [[ ${{ steps.sourcetype.outputs.selected}} == "wheel" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2", "==8.0.2.1", "==8.2.0.1", "==8.2.1.0", ""]))') + if [[ $npver == =1.* ]] ; then + psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2", "==8.0.2.1", "==8.2.0.1", "==8.2.1.0"]))') + else + psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", ""]))') + fi elif [[ ${{ steps.sourcetype.outputs.selected}} == "source" ]] ; then # These should be exact versions - psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", "==8.0.2.1", "==8.2.0.1", "==8.2.1.0", ""]))') + if [[ $npver == =1.* ]] ; then + psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", "==8.0.2.1", "==8.2.0.1", "==8.2.1.0"]))') + else + psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", ""]))') + fi fi + + # Numba is tightly coupled to numpy versions if [[ ${npver} == "=1.26" ]] ; then - numbaver=$(python -c 'import random ; print(random.choice(["=0.58", "=0.59", ""]))') + numbaver=$(python -c 'import random ; print(random.choice(["=0.58", "=0.59", "=0.60", "=0.61", ""]))') if [[ ${spver} == "=1.9" ]] ; then spver=$(python -c 'import random ; print(random.choice(["=1.10", "=1.11", ""]))') fi elif [[ ${npver} == "=1.25" ]] ; then - numbaver=$(python -c 'import random ; print(random.choice(["=0.58", "=0.59", ""]))') + numbaver=$(python -c 'import random ; print(random.choice(["=0.58", "=0.59", "=0.60", "=0.61", ""]))') elif [[ ${npver} == "=1.24" || ${{ startsWith(steps.pyver.outputs.selected, '3.11') }} == true ]] ; then - numbaver=$(python -c 'import random ; print(random.choice(["=0.57", "=0.58", "=0.59", ""]))') + numbaver=$(python -c 'import random ; print(random.choice(["=0.57", "=0.58", "=0.59", "=0.60", "=0.61", ""]))') else - numbaver=$(python -c 'import random ; print(random.choice(["=0.56", "=0.57", "=0.58", "=0.59", ""]))') + numbaver="" fi - # Only numba 0.59 support Python 3.12 + # Only numba >=0.59 support Python 3.12 if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]] ; then - numbaver=$(python -c 'import random ; print(random.choice(["=0.59", ""]))') + numbaver=$(python -c 'import random ; print(random.choice(["=0.59", "=0.60", "=0.61", ""]))') + fi + + # Handle NumPy 2 + if [[ $npver != =1.* ]] ; then + # Only pandas >=2.2.2 supports NumPy 2 + pdver=$(python -c 'import random ; print(random.choice(["=2.2", ""]))') + + # Only awkward >=2.6.3 supports NumPy 2 + if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.13') }} == true ]] ; then + akver=$(python -c 'import random ; print(random.choice(["=2.7", ""]))') + else + akver=$(python -c 'import random ; print(random.choice(["=2.6", "=2.7", ""]))') + fi + + # Only scipy >=1.13 supports NumPy 2 + if [[ $spver == "=1.9" || $spver == "=1.10" || $spver == "=1.11" || $spver == "=1.12" ]] ; then + spver="=1.13" + fi fi + fmm=fast_matrix_market${fmmver} awkward=awkward${akver} + + # Don't install numba and sparse for some versions if [[ ${{ contains(steps.pyver.outputs.selected, 'pypy') || - startsWith(steps.pyver.outputs.selected, '3.13') }} == true || + startsWith(steps.pyver.outputs.selected, '3.14') }} == true || ( ${{ matrix.slowtask != 'notebooks'}} == true && ( ( ${{ matrix.os == 'windows-latest' }} == true && $(python -c 'import random ; print(random.random() < .2)') == True ) || ( ${{ matrix.os == 'windows-latest' }} == false && $(python -c 'import random ; print(random.random() < .4)') == True ))) ]] @@ -260,7 +323,7 @@ jobs: pdver="" yamlver="" fi - elif [[ ${npver} == "=2.0" ]] ; then + elif [[ ${npver} == =2.* ]] ; then # Don't install numba for unsupported versions of numpy numba="" numbaver=NA @@ -270,18 +333,34 @@ jobs: numba=numba${numbaver} sparse=sparse${sparsever} fi + + # sparse does not yet support Python 3.13 + if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.13') }} == true ]] ; then + sparse="" + sparsever=NA + fi + # fast_matrix_market does not yet support Python 3.13 or osx-arm64 + if [[ ${{ startsWith(steps.pyver.outputs.selected, '3.13') }} == true || + ${{ matrix.os == 'macos-latest' }} == true ]] + then + fmm="" + fmmver=NA + fi + echo "versions: np${npver} sp${spver} pd${pdver} ak${akver} nx${nxver} numba${numbaver} yaml${yamlver} sparse${sparsever} psg${psgver}" set -x # echo on - $(command -v mamba || command -v conda) install packaging pytest coverage pytest-randomly cffi donfig tomli c-compiler make \ + $(command -v mamba || command -v conda) install -c nodefaults \ + packaging pytest coverage pytest-randomly cffi donfig tomli c-compiler make \ pyyaml${yamlver} ${sparse} pandas${pdver} scipy${spver} numpy${npver} ${awkward} \ networkx${nxver} ${numba} ${fmm} ${psg} \ ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ ${{ matrix.slowtask == 'notebooks' && 'matplotlib nbconvert jupyter "ipython>=7" drawsvg' || '' }} \ ${{ steps.sourcetype.outputs.selected == 'upstream' && 'cython' || '' }} \ - ${{ steps.sourcetype.outputs.selected != 'wheel' && '"graphblas>=7.4"' || '' }} \ + ${{ steps.sourcetype.outputs.selected != 'wheel' && '"graphblas>=7.4,<9.4"' || '' }} \ ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'pypy' || '' }} \ - ${{ matrix.os == 'windows-latest' && 'cmake' || 'm4' }} + ${{ matrix.os == 'windows-latest' && 'cmake' || 'm4' }} \ + # ${{ matrix.os != 'windows-latest' && 'pytest-forked' || '' }} # to investigate crashes - name: Build extension module run: | if [[ ${{ steps.sourcetype.outputs.selected }} == "wheel" ]]; then @@ -307,7 +386,11 @@ jobs: # Don't use our conftest.py ; allow `test_print_jit_config` to fail if it doesn't exist (cd .. pytest --pyargs suitesparse_graphblas -s -k test_print_jit_config || true - pytest -v --pyargs suitesparse_graphblas) + pytest -v --pyargs suitesparse_graphblas || true) + - name: Print platform and sysconfig variables + run: | + python -c "import platform ; print(platform.uname())" + python -c "import pprint, sysconfig ; pprint.pprint(sysconfig.get_config_vars())" - name: Unit tests run: | A=${{ needs.rngs.outputs.mapnumpy == 'A' || '' }} ; B=${{ needs.rngs.outputs.mapnumpy == 'B' || '' }} @@ -336,6 +419,8 @@ jobs: if [[ $H && $bizarro ]] ; then if [[ $macos ]] ; then echo " $suitesparse" ; elif [[ $windows ]] ; then echo " $vanilla" ; fi ; fi) echo ${args} set -x # echo on + # pytest ${{ matrix.os != 'windows-latest' && '--forked' || '' }} \ # to investigate crashes + # --color=yes --randomly -v -s ${args} \ coverage run -m pytest --color=yes --randomly -v ${args} \ ${{ matrix.slowtask == 'pytest_normal' && '--runslow' || '' }} - name: Unit tests (bizarro scalars) @@ -372,6 +457,8 @@ jobs: if [[ $H && $bizarro ]] ; then if [[ $macos ]] ; then echo " $vanilla" ; elif [[ $windows ]] ; then echo " $suitesparse" ; fi ; fi) echo ${args} set -x # echo on + # pytest ${{ matrix.os != 'windows-latest' && '--forked' || '' }} \ # to investigate crashes + # --color=yes --randomly -v -s ${args} \ coverage run -a -m pytest --color=yes --randomly -v ${args} \ ${{ matrix.slowtask == 'pytest_bizarro' && '--runslow' || '' }} git checkout . # Undo changes to scalar default diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 000000000..61f32c2e0 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,16 @@ +rules: + use-trusted-publishing: + # TODO: we should update to use trusted publishing + ignore: + - publish_pypi.yml + excessive-permissions: + # It is probably good practice to use narrow permissions + ignore: + - debug.yml + - imports.yml + - publish_pypi.yml + - test_and_build.yml + template-injection: + # We use templates pretty heavily + ignore: + - test_and_build.yml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 12e5dd865..43e28b8fe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,12 +11,12 @@ ci: autoupdate_commit_msg: "chore: update pre-commit hooks" autofix_commit_msg: "style: pre-commit fixes" skip: [pylint, no-commit-to-branch] -fail_fast: true +fail_fast: false default_language_version: - python: python3 + python: python3 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: check-added-large-files - id: check-case-conflict @@ -25,6 +25,10 @@ repos: - id: check-ast - id: check-toml - id: check-yaml + - id: check-executables-have-shebangs + - id: check-vcs-permalinks + - id: destroyed-symlinks + - id: detect-private-key - id: debug-statements - id: end-of-file-fixer exclude_types: [svg] @@ -33,72 +37,68 @@ repos: - id: name-tests-test args: ["--pytest-test-first"] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.16 + rev: v0.23 hooks: - id: validate-pyproject name: Validate pyproject.toml # I don't yet trust ruff to do what autoflake does - repo: https://github.com/PyCQA/autoflake - rev: v2.2.1 + rev: v2.3.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.13.2 + rev: 6.0.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.15.0 + rev: v3.19.1 hooks: - id: pyupgrade args: [--py310-plus] - repo: https://github.com/MarcoGorelli/auto-walrus - rev: v0.2.2 + rev: 0.3.4 hooks: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 24.1.1 + rev: 25.1.0 hooks: - id: black - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.1 + rev: v0.9.6 hooks: - id: ruff args: [--fix-only, --show-fixes] # 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: 7.0.0 + rev: 7.1.2 hooks: - id: flake8 - additional_dependencies: &flake8_dependencies - # These versions need updated manually - - flake8==7.0.0 - - flake8-bugbear==24.1.17 - - flake8-simplify==0.21.0 - - repo: https://github.com/asottile/yesqa - rev: v1.5.0 - hooks: - - id: yesqa - additional_dependencies: *flake8_dependencies + args: ["--config=.flake8"] + additional_dependencies: + &flake8_dependencies # These versions need updated manually + - flake8==7.1.2 + - flake8-bugbear==24.12.12 + - flake8-simplify==0.21.0 - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 + rev: v2.4.1 hooks: - id: codespell types_or: [python, rst, markdown] additional_dependencies: [tomli] files: ^(graphblas|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.1 + rev: v0.9.6 hooks: - id: ruff - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.9.1 + rev: v1.0.0 hooks: - id: sphinx-lint args: [--enable, all, "--disable=line-too-long,leaked-markup"] @@ -110,9 +110,39 @@ repos: - id: pyroma args: [-n, "10", .] - repo: https://github.com/shellcheck-py/shellcheck-py - rev: "v0.9.0.6" + rev: "v0.10.0.1" hooks: - - id: shellcheck + - id: shellcheck + - repo: https://github.com/rbubley/mirrors-prettier + rev: v3.5.1 + hooks: + - id: prettier + - repo: https://github.com/ComPWA/taplo-pre-commit + rev: v0.9.3 + hooks: + - id: taplo-format + - repo: https://github.com/rhysd/actionlint + rev: v1.7.7 + hooks: + - id: actionlint + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.31.1 + hooks: + - id: check-dependabot + - id: check-github-workflows + - id: check-readthedocs + - repo: https://github.com/adrienverge/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + - repo: https://github.com/woodruffw/zizmor-pre-commit + rev: v1.3.1 + hooks: + - id: zizmor + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes - repo: local hooks: # Add `--hook-stage manual` to pre-commit command to run (very slow) @@ -126,9 +156,9 @@ repos: args: [graphblas/] pass_filenames: false - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - - id: no-commit-to-branch # no commit directly to main + - id: no-commit-to-branch # no commit directly to main # # Maybe: # @@ -145,8 +175,10 @@ repos: # additional_dependencies: [tomli] # # - repo: https://github.com/PyCQA/bandit -# rev: 1.7.4 +# rev: 1.8.2 # hooks: # - id: bandit +# args: ["-c", "pyproject.toml"] +# additional_dependencies: ["bandit[toml]"] # -# blacken-docs, blackdoc prettier, mypy, pydocstringformatter, velin, flynt, yamllint +# blacken-docs, blackdoc, mypy, pydocstringformatter, velin, flynt diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 000000000..54e656293 --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,6 @@ +--- +extends: default +rules: + document-start: disable + line-length: disable + truthy: disable diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 814c8052a..eebd2c372 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -13,13 +13,13 @@ educational level, family status, culture, or political belief. Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery -* Personal attacks -* Trolling or insulting/derogatory comments -* Public or private harassment -* Publishing other's private information, such as physical or electronic +- The use of sexualized language or imagery +- Personal attacks +- Trolling or insulting/derogatory comments +- Public or private harassment +- Publishing other's private information, such as physical or electronic addresses, without explicit permission -* Other unethical or unprofessional conduct +- Other unethical or unprofessional conduct Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions @@ -52,7 +52,7 @@ that is deemed necessary and appropriate to the circumstances. Maintainers are obligated to maintain confidentiality with regard to the reporter of an incident. -This Code of Conduct is adapted from the [Numba Code of Conduct][numba], which is based on the [Contributor Covenant][homepage], +This Code of Conduct is adapted from the [Numba Code of Conduct][numba], which is based on the [Contributor Covenant][homepage], version 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version], and the [Swift Code of Conduct][swift]. diff --git a/README.md b/README.md index 96908989c..1080314c7 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,19 @@ For algorithms, see

## Install + Install the latest version of Python-graphblas via conda: + ``` $ conda install -c conda-forge python-graphblas ``` + or pip: + ``` $ pip install 'python-graphblas[default]' ``` + This will also install the [SuiteSparse:GraphBLAS](https://github.com/DrTimothyAldenDavis/GraphBLAS) compiled C library. We currently support the [GraphBLAS C API 2.0 specification](https://graphblas.org/docs/GraphBLAS_API_C_v2.0.0.pdf). @@ -57,6 +62,7 @@ The following are not required by python-graphblas, but may be needed for certai - `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. The approach taken with this library is to follow the C-API 2.0 specification as closely as possible while making improvements @@ -70,10 +76,12 @@ with how Python handles assignment, so instead we (ab)use the left-shift `<<` no assignment. This opens up all kinds of nice possibilities. This is an example of how the mapping works: + ```C // C call GrB_Matrix_mxm(M, mask, GrB_PLUS_INT64, GrB_MIN_PLUS_INT64, A, B, NULL) ``` + ```python # Python call M(mask.V, accum=binary.plus) << A.mxm(B, semiring.min_plus) @@ -91,10 +99,12 @@ is a much better approach, even if it doesn't feel very Pythonic. Descriptor flags are set on the appropriate elements to keep logic close to what it affects. Here is the same call with descriptor bits set. `ttcsr` indicates transpose the first and second matrices, complement the structure of the mask, and do a replacement on the output. + ```C // C call GrB_Matrix_mxm(M, mask, GrB_PLUS_INT64, GrB_MIN_PLUS_INT64, A, B, desc.ttcsr) ``` + ```python # Python call M(~mask.S, accum=binary.plus, replace=True) << A.T.mxm(B.T, semiring.min_plus) @@ -104,16 +114,20 @@ The objects receiving the flag operations (A.T, ~mask, etc) are also delayed obj do no computation, allowing the correct descriptor bits to be set in a single GraphBLAS call. **If no mask or accumulator is used, the call looks like this**: + ```python M << A.mxm(B, semiring.min_plus) ``` + The use of `<<` to indicate updating is actually just syntactic sugar for a real `.update()` method. The above expression could be written as: + ```python M.update(A.mxm(B, semiring.min_plus)) ``` ## Operations + ```python M(mask, accum) << A.mxm(B, semiring) # mxm w(mask, accum) << A.mxv(v, semiring) # mxv @@ -123,14 +137,18 @@ M(mask, accum) << A.ewise_mult(B, binaryop) # eWiseMult M(mask, accum) << A.kronecker(B, binaryop) # kronecker M(mask, accum) << A.T # transpose ``` + ## Extract + ```python M(mask, accum) << A[rows, cols] # rows and cols are a list or a slice w(mask, accum) << A[rows, col_index] # extract column w(mask, accum) << A[row_index, cols] # extract row s = A[row_index, col_index].value # extract single element ``` + ## Assign + ```python M(mask, accum)[rows, cols] << A # rows and cols are a list or a slice M(mask, accum)[rows, col_index] << v # assign column @@ -140,31 +158,42 @@ M[row_index, col_index] << s # assign scalar to single element # (mask and accum not allowed) del M[row_index, col_index] # remove single element ``` + ## Apply + ```python M(mask, accum) << A.apply(unaryop) M(mask, accum) << A.apply(binaryop, left=s) # bind-first M(mask, accum) << A.apply(binaryop, right=s) # bind-second ``` + ## Reduce + ```python v(mask, accum) << A.reduce_rowwise(op) # reduce row-wise v(mask, accum) << A.reduce_columnwise(op) # reduce column-wise s(accum) << A.reduce_scalar(op) s(accum) << v.reduce(op) ``` + ## Creating new Vectors / Matrices + ```python A = Matrix.new(dtype, num_rows, num_cols) # new_type B = A.dup() # dup A = Matrix.from_coo([row_indices], [col_indices], [values]) # build ``` + ## New from delayed + Delayed objects can be used to create a new object using `.new()` method + ```python C = A.mxm(B, semiring).new() ``` + ## Properties + ```python size = v.size # size nrows = M.nrows # nrows @@ -172,10 +201,13 @@ ncols = M.ncols # ncols nvals = M.nvals # nvals rindices, cindices, vals = M.to_coo() # extractTuples ``` + ## Initialization + There is a mechanism to initialize `graphblas` with a context prior to use. This allows for setting the backend to use as well as the blocking/non-blocking mode. If the context is not initialized, a default initialization will be performed automatically. + ```python import graphblas as gb @@ -186,10 +218,13 @@ gb.init("suitesparse", blocking=True) from graphblas import binary, semiring from graphblas import Matrix, Vector, Scalar ``` + ## Performant User Defined Functions + Python-graphblas requires `numba` which enables compiling user-defined Python functions to native C for use in GraphBLAS. Example customized UnaryOp: + ```python from graphblas import unary @@ -204,9 +239,11 @@ v = Vector.from_coo([0, 1, 3], [1, 2, 3]) w = v.apply(unary.force_odd).new() w # indexes=[0, 1, 3], values=[1, 3, 3] ``` + Similar methods exist for BinaryOp, Monoid, and Semiring. ## Relation to other network analysis libraries + Python-graphblas aims to provide an efficient and consistent expression of graph operations using linear algebra. This allows the development of high-performance implementations of existing and new graph algorithms @@ -223,7 +260,9 @@ other libraries, `graphblas.io` contains multiple connectors, see the following section. ## Import/Export connectors to the Python ecosystem + `graphblas.io` contains functions for converting to and from: + ```python import graphblas as gb diff --git a/binder/environment.yml b/binder/environment.yml index 11cd98e0c..9548f2126 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -1,12 +1,12 @@ name: graphblas channels: - - conda-forge + - conda-forge dependencies: - - python=3.11 - - python-graphblas - - matplotlib - - networkx - - pandas - - scipy - - drawsvg - - cairosvg + - python=3.11 + - python-graphblas + - matplotlib + - networkx + - pandas + - scipy + - drawsvg + - cairosvg diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 1b14402cd..f7dd59b74 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,78 +1,78 @@ - /* Main Page Stylings */ .intro-card { - background-color: var(--pst-color-background); - margin-bottom: 30px; + background-color: var(--pst-color-background); + margin-bottom: 30px; } .intro-card:hover { - box-shadow: 0.2rem 0.5rem 1rem var(--pst-color-link) !important; + box-shadow: 0.2rem 0.5rem 1rem var(--pst-color-link) !important; } .intro-card .card-header { - background-color: inherit; + background-color: inherit; } .intro-card .card-header .card-text { - font-weight: bold; + font-weight: bold; } .intro-card .card-body { - margin-top: 0; + margin-top: 0; } .intro-card .card-body .card-text:first-child { - margin-bottom: 0; + margin-bottom: 0; } .shadow { - box-shadow: 0.2rem 0.5rem 1rem var(--pst-color-text-muted) !important; + box-shadow: 0.2rem 0.5rem 1rem var(--pst-color-text-muted) !important; } .table { - font-size: smaller; - width: inherit; + font-size: smaller; + width: inherit; } -.table td, .table th { - padding: 0 .75rem; +.table td, +.table th { + padding: 0 0.75rem; } .table.inline { - display: inline-table; - margin-right: 30px; + display: inline-table; + margin-right: 30px; } p.rubric { - border-bottom: none; + border-bottom: none; } button.navbar-btn.rounded-circle { - padding: 0.25rem; + padding: 0.25rem; } button.navbar-btn.search-button { - color: var(--pst-color-text-muted); - padding: 0; + color: var(--pst-color-text-muted); + padding: 0; } -button.navbar-btn:hover -{ - color: var(--pst-color-primary); +button.navbar-btn:hover { + color: var(--pst-color-primary); } button.theme-switch-button { - font-size: calc(var(--pst-font-size-icon) - .1rem); - border: none; + font-size: calc(var(--pst-font-size-icon) - 0.1rem); + border: none; } button span.theme-switch:hover { - color: var(--pst-color-primary); + color: var(--pst-color-primary); } /* Styling for Jupyter Notebook ReST Exports */ -.dataframe tbody th, .dataframe tbody td { - padding: 10px; +.dataframe tbody th, +.dataframe tbody td { + padding: 10px; } diff --git a/docs/_static/matrix.css b/docs/_static/matrix.css index 5700ea3fc..1937178e5 100644 --- a/docs/_static/matrix.css +++ b/docs/_static/matrix.css @@ -1,104 +1,104 @@ /* Based on the stylesheet used by matrepr (https://github.com/alugowski/matrepr) and modified for sphinx */ -table.matrix { - border-collapse: collapse; - border: 0px; +table.matrix { + border-collapse: collapse; + border: 0px; } /* Disable a horizintal line from the default stylesheet */ .table.matrix > :not(caption) > * > * { - border-bottom-width: 0px; + border-bottom-width: 0px; } /* row indices */ table.matrix > tbody tr th { - font-size: smaller; - font-weight: bolder; - vertical-align: middle; - text-align: right; + font-size: smaller; + font-weight: bolder; + vertical-align: middle; + text-align: right; } /* row indices are often made bold in the source data; here make them match the boldness of the th column label style*/ table.matrix strong { - font-weight: bold; + font-weight: bold; } /* column indices */ table.matrix > thead tr th { - font-size: smaller; - font-weight: bolder; - vertical-align: middle; - text-align: center; + font-size: smaller; + font-weight: bolder; + vertical-align: middle; + text-align: center; } /* cells */ table.matrix > tbody tr td { - vertical-align: middle; - text-align: center; - position: relative; + vertical-align: middle; + text-align: center; + position: relative; } /* left border */ table.matrix > tbody tr td:first-of-type { - border-left: solid 2px var(--pst-color-text-base); + border-left: solid 2px var(--pst-color-text-base); } /* right border */ table.matrix > tbody tr td:last-of-type { - border-right: solid 2px var(--pst-color-text-base); + border-right: solid 2px var(--pst-color-text-base); } /* prevents empty cells from collapsing, especially empty rows */ table.matrix > tbody tr td:empty::before { - /* basicaly fills empty cells with   */ - content: "\00a0\00a0\00a0"; - visibility: hidden; + /* basicaly fills empty cells with   */ + content: "\00a0\00a0\00a0"; + visibility: hidden; } table.matrix > tbody tr td:empty::after { - content: "\00a0\00a0\00a0"; - visibility: hidden; + content: "\00a0\00a0\00a0"; + visibility: hidden; } /* matrix bracket ticks */ table.matrix > tbody > tr:first-child > td:first-of-type::before { - content: ""; - width: 4px; - position: absolute; - top: 0; - bottom: 0; - visibility: visible; - left: 0; - right: auto; - border-top: solid 2px var(--pst-color-text-base); + content: ""; + width: 4px; + position: absolute; + top: 0; + bottom: 0; + visibility: visible; + left: 0; + right: auto; + border-top: solid 2px var(--pst-color-text-base); } table.matrix > tbody > tr:last-child > td:first-of-type::before { - content: ""; - width: 4px; - position: absolute; - top: 0; - bottom: 0; - visibility: visible; - left: 0; - right: auto; - border-bottom: solid 2px var(--pst-color-text-base); + content: ""; + width: 4px; + position: absolute; + top: 0; + bottom: 0; + visibility: visible; + left: 0; + right: auto; + border-bottom: solid 2px var(--pst-color-text-base); } table.matrix > tbody > tr:first-child > td:last-of-type::after { - content: ""; - width: 4px; - position: absolute; - top: 0; - bottom: 0; - visibility: visible; - left: auto; - right: 0; - border-top: solid 2px var(--pst-color-text-base); + content: ""; + width: 4px; + position: absolute; + top: 0; + bottom: 0; + visibility: visible; + left: auto; + right: 0; + border-top: solid 2px var(--pst-color-text-base); } table.matrix > tbody > tr:last-child > td:last-of-type::after { - content: ""; - width: 4px; - position: absolute; - top: 0; - bottom: 0; - visibility: visible; - left: auto; - right: 0; - border-bottom: solid 2px var(--pst-color-text-base); + content: ""; + width: 4px; + position: absolute; + top: 0; + bottom: 0; + visibility: visible; + left: auto; + right: 0; + border-bottom: solid 2px var(--pst-color-text-base); } diff --git a/docs/env.yml b/docs/env.yml index c0c4c8999..78a50afbe 100644 --- a/docs/env.yml +++ b/docs/env.yml @@ -1,23 +1,23 @@ name: python-graphblas-docs channels: - - conda-forge - - nodefaults + - conda-forge + - nodefaults dependencies: - - python=3.10 - - pip - # python-graphblas dependencies - - donfig - - numba - - python-suitesparse-graphblas>=7.4.0.0 - - pyyaml - # extra dependencies - - matplotlib - - networkx - - pandas - - scipy>=1.7.0 - # docs dependencies - - commonmark # For RTD - - nbsphinx - - numpydoc - - pydata-sphinx-theme=0.13.1 - - sphinx-panels=0.6.0 + - python=3.10 + - pip + # python-graphblas dependencies + - donfig + - numba + - python-suitesparse-graphblas>=7.4.0.0 + - pyyaml + # extra dependencies + - matplotlib + - networkx + - pandas + - scipy>=1.7.0 + # docs dependencies + - commonmark # For RTD + - nbsphinx + - numpydoc + - pydata-sphinx-theme=0.13.1 + - sphinx-panels=0.6.0 diff --git a/docs/user_guide/operations.rst b/docs/user_guide/operations.rst index 3f710dc23..18d0352d7 100644 --- a/docs/user_guide/operations.rst +++ b/docs/user_guide/operations.rst @@ -8,7 +8,7 @@ Matrix Multiply The GraphBLAS spec contains three methods for matrix multiplication, depending on whether the inputs are Matrix or Vector. - - **mxm** -- Matrix-Matrix multplications + - **mxm** -- Matrix-Matrix multiplication - **mxv** -- Matrix-Vector multiplication - **vxm** -- Vector-Matrix multiplication diff --git a/environment.yml b/environment.yml index 1863d4006..2bae0b76e 100644 --- a/environment.yml +++ b/environment.yml @@ -11,103 +11,100 @@ # It is okay to comment out sections below that you don't need such as viz or building docs. name: graphblas-dev channels: - - conda-forge - - nodefaults # Only install packages from conda-forge for faster solving + - conda-forge + - nodefaults # Only install packages from conda-forge for faster solving dependencies: - - python - - donfig - - numba - - python-suitesparse-graphblas - - pyyaml - # For repr - - pandas - # For I/O - - awkward - - fast_matrix_market - - networkx - - scipy - - sparse - # For viz - - datashader - - hvplot - - matplotlib - # For linting - - pre-commit - # For testing - - packaging - - pytest-cov - - tomli - # For debugging - - icecream - - ipykernel - - ipython - # For type annotations - - mypy - # For building docs - - nbsphinx - - numpydoc - - pydata-sphinx-theme - - sphinx-panels - # For building logo - - drawsvg - - cairosvg - # EXTRA (optional; uncomment as desired) - # - autoflake - # - black - # - black-jupyter - # - build - # - codespell - # - commonmark - # - cython - # - cytoolz - # - distributed - # - flake8 - # - flake8-bugbear - # - flake8-comprehensions - # - flake8-print - # - flake8-quotes - # - flake8-simplify - # - gcc - # - gh - # - git - # - graph-tool - # - xorg-libxcursor # for graph-tool - # - grayskull - # - h5py - # - hiveplot - # - igraph - # - ipycytoscape - # - isort - # - jupyter - # - jupyterlab - # - line_profiler - # - lxml - # - make - # - memory_profiler - # - nbqa - # - netcdf4 - # - networkit - # - nxviz - # - pycodestyle - # - pydot - # - pygraphviz - # - pylint - # - pytest-runner - # - pytest-xdist - # - python-graphviz - # - python-igraph - # - python-louvain - # - pyupgrade - # - rich - # - ruff - # - scalene - # - scikit-network - # - setuptools-git-versioning - # - snakeviz - # - sphinx-lint - # - sympy - # - tuna - # - twine - # - vim - # - yesqa - # - zarr + - python + - donfig + - numba + - python-suitesparse-graphblas + - pyyaml + # For repr + - pandas + # For I/O + - awkward + - networkx + - scipy + - sparse + # For viz + - datashader + - hvplot + - matplotlib + # For linting + - pre-commit + # For testing + - packaging + - pytest-cov + - tomli + # For debugging + - icecream + - ipykernel + - ipython + # For type annotations + - mypy + # For building docs + - nbsphinx + - numpydoc + - pydata-sphinx-theme + - sphinx-panels + # For building logo + - drawsvg + - cairosvg + # EXTRA (optional; uncomment as desired) + # - autoflake + # - black + # - black-jupyter + # - codespell + # - commonmark + # - cython + # - cytoolz + # - distributed + # - flake8 + # - flake8-bugbear + # - flake8-comprehensions + # - flake8-print + # - flake8-quotes + # - flake8-simplify + # - gcc + # - gh + # - git + # - graph-tool + # - xorg-libxcursor # for graph-tool + # - grayskull + # - h5py + # - hiveplot + # - igraph + # - ipycytoscape + # - isort + # - jupyter + # - jupyterlab + # - line_profiler + # - lxml + # - make + # - memory_profiler + # - nbqa + # - netcdf4 + # - networkit + # - nxviz + # - pycodestyle + # - pydot + # - pygraphviz + # - pylint + # - pytest-runner + # - pytest-xdist + # - python-graphviz + # - python-igraph + # - python-louvain + # - pyupgrade + # - rich + # - ruff + # - scalene + # - scikit-network + # - setuptools-git-versioning + # - snakeviz + # - sphinx-lint + # - sympy + # - tuna + # - twine + # - vim + # - zarr diff --git a/graphblas/core/base.py b/graphblas/core/base.py index 5658e99c1..24a49ba1a 100644 --- a/graphblas/core/base.py +++ b/graphblas/core/base.py @@ -513,7 +513,7 @@ def _name_html(self): _expect_op = _expect_op # Don't let non-scalars be coerced to numpy arrays - def __array__(self, dtype=None): + def __array__(self, dtype=None, *, copy=None): raise TypeError( f"{type(self).__name__} can't be directly converted to a numpy array; " f"perhaps use `{self.name}.to_coo()` method instead." diff --git a/graphblas/core/dtypes.py b/graphblas/core/dtypes.py index 28ce60d03..2d4178b14 100644 --- a/graphblas/core/dtypes.py +++ b/graphblas/core/dtypes.py @@ -1,4 +1,5 @@ import warnings +from ast import literal_eval import numpy as np from numpy import promote_types, result_type @@ -97,7 +98,7 @@ def register_anonymous(dtype, name=None): # Allow dtypes such as `"INT64[3, 4]"` for convenience base_dtype, shape = dtype.split("[", 1) base_dtype = lookup_dtype(base_dtype) - shape = np.lib.format.safe_eval(f"[{shape}") + shape = literal_eval(f"[{shape}") dtype = np.dtype((base_dtype.np_type, shape)) else: raise @@ -115,7 +116,17 @@ def register_anonymous(dtype, name=None): from ..exceptions import check_status_carg gb_obj = ffi.new("GrB_Type*") - if backend == "suitesparse": + + if hasattr(lib, "GrB_Type_set_String"): + # We name this so that we can serialize and deserialize UDTs + # We don't yet have C definitions + np_repr = _dtype_to_string(dtype) + status = lib.GrB_Type_new(gb_obj, dtype.itemsize) + check_status_carg(status, "Type", gb_obj[0]) + val_obj = ffi.new("char[]", np_repr.encode()) + status = lib.GrB_Type_set_String(gb_obj[0], val_obj, lib.GrB_NAME) + elif backend == "suitesparse": + # For SuiteSparse < 9 # We name this so that we can serialize and deserialize UDTs # We don't yet have C definitions np_repr = _dtype_to_string(dtype).encode() @@ -429,7 +440,7 @@ def _dtype_to_string(dtype): np_type = dtype.np_type s = str(np_type) try: - if np.dtype(np.lib.format.safe_eval(s)) == np_type: # pragma: no branch (safety) + if np.dtype(literal_eval(s)) == np_type: # pragma: no branch (safety) return s except Exception: pass @@ -448,5 +459,5 @@ def _string_to_dtype(s): return lookup_dtype(s) except Exception: pass - np_type = np.dtype(np.lib.format.safe_eval(s)) + np_type = np.dtype(literal_eval(s)) return lookup_dtype(np_type) diff --git a/graphblas/core/infix.py b/graphblas/core/infix.py index 2c1014fe5..24c109639 100644 --- a/graphblas/core/infix.py +++ b/graphblas/core/infix.py @@ -316,6 +316,7 @@ class MatrixInfixExpr(InfixExprBase): ndim = 2 output_type = MatrixExpression _is_transposed = False + __networkx_backend__ = "graphblas" __networkx_plugin__ = "graphblas" def __init__(self, left, right): diff --git a/graphblas/core/matrix.py b/graphblas/core/matrix.py index e28e92a65..bf20cc953 100644 --- a/graphblas/core/matrix.py +++ b/graphblas/core/matrix.py @@ -184,6 +184,7 @@ class Matrix(BaseType): ndim = 2 _is_transposed = False _name_counter = itertools.count() + __networkx_backend__ = "graphblas" __networkx_plugin__ = "graphblas" def __new__(cls, dtype=FP64, nrows=0, ncols=0, *, name=None): @@ -3583,6 +3584,7 @@ class MatrixExpression(BaseExpression): ndim = 2 output_type = Matrix _is_transposed = False + __networkx_backend__ = "graphblas" __networkx_plugin__ = "graphblas" def __init__( @@ -3724,6 +3726,7 @@ class MatrixIndexExpr(AmbiguousAssignOrExtract): ndim = 2 output_type = Matrix _is_transposed = False + __networkx_backend__ = "graphblas" __networkx_plugin__ = "graphblas" def __init__(self, parent, resolved_indexes, nrows, ncols): @@ -3824,6 +3827,7 @@ class TransposedMatrix: ndim = 2 _is_scalar = False _is_transposed = True + __networkx_backend__ = "graphblas" __networkx_plugin__ = "graphblas" def __init__(self, matrix): diff --git a/graphblas/core/operator/base.py b/graphblas/core/operator/base.py index 4e19fbe96..97b2c9fbd 100644 --- a/graphblas/core/operator/base.py +++ b/graphblas/core/operator/base.py @@ -251,8 +251,7 @@ def __init__(self, parent, name, type_, return_type, gb_obj, gb_name, dtype2=Non def __repr__(self): classname = self.opclass.lower() - if classname.endswith("op"): - classname = classname[:-2] + classname = classname.removesuffix("op") dtype2 = "" if self._type2 is None else f", {self._type2.name}" return f"{classname}.{self.name}[{self.type.name}{dtype2}]" diff --git a/graphblas/core/scalar.py b/graphblas/core/scalar.py index 7e759e5d0..25aef5743 100644 --- a/graphblas/core/scalar.py +++ b/graphblas/core/scalar.py @@ -165,7 +165,7 @@ def __index__(self): return self.__int__ raise AttributeError("Scalar object only has `__index__` for integral dtypes") - def __array__(self, dtype=None): + def __array__(self, dtype=None, *, copy=None): if dtype is None: dtype = self.dtype.np_type return np.array(self.value, dtype=dtype) diff --git a/graphblas/core/ss/__init__.py b/graphblas/core/ss/__init__.py index c2e83ddcc..10a6fed94 100644 --- a/graphblas/core/ss/__init__.py +++ b/graphblas/core/ss/__init__.py @@ -1,3 +1,5 @@ import suitesparse_graphblas as _ssgb -_IS_SSGB7 = _ssgb.__version__.split(".", 1)[0] == "7" +(version_major, version_minor, version_bug) = map(int, _ssgb.__version__.split(".")[:3]) + +_IS_SSGB7 = version_major == 7 diff --git a/graphblas/core/ss/config.py b/graphblas/core/ss/config.py index 20cf318e8..70a7dd196 100644 --- a/graphblas/core/ss/config.py +++ b/graphblas/core/ss/config.py @@ -99,7 +99,7 @@ def __getitem__(self, key): return {reverse_bitwise[val]} rv = set() for k, v in self._bitwise[key].items(): - if isinstance(k, str) and val & v and bin(v).count("1") == 1: + if isinstance(k, str) and val & v and (v).bit_count() == 1: rv.add(k) return rv if is_bool: diff --git a/graphblas/core/ss/matrix.py b/graphblas/core/ss/matrix.py index 0a08c50e2..509c56113 100644 --- a/graphblas/core/ss/matrix.py +++ b/graphblas/core/ss/matrix.py @@ -3650,8 +3650,10 @@ def _import_any( def unpack_hyperhash(self, *, compute=False, name=None, **opts): """Unpacks the hyper_hash of a hypersparse matrix if possible. - Will return None if the matrix is not hypersparse or if the hash is not computed. - Use ``compute=True`` to compute the hyper_hash if the input is hypersparse. + Will return None if the matrix is not hypersparse, if the hash is not computed, + or if the hash is not needed. Use ``compute=True`` to try to compute the hyper_hash + if the input is hypersparse. The hyper_hash is optional in SuiteSparse:GraphBLAS, + so it may not be computed even with ``compute=True``. Use ``pack_hyperhash`` to move a hyper_hash matrix that was previously unpacked back into a matrix. @@ -4079,6 +4081,21 @@ def serialize(self, compression="default", level=None, **opts): blob_handle = ffi_new("void**") blob_size_handle = ffi_new("GrB_Index*") parent = self._parent + if parent.dtype._is_udt and hasattr(lib, "GrB_Type_get_String"): + # Get the name from the dtype and set it to the name of the matrix so we can + # recreate the UDT. This is a bit hacky and we should restore the original name. + # First get the size of name. + dtype_size = ffi_new("size_t*") + status = lib.GrB_Type_get_SIZE(parent.dtype.gb_obj[0], dtype_size, lib.GrB_NAME) + check_status_carg(status, "Type", parent.dtype.gb_obj[0]) + # Then get the name + dtype_char = ffi_new(f"char[{dtype_size[0]}]") + status = lib.GrB_Type_get_String(parent.dtype.gb_obj[0], dtype_char, lib.GrB_NAME) + check_status_carg(status, "Type", parent.dtype.gb_obj[0]) + # Then set the name + status = lib.GrB_Matrix_set_String(parent._carg, dtype_char, lib.GrB_NAME) + check_status_carg(status, "Matrix", parent._carg) + check_status( lib.GxB_Matrix_serialize( blob_handle, @@ -4120,8 +4137,8 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts): else: data = np.frombuffer(data, np.uint8) data_obj = ffi.from_buffer("void*", data) - # Get the dtype name first if dtype is None: + # Get the dtype name first (for non-UDTs) cname = ffi_new(f"char[{lib.GxB_MAX_NAME_LEN}]") info = lib.GxB_deserialize_type_name( cname, @@ -4131,6 +4148,22 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts): if info != lib.GrB_SUCCESS: raise _error_code_lookup[info]("Matrix deserialize failed to get the dtype name") dtype_name = b"".join(itertools.takewhile(b"\x00".__ne__, cname)).decode() + if not dtype_name and hasattr(lib, "GxB_Serialized_get_String"): + # Handle UDTs. First get the size of name + dtype_size = ffi_new("size_t*") + info = lib.GxB_Serialized_get_SIZE(data_obj, dtype_size, lib.GrB_NAME, data.nbytes) + if info != lib.GrB_SUCCESS: + raise _error_code_lookup[info]( + "Matrix deserialize failed to get the size of name" + ) + # Then get the name + dtype_char = ffi_new(f"char[{dtype_size[0]}]") + info = lib.GxB_Serialized_get_String( + data_obj, dtype_char, lib.GrB_NAME, data.nbytes + ) + if info != lib.GrB_SUCCESS: + raise _error_code_lookup[info]("Matrix deserialize failed to get the name") + dtype_name = ffi.string(dtype_char).decode() dtype = _string_to_dtype(dtype_name) else: dtype = lookup_dtype(dtype) diff --git a/graphblas/core/ss/vector.py b/graphblas/core/ss/vector.py index a21d54de9..fdde7eb92 100644 --- a/graphblas/core/ss/vector.py +++ b/graphblas/core/ss/vector.py @@ -1652,6 +1652,21 @@ def serialize(self, compression="default", level=None, **opts): blob_handle = ffi_new("void**") blob_size_handle = ffi_new("GrB_Index*") parent = self._parent + if parent.dtype._is_udt and hasattr(lib, "GrB_Type_get_String"): + # Get the name from the dtype and set it to the name of the vector so we can + # recreate the UDT. This is a bit hacky and we should restore the original name. + # First get the size of name. + dtype_size = ffi_new("size_t*") + status = lib.GrB_Type_get_SIZE(parent.dtype.gb_obj[0], dtype_size, lib.GrB_NAME) + check_status_carg(status, "Type", parent.dtype.gb_obj[0]) + # Then get the name + dtype_char = ffi_new(f"char[{dtype_size[0]}]") + status = lib.GrB_Type_get_String(parent.dtype.gb_obj[0], dtype_char, lib.GrB_NAME) + check_status_carg(status, "Type", parent.dtype.gb_obj[0]) + # Then set the name + status = lib.GrB_Vector_set_String(parent._carg, dtype_char, lib.GrB_NAME) + check_status_carg(status, "Vector", parent._carg) + check_status( lib.GxB_Vector_serialize( blob_handle, @@ -1694,7 +1709,7 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts): data = np.frombuffer(data, np.uint8) data_obj = ffi.from_buffer("void*", data) if dtype is None: - # Get the dtype name first + # Get the dtype name first (for non-UDTs) cname = ffi_new(f"char[{lib.GxB_MAX_NAME_LEN}]") info = lib.GxB_deserialize_type_name( cname, @@ -1704,6 +1719,22 @@ def deserialize(cls, data, dtype=None, *, name=None, **opts): if info != lib.GrB_SUCCESS: raise _error_code_lookup[info]("Vector deserialize failed to get the dtype name") dtype_name = b"".join(itertools.takewhile(b"\x00".__ne__, cname)).decode() + if not dtype_name and hasattr(lib, "GxB_Serialized_get_String"): + # Handle UDTs. First get the size of name + dtype_size = ffi_new("size_t*") + info = lib.GxB_Serialized_get_SIZE(data_obj, dtype_size, lib.GrB_NAME, data.nbytes) + if info != lib.GrB_SUCCESS: + raise _error_code_lookup[info]( + "Vector deserialize failed to get the size of name" + ) + # Then get the name + dtype_char = ffi_new(f"char[{dtype_size[0]}]") + info = lib.GxB_Serialized_get_String( + data_obj, dtype_char, lib.GrB_NAME, data.nbytes + ) + if info != lib.GrB_SUCCESS: + raise _error_code_lookup[info]("Vector deserialize failed to get the name") + dtype_name = ffi.string(dtype_char).decode() dtype = _string_to_dtype(dtype_name) else: dtype = lookup_dtype(dtype) diff --git a/graphblas/core/utils.py b/graphblas/core/utils.py index 6e91edd1b..e9a29b3a9 100644 --- a/graphblas/core/utils.py +++ b/graphblas/core/utils.py @@ -5,6 +5,8 @@ from ..dtypes import _INDEX, lookup_dtype from . import ffi, lib +_NP2 = np.__version__.startswith("2.") + def libget(name): """Helper to get items from GraphBLAS which might be GrB or GxB.""" @@ -60,7 +62,8 @@ def ints_to_numpy_buffer(array, dtype, *, name="array", copy=False, ownable=Fals and not np.issubdtype(array.dtype, np.bool_) ): raise ValueError(f"{name} must be integers, not {array.dtype.name}") - array = np.array(array, dtype, copy=copy, order=order) + # https://numpy.org/doc/stable/release/2.0.0-notes.html#new-copy-keyword-meaning-for-array-and-asarray-constructors + array = np.array(array, dtype, copy=copy or _NP2 and None, order=order) if ownable and (not array.flags.owndata or not array.flags.writeable): array = array.copy(order) return array @@ -90,10 +93,14 @@ def values_to_numpy_buffer( """ if dtype is not None: dtype = lookup_dtype(dtype) - array = np.array(array, _get_subdtype(dtype.np_type), copy=copy, order=order) + # https://numpy.org/doc/stable/release/2.0.0-notes.html#new-copy-keyword-meaning-for-array-and-asarray-constructors + array = np.array( + array, _get_subdtype(dtype.np_type), copy=copy or _NP2 and None, order=order + ) else: is_input_np = isinstance(array, np.ndarray) - array = np.array(array, copy=copy, order=order) + # https://numpy.org/doc/stable/release/2.0.0-notes.html#new-copy-keyword-meaning-for-array-and-asarray-constructors + array = np.array(array, copy=copy or _NP2 and None, order=order) if array.dtype.hasobject: raise ValueError("object dtype for values is not allowed") if not is_input_np and array.dtype == np.int32: # pragma: no cover @@ -312,7 +319,10 @@ def __init__(self, array=None, dtype=_INDEX, *, size=None, name=None): if size is not None: self.array = np.empty(size, dtype=dtype.np_type) else: - self.array = np.array(array, dtype=_get_subdtype(dtype.np_type), copy=False, order="C") + # https://numpy.org/doc/stable/release/2.0.0-notes.html#new-copy-keyword-meaning-for-array-and-asarray-constructors + self.array = np.array( + array, dtype=_get_subdtype(dtype.np_type), copy=_NP2 and None, order="C" + ) c_type = dtype.c_type if dtype._is_udt else f"{dtype.c_type}*" self._carg = ffi.cast(c_type, ffi.from_buffer(self.array)) self.dtype = dtype diff --git a/graphblas/exceptions.py b/graphblas/exceptions.py index e7f3b3a83..05cac988a 100644 --- a/graphblas/exceptions.py +++ b/graphblas/exceptions.py @@ -1,4 +1,3 @@ -from . import backend as _backend from .core import ffi as _ffi from .core import lib as _lib from .core.utils import _Pointer @@ -85,9 +84,14 @@ class NotImplementedException(GraphblasException): """ +# SuiteSparse errors +class JitError(GraphblasException): + """SuiteSparse:GraphBLAS error using JIT.""" + + # Our errors class UdfParseError(GraphblasException): - """Unable to parse the user-defined function.""" + """SuiteSparse:GraphBLAS unable to parse the user-defined function.""" _error_code_lookup = { @@ -112,8 +116,12 @@ class UdfParseError(GraphblasException): } GrB_SUCCESS = _lib.GrB_SUCCESS GrB_NO_VALUE = _lib.GrB_NO_VALUE -if _backend == "suitesparse": + +# SuiteSparse-specific errors +if hasattr(_lib, "GxB_EXHAUSTED"): _error_code_lookup[_lib.GxB_EXHAUSTED] = StopIteration +if hasattr(_lib, "GxB_JIT_ERROR"): # Added in 9.4 + _error_code_lookup[_lib.GxB_JIT_ERROR] = JitError def check_status(response_code, args): diff --git a/graphblas/tests/conftest.py b/graphblas/tests/conftest.py index a3acb3a94..964325e0d 100644 --- a/graphblas/tests/conftest.py +++ b/graphblas/tests/conftest.py @@ -3,6 +3,7 @@ import functools import itertools import platform +import sys from pathlib import Path import numpy as np @@ -156,3 +157,10 @@ def compute(x): def shouldhave(module, opname): """Whether an "operator" module should have the given operator.""" return supports_udfs or hasattr(module, opname) + + +def dprint(*args, **kwargs): # pragma: no cover (debug) + """Print to stderr for debugging purposes.""" + kwargs["file"] = sys.stderr + kwargs["flush"] = True + print(*args, **kwargs) diff --git a/graphblas/tests/test_dtype.py b/graphblas/tests/test_dtype.py index 3bd65f2b4..e2478fe7b 100644 --- a/graphblas/tests/test_dtype.py +++ b/graphblas/tests/test_dtype.py @@ -224,6 +224,10 @@ def test_record_dtype_from_dict(): def test_dtype_to_from_string(): types = [dtypes.BOOL, dtypes.FP64] for c in string.ascii_letters: + if c == "T": + # See NEP 55 about StringDtype "T". Notably, this doesn't work: + # >>> np.dtype(np.dtype("T").str) + continue try: dtype = np.dtype(c) types.append(dtype) diff --git a/graphblas/tests/test_matrix.py b/graphblas/tests/test_matrix.py index 63561930b..24f0e73d7 100644 --- a/graphblas/tests/test_matrix.py +++ b/graphblas/tests/test_matrix.py @@ -4074,10 +4074,11 @@ def test_ss_pack_hyperhash(A): Y = C.ss.unpack_hyperhash() Y = C.ss.unpack_hyperhash(compute=True) assert C.ss.unpack_hyperhash() is None - assert Y.nrows == C.nrows - C.ss.pack_hyperhash(Y) - assert Y.gb_obj[0] == gb.core.NULL - assert C.ss.unpack_hyperhash() is not None + if Y is not None: # hyperhash may or may not be computed + assert Y.nrows == C.nrows + C.ss.pack_hyperhash(Y) + assert Y.gb_obj[0] == gb.core.NULL + assert C.ss.unpack_hyperhash() is not None # May or may not be computed def test_to_dicts_from_dicts(A): diff --git a/graphblas/tests/test_numpyops.py b/graphblas/tests/test_numpyops.py index 25c52d7fd..999c6d5e0 100644 --- a/graphblas/tests/test_numpyops.py +++ b/graphblas/tests/test_numpyops.py @@ -5,6 +5,7 @@ import numpy as np import pytest +from packaging.version import parse import graphblas as gb import graphblas.binary.numpy as npbinary @@ -112,6 +113,15 @@ def test_npunary(): match(accum=gb.binary.lor) << gb_result.apply(npunary.isnan) compare = match.reduce(gb.monoid.land).new() if not compare: # pragma: no cover (debug) + import numba + + if ( + unary_name in {"sign"} + and np.__version__.startswith("2.") + and parse(numba.__version__) < parse("0.61.0") + ): + # numba <0.61.0 does not match numpy 2.0 + continue print(unary_name, gb_input.dtype) print(compute(gb_result)) print(np_result) diff --git a/graphblas/tests/test_scalar.py b/graphblas/tests/test_scalar.py index 3c7bffa9a..e93511914 100644 --- a/graphblas/tests/test_scalar.py +++ b/graphblas/tests/test_scalar.py @@ -50,7 +50,7 @@ def test_dup(s): s_empty = Scalar(dtypes.FP64) s_unempty = Scalar.from_value(0.0) if s_empty.is_cscalar: - # NumPy wraps around + # NumPy <2 wraps around; >=2 raises OverflowError uint_data = [ ("UINT8", 2**8 - 2), ("UINT16", 2**16 - 2), @@ -73,6 +73,10 @@ def test_dup(s): ("FP32", -2.5), *uint_data, ]: + if dtype.startswith("UINT") and s_empty.is_cscalar and not np.__version__.startswith("1."): + with pytest.raises(OverflowError, match="out of bounds for uint"): + s4.dup(dtype=dtype, name="s5") + continue s5 = s4.dup(dtype=dtype, name="s5") assert s5.dtype == dtype assert s5.value == val diff --git a/graphblas/tests/test_ssjit.py b/graphblas/tests/test_ssjit.py index 3c974c50d..4cea0b563 100644 --- a/graphblas/tests/test_ssjit.py +++ b/graphblas/tests/test_ssjit.py @@ -1,6 +1,8 @@ import os import pathlib +import platform import sys +import sysconfig import numpy as np import pytest @@ -26,11 +28,48 @@ @pytest.fixture(scope="module", autouse=True) def _setup_jit(): + """Set up the SuiteSparse:GraphBLAS JIT.""" + if _IS_SSGB7: + # SuiteSparse JIT was added in SSGB 8 + yield + return + + if not os.environ.get("GITHUB_ACTIONS"): + # Try to run the tests with defaults from sysconfig if not running in CI + prev = gb.ss.config["jit_c_control"] + cc = sysconfig.get_config_var("CC") + cflags = sysconfig.get_config_var("CFLAGS") + include = sysconfig.get_path("include") + libs = sysconfig.get_config_var("LIBS") + if not (cc is None or cflags is None or include is None or libs is None): + gb.ss.config["jit_c_control"] = "on" + gb.ss.config["jit_c_compiler_name"] = cc + gb.ss.config["jit_c_compiler_flags"] = f"{cflags} -I{include}" + gb.ss.config["jit_c_libraries"] = libs + else: + # Should we skip or try to run if sysconfig vars aren't set? + gb.ss.config["jit_c_control"] = "on" # "off" + try: + yield + finally: + gb.ss.config["jit_c_control"] = prev + return + + if ( + sys.platform == "darwin" + or sys.platform == "linux" + and "conda" not in gb.ss.config["jit_c_compiler_name"] + ): + # XXX TODO: tests for SuiteSparse JIT are not passing on linux when using wheels or on osx + # This should be understood and fixed! + gb.ss.config["jit_c_control"] = "off" + yield + return + # Configuration values below were obtained from the output of the JIT config # in CI, but with paths changed to use `{conda_prefix}` where appropriate. - if "CONDA_PREFIX" not in os.environ or _IS_SSGB7: - return conda_prefix = os.environ["CONDA_PREFIX"] + prev = gb.ss.config["jit_c_control"] gb.ss.config["jit_c_control"] = "on" if sys.platform == "linux": gb.ss.config["jit_c_compiler_name"] = f"{conda_prefix}/bin/x86_64-conda-linux-gnu-cc" @@ -59,7 +98,7 @@ def _setup_jit(): gb.ss.config["jit_c_compiler_flags"] = ( "-march=core2 -mtune=haswell -mssse3 -ftree-vectorize -fPIC -fPIE " f"-fstack-protector-strong -O2 -pipe -isystem {conda_prefix}/include -DGBNCPUFEAT " - "-Wno-pointer-sign -O3 -DNDEBUG -fopenmp=libomp -fPIC -arch x86_64" + f"-Wno-pointer-sign -O3 -DNDEBUG -fopenmp=libomp -fPIC -arch {platform.machine()}" ) gb.ss.config["jit_c_linker_flags"] = ( "-Wl,-pie -Wl,-headerpad_max_install_names -Wl,-dead_strip_dylibs " @@ -72,6 +111,7 @@ def _setup_jit(): # This probably means we're testing a `python-suitesparse-graphblas` wheel # in a conda environment. This is not yet working. gb.ss.config["jit_c_control"] = "off" + yield return gb.ss.config["jit_c_compiler_name"] = f"{conda_prefix}/bin/cc" @@ -86,6 +126,12 @@ def _setup_jit(): if not pathlib.Path(gb.ss.config["jit_c_compiler_name"]).exists(): # Can't use the JIT if we don't have a compiler! gb.ss.config["jit_c_control"] = "off" + yield + return + try: + yield + finally: + gb.ss.config["jit_c_control"] = prev @pytest.fixture diff --git a/graphblas/tests/test_vector.py b/graphblas/tests/test_vector.py index df1f5c86e..db80cdf71 100644 --- a/graphblas/tests/test_vector.py +++ b/graphblas/tests/test_vector.py @@ -29,6 +29,8 @@ suitesparse = backend == "suitesparse" +if suitesparse: + ss_version_major = gb.core.ss.version_major @pytest.fixture @@ -2205,7 +2207,10 @@ def test_udt(): long_dtype = np.dtype([("x", np.bool_), ("y" * 1000, np.float64)], align=True) if suitesparse: - with pytest.warns(UserWarning, match="too large"): + if ss_version_major < 9: + with pytest.warns(UserWarning, match="too large"): + long_udt = dtypes.register_anonymous(long_dtype) + else: long_udt = dtypes.register_anonymous(long_dtype) else: # UDTs don't currently have a name in vanilla GraphBLAS @@ -2216,13 +2221,19 @@ def test_udt(): if suitesparse: vv = Vector.ss.deserialize(v.ss.serialize(), dtype=long_udt) assert v.isequal(vv, check_dtype=True) - with pytest.raises(SyntaxError): - # The size of the UDT name is limited + if ss_version_major < 9: + with pytest.raises(SyntaxError): + # The size of the UDT name is limited + Vector.ss.deserialize(v.ss.serialize()) + else: Vector.ss.deserialize(v.ss.serialize()) # May be able to look up non-anonymous dtypes by name if their names are too long named_long_dtype = np.dtype([("x", np.bool_), ("y" * 1000, np.float64)], align=False) if suitesparse: - with pytest.warns(UserWarning, match="too large"): + if ss_version_major < 9: + with pytest.warns(UserWarning, match="too large"): + named_long_udt = dtypes.register_new("LongUDT", named_long_dtype) + else: named_long_udt = dtypes.register_new("LongUDT", named_long_dtype) else: named_long_udt = dtypes.register_new("LongUDT", named_long_dtype) diff --git a/pyproject.toml b/pyproject.toml index a3447b751..1bad95118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,6 @@ [build-system] build-backend = "setuptools.build_meta" -requires = [ - "setuptools >=64", - "setuptools-git-versioning", -] +requires = ["setuptools >=64", "setuptools-git-versioning"] [project] name = "python-graphblas" @@ -11,59 +8,61 @@ dynamic = ["version"] description = "Python library for GraphBLAS: high-performance sparse linear algebra for scalable graph analytics" readme = "README.md" requires-python = ">=3.10" -license = {file = "LICENSE"} +license = { file = "LICENSE" } authors = [ - {name = "Erik Welch", email = "erik.n.welch@gmail.com"}, - {name = "Jim Kitchen"}, - {name = "Python-graphblas contributors"}, + { 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"}, - {name = "Jim Kitchen", email = "jim22k@gmail.com"}, - {name = "Sultan Orazbayev", email = "contact@econpoint.com"}, + { name = "Erik Welch", email = "erik.n.welch@gmail.com" }, + { name = "Jim Kitchen", email = "jim22k@gmail.com" }, + { name = "Sultan Orazbayev", email = "contact@econpoint.com" }, ] keywords = [ - "graphblas", - "graph", - "sparse", - "matrix", - "lagraph", - "suitesparse", - "Networks", - "Graph Theory", - "Mathematics", - "network", - "discrete mathematics", - "math", + "graphblas", + "graph", + "sparse", + "matrix", + "lagraph", + "suitesparse", + "Networks", + "Graph Theory", + "Mathematics", + "network", + "discrete mathematics", + "math", ] classifiers = [ - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: Apache Software License", - "Operating System :: MacOS :: MacOS X", - "Operating System :: POSIX :: Linux", - "Operating System :: Microsoft :: Windows", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3 :: Only", - "Intended Audience :: Developers", - "Intended Audience :: Other Audience", - "Intended Audience :: Science/Research", - "Topic :: Scientific/Engineering", - "Topic :: Scientific/Engineering :: Information Analysis", - "Topic :: Scientific/Engineering :: Mathematics", - "Topic :: Software Development :: Libraries :: Python Modules", + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: Apache Software License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", + "Intended Audience :: Developers", + "Intended Audience :: Other Audience", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Information Analysis", + "Topic :: Scientific/Engineering :: Mathematics", + "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "numpy >=1.23", - "donfig >=0.6", - "pyyaml >=5.4", - # These won't be installed by default after 2024.3.0 - # Use e.g. "python-graphblas[suitesparse]" or "python-graphblas[default]" instead - "suitesparse-graphblas >=7.4.0.0, <9", - "numba >=0.55; python_version<'3.13'", # make optional where numba is not supported + "numpy >=1.23", + "donfig >=0.6", + "pyyaml >=5.4", + # These won't be installed by default after 2024.3.0 + # once pep-771 is supported: https://peps.python.org/pep-0771/ + # Use e.g. "python-graphblas[suitesparse]" or "python-graphblas[default]" instead + "suitesparse-graphblas >=7.4.0.0, <10", + "numba >=0.55; python_version<'3.14'", # make optional where numba is not supported ] [project.urls] @@ -73,56 +72,41 @@ repository = "https://github.com/python-graphblas/python-graphblas" changelog = "https://github.com/python-graphblas/python-graphblas/releases" [project.optional-dependencies] -suitesparse = [ - "suitesparse-graphblas >=7.4.0.0, <9", -] -networkx = [ - "networkx >=2.8", -] -numba = [ - "numba >=0.55", -] -pandas = [ - "pandas >=1.5", -] -scipy = [ - "scipy >=1.9", -] -suitesparse-udf = [ # udf requires numba - "python-graphblas[suitesparse,numba]", -] -repr = [ - "python-graphblas[pandas]", +suitesparse = ["suitesparse-graphblas >=7.4.0.0, <10"] +networkx = ["networkx >=2.8"] +numba = ["numba >=0.55"] +pandas = ["pandas >=1.5"] +scipy = ["scipy >=1.9"] +suitesparse-udf = [ # udf requires numba + "python-graphblas[suitesparse,numba]", ] +repr = ["python-graphblas[pandas]"] io = [ - "python-graphblas[networkx,scipy]", - "python-graphblas[numba]; python_version<'3.13'", - "awkward >=1.9", - "sparse >=0.14; python_version<'3.13'", # make optional, b/c sparse needs numba - "fast-matrix-market >=1.4.5", + "python-graphblas[networkx,scipy]", + "python-graphblas[numba]; python_version<'3.14'", + "awkward >=2.0", + "sparse >=0.14; python_version<'3.13'", # make optional, b/c sparse needs numba + "fast-matrix-market >=1.4.5; python_version<'3.13'", # py3.13 not supported yet ] -viz = [ - "python-graphblas[networkx,scipy]", - "matplotlib >=3.6", -] -datashade = [ # datashade requires numba - "python-graphblas[numba,pandas,scipy]", - "datashader >=0.14", - "hvplot >=0.8", +viz = ["python-graphblas[networkx,scipy]", "matplotlib >=3.6"] +datashade = [ # datashade requires numba + "python-graphblas[numba,pandas,scipy]", + "datashader >=0.14", + "hvplot >=0.8", ] test = [ - "python-graphblas[suitesparse,pandas,scipy]", - "packaging >=21", - "pytest >=6.2", - "tomli >=1", + "python-graphblas[suitesparse,pandas,scipy]", + "packaging >=21", + "pytest >=6.2", + "tomli >=1", ] default = [ - "python-graphblas[suitesparse,pandas,scipy]", - "python-graphblas[numba]; python_version<'3.13'", # make optional where numba is not supported + "python-graphblas[suitesparse,pandas,scipy]", + "python-graphblas[numba]; python_version<'3.14'", # make optional where numba is not supported ] all = [ - "python-graphblas[default,io,viz,test]", - "python-graphblas[datashade]; python_version<'3.13'", # make optional, b/c datashade needs numba + "python-graphblas[default,io,viz,test]", + "python-graphblas[datashade]; python_version<'3.14'", # make optional, b/c datashade needs numba ] [tool.setuptools] @@ -131,22 +115,22 @@ all = [ # $ find graphblas/ -name __init__.py -print | sort | sed -e 's/\/__init__.py//g' -e 's/\//./g' # $ python -c 'import tomli ; [print(x) for x in sorted(tomli.load(open("pyproject.toml", "rb"))["tool"]["setuptools"]["packages"])]' packages = [ - "graphblas", - "graphblas.agg", - "graphblas.binary", - "graphblas.core", - "graphblas.core.operator", - "graphblas.core.ss", - "graphblas.dtypes", - "graphblas.indexunary", - "graphblas.io", - "graphblas.monoid", - "graphblas.op", - "graphblas.semiring", - "graphblas.select", - "graphblas.ss", - "graphblas.tests", - "graphblas.unary", + "graphblas", + "graphblas.agg", + "graphblas.binary", + "graphblas.core", + "graphblas.core.operator", + "graphblas.core.ss", + "graphblas.dtypes", + "graphblas.indexunary", + "graphblas.io", + "graphblas.monoid", + "graphblas.op", + "graphblas.semiring", + "graphblas.select", + "graphblas.ss", + "graphblas.tests", + "graphblas.unary", ] [tool.setuptools-git-versioning] @@ -156,7 +140,7 @@ dirty_template = "{tag}+{ccount}.g{sha}.dirty" [tool.black] line-length = 100 -target-version = ["py310", "py311", "py312"] +target-version = ["py310", "py311", "py312", "py313"] [tool.isort] sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] @@ -170,56 +154,54 @@ line_length = 100 [tool.pytest.ini_options] minversion = "6.0" testpaths = "graphblas/tests" -xfail_strict = false # 2023-07-23: awkward and numpy 1.25 sometimes conflict +xfail_strict = false # 2023-07-23: awkward and numpy 1.25 sometimes conflict addopts = [ - "--strict-config", # Force error if config is mispelled - "--strict-markers", # Force error if marker is mispelled (must be defined in config) - "-ra", # Print summary of all fails/errors -] -markers = [ - "slow: Skipped unless --runslow passed", + "--strict-config", # Force error if config is mispelled + "--strict-markers", # Force error if marker is mispelled (must be defined in config) + "-ra", # Print summary of all fails/errors ] +markers = ["slow: Skipped unless --runslow passed"] log_cli_level = "info" filterwarnings = [ - # See: https://docs.python.org/3/library/warnings.html#describing-warning-filters - # and: https://docs.pytest.org/en/7.2.x/how-to/capture-warnings.html#controlling-warnings - "error", + # See: https://docs.python.org/3/library/warnings.html#describing-warning-filters + # and: https://docs.pytest.org/en/7.2.x/how-to/capture-warnings.html#controlling-warnings + "error", - # sparse 0.14.0 (2022-02-24) began raising this warning; it has been reported and fixed upstream. - "ignore:coords should be an ndarray. This will raise a ValueError:DeprecationWarning:sparse._coo.core", + # sparse 0.14.0 (2022-02-24) began raising this warning; it has been reported and fixed upstream. + "ignore:coords should be an ndarray. This will raise a ValueError:DeprecationWarning:sparse._coo.core", - # setuptools v67.3.0 deprecated `pkg_resources.declare_namespace` on 13 Feb 2023. See: - # https://setuptools.pypa.io/en/latest/history.html#v67-3-0 - # MAINT: check if this is still necessary in 2025 - "ignore:Deprecated call to `pkg_resources.declare_namespace:DeprecationWarning:pkg_resources", + # setuptools v67.3.0 deprecated `pkg_resources.declare_namespace` on 13 Feb 2023. See: + # https://setuptools.pypa.io/en/latest/history.html#v67-3-0 + # MAINT: check if this is still necessary in 2025 + "ignore:Deprecated call to `pkg_resources.declare_namespace:DeprecationWarning:pkg_resources", - # This deprecation warning was added in setuptools v67.5.0 (8 Mar 2023). See: - # https://setuptools.pypa.io/en/latest/history.html#v67-5-0 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:", + # This deprecation warning was added in setuptools v67.5.0 (8 Mar 2023). See: + # https://setuptools.pypa.io/en/latest/history.html#v67-5-0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:", - # sre_parse deprecated in 3.11; this is triggered by awkward 0.10 - "ignore:module 'sre_parse' is deprecated:DeprecationWarning:", - "ignore:module 'sre_constants' is deprecated:DeprecationWarning:", + # sre_parse deprecated in 3.11; this is triggered by awkward 0.10 + "ignore:module 'sre_parse' is deprecated:DeprecationWarning:", + "ignore:module 'sre_constants' is deprecated:DeprecationWarning:", - # numpy 1.25.0 (2023-06-17) deprecated `np.find_common_type`; many other dependencies use it. - # See if we can remove this filter in 2025. - "ignore:np.find_common_type is deprecated:DeprecationWarning:", + # numpy 1.25.0 (2023-06-17) deprecated `np.find_common_type`; many other dependencies use it. + # See if we can remove this filter in 2025. + "ignore:np.find_common_type is deprecated:DeprecationWarning:", - # pypy gives this warning - "ignore:can't resolve package from __spec__ or __package__:ImportWarning:", + # pypy gives this warning + "ignore:can't resolve package from __spec__ or __package__:ImportWarning:", - # Python 3.12 introduced this deprecation, which is triggered by pandas 2.1.1 - "ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil", + # Python 3.12 introduced this deprecation, which is triggered by pandas 2.1.1 + "ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil", - # Pandas 2.2 warns that pyarrow will become a required dependency in pandas 3.0 - "ignore:\\nPyarrow will become a required dependency of pandas:DeprecationWarning:", + # Pandas 2.2 warns that pyarrow will become a required dependency in pandas 3.0 + "ignore:\\nPyarrow will become a required dependency of pandas:DeprecationWarning:", ] [tool.coverage.run] branch = true source = ["graphblas"] omit = [ - "graphblas/viz.py", # TODO: test and get coverage for viz.py + "graphblas/viz.py", # TODO: test and get coverage for viz.py ] [tool.coverage.report] @@ -229,9 +211,9 @@ fail_under = 0 skip_covered = true skip_empty = true exclude_lines = [ - "pragma: no cover", - "raise AssertionError", - "raise NotImplementedError", + "pragma: no cover", + "raise AssertionError", + "raise NotImplementedError", ] [tool.codespell] @@ -241,164 +223,189 @@ ignore-words-list = "coo,ba" # https://github.com/charliermarsh/ruff/ line-length = 100 target-version = "py310" + +[tool.ruff.format] +exclude = ["*.ipynb"] # Consider enabling auto-formatting of notebooks + [tool.ruff.lint] +exclude = ["*.ipynb"] # Consider enabling auto-formatting of notebooks unfixable = [ - "F841", # unused-variable (Note: can leave useless expression) - "B905", # zip-without-explicit-strict (Note: prefer `zip(x, y, strict=True)`) + "F841", # unused-variable (Note: can leave useless expression) + "B905", # zip-without-explicit-strict (Note: prefer `zip(x, y, strict=True)`) ] 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 (Too strict, but maybe we should make things less complex) - # "I", # isort (Should we replace `isort` with this?) - "N", # pep8-naming - "D", # pydocstyle - "UP", # pyupgrade - "YTT", # flake8-2020 - # "ANN", # flake8-annotations (We don't use annotations yet) - "S", # bandit - # "BLE", # flake8-blind-except (Maybe consider) - # "FBT", # flake8-boolean-trap (Why?) - "B", # flake8-bugbear - "A", # flake8-builtins - "COM", # flake8-commas - "C4", # flake8-comprehensions - "DTZ", # flake8-datetimez - "T10", # flake8-debugger - # "DJ", # flake8-django (We don't use django) - # "EM", # flake8-errmsg (Perhaps nicer, but too much work) - "EXE", # flake8-executable - "ISC", # flake8-implicit-str-concat - # "ICN", # flake8-import-conventions (Doesn't allow "_" prefix such as `_np`) - "G", # flake8-logging-format - "INP", # flake8-no-pep420 - "PIE", # flake8-pie - "T20", # flake8-print - # "PYI", # flake8-pyi (We don't have stub files yet) - "PT", # flake8-pytest-style - "Q", # flake8-quotes - "RSE", # flake8-raise - "RET", # flake8-return - # "SLF", # flake8-self (We can use our own private variables--sheesh!) - "SIM", # flake8-simplify - # "TID", # flake8-tidy-imports (Rely on isort and our own judgement) - # "TCH", # flake8-type-checking (Note: figure out type checking later) - # "ARG", # flake8-unused-arguments (Sometimes helpful, but too strict) - "PTH", # flake8-use-pathlib (Often better, but not always) - # "ERA", # eradicate (We like code in comments!) - # "PD", # pandas-vet (Intended for scripts that use pandas, not libraries) - "PGH", # pygrep-hooks - "PL", # pylint - "PLC", # pylint Convention - "PLE", # pylint Error - "PLR", # pylint Refactor - "PLW", # pylint Warning - "TRY", # tryceratops - "NPY", # NumPy-specific rules - "RUF", # ruff-specific rules - "ALL", # Try new categories by default (making the above list unnecessary) + # 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 (Too strict, but maybe we should make things less complex) + # "I", # isort (Should we replace `isort` with this?) + "N", # pep8-naming + "D", # pydocstyle + "UP", # pyupgrade + "YTT", # flake8-2020 + # "ANN", # flake8-annotations (We don't use annotations yet) + "S", # bandit + # "BLE", # flake8-blind-except (Maybe consider) + # "FBT", # flake8-boolean-trap (Why?) + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "T10", # flake8-debugger + # "DJ", # flake8-django (We don't use django) + # "EM", # flake8-errmsg (Perhaps nicer, but too much work) + "EXE", # flake8-executable + "ISC", # flake8-implicit-str-concat + # "ICN", # flake8-import-conventions (Doesn't allow "_" prefix such as `_np`) + "G", # flake8-logging-format + "INP", # flake8-no-pep420 + "PIE", # flake8-pie + "T20", # flake8-print + # "PYI", # flake8-pyi (We don't have stub files yet) + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + # "SLF", # flake8-self (We can use our own private variables--sheesh!) + "SIM", # flake8-simplify + # "TID", # flake8-tidy-imports (Rely on isort and our own judgement) + # "TCH", # flake8-type-checking (Note: figure out type checking later) + # "ARG", # flake8-unused-arguments (Sometimes helpful, but too strict) + "PTH", # flake8-use-pathlib (Often better, but not always) + # "ERA", # eradicate (We like code in comments!) + # "PD", # pandas-vet (Intended for scripts that use pandas, not libraries) + "PGH", # pygrep-hooks + "PL", # pylint + "PLC", # pylint Convention + "PLE", # pylint Error + "PLR", # pylint Refactor + "PLW", # pylint Warning + "TRY", # tryceratops + "NPY", # NumPy-specific rules + "RUF", # ruff-specific rules + "ALL", # Try new categories by default (making the above list unnecessary) ] external = [ - # noqa codes that ruff doesn't know about: https://github.com/charliermarsh/ruff#external - "F811", + # noqa codes that ruff doesn't know about: https://github.com/charliermarsh/ruff#external + "F811", ] ignore = [ - # Would be nice to fix these - "D100", # Missing docstring in public module - "D101", # Missing docstring in public class - "D102", # Missing docstring in public method - "D103", # Missing docstring in public function - "D104", # Missing docstring in public package - "D105", # Missing docstring in magic method - "D107", # Missing docstring in `__init__` - # "D107", # Missing docstring in `__init__` - "D205", # 1 blank line required between summary line and description - "D401", # First line of docstring should be in imperative mood: - "D417", # D417 Missing argument description in the docstring for ...: ... - # "D417", # Missing argument description in the docstring: - "PLE0605", # Invalid format for `__all__`, must be `tuple` or `list` (Note: broken in v0.0.237) - - # Maybe consider - # "SIM300", # Yoda conditions are discouraged, use ... instead (Note: we're not this picky) - # "SIM401", # Use dict.get ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) - "B904", # Use `raise from` to specify exception cause (Note: sometimes okay to raise original exception) - "TRY004", # Prefer `TypeError` exception for invalid type (Note: good advice, but not worth the nuisance) - "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` (Note: no annotations yet) - "PERF401", # Use a list comprehension to create a transformed list (Note: poorly implemented atm) - - # Intentionally ignored - "COM812", # Trailing comma missing - "D203", # 1 blank line required before class docstring (Note: conflicts with D211, which is preferred) - "D213", # (Note: conflicts with D212, which is preferred) - "D400", # First line should end with a period (Note: prefer D415, which also allows "?" and "!") - "N801", # Class name ... should use CapWords convention (Note:we have a few exceptions to this) - "N802", # Function name ... should be lowercase - "N803", # Argument name ... should be lowercase (Maybe okay--except in tests) - "N806", # Variable ... in function should be lowercase - "N807", # Function name should not start and end with `__` - "N818", # Exception name ... should be named with an Error suffix (Note: good advice) - "PERF203", # `try`-`except` within a loop incurs performance overhead (Note: too strict) - "PLC0205", # Class `__slots__` should be a non-string iterable (Note: string is fine) - "PLR0124", # Name compared with itself, consider replacing `x == x` (Note: too strict) - "PLR0911", # Too many return statements - "PLR0912", # Too many branches - "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 - "RET504", # Unnecessary variable assignment before `return` statement - "S110", # `try`-`except`-`pass` detected, consider logging the exception (Note: good advice, but we don't log) - "S112", # `try`-`except`-`continue` detected, consider logging the exception (Note: good advice, but we don't log) - "S603", # `subprocess` call: check for execution of untrusted input (Note: not important for us) - "S607", # Starting a process with a partial executable path (Note: not important for us) - "SIM102", # Use a single `if` statement instead of nested `if` statements (Note: often necessary) - "SIM105", # Use contextlib.suppress(...) instead of try-except-pass (Note: try-except-pass is much faster) - "SIM108", # Use ternary operator ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) - "TRY003", # Avoid specifying long messages outside the exception class (Note: why?) - "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` (Note: using `|` is slower atm) - - # Ignored categories - "C90", # mccabe (Too strict, but maybe we should make things less complex) - "I", # isort (Should we replace `isort` with this?) - "ANN", # flake8-annotations (We don't use annotations yet) - "BLE", # flake8-blind-except (Maybe consider) - "FBT", # flake8-boolean-trap (Why?) - "DJ", # flake8-django (We don't use django) - "EM", # flake8-errmsg (Perhaps nicer, but too much work) - "ICN", # flake8-import-conventions (Doesn't allow "_" prefix such as `_np`) - "PYI", # flake8-pyi (We don't have stub files yet) - "SLF", # flake8-self (We can use our own private variables--sheesh!) - "TID", # flake8-tidy-imports (Rely on isort and our own judgement) - "TCH", # flake8-type-checking (Note: figure out type checking later) - "ARG", # flake8-unused-arguments (Sometimes helpful, but too strict) - "TD", # flake8-todos (Maybe okay to add some of these) - "FIX", # flake8-fixme (like flake8-todos) - "ERA", # eradicate (We like code in comments!) - "PD", # pandas-vet (Intended for scripts that use pandas, not libraries) + # Would be nice to fix these + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D107", # Missing docstring in `__init__` + "D205", # 1 blank line required between summary line and description + "D401", # First line of docstring should be in imperative mood: + "D417", # D417 Missing argument description in the docstring for ...: ... + "PLE0605", # Invalid format for `__all__`, must be `tuple` or `list` (Note: broken in v0.0.237) + + # Maybe consider + # "SIM300", # Yoda conditions are discouraged, use ... instead (Note: we're not this picky) + # "SIM401", # Use dict.get ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) + "B904", # Use `raise from` to specify exception cause (Note: sometimes okay to raise original exception) + "TRY004", # Prefer `TypeError` exception for invalid type (Note: good advice, but not worth the nuisance) + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` (Note: no annotations yet) + "RUF021", # parenthesize-chained-operators (Note: results don't look good yet) + "RUF023", # unsorted-dunder-slots (Note: maybe fine, but noisy changes) + "PERF401", # Use a list comprehension to create a transformed list (Note: poorly implemented atm) + + # Intentionally ignored + "COM812", # Trailing comma missing + "D203", # 1 blank line required before class docstring (Note: conflicts with D211, which is preferred) + "D213", # (Note: conflicts with D212, which is preferred) + "D400", # First line should end with a period (Note: prefer D415, which also allows "?" and "!") + "N801", # Class name ... should use CapWords convention (Note:we have a few exceptions to this) + "N802", # Function name ... should be lowercase + "N803", # Argument name ... should be lowercase (Maybe okay--except in tests) + "N806", # Variable ... in function should be lowercase + "N807", # Function name should not start and end with `__` + "N818", # Exception name ... should be named with an Error suffix (Note: good advice) + "PERF203", # `try`-`except` within a loop incurs performance overhead (Note: too strict) + "PLC0205", # Class `__slots__` should be a non-string iterable (Note: string is fine) + "PLR0124", # Name compared with itself, consider replacing `x == x` (Note: too strict) + "PLR0911", # Too many return statements + "PLR0912", # Too many branches + "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) + "PLW0642", # Reassigned `self` variable in instance method (Note: too strict for us) + "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 + "RET504", # Unnecessary variable assignment before `return` statement + "S110", # `try`-`except`-`pass` detected, consider logging the exception (Note: good advice, but we don't log) + "S112", # `try`-`except`-`continue` detected, consider logging the exception (Note: good advice, but we don't log) + "S603", # `subprocess` call: check for execution of untrusted input (Note: not important for us) + "S607", # Starting a process with a partial executable path (Note: not important for us) + "SIM102", # Use a single `if` statement instead of nested `if` statements (Note: often necessary) + "SIM105", # Use contextlib.suppress(...) instead of try-except-pass (Note: try-except-pass is much faster) + "SIM108", # Use ternary operator ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) + "TRY003", # Avoid specifying long messages outside the exception class (Note: why?) + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` (Note: using `|` is slower atm) + + # Ignored categories + "C90", # mccabe (Too strict, but maybe we should make things less complex) + "I", # isort (Should we replace `isort` with this?) + "ANN", # flake8-annotations (We don't use annotations yet) + "BLE", # flake8-blind-except (Maybe consider) + "FBT", # flake8-boolean-trap (Why?) + "DJ", # flake8-django (We don't use django) + "EM", # flake8-errmsg (Perhaps nicer, but too much work) + "ICN", # flake8-import-conventions (Doesn't allow "_" prefix such as `_np`) + "PYI", # flake8-pyi (We don't have stub files yet) + "SLF", # flake8-self (We can use our own private variables--sheesh!) + "TID", # flake8-tidy-imports (Rely on isort and our own judgement) + "TCH", # flake8-type-checking (Note: figure out type checking later) + "ARG", # flake8-unused-arguments (Sometimes helpful, but too strict) + "TD", # flake8-todos (Maybe okay to add some of these) + "FIX", # flake8-fixme (like flake8-todos) + "ERA", # eradicate (We like code in comments!) + "PD", # pandas-vet (Intended for scripts that use pandas, not libraries) ] [tool.ruff.lint.per-file-ignores] -"graphblas/core/operator/base.py" = ["S102"] # exec is used for UDF -"graphblas/core/ss/matrix.py" = ["NPY002"] # numba doesn't support rng generator yet -"graphblas/core/ss/vector.py" = ["NPY002"] # numba doesn't support rng generator yet -"graphblas/core/utils.py" = ["PLE0302"] # `__set__` is used as a property -"graphblas/ss/_core.py" = ["N999"] # We want _core.py to be underscopre +"graphblas/core/operator/__init__.py" = ["A005"] +"graphblas/io/__init__.py" = ["A005"] # shadows a standard-library module +"graphblas/core/operator/base.py" = ["S102"] # exec is used for UDF +"graphblas/core/ss/matrix.py" = [ + "NPY002", # numba doesn't support rng generator yet + "PLR1730", +] +"graphblas/core/ss/vector.py" = [ + "NPY002", # numba doesn't support rng generator yet +] +"graphblas/core/utils.py" = ["PLE0302"] # `__set__` is used as a property +"graphblas/ss/_core.py" = ["N999"] # We want _core.py to be underscopre # Allow useless expressions, assert, pickle, RNG, print, no docstring, and yoda in tests -"graphblas/tests/*py" = ["B018", "S101", "S301", "S311", "T201", "D103", "D100", "SIM300"] -"graphblas/tests/test_formatting.py" = ["E501"] # Allow long lines -"graphblas/**/__init__.py" = ["F401"] # Allow unused imports (w/o defining `__all__`) -"scripts/*.py" = ["INP001"] # Not a package -"scripts/create_pickle.py" = ["F403", "F405"] # Allow `from foo import *` -"docs/*.py" = ["INP001"] # Not a package +"graphblas/tests/*py" = [ + "B018", + "S101", + "S301", + "S311", + "T201", + "D103", + "D100", + "SIM300", +] +"graphblas/tests/test_formatting.py" = ["E501"] # Allow long lines +"graphblas/**/__init__.py" = [ + "F401", # Allow unused imports (w/o defining `__all__`) +] +"scripts/*.py" = ["INP001"] # Not a package +"scripts/create_pickle.py" = ["F403", "F405"] # Allow `from foo import *` +"docs/*.py" = ["INP001"] # Not a package [tool.ruff.lint.flake8-builtins] builtins-ignorelist = ["copyright", "format", "min", "max"] +builtins-allowed-modules = ["select"] [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false @@ -407,80 +414,86 @@ mark-parentheses = false [tool.lint.ruff.pydocstyle] convention = "numpy" +[tool.bandit] +exclude_dirs = ["graphblas/tests", "scripts"] +skips = [ + "B110", # Try, Except, Pass detected. (Note: it would be nice to not have this pattern) +] + [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.10" enable = ["I"] disable = [ - # Error - "assignment-from-no-return", - - # Warning - "arguments-differ", - "arguments-out-of-order", - "expression-not-assigned", - "fixme", - "global-statement", - "non-parent-init-called", - "redefined-builtin", - "redefined-outer-name", - "super-init-not-called", - "unbalanced-tuple-unpacking", - "unnecessary-lambda", - "unspecified-encoding", - "unused-argument", - "unused-variable", - - # Refactor - "cyclic-import", - "duplicate-code", - "inconsistent-return-statements", - "too-few-public-methods", - - # Convention - "missing-class-docstring", - "missing-function-docstring", - "missing-module-docstring", - "too-many-lines", - - # Intentionally turned off - # error - "class-variable-slots-conflict", - "invalid-unary-operand-type", - "no-member", - "no-name-in-module", - "not-an-iterable", - "too-many-function-args", - "unexpected-keyword-arg", - # warning - "broad-except", - "pointless-statement", - "protected-access", - "undefined-loop-variable", - "unused-import", - # refactor - "comparison-with-itself", - "too-many-arguments", - "too-many-boolean-expressions", - "too-many-branches", - "too-many-instance-attributes", - "too-many-locals", - "too-many-nested-blocks", - "too-many-public-methods", - "too-many-return-statements", - "too-many-statements", - # convention - "import-outside-toplevel", - "invalid-name", - "line-too-long", - "singleton-comparison", - "single-string-used-for-slots", - "unidiomatic-typecheck", - "unnecessary-dunder-call", - "wrong-import-order", - "wrong-import-position", - # informative - "locally-disabled", - "suppressed-message", + # Error + "assignment-from-no-return", + + # Warning + "arguments-differ", + "arguments-out-of-order", + "expression-not-assigned", + "fixme", + "global-statement", + "non-parent-init-called", + "redefined-builtin", + "redefined-outer-name", + "super-init-not-called", + "unbalanced-tuple-unpacking", + "unnecessary-lambda", + "unspecified-encoding", + "unused-argument", + "unused-variable", + + # Refactor + "cyclic-import", + "duplicate-code", + "inconsistent-return-statements", + "too-few-public-methods", + + # Convention + "missing-class-docstring", + "missing-function-docstring", + "missing-module-docstring", + "too-many-lines", + + # Intentionally turned off + # error + "class-variable-slots-conflict", + "invalid-unary-operand-type", + "no-member", + "no-name-in-module", + "not-an-iterable", + "too-many-function-args", + "unexpected-keyword-arg", + # warning + "broad-except", + "pointless-statement", + "protected-access", + "undefined-loop-variable", + "unused-import", + # refactor + "comparison-with-itself", + "too-many-arguments", + "too-many-boolean-expressions", + "too-many-branches", + "too-many-instance-attributes", + "too-many-locals", + "too-many-nested-blocks", + "too-many-public-methods", + "too-many-return-statements", + "too-many-statements", + # convention + "import-outside-toplevel", + "invalid-name", + "line-too-long", + "singleton-comparison", + "single-string-used-for-slots", + "unidiomatic-typecheck", + "unnecessary-dunder-call", + "wrong-import-order", + "wrong-import-position", + # informative + "locally-disabled", + "suppressed-message", ] diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index 893f09539..cd3451905 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -3,15 +3,15 @@ # Use, adjust, copy/paste, etc. as necessary to answer your questions. # This may be helpful when updating dependency versions in CI. # Tip: add `--json` for more information. -conda search 'flake8-bugbear[channel=conda-forge]>=24.1.17' +conda search 'flake8-bugbear[channel=conda-forge]>=24.12.12' conda search 'flake8-simplify[channel=conda-forge]>=0.21.0' -conda search 'numpy[channel=conda-forge]>=1.26.3' -conda search 'pandas[channel=conda-forge]>=2.2.0' -conda search 'scipy[channel=conda-forge]>=1.12.0' -conda search 'networkx[channel=conda-forge]>=3.2.1' -conda search 'awkward[channel=conda-forge]>=2.5.2' -conda search 'sparse[channel=conda-forge]>=0.15.1' +conda search 'numpy[channel=conda-forge]>=2.2.3' +conda search 'pandas[channel=conda-forge]>=2.2.3' +conda search 'scipy[channel=conda-forge]>=1.15.1' +conda search 'networkx[channel=conda-forge]>=3.4.2' +conda search 'awkward[channel=conda-forge]>=2.7.4' +conda search 'sparse[channel=conda-forge]>=0.15.5' conda search 'fast_matrix_market[channel=conda-forge]>=1.7.6' -conda search 'numba[channel=conda-forge]>=0.59.0' -conda search 'pyyaml[channel=conda-forge]>=6.0.1' +conda search 'numba[channel=conda-forge]>=0.61.0' +conda search 'pyyaml[channel=conda-forge]>=6.0.2' # conda search 'python[channel=conda-forge]>=3.10 *pypy*' From 9bf2ae233c202e8f13a74ae80858b8631ad3b308 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Feb 2025 09:51:19 -0600 Subject: [PATCH 84/87] Bump codecov/codecov-action from 4 to 5 (#554) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4...v5) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test_and_build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 7a8f06900..bfc17834b 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -504,7 +504,7 @@ jobs: coverage xml coverage report --show-missing - name: codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 - name: Notebooks Execution check if: matrix.slowtask == 'notebooks' run: | From a1e1904925a4e7a2eca12b903d0abcf772b4f8c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:26:27 -0600 Subject: [PATCH 85/87] Bump pypa/gh-action-pypi-publish from 1.9.0 to 1.12.4 (#553) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.9.0 to 1.12.4. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.9.0...v1.12.4) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index a9ad0be8c..32926c5c8 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -36,7 +36,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.9.0 + uses: pypa/gh-action-pypi-publish@v1.12.4 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 2a4891f9b1701afe3019f4d9cfcca3f9c7e505b8 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 17 Feb 2025 17:06:11 -0600 Subject: [PATCH 86/87] Fix a numpy 2 deprecation warning (dtype "a" code) (#556) --- graphblas/tests/test_dtype.py | 4 ++++ scripts/check_versions.sh | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/graphblas/tests/test_dtype.py b/graphblas/tests/test_dtype.py index e2478fe7b..ecbca707f 100644 --- a/graphblas/tests/test_dtype.py +++ b/graphblas/tests/test_dtype.py @@ -9,6 +9,7 @@ import graphblas as gb from graphblas import core, dtypes from graphblas.core import lib +from graphblas.core.utils import _NP2 from graphblas.dtypes import lookup_dtype suitesparse = gb.backend == "suitesparse" @@ -228,6 +229,9 @@ def test_dtype_to_from_string(): # See NEP 55 about StringDtype "T". Notably, this doesn't work: # >>> np.dtype(np.dtype("T").str) continue + if _NP2 and c == "a": + # Data type alias 'a' was deprecated in NumPy 2.0. Use the 'S' alias instead. + continue try: dtype = np.dtype(c) types.append(dtype) diff --git a/scripts/check_versions.sh b/scripts/check_versions.sh index cd3451905..5aa88e045 100755 --- a/scripts/check_versions.sh +++ b/scripts/check_versions.sh @@ -7,7 +7,7 @@ conda search 'flake8-bugbear[channel=conda-forge]>=24.12.12' conda search 'flake8-simplify[channel=conda-forge]>=0.21.0' conda search 'numpy[channel=conda-forge]>=2.2.3' conda search 'pandas[channel=conda-forge]>=2.2.3' -conda search 'scipy[channel=conda-forge]>=1.15.1' +conda search 'scipy[channel=conda-forge]>=1.15.2' conda search 'networkx[channel=conda-forge]>=3.4.2' conda search 'awkward[channel=conda-forge]>=2.7.4' conda search 'sparse[channel=conda-forge]>=0.15.5' From 22d42f615187a3a2c89b6400a898c5b72ef396a8 Mon Sep 17 00:00:00 2001 From: Jim Kitchen <2807270+jim22k@users.noreply.github.com> Date: Wed, 26 Feb 2025 10:02:04 -0600 Subject: [PATCH 87/87] Add 9.4.5.0 to tests (#557) --- .github/workflows/test_and_build.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index bfc17834b..af7525928 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -217,24 +217,24 @@ jobs: fi elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.13') }} == true ]] ; then if [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then - psgver=$(python -c 'import random ; print(random.choice(["=9.3.1.0", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["=9.3.1.0", "=9.4.5.0", ""]))') psg=python-suitesparse-graphblas${psgver} else - psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", "==9.4.5.0", ""]))') fi elif [[ ${{ startsWith(steps.pyver.outputs.selected, '3.12') }} == true ]] ; then if [[ ${{ steps.sourcetype.outputs.selected}} == "conda-forge" ]] ; then if [[ $npver == =1.* ]] ; then psgver=$(python -c 'import random ; print(random.choice(["=8.2.0.1", "=8.2.1.0"]))') else - psgver=$(python -c 'import random ; print(random.choice(["=9.3.1.0", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["=9.3.1.0", "=9.4.5.0", ""]))') fi psg=python-suitesparse-graphblas${psgver} else if [[ $npver == =1.* ]] ; then psgver=$(python -c 'import random ; print(random.choice(["==8.2.0.1", "==8.2.1.0"]))') else - psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", "==9.4.5.0", ""]))') fi fi # python-suitsparse-graphblas support is the same for Python 3.10 and 3.11 @@ -242,21 +242,21 @@ jobs: if [[ $npver == =1.* ]] ; then psgver=$(python -c 'import random ; print(random.choice(["=7.4.0", "=7.4.1", "=7.4.2", "=7.4.3.0", "=7.4.3.1", "=7.4.3.2", "=8.0.2.1", "=8.2.0.1", "=8.2.1.0"]))') else - psgver=$(python -c 'import random ; print(random.choice(["=9.3.1.0", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["=9.3.1.0", "=9.4.5.0", ""]))') fi psg=python-suitesparse-graphblas${psgver} elif [[ ${{ steps.sourcetype.outputs.selected}} == "wheel" ]] ; then if [[ $npver == =1.* ]] ; then psgver=$(python -c 'import random ; print(random.choice(["==7.4.3.2", "==8.0.2.1", "==8.2.0.1", "==8.2.1.0"]))') else - psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", "==9.4.5.0", ""]))') fi elif [[ ${{ steps.sourcetype.outputs.selected}} == "source" ]] ; then # These should be exact versions if [[ $npver == =1.* ]] ; then psgver=$(python -c 'import random ; print(random.choice(["==7.4.0.0", "==7.4.1.0", "==7.4.2.0", "==7.4.3.0", "==7.4.3.1", "==7.4.3.2", "==8.0.2.1", "==8.2.0.1", "==8.2.1.0"]))') else - psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", ""]))') + psgver=$(python -c 'import random ; print(random.choice(["==9.3.1.0", "==9.4.5.0", ""]))') fi fi @@ -357,7 +357,7 @@ jobs: ${{ matrix.slowtask == 'pytest_bizarro' && 'black' || '' }} \ ${{ matrix.slowtask == 'notebooks' && 'matplotlib nbconvert jupyter "ipython>=7" drawsvg' || '' }} \ ${{ steps.sourcetype.outputs.selected == 'upstream' && 'cython' || '' }} \ - ${{ steps.sourcetype.outputs.selected != 'wheel' && '"graphblas>=7.4,<9.4"' || '' }} \ + ${{ steps.sourcetype.outputs.selected != 'wheel' && '"graphblas>=7.4,<9.5"' || '' }} \ ${{ contains(steps.pyver.outputs.selected, 'pypy') && 'pypy' || '' }} \ ${{ matrix.os == 'windows-latest' && 'cmake' || 'm4' }} \ # ${{ matrix.os != 'windows-latest' && 'pytest-forked' || '' }} # to investigate crashes